From 7c858fb52da593a66a9617b99cbe6d05ec8a1366 Mon Sep 17 00:00:00 2001 From: Duc Duong Date: Mon, 14 Sep 2026 13:36:33 +0700 Subject: [PATCH 1/4] Add a tag-driven release process Releases were cut by hand: a version bump commit, an npm publish from a laptop, no tag, and no release page. Nothing tied the published tarball to a commit that had passed CI. A push of a `v*` tag now runs release.yml, which refuses the tag unless it is an ancestor of main and unless package.json matches it, runs the suite on real Chromium, publishes with npm trusted publishing (OIDC, no stored token), and creates the release page from the merged PRs. The plugin manifest carries its own version that `npm version` does not touch, and it had already drifted to 0.1.0 against a published 0.1.1. A `version` lifecycle script syncs it into the same commit, and a test asserts the two agree so the drift surfaces on a PR rather than on a tag push. --- .claude-plugin/plugin.json | 2 +- .github/workflows/release.yml | 203 ++++++++++++++++++++++++++++++++ RELEASING.md | 68 +++++++++++ package.json | 3 +- scripts/sync-plugin-version.mjs | 29 +++++ test/version-sync.test.mjs | 15 +++ 6 files changed, 318 insertions(+), 2 deletions(-) create mode 100644 .github/workflows/release.yml create mode 100644 RELEASING.md create mode 100644 scripts/sync-plugin-version.mjs create mode 100644 test/version-sync.test.mjs diff --git a/.claude-plugin/plugin.json b/.claude-plugin/plugin.json index b9a2b65..4833258 100644 --- a/.claude-plugin/plugin.json +++ b/.claude-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "verdict", - "version": "0.1.0", + "version": "0.1.1", "description": "Token-efficient browser verification for AI coding agents. Persistent headless Chromium with ARIA refs, CSS inspection, auth profiles. ~50-100 tokens per call vs ~1500 for MCP tools. Use for all browser verification, page testing, form filling, CSS debugging, responsive testing, and authenticated page access.", "author": { "name": "Tuan Duong", diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..aeca953 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,203 @@ +name: Release + +# A tag is the trigger, not a manual dispatch: the thing being released has to be a commit +# that already went through PR CI on main, and a tag is the only artifact that names one +# immutably. Nothing here writes to main, so the read-only token posture of the other +# workflows survives. +on: + push: + tags: ['v*'] + +permissions: + contents: read + +jobs: + # Everything that can say "do not ship this" runs before any job holding a publish + # credential. A failure here costs a deleted tag; a failure after publish costs a + # version number that can never be reused. + verify: + runs-on: ubuntu-latest + outputs: + version: ${{ steps.version.outputs.version }} + published: ${{ steps.published.outputs.exists }} + + steps: + # Pinned to a commit, not a tag: a tag is mutable. Dependabot keeps the pin and its + # comment current. fetch-depth 0 because the ancestry check below needs main's history. + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + fetch-depth: 0 + persist-credentials: false + + # A tag can be pushed from any commit, including one that never opened a PR. Refusing + # a tag that is not an ancestor of main is what makes "released code passed CI" true + # rather than merely likely. + - name: Require the tag to be on main + run: | + git fetch --no-tags origin +refs/heads/main:refs/remotes/origin/main + if ! git merge-base --is-ancestor "$GITHUB_SHA" origin/main; then + echo "::error::$GITHUB_REF_NAME points at a commit that is not on main" + exit 1 + fi + + # package.json is what npm publishes; the tag is what humans and the GitHub release + # page show. When they disagree the registry and the changelog describe different + # code, and nothing downstream ever notices. + - name: Require package.json to match the tag + id: version + run: | + version=$(node -p "require('./package.json').version") + echo "version=$version" >> "$GITHUB_OUTPUT" + if [ "$GITHUB_REF_NAME" != "v$version" ]; then + echo "::error::tag $GITHUB_REF_NAME does not match package.json version $version" + exit 1 + fi + + # The plugin manifest carries its own version and npm version does not touch it, so it + # drifted silently once already (0.1.0 against a published 0.1.1). The `version` npm + # script keeps them in step; this is the check that proves the script ran. + - name: Require the plugin manifest to match + run: | + plugin=$(node -p "require('./.claude-plugin/plugin.json').version") + if [ "$plugin" != "${{ steps.version.outputs.version }}" ]; then + echo "::error::.claude-plugin/plugin.json is $plugin, package.json is ${{ steps.version.outputs.version }}" + exit 1 + fi + + # A workflow gets re-run, and a second publish of a live version is a hard 403. + # Checking first turns that into a skip, so a rerun after a flake is safe. + - name: Check whether this version is already on npm + id: published + run: | + if [ -n "$(npm view "verdict-cli@${{ steps.version.outputs.version }}" version 2>/dev/null)" ]; then + echo "::notice::verdict-cli@${{ steps.version.outputs.version }} is already published; publish will be skipped" + echo "exists=true" >> "$GITHUB_OUTPUT" + else + echo "exists=false" >> "$GITHUB_OUTPUT" + fi + + - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + with: + node-version: '22' + cache: npm + + # Same shape as pr-ci.yml: --ignore-scripts keeps the Chromium download out of npm ci + # so it can be cached on its own. + - name: Install dependencies + run: npm ci --ignore-scripts + + - name: Resolve the Playwright version + id: pw + run: echo "version=$(node -p "require('./node_modules/playwright/package.json').version")" >> "$GITHUB_OUTPUT" + + - name: Cache Chromium + id: chromium-cache + uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 + with: + path: ~/.cache/ms-playwright + key: chromium-${{ runner.os }}-${{ steps.pw.outputs.version }} + + - name: Install Chromium + if: steps.chromium-cache.outputs.cache-hit != 'true' + run: npx playwright install --with-deps chromium + + # A cache hit restores the browser but not the OS packages it links against. + - name: Install Chromium system dependencies + if: steps.chromium-cache.outputs.cache-hit == 'true' + run: npx playwright install-deps chromium + + # The browser tests skip themselves when Chromium is missing, so a suite that skipped + # everything would pass silently and prove nothing. Fail on any skip. + - name: Run tests + run: | + set -o pipefail + npm test 2>&1 | tee result.txt + if grep -qE 'skipped [1-9]' result.txt; then + echo "::error::tests were skipped; Chromium did not install" + exit 1 + fi + + # Catches the packaging mistakes that only appear in the tarball: a file missing from + # the `files` allowlist, or a secret swept in by adding one. Cheap, and the only place + # this is visible before it is on the registry forever. + - name: Show what would be published + run: npm pack --dry-run + + publish: + needs: verify + if: needs.verify.outputs.published == 'false' + runs-on: ubuntu-latest + + # The only job that holds a publish credential, and it holds nothing else. id-token is + # for npm trusted publishing (OIDC): no NPM_TOKEN is stored in this repo, so there is no + # long-lived secret to leak, and provenance is attested to this workflow and tag. + permissions: + contents: read + id-token: write + + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + + # Node 24 for its bundled npm 11: trusted publishing needs npm >= 11.5.1, and Node 22 + # still ships npm 10. This pin is about the npm version, not the runtime the package + # supports - the published code still targets the engines range in package.json. + - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + with: + node-version: '24' + registry-url: https://registry.npmjs.org + cache: npm + + # No npm ci. There is no prepack or prepublishOnly script, so publishing needs no + # node_modules - and not installing them means no dependency code runs at all in the + # job that holds the OIDC token. + + # setup-node picks the newest 24.x, but the pin itself guarantees only Node 24.0, + # which shipped npm 11.3. Assert the floor rather than assume it; failing here is a + # clear message, whereas an npm below it falls back to token auth and 401s. + - name: Require an npm that supports trusted publishing + run: | + npm_version=$(npm --version) + if [ "$(printf '%s\n11.5.1\n' "$npm_version" | sort -V | head -1)" != "11.5.1" ]; then + echo "::error::npm $npm_version is below the 11.5.1 trusted-publishing floor" + exit 1 + fi + + - name: Publish to npm + run: npm publish --provenance --access public + + github-release: + needs: [verify, publish] + # always() so a version that was already on npm still gets its release page, and a + # rerun after a failure here does not require re-publishing. + if: | + always() && needs.verify.result == 'success' && + (needs.publish.result == 'success' || needs.publish.result == 'skipped') + runs-on: ubuntu-latest + + permissions: + contents: write # required to create the release + + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + fetch-depth: 0 + persist-credentials: false + + # gh rather than a third-party action, matching dependabot-auto-merge.yml: one less + # supply-chain edge in a job that holds a write token. --verify-tag refuses to invent + # a tag if the ref is somehow gone; --generate-notes writes the notes from the merged + # PRs, which is why no CHANGELOG file is proposed here. + - name: Create the GitHub release + run: | + if gh release view "$GITHUB_REF_NAME" >/dev/null 2>&1; then + echo "::notice::release $GITHUB_REF_NAME already exists" + exit 0 + fi + gh release create "$GITHUB_REF_NAME" \ + --verify-tag \ + --title "$GITHUB_REF_NAME" \ + --generate-notes + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/RELEASING.md b/RELEASING.md new file mode 100644 index 0000000..75b7e66 --- /dev/null +++ b/RELEASING.md @@ -0,0 +1,68 @@ +# Releasing + +Cutting a release is two commands. Everything after the tag push is automated by +`.github/workflows/release.yml`. + +```bash +git checkout main && git pull +npm version patch # or minor / major +git push origin main --follow-tags +``` + +`npm version` bumps `package.json`, runs the `version` script to copy the new number into +`.claude-plugin/plugin.json`, commits both, and creates the `vX.Y.Z` tag. The tag push +starts the release workflow. + +## What the workflow does + +1. **verify** - refuses the tag unless it is an ancestor of `main` (so the code went + through PR CI), unless `package.json` matches the tag, and unless the plugin manifest + matches `package.json`. Then it runs the full suite on real Chromium and prints + `npm pack --dry-run` so the tarball contents are visible before they are permanent. +2. **publish** - `npm publish --provenance`. Skipped when that version is already on the + registry, so a rerun after a flake is safe. +3. **github-release** - creates the release page with notes generated from the merged + PRs. Runs even when publish was skipped. + +A failure in **verify** costs a deleted tag: + +```bash +git tag -d v0.2.0 && git push origin :refs/tags/v0.2.0 +``` + +Once **publish** has succeeded the version number is spent - npm does not allow +re-publishing one. Fix forward with the next patch. + +## One-time setup + +The workflow publishes with npm **trusted publishing** (OIDC), so this repo stores no npm +token. Before the first release, a package maintainer must register the publisher on +npmjs.com: + +- Package `verdict-cli` → Settings → Trusted publisher +- Organization/user `tuandm`, repository `verdict-cli` +- Workflow filename `release.yml`, environment blank + +Until that exists the publish step fails authentication. Nothing else in the repo needs a +secret. + +## Versioning + +Semver against the CLI's observable surface: command names, flags, output shape, and the +`VERDICT_*` environment variables. Agents parse this output, so a changed field name is a +break even when no code signature moved. + +- **patch** - fixes that leave the surface alone +- **minor** - new commands or flags +- **major** - a removed or renamed command, flag, output field, or env var + +## Backfilling tags for 0.1.0 and 0.1.1 + +Both are on npm but neither has a git tag, so neither has a release page. A tag push runs +the workflow that exists **at the tagged commit**, and neither of those commits contains +one, so backfilled tags do nothing - they are safe, and they also publish nothing. Create +those pages by hand if you want them: + +```bash +gh release create v0.1.1 --target 883f5bb --generate-notes +``` diff --git a/package.json b/package.json index 552beda..629fb74 100644 --- a/package.json +++ b/package.json @@ -48,6 +48,7 @@ "scripts": { "postinstall": "npx playwright install chromium", "start": "node bin/browse.mjs", - "test": "node --test test/*.test.mjs" + "test": "node --test test/*.test.mjs", + "version": "node scripts/sync-plugin-version.mjs" } } diff --git a/scripts/sync-plugin-version.mjs b/scripts/sync-plugin-version.mjs new file mode 100644 index 0000000..72b66f8 --- /dev/null +++ b/scripts/sync-plugin-version.mjs @@ -0,0 +1,29 @@ +// Copies package.json's version into .claude-plugin/plugin.json. +// +// Two manifests describe the same artifact and only one of them is a package.json, so +// `npm version` moves one and leaves the other behind - which is how the plugin manifest +// came to read 0.1.0 against a published 0.1.1. Wired as the `version` npm lifecycle +// script, this runs after the bump and before the commit, and the `git add` puts the +// manifest in the same commit as package.json rather than a follow-up nobody makes. +import { readFileSync, writeFileSync } from 'node:fs'; +import { execFileSync } from 'node:child_process'; +import { dirname, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..'); +const MANIFEST = resolve(ROOT, '.claude-plugin', 'plugin.json'); + +const { version } = JSON.parse(readFileSync(resolve(ROOT, 'package.json'), 'utf8')); +const raw = readFileSync(MANIFEST, 'utf8'); + +// A textual replace of the one line, not JSON.stringify of a parsed object: the manifest +// is hand-maintained and keeps its keyword lines packed several to a row. Reserialising +// would reformat the whole file and bury the version change in the diff. +const next = raw.replace(/("version"\s*:\s*")[^"]*(")/, `$1${version}$2`); +if (next === raw && !raw.includes(`"version": "${version}"`)) { + throw new Error(`no version field found in ${MANIFEST}`); +} + +writeFileSync(MANIFEST, next); +execFileSync('git', ['add', MANIFEST], { cwd: ROOT, stdio: 'inherit' }); +console.log(`plugin.json version -> ${version}`); diff --git a/test/version-sync.test.mjs b/test/version-sync.test.mjs new file mode 100644 index 0000000..3d9e54c --- /dev/null +++ b/test/version-sync.test.mjs @@ -0,0 +1,15 @@ +// The two manifests must agree. Checked here rather than only in the release workflow so +// the drift is caught on the PR that introduces it, when the fix is one line, instead of +// on a tag push where the fix is a deleted tag and a re-release. +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; +import { dirname, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..'); +const read = p => JSON.parse(readFileSync(resolve(ROOT, p), 'utf8')); + +test('plugin.json version matches package.json', () => { + assert.equal(read('.claude-plugin/plugin.json').version, read('package.json').version); +}); From 03fceb7786c2e304a43c671aaa842450a029f83e Mon Sep 17 00:00:00 2001 From: Duc Duong Date: Mon, 14 Sep 2026 14:04:18 +0700 Subject: [PATCH 2/4] Say what the version rules mean while the package is 0.x Semver lets a 0.x line ship additive changes as a patch, and the 0.1.x releases have done exactly that. Left as written, the rules would be contradicted by the first release cut under them. --- RELEASING.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/RELEASING.md b/RELEASING.md index 75b7e66..42f67fb 100644 --- a/RELEASING.md +++ b/RELEASING.md @@ -56,6 +56,10 @@ break even when no code signature moved. - **minor** - new commands or flags - **major** - a removed or renamed command, flag, output field, or env var +**While the package is pre-1.0** those rules shift down one: additive changes ship as a +patch and a minor marks a break, which is what semver permits for 0.x and what the 0.1.x +line has done so far. The list above applies from 1.0. + ## Backfilling tags for 0.1.0 and 0.1.1 Both are on npm but neither has a git tag, so neither has a release page. A tag push runs From 67e81b9f16b969780e7560ab4ba0b31bcb1317eb Mon Sep 17 00:00:00 2001 From: Duc Duong Date: Mon, 14 Sep 2026 14:06:12 +0700 Subject: [PATCH 3/4] Correct two claims in RELEASING.md npm does allow unpublishing within 72 hours; it is the permanent block on reuse afterwards that makes fixing forward the only option. The advice was right, the reason given for it was not. The 0.x clause claimed minor marks a break. Semver says nothing of the kind for 0.x, and this package has not done that - 0.1.1 carried new flags and the BROWSE_* to VERDICT_* rename together, as a patch. --- RELEASING.md | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/RELEASING.md b/RELEASING.md index 42f67fb..9968fda 100644 --- a/RELEASING.md +++ b/RELEASING.md @@ -30,8 +30,9 @@ A failure in **verify** costs a deleted tag: git tag -d v0.2.0 && git push origin :refs/tags/v0.2.0 ``` -Once **publish** has succeeded the version number is spent - npm does not allow -re-publishing one. Fix forward with the next patch. +Once **publish** has succeeded the version number is spent. Unpublishing is possible +within 72 hours, but npm then blocks that version forever, so either way it is gone. Fix +forward with the next patch. ## One-time setup @@ -56,9 +57,9 @@ break even when no code signature moved. - **minor** - new commands or flags - **major** - a removed or renamed command, flag, output field, or env var -**While the package is pre-1.0** those rules shift down one: additive changes ship as a -patch and a minor marks a break, which is what semver permits for 0.x and what the 0.1.x -line has done so far. The list above applies from 1.0. +**While the package is pre-1.0**, expect both additive changes and small breaks to ship +as a patch - semver puts no constraint on 0.x, and 0.1.1 already shipped new flags +alongside the `BROWSE_*` to `VERDICT_*` rename. The list above applies from 1.0. ## Backfilling tags for 0.1.0 and 0.1.1 From 7894d07ac6019726091087be054b688f6663fc20 Mon Sep 17 00:00:00 2001 From: Duc Duong Date: Mon, 14 Sep 2026 14:54:25 +0700 Subject: [PATCH 4/4] Name the commit each published version came from The backfill section gave only 0.1.1's target, leaving 0.1.0's to be derived. Both now come from npm's own gitHead rather than from reading the log, which is the only source that actually knows what was published. Says outright that the backfill is optional and not a prerequisite for the next release, so nobody sequences work around it. --- RELEASING.md | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/RELEASING.md b/RELEASING.md index 9968fda..1515b19 100644 --- a/RELEASING.md +++ b/RELEASING.md @@ -65,9 +65,16 @@ alongside the `BROWSE_*` to `VERDICT_*` rename. The list above applies from 1.0. Both are on npm but neither has a git tag, so neither has a release page. A tag push runs the workflow that exists **at the tagged commit**, and neither of those commits contains -one, so backfilled tags do nothing - they are safe, and they also publish nothing. Create -those pages by hand if you want them: +one, so backfilled tags do nothing - they are safe, and they also publish nothing. + +The targets below are the commits npm actually published, read back with +`npm view verdict-cli@ gitHead`, not guessed from the log: ```bash +gh release create v0.1.0 --target d4f76bf --generate-notes gh release create v0.1.1 --target 883f5bb --generate-notes ``` + +Optional, and not a prerequisite for the next release: with no earlier tag, +`--generate-notes` falls back to a single Full Changelog link rather than an enumerated +list, and a release page can be deleted and recreated later if you change your mind.