Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
135 changes: 135 additions & 0 deletions .github/workflows/half-state-patrol.yml
Original file line number Diff line number Diff line change
Expand Up @@ -161,6 +161,11 @@ on:
# as the row above, one file along: a step whose script can change without
# this trigger firing is a step whose PR-time proof is a coincidence.
- 'scripts/pm/sweep-closed-cards.mjs'
# The citation census this workflow also calls (#18224). Same reasoning as
# the two rows above, one file along: a step whose script can change
# without this trigger firing is a step whose PR-time proof is a
# coincidence.
- 'scripts/check-issue-citations.mjs'
# The patrol's body. A glob, not the one file: anything added to that
# directory is part of what runs here.
- '.github/actions/half-state-patrol/**'
Expand All @@ -170,9 +175,21 @@ on:
# plus the labels and comment the closed-card sweep is ruled to write. `issues:
# write` is the narrowest scope GitHub offers for either edit; the sweeper the
# patrol calls is read-only against the API by construction.
#
# `pull-requests: read` is READ-ONLY and buys one thing, for the citation
# census step only — MEASURED on run 35495222460, this workflow's own
# pull_request run WITHOUT this row: the census reported 3,628 unresolvable
# citation sites where a full-scope read of the same tree reported 2,168, and
# the difference is 1,460 — EXACTLY the `resolves-as-pull-request` tally.
# `GET /repos/{owner}/{repo}/issues` answers with the pull requests omitted
# unless this scope is held, so every citation naming a PR number was reported
# as a number the board never had. A report-only reading that is wrong by 67%
# is still a machine-readable surface telling a lie. ⛔ Do not drop this row
# as tidying, and ⛔ do not widen it to `write`: nothing here writes a PR.
permissions:
contents: read
issues: write
pull-requests: read

# One patrol at a time. A scheduled run overlapping a manual dispatch would have
# two runs racing to rewrite the same body, and the loser's findings would vanish
Expand Down Expand Up @@ -351,3 +368,121 @@ jobs:
elif [ "$code" != "0" ]; then
echo "::warning::closed-card sweep exited $code — at least one card was left UNJUDGED. An unjudged card is not a clean card; see this run's summary."
fi

- name: Census the repo's issue citations
# #17512's gate, wired here by #18224 — the REPORT-ONLY half, and the
# posture is a ruling, not a preference. `--census` judges every
# citation in the gate's declared surfaces (the published release pages
# and package source docblocks), which is ~2,785 unresolvable sites on a
# tree nobody touched, and its verdict is NOT a function of this tree:
# #16783, #16786 and #16787 were measured RESOLVING on 2026-09-10 and
# 404 on 2026-09-14 with no change to this repository. That is exactly
# the shape this workflow exists for — a fact about a live shared board,
# not about whichever change happens to run CI next — so it belongs on
# the patrol lane and ⛔ NEVER on a blocking one. The DIFF-scoped half of
# the same gate is the blocking one and lives in `lint.yml`; the two
# postures are opposite on purpose and ⛔ neither moves to the other's
# lane.
#
# ⛔ NOT in the composite action this job `uses:` above — it is a step of
# the CALLER, for the same reason the closed-card sweep one step up
# states, plus two that are specific to a census of a TREE. All three
# are measurable on this repo rather than argued:
#
# - It is not repo-agnostic. `scripts/check-issue-citations.mjs` is
# objectstack-only, so the action's own "Locate the patrol sources"
# step would have to either name it — and then REFUSE to run in
# every sibling that adopted the action, which is the one thing that
# action exists to make possible — or not name it, and leave a step
# inside the action failing on a missing file in that sibling. The
# repo-name gate below is what answers both, and it belongs where
# the repository is the caller's own.
# - The action runs its scripts from `steps.sources.outputs.root`, the
# tree the ACTION ships from, while the board's checkout is
# `github.workspace`; that split is deliberate and the action's own
# comments carry it. This census's subject is the WORKSPACE tree's
# declared surfaces, so from inside the action a sibling's run would
# census objectstack's own release pages and report the count under
# the sibling's name — a machine-readable surface telling a lie, the
# failure this step's `permissions:` note above is about. In the
# caller the two directories are the same one, on every install.
# - `pull-requests: read` is granted by the `permissions:` block of
# this file. A composite action declares no permissions and the
# action's input documentation names only `issues: write` and
# `contents: read`, so a census inside it would silently depend on a
# scope no adopting caller was told to grant — measured above as a
# reading wrong by 67%. A scope and its one consumer stay in one
# file.
#
# WHERE THE REPORT GOES: this run's step summary and job log, plus one
# `::warning::` carrying the site count. ⛔ NOT the anchor issue — that
# body is owned end-to-end by `check-half-states.mjs`'s generator, and a
# second writer is how half of a generated body goes stale.
# HOW OFTEN: on this workflow's schedule — four times a day, six hours
# apart — plus any `workflow_dispatch`, plus the `pull_request` runs the
# paths filter above admits.
# WHAT IT COSTS AND WHO PAYS: the census enumerates the whole board once
# (159 requests on this repo at the time of writing, cursor-paginated —
# the alternative is one request per distinct number). It is paid by
# THIS repository's own `secrets.GITHUB_TOKEN` core quota, the same
# 5,000/hour this job already draws the live sweep from: ~636
# requests/day at four runs, under half a percent of a single hour's
# allowance. ⛔ No PAT, no cross-repo credential — the file's own rule.
#
# ⛔ Gated on the repository NAME, for the reason the closed-card sweep
# above states: this script is objectstack-only until a sibling has a
# copy, and a verbatim copy of this workflow elsewhere must SKIP rather
# than fail on a missing file.
#
# ⛔ `!cancelled()` rather than an implicit `success()`, and this
# placement has to SPELL that rather than inherit it. While the patrol's
# steps were written inline in this file, this step sat ABOVE the one
# step that fails the job, so it ran whatever the sweep returned. The
# patrol is now a single `uses:` step that goes red itself when the
# sweep could not read the board, so a default `success()` here would
# skip the census on exactly the runs where the patrol is down. This
# step reads neither the sweep's exit code nor its files: a patrol that
# could not reach the board says nothing about whether the citations in
# this tree resolve.
#
# ⛔ This step never fails the job, whatever the census returns —
# findings are not a failure condition here, and neither is a census
# that could not read the board: that is an alarm (`::error::`), not a
# gate. LAST on purpose, after the patrol's own anchor write and after
# the closed-card sweep: the anchor is this patrol's product, this job
# has a 15-minute timeout, and a report-only reading must never be able
# to starve the thing the workflow exists to deliver.
if: ${{ !cancelled() && github.repository == 'objectstack-ai/objectstack' }}
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
set +e
node scripts/check-issue-citations.mjs --census \
> "$RUNNER_TEMP/issue-citations.md" 2> "$RUNNER_TEMP/issue-citations.err"
code=$?
set -e
# Captured with NO pipe in between, for the reason the two steps above
# state at length: piped, `$?` is the pipe's status and a red run and a
# green one read the same.
{
echo "### Issue-citation census — exit $code (report-only)"
echo
echo '```'
cat "$RUNNER_TEMP/issue-citations.md" 2>/dev/null || echo '(no report produced)'
echo '```'
echo
echo '<details><summary>stderr</summary>'
echo
echo '```'
cat "$RUNNER_TEMP/issue-citations.err" 2>/dev/null || true
echo '```'
echo
echo '</details>'
} >> "$GITHUB_STEP_SUMMARY"
cat "$RUNNER_TEMP/issue-citations.err" >&2 || true
if [ "$code" != "0" ]; then
echo "::error::issue-citation census exited $code — the board was NOT read, so this run says nothing about whether unresolvable citations are accumulating. A census that could not run is not a clean census. See this run's summary."
else
sites=$(sed -n 's/^.*census: \([0-9][0-9]*\) unresolvable citation site(s).*$/\1/p' "$RUNNER_TEMP/issue-citations.md" | tail -1)
echo "::warning::issue-citation census: ${sites:-unknown} unresolvable citation site(s) in the declared surfaces. Report-only — the blocking half judges only what a change ADDS."
fi
120 changes: 120 additions & 0 deletions .github/workflows/lint.yml
Original file line number Diff line number Diff line change
Expand Up @@ -184,6 +184,27 @@ jobs:
# `scripts/report-unmeasured-gate-tail.mjs`. Nothing else in this job
# reads it, and no step writes anything anywhere.
actions: read
# ⭐ Added by #18224, for the issue-citation step far below: it resolves
# the numbers THIS change cites against the board. Read-only and one
# scope wide — no issue, comment, label or assignee is ever written. It
# is spelled even though this board is PUBLIC, because an explicit
# `permissions:` block sets every unnamed scope to `none`, and a BLOCKING
# gate whose transport depends on repository visibility is a gate that
# goes red on a settings change no file here can assert.
issues: read
# ⭐ AND `pull-requests`, which is NOT redundant with the row above —
# MEASURED, on run 35495222460 of `half-state-patrol.yml`, whose token
# carries `issues` and NOT this scope: its census reported 3,628
# unresolvable citation sites where a full-scope read of the same tree
# reported 2,168, and the difference is 1,460 — EXACTLY the
# `resolves-as-pull-request` tally. `GET /repos/{owner}/{repo}/issues`
# answers with the pull requests omitted, so every citation naming a PR
# number reads as a number the board never had. On THIS blocking step
# that is a false RED on a correct citation, and the frontier probe
# (`?per_page=1&sort=created&direction=desc`) is read from the same
# response — so an understated frontier turns later numbers into
# `never-issued` too. ⛔ Do not drop this row as tidying.
pull-requests: read

steps:
- name: Checkout repository
Expand Down Expand Up @@ -4638,6 +4659,105 @@ jobs:
- name: workspace manifest dependency graph has no cycle
run: pnpm check:workspace-manifest-cycles

# Merged-result budget probe, wired by the card that wired the citation
# gate below (#18224). `node scripts/check-merged-result.mjs --self-test`
# IS the whole gate and is exactly what the root manifest registers as
# `check:merged-result`; the probe landed in #18338 with the file surface
# of its own card excluding `.github/workflows/**`, so until this step no
# workflow named it and its self-test ran nowhere. Offline: no build, no
# network, no credential, sub-second. Its defect class is a MATCHING RULE,
# which a clean tree cannot tell from a rule that stopped matching — the
# self-test is the only instrument that can, and an instrument nobody runs
# is silence.
- name: Merged-result probe self-test
run: pnpm check:merged-result

# Issue citations (#17512, wired by #18224). TWO commands, and the split
# is the point:
#
# `pnpm check:issue-citations` the checker's own
# `--self-test` — offline,
# no board, no credential.
# `node scripts/check-issue-citations.mjs` the DIFF-SCOPED verdict —
# the live run, judging only
# the citations THIS change
# adds.
#
# ⛔ The manifest alias is NOT the verdict. It runs `--self-test` and
# nothing else — the shape every credential-needing gate in this manifest
# uses (`check:pm-half-states`, `check:pm-closed-card-sweep`), because a
# live mode needs a board and a credential. Wiring the alias ALONE would
# run the self-test twice and scan nothing. Both spellings are here on
# purpose, self-test first: `check-self-test-wired` requires CI to run the
# self-test of every script CI runs, and a verdict from a checker whose
# own cases were never exercised is a verdict about nothing.
#
# ⭐ WHY DIFF-SCOPED AND NOT TREE-WIDE, which is the whole ruling. The
# standing population is ~2,785 unresolvable citation sites, and the
# predicate is NOT a function of this tree: #16783, #16786 and #16787 were
# measured RESOLVING on 2026-09-10 and 404 on 2026-09-14, with no change
# to this repository. A tree-wide blocking verdict therefore reds a
# MOTIONLESS tree because somebody else deleted an issue, and fails a PR
# for a change its author did not make and cannot repair. The diff-scoped
# half is the part an author owns, it is small, and it is the only thing
# that stops 2,785 becoming 3,000. The tree-wide reading is a `--census`
# and it runs REPORT-ONLY in `half-state-patrol.yml`, four times a day —
# never here, and never blocking. ⛔ Do not "upgrade" this step to
# `--census`.
#
# THE DIFF BASE, per event, and the merge_group row is the whole of
# #19259's ejection:
#
# pull_request `git merge-base origin/main HEAD`. CORRECT and left
# alone: the checked-out merge ref already CONTAINS the
# main it was computed against, so the merge base IS that
# main and nothing newer can leak into the added set.
# push (main) the merge base IS the head, so the added set is empty,
# the run makes ZERO requests and exits 0 before it asks
# the board anything.
# merge_group ⭐ the group's own `base_sha`, declared below. A queue
# entry is built on the GROUP's base, which carries the
# entries AHEAD of it in the queue and has NOT landed on
# `main` yet, so the published `origin/main` this runner
# fetched is BEHIND that base and the ref guess lands at
# the published tip — attributing every entry ahead in the
# queue to THIS change.
#
# MEASURED, on this step's own first queue build (entry `a7109d1f08`,
# 2026-09-20): the ref guess resolved to the published main of 14:30Z and
# judged 15 file(s) / 16 citations, 3 unresolvable — `#6361` twice from
# `ada701220` (#19364) and `#18003` from `8271c81425` (#19363), the two
# entries ahead in the queue, both of which landed on `main` only at
# 14:36:53Z and 14:47:59Z. The step exited 2 (its findings code) and the
# queue ejected the PR for citations its author never wrote. Against
# `base_sha` the same tree judges 0 file(s).
#
# ⛔ Do NOT `if:`-skip this step on `merge_group` instead: this file's
# header asserts that every gate step here runs on merge_group, and a
# blocking gate turned off on the one event that guards what is about to
# reach `main` is a gate that is not one. Both halves of the repair are
# this: the base above, and the gate's own refusal below.
#
# `fetch-depth: 0` is what makes either spelling resolvable (the checkout
# step above says so for its own reason).
#
# ⛔ A failed read is never a pass: an unreadable board — and now an
# unresolvable base, which used to throw an unclassified exit code —
# exits PREREQUISITE NOT MET rather than reporting a clean tree.
- name: Issue citations this change adds resolve on the board
env:
# The board read is one GET per distinct number the change cites
# (none at all when it cites none), against a PUBLIC board. Spelled
# anyway: the gate uses the token when it finds one, and an
# unauthenticated runner shares one IP's 60 requests/hour.
GITHUB_TOKEN: ${{ github.token }}
# Renders the group's base on `merge_group` and EMPTY on every other
# event, where the gate keeps the `origin/main` / `main` guesses. Same
# name, same expression, same fact as the gate-family selector above
# — one fact, one spelling.
OS_GATE_MERGE_GROUP_BASE_SHA: ${{ github.event.merge_group.base_sha }}
run: pnpm check:issue-citations && node scripts/check-issue-citations.mjs

# #15149. Grep-level guard for the defect this very repo just shipped: an
# unquoted `- name:` step name containing ` #` is silently truncated by
# YAML at that point (a space + hash starts a comment inside a plain
Expand Down
Loading
Loading