diff --git a/.github/workflows/action-tag-recovery.yml b/.github/workflows/action-tag-recovery.yml new file mode 100644 index 0000000..f94311e --- /dev/null +++ b/.github/workflows/action-tag-recovery.yml @@ -0,0 +1,141 @@ +name: Recover major Action tag + +on: + workflow_dispatch: + inputs: + tag: + description: Existing successful stable lockstep release tag to restore (vMAJOR.MINOR.PATCH). + required: true + type: string + +concurrency: + # This is intentionally identical to release.yml: forward promotion and + # recovery must never race each other. + group: release-${{ github.repository }} + cancel-in-progress: false + +jobs: + validate-context: + runs-on: ubuntu-latest + permissions: + contents: read + steps: + - name: Require the canonical repository and main branch + run: | + set -euo pipefail + test "$GITHUB_REPOSITORY" = "mbeacom/adrkit" + test "$GITHUB_REF" = "refs/heads/main" + + recover: + needs: validate-context + if: >- + github.repository == 'mbeacom/adrkit' && + github.ref == 'refs/heads/main' + runs-on: ubuntu-latest + environment: npm + permissions: + actions: read + contents: write + steps: + - name: Resolve the requested stable release + id: release + env: + RELEASE_TAG: ${{ inputs.tag }} + run: | + set -euo pipefail + if [[ ! "$RELEASE_TAG" =~ ^v(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$ ]]; then + echo "Expected a stable lockstep tag v, got '$RELEASE_TAG'." >&2 + exit 1 + fi + version="${RELEASE_TAG#v}" + { + echo "tag=$RELEASE_TAG" + echo "version=$version" + } >> "$GITHUB_OUTPUT" + + - name: Check out trusted recovery tooling + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + fetch-depth: 0 + persist-credentials: false + + - name: Set up Bun + uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0 + with: + bun-version: 1.3.14 + + - name: Require an existing successful stable release + id: source + env: + GH_TOKEN: ${{ github.token }} + RELEASE_TAG: ${{ steps.release.outputs.tag }} + RELEASE_VERSION: ${{ steps.release.outputs.version }} + run: | + set -euo pipefail + test "$(git cat-file -t "refs/tags/$RELEASE_TAG")" = tag + revision=$(git rev-parse "$RELEASE_TAG^{commit}") + test "$(git show "$revision:package.json" | jq -r .version)" = "$RELEASE_VERSION" + git cat-file -e "$revision:packages/ci/action.yml" + git cat-file -e "$revision:packages/ci/dist/index.js" + git cat-file -e "$revision:packages/ci/queue/action.yml" + git cat-file -e "$revision:packages/ci/dist/queue-action.js" + + git fetch --no-tags origin main + git merge-base --is-ancestor "$revision" origin/main + + release=$(gh release view "$RELEASE_TAG" \ + --repo "$GITHUB_REPOSITORY" \ + --json isDraft,isPrerelease) + test "$(jq -r .isDraft <<<"$release")" = false + test "$(jq -r .isPrerelease <<<"$release")" = false + + runs=$(gh api \ + "repos/$GITHUB_REPOSITORY/actions/workflows/release.yml/runs?event=push&head_sha=$revision&per_page=100") + successful_run=$(jq -r --arg revision "$revision" --arg tag "$RELEASE_TAG" \ + '[.workflow_runs[] | select(.head_sha == $revision and .head_branch == $tag and .conclusion == "success")][0].id // empty' \ + <<<"$runs") + test -n "$successful_run" + echo "revision=$revision" >> "$GITHUB_OUTPUT" + + moving_refs=$(git ls-remote --tags origin refs/tags/v0 refs/tags/v0^{} || true) + moving_ref_sha=$(awk '$2 == "refs/tags/v0" { print $1 }' <<<"$moving_refs") + moving_commit_sha=$(awk '$2 == "refs/tags/v0^{}" { print $1 }' <<<"$moving_refs") + moving_commit_sha=${moving_commit_sha:-$moving_ref_sha} + echo "moving_ref_sha=$moving_ref_sha" >> "$GITHUB_OUTPUT" + echo "moving_commit_sha=$moving_commit_sha" >> "$GITHUB_OUTPUT" + + - name: Restore the moving major Action tag + env: + GH_TOKEN: ${{ github.token }} + RELEASE_TAG: ${{ steps.release.outputs.tag }} + run: | + # The write credential exists in Git config only for this step and is + # removed even when the lease-protected push fails. + set -euo pipefail + header="AUTHORIZATION: basic $(printf 'x-access-token:%s' "$GH_TOKEN" | base64 | tr -d '\n')" + cleanup() { + git config --local --unset-all 'http.https://github.com/.extraheader' || true + } + trap cleanup EXIT + git config --local 'http.https://github.com/.extraheader' "$header" + if [ -n "${{ steps.source.outputs.moving_commit_sha }}" ] && + [ "${{ steps.source.outputs.moving_commit_sha }}" != "${{ steps.source.outputs.revision }}" ]; then + marker="action-recovery-block/${{ steps.source.outputs.moving_commit_sha }}" + if ! git show-ref --verify --quiet "refs/tags/$marker"; then + git -c tag.gpgSign=false tag "$marker" "${{ steps.source.outputs.moving_commit_sha }}" + fi + git push origin "refs/tags/$marker:refs/tags/$marker" + fi + bun run release:action-tag -- --recover "$RELEASE_TAG" \ + --expected-remote-ref-sha "${{ steps.source.outputs.moving_ref_sha }}" | + tee action-tag-recovery.log + { + echo "### Major Action tag recovery" + echo + echo "- Requested release: \`$RELEASE_TAG\`" + echo "- Resolved commit: \`${{ steps.source.outputs.revision }}\`" + echo + echo '```text' + cat action-tag-recovery.log + echo '```' + } >> "$GITHUB_STEP_SUMMARY" diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index ba7e068..2abef35 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -59,8 +59,34 @@ jobs: - name: Require the release commit on main run: | + set -euo pipefail git fetch --no-tags origin main git merge-base --is-ancestor "$GITHUB_SHA" origin/main + if [ "${{ steps.scope.outputs.lockstep }}" = "true" ]; then + test "$(git cat-file -t "refs/tags/$GITHUB_REF_NAME")" = tag + fi + + - name: Refuse a withdrawn lockstep release + if: steps.scope.outputs.lockstep == 'true' + run: | + set -euo pipefail + marker="refs/tags/action-recovery-block/$GITHUB_SHA" + if git ls-remote --exit-code --refs origin "$marker" >/dev/null; then + marker_status=0 + else + marker_status=$? + fi + case "$marker_status" in + 0) + echo "Lockstep release commit $GITHUB_SHA was withdrawn from the moving Action tag." >&2 + exit 1 + ;; + 2) ;; + *) + echo "Unable to check withdrawal marker $marker (git ls-remote exit $marker_status)." >&2 + exit "$marker_status" + ;; + esac - name: Install dependencies run: bun install --frozen-lockfile @@ -177,4 +203,11 @@ jobs: } trap cleanup EXIT git config --local 'http.https://github.com/.extraheader' "$header" - bun run release:action-tag -- "$GITHUB_REF_NAME" + bun run release:action-tag -- "$GITHUB_REF_NAME" | tee action-tag-update.log + { + echo "### Moving major Action tag" + echo + echo '```text' + cat action-tag-update.log + echo '```' + } >> "$GITHUB_STEP_SUMMARY" diff --git a/AGENTS.md b/AGENTS.md index 6784999..d073307 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -161,6 +161,26 @@ are reported as `corpus.file-skipped` corpus findings at **`warn`** severity, so `proposed` record never disappears from the queue silently. Being `warn`, they do not change the exit code and do not fail the managed-issue Action. +## Moving Action tag recovery + +Normal lockstep releases move the lightweight major Action tag (`v0`) forward +only after npm publication and GitHub release creation succeed. +`.github/workflows/action-tag-recovery.yml` is the explicit backward path. Run it +from `main` with an existing stable `vX.Y.Z` release tag. It requires an annotated +tag that peels to a commit on `main`, an exact successful `Release` run, matching +root version, and both committed Action bundles. It shares the release concurrency +group, holds only `actions: read` and `contents: write`, and pushes with a lease +against the observed remote tag object. +Recovery also records a durable `action-recovery-block/` tag for the +commit removed from `v0`; the normal release workflow rejects a rerun of that +commit before npm publication. A context-validation job fails dispatches from +another repository or ref instead of leaving a skipped workflow green. + +Moving `v0` stops future jobs from resolving a bad release; it does not undo an +already-edited PR comment or change a job that already resolved the old SHA. +Restore comment content from GitHub's edit history or rerun the known-good Action. +The full preferred and manual fallback runbook is in `docs/RELEASING.md`. + ## The agent plugin (`packages/adapters/agent-plugin`) The `adrkit` plugin is the fourth distribution surface and the one that reaches diff --git a/docs/DISTRIBUTION.md b/docs/DISTRIBUTION.md index 8dba4e7..ba22078 100644 --- a/docs/DISTRIBUTION.md +++ b/docs/DISTRIBUTION.md @@ -615,6 +615,14 @@ current target (see `docs/RELEASING.md`, "Subsequent releases"). contains `packages/ci/queue/action.yml`, and adopters can switch the pin from the commit SHA to `@v0`. +Forward promotion is monotonic. If a verified release must be restored instead, +dispatch `.github/workflows/action-tag-recovery.yml` from `main`; it validates +the stable GitHub release and successful release run, peels the annotated version +tag to its commit, shares promotion concurrency with `release.yml`, and moves the +lightweight major tag with a remote-SHA lease. The full containment and comment +restoration runbook is in `docs/RELEASING.md`, "Recovering the moving major +Action tag." + **Done.** v0.2.1 executed exactly this: `v0` and `v0.2.1^{}` both peel to `31bed03a179b6bfa4a62f7e69008c7441c62598f`, and `GET /repos/mbeacom/adrkit/contents/packages/ci/queue/action.yml?ref=v0` returns the diff --git a/docs/RELEASING.md b/docs/RELEASING.md index 9b14092..a3c29da 100644 --- a/docs/RELEASING.md +++ b/docs/RELEASING.md @@ -385,6 +385,121 @@ merge and the tag the site is deployed claiming a release that does not exist ye The window is short and self-correcting; it is called out here so it is not mistaken for a mistake. +## Recovering the moving major Action tag + +The repository-backed Actions are consumed through a moving lightweight tag, +currently `mbeacom/adrkit/packages/ci@v0` and +`mbeacom/adrkit/packages/ci/queue@v0`. A bad move has a broad but bounded blast +radius: new jobs using `@v0` can resolve the bad release, but the Action cannot +delete repository content or approve a change. + +Moving `v0` back **stops future jobs from resolving the bad release; it does not +undo work a completed job already performed**. In particular, it does not restore +an already-edited PR comment. Restore that content from GitHub's comment edit +history, or rerun the known-good Action after recovery so it replaces its managed +comment. Cancel still-running release or consumer jobs when immediate containment +matters: a job that already resolved or checked out the old SHA can continue using +it even after the tag moves. + +### Preferred guarded recovery + +Choose the last verified lockstep release, then dispatch the recovery workflow +from `main`: + +```sh +target=v0.10.0 +gh workflow run action-tag-recovery.yml --ref main -f tag="$target" +gh run list --workflow action-tag-recovery.yml --limit 1 +``` + +The workflow refuses a prerelease, draft, lightweight tag, tag whose commit is +not on `main`, release without a successful `Release` run for the exact peeled +commit, root-version mismatch, or tree without both committed Action bundles. +Stable release tags are annotated objects, so the workflow resolves the commit +with `git rev-parse "$target^{commit}"`; the annotated tag object's own SHA is +not a runnable Action revision. + +Recovery uses only `actions: read` and `contents: write`, checks out with +credentials disabled, and exposes the write credential only during the final +push. It shares `release-${{ github.repository }}` concurrency with the normal +release workflow, so rollback and forward promotion cannot overlap. The push is +guarded by `--force-with-lease` against the exact remote tag object observed +during validation. If another actor moves the tag despite serialization, recovery +fails rather than overwriting that change. + +Both forward promotion and recovery record the prior release tag/commit and the +new release tag/commit in the run summary. Verify the result independently: + +```sh +target_commit=$(git rev-parse "$target^{commit}") +test "$(git ls-remote --tags origin refs/tags/v0 | awk '{print $1}')" = "$target_commit" +gh api repos/mbeacom/adrkit/git/ref/tags/v0 \ + --jq '{type: .object.type, sha: .object.sha}' +``` + +The API should report a lightweight tag (`type: commit`) at `target_commit`. +Consumers do not need to change their workflow files. New runs resolve the moved +tag; rerun any job that had already resolved the bad SHA. Consumers needing an +immediate immutable containment pin can temporarily use `@`. + +### Manual fallback when GitHub Actions is unavailable + +Use a clean checkout of current `main` and a credential limited to this +repository with **Actions: read** and **Contents: write**. Do not hand-write a +plain `git tag -f` / `git push --force` sequence: it omits the release guards and +can overwrite a concurrent promotion. + +```sh +set -euo pipefail +target=v0.10.0 +git fetch --no-tags origin main "refs/tags/$target:refs/tags/$target" +test "$(git cat-file -t "refs/tags/$target")" = tag +target_commit=$(git rev-parse "$target^{commit}") +test "$(git show "$target_commit:package.json" | jq -r .version)" = "${target#v}" +git cat-file -e "$target_commit:packages/ci/action.yml" +git cat-file -e "$target_commit:packages/ci/dist/index.js" +git cat-file -e "$target_commit:packages/ci/queue/action.yml" +git cat-file -e "$target_commit:packages/ci/dist/queue-action.js" +git merge-base --is-ancestor "$target_commit" origin/main + +release=$(gh release view "$target" --json isDraft,isPrerelease) +test "$(jq -r .isDraft <<<"$release")" = false +test "$(jq -r .isPrerelease <<<"$release")" = false +runs=$(gh api \ + "repos/mbeacom/adrkit/actions/workflows/release.yml/runs?event=push&head_sha=$target_commit&per_page=100") +test "$(jq -r --arg sha "$target_commit" --arg tag "$target" \ + '[.workflow_runs[] | select(.head_sha == $sha and .head_branch == $tag and .conclusion == "success")] | length' \ + <<<"$runs")" -gt 0 + +moving_refs=$(git ls-remote --tags origin refs/tags/v0 refs/tags/v0^{} || true) +moving_ref_sha=$(awk '$2 == "refs/tags/v0" { print $1 }' <<<"$moving_refs") +moving_commit_sha=$(awk '$2 == "refs/tags/v0^{}" { print $1 }' <<<"$moving_refs") +moving_commit_sha=${moving_commit_sha:-$moving_ref_sha} +if [ -n "$moving_commit_sha" ] && [ "$moving_commit_sha" != "$target_commit" ]; then + marker="action-recovery-block/$moving_commit_sha" + if ! git show-ref --verify --quiet "refs/tags/$marker"; then + git -c tag.gpgSign=false tag "$marker" "$moving_commit_sha" + fi + git push origin "refs/tags/$marker:refs/tags/$marker" +fi + +bun run release:action-tag -- --recover "$target" \ + --expected-remote-ref-sha "$moving_ref_sha" +``` + +This fallback performs the same package-version, four-bundle, annotated-tag, +main-ancestry, stable-release, and exact successful-run checks as the workflow. +It also records a durable withdrawal marker for the commit being removed from +`v0`; the normal release workflow refuses any later rerun of that withdrawn +commit before npm publication. The script allows recovery from an arbitrary +current `v0` target, but normal `release:action-tag` calls remain monotonic and +cannot bypass the marker gate. + +This recovery is intentionally separate from npm rollback. npm versions and +immutable `vX.Y.Z` git tags never move; deprecate a bad npm version, optionally +move npm's `latest` dist-tag for containment, and publish a higher hotfix as +described in [Recovering a bad npm release](#recovering-a-bad-npm-release). + ## OCI container image [ADR-0032](adr/0032-publish-one-lockstep-oci-image-after-the-coordinated-release-succeeds.md) diff --git a/scripts/action-tag-recovery-contract.test.ts b/scripts/action-tag-recovery-contract.test.ts new file mode 100644 index 0000000..a783933 --- /dev/null +++ b/scripts/action-tag-recovery-contract.test.ts @@ -0,0 +1,129 @@ +import { describe, expect, test } from 'bun:test'; +import { readFileSync } from 'node:fs'; +import { join, resolve } from 'node:path'; + +const ROOT = resolve(import.meta.dir, '..'); +const RECOVERY_WORKFLOW = readFileSync( + join(ROOT, '.github', 'workflows', 'action-tag-recovery.yml'), + 'utf8', +); +const RELEASE_WORKFLOW = readFileSync( + join(ROOT, '.github', 'workflows', 'release.yml'), + 'utf8', +); +const UPDATE_SCRIPT = readFileSync(join(ROOT, 'scripts', 'update-action-tag.ts'), 'utf8'); +const RELEASING_DOCS = readFileSync(join(ROOT, 'docs', 'RELEASING.md'), 'utf8'); + +function manualFallbackBlock(): string { + const sectionStart = RELEASING_DOCS.indexOf('### Manual fallback when GitHub Actions is unavailable'); + const fenceStart = RELEASING_DOCS.indexOf('```sh', sectionStart); + const fenceEnd = RELEASING_DOCS.indexOf('```', fenceStart + 5); + expect(sectionStart).toBeGreaterThan(-1); + expect(fenceStart).toBeGreaterThan(sectionStart); + expect(fenceEnd).toBeGreaterThan(fenceStart); + return RELEASING_DOCS.slice(fenceStart + '```sh\n'.length, fenceEnd); +} + +function assertManualMarkerSequence(block: string): void { + const creation = block.indexOf('git -c tag.gpgSign=false tag "$marker" "$moving_commit_sha"'); + const push = block.indexOf('git push origin "refs/tags/$marker:refs/tags/$marker"'); + const recovery = block.indexOf('bun run release:action-tag -- --recover "$target"'); + expect(creation).toBeGreaterThan(-1); + expect(push).toBeGreaterThan(creation); + expect(recovery).toBeGreaterThan(push); + expect(block.slice(creation, recovery)).not.toContain('|| true'); +} + +describe('major Action tag recovery contract', () => { + test('serializes recovery with normal release promotion', () => { + const concurrency = 'group: release-${{ github.repository }}'; + expect(RELEASE_WORKFLOW).toContain(concurrency); + expect(RECOVERY_WORKFLOW).toContain(concurrency); + expect(RECOVERY_WORKFLOW).toContain('cancel-in-progress: false'); + }); + + test('runs only from main with the minimum repository permissions', () => { + expect(RECOVERY_WORKFLOW).toContain('validate-context:'); + expect(RECOVERY_WORKFLOW).toContain('test "$GITHUB_REF" = "refs/heads/main"'); + expect(RECOVERY_WORKFLOW).toContain("github.ref == 'refs/heads/main'"); + expect(RECOVERY_WORKFLOW).toContain('actions: read'); + expect(RECOVERY_WORKFLOW).toContain('contents: write'); + expect(RECOVERY_WORKFLOW).not.toContain('id-token: write'); + expect(RECOVERY_WORKFLOW).not.toContain('packages: write'); + expect(RECOVERY_WORKFLOW).not.toContain('attestations: write'); + expect(RECOVERY_WORKFLOW).toContain('persist-credentials: false'); + }); + + test('accepts only a successful stable annotated release and resolves its commit', () => { + expect(RECOVERY_WORKFLOW).toContain( + '^v(0|[1-9][0-9]*)\\.(0|[1-9][0-9]*)\\.(0|[1-9][0-9]*)$', + ); + expect(RECOVERY_WORKFLOW).toContain('git cat-file -t "refs/tags/$RELEASE_TAG"'); + expect(RECOVERY_WORKFLOW).toContain('git rev-parse "$RELEASE_TAG^{commit}"'); + expect(RECOVERY_WORKFLOW).toContain('--json isDraft,isPrerelease'); + expect(RECOVERY_WORKFLOW).toContain( + '.head_sha == $revision and .head_branch == $tag and .conclusion == "success"', + ); + expect(RECOVERY_WORKFLOW).toContain('git merge-base --is-ancestor "$revision" origin/main'); + }); + + test('uses the explicit recovery path and a race-safe tag update', () => { + expect(RECOVERY_WORKFLOW).toContain( + 'bun run release:action-tag -- --recover "$RELEASE_TAG"', + ); + expect(UPDATE_SCRIPT).toContain( + '`--force-with-lease=refs/tags/${majorTag}:${leaseRefSha ?? \'\'}', + ); + expect(UPDATE_SCRIPT).not.toContain("['git', 'push', '--force'"); + expect(RECOVERY_WORKFLOW).toContain('action-recovery-block/'); + expect(RELEASE_WORKFLOW).toContain('Refuse a withdrawn lockstep release'); + expect(RELEASE_WORKFLOW).toContain('action-recovery-block/$GITHUB_SHA'); + expect(RELEASE_WORKFLOW).toContain('marker_status=$?'); + expect(RELEASE_WORKFLOW).toContain('2) ;;'); + expect(RELEASE_WORKFLOW).toContain('Unable to check withdrawal marker'); + expect(RECOVERY_WORKFLOW).toContain('head_sha=$revision'); + expect(UPDATE_SCRIPT).toContain('--expected-remote-ref-sha'); + expect(RECOVERY_WORKFLOW).toContain('moving_ref_sha'); + expect(RECOVERY_WORKFLOW).toContain('git -c tag.gpgSign=false tag "$marker"'); + const fallback = manualFallbackBlock(); + expect(fallback).toMatch(/^set -euo pipefail\n/); + assertManualMarkerSequence(fallback); + expect(() => assertManualMarkerSequence( + fallback.replace('git push origin "refs/tags/$marker:refs/tags/$marker"\n', ''), + )).toThrow(); + expect(() => assertManualMarkerSequence( + fallback.replace( + 'git push origin "refs/tags/$marker:refs/tags/$marker"', + 'bun run release:action-tag -- --recover "$target"\n' + + 'git push origin "refs/tags/$marker:refs/tags/$marker"', + ), + )).toThrow(); + expect(RELEASE_WORKFLOW).toContain('cat action-tag-update.log'); + expect(RELEASE_WORKFLOW).toContain('>> "$GITHUB_STEP_SUMMARY"'); + }); + + test('checks the annotated lockstep tag before publishing', () => { + const gate = RELEASE_WORKFLOW.indexOf('test "$(git cat-file -t "refs/tags/$GITHUB_REF_NAME")" = tag'); + const publish = RELEASE_WORKFLOW.indexOf('name: Publish npm packages with provenance'); + expect(gate).toBeGreaterThan(-1); + expect(gate).toBeLessThan(publish); + }); + + test('does not accept a successful adapter run for a lockstep release', () => { + const revision = 'a'.repeat(40); + const runs = [ + { head_sha: revision, head_branch: 'spec-kit-v0.1.3', conclusion: 'success' }, + { head_sha: revision, head_branch: 'v0.11.0', conclusion: 'failure' }, + ]; + const target = 'v0.11.0'; + const successfulLockstepRuns = runs.filter( + (run) => run.head_sha === revision && run.head_branch === target && run.conclusion === 'success', + ); + expect(successfulLockstepRuns).toHaveLength(0); + expect(RECOVERY_WORKFLOW).toContain('.head_branch == $tag'); + expect(RELEASING_DOCS).toContain( + '.head_branch == $tag and .conclusion == "success"', + ); + expect(UPDATE_SCRIPT).toContain("rawRemoteRefSha === ''"); + }); +}); diff --git a/scripts/update-action-tag.test.ts b/scripts/update-action-tag.test.ts index 6e28a71..80e744b 100644 --- a/scripts/update-action-tag.test.ts +++ b/scripts/update-action-tag.test.ts @@ -1,7 +1,12 @@ import { afterEach, describe, expect, test } from 'bun:test'; import { join } from 'node:path'; import { cleanupTestDir, resetTestDir, writeText } from '../packages/core/test/helpers.ts'; -import { compareStableVersions, parseStableVersionTag, updateActionTag } from './update-action-tag.ts'; +import { + compareStableVersions, + parseStableVersionTag, + recoverActionTag, + updateActionTag, +} from './update-action-tag.ts'; const DIR_NAME = 'update-action-tag'; @@ -50,7 +55,7 @@ describe('moving Action tag version guard', () => { expect(compareStableVersions(parseStableVersionTag('v0.1.0'), parseStableVersionTag('v0.1.0'))).toBe(0); }); - test('never rolls a moving major tag backward and advances it for a newer release', async () => { + test('requires explicit recovery to move backward and advances normally afterward', async () => { const root = await resetTestDir(DIR_NAME); const remote = join(root, 'remote.git'); const work = join(root, 'work'); @@ -77,6 +82,10 @@ describe('moving Action tag version guard', () => { const v02Sha = await git(work, 'rev-list', '-n', '1', 'v0.2.0'); expect(await remoteTagCommit(work, 'v0')).toBe(v02Sha); + expect(await recoverActionTag('v0.1.0', { repositoryRoot: work })).toBe(true); + const v01Sha = await git(work, 'rev-list', '-n', '1', 'v0.1.0'); + expect(await remoteTagCommit(work, 'v0')).toBe(v01Sha); + await writeText(join(work, 'release.txt'), 'v0.3.0\n'); await git(work, 'commit', '-am', 'v0.3.0'); await git(work, 'tag', '-a', 'v0.3.0', '-m', 'v0.3.0'); @@ -87,6 +96,99 @@ describe('moving Action tag version guard', () => { expect(await remoteTagCommit(work, 'v0')).toBe(v03Sha); }); + test('rejects a lightweight tag as a recovery target', async () => { + const root = await resetTestDir(`${DIR_NAME}-lightweight`); + const remote = join(root, 'remote.git'); + const work = join(root, 'work'); + await git(root, 'init', '--bare', '--initial-branch=main', remote); + await git(root, 'init', '--initial-branch=main', work); + await git(work, 'config', 'user.name', 'adrkit test'); + await git(work, 'config', 'user.email', 'test@adrkit.dev'); + await git(work, 'config', 'commit.gpgSign', 'false'); + await git(work, 'config', 'tag.gpgSign', 'false'); + await git(work, 'remote', 'add', 'origin', remote); + + await writeText(join(work, 'release.txt'), 'v0.1.0\n'); + await git(work, 'add', 'release.txt'); + await git(work, 'commit', '-m', 'v0.1.0'); + await git(work, 'tag', 'v0.1.0'); + await git(work, 'push', 'origin', 'refs/tags/v0.1.0'); + + await expect(recoverActionTag('v0.1.0', { repositoryRoot: work })).rejects.toThrow( + 'must be an existing annotated tag', + ); + }); + + test('recovers when the moving tag points at an arbitrary commit', async () => { + const root = await resetTestDir(`${DIR_NAME}-arbitrary-current`); + const remote = join(root, 'remote.git'); + const work = join(root, 'work'); + await git(root, 'init', '--bare', '--initial-branch=main', remote); + await git(root, 'init', '--initial-branch=main', work); + await git(work, 'config', 'user.name', 'adrkit test'); + await git(work, 'config', 'user.email', 'test@adrkit.dev'); + await git(work, 'config', 'commit.gpgSign', 'false'); + await git(work, 'config', 'tag.gpgSign', 'false'); + await git(work, 'remote', 'add', 'origin', remote); + + await writeText(join(work, 'release.txt'), 'v0.1.0\n'); + await git(work, 'add', 'release.txt'); + await git(work, 'commit', '-m', 'v0.1.0'); + await git(work, 'tag', '-a', 'v0.1.0', '-m', 'v0.1.0'); + await writeText(join(work, 'release.txt'), 'unreleased\n'); + await git(work, 'commit', '-am', 'unreleased'); + const arbitrarySha = await git(work, 'rev-parse', 'HEAD'); + await git(work, 'tag', 'v0', arbitrarySha); + await git(work, 'push', 'origin', '--tags'); + + await expect(updateActionTag('v0.1.0', { repositoryRoot: work })).rejects.toThrow( + 'immutable v0.x.y release tag', + ); + const movingRefSha = await remoteTagCommit(work, 'v0'); + await expect( + recoverActionTag('v0.1.0', { + repositoryRoot: work, + remoteRefSha: '0'.repeat(40), + }), + ).rejects.toThrow('does not match the captured ref'); + expect(await recoverActionTag('v0.1.0', { + repositoryRoot: work, + remoteRefSha: movingRefSha, + })).toBe(true); + const targetSha = await git(work, 'rev-list', '-n', '1', 'v0.1.0'); + expect(await remoteTagCommit(work, 'v0')).toBe(targetSha); + }); + + test('creates an absent moving tag with an empty lease and rejects a competing ref', async () => { + const root = await resetTestDir(`${DIR_NAME}-absent-current`); + const remote = join(root, 'remote.git'); + const work = join(root, 'work'); + await git(root, 'init', '--bare', '--initial-branch=main', remote); + await git(root, 'init', '--initial-branch=main', work); + await git(work, 'config', 'user.name', 'adrkit test'); + await git(work, 'config', 'user.email', 'test@adrkit.dev'); + await git(work, 'config', 'commit.gpgSign', 'false'); + await git(work, 'config', 'tag.gpgSign', 'false'); + await git(work, 'remote', 'add', 'origin', remote); + + await writeText(join(work, 'release.txt'), 'v0.1.0\n'); + await git(work, 'add', 'release.txt'); + await git(work, 'commit', '-m', 'v0.1.0'); + await git(work, 'tag', '-a', 'v0.1.0', '-m', 'v0.1.0'); + await git(work, 'push', 'origin', 'refs/tags/v0.1.0'); + + expect(await recoverActionTag('v0.1.0', { + repositoryRoot: work, + remoteRefSha: '', + })).toBe(true); + const targetSha = await git(work, 'rev-list', '-n', '1', 'v0.1.0'); + expect(await remoteTagCommit(work, 'v0')).toBe(targetSha); + await expect(recoverActionTag('v0.1.0', { + repositoryRoot: work, + remoteRefSha: '', + })).rejects.toThrow('does not match the captured ref'); + }); + /** * Regression: the test above sets `tag.gpgSign false`, which is exactly the * condition that hid this. With `tag.gpgSign = true` — a common global default diff --git a/scripts/update-action-tag.ts b/scripts/update-action-tag.ts index 9a66468..5e76019 100644 --- a/scripts/update-action-tag.ts +++ b/scripts/update-action-tag.ts @@ -8,6 +8,20 @@ export interface StableVersion { patch: number; } +export interface ActionTagUpdate { + changed: boolean; + majorTag: string; + previousReleaseTag?: string; + previousSha?: string; + targetReleaseTag: string; + targetSha: string; +} + +interface RemoteTag { + objectSha?: string; + peeledSha?: string; +} + function assert(condition: unknown, message: string): asserts condition { if (!condition) throw new Error(message); } @@ -42,63 +56,130 @@ async function run(command: string[], repositoryRoot: string, allowEmpty = false return output; } -export async function updateActionTag( +function parseRemoteTags(output: string): Map { + const tags = new Map(); + for (const line of output.split('\n').filter(Boolean)) { + const [sha, ref] = line.split(/\s+/); + if (!sha || !ref?.startsWith('refs/tags/')) continue; + const peeled = ref.endsWith('^{}'); + const tag = ref.slice('refs/tags/'.length, peeled ? -3 : undefined); + const current = tags.get(tag) ?? {}; + if (peeled) current.peeledSha = sha; + else current.objectSha = sha; + tags.set(tag, current); + } + return tags; +} + +async function remoteTags( + remoteName: string, + patterns: string[], + repositoryRoot: string, +): Promise> { + const output = await run( + ['git', 'ls-remote', '--tags', remoteName, ...patterns], + repositoryRoot, + true, + ); + return parseRemoteTags(output); +} + +async function moveActionTag( releaseTag: string, - options: { repositoryRoot?: string; remote?: string } = {}, -): Promise { + options: { + repositoryRoot?: string; + remote?: string; + recovery: boolean; + remoteRefSha?: string; + }, +): Promise { const repositoryRoot = options.repositoryRoot ?? REPOSITORY_ROOT; const remoteName = options.remote ?? 'origin'; const releaseVersion = parseStableVersionTag(releaseTag); const majorTag = `v${releaseVersion.major}`; - const remote = await run( - [ - 'git', - 'ls-remote', - '--tags', - remoteName, - `refs/tags/${majorTag}`, - `refs/tags/${majorTag}^{}`, - ], + const targetRemote = (await remoteTags( + remoteName, + [`refs/tags/${releaseTag}`, `refs/tags/${releaseTag}^{}`], + repositoryRoot, + )).get(releaseTag); + assert( + targetRemote?.objectSha && targetRemote.peeledSha, + `Remote release ${releaseTag} must be an existing annotated tag`, + ); + const localTagType = await run( + ['git', 'cat-file', '-t', `refs/tags/${releaseTag}`], + repositoryRoot, + ); + assert(localTagType === 'tag', `Local release ${releaseTag} must be an annotated tag`); + const releaseSha = await run( + ['git', 'rev-parse', `${releaseTag}^{commit}`], repositoryRoot, - true, + ); + assert( + targetRemote.peeledSha === releaseSha, + `Local ${releaseTag} resolves to ${releaseSha}, but ${remoteName} resolves to ${targetRemote.peeledSha}`, ); - if (remote) { - const remoteLines = remote.split('\n').filter(Boolean); - const remoteLine = remoteLines.find((line) => line.endsWith(`refs/tags/${majorTag}^{}`)) - ?? remoteLines.find((line) => line.endsWith(`refs/tags/${majorTag}`)); - const remoteSha = remoteLine?.split(/\s+/)[0]; - assert(remoteSha, `Could not parse remote ${majorTag} ref`); - const releaseTags = await run( - [ - 'git', - 'for-each-ref', - '--format=%(refname:strip=2)%09%(objectname)%09%(*objectname)', - `refs/tags/${majorTag}.*.*`, - ], + const movingRemote = (await remoteTags( + remoteName, + [`refs/tags/${majorTag}`, `refs/tags/${majorTag}^{}`], + repositoryRoot, + )).get(majorTag); + const remoteRefSha = movingRemote?.objectSha; + const remoteSha = movingRemote?.peeledSha ?? movingRemote?.objectSha; + if (options.remoteRefSha !== undefined) { + const capturedRemoteRefSha = options.remoteRefSha || undefined; + assert( + capturedRemoteRefSha === remoteRefSha, + `Observed remote ${majorTag} ref ${remoteRefSha ?? '(absent)'} does not match the captured ref ${options.remoteRefSha}`, + ); + } + const leaseRefSha = options.remoteRefSha ?? remoteRefSha; + let current: { tag: string; sha: string; version: StableVersion } | undefined; + + if (remoteSha) { + const releaseTags = await remoteTags( + remoteName, + [`refs/tags/${majorTag}.*.*`, `refs/tags/${majorTag}.*.*^{}`], repositoryRoot, - true, ); - const currentVersions = releaseTags - .split('\n') - .filter(Boolean) - .map((line) => { - const [tag, objectSha, peeledSha] = line.split('\t'); - if (!tag || !objectSha || !/^v(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)$/.test(tag)) return undefined; - return { tag, sha: peeledSha || objectSha, version: parseStableVersionTag(tag) }; + const currentVersions = [...releaseTags.entries()] + .map(([tag, remoteTag]) => { + if (!remoteTag.objectSha || !remoteTag.peeledSha) return undefined; + if (!/^v(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)$/.test(tag)) return undefined; + return { tag, sha: remoteTag.peeledSha, version: parseStableVersionTag(tag) }; }) .filter((candidate) => candidate !== undefined) .filter(({ sha, version }) => sha === remoteSha && version.major === releaseVersion.major) .sort((left, right) => compareStableVersions(right.version, left.version)); - const current = currentVersions[0]; - assert(current, `Remote ${majorTag} does not point at an immutable ${majorTag}.x.y release tag`); - if (compareStableVersions(releaseVersion, current.version) <= 0) { - console.log(`release-action-tag: ${majorTag} remains at ${current.tag}; ${releaseTag} is not newer`); - return false; + current = currentVersions[0]; + if (!options.recovery) { + assert(current, `Remote ${majorTag} does not point at an immutable ${majorTag}.x.y release tag`); + if (releaseSha !== remoteSha && compareStableVersions(releaseVersion, current.version) <= 0) { + console.log(`release-action-tag: ${majorTag} remains at ${current.tag}; ${releaseTag} is not newer`); + return { + changed: false, + majorTag, + previousReleaseTag: current.tag, + previousSha: remoteSha, + targetReleaseTag: releaseTag, + targetSha: releaseSha, + }; + } + } + if (releaseSha === remoteSha) { + console.log(`release-action-tag: ${majorTag} remains at ${current?.tag ?? releaseTag} (${remoteSha})`); + return { + changed: false, + majorTag, + previousReleaseTag: current?.tag, + previousSha: remoteSha, + targetReleaseTag: releaseTag, + targetSha: releaseSha, + }; } } - const releaseSha = await run(['git', 'rev-list', '-n', '1', releaseTag], repositoryRoot); // `-c tag.gpgSign=false` rather than a bare `git tag`: the moving major tag is // a pointer, not a release artifact, and it is created unannotated so it peels // to the commit directly. A developer with `tag.gpgSign = true` in their global @@ -109,16 +190,63 @@ export async function updateActionTag( // rather than misconfigured. await run(['git', '-c', 'tag.gpgSign=false', 'tag', '--force', majorTag, releaseSha], repositoryRoot, true); await run( - ['git', 'push', '--force', remoteName, `refs/tags/${majorTag}`], + [ + 'git', + 'push', + `--force-with-lease=refs/tags/${majorTag}:${leaseRefSha ?? ''}`, + remoteName, + `refs/tags/${majorTag}`, + ], repositoryRoot, true, ); - console.log(`release-action-tag: moved ${majorTag} to ${releaseTag} (${releaseSha})`); - return true; + const previous = current && remoteSha ? ` from ${current.tag} (${remoteSha})` : ''; + const mode = options.recovery ? 'recovered' : 'moved'; + console.log(`release-action-tag: ${mode} ${majorTag}${previous} to ${releaseTag} (${releaseSha})`); + return { + changed: true, + majorTag, + previousReleaseTag: current?.tag, + previousSha: remoteSha, + targetReleaseTag: releaseTag, + targetSha: releaseSha, + }; +} + +export async function updateActionTag( + releaseTag: string, + options: { repositoryRoot?: string; remote?: string } = {}, +): Promise { + return (await moveActionTag(releaseTag, { ...options, recovery: false })).changed; +} + +export async function recoverActionTag( + releaseTag: string, + options: { repositoryRoot?: string; remote?: string; remoteRefSha?: string } = {}, +): Promise { + return (await moveActionTag(releaseTag, { ...options, recovery: true })).changed; } if (import.meta.main) { - const [releaseTag, ...extra] = Bun.argv.slice(2); - assert(releaseTag && extra.length === 0, 'Usage: bun scripts/update-action-tag.ts vMAJOR.MINOR.PATCH'); - await updateActionTag(releaseTag); + const args = Bun.argv.slice(2); + const recovery = args[0] === '--recover'; + const candidateArgs = recovery ? args.slice(1) : args; + const leaseFlag = '--expected-remote-ref-sha'; + const leaseIndex = candidateArgs.indexOf(leaseFlag); + const rawRemoteRefSha = leaseIndex >= 0 ? candidateArgs[leaseIndex + 1] : undefined; + const remoteRefSha = leaseIndex >= 0 ? rawRemoteRefSha : undefined; + const releaseArgs = leaseIndex >= 0 + ? candidateArgs.filter((_, index) => index !== leaseIndex && index !== leaseIndex + 1) + : candidateArgs; + const [releaseTag, ...extra] = releaseArgs; + assert( + releaseTag && extra.length === 0 && + (leaseIndex < 0 || recovery) && + (leaseIndex < 0 || rawRemoteRefSha !== undefined && + (rawRemoteRefSha === '' || /^[0-9a-f]{40}$/.test(rawRemoteRefSha))), + 'Usage: bun scripts/update-action-tag.ts [--recover] vMAJOR.MINOR.PATCH ' + + '[--expected-remote-ref-sha SHA]', + ); + if (recovery) await recoverActionTag(releaseTag, { remoteRefSha }); + else await updateActionTag(releaseTag); }