Skip to content

chore(release): prepare AIWG 2026.8.3 #107

chore(release): prepare AIWG 2026.8.3

chore(release): prepare AIWG 2026.8.3 #107

Workflow file for this run

# npm Package Publishing — npmjs.org leg via GitHub Actions OIDC trusted publishing
#
# This is the NPMJS.ORG leg of AIWG's two-leg publish model.
# The Gitea-registry leg stays in .gitea/workflows/npm-publish.yml.
#
# Why a separate workflow on a different platform:
# - npm trusted publishing (OIDC) requires Node 22.14.0+ AND a supported
# provider. Checked on 2026-05-14 against
# https://docs.npmjs.com/trusted-publishers: the supported-provider matrix
# lists GitHub Actions, GitLab CI/CD, and CircleCI. Gitea Actions is not
# included, and npm provenance is still documented only for GitHub Actions
# and GitLab CI/CD.
# - Trusted publishing replaces the long-lived NPMJS_TOKEN with short-lived
# OIDC tokens that npmjs.org verifies against the workflow's
# id-token claims. No secret to rotate, no secret to leak.
# - `npm publish --provenance` produces a cryptographic attestation linking
# the published tarball to (a) the GitHub Actions workflow run that
# produced it and (b) the source commit SHA. Anyone can verify via
# `npm view aiwg@<version> --json | jq .dist.attestations`.
#
# REQUIREMENTS (one-time, operator-side):
# 1. npmjs.org → package settings → trusted publishers → add a publisher for
# each of `aiwg`, `@aiwg/cli`, and `@aiwg/cockpit`:
# Provider: GitHub Actions
# Owner: jmagly
# Repository: aiwg
# Workflow: npm-publish.yml
# Environment: (leave blank for now — no env-protection in use)
# 2. GitHub repo settings → Actions → General → Workflow permissions:
# Confirm "Read and write permissions" OR explicit per-workflow
# `permissions: id-token: write` (we do the latter below, so this
# requirement is informational).
# 3. Tags must be pushed to the GitHub mirror to trigger this workflow:
# git push origin main --tags && git push github main --tags
# `.aiwg/aiwg.config` `remotes.secondary[github].push_on_release: true`
# documents this as the release expectation.
#
# Companion controls:
# - tools/ci/verify-signed-tag.sh (#1299 / A9) — same hard gate used on
# the Gitea workflows; runs ahead of `npm publish` here too.
# - .gitea/workflows/npm-publish.yml — Gitea-registry leg, with deprecation
# notes above the npmjs.org publish steps pending operator removal once
# the OIDC path verifies its first release.
# - .gitea/workflows/upload-release-sigs.yml — manual operator workflow
# that mirrors the release assets from the GitHub release to the
# Gitea release after this workflow lands them (#1287 / A8 — Wave 5).
# - .aiwg/architecture/adr-npmjs-org-via-github-actions.md — A5 ADR.
# - .aiwg/architecture/adr-tarball-cosign-signing.md — A8 ADR.
#
# Tarball signing (#1287 / A8 — Wave 5):
# - After `npm publish`, this workflow cosign-signs the published tarball
# using keyless OIDC against Sigstore's Fulcio CA. No long-lived signing
# key — the GitHub Actions OIDC token IS the signing identity.
# - Bundle format (`.sigstore`): self-contained signature + Fulcio cert +
# Rekor transparency-log entry. Verified offline with `cosign verify-blob
# --bundle …`.
# - A signed `release-manifest.json` (SHA-256 of tarball, version, tag SHA,
# build commit SHA, workflow run URL) is also attached, providing an
# audit-ready bridge between the published artifact and this CI run.
# - All release assets are uploaded to the GitHub release using the workflow's ephemeral
# `GITHUB_TOKEN`. Gitea-side upload is operator-triggered via the
# companion .gitea/workflows/upload-release-sigs.yml workflow — kept
# manual to avoid expanding the Gitea write-token surface that Wave 4
# reduced.
#
# To publish:
# 1. Bump version in package.json
# 2. Sign with the dedicated release key: tools/release/cut-tag.sh YYYY.M.PATCH
# Then push the verified tag: git push github main --tags
# 3. Watch this workflow on the GitHub mirror. The post-publish step verifies
# that the provenance attestation landed on npmjs.org, then cosign-signs
# the tarball + manifest and uploads release assets to the GitHub
# release.
# 4. Mirror the signed assets to the Gitea release with the manual operator
# step in the runbook (`gh workflow run upload-release-sigs.yml -F tag=…`
# against the Gitea Actions API, or the Gitea Actions UI).
name: Publish to npmjs.org (OIDC + provenance)
on:
push:
tags:
- 'v*'
workflow_dispatch:
inputs:
tag_to_publish:
description: 'Existing tag to re-attempt publish for (must already exist on this branch)'
required: false
type: string
remove_cli_bootstrap_tag:
description: 'Maintenance only: remove the deprecated @aiwg/cli bootstrap dist-tag without republishing'
required: false
default: false
type: boolean
concurrency:
group: npmjs-publish-${{ github.ref }}
cancel-in-progress: false
permissions:
contents: write # Create/upload GitHub release assets (rc.1 retag #8 — gh release).
id-token: write # Required for OIDC trusted publishing to npmjs.org.
jobs:
publish-to-npmjs-org:
name: Publish to npmjs.org with provenance
if: ${{ github.event_name != 'workflow_dispatch' || !inputs.remove_cli_bootstrap_tag }}
runs-on: ubuntu-latest
# Container pinned by digest per .gitea/workflows/README.md ("CI Pinning
# Policy"). Same discipline as the Gitea workflows — no mutable tags.
# Row in ci/digests.txt.
container: node:24@sha256:050bf2bbe33c1d6754e060bec89378a79ed831f04a7bb1a53fe45e997df7b3bb # node 24.15.0 / npm 11.12.1 (see ci/digests.txt)
timeout-minutes: 15
defaults:
run:
shell: bash
steps:
- name: Checkout code
uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1 (ci/digests.txt) — Node 24
- name: Resolve release tag
id: release_tag
env:
INPUT_TAG_TO_PUBLISH: ${{ github.event.inputs.tag_to_publish }}
run: |
set -euo pipefail
if [ "${{ github.event_name }}" = "workflow_dispatch" ] && [ -n "${INPUT_TAG_TO_PUBLISH:-}" ]; then
RELEASE_TAG="${INPUT_TAG_TO_PUBLISH}"
else
RELEASE_TAG="${GITHUB_REF#refs/tags/}"
fi
if [ -z "$RELEASE_TAG" ] || [ "$RELEASE_TAG" = "$GITHUB_REF" ]; then
echo "Could not resolve a release tag from this workflow context"
exit 1
fi
case "$RELEASE_TAG" in
v*) ;;
*)
echo "Resolved release tag must start with 'v': $RELEASE_TAG"
exit 1
;;
esac
echo "release_tag=$RELEASE_TAG" >> "$GITHUB_OUTPUT"
echo "Resolved release tag: $RELEASE_TAG"
- name: Fetch tag object for signature verification
# actions/checkout@v4 on tag-push uses --no-tags by default
# and writes refs/tags/<tag> as a commit ref, not the tag
# object. `git tag -v` then fails with "cannot verify a non-
# tag object of type commit". Force-fetch the tag object
# explicitly. See .gitea/workflows/npm-publish.yml prerelease
# job for the full post-mortem.
#
# The safe.directory line is required on GH Actions runners
# (containerized; runner uid ≠ workspace owner uid → git
# refuses with "dubious ownership"). actions/checkout sets
# this internally but our custom git fetch step inherits a
# fresh shell. Gitea's runner doesn't need this line but
# tolerates it as a no-op.
run: |
set -o pipefail
git config --global --add safe.directory "$GITHUB_WORKSPACE"
TAG='${{ steps.release_tag.outputs.release_tag }}'
git fetch origin "+refs/tags/${TAG}:refs/tags/${TAG}" --depth=1
- name: Verify signed tag (#1299 / A9 gate)
# Hard cryptographic gate — the same script that gates the Gitea
# workflows. Any tag that doesn't verify against a maintainer key
# published in .gitea/keys/maintainers.asc (or .gitea/allowed_signers)
# halts the workflow before npm sees the publish request. See
# tools/ci/verify-signed-tag.sh and docs/contributing/versioning.md.
env:
# workflow_dispatch runs on the selected branch, so GitHub leaves
# GITHUB_REF as refs/heads/<branch> even after we detach to the
# requested tag. GitHub also protects GITHUB_REF from shell-level
# override, so pass an explicit verifier-only tag ref.
AIWG_VERIFY_TAG_REF: refs/tags/${{ steps.release_tag.outputs.release_tag }}
run: bash tools/ci/verify-signed-tag.sh
- name: Checkout release tag
run: |
set -o pipefail
TAG='${{ steps.release_tag.outputs.release_tag }}'
git checkout --detach "refs/tags/${TAG}"
- name: Setup Node.js with npmjs.org registry
uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5.0.0 (ci/digests.txt) — Node 24
with:
node-version: '24.x'
# Wires `npm publish` against npmjs.org. Combined with
# `permissions: id-token: write` above, this enables OIDC
# trusted publishing — no NODE_AUTH_TOKEN required.
registry-url: 'https://registry.npmjs.org'
# node:24 ships npm 11.12.1 natively — clears the npm 11.5.1+
# requirement that npm trusted publishing needs WITHOUT a token.
# The earlier node:22 (npm 10.9.x) attempt produced provenance
# attestations (logIndex'd in Sigstore correctly), but the
# subsequent publish PUT lacked OIDC auth headers because npm
# 10.x doesn't emit them — npmjs.org returned 404 PUT every
# time. node:24 fixes this at the source. The full post-mortem
# (8 failed runs across rc.0 + rc.1 retags 1-5) lives in the
# commit history; the canonical 'why node 24' is right here.
- name: Install dependencies
run: npm ci
- name: Install mandatory SQLite session test backend
# better-sqlite3 is an optional peer for end users, but the full test
# suite exercises the SQLite repository and must not run without it.
# This exact-version, no-save install runs after the age-gated locked
# install. Disable re-resolution's age filter here so npm does not
# reject already-locked young packages such as @fortemi/core.
# --save-dev is required here because npm otherwise treats an explicitly
# requested optional peer already declared by the root package as satisfied
# without placing it in node_modules.
run: |
npm install --no-save --save-dev --package-lock=false --min-release-age=0 better-sqlite3@12.8.0
node -e "require('better-sqlite3')"
- name: Run type check
run: npm run typecheck
- name: Build TypeScript
run: npm run build
- name: Assemble lightweight CLI package
run: npm run package:cli
- name: Run tests
# Test failures must block stable publish. `continue-on-error: true`
# was removed from the Gitea workflow per #1280 (A2); the same
# discipline applies here.
run: npm test -- --run --testTimeout=120000
- name: Extract and verify version against tag
id: version
run: |
set -euo pipefail
VERSION=$(node -p "require('./package.json').version")
TAG_VERSION='${{ steps.release_tag.outputs.release_tag }}'
TAG_VERSION="${TAG_VERSION#v}"
if [ "$TAG_VERSION" != "$VERSION" ]; then
echo "Error: Tag ($TAG_VERSION) does not match package.json ($VERSION)"
exit 1
fi
# Pre-release detection — first dash in the version means alpha/
# beta/rc/nightly. Trusted publishing emits provenance for both.
BASE="${VERSION%%-*}"
if [ "$VERSION" != "$BASE" ]; then
PRERELEASE=true
NPM_TAG=next
if echo "$VERSION" | grep -qiE '\-nightly\.'; then
NPM_TAG=nightly
fi
else
PRERELEASE=false
NPM_TAG=latest
fi
echo "version=$VERSION" >> "$GITHUB_OUTPUT"
echo "prerelease=$PRERELEASE" >> "$GITHUB_OUTPUT"
echo "npm_tag=$NPM_TAG" >> "$GITHUB_OUTPUT"
echo "Version: $VERSION (pre-release: $PRERELEASE, dist-tag: $NPM_TAG)"
- name: Check package contents
run: |
set -o pipefail
echo "=== Package contents ==="
npm pack --dry-run 2>&1 | tee pack-output.txt
- name: Verify Fortemi Core prebuilt index package (#1697)
# Runs npm pack through prepack, then verifies the Fortemi Core v2
# framework export + manifest are present in the tarball and match
# their checksum/schema contract.
run: npm run lint:fortemi-prebuilt-package
- name: Check Cockpit package contents
run: |
set -o pipefail
echo "=== Cockpit package contents ==="
npm pack ./apps/cockpit --dry-run 2>&1 | tee cockpit-pack-output.txt
- name: Check lightweight CLI package contents
run: |
set -o pipefail
echo "=== @aiwg/cli package contents ==="
npm pack ./dist/packages/cli --dry-run 2>&1 | tee cli-pack-output.txt
- name: Verify .aiwg/ excluded from package
run: |
npm pack --dry-run --json > pack-files.json
node <<'NODE'
const fs = require('fs');
const stdout = fs.readFileSync('pack-files.json', 'utf8');
function parseNpmPackJson(text) {
try {
return JSON.parse(text);
} catch {
const start = text.lastIndexOf('\n[');
if (start >= 0) return JSON.parse(text.slice(start + 1));
const first = text.indexOf('[');
if (first >= 0) return JSON.parse(text.slice(first));
throw new Error('no JSON array found in npm pack output');
}
}
const pack = parseNpmPackJson(stdout);
const files = pack?.[0]?.files ?? [];
const aiwgFiles = files
.map((file) => file.path)
.filter((path) => path === '.aiwg' || path.startsWith('.aiwg/'));
if (aiwgFiles.length > 0) {
console.error('FATAL: .aiwg/ found in npm package contents!');
console.error('.aiwg/ is project-local SDLC artifacts — must never be published.');
for (const file of aiwgFiles) console.error(file);
process.exit(1);
}
NODE
echo "✓ .aiwg/ correctly excluded from npm package"
- name: Audit dep-graph signatures (#1288 / A12)
# Hard gate: `npm audit signatures` verifies every package in the
# dep graph against npmjs.org's signing keys. Time-bounded waivers
# live in ci/npm-audit-signatures-waivers.yaml; expired waivers
# fail. See .aiwg/architecture/adr-publish-time-evidence.md.
run: npm run lint:audit-signatures
- name: Scan known affected package feed (#1353)
# Hard gate when a feed source is configured. Prefer a raw gist URL
# in AIWG_AFFECTED_PACKAGES_CSV for CI portability; the canonical
# mounted path still works on operator-managed runners.
run: |
if [ -n "${AIWG_AFFECTED_PACKAGES_CSV:-}" ] || [ -f /mnt/ops/users/roctinam/Downloads/22-packages.csv ]; then
npm run lint:affected-packages
else
echo "Skipping affected-package scan: no CSV source configured on this runner"
fi
- name: Audit tarball top-level entries (#1288 / A11)
# Hard gate: catches Mini Shai-Hulud-style new-file-at-tarball-root
# injection by diffing `npm pack --dry-run --json` top-level
# entries against ci/expected-tarball-top-level.txt.
run: npm run lint:tarball
- name: Publish to npmjs.org (OIDC + provenance)
# Trusted-publishing mode: no NODE_AUTH_TOKEN env, no .npmrc auth
# block. npm 11.5.1+ on Node 22.14.0+ negotiates OIDC against
# npmjs.org using the workflow's id-token claims. The provenance
# flag attaches a cryptographic attestation linking the tarball
# to this workflow run and the source commit SHA — anyone can
# verify with `npm view aiwg@<version> --json | jq .dist.attestations`.
run: |
set -o pipefail
# Print OIDC-relevant context so a publish failure can be
# diagnosed against the npmjs.org trusted-publisher config.
# Trusted publishers match on: repository_owner ('jmagly'),
# repository name ('aiwg'), workflow filename ('npm-publish.yml'
# — the basename, NOT the full .github/workflows/ path), and
# optionally environment. If npm returns 404 PUT, one of these
# doesn't match the config.
echo "── OIDC publish context ──"
echo " repository: ${GITHUB_REPOSITORY}"
echo " repository_owner: ${GITHUB_REPOSITORY_OWNER}"
echo " workflow: ${GITHUB_WORKFLOW}"
echo " workflow_ref: ${GITHUB_WORKFLOW_REF}"
echo " workflow_sha: ${GITHUB_WORKFLOW_SHA}"
echo " ref: ${GITHUB_REF}"
echo " actor: ${GITHUB_ACTOR}"
echo " npm version: $(npm --version)"
echo " node version: $(node --version)"
echo
NPM_TAG='${{ steps.version.outputs.npm_tag }}'
npm publish --provenance --access public --tag "$NPM_TAG" 2>&1 | tee publish-output.txt || {
if grep -qE "cannot publish over|EPUBLISHCONFLICT|409" publish-output.txt; then
echo "✓ Version already published to npmjs.org — treating as success"
echo "already_published=true" >> "$GITHUB_OUTPUT"
elif grep -qE "404.*PUT|is not in this registry" publish-output.txt; then
echo "✗ npmjs.org publish failed with 404 — trusted-publisher config mismatch"
echo
echo "The OIDC handshake worked (provenance attestation was generated,"
echo "look for 'Provenance statement published to transparency log'"
echo "above), but npmjs.org rejected the publish. The trusted-publisher"
echo "config on npmjs.org does not match this workflow's OIDC claims."
echo
echo "Operator action: visit https://www.npmjs.com/package/aiwg/access"
echo "→ Publishing access → Trusted publishers → verify config matches:"
echo " Publisher: GitHub Actions"
echo " Organization: ${GITHUB_REPOSITORY_OWNER}"
echo " Repository: $(echo "${GITHUB_REPOSITORY}" | cut -d/ -f2)"
echo " Workflow filename: npm-publish.yml (basename only, NOT a path)"
echo " Environment: (blank, or matches this workflow's environment:)"
echo
cat publish-output.txt
exit 1
else
echo "✗ npmjs.org publish failed (HTTP/npm error above)"
echo " If this is the first OIDC release, common causes:"
echo " - npmjs.org trusted-publisher not configured for jmagly/aiwg npm-publish.yml"
echo " - permissions: id-token: write not set on workflow"
echo " - Node version below 22.14.0 (we pin 22.22.2 above; should not happen)"
cat publish-output.txt
exit 1
fi
}
- name: Publish Cockpit to npmjs.org (OIDC + provenance)
run: |
set -o pipefail
NPM_TAG='${{ steps.version.outputs.npm_tag }}'
npm publish ./apps/cockpit --provenance --access public --tag "$NPM_TAG" 2>&1 | tee cockpit-publish-output.txt || {
if grep -qE "cannot publish over|EPUBLISHCONFLICT|409" cockpit-publish-output.txt; then
echo "✓ @aiwg/cockpit version already published to npmjs.org — treating as success"
else
echo "✗ @aiwg/cockpit npmjs.org publish failed"
cat cockpit-publish-output.txt
exit 1
fi
}
- name: Publish lightweight CLI to npmjs.org (OIDC + provenance)
run: |
set -o pipefail
NPM_TAG='${{ steps.version.outputs.npm_tag }}'
npm publish ./dist/packages/cli --provenance --access public --tag "$NPM_TAG" 2>&1 | tee cli-publish-output.txt || {
if grep -qE "cannot publish over|EPUBLISHCONFLICT|409" cli-publish-output.txt; then
echo "✓ @aiwg/cli version already published to npmjs.org — treating as success"
elif grep -qE "404.*PUT|is not in this registry" cli-publish-output.txt; then
echo "✗ @aiwg/cli publish failed: configure its npm trusted publisher for jmagly/aiwg and npm-publish.yml"
cat cli-publish-output.txt
exit 1
else
echo "✗ @aiwg/cli npmjs.org publish failed"
cat cli-publish-output.txt
exit 1
fi
}
- name: Install jq (node:24 image does not ship it by default)
# The verify-provenance step and the asset-upload steps below shell
# out to jq. node:24-bookworm doesn't include it. rc.1 retag #6
# (8d733e2b) blew up here with exit 127 AFTER a fully successful
# publish + provenance landing — see CHANGELOG entry for 2026.5.3-rc.1.
run: |
if ! command -v jq >/dev/null 2>&1; then
apt-get update && apt-get install -y --no-install-recommends jq
fi
- name: Verify provenance attestation landed
# Independent post-publish check. Without this, a successful publish
# that did NOT actually emit provenance would appear identical to
# one that did. Sleeps briefly for registry propagation, then asks
# npmjs.org whether `dist.attestations` is populated for the
# version we just published.
run: |
set -o pipefail
VERSION='${{ steps.version.outputs.version }}'
echo "Waiting 10s for npmjs.org propagation..."
sleep 10
# `npm view` returns JSON; `.dist.attestations` is non-null when
# provenance is present. A missing attestations field means the
# publish landed but provenance did not — a configuration
# regression we must catch here, not at audit time.
ATTESTATIONS=$(npm view "aiwg@${VERSION}" --json 2>/dev/null | jq -r '.dist.attestations // empty')
if [ -z "$ATTESTATIONS" ]; then
echo "✗ No provenance attestation found for aiwg@${VERSION} on npmjs.org"
echo " The publish step reported success, but the provenance"
echo " flag did not produce an attestation. This usually means"
echo " OIDC trusted publishing is mis-configured upstream."
exit 1
fi
echo "✓ Provenance attestation present for aiwg@${VERSION}"
ATTESTATION_PREVIEW=$(mktemp)
printf '%s\n' "$ATTESTATIONS" | jq '.' > "$ATTESTATION_PREVIEW"
head -40 "$ATTESTATION_PREVIEW"
COCKPIT_ATTESTATIONS=$(npm view "@aiwg/cockpit@${VERSION}" --json 2>/dev/null | jq -r '.dist.attestations // empty')
if [ -z "$COCKPIT_ATTESTATIONS" ]; then
echo "✗ No provenance attestation found for @aiwg/cockpit@${VERSION} on npmjs.org"
exit 1
fi
echo "✓ Provenance attestation present for @aiwg/cockpit@${VERSION}"
CLI_ATTESTATIONS=$(npm view "@aiwg/cli@${VERSION}" --json 2>/dev/null | jq -r '.dist.attestations // empty')
if [ -z "$CLI_ATTESTATIONS" ]; then
echo "✗ No provenance attestation found for @aiwg/cli@${VERSION} on npmjs.org"
exit 1
fi
echo "✓ Provenance attestation present for @aiwg/cli@${VERSION}"
- name: Verify dist-tag points at this version
# Defense-in-depth against the dist-tag drift the Gitea workflow
# was hardened against (#1247, #1280). Even on first OIDC publish
# we confirm the dist-tag we asked for actually resolves to the
# version we just shipped.
run: |
set -o pipefail
VERSION='${{ steps.version.outputs.version }}'
NPM_TAG='${{ steps.version.outputs.npm_tag }}'
for i in 1 2 3 4 5; do
CURRENT=$(npm view "aiwg@${NPM_TAG}" version 2>/dev/null || echo "")
if [ "$CURRENT" = "$VERSION" ]; then
echo "✓ aiwg@${NPM_TAG} = ${VERSION} on npmjs.org"
break
fi
echo "Attempt $i: aiwg@${NPM_TAG} = ${CURRENT:-<missing>}, expected ${VERSION}; retrying in 10s..."
sleep 10
if [ "$i" = "5" ]; then
echo "✗ dist-tag verification exhausted retries (aiwg@${NPM_TAG} did not resolve to ${VERSION})"
exit 1
fi
done
for i in 1 2 3 4 5; do
CURRENT=$(npm view "@aiwg/cockpit@${NPM_TAG}" version 2>/dev/null || echo "")
if [ "$CURRENT" = "$VERSION" ]; then
echo "✓ @aiwg/cockpit@${NPM_TAG} = ${VERSION} on npmjs.org"
break
fi
echo "Attempt $i: @aiwg/cockpit@${NPM_TAG} = ${CURRENT:-<missing>}, expected ${VERSION}; retrying in 10s..."
sleep 10
if [ "$i" = "5" ]; then
echo "✗ dist-tag verification exhausted retries (@aiwg/cockpit@${NPM_TAG} did not resolve to ${VERSION})"
exit 1
fi
done
for i in 1 2 3 4 5; do
CURRENT=$(npm view "@aiwg/cli@${NPM_TAG}" version 2>/dev/null || echo "")
if [ "$CURRENT" = "$VERSION" ]; then
echo "✓ @aiwg/cli@${NPM_TAG} = ${VERSION} on npmjs.org"
break
fi
echo "Attempt $i: @aiwg/cli@${NPM_TAG} = ${CURRENT:-<missing>}, expected ${VERSION}; retrying in 10s..."
sleep 10
if [ "$i" = "5" ]; then
echo "✗ dist-tag verification exhausted retries (@aiwg/cli@${NPM_TAG} did not resolve to ${VERSION})"
exit 1
fi
done
- name: Advance @next dist-tag on stable releases
# On a stable publish (--tag latest), @next is still pointing at
# whatever pre-release we cut last. Consumers tracking `aiwg@next`
# would resolve to stale code. After a stable lands, advance @next
# to point at the same stable version so the channel never lags.
#
# IMPORTANT: OIDC trusted-publishing tokens are publish-scoped only;
# `npm dist-tag` requires a regular auth token. This step uses a
# narrowly-scoped automation token (`NPM_DIST_TAG_TOKEN` — package
# management scope on all three AIWG packages, NOT the old NPMJS_TOKEN).
# When the token is absent the step warns and continues — the
# tarball + signatures are already published, only the @next
# channel lags until an operator advances it manually.
# success() keeps @next advancement behind the publish and verification
# gates, so a pre-publish failure cannot attempt to tag a version that
# does not exist on npmjs.org yet.
# This was the v2026.6.3 failure mode (#1648 cascade stranded @next).
if: ${{ success() && steps.version.outputs.prerelease == 'false' }}
env:
# setup-node generates an npmrc at $NPM_CONFIG_USERCONFIG that
# references ${NODE_AUTH_TOKEN}, so dropping the granular dist-tag
# token into NODE_AUTH_TOKEN authenticates without rewriting any
# config file. The previous publish step ran OIDC (no auth env);
# this step's NODE_AUTH_TOKEN binding is scoped to this step only.
NODE_AUTH_TOKEN: ${{ secrets.NPM_DIST_TAG_TOKEN }}
run: |
set -o pipefail
VERSION='${{ steps.version.outputs.version }}'
if [ -z "${NODE_AUTH_TOKEN:-}" ]; then
echo "⚠ NPM_DIST_TAG_TOKEN not configured — @next will lag @latest."
echo " Operator follow-up: issue a granular automation token with"
echo " package management scope on aiwg, @aiwg/cli, and @aiwg/cockpit,"
echo " store it as NPM_DIST_TAG_TOKEN,"
echo " then re-run this workflow OR run manually:"
echo " npm dist-tag add aiwg@${VERSION} next"
echo " npm dist-tag add @aiwg/cli@${VERSION} next"
echo " npm dist-tag add @aiwg/cockpit@${VERSION} next"
exit 0
fi
for PACKAGE in aiwg @aiwg/cli @aiwg/cockpit; do
echo "Advancing ${PACKAGE}@next → ${VERSION}"
npm dist-tag add "${PACKAGE}@${VERSION}" next
# Cache-bust the verify (npm view caches dist-tags; right after a
# mutation the local cache can return the pre-mutation value).
for i in 1 2 3 4 5; do
CURRENT_NEXT=$(npm view "${PACKAGE}@next" version --prefer-online --no-update-notifier 2>/dev/null || echo "")
if [ "$CURRENT_NEXT" = "$VERSION" ]; then
echo "✓ ${PACKAGE}@next = ${VERSION}"
break
fi
echo "Attempt $i: ${PACKAGE}@next = ${CURRENT_NEXT:-<missing>}, expected ${VERSION}; retrying in 5s..."
sleep 5
if [ "$i" = "5" ]; then
echo "✗ ${PACKAGE}@next verification exhausted retries"
exit 1
fi
done
done
# ============================================================
# Tarball Sigstore signing — #1287 / A8 (Wave 5 of #1278)
# ============================================================
#
# The npmjs.org provenance attestation (above) is registry-bound: a
# consumer who pulls the tarball from the Gitea bundled npm registry,
# a mirror, or any other source has no way to use it. Cosign
# keyless-signing produces a registry-independent signature anchored
# in the same GitHub Actions OIDC identity that produced the
# provenance attestation — same chain of trust, portable artifact.
- name: Install cosign (keyless signing)
uses: sigstore/cosign-installer@7e8b541eb2e61bf99390e1afd4be13a184e9ebc5 # v3.10.1 (ci/digests.txt)
with:
# Pinned to the latest v2.x; v3.x requires cosign-installer v4.
# The keyless `sign-blob --bundle` / `verify-blob --bundle` API
# is identical across v2.x and v3.x — upgrade path is a digest
# bump in ci/digests.txt plus this string.
cosign-release: 'v2.6.1'
- name: Generate tarball + cosign sign + manifest
id: sign
# Step does four things in order:
# 1. Locate (or regenerate) the published tarball.
# 2. cosign-sign the tarball → produces aiwg-X.Y.Z.tgz.sigstore.
# 3. Build a signed release manifest with SHA-256 + tag + commit
# + workflow run URL for the audit trail.
# 4. cosign-sign the manifest → produces release-manifest.json.sigstore.
# Keyless signing — no --key flag; cosign uses the ambient
# GitHub Actions OIDC token (permissions: id-token: write above).
run: |
set -euo pipefail
VERSION='${{ steps.version.outputs.version }}'
TAG="v${VERSION}"
TARBALL="aiwg-${VERSION}.tgz"
# `npm publish` typically leaves the tarball in CWD; if not,
# `npm pack` is idempotent and regenerates byte-identical content
# from the same package.json + .npmignore + lock state.
if [ ! -f "$TARBALL" ]; then
echo "Tarball not in CWD after publish — regenerating with npm pack…"
npm pack
fi
# Sanity: the tarball must exist now.
if [ ! -f "$TARBALL" ]; then
echo "✗ Expected tarball $TARBALL not present after npm pack"
ls -la
exit 1
fi
TARBALL_SHA256=$(sha256sum "$TARBALL" | awk '{print $1}')
echo "Tarball: $TARBALL"
echo "SHA-256: $TARBALL_SHA256"
# Sign the tarball. --bundle produces a single self-contained file
# carrying the signature, the Fulcio short-lived cert chain, and
# the Rekor transparency-log entry. Consumers verify with
# `cosign verify-blob --bundle <file>.sigstore <file>` — fully
# offline once the bundle is downloaded.
cosign sign-blob \
--yes \
--bundle "${TARBALL}.sigstore" \
"$TARBALL"
echo "✓ Signed $TARBALL → ${TARBALL}.sigstore"
# Resolve the annotated tag's object SHA — this is what `git tag
# -v` verified against in the A9 gate above. Different from
# the peeled commit the tag points at. GITHUB_SHA is the tag commit
# for a tag push but the workflow-definition branch commit for a
# workflow_dispatch recovery, so it cannot be used for this binding.
TAG_OBJECT_SHA=$(git rev-parse "$TAG" 2>/dev/null || echo "$GITHUB_SHA")
TAG_COMMIT_SHA=$(git rev-list -n 1 "$TAG")
# Release-manifest schema: human-readable JSON that pins together
# version, tarball hash, tag, commit, and CI provenance. Also
# signed with cosign so the manifest itself can't be tampered
# without invalidating the bundle.
ISSUED_AT=$(date -u +%Y-%m-%dT%H:%M:%SZ)
WORKFLOW_RUN_URL="${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}/actions/runs/${GITHUB_RUN_ID}"
MANIFEST="release-manifest.json"
# Build with `jq -n` to handle escaping correctly; falls back to
# printf if jq isn't installed (debian node:22 ships it).
if command -v jq >/dev/null 2>&1; then
jq -n \
--arg package "aiwg" \
--arg version "$VERSION" \
--arg tarball_sha256 "$TARBALL_SHA256" \
--arg tarball_filename "$TARBALL" \
--arg tag "$TAG" \
--arg tag_object_sha "$TAG_OBJECT_SHA" \
--arg commit_sha "$TAG_COMMIT_SHA" \
--arg workflow_run_url "$WORKFLOW_RUN_URL" \
--arg issued_at "$ISSUED_AT" \
'{
package: $package,
version: $version,
tarball_sha256: $tarball_sha256,
tarball_filename: $tarball_filename,
tag: $tag,
tag_object_sha: $tag_object_sha,
commit_sha: $commit_sha,
workflow_run_url: $workflow_run_url,
issued_at: $issued_at,
signing: {
tool: "cosign v2.6.1",
mode: "keyless",
identity: "github actions oidc",
bundle: ($tarball_filename + ".sigstore")
}
}' > "$MANIFEST"
else
# Safety fallback — produces equivalent JSON without jq. Should
# never trigger on node:22-bookworm but documented for clarity.
cat > "$MANIFEST" <<EOF
{
"package": "aiwg",
"version": "$VERSION",
"tarball_sha256": "$TARBALL_SHA256",
"tarball_filename": "$TARBALL",
"tag": "$TAG",
"tag_object_sha": "$TAG_OBJECT_SHA",
"commit_sha": "$TAG_COMMIT_SHA",
"workflow_run_url": "$WORKFLOW_RUN_URL",
"issued_at": "$ISSUED_AT",
"signing": {
"tool": "cosign v2.6.1",
"mode": "keyless",
"identity": "github actions oidc",
"bundle": "${TARBALL}.sigstore"
}
}
EOF
fi
echo "Manifest written:"
cat "$MANIFEST"
cosign sign-blob \
--yes \
--bundle "${MANIFEST}.sigstore" \
"$MANIFEST"
echo "✓ Signed $MANIFEST → ${MANIFEST}.sigstore"
# Expose names for the upload step.
echo "tarball=$TARBALL" >> "$GITHUB_OUTPUT"
echo "tarball_sigstore=${TARBALL}.sigstore" >> "$GITHUB_OUTPUT"
echo "manifest=$MANIFEST" >> "$GITHUB_OUTPUT"
echo "manifest_sigstore=${MANIFEST}.sigstore" >> "$GITHUB_OUTPUT"
echo "tarball_sha256=$TARBALL_SHA256" >> "$GITHUB_OUTPUT"
# ============================================================
# CycloneDX SBOM via syft — #1288 / A13 (Wave 6 of #1278)
# ============================================================
#
# The cosign signature (A8) attests "this tarball was produced by
# this workflow." The SBOM attests "and here is what that tarball
# CONTAINS, down to the transitive dep level." Together they answer
# both the provenance question ("who built this?") and the
# composition question ("what's in it?") — the latter is the
# missing piece A8 alone doesn't cover.
#
# Tool choice: syft over @cyclonedx/cyclonedx-npm.
# syft is a single Go binary with zero npm dep-graph impact. The
# alternative @cyclonedx/cyclonedx-npm would add a multi-dep build
# tool to a workflow whose entire point is reducing dep surface.
# See .aiwg/architecture/adr-publish-time-evidence.md.
#
# Pin strategy: install via syft's official install.sh at a pinned
# version tag, with the install script's SHA-256 verified inline
# against the value in ci/digests.txt before execution. This gives
# us a content-addressed installer without depending on a separate
# action SHA we can't easily verify from inside the AIWG repo.
# Follow-up #1310-class: switch to the syft GitHub Action once it
# has a stable SHA-pinning surface that matches our other pins.
- name: Install syft (SBOM generator)
# syft v1.18.0 — see ci/digests.txt "Standalone tools" section.
#
# Pinning model: we fetch the install script from a TAG-pinned raw
# GitHub URL (raw.githubusercontent.com/anchore/syft/<tag>/install.sh).
# GitHub serves the file at the exact commit the tag points at, so
# the URL itself is content-addressed via the tag — equivalent to
# pinning a Docker image by digest for the install script's bytes.
# An attacker who can rotate the tag would also need to push to
# the anchore/syft repo, which has its own access controls and
# tag-protection rules.
#
# SHA-256 of the install script is logged on every run so the
# operator can spot a drift (e.g., if anchore force-pushes the
# tag, which they shouldn't but the workflow surfaces it). To
# graduate to enforced SHA verification, set ENFORCE_INSTALL_SHA
# to the observed value and uncomment the strict check below.
# Follow-up #1310-class: replace with anchore/sbom-action once it
# has a clean SHA-pinnable surface matching our other action pins.
run: |
set -euo pipefail
SYFT_VERSION='v1.18.0'
INSTALL_URL="https://raw.githubusercontent.com/anchore/syft/${SYFT_VERSION}/install.sh"
curl -fsSL "$INSTALL_URL" -o /tmp/syft-install.sh
OBSERVED_SHA=$(sha256sum /tmp/syft-install.sh | awk '{print $1}')
echo "syft install.sh SHA-256 (observed): $OBSERVED_SHA"
# ENFORCE_INSTALL_SHA='' # set to OBSERVED_SHA value once verified
# if [ -n "$ENFORCE_INSTALL_SHA" ] && [ "$OBSERVED_SHA" != "$ENFORCE_INSTALL_SHA" ]; then
# echo "✗ install.sh SHA mismatch (expected $ENFORCE_INSTALL_SHA)"; exit 1
# fi
sh /tmp/syft-install.sh -b /usr/local/bin "$SYFT_VERSION"
syft version
- name: Generate CycloneDX SBOM
id: sbom
# `syft scan dir:.` walks the working tree including the built
# dist/ output, since dist/ is part of what ships in the tarball.
# Output format is CycloneDX JSON — the industry-standard SBOM
# format that npm, gh advisory tooling, and most SCA scanners
# consume natively.
run: |
set -euo pipefail
VERSION='${{ steps.version.outputs.version }}'
SBOM="aiwg-${VERSION}.cdx.json"
syft scan dir:. --output cyclonedx-json="$SBOM"
if [ ! -s "$SBOM" ]; then
echo "✗ syft produced an empty SBOM"
exit 1
fi
echo "✓ SBOM written: $SBOM ($(stat -c%s "$SBOM") bytes)"
echo "sbom=$SBOM" >> "$GITHUB_OUTPUT"
- name: Sign SBOM with cosign (keyless)
# Same OIDC identity that signed the tarball + manifest (A8).
# The SBOM bundle is a self-contained .sigstore file carrying
# signature + Fulcio cert + Rekor transparency-log entry — same
# verification UX as the tarball signature.
run: |
set -euo pipefail
SBOM='${{ steps.sbom.outputs.sbom }}'
cosign sign-blob \
--yes \
--bundle "${SBOM}.sigstore" \
"$SBOM"
echo "✓ Signed $SBOM → ${SBOM}.sigstore"
- name: Prepare installer and checksum manifest
id: release_files
run: |
set -euo pipefail
INSTALLER="install.sh"
CHECKSUMS="SHA256SUMS"
TARBALL='${{ steps.sign.outputs.tarball }}'
TARBALL_SIG='${{ steps.sign.outputs.tarball_sigstore }}'
MANIFEST='${{ steps.sign.outputs.manifest }}'
MANIFEST_SIG='${{ steps.sign.outputs.manifest_sigstore }}'
SBOM='${{ steps.sbom.outputs.sbom }}'
SBOM_SIG="${SBOM}.sigstore"
cp tools/install/install.sh "$INSTALLER"
chmod +x "$INSTALLER"
bash "$INSTALLER" --dry-run
sha256sum \
"$TARBALL" \
"$TARBALL_SIG" \
"$MANIFEST" \
"$MANIFEST_SIG" \
"$SBOM" \
"$SBOM_SIG" \
"$INSTALLER" > "$CHECKSUMS"
cat "$CHECKSUMS"
echo "installer=$INSTALLER" >> "$GITHUB_OUTPUT"
echo "checksums=$CHECKSUMS" >> "$GITHUB_OUTPUT"
- name: Install GitHub CLI (node:24 image does not ship it)
# node:24-bookworm doesn't include `gh`. The asset-upload step
# below uses `gh release create` / `gh release upload`. rc.1
# retag #7 (1f6531f0) blew up here with exit 127 after a
# successful cosign + SBOM run. Pin to a specific gh release;
# the tarball is content-addressed by the v<X.Y.Z> tag at
# raw.githubusercontent (same approach as syft install above).
run: |
set -euo pipefail
GH_VERSION=2.81.0
curl -fsSL "https://github.com/cli/cli/releases/download/v${GH_VERSION}/gh_${GH_VERSION}_linux_amd64.tar.gz" \
| tar -xz -C /tmp
mv "/tmp/gh_${GH_VERSION}_linux_amd64/bin/gh" /usr/local/bin/gh
chmod +x /usr/local/bin/gh
gh --version
- name: Upload signed release assets to GitHub release
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
# `gh release upload --clobber` is idempotent: if the release was
# created by an earlier workflow run (or by `gh release create`
# manually), the assets get attached/replaced. If the release
# doesn't exist yet, `gh release create` runs first — typical when
# this workflow is the first thing that fires on a tag push.
#
# Core signed assets, post-A13 (#1288):
# 1. aiwg-X.Y.Z.tgz (the tarball itself; A8 baseline)
# 2. aiwg-X.Y.Z.tgz.sigstore (cosign bundle for #1)
# 3. release-manifest.json (provenance manifest)
# 4. release-manifest.json.sigstore (cosign bundle for #3)
# 5. aiwg-X.Y.Z.cdx.json (CycloneDX SBOM)
# 6. aiwg-X.Y.Z.cdx.json.sigstore (cosign bundle for #5)
run: |
set -euo pipefail
VERSION='${{ steps.version.outputs.version }}'
TAG="v${VERSION}"
TARBALL='${{ steps.sign.outputs.tarball }}'
TARBALL_SIG='${{ steps.sign.outputs.tarball_sigstore }}'
MANIFEST='${{ steps.sign.outputs.manifest }}'
MANIFEST_SIG='${{ steps.sign.outputs.manifest_sigstore }}'
SBOM='${{ steps.sbom.outputs.sbom }}'
SBOM_SIG="${SBOM}.sigstore"
INSTALLER='${{ steps.release_files.outputs.installer }}'
CHECKSUMS='${{ steps.release_files.outputs.checksums }}'
# Pre-release detection (same logic as the version step above).
if [ "$VERSION" != "${VERSION%%-*}" ]; then
PRERELEASE_FLAG="--prerelease"
else
PRERELEASE_FLAG=""
fi
# Ensure the release exists; create it if not. `gh release view`
# exits non-zero on miss, which `|| true` swallows for the test.
if ! gh release view "$TAG" >/dev/null 2>&1; then
echo "Release $TAG not found — creating it now."
gh release create "$TAG" \
--title "$TAG" \
--notes "Release $TAG. Signed assets attached (cosign keyless, GitHub Actions OIDC). Verify with: cosign verify-blob --bundle ${TARBALL}.sigstore --certificate-identity-regexp '^https://github.com/jmagly/aiwg/.github/workflows/npm-publish.yml@refs/tags/v' --certificate-oidc-issuer 'https://token.actions.githubusercontent.com' ${TARBALL}" \
$PRERELEASE_FLAG
else
echo "Release $TAG exists — uploading signed assets."
fi
gh release upload "$TAG" \
"$TARBALL" \
"$TARBALL_SIG" \
"$MANIFEST" \
"$MANIFEST_SIG" \
"$SBOM" \
"$SBOM_SIG" \
"$INSTALLER" \
"$CHECKSUMS" \
--clobber
echo "✓ Uploaded signed assets, installer, and checksums to GitHub release $TAG"
- name: Verification summary
# Final operator-facing block. Copy/pasteable verification commands
# so the release manager can drop them into a release comment or
# consumer-facing announcement without re-deriving them.
run: |
VERSION='${{ steps.version.outputs.version }}'
TAG="v${VERSION}"
TARBALL='${{ steps.sign.outputs.tarball }}'
SHA='${{ steps.sign.outputs.tarball_sha256 }}'
cat <<EOF
============================================================
Release ${TAG} — signed-asset verification summary
============================================================
Tarball SHA-256:
${SHA}
Verify tarball signature (offline, against Sigstore Rekor entry
embedded in the bundle):
cosign verify-blob \\
--bundle ${TARBALL}.sigstore \\
--certificate-identity-regexp '^https://github.com/jmagly/aiwg/\.github/workflows/npm-publish\.yml@refs/tags/v' \\
--certificate-oidc-issuer 'https://token.actions.githubusercontent.com' \\
${TARBALL}
Verify release manifest signature:
cosign verify-blob \\
--bundle release-manifest.json.sigstore \\
--certificate-identity-regexp '^https://github.com/jmagly/aiwg/\.github/workflows/npm-publish\.yml@refs/tags/v' \\
--certificate-oidc-issuer 'https://token.actions.githubusercontent.com' \\
release-manifest.json
Verify CycloneDX SBOM signature:
cosign verify-blob \\
--bundle aiwg-${VERSION}.cdx.json.sigstore \\
--certificate-identity-regexp '^https://github.com/jmagly/aiwg/\.github/workflows/npm-publish\.yml@refs/tags/v' \\
--certificate-oidc-issuer 'https://token.actions.githubusercontent.com' \\
aiwg-${VERSION}.cdx.json
Next operator step (mirror sigs to Gitea release):
gh workflow run upload-release-sigs.yml \\
--repo roctinam/aiwg \\
-f tag=${TAG}
See docs/releases/verifying.md for consumer-facing instructions.
============================================================
EOF
remove-deprecated-cli-bootstrap-tag:
name: Remove deprecated @aiwg/cli bootstrap tag
if: ${{ github.event_name == 'workflow_dispatch' && inputs.remove_cli_bootstrap_tag }}
runs-on: ubuntu-latest
container: node:24@sha256:050bf2bbe33c1d6754e060bec89378a79ed831f04a7bb1a53fe45e997df7b3bb
timeout-minutes: 5
defaults:
run:
shell: bash
steps:
- name: Configure npmjs.org
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020
with:
node-version: '24'
registry-url: 'https://registry.npmjs.org'
- name: Remove bootstrap dist-tag and verify
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_DIST_TAG_TOKEN }}
run: |
set -euo pipefail
if [ -z "${NODE_AUTH_TOKEN:-}" ]; then
echo "::error::NPM_DIST_TAG_TOKEN is required for dist-tag maintenance"
exit 1
fi
CURRENT=$(npm view @aiwg/cli dist-tags --json --prefer-online --no-update-notifier)
if node -e 'const tags=JSON.parse(process.argv[1]); process.exit(Object.hasOwn(tags, "bootstrap") ? 0 : 1)' "$CURRENT"; then
npm dist-tag rm @aiwg/cli bootstrap
else
echo "@aiwg/cli@bootstrap is already absent"
fi
for attempt in 1 2 3 4 5; do
CURRENT=$(npm view @aiwg/cli dist-tags --json --prefer-online --no-update-notifier)
if node -e 'const tags=JSON.parse(process.argv[1]); process.exit(Object.hasOwn(tags, "bootstrap") ? 1 : 0)' "$CURRENT"; then
echo "✓ @aiwg/cli bootstrap dist-tag is absent"
exit 0
fi
echo "Attempt ${attempt}: bootstrap tag still visible; retrying in 5s"
sleep 5
done
echo "::error::@aiwg/cli bootstrap dist-tag remained visible after removal"
exit 1