Skip to content
Open
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
341 changes: 341 additions & 0 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
@@ -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
Comment thread
yakimoto marked this conversation as resolved.

- 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 .
Comment on lines +89 to +92

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔍 content-policy gate is not re-run on build output

The release path re-runs gitleaks over dist/ in both verify and publish, but the WAVE content-policy script (private-repo references, developer absolute paths, account IDs) is only run once over the source tree in secret-scan. Note the script's own IGNORE list excludes **/dist/**, so pointing it at the build output would require passing dist as the root. If bundlers can inline absolute developer paths or internal repo names into dist/, this asymmetry leaves that class of leak unscanned.

Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.


# ---------------------------------------------------------------------------
# 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
Comment thread
yakimoto marked this conversation as resolved.
Comment thread
yakimoto marked this conversation as resolved.

# 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
Comment thread
yakimoto marked this conversation as resolved.
Comment thread
yakimoto marked this conversation as resolved.

- 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);
Comment thread
cubic-dev-ai[bot] marked this conversation as resolved.
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:
Comment thread
qodo-code-review[bot] marked this conversation as resolved.
- 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]<min[i]){console.error('npm '+raw+' < 11.5.1 - trusted publishing unavailable');process.exit(1);}
}"

# --ignore-scripts: this job holds id-token: write (a short-lived OIDC
# publish credential). Dependency lifecycle scripts (preinstall/install/
# postinstall) are untrusted code paths; running them here would widen
# the blast radius of a compromised dependency to the OIDC token. The
# verify job (no id-token permission) already ran a full `npm ci` +
# build/lint/type-check/test, so nothing here depends on install-time
# lifecycle scripts having run.
- run: npm ci --ignore-scripts

# Build in THIS job, on THIS checkout, immediately before publish.
# `verify` builds and e2e-smokes on a separate runner/workspace that is
# not shared with `publish` (GitHub Actions jobs do not share a
# filesystem) — so without a build here, `npm publish` below would pack
# whatever happens to be in the working tree, which may be missing
# `dist/` entirely and would not be the artifact that `verify` proved
# working.
- run: npm run build
Comment thread
yakimoto marked this conversation as resolved.

# This job builds on its OWN checkout (see comment above), so the dist/
# scan that runs in `verify` never sees these bytes. Re-run the same
# pinned+checksum-verified gitleaks, with the repo's config, over THIS
# job's build output too — this is the tree `npm publish` actually
# uploads, and it is otherwise the one build in this whole workflow
# that no secret-scan gate ever touches.
- 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 — publish job's own 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

# GITHUB_REF_NAME is attacker-influenceable by anyone who can push a tag,
# so it is read from the environment and never interpolated into the
# script body.
- name: Verify tag matches package.json version + choose dist-tag
id: ver
env:
TAG_NAME: ${{ github.ref_name }}
run: |
set -euo pipefail
TAG_VERSION="${TAG_NAME#v}"
PKG_VERSION="$(node -p "require('./package.json').version")"
echo "tag=$TAG_NAME version-from-tag=$TAG_VERSION package.json=$PKG_VERSION"
if [ "$TAG_VERSION" != "$PKG_VERSION" ]; then
echo "::error::tag $TAG_NAME implies version $TAG_VERSION but package.json is $PKG_VERSION - refusing to publish a mismatched release"
exit 1
fi
case "$PKG_VERSION" in
*-*) DIST_TAG=next ;; # any prerelease (next/rc/beta) - never 'latest'
*) DIST_TAG=latest ;; # stable
esac
echo "dist-tag=$DIST_TAG"
echo "dist_tag=$DIST_TAG" >> "$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"
Comment thread
yakimoto marked this conversation as resolved.
Comment thread
yakimoto marked this conversation as resolved.
Comment thread
yakimoto marked this conversation as resolved.
Loading