Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
49 changes: 0 additions & 49 deletions .github/workflows/ci.yml

This file was deleted.

202 changes: 202 additions & 0 deletions .github/workflows/nightly-release.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,202 @@
name: Nightly release preparation

on:
schedule:
# 00:07 Asia/Shanghai, away from GitHub's start-of-hour congestion window.
- cron: '7 0 * * *'
timezone: 'Asia/Shanghai'
workflow_dispatch:

permissions:
actions: write
contents: write
pull-requests: write

concurrency:
group: nightly-release-preparation
cancel-in-progress: false

jobs:
prepare:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0

- uses: actions/setup-node@v4
with:
node-version: 22

- name: Find unreleased commits
id: release
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
shell: bash
run: |
set -euo pipefail
latest_tag="$(gh release view --json tagName --jq .tagName)"
if ! git rev-parse --verify --quiet "refs/tags/$latest_tag" >/dev/null; then
git fetch origin "refs/tags/$latest_tag:refs/tags/$latest_tag"
fi
if git merge-base --is-ancestor "$latest_tag" HEAD; then
unreleased="$(git rev-list --count "$latest_tag"..HEAD)"
else
echo "::error::Latest published release $latest_tag is not an ancestor of main"
exit 1
fi

echo "latest_tag=$latest_tag" >> "$GITHUB_OUTPUT"
echo "unreleased=$unreleased" >> "$GITHUB_OUTPUT"
if [ "$unreleased" -eq 0 ]; then
echo "No commits after $latest_tag"
echo "prepare=false" >> "$GITHUB_OUTPUT"
exit 0
fi

next_version="$(node scripts/prepare-release.mjs next "$latest_tag")"
current_version="$(node scripts/prepare-release.mjs current)"
echo "next_version=$next_version" >> "$GITHUB_OUTPUT"
echo "branch=automation/release-v$next_version" >> "$GITHUB_OUTPUT"
if [ "$current_version" = "${latest_tag#v}" ]; then
echo "prepare=true" >> "$GITHUB_OUTPUT"
elif [ "$current_version" = "$next_version" ]; then
node scripts/prepare-release.mjs verify-current
echo "v$current_version is already prepared on main and is waiting to be released."
echo "prepare=false" >> "$GITHUB_OUTPUT"
else
echo "::error::App version $current_version is neither latest release ${latest_tag#v} nor next patch $next_version"
exit 1
fi

- name: Stop when main is already fully released
if: steps.release.outputs.unreleased == '0'
run: echo "main matches ${{ steps.release.outputs.latest_tag }}; no release PR is needed."

- name: Recover or refuse an existing release branch
id: branch
if: steps.release.outputs.prepare == 'true'
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
RELEASE_BRANCH: ${{ steps.release.outputs.branch }}
shell: bash
run: |
set -euo pipefail
if git ls-remote --exit-code --heads origin "$RELEASE_BRANCH" >/dev/null 2>&1; then
pr_url="$(gh pr list --state open --head "$RELEASE_BRANCH" --json url --jq '.[0].url // empty')"
if [ -n "$pr_url" ]; then
echo "Release PR already exists: $pr_url"
echo "exists=true" >> "$GITHUB_OUTPUT"
exit 0
fi

# Recover only an exact generated release commit whose parent is
# the current main. Never adopt a stale or unrelated branch.
git fetch origin "refs/heads/$RELEASE_BRANCH:refs/remotes/origin/$RELEASE_BRANCH"
branch_ref="refs/remotes/origin/$RELEASE_BRANCH"
branch_sha="$(git rev-parse "$branch_ref")"
parent_sha="$(git rev-parse "$branch_ref^")"
version="${RELEASE_BRANCH#automation/release-v}"
subject="$(git log -1 --format=%s "$branch_ref")"
if [ "$parent_sha" != "$GITHUB_SHA" ] || [ "$subject" != "chore(release): prepare v$version" ]; then
echo "::error::Remote branch $RELEASE_BRANCH is not the expected release commit on current main"
exit 1
fi

recovery_dir="$(mktemp -d)"
git worktree add --detach "$recovery_dir" "$branch_sha"
if ! (cd "$recovery_dir" && node scripts/prepare-release.mjs verify "v$version"); then
git worktree remove --force "$recovery_dir"
echo "::error::Remote branch $RELEASE_BRANCH failed release metadata validation"
exit 1
fi
git worktree remove --force "$recovery_dir"

body_file="$(mktemp)"
cat > "$body_file" <<EOF
## Summary

Nightly automation recovered the already-prepared ModbusSim patch release \`v$version\` after an earlier run pushed the branch but failed before creating its pull request.

Review the generated CHANGELOG before merging.

## Automated checks

- release branch is exactly one generated commit on the current \`main\`
- release metadata consistency validation
EOF
gh pr create \
--base main \
--head "$RELEASE_BRANCH" \
--title "chore(release): prepare v$version" \
--body-file "$body_file" \
--draft
gh workflow run test.yml --ref "$RELEASE_BRANCH"
echo "Recovered release PR for $RELEASE_BRANCH"
echo "exists=true" >> "$GITHUB_OUTPUT"
exit 0
fi
echo "exists=false" >> "$GITHUB_OUTPUT"

- name: Prepare patch release
if: steps.release.outputs.prepare == 'true' && steps.branch.outputs.exists != 'true'
env:
LATEST_TAG: ${{ steps.release.outputs.latest_tag }}
NEXT_VERSION: ${{ steps.release.outputs.next_version }}
shell: bash
run: |
node scripts/prepare-release.mjs prepare \
--from "$LATEST_TAG" \
--version "$NEXT_VERSION" \
--date "$(TZ=Asia/Shanghai date +%F)"

- name: Test release automation
if: steps.release.outputs.prepare == 'true' && steps.branch.outputs.exists != 'true'
run: node --test scripts/*.test.mjs

- name: Commit release preparation
if: steps.release.outputs.prepare == 'true' && steps.branch.outputs.exists != 'true'
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
LATEST_TAG: ${{ steps.release.outputs.latest_tag }}
NEXT_VERSION: ${{ steps.release.outputs.next_version }}
RELEASE_BRANCH: ${{ steps.release.outputs.branch }}
shell: bash
run: |
set -euo pipefail
git switch -c "$RELEASE_BRANCH"
git config user.name "github-actions[bot]"
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
git add \
CHANGELOG.md \
crates/modbussim-app/Cargo.toml \
crates/modbussim-app/tauri.conf.json \
crates/modbusmaster-app/Cargo.toml \
crates/modbusmaster-app/tauri.conf.json
git commit -m "chore(release): prepare v$NEXT_VERSION"
git push origin "$RELEASE_BRANCH"

body_file="$(mktemp)"
cat > "$body_file" <<EOF
## Summary

Nightly automation found ${{ steps.release.outputs.unreleased }} commit(s) on \`main\` after \`$LATEST_TAG\` and prepared ModbusSim patch release \`v$NEXT_VERSION\`.

The generated CHANGELOG is intentionally editable. Review its wording before merging.

## Release behavior

After this PR is merged and normal CI succeeds, the release-on-merge workflow will validate all four app version files, create tag \`v$NEXT_VERSION\`, and dispatch the multi-platform Release workflow.

## Automated checks

- \`node --test scripts/*.test.mjs\`
- release metadata consistency validation
EOF
gh pr create \
--base main \
--head "$RELEASE_BRANCH" \
--title "chore(release): prepare v$NEXT_VERSION" \
--body-file "$body_file" \
--draft
gh workflow run test.yml --ref "$RELEASE_BRANCH"
135 changes: 135 additions & 0 deletions .github/workflows/release-on-merge.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,135 @@
name: Release tested version

on:
workflow_run:
workflows: [Test]
types: [completed]

permissions:
actions: write
contents: write
pull-requests: read

concurrency:
group: release-on-merge
cancel-in-progress: false

jobs:
tag-and-dispatch:
if: >-
github.event.workflow_run.conclusion == 'success' &&
github.event.workflow_run.head_branch == 'main'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
ref: ${{ github.event.workflow_run.head_sha }}
fetch-depth: 0

- uses: actions/setup-node@v4
with:
node-version: 22

- name: Identify the merged release PR
id: pull_request
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
shell: bash
run: |
set -euo pipefail
version="$(node scripts/prepare-release.mjs current)"
tag="v$version"

# Every successful Test run on main emits workflow_run. Once this
# version is published, later feature commits must be a clean no-op
# rather than trying to release the same merged preparation PR again.
if gh release view "$tag" >/dev/null 2>&1; then
echo "$tag is already published; nothing to publish."
echo "release=false" >> "$GITHUB_OUTPUT"
exit 0
fi

release_branch="$(gh pr list \
--state merged \
--base main \
--search "head:automation/release-v$version" \
--json headRefName \
--jq ".[] | select(.headRefName == \"automation/release-v$version\") | .headRefName" \
| head -n 1)"
if [ -z "$release_branch" ]; then
echo "Current version $version has no matching merged release PR; nothing to publish."
echo "release=false" >> "$GITHUB_OUTPUT"
exit 0
fi
echo "release=true" >> "$GITHUB_OUTPUT"
echo "branch=$release_branch" >> "$GITHUB_OUTPUT"

- name: Validate release metadata
if: steps.pull_request.outputs.release == 'true'
id: release
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
RELEASE_BRANCH: ${{ steps.pull_request.outputs.branch }}
shell: bash
run: |
set -euo pipefail
version="${RELEASE_BRANCH#automation/release-v}"
tag="v$version"
current="$(node scripts/prepare-release.mjs current)"
if [ "$current" != "$version" ]; then
echo "::error::Release branch says $version but app metadata says $current"
exit 1
fi
node scripts/prepare-release.mjs verify "$tag"
latest_tag="$(gh release view --json tagName --jq .tagName)"
expected="$(node scripts/prepare-release.mjs next "$latest_tag")"
if [ "$version" != "$expected" ]; then
echo "::error::Expected patch version $expected after $latest_tag, got $version"
exit 1
fi
echo "tag=$tag" >> "$GITHUB_OUTPUT"

- name: Create release tag
if: steps.pull_request.outputs.release == 'true'
env:
TAG: ${{ steps.release.outputs.tag }}
shell: bash
run: |
set -euo pipefail
git fetch origin --tags --force
if git rev-parse --verify --quiet "refs/tags/$TAG" >/dev/null; then
if ! git merge-base --is-ancestor "$TAG" "$GITHUB_SHA"; then
echo "::error::Tag $TAG does not belong to the tested main history"
exit 1
fi
echo "$TAG already exists on the tested main history; continuing idempotently."
exit 0
fi
git config user.name "github-actions[bot]"
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
git tag -a "$TAG" -m "$TAG"
git push origin "$TAG"

# Tags pushed with GITHUB_TOKEN do not trigger another push workflow.
- name: Start multi-platform release build
if: steps.pull_request.outputs.release == 'true'
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
TAG: ${{ steps.release.outputs.tag }}
shell: bash
run: |
set -euo pipefail
existing="$(
gh run list \
--workflow release.yml \
--branch "$TAG" \
--limit 20 \
--json headSha,status,conclusion \
--jq ".[] | select(.headSha == \"$GITHUB_SHA\") | [.status, (.conclusion // \"\")] | @tsv" \
| head -n 1
)"
if [[ "$existing" == queued$'\t'* || "$existing" == in_progress$'\t'* || "$existing" == completed$'\t'success ]]; then
echo "Release workflow already active or successful for $TAG: $existing"
exit 0
fi
gh workflow run release.yml --ref "$TAG" -f tag="$TAG"
Loading
Loading