LearnNewsExamplesServices
Frontmatter
id19637
titlepublish.mjs cannot release without a dev-ruleset bypass
stateClosed
labels
bugaibuild
assigneesneo-opus-grace
createdAtOct 12, 2026, 1:10 AM
updatedAtOct 12, 2026, 3:55 PM
githubUrlhttps://github.com/neomjs/neo/issues/19637
authorneo-opus-grace
commentsCount0
parentIssuenull
subIssues[]
subIssuesCompleted0
subIssuesTotal0
contentTrust
projected
quarantined0
signals[]
blockedBy[]
blocking[]
closedAtOct 12, 2026, 3:55 PM
milestonev13.3

publish.mjs cannot release without a dev-ruleset bypass

Closed Backlog/active-chunk-8 bugaibuild
neo-opus-grace
neo-opus-grace commented on Oct 12, 2026, 1:10 AM

Context

The 13.2.0 cut (2026-10-11) could not run buildScripts/release/publish.mjs as written. It finished only with an operator-added ruleset bypass and hand-run git (record: #14800, runbook comment 6110782905).

  • Run 1 stopped at publish.mjs:155 (git push origin dev) with GH013. The "code scanning merge protection" ruleset (19087298, ~DEFAULT_BRANCH) requires CodeQL plus 18 status checks on every commit pushed to dev, for every actor without a bypass. Nothing reached the remote.
  • Run 2 came after the operator added the admin org role as a bypass. The release commit landed on dev (b3c78f5523), then the squash push to main (publish.mjs:185) stopped at the local .husky/pre-push hook, in check-spec-retirement.
  • Finished by hand. The operator pushed the squash 14b1924eeb with --no-verify (its tree equals the release commit's), then the atomic-line commit af539a9f0c to dev. Grace created the GitHub release with the script's own note helper. npm-publish.yml published 13.2.0.

The operator removed the bypass afterwards and set the requirement for the fix: the AI maintainers must be able to trigger the release.

The Problem

Four defects, one root: the script was designed before the ruleset existed (13.1.0, 2026-07-03, predates the ruleset's 2026-07-17 creation; its 2026-10-10 update added the required checks). No rehearsal exercised a push.

  1. Two direct dev pushes (:155 release commit, :206 atomic-line commit). Each needs a bypass. A standing bypass lets anything that authenticates as the bypassing account land on dev past CI and CodeQL. Agent sessions that push from outside their seat tree authenticate as the operator, so a standing bypass widens what a mis-authenticated agent push can do.
  2. The main push runs the developer pre-push battery. check-spec-retirement wants every deleted spec explained in a commit message. A release squash flattens months of reviewed, already-explained deletions into one commit named v<version>, so it fails by construction.
  3. No resume. After a failed push, re-running fails too: stampReleaseNote leaves an already-stamped note unchanged, the commit is empty, and runCommand calls process.exit(1). Every partial run needs hand-run git.
  4. Maintain-role seats cannot release. current_user_can_bypass is never for them, so the AI maintainers cannot run the cut the operator wants them to trigger.

npm's publish step also printed that tokens which bypass 2FA are being restricted for direct publishing. The next release may hit that.

The Architectural Reality

  • buildScripts/release/publish.mjs: stamp → prepare.mjs → build-all → git add . + commit --no-verify (the comment explains why developer hooks skip machine-generated release commits) → push dev → commit-tree squash onto origin/main → push main → append the atomic line to the note, commit, push dev → gh release create --target dev.
  • .husky/pre-push: check-branch-discipline → check-spec-retirement → commit-authorship, under set -e.
  • main carries no rule; the ruleset targets ~DEFAULT_BRANCH (dev) only.
  • The site takes release notes from the GitHub Release archived by neomjs/github-content-sync, so the release body, not the dev copy of the note, is what readers see.
  • npm-publish.yml runs on the release event and already publishes with signed provenance (an OIDC identity exists).

Design authority: AGENTS.md §critical_gates #8 ("Release-line mutation happens via buildScripts/release/publish.mjs") and ADR 0037 ("Release authority remains buildScripts/release/publish.mjs … human merge/release gates remaining operator-owned per ADR 0034 §2.5"). Nothing makes a direct dev push intended under a ruleset that postdates the script.

The Fix

Make the release follow the same path as every other change, so the only human act is a merge:

  1. prepare: preflight (on dev at origin/dev, clean, version bumped, note present, no tag yet), stamp, prepare.mjs, build-all, then commit to release/v<version>, push it, and open a PR to dev. CI and CodeQL run on it, and the operator merges it.
  2. finish: preflight (dev carries the release tree, no tag yet), squash dev's tree onto origin/main, then push main with --no-verify, with the reason in a comment beside the existing --no-verify one. Create the GitHub release with the atomic-commit line in its body, so no second dev commit is needed.
  3. Each phase detects how far a release already got (branch, PR, main, tag) and continues from there.
  4. A --rehearse mode reads the target branches' rules and the acting account's bypass, and runs the pre-push battery against the would-be pushes, without pushing anything.

Decision Record impact: aligned-with ADR 0034 §2.5 and ADR 0037. Release authority stays publish.mjs, and the operator-owned human gate becomes the release PR's merge.

Acceptance Criteria

  • AC-1: No step pushes a commit directly to dev. The release commit reaches dev through a PR on which the ruleset's required checks and CodeQL ran, with no bypass. (The real-cut validation lives on #19652, which stays open until finish, npm and the Portal are verified.)
  • AC-2: A maintain-role seat (current_user_can_bypass: never) can run every step except merging the release PR. The runbook names that merge as the one human step. (The real-cut validation lives on #19652, which stays open until finish, npm and the Portal are verified.)
  • AC-3: The squash push to main skips the developer pre-push guards on purpose, with the reason in the script, and no longer fails on check-spec-retirement.
  • AC-4: The atomic-commit line reaches the GitHub release body without a second dev commit, and the Portal's notes route still shows it once the content sync archives the release. (The real-cut validation lives on #19652, which stays open until finish, npm and the Portal are verified.)
  • AC-5: After a failure at any push, re-running continues the release without hand-run git; the empty re-stamp commit no longer kills the run.
  • AC-6: --rehearse reports the push gates (rules, bypass, pre-push hook) before a real run, and the pre-release rehearsal in the runbook uses it.
  • AC-7 (post-merge): npm-publish.yml's credential is confirmed to keep working under npm's restriction of 2FA-bypassing tokens, through trusted publishing with the workflow's OIDC identity or a named follow-up. (The real-cut validation lives on #19652, which stays open until finish, npm and the Portal are verified.)

Contract Ledger

Target Surface Source of Authority Proposed Behavior Fallback Docs Evidence
publish.mjs prepare --ticket <N> --body-file <path> This ticket: the operator requires that AI maintainers can trigger the release, and the dev ruleset requires checks 1. Preflight: on dev at origin/dev, clean tree, release note present, no tag yet.
2. Stamp the note, run prepare.mjs and build-all.
3. Commit to release/v<version> and push it.
4. Open a PR to dev with the given body; it resolves preparation leaf #N.
A failed read stops the phase, so an unknown never passes for an absence. A body that disagrees with #N (exactly one Resolves #N, one AC row per AC on #N) or fails the PR-body lint stops it before anything is built. A re-run resumes from the remote state. A pushed branch or open PR is reused only when it is the local candidate. Yes: publish.mjs header, buildScripts/README.md, release-notes skill §6/§7 ReleaseState.spec.mjs; live --rehearse; #19652
publish.mjs finish AC-3, AC-4 Squash the release PR merge commit tree onto origin/main (--no-verify push). Then create the GitHub release on the merge commit, with the atomic-commit line in its body. With no merged PR, it stops. If an existing tag is not on the merge commit, it stops. If main already holds the tree, the push is skipped. If an existing tag is on the merge commit, the release is created on that tag. Yes ReleaseState.spec.mjs; #19652
publish.mjs <phase> --rehearse AC-6 Reports the account, its permission, the dev and main rules with bypass, the release refs and their binding, the pre-push hook, the release PR body and the preflight. Changes nothing. A gate whose read fails is ❌ unknown; any ❌ exits 1. Yes Live runs recorded on #19653

Out of Scope

  • Changing the dev ruleset (the operator's security setting).
  • The Brain-side ai:post-release-sync, the Pages deploy, and the create-app publish.

Avoided Traps

  • A standing ruleset bypass for the release account: rejected for the reason in Problem 1.
  • A GitHub App with a bypass, running a dispatched release workflow: it adds a privileged credential and a standing bypass for an automation, and the release commit would land unchecked. Worth revisiting only if the PR route proves too slow.
  • Re-running the whole script after a failure: dies on the empty commit (Problem 3).

Related

#14800 (13.2 cut runbook), #19636, ADR 0034 §2.5, ADR 0037, AGENTS.md §critical_gates #8.

Live latest-open sweep: latest 20 open neomjs/neo issues at 2026-10-11T23:08Z (re-checked 23:10Z), no equivalent. Exact sweep: publish.mjs across neomjs, nothing open on the release mechanism. MC sweep: "publish.mjs release push rejected dev ruleset required status checks bypass", 5 results, no prior decision (only this session's records and the 2026-10-10 note that the ruleset's required checks were being changed). Own-assignment sweep: 2 open (#14800, #15000), neither covers the release mechanism.

Retrieval Hint: "publish.mjs dev ruleset GH013 release 13.2 manual completion" Origin Session ID: 87a71479-e634-40ad-9609-c54cf18ab83a

🖖 Grace (Claude Opus 5.5, Claude Code) · session 87a71479-e634-40ad-9609-c54cf18ab83a

tobiu referenced in commit 9b9e47b - "docs(roadmap): Engine 13.3 replaces the shipped 13.2 section (#19642) (#19644) on Oct 12, 2026, 1:03 PM
tobiu referenced in commit bf91293 - "feat(release): release through a PR, squash on finish, resume on re-run (#19637) (#19653) on Oct 12, 2026, 3:55 PM
tobiu closed this issue on Oct 12, 2026, 3:55 PM