ci: add gated provenance-emitting release workflow

Merged
YA
1mo ago
+360-62

What this does

Adds .github/workflows/release.yml, a v*-tag-triggered, gated public-npm
release path for @wave-av/cli, modelled directly on wave-av/adk's
release.yml (the file that published @wave-av/adk@1.0.15 today with
OIDC trusted-publisher + --provenance).

Three gates, in order: secret-scan (pinned+checksummed gitleaks + the
existing WAVE content-policy script) -> verify (install, lint/type-check/
test if declared, build, then an e2e-smoke that packs the real tarball,
installs it into a throwaway project, and imports it) -> publish (OIDC
trusted publishing only — no NODE_AUTH_TOKEN/NPM_TOKEN anywhere in this
file — gated on a tag-version == package.json-version equality check, and
on both prior gates being green). All third-party actions are pinned to
full commit SHAs, copied from the adk file.

This PR does NOT publish anything and does NOT push a tag. It only adds
the workflow file. The first v* tag push is a separate, explicit,
operator-authorized act — not part of this change.

Known gap — please read before merging

main in this repo currently has no package.json and no src/ —
only governance scaffolding (AGENTS.md, capabilities.json, the guard
CI). @wave-av/cli was hand-published to npm from a checkout that was
never committed to this repo. Until a package.json + build (the repo
would need tsup/eslint/vitest per the shape already published as
@wave-av/cli@1.0.8, confirmed by fetching that version's manifest from
the npm registry) lands on main, the verify job in this workflow will
fail at npm ci (no manifest to install against). This PR builds the
release path; it does not by itself make the repo publishable — that
needs a separate PR that actually commits the source.

Before the first tag push

The Trusted Publisher for @wave-av/cli (org: wave-av, repo: cli,
workflow: release.yml) must be registered/verified in the npmjs.com UI
before any v* tag is pushed. If it isn't registered (or the
repo/workflow name doesn't match exactly), the publish job's npm publish --provenance step fails with npm error code EOTP — that's a
2FA prompt, and it's how an unregistered/mismatched Trusted Publisher
binding actually presents, not an obviously-labelled "not configured"
error.

Verification performed

  • python3 -c "import yaml; yaml.safe_load(open('.github/workflows/release.yml'))" — parses clean, all 3 jobs (secret-scan, verify, publish) present.
  • actionlint .github/workflows/release.yml — exit 0, zero findings.
  • Checked every step name: for the unquoted-colon-space bug that broke wave-av/sdks' publish-npm.yml for 28 runs — none present.

Tag findings (read-only, reported, not acted on)

A v*-shaped tag already exists in this repo: v1.0.0 (only one found via
gh api repos/wave-av/cli/tags). It predates the currently-published
1.0.8 on npm, so it does not correspond to the latest hand-published
version — worth the operator's attention separately from this PR.


[!NOTE]
Medium Risk
Changes the only path to public npm for @wave-av/cli and adds supply-chain-sensitive publish steps (OIDC, provenance, secret scans); misconfiguration of Trusted Publisher or dist-tags could block or mis-tag releases.

Overview
Replaces the single-job v* tag release workflow with a three-gate pipeline (secret-scan → verify → publish), combining the parallel main (PR #44) and PR #17 designs so nothing is dropped from either side. Action pins are unified to newer SHAs (checkout v6.0.3, setup-node v7.0.0).

Gate 1 (secret-scan) runs checksum-pinned gitleaks on the source tree plus the same WAVE content-policy.sh gate used on merge CI.

Gate 2 (verify) now always runs lint, type-check, and unit tests (not conditional), rescans dist/ after build, then e2e-smokes the packed tarball: disposable install with --ignore-scripts, ESM import.meta.resolve, wave bin presence/executability, and three-way version parity (package.json, wave --version, and startup banner v* after clearing CI/agent env so printBanner() actually runs).

Gate 3 (publish) depends on both prior jobs, rebuilds on its own runner (jobs don’t share artifacts), gitleaks-scans that build output again, enforces tag ↔ package.json version, picks latest vs next from prerelease semver, upgrades npm to ≥ 11.5.1 for OIDC trusted publishing, uses npm ci --ignore-scripts where id-token: write is held, keeps optional NPM_TOKEN fallback, and publishes with --provenance and the chosen dist-tag.

Reviewed by Cursor Bugbot for commit d7ad243370f582cb2f13208ceb611f96a1d31671. Bugbot is set up for automated code reviews on this repo. Configure here.

[!NOTE]

Add gated provenance-emitting release workflow for npm publishing

  • Adds release.yml, a GitHub Actions workflow triggered on v* tags, with three sequential gated jobs: secret-scan, verify, and publish.
  • secret-scan runs gitleaks and a content policy script against the source tree, blocking the release if secrets or internal content are detected.
  • verify installs dependencies, conditionally runs lint/type-check/tests, builds the package, rescans dist/ with gitleaks, and e2e smoke-tests the packed tarball (ESM import + wave bin presence and executability).
  • publish validates that the git tag version matches package.json, selects a dist-tag (latest or next), and publishes to npm using OIDC-based trusted publishing with provenance (no secret token required).
  • Risk: publish requires npm ≥ 11.5.1 for OIDC trusted publishing; the workflow enforces this and fails if the version floor is not met.

Macroscope summarized d95fbd3.


Conflict repair (2026-09-03)

The section above is the original author's, appended to rather than replaced. One correction to it: its "KNOWN GAP" banner says main carries no package.json and no src/. That was true when this branch was opened; it is no longer — main now ships both, at version 1.0.9.

What this PR does (unchanged intent)

Adds a gated, provenance-emitting release path for @wave-av/cli: a v* tag runs
secret-scan → verify → publish, and nothing reaches public npm until the first two are
green. GA criteria: SUPPLY-001 (publish with --provenance under OIDC), VER-001 and
ART-001 (source version = tag = GitHub Release = npm registry).

Why it was stalled, and what changed

main grew its own release.yml in #44 after this branch was opened. Two workflows
collided add/add and the PR went CONFLICTING / DIRTY — all checks were passing, the merge
conflict was the only blocker.

Resolved by merging origin/main into this branch (fast-forward 64cb6b3..d7ad243, no
force-push
— the author's three commits are intact) and producing the union of both
workflows. Relative to origin/main this branch now changes exactly one file:

$ git diff --name-only origin/main
.github/workflows/release.yml

Gate ledger — proof nothing was dropped

A conflict resolution that silently loses a gate is a regression, so every gate on both sides
is asserted mechanically (27/27):

From main (#44)Where it lives now
tag == package.json version, fail closedpublish / Verify tag
npm ci --include=devverify / Install
npm run buildverify + publish
npm test (unconditional)verify / Unit tests
npm pack + install the REAL tarballverify / e2e-smoke
run the installed bin, wave --version == package.jsonverify / e2e-smoke
NPM_TOKEN classic-auth fallbackpublish / Configure npm auth
npm publish --provenance --access publicpublish / npm publish
From this branch (#17)Where it lives now
gitleaks, pinned 8.30.1 + SHA-256 verifiedsecret-scan, verify, publish
gitleaks over the source treesecret-scan
WAVE content-policy trade-secret gatesecret-scan
lint + type-checkverify
gitleaks over each job's own dist/verify + publish
ESM smoke of the packed tarballverify / e2e-smoke
declared wave bin exists + is executableverify / e2e-smoke
publish gated on needs: [secret-scan, verify]publish
npm ≥ 11.5.1 upgrade, floor asserted not assumedpublish
npm ci --ignore-scripts (OIDC blast radius)publish
build on the publish job's own checkoutpublish
dist-tag derivation: prerelease → next, never latestpublish / Verify tag
id-token: write scoped to publish onlypublish
tag name read via env:, never interpolated into a scriptpublish / Verify tag

Action pins take the newer of the two sides and never downgrade either: actions/checkout
v6.0.3 (this branch's, > main's v4.3.1) and actions/setup-node v7.0.0 (main's, > this
branch's v6.4.0). Both SHAs verified against the actions/* repos.

main's NPM_TOKEN fallback is kept rather than dropped. Without it, a tag pushed before
the npmjs Trusted Publisher registration exists fails with no recourse. It only engages when
the secret is actually set, and --provenance still produces a signed attestation on that path
because the job holds id-token: write — so SUPPLY-001 holds either way. Drop the step once
the Trusted Publisher is registered.

Defect 1 — the ESM smoke could never have passed

package.json sets "main" and "bin.wave" to the same file (./dist/index.js) and
declares no exports. So import * as m from '@wave-av/cli' does not import a library — it
executes the CLI, which with no argv prints help and exits non-zero. Reproduced against a
correctly built 1.0.9 tarball:

=== package main vs bin (same file?) ===
main: ./dist/index.js | bin: {"wave":"./dist/index.js"} | type: module | exports: undefined

=== A) the old assertion: import * as m from '@wave-av/cli' ===
exit=1                      <-- would have failed the release, at tag time

=== B) import.meta.resolve (resolve WITHOUT executing) ===
resolved: file:///.../node_modules/@wave-av/cli/dist/index.js
exit=0

=== C) run the bin (loads the whole ESM graph AND executes) ===
1.0.9
exit=0

Replaced with import.meta.resolve (proves the entry resolves, without executing) plus the
bin run below (proves the ESM graph actually loads — the "module is not defined in ES module
scope"
class of break that took 1.0.8 down). Strictly stronger than what it replaced: the old
check could not pass on a correct package, so it carried no signal at all.

Defect 2 — nothing asserted the BANNER version (the actual live bug)

npm serves 1.0.8 whose banner prints v1.0.0 while source says 1.0.9. The banner is a
separate code path from --version (src/cli.ts printBanner(), suppressed whenever the
CLI detects CI or an agent — i.e. always on a runner). This branch only checked that the bin
file existed; that would have shipped the bug again.

verify / e2e-smoke now asserts three-way parity — package.json == wave --version ==
the version the banner prints — clearing the CI/agent env vars so the banner actually renders.

Proven locally, in an isolated worktree, against the real packed tarball:

install OK
ESM resolve ok: file:///.../node_modules/@wave-av/cli/dist/index.js
bin ok (exists + executable): wave -> ./dist/index.js
smoke: wave --version -> '1.0.9' (expected '1.0.9')
--- banner line as rendered ---
  Enterprise Streaming Platform  v1.0.9
smoke: banner prints v1.0.9 - three-way parity OK (package.json == --version == banner)
SMOKE EXIT=0

And a negative control — re-inject the 1.0.8 defect and confirm the gate is not vacuous:

=== banner output AFTER injecting the 1.0.8-style defect ===
(banner line not found)
=== gate verdict ===
GATE CORRECTLY FAILED: banner does not print v1.0.9
NEGATIVE_CONTROL_EXIT=1   (1 = gate correctly rejected the defect)

⚠️ Two pre-existing main defects that will block the first tag

lint and type-check were conditional here because main carried no package.json when
this branch was written. main declares both scripts now, so they are unconditional — a
gate that downgrades itself to a ::warning when a script goes missing is a gate that can be
deleted by accident. Both currently fail on origin/main, for reasons that have nothing to
do with this file. My tree is byte-identical to origin/main outside release.yml, so these
are not merge artifacts:

$ npm run type-check
src/lib/auth/device-flow.ts(11,56): error TS2307: Cannot find module '../../types/index.js'
src/lib/config/manager.ts(5,33):    error TS2307: Cannot find module '../../types/index.js'
src/lib/output/index.ts(1,35):      error TS2307: Cannot find module '../../types/index.js'
src/commands/zoom/index.ts(109,51): error TS2339: Property 'create' does not exist ...
src/commands/zoom/index.ts(125,51): error TS2339: Property 'list' does not exist ...

$ npm run lint
sh: eslint: command not found      # "lint": "eslint src/" is declared, but eslint is
                                   # NOT in devDependencies and there is no eslint config
  1. src/types/index.ts does not exist on origin/main — three files import it.
  2. eslint is referenced by the lint script but absent from devDependencies, with no
    eslint config anywhere in the repo.

These are deliberately not worked around here — weakening the gate to get green is the one
thing that must not happen, and the source is another lane's file. They do not block merging
this PR (release.yml only triggers on push: tags: v*, so it does not run on a PR), but they
will fail the first v* tag. Fix them before tagging, or the release fails at the worst
possible moment.

Validation

$ actionlint .github/workflows/release.yml   ->  exit 0, no findings
$ node -e '<parse + assert>'
jobs: secret-scan, verify, publish
trigger tags: ["v*"]
publish.needs: ["secret-scan","verify"]
publish.permissions: {"id-token":"write","contents":"read"}
OK: id-token: write present on publish job
uses: entries not pinned to a 40-char SHA: NONE
$ npm test    ->  Test Files 4 passed (4) | Tests 11 passed (11)

id-token: write is present and scoped to publish only — without it the --provenance
publish fails at release time.

Rollback

Single file, single merge commit. git revert -m 1 d7ad243 restores main's release.yml
verbatim; the release path degrades to what #44 shipped, and no published artifact is affected
because nothing publishes until a v* tag is pushed.

Coordination

  • #45 (feat/npm-trusted-publish) touches this same file on top of main's version, adding
    npm install -g npm@11.19.0 and post-publish verification. This PR already installs and
    asserts an npm ≥ 11.5.1 floor, so the two overlap. Whichever merges second needs a small
    rebase; #45's post-publish registry verification is additive and worth keeping.
  • Renovate #9 / #12 also touch release.yml (checkout bumps) and will need a rebase.

Evidence

Isolated worktree /tmp/gar2-cli-17-release off origin/main (70e0ad8); merge commit
d7ad243, fast-forward 64cb6b3..d7ad243, no force-push.

🤖 Generated with Claude Code