diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..94040db --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,341 @@ +name: Release (public npm) + +# Gated public-release path for @wave-av/cli. +# +# **************************************************************************** +# ** KNOWN GAP AT THE TIME THIS WORKFLOW WAS ADDED (see PR description): ** +# ** `main` in this repo currently carries NO `package.json` and no `src/` — ** +# ** only governance scaffolding (AGENTS.md, capabilities.json, guard CI). ** +# ** @wave-av/cli was hand-published to npm from a checkout that was never ** +# ** committed here. Until a package.json + build (tsup/eslint/vitest, per ** +# ** the shape already published as @wave-av/cli@1.0.8) lands on `main`, ** +# ** the `verify` job below will fail at `npm ci` (no manifest to install). ** +# ** This workflow adds the PATH; it does not by itself make the repo ** +# ** publishable. Script names below are taken from the package.json that ** +# ** WAS actually published for 1.0.8 (fetched from the npm registry), not ** +# ** guessed. ** +# **************************************************************************** +# +# Trigger: pushing a `v*` git tag (e.g. `v1.0.9`). Nothing reaches public npm +# until three gates are green: +# 1. secret-scan — org-standard gitleaks (pinned + checksum-verified) over the +# published tree + the WAVE content-policy trade-secret gate. +# 2. verify — install + lint + type-check + build, then an e2e-smoke that +# PACKS the real tarball, installs it into a throwaway +# project, imports it as ESM, and checks the declared `wave` +# bin exists and is executable. +# 3. publish — only after 1+2 pass. The tag version MUST equal +# package.json version, and the npm dist-tag is derived from +# the version: any prerelease -> `next`, stable -> `latest`, +# so a prerelease can never take `latest` by accident. +# +# Modelled directly on wave-av/adk's .github/workflows/release.yml, which +# published the OIDC-provenance @wave-av/adk@1.0.15 today. Action pins are +# copied from it deliberately: that file has demonstrably run and published. +# +# Auth is npm OIDC trusted publishing (id-token: write on the publish job +# only) — there is NO NODE_AUTH_TOKEN / NPM_TOKEN anywhere in this file. The +# Trusted Publisher for @wave-av/cli (org: wave-av, repo: cli, workflow: +# release.yml) must be registered on npmjs.com BEFORE the first `v*` tag is +# pushed, or the publish step fails with `npm error code EOTP` (a 2FA prompt) +# — that is how an unregistered/mismatched binding actually presents. + +on: + push: + tags: ['v*'] + +permissions: + contents: read + +concurrency: + group: cli-release-${{ github.ref }} + cancel-in-progress: false + +jobs: + # --------------------------------------------------------------------------- + # Gate 1 — secret scan (org standard: gitleaks + WAVE content-policy). + # Mirrors public-repo-guard.yml so the release path enforces the SAME gate the + # merge path does; pinned version + SHA-256 so a tampered download cannot run. + # --------------------------------------------------------------------------- + secret-scan: + name: Secret scan + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + with: + persist-credentials: false + + - name: Install gitleaks (pinned + checksum-verified) + env: + GITLEAKS_VERSION: "8.30.1" + GITLEAKS_SHA256: "551f6fc83ea457d62a0d98237cbad105af8d557003051f41f3e7ca7b3f2470eb" + run: | + set -euo pipefail + curl -fsSL --proto '=https' --tlsv1.2 -o gitleaks.tar.gz \ + "https://github.com/gitleaks/gitleaks/releases/download/v${GITLEAKS_VERSION}/gitleaks_${GITLEAKS_VERSION}_linux_x64.tar.gz" + echo "${GITLEAKS_SHA256} gitleaks.tar.gz" | sha256sum -c - + tar -xzf gitleaks.tar.gz gitleaks + sudo install -m 0755 gitleaks /usr/local/bin/gitleaks + rm -f gitleaks gitleaks.tar.gz + gitleaks version + + - name: gitleaks (secret scan — published tree) + run: gitleaks detect --no-git --source . --config .gitleaks.toml --redact --no-banner --exit-code 1 + + - name: Install ripgrep + run: command -v rg >/dev/null || (sudo apt-get update -qq && sudo apt-get install -y -qq ripgrep) + + - name: content policy (WAVE trade-secret / internal-leak gate) + env: + GUARD_PRIVATE_REPOS: ${{ vars.GUARD_PRIVATE_REPOS }} + run: bash scripts/public-repo-guard/content-policy.sh . + + # --------------------------------------------------------------------------- + # Gate 2 — build, lint, type-check, then e2e-smoke the real tarball. + # --------------------------------------------------------------------------- + verify: + name: Build + e2e-smoke + runs-on: ubuntu-latest + timeout-minutes: 15 + steps: + - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + with: + persist-credentials: false + + - uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 + with: + node-version: '22' + cache: 'npm' + + - run: npm ci + + # Script names below are taken from the package.json that was actually + # published as @wave-av/cli@1.0.8 (build/lint/type-check/test all exist + # there today). Each is still run conditionally: if a future edit to + # package.json drops one, this gate should say so plainly rather than + # hard-failing on a missing script. + - name: Lint (if the package declares it) + run: | + set -euo pipefail + if node -e "const s=require('./package.json').scripts||{}; process.exit(s.lint?0:1)"; then + npm run lint + else + echo "::warning title=no lint script::package.json declares no \"lint\" script" + fi + + - name: Type-check (if the package declares it) + run: | + set -euo pipefail + if node -e "const s=require('./package.json').scripts||{}; process.exit(s['type-check']?0:1)"; then + npm run type-check + else + echo "::warning title=no type-check script::package.json declares no \"type-check\" script" + fi + + - name: Unit tests (if the package declares any) + run: | + set -euo pipefail + if node -e "const s=require('./package.json').scripts||{}; process.exit(s.test?0:1)"; then + npm run test + else + echo "::warning title=no unit tests::package.json declares no \"test\" script - gate 2 is lint + type-check + build + e2e-smoke only" + fi + + - run: npm run build + + # Gate 1 (secret-scan) only scans the checked-out source tree, BEFORE + # `npm run build` runs. Anything the build step generates or bundles + # into dist/ (env values baked in at build time, vendored deps, etc.) + # is what actually ships to npm and has never been scanned. Re-run the + # same pinned+checksum-verified gitleaks over the build output so the + # "secret-scanned" claim covers what npm actually receives. + - name: Install gitleaks (pinned + checksum-verified) + env: + GITLEAKS_VERSION: "8.30.1" + GITLEAKS_SHA256: "551f6fc83ea457d62a0d98237cbad105af8d557003051f41f3e7ca7b3f2470eb" + run: | + set -euo pipefail + curl -fsSL --proto '=https' --tlsv1.2 -o gitleaks.tar.gz \ + "https://github.com/gitleaks/gitleaks/releases/download/v${GITLEAKS_VERSION}/gitleaks_${GITLEAKS_VERSION}_linux_x64.tar.gz" + echo "${GITLEAKS_SHA256} gitleaks.tar.gz" | sha256sum -c - + tar -xzf gitleaks.tar.gz gitleaks + sudo install -m 0755 gitleaks /usr/local/bin/gitleaks + rm -f gitleaks gitleaks.tar.gz + + - name: gitleaks (secret scan — build output) + run: | + set -euo pipefail + if [ -d dist ]; then + gitleaks detect --no-git --source dist --config .gitleaks.toml --redact --no-banner --exit-code 1 + else + echo "::warning title=no dist directory::npm run build produced no dist/ - nothing to scan" + fi + + - name: e2e-smoke — pack + install + import the real tarball + run: | + set -euo pipefail + TARBALL="$(npm pack --silent | tail -n1)" + TARBALL="$PWD/$TARBALL" + echo "packed: $TARBALL" + # Disposable project, outside the workspace, with lifecycle scripts + # disabled: this installs a freshly built artifact and we only want to + # prove it RESOLVES, not to execute anything it ships. + SMOKE="$(mktemp -d)" + cd "$SMOKE" + npm init -y >/dev/null 2>&1 + npm install --no-save --ignore-scripts "$TARBALL" >/dev/null 2>&1 + # @wave-av/cli publishes "type": "module" with no `require` export + # condition (per the package.json published as 1.0.8) — it is + # ESM-only. A CJS require() smoke test would therefore fail on a + # correctly-built package, so only ESM import is exercised here. + node --input-type=module -e "import * as m from '@wave-av/cli'; if(!m||Object.keys(m).length===0){console.error('ESM import produced no exports');process.exit(1);} console.log('ESM ok - exports:',Object.keys(m).length);" + # The declared `wave` bin actually exists in the packed tree and is + # executable. This is a CLI package — the bin IS the product — so + # this check is load-bearing, not incidental. It only checks + # existence + the executable permission bit; it does not invoke the + # bin, consistent with the "resolve, don't execute" smoke-test + # philosophy above. + PKG_DIR="$SMOKE/node_modules/@wave-av/cli" node -e " + const fs=require('fs'),path=require('path'); + const dir=process.env.PKG_DIR; + const pkg=JSON.parse(fs.readFileSync(path.join(dir,'package.json'),'utf8')); + const bins=typeof pkg.bin==='string'?{[pkg.name]:pkg.bin}:(pkg.bin||{}); + const names=Object.keys(bins); + if(names.length===0){console.error('no bin declared - @wave-av/cli is expected to ship a bin');process.exit(1);} + if(!bins.wave){console.error('no "wave" bin declared - @wave-av/cli is expected to ship a bin named exactly "wave", found: '+names.join(', '));process.exit(1);} + for(const n of names){ + const f=path.join(dir,bins[n]); + if(!fs.existsSync(f)){console.error('declared bin missing from tarball: '+n+' -> '+bins[n]);process.exit(1);} + try{ fs.accessSync(f, fs.constants.X_OK); }catch{ console.error('declared bin not executable: '+n+' -> '+bins[n]); process.exit(1); } + console.log('bin ok (exists + executable):',n,'->',bins[n]); + }" + + # --------------------------------------------------------------------------- + # Gate 3 — publish. Runs ONLY if secret-scan + verify are green. + # + # Auth: npm OIDC trusted publishing (no long-lived token). The runner mints a + # short-lived OIDC identity token (id-token: write) and npm (>= 11.5.1) + # exchanges it for a scoped, single-use publish credential -- provided + # @wave-av/cli has a Trusted Publisher configured on npmjs.com + # (org: wave-av, repo: cli, workflow: release.yml). No NPM_TOKEN is used. + # --------------------------------------------------------------------------- + publish: + name: Publish to npm (gated) + needs: [secret-scan, verify] + runs-on: ubuntu-latest + timeout-minutes: 15 + permissions: + id-token: write # mint the OIDC token npm exchanges for a publish credential + contents: read + steps: + - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + with: + persist-credentials: false + + - uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 + with: + node-version: '22' + cache: 'npm' + registry-url: 'https://registry.npmjs.org' + + # Trusted publishing (OIDC) requires npm >= 11.5.1; Node 22 ships an older + # npm. Upgrade the CLI on the runner, then PROVE the floor is met rather + # than assuming the upgrade did what it said. Pinned rather than @latest: + # this job holds id-token: write, so it should not execute whatever npm + # publishes next. + - name: Upgrade npm to a trusted-publishing-capable CLI (>= 11.5.1) + run: | + set -euo pipefail + npm install -g npm@11.5.1 + NPM_VER="$(npm --version)" + echo "npm version: $NPM_VER" + NPM_VER="$NPM_VER" node -e " + const raw=process.env.NPM_VER; + const m=raw.match(/^([0-9]+)\.([0-9]+)\.([0-9]+)/); + if(!m){console.error('npm version '+raw+' is not a parseable x.y.z - cannot verify trusted-publishing floor');process.exit(1);} + const cur=[Number(m[1]),Number(m[2]),Number(m[3])], min=[11,5,1]; + for(let i=0;i<3;i++){ + if(cur[i]>min[i]) process.exit(0); + if(cur[i]> "$GITHUB_OUTPUT" + + # Auth comes from the OIDC token (id-token: write) exchanged by npm + # against the Trusted Publisher registered for @wave-av/cli -- NO + # NODE_AUTH_TOKEN / NPM_TOKEN / .npmrc _authToken. --provenance is free + # under OIDC: it supplies the signing identity for the attestation. + - name: npm publish (OIDC trusted publishing) + env: + DIST_TAG: ${{ steps.ver.outputs.dist_tag }} + run: | + set -euo pipefail + npm publish --access public --provenance --tag "$DIST_TAG"