diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index af34398..4d4273f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -5,6 +5,9 @@ on: branches: [main] pull_request: branches: [main] + # A branch with no open pull request matches neither trigger above, so this + # is the only way to run the gate on one. + workflow_dispatch: # A superseded pull-request run is worthless, so cancel it; a superseded # push-to-main run is the ONLY signal that commit will ever get, so queue it diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index c18e503..d9a84d8 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1,10 +1,10 @@ name: Release -# `workflow_dispatch` is not a convenience. The placeholder guard that used to -# make an accidental run harmless is gone with the `0.0.0` versions it read, so -# a deliberate trigger is what replaces it — and the bootstrap publish, which -# has to happen once before any package has a trusted publisher to authenticate -# against, has no other way to be invoked. +# `workflow_dispatch` is not a convenience: it is the only way to re-run a +# release at the same commit. The version is derived from the commit count, so +# it cannot be advanced without one, and a run that published some packages +# before failing has no other recovery path — the publish step treats an +# already-published version as a skip, so the re-run completes the set. on: push: branches: [main] diff --git a/docs/contributing/releasing.md b/docs/contributing/releasing.md index 5b39843..33ed8ad 100644 --- a/docs/contributing/releasing.md +++ b/docs/contributing/releasing.md @@ -103,21 +103,20 @@ while attaching nothing. Confirm it on the registry page. [1551]: https://github.com/actions/setup-node/issues/1551 -## Bootstrapping (one time) +## Adding a package to the published set Trusted publishing is configured **per package**, and a package must -already exist before it can be configured — so the first publish cannot -itself use OIDC. In order: - -1. Cut a release tag so the counter has a floor. The match pattern is - deliberately narrow (`v[0-9]*.[0-9]*.[0-9]*`): this repository - carries marker tags such as `v0-api-freeze`, which a naive `v[0-9]*` - matches — `v` followed by `0-api-freeze` — and would silently anchor - every version number to a tag that is not a release. -2. Publish once with a token, **from CI rather than a laptop**. A local - `npm publish` has neither `id-token: write` nor provenance and looks - entirely successful. -3. Configure the publisher for each package: +already exist before it can be configured — so a new package's first +publish cannot use OIDC, and until it is configured it fails the next +release for the whole set, which publishes in lockstep. + +1. Publish that package once **locally**, under `npm login`. It cannot + be done from `release.yml`: that workflow is OIDC-only and its + preflight fails when it finds an `_authToken`, because a token takes + precedence over OIDC and would leave the trusted-publisher path + untested. That version carries no provenance; the first OIDC publish + attaches it. +2. Configure the publisher: ```bash npm trust github --repo eclipse-emfcloud/hydranium \ @@ -129,9 +128,12 @@ itself use OIDC. In order: turn every release into a staged submission awaiting 2FA approval. Account-level 2FA is required, and granular tokens with the bypass option are rejected. -4. Verify on **one** package before trusting all ten — publishing scoped - packages over OIDC has an open failure report ([npm/cli#8976][8976]). -5. Delete the `NPM_TOKEN` secret and revoke the token. +3. `npm logout`, so no credential remains that could take precedence + over OIDC on a later local run. + +Publishing a scoped package over OIDC has an open failure report +([npm/cli#8976][8976]), so confirm the next automated release carries the +new package rather than assuming it. [8976]: https://github.com/npm/cli/issues/8976 diff --git a/scripts/release.mjs b/scripts/release.mjs index 09b0e64..394aea1 100644 --- a/scripts/release.mjs +++ b/scripts/release.mjs @@ -34,15 +34,21 @@ import { fileURLToPath } from 'node:url'; const REPO_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..'); const PACKAGES_DIR = join(REPO_ROOT, 'packages'); -// Deliberately narrower than `v*`: this repository carries marker tags such as -// `v0-api-freeze`, and a naive `v[0-9]*` matches that one — `v` followed by -// `0-api-freeze` — which would silently anchor the counter to a tag that is not -// a release. Requiring all three dot-separated numeric segments excludes it. +// Deliberately narrower than `v*`, and than `v[0-9]*`: both also match a +// `v`-prefixed marker tag, which would silently anchor the counter to a tag +// that is not a release. All three dot-separated numeric segments are required. const RELEASE_TAG_GLOB = 'v[0-9]*.[0-9]*.[0-9]*'; -/** Re-reads while the registry propagates; see {@link verifyPublished}. */ -const VERIFY_ATTEMPTS = 5; -const VERIFY_BACKOFF_MS = 2_000; +/** + * Re-reads while the registry propagates; see {@link verifyPublished}. + * + * A round covers every package still pending, so the linear backoff is shared + * rather than paid per straggler — three minutes in total, however many lag. + * Per-package it multiplies by the number of stragglers and overruns the + * release job's own timeout, which kills the run before it can report which. + */ +const VERIFY_ROUNDS = 10; +const VERIFY_BACKOFF_MS = 4_000; /** * What `distTag` currently points at for `name`, or undefined. @@ -267,29 +273,33 @@ function publish(pkg, distTag, dryRun) { * moved, not merely that a version exists somewhere. * * Retried regardless, because propagation is eventual and a fresh CDN edge can - * lag a write by seconds. + * lag a write by seconds — in rounds over the packages still pending, so that + * waiting on one re-reads the rest for free. */ function verifyPublished(packages, version, distTag) { - const missing = []; - for (const pkg of packages) { - const name = pkg.manifest.name; - let seen; - for (let attempt = 0; attempt < VERIFY_ATTEMPTS; attempt++) { - if (attempt > 0) { - sleepSync(VERIFY_BACKOFF_MS * attempt); - } - seen = publishedTag(name, distTag); + // Holds the last version seen, not just the name: the failure has to tell a + // tag serving the PREVIOUS version apart from one it could not read. + const pending = new Map(packages.map(pkg => [pkg.manifest.name, undefined])); + for (let round = 0; round < VERIFY_ROUNDS && pending.size > 0; round++) { + if (round > 0) { + sleepSync(VERIFY_BACKOFF_MS * round); + } + for (const name of [...pending.keys()]) { + const seen = publishedTag(name, distTag); if (seen === version) { - break; + pending.delete(name); + } else { + pending.set(name, seen); } } - if (seen !== version) { - missing.push(`${name} (${distTag} → ${seen ?? 'unreadable'})`); - } } - if (missing.length > 0) { + if (pending.size > 0) { + const missing = [...pending].map(([name, seen]) => `${name} (${distTag} → ${seen ?? 'unreadable'})`); throw new Error( - `Published ${packages.length} packages but '${distTag}' does not point at ${version} for:\n ${missing.join('\n ')}` + `Published ${packages.length} packages but '${distTag}' does not point at ${version} for:\n ${missing.join('\n ')}\n\n` + + 'A tag showing the PREVIOUS version is propagation lag rather than a failed publish, and the\n' + + 'publishes above have already succeeded. Re-check with `npm dist-tag ls ` before\n' + + 'treating this as a release failure; if it has caught up, only this budget was too short.' ); } console.log(`✓ all ${packages.length} packages serve ${version} on '${distTag}'`);