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
2 changes: 1 addition & 1 deletion .claude-plugin/plugin.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
203 changes: 203 additions & 0 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
@@ -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 }}
80 changes: 80 additions & 0 deletions RELEASING.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
# 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. 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

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

**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

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.

The targets below are the commits npm actually published, read back with
`npm view verdict-cli@<version> 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.
3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
}
}
29 changes: 29 additions & 0 deletions scripts/sync-plugin-version.mjs
Original file line number Diff line number Diff line change
@@ -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}`);
15 changes: 15 additions & 0 deletions test/version-sync.test.mjs
Original file line number Diff line number Diff line change
@@ -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);
});