fix: split publish into siblings-then-bitgo passes - #9424
Open
lokesh-bitgo wants to merge 1 commit into
Open
Conversation
Contributor
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
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Ticket: WCI-1818
What changes are in this PR
Phase 2 of WCN-1818. Splits the single
lerna publishcall in both.github/workflows/publish.yml(alpha/beta) and.github/workflows/npmjs-release.yml(prod) into two passes — siblings first,bitgoalone second — using theset-umbrella-publishable.tstoggle script added in Phase 1 (#TODO-phase1-pr-number). This is the change that actually fixes the underlying race condition; Phase 1 only fixed the detection/safety-check logic without changing when publishing happens.What was the issue
bitgo'sprepackscript (scripts/generate-bitgo-shrinkwrap.ts) resolves its 89@bitgo/*sibling packages from the npm registry to build thenpm-shrinkwrap.jsonbundled inside the publishedbitgotarball — this is what letsnpm install bitgoactually pull in all its siblings instead of shipping an empty shell (WCI-1200).The problem:
lerna publishruns every package's lifecycle hooks (bitgo'sprepackincluded) before uploading any of them. So at the exact momentbitgo'sprepacktries to resolve its siblings, none of them are live on the registry yet — they're all still mid-pipeline, exactly likebitgoitself.This wasn't theoretical — it happened for real. The prod release immediately after the Phase 1-equivalent fix landed (run 30934555125) failed with:
Because this happens before any upload step, zero packages published in that run — not just
bitgo.It also went unnoticed for a while because beta never exercised this code path at all:
publish.ymlnever setBITGO_GENERATE_SHRINKWRAP=true, so the shrinkwrap generator always silently no-op'd there (confirmed directly in a "successful" beta run's own logs:BITGO_GENERATE_SHRINKWRAP not set to "true" — skipping npm-shrinkwrap.json generation.). A green beta build was never meaningful evidence that publishing actually worked.What we are solving
Making
bitgo's siblings genuinely live on the registry beforebitgo's ownprepacktries to resolve them — without changing what actually gets published, what versions get assigned, or any of the surrounding checks (GPG signing, OSV vulnerability gate, GitHub release creation, Express Docker publish).What we have done
The core mechanism, same in both workflows:
bitgoback:npx tsx ./scripts/set-umbrella-publishable.ts falsesets"private": trueonmodules/bitgo/package.json. Lerna'sfilterPrivatePkgUpdatesfilters private packages out before packing (verified directly against lerna 9.0.0's source,node_modules/lerna/dist/commands/publish/index.js), sobitgo'sprepacksimply doesn't run yet.lerna publish. All 89 siblings publish;bitgois invisible to this pass.bitgo:set-umbrella-publishable.ts true, underif: always()— runs even if pass 1 failed, sobitgocan never end up permanently stuckprivateby accident.bitgo:lerna publishagain. Onlybitgois left to publish, and its siblings are now genuinely resolvable..github/workflows/publish.yml(alpha/beta)Replaced the single
Lerna Publishstep with the four steps above (Hold back→Lerna Publish (siblings)→Restore→Lerna Publish (bitgo)). Thebitgopass now also setsBITGO_GENERATE_SHRINKWRAP: true— this workflow never set it before, so this permanently closes the "beta never validates this" gap going forward, not just for one test run.continue-on-error: ${{ inputs.recovery-mode }}(the existing "don't hard-fail on a stuck-package Rekor conflict during recovery" escape hatch) is applied to both new publish steps, since that conflict could now occur in either pass..github/workflows/npmjs-release.yml(prod)Bigger change because versioning and publishing were combined in one command here. Split into:
Bump version(!recovery-modeonly) — moved the version-bump flags (--sign-git-tag --sign-git-commit --include-merged-tags --conventional-commits --conventional-graduate --yes) ontolerna versionalone. Still commits, tags, signs, and pushes torel/latestexactly as before — just doesn't publish anything yet.lerna publish from-package --yes. Because both the normal path (runs right after the version bump) and the recovery path (runs with no preceding bump,from-packagejust picks up whatever's on disk and missing from npm) end up calling the exact samefrom-packagecommand, one shared sequence now covers both paths — previously these were two separate, differently-shaped steps (Publish new versionusing plainlerna publish, andPublish missing versions (recovery)usingfrom-package).BITGO_GENERATE_SHRINKWRAP: truemoved from the old single combined step onto pass 2 specifically (the only pass that actually packsbitgo).Verify recovery published the missing versions(unchanged content, still recovery-only) now runs after pass 2 instead of after the old single recovery step — same relative position in the job otherwise.Verify all packages exist on npm,Verify bitgo package has shrinkwrap metadata, GitHub release creation, and the Express Docker publish steps are all untouched.Why this is needed
Without this, every prod release that actually reaches the point of resolving
bitgo's siblings will fail the same way the real incident did — deterministically, not intermittently, because the siblings genuinely never exist yet at that point in a single combined publish. Phase 1 (already merged/staged separately) made the failure cleaner and more informative when it happens, but did not change whether it happens. This PR is what actually makesnpm install bitgowork end-to-end for real consumers going forward.Considered and explicitly rejected: lerna's
--include-privateLerna has a built-in
--include-private <names>flag that publishes a named private package by temporarily removingprivatefrom its manifest — which would letbitgostay permanentlyprivate: truein the committed repo with no scripted toggle needed. Rejected because three separate checks in the pipeline enumerate non-private packages to verify they exist/were published:A permanently-private
bitgowould silently stop being covered by all three. The scripted, temporary toggle (flip off → publish → flip back on, all within the same job run, never committed) keeps every one of those checks seeing a normal, publishable package by the time they run.Correctness review performed before opening this PR
if:/continue-on-error/always()interaction by hand across normal-mode-success, normal-mode-pass-1-failure, recovery-mode-success, and recovery-mode-pass-1-failure scenarios in both files — confirmed the restore step fires in every case, and that a genuine pass-1 failure in normal mode still correctly skips pass 2 (fail-fast, same as the original single-step behavior).actionlint(with shellcheck) via Docker against both files:docker run --rm -v "$PWD:/repo" -w /repo rhysd/actionlint:latest \ .github/workflows/publish.yml .github/workflows/npmjs-release.ymlpublish.yml), unrelated to this change.yaml.safe_load).npx tsx ./scripts/set-umbrella-publishable.ts, matchingpublish.yml's existing./scripts/...convention andnpmjs-release.yml's own./.github/actions/...local-reference convention — that file had no priornpx tsxcalls to match against).What this PR does not cover yet
always()restore really fire under a real GitHub Actions failure, not just in my reading of the semantics?) has not been exercised for real yet. See "How to validate after merging" below.Verify recovery published the missing versionsis still recovery-only; a package outsidebitgo's dependency tree silently failing to publish in normal mode's pass 1 wouldn't be caught by a dedicated check (onlybitgo's own 89 dependencies get implicitly checked, via pass 2's resolution). Confirmed this is a pre-existing gap, not something this PR introduces or worsens — normal mode never had this check before either, since the old single-commandlerna publishwould hard-fail immediately on any real error. Deliberately left out of scope here; worth its own separate follow-up if desired.rel/latest/masterbranch divergence (orphaned 52.4.2 tags from the original failed release) is still unresolved — a separate, pre-release-time cleanup task, not blocking this PR's review.scripts/verify-release.ts's recovery-retry path still doesn't setBITGO_GENERATE_SHRINKWRAP— a known, documented gap from Phase 1, harmless today (beta never shipped a shrinkwrap before this PR's beta change either — though note this PR does now enable it on the main beta publish path, just not on that specific retry script's path), tracked separately.How to validate this after merging
Static checks (lint, YAML parse, manual trace-through) are already done above; none of them prove runtime behavior. This is the concrete, ordered checklist to actually run — organized by which workflow to dispatch, with exact inputs and exact things to look at.
Step 1 —
Publish @bitgo-beta(file:.github/workflows/publish.yml), normal dispatchWhere: GitHub → Actions tab → workflow named "Publish @bitgo-beta" → "Run workflow".
Inputs:
recovery-mode=false(default — leave unchecked).Branch: any feature branch off
master(this workflow publishes to thealphapreid/dist-tag whenevergithub.ref != 'refs/heads/master', so this is safe to run from this PR's own branch after merge, or frommasterdirectly for a realbetapublish).What to check in the run's logs, step by step:
Marked modules/bitgo/package.json as private....@bitgo-beta/*(or@bitgo/*on the realbetapath) sibling packages being published —bitgoitself should not appear in this step's publish list.Restored modules/bitgo/package.json to publishable....prepackscript's own log lines appear:Resolving 89 workspace siblings as part of the shrinkwrap:(notBITGO_GENERATE_SHRINKWRAP not set to "true" — skipping...— if you see that skip message, this step's env var didn't take effect and something is wrong).npm install --package-lock-only/npm shrinkwrapsequence, ending inWrote .../modules/bitgo/npm-shrinkwrap.json.This step alone confirms the "beta never exercises this" blind spot is closed — this is the first time this workflow will have ever actually attempted shrinkwrap generation.
Step 2 — Confirm the actual published package works, for real
Take the version number from step 1's "Lerna Publish (bitgo)" log output, then run these outside CI, on your own machine:
_hasShrinkwrapistrue.node_modules/@bitgo-beta/actually contains the sibling packages (not empty).require()calls succeed with noMODULE_NOT_FOUND.This is the actual end-to-end proof that WCI-1200 is fixed — not "the workflow didn't error," but "a real
npm installfrom a clean directory works."Step 3 — Prove the
always()restore really survives a failureDon't just trust the YAML semantics — force a failure and check the aftermath:
How: dispatch
Publish @bitgo-betaagain, but engineer a failure in the siblings pass (e.g., temporarily point--dist-tagat something that will collide, or dispatch twice in quick succession so the second run hits an already-published-version conflict).recovery-modewas also on).modules/bitgo/package.jsondoes not have"private": trueleft in it.Step 4 —
BitGoJS Release(file:.github/workflows/npmjs-release.yml), dry-run firstWhere: GitHub → Actions tab → workflow named "BitGoJS Release" → "Run workflow".
Inputs:
dry-run=true,recovery-mode=false.Verify all packages exist on npm.Bump version,Hold back bitgo umbrella...,Publish siblings (pass 1),Restore bitgo umbrella...,Publish bitgo (pass 2)all show as skipped (they're all gated ondry-run == false) — this just confirms theif:gating itself is wired correctly; a dry run does not exercise any of the new logic.Step 5 — Resolve
rel/latest/masterdivergence (prerequisite, not part of this PR)Before any real (non-dry-run) prod dispatch:
rel/latestcurrently has an orphaned 52.4.2 version bump from the original incident thatmasterdoesn't have, andmasterhas 3 commitsrel/latestdoesn't have. Theverify-back-mergejob innpmjs-release.ymlwill actively block a normal dispatch until this is reconciled. This is tracked separately — do not attempt step 6 until it's resolved.Step 6 —
BitGoJS Release, real dispatch in recovery modeInputs:
dry-run=false,recovery-mode=true.Recovery mode specifically because
rel/latestalready holds the (unpublished) 52.4.2 version bump — a normal-mode dispatch would re-bump past it and orphan those tags further.Bump versionis skipped (recovery mode never re-versions).Hold back bitgo umbrella...→Publish siblings (pass 1)→Restore bitgo umbrella...→Publish bitgo (pass 2)all ran, in that order, and all succeeded.bitgopackage (not@bitgo-beta/bitgo), to confirm the fix holds in prod too.Ticket: WCN-1818