diff --git a/.github/workflows/bench.yml b/.github/workflows/bench.yml index 091659f..e22971a 100644 --- a/.github/workflows/bench.yml +++ b/.github/workflows/bench.yml @@ -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 diff --git a/.github/workflows/pr-checks.yml b/.github/workflows/pr-checks.yml index 6bb018b..ae18d07 100644 --- a/.github/workflows/pr-checks.yml +++ b/.github/workflows/pr-checks.yml @@ -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 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index c2a7c55..53b6746 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -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 }} + # 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" @@ -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 diff --git a/tools/release/README.md b/tools/release/README.md index 27ee02d..90c7d72 100644 --- a/tools/release/README.md +++ b/tools/release/README.md @@ -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 @@ -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: @@ -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 --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 '' + 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`. diff --git a/tools/release/setup-branch-ruleset.sh b/tools/release/setup-branch-ruleset.sh index 84ac0c1..482bb3c 100644 --- a/tools/release/setup-branch-ruleset.sh +++ b/tools/release/setup-branch-ruleset.sh @@ -1,42 +1,236 @@ #!/usr/bin/env bash # # One-time setup: move main's protection from classic branch protection to a -# repository ruleset that lists the GitHub Actions app as a bypass actor. +# repository ruleset with a bypass actor the release workflow can push as. +# +# TWO ROUTES. Pick by what access you have: +# +# BYPASS=deploy-key (default) REPO ADMIN is enough. +# A repo-scoped SSH deploy key with write access. Blunter than the App -- +# write to the whole repo, no expiry -- but it belongs to the repository +# rather than to a person, and needs nobody above repo admin. +# +# BYPASS=app Requires an ORGANIZATION OWNER. +# An org-owned GitHub App. Better hygiene: scoped to Contents: write, the +# token expires in an hour, and it is auditable as an app. Preferred if +# you can get an owner to do STEP 1-APP. +# +# Both are recognised by release.yml, which prefers the App when both exist. +# Neither puts a personal credential in the pipeline, which is the thing the +# CircleCI setup (a maintainer's own SSH key) got wrong. # # WHY # The release workflow pushes the `chore(release): publish` commit and the -# per-package tags with the built-in GITHUB_TOKEN. Classic branch protection -# has no bypass list — only repo admins skip the pull-request requirement — -# which is why the CircleCI release had to push with a maintainer's personal -# SSH key. Rulesets do support bypass actors, so migrating lets the bot push -# with no personal credential anywhere in the pipeline. +# per-package tags. Classic branch protection has no bypass list — only repo +# admins skip the pull-request requirement — which is why the CircleCI release +# had to push with a maintainer's personal SSH key. Rulesets do support bypass +# actors, so migrating lets CI push with no personal credential anywhere. # # A ruleset cannot relax classic protection: when both exist GitHub applies # the most restrictive of the two. The classic rule must therefore be deleted, # which is why this script does both halves. # +# WHY NOT THE BUILT-IN GITHUB_TOKEN +# An earlier version of this script used the "GitHub Actions" app (id 15368) +# as the bypass actor. That cannot work, and GitHub rejects it outright: +# +# HTTP 422: Actor GitHub Actions integration must be part of the ruleset +# source or owner organization +# +# App 15368 is owned by `github`, not by this organization, and a ruleset only +# accepts bypass actors belonging to the repo or its owning org. There is no +# repository setting that grants the built-in GITHUB_TOKEN a push to a +# PR-protected branch. An org-owned App is the supported route, and unlike a +# PAT it is not tied to any individual's account or expiry. +# # WHAT CHANGES FOR HUMANS # Nothing. The ruleset below reproduces main's current rules exactly: # 1 approving review, code-owner review required, stale reviews dismissed on # push, last-push approval required, no force pushes, no branch deletion. # -# PREREQUISITES -# gh auth login, as a repo admin. +# --------------------------------------------------------------------------- +# STEP 1-DEPLOY-KEY — the repo-admin route (~3 minutes, no org access) +# +# 1. Generate a keypair. Nothing but this repo will ever use it, so it does +# not belong in ~/.ssh: +# ssh-keygen -t ed25519 -N '' -C 'codecs release' -f ./codecs-release-key +# +# 2. Add the PUBLIC half as a deploy key WITH WRITE ACCESS: +# gh repo deploy-key add ./codecs-release-key.pub \ +# --repo cornerstonejs/codecs --title 'codecs release' --allow-write +# (UI equivalent: Settings -> Deploy keys -> Add deploy key, tick +# "Allow write access".) +# +# 3. Add the PRIVATE half as the secret release.yml reads, then delete both +# local halves -- the repo and the secret are the only copies you need: +# gh secret set RELEASE_DEPLOY_KEY --repo cornerstonejs/codecs < ./codecs-release-key +# rm ./codecs-release-key ./codecs-release-key.pub +# +# 4. Run this script (default BYPASS=deploy-key), then STEP 3. +# gh auth login # as a repo admin +# bash tools/release/setup-branch-ruleset.sh +# +# STEP 1-APP — the org-owner route (GitHub UI, ~5 minutes) +# +# 1. https://github.com/organizations/cornerstonejs/settings/apps/new +# GitHub App name: cornerstonejs-release +# Homepage URL: https://github.com/cornerstonejs/codecs +# Webhook: UNCHECK "Active" — this App never receives events +# Repository permissions: +# Contents ......... Read and write (push the commit + tags) +# Metadata ......... Read-only (added automatically) +# Nothing else. Do NOT grant Actions, Packages, or Administration. +# "Where can this GitHub App be installed?" -> Only on this account +# Create, then note the App ID shown on the settings page. +# +# 2. Still on the App's page: "Private keys" -> "Generate a private key". +# A .pem downloads. It is shown once. +# +# 3. "Install App" (left sidebar) -> Install on cornerstonejs -> +# "Only select repositories" -> codecs -> Install. +# +# 4. Store the credentials on the repo (or the org, if you prefer to share +# the App with other repos later): +# gh variable set RELEASE_APP_ID --repo cornerstonejs/codecs --body '' +# gh secret set RELEASE_APP_PRIVATE_KEY --repo cornerstonejs/codecs < /path/to/key.pem +# Then delete the local .pem. release.yml reads exactly these two names. # -# Verify afterwards with: -# gh api repos/cornerstonejs/codecs/rulesets -# gh api repos/cornerstonejs/codecs/branches/main/protection # expect 404 +# 2. Run this script in app mode, then STEP 3: +# gh auth login # as the org owner +# BYPASS=app RELEASE_APP_SLUG=cornerstonejs-release \ +# bash tools/release/setup-branch-ruleset.sh +# +# STEP 3 — verify, either route: +# gh api repos/cornerstonejs/codecs/rulesets +# gh api repos/cornerstonejs/codecs/branches/main/protection # expect 404 +# Then re-run the failed Release workflow. Its push step logs which +# credential it used, and warns if neither is configured, so a half-done +# STEP 1 says so plainly instead of failing with "protected branch hook +# declined". +# --------------------------------------------------------------------------- set -euo pipefail REPO="${REPO:-cornerstonejs/codecs}" +ORG="${REPO%%/*}" +BYPASS="${BYPASS:-deploy-key}" + +case "$BYPASS" in + deploy-key) + # A deploy key belongs to the repository by definition, so it satisfies + # "part of the ruleset source" with no id to resolve and no ownership + # question -- which is exactly why this route needs nothing above repo + # admin. actor_id MUST be null for this actor_type. + BYPASS_ACTOR_JSON='{ "actor_id": null, "actor_type": "DeployKey", "bypass_mode": "always" }' + echo "Bypass actor: EVERY write-enabled deploy key on $REPO" + echo + + # Note the blast radius, which is the one real drawback of this route: the + # DeployKey actor takes actor_id null, so it is a category, not a specific + # key. There is no way to grant bypass to one deploy key and withhold it from + # another. Every write-enabled key on the repo, present and future, can push + # to main without review. + # + # So the write-enabled keys are listed here rather than merely counted, and + # the operator has to acknowledge the list by name before the ruleset is + # created. A leftover key from a retired CI system is the case that matters: + # it stops being an unused credential and becomes one that bypasses branch + # protection. This repo had exactly that -- a read-write `Codecs CircleCI` + # key, years after CircleCI stopped running here. A warning printed above a + # y/N prompt is too easy to scroll past for a privilege escalation that + # silent, hence the typed acknowledgement. + WRITE_KEYS=$(gh repo deploy-key list --repo "$REPO" 2>/dev/null | grep -F 'read-write' || true) + if [ -z "$WRITE_KEYS" ]; then + echo "WARNING: $REPO has no write-enabled deploy key, so the release still" >&2 + echo " cannot push. Do STEP 1-DEPLOY-KEY 1-3." >&2 + echo >&2 + else + echo "These write-enabled deploy keys will ALL be able to push to main," + echo "bypassing pull request review, once this ruleset exists:" + echo + printf '%s\n' "$WRITE_KEYS" | sed 's/^/ /' + echo + echo "Delete any that are not the release key, then re-run:" + echo " gh repo deploy-key delete --repo $REPO" + echo + if [ "${DEPLOY_KEYS_AUDITED:-}" = "1" ]; then + echo "DEPLOY_KEYS_AUDITED=1 set; skipping the acknowledgement prompt." + else + read -r -p "Type 'audited' if every key above is meant to have that: " ack + if [ "$ack" != "audited" ]; then + echo "Aborted -- nothing was changed." >&2 + exit 1 + fi + fi + echo + fi + ;; + + app) + # The App whose installation is allowed to bypass the pull-request rule. Must + # be owned by $ORG — see "WHY NOT THE BUILT-IN GITHUB_TOKEN" above. Pass the + # slug from the App's URL + # (github.com/organizations//settings/apps/), which is the name + # lowercased with spaces as hyphens. + RELEASE_APP_SLUG="${RELEASE_APP_SLUG:-cornerstonejs-release}" + + # gh's built-in --jq, not standalone jq: this script is run from a + # maintainer's own machine, where jq is not a given (release.yml can assume + # it, a laptop cannot). One call, both fields, split below. + if ! APP_INFO=$(gh api "apps/$RELEASE_APP_SLUG" --jq '"\(.id) \(.owner.login)"' 2>/dev/null); then + cat >&2 < bash tools/release/setup-branch-ruleset.sh + +The slug is the last path segment of the App's settings URL. +MSG + exit 1 + fi + + read -r RELEASE_APP_ID RELEASE_APP_OWNER <<<"$APP_INFO" -# The built-in GITHUB_TOKEN acts as the "GitHub Actions" app installation, and -# the bypass actor below is keyed on that app's id. Resolved at runtime rather -# than hardcoded to 15368: the value differs on GitHub Enterprise Server, and a -# wrong id produces a ruleset that looks correct but silently fails to let the -# release workflow push. -GITHUB_ACTIONS_APP_ID=$(gh api apps/github-actions --jq .id) + # Fail here rather than let the API return the 422 this script exists to + # avoid. + if [ "$RELEASE_APP_OWNER" != "$ORG" ]; then + cat >&2 </dev/null); then + if ! printf '%s\n' "$INSTALLS" | grep -qx "$RELEASE_APP_SLUG"; then + echo "WARNING: '$RELEASE_APP_SLUG' is not installed on $ORG. Do STEP 1-APP.3." >&2 + fi + else + echo "NOTE: could not list org installations (needs admin:org); skipping" >&2 + echo " the install check. Confirm STEP 1-APP.3 was done." >&2 + fi + echo + ;; + + *) + echo "BYPASS must be 'deploy-key' (repo admin) or 'app' (org owner); got '$BYPASS'." >&2 + exit 1 + ;; +esac echo "Current protection on $REPO main:" gh api "repos/$REPO/branches/main/protection" || true @@ -58,11 +252,7 @@ gh api -X POST "repos/$REPO/rulesets" --input - <