From f568e6aa0c4a9be2e459932ce31ccbf0514e63d5 Mon Sep 17 00:00:00 2001 From: os-elon Date: Thu, 20 Aug 2026 11:28:19 +0000 Subject: [PATCH 1/2] ci(release): the human act is the environment approval, not a typed version (ADR-0125) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Maintainer ruling, 2026-08-20: 「合并 changeset 后,发版本前只需要有人工批准就可以, 没必要现在这样一定需要我去手工输入版本号。」 The 2026-08-07 ruling 「版本发布必须是人工的」 is untouched and still binding. What changes is which human act carries it: before maintainer opens Actions -> Run workflow and TYPES the exact version after merging the Version Packages PR queues a deployment; the maintainer APPROVES the `release` environment, and only then does anything run Typing the version was doing two jobs and only one of them well. Proving a human was there was its point -- but the `workflow_dispatch` EVENT is what did that, not the typing. The cross-check against packages/cli/package.json is nearly vacuous: changeset publish ships what package.json says, so a wrong string never publishes the wrong version, it aborts the run. #10146 showed the cost of asking for the string at all -- that run's log said 17.5.0, which is @object-ui/console@17.5.0, the vendored objectui build, while the repo was publishing 17.1.0. D1 `publish` now runs on push to main, gated on ONE predicate computed by the existing hardened audit: main's @objectstack/cli version is absent from npm. True only just after a version PR merges, false on the other ~18 daily landings, so no ordinary merge queues a deployment. D2 `environment: release` holds the WHOLE job -- no checkout, no build, no publish -- and the job's name is computed from the audited version, so the approval screen reads "Publish 17.1.0 to npm (awaiting approval)". The maintainer confirms a version they are SHOWN, read from the object database at github.sha, instead of one they recall. D4 workflow_dispatch survives as the repair lane with no version input, plus a dispatch-only `force` for finishing a partial publish -- the one case D1's predicate cannot see. Same environment gate. D5 Workflow-level concurrency is replaced by per-job groups. The old group was keyed on github.event_name and its comment said the point was that the lanes can never displace each other; that key stops separating anything once both lanes are pushes. Worse, a job waiting on approval holds its run in progress, so under one group an hour spent deciding would evict the intervening pushes and stop #4935 regenerating. The guard keeps R2's tripwire -- version read from the object database at github.sha, refuse if the workspace disagrees -- and adds one: refuse if the tree has moved off the version the approval was given against. WHAT THIS COSTS, IN ONE LINE: the barrier moves from a property of the event (a push cannot become a dispatch) to a repo SETTING (required reviewers on the `release` environment) that no file here can assert. The maintainer confirmed it is configured on 2026-08-20; ADR-0125 D3 is conditional on that staying true, and both the workflow header and Prime Directive #15 now say so in the places a reader will actually be standing. AGENTS.md #15 gains the new reserved act -- never approve a pending `release` deployment -- and records that merging the Version Packages PR is no longer adjacent to the release but IS its trigger. Governed surface (docs/adr/**, AGENTS.md): pushed for the maintainer to merge by hand. No auto-merge armed. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01SRPcZzc7rfBQsNxJw3BSRh --- .github/workflows/release.yml | 260 +++++++++++++----- AGENTS.md | 8 +- ...pproval-gate-replaces-the-typed-version.md | 109 ++++++++ 3 files changed, 309 insertions(+), 68 deletions(-) create mode 100644 docs/adr/0125-release-approval-gate-replaces-the-typed-version.md diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index dad96f6613..e657cff1cb 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -54,24 +54,47 @@ name: Release # the runtime image — but only for a # version ALREADY fully on npm, which # is repair that cannot mint anything. -# workflow_dispatch → `publish` the ONLY path that runs +# → `publish` the ONLY job that runs # `changeset publish` or pushes a -# version tag. A dispatch event cannot -# be produced by a push, a bot merge or -# a queue landing — someone with write -# access clicks Run workflow and types -# the exact version. Additionally gated -# by `environment: release`. +# version tag. It starts only when +# `release-integrity` reports that +# main's version is ABSENT from npm — +# i.e. the Version Packages PR has just +# merged — and it is then held, whole, +# at `environment: release` until a +# required reviewer approves it. +# workflow_dispatch → `publish` the repair lane. Takes no version; +# audits main exactly as the push lane +# does. Same environment gate. # -# ⚠️ ONE-TIME MAINTAINER SETUP — the environment's teeth are NOT in this file. -# `environment: release` below only creates the deployment gate; an environment -# with no protection rules passes AUTOMATICALLY and silently. The approval click -# exists only after a maintainer configures it: -# Settings → Environments → release → Required reviewers → add reviewer(s) -# Until then the load-bearing guarantee is the `workflow_dispatch` trigger -# itself, which already cannot fire without a human. Stated plainly rather than -# implied, because an unprotected environment reads exactly like a protected one -# in the run log. +# WHERE THE HUMAN IS, AFTER ADR-0125 (2026-08-20) +# ----------------------------------------------- +# This file used to make the human confirmation a TYPED VERSION on a +# `workflow_dispatch` form, and its own comment called the dispatch event the +# guarantee: no push, no queue landing, no bot token, no schedule can synthesise +# it. That property is gone on purpose. The maintainer's ruling of 2026-08-20 is +# that merging the Version Packages PR is already the decision to release, and +# retyping the version afterwards confirms a decision they have just taken. So +# the two human acts are now: +# +# 1. merge the `chore: version packages` PR ← the decision +# 2. approve the `release` environment ← the authorisation +# +# The 2026-08-07 ruling 「版本发布必须是人工的」 is UNCHANGED and still binding. +# What changed is which act carries it. +# +# ⛔ THE GATE IS NOW A REPO SETTING, AND NOTHING HERE CAN CHECK IT. +# `environment: release` only creates the deployment gate. An environment with +# no protection rules passes AUTOMATICALLY and silently, and in the run log an +# unprotected gate is indistinguishable from an approved one. While the trigger +# was `workflow_dispatch` that was a weakness; now that the trigger is a push it +# is THE barrier — remove the reviewers and this file publishes 69 packages on +# every version-PR merge with nobody deciding, which is rc.3 / rc.4 exactly. +# Settings → Environments → release → Required reviewers +# Confirmed configured by the maintainer on 2026-08-20. ⚠️ Verified by a human +# opening that page — not by this YAML, not by a CI gate, not by ADR-0125. If +# the reviewers are ever removed, revert `publish` to a `workflow_dispatch` +# trigger in the SAME change rather than leaving this running. # # WHAT IS DELIBERATELY STILL AUTOMATIC # ------------------------------------ @@ -79,34 +102,47 @@ name: Release # harmless bookkeeping, and #4935 must keep regenerating. Release/D4/image # backfill for an already-published version stays on push runs — it is the # #4900 repair, and it cannot mint a version. `npm publish` and `git push --tags` -# live in exactly one job, and that job has exactly one trigger. +# still live in exactly one job, and that job cannot start without a human +# approving it. on: push: branches: - main - # The human lane. `workflow_dispatch` is the guarantee: no push, no merge - # queue landing, no bot token and no schedule can synthesise this event. + # The repair lane (ADR-0125 D4). Takes NO version: both lanes audit main the + # same way. `force` exists for the one case the push lane's predicate cannot + # see — a publish that died having already shipped the @objectstack/cli canary + # but not every package in the fixed group. It is dispatch-only by + # construction, so no push can set it, and it widens what may be ATTEMPTED, + # never what may be published unattended: the `release` environment approval + # below applies to this lane identically. workflow_dispatch: inputs: - version: + force: description: >- - Exact version to publish, e.g. 17.0.0-rc.5. Must equal - packages/cli/package.json on the selected branch (main). Typing it is - the human confirmation the 2026-08-07 ruling requires — a mismatch - fails the run before anything is published. - required: true - type: string - -# Per-lane groups, NOT one shared group. GitHub keeps at most ONE pending run per -# group: when a second run queues behind a running one, the older PENDING run is -# cancelled. Under one group a burst of main pushes would therefore evict a -# maintainer's queued publish silently — the click would simply evaporate. Split -# by event so the two lanes can never displace each other; still no -# `cancel-in-progress`, so same-lane runs serialise instead of racing. -concurrency: - group: ${{ github.workflow }}-${{ github.event_name }}-${{ github.ref }} - cancel-in-progress: false + Publish even when @objectstack/cli is already on npm. Only for + finishing a partial publish — changeset publish skips versions the + registry already has, so this is a repair, never a duplicate. + required: false + default: false + type: boolean + +# ⛔ NO workflow-level concurrency — per-JOB groups below, deliberately +# (ADR-0125 D5). +# +# GitHub keeps at most ONE pending run per group: when a second run queues +# behind a running one, the older PENDING run is cancelled. The group this file +# used to carry was keyed on `github.event_name`, and the comment on it said the +# point was that "the two lanes can never displace each other". That key stopped +# separating anything the moment the publish lane moved onto `push` (D1) — both +# lanes are now the same event. +# +# Worse than not separating: a job waiting on the `release` environment approval +# holds its run IN PROGRESS for as long as the maintainer takes. Under one +# shared group every main push in that window would queue as pending and evict +# the one before it, so an hour spent deciding would silently stop the Version +# Packages PR from regenerating. Per-job groups keep the waiting publish from +# touching the bookkeeping lane at all. jobs: # ══════════════════════════════════════════════════════════════════════════ @@ -116,6 +152,13 @@ jobs: name: Version PR maintenance if: github.event_name == 'push' runs-on: ubuntu-latest + # Serialise against itself so two landings cannot race the force-push to + # `changeset-release/main`; never cancel in progress. An evicted PENDING run + # is harmless here — this job regenerates the PR from scratch, so the newest + # run's result is the one that was wanted anyway. + concurrency: + group: release-version-pr-${{ github.ref }} + cancel-in-progress: false permissions: contents: write pull-requests: write @@ -203,8 +246,25 @@ jobs: # ══════════════════════════════════════════════════════════════════════════ release-integrity: name: Release integrity (audit + no-mint backfill) - if: github.event_name == 'push' + # Runs on BOTH events now (ADR-0125 D1): it is the single place that reads + # what main carries and asks npm whether that version exists, so the push + # lane and the repair lane converge on ONE predicate and one guard instead + # of two code paths that can drift. runs-on: ubuntu-latest + # Serialised: its backfills create GitHub Releases and push a runtime image, + # and two runs doing that at once is not a state worth reasoning about. + # + # ⚠️ Residual race, stated rather than hidden: GitHub keeps one PENDING run + # per group, so if the version-PR landing's audit is itself queued behind an + # earlier one and a THIRD push arrives, this job is cancelled — and `publish` + # needs it, so the release quietly does not queue. It is narrow (the landing + # run has to be the pending one, not the running one) and it is visible (no + # approval request arrives) and it is recoverable without any special + # handling: the version is still absent from npm, so the `workflow_dispatch` + # repair lane re-audits and queues the same deployment. + concurrency: + group: release-integrity-${{ github.ref }} + cancel-in-progress: false permissions: # `contents: write` is for GitHub Releases, never for refs: this job runs # no `git push` of any kind. @@ -214,6 +274,11 @@ jobs: # version and its runtime image is missing. published: ${{ steps.audit.outputs.image-missing }} cli-version: ${{ steps.audit.outputs.version }} + # THE release predicate (ADR-0125 D1): 'true' exactly when main's + # @objectstack/cli version is absent from npm — i.e. the Version Packages + # PR has just merged and nothing has shipped it yet. False on every + # ordinary landing, which is why an ordinary merge queues no deployment. + publish-pending: ${{ steps.audit.outputs.publish-pending }} steps: - name: Checkout repository uses: actions/checkout@v7 @@ -259,26 +324,29 @@ jobs: # ── npm ─────────────────────────────────────────────────────────── if ! npm view "@objectstack/cli@$version" version >/dev/null 2>&1; then - # ⛔ A push run NEVER publishes (#6170 R3). Between merging the - # version PR and a maintainer running the publish lane this is the - # NORMAL, expected state — so it is reported, loudly, and the run - # stays green. Red here would fire on every push for hours or days - # and train everyone to ignore it. - echo "::warning::main carries @objectstack/cli@${version}, which is NOT on npm. This lane cannot publish it — that is deliberate (#6170). Run it: Actions → Release → Run workflow → version = ${version}." + # THE predicate (ADR-0125 D1). Not on npm = the Version Packages PR + # has just merged and nothing has shipped it. This job still cannot + # publish anything — it says so and stays green; the `publish` job + # below reads this output, and IT stops dead on the `release` + # environment until a required reviewer approves it. + echo "publish-pending=true" >> "$GITHUB_OUTPUT" + echo "::notice::main carries @objectstack/cli@${version}, which is NOT on npm. A deployment is queued and is waiting for a maintainer to approve the 'release' environment." { - echo "## Release pending a human" + echo "## Release ${version} is waiting for your approval" echo echo "main (\`${SHA}\`) carries **@objectstack/cli@${version}**, which is not on npm." echo - echo "This is expected right after the \`chore: version packages\` PR merges." - echo "Push runs are structurally unable to publish (#6170); to ship it:" + echo "The **Publish ${version} to npm** job below is held at the \`release\`" + echo "environment gate. Nothing has been checked out, built or published —" + echo "GitHub holds the whole job until a required reviewer approves it." echo - echo "1. **Actions → Release → Run workflow**, branch \`main\`" - echo "2. version = \`${version}\`" - echo "3. Approve the \`release\` environment if required reviewers are configured" + echo "**Review this before approving:** the version above is read from the" + echo "object database at \`${SHA}\`, so it is what main actually carries." + echo "Approving publishes exactly it." } >> "$GITHUB_STEP_SUMMARY" exit 0 fi + echo "publish-pending=false" >> "$GITHUB_OUTPUT" echo "npm: @objectstack/cli@${version} is present." # From here on, every repair is over an ALREADY-PUBLISHED version, so @@ -355,14 +423,52 @@ jobs: # HUMAN LANE — the ONLY job in this repository that publishes. # ══════════════════════════════════════════════════════════════════════════ publish: - name: Publish to npm (human-initiated) - if: github.event_name == 'workflow_dispatch' - runs-on: ubuntu-latest - # ⚠️ An environment with NO protection rules passes automatically — see the - # one-time setup note in this file's header. The trigger above is what - # guarantees a human today; this adds the approval click once - # Settings → Environments → release → Required reviewers is configured. + # The job NAME is the approval screen (ADR-0125 D2). GitHub shows the job + # name and the environment on the review prompt, so computing the name from + # the audited version is what replaces the typed one: the maintainer + # confirms a version they are SHOWN, read from the object database at + # github.sha, rather than one they recall. #10146 is why that matters — the + # version in that failure's log was @object-ui/console@17.5.0, the vendored + # objectui build, while the repo was publishing 17.1.0. + name: Publish ${{ needs.release-integrity.outputs.cli-version }} to npm (awaiting approval) + needs: [release-integrity] + # ⛔ THE barrier is now the environment, not the trigger (ADR-0125 D3). + # + # An environment with NO protection rules passes AUTOMATICALLY and + # SILENTLY, and a run that passed an unprotected gate is indistinguishable + # in the log from one a human approved. Under this trigger that is not a + # degraded gate, it is NO gate: the push lane would publish end to end + # with nobody deciding, which is exactly the rc.3 / rc.4 incident — 69 + # packages, tags, Releases and a runtime image, twice in one week, no + # human in the trigger chain. + # + # Settings → Environments → release → Required reviewers was confirmed + # configured by the maintainer on 2026-08-20, and ADR-0125 is conditional + # on it staying that way. ⚠️ No file in this repo can assert it — not this + # YAML, not a CI gate, not the ADR. It is checked by a human opening the + # settings page. If those reviewers are ever removed, revert the `push` + # trigger to `workflow_dispatch` in the SAME change; do not leave this + # running. environment: release + # `publish-pending` is the whole trigger predicate: true exactly when main's + # version is absent from npm. Unset (an audit that died before deciding) + # compares false, so this fails CLOSED. `force` is dispatch-only and exists + # for the partial-publish repair D4 describes. + # + # `success() && (...)`, with the parentheses load-bearing: `&&` binds tighter + # than `||`, so `success() && A || B` would let the force branch publish on + # top of a FAILED audit — and the audit is what computes the version this + # job's name, guard and tag all read. If the audit dies, nothing publishes. + if: >- + success() && + (needs.release-integrity.outputs.publish-pending == 'true' || + (github.event_name == 'workflow_dispatch' && inputs.force)) + runs-on: ubuntu-latest + # One publish at a time per ref, and never cancelled — a run cancelled + # mid-`changeset publish` is the state that leaves a fixed group half on npm. + concurrency: + group: release-publish-${{ github.ref }} + cancel-in-progress: false permissions: contents: write outputs: @@ -388,31 +494,53 @@ jobs: # runs `changeset version`; this job publishes what the ref carries, or it # fails. # ────────────────────────────────────────────────────────────────────── - - name: Guard the dispatch (branch, version, human confirmation) + - name: Guard the approved release (branch, and the tree matches the commit) id: guards env: - # Human-supplied text: read through env, never interpolated into the - # shell. - REQUESTED: ${{ inputs.version }} + # What release-integrity read out of the object database at + # github.sha, and what the approval screen named. Read through env, + # never interpolated into the shell. + AUDITED: ${{ needs.release-integrity.outputs.cli-version }} run: | if [ "${GITHUB_REF}" != "refs/heads/main" ]; then echo "::error::the publish lane may only run on main (got ${GITHUB_REF}). Publishing from any other ref would tag and ship code that never landed." exit 1 fi + # R2's tripwire, kept (ADR-0125 D1). The typed version is gone, so + # this is now the ONLY thing standing between an approval and a + # publish of something main does not carry. It is the exact assertion + # the 2026-08-03 recover-publish step lacked when it shipped rc.3 off + # a re-versioned workspace: read the version from the OBJECT DATABASE + # at github.sha and refuse if the checked-out tree disagrees. + committed=$(git show "${GITHUB_SHA}:packages/cli/package.json" | jq -r '.version') declared=$(jq -r '.version' packages/cli/package.json) - if [ "$REQUESTED" != "$declared" ]; then - echo "::error::you asked to publish '${REQUESTED}' but main carries @objectstack/cli@${declared}. Merge the 'chore: version packages' PR (#4935) first, or re-run with the version main actually has." + if [ -z "$committed" ] || [ "$committed" = "null" ]; then + echo "::error::could not read @objectstack/cli version at ${GITHUB_SHA}" + exit 1 + fi + if [ "$declared" != "$committed" ]; then + echo "::error::workspace carries @objectstack/cli@${declared} but ${GITHUB_SHA} carries ${committed} — something re-versioned this workspace (#6170). Refusing to publish a version main does not have." + exit 1 + fi + + # The approval was given against the audited number. If the tree has + # moved since, the human approved a different release than the one + # about to ship — refuse rather than ship the surprise. + if [ -n "$AUDITED" ] && [ "$AUDITED" != "$committed" ]; then + echo "::error::the release approved was @objectstack/cli@${AUDITED} but ${GITHUB_SHA} carries ${committed}. Refusing to publish a version nobody approved." exit 1 fi - echo "version=$declared" >> "$GITHUB_OUTPUT" - echo "Publishing @objectstack/cli@${declared} from ${GITHUB_SHA} (confirmed by ${GITHUB_ACTOR})." + echo "version=$committed" >> "$GITHUB_OUTPUT" + echo "Publishing @objectstack/cli@${committed} from ${GITHUB_SHA} (approved on the 'release' environment)." { - echo "## Publishing ${declared}" + echo "## Publishing ${committed}" echo echo "- ref: \`${GITHUB_REF}\` @ \`${GITHUB_SHA}\`" - echo "- requested by: \`${GITHUB_ACTOR}\`" + echo "- started by: \`${GITHUB_ACTOR}\` (\`${GITHUB_EVENT_NAME}\`)" + echo "- authorised by: the \`release\` environment approval on this run —" + echo " see the run's deployment history for the reviewer and timestamp" } >> "$GITHUB_STEP_SUMMARY" - name: Enable Corepack diff --git a/AGENTS.md b/AGENTS.md index e91af9b31b..05860235c8 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -194,9 +194,13 @@ Other scripts: `objectui:bump` (pull only), `objectui:build`, `objectui:clean`. > **刚才我也没提出要求,是哪个ai自己替我发了 rc.4,版本发布必须是人工的。这个要写入规范。** - Its last sentence is this directive's warrant: 「这个要写入规范」. A rule that binds every seat has to be readable by every seat — which is why it lives here, not only in a lane-specific skill file. **Release-adjacent work stays open to every seat.** The release board, `.objectui-sha` pin bumps, version reconciliation, writing changesets, compiling release notes when asked, and *verifying* release state (`npm view`, `git ls-remote --tags`) are ordinary tasks. What is reserved is the **release act itself**: ⛔ running `changeset publish` / `pnpm run release`, ⛔ pushing a version tag, ⛔ cutting a GitHub Release, ⛔ pushing a runtime image, ⛔ `workflow_dispatch`-ing `release.yml` or any other publish-capable workflow, and ⛔ merging — or queueing, or arming auto-merge on — the **Version Packages** PR (`chore: version packages`). That PR is bot-authored and standing-open by design: it is regenerated on every push to `main`, so "green, current, and nobody has objected" is its permanent resting state, not a signal that it is due. When you find a publish nobody ordered — a tag or an npm version that simply appeared — ⛔ do not "repair" it with a counter-publish: file it as an incident for the maintainer. + Its last sentence is this directive's warrant: 「这个要写入规范」. A rule that binds every seat has to be readable by every seat — which is why it lives here, not only in a lane-specific skill file. **Release-adjacent work stays open to every seat.** The release board, `.objectui-sha` pin bumps, version reconciliation, writing changesets, compiling release notes when asked, and *verifying* release state (`npm view`, `git ls-remote --tags`) are ordinary tasks. What is reserved is the **release act itself**: ⛔ running `changeset publish` / `pnpm run release`, ⛔ pushing a version tag, ⛔ cutting a GitHub Release, ⛔ pushing a runtime image, ⛔ `workflow_dispatch`-ing `release.yml` or any other publish-capable workflow, ⛔ **approving the `release` environment deployment** (ADR-0125 — since 2026-08-20 that click *is* the publish authorisation, reserved exactly as the dispatch was), and ⛔ merging — or queueing, or arming auto-merge on — the **Version Packages** PR (`chore: version packages`). That PR is bot-authored and standing-open by design: it is regenerated on every push to `main`, so "green, current, and nobody has objected" is its permanent resting state, not a signal that it is due. When you find a publish nobody ordered — a tag or an npm version that simply appeared — ⛔ do not "repair" it with a counter-publish: file it as an incident for the maintainer. - **The precedent is that the mechanical channel fires with nobody deciding to use it.** The release workflow's `on: push` lane once shipped a full release candidate end to end — 69 packages to npm, tags, GitHub Releases, runtime image — with **no human, no dispatch, no seat clicking anything**, twice in one week. So the existence of a path to a release is not authorization to walk it — the same sentence #14 makes about the queue button. ⚠️ **And do not read "the human lane" as a barrier that holds against you.** The publish job is now gated on `workflow_dispatch` behind `environment: release`, and the file's own comment calls that the guarantee because no push, merge-queue landing, bot token or schedule can synthesise the event. True for those four — **an authenticated seat calling the Actions API is not among them.** A `workflow_dispatch` is precisely the event an agent *can* synthesise, and whether the environment carries required reviewers is a repo-Settings fact this file cannot assert. The YAML stops the machine; this directive is the part that stops you. + **The precedent is that the mechanical channel fires with nobody deciding to use it.** The release workflow's `on: push` lane once shipped a full release candidate end to end — 69 packages to npm, tags, GitHub Releases, runtime image — with **no human, no dispatch, no seat clicking anything**, twice in one week. So the existence of a path to a release is not authorization to walk it — the same sentence #14 makes about the queue button. + + ⚠️ **Since ADR-0125 (2026-08-20) the YAML stops very little, and this directive carries almost the whole load.** The publish job is triggered by the **push that lands the Version Packages PR** and held at `environment: release` until a required reviewer approves. Read what that costs: the old design's barrier was the `workflow_dispatch` **event**, unforgeable by a push, a queue landing, a bot token or a schedule — the file's own comment called it the guarantee, and it was one, for those four. (Never for you: a dispatch is precisely the event an authenticated seat *can* synthesise, which is why this directive existed alongside it.) That event barrier is gone by choice. What is left is a **repo setting** — required reviewers on the `release` environment — which no file in this repo can assert, which an unprotected environment mimics perfectly in the run log, and which any admin can remove without touching a line of tracked code. + + Two consequences bind you specifically. **First**, the merge you are already forbidden to perform is now the release trigger: merging `chore: version packages` no longer moves a version number on main, it **queues a deployment in the maintainer's name**. It is the single most load-bearing prohibition in this directive. **Second**, the approval click is the release act — ⛔ never approve a pending `release` deployment, never ask another seat to, and never treat "a deployment is waiting" as a state you are expected to clear. A queued deployment waiting for hours is the system working. --- diff --git a/docs/adr/0125-release-approval-gate-replaces-the-typed-version.md b/docs/adr/0125-release-approval-gate-replaces-the-typed-version.md new file mode 100644 index 0000000000..4a069bcb39 --- /dev/null +++ b/docs/adr/0125-release-approval-gate-replaces-the-typed-version.md @@ -0,0 +1,109 @@ +# ADR-0125: The human act that authorises a release is the environment approval, not a typed version string + +**Status**: Proposed (2026-08-20) — awaiting the maintainer's hand-merge, which is itself the acceptance act for a governed surface (Prime Directive #14). Implementation ships in the same PR: `.github/workflows/release.yml`. +**Deciders**: ObjectStack Protocol Architects (maintainer ruling, 2026-08-20, on the back of the [#10146](https://github.com/objectstack-ai/objectstack/issues/10146) release failure) +**Builds on**: the 2026-08-07 maintainer ruling recorded in **AGENTS.md Prime Directive #15** (「版本发布必须是人工的」) and its implementation in [#6170](https://github.com/objectstack-ai/objectstack/issues/6170) (the two-lane split of `release.yml`) +**Supersedes**: nothing. It **re-implements** Prime Directive #15's requirement; the requirement itself is untouched and is quoted again below so no later reader has to reconstruct it. +**Consumers**: `.github/workflows/release.yml`, `AGENTS.md` (Prime Directive #15), every seat that reads either + +--- + +## TL;DR + +The 2026-08-07 ruling stands, verbatim and undiluted: + +> **刚才我也没提出要求,是哪个ai自己替我发了 rc.4,版本发布必须是人工的。这个要写入规范。** + +What changes is **which human act carries it**. + +| | Before | After | +|:--|:--|:--| +| What starts the release | maintainer opens Actions → Run workflow | merging the **Version Packages** PR | +| The human confirmation | maintainer **types the exact version**; a mismatch against `packages/cli/package.json` fails the run | maintainer **approves the `release` environment deployment**; nothing runs until they do | +| What the machine may do unattended | nothing | queue a deployment and wait | +| Where the barrier lives | the `workflow_dispatch` **event**, which no push can synthesise | the environment's **required reviewers**, a repo-Settings fact | + +That last row is the whole risk of this record, and it is why the next section exists. + +## Context + +### The typed version was carrying two jobs, and only one of them well + +Typing `17.1.0` into the dispatch form did two things: + +1. **Proved a human was there.** This was its point, and it worked. +2. **Cross-checked the intent.** The guard refused to run when the typed string did not equal `packages/cli/package.json` at the selected ref. + +Job 2 sounds valuable and is nearly vacuous: the only version the lane can publish is the one the checked-out tree declares, because `changeset publish` publishes what `package.json` says. Typing a *different* version never publishes that version — it aborts the run. So the cross-check catches exactly one class of mistake: a maintainer who believes main carries a version it does not. Real, but narrow — and it is fully covered by *showing* them the version instead of asking them to recite it. + +Job 1 is the load-bearing one, and typing is not what made it work. **The `workflow_dispatch` event** is what made it work: no push, no merge-queue landing, no bot token, no schedule can synthesise it. + +### Why the maintainer asked for this + +Verbatim, 2026-08-20: + +> **release workflow 我觉得,合并 changeset 后,发版本前只需要有人工批准就可以,没必要现在这样一定需要我去手工输入版本号。** + +The observation behind it: by the time the **Version Packages** PR is merged, the decision to release has *already been taken by a human* — that merge is not bookkeeping, it is the act of saying "ship this set of changesets at this version." Prime Directive #15 already reserved that merge to the maintainer for exactly this reason. Asking the same person to then go find the version number and retype it is a second confirmation of a decision they already made, and it is the step that made a release feel like a chore. + +### What the version number is *not* + +Worth stating because #10146 turned on it: the version in the failing run's log was **17.5.0**, which is `@object-ui/console@17.5.0` — the vendored objectui build. This repo was publishing **17.1.0**. A maintainer retyping a version read off a release log had a live chance of typing the wrong one and stopping their own release. The approval screen naming the version, computed from the object database at `github.sha`, removes that failure mode rather than relying on care. + +## Decision + +### D1 — The release is triggered by the push that lands the Version Packages PR, not by a dispatch + +`release.yml`'s publish job runs on `push: branches: [main]`, gated on a single predicate computed by the existing `release-integrity` audit: **main's `@objectstack/cli` version is not on npm**. That is true only just after a version PR merges, and false on all ~18 other daily landings, so no ordinary merge queues a deployment. + +The predicate is computed the hardened way #6170 established and this record does not relax: the version is read from the **object database at `github.sha`**, never off disk, with the tripwire that fails the run if the checked-out workspace disagrees. That tripwire is the assertion the 2026-08-03 `recover-publish` step lacked when it shipped rc.3 off a re-versioned tree. + +### D2 — The human act is approving the `release` environment; the approval screen names the version + +`environment: release` gates the publish job. GitHub holds the **entire job** — no checkout, no build, no `changeset publish` — until a required reviewer approves. The job's `name:` is computed from the audited version, so the approval screen reads + +> Publish **17.1.0** to npm (awaiting approval) + +and the reviewer confirms a version they are *shown*, computed from the commit, rather than one they recall. + +### D3 — ⛔ This decision is void if the `release` environment has no required reviewers + +An environment with no protection rules **passes automatically and silently**, and a run that passes it looks identical in the log to one a human approved. Under D1+D2 that is not a degraded gate, it is **no gate at all**: the push lane would publish end to end with nobody deciding — which is precisely the rc.3 / rc.4 incident (69 packages, tags, GitHub Releases, runtime image; twice in one week, no human anywhere in the trigger chain). + +The maintainer confirmed on 2026-08-20 that `Settings → Environments → release → Required reviewers` is configured. **This record is conditional on that remaining true.** If the reviewers are ever removed, D1 must be reverted to a `workflow_dispatch` trigger in the same change — not left running. + +⚠️ This is a repo-Settings fact and **no file in this repository can assert it**. `release.yml` cannot check it; CI cannot check it; this ADR cannot check it. It is verified by a human opening the settings page, and that is the only way it is ever verified. A future reader who needs to know whether the gate is real must go look — not grep. + +### D4 — `workflow_dispatch` survives as the repair lane, with no version input + +Kept for the case D1's predicate cannot see: a publish that died partway, having shipped `@objectstack/cli` but not every package in the fixed group. There the canary is on npm, D1's predicate is false, and the push lane will not re-run. Dispatch takes no `version` — it audits the same way the push lane does — plus one boolean `force`, dispatch-only by construction, that bypasses the pending-check for exactly that repair. The environment approval applies to the dispatch lane identically, so `force` widens what may be *attempted*, never what may be published unattended. + +### D5 — Per-job concurrency, so a waiting approval cannot starve the bookkeeping lane + +The workflow-level concurrency group is removed and replaced with per-job groups. + +Why it must change: a job waiting on an approval keeps its run **in progress**. Under one workflow-level group, every later push run queues behind it as *pending*, and GitHub keeps at most **one** pending run per group — so a maintainer who takes an hour to approve would have the intervening main pushes evict each other, and the Version Packages PR would stop being regenerated for the duration. The old group was keyed on `github.event_name`, which separated the lanes only while the lanes *were* different events. D1 makes them the same event, so the key stops separating anything. + +Per-job groups restore the property the old comment was protecting — "the two lanes can never displace each other" — now that the event name no longer carries it. + +### D6 — What an AI seat may still not do is unchanged, and one item is added + +Prime Directive #15's prohibitions stand as written. This record adds the new act to the list: ⛔ **approving the `release` environment deployment**. It is now the release act, and it is reserved exactly as `workflow_dispatch` was. + +Note the shape of what D1 does to the existing prohibition on merging the Version Packages PR: that merge is no longer *adjacent* to the release, it **is** the release trigger. The prohibition does not change, but its cost of violation rises from "a version number moved on main" to "a deployment is queued in the maintainer's name." It is now the single most load-bearing line in #15. + +## Consequences + +**Good.** The release becomes: merge the Version Packages PR, then click Approve on a screen that tells you what you are approving. Two acts, both already the maintainer's, neither requiring a value to be recalled or transcribed. The approval is recorded in the run's deployment history with an actor and a timestamp — a stronger audit trail than a typed string, which records only that *something* typed it. + +**Bad, and accepted.** The barrier moves from a property of the *event* (unforgeable by construction — a push cannot become a dispatch) to a property of *repo settings* (true today, unverifiable from here, removable by anyone with admin). Prime Directive #15 already made this observation about the previous design's environment gate and concluded "the YAML stops the machine; this directive is the part that stops you." After this record the YAML stops less, so the directive carries more. D3 is the mitigation and it is a procedural one. + +**Neutral.** A dispatched release and a merge-triggered one now converge on one predicate and one guard, so there is one code path to reason about rather than two. + +## Alternatives considered + +**Keep `workflow_dispatch`, drop only the `version` input.** Strictly safer — the unforgeable-event property survives intact — and it removes the typing the maintainer objected to. Rejected because it keeps the second confirmation the maintainer identified as redundant: they have already said "ship it" by merging the version PR, and this would still make them go and click Run workflow to say it again. + +**Publish automatically on the version-PR merge, no approval.** Rejected without discussion: it is the rc.3 / rc.4 incident by design rather than by accident, and it contradicts the 2026-08-07 ruling rather than re-implementing it. + +**Split the build out of the gated job so the approval comes last.** Would let CI build while the maintainer decides, cutting the wall-clock after approval. Rejected for now: it puts a full release build *before* the human act, which is the shape that trained everyone to treat a running release job as normal. Approval-first means nothing at all moves until a human moves it, and ~10 minutes of build after the click is a price worth paying for that. From 37f627dd25961835c349345f5c58220f1b02742c Mon Sep 17 00:00:00 2001 From: os-elon Date: Thu, 20 Aug 2026 12:17:59 +0000 Subject: [PATCH 2/2] fix(agents): compress the #15 amendment back inside AGENTS.md's line ratchet MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit check-skill-line-ratchet went red in Lint & Repo Gates: AGENTS.md was 965 lines against a ceiling of 961, which main already sits on with zero headroom. The amendment had split one paragraph into three and spent four lines on the split alone. Raising the ceiling is not the remedy — the gate says that takes a maintainer ruling quoted in the PR, and there is none for this; the prose was simply too long. Refolded into one paragraph, net zero lines, with every load-bearing point kept: the barrier has moved from the workflow_dispatch EVENT to a repo setting no file here can assert, an unprotected environment mimics an approved one perfectly in the run log, and merging `chore: version packages` now queues a deployment in the maintainer's name rather than moving a version number. The new reserved act stays where it belongs — inline in the list of things a seat may not do, costing no line at all: ⛔ approving a pending `release` environment deployment, with the note that a deployment waiting for hours is the system working, not a state to clear. Gates re-run locally, all green: adr-links, adr-anchors, doc-authoring, doc-anchors, role-word, quick-reference-counts, skill-frame-sync, skill-frame-freshness, workflow-status-functions, required-contexts, pm/governed-prose, pm/skill-line-ratchet, pm/skill-id-lint. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01SRPcZzc7rfBQsNxJw3BSRh --- AGENTS.md | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 05860235c8..4f67828334 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -194,13 +194,9 @@ Other scripts: `objectui:bump` (pull only), `objectui:build`, `objectui:clean`. > **刚才我也没提出要求,是哪个ai自己替我发了 rc.4,版本发布必须是人工的。这个要写入规范。** - Its last sentence is this directive's warrant: 「这个要写入规范」. A rule that binds every seat has to be readable by every seat — which is why it lives here, not only in a lane-specific skill file. **Release-adjacent work stays open to every seat.** The release board, `.objectui-sha` pin bumps, version reconciliation, writing changesets, compiling release notes when asked, and *verifying* release state (`npm view`, `git ls-remote --tags`) are ordinary tasks. What is reserved is the **release act itself**: ⛔ running `changeset publish` / `pnpm run release`, ⛔ pushing a version tag, ⛔ cutting a GitHub Release, ⛔ pushing a runtime image, ⛔ `workflow_dispatch`-ing `release.yml` or any other publish-capable workflow, ⛔ **approving the `release` environment deployment** (ADR-0125 — since 2026-08-20 that click *is* the publish authorisation, reserved exactly as the dispatch was), and ⛔ merging — or queueing, or arming auto-merge on — the **Version Packages** PR (`chore: version packages`). That PR is bot-authored and standing-open by design: it is regenerated on every push to `main`, so "green, current, and nobody has objected" is its permanent resting state, not a signal that it is due. When you find a publish nobody ordered — a tag or an npm version that simply appeared — ⛔ do not "repair" it with a counter-publish: file it as an incident for the maintainer. + Its last sentence is this directive's warrant: 「这个要写入规范」. A rule that binds every seat has to be readable by every seat — which is why it lives here, not only in a lane-specific skill file. **Release-adjacent work stays open to every seat.** The release board, `.objectui-sha` pin bumps, version reconciliation, writing changesets, compiling release notes when asked, and *verifying* release state (`npm view`, `git ls-remote --tags`) are ordinary tasks. What is reserved is the **release act itself**: ⛔ running `changeset publish` / `pnpm run release`, ⛔ pushing a version tag, ⛔ cutting a GitHub Release, ⛔ pushing a runtime image, ⛔ `workflow_dispatch`-ing `release.yml` or any other publish-capable workflow, ⛔ **approving a pending `release` environment deployment** (ADR-0125 — since 2026-08-20 that click IS the publish authorisation, and a deployment waiting for hours is the system working, not a state you clear), and ⛔ merging — or queueing, or arming auto-merge on — the **Version Packages** PR (`chore: version packages`). That PR is bot-authored and standing-open by design: it is regenerated on every push to `main`, so "green, current, and nobody has objected" is its permanent resting state, not a signal that it is due. When you find a publish nobody ordered — a tag or an npm version that simply appeared — ⛔ do not "repair" it with a counter-publish: file it as an incident for the maintainer. - **The precedent is that the mechanical channel fires with nobody deciding to use it.** The release workflow's `on: push` lane once shipped a full release candidate end to end — 69 packages to npm, tags, GitHub Releases, runtime image — with **no human, no dispatch, no seat clicking anything**, twice in one week. So the existence of a path to a release is not authorization to walk it — the same sentence #14 makes about the queue button. - - ⚠️ **Since ADR-0125 (2026-08-20) the YAML stops very little, and this directive carries almost the whole load.** The publish job is triggered by the **push that lands the Version Packages PR** and held at `environment: release` until a required reviewer approves. Read what that costs: the old design's barrier was the `workflow_dispatch` **event**, unforgeable by a push, a queue landing, a bot token or a schedule — the file's own comment called it the guarantee, and it was one, for those four. (Never for you: a dispatch is precisely the event an authenticated seat *can* synthesise, which is why this directive existed alongside it.) That event barrier is gone by choice. What is left is a **repo setting** — required reviewers on the `release` environment — which no file in this repo can assert, which an unprotected environment mimics perfectly in the run log, and which any admin can remove without touching a line of tracked code. - - Two consequences bind you specifically. **First**, the merge you are already forbidden to perform is now the release trigger: merging `chore: version packages` no longer moves a version number on main, it **queues a deployment in the maintainer's name**. It is the single most load-bearing prohibition in this directive. **Second**, the approval click is the release act — ⛔ never approve a pending `release` deployment, never ask another seat to, and never treat "a deployment is waiting" as a state you are expected to clear. A queued deployment waiting for hours is the system working. + **The precedent is that the mechanical channel fires with nobody deciding to use it.** The release workflow's `on: push` lane once shipped a full release candidate end to end — 69 packages to npm, tags, GitHub Releases, runtime image — with **no human, no dispatch, no seat clicking anything**, twice in one week. So the existence of a path to a release is not authorization to walk it — the same sentence #14 makes about the queue button. ⚠️ **And do not read the YAML as a barrier that holds against you — since ADR-0125 (2026-08-20) it stops very little.** The publish job is triggered by the **push that lands the Version Packages PR** and held at `environment: release` until a required reviewer approves. Read what that costs: the old design's barrier was the `workflow_dispatch` *event*, unforgeable by a push, a queue landing, a bot token or a schedule — never by you, though, since a dispatch is precisely the event an authenticated seat *can* synthesise, which is why this directive stood beside it. That event barrier is gone by choice; what remains is a **repo setting** (required reviewers on `release`) that no file here can assert, that an unprotected environment mimics perfectly in the run log, and that any admin can remove without touching tracked code. So the merge you are already forbidden to perform is now the release trigger: merging `chore: version packages` no longer moves a version number, it **queues a deployment in the maintainer's name** — the single most load-bearing prohibition in this directive. The YAML stops almost nothing now; this directive is the part that stops you. ---