Implement staged publish - #6459
Conversation
|
qxprakash
left a comment
There was a problem hiding this comment.
few comments , I will add more as I go through it more
| images: transloadit/companion | ||
| tags: | | ||
| type=edge | ||
| type=semver,pattern={{version}},value=${{ needs.release.outputs.companionWasReleased }} |
There was a problem hiding this comment.
stale reference here, job was renamed to publish so it should be needs.publish here ?
| - The npm trust relationship (OIDC) used by CI must allow `npm stage publish`. | ||
| On npmjs.com this is the `--allow-stage-publish` permission; you can leave | ||
| `--allow-publish` disabled so CI is unable to publish directly at all. | ||
| - Approving is a 2FA action and can never be done by a token or in CI. It has to | ||
| be a maintainer on their own machine. |
There was a problem hiding this comment.
AI suggested this : RELEASE.md recommends leaving --allow-publish disabled on the npm trust relationship "so CI is unable to publish directly at all" But the script's fallback for a brand-new package is a direct yarn npm publish ?
uppy/scripts/stage-publish.mjs
Lines 204 to 211 in 260811d
remcohaszing
left a comment
There was a problem hiding this comment.
I want to point out that the biggest security benefit doesn’t come from any changes we make to the code. The real security benefit comes from enforcing staged publishing in the package settings on npmjs.
| ## 4. Approve the release | ||
|
|
||
| From a checkout of `main`, logged in to npm (`npm login`) with 2FA enabled: | ||
|
|
||
| ```bash | ||
| yarn release:approve | ||
| ``` | ||
|
|
||
| It lists everything CI staged for this repo, asks for confirmation, and then | ||
| approves each package. One 2FA code is reused for as long as npm accepts it; you | ||
| are only asked for a new one when a code is refused. | ||
|
|
||
| Useful variants: | ||
|
|
||
| ```bash | ||
| yarn release:approve --dry-run | ||
| ``` | ||
|
|
||
| ```bash | ||
| yarn release:approve @uppy/core | ||
| ``` | ||
|
|
||
| To throw a staged version away instead of publishing it, find its id with | ||
| `npm stage list` and run `npm stage reject <stage-id>`. |
There was a problem hiding this comment.
Did you try this in the npmjs UI? Maybe we should see if we like that before we try semi-automating the process with a script.
| - name: Checkout Repo | ||
| uses: actions/checkout@v6 |
There was a problem hiding this comment.
Just a personal opinion: IMO adding name to a step reduces clarity.
When I see “actions/checkout” or “corepack yarn install --immutable” somewhere in the GitHub Actions UI, I know exactly what it does. When I see “Checkout Repo” or “Install dependencies”, I need to dive deeper to find out.
This is definitely not a blocker. This is consistent with what we do in other workflows.
| # `npm stage` (staged publishing) needs npm 11.15.0 or newer, which is | ||
| # newer than what Node LTS bundles. | ||
| - run: npm install --global npm@latest | ||
|
|
There was a problem hiding this comment.
This step is redundant, based on https://github.com/transloadit/uppy/actions/runs/32023136464/job/95366811336#step:5:1.
| # `npm stage` (staged publishing) needs npm 11.15.0 or newer, which is | |
| # newer than what Node LTS bundles. | |
| - run: npm install --global npm@latest |
TLDR: We should probably wait for changesets to natively support staged publish (it doesn't currently): changesets/changesets#2025 that way we won't have to maintain all this custom logic and scripts. If we want staged publish before that, then this PR could be the workaround. Dry run was tested by AI but I haven't really reviewed, as i'm not very familiar with how changeset releases work.
What changed
[scripts/stage-publish.mjs](scripts/stage-publish.mjs) (new) — replaces
changeset publishasyarn release. It finds every non-private workspace package whose version isn't on npm, packs each with Yarn (soworkspace:^resolves, as your changesets patch does today), then uploads withnpm stage publish <tarball>. It printsNew tag: <pkg>@<version>and creates the annotated git tag, which is exactly whatchangesets/action@a45c4d5(v1.9.0) greps for to push tags and open GitHub releases.[scripts/approve-staged-release.mjs](scripts/approve-staged-release.mjs) (new) —
yarn release:approve. Lists what CI staged (filtered to this monorepo's packages), confirms, then approves each one. One OTP is reused until npm refuses it, then it asks for a new one — as you asked. Supports--dry-runand a package-name filter.[.github/workflows/release.yml](.github/workflows/release.yml) — split into
version→publish→docker, per [Andarist's recommendation](changesets/changesets#2025 (comment)). Top-levelpermissions: {}, with each job declaring its own; onlypublishgetsid-token: write. Plus anpm install --global npm@lateststep, sincenpm stageneeds npm ≥ 11.15.0 and Node LTS ships older.[RELEASE.md](RELEASE.md) (new) — the full process.
On the split workflow
I've taken the structural half — the permission scoping and independent re-runnability are real wins. I deliberately left out the
environment: npmmanual approval that changesets pairs it with: that would give you two human gates per release, and npm staging is the stronger of the two for your threat model (proof-of-presence at the registry, not at the runner — it still holds if the OIDC token or a build dependency is compromised). RELEASE.md documents the one line to add if you want both.What you need to do on npmjs.com
Your trust relationship must allow staging —
npm trusthas--allow-stage-publishalongside--allow-publish. npm's own guidance is to enable only stage-publish, so CI can't publish directly at all. Nothing in this repo can set that.Verified vs. not
I confirmed against npm 11.19 locally that
npm stage publish <yarn-tarball>reads the Yarn-packed tarball correctly and reaches the registry — it failed only with the expected "cannot publish over the previously published versions: 5.1.1". Package selection and dist-tag derivation are tested (a simulated99.0.0-beta.2correctly resolved to tagbeta). Thestage list/approvehandling is built from the npm CLI source (stage ids are UUIDs; items carryid/packageName/version/tag/actor/createdAt) but I could not exercise it against a live staged package.Two caveats worth knowing: a package that has never been published can't be staged — the script detects this, warns, and publishes that one directly. And per your choice, tags/releases/CDN/Docker all happen at stage time, so a rejected stage leaves those behind to clean up by hand.
Sources: [npm-stage docs](https://docs.npmjs.com/cli/v11/commands/npm-stage/), [staged publishing](https://docs.npmjs.com/staged-publishing/), [changesets#2025](changesets/changesets#2025), [changesets publish.yml](https://github.com/changesets/changesets/blob/372523f4c2ee4ffeb8330d444d47ffb6d0af5126/.github/workflows/publish.yml)