chore(release): prepare AIWG 2026.8.3 #107
Workflow file for this run
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| # 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 |