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
16 changes: 6 additions & 10 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ on:
type: choice
options: [next, latest]
confirmation:
description: Type publish 2.0.0 with latest
description: Type publish <version> with <channel>
required: true

permissions:
Expand All @@ -22,17 +22,15 @@ jobs:
authorize:
runs-on: ubuntu-latest
steps:
- name: Require main and exact stable release confirmation
- name: Require main and exact release confirmation
env:
ACTUAL_REF: ${{ github.ref }}
ACTUAL_VERSION: ${{ inputs.version }}
ACTUAL_CHANNEL: ${{ inputs.channel }}
ACTUAL_CONFIRMATION: ${{ inputs.confirmation }}
run: |
test "$ACTUAL_REF" = "refs/heads/main"
test "$ACTUAL_VERSION" = "2.0.0"
test "$ACTUAL_CHANNEL" = "latest"
test "$ACTUAL_CONFIRMATION" = "publish 2.0.0 with latest"
test "$ACTUAL_CONFIRMATION" = "publish $ACTUAL_VERSION with $ACTUAL_CHANNEL"

verify:
needs: authorize
Expand All @@ -53,7 +51,7 @@ jobs:
env:
REQUESTED_CHANNEL: ${{ inputs.channel }}
run: node scripts/verify-release-channel.mjs "$REQUESTED_CHANNEL"
- name: Verify 2.0.0 is absent from npm
- name: Verify requested version is absent from npm
run: node scripts/verify-registry.mjs unpublished
- name: Assert exactly one verified tarball
run: |
Expand Down Expand Up @@ -114,14 +112,12 @@ jobs:
tarballs=(.artifacts/*.tgz)
test "${#tarballs[@]}" -eq 1
printf 'VERIFIED_TARBALL=%s\n' "${tarballs[0]}" >> "$GITHUB_ENV"
- name: Reconfirm 2.0.0 is absent from npm
- name: Reconfirm requested version is absent from npm
run: node scripts/verify-registry.mjs unpublished
- name: Publish verified tarball for initial package bootstrap
- name: Publish verified tarball with npm trusted publishing
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
REQUESTED_CHANNEL: ${{ inputs.channel }}
run: |
test -n "$NODE_AUTH_TOKEN"
npm publish "$VERIFIED_TARBALL" \
--access public \
--tag "$REQUESTED_CHANNEL" \
Expand Down
72 changes: 28 additions & 44 deletions docs/RELEASING.md
Original file line number Diff line number Diff line change
@@ -1,9 +1,8 @@
# Releasing

Releases are manual, protected, and run only from `main`. The current workflow is
locked to the first stable release: `@nipe-solutions/[email protected]` on the
`latest` npm channel. Its inputs remain versioned so a future release can update
the explicit authorization gate without redesigning the workflow.
Releases are manual, protected, and run only from `main`. The workflow requires
an exact version, npm channel, and matching confirmation phrase. Stable versions
use `latest`; prereleases use `next`.

## Hosted documentation

Expand All @@ -19,72 +18,56 @@ If a documentation deployment is defective, restore the previous known-good
Vercel deployment immediately and follow with a reviewed revert or fix on
`main`. Do not publish a package merely to repair the hosted documentation.

## First-publication authentication

Create a protected GitHub environment named `npm`, restrict deployments to
`main`, and require a reviewer when the repository plan supports it. Because npm
does not allow trusted-publisher setup until the package exists, the initial
`2.0.0` publication uses a short-lived granular npm access token:

1. Immediately before dispatch, create a granular token with the shortest
practical expiry. Scope its package and organization permissions only as
narrowly as npm permits for the initial `@nipe-solutions/readonly-view`
publication, grant the required package write access, and select the token's
`Bypass 2FA` option. Bypassing 2FA is required for this non-interactive direct
publication because GitHub Actions cannot enter a one-time password.
2. Add it as an environment secret named `NPM_TOKEN` on the protected `npm`
environment, not as a repository-wide secret. Only the `publish` job reads
it.
3. After `2.0.0` exists and all checks below pass, configure the npm trusted
publisher with this exact tuple:
## Trusted publishing

The protected GitHub environment is named `npm`, permits only `main`, and
requires review. npm trusts this exact publisher tuple:

- repository owner/name: `NIPE-Solutions/readonly-view`
- workflow filename: `release.yml`
- GitHub environment: `npm`

4. Immediately after the trusted publisher is configured, delete the
`NPM_TOKEN` GitHub environment secret and revoke the granular token before
any further release work. For future versions, update the explicit release
authorization and remove `NODE_AUTH_TOKEN` from the publish step so npm
authenticates through the trusted publisher.

The initial workflow uses the token for registry authentication and the
publish job's OIDC permission for `--provenance`; it is not an OIDC-only
publication. Never use a long-lived npm token.
The workflow uses GitHub's short-lived OIDC identity and npm provenance. No npm
token secret is stored in GitHub. npm publishing access is set to require 2FA
and disallow bypass-2FA tokens. Do not add a long-lived npm token.

## Stable 2.0.0 procedure
## Release procedure

1. On the reviewed `main` commit, use Node 24 and run `npm ci`,
`npm run release:check`, and `npm run test:browser`.
2. Review `npm pack --dry-run`, declarations, bundle budgets, license, and the
exact tarball. Confirm the production documentation checks above pass.
3. Confirm `npm view @nipe-solutions/readonly-view@2.0.0 version` returns an npm
3. Confirm `npm view @nipe-solutions/readonly-view@<version> version` returns an npm
`E404`; an existing version must never be overwritten.
4. Dispatch the protected workflow with exactly these inputs:
4. Dispatch the protected workflow with matching inputs:

```bash
version=2.0.1
channel=latest
gh workflow run release.yml --ref main \
-f version=2.0.0 \
-f channel=latest \
-f 'confirmation=publish 2.0.0 with latest'
-f version="$version" \
-f channel="$channel" \
-f "confirmation=publish $version with $channel"
```

5. Approve the `npm` environment deployment, then monitor the publish job. It
downloads the single tarball produced by the verified job, reconfirms
registry absence, publishes those exact bytes with provenance, waits for
registry propagation, and only then creates GitHub release `v2.0.0` at the
registry propagation, and only then creates GitHub release `v<version>` at the
immutable workflow commit.
6. Verify the public result:

```bash
npm view @nipe-solutions/[email protected] name version dist-tags.latest repository homepage
test -n "$(npm view @nipe-solutions/[email protected] dist.attestations.url)"
version=2.0.1
channel=latest
npm view "@nipe-solutions/readonly-view@$version" name version "dist-tags.$channel" repository homepage
test -n "$(npm view "@nipe-solutions/readonly-view@$version" dist.attestations.url)"

consumer_dir="$(mktemp -d)"
(
cd "$consumer_dir"
npm init --yes >/dev/null
npm install --ignore-scripts --save-exact @nipe-solutions/readonly-view@2.0.0
npm install --ignore-scripts --save-exact "@nipe-solutions/readonly-view@$version"
node --input-type=module -e \
"import('@nipe-solutions/readonly-view').then(m => console.log(typeof m.readonlyView))"
)
Expand All @@ -94,17 +77,18 @@ publication. Never use a long-lived npm token.
"https://readonly-view.nipesolutions.com${route}" >/dev/null
done

gh release view v2.0.0 --repo NIPE-Solutions/readonly-view \
gh release view "v$version" --repo NIPE-Solutions/readonly-view \
--json tagName,targetCommitish,url
```

The metadata must report version `2.0.0`, `latest` must resolve to `2.0.0`, the
The metadata must report the requested version, the requested channel must
resolve to that version, the
homepage must be `https://readonly-view.nipesolutions.com`, the import must print
`function` from the temporary consumer, the attestation URL must be nonempty,
all production requests must succeed over HTTPS, and the GitHub release target
must match the dispatched `main` SHA. Remove `consumer_dir` after inspection.

npm versions are immutable: never attempt to republish `2.0.0`. If the package
npm versions are immutable: never attempt to republish an existing version. If the package
is defective, deprecate it with a precise warning when appropriate, publish a
reviewed patch version, and move `latest` only through the guarded workflow. Do
not deprecate or otherwise mutate the old `immuview` package.
10 changes: 5 additions & 5 deletions docs/release-readiness.md
Original file line number Diff line number Diff line change
Expand Up @@ -62,11 +62,11 @@ CI runs the Node 24 quality gate, Node 22 compatibility, and
Chromium/Firefox/WebKit jobs. Release is manual, main-only, exact-confirmation
gated, environment protected, checks version/channel policy and registry
availability, carries the verified tarball through the browser gate, and waits
for propagation before creating a SHA-targeted GitHub release. The first
publication uses a short-lived granular `NPM_TOKEN` environment secret plus
OIDC provenance. After the package exists, that token must be revoked and
deleted and the documented npm trusted publisher must replace it for future
releases.
for propagation before creating a SHA-targeted GitHub release. The initial
publication used a short-lived granular token plus OIDC provenance. That
bootstrap token and its GitHub secret were revoked after publication. Future
releases use the configured npm trusted publisher and short-lived OIDC
credentials only.

The local browser command passed all 22 scheduled tests: 11 on Chromium and 11
on Firefox. Playwright deliberately excluded WebKit on this macOS 14 host under
Expand Down
21 changes: 20 additions & 1 deletion scripts/verify-release.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,10 @@ assert(
pkg.homepage === 'https://readonly-view.nipesolutions.com',
'Homepage is stale',
);
assert(pkg.version === '2.0.0', 'Unexpected package version');
assert(
/^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$/.test(pkg.version),
'Package version must be a publishable semver',
);
assert(pkg.version === lock.version, 'package-lock root version is stale');
assert(
pkg.version === lock.packages[''].version,
Expand Down Expand Up @@ -60,4 +63,20 @@ assert(
changelog.includes('## Unreleased'),
'CHANGELOG needs an Unreleased section',
);

const releaseWorkflow = await readFile(
resolve(root, '.github/workflows/release.yml'),
'utf8',
);
assert(
!releaseWorkflow.includes('secrets.NPM_TOKEN') &&
!releaseWorkflow.includes('NODE_AUTH_TOKEN'),
'Release workflow must use OIDC trusted publishing without a token secret',
);
assert(
releaseWorkflow.includes(
'test "$ACTUAL_CONFIRMATION" = "publish $ACTUAL_VERSION with $ACTUAL_CHANNEL"',
),
'Release confirmation must follow the requested version and channel',
);
console.log(`Release metadata verified for ${pkg.name}@${pkg.version}`);
8 changes: 7 additions & 1 deletion test/package/verify-package.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,13 @@ export async function verifyPackage() {
);
await exec(
'npm',
['install', '--ignore-scripts', '--no-package-lock'],
[
'install',
'--ignore-scripts',
'--no-package-lock',
'--no-audit',
'--no-fund',
],
{ cwd: temporary },
);

Expand Down