diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 7199dd2..bcb525a 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -53,7 +53,7 @@ jobs: - run: npm run pack windows-kimi: - if: github.event_name == 'pull_request' || github.event_name == 'workflow_dispatch' + if: startsWith(github.ref, 'refs/tags/v') || github.event_name == 'pull_request' || github.event_name == 'workflow_dispatch' runs-on: windows-latest timeout-minutes: 20 strategy: @@ -75,7 +75,7 @@ jobs: # Grok and Qoder through their marketplaces, Codex at --ref, Antigravity, OpenCode and DeepSeek Harness from a # clone), moves one commit on, with the tag re-pointed there. A push made with this workflow's token starts no run. publish: - needs: [test, windows-minimax] + needs: [test, windows-minimax, windows-kimi] if: startsWith(github.ref, 'refs/tags/v') runs-on: [self-hosted, Linux, X64, evox-gcp] timeout-minutes: 10 @@ -94,7 +94,9 @@ jobs: registry-url: https://registry.npmjs.org - run: npm ci - run: node scripts/pack.mjs all dist "--version=$GITHUB_REF_NAME" - - run: scripts/publish-tag.sh "$GITHUB_REF_NAME" release + - name: Publish Git bundle + id: git_release + run: scripts/publish-tag.sh "$GITHUB_REF_NAME" release env: GIT_AUTHOR_NAME: github-actions[bot] GIT_AUTHOR_EMAIL: 41898282+github-actions[bot]@users.noreply.github.com @@ -103,6 +105,7 @@ jobs: # npm refuses a version it already holds, so a re-run of a released tag skips the publish. Provenance needs a # GitHub-hosted runner, and this job runs on the org's own. - name: Publish @evomap/dsh-evolver to npm + id: npm_release run: | version="${GITHUB_REF_NAME#v}" dist_tag=latest @@ -114,6 +117,24 @@ jobs: fi env: NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} - - run: gh release view "$GITHUB_REF_NAME" > /dev/null 2>&1 || gh release create "$GITHUB_REF_NAME" --verify-tag --title "$GITHUB_REF_NAME" --generate-notes + - name: Publish GitHub Release + id: github_release + run: gh release view "$GITHUB_REF_NAME" > /dev/null 2>&1 || gh release create "$GITHUB_REF_NAME" --verify-tag --title "$GITHUB_REF_NAME" --generate-notes env: GH_TOKEN: ${{ github.token }} + - name: Summarize publication + if: always() + env: + RELEASE_TAG: ${{ github.ref_name }} + GIT_RESULT: ${{ steps.git_release.outcome }} + NPM_RESULT: ${{ steps.npm_release.outcome }} + GITHUB_RESULT: ${{ steps.github_release.outcome }} + run: | + { + printf '## Release %s\n\n' "$RELEASE_TAG" + printf '| Channel | Result |\n| --- | --- |\n' + printf '| Git bundle | %s |\n' "${GIT_RESULT:-skipped}" + printf '| npm package | %s |\n' "${NPM_RESULT:-skipped}" + printf '| GitHub Release | %s |\n\n' "${GITHUB_RESULT:-skipped}" + printf 'Published stages remain available. Fix the failure and rerun the failed workflow jobs to finish this tag.\n' + } >> "$GITHUB_STEP_SUMMARY" diff --git a/README.md b/README.md index f771713..b793a33 100644 --- a/README.md +++ b/README.md @@ -84,9 +84,13 @@ claude plugin marketplace update evolver-agent-plugin && claude plugin update ev Claude Code updates an installed plugin only when its version changes. A release is one tag on `main`, `git tag vX.Y.Z && git push origin vX.Y.Z`, with no file to edit: CI tests, packs with that version stamped into -every host's manifest, moves `release` one commit on, points the tag there and opens a GitHub Release. A failed -release changes nothing; delete the tag, fix, and push it again. To -try a local build, `npm run pack` and add `/dist/claude-code` as the marketplace. +every host's manifest, moves `release` one commit on, points the tag there, publishes the dsh npm package and opens +a GitHub Release. These are separate publication stages. The publish job's summary reports each stage's result, and +completed stages remain available if a later stage fails. Fix the reported failure and rerun the failed workflow +jobs on the same tag while it is the current release. The Git stage reuses its published commit; npm and GitHub +skip releases they already hold. A newer release stops older tags from resuming and replacing npm latest. +A source fix for an npm version already published needs a new version. To try a local build, `npm run pack` and +add `/dist/claude-code` as the marketplace. ## Install (other hosts) diff --git a/scripts/publish-tag.sh b/scripts/publish-tag.sh index d25a4d2..9c3ca58 100755 --- a/scripts/publish-tag.sh +++ b/scripts/publish-tag.sh @@ -6,8 +6,9 @@ # node scripts/pack.mjs all dist --version=vX.Y.Z; the branch defaults to release) # Publishes the tag a maintainer pushed on main: one commit more on the release branch, the tagged source plus the # packed dist/, and the tag re-pointed at it, in one atomic push. The hosts install from that branch, so nothing on -# main names a version. A tag already published is left alone, a tag off main or older than the release is refused, -# and the push only replaces the tag the maintainer pushed. +# main names a version. A tag already published is left alone while it is the newest release, whether run from its +# source or its published commit; a tag off main or older than the release is refused, and the push only replaces +# the tag the maintainer pushed. set -euo pipefail @@ -25,18 +26,9 @@ if [ ! -d dist ]; then exit 1 fi -if git cat-file -e "$source_commit:dist" 2>/dev/null; then - echo "$tag already holds dist/: it is published." - exit 0 -fi - -git fetch --quiet origin main -if ! git merge-base --is-ancestor "$source_commit" FETCH_HEAD; then - echo "$tag points at $source_commit, which is not on main: only main is released." >&2 - exit 1 -fi - pushed="$(git ls-remote --exit-code origin "refs/tags/$tag" | cut -f1)" +git fetch --quiet origin "refs/tags/$tag" +published="$(git rev-parse 'FETCH_HEAD^{commit}')" parents=() listed=0 @@ -45,16 +37,37 @@ if [ "$listed" -eq 0 ]; then git fetch --quiet origin "refs/heads/$branch" previous="$(git rev-parse FETCH_HEAD)" previous_source="$(git log -1 --format=%B "$previous" | sed -n 's/^main at \([0-9a-f]\{40\}\).*/\1/p')" - if [ -n "$previous_source" ] && [ "$previous_source" != "$source_commit" ] && git merge-base --is-ancestor "$source_commit" "$previous_source"; then - echo "$tag is older than what $branch holds ($previous_source): a release only moves forward." >&2 - exit 1 - fi parents=(-p "$previous") elif [ "$listed" -ne 2 ]; then echo "Could not list origin's branches (git exit $listed)." >&2 exit "$listed" fi +# A retry must not make an older tag the npm latest after a newer Git release. +if git cat-file -e "$published:dist" 2>/dev/null; then + if [ "$listed" -eq 0 ] && [ "$published" != "$previous" ]; then + echo "$tag is older than what $branch holds ($previous): a release only moves forward." >&2 + exit 1 + fi + published_source="$(git log -1 --format=%B "$published" | sed -n 's/^main at \([0-9a-f]\{40\}\).*/\1/p')" + if [ "$source_commit" != "$published" ] && [ "$source_commit" != "$published_source" ]; then + echo "$tag already publishes $published_source; it cannot publish $source_commit." >&2 + exit 1 + fi + echo "$tag already holds dist/: it is published." + exit 0 +fi + +git fetch --quiet origin main +if ! git merge-base --is-ancestor "$source_commit" FETCH_HEAD; then + echo "$tag points at $source_commit, which is not on main: only main is released." >&2 + exit 1 +fi +if [ "$listed" -eq 0 ] && [ -n "$previous_source" ] && [ "$previous_source" != "$source_commit" ] && git merge-base --is-ancestor "$source_commit" "$previous_source"; then + echo "$tag is older than what $branch holds ($previous_source): a release only moves forward." >&2 + exit 1 +fi + index="$(mktemp)" trap 'rm -f "$index"' EXIT GIT_INDEX_FILE="$index" git read-tree HEAD diff --git a/test/release.test.js b/test/release.test.js index 6e3f995..04a94df 100644 --- a/test/release.test.js +++ b/test/release.test.js @@ -1,8 +1,8 @@ import assert from 'node:assert/strict'; import { execFileSync } from 'node:child_process'; -import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; +import { copyFileSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; import { tmpdir } from 'node:os'; -import { join } from 'node:path'; +import { delimiter, join } from 'node:path'; import { test } from 'node:test'; import { fileURLToPath } from 'node:url'; @@ -32,9 +32,9 @@ test('packing with a version stamps it into every manifest a packed host reads', test('only a pushed vX.Y.Z tag publishes, after the tests: pack at its version, publish, then the GitHub Release', () => { assert.match(workflow, /tags: \['v\*'\]/); - assert.match(workflow, /needs: \[test, windows-minimax\]\r?\n\s+if: startsWith\(github\.ref, 'refs\/tags\/v'\)/); + assert.match(workflow, /needs: \[test, windows-minimax, windows-kimi\]\r?\n\s+if: startsWith\(github\.ref, 'refs\/tags\/v'\)/); const kimi = workflow.slice(workflow.indexOf(' windows-kimi:'), workflow.indexOf(' publish:')); - assert.match(kimi, /if: github\.event_name == 'pull_request' \|\| github\.event_name == 'workflow_dispatch'/); + assert.match(kimi, /if: startsWith\(github\.ref, 'refs\/tags\/v'\) \|\| github\.event_name == 'pull_request' \|\| github\.event_name == 'workflow_dispatch'/); assert.match(kimi, /runs-on: windows-latest/); assert.match(kimi, /node: \[22, 26\]/); assert.match(kimi, /run: npm run test:kimi/); @@ -88,7 +88,11 @@ test('a tag on main moves the release branch one commit on and points the tag th assert.match(git(origin, 'log', '-1', '--format=%B', released), new RegExp(`main at ${first}`)); assert.equal(git(origin, 'rev-parse', 'refs/heads/main'), first, 'main is untouched'); + assert.match(publishTag(work, 'v1.0.0'), /already holds dist\/: it is published/); + assert.equal(git(origin, 'rev-parse', 'refs/heads/release'), released, 'retrying the original source preserves the Git stage'); + const second = commitOnMain(work, 'two'); + assert.throws(() => publishTag(work, 'v1.0.0'), /already publishes.*cannot publish/); tagAndPack(work, 'v1.0.1'); publishTag(work, 'v1.0.1'); const next = git(origin, 'rev-parse', 'refs/heads/release'); @@ -102,6 +106,23 @@ test('a tag on main moves the release branch one commit on and points the tag th assert.equal(git(origin, 'rev-parse', 'refs/heads/release'), next, 'a re-run moves nothing'); }); +test('an older published tag cannot resume after a newer release, from its source or its packed checkout', posix, (t) => { + const { origin, work } = releaseRepo(t); + commitOnMain(work, 'one source'); + tagAndPack(work, 'v1.0.0'); + publishTag(work, 'v1.0.0'); + const older = git(origin, 'rev-parse', 'refs/heads/release'); + tagAndPack(work, 'v1.0.1'); + publishTag(work, 'v1.0.1'); + const current = git(origin, 'rev-parse', 'refs/heads/release'); + assert.throws(() => publishTag(work, 'v1.0.0'), /older than what release holds/); + rmSync(join(work, 'dist'), { recursive: true }); + git(work, 'checkout', '--quiet', older); + assert.throws(() => publishTag(work, 'v1.0.0'), /older than what release holds/); + assert.equal(git(origin, 'rev-parse', 'refs/heads/release'), current); + assert.equal(git(origin, 'rev-parse', 'v1.0.0^{commit}'), older); +}); + test('a tag off main, older than the release, or not vX.Y.Z is refused and moves nothing', posix, (t) => { const { origin, work } = releaseRepo(t); const old = commitOnMain(work, 'old'); @@ -122,3 +143,82 @@ test('a tag off main, older than the release, or not vX.Y.Z is refused and moves assert.throws(() => publishTag(work, 'nightly'), /not a release tag/); assert.equal(git(origin, 'rev-parse', 'refs/heads/release'), released); }); + +function publicationRun(name) { + const block = workflow.split(` - name: ${name}\n`)[1]?.split('\n - ')[0]; + assert.ok(block, `publication step ${name} exists`); + const afterRun = block.split('\n run: ')[1]; + assert.ok(afterRun, `publication step ${name} runs a command`); + const [first, ...rest] = afterRun.split('\n'); + if (first !== '|') return first; + const lines = []; + for (const line of rest) { + if (!line.startsWith(' ')) break; + lines.push(line.slice(10)); + } + return lines.join('\n'); +} + +for (const failedStage of ['npm', 'github']) { + test(`a release interrupted at ${failedStage} reports partial publication and resumes its completed stages`, posix, (t) => { + const { origin, work } = releaseRepo(t); + const source = commitOnMain(work, 'release fixture'); + tagAndPack(work, 'v1.0.0'); + mkdirSync(join(work, 'scripts')); + copyFileSync(PUBLISH, join(work, 'scripts', 'publish-tag.sh')); + const bin = join(work, 'stub-bin'); + mkdirSync(bin); + writeFileSync(join(bin, 'npm'), `#!/usr/bin/env bash +case "$1" in + view) test -f "$RELEASE_TEST_ROOT/npm-published";; + publish) + echo npm >> "$RELEASE_TEST_ROOT/attempts" + [ "$FAIL_STAGE" != npm ] || exit 1 + touch "$RELEASE_TEST_ROOT/npm-published";; + *) exit 1;; +esac +`, { mode: 0o755 }); + writeFileSync(join(bin, 'gh'), `#!/usr/bin/env bash +case "$1 $2" in + 'release view') test -f "$RELEASE_TEST_ROOT/github-published";; + 'release create') + echo github >> "$RELEASE_TEST_ROOT/attempts" + [ "$FAIL_STAGE" != github ] || exit 1 + touch "$RELEASE_TEST_ROOT/github-published";; + *) exit 1;; +esac +`, { mode: 0o755 }); + const summary = join(work, 'summary.md'); + const env = { ...ENV, PATH: `${bin}${delimiter}${process.env.PATH}`, RELEASE_TEST_ROOT: work, GITHUB_REF_NAME: 'v1.0.0', GITHUB_STEP_SUMMARY: summary, RELEASE_TAG: 'v1.0.0' }; + const stages = [['GIT_RESULT', 'Publish Git bundle'], ['NPM_RESULT', 'Publish @evomap/dsh-evolver to npm'], ['GITHUB_RESULT', 'Publish GitHub Release']]; + const run = (failure = '') => { + const outcomes = { GIT_RESULT: 'skipped', NPM_RESULT: 'skipped', GITHUB_RESULT: 'skipped' }; + for (const [key, name] of stages) { + try { + execFileSync('bash', ['-e', '-o', 'pipefail', '-c', publicationRun(name)], { cwd: work, env: { ...env, FAIL_STAGE: failure }, stdio: 'pipe' }); + outcomes[key] = 'success'; + } catch { + outcomes[key] = 'failure'; + break; + } + } + execFileSync('bash', ['-e', '-c', publicationRun('Summarize publication')], { cwd: work, env: { ...env, ...outcomes } }); + return Object.values(outcomes); + }; + assert.match(workflow, /name: Summarize publication\n\s+if: always\(\)/); + assert.deepEqual(run(failedStage), failedStage === 'npm' ? ['success', 'failure', 'skipped'] : ['success', 'success', 'failure']); + const released = git(origin, 'rev-parse', 'refs/heads/release'); + assert.equal(git(origin, 'rev-parse', 'v1.0.0^{commit}'), released); + assert.equal(git(work, 'rev-parse', 'HEAD'), source, 'the rerun starts at the original source SHA'); + const report = readFileSync(summary, 'utf8'); + assert.match(report, /Git bundle \| success/); + assert.match(report, failedStage === 'npm' ? /npm package \| failure/ : /GitHub Release \| failure/); + assert.match(report, /rerun the failed workflow jobs/); + assert.deepEqual(run(), ['success', 'success', 'success']); + assert.equal(git(origin, 'rev-parse', 'refs/heads/release'), released, 'the published Git commit is retained'); + const attempts = readFileSync(join(work, 'attempts'), 'utf8'); + assert.deepEqual(run(), ['success', 'success', 'success']); + assert.equal(readFileSync(join(work, 'attempts'), 'utf8'), attempts, 'existing npm and GitHub releases are reused'); + assert.equal(git(origin, 'rev-parse', 'refs/heads/release'), released); + }); +}