mirror of
https://github.com/vercel-labs/scriptc.git
synced 2026-10-02 00:25:34 +08:00
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.
This commit is contained in:
+169
-47
@@ -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 }}
|
||||
|
||||
Reference in New Issue
Block a user