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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 25 additions & 4 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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
Expand All @@ -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
Expand All @@ -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
Expand All @@ -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"
10 changes: 7 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<checkout>/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 `<checkout>/dist/claude-code` as the marketplace.


## Install (other hosts)
Expand Down
47 changes: 30 additions & 17 deletions scripts/publish-tag.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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
Expand All @@ -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
Expand Down
108 changes: 104 additions & 4 deletions test/release.test.js
Original file line number Diff line number Diff line change
@@ -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';

Expand Down Expand Up @@ -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/);
Expand Down Expand Up @@ -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');
Expand All @@ -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');
Expand All @@ -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);
});
}
Loading