chore: initial spec-forge project import #6
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
| 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 "::error::GitHub Release v${version} already has assets. Provide recover-run-id to reuse original artifacts." >&2 | |
| exit 1 | |
| 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())); | |
| for (const name of names) { | |
| const r = spawnSync('npm', ['view', name + '@' + v, 'version'], { encoding: 'utf8', stdio: 'pipe' }); | |
| if (r.status === 0) { | |
| console.error(name + '@' + v + ' already published. Provide recover-run-id to reuse original artifacts.'); | |
| process.exit(1); | |
| } | |
| const stderr = (r.stderr ?? '').toLowerCase(); | |
| const notFound = stderr.includes('e404') || stderr.includes('etarget') || stderr.includes('not found'); | |
| if (!notFound) throw new Error('npm view ' + name + '@' + v + ' failed: ' + (r.stderr ?? '').trim()); | |
| } | |
| " | |
| 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 |