Skip to content

Latest commit

 

History

History
523 lines (419 loc) · 56.2 KB

File metadata and controls

523 lines (419 loc) · 56.2 KB

socket-patch weekly release train: final lean design

Base: origin/main @ 045d7ec7. This design starts from Candidate 1 ("two workflows, one script, one routine"), which all three judges picked. It fixes every confirmed invariant violation and takes over the best ideas from Candidates 2 and 3. Nothing here has been built or run. Every fact cited below was re-read from origin/main.

Size: 4 PRs, 3 must-run probes, 2 workflows (1 rewritten, 1 new), 2 Python files, 2 environments, 1 routine, 1 GitHub App (tag minting only) + 1 tag ruleset, no reconciler.

Maintainer decisions (2026-10-02), applied throughout this document:

  • D1 Tags. A GitHub App socket-patch-release plus a refs/tags/v* ruleset (creation, update and deletion restricted; the App is the only bypass actor). The tag is minted by the App inside the publish environment job (PR 3). Resolves §9 Q1.
  • D2 Version and CHANGELOG reach main on every rc, not only on stable. The rolling release-sync PR moves main to the newest cut tag (rc or stable), with rc sections folded at promotion (§3.7). Resolves §9 Q2.
  • D3 Routines run as mikolalysenko for now (listed in RELEASE_ROUTINE_ACTORS, so excluded from approvers) and migrate to a bot later. npm stable is a direct OIDC publish after environment approval (no 2FA staging). Hotfixes are for the newest line only. Resolves §9 Q3.
  • D4 The first release through the train is 5.0.0 (first cut 5.0.0-rc.1). The maintainer pre-approved this major; APPROVED_MAJORS = (5,) in scripts/release.py records it (§3.1).

1. Summary and weekly timeline

What runs. One release workflow on main, release.yml, runs from cron or manual dispatch. Every run goes straight through the same steps: plan → cut → QA → [approve] → publish → report.

  1. Cut. The run creates an ephemeral branch release/v<V>: base C plus one signed, version-only bump commit, whose head is H.

  2. QA. It dispatches one run of release-qa.yml at that branch. The QA run:

    • builds the 14 archives and 15 npm tarballs once;
    • smoke-tests those bytes;
    • calls ci.yml and the 9 compat workflows with workflow_call, testing tree H.

    The single run's head_sha == H is the whole evidence.

  3. Approve (stable only). The run waits on the protected release environment.

  4. Publish. The publish job (environment publish, main only, the only job with id-token: write and the only holder of the release App's key) re-checks everything, mints the tag through the App, then publishes exactly the QA run's bytes.

Release-blockers are the only bug gate. They are evaluated mechanically from label and close events, filtered by actor. No Claude routine and no untrusted account can clear one.

The routine is not on the critical path. One daily Claude routine maintains a single rolling PR to main that syncs version and CHANGELOG and fills changelog gaps. It never merges; a human reviews and merges that PR like any other. If the routine dies, releases still ship.

            Mon                                   Tue                         daily
05:41  nightly ci.yml (existing; gives a full-tier green SHA)
10:15                                                                      ROUTINE: refresh rolling
                                                                           "release-sync" PR (sync + gap-fill)
12:00  release.yml (rc)                       12:00 release.yml (stable)
        plan: blockers? green SHA? version     plan: rc published >=7d ago, core > L,
        cut release/vX.Y.Z-rc.N (signed)             rc's full QA run found, no blocker
        release-qa(full) at H, <=2 reruns      cut release/vX.Y.Z = rc tag + version-only commit
          red -> fallback SHA, rc.N+1          release-qa(smoke) at H  (~30-45 min)
          red again -> SKIP + health report    ntfy/Slack/issue: "approval needed"
        publish: crates -> npm @next ->        approve: env `release` (1 of approvers team)
          GitHub prerelease (latest=false)     publish: re-check -> crates -> npm latest ->
        verify-channels (I4)                     GitHub release, Latest LAST -> verify
        report: issue + ntfy + Slack           report
        typical publish ~14:00-16:00           (waits as long as approval takes)

The promotion rule is "newest rc whose prerelease was published at least 7 days ago". Monday's rc is therefore promoted on the second Tuesday after it (8 days of soak).

  • Example: 5.0.0-rc.1 is cut Mon 10-12 and promoted Tue 10-20.
  • 5.0.0-rc.2 (Mon 10-19) is never promoted, because once 5.0.0 ships its core is no longer above the latest stable.
  • rc.2's section is synced to main like every rc (D2). When 5.0.0 is synced, the fold drops it and its blocks that did not ship in [5.0.0] return to [Unreleased], so they flow into the next train.

2. Components (and why each must exist)

Component What it is Why it can't be dropped
.github/workflows/release.yml (rewritten) Orchestrator, always evaluated from main. Crons 0 12 * * 1 (rc) and 0 12 * * 2 (stable); workflow_dispatch with mode rc / stable / hotfix. Jobs: plan, attempt-1, attempt-2, approve, publish, report. Main is reviewed code (ruleset 14306549: PR + 1 approval, no bypass). That makes it the only place where registry OIDC can be bound safely (I6). It also gives Monday's rc an Actions-side schedule that doesn't depend on a cloud routine staying alive.
.github/workflows/release-qa.yml (new) Dispatched by release.yml with --ref release/v<V>. Profile full (rc, hotfix) or smoke (stable). Jobs: guard, build×14, bundle, smoke, live-e2e, ci + 9 compat via uses:, verdict. Only contents: read (plus actions: read on verdict). No secrets. It is the single unit of evidence for I1 and I5. Dispatching at the release ref makes every existing checkout resolve to H, and the workflow_dispatch event turns on every event-gated tier, all without editing the tiers.
scripts/release.py (stdlib Python, one file) Subcommands: semver, stamp, npm-lock-check, next-version, `changelog cut promote
scripts/release_smoke.py Black-box smoke of the bundle: Tier 0 on every executable target, Tier 1 live lifecycle plus channels on 3 OSes. I5 requires black-box smoke of the artifacts. Python runs the same way on Linux, macOS and Windows runners.
Environment release Required reviewers = team socket-patch-release-approvers (any 1). Deployment branch main. can_admins_bypass: false. The team never contains a routine identity. I2. This is the human gate. (prevent_self_review is deliberately off, see §5 I2.)
Environment publish No reviewers. Deployment branch main. can_admins_bypass: false. The 15 npm and 2 crates trusted publishers are bound to (repo, release.yml, publish). Holds the App secrets RELEASE_APP_ID and RELEASE_APP_PRIVATE_KEY. I6. A same-named workflow on any other ref cannot get a usable OIDC token or App token.
GitHub App socket-patch-release (D1) Installed on this repo only, permission contents: write and nothing else. Used once per release, in publish, through actions/create-github-app-token, to POST /git/refs the tag refs/tags/vV at H. The only identity that can create a release tag (below), so a write-access identity, the routine included, can no longer mint a tag and with it a GitHub release that install.sh would serve.
Ruleset refs/tags/v* (D1) Target tags v*: creation, update and deletion restricted. Bypass list = the App only (no admin, no team, no deploy keys). Preventive half of I6 for the GitHub channel. Setting an App as the only bypass actor needs an enterprise/org admin (setup S9).
Repo variables RELEASE_APPROVERS: comma-separated logins that mirror the team. RELEASE_ROUTINE_ACTORS: every login any Claude routine runs as; today that is mikolalysenko (D3), later the bot. Both are admin-only to change. GITHUB_TOKEN cannot read team membership. The blocker gate needs to know which logins may clear a blocker (I3, I6).
Repo secrets NTFY_TOPIC; SLACK_WEBHOOK_URL (optional). Notifications. Neither can publish anything.
Repo setting Immutable releases. Once a release is published, its tag and assets cannot move. Complements the tag ruleset: the ruleset stops new tags, immutability freezes shipped ones.
Routine release-train Claude cloud, 15 10 * * *, exits idle when the PR is already current. REST only, no secrets, Slack connector. Runs as mikolalysenko until the bot account exists (D3). Covers the fixed decision "agent fills changelog gaps" and gets version and CHANGELOG to main through a PR. Not on the critical path.
Issues One pinned "Release train" issue (label release:train) carries a weekly log line. One Release v<V> tracking issue per release (label release). Fixed decision: a per-release tracking issue, plus somewhere to see skips week over week.

3. Flow

3.1 rc: Monday 12:00Z (mode=rc)

plan (contents read, actions write, issues write):

  1. Blocker gate (§3.5) with t = the candidate's base. If blocked, the run is SKIPPED with reason blocker-open:#N.
  2. Window. Take the first-parent commits of origin/main that are newer than the previous weekly rc's Release-Base: trailer (v4.0.0 for the first train) and committed at most 14 days ago.
  3. Primary candidate. The newest SHA in the window with a ci.yml run whose event is push or schedule and whose latest attempt concluded success.
  4. Fallback candidate. The newest SHA in the window, older than the primary, with a successful schedule run. The nightly includes e2e-docker.
  5. Skip conditions:
    • no primary → SKIPPED no-green-main-sha, and post a health report;
    • primary equals the previous base → SKIPPED nothing-new, quietly: log line and Slack only, no ntfy.
  6. Version V:
    • L = the newest stable tag.
    • U = the [Unreleased] section of sync-main(C), computed in memory from git tags only (§3.7), never from main's Cargo version. This covers every release-sync PR that hasn't merged yet: pending rc sections' blocks are removed, shipped stables' blocks are removed, and blocks of rcs that did not ship come back. So whether the sync PR merged changes neither V nor the cut's CHANGELOG.
    • Level comes from U's ### headings only, which is human-reviewed text on main:
      • a heading containing breaking, or starting Removed → major;
      • Added / Changed / Deprecated → minor;
      • anything else → patch.
    • core = max(bump(L, level), max core of rc tags with core > L).
    • Major rule (D4). If core.major > L.major, the cut is allowed only when core.major == L.major + 1 (a major is never skipped) and core.major is listed in APPROVED_MAJORS in scripts/release.py. Adding a major there is a reviewed PR on main, i.e. the human approval. Otherwise next-version refuses with an error naming the breaking heading, and the run is SKIPPED unapproved-major. Once an approved major's train is in flight (an rc tag M.0.0-rc.N exists), further breaking entries are absorbed into M.0.0 by the max. APPROVED_MAJORS = (5,) today.
    • N = 1 + max N over existing tags and release/v<core>-rc.* branches. N is burned when the branch is created.
    • First train: ### Breaking changes is present and 5 is approved, so V = 5.0.0-rc.1.
  7. Open the tracking issue.

attempt-1 (concurrency group release-cut, timeout 355 min):

cut:

  1. POST /git/refs creates release/v<V> at C.
  2. stamp V and changelog cut run locally: sync-main is applied to C's CHANGELOG in memory, then its [Unreleased] becomes ## [V] — <UTC date>, and an empty [Unreleased] stays above it.
  3. createCommitOnBranch (expectedHeadOid = C) writes the result as one commit, which GitHub signs (probe P1).
  4. Assert that:
    • the remote tree SHA equals the locally computed tree;
    • H has one parent;
    • git diff --name-status C H is only M lines on the allowlist (Cargo.toml, Cargo.lock, CHANGELOG.md, npm/socket-patch/package.json, npm/socket-patch/package-lock.json, npm/socket-patch-*/package.json).
  5. Commit trailers: Release-Kind: rc, Release-Base: <C>.

qa:

  1. Dispatch release-qa.yml --ref release/v<V> -f version=V -f profile=full.
  2. Find the run: path release-qa.yml, head_sha == H, event workflow_dispatch, created_at at or after the dispatch.
  3. Poll every 60 s.
  4. On failure, call rerun-failed-jobs, at most 2 times, the second at least 10 minutes after the first. There is no failure classification.

attempt-2 runs only if attempt-1 is not ok and a fallback exists. It repeats the cut and QA with the fallback SHA at rc.N+1. If this fails too, the run is SKIPPED qa-red, and the health report lists each failing job with its URL.

publish (environment publish, concurrency release-publish, never cancelled):

  1. publish-checks:
    • the version grammar matches the kind;
    • verify-qa (§3.6) passes;
    • the blocker gate passes again;
    • tag vV is absent or already points at H.
  2. Download the release-bundle artifact from the QA run and run sha256sum -c.
  3. Mint the tag (D1): an App installation token (actions/create-github-app-token, secrets from env publish) does POST /git/refs refs/tags/vV → H; an existing tag at H is accepted, at any other SHA the run fails.
  4. gh release create vV --draft --verify-tag --prerelease --latest=false with the 14 archives and SHA256SUMS. An existing draft is reused, and only missing assets are uploaded.
  5. cargo publish --locked for core, then cli, from a checkout of H. The crates.io "already published" probes are kept.
  6. For each tarball, platform packages first and main last: skip it if npm view pkg@V already returns it; otherwise npm publish <tgz> --provenance --access public --tag next. Use --tag rc instead when V is not above dist-tags.next.
  7. gh release edit vV --draft=false.
  8. verify-channels (§5 I4).

report (if: always()):

  • the tracking issue body;
  • a log line on the "Release train" issue;
  • ntfy (low priority when published, default when skipped, high when publish or verify failed);
  • Slack.

Each event is sent once per run, with dedupe key <V>:<event>:<run_id>.

3.2 Stable: Tuesday 12:00Z (mode=stable)

plan picks the newest rc tag R that meets all of these:

  • its GitHub prerelease published_at ≤ now − 7d;
  • core(R) > L;
  • no tag v<core(R)> exists;
  • R's own full QA evidence is re-found: a release-qa.yml run with head_sha == R^{commit}, run-name profile full, conclusion == success, that passes verify-qa. A hand-made rc tag or prerelease therefore cannot be promoted (graft from Candidate 3);
  • the blocker gate passes with t = the time of R's Release-Base.

plan also cancels older stable runs that are still parked at approval (pending_deployments is non-empty). Safety doesn't depend on this, because publish re-checks everything after approval.

attempt-1:

  1. Cut release/vX.Y.Z = R's commit plus one commit. The commit's tree must equal stamp(R tree, X.Y.Z) plus changelog promote --rc R (the rc sections up to R fold into ## [X.Y.Z] — <date>; with a single rc this is just the heading rename). The diff must be a subset of the allowlist. This is the version-only guard for I1.
  2. Run release-qa(smoke): rebuild plus Tier 0/1 smoke.
  3. Notify "approval needed: " through ntfy (high), Slack and a tracking-issue comment.

approve: environment release. Approval has no expiry; the post-approval re-check makes a late approval safe.

publish:

  1. publish-checks:
    • /actions/runs/{this}/approvals contains an approved entry for release from a login that is in RELEASE_APPROVERS and not in RELEASE_ROUTINE_ACTORS;
    • the blocker gate is re-run after approval;
    • R is still the newest promotable rc;
    • no stable ≥ V exists.
  2. crates.
  3. npm --tag latest, direct OIDC with no 2FA staging (D3).
  4. Tag minted by the App (as in §3.1), then the GitHub release with --latest, last.
  5. verify-channels.
  6. report.

After the stable: the routine's next run puts the folded [X.Y.Z] section and version X.Y.Z into the rolling sync PR (§3.7).

3.3 Skip and fallback (I3)

There are exactly two attempts per week, and only the rc path has a fallback.

Situation Outcome
No green SHA in the bound Skip
An open blocker Skip
QA still red after 2 reruns on the primary and on the fallback Skip
A publish-time re-check fails Skip. The draft is left in place, nothing is published, ntfy is sent at high priority.

Every skip produces a health report with the reason, the tried SHAs, the run links and the failing jobs. It goes to the tracking issue, the "Release train" log, ntfy and Slack. A skip is never converted into a ship.

Recovering from an infra failure mid-publish: use "Re-run failed jobs" on the release.yml run. Every publish step is idempotent.

3.4 Hotfix (newest line only, manual)

  1. A maintainer dispatches mode=hotfix picks="<sha> …" reason=…. The dispatch actor must be in RELEASE_APPROVERS and not in RELEASE_ROUTINE_ACTORS.
  2. Checks:
    • base = the newest stable tag L;
    • each pick is a first-parent commit of origin/main that is not an ancestor of L.
  3. Cut: cherry-pick the picks locally plus the stamp, written as one commit with Release-Kind: hotfix. The guard recomputes that tree. Anything the API can't reproduce, such as a file-mode change, is refused.
  4. V = patch(L)-rc.N. Full QA, then publish as an rc. The hotfix CHANGELOG is cut from L's tree with changelog cut --no-sync, so no pending train rc section enters it. When the hotfix is promoted, sync-main hands any train rc of the same core back to [Unreleased], because those blocks are not in the hotfix's [X.Y.Z] (§3.7). Nothing is lost whichever of the two was cut first.
  5. A maintainer dispatches mode=stable rc_tag=v<V> waive_soak=true reason=…. The soak waiver is accepted only when R's trailer is Release-Kind: hotfix and R's cut run was a human hotfix dispatch. Approval is still required.

3.5 Blocker gate (release.py blockers --base <sha>): the fixed rule

Label check first. GET /labels/release-blocker must return that exact name. Otherwise the gate is blocked with error label:. A deleted or renamed label silently drops off every issue without an unlabeled event, and before setup S5 the label does not exist at all. Label names are compared case-insensitively everywhere, as GitHub's labels= filter does.

Candidate set:

  • all open issues labelled release-blocker, with no time bound (fix for the 90-day-window violation);
  • ∪ issues closed since since, and issues with a release-blocker labeled or unlabeled event since since, from issues/events.
  • since = the earlier of the committer date of merge-base(L, base) and the base commit's date minus 35 days (SINCE_LOOKBACK). The merge-base is a commit on main's history; the tag's own commit date is never read, since a v* tag can point at an off-main commit with a forged future date. The lookback is what no tag can move: an account that can push tags could point a high stable tag (v99.0.0) at base itself, which makes the merge-base base and would drop every older close or unlabel out of the candidate set. With the lookback, such a tag can hide only events older than 35 days before the base (a stable's 8-day soak plus several skipped weeks). An earlier since only adds candidates. A --since later than the base time fails closed.
  • The tag ruleset (setup S9) is a hard prerequisite for any live gate run (PR 3 merges only after S9): before it, the lookback bounds what a forged tag can hide but does not remove it.

Config. RELEASE_APPROVERS and RELEASE_ROUTINE_ACTORS are split on commas and whitespace, and a leading @ is dropped. A token that is not a GitHub login, an empty list, or no approver left after removing routine actors blocks with error config:. An unset routine list must never quietly make mik trusted.

Trusted = a login in RELEASE_APPROVERS and not in RELEASE_ROUTINE_ACTORS. The second list contains every routine identity, including mikolalysenko while existing routines (issue janitor, burn-down, CI janitor) run as him. This fixes the "janitor running as a trusted maintainer" violation.

An issue blocks iff it is effectively labelled and not resolved:

  • Effectively labelled: the label is present; or the last release-blocker label event is a labeled one although the label is gone (it vanished without an event: label deleted or renamed); or the last one is an unlabel by an untrusted actor.
    • A trusted unlabel is the immediate human override ("not a blocker"), and it counts at any time.
    • An issue that now 404s or 410s (deleted, or converted to a discussion), or that resolves to another repository or number (transferred), blocks.
  • Resolved: the issue is closed, and either
    • it was closed by a closed event whose commit_id is an ancestor of the tree's base (checked with GET /compare). So a fix that isn't in this tree doesn't unblock it; or
    • it was closed by merging a PR. GitHub's closed event for that auto-close has commit_id: null (recorded: #454, closed by merging #456). The closing PR is the same-repo PR that cross-references the issue in /issues/{n}/timeline, merged at most 120 s before the close, and was merged by the close event's own actor (merged_by.login from GET /pulls/{n}; GitHub attributes a merge auto-close to the merger). So a manual close by anyone else right after an unrelated PR that merely mentions #N merges matches nothing. Its merge_commit_sha must be an ancestor of the base. If several PRs match, all of them must be. Without this path, every blocker fixed by a PR that mik merges would stay blocked, because he is a routine actor (D3). Residual: a merger who closes the issue by hand within 120 s of merging a PR that mentions it is treated like that PR's auto-close, which is the same trust as a Fixes #N merged PR (it went through the reviewed-PR ruleset and its merge is in the base); or
    • a trusted actor closed it with closed_at ≤ t, where t is the base commit time.
    • Any other close is ignored, so the issue is still blocking.

When it runs: in plan, at the start of publish, and after approval. Any API error counts as blocked.

Open P1s are not a gate. Release notes carry a link to the open-P1 query, never issue titles (graft from Candidate 2), so no untrusted text reaches the notes.

3.6 QA verdict (verify-qa, also run as the verdict job)

Run-level checks:

  • run path is release-qa.yml;
  • head_sha == H;
  • conclusion == success;
  • run_attempt ≤ 3.

Job-level checks, on the latest attempt's jobs:

  • every job is success;
  • skipped is allowed only for vlt canary / downgrade, and for the profile-disabled callers under smoke;
  • these job names must be present and successful: e2e-docker*, e2e-full*, hosted-e2e, test, smoke-*, live-e2e, and at least one job per compat workflow.

Step-level check: inside hosted-e2e, the "Run hosted-mode production e2e" and "Run vendored-mode production e2e (vlt)" steps must be success, not skipped. This is defence in depth against the kill-switch-green hole even though hosted_e2e=force is passed (graft from Candidate 3).

Artifact check: exactly one non-expired release-bundle* artifact, whose API digest matches the downloaded zip.

3.7 Rolling sync PR (the routine, docs/release-train/ROUTINE.md)

The routine runs daily at 10:15Z and maintains one branch, release-sync. Each run:

  1. Run python3 scripts/release.py sync-main on a clean origin/main checkout with tags fetched. It is deterministic given main plus the tags, and idempotent (a second run changes nothing). Per D2 it brings main to the newest cut tag, rc or stable:
    • version: stamp the highest-precedence tag's version (e.g. 5.0.0-rc.1 during the first rc week, 5.0.0 after promotion). release-lint accepts main at an rc version.
    • rc sections: for each pending rc tag (no stable of its core yet) whose section main lacks, insert the tag's ## [X.Y.Z-rc.N] — date section verbatim and remove exactly those blocks from [Unreleased].
    • stable sections: for each stable tag whose section main lacks, insert the tag's folded ## [X.Y.Z] — date section; its blocks not accounted for by rc sections already on main are removed from [Unreleased] (next bullet).
    • fold at promotion: changelog promote --rc R folds rc.1..R into [X.Y.Z] keeping every occurrence (an entry written in two rcs, - Updated dependencies., appears twice), so [X.Y.Z] is exactly the multiset sum of the folded rcs. At sync, every rc section on main whose core has shipped is dropped. Its blocks, with text taken from its tag, are charged against the blocks of the tag's [X.Y.Z] section (a multiset budget shared across all rcs of that stable, oldest rc first); the uncovered ones go back into [Unreleased]. Only then is what is left of the budget (shipped blocks main never got as an rc section, i.e. still in [Unreleased] on an unsynced main) removed from [Unreleased], oldest occurrence first. Charging the rc sections first keeps a newer identical entry in [Unreleased] (which did not ship) in its place whether or not the sync PR merged. A folded rc therefore returns nothing. A later rc abandoned at promotion (rc.2 cut after rc.1 was chosen), or a train rc cut beside a hotfix of the same core (§3.4), returns exactly the blocks that did not ship. They go back oldest rc first, before the blocks already in their ###, which is their original chronological place, and their headers are dropped. They ship in the next train. No rc-number or ancestry rule is involved, so the cut order doesn't matter.
    • only blocks of sections inserted in this run are removed from [Unreleased], so entries added to [Unreleased] since are never touched.
    • Section text always comes from the tag (the bytes that shipped). An edit made on main to an rc section is discarded by the fold; fix release notes before promotion through a new rc, or edit the stable section after the sync.
    • Matching is exact (subsection + text) and counts occurrences: a shipped block removes one occurrence, so an identical entry written again later (- Updated dependencies.) stays. A block reworded on main after the cut is not matched and may duplicate in [Unreleased]; the sync PR reviewer removes it.
    • CRLF CHANGELOGs (a Windows checkout) stay CRLF, generated lines included. The stamp is CRLF-safe too: each stamped file keeps its own line endings, so stamp --check and release-lint check 2 are byte no-ops on a Windows core.autocrlf checkout (the repo has no * text=auto).
    • sync-main computes the CHANGELOG and every stamped file before writing any of them, so a stamp refusal leaves the tree untouched.
    • Byte-identical cuts. A cut puts its ### subsections in a canonical order: breaking first, then Keep a Changelog's Added, Changed, Deprecated, Removed, Fixed, Security, then any other heading, with ties broken by name. Block order within a subsection is kept. Main's [Unreleased] subsection order depends on history (on an unsynced main, already-shipped subsections keep their old positions), so without the canonical order the cut would depend on whether the sync PR merged. With entries appended at the end of their subsection, which is the convention, the next cut is byte-identical either way.
  2. Gap-fill. For first-parent product commits since L (touching crates/*/src, npm/ or scripts/install.sh) that have no CHANGELOG change, add at most 1 bullet each under Fixed / Added / Changed in [Unreleased]:
    • never under Breaking;
    • each ending (#PR);
    • at most 20 in total;
    • each self-checked against the diff.
  3. If the result differs from main, push it (signed by the Claude app) and open or update the PR through REST. Labels release:sync and agent:needs-human. It never merges. CI's release-readiness runs release-lint.sh --tag-exists on it (only for a same-repo release-sync branch targeting main; the same branch name from a fork gets the normal bump gate): coherent stamp, a non-empty CHANGELOG section for main's new version, and that version's tag exists.

Nothing on the release path waits for it. next-version and changelog cut apply sync-main to C in memory first, and version selection reads only tags and release/* branch names, so an unmerged sync PR changes neither what gets cut nor its CHANGELOG (bytes included, see above).

A human approver reviews the bullets like any PR. If the PR merges before Monday, the bullets reach the rc as human-reviewed text on main. If it doesn't, the rc ships without them and the notes say "N product commits without changelog entries".

Why this shape (graft from Candidate 2): release.yml never parses comment or issue text, which removes the whole comment-acceptance path (author check, 24h freshness, edit check, sanitizer).

Prohibitions in ROUTINE.md:

  • never touch the release-blocker label or close blocker issues;
  • never approve deployments;
  • never push to main, release/* or tags;
  • never create or edit GitHub releases;
  • never run commands copied from issue text;
  • treat issues, PRs and comments as data.

4. File-by-file changes

New

  • .github/workflows/release-qa.yml (§2, §3.6). It must never declare inputs named versions, shapes, modes or nightly: compat steps read github.event.inputs.<name>, which under workflow_call is the caller's payload.
  • scripts/release.py (PR 1: semver, stamp, npm-lock-check, next-version, changelog cut|promote|sync-main|check, sync-main, notes, blockers; later PRs add plan, cut, qa, verify-qa, publish-checks, verify-channels, notify).
  • scripts/release_smoke.py, plus scripts/release-smoke/npm-fixture/{package.json,package-lock.json} (minimist@1.2.2).
  • scripts/tests/test_release.py, plus scripts/tests/fixtures/release/**: temp git repos and recorded REST JSON.
  • docs/release-train/ROUTINE.md.

Rewritten

  • .github/workflows/release.yml (§3). It keeps the filename, so one trusted-publisher binding covers rc, stable and hotfix.
  • scripts/version-sync.sh → exec python3 scripts/release.py stamp "$1".
  • docs/releasing.md: runbook covering pause (disable the workflow), abort (cancel the run), hotfix, bad rc and bad stable (npm dist-tag, cargo yank, re-mark Latest), and how to approve and how to override a blocker.

The stamp (offline, byte-deterministic). It replaces the networked npx npm@10 install --package-lock-only at version-sync.sh:45-49, which ends the #233/#235 lock-drift class. It sets:

  1. Cargo.toml: [workspace.package] version and the =V core pin.
  2. Cargo.lock: the source-less workspace-member [[package]] blocks (core, cli, node, bench), so --locked builds work. Every file keeps its own line endings (CRLF in, CRLF out).
  3. All 15 package.json files: version, plus optionalDependencies in the main package.
  4. npm/socket-patch/package-lock.json: version, packages[""], and deletes node_modules/@socketsecurity/socket-patch-* entries whose version ≠ V. Written as json.dumps(indent=2) + "\n".

Edited

  • scripts/release-lint.sh:68: the grammar accepts -rc.N (via release.py semver validate); --stable-only refuses an rc (used by the legacy release.yml until PR 3 replaces it). Check 2 becomes the offline stamp (release.py stamp --check, byte compare, no clean-tree requirement) plus release.py npm-lock-check. The lock check compares the npm wrapper's package-lock.json packages[""] with package.json (dependency maps, engines, bin, name) and requires a node_modules/<dep> entry per non-optional dependency, at the pinned version. That is the dependency drift the networked lock refresh used to catch. Check 3 is release.py changelog check (rc sections included; a stable fails while [V-rc.*] sections remain). New --tag-exists (the tag must already exist).
  • .github/workflows/ci.yml:
    • (a) under on:, add workflow_call: {inputs: {hosted_e2e: {type: string, default: auto}}};
    • (b) in release-readiness, when head_ref == 'release-sync' and the head repo is this repo and base_ref == 'main', run release-lint.sh --tag-exists (main's new version, rc or stable, must be a cut tag with its CHANGELOG section); every other PR keeps today's behavior;
    • (c) one step in the ubuntu e2e-build leg: release_smoke.py --tier 0 on the freshly built CLI, so the smoke cases can't drift from the CLI before a cut (graft from Candidate 2).
    • No tier logic changes.
  • The 9 *-compatibility.yml files: add workflow_call: {} under on:, one line each.
  • CHANGELOG.md:12-16: header prose.
  • .github/workflows/release.yml (PR 1, comments only plus --stable-only on its lint step): references to the deleted bump script.

Deleted

  • version-bump.yml: its unsigned push is rejected by ruleset 14265462, and it has never run.
  • scripts/bump-version.sh.
  • publish-cargo.yml, publish-npm.yml and scripts/dispatch-publish.sh: dispatchable at any tag, they have never run in a real release, and their publishers have to be re-registered anyway.

Other routine prompts (hygiene only; §3.5 already ignores their closes and unlabels):

  • issue janitor: never touch release-blocker, release, release:*;
  • burn-down: skip release:sync, release/*, release-sync;
  • CI janitor: ignore runs on release/*.

5. Security / invariant table

Inv How it is enforced Residual risk
I1 tested tree == shipped tree rc: main's release.yml creates H = C + one allowlisted, tree-asserted stamp commit. One release-qa run at H builds once (--locked, no caches), smokes those bytes, and tests tree H through ci + 9 compat (every checkout defaults to H). Publish accepts only that run (path, head_sha == H, success, verdict, artifact digest) and publishes exactly those files after sha256sum -c. crates are published from a checkout of H with --locked. The release targets H, and verify-channels asserts refs/tags/vV == H. Immutable releases freeze tag and assets afterwards. stable: H = rc commit + a version-only commit whose tree equals stamp(rc tree) plus changelog promote --rc R (the fold of rc.1..rc.R into [X.Y.Z]). It is rebuilt and smoked in its own QA run, and R's full-QA run is re-verified. The live --ignored suites test tree H built from source, not the artifact binary. The artifact is exercised live by Tier 1 smoke instead.
I2 stable needs a non-routine maintainer The only stable publish path is publish after approve in environment release: reviewers = the approvers team (no routine identity), admin bypass off, main only. publish-checks independently requires an approval on this run's /approvals by a login in RELEASE_APPROVERS minus RELEASE_ROUTINE_ACTORS. The rc path refuses any version without -rc.N. All of this logic is on main, which needs a reviewed PR to change. prevent_self_review is off: on a scheduled run the "triggering actor" is whoever last edited the cron, and that setting could lock a maintainer out. The routine is excluded by team membership and by the code check, not by self-review. mik is the routine identity for now (D3), so he can't approve; a second human must exist on the team (setup S2) until routines move to the bot.
I3 blockers stop rc and stable; skip > ship; bounded fallback §3.5 rule, evaluated in plan, in publish and after approval; API error = blocked. Untrusted closes and unlabels, including by any routine identity, never unblock. A close counts only via a fix commit that is an ancestor of the base, or a trusted close before t. Open blockers are queried with no time bound. Bounded fallback: a 14-day window, newer than the previous base, at most 1 fallback attempt, then skip + health report. There is no override path that publishes on red. A trusted human can unlabel to override. That is intended. When mik is the routine identity, his overrides go through another maintainer.
I4 rc never Latest / npm latest / default rc publishes with --prerelease --latest=false and the GitHub flip comes last; npm --tag next/rc; crates semver prerelease. install.sh:135-136 and self-update release.rs:88-93 resolve /releases/latest, which excludes prereleases. verify-channels asserts after each rc that GitHub latest (API and redirect), npm latest and crates max_stable_version are unchanged. On violation it re-marks the previous stable make_latest=true, fails and sends ntfy at high priority. —
I5 full QA before rc publish release-qa(full): ci.yml under a dispatch event, which enables e2e-full, yarn-berry-full and cargo-vex-matrix-full (:1481,1695,1787) and e2e-docker (:1546), with hosted_e2e=force (:1894,1902); all 9 compat workflows with their full default matrices; the --ignored live suites; Tier 0 on 13 targets plus an Android static check; Tier 1 live lifecycle plus install.sh / npm-launcher / self-update on 3 OSes. The verdict requires every job to succeed, the named tiers to be present, and the hosted production steps to be success, with run_attempt ≤ 3. It is re-run inside publish. Android is not executed. The vlt canary and downgrade jobs are excluded; they detect upstream drift and don't QA the tree.
I6 credentials only on the intended path; untrusted text can't steer id-token: write exists only on the publish job, in environment publish (main only, no admin bypass). Trusted publishers are bound to repo + release.yml + publish. release-qa has contents: read and no secrets, and its evidence is accepted only for the H this run created. Decisions come only from git facts, CI conclusions, actor-filtered label and close events, and the /approvals API. No issue, comment or PR text is parsed by Actions. Version level comes from human-merged [Unreleased] on main. Gap-fill reaches a release only through a human-reviewed PR. Notes link to P1s instead of quoting titles. The routine has no secrets, is not an approver, and the gate ignores its blocker actions. Tags (D1): the refs/tags/v* ruleset lets only the App create, move or delete a release tag, and the App's key exists only in env publish (main only, no admin bypass). A GitHub release needs its tag, so no other identity, the routine's included, can publish a release that install.sh or self-update would serve; immutable releases freeze the shipped ones. Defence in depth: every plan still asserts that the current Latest release and every non-draft release since the last run point at a tag whose commit carries a Release-Kind trailer and whose QA run verifies; if not, it pages high priority and skips. Enterprise/org admins can edit the ruleset, and a leaked App key could mint tags (it cannot publish to registries). The App key is rotated if publish is ever misconfigured.

6. Phase 0: setup and must-run probes

Setup (maintainer, manual)

# Task
S1 Routine identity: mikolalysenko for now (D3); migrate to a dedicated non-admin bot account with write role later. Set RELEASE_ROUTINE_ACTORS to every account any Claude routine runs as (today mikolalysenko; add the bot when it exists and remove mik only once no routine runs as him).
S2 Team socket-patch-release-approvers with at least 2 humans, none of them in RELEASE_ROUTINE_ACTORS. Mirror it in RELEASE_APPROVERS.
S3 Environments release (reviewers = team, branch main, admin bypass off, self-review prevention off) and publish (no reviewers, branch main, admin bypass off). Delete pypi and rubygems.
S4 Trusted publishers: 15 npm packages and 2 crates → SocketDev/socket-patch / release.yml / env publish. Do this when PR 3 merges. npm has one publisher per package, so this is an atomic cutover. Confirm the org policy allows direct OIDC publish.
S5 Enable immutable releases. Labels release-blocker (exact name; until it exists the blocker gate fails closed with label:), release, release:train, release:sync. Secrets NTFY_TOPIC and SLACK_WEBHOOK_URL (optional; without the webhook, the routine posts a daily Slack digest instead). Pin the "Release train" issue.
S6 Human triage of release-blocker on #559, #424, #325/#356/#519/#588 and #578/#579.
S7 Run e2e_npm, e2e_pypi, e2e_gem and e2e_scan --ignored by hand on main. Drop any that are chronically red because of the public proxy from live-e2e, and document why.
S8 Create the GitHub App socket-patch-release (D1): repository permission contents: write only, no webhooks, installed on SocketDev/socket-patch only. Store its app id and private key as RELEASE_APP_ID / RELEASE_APP_PRIVATE_KEY secrets of env publish (not repo secrets).
S9 Tag ruleset on refs/tags/v*: restrict creations, updates and deletions; bypass list = the App only. Adding an App as bypass actor (and keeping admins out of it) needs an enterprise/org admin, so schedule it with one before PR 3 merges. Existing v* tags are unaffected. Hard prerequisite for any live blocker-gate run (§3.5): before it, a tag pusher can still narrow the gate's since to the 35-day lookback.

Must-run probes. Only these change the design if they fail.

Probe What it verifies If it fails
P1 On scratch branch release/v0.0.0-rc.1, GITHUB_TOKEN can do POST /git/refs and createCommitOnBranch, giving a verified commit that ruleset 14265462 accepts. cut moves to the routine (signed push), and release.yml only verifies the tree and dispatches. The routine is then on the critical path for Monday's cut.
P2 release-qa.yml (PR 2) dispatched on that branch runs ci + 9 compat as one run. Checks: the job count is accepted; concurrency groups don't stall; e2e-docker and hosted-e2e (force) execute; no artifact-name collisions; rerun-failed-jobs reruns called-workflow jobs plus verdict. Also measure wall time: if p95 of full QA plus 2 failed-job reruns exceeds about 330 min, split the in-job poller into chained wait-0 / wait-1 / wait-2 jobs, each with its own 355-min budget. Dispatch the 10 workflows separately, and have verify-qa check each one by head_sha == H and unique branch.
P3 With immutable releases and the tag ruleset on, the App token can POST /git/refs a v0.0.0-rc.1 scratch tag that GITHUB_TOKEN cannot; a draft created on that existing tag (--verify-tag) accepts uploads; publishing it keeps the tag at that SHA. If the App cannot bypass, publish fails closed (no tag, no release); fix the ruleset bypass before go-live.

Not pre-probed:

  • Registry OIDC. The first live 5.0.0-rc.1 is the test, and it is harmless by I4. A failure leaves a draft and maybe crates without npm; fix the binding, then "Re-run failed jobs".
  • The /approvals read. Exercised by PR 4's stable dry run. If it can't be read, publish-checks fails closed, so the result is a skip, not a ship.
  • The routine's REST PR creation. If it fails, a human opens the PR from the pushed branch.

7. PR plan (in order, 4 PRs)

PR 1: release.py core + stamp + lint

Contents:

  • in release.py: stamp, semver and precedence, CHANGELOG cut / promote (fold) / sync-main (D2), version selection with the major rule (D4), the blocker rule, notes;
  • the version-sync.sh wrapper;
  • release-lint.sh:68 (rc grammar, offline check 2, rc-aware check 3, --stable-only, --tag-exists);
  • ci.yml release-readiness handling for a same-repo release-sync PR into main;
  • delete version-bump.yml and bump-version.sh, fixing every reference;
  • tests (scripts/tests/test_release.py, fixtures under scripts/tests/fixtures/release/).

Accept:

  • version-sync.sh 4.0.0 on main produces no diff;
  • stamp 5.0.0-rc.1 run twice gives identical bytes with the network off (npm_config_registry=http://127.0.0.1:1);
  • the stamped tree passes cargo build --locked -p socket-patch-cli and npm install --no-save --ignore-scripts;
  • release-lint.sh passes at 5.0.0-rc.1;
  • 5.0.0-rc.1 < 5.0.0;
  • version selection on the main CHANGELOG snapshot gives 5.0.0-rc.1;
  • version scenarios: rc.2 while rc.1 is pending gives 5.0.0-rc.2; after a v5.0.0 tag with the sync PR unmerged, the result is 5.0.1-rc.1 or 5.1.0-rc.1; burned branches are skipped;
  • blocker fixtures:
    • a missing or renamed release-blocker label blocks (label:); case variants of the label on issues and events count;
    • a label that vanished without an unlabeled event, and a deleted, converted or transferred issue, block;
    • an empty, unset or malformed RELEASE_ROUTINE_ACTORS / RELEASE_APPROVERS blocks (config:); newline-, space- and @-separated lists parse;
    • since comes from merge-base(L, base), not from a forgeable tag date, and is never later than the base minus 35 days, so a forged high stable tag pointed at the base cannot move it; a since after the base fails closed;
    • a PR-merge close (commit_id: null, recorded from #454) resolves only through the closing PR's merge commit being in the base, and only when the PR's merger is the close actor (a manual close after an unrelated mentioning PR does not resolve);
    • an open issue untouched for 200 days blocks;
    • closed by a commit not in the base blocks;
    • closed by a commit that is an ancestor of the base passes;
    • closed by a trusted human after t blocks;
    • unlabelled or closed by any RELEASE_ROUTINE_ACTORS login (including mik in fallback) blocks;
    • unlabelled by a trusted approver passes;
    • an API error blocks;
  • sync-main removes the shipped blocks and leaves everything else untouched;
  • D2 scenarios:
    • after an rc tag, sync-main inserts the tag's [X.Y.Z-rc.N] section, removes exactly its blocks from [Unreleased] and stamps main to the rc version; release-lint.sh passes on main at that rc version;
    • stable promotion of rc.1 with a later rc.2 already synced to main: one ## [X.Y.Z] section equal to the tag's, no rc headers left, rc.2's blocks back in [Unreleased], newer [Unreleased] entries untouched and in place;
    • the same end state when the sync PR never merged;
    • sync-main is idempotent;
    • next-version and changelog cut give the same result, byte for byte, with the sync PR merged or unmerged, including a repeated identical entry (also one repeated across rc.1 and a promoted rc.2, and one re-added after a promotion), an abandoned later rc, and subsection order left over from shipped history;
    • the fold keeps every occurrence of a repeated entry, and promote returns every block of a later rc;
    • a hotfix promoted beside a pending train rc of the same core returns that rc's entries to [Unreleased];
    • CRLF CHANGELOGs round-trip and every transform keeps CRLF;
    • release-lint.sh (no flags and --tag-exists) passes on a sync-mained working tree at an rc, and --tag-exists fails once the tag is gone;
    • the stamp tests run on a temp tree stamped to a fixed baseline (4.0.0 and an rc), never on the live checkout's version;
    • the stamp on a CRLF checkout gives the LF result with CRLF endings and stamp --check is a no-op there; sync-main writes nothing when the stamp refuses;
    • release-lint.sh catches npm dependency drift between package.json and its lock, offline;
    • a breaking heading that would open an unapproved major is refused; a major is never skipped.

PR 2: release-qa.yml + smoke

Contents:

  • release-qa.yml;
  • workflow_call lines on ci.yml and the 9 compat workflows;
  • release_smoke.py and its fixture;
  • verify-qa;
  • the ci.yml e2e-build Tier 0 step.

Accept:

  • P1 and P2 pass on a scratch release/v0.0.0-rc.1 cut by release.py cut;
  • the run concludes success, or each red job is filed as an issue;
  • verdict goes red when a required name is filtered out, and red when the hosted production steps are skipped (hosted_e2e=skip fixture);
  • Tier 0 is green on 13 targets plus the Android static check;
  • the install.sh negative case (tampered SHA256SUMS) fails as expected;
  • the job list of a PR's CI run is unchanged before and after.

PR 3: release.yml rc path

Contents:

  • plan, cut, qa, fallback, publish (including App tag minting, D1), verify-channels, notify and report for rc;
  • the detective Latest audit (§5 I6);
  • delete publish-*.yml and dispatch-publish.sh.

Before merge: P3, S8, S9. At merge: S3, S4.

Accept:

  • mode=rc dry_run=true on main creates the tracking issue, branch and QA run, stops before publish, and reports through ntfy;
  • a forced-red QA (env flag on the scratch branch) does 2 reruns, then a fallback at rc.N+1, then SKIPPED with a health report;
  • an open blocker makes plan skip;
  • unit tests: publish-checks refuses head_sha ≠ H, a stable-shaped version in rc mode, and a tag present at another SHA;
  • the Latest audit fires on a fixture release authored by a user.

PR 4: stable + hotfix + docs

Contents:

  • the approve job;
  • stable plan and cut, including re-finding R's full-QA run;
  • hotfix mode and the waive_soak rule;
  • cancelling parked runs;
  • docs/releasing.md, ROUTINE.md, the CHANGELOG header.

Accept:

  • unit tests: the stable tree equals stamp(rc tree) plus changelog promote --rc R (the fold), with the diff a subset of the allowlist;
  • the approvals check rejects an approver in RELEASE_ROUTINE_ACTORS, or one not in RELEASE_APPROVERS;
  • a stable plan with a fabricated rc tag (no QA run) is refused;
  • a hotfix with a pick off main's first-parent is refused, and so is a dispatch by a routine actor;
  • live, after rc.1 exists: mode=stable rc_tag=v5.0.0-rc.1 dry_run=true builds and smokes release/v5.0.0, renders the approval summary, exercises the /approvals read on a test approval, and stops.

After the PRs

Create the routine and make the prompt edits.

Go-live:

Date Step
Mon 2026-10-12 5.0.0-rc.1 (D4: pre-approved major). Requires PRs 1–3, S1–S9 and P1–P3; otherwise the date slips by whole weeks.
Mon 10-19 rc.2
Tue 10-20 Promote rc.1 to 5.0.0
Mon 10-26 5.0.1-rc.1 or 5.1.0-rc.1

8. What was cut from the full design, and the accepted risk of each cut

Cut Accepted risk / what replaces it
release-tagger env, release-branch rulesets (the App and the tag ruleset are kept, D1) The App's key lives in env publish instead of a dedicated env. release/* branches are protected only by the existing no-force-push ruleset 14265462 and the tree assertions in cut and publish-checks.
15-minute reconciler (release_driver.py), derived state machine, issue render cache A stuck run is noticed only through notifications or the weekly log. Recovery is "Re-run failed jobs" or the next week.
notify and registry-publish environments (5 → 2) ntfy is a plain repo secret. A leaked topic allows spam, not publishing.
release-build / qa-smoke / stable / verify workflows, the 12 separately-correlated dispatches, distinct_id Everything rides on one workflow_call run. P2 is load-bearing, and its fallback is per-workflow dispatch.
release_health.py classifier, signatures/flaky/gates JSON, graduated requiredness, flake-storm budget A flake that survives 2 reruns on both attempts skips the week. Skipping is preferred.
Evidence JSON bundle, attestations (gh attestation verify) Evidence is the run record plus the artifact digest. npm --provenance is kept.
stage-release-cli artifact substitution into ci and compat e2e legs, docker overlay, the live-suite artifact overlay ci and compat test tree H built from source. The exact shipped bytes are covered by Tier 0/1 smoke. A miscompile that appears only in the release profile and that only the e2e suites would catch could slip through.
Verdaccio, serve_mirror.py Install from local tarballs plus python -m http.server. The npm registry resolution path itself is untested until publish.
Soak-watch daily live runs against the published rc, hourly release-verify Soak is passive: 7 days in which a human or bughunt can file a blocker. Problems that show up after publish rely on users and the routines.
Bench as a gate Perf regressions only gate if someone labels them release-blocker (the daily bench routine files issues).
Three routines plus the claim protocol (cut / promote confirm, classification, attribution, fix-forward, changelog-drop markers) The routine is off the critical path. The only cost is that gap-fill is missing if the PR isn't merged.
Routine-applied blocker labels, the B1–B7 rubric in code, attribution and ancestry lines, override comments Blockers are labelled by humans, or by bughunt/triage routines adding them, which only adds safety. Override = a trusted human unlabels.
Fix-forward rc and 48h mini-soak, major-hold, ABANDONED / SUPERSEDED states (APPROVED_MAJORS is kept, D4) Fall out of "cumulative since last stable" plus core = max(...). An urgent fix goes through hotfix mode. A major needs both a Breaking heading in [Unreleased] and a reviewed APPROVED_MAJORS entry in release.py, and a major is never skipped (§3.1).
CHANGELOG block-identity hashing, fuzzy matching (rc sections are synced to main, D2; blocks are counted as a multiset, §3.7) Exact-match transforms. A reworded block on main after an rc may duplicate in [Unreleased]; the sync PR reviewer cleans it up. rc-section edits on main are dropped at the fold; the tag text is canonical.
Approval TTL, single-use approval binding via digest A late approval is safe because of the post-approval re-check. A stale parked run is cancelled by the next plan.
Older-line hotfixes, patch-id equivalence Picks must be exact main first-parent SHAs, on the newest line only.
Deadline bookkeeping (Tue 06:00Z) Bounded implicitly by two attempt jobs of at most 355 min each. The worst case finishes about Tue 00:00.
train.json, TOOLING_FLOOR, CODEOWNERS, blocking zizmor and cargo-deny steps, dead head_ref cleanup Hygiene, not needed for I1–I6. Left to the janitors.
release:hold / release:abort labels, release:log issue, ledger branch Pause = disable the workflow. Abort = cancel the run. Hold = file a release-blocker. The log goes on the "Release train" issue.
Comment-based gap-fill acceptance (Candidate 1's JSON comment) Replaced by the human-reviewed rolling PR. Gap-fill bullets miss the rc if the PR isn't merged by Monday.
P1 issue titles in the notes Replaced by a link to the query. Notes are less self-contained.
13 probes → 3 OIDC is proven by the harmless first rc; /approvals by the PR 4 dry run (fails closed).

9. Open questions

  1. GitHub-channel forgery (I6 residual). Resolved (D1): the App + refs/tags/v* ruleset come back; the App mints the tag in publish; the Latest audit stays as defence in depth.
  2. Main's version during the rc week. Resolved (D2): main gets every rc's version and CHANGELOG section through the sync PR; rc sections fold into the stable section at promotion and abandoned later rcs return to [Unreleased] (§3.7).
  3. Override ergonomics when mik is the routine identity. Resolved (D3): routines go live as mikolalysenko (in RELEASE_ROUTINE_ACTORS, so he can neither approve nor clear a blocker); a second human approver is required on the team, and routines move to a bot later. npm stable publishes directly over OIDC after approval; hotfixes cover the newest line only.
  4. live-e2e membership depends on S7. Suites that are chronically red because of the public proxy get dropped. Is "drop and document" acceptable for I5, or must they be fixed first?
  5. Gap-fill cadence. The routine runs daily. Is merging the rolling PR before Monday 12:00Z a realistic weekly human task, or should gap-fill be accepted as "best effort, often missing"?
  6. Android is a static ELF check only. Is that acceptable under I5's "black-box artifact smoke", or should a termux or emulator job be added later?