Skip to content
Merged
31 changes: 30 additions & 1 deletion .github/workflows/bench.yml
Original file line number Diff line number Diff line change
Expand Up @@ -395,4 +395,33 @@ jobs:
SCOPE_FLAGS: ${{ steps.scope.outputs.flags }}
with:
mode: simulation
run: bash tools/ci/with-nashua-lock.sh pnpm --parallel $SCOPE_FLAGS run bench
# --workspace-concurrency=1, matching codspeed-walltime in
# pr-checks.yml. NOT merely dropping --parallel: pnpm's default
# workspace concurrency is 4, so removing the flag alone would still
# run four packages' bench processes against each other.
#
# This job used to run all eight in parallel, on the premise -- stated
# in codspeed-walltime's own comment -- that instruction counting is
# immune to contention. That premise does not survive #76. The charls
# bench `decode CT-512x512-near-lossless.JLS (.81 near-lossless) —
# warm` was reported as a 19.8ms -> 37.9ms regression on a commit
# whose entire diff was one vitest file, with charls' source and its
# built wasm byte-identical to main's and its real wall-clock bench
# duration unchanged (26.7s vs 27.5s). The same -47.76% appeared again
# on the next commit, so it was reproducible rather than flake. Per
# package completion times from that run put charls at 27s in, sharing
# the box with six or seven siblings, while dicom-codec then ran alone
# for ~5m54s -- i.e. the packages are measured under wildly different
# neighbours, and #76 changed what those neighbours do (its openjph
# benches got 3-7.4x faster).
#
# Whatever the mechanism inside Cachegrind, a gate that measures eight
# packages simultaneously cannot attribute a per-package delta, and it
# spent #76 blaming an untouched package. Serial costs ~3 minutes:
# dicom-codec alone is ~6m of the 6m22s bench step, and the job
# timeout is 100 minutes.
#
# Landing this resets the comparison basis for every bench measured
# under contention, so the first main run after merge is the new
# baseline -- expect one round of large apparent deltas there.
run: bash tools/ci/with-nashua-lock.sh pnpm --workspace-concurrency=1 $SCOPE_FLAGS run bench
6 changes: 4 additions & 2 deletions .github/workflows/pr-checks.yml
Original file line number Diff line number Diff line change
Expand Up @@ -519,8 +519,10 @@ jobs:
- name: Run CodSpeed benchmarks (walltime)
# Walltime measures actual elapsed time, so parallel benchmark
# processes would contend for cores and add noise — run packages
# sequentially (--workspace-concurrency=1), unlike the simulation job
# where instruction counting is immune to contention.
# sequentially (--workspace-concurrency=1). The simulation job in
# bench.yml now does the same: it was left parallel on the premise that
# instruction counting is immune to contention, and #76 showed it is
# not. See the comment on that job's run step.
uses: CodSpeedHQ/action@4e969336ab9acd4f6f8d025fdd793292b0835df0 # v4.18.2
env:
# Keep this in env, NOT `${{ }}` in the run: below — an env value is
Expand Down
114 changes: 105 additions & 9 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -221,13 +221,77 @@ jobs:
count=$(jq 'length' release-plan.json)
echo "count=$count" >> "$GITHUB_OUTPUT"
jq -r '.[] | " \(.name): \(.previousVersion) -> \(.version) [\(.releaseType)]"' release-plan.json
- name: Mint a push token from the release GitHub App
id: app-token
# The built-in GITHUB_TOKEN CANNOT push to main, and no repository
# setting can grant it that: main's protection requires a pull request,
# and the "GitHub Actions" app it authenticates as (id 15368) is owned by
# `github`, not by this org. Adding it as a ruleset bypass actor is
# rejected outright -- "Actor GitHub Actions integration must be part of
# the ruleset source or owner organization" (HTTP 422). An org-owned app
# is the supported way to let CI push to a protected branch without a
# human's personal credential in the pipeline.
#
# Configured with an org/repo VARIABLE plus a SECRET, both optional, so
# this step no-ops on a fork, before the one-time setup has been done, or
# when the deploy-key route below is used instead. See
# tools/release/setup-branch-ruleset.sh for both setups.
#
# Deliberately NOT continue-on-error. If RELEASE_APP_ID is set and
# minting fails, this job stops rather than quietly pushing with the
# deploy key instead. Falling through would swap a token scoped to
# Contents: write and expiring in an hour for one with write access to
# the whole repo and no expiry -- a downgrade nobody asked for, in a
# pipeline that publishes to npm, discoverable only by reading the log of
# a release that appeared to succeed. A broken App config should be
# fixed; the action validates the private key up front and retries
# transient 5xx itself, so what reaches here is a real misconfiguration,
# and --atomic means the failed run leaves no debris to clean up.
if: steps.version.outputs.count != '0' && vars.RELEASE_APP_ID != ''
uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0
with:
app-id: ${{ vars.RELEASE_APP_ID }}
private-key: ${{ secrets.RELEASE_APP_PRIVATE_KEY }}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
# Narrow the token to what the push needs. Without this it inherits
# every permission the installation holds, so a later widening of the
# App silently widens the release token too.
permission-contents: write
- name: Commit, tag and push
id: push
if: steps.version.outputs.count != '0'
env:
GH_TOKEN: ${{ github.token }}
# Three credentials, in order of preference. Both of the first two work
# because main's ruleset lists them as bypass actors; GITHUB_TOKEN
# cannot be listed at all (see the app-token step) and is only here so
# that forks and a not-yet-configured repo still reach the push and
# report something useful instead of failing earlier and vaguer.
#
# 1. App token — org-owned App. Best: scoped to Contents: write,
# expires in an hour, and belongs to the org.
# Requires an ORG OWNER to create and install it.
# 2. Deploy key — repo-scoped SSH key with write access. Slightly
# blunter (write to the whole repo, no expiry) but a
# REPO ADMIN can set it up alone, and like the App it
# is not tied to any individual's account.
# 3. GITHUB_TOKEN — declined by main's protection. Warns below.
GH_TOKEN: ${{ steps.app-token.outputs.token || github.token }}
APP_TOKEN_CONFIGURED: ${{ steps.app-token.outputs.token != '' }}
RELEASE_DEPLOY_KEY: ${{ secrets.RELEASE_DEPLOY_KEY }}
run: |
set -euo pipefail

# Resolved before any git work so the log says which credential is in
# play before it matters.
if [ "$APP_TOKEN_CONFIGURED" = "true" ]; then
PUSH_VIA=app
elif [ -n "${RELEASE_DEPLOY_KEY:-}" ]; then
PUSH_VIA=deploy-key
else
PUSH_VIA=github-token
echo "::warning::Neither RELEASE_APP_ID nor RELEASE_DEPLOY_KEY is configured, so this push uses GITHUB_TOKEN, which main's branch protection will decline. See tools/release/setup-branch-ruleset.sh (the deploy-key route needs only repo admin)."
fi
echo "Pushing via: $PUSH_VIA"

git config user.name "github-actions[bot]"
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"

Expand Down Expand Up @@ -257,14 +321,46 @@ jobs:
git tag -a "$tag" -m "$tag"
done

# Pushed with GITHUB_TOKEN, which main's ruleset lets bypass the pull
# request requirement. The token goes in the remote URL because
# checkout was told not to persist it (see above). Note this push does
# NOT trigger further workflow runs (GitHub suppresses them for
# GITHUB_TOKEN pushes), which is why the GitHub Releases are created
# by a later job rather than by a separate tag-triggered workflow.
git push --follow-tags \
"https://x-access-token:${GH_TOKEN}@github.com/${GITHUB_REPOSITORY}.git" HEAD:main
# Pushed with whichever credential main's ruleset lists as a bypass
# actor. (An earlier comment here said GITHUB_TOKEN could do this; it
# cannot -- see the app-token step.) Credentials are passed per-push
# rather than persisted, because checkout was told not to keep them in
# .git/config while `pnpm install` runs.
#
# The commit message carries [skip ci], and it is load-bearing on BOTH
# of the routes that can actually push. GitHub suppresses workflow runs
# only for pushes made with GITHUB_TOKEN; an App-token push and a
# deploy-key push are both ordinary pushes and WOULD retrigger this
# workflow on main. [skip ci] is what stops that being a release loop,
# so do not remove it -- on either route.
#
# --atomic: all refs land or none do. Without it git updates each ref
# independently, and the 2026-08-24 run (32733067241) showed what that
# costs -- `! [remote rejected] HEAD -> main (protected branch hook
# declined)` while all eight version tags pushed successfully anyway.
# That left them pointing at a `chore(release): publish` commit which
# is not an ancestor of main, so every later run died at `git tag -a`
# with "tag already exists" before it even reached this push, and the
# recovery needed a human deleting eight remote tags. A rejected
# branch update must not be able to publish tags for a release that
# did not happen.
if [ "$PUSH_VIA" = "deploy-key" ]; then
# Key material into a file the runner discards with the job, never
# onto a command line. IdentitiesOnly stops any agent key being tried
# first; accept-new is safe on an ephemeral runner with no prior
# known_hosts to be spoofed against.
key_file=$(mktemp)
printf '%s\n' "$RELEASE_DEPLOY_KEY" > "$key_file"
chmod 600 "$key_file"
export GIT_SSH_COMMAND="ssh -i $key_file -o IdentitiesOnly=yes -o StrictHostKeyChecking=accept-new"
trap 'rm -f "$key_file"' EXIT

git push --atomic --follow-tags \
"git@github.com:${GITHUB_REPOSITORY}.git" HEAD:main
else
git push --atomic --follow-tags \
"https://x-access-token:${GH_TOKEN}@github.com/${GITHUB_REPOSITORY}.git" HEAD:main
fi

echo "sha=$(git rev-parse HEAD)" >> "$GITHUB_OUTPUT"
- name: Nothing to release
Expand Down
127 changes: 114 additions & 13 deletions tools/release/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,12 @@

Releases are fully automated: merge to `main`, and
[.github/workflows/release.yml](../../.github/workflows/release.yml) versions, tags, publishes and
writes the GitHub Releases. There are no release secrets to rotate — npm auth is OIDC trusted
publishing and git auth is the built-in `GITHUB_TOKEN`.
writes the GitHub Releases. npm auth is OIDC trusted publishing, so there is no npm token to
rotate. Git auth for the one push to `main` is either a repo deploy key (`RELEASE_DEPLOY_KEY`) or an
org-owned GitHub App (`RELEASE_APP_ID` + `RELEASE_APP_PRIVATE_KEY`) — the built-in `GITHUB_TOKEN`
cannot push to a PR-protected branch and cannot be granted a ruleset bypass, since the app it
authenticates as is owned by `github` rather than by this org. See
[§2 below](#2-a-push-credential-for-main--the-branch-ruleset).

## How a release is decided

Expand Down Expand Up @@ -52,9 +56,14 @@ token runs nothing but `npm`, the pinned actions and `publish-order.mjs` (node b
`pnpm-lock.yaml` (pnpm records each importer's *specifier*, so rewriting dicom-codec's ranges
strands the lockfile and the next `--frozen-lockfile` install fails), commits
`chore(release): publish [skip ci]` and one annotated tag per released package, and pushes to
`main` with `GITHUB_TOKEN`. The token is passed to `git push` in the remote URL rather than
persisted into `.git/config` by `actions/checkout`, so it is not sitting on disk while `pnpm
install` runs. The job outputs the pushed commit SHA.
`main` with a token minted per-run from the release App. The token is passed to `git push` in the
remote URL rather than persisted into `.git/config` by `actions/checkout`, so it is not sitting on
disk while `pnpm install` runs. The job outputs the pushed commit SHA.

The push is `--atomic`: without it a declined branch update still publishes the tags, which is how
run [32733067241](https://github.com/cornerstonejs/codecs/actions/runs/32733067241) left eight
version tags on a commit that never reached `main` and wedged every later release at
`git tag -a` with "tag already exists".
3. **`publish`** — checks out that SHA, replays the dists, and publishes each package with
`npm publish --ignore-scripts` in the dependency order `publish-order.mjs` computes — dicom-codec
goes out after the six siblings whose ranges it carries. `--ignore-scripts` is deliberate:
Expand Down Expand Up @@ -107,14 +116,106 @@ After the first green release, harden on npmjs.com: set each package's *Publishi
"Require two-factor authentication and disallow tokens", and delete the old `NPM_TOKEN` from the
CircleCI project (CircleCI no longer runs anything for this repo — the project should be disabled).

### 2. `main` branch ruleset
### 2. A push credential for `main` + the branch ruleset

```bash
gh auth login # as a repo admin
bash tools/release/setup-branch-ruleset.sh
Two routes. **The deploy-key route needs only repo admin**; the App route is better hygiene but
requires an organization owner. `release.yml` accepts either and prefers the App when both exist.

| | Deploy key | GitHub App |
|---|---|---|
| Who can set it up | repo admin | **org owner** |
| Scope | write to the whole repo | `Contents: write` |
| Lifetime | no expiry | token expires hourly |
| Bypass granularity | **every** write-enabled deploy key on the repo | that one App |
| Secrets | `RELEASE_DEPLOY_KEY` | `RELEASE_APP_ID` + `RELEASE_APP_PRIVATE_KEY` |

Neither is a personal credential, which is the thing to preserve — the point of moving off
CircleCI's arrangement was that releases must not depend on one person's key.

The release job needs to push the version commit to `main`, which requires a pull request. The
built-in `GITHUB_TOKEN` cannot be exempted from that: the "GitHub Actions" app it authenticates as
(id 15368) is owned by `github`, and a ruleset only accepts bypass actors belonging to the repo or
its owning org, so GitHub rejects it with

```
422 Actor GitHub Actions integration must be part of the ruleset source or owner organization
```

Replaces main's classic branch protection with an equivalent ruleset that lets the GitHub Actions app
bypass the pull-request requirement, so the release job can push the version commit. Review
requirements for humans are unchanged. See the script's header for why the classic rule has to go
rather than sit alongside the ruleset.
A **deploy key** or an **org-owned App** can both be listed as bypass actors. A deploy key belongs to
the repository, so it satisfies "part of the ruleset source" with no ownership question — which is
why it works without org access.

#### Route A — deploy key (repo admin)

1. **Create the key and store both halves.** Full walkthrough is `STEP 1-DEPLOY-KEY` in
[setup-branch-ruleset.sh](setup-branch-ruleset.sh)'s header. Summary:

```bash
ssh-keygen -t ed25519 -N '' -C 'codecs release' -f ./codecs-release-key
gh repo deploy-key add ./codecs-release-key.pub \
--repo cornerstonejs/codecs --title 'codecs release' --allow-write
gh secret set RELEASE_DEPLOY_KEY --repo cornerstonejs/codecs < ./codecs-release-key
rm ./codecs-release-key ./codecs-release-key.pub
```

2. **Migrate the branch protection:**

```bash
gh auth login # as a repo admin
bash tools/release/setup-branch-ruleset.sh
```

> [!WARNING]
> The `DeployKey` bypass actor takes `actor_id: null` — it is a **category, not a specific key**.
> Every write-enabled deploy key on the repo, present and future, can then push to `main` without
> review. The script lists them and makes you acknowledge the list by name before it creates
> anything. Audit before enabling, and delete any left over from retired CI — a key nobody uses stops
> being merely unused and becomes one that bypasses branch protection:
>
> ```bash
> gh repo deploy-key list --repo cornerstonejs/codecs
> gh repo deploy-key delete <id> --repo cornerstonejs/codecs
> ```
>
> The release key is the only write-enabled key that should appear. Anything else is a finding.

#### Route B — GitHub App (org owner)

1. **Create and install the App** — walkthrough is `STEP 1-APP` in the script's header. Summary:
create `cornerstonejs-release` under the org with **Contents: read and write** and nothing else, no
webhook, generate a private key, install it on `codecs` only, then

```bash
gh variable set RELEASE_APP_ID --repo cornerstonejs/codecs --body '<App ID>'
gh secret set RELEASE_APP_PRIVATE_KEY --repo cornerstonejs/codecs < /path/to/key.pem
rm /path/to/key.pem
```

2. **Migrate the branch protection:**

```bash
gh auth login # as the org owner
BYPASS=app RELEASE_APP_SLUG=cornerstonejs-release bash tools/release/setup-branch-ruleset.sh
```

#### Either route

The script replaces main's classic branch protection with an equivalent ruleset listing the chosen
bypass actor. Review requirements for humans are unchanged: 1 approving review, code-owner review,
stale reviews dismissed on push, last-push approval, no force pushes, no branch deletion. See the
script's header for why the classic rule has to go rather than sit alongside the ruleset.

One behavioural note that applies to both routes: GitHub suppresses workflow runs only for pushes
made with `GITHUB_TOKEN`. An App-token push and a deploy-key push are both ordinary pushes and
*would* retrigger the release workflow on `main`. The `[skip ci]` in the release commit message is
what prevents a loop — do not remove it, whichever route you set up.

3. **Verify**, then re-run the failed Release workflow:

```bash
gh api repos/cornerstonejs/codecs/rulesets
gh api repos/cornerstonejs/codecs/branches/main/protection # expect 404
```

If `RELEASE_APP_ID` is unset the release still runs and fails at the push, but logs a warning naming
this section rather than only `protected branch hook declined`.
Loading