From fa304df9f5dd1d91d2c7157f7ba496f5f74ea444 Mon Sep 17 00:00:00 2001 From: Loong Date: Sun, 13 Sep 2026 14:46:09 +0100 Subject: [PATCH 1/6] ci: publish releases from explicit release PRs --- .github/scripts/release-artifacts.sh | 14 + .github/scripts/release-files.sh | 70 ++++ .github/scripts/release-tag.sh | 22 ++ .github/scripts/test-release-files.sh | 114 ++++++ .github/scripts/test-release-recovery.sh | 66 ++++ .github/workflows/prepare-release.yml | 115 ++++++ .github/workflows/publish-changelog.yml | 74 ---- .github/workflows/release-tools.yml | 32 ++ .github/workflows/release.yml | 418 +++++++++++++------- DEVELOPMENT.md | 42 ++ cliff.toml | 16 +- xtask/src/env.rs | 10 - xtask/src/github.rs | 88 +---- xtask/src/release/meta.rs | 471 ++++++++++++++++------- xtask/src/release/mod.rs | 39 +- xtask/src/release/package.rs | 315 ++++++++++++++- xtask/src/release/selection.rs | 254 ++++++++++++ 17 files changed, 1701 insertions(+), 459 deletions(-) create mode 100644 .github/scripts/release-artifacts.sh create mode 100644 .github/scripts/release-files.sh create mode 100644 .github/scripts/release-tag.sh create mode 100644 .github/scripts/test-release-files.sh create mode 100644 .github/scripts/test-release-recovery.sh create mode 100644 .github/workflows/prepare-release.yml delete mode 100644 .github/workflows/publish-changelog.yml create mode 100644 .github/workflows/release-tools.yml create mode 100644 xtask/src/release/selection.rs diff --git a/.github/scripts/release-artifacts.sh b/.github/scripts/release-artifacts.sh new file mode 100644 index 00000000..2a2ee7e6 --- /dev/null +++ b/.github/scripts/release-artifacts.sh @@ -0,0 +1,14 @@ +#!/usr/bin/env bash +# Query on every attempt, including retries that did not rerun the meta job. +set -euo pipefail +: "${GH_REPO:?}" "${GITHUB_RUN_ID:?}" "${GITHUB_OUTPUT:?}" +artifacts=$(gh api --paginate "repos/$GH_REPO/actions/runs/$GITHUB_RUN_ID/artifacts" | + jq -s '[.[].artifacts[] | select(.name == "release-plan" or .name == "release-bundle")]') +if jq -e 'any(.[]; .expired)' <<< "$artifacts" > /dev/null; then + echo 'Release artifacts expired; do not recalculate or rebuild an existing release.' >&2 + exit 1 +fi +for name in plan bundle; do + exists=$(jq --arg name "release-$name" 'any(.[]; .name == $name)' <<< "$artifacts") + echo "$name=$exists" >> "$GITHUB_OUTPUT" +done diff --git a/.github/scripts/release-files.sh b/.github/scripts/release-files.sh new file mode 100644 index 00000000..83e12bd8 --- /dev/null +++ b/.github/scripts/release-files.sh @@ -0,0 +1,70 @@ +#!/usr/bin/env bash +# Source-file operations shared by release preparation and its PR check. +set -euo pipefail + +package_version() { + cargo metadata --locked --no-deps --format-version 1 | + jq -r '.packages[] | select(.name == "maa-cli") | .version' +} + +release_notes() { + awk -v heading="## Release $1" ' + /^## / { if (found) exit; if ($0 == heading) found = 1 } + found { print } + END { if (!found) exit 1 } + ' CHANGELOG.md +} + +case "${1:?expected prepare, check, or notes}" in + prepare) + : "${VERSION:?}" "${PREVIOUS_TAG:?}" + current_version=$(package_version) + if [[ "$current_version" != "$VERSION" ]]; then + # Versions have already been parsed and validated by release select-version. + sed -i.bak "s/^version = \"$current_version\"$/version = \"$VERSION\"/" \ + crates/maa-cli/Cargo.toml + mv crates/maa-cli/Cargo.toml.bak "$RUNNER_TEMP/Cargo.toml.before-release" + cargo update -p maa-cli --precise "$VERSION" --offline + fi + [[ "$(package_version)" == "$VERSION" ]] + + # A previous preparation may already be on main without a published tag. + # Remove only its section; git-cliff owns generation and prepending. + awk -v heading="## Release $VERSION" ' + /^## / { skip = ($0 == heading) } + !skip { print } + ' CHANGELOG.md > "$RUNNER_TEMP/changelog-base.md" + mv "$RUNNER_TEMP/changelog-base.md" CHANGELOG.md + git-cliff --config cliff.toml --tag "v$VERSION" \ + --prepend CHANGELOG.md "$PREVIOUS_TAG..$(git rev-parse HEAD)" + + # A separately bumped Cargo version leaves only CHANGELOG.md to change. + git diff --name-only | while IFS= read -r file; do + case "$file" in + CHANGELOG.md|Cargo.lock|crates/maa-cli/Cargo.toml) ;; + *) echo "Unexpected release preparation change: $file" >&2; exit 1 ;; + esac + done + if git diff --quiet; then + echo 'Release files already match; no preparation PR is needed.' >&2 + exit 1 + fi + ;; + check) + : "${PREVIOUS_TAG:?}" + version=$(package_version) + release_notes "$version" > "$RUNNER_TEMP/actual-release-notes.md" + git-cliff --config cliff.toml --tag "v$version" --strip all \ + --output "$RUNNER_TEMP/expected-release-notes.md" \ + "$PREVIOUS_TAG..$(git rev-parse HEAD)" + perl -0pi -e 's/\s+\z/\n/' "$RUNNER_TEMP/"{actual,expected}-release-notes.md + if ! diff -u "$RUNNER_TEMP/"{actual,expected}-release-notes.md; then + echo 'Release PR is stale. Run Prepare Stable Release again before merging.' >&2 + exit 1 + fi + ;; + notes) + release_notes "${2:?expected version}" + ;; + *) echo "Unknown operation: $1" >&2; exit 1 ;; +esac diff --git a/.github/scripts/release-tag.sh b/.github/scripts/release-tag.sh new file mode 100644 index 00000000..b7f012c0 --- /dev/null +++ b/.github/scripts/release-tag.sh @@ -0,0 +1,22 @@ +#!/usr/bin/env bash +set -euo pipefail +: "${TAG:?}" "${COMMIT:?}" "${GH_REPO:?}" "${RUNNER_TEMP:?}" +refs=$(git ls-remote --tags origin "refs/tags/$TAG" "refs/tags/$TAG^{}") +existing=$(awk '/\^\{\}$/ { peeled=$1 } { direct=$1 } END { print peeled ? peeled : direct }' <<< "$refs") +if [[ -z "$existing" || "$existing" == "$COMMIT" ]]; then + exit 0 +fi +if [[ "$TAG" != nightly ]]; then + echo "Tag $TAG targets $existing instead of $COMMIT" >&2 + exit 1 +fi + +if gh api "repos/$GH_REPO/releases/tags/nightly" --silent 2> "$RUNNER_TEMP/nightly-error"; then + gh release delete nightly --yes --cleanup-tag +elif grep -q 'HTTP 404' "$RUNNER_TEMP/nightly-error"; then + # The tag can survive a failed release creation or manual deletion of the release. + gh api --method DELETE "repos/$GH_REPO/git/refs/tags/nightly" +else + cat "$RUNNER_TEMP/nightly-error" >&2 + exit 1 +fi diff --git a/.github/scripts/test-release-files.sh b/.github/scripts/test-release-files.sh new file mode 100644 index 00000000..d9fff556 --- /dev/null +++ b/.github/scripts/test-release-files.sh @@ -0,0 +1,114 @@ +#!/usr/bin/env bash +# Exercise real git-cliff/Cargo commands without touching the project checkout. +set -euo pipefail +repo=$(cd "$(dirname "$0")/../.." && pwd) +xtask=${XTASK_BIN:-$repo/target/debug/xtask} +files="$repo/.github/scripts/release-files.sh" +test_root=$(mktemp -d) +export RUNNER_TEMP="$test_root/output" +export GITHUB_OUTPUT="$test_root/outputs" +export GIT_CLIFF_OFFLINE=true +mkdir -p "$RUNNER_TEMP" "$test_root/repo/crates/maa-cli/src" +cd "$test_root/repo" +git init -q -b main +git config user.name 'Release Test' +git config user.email 'release@example.invalid' +git config commit.gpgsign false +cp "$repo/cliff.toml" . +printf '[workspace]\nmembers = ["crates/maa-cli"]\nresolver = "2"\n' > Cargo.toml +printf '[package]\nname = "maa-cli"\nversion = "0.7.5"\nedition = "2024"\n' > crates/maa-cli/Cargo.toml +printf 'pub fn example() {}\n' > crates/maa-cli/src/lib.rs +printf '# Release Notes\n\n## Release 0.7.5\n\nPrevious release\n' > CHANGELOG.md +cargo generate-lockfile --offline +git add . +git commit -qm 'chore: initial' +git tag v0.7.5 + +select_version() { + : > "$GITHUB_OUTPUT" + "$xtask" release select-version --version "$1" > "$RUNNER_TEMP/select.log" + VERSION=$(sed -n 's/^version=//p' "$GITHUB_OUTPUT") + PREVIOUS_TAG=$(sed -n 's/^previous_tag=//p' "$GITHUB_OUTPUT") + export VERSION PREVIOUS_TAG +} + +git commit -q --allow-empty -m 'docs: clarify usage' +select_version auto +[[ "$VERSION" == 0.7.5 ]] +git commit -q --allow-empty -m 'fix: repair A' +select_version auto +[[ "$VERSION" == 0.7.6 ]] +git checkout -qb release-prep/v0.7.6 +bash "$files" prepare +bash "$files" check +git add . +git commit -qm 'chore(release): prepare for v0.7.6' +prepared_sha=$(git rev-parse HEAD) +bash "$files" notes 0.7.6 > "$RUNNER_TEMP/before-tag.md" +git tag v0.7.6 +bash "$files" notes 0.7.6 > "$RUNNER_TEMP/after-tag.md" +cmp "$RUNNER_TEMP/before-tag.md" "$RUNNER_TEMP/after-tag.md" +git tag -d v0.7.6 > /dev/null + +# A stale PR must be refreshable even after its version files reached main. +git checkout -q main +git commit -q --allow-empty -m 'fix: repair B' +git cherry-pick "$prepared_sha" > /dev/null +if bash "$files" check > "$RUNNER_TEMP/stale.log" 2>&1; then + echo 'Expected stale release notes to fail' >&2 + exit 1 +fi +select_version auto +[[ "$VERSION" == 0.7.6 ]] +bash "$files" prepare +[[ "$(git diff --name-only)" == CHANGELOG.md ]] +bash "$files" check +[[ "$(grep -c '^## Release 0.7.6$' CHANGELOG.md)" == 1 ]] +grep -q 'Repair B' CHANGELOG.md +git add CHANGELOG.md +git commit -qm 'chore(release): prepare for v0.7.6' + +# A normal version-only PR can be followed by a changelog-only release PR. +git checkout -qb manual-bump v0.7.5 +sed -i.bak 's/0.7.5/0.8.0/' crates/maa-cli/Cargo.toml +mv crates/maa-cli/Cargo.toml.bak "$RUNNER_TEMP/old-manifest" +cargo update -p maa-cli --precise 0.8.0 --offline +git add . +git commit -qm 'chore: bump maa-cli to 0.8.0' +select_version auto +[[ "$VERSION" == 0.8.0 ]] +bash "$files" prepare +[[ "$(git diff --name-only)" == CHANGELOG.md ]] +bash "$files" check +select_version 0.9.0 +[[ "$VERSION" == 0.9.0 ]] +select_version patch +[[ "$VERSION" == 0.7.6 ]] + +# The beta CLI reads its published index but must not write source files. +mkdir version +git init -q version +git -C version config user.name 'Release Test' +git -C version config user.email 'release@example.invalid' +git -C version config commit.gpgsign false +for channel in stable beta alpha; do + jq -n --arg channel "$channel" \ + '{version:"0.7.5",details:{tag:"v0.7.5",commit:"old",assets:{}}}' > "version/$channel.json" +done +git -C version add . +git -C version commit -qm 'chore: published index' +git diff > "$RUNNER_TEMP/before-beta.diff" +commit=$(git rev-parse HEAD) +: > "$GITHUB_OUTPUT" +"$xtask" release meta --channel beta --commit "$commit" --version 0.8.0 --publish +grep -qx 'version=0.8.0-beta.1' "$GITHUB_OUTPUT" +git diff > "$RUNNER_TEMP/after-beta.diff" +cmp "$RUNNER_TEMP/before-beta.diff" "$RUNNER_TEMP/after-beta.diff" +git -C version diff --exit-code + +# A stable tag may already exist while its version-index job still needs a retry. +git tag v0.8.0 +: > "$GITHUB_OUTPUT" +"$xtask" release meta --channel beta --commit "$commit" --version auto --publish +grep -qx 'skip=true' "$GITHUB_OUTPUT" +echo "Release file and beta integration checks passed (fixtures: $test_root)" diff --git a/.github/scripts/test-release-recovery.sh b/.github/scripts/test-release-recovery.sh new file mode 100644 index 00000000..9759109c --- /dev/null +++ b/.github/scripts/test-release-recovery.sh @@ -0,0 +1,66 @@ +#!/usr/bin/env bash +# Mock only remote API responses; exercise the actual workflow helper scripts. +set -euo pipefail +scripts=$(cd "$(dirname "$0")" && pwd) +export RUNNER_TEMP +RUNNER_TEMP=$(mktemp -d) +export GITHUB_OUTPUT="$RUNNER_TEMP/outputs" +export GH_REPO=example/repo GITHUB_RUN_ID=123 COMMIT=new TAG=nightly +export GH_CALLS="$RUNNER_TEMP/calls" TEST_CASE=orphan + +git() { printf 'old\trefs/tags/%s\n' "$TAG"; } +gh() { + echo "$*" >> "$GH_CALLS" + case "$*" in + *actions/runs/*) + case "$TEST_CASE" in + empty) echo '{"artifacts":[]}' ;; + bundle) echo '{"artifacts":[{"name":"release-plan","expired":false},{"name":"release-bundle","expired":false}]}' ;; + expired) echo '{"artifacts":[{"name":"release-bundle","expired":true}]}' ;; + denied) echo 'HTTP 403' >&2; return 1 ;; + esac + ;; + 'api --method DELETE '*) ;; + *releases/tags/nightly*) + case "$TEST_CASE" in + orphan) echo 'gh: Not Found (HTTP 404)' >&2; return 1 ;; + denied) echo 'gh: Forbidden (HTTP 403)' >&2; return 1 ;; + esac + ;; + 'release delete nightly --yes --cleanup-tag') ;; + *) echo "Unexpected gh call: $*" >&2; return 1 ;; + esac +} +export -f git gh + +bash "$scripts/release-tag.sh" +grep -q '^api --method DELETE .*git/refs/tags/nightly$' "$GH_CALLS" +TEST_CASE=exists +: > "$GH_CALLS" +bash "$scripts/release-tag.sh" +grep -qx 'release delete nightly --yes --cleanup-tag' "$GH_CALLS" +TEST_CASE=denied +: > "$GH_CALLS" +if bash "$scripts/release-tag.sh" 2> "$RUNNER_TEMP/error"; then exit 1; fi +if grep -q 'DELETE\|release delete' "$GH_CALLS"; then exit 1; fi +TAG=v0.8.0 +if bash "$scripts/release-tag.sh" 2> "$RUNNER_TEMP/error"; then exit 1; fi +COMMIT=old +bash "$scripts/release-tag.sh" + +# A failed-job retry must see a bundle uploaded after meta last ran. +TEST_CASE=empty +: > "$GITHUB_OUTPUT" +bash "$scripts/release-artifacts.sh" +grep -qx 'bundle=false' "$GITHUB_OUTPUT" +TEST_CASE=bundle +: > "$GITHUB_OUTPUT" +bash "$scripts/release-artifacts.sh" +grep -qx 'bundle=true' "$GITHUB_OUTPUT" +grep -qx 'plan=true' "$GITHUB_OUTPUT" +for TEST_CASE in expired denied; do + : > "$GITHUB_OUTPUT" + if bash "$scripts/release-artifacts.sh" 2> "$RUNNER_TEMP/error"; then exit 1; fi + [[ ! -s "$GITHUB_OUTPUT" ]] +done +echo "Release recovery checks passed (fixtures: $RUNNER_TEMP)" diff --git a/.github/workflows/prepare-release.yml b/.github/workflows/prepare-release.yml new file mode 100644 index 00000000..83d141af --- /dev/null +++ b/.github/workflows/prepare-release.yml @@ -0,0 +1,115 @@ +name: Prepare Stable Release + +on: + workflow_dispatch: + inputs: + version: + description: 'auto, patch, minor, major, or an exact stable version' + type: string + default: auto + +permissions: + contents: write + pull-requests: write + +concurrency: + group: release-state + queue: max + +defaults: + run: + shell: bash + +jobs: + prepare: + name: Prepare Stable Release + if: github.ref == 'refs/heads/main' + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v7 + with: + fetch-depth: 0 + ref: main + + - name: Install git-cliff + uses: taiki-e/install-action@v2 + with: + tool: git-cliff@2.13.1 + + - name: Select stable version + id: release + env: + VERSION_SELECTION: ${{ inputs.version }} + run: | + cargo x -- release select-version --version "$VERSION_SELECTION" + + - name: Prepare release files + env: + VERSION: ${{ steps.release.outputs.version }} + PREVIOUS_TAG: ${{ steps.release.outputs.previous_tag }} + GITHUB_TOKEN: ${{ github.token }} + run: | + if [[ "v$VERSION" == "$PREVIOUS_TAG" ]]; then + echo 'No changes require an automatic release; specify a version to release anyway.' + exit 1 + fi + bash .github/scripts/release-files.sh prepare + + - name: Commit release preparation as GitHub Actions + env: + VERSION: ${{ steps.release.outputs.version }} + run: | + git config user.name "github-actions[bot]" + git config user.email \ + "41898282+github-actions[bot]@users.noreply.github.com" + git add CHANGELOG.md Cargo.lock crates/maa-cli/Cargo.toml + git commit -m "chore(release): prepare for v$VERSION" + + - name: Push branch and create or update pull request + env: + GH_TOKEN: ${{ github.token }} + VERSION: ${{ steps.release.outputs.version }} + run: | + branch="release-prep/v$VERSION" + remote_head="$(git ls-remote --heads origin "refs/heads/$branch" | cut -f1)" + if [[ -n "$remote_head" ]]; then + git push origin \ + --force-with-lease="refs/heads/$branch:$remote_head" \ + "HEAD:refs/heads/$branch" + else + git push origin "HEAD:refs/heads/$branch" + fi + + body="$RUNNER_TEMP/release-pr-body.md" + { + echo "Prepare the source release state for **v$VERSION**." + echo + echo "- Version selected during release preparation" + echo "- Cargo manifest and lockfile updated together" + echo "- Changelog prepended by git-cliff from the exact candidate commit" + echo + echo 'The release label makes merging this PR into main publish Stable.' + echo 'Approve its workflows, check Release Readiness, and refresh this PR if it is stale.' + } > "$body" + + pr_url="$(gh pr list \ + --base main \ + --head "$branch" \ + --state open \ + --json url \ + --jq '.[0].url')" + if ! gh label list --json name --jq '.[].name' | grep -qx release; then + gh label create release --description 'Merging this PR publishes a stable release' + fi + if [[ -n "$pr_url" ]]; then + gh pr edit "$pr_url" \ + --title "chore(release): prepare for v$VERSION" \ + --body-file "$body" --add-label release + else + gh pr create \ + --base main \ + --head "$branch" \ + --title "chore(release): prepare for v$VERSION" \ + --body-file "$body" --label release + fi diff --git a/.github/workflows/publish-changelog.yml b/.github/workflows/publish-changelog.yml deleted file mode 100644 index d7c83800..00000000 --- a/.github/workflows/publish-changelog.yml +++ /dev/null @@ -1,74 +0,0 @@ -name: Update Changelog - -on: - workflow_dispatch: - inputs: - version: - description: Version number - required: true - type: string - dryrun: - description: Do not push commits - required: false - type: boolean - default: false - workflow_call: - inputs: - version: - description: Version number - required: true - type: string - dryrun: - description: Do not push commits - required: false - type: boolean - default: false - -defaults: - run: - shell: bash - -jobs: - release-note: - name: Update Changelog - runs-on: ubuntu-latest - permissions: - contents: write - steps: - - name: Checkout - uses: actions/checkout@v7 - with: - fetch-depth: 0 - - name: Generate Release Notes - id: git_cliff - uses: orhun/git-cliff-action@v4 - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - with: - config: cliff.toml - # If the release is triggered by a tag, process commits starting - # from the latest tag, otherwise tag will be create later, so use - # unreleased commits - args: -v -l -p CHANGELOG.md - - - name: Setup Git User - run: | - git config user.name "github-actions[bot]" - git config user.email "41898282+github-actions[bot]@users.noreply.github.com" - - - name: Preview Changes - if: ${{ inputs.dryrun }} - run: | - { - echo "Changes to CHANGELOG.md" - echo '```diff' - git diff CHANGELOG.md - echo '```' - } >> "$GITHUB_STEP_SUMMARY" - - - name: Commit Changes - if: ${{ !inputs.dryrun }} - run: | - git add CHANGELOG.md - git commit -m "chore(changelog): update changelog for ${{ inputs.version }}" - git push diff --git a/.github/workflows/release-tools.yml b/.github/workflows/release-tools.yml new file mode 100644 index 00000000..51440ca8 --- /dev/null +++ b/.github/workflows/release-tools.yml @@ -0,0 +1,32 @@ +name: Test Release Tools + +on: + pull_request: + branches: [main] + paths: + - .github/scripts/release-*.sh + - .github/scripts/test-release-*.sh + - .github/workflows/release.yml + - .github/workflows/prepare-release.yml + - .github/workflows/release-tools.yml + - cliff.toml + - xtask/src/release/** + - xtask/src/github.rs + +permissions: + contents: read + +jobs: + test: + name: Release Tool Regression Tests + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v7 + - uses: taiki-e/install-action@v2 + with: + tool: git-cliff@2.13.1 + - run: cargo build -p xtask --locked + - run: cargo test -p xtask --locked + - run: cargo clippy -p xtask --all-targets --locked -- -D warnings + - run: bash .github/scripts/test-release-files.sh + - run: bash .github/scripts/test-release-recovery.sh diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 3bc1a323..e0a4c6a2 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1,110 +1,186 @@ name: Release on: - push: - tags: - - "v[0-9]+.[0-9]+.[0-9]+" - # Just used for testing pull_request: - branches: - - main - paths: - - ".github/workflows/release.yml" + branches: [main] + types: [opened, reopened, synchronize, labeled, unlabeled, closed] workflow_dispatch: inputs: channel: - description: The release channel + description: The prerelease channel default: beta required: true type: choice - options: - - alpha - - beta - - stable + options: [alpha, beta] + version: + description: 'Base version: auto, patch, minor, major, or X.Y.Z' + default: auto + type: string publish: description: Whether to publish a new release default: false required: true type: boolean schedule: - # Release alpha every day at 20:00 UTC (4:00 AM UTC+8) - cron: "0 20 * * *" defaults: run: shell: bash +# PR checks must not hold up publishing; publishing and preparation share a queue. +concurrency: + group: ${{ github.event_name == 'pull_request' && github.event.action != 'closed' && format('release-check-{0}', github.event.pull_request.number) || 'release-state' }} + queue: max + permissions: contents: read + actions: read jobs: - meta: - name: Meta + readiness: + name: Release Readiness + if: >- + github.event_name == 'pull_request' && + github.event.action != 'closed' && + (contains(github.event.pull_request.labels.*.name, 'release') || + startsWith(github.event.pull_request.head.ref, 'release-prep/')) && + github.event.pull_request.head.repo.full_name == github.repository runs-on: ubuntu-latest - outputs: - version: ${{ steps.meta.outputs.version }} - tag: ${{ steps.meta.outputs.tag }} - commit: ${{ steps.meta.outputs.commit }} - channel: ${{ steps.meta.outputs.channel }} - profile: ${{ steps.meta.outputs.profile }} - publish: ${{ steps.meta.outputs.publish }} - skip: ${{ steps.meta.outputs.skip }} steps: - - name: Checkout - uses: actions/checkout@v7 - - name: Checkout Version Branch - uses: actions/checkout@v7 + - uses: actions/checkout@v7 with: - ref: version - path: version - - name: Meta - id: meta + fetch-depth: 0 + - uses: taiki-e/install-action@v2 + with: + tool: git-cliff@2.13.1 + - name: Select candidate + id: candidate + run: cargo x -- release select-version + - name: Verify release files against the candidate merge + env: + PREVIOUS_TAG: ${{ steps.candidate.outputs.previous_tag }} + GITHUB_TOKEN: ${{ github.token }} + BASE_SHA: ${{ github.event.pull_request.base.sha }} + HEAD_SHA: ${{ github.event.pull_request.head.sha }} run: | - cargo x -- release meta + git diff --name-only "$BASE_SHA...$HEAD_SHA" | while IFS= read -r file; do + case "$file" in + CHANGELOG.md|Cargo.lock|crates/maa-cli/Cargo.toml) ;; + *) echo "Unexpected file in release PR: $file"; exit 1 ;; + esac + done + bash .github/scripts/release-files.sh check - release-note: - name: Generate Release Notes - if: ${{ !fromJson(needs.meta.outputs.skip) }} + meta: + name: Release Plan + if: >- + (github.event_name == 'pull_request' && + github.event.action == 'closed' && + github.event.pull_request.merged == true && + contains(github.event.pull_request.labels.*.name, 'release') && + github.event.pull_request.head.repo.full_name == github.repository) || + (github.event_name != 'pull_request' && github.ref == 'refs/heads/main') runs-on: ubuntu-latest - needs: [meta] - env: - CLIFF_SHORT_USERNAME: true outputs: - content: ${{ steps.git_cliff.outputs.content }} + version: ${{ steps.plan.outputs.version }} + tag: ${{ steps.plan.outputs.tag }} + commit: ${{ steps.plan.outputs.commit }} + channel: ${{ steps.plan.outputs.channel }} + profile: ${{ steps.plan.outputs.profile }} + publish: ${{ steps.plan.outputs.publish }} + skip: ${{ steps.plan.outputs.skip }} + packaged: ${{ steps.saved.outputs.bundle }} + env: + GH_REPO: ${{ github.repository }} + GH_TOKEN: ${{ github.token }} steps: - - name: Checkout - uses: actions/checkout@v7 + - name: Select source commit + id: source + env: + EVENT_NAME: ${{ github.event_name }} + SOURCE_SHA: ${{ github.sha }} + run: | + if [[ "$EVENT_NAME" == pull_request ]]; then + commit=$(jq -r '.pull_request.merge_commit_sha' "$GITHUB_EVENT_PATH") + else + commit="$SOURCE_SHA" + fi + echo "commit=$commit" >> "$GITHUB_OUTPUT" + - uses: actions/checkout@v7 with: fetch-depth: 0 - - name: Generate Release Notes - id: git_cliff - uses: orhun/git-cliff-action@v4 - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - VERSION: ${{ needs.meta.outputs.version }} - TAG: ${{ needs.meta.outputs.tag }} + ref: ${{ steps.source.outputs.commit }} + - name: Locate this run's frozen plan and bundle + id: saved + run: bash .github/scripts/release-artifacts.sh + - name: Restore frozen plan + if: steps.saved.outputs.plan == 'true' + uses: actions/download-artifact@v8 with: - config: cliff.toml - # If the release is triggered by a tag, process commits starting - # from the latest tag, otherwise tag will be create later, so use - # unreleased commits - args: > - -v - ${{ github.event_name == 'push' && '-l' || '-u' }} - ${{ needs.meta.outputs.channel != 'stable' && '--tag-pattern="^v"' || '' }} - - - name: Preview Release Notes - if: ${{ !fromJson(needs.meta.outputs.publish) }} + name: release-plan + path: ${{ runner.temp }}/release-plan + - name: Checkout version index + if: steps.saved.outputs.plan != 'true' + uses: actions/checkout@v7 + with: + ref: version + path: version + - name: Install git-cliff + if: steps.saved.outputs.plan != 'true' + uses: taiki-e/install-action@v2 + with: + tool: git-cliff@2.13.1 + - name: Resolve plan once + if: steps.saved.outputs.plan != 'true' + env: + COMMIT: ${{ steps.source.outputs.commit }} + EVENT_NAME: ${{ github.event_name }} + CHANNEL_INPUT: ${{ inputs.channel }} + VERSION_SELECTION: ${{ inputs.version || 'auto' }} + PUBLISH_INPUT: ${{ inputs.publish }} + GITHUB_TOKEN: ${{ github.token }} run: | - { - echo '```markdown' - cat '${{ steps.git_cliff.outputs.changelog }}' - echo '```' - } >> "$GITHUB_STEP_SUMMARY" + mkdir -p "$RUNNER_TEMP/release-plan" + case "$EVENT_NAME" in + pull_request) channel=stable; publish=true ;; + schedule) channel=alpha; publish=true ;; + workflow_dispatch) channel="$CHANNEL_INPUT"; publish="$PUBLISH_INPUT" ;; + *) exit 1 ;; + esac + args=(--channel "$channel" --commit "$COMMIT" --version "$VERSION_SELECTION") + if [[ "$publish" == true ]]; then args+=(--publish); fi + GITHUB_OUTPUT="$RUNNER_TEMP/release-plan/outputs" cargo x -- release meta "${args[@]}" + if ! grep -qx 'skip=true' "$RUNNER_TEMP/release-plan/outputs"; then + version=$(sed -n 's/^version=//p' "$RUNNER_TEMP/release-plan/outputs") + if [[ "$channel" == stable ]]; then + # Check the actual merge once, before freezing anything or creating a tag. + # Replays restore the frozen plan instead of running this check again. + GITHUB_OUTPUT="$RUNNER_TEMP/candidate" cargo x -- release select-version + PREVIOUS_TAG=$(sed -n 's/^previous_tag=//p' "$RUNNER_TEMP/candidate") \ + bash .github/scripts/release-files.sh check + bash .github/scripts/release-files.sh notes "$version" > "$RUNNER_TEMP/release-plan/notes.md" + else + VERSION="$version" TAG="$(sed -n 's/^tag=//p' "$RUNNER_TEMP/release-plan/outputs")" \ + git-cliff --config cliff.toml --unreleased --strip all \ + --output "$RUNNER_TEMP/release-plan/notes.md" + fi + fi + - name: Freeze plan before any publication + if: steps.saved.outputs.plan != 'true' + uses: actions/upload-artifact@v7 + with: + name: release-plan + path: ${{ runner.temp }}/release-plan + retention-days: 90 + if-no-files-found: error + - name: Output frozen plan + id: plan + run: cat "$RUNNER_TEMP/release-plan/outputs" >> "$GITHUB_OUTPUT" build: name: Build needs: meta - if: ${{ !fromJson(needs.meta.outputs.skip) }} + if: needs.meta.outputs.skip != 'true' && needs.meta.outputs.packaged != 'true' runs-on: ${{ matrix.runner }} strategy: fail-fast: false @@ -119,6 +195,8 @@ jobs: steps: - name: Checkout uses: actions/checkout@v7 + with: + ref: ${{ needs.meta.outputs.commit }} - name: Build id: build env: @@ -131,12 +209,13 @@ jobs: with: name: maa_cli-${{ steps.build.outputs.host_triplet }} path: ${{ steps.build.outputs.host_triplet }}.tar - retention-days: 1 + retention-days: 7 + overwrite: true if-no-files-found: error build-universal: name: Build Universal Binary - if: ${{ !fromJson(needs.meta.outputs.skip) }} + if: needs.meta.outputs.skip != 'true' && needs.meta.outputs.packaged != 'true' runs-on: macos-latest needs: [meta, build] steps: @@ -158,13 +237,14 @@ jobs: with: name: maa_cli-universal-apple-darwin path: universal-apple-darwin.tar - retention-days: 1 + retention-days: 7 + overwrite: true if-no-files-found: error build-winget: name: Build for Winget needs: meta - if: ${{ !fromJson(needs.meta.outputs.skip) }} + if: needs.meta.outputs.skip != 'true' && needs.meta.outputs.packaged != 'true' runs-on: ${{ matrix.runner }} strategy: fail-fast: false @@ -175,6 +255,8 @@ jobs: steps: - name: Checkout uses: actions/checkout@v7 + with: + ref: ${{ needs.meta.outputs.commit }} - name: Build id: build env: @@ -189,87 +271,156 @@ jobs: with: name: maa_cli-${{ steps.build.outputs.host_triplet }}-winget path: ${{ steps.build.outputs.host_triplet }}.tar - retention-days: 1 + retention-days: 7 + overwrite: true if-no-files-found: error release: - name: Release - if: ${{ !fromJson(needs.meta.outputs.skip) }} + name: Publish Release + needs: [meta, build, build-universal, build-winget] + if: >- + !cancelled() && needs.meta.result == 'success' && + needs.meta.outputs.skip != 'true' && + (needs.meta.outputs.packaged == 'true' || + (needs.build.result == 'success' && needs.build-universal.result == 'success' && + needs.build-winget.result == 'success')) runs-on: ubuntu-latest - needs: [meta, build, build-universal, build-winget, release-note] permissions: contents: write + actions: read env: GH_REPO: ${{ github.repository }} - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + GH_TOKEN: ${{ github.token }} CHANNEL: ${{ needs.meta.outputs.channel }} VERSION: ${{ needs.meta.outputs.version }} TAG: ${{ needs.meta.outputs.tag }} COMMIT: ${{ needs.meta.outputs.commit }} steps: - - name: Checkout - uses: actions/checkout@v7 - - name: Checkout version branch - uses: actions/checkout@v7 + - uses: actions/checkout@v7 + with: + ref: ${{ needs.meta.outputs.commit }} + - uses: actions/checkout@v7 with: ref: version path: version - - name: Download Artifacts + - name: Restore release plan uses: actions/download-artifact@v8 + with: + name: release-plan + path: ${{ runner.temp }}/release-plan + - name: Locate immutable bundle on every job attempt + id: bundle + run: bash .github/scripts/release-artifacts.sh + - name: Restore packaged artifacts + if: steps.bundle.outputs.bundle == 'true' + uses: actions/download-artifact@v8 + with: + name: release-bundle + path: release-bundle + - name: Download build artifacts + if: steps.bundle.outputs.bundle != 'true' + uses: actions/download-artifact@v8 + with: + pattern: maa_cli-* - name: Install cargo-about + if: steps.bundle.outputs.bundle != 'true' uses: taiki-e/install-action@v2 with: tool: cargo-about - - name: Generate Third-Party Licenses Information - run: cargo about generate about.hbs -o licenses.md - - name: Package Release Artifacts and Update Version Files - run: cargo x --release -- release package - - name: Check existing release - if: ${{ fromJson(needs.meta.outputs.publish) }} + - name: Package artifacts + if: steps.bundle.outputs.bundle != 'true' run: | - if gh release view "$TAG" &> /dev/null; then - if [ "$TAG" == "nightly" ]; then - echo "Release as nightly, clear existing release and tag" - gh release delete "$TAG" --yes --cleanup-tag - else - echo "Release $TAG already exists, abort" - exit 1 - fi - fi - sleep 5 # wait to avoid the release becomes draft - - name: Create Release + cargo about generate about.hbs -o licenses.md + cargo x -- release package + mkdir -p release-bundle/version + cp maa_cli-*.tar.gz maa_cli-*.zip release-bundle/ + cp version/*.json version/*.txt release-bundle/version/ + # Packaging wrote manifests locally; restore the published index for the guard. + git -C version restore . + - name: Freeze bundle before publication + if: steps.bundle.outputs.bundle != 'true' + uses: actions/upload-artifact@v7 + with: + name: release-bundle + path: release-bundle + retention-days: 90 + if-no-files-found: error + - name: Verify publication is not obsolete + if: needs.meta.outputs.publish == 'true' + run: cargo x -- release check-publication + - name: Check existing tag + if: needs.meta.outputs.publish == 'true' + run: bash .github/scripts/release-tag.sh + - name: Create or finish release with the same artifacts + if: needs.meta.outputs.publish == 'true' uses: softprops/action-gh-release@v3 - if: ${{ fromJson(needs.meta.outputs.publish) }} with: name: v${{ needs.meta.outputs.version }} tag_name: ${{ needs.meta.outputs.tag }} target_commitish: ${{ needs.meta.outputs.commit }} prerelease: ${{ needs.meta.outputs.channel != 'stable' }} - body: ${{ needs.release-note.outputs.content }} + body_path: ${{ runner.temp }}/release-plan/notes.md fail_on_unmatched_files: true files: | - maa_cli-*-unknown-linux-gnu.tar.gz - maa_cli-*-apple-darwin.tar.gz - maa_cli-*-pc-windows-msvc.zip - maa_cli-*-pc-windows-msvc-winget.zip - - name: Commit version files and Push + release-bundle/maa_cli-*-unknown-linux-gnu.tar.gz + release-bundle/maa_cli-*-apple-darwin.tar.gz + release-bundle/maa_cli-*-pc-windows-msvc.zip + release-bundle/maa_cli-*-pc-windows-msvc-winget.zip + - name: Preview + if: needs.meta.outputs.publish != 'true' + run: cat "$RUNNER_TEMP/release-plan/notes.md" >> "$GITHUB_STEP_SUMMARY" + + update-index: + name: Update Version Index + outputs: + current: ${{ steps.current.outputs.current }} + needs: [meta, release] + if: >- + !cancelled() && needs.release.result == 'success' && + needs.meta.outputs.publish == 'true' && needs.meta.outputs.skip != 'true' + runs-on: ubuntu-latest + permissions: + contents: write + env: + CHANNEL: ${{ needs.meta.outputs.channel }} + VERSION: ${{ needs.meta.outputs.version }} + TAG: ${{ needs.meta.outputs.tag }} + COMMIT: ${{ needs.meta.outputs.commit }} + steps: + - uses: actions/checkout@v7 + with: + ref: ${{ needs.meta.outputs.commit }} + - uses: actions/checkout@v7 + with: + ref: version + path: version + - uses: actions/download-artifact@v8 + with: + name: release-bundle + path: release-bundle + - name: Apply published manifests + run: cargo x -- release index + - name: Commit index working-directory: version run: | - git config --local user.name "github-actions[bot]" - git config --local user.email "41898282+github-actions[bot]@users.noreply.github.com" - { - echo "Commit changes to version files" - echo '```diff' - git diff ./*.json ./*.txt - echo '```' - } >> "$GITHUB_STEP_SUMMARY" - git commit ./*.json ./*.txt -m 'chore: bump version to v${{ needs.meta.outputs.version }}' - git push --verbose ${{ !fromJson(needs.meta.outputs.publish) && '--dry-run' || ''}} + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + git add ./*.json ./*.txt + if ! git diff --cached --quiet; then + git commit -m "chore: bump version to v$VERSION" + git push origin HEAD:refs/heads/version + fi + - name: Check whether this is still the current channel release + id: current + run: | + current=$(jq --arg version "$VERSION" --arg commit "$COMMIT" \ + '.version == $version and .details.commit == $commit' "version/$CHANNEL.json") + echo "current=$current" >> "$GITHUB_OUTPUT" update-stable: name: Update Stable Tag - if: ${{ fromJson(needs.meta.outputs.publish) && needs.meta.outputs.channel == 'stable' }} - needs: [meta, release] + if: ${{ !cancelled() && needs.update-index.result == 'success' && needs.update-index.outputs.current == 'true' && needs.meta.outputs.publish == 'true' && needs.meta.outputs.channel == 'stable' }} + needs: [meta, update-index] permissions: contents: write runs-on: ubuntu-latest @@ -278,26 +429,27 @@ jobs: uses: actions/checkout@v7 with: ref: ${{ needs.meta.outputs.commit }} + - uses: actions/checkout@v7 + with: + ref: version + path: version - name: Update Stable Tag + env: + VERSION: ${{ needs.meta.outputs.version }} + COMMIT: ${{ needs.meta.outputs.commit }} run: | + if ! jq -e --arg version "$VERSION" --arg commit "$COMMIT" \ + '.version == $version and .details.commit == $commit' version/stable.json > /dev/null; then + echo 'A newer stable release is indexed; leave the stable tag unchanged.' + exit 0 + fi git tag -f stable ${{ needs.meta.outputs.commit }} git push --verbose --force origin stable - update-changelog: - name: Update Changelog - needs: [meta, release] - permissions: - contents: write - if: ${{ fromJson(needs.meta.outputs.publish) && needs.meta.outputs.channel == 'stable' }} - uses: ./.github/workflows/publish-changelog.yml - with: - version: ${{ needs.meta.outputs.version }} - dryrun: false - publish-homebrew: name: Publish Homebrew Formulae - needs: [meta, release] - if: ${{ fromJson(needs.meta.outputs.publish) && needs.meta.outputs.channel == 'stable' }} + needs: [meta, update-index] + if: ${{ !cancelled() && needs.update-index.result == 'success' && needs.update-index.outputs.current == 'true' && needs.meta.outputs.publish == 'true' && needs.meta.outputs.channel == 'stable' }} uses: ./.github/workflows/publish-homebrew.yml with: version: ${{ needs.meta.outputs.version }} @@ -307,8 +459,8 @@ jobs: publish-aur: name: Publish AUR Package - needs: [meta, release] - if: ${{ fromJson(needs.meta.outputs.publish) && needs.meta.outputs.channel == 'stable' }} + needs: [meta, update-index] + if: ${{ !cancelled() && needs.update-index.result == 'success' && needs.update-index.outputs.current == 'true' && needs.meta.outputs.publish == 'true' && needs.meta.outputs.channel == 'stable' }} uses: ./.github/workflows/publish-aur.yml with: pkgver: ${{ needs.meta.outputs.version }} @@ -318,8 +470,8 @@ jobs: publish-winget: name: Publish Winget Package - needs: [meta, release] - if: ${{ fromJson(needs.meta.outputs.publish) && needs.meta.outputs.channel == 'stable' }} + needs: [meta, update-index] + if: ${{ !cancelled() && needs.update-index.result == 'success' && needs.update-index.outputs.current == 'true' && needs.meta.outputs.publish == 'true' && needs.meta.outputs.channel == 'stable' }} uses: ./.github/workflows/publish-winget.yml with: version: ${{ needs.meta.outputs.version }} diff --git a/DEVELOPMENT.md b/DEVELOPMENT.md index a1ec8a50..1af173e7 100644 --- a/DEVELOPMENT.md +++ b/DEVELOPMENT.md @@ -62,6 +62,48 @@ cargo test -p maa-cli cargo test <测试名称> ``` +## 发布流程 + +`main` 是发布源码分支。Stable 的发布意图由 PR 的 `release` 标签表示:同仓库的 PR 带此标签合入 `main` 才会发布。普通 PR 可以单独更新 Cargo 版本;修改 `Cargo.toml` 本身不会触发发布。 + +### 选择版本 + +`Prepare Stable Release` 和预发布的 `Release` 都接受 `version` 输入: + +- `auto`(默认):优先沿用 Cargo 中高于最近正式 tag 的待发布版本,否则由 git-cliff 推导。 +- `patch`、`minor`、`major`:由 git-cliff 相对最近正式 tag 计算指定级别的版本。 +- `X.Y.Z`:直接指定稳定版本号,必须高于最近正式 tag;预发布不接受在这里输入 `beta.N`。 + +自动推导只有 `fix`、`feat` 或 breaking change 会触发新版本:`fix` 推进 patch,`feat` 推进 minor,breaking change 在 `0.x` 阶段推进 minor。文档、重构等其他变更仍可出现在 changelog 中,但不单独触发自动 bump;需要发布时可以显式选择版本。 + +### Stable + +1. 在 Actions 中从 `main` 运行 `Prepare Stable Release`,选择版本。 +2. git-cliff 生成并 prepend changelog,Cargo 更新 lockfile;工作流创建或更新 `release-prep/vX.Y.Z` PR,并添加 `release` 标签。如果版本已通过普通 PR 更新,本次 PR 可以只修改 changelog。 +3. 批准自动创建 PR 的 workflow runs,检查普通 CI 和 `Release Readiness`。准备后若 main 有新变更,重新运行 Prepare 刷新 PR;版本选择变化时应关闭旧的准备 PR,避免误合并。 +4. 合并带 `release` 标签的 PR。工作流使用实际合并 commit 和已确认的 Cargo 版本,在创建任何 tag 前检查一次最终候选 changelog,然后保存发布计划和说明。 +5. 编译并保存打包产物,创建正式 tag 和 GitHub Release,再由独立 job 更新 `version` 分支。Stable tag、Homebrew、AUR 和 WinGet 的后续任务在索引更新成功后运行。 + +准备检查仅针对 release PR;普通 CI 不承担版本推导或 changelog 一致性检查。最终合并校验若发现过期内容,会在发布前停止;即使 Cargo 版本已合入,也可以重新运行 Prepare 创建修复 changelog 的 release PR。 + +### Beta 和 Nightly + +Beta 在 Actions 中从 `main` 手动运行 `Release`,选择 `channel=beta`,设置基础版本,并在真正发布时勾选 `publish`。默认不勾选,仅构建预览。Nightly 每日自动运行,也可以选择 `channel=alpha` 手动运行。 + +基础版本不变时 Beta 编号递增,例如 `0.8.0-beta.2` → `0.8.0-beta.3`;基础版本变化时从 `beta.1` 开始。相同 commit 和基础版本已经发布过该通道时跳过。预发布通过 `MAA_VERSION` 注入编译,不修改 main 的 `Cargo.toml`、`Cargo.lock` 或 `CHANGELOG.md`,也不需要版本 PR。 + +Beta 发布后更新 `version` 分支的 `beta.json`、`alpha.json` 及对应 `.txt`;Nightly 只更新 alpha;Stable 更新三个通道。若 Beta tag 超前于索引,先恢复前一次发布的索引 job,再分配新编号。 + +### 重试与验证 + +同一次 workflow run 的版本、commit 和发布说明保存为 `release-plan` artifact,打包结果和索引清单保存为 `release-bundle` artifact。重跑全部 jobs 或只重跑失败 jobs 都复用已保存的内容,不重新分配版本或改写已生成的产物。版本索引更新失败时,重跑 `Update Version Index` 及其失败的后续任务即可,不需要重新编译。 + +这些 artifacts 保留 90 天;过期时工作流停止,不能把重新推导和编译当作原发布的重试。不要提前删除恢复所需的 artifacts。正式 tag 已存在时必须指向同一 commit;旧 run 不能覆盖更新的通道版本。 + +`Prepare Stable Release` 使用内置 `GITHUB_TOKEN`,以 `github-actions[bot]` 身份创建 PR。仓库需要在 Settings → Actions → General 中启用 `Allow GitHub Actions to create and approve pull requests`。具有 write 权限的维护者批准 PR 的 workflow runs 后,检查才会运行。合并操作应由维护者完成,不使用 `GITHUB_TOKEN` 自动合并,否则不会产生所需的后续发布事件。 + +修改发布工具后运行 `cargo test -p xtask`、`cargo clippy -p xtask --all-targets -- -D warnings`、`bash .github/scripts/test-release-files.sh` 和 `bash .github/scripts/test-release-recovery.sh`。文件集成测试需要先 `cargo build -p xtask`,并在 PATH 中提供 git-cliff 2.13.1、git、Cargo、jq 和 Perl;测试只操作临时仓库。 + ## Workspace 架构 ### 分层概览 diff --git a/cliff.toml b/cliff.toml index bf0cf89c..9864065b 100644 --- a/cliff.toml +++ b/cliff.toml @@ -82,11 +82,19 @@ footer = """ """ +[bump] +# Features always advance the minor version, including while the project is 0.x. +features_always_bump_minor = true +# Breaking changes remain within 0.x until the project explicitly declares 1.0. +breaking_always_bump_major = false + [git] # parse the commits based on https://www.conventionalcommits.org conventional_commits = true # filter out the commits that are not conventional filter_unconventional = true +# fail instead of silently deriving a version from an incomplete commit set +require_conventional = true # process each line of a commit as an individual commit split_commits = false # replace the patterns in the commit message @@ -95,6 +103,9 @@ commit_preprocessors = [ ] # regex for parsing and grouping commits commit_parsers = [ + # skip synthetic merge commits created for PR and merge queue checks + { message = "^Merge ", skip = true }, + # skip commits with `[no changelog]` marker { message = "\\[(no|skip) changelog\\]", skip = true }, @@ -123,6 +134,7 @@ commit_parsers = [ # skip ci commits { message = "^ci", skip = true }, + { message = "^build", skip = true }, # skip xtask related commits { message = "^chore\\(xtask\\)", skip = true }, @@ -134,9 +146,11 @@ commit_parsers = [ { message = "^chore", group = "Miscellaneous" }, ] # regex for parsing and grouping commits # protect breaking changes from being skipped due to matching a skipping commit_parser -protect_breaking_commits = false +protect_breaking_commits = true # filter out the commits that are not matched by commit parsers filter_commits = false +# fail if a conventional commit type has no explicit changelog policy +fail_on_unmatched_commit = true # glob pattern for matching git tags tag_pattern = "^v[0-9]+\\.[0-9]+\\.[0-9]+$" # regex for skipping tags diff --git a/xtask/src/env.rs b/xtask/src/env.rs index 35b415a9..6abd9920 100644 --- a/xtask/src/env.rs +++ b/xtask/src/env.rs @@ -15,13 +15,3 @@ use anyhow::{Context, Result}; pub fn var(key: &str) -> Result { env::var(key).with_context(|| format!("{key} environment variable not set")) } - -/// Get an environment variable with a default value if not set. -/// -/// # Example -/// ``` -/// let value = env::var_or("MY_VAR", "default"); -/// ``` -pub fn var_or(key: &str, default: &str) -> String { - env::var(key).unwrap_or_else(|_| default.to_string()) -} diff --git a/xtask/src/github.rs b/xtask/src/github.rs index fb6b73cc..b2d8c027 100644 --- a/xtask/src/github.rs +++ b/xtask/src/github.rs @@ -1,95 +1,13 @@ -//! GitHub Actions utilities for interacting with workflow context and outputs. +//! GitHub Actions output helpers. use std::{ fs::{self, File}, io::Write, - str::FromStr, }; -use anyhow::{Context, Result, bail}; -use serde::Deserialize; +use anyhow::{Context, Result}; -use crate::{env, release::Channel}; - -/// GitHub event types that trigger workflows. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum EventName { - /// Pull request event - PullRequest, - /// Scheduled event (cron) - Schedule, - /// Manually triggered workflow - WorkflowDispatch, - /// Push event (including tags) - Push, -} - -impl FromStr for EventName { - type Err = anyhow::Error; - - fn from_str(s: &str) -> Result { - match s { - "pull_request" => Ok(EventName::PullRequest), - "schedule" => Ok(EventName::Schedule), - "workflow_dispatch" => Ok(EventName::WorkflowDispatch), - "push" => Ok(EventName::Push), - _ => bail!("Unknown GitHub event: {s}"), - } - } -} - -impl EventName { - /// Get the GitHub event name from environment. - pub fn from_env() -> Result { - let event_str = env::var("GITHUB_EVENT_NAME")?; - event_str.parse() - } -} - -/// GitHub workflow event structure for workflow_dispatch events. -#[derive(Deserialize)] -pub struct WorkflowEvent { - pub inputs: WorkflowInputs, -} - -impl WorkflowEvent { - /// Read and parse the workflow event from the file specified in GITHUB_EVENT_PATH. - pub fn from_env() -> Result { - let event_path = env::var("GITHUB_EVENT_PATH")?; - - let content = fs::read_to_string(&event_path) - .with_context(|| format!("Failed to read event file: {event_path}"))?; - - serde_json::from_str(&content) - .with_context(|| "Failed to parse workflow event JSON:\n{content}") - } -} - -/// Inputs for workflow_dispatch events. -#[derive(Deserialize)] -pub struct WorkflowInputs { - pub channel: Channel, - #[serde(deserialize_with = "deserialize_bool_from_string")] - pub publish: bool, -} - -/// Deserialize a boolean from a string. -fn deserialize_bool_from_string<'de, D>(deserializer: D) -> Result -where - D: serde::de::Deserializer<'de>, -{ - let s = String::deserialize(deserializer)?; - match s.as_str() { - "true" => Ok(true), - "false" => Ok(false), - _ => Err(serde::de::Error::custom("Invalid boolean string: {s}")), - } -} - -/// Get the GitHub ref (e.g., refs/tags/v1.0.0) from environment. -pub fn github_ref() -> String { - env::var_or("GITHUB_REF", "") -} +use crate::env; fn open_github_output() -> Result { let github_output = env::var("GITHUB_OUTPUT")?; diff --git a/xtask/src/release/meta.rs b/xtask/src/release/meta.rs index e2fe8f8e..a52e1c5a 100644 --- a/xtask/src/release/meta.rs +++ b/xtask/src/release/meta.rs @@ -1,46 +1,79 @@ -use std::{ - fs, - num::NonZeroU16, - path::Path, - process::{Command, exit}, - str::FromStr, -}; - -use anyhow::{Context, Result, ensure}; +use std::{num::NonZeroU16, path::Path, process::Command}; + +use anyhow::{Context, Result, bail, ensure}; use maa_version::{VersionManifest, cli::Details}; use semver::{BuildMetadata, Prerelease, Version}; -use serde::Deserialize; -use super::Channel; +use super::{Channel, ReleaseVersionOptions, selection}; use crate::{cmd::CommandExt, github}; -#[derive(Deserialize)] -struct CargoToml { - package: Package, -} - -#[derive(Deserialize)] -struct Package { - version: Version, -} - -pub fn run() -> Result<()> { - let cargo_pkg_version = get_cargo_version()?; +pub fn run(options: ReleaseVersionOptions) -> Result<()> { let commit_sha = get_commit_sha()?; + ensure!( + commit_sha == options.commit, + "Checked-out commit {commit_sha} does not match expected commit {}", + options.commit + ); let commit_short_sha = get_commit_short_sha()?; - - let event_name = github::EventName::from_env()?; - - let (channel, publish) = determine_channel_and_publish(event_name)?; + let channel = options.channel; + let publish = options.publish; + let candidate = if channel == Channel::Stable { + selection::cargo_version()? + } else { + let (candidate, previous_tag) = selection::select(&options.version)?; + // A stable tag can be published before the index job completes. Do not + // create an older prerelease just because stable.json still lags behind. + if previous_tag == format!("v{candidate}") { + println!("No releasable changes since {previous_tag}"); + github::set_output("skip", "true")?; + return Ok(()); + } + candidate + }; + selection::ensure_stable(&candidate)?; // Check if version directory exists ensure!(Path::new("version").exists(), "version directory not found"); let version_file = channel.version_file(); + let stable_manifest = read_version_manifest(Channel::Stable.version_file())?; + ensure!( + stable_manifest.version.pre.is_empty() && stable_manifest.version.build.is_empty(), + "version/stable.json contains an invalid stable version: {}", + stable_manifest.version + ); + ensure!( + candidate >= stable_manifest.version, + "Selected stable v{} is older than version/stable.json v{}", + candidate, + stable_manifest.version + ); - // Skip if no new commits + if channel != Channel::Stable && candidate == stable_manifest.version { + println!("No releasable changes since v{}", stable_manifest.version); + github::set_output("skip", "true")?; + return Ok(()); + } let manifest = read_version_manifest(&version_file)?; - if manifest.details.commit == commit_sha { + if channel == Channel::Stable { + validate_stable_release( + &candidate, + &manifest.version, + &commit_sha, + &manifest.details.commit, + )?; + } + if channel == Channel::Beta { + let tags = Command::new("git").args(["tag", "--list", "v*"]).read()?; + ensure_beta_index_current(&manifest.version, &tags)?; + } + if skip_existing_prerelease( + channel, + &candidate, + &manifest.version, + &commit_sha, + &manifest.details.commit, + ) { println!("No new commits, skipping all steps"); github::set_output("skip", "true")?; return Ok(()); @@ -48,22 +81,8 @@ pub fn run() -> Result<()> { let published_version = manifest.version; - // For stable releases triggered by push tag, validate tag matches version - if event_name == github::EventName::Push { - let github_ref = github::github_ref(); - let ref_version = github_ref.strip_prefix("refs/tags/v").unwrap_or(""); - ensure!( - ref_version == cargo_pkg_version.to_string(), - "Version tag not matched: expected v{cargo_pkg_version}, got {github_ref}" - ); - } - - let (version, tag) = compute_version( - channel, - &cargo_pkg_version, - &published_version, - &commit_short_sha, - )?; + let (version, tag) = + compute_version(channel, &candidate, &published_version, &commit_short_sha)?; let channel_str = channel.as_str(); println!( @@ -83,15 +102,6 @@ pub fn run() -> Result<()> { Ok(()) } -fn get_cargo_version() -> Result { - let content = - fs::read_to_string("crates/maa-cli/Cargo.toml").context("Failed to read Cargo.toml")?; - - let cargo_toml: CargoToml = toml::from_str(&content).context("Failed to parse Cargo.toml")?; - - Ok(cargo_toml.package.version) -} - fn get_commit_sha() -> Result { Command::new("git").args(["rev-parse", "HEAD"]).read() } @@ -102,56 +112,81 @@ fn get_commit_short_sha() -> Result { .read() } -fn determine_channel_and_publish(event_name: github::EventName) -> Result<(Channel, bool)> { - match event_name { - github::EventName::PullRequest => { - println!("PR detected"); - Ok((Channel::Alpha, false)) - } - github::EventName::Schedule => { - println!("Scheduled event detected"); - Ok((Channel::Alpha, true)) - } - github::EventName::WorkflowDispatch => { - println!("Workflow dispatch event detected"); - let event = github::WorkflowEvent::from_env()?; - let channel = event.inputs.channel; - Ok((channel, event.inputs.publish)) - } - github::EventName::Push => { - println!("New tag detected"); - Ok((Channel::Stable, true)) +fn validate_stable_release( + candidate: &Version, + published: &Version, + commit: &str, + published_commit: &str, +) -> Result<()> { + ensure!( + candidate > published || (candidate == published && commit == published_commit), + "Stable version {candidate} must be newer than {published}, or replay the same release commit" + ); + Ok(()) +} + +fn skip_existing_prerelease( + channel: Channel, + candidate: &Version, + published: &Version, + commit: &str, + published_commit: &str, +) -> bool { + channel != Channel::Stable + && is_same_core_version(candidate, published) + && commit == published_commit +} + +fn ensure_beta_index_current(indexed: &Version, tags: &str) -> Result<()> { + for tag in tags.lines() { + let Some(version) = tag + .strip_prefix('v') + .and_then(|version| Version::parse(version).ok()) + else { + continue; + }; + if version + .pre + .as_str() + .strip_prefix("beta.") + .is_some_and(|counter| counter.parse::().is_ok()) + { + ensure!( + version <= *indexed, + "Beta tag {tag} is newer than version/beta.json ({indexed}); rerun the previous release's index job before allocating another beta" + ); } } + Ok(()) } -fn read_version_manifest(file: &str) -> Result> { - let content = fs::read_to_string(file).with_context(|| format!("Failed to read {}", file))?; +fn read_version_manifest(file: impl AsRef) -> Result> { + let file = file.as_ref(); + let content = std::fs::read_to_string(file) + .with_context(|| format!("Failed to read {}", file.display()))?; - serde_json::from_str(&content).with_context(|| format!("Failed to parse {}", file)) + serde_json::from_str(&content).with_context(|| format!("Failed to parse {}", file.display())) } fn compute_version( channel: Channel, - cargo_version: &Version, + stable_version: &Version, published_version: &Version, commit_short_sha: &str, ) -> Result<(Version, String)> { match channel { Channel::Stable => { - let tag = format!("v{}", cargo_version); - Ok((cargo_version.clone(), tag)) + let tag = format!("v{stable_version}"); + Ok((stable_version.clone(), tag)) } Channel::Beta => { - check_version_bumped(cargo_version, published_version)?; - - let mut version = cargo_version.clone(); + ensure_candidate_not_older(stable_version, published_version)?; + let published_prerelease = PrereleaseVersion::try_from(&published_version.pre)?; + let mut version = stable_version.clone(); version.build = BuildMetadata::EMPTY; - if is_same_core_version(cargo_version, published_version) { - version.pre = PrereleaseVersion::from(&published_version.pre) - .bump_beta() - .into(); + if is_same_core_version(stable_version, published_version) { + version.pre = published_prerelease.bump_beta()?.into(); } else { version.pre = Prerelease::new("beta.1")?; } @@ -160,15 +195,13 @@ fn compute_version( Ok((version, tag)) } Channel::Alpha => { - check_version_bumped(cargo_version, published_version)?; - - let mut version = cargo_version.clone(); + ensure_candidate_not_older(stable_version, published_version)?; + let published_prerelease = PrereleaseVersion::try_from(&published_version.pre)?; + let mut version = stable_version.clone(); version.build = BuildMetadata::new(&format!("sha.{}", commit_short_sha))?; - if is_same_core_version(cargo_version, published_version) { - version.pre = PrereleaseVersion::from(&published_version.pre) - .bump_alpha() - .into(); + if is_same_core_version(stable_version, published_version) { + version.pre = published_prerelease.bump_alpha()?.into(); } else { version.pre = Prerelease::new("alpha.1")?; } @@ -186,10 +219,8 @@ struct PrereleaseVersion { alpha: Option, } -impl FromStr for PrereleaseVersion { - type Err = std::convert::Infallible; - - fn from_str(s: &str) -> Result { +impl PrereleaseVersion { + fn parse(s: &str) -> Result { if s.is_empty() { return Ok(Self::default()); } @@ -198,35 +229,44 @@ impl FromStr for PrereleaseVersion { let parts: Vec<&str> = rest.split('.').collect(); if parts.len() == 1 { - // beta.N - let beta = parts[0].parse().ok().and_then(NonZeroU16::new); - Ok(PrereleaseVersion { beta, alpha: None }) - } else if parts.len() >= 3 && parts[1] == "alpha" { - // beta.N.alpha.M - let beta = parts[0].parse().ok().and_then(NonZeroU16::new); - let alpha = parts[2].parse().ok().and_then(NonZeroU16::new); - Ok(PrereleaseVersion { beta, alpha }) + let beta = parse_counter(parts[0], "beta")?; + Ok(Self { + beta: Some(beta), + alpha: None, + }) + } else if parts.len() == 3 && parts[1] == "alpha" { + let beta = parse_counter(parts[0], "beta")?; + let alpha = parse_counter(parts[2], "alpha")?; + Ok(PrereleaseVersion { + beta: Some(beta), + alpha: Some(alpha), + }) } else { - Ok(Self::default()) + bail!("Unsupported pre-release version: {s}") } } else if let Some(rest) = s.strip_prefix("alpha.") { - // alpha.N - let alpha = rest.parse().ok().and_then(NonZeroU16::new); - Ok(PrereleaseVersion { beta: None, alpha }) + let alpha = parse_counter(rest, "alpha")?; + Ok(Self { + beta: None, + alpha: Some(alpha), + }) } else { - Ok(Self::default()) + bail!("Unsupported pre-release version: {s}") } } } -impl From<&Prerelease> for PrereleaseVersion { - fn from(prerelease: &Prerelease) -> Self { - prerelease.as_str().parse().unwrap() +impl TryFrom<&Prerelease> for PrereleaseVersion { + type Error = anyhow::Error; + + fn try_from(prerelease: &Prerelease) -> Result { + Self::parse(prerelease.as_str()) } } impl From for Prerelease { fn from(version: PrereleaseVersion) -> Self { + // Fixed identifiers and nonzero integer counters always form valid semver prereleases. match (version.beta, version.alpha) { (None, None) => Prerelease::EMPTY, (None, Some(alpha)) => Prerelease::new(&format!("alpha.{}", alpha.get())).unwrap(), @@ -239,46 +279,189 @@ impl From for Prerelease { } impl PrereleaseVersion { - fn bump_beta(self) -> Self { - let beta_num = self.beta.map(|n| n.get()).unwrap_or(0) + 1; - PrereleaseVersion { - beta: NonZeroU16::new(beta_num), + fn bump_beta(self) -> Result { + Ok(PrereleaseVersion { + beta: Some(next_counter(self.beta, "beta")?), alpha: None, - } + }) } - fn bump_alpha(self) -> Self { - match (self.beta, self.alpha) { - (None, alpha) => { - // None or alpha.N -> alpha.N+1 - let alpha_num = alpha.map(|n| n.get()).unwrap_or(0) + 1; - PrereleaseVersion { - beta: None, - alpha: NonZeroU16::new(alpha_num), - } - } - (Some(beta), alpha) => { - // beta.N or beta.N.alpha.M -> beta.N.alpha.M+1 - let alpha_num = alpha.map(|n| n.get()).unwrap_or(0) + 1; - PrereleaseVersion { - beta: Some(beta), - alpha: NonZeroU16::new(alpha_num), - } - } - } + fn bump_alpha(self) -> Result { + Ok(PrereleaseVersion { + beta: self.beta, + alpha: Some(next_counter(self.alpha, "alpha")?), + }) } } +fn next_counter(current: Option, name: &str) -> Result { + current + .map_or(0, NonZeroU16::get) + .checked_add(1) + .and_then(NonZeroU16::new) + .with_context(|| format!("{name} release counter exhausted")) +} + fn is_same_core_version(v1: &Version, v2: &Version) -> bool { v1.major == v2.major && v1.minor == v2.minor && v1.patch == v2.patch } -fn check_version_bumped(cargo_pkg_version: &Version, published_version: &Version) -> Result<()> { - if cargo_pkg_version == published_version { - println!("The version in Cargo.toml is the same as the published version"); - println!("No pre-release is allowed for the same version"); - github::set_output("skip", "true")?; - exit(0) - } +fn ensure_candidate_not_older(candidate: &Version, published: &Version) -> Result<()> { + let candidate_core = (candidate.major, candidate.minor, candidate.patch); + let published_core = (published.major, published.minor, published.patch); + ensure!( + candidate_core >= published_core, + "Derived stable v{candidate} is older than published pre-release v{published}" + ); Ok(()) } + +fn parse_counter(value: &str, name: &str) -> Result { + value + .parse::() + .ok() + .and_then(NonZeroU16::new) + .with_context(|| format!("Invalid {name} counter: {value}")) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn beta_uses_the_derived_stable_version_without_changing_it() -> Result<()> { + let stable = Version::new(0, 8, 0); + let published = Version::parse("0.8.0-beta.2")?; + + let (version, tag) = compute_version(Channel::Beta, &stable, &published, "abc1234")?; + + assert_eq!(version, Version::parse("0.8.0-beta.3")?); + assert_eq!(tag, "v0.8.0-beta.3"); + assert_eq!(stable, Version::new(0, 8, 0)); + Ok(()) + } + + #[test] + fn alpha_starts_again_when_the_stable_candidate_changes() -> Result<()> { + let stable = Version::new(0, 9, 0); + let published = Version::parse("0.8.0-beta.2.alpha.4+sha.old")?; + + let (version, tag) = compute_version(Channel::Alpha, &stable, &published, "abc1234")?; + + assert_eq!(version, Version::parse("0.9.0-alpha.1+sha.abc1234")?); + assert_eq!(tag, "nightly"); + Ok(()) + } + + #[test] + fn beta_starts_again_when_the_base_changes() -> Result<()> { + let (version, tag) = compute_version( + Channel::Beta, + &Version::new(0, 9, 0), + &Version::parse("0.8.0-beta.2")?, + "abc1234", + )?; + assert_eq!(version, Version::parse("0.9.0-beta.1")?); + assert_eq!(tag, "v0.9.0-beta.1"); + Ok(()) + } + + #[test] + fn same_commit_only_skips_the_same_prerelease_base() -> Result<()> { + let published = Version::parse("0.8.0-beta.2")?; + assert!(skip_existing_prerelease( + Channel::Beta, + &Version::new(0, 8, 0), + &published, + "same", + "same" + )); + assert!(!skip_existing_prerelease( + Channel::Beta, + &Version::new(0, 9, 0), + &published, + "same", + "same" + )); + assert!(!skip_existing_prerelease( + Channel::Stable, + &Version::new(0, 8, 0), + &Version::new(0, 8, 0), + "same", + "same" + )); + Ok(()) + } + + #[test] + fn stable_replay_requires_the_original_commit() -> Result<()> { + let published = Version::new(0, 8, 0); + validate_stable_release(&published, &published, "same", "same")?; + assert!(validate_stable_release(&published, &published, "new", "old").is_err()); + assert!( + validate_stable_release(&Version::new(0, 7, 6), &published, "same", "same").is_err() + ); + validate_stable_release(&Version::new(0, 9, 0), &published, "new", "old")?; + Ok(()) + } + + #[test] + fn newer_beta_tags_require_index_recovery() -> Result<()> { + let indexed = Version::parse("0.8.0-beta.2")?; + ensure_beta_index_current(&indexed, "v0.8.0-beta.1\nv0.8.0-beta.2\nnightly\nv0.9.0")?; + assert!(ensure_beta_index_current(&indexed, "v0.8.0-beta.3").is_err()); + assert!(ensure_beta_index_current(&indexed, "v0.9.0-beta.1").is_err()); + Ok(()) + } + + #[test] + fn exhausted_prerelease_counters_return_errors() -> Result<()> { + for (channel, version) in [ + (Channel::Beta, "0.8.0-beta.65535"), + (Channel::Alpha, "0.8.0-alpha.65535"), + (Channel::Alpha, "0.8.0-beta.2.alpha.65535"), + ] { + assert!( + compute_version( + channel, + &Version::new(0, 8, 0), + &Version::parse(version)?, + "abc1234" + ) + .is_err() + ); + } + Ok(()) + } + + #[test] + fn prerelease_state_rejects_unknown_or_malformed_versions() -> Result<()> { + for version in ["0.8.0-rc.1", "0.8.0-beta.bad", "0.8.0-beta.1.alpha"] { + let published = Version::parse(version)?; + assert!( + compute_version(Channel::Beta, &Version::new(0, 8, 0), &published, "abc1234") + .is_err(), + "{version} should be rejected" + ); + } + Ok(()) + } + + #[test] + fn prerelease_candidate_must_not_move_backwards() -> Result<()> { + let error = compute_version( + Channel::Beta, + &Version::new(0, 8, 0), + &Version::parse("0.9.0-beta.1")?, + "abc1234", + ) + .unwrap_err(); + + assert!( + error + .to_string() + .contains("older than published pre-release") + ); + Ok(()) + } +} diff --git a/xtask/src/release/mod.rs b/xtask/src/release/mod.rs index e2678d75..4f921765 100644 --- a/xtask/src/release/mod.rs +++ b/xtask/src/release/mod.rs @@ -1,12 +1,13 @@ use std::str::FromStr; use anyhow::{Result, bail}; -use clap::Subcommand; +use clap::{Args, Subcommand}; use serde::Deserialize; pub mod archive; mod meta; mod package; +mod selection; /// Release channel for maa-cli. #[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)] @@ -70,14 +71,46 @@ impl Channel { #[derive(Subcommand)] pub enum ReleaseCommands { /// Parse version and determine release metadata - Meta, + Meta(ReleaseVersionOptions), + /// Select a stable version for a release PR or pre-release base + SelectVersion(SelectVersionOptions), /// Update version.json files with release information Package, + /// Update the version branch from the packaged release manifests + Index, + /// Check that the prepared release does not supersede a newer publication + CheckPublication, +} + +#[derive(Args)] +pub struct ReleaseVersionOptions { + /// Channel to build or publish + #[arg(long)] + channel: Channel, + /// Publish an optimized release build + #[arg(long)] + publish: bool, + /// Version selection for pre-releases; stable uses the Cargo package version + #[arg(long, default_value = "auto")] + version: selection::VersionSelection, + /// Full SHA of the commit selected by the triggering workflow + #[arg(long)] + commit: String, +} + +#[derive(Args)] +pub struct SelectVersionOptions { + /// auto, patch, minor, major, or an explicit stable X.Y.Z + #[arg(long, default_value = "auto")] + version: selection::VersionSelection, } pub fn run(command: ReleaseCommands) -> Result<()> { match command { - ReleaseCommands::Meta => meta::run(), + ReleaseCommands::Meta(options) => meta::run(options), + ReleaseCommands::SelectVersion(options) => selection::run(options), ReleaseCommands::Package => package::run(), + ReleaseCommands::Index => package::update_index(), + ReleaseCommands::CheckPublication => package::check_publication(), } } diff --git a/xtask/src/release/package.rs b/xtask/src/release/package.rs index 55fcae0a..5aaa9c2d 100644 --- a/xtask/src/release/package.rs +++ b/xtask/src/release/package.rs @@ -1,6 +1,6 @@ -use std::{collections::BTreeMap, fs, path::PathBuf}; +use std::{cmp::Ordering, collections::BTreeMap, fs, path::Path}; -use anyhow::{Context, Result}; +use anyhow::{Context, Result, ensure}; use maa_version::{ VersionManifest, cli::{Asset, Details}, @@ -108,6 +108,128 @@ pub fn run() -> Result<()> { Ok(()) } +/// Apply a published bundle to a fresh checkout of the version branch. +pub fn update_index() -> Result<()> { + let channel = env::var("CHANNEL")?.parse()?; + let version = Version::parse(&env::var("VERSION")?)?; + let tag = env::var("TAG")?; + let commit = env::var("COMMIT")?; + + apply_index( + Path::new("release-bundle"), + Path::new("."), + channel, + &version, + &tag, + &commit, + ) +} + +/// Reject stale publication plans before changing the release or its assets. +pub fn check_publication() -> Result<()> { + let channel: Channel = env::var("CHANNEL")?.parse()?; + let version = Version::parse(&env::var("VERSION")?)?; + let tag = env::var("TAG")?; + let commit = env::var("COMMIT")?; + let file = channel.version_file(); + let current: VersionManifest
= + serde_json::from_slice(&fs::read(&file).with_context(|| format!("Failed to read {file}"))?) + .with_context(|| format!("Failed to parse {file}"))?; + validate_publication(¤t, &version, &tag, &commit) +} + +fn matches_identity( + manifest: &VersionManifest
, + version: &Version, + tag: &str, + commit: &str, +) -> bool { + manifest.version == *version && manifest.details.tag == tag && manifest.details.commit == commit +} + +fn validate_publication( + current: &VersionManifest
, + version: &Version, + tag: &str, + commit: &str, +) -> Result<()> { + match current.version.cmp_precedence(version) { + Ordering::Greater => anyhow::bail!( + "Cannot publish {version}: channel already contains newer version {}", + current.version + ), + Ordering::Equal => ensure!( + matches_identity(current, version, tag, commit), + "Conflicting publication identity at version {version}" + ), + Ordering::Less => {} + } + Ok(()) +} + +fn apply_index( + bundle: &Path, + checkout: &Path, + channel: Channel, + version: &Version, + tag: &str, + commit: &str, +) -> Result<()> { + let mut updates = Vec::new(); + // Validate every source and destination before changing any file. A later + // channel conflict must not leave earlier channels partially updated. + for file in channel.version_files() { + let source = bundle.join(file); + let incoming: VersionManifest
= serde_json::from_slice( + &fs::read(&source).with_context(|| format!("Failed to read {}", source.display()))?, + ) + .with_context(|| format!("Failed to parse {}", source.display()))?; + ensure!( + matches_identity(&incoming, version, tag, commit), + "Release identity mismatch in {}", + source.display() + ); + + let destination = checkout.join(file); + match fs::read(&destination) { + Ok(content) => { + let current: VersionManifest
= serde_json::from_slice(&content) + .with_context(|| format!("Failed to parse {}", destination.display()))?; + // Build metadata has no chronological ordering. Equal alpha + // counters with different commit metadata are conflicts too. + match current.version.cmp_precedence(&incoming.version) { + Ordering::Greater => { + println!( + "Preserving {} at newer version {}", + destination.display(), + current.version + ); + continue; + } + Ordering::Equal => ensure!( + serde_json::to_value(¤t)? == serde_json::to_value(&incoming)?, + "Conflicting release at version {version} in {}", + destination.display() + ), + Ordering::Less => {} + } + } + Err(error) if error.kind() == std::io::ErrorKind::NotFound => {} + Err(error) => { + return Err(error) + .with_context(|| format!("Failed to read {}", destination.display())); + } + } + updates.push((destination, incoming)); + } + + for (destination, manifest) in updates { + write_manifest(&destination, &manifest)?; + write_shell_format(&destination, &manifest)?; + } + Ok(()) +} + fn read_or_create_manifest(file: &str) -> Result> { if fs::metadata(file).is_ok() { let content = @@ -127,21 +249,21 @@ fn read_or_create_manifest(file: &str) -> Result> { } } -fn write_manifest(file: &str, manifest: &VersionManifest
) -> Result<()> { +fn write_manifest(file: impl AsRef, manifest: &VersionManifest
) -> Result<()> { + let file = file.as_ref(); let content = serde_json::to_string_pretty(manifest).context("Failed to serialize manifest")?; - fs::write(file, content).with_context(|| format!("Failed to write {file}")) + fs::write(file, content).with_context(|| format!("Failed to write {}", file.display())) } -fn write_shell_format(file: &str, manifest: &VersionManifest
) -> Result<()> { +fn write_shell_format(file: impl AsRef, manifest: &VersionManifest
) -> Result<()> { // Write a shell-friendly .txt format alongside the JSON - let txt_path = PathBuf::from(file).with_extension("txt"); - let txt_path_str = txt_path.to_str().unwrap(); + let txt_path = file.as_ref().with_extension("txt"); use std::io::Write; let mut txt_file = std::fs::File::create(&txt_path) - .with_context(|| format!("Failed to create {txt_path_str}"))?; + .with_context(|| format!("Failed to create {}", txt_path.display()))?; writeln!(txt_file, "VERSION={}", manifest.version)?; writeln!(txt_file, "TAG={}", manifest.details.tag)?; @@ -160,7 +282,7 @@ fn write_shell_format(file: &str, manifest: &VersionManifest
) -> Result txt_file .sync_all() - .with_context(|| format!("Failed to sync {txt_path_str}"))?; + .with_context(|| format!("Failed to sync {}", txt_path.display()))?; Ok(()) } @@ -191,3 +313,178 @@ fn create_archive(target: &str, version: &str, dir: &str) -> Result<(String, Str Ok((archive_name, checksum_hash)) } + +#[cfg(test)] +mod tests { + use super::*; + + fn manifest(version: &str) -> Result> { + Ok(VersionManifest { + version: Version::parse(version)?, + details: Details { + tag: format!("v{version}"), + commit: "release-commit".into(), + assets: BTreeMap::from([("aarch64-apple-darwin".into(), Asset { + name: format!("maa_cli-v{version}-aarch64-apple-darwin.tar.gz"), + size: 123, + sha256sum: "a".repeat(64), + })]), + }, + }) + } + + fn setup(channel: Channel, incoming: &VersionManifest
) -> Result { + let directory = tempfile::tempdir()?; + fs::create_dir_all(directory.path().join("release-bundle/version"))?; + fs::create_dir(directory.path().join("version"))?; + for file in channel.version_files() { + write_manifest(directory.path().join("release-bundle").join(file), incoming)?; + } + Ok(directory) + } + + fn apply(root: &Path, channel: Channel, incoming: &VersionManifest
) -> Result<()> { + apply_index( + &root.join("release-bundle"), + root, + channel, + &incoming.version, + &incoming.details.tag, + &incoming.details.commit, + ) + } + + #[test] + fn index_initial_apply_and_replay_repair_text() -> Result<()> { + let incoming = manifest("0.8.0")?; + let directory = setup(Channel::Stable, &incoming)?; + let root = directory.path(); + fs::write(root.join("version/unrelated.txt"), "preserve me")?; + + apply(root, Channel::Stable, &incoming)?; + let first = fs::read(root.join("version/stable.json"))?; + fs::write( + root.join("version/stable.txt"), + "interrupted previous write", + )?; + apply(root, Channel::Stable, &incoming)?; + + assert_eq!(fs::read(root.join("version/stable.json"))?, first); + for file in Channel::Stable.version_files() { + let actual: serde_json::Value = serde_json::from_slice(&fs::read(root.join(file))?)?; + assert_eq!(actual, serde_json::to_value(&incoming)?); + let text = fs::read_to_string(root.join(file).with_extension("txt"))?; + assert!(text.starts_with("VERSION=0.8.0\nTAG=v0.8.0\nCOMMIT=release-commit\n")); + assert!(text.contains("AARCH64_APPLE_DARWIN_SHA256=")); + } + assert_eq!( + fs::read_to_string(root.join("version/unrelated.txt"))?, + "preserve me" + ); + Ok(()) + } + + #[test] + fn index_rejects_identity_and_asset_conflicts_before_any_write() -> Result<()> { + for field in ["commit", "tag", "checksum"] { + let incoming = manifest("0.8.0")?; + let directory = setup(Channel::Stable, &incoming)?; + let root = directory.path(); + let mut conflicting = manifest("0.8.0")?; + match field { + "commit" => conflicting.details.commit = "different-commit".into(), + "tag" => conflicting.details.tag = "different-tag".into(), + _ => { + for asset in conflicting.details.assets.values_mut() { + asset.sha256sum = "b".repeat(64); + } + } + } + write_manifest(root.join("version/stable.json"), &conflicting)?; + let before = fs::read(root.join("version/stable.json"))?; + + assert!(apply(root, Channel::Stable, &incoming).is_err(), "{field}"); + assert!(!root.join("version/alpha.json").exists()); + assert!(!root.join("version/beta.json").exists()); + assert_eq!(fs::read(root.join("version/stable.json"))?, before); + } + Ok(()) + } + + #[test] + fn index_preserves_newer_channels_while_repairing_stable() -> Result<()> { + let incoming = manifest("0.8.0")?; + let directory = setup(Channel::Stable, &incoming)?; + let root = directory.path(); + for (file, version) in [ + ("version/alpha.json", "0.9.0-beta.1.alpha.1+sha.new"), + ("version/beta.json", "0.9.0-beta.1"), + ] { + let newer = manifest(version)?; + write_manifest(root.join(file), &newer)?; + write_shell_format(root.join(file), &newer)?; + } + let alpha_before = fs::read(root.join("version/alpha.json"))?; + let beta_before = fs::read(root.join("version/beta.txt"))?; + apply(root, Channel::Stable, &incoming)?; + assert_eq!(fs::read(root.join("version/alpha.json"))?, alpha_before); + assert_eq!(fs::read(root.join("version/beta.txt"))?, beta_before); + assert_eq!( + serde_json::from_slice::(&fs::read( + root.join("version/stable.json") + )?)?, + serde_json::to_value(&incoming)? + ); + Ok(()) + } + + #[test] + fn index_rejects_equal_alpha_counters_with_different_build_metadata() -> Result<()> { + let incoming = manifest("0.8.0-alpha.1+sha.zzz")?; + let directory = setup(Channel::Alpha, &incoming)?; + let destination = directory.path().join("version/alpha.json"); + write_manifest(&destination, &manifest("0.8.0-alpha.1+sha.aaa")?)?; + let before = fs::read(&destination)?; + assert!(apply(directory.path(), Channel::Alpha, &incoming).is_err()); + assert_eq!(fs::read(destination)?, before); + Ok(()) + } + + #[test] + fn index_validates_all_bundle_inputs_before_writing() -> Result<()> { + for invalid in ["not json", &serde_json::to_string(&manifest("0.9.0")?)?] { + let incoming = manifest("0.8.0")?; + let directory = setup(Channel::Stable, &incoming)?; + let root = directory.path(); + fs::write(root.join("release-bundle/version/stable.json"), invalid)?; + assert!(apply(root, Channel::Stable, &incoming).is_err()); + assert_eq!(fs::read_dir(root.join("version"))?.count(), 0); + } + Ok(()) + } + + #[test] + fn publication_rejects_stale_or_conflicting_plans_but_allows_replay() -> Result<()> { + let current = manifest("0.8.0-alpha.2+sha.current")?; + let tag = ¤t.details.tag; + let commit = ¤t.details.commit; + assert!(validate_publication(¤t, ¤t.version, tag, commit).is_ok()); + assert!( + validate_publication( + ¤t, + &Version::parse("0.8.0-alpha.3+sha.new")?, + tag, + "new-commit" + ) + .is_ok() + ); + for version in ["0.8.0-alpha.1+sha.old", "0.8.0-alpha.2+sha.zzz"] { + assert!( + validate_publication(¤t, &Version::parse(version)?, tag, commit).is_err() + ); + } + assert!(validate_publication(¤t, ¤t.version, tag, "different").is_err()); + assert!(validate_publication(¤t, ¤t.version, "different", commit).is_err()); + Ok(()) + } +} diff --git a/xtask/src/release/selection.rs b/xtask/src/release/selection.rs new file mode 100644 index 00000000..c858c7da --- /dev/null +++ b/xtask/src/release/selection.rs @@ -0,0 +1,254 @@ +use std::{process::Command, str::FromStr}; + +use anyhow::{Context, Result, ensure}; +use semver::Version; +use serde::Deserialize; + +use super::SelectVersionOptions; +use crate::{cmd::CommandExt, github}; + +#[derive(Debug, Clone)] +pub enum VersionSelection { + Auto, + Patch, + Minor, + Major, + Explicit(Version), +} + +impl FromStr for VersionSelection { + type Err = anyhow::Error; + + fn from_str(value: &str) -> Result { + Ok(match value { + "auto" => Self::Auto, + "patch" => Self::Patch, + "minor" => Self::Minor, + "major" => Self::Major, + _ => { + let version = Version::parse(value) + .context("Expected auto, patch, minor, major, or X.Y.Z")?; + ensure_stable(&version)?; + Self::Explicit(version) + } + }) + } +} + +pub fn run(options: SelectVersionOptions) -> Result<()> { + let (version, previous_tag) = select(&options.version)?; + github::set_outputs(&[ + ("version", &version.to_string()), + ("previous_tag", &previous_tag), + ]) +} + +pub fn select(selection: &VersionSelection) -> Result<(Version, String)> { + let tags = Command::new("git") + .args(["tag", "--merged", "HEAD"]) + .read()?; + let (latest, previous_tag) = latest_stable_tag(&tags)?; + let cargo = cargo_version()?; + let version = choose(selection, &cargo, &latest, |bump| { + let range = format!("{previous_tag}..HEAD"); + if bump.is_none() { + let context = Command::new("git-cliff") + .args(["--offline", &range, "--context"]) + .read()?; + if !has_version_changes(&context)? { + return Ok(latest.clone()); + } + } + let mut command = Command::new("git-cliff"); + command.args(["--offline", &range, "--bumped-version", "--bump"]); + if let Some(bump) = bump { + command.arg(bump); + } + let output = command.read()?; + let version = Version::parse(output.strip_prefix('v').unwrap_or(&output)) + .context("git-cliff returned an invalid bumped version")?; + ensure_stable(&version)?; + Ok(version) + })?; + ensure!( + version >= latest, + "Selected version {version} is older than published stable {latest}" + ); + if !matches!(selection, VersionSelection::Auto) { + ensure!( + version > latest, + "Requested bump must produce a version newer than published stable {latest}" + ); + } + Ok((version, previous_tag)) +} + +fn choose( + selection: &VersionSelection, + cargo: &Version, + latest: &Version, + infer: impl FnOnce(Option<&str>) -> Result, +) -> Result { + ensure_stable(cargo)?; + match selection { + VersionSelection::Explicit(version) => { + ensure!( + version > latest, + "Requested version {version} must be newer than published stable {latest}" + ); + Ok(version.clone()) + } + VersionSelection::Auto if cargo > latest => Ok(cargo.clone()), + VersionSelection::Auto => infer(None), + VersionSelection::Patch => infer(Some("patch")), + VersionSelection::Minor => infer(Some("minor")), + VersionSelection::Major => infer(Some("major")), + } +} + +fn latest_stable_tag(tags: &str) -> Result<(Version, String)> { + tags.lines() + .filter_map(|tag| { + let version = Version::parse(tag.strip_prefix('v')?).ok()?; + (version.pre.is_empty() && version.build.is_empty()).then(|| (version, tag.to_owned())) + }) + .max_by(|a, b| a.0.cmp(&b.0)) + .context("No reachable stable vX.Y.Z tag found") +} + +#[derive(Deserialize)] +struct CliffRelease { + commits: Vec, +} + +#[derive(Deserialize)] +struct CliffCommit { + breaking: bool, + raw_message: String, +} + +fn has_version_changes(context: &str) -> Result { + let releases: Vec = + serde_json::from_str(context).context("Failed to parse git-cliff release context")?; + Ok(releases + .iter() + .flat_map(|release| &release.commits) + .any(|commit| { + let kind = commit + .raw_message + .split(['(', ':', '!']) + .next() + .unwrap_or_default(); + commit.breaking || matches!(kind, "feat" | "fix") + })) +} + +pub fn ensure_stable(version: &Version) -> Result<()> { + ensure!( + version.pre.is_empty() && version.build.is_empty(), + "Expected a stable version without pre-release or build metadata: {version}" + ); + Ok(()) +} + +pub fn cargo_version() -> Result { + let content = std::fs::read_to_string("crates/maa-cli/Cargo.toml") + .context("Failed to read maa-cli Cargo.toml")?; + let manifest: toml::Value = + toml::from_str(&content).context("Failed to parse maa-cli Cargo.toml")?; + let version = manifest + .get("package") + .and_then(|package| package.get("version")) + .and_then(toml::Value::as_str) + .context("maa-cli Cargo.toml has no package version")?; + Version::parse(version).context("Failed to parse maa-cli Cargo version") +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn explicit_version_overrides_pending_cargo_and_requires_new_stable() -> Result<()> { + let latest = Version::new(0, 7, 5); + let cargo = Version::new(0, 9, 0); + let requested = VersionSelection::from_str("0.8.0")?; + let result = choose(&requested, &cargo, &latest, |_| { + anyhow::bail!("must not infer") + })?; + assert_eq!(result, Version::new(0, 8, 0)); + assert!( + choose( + &VersionSelection::Explicit(latest.clone()), + &cargo, + &latest, + |_| Ok(cargo.clone()) + ) + .is_err() + ); + assert!(VersionSelection::from_str("0.8.0-beta.1").is_err()); + Ok(()) + } + + #[test] + fn auto_preserves_pending_cargo_version() -> Result<()> { + let cargo = Version::new(0, 8, 0); + assert_eq!( + choose( + &VersionSelection::Auto, + &cargo, + &Version::new(0, 7, 5), + |_| anyhow::bail!("must not infer") + )?, + cargo + ); + Ok(()) + } + + #[test] + fn bump_levels_override_cargo_and_are_delegated_to_git_cliff() -> Result<()> { + for (selection, expected) in [ + (VersionSelection::Patch, "patch"), + (VersionSelection::Minor, "minor"), + (VersionSelection::Major, "major"), + ] { + let version = choose( + &selection, + &Version::new(0, 9, 0), + &Version::new(0, 7, 5), + |bump| { + assert_eq!(bump, Some(expected)); + Ok(Version::new(1, 0, 0)) + }, + )?; + assert_eq!(version, Version::new(1, 0, 0)); + } + Ok(()) + } + + #[test] + fn only_fixes_features_or_breaking_changes_trigger_auto_bumps() -> Result<()> { + assert!(!has_version_changes( + r#"[{"commits":[{"breaking":false,"raw_message":"docs: usage"},{"breaking":false,"raw_message":"refactor(core): simplify"},{"breaking":false,"raw_message":"chore: cleanup"}]}]"# + )?); + for message in ["fix: bug", "feat(cli): flag", "fix!: removed"] { + assert!(has_version_changes( + &serde_json::json!([{"commits":[{"breaking":false,"raw_message":message}]}]) + .to_string() + )?); + } + assert!(has_version_changes( + r#"[{"commits":[{"breaking":true,"raw_message":"refactor!: remove API"}]}]"# + )?); + Ok(()) + } + + #[test] + fn stable_tag_selection_ignores_prereleases_and_sorts_numerically() -> Result<()> { + assert_eq!( + latest_stable_tag("v0.9.0\nv0.10.0\nv1.0.0-beta.1\nnightly")?, + (Version::new(0, 10, 0), "v0.10.0".into()) + ); + Ok(()) + } +} From 5ff3e833d770e08406d358855a665bada11df237 Mon Sep 17 00:00:00 2001 From: Loong Date: Sun, 13 Sep 2026 15:35:15 +0100 Subject: [PATCH 2/6] fix(release): guard prerelease allocation and downstream retries --- .github/scripts/release-current.sh | 15 +++++ .github/scripts/test-release-files.sh | 63 ++++++++++++++++++- .github/scripts/test-release-recovery.sh | 32 ++++++++++ .github/workflows/publish-aur.yml | 19 +++++- .github/workflows/publish-homebrew.yml | 12 ++++ .github/workflows/publish-winget.yml | 12 ++++ .github/workflows/release-tools.yml | 3 + .github/workflows/release.yml | 8 +-- DEVELOPMENT.md | 4 +- xtask/src/release/meta.rs | 79 ++++++++++++++++++++++++ xtask/src/release/mod.rs | 2 +- xtask/src/release/package.rs | 23 ++++--- 12 files changed, 255 insertions(+), 17 deletions(-) create mode 100644 .github/scripts/release-current.sh diff --git a/.github/scripts/release-current.sh b/.github/scripts/release-current.sh new file mode 100644 index 00000000..49314341 --- /dev/null +++ b/.github/scripts/release-current.sh @@ -0,0 +1,15 @@ +#!/usr/bin/env bash +# Re-read the published index inside every downstream job, including failed-job retries. +set -euo pipefail +: "${VERSION:?}" "${GITHUB_REPOSITORY:?}" "${GH_TOKEN:?}" +index=$(curl --fail --silent --show-error \ + --header "Authorization: Bearer $GH_TOKEN" \ + --header 'Accept: application/vnd.github.raw+json' \ + --header 'Cache-Control: no-cache' \ + "${GITHUB_API_URL:-https://api.github.com}/repos/$GITHUB_REPOSITORY/contents/stable.json?ref=version") +if ! jq -e --arg version "$VERSION" --arg tag "${TAG:-v$VERSION}" --arg commit "${COMMIT:-}" \ + '.version == $version and .details.tag == $tag and + ($commit == "" or .details.commit == $commit)' <<< "$index" > /dev/null; then + echo 'This is no longer the indexed stable release; refusing to publish downstream.' >&2 + exit 1 +fi diff --git a/.github/scripts/test-release-files.sh b/.github/scripts/test-release-files.sh index d9fff556..e91145f3 100644 --- a/.github/scripts/test-release-files.sh +++ b/.github/scripts/test-release-files.sh @@ -111,4 +111,65 @@ git tag v0.8.0 : > "$GITHUB_OUTPUT" "$xtask" release meta --channel beta --commit "$commit" --version auto --publish grep -qx 'skip=true' "$GITHUB_OUTPUT" -echo "Release file and beta integration checks passed (fixtures: $test_root)" + +# Publishing nightly without its index must block the next allocation, then recover. +mkdir -p "$test_root/alpha/crates/maa-cli" "$test_root/bin" +cd "$test_root/alpha" +git init -q -b main +git config user.name 'Release Test' +git config user.email 'release@example.invalid' +git config commit.gpgsign false +printf '[package]\nname = "maa-cli"\nversion = "0.8.0"\n' > crates/maa-cli/Cargo.toml +git add crates +git commit -qm 'chore: initial' +git tag v0.7.5 +mkdir -p version release-bundle/version +printf '{"version":"0.7.5","details":{"tag":"v0.7.5","commit":"old","assets":{}}}' > version/stable.json +printf '{"version":"0.8.0-alpha.1+sha.old","details":{"tag":"nightly","commit":"old","assets":{}}}' > version/alpha.json +commit=$(git rev-parse HEAD) +: > "$GITHUB_OUTPUT" +"$xtask" release meta --channel alpha --commit "$commit" --version 0.8.0 --publish +export TEST_NIGHTLY_VERSION +TEST_NIGHTLY_VERSION=$(sed -n 's/^version=//p' "$GITHUB_OUTPUT") +[[ "$TEST_NIGHTLY_VERSION" == 0.8.0-alpha.2+sha.* ]] +jq -n --arg version "$TEST_NIGHTLY_VERSION" --arg commit "$commit" \ + '{version:$version,details:{tag:"nightly",commit:$commit,assets:{}}}' > release-bundle/version/alpha.json +git tag nightly +cat > "$test_root/bin/gh" <<'MOCK' +#!/usr/bin/env bash +set -euo pipefail +[[ "$*" == 'api repos/{owner}/{repo}/releases/tags/nightly' ]] +if [[ "${TEST_NIGHTLY_DENIED:-false}" == true ]]; then exit 1; fi +jq -n --arg name "v$TEST_NIGHTLY_VERSION" '{name:$name}' +MOCK +chmod +x "$test_root/bin/gh" +export PATH="$test_root/bin:$PATH" +git commit -q --allow-empty -m 'fix: next nightly' +next_commit=$(git rev-parse HEAD) +: > "$GITHUB_OUTPUT" +if "$xtask" release meta --channel alpha --commit "$next_commit" --version 0.8.0 --publish > "$RUNNER_TEMP/alpha-stale.log" 2>&1; then + echo 'Expected unindexed nightly to block the next allocation' >&2 + exit 1 +fi +grep -q 'rerun its index job' "$RUNNER_TEMP/alpha-stale.log" +[[ ! -s "$GITHUB_OUTPUT" ]] +CHANNEL=alpha VERSION="$TEST_NIGHTLY_VERSION" TAG=nightly COMMIT="$commit" "$xtask" release index +"$xtask" release meta --channel alpha --commit "$next_commit" --version 0.8.0 --publish +grep -q '^version=0.8.0-alpha.3+sha\.' "$GITHUB_OUTPUT" +if TEST_NIGHTLY_DENIED=true "$xtask" release meta --channel alpha --commit "$next_commit" --version 0.8.0 --publish > "$RUNNER_TEMP/alpha-denied.log" 2>&1; then + echo 'Expected an unreadable nightly release to block allocation' >&2 + exit 1 +fi +# Packaging produces a complete bundle without rewriting its index input. +mkdir maa_cli-x86_64-unknown-linux-gnu +printf 'fixture binary\n' > "$test_root/bin/maa" +tar -cf maa_cli-x86_64-unknown-linux-gnu/x86_64-unknown-linux-gnu.tar -C "$test_root/bin" maa +printf 'fixture license\n' > licenses.md +cp version/alpha.json "$RUNNER_TEMP/index-before-package.json" +CHANNEL=alpha VERSION="$TEST_NIGHTLY_VERSION" TAG=nightly COMMIT="$commit" "$xtask" release package +cmp version/alpha.json "$RUNNER_TEMP/index-before-package.json" +asset=$(jq -r '.details.assets["x86_64-unknown-linux-gnu"].name' release-bundle/version/alpha.json) +[[ -f "release-bundle/$asset" && ! -f "$asset" ]] +[[ -f release-bundle/version/alpha.txt ]] +tar -tzf "release-bundle/$asset" | grep -qx maa +echo "Release file, prerelease recovery and packaging checks passed (fixtures: $test_root)" diff --git a/.github/scripts/test-release-recovery.sh b/.github/scripts/test-release-recovery.sh index 9759109c..97c8e9dd 100644 --- a/.github/scripts/test-release-recovery.sh +++ b/.github/scripts/test-release-recovery.sh @@ -63,4 +63,36 @@ for TEST_CASE in expired denied; do if bash "$scripts/release-artifacts.sh" 2> "$RUNNER_TEMP/error"; then exit 1; fi [[ ! -s "$GITHUB_OUTPUT" ]] done + +# A successful index job from run A is not a currentness check for a later retry. +unset TAG +export GITHUB_REPOSITORY=example/repo GH_TOKEN=test VERSION=0.8.0 COMMIT=release-a +export CURRENT_INDEX='{"version":"0.8.0","details":{"tag":"v0.8.0","commit":"release-a"}}' +curl() { + echo 'read current stable index' >> "$GH_CALLS" + if [[ "$TEST_CASE" == denied ]]; then return 22; fi + printf '%s\n' "$CURRENT_INDEX" +} +export -f curl +TEST_CASE=current +bash "$scripts/release-current.sh" +# B publishes after A's downstream job failed. Replaying only A must not write. +CURRENT_INDEX='{"version":"0.9.0","details":{"tag":"v0.9.0","commit":"release-b"}}' +if bash "$scripts/release-current.sh" 2> "$RUNNER_TEMP/error"; then + echo 'An obsolete downstream retry was allowed to publish' >&2 + exit 1 +fi +VERSION=0.9.0 +if bash "$scripts/release-current.sh" 2> "$RUNNER_TEMP/error"; then exit 1; fi +COMMIT=release-b +bash "$scripts/release-current.sh" +# Manual publishing still checks the requested version without a supplied SHA. +COMMIT='' bash "$scripts/release-current.sh" +TAG=v0.9.0 COMMIT='' bash "$scripts/release-current.sh" +if TAG=nightly COMMIT='' bash "$scripts/release-current.sh" 2> "$RUNNER_TEMP/error"; then + echo 'Manual WinGet publication accepted assets from a different tag' >&2 + exit 1 +fi +TEST_CASE=denied +if bash "$scripts/release-current.sh" 2> "$RUNNER_TEMP/error"; then exit 1; fi echo "Release recovery checks passed (fixtures: $RUNNER_TEMP)" diff --git a/.github/workflows/publish-aur.yml b/.github/workflows/publish-aur.yml index 339a026c..5a58d3ee 100644 --- a/.github/workflows/publish-aur.yml +++ b/.github/workflows/publish-aur.yml @@ -3,6 +3,10 @@ name: Publish AUR Package on: workflow_call: inputs: + commit: + description: Expected stable release commit + required: true + type: string pkgver: description: Package version required: false @@ -47,9 +51,11 @@ jobs: steps: - name: Upgrade system run: | - pacman -Syu --needed --noconfirm git openssh pacman-contrib namcap + pacman -Syu --needed --noconfirm git openssh pacman-contrib namcap curl jq sed -i '/E_ROOT/d' /usr/bin/makepkg + - uses: actions/checkout@v7 + - name: Setup ssh run: | mkdir -vpm700 ~root/.ssh && cd "$_" @@ -75,6 +81,17 @@ jobs: namcap *.pkg.tar.zst makepkg --printsrcinfo > .SRCINFO + - name: Verify current stable release + if: ${{ !inputs.dryrun }} + env: + VERSION: ${{ inputs.pkgver || github.ref_name }} + COMMIT: ${{ inputs.commit }} + GH_TOKEN: ${{ github.token }} + shell: bash + run: | + export VERSION="${VERSION#v}" + bash .github/scripts/release-current.sh + - name: Commit changes run: | cd maa-cli diff --git a/.github/workflows/publish-homebrew.yml b/.github/workflows/publish-homebrew.yml index 158db4d1..be374b7a 100644 --- a/.github/workflows/publish-homebrew.yml +++ b/.github/workflows/publish-homebrew.yml @@ -3,6 +3,10 @@ name: Publish Homebrew on: workflow_call: inputs: + commit: + description: Expected stable release commit + required: true + type: string version: description: Package version required: true @@ -42,12 +46,20 @@ jobs: name: Bump Formulae and Casks runs-on: macos-latest steps: + - uses: actions/checkout@v7 - name: Tap MaasistantArknights Tap run: brew tap MaaAssistantArknights/homebrew-tap - name: Configure Git User run: | git config --global user.name "github-actions[bot]" git config --global user.email "41898282+github-actions[bot]@users.noreply.github.com" + - name: Verify current stable release + if: ${{ !inputs.dryrun }} + env: + VERSION: ${{ inputs.version }} + COMMIT: ${{ inputs.commit }} + GH_TOKEN: ${{ github.token }} + run: bash .github/scripts/release-current.sh - name: Bump Formulae and Casks run: | for formula in maa-cli maa-cli-beta; do diff --git a/.github/workflows/publish-winget.yml b/.github/workflows/publish-winget.yml index fe86aaec..b50041b5 100644 --- a/.github/workflows/publish-winget.yml +++ b/.github/workflows/publish-winget.yml @@ -3,6 +3,10 @@ name: Publish Winget on: workflow_call: inputs: + commit: + description: Expected stable release commit + required: true + type: string version: description: Package version required: true @@ -44,6 +48,14 @@ jobs: WINGET_CREATE_VERSION: "v1.12.8.0" WINGET_CREATE_SHA256: "8BD738851B524885410112678E3771B341C5C716DE60FBBECB88AB0A363ED85D" steps: + - uses: actions/checkout@v7 + - name: Verify current stable release + env: + VERSION: ${{ inputs.version }} + TAG: ${{ env.RELEASE_TAG }} + COMMIT: ${{ inputs.commit }} + GH_TOKEN: ${{ github.token }} + run: bash .github/scripts/release-current.sh - name: Update Winget Package shell: pwsh run: | diff --git a/.github/workflows/release-tools.yml b/.github/workflows/release-tools.yml index 51440ca8..fcee2c31 100644 --- a/.github/workflows/release-tools.yml +++ b/.github/workflows/release-tools.yml @@ -9,6 +9,9 @@ on: - .github/workflows/release.yml - .github/workflows/prepare-release.yml - .github/workflows/release-tools.yml + - .github/workflows/publish-homebrew.yml + - .github/workflows/publish-aur.yml + - .github/workflows/publish-winget.yml - cliff.toml - xtask/src/release/** - xtask/src/github.rs diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index e0a4c6a2..369aa6a9 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -332,11 +332,6 @@ jobs: run: | cargo about generate about.hbs -o licenses.md cargo x -- release package - mkdir -p release-bundle/version - cp maa_cli-*.tar.gz maa_cli-*.zip release-bundle/ - cp version/*.json version/*.txt release-bundle/version/ - # Packaging wrote manifests locally; restore the published index for the guard. - git -C version restore . - name: Freeze bundle before publication if: steps.bundle.outputs.bundle != 'true' uses: actions/upload-artifact@v7 @@ -453,6 +448,7 @@ jobs: uses: ./.github/workflows/publish-homebrew.yml with: version: ${{ needs.meta.outputs.version }} + commit: ${{ needs.meta.outputs.commit }} dryrun: false secrets: MAA_HOMEBREW_BUMP_PR: ${{ secrets.MAA_HOMEBREW_BUMP_PR }} @@ -464,6 +460,7 @@ jobs: uses: ./.github/workflows/publish-aur.yml with: pkgver: ${{ needs.meta.outputs.version }} + commit: ${{ needs.meta.outputs.commit }} dryrun: false secrets: AUR_SSH_PRIVATE_KEY: ${{ secrets.AUR_SSH_PRIVATE_KEY }} @@ -475,6 +472,7 @@ jobs: uses: ./.github/workflows/publish-winget.yml with: version: ${{ needs.meta.outputs.version }} + commit: ${{ needs.meta.outputs.commit }} tag: ${{ needs.meta.outputs.tag }} secrets: MAABOT_WINGET_TOKEN: ${{ secrets.MAABOT_WINGET_TOKEN }} diff --git a/DEVELOPMENT.md b/DEVELOPMENT.md index 1af173e7..cbb00185 100644 --- a/DEVELOPMENT.md +++ b/DEVELOPMENT.md @@ -92,7 +92,7 @@ Beta 在 Actions 中从 `main` 手动运行 `Release`,选择 `channel=beta`, 基础版本不变时 Beta 编号递增,例如 `0.8.0-beta.2` → `0.8.0-beta.3`;基础版本变化时从 `beta.1` 开始。相同 commit 和基础版本已经发布过该通道时跳过。预发布通过 `MAA_VERSION` 注入编译,不修改 main 的 `Cargo.toml`、`Cargo.lock` 或 `CHANGELOG.md`,也不需要版本 PR。 -Beta 发布后更新 `version` 分支的 `beta.json`、`alpha.json` 及对应 `.txt`;Nightly 只更新 alpha;Stable 更新三个通道。若 Beta tag 超前于索引,先恢复前一次发布的索引 job,再分配新编号。 +Beta 发布后更新 `version` 分支的 `beta.json`、`alpha.json` 及对应 `.txt`;Nightly 只更新 alpha;Stable 更新三个通道。若 Beta tag 或已发布的 Nightly 超前于索引,先恢复前一次发布的索引 job,再分配新编号。Alpha 分配前会通过 GitHub API 核对 Nightly 的版本和 tag commit;已有 Nightly 无法读取时也会停止,本地运行需提供已认证的 `gh`。 ### 重试与验证 @@ -100,6 +100,8 @@ Beta 发布后更新 `version` 分支的 `beta.json`、`alpha.json` 及对应 `. 这些 artifacts 保留 90 天;过期时工作流停止,不能把重新推导和编译当作原发布的重试。不要提前删除恢复所需的 artifacts。正式 tag 已存在时必须指向同一 commit;旧 run 不能覆盖更新的通道版本。 +Homebrew、AUR 和 WinGet 在每次发布任务执行时重新读取 stable 索引,核对版本和 commit;新版已经发布后,旧任务重试会失败并停止写入。手动运行这些下游工作流也只允许发布当前 stable 版本;Homebrew 和 AUR 的 dry-run 仍可预览其他版本。 + `Prepare Stable Release` 使用内置 `GITHUB_TOKEN`,以 `github-actions[bot]` 身份创建 PR。仓库需要在 Settings → Actions → General 中启用 `Allow GitHub Actions to create and approve pull requests`。具有 write 权限的维护者批准 PR 的 workflow runs 后,检查才会运行。合并操作应由维护者完成,不使用 `GITHUB_TOKEN` 自动合并,否则不会产生所需的后续发布事件。 修改发布工具后运行 `cargo test -p xtask`、`cargo clippy -p xtask --all-targets -- -D warnings`、`bash .github/scripts/test-release-files.sh` 和 `bash .github/scripts/test-release-recovery.sh`。文件集成测试需要先 `cargo build -p xtask`,并在 PATH 中提供 git-cliff 2.13.1、git、Cargo、jq 和 Perl;测试只操作临时仓库。 diff --git a/xtask/src/release/meta.rs b/xtask/src/release/meta.rs index a52e1c5a..0add7348 100644 --- a/xtask/src/release/meta.rs +++ b/xtask/src/release/meta.rs @@ -67,6 +67,9 @@ pub fn run(options: ReleaseVersionOptions) -> Result<()> { let tags = Command::new("git").args(["tag", "--list", "v*"]).read()?; ensure_beta_index_current(&manifest.version, &tags)?; } + if channel == Channel::Alpha { + check_nightly_index(&manifest)?; + } if skip_existing_prerelease( channel, &candidate, @@ -160,6 +163,51 @@ fn ensure_beta_index_current(indexed: &Version, tags: &str) -> Result<()> { Ok(()) } +fn check_nightly_index(indexed: &VersionManifest
) -> Result<()> { + if Command::new("git") + .args(["tag", "--list", "nightly"]) + .read()? + .is_empty() + { + return Ok(()); + } + // The movable nightly tag alone cannot tell us which counter was published. + // Its release name records the full version even when the index push failed. + let release = Command::new("gh") + .args(["api", "repos/{owner}/{repo}/releases/tags/nightly"]) + .read() + .context("Cannot inspect the existing nightly release; repair it before allocating another alpha")?; + #[derive(serde::Deserialize)] + struct NightlyRelease { + name: String, + } + let release: NightlyRelease = serde_json::from_str(&release)?; + let published = Version::parse(release.name.strip_prefix('v').unwrap_or(&release.name)) + .context("The nightly release name must contain its published version")?; + let commit = Command::new("git") + .args(["rev-parse", "refs/tags/nightly^{}"]) + .read()?; + ensure_nightly_index_current(indexed, &published, &commit) +} + +fn ensure_nightly_index_current( + indexed: &VersionManifest
, + published: &Version, + commit: &str, +) -> Result<()> { + use std::cmp::Ordering; + + ensure!( + match published.cmp_precedence(&indexed.version) { + Ordering::Less => true, // A later beta or stable also advances the alpha index. + Ordering::Equal => published == &indexed.version && commit == indexed.details.commit, + Ordering::Greater => false, + }, + "Nightly {published} ({commit}) is not recorded in version/alpha.json; rerun its index job before allocating another alpha" + ); + Ok(()) +} + fn read_version_manifest(file: impl AsRef) -> Result> { let file = file.as_ref(); let content = std::fs::read_to_string(file) @@ -414,6 +462,37 @@ mod tests { Ok(()) } + #[test] + fn nightly_requires_index_recovery_before_allocating_another_alpha() -> Result<()> { + let mut indexed = VersionManifest { + version: Version::parse("0.8.0-alpha.1+sha.old")?, + details: Details { + tag: "nightly".into(), + commit: "old".into(), + assets: Default::default(), + }, + }; + let published = Version::parse("0.8.0-alpha.2+sha.new")?; + assert!(ensure_nightly_index_current(&indexed, &published, "new").is_err()); + indexed.version = published.clone(); + assert!(ensure_nightly_index_current(&indexed, &published, "new").is_err()); + indexed.details.commit = "new".into(); + ensure_nightly_index_current(&indexed, &published, "new")?; + assert!( + ensure_nightly_index_current( + &indexed, + &Version::parse("0.8.0-alpha.2+sha.other")?, + "other" + ) + .is_err() + ); + for newer in ["0.8.0-beta.1", "0.8.0", "0.9.0-alpha.1+sha.next"] { + indexed.version = Version::parse(newer)?; + ensure_nightly_index_current(&indexed, &published, "new")?; + } + Ok(()) + } + #[test] fn exhausted_prerelease_counters_return_errors() -> Result<()> { for (channel, version) in [ diff --git a/xtask/src/release/mod.rs b/xtask/src/release/mod.rs index 4f921765..5ecb108d 100644 --- a/xtask/src/release/mod.rs +++ b/xtask/src/release/mod.rs @@ -74,7 +74,7 @@ pub enum ReleaseCommands { Meta(ReleaseVersionOptions), /// Select a stable version for a release PR or pre-release base SelectVersion(SelectVersionOptions), - /// Update version.json files with release information + /// Package archives and version manifests into release-bundle Package, /// Update the version branch from the packaged release manifests Index, diff --git a/xtask/src/release/package.rs b/xtask/src/release/package.rs index 5aaa9c2d..9db9929e 100644 --- a/xtask/src/release/package.rs +++ b/xtask/src/release/package.rs @@ -18,6 +18,8 @@ pub fn run() -> Result<()> { let version = Version::parse(&version_str) .with_context(|| format!("Failed to parse version: {}", version_str))?; + let bundle = Path::new("release-bundle"); + fs::create_dir_all(bundle.join("version")).context("Failed to create release bundle")?; // Determine which version files to update let version_files = channel.version_files(); @@ -69,8 +71,8 @@ pub fn run() -> Result<()> { .context("Failed to copy licenses.md")?; // Create archive based on platform and get checksum - let (archive_name, checksum_hash) = create_archive(target, &version_str, dir_str)?; - let size = fs::metadata(&archive_name) + let (archive_name, checksum_hash) = create_archive(bundle, target, &version_str, dir_str)?; + let size = fs::metadata(bundle.join(&archive_name)) .context("Failed to get file metadata")? .len(); @@ -98,13 +100,13 @@ pub fn run() -> Result<()> { } } - // Write updated manifests back to files + // The published index is read-only input; all outputs belong to the bundle. for (file, manifest) in version_files.iter().zip(&manifests) { - write_manifest(file, manifest)?; - write_shell_format(file, manifest)?; + write_manifest(bundle.join(file), manifest)?; + write_shell_format(bundle.join(file), manifest)?; } - println!("Version JSON files updated successfully"); + println!("Release bundle created successfully"); Ok(()) } @@ -287,7 +289,12 @@ fn write_shell_format(file: impl AsRef, manifest: &VersionManifest
Result<(String, String)> { +fn create_archive( + output: &Path, + target: &str, + version: &str, + dir: &str, +) -> Result<(String, String)> { // Determine archive format and binary name based on target // Use tar.gz for Unix-like systems (Linux, macOS) and zip for Windows let (format, bin_name) = if target.contains("-windows-msvc-winget") { @@ -306,7 +313,7 @@ fn create_archive(target: &str, version: &str, dir: &str) -> Result<(String, Str let binary = format!("{dir}/{bin_name}"); let licenses = format!("{dir}/licenses.md"); - let checksum_hash = format.create(&archive_name, &[ + let checksum_hash = format.create(output.join(&archive_name), &[ (binary.as_str(), bin_name), (licenses.as_str(), "licenses.md"), ])?; From 8d92463747755e7fb933f176cc67cfb792784b20 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 13 Oct 2026 19:50:50 +0000 Subject: [PATCH 3/6] fix(release): unblock unconventional commits and check releases in merge queue - Drop require_conventional and fail_on_unmatched_commit so a single unconventional commit (e.g. GitHub's Revert button) no longer blocks nightly, beta and stable releases; map `Revert "..."` to `revert:`. - Move Release Readiness into its own workflow that also runs on merge_group, checking the queue entry that lands the release commit. - Fail readiness for release-prep PRs without the release label. - Release only listens for closed PRs; unlabeled closes use a throwaway concurrency group instead of queueing behind release builds. - Wait for the deleted nightly tag to disappear before recreating it. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_012ULwm4FYPWei21gc5bhw1x --- .github/scripts/release-readiness.sh | 46 ++++++++++++++++++++++++ .github/scripts/release-tag.sh | 16 +++++++++ .github/scripts/test-release-files.sh | 44 +++++++++++++++++++++++ .github/scripts/test-release-recovery.sh | 21 ++++++++--- .github/workflows/release-readiness.yml | 45 +++++++++++++++++++++++ .github/workflows/release-tools.yml | 1 + .github/workflows/release.yml | 41 +++------------------ DEVELOPMENT.md | 4 ++- cliff.toml | 6 ++-- 9 files changed, 178 insertions(+), 46 deletions(-) create mode 100644 .github/scripts/release-readiness.sh create mode 100644 .github/workflows/release-readiness.yml diff --git a/.github/scripts/release-readiness.sh b/.github/scripts/release-readiness.sh new file mode 100644 index 00000000..072c27a3 --- /dev/null +++ b/.github/scripts/release-readiness.sh @@ -0,0 +1,46 @@ +#!/usr/bin/env bash +# Decide whether a PR or merge-queue entry carries a release, then verify its files. +set -euo pipefail +: "${EVENT_NAME:?}" "${RUNNER_TEMP:?}" +files="$(cd "$(dirname "$0")" && pwd)/release-files.sh" +read -ra xtask <<< "${XTASK:-cargo x --}" + +case "$EVENT_NAME" in + pull_request) + : "${LABELED:?}" "${HEAD_REF:?}" "${BASE_SHA:?}" "${HEAD_SHA:?}" + if [[ "$LABELED" != true ]]; then + if [[ "$HEAD_REF" == release-prep/* ]]; then + echo 'This release-prep PR has no release label; merging it would not publish.' >&2 + exit 1 + fi + echo 'Not a release PR.' + exit 0 + fi + changed=$(git diff --name-only "$BASE_SHA...$HEAD_SHA") + ;; + merge_group) + # Each queue entry is checked at its own head. Later entries contain earlier + # releases, so only the entry that lands the release commit is checked. + subject=$(git log -1 --format=%s HEAD) + prepare='^chore\(release\): prepare for v' + merge='^Merge pull request #[0-9]+ from [^ ]+/release-prep/v' + if [[ ! "$subject" =~ $prepare && ! "$subject" =~ $merge ]]; then + echo 'Not a release queue entry.' + exit 0 + fi + changed=$(git diff --name-only HEAD^1 HEAD) + ;; + *) echo "Unsupported event: $EVENT_NAME" >&2; exit 1 ;; +esac + +while IFS= read -r file; do + case "$file" in + CHANGELOG.md|Cargo.lock|crates/maa-cli/Cargo.toml|'') ;; + *) echo "Unexpected file in release PR: $file" >&2; exit 1 ;; + esac +done <<< "$changed" + +: > "$RUNNER_TEMP/readiness-candidate" +GITHUB_OUTPUT="$RUNNER_TEMP/readiness-candidate" "${xtask[@]}" release select-version +PREVIOUS_TAG=$(sed -n 's/^previous_tag=//p' "$RUNNER_TEMP/readiness-candidate") \ + bash "$files" check diff --git a/.github/scripts/release-tag.sh b/.github/scripts/release-tag.sh index b7f012c0..77dc890e 100644 --- a/.github/scripts/release-tag.sh +++ b/.github/scripts/release-tag.sh @@ -20,3 +20,19 @@ else cat "$RUNNER_TEMP/nightly-error" >&2 exit 1 fi + +# Recreating a release while GitHub still sees the old tag can leave it as a draft. +for _ in {1..20}; do + status=0 + git ls-remote --exit-code --tags origin "refs/tags/$TAG" > /dev/null || status=$? + case "$status" in + 2) + sleep "${TAG_SETTLE_SECONDS:-5}" + exit 0 + ;; + 0) sleep "${TAG_POLL_SECONDS:-3}" ;; + *) exit "$status" ;; + esac +done +echo "Tag $TAG is still visible after deleting it" >&2 +exit 1 diff --git a/.github/scripts/test-release-files.sh b/.github/scripts/test-release-files.sh index e91145f3..847686da 100644 --- a/.github/scripts/test-release-files.sh +++ b/.github/scripts/test-release-files.sh @@ -36,6 +36,9 @@ git commit -q --allow-empty -m 'docs: clarify usage' select_version auto [[ "$VERSION" == 0.7.5 ]] git commit -q --allow-empty -m 'fix: repair A' +# Unconventional subjects must not block version selection or changelogs. +git commit -q --allow-empty -m 'Revert "feat: experiment"' -m 'This reverts commit 1234567.' +git commit -q --allow-empty -m 'Update README.md' select_version auto [[ "$VERSION" == 0.7.6 ]] git checkout -qb release-prep/v0.7.6 @@ -44,6 +47,47 @@ bash "$files" check git add . git commit -qm 'chore(release): prepare for v0.7.6' prepared_sha=$(git rev-parse HEAD) +grep -q '^- Feat: experiment$' CHANGELOG.md + +readiness() { + EVENT_NAME=$1 LABELED=${2:-} HEAD_REF=${3:-} BASE_SHA=$(git rev-parse main) HEAD_SHA=$(git rev-parse HEAD) \ + XTASK="$xtask" bash "$repo/.github/scripts/release-readiness.sh" > "$RUNNER_TEMP/readiness.log" 2>&1 +} +readiness pull_request true release-prep/v0.7.6 +if readiness pull_request false release-prep/v0.7.6; then + echo 'Expected an unlabeled release-prep PR to fail' >&2 + exit 1 +fi +grep -q 'no release label' "$RUNNER_TEMP/readiness.log" +readiness pull_request false feature/other +git checkout -q --detach +printf 'unrelated\n' > unrelated.txt +git add unrelated.txt +git commit -qm 'chore(release): prepare for v0.7.6' +if readiness pull_request true release-prep/v0.7.6; then + echo 'Expected an unrelated file in a release PR to fail' >&2 + exit 1 +fi +grep -q 'Unexpected file in release PR: unrelated.txt' "$RUNNER_TEMP/readiness.log" +# Merge queue entries: the squashed release is checked; entries behind it are not. +git checkout -q --detach main +git merge -q --squash "$prepared_sha" > /dev/null +git commit -qm 'chore(release): prepare for v0.7.6 (#9)' +readiness merge_group +git commit -q --allow-empty -m 'fix: queued behind release' +readiness merge_group +grep -q 'Not a release queue entry' "$RUNNER_TEMP/readiness.log" +# A releasable change queued ahead of the release makes it stale. +git checkout -q --detach main +git commit -q --allow-empty -m 'fix: queued ahead of release' +git merge -q --squash "$prepared_sha" > /dev/null +git commit -qm 'chore(release): prepare for v0.7.6 (#9)' +if readiness merge_group; then + echo 'Expected a release behind newer fixes to fail in the merge queue' >&2 + exit 1 +fi +grep -q 'Release PR is stale' "$RUNNER_TEMP/readiness.log" +git checkout -q release-prep/v0.7.6 bash "$files" notes 0.7.6 > "$RUNNER_TEMP/before-tag.md" git tag v0.7.6 bash "$files" notes 0.7.6 > "$RUNNER_TEMP/after-tag.md" diff --git a/.github/scripts/test-release-recovery.sh b/.github/scripts/test-release-recovery.sh index 97c8e9dd..1b6ccc19 100644 --- a/.github/scripts/test-release-recovery.sh +++ b/.github/scripts/test-release-recovery.sh @@ -7,8 +7,15 @@ RUNNER_TEMP=$(mktemp -d) export GITHUB_OUTPUT="$RUNNER_TEMP/outputs" export GH_REPO=example/repo GITHUB_RUN_ID=123 COMMIT=new TAG=nightly export GH_CALLS="$RUNNER_TEMP/calls" TEST_CASE=orphan +export TAG_SETTLE_SECONDS=0 TAG_POLL_SECONDS=0 TAG_DELETED="$RUNNER_TEMP/tag-deleted" -git() { printf 'old\trefs/tags/%s\n' "$TAG"; } +git() { + if [[ -f "$TAG_DELETED" ]]; then return 2; fi + printf 'old\trefs/tags/%s\n' "$TAG" +} +mark_deleted() { + if [[ "$TEST_CASE" != sticky ]]; then touch "$TAG_DELETED"; fi +} gh() { echo "$*" >> "$GH_CALLS" case "$*" in @@ -20,25 +27,31 @@ gh() { denied) echo 'HTTP 403' >&2; return 1 ;; esac ;; - 'api --method DELETE '*) ;; + 'api --method DELETE '*) mark_deleted ;; *releases/tags/nightly*) case "$TEST_CASE" in orphan) echo 'gh: Not Found (HTTP 404)' >&2; return 1 ;; denied) echo 'gh: Forbidden (HTTP 403)' >&2; return 1 ;; esac ;; - 'release delete nightly --yes --cleanup-tag') ;; + 'release delete nightly --yes --cleanup-tag') mark_deleted ;; *) echo "Unexpected gh call: $*" >&2; return 1 ;; esac } -export -f git gh +export -f git gh mark_deleted bash "$scripts/release-tag.sh" grep -q '^api --method DELETE .*git/refs/tags/nightly$' "$GH_CALLS" +rm "$TAG_DELETED" TEST_CASE=exists : > "$GH_CALLS" bash "$scripts/release-tag.sh" grep -qx 'release delete nightly --yes --cleanup-tag' "$GH_CALLS" +rm "$TAG_DELETED" +# The new release must not be created while the old tag is still visible. +TEST_CASE=sticky +if bash "$scripts/release-tag.sh" 2> "$RUNNER_TEMP/error"; then exit 1; fi +grep -q 'still visible' "$RUNNER_TEMP/error" TEST_CASE=denied : > "$GH_CALLS" if bash "$scripts/release-tag.sh" 2> "$RUNNER_TEMP/error"; then exit 1; fi diff --git a/.github/workflows/release-readiness.yml b/.github/workflows/release-readiness.yml new file mode 100644 index 00000000..e4952f1f --- /dev/null +++ b/.github/workflows/release-readiness.yml @@ -0,0 +1,45 @@ +name: Release Readiness + +on: + pull_request: + branches: [main] + types: [opened, reopened, synchronize, labeled, unlabeled] + merge_group: + branches: [main] + types: [checks_requested] + +permissions: + contents: read + +concurrency: + group: release-readiness-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + +defaults: + run: + shell: bash + +jobs: + readiness: + name: Release Readiness + # Fork PRs cannot publish; a skipped job still satisfies the required check. + if: >- + github.event_name == 'merge_group' || + github.event.pull_request.head.repo.full_name == github.repository + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v7 + with: + fetch-depth: 0 + - uses: taiki-e/install-action@v2 + with: + tool: git-cliff@2.13.1 + - name: Verify release files + env: + EVENT_NAME: ${{ github.event_name }} + LABELED: ${{ contains(github.event.pull_request.labels.*.name, 'release') }} + HEAD_REF: ${{ github.event.pull_request.head.ref }} + BASE_SHA: ${{ github.event.pull_request.base.sha }} + HEAD_SHA: ${{ github.event.pull_request.head.sha }} + GITHUB_TOKEN: ${{ github.token }} + run: bash .github/scripts/release-readiness.sh diff --git a/.github/workflows/release-tools.yml b/.github/workflows/release-tools.yml index fcee2c31..de2afb6a 100644 --- a/.github/workflows/release-tools.yml +++ b/.github/workflows/release-tools.yml @@ -7,6 +7,7 @@ on: - .github/scripts/release-*.sh - .github/scripts/test-release-*.sh - .github/workflows/release.yml + - .github/workflows/release-readiness.yml - .github/workflows/prepare-release.yml - .github/workflows/release-tools.yml - .github/workflows/publish-homebrew.yml diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 369aa6a9..50548f70 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -2,7 +2,7 @@ name: Release on: pull_request: branches: [main] - types: [opened, reopened, synchronize, labeled, unlabeled, closed] + types: [closed] workflow_dispatch: inputs: channel: @@ -27,9 +27,10 @@ defaults: run: shell: bash -# PR checks must not hold up publishing; publishing and preparation share a queue. +# Publishing and preparation share a queue. Closed PRs that cannot publish +# get a throwaway group so they never wait behind a release build. concurrency: - group: ${{ github.event_name == 'pull_request' && github.event.action != 'closed' && format('release-check-{0}', github.event.pull_request.number) || 'release-state' }} + group: ${{ github.event_name == 'pull_request' && !(github.event.pull_request.merged && contains(github.event.pull_request.labels.*.name, 'release')) && format('release-skip-{0}', github.run_id) || 'release-state' }} queue: max permissions: @@ -37,40 +38,6 @@ permissions: actions: read jobs: - readiness: - name: Release Readiness - if: >- - github.event_name == 'pull_request' && - github.event.action != 'closed' && - (contains(github.event.pull_request.labels.*.name, 'release') || - startsWith(github.event.pull_request.head.ref, 'release-prep/')) && - github.event.pull_request.head.repo.full_name == github.repository - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v7 - with: - fetch-depth: 0 - - uses: taiki-e/install-action@v2 - with: - tool: git-cliff@2.13.1 - - name: Select candidate - id: candidate - run: cargo x -- release select-version - - name: Verify release files against the candidate merge - env: - PREVIOUS_TAG: ${{ steps.candidate.outputs.previous_tag }} - GITHUB_TOKEN: ${{ github.token }} - BASE_SHA: ${{ github.event.pull_request.base.sha }} - HEAD_SHA: ${{ github.event.pull_request.head.sha }} - run: | - git diff --name-only "$BASE_SHA...$HEAD_SHA" | while IFS= read -r file; do - case "$file" in - CHANGELOG.md|Cargo.lock|crates/maa-cli/Cargo.toml) ;; - *) echo "Unexpected file in release PR: $file"; exit 1 ;; - esac - done - bash .github/scripts/release-files.sh check - meta: name: Release Plan if: >- diff --git a/DEVELOPMENT.md b/DEVELOPMENT.md index cbb00185..906e7a99 100644 --- a/DEVELOPMENT.md +++ b/DEVELOPMENT.md @@ -84,7 +84,9 @@ cargo test <测试名称> 4. 合并带 `release` 标签的 PR。工作流使用实际合并 commit 和已确认的 Cargo 版本,在创建任何 tag 前检查一次最终候选 changelog,然后保存发布计划和说明。 5. 编译并保存打包产物,创建正式 tag 和 GitHub Release,再由独立 job 更新 `version` 分支。Stable tag、Homebrew、AUR 和 WinGet 的后续任务在索引更新成功后运行。 -准备检查仅针对 release PR;普通 CI 不承担版本推导或 changelog 一致性检查。最终合并校验若发现过期内容,会在发布前停止;即使 Cargo 版本已合入,也可以重新运行 Prepare 创建修复 changelog 的 release PR。 +`Release Readiness` 是独立的 workflow,需要加入 `main` 的 required checks。普通 PR 直接通过;带 `release` 标签的 PR 只能修改 `CHANGELOG.md`、`Cargo.lock` 和 `crates/maa-cli/Cargo.toml`,并且 changelog 必须与候选合并一致;`release-prep/` 分支缺少 `release` 标签时检查失败,因为合并它不会发布。在 merge queue 中,检查只针对落入 release 提交的队列项,在其队列 head 上重新核对 changelog;若前面排队的 PR 带来了新的 changelog 条目,release PR 会在入队阶段被拒绝,此时重新运行 Prepare 刷新 PR。最终合并校验仍会在发布前再检查一次;即使 Cargo 版本已合入,也可以重新运行 Prepare 创建修复 changelog 的 release PR。 + +不符合约定式提交格式的 commit(如网页编辑生成的 `Update README.md`)不会阻止版本推导或发布,只是不出现在 changelog 中,也不会触发自动 bump;GitHub Revert 按钮生成的 `Revert "..."` 会被归入 Revert 分组。 ### Beta 和 Nightly diff --git a/cliff.toml b/cliff.toml index 9864065b..2e1463e4 100644 --- a/cliff.toml +++ b/cliff.toml @@ -93,13 +93,13 @@ breaking_always_bump_major = false conventional_commits = true # filter out the commits that are not conventional filter_unconventional = true -# fail instead of silently deriving a version from an incomplete commit set -require_conventional = true # process each line of a commit as an individual commit split_commits = false # replace the patterns in the commit message commit_preprocessors = [ { pattern = ' \(#([0-9]+)\)', replace = "" }, # remove pr references + # GitHub's revert button creates `Revert ""` + { pattern = '^Revert "(.+)"', replace = "revert: $1" }, ] # regex for parsing and grouping commits commit_parsers = [ @@ -149,8 +149,6 @@ commit_parsers = [ protect_breaking_commits = true # filter out the commits that are not matched by commit parsers filter_commits = false -# fail if a conventional commit type has no explicit changelog policy -fail_on_unmatched_commit = true # glob pattern for matching git tags tag_pattern = "^v[0-9]+\\.[0-9]+\\.[0-9]+$" # regex for skipping tags From 034b641994ad9a77a365a9e56217ea21eb797e27 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 13 Oct 2026 19:57:01 +0000 Subject: [PATCH 4/6] fix(release): build nightly for every commit and tidy release follow-ups - Nightly builds new commits without a bumping change against the next patch version instead of skipping; beta still skips. - Group security-tagged commits after the skip rules so dependency bumps stay out of the changelog. - Prepare closes other open release-prep PRs when the version changes. - Require pkgver for non-dry-run AUR publication. - Document the 7-day build artifact limit for failed-job reruns. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_012ULwm4FYPWei21gc5bhw1x --- .github/scripts/test-release-files.sh | 5 +++ .github/workflows/prepare-release.yml | 10 ++++++ .github/workflows/publish-aur.yml | 6 ++++ DEVELOPMENT.md | 8 +++-- cliff.toml | 4 ++- xtask/src/release/meta.rs | 50 +++++++++++++++++++++++++-- 6 files changed, 76 insertions(+), 7 deletions(-) diff --git a/.github/scripts/test-release-files.sh b/.github/scripts/test-release-files.sh index 847686da..8f01e37b 100644 --- a/.github/scripts/test-release-files.sh +++ b/.github/scripts/test-release-files.sh @@ -39,6 +39,7 @@ git commit -q --allow-empty -m 'fix: repair A' # Unconventional subjects must not block version selection or changelogs. git commit -q --allow-empty -m 'Revert "feat: experiment"' -m 'This reverts commit 1234567.' git commit -q --allow-empty -m 'Update README.md' +git commit -q --allow-empty -m 'chore(deps): bump example' -m 'Includes upstream security fixes.' select_version auto [[ "$VERSION" == 0.7.6 ]] git checkout -qb release-prep/v0.7.6 @@ -48,6 +49,10 @@ git add . git commit -qm 'chore(release): prepare for v0.7.6' prepared_sha=$(git rev-parse HEAD) grep -q '^- Feat: experiment$' CHANGELOG.md +if grep -qi 'bump example' CHANGELOG.md; then + echo 'Dependency bumps mentioning security must stay out of the changelog' >&2 + exit 1 +fi readiness() { EVENT_NAME=$1 LABELED=${2:-} HEAD_REF=${3:-} BASE_SHA=$(git rev-parse main) HEAD_SHA=$(git rev-parse HEAD) \ diff --git a/.github/workflows/prepare-release.yml b/.github/workflows/prepare-release.yml index 83d141af..803c1f1e 100644 --- a/.github/workflows/prepare-release.yml +++ b/.github/workflows/prepare-release.yml @@ -113,3 +113,13 @@ jobs: --title "chore(release): prepare for v$VERSION" \ --body-file "$body" --label release fi + + # A different version selection supersedes older preparation PRs. + gh pr list --base main --state open --json number,headRefName,isCrossRepository \ + --jq ".[] | select(.isCrossRepository | not) + | select(.headRefName | startswith(\"release-prep/\")) + | select(.headRefName != \"$branch\") | .number" | + while IFS= read -r number; do + gh pr close "$number" --delete-branch \ + --comment "Superseded by the v$VERSION release preparation." + done diff --git a/.github/workflows/publish-aur.yml b/.github/workflows/publish-aur.yml index 5a58d3ee..b0f85667 100644 --- a/.github/workflows/publish-aur.yml +++ b/.github/workflows/publish-aur.yml @@ -49,6 +49,12 @@ jobs: image: archlinux:base-devel steps: + - name: Require an explicit version for real publication + if: ${{ !inputs.dryrun && !inputs.pkgver }} + run: | + echo 'pkgver is required unless dryrun is enabled.' >&2 + exit 1 + - name: Upgrade system run: | pacman -Syu --needed --noconfirm git openssh pacman-contrib namcap curl jq diff --git a/DEVELOPMENT.md b/DEVELOPMENT.md index 906e7a99..ccc38ffa 100644 --- a/DEVELOPMENT.md +++ b/DEVELOPMENT.md @@ -80,7 +80,7 @@ cargo test <测试名称> 1. 在 Actions 中从 `main` 运行 `Prepare Stable Release`,选择版本。 2. git-cliff 生成并 prepend changelog,Cargo 更新 lockfile;工作流创建或更新 `release-prep/vX.Y.Z` PR,并添加 `release` 标签。如果版本已通过普通 PR 更新,本次 PR 可以只修改 changelog。 -3. 批准自动创建 PR 的 workflow runs,检查普通 CI 和 `Release Readiness`。准备后若 main 有新变更,重新运行 Prepare 刷新 PR;版本选择变化时应关闭旧的准备 PR,避免误合并。 +3. 批准自动创建 PR 的 workflow runs,检查普通 CI 和 `Release Readiness`。准备后若 main 有新变更,重新运行 Prepare 刷新 PR;版本选择变化时,Prepare 会自动关闭其他打开的 `release-prep/*` PR 并删除其分支。 4. 合并带 `release` 标签的 PR。工作流使用实际合并 commit 和已确认的 Cargo 版本,在创建任何 tag 前检查一次最终候选 changelog,然后保存发布计划和说明。 5. 编译并保存打包产物,创建正式 tag 和 GitHub Release,再由独立 job 更新 `version` 分支。Stable tag、Homebrew、AUR 和 WinGet 的后续任务在索引更新成功后运行。 @@ -92,6 +92,8 @@ cargo test <测试名称> Beta 在 Actions 中从 `main` 手动运行 `Release`,选择 `channel=beta`,设置基础版本,并在真正发布时勾选 `publish`。默认不勾选,仅构建预览。Nightly 每日自动运行,也可以选择 `channel=alpha` 手动运行。 +Nightly 会构建每个新提交:自最近正式 tag 以来没有 `feat`、`fix` 或 breaking change 时,以下一个 patch 版本为基础,例如 `0.8.0` 之后为 `0.8.1-alpha.1`;正式 tag 之后没有新提交时跳过。Beta 在没有触发 bump 的变更时跳过,需要发布时显式选择版本。 + 基础版本不变时 Beta 编号递增,例如 `0.8.0-beta.2` → `0.8.0-beta.3`;基础版本变化时从 `beta.1` 开始。相同 commit 和基础版本已经发布过该通道时跳过。预发布通过 `MAA_VERSION` 注入编译,不修改 main 的 `Cargo.toml`、`Cargo.lock` 或 `CHANGELOG.md`,也不需要版本 PR。 Beta 发布后更新 `version` 分支的 `beta.json`、`alpha.json` 及对应 `.txt`;Nightly 只更新 alpha;Stable 更新三个通道。若 Beta tag 或已发布的 Nightly 超前于索引,先恢复前一次发布的索引 job,再分配新编号。Alpha 分配前会通过 GitHub API 核对 Nightly 的版本和 tag commit;已有 Nightly 无法读取时也会停止,本地运行需提供已认证的 `gh`。 @@ -100,9 +102,9 @@ Beta 发布后更新 `version` 分支的 `beta.json`、`alpha.json` 及对应 `. 同一次 workflow run 的版本、commit 和发布说明保存为 `release-plan` artifact,打包结果和索引清单保存为 `release-bundle` artifact。重跑全部 jobs 或只重跑失败 jobs 都复用已保存的内容,不重新分配版本或改写已生成的产物。版本索引更新失败时,重跑 `Update Version Index` 及其失败的后续任务即可,不需要重新编译。 -这些 artifacts 保留 90 天;过期时工作流停止,不能把重新推导和编译当作原发布的重试。不要提前删除恢复所需的 artifacts。正式 tag 已存在时必须指向同一 commit;旧 run 不能覆盖更新的通道版本。 +编译产物只保留 7 天:若 `Publish Release` 在生成 `release-bundle` 前失败且已超过 7 天,需要选择「Re-run all jobs」重新编译,「Re-run failed jobs」会因找不到编译产物而失败。plan 和 bundle 这些 artifacts 保留 90 天;过期时工作流停止,不能把重新推导和编译当作原发布的重试。不要提前删除恢复所需的 artifacts。正式 tag 已存在时必须指向同一 commit;旧 run 不能覆盖更新的通道版本。 -Homebrew、AUR 和 WinGet 在每次发布任务执行时重新读取 stable 索引,核对版本和 commit;新版已经发布后,旧任务重试会失败并停止写入。手动运行这些下游工作流也只允许发布当前 stable 版本;Homebrew 和 AUR 的 dry-run 仍可预览其他版本。 +Homebrew、AUR 和 WinGet 在每次发布任务执行时重新读取 stable 索引,核对版本和 commit;新版已经发布后,旧任务重试会失败并停止写入。手动运行这些下游工作流也只允许发布当前 stable 版本,AUR 非 dry-run 时必须填写 `pkgver`;Homebrew 和 AUR 的 dry-run 仍可预览其他版本。 `Prepare Stable Release` 使用内置 `GITHUB_TOKEN`,以 `github-actions[bot]` 身份创建 PR。仓库需要在 Settings → Actions → General 中启用 `Allow GitHub Actions to create and approve pull requests`。具有 write 权限的维护者批准 PR 的 workflow runs 后,检查才会运行。合并操作应由维护者完成,不使用 `GITHUB_TOKEN` 自动合并,否则不会产生所需的后续发布事件。 diff --git a/cliff.toml b/cliff.toml index 2e1463e4..a2f1134d 100644 --- a/cliff.toml +++ b/cliff.toml @@ -117,7 +117,6 @@ commit_parsers = [ { message = "^doc", group = "Documentation" }, { message = "^test", group = "Testing" }, - { body = ".*security", group = " Security" }, { message = "^[Rr]evert", group = " Revert" }, # skip code style changes @@ -142,6 +141,9 @@ commit_parsers = [ # skip changelog commits { message = "(?i)^chore(?:\\(changelog\\))?: update changelog(?: for \\S+)?$", skip = true }, + # after the skip rules, so dependency bumps mentioning security stay skipped + { body = ".*security", group = " Security" }, + # chore should be the last one to avoid overriding above rules { message = "^chore", group = "Miscellaneous" }, ] # regex for parsing and grouping commits diff --git a/xtask/src/release/meta.rs b/xtask/src/release/meta.rs index 0add7348..38f04864 100644 --- a/xtask/src/release/meta.rs +++ b/xtask/src/release/meta.rs @@ -21,14 +21,19 @@ pub fn run(options: ReleaseVersionOptions) -> Result<()> { selection::cargo_version()? } else { let (candidate, previous_tag) = selection::select(&options.version)?; + let commits_since = Command::new("git") + .args(["rev-list", "--count", &format!("{previous_tag}..HEAD")]) + .read()? + .parse() + .context("Failed to count commits since the latest stable tag")?; // A stable tag can be published before the index job completes. Do not // create an older prerelease just because stable.json still lags behind. - if previous_tag == format!("v{candidate}") { + let Some(base) = prerelease_base(channel, candidate, &previous_tag, commits_since) else { println!("No releasable changes since {previous_tag}"); github::set_output("skip", "true")?; return Ok(()); - } - candidate + }; + base }; selection::ensure_stable(&candidate)?; @@ -105,6 +110,24 @@ pub fn run(options: ReleaseVersionOptions) -> Result<()> { Ok(()) } +/// Choose the base version of a prerelease, or `None` to skip it. +/// +/// Without a version-bumping change since the latest stable tag, beta has +/// nothing to release, while nightly still builds every new commit against +/// the next patch version. +fn prerelease_base( + channel: Channel, + candidate: Version, + previous_tag: &str, + commits_since: u64, +) -> Option { + if previous_tag != format!("v{candidate}") { + return Some(candidate); + } + (channel == Channel::Alpha && commits_since > 0) + .then(|| Version::new(candidate.major, candidate.minor, candidate.patch + 1)) +} + fn get_commit_sha() -> Result { Command::new("git").args(["rev-parse", "HEAD"]).read() } @@ -376,6 +399,27 @@ fn parse_counter(value: &str, name: &str) -> Result { mod tests { use super::*; + #[test] + fn nightly_builds_new_commits_without_a_version_bump() { + let stable = Version::new(0, 8, 0); + assert_eq!( + prerelease_base(Channel::Alpha, stable.clone(), "v0.8.0", 2), + Some(Version::new(0, 8, 1)) + ); + assert_eq!( + prerelease_base(Channel::Alpha, stable.clone(), "v0.8.0", 0), + None + ); + assert_eq!( + prerelease_base(Channel::Beta, stable.clone(), "v0.8.0", 2), + None + ); + assert_eq!( + prerelease_base(Channel::Beta, Version::new(0, 9, 0), "v0.8.0", 2), + Some(Version::new(0, 9, 0)) + ); + } + #[test] fn beta_uses_the_derived_stable_version_without_changing_it() -> Result<()> { let stable = Version::new(0, 8, 0); From 49210074e454eb2ca953e7dacd18283a0e1ac159 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 13 Oct 2026 22:15:17 +0000 Subject: [PATCH 5/6] fix(release): keep features on the patch series before 1.0 Set features_always_bump_minor = false so `auto` derives 0.7.x from features on main; starting 0.8 requires an explicit `minor` selection or a breaking change. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_012ULwm4FYPWei21gc5bhw1x --- .github/scripts/test-release-files.sh | 6 ++++++ DEVELOPMENT.md | 2 +- cliff.toml | 5 +++-- 3 files changed, 10 insertions(+), 3 deletions(-) diff --git a/.github/scripts/test-release-files.sh b/.github/scripts/test-release-files.sh index 8f01e37b..7c94fa2a 100644 --- a/.github/scripts/test-release-files.sh +++ b/.github/scripts/test-release-files.sh @@ -35,6 +35,12 @@ select_version() { git commit -q --allow-empty -m 'docs: clarify usage' select_version auto [[ "$VERSION" == 0.7.5 ]] +# Before 1.0, features only advance the patch version. +git commit -q --allow-empty -m 'feat: add option A' +select_version auto +[[ "$VERSION" == 0.7.6 ]] +select_version minor +[[ "$VERSION" == 0.8.0 ]] git commit -q --allow-empty -m 'fix: repair A' # Unconventional subjects must not block version selection or changelogs. git commit -q --allow-empty -m 'Revert "feat: experiment"' -m 'This reverts commit 1234567.' diff --git a/DEVELOPMENT.md b/DEVELOPMENT.md index ccc38ffa..de374a58 100644 --- a/DEVELOPMENT.md +++ b/DEVELOPMENT.md @@ -74,7 +74,7 @@ cargo test <测试名称> - `patch`、`minor`、`major`:由 git-cliff 相对最近正式 tag 计算指定级别的版本。 - `X.Y.Z`:直接指定稳定版本号,必须高于最近正式 tag;预发布不接受在这里输入 `beta.N`。 -自动推导只有 `fix`、`feat` 或 breaking change 会触发新版本:`fix` 推进 patch,`feat` 推进 minor,breaking change 在 `0.x` 阶段推进 minor。文档、重构等其他变更仍可出现在 changelog 中,但不单独触发自动 bump;需要发布时可以显式选择版本。 +自动推导只有 `fix`、`feat` 或 breaking change 会触发新版本:`0.x` 阶段 `fix` 和 `feat` 都推进 patch,breaking change 推进 minor;进入新的 minor 系列(例如 0.8)需要显式选择 `minor` 或指定版本。文档、重构等其他变更仍可出现在 changelog 中,但不单独触发自动 bump;需要发布时可以显式选择版本。 ### Stable diff --git a/cliff.toml b/cliff.toml index a2f1134d..80d04fb1 100644 --- a/cliff.toml +++ b/cliff.toml @@ -83,8 +83,9 @@ footer = """ """ [bump] -# Features always advance the minor version, including while the project is 0.x. -features_always_bump_minor = true +# While the project is 0.x, features only advance the patch version; use an +# explicit `minor` selection (or a breaking change) to start a new minor series. +features_always_bump_minor = false # Breaking changes remain within 0.x until the project explicitly declares 1.0. breaking_always_bump_major = false From ec28b97c25249003b908b04c6ff74ab07bed28cd Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 13 Oct 2026 23:03:22 +0000 Subject: [PATCH 6/6] feat(release): publish beta and nightly from a configurable dev branch - PRERELEASE_BRANCH (default main) selects where beta and nightly come from; scheduled nightly builds that branch's head and manual prereleases from other branches fail fast. Stable stays on main. - Prerelease notes use the latest reachable stable tag as an explicit range, so a dev branch that merges main does not repeat published fixes. - Stable releases no longer move the Homebrew maa-cli-beta formula back. - Run CI and markdown lint on dev-* branches. - Document the dev branch model and how it merges back into main. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_012ULwm4FYPWei21gc5bhw1x --- .github/scripts/release-files.sh | 14 ++++++++++- .github/scripts/release-is-newer.sh | 15 ++++++++++++ .github/scripts/test-release-files.sh | 28 +++++++++++++++++++++ .github/scripts/test-release-recovery.sh | 8 ++++++ .github/workflows/ci.yml | 6 ++--- .github/workflows/lint-docs.yml | 6 ++--- .github/workflows/publish-homebrew.yml | 8 ++++++ .github/workflows/release.yml | 31 ++++++++++++++++++------ DEVELOPMENT.md | 14 +++++++++-- 9 files changed, 113 insertions(+), 17 deletions(-) create mode 100644 .github/scripts/release-is-newer.sh diff --git a/.github/scripts/release-files.sh b/.github/scripts/release-files.sh index 83e12bd8..0f163285 100644 --- a/.github/scripts/release-files.sh +++ b/.github/scripts/release-files.sh @@ -15,7 +15,7 @@ release_notes() { ' CHANGELOG.md } -case "${1:?expected prepare, check, or notes}" in +case "${1:?expected prepare, check, notes, or prerelease-notes}" in prepare) : "${VERSION:?}" "${PREVIOUS_TAG:?}" current_version=$(package_version) @@ -66,5 +66,17 @@ case "${1:?expected prepare, check, or notes}" in notes) release_notes "${2:?expected version}" ;; + prerelease-notes) + : "${VERSION:?}" "${TAG:?}" + # Use an explicit range: on a dev branch that merges main, git-cliff's + # --unreleased also lists fixes already published in the merged stable tags. + previous=$(git tag --merged HEAD --list 'v*' | + awk '/^v[0-9]+\.[0-9]+\.[0-9]+$/' | sort -V | tail -n 1) + if [[ -z "$previous" ]]; then + echo 'No reachable stable vX.Y.Z tag found' >&2 + exit 1 + fi + git-cliff --config cliff.toml --strip all "$previous..$(git rev-parse HEAD)" + ;; *) echo "Unknown operation: $1" >&2; exit 1 ;; esac diff --git a/.github/scripts/release-is-newer.sh b/.github/scripts/release-is-newer.sh new file mode 100644 index 00000000..a12cea44 --- /dev/null +++ b/.github/scripts/release-is-newer.sh @@ -0,0 +1,15 @@ +#!/usr/bin/env bash +# Exit 0 when stable version $1 is newer than packaged version $2. +# Used to avoid moving a beta package back to an older stable release. +set -euo pipefail +stable=${1:?expected stable version} +current=${2:?expected current version} +core() { grep -oE '^[0-9]+\.[0-9]+\.[0-9]+' <<< "$1"; } +stable_core=$(core "$stable") +current_core=$(core "$current") +if [[ "$stable_core" == "$current_core" ]]; then + # The stable release supersedes a prerelease of the same version. + [[ "$current" != "$current_core" ]] + exit +fi +[[ "$(printf '%s\n%s\n' "$stable_core" "$current_core" | sort -V | tail -n 1)" == "$stable_core" ]] diff --git a/.github/scripts/test-release-files.sh b/.github/scripts/test-release-files.sh index 7c94fa2a..4e33c1cc 100644 --- a/.github/scripts/test-release-files.sh +++ b/.github/scripts/test-release-files.sh @@ -227,4 +227,32 @@ asset=$(jq -r '.details.assets["x86_64-unknown-linux-gnu"].name' release-bundle/ [[ -f "release-bundle/$asset" && ! -f "$asset" ]] [[ -f release-bundle/version/alpha.txt ]] tar -tzf "release-bundle/$asset" | grep -qx maa +# Prerelease notes on a dev branch that merges main must skip fixes already +# published in the merged stable tag. +mkdir "$test_root/dev" +cd "$test_root/dev" +git init -q -b main +git config user.name 'Release Test' +git config user.email 'release@example.invalid' +git config commit.gpgsign false +cp "$repo/cliff.toml" . +git add cliff.toml +git commit -qm 'chore: initial' +git tag v0.7.5 +git checkout -qb dev-0.8 +git commit -q --allow-empty -m 'feat: dev change one' +git checkout -q main +git commit -q --allow-empty -m 'fix: stable fix' +git tag v0.7.6 +git checkout -q dev-0.8 +git merge -q --no-ff main -m 'Merge branch main into dev-0.8' +git commit -q --allow-empty -m 'feat: dev change two' +VERSION=0.8.0-beta.1 TAG=v0.8.0-beta.1 bash "$files" prerelease-notes > "$RUNNER_TEMP/dev-notes.md" +grep -q 'compare/v0.7.6...v0.8.0-beta.1>' "$RUNNER_TEMP/dev-notes.md" +grep -q '^- Dev change one$' "$RUNNER_TEMP/dev-notes.md" +grep -q '^- Dev change two$' "$RUNNER_TEMP/dev-notes.md" +if grep -q 'Stable fix' "$RUNNER_TEMP/dev-notes.md"; then + echo 'Prerelease notes repeated a fix from a published stable tag' >&2 + exit 1 +fi echo "Release file, prerelease recovery and packaging checks passed (fixtures: $test_root)" diff --git a/.github/scripts/test-release-recovery.sh b/.github/scripts/test-release-recovery.sh index 1b6ccc19..2f4a3f22 100644 --- a/.github/scripts/test-release-recovery.sh +++ b/.github/scripts/test-release-recovery.sh @@ -108,4 +108,12 @@ if TAG=nightly COMMIT='' bash "$scripts/release-current.sh" 2> "$RUNNER_TEMP/err fi TEST_CASE=denied if bash "$scripts/release-current.sh" 2> "$RUNNER_TEMP/error"; then exit 1; fi +# A stable release must not move the beta package back to an older version. +newer="$scripts/release-is-newer.sh" +bash "$newer" 0.7.6 0.7.5 +bash "$newer" 0.8.0 0.8.0-beta.3 +bash "$newer" 0.10.0 0.9.1 +if bash "$newer" 0.7.6 0.8.0-beta.1; then exit 1; fi +if bash "$newer" 0.8.0 0.8.0; then exit 1; fi +if bash "$newer" 0.9.1 0.10.0; then exit 1; fi echo "Release recovery checks passed (fixtures: $RUNNER_TEMP)" diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 6469649e..dd77a578 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -2,11 +2,11 @@ name: CI on: push: - branches: ["main"] + branches: ["main", "dev-*"] pull_request: - branches: ["main"] + branches: ["main", "dev-*"] merge_group: - branches: ["main"] + branches: ["main", "dev-*"] types: [checks_requested] env: diff --git a/.github/workflows/lint-docs.yml b/.github/workflows/lint-docs.yml index 0edadbdf..6c42f4ea 100644 --- a/.github/workflows/lint-docs.yml +++ b/.github/workflows/lint-docs.yml @@ -2,11 +2,11 @@ name: Lint Markdown files on: push: - branches: ["main"] + branches: ["main", "dev-*"] pull_request: - branches: ["main"] + branches: ["main", "dev-*"] merge_group: - branches: ["main"] + branches: ["main", "dev-*"] types: [checks_requested] schedule: - cron: "44 7 * * 2" diff --git a/.github/workflows/publish-homebrew.yml b/.github/workflows/publish-homebrew.yml index be374b7a..9686c897 100644 --- a/.github/workflows/publish-homebrew.yml +++ b/.github/workflows/publish-homebrew.yml @@ -61,8 +61,16 @@ jobs: GH_TOKEN: ${{ github.token }} run: bash .github/scripts/release-current.sh - name: Bump Formulae and Casks + env: + VERSION: ${{ inputs.version }} run: | for formula in maa-cli maa-cli-beta; do + current=$(brew info --json=v2 "$formula" | jq -r '.formulae[0].versions.stable') + if [[ "$formula" == maa-cli-beta ]] && + ! bash .github/scripts/release-is-newer.sh "$VERSION" "$current"; then + echo "Keeping $formula at newer prerelease $current" + continue + fi brew bump-formula-pr "$formula" \ ${{ inputs.dryrun && '--dry-run' || '' }} \ --no-browse \ diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 50548f70..88394db9 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -46,7 +46,7 @@ jobs: github.event.pull_request.merged == true && contains(github.event.pull_request.labels.*.name, 'release') && github.event.pull_request.head.repo.full_name == github.repository) || - (github.event_name != 'pull_request' && github.ref == 'refs/heads/main') + github.event_name == 'schedule' || github.event_name == 'workflow_dispatch' runs-on: ubuntu-latest outputs: version: ${{ steps.plan.outputs.version }} @@ -65,13 +65,28 @@ jobs: id: source env: EVENT_NAME: ${{ github.event_name }} + SOURCE_REF: ${{ github.ref }} SOURCE_SHA: ${{ github.sha }} + # Stable always publishes from main; beta and nightly follow this branch. + PRERELEASE_BRANCH: ${{ vars.PRERELEASE_BRANCH || 'main' }} run: | - if [[ "$EVENT_NAME" == pull_request ]]; then - commit=$(jq -r '.pull_request.merge_commit_sha' "$GITHUB_EVENT_PATH") - else - commit="$SOURCE_SHA" - fi + case "$EVENT_NAME" in + pull_request) + commit=$(jq -r '.pull_request.merge_commit_sha' "$GITHUB_EVENT_PATH") + ;; + schedule) + # Scheduled runs always start on main; build the prerelease branch head. + commit=$(gh api "repos/$GH_REPO/commits/$PRERELEASE_BRANCH" --jq .sha) + ;; + workflow_dispatch) + if [[ "$SOURCE_REF" != "refs/heads/$PRERELEASE_BRANCH" ]]; then + echo "Prereleases are published from $PRERELEASE_BRANCH, not $SOURCE_REF." >&2 + exit 1 + fi + commit="$SOURCE_SHA" + ;; + *) exit 1 ;; + esac echo "commit=$commit" >> "$GITHUB_OUTPUT" - uses: actions/checkout@v7 with: @@ -128,8 +143,8 @@ jobs: bash .github/scripts/release-files.sh notes "$version" > "$RUNNER_TEMP/release-plan/notes.md" else VERSION="$version" TAG="$(sed -n 's/^tag=//p' "$RUNNER_TEMP/release-plan/outputs")" \ - git-cliff --config cliff.toml --unreleased --strip all \ - --output "$RUNNER_TEMP/release-plan/notes.md" + bash .github/scripts/release-files.sh prerelease-notes \ + > "$RUNNER_TEMP/release-plan/notes.md" fi fi - name: Freeze plan before any publication diff --git a/DEVELOPMENT.md b/DEVELOPMENT.md index de374a58..89bb7327 100644 --- a/DEVELOPMENT.md +++ b/DEVELOPMENT.md @@ -64,7 +64,7 @@ cargo test <测试名称> ## 发布流程 -`main` 是发布源码分支。Stable 的发布意图由 PR 的 `release` 标签表示:同仓库的 PR 带此标签合入 `main` 才会发布。普通 PR 可以单独更新 Cargo 版本;修改 `Cargo.toml` 本身不会触发发布。 +`main` 是 Stable 的发布源码分支,应始终保持可发布。Stable 的发布意图由 PR 的 `release` 标签表示:同仓库的 PR 带此标签合入 `main` 才会发布。普通 PR 可以单独更新 Cargo 版本;修改 `Cargo.toml` 本身不会触发发布。 ### 选择版本 @@ -90,7 +90,7 @@ cargo test <测试名称> ### Beta 和 Nightly -Beta 在 Actions 中从 `main` 手动运行 `Release`,选择 `channel=beta`,设置基础版本,并在真正发布时勾选 `publish`。默认不勾选,仅构建预览。Nightly 每日自动运行,也可以选择 `channel=alpha` 手动运行。 +Beta 和 Nightly 从仓库变量 `PRERELEASE_BRANCH` 指定的分支发布,未设置时为 `main`。Beta 在 Actions 中从该分支手动运行 `Release`(从其他分支运行会直接失败),选择 `channel=beta`,设置基础版本,并在真正发布时勾选 `publish`。默认不勾选,仅构建预览。Nightly 每日自动运行;定时任务总在 `main` 上触发,但会构建 `PRERELEASE_BRANCH` 的最新提交。也可以从该分支选择 `channel=alpha` 手动运行。预发布说明只包含最近正式 tag 之后的提交,不会重复列出已发布的 Stable 修复。 Nightly 会构建每个新提交:自最近正式 tag 以来没有 `feat`、`fix` 或 breaking change 时,以下一个 patch 版本为基础,例如 `0.8.0` 之后为 `0.8.1-alpha.1`;正式 tag 之后没有新提交时跳过。Beta 在没有触发 bump 的变更时跳过,需要发布时显式选择版本。 @@ -98,6 +98,16 @@ Nightly 会构建每个新提交:自最近正式 tag 以来没有 `feat`、`fi Beta 发布后更新 `version` 分支的 `beta.json`、`alpha.json` 及对应 `.txt`;Nightly 只更新 alpha;Stable 更新三个通道。若 Beta tag 或已发布的 Nightly 超前于索引,先恢复前一次发布的索引 job,再分配新编号。Alpha 分配前会通过 GitHub API 核对 Nightly 的版本和 tag commit;已有 Nightly 无法读取时也会停止,本地运行需提供已认证的 `gh`。 +### 分支模型 + +大型或破坏性更新(如 0.8)在 `dev-X.Y` 分支上集成,避免打断 `main`。期间 `main` 只合入修复和小改动并发布 Stable,`PRERELEASE_BRANCH` 设为 `dev-X.Y`,Beta 和 Nightly 从 dev 分支发布。CI 同样在 `dev-*` 分支的 PR 和 merge queue 上运行。 + +- 将 `dev-X.Y` 的 `crates/maa-cli/Cargo.toml` 版本设为 `X.Y.0`,预发布版本即为 `X.Y.0-beta.N`。 +- 定期把 `main` 合入 dev 分支,不要反向合并。`main` 每次发布 Stable 后,合并时 `Cargo.toml` 和 `Cargo.lock` 的版本号会冲突,保留 dev 分支的版本;dev 分支不修改 `CHANGELOG.md`。 +- Stable 发布不会把 Homebrew `maa-cli-beta` 或 `version` 分支中更新的预发布版本回退到较旧的 Stable。 +- 合回 `main` 前,先将 `main` 最后一次合入 dev 分支并发布一个 Beta 验证。然后用 merge commit 合并 dev → `main` 的 PR(merge queue 若只允许 squash,需临时改为 merge commit),不要 squash 或 rebase,以保留每个提交和已发布 Beta tag 的历史。 +- 合并后在 `main` 运行 `Prepare Stable Release` 发布 `X.Y.0`,将 `PRERELEASE_BRANCH` 改回 `main`,把仍指向 dev 分支的 PR 改为指向 `main`,再删除 dev 分支。 + ### 重试与验证 同一次 workflow run 的版本、commit 和发布说明保存为 `release-plan` artifact,打包结果和索引清单保存为 `release-bundle` artifact。重跑全部 jobs 或只重跑失败 jobs 都复用已保存的内容,不重新分配版本或改写已生成的产物。版本索引更新失败时,重跑 `Update Version Index` 及其失败的后续任务即可,不需要重新编译。