…a sha, with the no-anchor opt-in (objectui#11174) (objectstack-ai#11332)
Fixes objectstack-ai#11174
Clause-②: no. CI wiring only; no published contract or accept set moves.
objectui's half of objectstack-ai/objectstack#18471 (ruling 只做②). The
half-state patrol stops checking out objectstack `main` unpinned and
running the sweeper from it. It now calls objectstack's composite action
at a pinned 40-character sha, using the no-anchor opt-in that
objectstack-ai/objectstack#20793 (decision A) declared for this board.
Dispatched by `domain:devx` seat 2, session
`https://claude.ai/code/session_01TdiauJaVCHuj45EzZGUxHh`, claim comment
5924897388.
## What changed
- **`.github/workflows/half-state-patrol.yml`.** These steps are gone:
the objectstack checkout (no `ref:`, so `main`), the `node
objectstack-tooling/scripts/pm/check-half-states.mjs` step, the inline
resolve-anchor, update-anchor, summary and fail steps, and the
`ANCHOR_ISSUE` env.
- One step replaces them: `uses:
objectstack-ai/objectstack@0c5a71b`.
- It passes `github-token: secrets.GITHUB_TOKEN`, `anchor-issue: ''`,
`anchor-optional: true` and `closed-floor: '2026-08-28'`.
- `actions/checkout` and `actions/setup-node` (node `'22'`) stay in the
caller. The action needs both: the checkout is the swept subject, and
the action has no Node pin of its own.
- **Unchanged:** the trigger, schedule, permissions, concurrency, job
key and job name. The job name is "Live half-state sweep", the check-run
name that `scripts/dependabot-merge-gate.mjs` classifies. To confirm,
both versions were parsed with `yaml` and `on`, `permissions`,
`concurrency`, `jobs.patrol.name`, `runs-on` and `timeout-minutes` all
compare SAME.
- The prose adopted from upstream is deleted. The header now holds only
what belongs to this board: the pin and how to bump it, the no-anchor
decision (objectui#8740), and the closed-card floor (objectui#5985). The
file shrinks to about a third of its old length.
- **`content/docs/guide/ci-cd-pipeline.md`, Half-State Patrol section.**
It now describes the action call, the pin and how to bump it, the four
inputs, the no-anchor configuration and its accepted cost, the PR-run
blind spot, the floor, and a hand-run recipe. The recipe gains
`PM_SWEEP_CLOSED_FLOOR=2026-08-28` and a note to check out the pinned
sha to reproduce a run.
- The Trigger line and the inventory row are unchanged; both are still
true.
- **`scripts/__tests__/ci-cd-pipeline-doc.test.ts`.**
- The objectui#8043 block compared `PM_SWEEP_*` env keys, and the
workflow no longer sets any. It now compares the section against the
patrol step's *parsed* `with:` inputs (via `yaml`), in both directions.
It also holds the floor value, the anchor configuration (empty
`anchor-issue` + `anchor-optional: true`), and the 40-hex shape of the
ref.
- One `SWEEP_DECLARED_NON_RUN_COMMANDS` entry is added, for
`half-state-patrol.yml: scripts/pm/check-half-states.mjs`. The section
names objectstack's sweeper, and the job now has no `run:` step at all.
This is the same dual-eligible shape as the existing `check-links.yml`
entry.
- **`scripts/check-action-ref-convention.mjs` and its test (see the
surface note below).**
- The sha spelling is off-convention for this repository's Action Ref
Convention gate, so it gets a `DECLARED_EXCEPTIONS` entry with its
reason and issue.
- That gate's two non-vacuity cases used to *replace* the real table
with a synthetic one. They now *append* the synthetic entry to it, so
they hold whatever the real table carries.
- The table's docblock no longer says "deliberately empty". It now says
that an entry is matched by workflow and action, not by spelling, and
names where this entry's "sha, never a branch" half is held.
## The pin
- **Sha:** `0c5a71b0942d54e56ad365f0b2a173fd84c1c5e1`. This is
objectstack `origin/main`'s tip, read at **2026-10-01T04:46:15Z** with
`git -C /home/user/objectstack fetch origin main` and then `git
rev-parse origin/main`. Its commit date is 2026-10-01T03:32:30Z
(objectstack-ai/objectstack#21026).
- **Ancestry:** `git merge-base --is-ancestor
79114850f0f559dcb4fb432f5bcfc12947a03de6
0c5a71b0942d54e56ad365f0b2a173fd84c1c5e1` gives **exit 0**. An "is an
ancestor" answer proves itself even on the shallow objectstack clone, so
no control leg is owed. `git rev-list --count` between them gives 127
commits.
- **Not the action's own last commit.** The pin is `main`'s tip, as the
Q2 ruling requires. At the pin, both `action.yml` (blob `2df36034`) and
`scripts/pm/check-half-states.mjs` (blob `fa35f635`) are byte-identical
to what 79114850f landed. So today the two choices run the same code.
The tip rule is what keeps that true on the next bump.
- **Input names read off `action.yml` at the pin:**
- declared: `anchor-issue`, `anchor-optional`, `closed-floor`,
`github-token`;
- passed: the same four;
- passed but undeclared: none; required but not passed: none.
- **Ruling sources:** the Q2 ruling A is comment 5905360637, the
handover is 5908038257, and `anchor-optional` comes from
objectstack-ai/objectstack#20812.
## Acceptance evidence: what this PR's own run can and cannot show
- This PR's `pull_request` run of **Half-State Patrol** exercises the
pin, the inputs and the transport on a real runner. The action's `Locate
the patrol sources` step judges `github-token` and the `anchor-optional`
spelling on every event, `pull_request` included, and the sweep runs.
- **But it skips both anchor steps, so it cannot show the opt-in.** The
live reading is the **first scheduled run (cron `37 1,7,13,19 * * *`) or
`workflow_dispatch` run after this lands**. The expected result:
- `Resolve the anchor issue` writes `configured=false` and emits the
`::notice::` naming `anchor-optional: true`;
- `Update the pinned anchor issue` is skipped;
- the run is green.
## Tests
Every check below was run on head `ec0c6e49`, except where a line names
`a13f4e5f`. That earlier head differs only by the header-comment edit in
the second commit.
- **actionlint 1.7.7** (release binary; actionlint is not wired in this
repository): `actionlint -shellcheck= -pyflakes=
.github/workflows/half-state-patrol.yml` gives exit 0.
- Positive control: the same file with `permissions:` misspelled gives
exit 1, `unexpected key "permissionz"`.
- ⚠️ actionlint cannot check a remote composite action's inputs. That
check is the `action.yml` read under The pin.
- **`vitest run scripts/__tests__/`** (through `os-verify-lock`,
`--maxWorkers=2`) on **`ec0c6e49`**: 177 files passed and 2 skipped
(179); 5374 tests passed and 2 skipped (5376); `VERDICT command-exit 0`.
The same result on `a13f4e5f`.
- **Targeted:** `pnpm exec vitest run
scripts/__tests__/ci-cd-pipeline-doc.test.ts
scripts/__tests__/check-action-ref-convention.test.ts` gives 2 files and
104 tests passed. On the base it gave 101.
- **`tsc -p tsconfig.scripts.json --listFiles`** on `a13f4e5f` gives
exit 0, and both edited test files are in the program. `ec0c6e49`
changes only the workflow YAML, which is outside that program and
outside the lint population below.
- **Gates, all exit 0:**
- `pnpm check:doc-fences`, `pnpm docs:check-links`, `pnpm
check:control-bytes`, `pnpm check:action-ref-convention`, `pnpm
check:new-line-citations` (0 new citations), `pnpm
check:test-path-roots` and `node
scripts/check-shell-escape-residue.mjs`;
- `node scripts/check-changeset-presence.mjs`: "no changeset is owed".
Nothing under a released package's source or contract moves, so no
changeset is added and no label is applied;
- `node scripts/check-governed-queue-guard.mjs --test` over the five
paths: NOT GOVERNED.
- **ESLint, narrowed to this diff:** `eslint --format json` over the
three lintable files reports 3 files, 0 errors and 0 warnings. The YAML
and the Markdown are not in the lint population.
- The population is `lint:root`, which is `eslint .` minus `packages/`,
`examples/`, `apps/` and `docs/`. The three files sit inside it.
- The narrowing excludes nothing that could move:
- `eslint.config.js` has no `parserOptions` or `projectService`, so the
linting is not type-aware;
- `eslint-rules/*.js` read no files
(`readFileSync`/`existsSync`/`readdirSync` give zero hits);
- so no untouched file's verdict can change.
### Ablations (one-shot; nothing kept)
Each ablation ran through objectstack's `scripts/ablation-replace.mjs`
in WRAP mode on committed head `a13f4e5f`. In each, the anchor hit once,
the blob changed, and the file was restored with blob == HEAD and `git
diff HEAD` empty.
1. `@0c5a71b0…` changed to `@main`: exactly 1 red, "pins the action to a
40-character sha … never a branch".
`check-action-ref-convention.test.ts` stayed **green**, which is the
hole that pin covers: the exception matches by workflow and action, not
by spelling.
2. `anchor-issue: ''` changed to `'9857'`: exactly 1 red, "describes the
anchor configuration the step actually passes".
3. `closed-floor` deleted: 2 red, "names only inputs the workflow
actually passes" and "quotes the closure floor".
4. The doc's `github-token` bullet stripped of its name: 1 red, "names
every input the workflow passes".
5. The gate without the new entry, run as `scan(root, { exceptions: []
})` with no file mutated: 1 offender, `half-state-patrol.yml … [sha]`.
## Surface note for the seat
The claim's surface lists the workflow, the doc section,
`ci-cd-pipeline-doc.test.ts` and "any other `git grep half-state-patrol`
hit whose assertion moves". This PR also edits
**`scripts/check-action-ref-convention.mjs`** and
**`scripts/__tests__/check-action-ref-convention.test.ts`**.
- Neither file was a grep hit before this change. Both read this
workflow's text, and their verdict moves with it, so they fall under the
original claim's definition of the surface (5905159945: "the tests or
scripts that read this workflow's text").
- The ruling's sha pin is off-convention for the blocking Action Ref
Convention gate, and that gate admits exactly one form of exception: a
`DECLARED_EXCEPTIONS` entry.
- No other workflow and nothing in objectstack is touched. The seat may
want to amend the claim's surface to match.
## Acceptance notes (noted, not filed)
- **Prose drift in workflows this claim excludes:**
- `merge-queue-head-patrol.yml`'s header still says
`half-state-patrol.yml` "fails when its anchor variable is unset". That
has been false since objectui#8740. Its env comment cites "the reason
`half-state-patrol.yml` states"; the new header keeps that sentence, so
the citation still resolves.
- `changeset-guard.yml`, `performance-budget.yml`, `lockfile-dedupe.yml`
and the docblock of `render-budget-comment.test.ts` say this workflow
lists `scripts/invoked-as.mjs` in its paths. That has been false since
objectui#10208, not because of this PR.
- Carrier: none.
- **Dependabot.** Its `github-actions` ecosystem runs monthly here.
Whether it proposes bumps for a sha-pinned ref to a non-tagged action
path inside a monorepo is NOT MEASURED. If it does, the bump arrives as
a PR touching this file, which runs the patrol, so it is a reviewed
moment in any case.
---
_Generated by [Claude
Code](https://claude.ai/code/session_01TdiauJaVCHuj45EzZGUxHh)_
---------
Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: 黄怡 <huangyi@hotoa.com>
Fixes #20798
Clause-②: no. CI wiring, no published contract, per ruling
5905998989.This executes decision A of the decision card #20793 (ruling
5905998989; the maintainer's answer, verbatim: "A: opt-in action input (Recommended)"), inside the maintainer's order on #18471. objectstack-ai/objectui#11174 is waiting on this card and switches onto the action once this lands, with the opt-in on and a sha pinned at that time.What changes
.github/actions/half-state-patrol/action.yml: one new declared input,anchor-optional, default'false'.anchor_optional. It accepts the six YAML 1.2 core-schema boolean spellings thatcore.getBooleanInputtakes. Any other value is::error::+ exit 1.id: anchorand one new block in front of the existing ones. An empty (or whitespace-only)anchor-issuewith the opt-in on writesconfigured=false, prints a::notice::, and exits 0. The existing empty-anchor block that follows it (::error::+ exit 1,action.yml:313-315onmain) is byte-identical. The non-numeric check is unchanged and runs in both modes. A usable anchor writesconfigured=true.&& steps.anchor.outputs.configured == 'true', so the opt-in never reaches the PATCH. H1 holds: without this guard, the opt-in PATCHes issue 0 (see the ablation below).configured == 'false'reading. The findings go to the run summary as before, since this step isif: always().descriptionnames the summary-only delivery.scripts/pm/check-half-states.mjsis untouched..github/workflows/half-state-patrol.yml(objectstack's own caller): prose only, and the default is kept. The sibling-install section now names the opt-in for a board that has no anchor by decision. The anchor-resolution comment says this caller keepsanchor-optionalat its loud default. Thewith:block is unchanged.The input as declared
The three modes: a runnable probe of the action's own shell
There is no action or workflow test harness for this action in the repo. H2 is falsified:
git grep -n "anchor-issue\|closed-floor"finds only the action itself and the caller's prose. No test, doc or script reads the input list. So this is measured with a probe,probe-anchor-modes.py, whose full source is at the end of this body. How it works:action.ymland runs every step's realrun:body in order. Each body is template-expanded and run the way the runner'sshell: bashruns it (bash --noprofile --norc -eo pipefail).if:with a small GitHub-expression evaluator. As the runner does, it wraps the expression insuccess() && (...)unless it names a status function.GITHUB_OUTPUT,GITHUB_STEP_SUMMARYandRUNNER_TEMPfiles.Number(ANCHOR_ISSUE)would target, computed by node.Run at
c35b098b1:python3 probe-anchor-modes.py .github/actions/half-state-patrol/action.yml .(exit 0).''::error::''false::error::''true::notice::' 'true::notice::'abc'::error::'abc'true::error::'9857''9857'true''''true''yes::error::''yes::error::''true::notice::''::error::The rows the card requires, quoted from that run:
The default mode is unchanged, measured against
main. The same probe was run on the base blob (41dcf1188:.github/actions/half-state-patrol/action.yml, blobfc455f1ef) and on this head, for off+empty, off+non-numeric, off+number, pr+off+empty and off+empty with sweep exit 3. The outputs were compared after dropping theoutputs {...}annotation, since the head's steps now also publishanchor_optionalandconfigured. Across all 66 lines, each scenario differs in one line only: the scenario header, which readsUNSET(default '-')on base (no such input) andUNSET(default 'false')on head. Every step line, annotation, summary line and job verdict is identical.Ablation of the Update guard (H1). This was run on a scratch copy of the committed
action.yml, never on the tree. The copy had the guard's&& steps.anchor.outputs.configured == 'true'removed;grep -cfound that text 0 times in the copy and 1 time in HEAD. The probe then read:So the opt-in needs the guard. On a real runner that PATCH answers 404 and turns the run red, which is the state this card exists to remove.
Gates (local, at
c35b098b1)node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack: 39 commands, derived from merge base41dcf1188over the 2 changed paths.node scripts/pm/dispatch-gates.mjs --ran ran.listreconciles them as39 derived, 39 run, 0 NOT-MEASURED, 0 UNRUN, with an exit code recorded for every one.pnpm check:pm-dispatch-gatesis longer than the container's foreground cap, so it ran detached, as its header prescribes, under a foregroundtail --pidwait. It printed✓ check:pm-dispatch-gates --self-test: the exit contract holds in all three directions.and✓ dispatch-gates self-test: 1976 cases pass.(1018.6s), with 0✗lines.pnpm installfor the root package only (--filter @objectstack/spec-monorepo). 15 gates first exited 3 (PREREQUISITE NOT MET, theyamldependency) and are green on the re-run after it.check-half-states.mjsandcheck-issue-citations.mjs --censusare NOT MEASURED locally. Their argv carries runner-only values. This PR's ownhalf-state-patrol.ymlrun exercises the PR path on a real runner, because the action's directory is a trigger path. That run includes the new value check at its default.actionlint: NOT MEASURED. It is not on PATH and not innode_modules/.bin.check:workflow-status-functionsandcheck:workflow-step-name-quotingare green.@objectstack/spec-monorepo.pnpm ls -rfinds 81 workspace packages, 69 public, and none of them owns a changed path.check-empty-changeset.mjs --base 41dcf1188is green (0 changesets added).check-changeset-no-major.mjs --base 41dcf1188 --event EVENTis green, reading this body'sClause-②: noline. The disposition isskip-changeset. This dispatch did not allow label writes, so the seat applies it, andCheck Changesetreads red until then.Acceptance notes
anchor-optionalsits in "Locate the patrol sources", so it runs on pull_request runs too.caseinto the Resolve step (non-PR only).anchor-optional: true) where objectui's named its repository variable.workflow_dispatchof this branch would PATCH objectstack's live anchor. The first live reading of on+empty will be objectui's switch (ci(half-state-patrol): run objectstack's composite action pinned to a sha instead of checking out objectstackmain— objectui's half of objectstack-ai/objectstack#18471 (ruling 只做②) objectui#11174).anchor-optionalgets only the runner's "Unexpected input" warning, and the empty anchor still fails loudly. That is safe, but objectui's switch has to pin a sha that contains this change.The probe (
probe-anchor-modes.py)Generated by Claude Code