-
Notifications
You must be signed in to change notification settings - Fork 1
fix(release): make npm publish resumable #567
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -39,8 +39,20 @@ on: | |
| required: true | ||
| default: false | ||
| type: boolean | ||
| resume_publish: | ||
| description: 'Resume an interrupted release by reusing exact package versions already published to npm' | ||
| required: true | ||
| default: false | ||
| type: boolean | ||
| resume_source_sha: | ||
| description: 'Original failed release commit SHA (required with resume_publish)' | ||
| required: false | ||
| resume_run_id: | ||
| description: 'Original failed GitHub Actions run ID (required with resume_publish)' | ||
| required: false | ||
|
|
||
| permissions: | ||
| actions: read | ||
| contents: write | ||
| id-token: write | ||
|
|
||
|
|
@@ -94,9 +106,73 @@ jobs: | |
| runs-on: ubuntu-latest | ||
| outputs: | ||
| release_version: ${{ steps.compute.outputs.release_version }} | ||
| recovery_source: ${{ steps.recovery.outputs.source_commit }} | ||
| recovery_run_id: ${{ steps.recovery.outputs.run_id }} | ||
| recovery_release_date: ${{ steps.recovery.outputs.release_date }} | ||
| steps: | ||
| - name: Checkout | ||
| uses: actions/checkout@v6 | ||
| with: | ||
| fetch-depth: 0 | ||
|
|
||
| - name: Validate recovery inputs and source | ||
| id: recovery | ||
| if: ${{ github.event.inputs.resume_publish == 'true' }} | ||
| env: | ||
| CUSTOM_VERSION: ${{ github.event.inputs.custom_version }} | ||
| RESUME_SOURCE_SHA: ${{ github.event.inputs.resume_source_sha }} | ||
| RESUME_RUN_ID: ${{ github.event.inputs.resume_run_id }} | ||
| GH_TOKEN: ${{ github.token }} | ||
| run: | | ||
| set -euo pipefail | ||
| if [ -z "$CUSTOM_VERSION" ]; then | ||
| echo "::error title=Exact recovery version required::Set custom_version to the interrupted release version when resume_publish is enabled." | ||
| exit 1 | ||
| fi | ||
| if [ -z "$RESUME_SOURCE_SHA" ]; then | ||
| echo "::error title=Recovery source required::Set resume_source_sha to the original failed release commit when resume_publish is enabled." | ||
| exit 1 | ||
| fi | ||
| if ! [[ "$RESUME_RUN_ID" =~ ^[0-9]+$ ]]; then | ||
| echo "::error title=Recovery run required::Set resume_run_id to the numeric ID of the original failed release run." | ||
| exit 1 | ||
| fi | ||
|
|
||
| SOURCE_COMMIT=$(git rev-parse "$RESUME_SOURCE_SHA^{commit}" 2>/dev/null || true) | ||
| if [ -z "$SOURCE_COMMIT" ]; then | ||
| echo "::error title=Invalid recovery source::$RESUME_SOURCE_SHA does not resolve to a commit." | ||
| exit 1 | ||
| fi | ||
| if ! git merge-base --is-ancestor "$SOURCE_COMMIT" HEAD; then | ||
| echo "::error title=Recovery source is not an ancestor::$SOURCE_COMMIT is not an ancestor of the dispatched ref." | ||
| exit 1 | ||
| fi | ||
|
|
||
| RUN_HEAD_SHA=$(gh api "repos/$GITHUB_REPOSITORY/actions/runs/$RESUME_RUN_ID" --jq .head_sha) | ||
| RUN_CREATED_AT=$(gh api "repos/$GITHUB_REPOSITORY/actions/runs/$RESUME_RUN_ID" --jq .created_at) | ||
| if [ "$RUN_HEAD_SHA" != "$SOURCE_COMMIT" ]; then | ||
| echo "::error title=Recovery run/source mismatch::Run $RESUME_RUN_ID used $RUN_HEAD_SHA, not $SOURCE_COMMIT." | ||
| exit 1 | ||
| fi | ||
| RELEASE_DATE="${RUN_CREATED_AT%%T*}" | ||
| if ! [[ "$RELEASE_DATE" =~ ^[0-9]{4}-[0-9]{2}-[0-9]{2}$ ]]; then | ||
| echo "::error title=Invalid recovery date::Could not derive the release date from run $RESUME_RUN_ID." | ||
| exit 1 | ||
| fi | ||
|
|
||
| # Recovery may contain the workflow repair that makes resumption | ||
| # possible, but no release inputs or artifact sources may have moved. | ||
| if ! git diff --quiet "${SOURCE_COMMIT}..HEAD" -- . ':(exclude).github/workflows/publish.yml'; then | ||
| echo "::error title=Recovery source drift::Files outside the publish workflow changed after $SOURCE_COMMIT. Resume from a ref with identical release sources." | ||
| git diff --name-only "${SOURCE_COMMIT}..HEAD" -- . ':(exclude).github/workflows/publish.yml' | ||
| exit 1 | ||
| fi | ||
| { | ||
| echo "source_commit=$SOURCE_COMMIT" | ||
| echo "run_id=$RESUME_RUN_ID" | ||
| echo "release_date=$RELEASE_DATE" | ||
| } >> "$GITHUB_OUTPUT" | ||
| echo "Recovery source verified: $SOURCE_COMMIT (run $RESUME_RUN_ID, date $RELEASE_DATE)" | ||
|
|
||
| - name: Setup Node | ||
| uses: actions/setup-node@v6 | ||
|
|
@@ -306,6 +382,8 @@ jobs: | |
| path: /tmp/sdk-artifacts | ||
| pattern: relayburn-sdk-* | ||
| merge-multiple: false | ||
| run-id: ${{ needs.resolve-release-version.outputs.recovery_run_id || github.run_id }} | ||
| github-token: ${{ github.token }} | ||
|
|
||
| - name: Stage native SDK binding for tests | ||
| run: | | ||
|
|
@@ -484,9 +562,10 @@ jobs: | |
| # manually published a one-off from another branch between when | ||
| # the precursor ran and now). Catch it before we waste a build + | ||
| # before npm rejects with a less specific error. | ||
| - name: Verify new versions are not yet published | ||
| - name: Verify npm package versions | ||
| env: | ||
| TARGETS: ${{ steps.targets.outputs.targets }} | ||
| RESUME_PUBLISH: ${{ github.event.inputs.resume_publish }} | ||
| run: | | ||
| set -euo pipefail | ||
| declare -A DIRS | ||
|
|
@@ -502,10 +581,14 @@ jobs: | |
| NPM_NAME=$(node -p "require('./$dir/package.json').name") | ||
| EXISTS=$(npm view "$NPM_NAME@$ver" version 2>/dev/null || true) | ||
| if [ -n "$EXISTS" ]; then | ||
| echo "::error title=Version already published::$NPM_NAME@$ver is already on npm. Pick a different bump type or set custom_version to a higher version." | ||
| exit 1 | ||
| if [ "$RESUME_PUBLISH" != "true" ]; then | ||
| echo "::error title=Version already published::$NPM_NAME@$ver is already on npm. Pick a different bump type, set custom_version to a higher version, or explicitly resume the interrupted release." | ||
| exit 1 | ||
| fi | ||
| echo "$NPM_NAME@$ver: already published — recovery will reuse it" | ||
|
Comment on lines
+584
to
+588
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🔴 Current-version recovery skips finalization With Learn more
Example: Six Recommended fix: Include Was this helpful? React with 👍 or 👎 to provide feedback.
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Fixed in 47b4f7a: recovery requires custom_version, which keeps the existing commit/lockfile/tag/release finalization gates enabled. A version=none recovery is rejected before builds. |
||
| else | ||
| echo "$NPM_NAME@$ver: unpublished — OK" | ||
| fi | ||
| echo "$NPM_NAME@$ver: unpublished — OK" | ||
| done | ||
|
|
||
| # Per-package CHANGELOG.md generation. For each TS-only package being | ||
|
|
@@ -528,13 +611,16 @@ jobs: | |
| if: ${{ github.event.inputs.version != 'none' || github.event.inputs.custom_version != '' }} | ||
| env: | ||
| TARGETS: ${{ steps.targets.outputs.targets }} | ||
| CHANGELOG_END_REF: ${{ needs.resolve-release-version.outputs.recovery_source }} | ||
| RECOVERY_RELEASE_DATE: ${{ needs.resolve-release-version.outputs.recovery_release_date }} | ||
| run: | | ||
| TODAY=$(date -u +%Y-%m-%d) | ||
| TODAY="${RECOVERY_RELEASE_DATE:-$(date -u +%Y-%m-%d)}" | ||
| cat > /tmp/gen-changelog.mjs << 'GENEOF' | ||
| import { execSync } from 'node:child_process'; | ||
| import { readFileSync, writeFileSync, existsSync } from 'node:fs'; | ||
|
|
||
| const [,, key, dir, newVersion, today] = process.argv; | ||
| const endRef = process.env.CHANGELOG_END_REF || 'HEAD'; | ||
| const path = `${dir}/CHANGELOG.md`; | ||
|
|
||
| if (newVersion.includes('-')) { | ||
|
|
@@ -597,7 +683,7 @@ jobs: | |
| process.exit(0); | ||
| } else { | ||
| const log = execSync( | ||
| `git log ${lastTag}..HEAD --pretty=format:"%H|%s|%b%x00" --no-merges -- ${dir}`, | ||
| `git log ${lastTag}..${endRef} --pretty=format:"%H|%s|%b%x00" --no-merges -- ${dir}`, | ||
| { encoding: 'utf-8' } | ||
| ).trim(); | ||
| if (!log) { | ||
|
|
@@ -743,8 +829,9 @@ jobs: | |
| if: ${{ github.event.inputs.version != 'none' || github.event.inputs.custom_version != '' }} | ||
| env: | ||
| RELEASE_VERSION: ${{ steps.bump.outputs.release_version }} | ||
| RECOVERY_RELEASE_DATE: ${{ needs.resolve-release-version.outputs.recovery_release_date }} | ||
| run: | | ||
| TODAY=$(date -u +%Y-%m-%d) | ||
| TODAY="${RECOVERY_RELEASE_DATE:-$(date -u +%Y-%m-%d)}" | ||
| cat > /tmp/gen-root-changelog.mjs << 'GENEOF' | ||
| import { readFileSync, writeFileSync, existsSync } from 'node:fs'; | ||
|
|
||
|
|
@@ -949,6 +1036,8 @@ jobs: | |
| path: /tmp/cli-artifacts | ||
| pattern: relayburn-cli-* | ||
| merge-multiple: false | ||
| run-id: ${{ needs.resolve-release-version.outputs.recovery_run_id || github.run_id }} | ||
| github-token: ${{ github.token }} | ||
|
|
||
| - name: Stage prebuilt binaries into platform packages | ||
| run: | | ||
|
|
@@ -1089,6 +1178,7 @@ jobs: | |
| - name: Pack + publish | ||
| env: | ||
| TARGETS: ${{ steps.targets.outputs.targets }} | ||
| RESUME_PUBLISH: ${{ github.event.inputs.resume_publish }} | ||
| run: | | ||
| set -euo pipefail | ||
| PACK_DIR="$RUNNER_TEMP/packs" | ||
|
|
@@ -1141,10 +1231,80 @@ jobs: | |
| exit 1 | ||
| fi | ||
|
|
||
| # npm package versions are immutable. A recovery may reuse a | ||
| # published version only when its unpacked files and executable | ||
| # modes match the artifact built from the verified source. Compare | ||
| # canonical contents rather than tarball bytes because gzip/tar | ||
| # metadata can differ across otherwise identical pack operations. | ||
| EXISTS=$(npm view "$NPM_NAME@$version" version 2>/dev/null || true) | ||
| if [ -n "$EXISTS" ]; then | ||
| if [ "$RESUME_PUBLISH" != "true" ]; then | ||
| echo "::error title=Version already published::$NPM_NAME@$version appeared after preflight; aborting normal release." | ||
| exit 1 | ||
| fi | ||
| COMPARE_DIR="$RUNNER_TEMP/recovery-compare/$key" | ||
| mkdir -p "$COMPARE_DIR/local" "$COMPARE_DIR/remote" "$COMPARE_DIR/registry" | ||
| tar -xzf "$TARBALL" -C "$COMPARE_DIR/local" | ||
| REMOTE_TARBALL_BASENAME=$(npm pack "$NPM_NAME@$version" --silent --pack-destination "$COMPARE_DIR/registry") | ||
| REMOTE_TARBALL="$COMPARE_DIR/registry/$REMOTE_TARBALL_BASENAME" | ||
| tar -xzf "$REMOTE_TARBALL" -C "$COMPARE_DIR/remote" | ||
| if ! diff -qr "$COMPARE_DIR/local/package" "$COMPARE_DIR/remote/package"; then | ||
| echo "::error title=Published artifact mismatch::$NPM_NAME@$version contents do not match the artifact packed from the verified recovery source." | ||
| exit 1 | ||
| fi | ||
|
cursor[bot] marked this conversation as resolved.
|
||
| if ! diff \ | ||
| <(cd "$COMPARE_DIR/local/package" && find . -type f -printf '%m %p\n' | sort) \ | ||
| <(cd "$COMPARE_DIR/remote/package" && find . -type f -printf '%m %p\n' | sort); then | ||
| echo "::error title=Published artifact mode mismatch::$NPM_NAME@$version executable modes do not match the artifact packed from the verified recovery source." | ||
| exit 1 | ||
| fi | ||
| echo "==> Reusing $NPM_NAME@$version (artifact contents verified)" | ||
| continue | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Resume comparison rejects rebuilt binariesHigh Severity Recovery rebuilds the eight native platform packages, then reuses an npm version only when the new tarball matches the published one byte-for-byte. Those Rust artifacts link bundled SQLite and are not reproducible across CI runs, so a valid resume fails the compare and never reaches lockfile refresh, tags, or push. Reviewed by Cursor Bugbot for commit 47b4f7a. Configure here.
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Fixed in 8835752: recovery now requires the original failed Actions run ID, verifies that run used the pinned source SHA, and downloads the CLI/SDK artifacts from that original run instead of using rebuilt binaries. I also verified all 8 retained native artifacts byte-match their published 4.1.0 package payloads. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Recovery compare fails on workflow fixesHigh Severity Recovery packs from the dispatched Additional Locations (1)Reviewed by Cursor Bugbot for commit 8835752. Configure here.
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Audited as a false positive: both npm pack and pnpm pack were executed from this Git checkout and their packed package.json files contain no gitHead field. npm stores gitHead as registry metadata; it is not injected into these tarballs. I also compared a freshly packed relayburn artifact from the newer workflow-only commit against the published package and the unpacked contents matched. The recovery comparison therefore does not fail on the workflow commit SHA. |
||
| fi | ||
|
|
||
| echo "==> Publishing $TARBALL $COMMON_FLAGS" | ||
| npm publish "$TARBALL" $COMMON_FLAGS | ||
| done | ||
|
|
||
| # npm's OIDC publisher can return success while the registry still says | ||
| # a package is being processed. Do not regenerate the lockfile until all | ||
| # eleven exact versions are readable; pnpm otherwise silently drops an | ||
| # unavailable optional dependency from the importer and only reports the | ||
| # mismatch in the later frozen-lockfile check. | ||
| - name: Wait for npm registry propagation | ||
| if: ${{ github.event.inputs.dry_run != 'true' }} | ||
| env: | ||
| TARGETS: ${{ steps.targets.outputs.targets }} | ||
| run: | | ||
| set -euo pipefail | ||
| declare -A DIRS | ||
| while IFS=: read -r key dir; do | ||
| [ -z "$key" ] && continue | ||
| DIRS[$key]="$dir" | ||
| done <<< "$TARGETS" | ||
|
|
||
| attempts=30 | ||
| for entry in ${{ steps.bump.outputs.versions }}; do | ||
| key="${entry%%:*}" | ||
| version="${entry##*:}" | ||
| dir="${DIRS[$key]}" | ||
| NPM_NAME=$(node -p "require('./$dir/package.json').name") | ||
|
|
||
| for i in $(seq 1 "$attempts"); do | ||
| visible=$(npm view "$NPM_NAME@$version" version 2>/dev/null || true) | ||
| if [ "$visible" = "$version" ]; then | ||
| echo "$NPM_NAME@$version: visible (attempt $i/$attempts)" | ||
| break | ||
| fi | ||
| if [ "$i" -eq "$attempts" ]; then | ||
| echo "::error title=npm propagation timeout::$NPM_NAME@$version was not readable after $attempts attempts. Rerun with resume_publish enabled once npm finishes processing it." | ||
| exit 1 | ||
| fi | ||
| echo "$NPM_NAME@$version: not visible yet (attempt $i/$attempts); retrying in 10s..." | ||
| sleep 10 | ||
| done | ||
| done | ||
|
|
||
| # Refresh pnpm-lock.yaml AFTER the publish loop so the umbrella | ||
| # `optionalDependencies` (`@relayburn/{cli,sdk}-<short>`) resolve | ||
| # against the now-published RELEASE_VER tarballs on the npm | ||
|
|
||


There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🔴 Default recovery publishes another version
With
resume_publish=trueand defaultversion=patch,release_versionadvances beyond the interrupted version. The workflow publishes another release because no package matches that version.Learn more
The recovery flag changes only collision handling. The version resolver still applies the selected bump after healing local versions to npm's highest stable version. After a partial
4.1.0release from a4.0.0checkout, healing selects4.1.0and the default patch bump resolves4.1.1. Every lookup for4.1.1is empty, so recovery publishes all packages under an unintended new version.Example: A run publishes five packages at
4.1.0and then fails. Rerunning with onlyresume_publish=trueresolves4.1.1and publishes eleven new packages instead of finishing the remaining six at4.1.0.Recommended fix: Make recovery resolve the interrupted exact version independently of normal bumping. Either require and validate
custom_versionwheneverresume_publishis enabled, or derive a single recoverable version from npm and reject ambiguous or absent candidates.Was this helpful? React with 👍 or 👎 to provide feedback.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Fixed in 47b4f7a: recovery now fails early unless an explicit custom_version is supplied, so the default patch choice cannot advance an interrupted release.