From dd03a9fb505f5d1c5168fe7243873c60f4abf568 Mon Sep 17 00:00:00 2001 From: Daniel Sticker Date: Sun, 27 Sep 2026 14:52:32 +0200 Subject: [PATCH 1/2] ci(release): Isolate trusted npm publishing --- .github/workflows/actionlint.yml | 25 ++ .github/workflows/release.yml | 216 ++++++++++++++-- CHANGELOG.md | 9 +- CONTRIBUTING.md | 6 +- package-lock.json | 23 +- package.json | 5 +- plugin/.claude-plugin/plugin.json | 2 +- plugin/skills/setup/SKILL.md | 2 +- scripts/pack-check.sh | 9 +- test/release-workflow.test.mjs | 400 ++++++++++++++++++++++++++++++ 10 files changed, 668 insertions(+), 29 deletions(-) create mode 100644 .github/workflows/actionlint.yml create mode 100644 test/release-workflow.test.mjs diff --git a/.github/workflows/actionlint.yml b/.github/workflows/actionlint.yml new file mode 100644 index 0000000..f81e746 --- /dev/null +++ b/.github/workflows/actionlint.yml @@ -0,0 +1,25 @@ +name: Actionlint + +on: + push: + paths: + - .github/workflows/** + pull_request: + paths: + - .github/workflows/** + +permissions: + contents: read + +jobs: + actionlint: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - uses: reviewdog/action-actionlint@6fb7acc99f4a1008869fa8a0f09cfca740837d9d # v1.72.0 + with: + reporter: github-annotations + filter_mode: nofilter + fail_level: error diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index e7f1773..439d798 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -2,39 +2,217 @@ name: Release on: push: - tags: - - "v*" + branches: [main] + pull_request: + paths: + - .github/workflows/release.yml + - .github/workflows/actionlint.yml + - scripts/pack-check.sh + - test/release-workflow.test.mjs + - package.json + - package-lock.json + - plugin/.claude-plugin/plugin.json + - plugin/skills/setup/SKILL.md + - CHANGELOG.md + workflow_dispatch: permissions: contents: read jobs: - publish: + verify: runs-on: macos-latest + timeout-minutes: 20 permissions: contents: read - id-token: write # npm provenance via OIDC trusted publishing steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 with: node-version: 24.21.0 - registry-url: https://registry.npmjs.org - cache: npm - - name: Ensure npm supports trusted publishing - run: npm install -g npm@^11.5.1 + package-manager-cache: false - run: npm ci - - run: npm run check - - run: npm run lint - - run: npm run format:check - - run: npm run verify:versions - - run: npm test - - name: Verify tag matches package version + - run: npm run verify + env: + BROWSERJACK_RELEASE_GATE_TEST: required + - run: npm run pack:check + env: + BROWSERJACK_ARTIFACT_DIR: ${{ runner.temp }}/browserjack-artifact + - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 + with: + name: browserjack-${{ github.sha }} + path: ${{ runner.temp }}/browserjack-artifact/* + if-no-files-found: error + retention-days: 30 + + release-gate: + needs: verify + if: >- + github.repository == 'stickerdaniel/browserjack' && + (github.event_name == 'push' || github.event_name == 'workflow_dispatch') && + github.ref == 'refs/heads/main' + runs-on: ubuntu-latest + timeout-minutes: 10 + permissions: + contents: read + outputs: + publish: ${{ steps.gate.outputs.publish }} + version: ${{ steps.gate.outputs.version }} + sha256: ${{ steps.gate.outputs.sha256 }} + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + with: + node-version: 24.21.0 + package-manager-cache: false + - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: browserjack-${{ github.sha }} + path: ${{ runner.temp }}/release + - id: gate + name: Check the tested tarball against main and npm + env: + RELEASE_DIR: ${{ runner.temp }}/release + run: | + set -euo pipefail + fail() { echo "::error::$1"; exit 1; } + + shopt -s nullglob + tarballs=("$RELEASE_DIR"/browserjack-*.tgz) + [ "${#tarballs[@]}" -eq 1 ] || fail "Expected one browserjack tarball, found ${#tarballs[@]}." + tarball="${tarballs[0]}" + + npm_modules="$(npm root -g)/npm/node_modules" + for module in tar pacote semver; do + [ -f "$npm_modules/$module/package.json" ] || fail "npm does not bundle $module." + done + TARBALL="$tarball" NPM_MODULES="$npm_modules" node --input-type=commonjs - <<'JS' || fail "Tarball entries are not safe regular package files." + const path = require('node:path'); + const tar = require(path.join(process.env.NPM_MODULES, 'tar')); + const required = new Set(['package/package.json', 'package/LICENSE', 'package/dist/cli.js', 'package/compatibility/manifest.json']); + (async () => { + const seen = new Set(); + await tar.t({ + file: process.env.TARBALL, + strict: true, + onwarn: (code, message) => { throw new Error(`${code}: ${message}`); }, + onReadEntry: (entry) => { + const name = entry.path; + if (entry.type !== 'File' || !name.startsWith('package/') || + name.split('/').some((segment) => !segment || segment === '.' || segment === '..') || + seen.has(name)) throw new Error(`Unsafe tarball entry: ${name} (${entry.type}).`); + seen.add(name); + } + }); + for (const name of required) if (!seen.has(name)) throw new Error(`Missing ${name}.`); + })().catch((error) => { console.error(`::error::${error.message}`); process.exitCode = 1; }); + JS + + manifest="$(TARBALL="$tarball" NPM_MODULES="$npm_modules" node --input-type=commonjs - <<'JS' + const { mkdtempSync, rmSync } = require('node:fs'); + const path = require('node:path'); + const pacote = require(path.join(process.env.NPM_MODULES, 'pacote')); + const cache = mkdtempSync(path.join(process.env.RUNNER_TEMP, 'browserjack-pacote-')); + pacote.manifest(path.resolve(process.env.TARBALL), { cache, fullMetadata: true, fullReadJson: true }) + .then(({ name, version, publishConfig }) => console.log(JSON.stringify({ name, version, publishConfig }))) + .catch((error) => { console.error(`::error::${error.message}`); process.exitCode = 1; }) + .finally(() => rmSync(cache, { recursive: true, force: true })); + JS + )" || fail "npm cannot read the tarball manifest." + name="$(node -p 'JSON.parse(process.argv[1]).name' "$manifest")" + version="$(node -p 'JSON.parse(process.argv[1]).version' "$manifest")" + source_version="$(node -p 'require(process.argv[1]).version' "$PWD/package.json")" + [ "$name" = browserjack ] || fail "Tarball package name is $name." + [[ "$version" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]] || fail "Not a stable release version: $version." + NPM_MODULES="$npm_modules" node -e 'const path=require("node:path");const semver=require(path.join(process.env.NPM_MODULES,"semver"));if(semver.valid(process.argv[1])!==process.argv[1])process.exit(1)' "$version" || fail "npm does not accept release version $version." + [ "$version" = "$source_version" ] || fail "Tarball version differs from the source version." + [ "$(basename "$tarball")" = "browserjack-$version.tgz" ] || fail "Tarball filename differs from its version." + SOURCE="$PWD/package.json" MANIFEST="$manifest" node --input-type=commonjs - <<'JS' || fail "Source or tarball publishConfig differs from the reviewed policy." + const { readFileSync } = require('node:fs'); + const expected = { access: 'public', provenance: true }; + for (const config of [JSON.parse(readFileSync(process.env.SOURCE, 'utf8')).publishConfig, JSON.parse(process.env.MANIFEST).publishConfig]) { + if (!config || Object.keys(config).length !== 2 || + config.access !== expected.access || config.provenance !== expected.provenance) process.exit(1); + } + JS + + sha256="$(shasum -a 256 "$tarball" | cut -d' ' -f1)" + [ "$sha256" = "$(cut -d' ' -f1 "$tarball.sha256")" ] || fail "Tarball differs from the smoke-tested artifact." + + # An empty successful answer is not evidence of absence. Only E404 + # authorizes publication; all other responses stop the release. + status=0 + view="$(npm view "browserjack@$version" version --json --registry=https://registry.npmjs.org/ 2>/dev/null)" || status=$? + if [ "$status" -eq 0 ] && [ -n "$view" ]; then + found="$(node -e 'try { const v=JSON.parse(process.argv[1]); if(typeof v==="string") process.stdout.write(v); } catch {}' "$view")" + [ "$found" = "$version" ] || fail "npm view returned an unexpected version or response." + publish=false + elif [ "$status" -ne 0 ] && [ "$(node -e 'try { process.stdout.write(JSON.parse(process.argv[1]).error?.code ?? ""); } catch {}' "$view")" = E404 ]; then + publish=true + else + fail "npm view failed to establish whether browserjack@$version is published (exit $status)." + fi + + { + echo "publish=$publish" + echo "version=$version" + echo "sha256=$sha256" + } >> "$GITHUB_OUTPUT" + + publish: + needs: [verify, release-gate] + if: >- + github.repository == 'stickerdaniel/browserjack' && + (github.event_name == 'push' || github.event_name == 'workflow_dispatch') && + github.ref == 'refs/heads/main' && + needs.release-gate.outputs.publish == 'true' + runs-on: ubuntu-latest + timeout-minutes: 10 + concurrency: + group: browserjack-npm-release + cancel-in-progress: false + environment: + name: npm + url: https://www.npmjs.com/package/browserjack/v/${{ needs.release-gate.outputs.version }} + permissions: + id-token: write + steps: + - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + with: + node-version: 24.21.0 + registry-url: https://registry.npmjs.org + package-manager-cache: false + - name: Require npm with trusted publishing support + run: | + set -euo pipefail + npm_version="$(npm --version)" + printf '11.5.1\n%s\n' "$npm_version" | sort -V -C || { + echo "::error::npm $npm_version is older than 11.5.1, which trusted publishing requires." + exit 1 + } + - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: browserjack-${{ github.sha }} + path: ${{ runner.temp }}/release + - name: Publish the tested tarball + env: + RELEASE_DIR: ${{ runner.temp }}/release + VERSION: ${{ needs.release-gate.outputs.version }} + SHA256: ${{ needs.release-gate.outputs.sha256 }} run: | - tag="${GITHUB_REF_NAME#v}" - pkg="$(node -p "require('./package.json').version")" - test "$tag" = "$pkg" || { - echo "Tag $tag does not match package version $pkg" >&2 + set -euo pipefail + tarball="$RELEASE_DIR/browserjack-$VERSION.tgz" + [ "$(sha256sum "$tarball" | cut -d' ' -f1)" = "$SHA256" ] || { + echo "::error::$tarball is not the tarball the release gate checked." exit 1 } - - run: npm publish + npm publish "$tarball" --access public --ignore-scripts --registry https://registry.npmjs.org/ + { + echo "Published browserjack@$VERSION" + echo + echo "Tarball sha256: \`$SHA256\`" + } >> "$GITHUB_STEP_SUMMARY" diff --git a/CHANGELOG.md b/CHANGELOG.md index d14ef2d..4c8415e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,12 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), ## [Unreleased] +## [0.3.1] - 2026-09-27 + +### Changed + +- Publish new versions from `main` through a credential-isolated job that uploads the tarball CI tested; tags no longer trigger publishing + ### Added - Compatibility manifest entry for ChatGPT.app 26.814.41407, verified end to end against Chrome and Helium @@ -62,7 +68,8 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), - Disabled-by-default Claude Code plugin with setup, doctor, and browser skills - stderr secret redaction and strict stdout protocol purity -[Unreleased]: https://github.com/stickerdaniel/browserjack/compare/v0.3.0...HEAD +[Unreleased]: https://github.com/stickerdaniel/browserjack/compare/v0.3.1...HEAD +[0.3.1]: https://github.com/stickerdaniel/browserjack/compare/v0.3.0...v0.3.1 [0.3.0]: https://github.com/stickerdaniel/browserjack/compare/v0.2.0...v0.3.0 [0.2.0]: https://github.com/stickerdaniel/browserjack/compare/v0.1.0...v0.2.0 [0.1.0]: https://github.com/stickerdaniel/browserjack/releases/tag/v0.1.0 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index a922800..60064ce 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -9,7 +9,7 @@ npm install npm run verify # typecheck + lint + format check + tests ``` -`npm run verify` must pass before every pull request. CI runs tests and CLI smoke checks on macOS with Node.js 22 and 24, plus quality and package checks on Node.js 24. +`npm run verify` must pass before every pull request. CI runs tests and CLI smoke checks on macOS with Node.js 22 and 24, plus quality and package checks on Node.js 24. Release changes also run actionlint and pack a tested tarball without publishing credentials. Individual steps: @@ -33,6 +33,10 @@ node dist/cli.js doctor --live # requires a supported ChatGPT.app New ChatGPT.app builds verify themselves through the one-time runtime self-test, so most updates need no manifest change. Manifest entries remain useful as pre-verified defaults: to propose one, open an issue with the `doctor --json` output (redact your username in paths). Maintainers verify the new browser-client hash against an OpenAI-signed installation before extending the manifest. +## Releasing + +Bump the version with `npm version --no-git-tag-version`, then update the plugin manifest, the pinned setup skill, and the dated changelog entry. Run `npm run verify` and open a PR. A merge to `main` publishes the tested tarball if npm does not already have that version. The publish job uses the `npm` environment and trusted publishing, without an npm token or a checkout. After npm confirms the release, tag the published merge commit with `v`; tags do not start another publish. + ## Pull requests Keep PRs small and single-purpose. Describe the problem, the change, and how you verified it on your machine. Test files live in `test/` and use the Node.js test runner; new install/runtime behaviour needs a test. diff --git a/package-lock.json b/package-lock.json index c4f3bd9..65a1322 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "browserjack", - "version": "0.3.0", + "version": "0.3.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "browserjack", - "version": "0.3.0", + "version": "0.3.1", "cpu": [ "arm64" ], @@ -22,7 +22,8 @@ "oxfmt": "0.60.0", "oxlint": "1.75.0", "oxlint-tsgolint": "7.0.2001", - "typescript": "7.0.2" + "typescript": "7.0.2", + "yaml": "2.9.1" }, "engines": { "node": ">=22" @@ -1326,6 +1327,22 @@ "integrity": "sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ==", "dev": true, "license": "MIT" + }, + "node_modules/yaml": { + "version": "2.9.1", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.9.1.tgz", + "integrity": "sha512-3NxN8+78OdzbT7C/WjGsyfPAtJaN3FNDsWxv7Y7mcDsT/oOmgW8BpyQQFFBnvZE3j9Y2Sdz1ULFLezL7Eb2yFw==", + "dev": true, + "license": "ISC", + "bin": { + "yaml": "bin.mjs" + }, + "engines": { + "node": ">= 14.6" + }, + "funding": { + "url": "https://github.com/sponsors/eemeli" + } } } } diff --git a/package.json b/package.json index 2f7b520..22aef84 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "browserjack", - "version": "0.3.0", + "version": "0.3.1", "description": "A local macOS MCP bridge that lets Claude Code and other MCP clients reuse OpenAI's installed Codex browser runtime.", "keywords": [ "browser-automation", @@ -70,7 +70,8 @@ "oxfmt": "0.60.0", "oxlint": "1.75.0", "oxlint-tsgolint": "7.0.2001", - "typescript": "7.0.2" + "typescript": "7.0.2", + "yaml": "2.9.1" }, "engines": { "node": ">=22" diff --git a/plugin/.claude-plugin/plugin.json b/plugin/.claude-plugin/plugin.json index 07d276b..a0827c6 100644 --- a/plugin/.claude-plugin/plugin.json +++ b/plugin/.claude-plugin/plugin.json @@ -2,7 +2,7 @@ "$schema": "https://json.schemastore.org/claude-code-plugin-manifest.json", "name": "browserjack", "displayName": "Browserjack", - "version": "0.3.0", + "version": "0.3.1", "description": "Use OpenAI's locally installed Codex browser runtime from Claude Code (macOS)", "author": { "name": "Daniel Sticker" diff --git a/plugin/skills/setup/SKILL.md b/plugin/skills/setup/SKILL.md index 7649715..ca367f2 100644 --- a/plugin/skills/setup/SKILL.md +++ b/plugin/skills/setup/SKILL.md @@ -8,7 +8,7 @@ Explain the trust boundary before installation: the bridge can control authentic For a published release, install an exact reviewed version: ```bash -npx --yes browserjack@0.3.0 setup --client plugin --scope user +npx --yes browserjack@0.3.1 setup --client plugin --scope user ``` For a source checkout, run: diff --git a/scripts/pack-check.sh b/scripts/pack-check.sh index 826a156..e88937f 100755 --- a/scripts/pack-check.sh +++ b/scripts/pack-check.sh @@ -8,7 +8,10 @@ workdir="$(mktemp -d)" trap 'rm -rf "$workdir"' EXIT cd "$root" -tarball="$workdir/$(npm pack --pack-destination "$workdir" | tail -1)" +packdir="${BROWSERJACK_ARTIFACT_DIR:-$workdir}" +mkdir -p "$packdir" +packdir="$(cd "$packdir" && pwd -P)" +tarball="$packdir/$(npm pack --pack-destination "$packdir" | tail -1)" cd "$workdir" npm init -y > /dev/null @@ -23,4 +26,8 @@ set -e test "$status_exit" -eq 2 node -e "const r = require('./status.json'); if (r.installed !== false) process.exit(1)" +if [ -n "${BROWSERJACK_ARTIFACT_DIR:-}" ]; then + shasum -a 256 "$tarball" > "$tarball.sha256" +fi + echo "pack:check ok ($(basename "$tarball"))" diff --git a/test/release-workflow.test.mjs b/test/release-workflow.test.mjs new file mode 100644 index 0000000..fd9e28d --- /dev/null +++ b/test/release-workflow.test.mjs @@ -0,0 +1,400 @@ +import assert from "node:assert/strict"; +import { spawnSync } from "node:child_process"; +import { createHash } from "node:crypto"; +import { chmod, mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +import { createRequire } from "node:module"; +import { tmpdir } from "node:os"; +import { dirname, join, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; +import { gzipSync } from "node:zlib"; +import test from "node:test"; + +import { parse } from "yaml"; + +const root = resolve(dirname(fileURLToPath(import.meta.url)), ".."); +const workflow = parse(await readFile(join(root, ".github/workflows/release.yml"), "utf8")); +const sourceManifest = JSON.parse(await readFile(join(root, "package.json"), "utf8")); +const gate = workflow.jobs["release-gate"].steps.find((step) => step.id === "gate").run; +const mainOnly = + "github.repository == 'stickerdaniel/browserjack' && (github.event_name == 'push' || github.event_name == 'workflow_dispatch') && github.ref == 'refs/heads/main'"; +const setupNode = "actions/setup-node@820762786026740c76f36085b0efc47a31fe5020"; +const downloadArtifact = "actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c"; +const artifactName = "browserjack-${{ github.sha }}"; + +function normalized(value) { + return value.replaceAll(/\s+/g, " ").trim(); +} + +function assertReleaseBoundary(value) { + assert.deepEqual(Object.keys(value).toSorted(), ["jobs", "name", "on", "permissions"]); + assert.deepEqual(value.permissions, { contents: "read" }); + assert.deepEqual(value.on.push, { branches: ["main"] }); + assert.ok(value.on.pull_request.paths.includes(".github/workflows/release.yml")); + assert.ok(Object.hasOwn(value.on, "workflow_dispatch")); + assert.deepEqual(Object.keys(value.on).toSorted(), ["pull_request", "push", "workflow_dispatch"]); + + const { verify, publish } = value.jobs; + const releaseGate = value.jobs["release-gate"]; + assert.deepEqual(Object.keys(value.jobs).toSorted(), ["publish", "release-gate", "verify"]); + assert.deepEqual(verify.permissions, { contents: "read" }); + assert.equal(verify.concurrency, undefined); + assert.equal(verify.steps[0].with["persist-credentials"], false); + assert.equal(verify.steps[1].with["node-version"], "24.21.0"); + assert.equal(verify.steps[1].with["package-manager-cache"], false); + assert.equal(verify.steps[3].env.BROWSERJACK_RELEASE_GATE_TEST, "required"); + assert.equal( + verify.steps[4].env.BROWSERJACK_ARTIFACT_DIR, + "${{ runner.temp }}/browserjack-artifact", + ); + assert.deepEqual(verify.steps[5].with, { + name: artifactName, + path: "${{ runner.temp }}/browserjack-artifact/*", + "if-no-files-found": "error", + "retention-days": 30, + }); + + assert.equal(releaseGate.needs, "verify"); + assert.equal(normalized(releaseGate.if), mainOnly); + assert.deepEqual(releaseGate.permissions, { contents: "read" }); + assert.equal(releaseGate.concurrency, undefined); + assert.equal(releaseGate.steps[0].with["persist-credentials"], false); + assert.equal(releaseGate.steps[1].with["node-version"], "24.21.0"); + assert.deepEqual(releaseGate.steps[2].with, { + name: artifactName, + path: "${{ runner.temp }}/release", + }); + assert.equal(releaseGate.steps[2].uses, downloadArtifact); + assert.deepEqual(releaseGate.steps[3].env, { RELEASE_DIR: "${{ runner.temp }}/release" }); + assert.deepEqual(releaseGate.outputs, { + publish: "${{ steps.gate.outputs.publish }}", + version: "${{ steps.gate.outputs.version }}", + sha256: "${{ steps.gate.outputs.sha256 }}", + }); + + assert.deepEqual(Object.keys(publish).toSorted(), [ + "concurrency", + "environment", + "if", + "needs", + "permissions", + "runs-on", + "steps", + "timeout-minutes", + ]); + assert.deepEqual(publish.needs, ["verify", "release-gate"]); + assert.equal( + normalized(publish.if), + `${mainOnly} && needs.release-gate.outputs.publish == 'true'`, + ); + assert.deepEqual(publish.permissions, { "id-token": "write" }); + assert.deepEqual(publish.environment, { + name: "npm", + url: "https://www.npmjs.com/package/browserjack/v/${{ needs.release-gate.outputs.version }}", + }); + assert.deepEqual(publish.concurrency, { + group: "browserjack-npm-release", + "cancel-in-progress": false, + }); + assert.deepEqual( + publish.steps.map((step) => step.uses ?? `run: ${step.name}`), + [ + setupNode, + "run: Require npm with trusted publishing support", + downloadArtifact, + "run: Publish the tested tarball", + ], + ); + assert.deepEqual( + publish.steps.map((step) => Object.keys(step).toSorted()), + [ + ["uses", "with"], + ["name", "run"], + ["uses", "with"], + ["env", "name", "run"], + ], + ); + assert.deepEqual(publish.steps[0].with, { + "node-version": "24.21.0", + "registry-url": "https://registry.npmjs.org", + "package-manager-cache": false, + }); + assert.deepEqual(publish.steps[2].with, { + name: artifactName, + path: "${{ runner.temp }}/release", + }); + assert.deepEqual(publish.steps[3].env, { + RELEASE_DIR: "${{ runner.temp }}/release", + VERSION: "${{ needs.release-gate.outputs.version }}", + SHA256: "${{ needs.release-gate.outputs.sha256 }}", + }); + assert.deepEqual(publish.steps[1].run.trim().split("\n"), [ + "set -euo pipefail", + 'npm_version="$(npm --version)"', + "printf '11.5.1\\n%s\\n' \"$npm_version\" | sort -V -C || {", + ' echo "::error::npm $npm_version is older than 11.5.1, which trusted publishing requires."', + " exit 1", + "}", + ]); + assert.deepEqual(publish.steps[3].run.trim().split("\n"), [ + "set -euo pipefail", + 'tarball="$RELEASE_DIR/browserjack-$VERSION.tgz"', + '[ "$(sha256sum "$tarball" | cut -d\' \' -f1)" = "$SHA256" ] || {', + ' echo "::error::$tarball is not the tarball the release gate checked."', + " exit 1", + "}", + 'npm publish "$tarball" --access public --ignore-scripts --registry https://registry.npmjs.org/', + "{", + ' echo "Published browserjack@$VERSION"', + " echo", + ' echo "Tarball sha256: \\`$SHA256\\`"', + '} >> "$GITHUB_STEP_SUMMARY"', + ]); + for (const [name, job] of Object.entries(value.jobs)) { + if (name !== "publish") { + assert.ok(!Object.hasOwn(job.permissions ?? {}, "id-token")); + assert.ok(job.steps.every((step) => !/\bnpm\s+publish\b/.test(step.run ?? ""))); + } + assert.ok( + job.steps.every((step) => step.shell === undefined && step.continue_on_error === undefined), + ); + } + assert.equal(JSON.stringify(value).includes("write-all"), false); +} + +test("release workflow confines publishing credentials and the tested artifact", () => { + assertReleaseBoundary(workflow); +}); + +test("release boundary rejects credential and artifact wiring changes", () => { + for (const mutate of [ + (v) => { + v.jobs.publish.steps[3].run = v.jobs.publish.steps[3].run.replace("--ignore-scripts", ""); + }, + (v) => { + v.jobs.publish.steps[2].with.name = "another-artifact"; + }, + (v) => { + v.jobs.publish.steps[3].env.SHA256 = "abc"; + }, + (v) => { + v.jobs.verify.permissions["id-token"] = "write"; + }, + (v) => { + v.jobs.publish.steps[3].shell = "node"; + }, + (v) => { + v.jobs.publish.steps.unshift({ uses: "actions/checkout@main" }); + }, + (v) => { + v.jobs.publish.if += " || true"; + }, + (v) => { + v.on.push.tags = ["v*"]; + }, + ]) { + const changed = structuredClone(workflow); + mutate(changed); + assert.throws(() => assertReleaseBoundary(changed)); + } +}); + +const npmBin = process.platform === "win32" ? "npm.cmd" : "npm"; +const npmVersion = spawnSync(npmBin, ["--version"], { encoding: "utf8" }); +const supportsGate = npmVersion.status === 0 && Number.parseInt(npmVersion.stdout, 10) >= 11; +if (!supportsGate && process.env.BROWSERJACK_RELEASE_GATE_TEST === "required") { + test("release gate requires npm 11 when publishing is enabled", () => { + assert.fail( + `npm 11 is required for gate tests (got ${npmVersion.stdout.trim() || npmVersion.stderr.trim()})`, + ); + }); +} + +const require = createRequire(import.meta.url); +const tar = supportsGate + ? require( + join( + spawnSync(npmBin, ["root", "-g"], { encoding: "utf8" }).stdout.trim(), + "npm/node_modules/tar", + ), + ) + : undefined; +const requiredEntries = [ + "package/package.json", + "package/LICENSE", + "package/dist/cli.js", + "package/compatibility/manifest.json", +]; + +async function runGate({ + manifest = sourceManifest, + members = requiredEntries, + view = { status: 1, body: '{"error":{"code":"E404"}}' }, + checksum = true, + archive, +} = {}) { + const dir = await mkdtemp(join(tmpdir(), "browserjack-gate-")); + try { + const releaseDir = join(dir, "release"); + const stage = join(dir, "stage"); + const bin = join(dir, "bin"); + await mkdir(releaseDir); + await mkdir(bin); + for (const name of requiredEntries) { + const path = join(stage, name); + await mkdir(dirname(path), { recursive: true }); + await writeFile( + path, + name === "package/package.json" ? JSON.stringify(manifest) : `${name}\n`, + ); + } + for (const name of members.filter((entry) => !requiredEntries.includes(entry))) { + const path = join(stage, name); + await mkdir(dirname(path), { recursive: true }); + await writeFile(path, `${name}\n`); + } + await writeFile(join(dir, "package.json"), JSON.stringify(sourceManifest)); + const tarball = join(releaseDir, `browserjack-${sourceManifest.version}.tgz`); + if (archive) { + await writeFile(tarball, archive); + } else { + await tar.c({ file: tarball, gzip: true, cwd: stage, portable: true }, members); + } + const digest = createHash("sha256") + .update(await readFile(tarball)) + .digest("hex"); + await writeFile(`${tarball}.sha256`, `${checksum ? digest : "0".repeat(64)} ${tarball}\n`); + const realNpm = spawnSync("/bin/sh", ["-c", "command -v npm"], { + encoding: "utf8", + }).stdout.trim(); + await writeFile( + join(bin, "npm"), + `#!/bin/sh\nif [ "$1" = view ]; then printf '%s\\n' "$NPM_VIEW_BODY"; exit "$NPM_VIEW_STATUS"; fi\nexec '${realNpm}' "$@"\n`, + ); + await chmod(join(bin, "npm"), 0o755); + const script = join(dir, "gate.sh"); + await writeFile(script, gate); + const output = join(dir, "output"); + await writeFile(output, ""); + const result = spawnSync("/bin/bash", [script], { + cwd: dir, + env: { + ...process.env, + HOME: dir, + PATH: `${bin}:${process.env.PATH}`, + RELEASE_DIR: releaseDir, + RUNNER_TEMP: dir, + GITHUB_OUTPUT: output, + NPM_VIEW_BODY: view.body, + NPM_VIEW_STATUS: String(view.status), + }, + encoding: "utf8", + timeout: 30_000, + }); + return { ...result, output: await readFile(output, "utf8"), digest }; + } finally { + await rm(dir, { recursive: true, force: true }); + } +} + +const gateTest = supportsGate ? test : test.skip; +gateTest("release gate authorizes only an explicit npm E404", async () => { + const absent = await runGate(); + assert.equal(absent.status, 0, absent.stdout + absent.stderr); + assert.equal( + absent.output, + `publish=true\nversion=${sourceManifest.version}\nsha256=${absent.digest}\n`, + ); + const present = await runGate({ + view: { status: 0, body: JSON.stringify(sourceManifest.version) }, + }); + assert.equal(present.status, 0, present.stdout + present.stderr); + assert.match(present.output, /publish=false/); + for (const view of [ + { status: 0, body: "" }, + { status: 0, body: "not json" }, + { status: 0, body: '"0.0.0"' }, + { status: 1, body: '{"error":{"code":"E500"}}' }, + { status: 1, body: "" }, + ]) { + const result = await runGate({ view }); + assert.notEqual(result.status, 0, `view ${JSON.stringify(view)} was accepted`); + assert.equal(result.output, ""); + } +}); + +gateTest("release gate rejects manifest, archive and hash mismatches", async () => { + for (const args of [ + { manifest: { ...sourceManifest, name: "other" } }, + { manifest: { ...sourceManifest, version: "9.9.9" } }, + { manifest: { ...sourceManifest, publishConfig: { access: "public" } } }, + { + manifest: { + ...sourceManifest, + publishConfig: { ...sourceManifest.publishConfig, registry: "https://example.test" }, + }, + }, + { members: [...requiredEntries, "shadow/package.json"] }, + { checksum: false }, + ]) { + const result = await runGate(args); + assert.notEqual(result.status, 0, JSON.stringify(args)); + assert.equal(result.output, ""); + } +}); + +function ustarArchive(members) { + const blocks = members.flatMap( + ({ name, data = "", type = "0", linkname = "", badChecksum = false }) => { + const content = Buffer.from(data); + const header = Buffer.alloc(512); + const field = (value, start) => header.write(value, start, "latin1"); + const number = (value, start, length) => + field(`${value.toString(8).padStart(length - 1, "0")}\0`, start); + field(name, 0); + number(0o644, 100, 8); + number(0, 108, 8); + number(0, 116, 8); + number(type === "0" ? content.length : 0, 124, 12); + number(0, 136, 12); + field(" ".repeat(8), 148); + field(type, 156); + field(linkname, 157); + field("ustar\0", 257); + field("00", 263); + field( + badChecksum + ? "garbled!" + : `${header + .reduce((sum, byte) => sum + byte, 0) + .toString(8) + .padStart(6, "0")}\0 `, + 148, + ); + if (type !== "0") return [header]; + const padded = Buffer.alloc(Math.ceil(content.length / 512) * 512); + content.copy(padded); + return [header, padded]; + }, + ); + return gzipSync(Buffer.concat([...blocks, Buffer.alloc(1024)])); +} + +function packedMembers() { + return requiredEntries.map((name) => ({ + name, + data: name === "package/package.json" ? JSON.stringify(sourceManifest) : `${name}\n`, + })); +} + +gateTest("release gate rejects nonregular, duplicate and malformed entries", async () => { + for (const members of [ + [...packedMembers(), { name: "package/dist/cli.js", data: "duplicate" }], + [...packedMembers(), { name: "package/./extra", data: "dot path" }], + [...packedMembers(), { name: "package/link", type: "2", linkname: "package.json" }], + [{ name: "package/dist/notes", data: "bad", badChecksum: true }, ...packedMembers()], + ]) { + const result = await runGate({ archive: ustarArchive(members) }); + assert.notEqual(result.status, 0, JSON.stringify(members.map((m) => m.name))); + assert.equal(result.output, ""); + } +}); From 574060c9fb879a64db188bf8669db7c06bfb93ef Mon Sep 17 00:00:00 2001 From: Daniel Sticker Date: Sun, 27 Sep 2026 15:36:34 +0200 Subject: [PATCH 2/2] test(release): Guard failed verify steps --- test/release-workflow.test.mjs | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/test/release-workflow.test.mjs b/test/release-workflow.test.mjs index fd9e28d..957df6d 100644 --- a/test/release-workflow.test.mjs +++ b/test/release-workflow.test.mjs @@ -155,7 +155,9 @@ function assertReleaseBoundary(value) { assert.ok(job.steps.every((step) => !/\bnpm\s+publish\b/.test(step.run ?? ""))); } assert.ok( - job.steps.every((step) => step.shell === undefined && step.continue_on_error === undefined), + job.steps.every( + (step) => step.shell === undefined && step["continue-on-error"] === undefined, + ), ); } assert.equal(JSON.stringify(value).includes("write-all"), false); @@ -179,6 +181,9 @@ test("release boundary rejects credential and artifact wiring changes", () => { (v) => { v.jobs.verify.permissions["id-token"] = "write"; }, + (v) => { + v.jobs.verify.steps[3]["continue-on-error"] = true; + }, (v) => { v.jobs.publish.steps[3].shell = "node"; },