Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
10 changes: 5 additions & 5 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
@@ -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]
Expand Down
34 changes: 18 additions & 16 deletions docs/contributing/releasing.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 \
Expand All @@ -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

Expand Down
56 changes: 33 additions & 23 deletions scripts/release.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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 <package>` 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}'`);
Expand Down
Loading