From ec22b4beab0baeecc9b69b013b178ec6308f314b Mon Sep 17 00:00:00 2001 From: Chris Tate Date: Wed, 30 Sep 2026 17:14:31 -0500 Subject: [PATCH] fix: resume asynchronous npm releases (#594) - Preserve accepted uploads and continue past pending npm publications. - Reuse completed native builds when only the release workflow changes. - Require public packages and complete assets before finishing a release. --- .github/workflows/release.yml | 216 ++++++++++++++++++++++++++-------- 1 file changed, 169 insertions(+), 47 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 286b9f57..430790e5 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -18,12 +18,18 @@ jobs: timeout-minutes: 5 permissions: contents: read + actions: read outputs: should_release: ${{ steps.check.outputs.should_release }} version: ${{ steps.check.outputs.version }} + artifact_run: ${{ steps.reuse.outputs.artifact_run || github.run_id }} + reuse_artifacts: ${{ steps.reuse.outputs.artifact_run != '' }} + receipt_run: ${{ steps.reuse.outputs.receipt_run }} steps: - name: Checkout repository uses: actions/checkout@v4 + with: + fetch-depth: 0 - name: Setup Node.js uses: actions/setup-node@v4 @@ -31,28 +37,69 @@ jobs: node-version: "24" registry-url: "https://registry.npmjs.org" - - name: Compare package.json version to npm + - name: Check npm version and GitHub release id: check + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: | LOCAL_VERSION=$(node -p "require('./packages/cli/package.json').version") echo "Local version: $LOCAL_VERSION" - - NPM_VERSION=$(npm view scriptc version 2>/dev/null || echo "0.0.0") - echo "npm version: $NPM_VERSION" - - if [ "$LOCAL_VERSION" != "$NPM_VERSION" ]; then - echo "Version changed: $NPM_VERSION -> $LOCAL_VERSION" - echo "should_release=true" >> "$GITHUB_OUTPUT" - else - echo "Version unchanged on npm, skipping publish" + RELEASE_DRAFT=$(gh release view "v$LOCAL_VERSION" --json isDraft --jq .isDraft 2>/dev/null || echo missing) + if npm view "scriptc@$LOCAL_VERSION" version >/dev/null 2>&1 && [ "$RELEASE_DRAFT" = false ]; then + echo "npm and GitHub release are complete" echo "should_release=false" >> "$GITHUB_OUTPUT" + else + echo "Release needs publishing or completion" + echo "should_release=true" >> "$GITHUB_OUTPUT" fi echo "version=$LOCAL_VERSION" >> "$GITHUB_OUTPUT" + - name: Find reusable builds and accepted upload receipts + id: reuse + if: steps.check.outputs.should_release == 'true' + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + VERSION: ${{ steps.check.outputs.version }} + run: | + node --input-type=module <<'NODE' + import { execFileSync, spawnSync } from 'node:child_process'; + import { appendFileSync } from 'node:fs'; + const api = (path) => JSON.parse(execFileSync('gh', ['api', path], { encoding: 'utf8', maxBuffer: 8 * 1024 * 1024 })); + const repo = process.env.GITHUB_REPOSITORY; + const expected = [ + 'darwin-arm64', 'darwin-x64', 'linux-x64', 'linux-arm64', + 'linux-x64-musl', 'linux-arm64-musl', 'windows-x64', 'wasm32-wasi', + 'runtime-ios-arm64', 'runtime-ios-simulator-arm64', 'runtime-android-arm64', + ].map((platform) => `native-${platform}`); + const runs = api(`repos/${repo}/actions/workflows/release.yml/runs?branch=main&status=completed&per_page=100`).workflow_runs; + let artifactRun; + let receiptRun; + for (const run of runs) { + if (String(run.id) === process.env.GITHUB_RUN_ID || run.head_branch !== 'main' || + run.head_repository?.full_name !== repo || !['push', 'workflow_dispatch'].includes(run.event)) continue; + // Only workflow edits may differ from the source of reused packages. + if (spawnSync('git', ['merge-base', '--is-ancestor', run.head_sha, 'HEAD']).status !== 0 || + spawnSync('git', ['diff', '--quiet', run.head_sha, 'HEAD', '--', '.', ':(exclude).github/workflows/release.yml']).status !== 0) continue; + const artifacts = api(`repos/${repo}/actions/runs/${run.id}/artifacts?per_page=100`).artifacts + .filter((artifact) => !artifact.expired && artifact.size_in_bytes > 0); + if (!artifactRun && expected.every((name) => artifacts.filter((artifact) => artifact.name === name).length === 1)) { + artifactRun = run.id; + console.log(`Reusing native builds from run ${run.id} (${run.head_sha})`); + } + if (!receiptRun && artifacts.some((artifact) => artifact.name.startsWith(`npm-accepted-${process.env.VERSION}-`))) { + receiptRun = run.id; + console.log(`Restoring accepted uploads from run ${run.id}`); + } + if (artifactRun && receiptRun) break; + } + if (artifactRun) appendFileSync(process.env.GITHUB_OUTPUT, `artifact_run=${artifactRun}\n`); + if (receiptRun) appendFileSync(process.env.GITHUB_OUTPUT, `receipt_run=${receiptRun}\n`); + NODE + build-native-packages: name: Build native package (${{ matrix.platform }}) needs: check-release - if: needs.check-release.outputs.should_release == 'true' + if: needs.check-release.outputs.should_release == 'true' && needs.check-release.outputs.reuse_artifacts != 'true' strategy: fail-fast: false matrix: @@ -201,12 +248,12 @@ jobs: packages/${{ matrix.helper }} packages/${{ matrix.runtime }} ${{ matrix.cli != '' && format('packages/{0}/dist', matrix.cli) || '' }} - retention-days: 1 + retention-days: 7 build-mobile-runtime-packs: name: Build ${{ matrix.runtime }} needs: check-release - if: needs.check-release.outputs.should_release == 'true' + if: needs.check-release.outputs.should_release == 'true' && needs.check-release.outputs.reuse_artifacts != 'true' strategy: fail-fast: false matrix: @@ -239,17 +286,21 @@ jobs: with: name: native-${{ matrix.runtime }} path: ${{ runner.temp }}/mobile-pack - retention-days: 1 + retention-days: 7 publish: name: Publish to npm needs: [check-release, build-native-packages, build-mobile-runtime-packs] - if: needs.check-release.outputs.should_release == 'true' + if: >- + !cancelled() && needs.check-release.outputs.should_release == 'true' && + (needs.check-release.outputs.reuse_artifacts == 'true' || + (needs.build-native-packages.result == 'success' && needs.build-mobile-runtime-packs.result == 'success')) runs-on: macos-15 timeout-minutes: 15 environment: Release permissions: contents: read + actions: read id-token: write steps: - name: Checkout repository @@ -261,6 +312,25 @@ jobs: pattern: native-* path: native-artifacts merge-multiple: true + run-id: ${{ needs.check-release.outputs.artifact_run }} + github-token: ${{ secrets.GITHUB_TOKEN }} + + - name: Restore accepted upload receipts + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + VERSION: ${{ needs.check-release.outputs.version }} + RECEIPT_RUN: ${{ needs.check-release.outputs.receipt_run }} + run: | + touch "$RUNNER_TEMP/npm-accepted.txt" + # Include this run so re-running only failed jobs restores its receipts. + for run in "$GITHUB_RUN_ID" "$RECEIPT_RUN"; do + if [ -z "$run" ]; then continue; fi + count=$(gh api "repos/$GITHUB_REPOSITORY/actions/runs/$run/artifacts?per_page=100" \ + --jq "[.artifacts[] | select(.expired == false and (.name | startswith(\"npm-accepted-$VERSION-\")))] | length") + if [ "$count" -eq 0 ]; then continue; fi + gh run download "$run" --repo "$GITHUB_REPOSITORY" --pattern "npm-accepted-$VERSION-*" --dir "$RUNNER_TEMP/npm-receipts/$run" + find "$RUNNER_TEMP/npm-receipts/$run" -name npm-accepted.txt -exec cat {} + >> "$RUNNER_TEMP/npm-accepted.txt" + done - name: Setup pnpm uses: pnpm/action-setup@v4 @@ -316,9 +386,11 @@ jobs: echo "HELPER_TARBALL=$HELPER_TARBALL" >> "$GITHUB_ENV" - name: Publish to npm + shell: bash + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + VERSION: ${{ needs.check-release.outputs.version }} run: | - VERSION="${{ needs.check-release.outputs.version }}" - # npm accepts --provenance only from PUBLIC source repositories; # while this repo is internal the flag is dropped, and the same # step starts attaching provenance the moment the repo goes @@ -331,32 +403,19 @@ jobs: echo "repository visibility is '$VISIBILITY': publishing without provenance" fi - # Dependency order, so each package's deps are resolvable the - # moment it lands. pnpm pack rewrites workspace:* to the real - # version; npm publish on the tarball handles OIDC. - # Re-runs skip anything already on the registry at this version. - # npm may finish a trusted publication asynchronously. In that - # window a retry can report "previously staged" even though the - # same package/version is about to become publicly visible. - wait_for_published_version() { - name="$1" - for attempt in $(seq 1 12); do - if npm view "$name@$VERSION" version >/dev/null 2>&1; then - return 0 - fi - if [ "$attempt" -lt 12 ]; then - sleep 10 - fi - done - return 1 - } - + # Upload acceptance and public availability are separate states. + # Save acceptance immediately so retries never depend on registry lag. publish_dir() { dir="$1" packed="${2:-}" name=$(node -p "require('./$dir/package.json').name") if npm view "$name@$VERSION" version >/dev/null 2>&1; then echo "$name@$VERSION already published, skipping" + echo "$name@$VERSION" >> "$RUNNER_TEMP/npm-accepted.txt" + return 0 + fi + if grep -Fxq "$name@$VERSION" "$RUNNER_TEMP/npm-accepted.txt"; then + echo "$name@$VERSION upload already accepted; skipping upload while npm processes it" return 0 fi if [ -z "$packed" ]; then @@ -368,19 +427,67 @@ jobs: node scripts/verify-llvm-package-mode.mjs "$packed" "$name" ;; esac - if npm publish "$packed" $PROVENANCE --access public; then + publish_log="$RUNNER_TEMP/npm-publish.log" + if npm publish "$packed" $PROVENANCE --access public 2>&1 | tee "$publish_log"; then + echo "$name@$VERSION" >> "$RUNNER_TEMP/npm-accepted.txt" return 0 else - publish_status=$? + publish_status=${PIPESTATUS[0]} fi - echo "$name@$VERSION publish failed; waiting for registry visibility before retrying" - if wait_for_published_version "$name"; then - echo "$name@$VERSION is now published; treating the publish as successful" + if [ "$publish_status" -eq 0 ]; then return 1; fi + # Older runs have no receipts. This specific conflict means npm + # already accepted this version; other publish failures still fail. + if grep -Fq 'npm error code E409' "$publish_log" && \ + grep -Fq "Cannot publish over previously staged version \"$VERSION\"." "$publish_log"; then + echo "$name@$VERSION is already accepted and awaiting npm processing" + echo "$name@$VERSION" >> "$RUNNER_TEMP/npm-accepted.txt" + return 0 + fi + if npm view "$name@$VERSION" version >/dev/null 2>&1; then + echo "$name@$VERSION is now public" + echo "$name@$VERSION" >> "$RUNNER_TEMP/npm-accepted.txt" return 0 fi return "$publish_status" } + # Check exact public versions concurrently. A staged upload is never + # sufficient to publish a dependent package or finish the release. + require_public_versions() { + node --input-type=module - "$@" <<'NODE' + import { readFileSync } from 'node:fs'; + const version = process.env.VERSION; + let pending = process.argv.slice(2) + .map((dir) => JSON.parse(readFileSync(`${dir}/package.json`, 'utf8'))) + .filter((pkg) => pkg.private !== true).map((pkg) => pkg.name); + for (let attempt = 0; attempt < 12; attempt++) { + pending = (await Promise.all(pending.map(async (name) => { + try { + const response = await fetch(`https://registry.npmjs.org/${encodeURIComponent(name)}`, { + headers: { accept: 'application/vnd.npm.install-v1+json' }, + signal: AbortSignal.timeout(10000), + }); + if (response.ok) { + const metadata = (await response.json()).versions?.[version]; + if (metadata?.name === name && metadata.version === version && metadata.dist?.tarball) { + const tarball = await fetch(metadata.dist.tarball, { method: 'HEAD', signal: AbortSignal.timeout(10000) }); + if (tarball.ok) return null; + } + } + } catch {} + return name; + }))).filter(Boolean); + if (pending.length === 0) process.exit(0); + console.log(`Awaiting public availability: ${pending.map((name) => `${name}@${version}`).join(', ')}`); + if (attempt < 11) await new Promise((resolve) => setTimeout(resolve, 10000)); + } + console.error('::error::npm has not made every accepted upload public. Receipts are saved; rerun failed jobs to resume without re-uploading.'); + process.exit(1); + NODE + } + + # pnpm pack resolves workspace dependencies; npm handles OIDC. + # Upload all independent platform packages before waiting for npm. publish_dir packages/runtime publish_dir packages/runtime-darwin-arm64 publish_dir packages/llvm-darwin-arm64 "$HELPER_TARBALL" @@ -400,22 +507,33 @@ jobs: publish_dir packages/runtime-wasm32-wasi publish_dir packages/runtime-win32-x64-msvc publish_dir packages/llvm-win32-x64-msvc - publish_dir packages/compiler for pkg in packages/cli-*; do publish_dir "$pkg"; done + require_public_versions packages/runtime packages/runtime-* packages/llvm-* packages/cli-* + publish_dir packages/compiler publish_dir packages/cli - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + require_public_versions packages/compiler packages/cli + + - name: Save accepted upload receipts + if: always() + uses: actions/upload-artifact@v4 + with: + name: npm-accepted-${{ needs.check-release.outputs.version }}-${{ github.run_attempt }} + path: ${{ runner.temp }}/npm-accepted.txt + if-no-files-found: ignore + retention-days: 90 # Publish standalone native distributions and the surface manifest after # npm publishing succeeds. The release body comes from the marked changelog. github-release: name: Create GitHub Release needs: [check-release, publish] - if: needs.check-release.outputs.should_release == 'true' + if: >- + !cancelled() && needs.check-release.outputs.should_release == 'true' && needs.publish.result == 'success' runs-on: ubuntu-latest timeout-minutes: 10 permissions: contents: write + actions: read steps: - name: Checkout repository uses: actions/checkout@v4 @@ -431,7 +549,7 @@ jobs: node-version: "24" # Regenerate the surface manifest from this tree and require it to - # match the committed file byte-for-byte — the same staleness guard + # match the committed file exactly — the same staleness guard # the test suite runs — so the attached asset is provably the # manifest of the code being released. - name: Generate surface manifest @@ -457,6 +575,8 @@ jobs: pattern: native-* path: packages merge-multiple: true + run-id: ${{ needs.check-release.outputs.artifact_run }} + github-token: ${{ secrets.GITHUB_TOKEN }} - name: Package standalone compilers run: node scripts/package-native-cli.mjs packages /tmp/scriptc-release-assets @@ -473,6 +593,7 @@ jobs: gh release create "$TAG" \ --target "$GITHUB_SHA" \ --title "$TAG" \ + --draft \ --notes-file /tmp/release-notes.md fi @@ -480,5 +601,6 @@ jobs: # re-runs replace the asset instead of failing). gh release upload "$TAG" packages/compiler/surface-manifest.json --clobber gh release upload "$TAG" /tmp/scriptc-release-assets/* --clobber + gh release edit "$TAG" --draft=false env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}