Skip to content

docs: refine spec-forge documentation #10

docs: refine spec-forge documentation

docs: refine spec-forge documentation #10

Workflow file for this run

name: Release
on:
push:
branches: [main]
workflow_dispatch:
inputs:
recover-version:
description: "Recover a wedged release: set to the version string (e.g. 1.2.3) to republish npm packages for an existing tag without running semantic-release."
required: false
default: ""
recover-run-id:
description: "Optional original release run ID. When set, downloads the exact build artifacts from that run instead of rebuilding."
required: false
default: ""
permissions:
actions: read
contents: write
id-token: write
issues: write
pull-requests: write
concurrency:
group: release-${{ github.ref }}
cancel-in-progress: false
env:
CARGO_TERM_COLOR: always
RUST_BACKTRACE: 1
defaults:
run:
shell: bash
working-directory: spec-forge-cli
jobs:
release:
runs-on: macos-14
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
steps:
- name: Checkout
uses: actions/checkout@v6
with:
fetch-depth: 0
ref: ${{ inputs['recover-version'] != null && inputs['recover-version'] != '' && format('refs/tags/v{0}', inputs['recover-version']) || '' }}
- name: Setup Node.js
uses: actions/setup-node@v6
with:
node-version: 24
registry-url: https://registry.npmjs.org
oidc-match: ${{ vars.NPM_PUBLISHER_PATTERN || '*' }}
- name: Read release config
id: cfg
run: |
set -euo pipefail
targets=$(node -e "const c=require('./release/config.json');console.log(c.targets.map(t=>t.rustTarget).join(','))")
cli=$(node -e "const c=require('./release/config.json');console.log(c.cliName)")
echo "targets=$targets" >> "$GITHUB_OUTPUT"
echo "cli=$cli" >> "$GITHUB_OUTPUT"
- name: Setup Rust
uses: dtolnay/rust-toolchain@stable
with:
components: rustfmt, clippy
targets: ${{ steps.cfg.outputs.targets }}
- name: Restore Rust cache
uses: Swatinem/rust-cache@v2
with:
workspaces: "spec-forge-cli -> target"
- name: Setup cross-build environment
uses: ./spec-forge-cli/.github/actions/setup-build-env
- name: Install release harness dependencies
run: npm ci
- name: Verify release config
run: node scripts/release/validate-config.mjs
- name: Rust quality gates
if: ${{ inputs['recover-version'] == null || inputs['recover-version'] == '' }}
run: |
set -euo pipefail
cargo fmt --all -- --check
cargo clippy --all-targets --all-features -- -D warnings
cargo test
- name: Download original build artifacts
if: ${{ inputs['recover-version'] != null && inputs['recover-version'] != '' && inputs['recover-run-id'] != null && inputs['recover-run-id'] != '' }}
uses: actions/download-artifact@v4
with:
name: release-dist
path: spec-forge-cli/dist/
run-id: ${{ inputs['recover-run-id'] }}
- name: Resolve checked-out commit SHA
if: ${{ inputs['recover-version'] != null && inputs['recover-version'] != '' && inputs['recover-run-id'] != null && inputs['recover-run-id'] != '' }}
id: checkout-sha
run: echo "sha=$(git rev-parse HEAD)" >> "$GITHUB_OUTPUT"
- name: Verify artifact provenance
if: ${{ inputs['recover-version'] != null && inputs['recover-version'] != '' && inputs['recover-run-id'] != null && inputs['recover-run-id'] != '' }}
run: |
node -e "
const fs = require('fs');
const provPath = './dist/provenance.json';
if (!fs.existsSync(provPath)) {
console.log('No provenance.json found, skipping verification.');
process.exit(0);
}
const p = JSON.parse(fs.readFileSync(provPath, 'utf8'));
const expectedVersion = process.env.EXPECTED_VERSION;
const expectedSha = process.env.EXPECTED_SHA;
const expectedRepo = process.env.EXPECTED_REPO;
if (p.version !== expectedVersion) {
console.error('Artifact version ' + p.version + ' does not match requested ' + expectedVersion + '.');
process.exit(1);
}
if (p.commitSha && p.commitSha !== expectedSha) {
console.error('Artifact commit ' + p.commitSha + ' does not match checkout ' + expectedSha + '.');
process.exit(1);
}
if (p.repository && p.repository !== expectedRepo) {
console.error('Artifact repository ' + p.repository + ' does not match ' + expectedRepo + '.');
process.exit(1);
}
console.log('Provenance OK: v' + p.version + ' @ ' + (p.commitSha || 'unknown'));
"
env:
EXPECTED_VERSION: ${{ inputs['recover-version'] }}
EXPECTED_SHA: ${{ steps.checkout-sha.outputs.sha }}
EXPECTED_REPO: ${{ github.repository }}
- name: Verify already-complete recovery
if: ${{ inputs['recover-version'] != null && inputs['recover-version'] != '' }}
id: already-complete
run: |
set -euo pipefail
version="${{ inputs['recover-version'] }}"
echo "Checking if v${version} is already fully published..."
missing_reasons=""
if ! release_assets_json="$(gh release view "v${version}" --json assets 2>/dev/null)"; then
missing_reasons="github_release_missing"
else
missing_reasons="$(RELEASE_ASSETS_JSON="$release_assets_json" node scripts/release/check-release-complete.mjs "$version")"
fi
if [[ "$missing_reasons" == "github_release_missing" ]]; then
npm_check_output="$(node scripts/release/check-release-complete.mjs "$version")"
if [[ -n "$npm_check_output" ]]; then
missing_reasons="${missing_reasons};${npm_check_output}"
fi
fi
if [[ -z "$missing_reasons" ]]; then
echo "Version v${version} is already fully published."
echo "is_complete=true" >> "$GITHUB_OUTPUT"
else
echo "Version v${version} is not yet complete (${missing_reasons}). Proceeding with recovery."
echo "is_complete=false" >> "$GITHUB_OUTPUT"
fi
- name: Report already-complete recovery
if: ${{ steps.already-complete.outputs.is_complete == 'true' }}
run: |
version="${{ inputs['recover-version'] }}"
mkdir -p ../.cli-forge
echo "is_recovery: true" > ../.cli-forge/release-receipt.yml
echo "recovery_version: \"${version}\"" >> ../.cli-forge/release-receipt.yml
echo "outcome: already_complete" >> ../.cli-forge/release-receipt.yml
- name: Build release binaries
if: ${{ inputs['recover-version'] != null && inputs['recover-version'] != '' && (inputs['recover-run-id'] == null || inputs['recover-run-id'] == '') && steps.already-complete.outputs.is_complete != 'true' }}
run: |
echo "::warning::Rebuilding from source. Provide recover-run-id for byte-for-byte original artifacts."
node scripts/release/build-binaries.mjs "${{ inputs['recover-version'] }}"
- name: Recover wedged release
if: ${{ inputs['recover-version'] != null && inputs['recover-version'] != '' && steps.already-complete.outputs.is_complete != 'true' }}
run: |
set -euo pipefail
version="${{ inputs['recover-version'] }}"
mkdir -p ../.cli-forge
echo "is_recovery: true" > ../.cli-forge/release-receipt.yml
echo "recovery_version: \"${version}\"" >> ../.cli-forge/release-receipt.yml
if [[ -z "${{ inputs['recover-run-id'] }}" ]]; then
if gh release view "v${version}" --json assets -q '.assets | length' 2>/dev/null | grep -qv '^0$'; then
echo "::notice::GitHub Release v${version} already has assets. Recovery will reuse them and only upload missing files."
fi
node --input-type=module -e "
import { spawnSync } from 'node:child_process';
import { buildAllPackageNames, readReleaseConfig } from './scripts/release/release-config.mjs';
const v = '${version}';
const names = buildAllPackageNames(readReleaseConfig(process.cwd()));
const already = [];
const missing = [];
for (const name of names) {
const r = spawnSync('npm', ['view', name + '@' + v, 'version'], { encoding: 'utf8', stdio: 'pipe' });
if (r.status === 0) {
if (r.stdout.trim() === v) {
already.push(name + '@' + v);
continue;
}
}
const stderr = (r.stderr ?? '').toLowerCase();
const notFound = stderr.includes('e404') || stderr.includes('etarget') || stderr.includes('not found');
if (r.status !== 0) {
if (!notFound) throw new Error('npm view ' + name + '@' + v + ' failed: ' + (r.stderr ?? '').trim());
missing.push(name + '@' + v);
continue;
}
missing.push(name + '@' + v);
}
if (already.length > 0) {
console.log('Recovery preflight: already published npm packages: ' + already.join(', '));
}
if (missing.length > 0) {
console.log('Recovery preflight: missing npm packages: ' + missing.join(', '));
}
"
fi
node scripts/release/sync-platform-packages.mjs "$version"
npm_output="$(node scripts/release/publish-npm-packages.mjs "$version" 2>&1)"
echo "$npm_output"
if echo "$npm_output" | grep -q '^skipped_steps:'; then
echo "" >> ../.cli-forge/release-receipt.yml
echo "$npm_output" | sed -n '/^skipped_steps:/,$p' >> ../.cli-forge/release-receipt.yml
fi
gh release view "v${version}" >/dev/null 2>&1 || gh release create "v${version}" --title "v${version}" --notes "Recovered release v${version}."
existing=$(gh release view "v${version}" --json assets -q '.assets[].name' 2>/dev/null || true)
upload_files=""
for f in dist/*.tar.gz dist/*.sha256 dist/provenance.json; do
[ -f "$f" ] || continue
bn=$(basename "$f")
if ! echo "$existing" | grep -qxF "$bn"; then
upload_files="$upload_files $f"
else
echo "skip upload ${bn} (already on release)"
fi
done
if [ -n "$upload_files" ]; then
# shellcheck disable=SC2086
gh release upload "v${version}" $upload_files
fi
- name: Run semantic-release
if: ${{ inputs['recover-version'] == null || inputs['recover-version'] == '' }}
run: npx --yes semantic-release
env:
GIT_AUTHOR_NAME: github-actions[bot]
GIT_AUTHOR_EMAIL: github-actions[bot]@users.noreply.github.com
GIT_COMMITTER_NAME: github-actions[bot]
GIT_COMMITTER_EMAIL: github-actions[bot]@users.noreply.github.com
- name: Preserve build artifacts
if: always()
uses: actions/upload-artifact@v4
with:
name: release-dist
path: spec-forge-cli/dist/
retention-days: 30