diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 21ebfc9534..3d13f459cd 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1385,11 +1385,19 @@ jobs: # 2026-08-18 (#9533) found it had never landed there, and the row was dropped # rather than left claiming otherwise — see that file's TOMBSTONE for the full # provenance. So a red here does NOT block a merge, and renaming this job no - # longer turns anything red. The name is still load-bearing PROSE in four - # places that tell it apart from `Console Pin Freshness` - # (docs/releases-maintenance.md, packages/console/README.md, - # scripts/check-objectui-pin-fresh.mjs, .github/workflows/objectui-pin-freshness.yml); - # renaming it silently falsifies all four. + # longer turns anything red. The name is still load-bearing PROSE that renaming + # this job silently falsifies — docs/releases-maintenance.md and + # packages/console/README.md both describe the pin flow by this name (#9793 + # tracks the missing pin for job names that are not required contexts). + # + # ⚠️ It used to need telling apart from a same-shaped sibling, `Console Pin + # Freshness`, which asked whether the pin was CURRENT. That gate is gone + # (#10134, 2026-08-20 ruling: which objectui revision we pin is a decision + # recorded in an objectstack issue, never derived from objectui `main`). This + # gate's question is unaffected and it stays: does the PINNED SHA still BUILD. + # ⛔ Do not read the sibling's deletion as licence to rebuild one under this + # name — see scripts/check-required-contexts.mjs for why that name in + # particular must stay out of any required set. console-pin: name: Console Pin Gate needs: filter diff --git a/.github/workflows/cut-rc.yml b/.github/workflows/cut-rc.yml index 381912c753..6da658517f 100644 --- a/.github/workflows/cut-rc.yml +++ b/.github/workflows/cut-rc.yml @@ -17,34 +17,44 @@ name: Cut RC # # WHAT THIS SOLVES THAT `release.yml` CANNOT # ------------------------------------------ -# Cutting an rc through the standing "Version Packages" PR (#6208) requires the -# objectui pin to be fresh at the moment of the cut, and `check:objectui-pin-fresh` -# judges the pin against a MOVING objectui `main`. On a busy day that is a race the -# cutter cannot win: rc.6 was chased across four pin-bump laps -# (8aad9fd -> 9b9fa49 -> cfeb378 -> 1b6188d -> bcd3e02), every lap overtaken before -# its CI finished, and finishing would have needed ~40 minutes of coordinated -# freezes across two repositories. The version PR itself is force-refreshed on -# every main push, so its CI can never converge while main is busy. +# The standing "Version Packages" PR (#6208) is force-refreshed on every push to +# main, so its CI can never converge while main is busy — on a repo with ~18 +# merges a working day, cutting through it means asking two repositories to hold +# still. rc.6 was chased across four pin-bump laps +# (8aad9fd -> 9b9fa49 -> cfeb378 -> 1b6188d -> bcd3e02), every lap overtaken +# before its CI finished, and finishing would have needed ~40 minutes of +# coordinated freezes. # -# The fix is not more discipline, it is a SNAPSHOT. This lane resolves objectui -# `main` HEAD exactly once, records the objectstack `main` sha it checked out, and -# does every downstream step against those two frozen values. Both repositories may -# keep moving freely for the whole run. +# The fix is not more discipline, it is a SNAPSHOT: this lane records the +# objectstack `main` sha it checked out and does every downstream step against +# that one frozen value. main may keep moving freely for the whole run. # -# WHY NOT `check:objectui-pin-fresh` HERE (and why that is not a hole) -# ------------------------------------------------------------------- -# That script asks "is the pin equal to objectui main RIGHT NOW", which is a -# liveness question, and liveness is precisely what a snapshot gives up on purpose. -# Calling it here would re-introduce the race the whole lane exists to remove: a -# single objectui merge mid-run would fail a cut that is otherwise perfect. +# THE objectui PIN IS AN INPUT HERE, NEVER A DECISION (#10134) +# ------------------------------------------------------------ +# Maintainer ruling, 2026-08-20, verbatim and untranslated: # -# What this lane asserts instead is pin SELF-CONSISTENCY — `.objectui-sha` equals -# the sha THIS run resolved and bumped to. #3340's actual invariant is "everything -# shipped is covered by the changeset record", and that stays true by construction: -# the bump changeset covers OLD_PIN..OBJECTUI_SHA, and objectui commits landing past -# the snapshot are simply the next release's record, not a gap in this one. -# `scripts/check-objectui-pin-fresh.mjs` is UNCHANGED and still enforces on the GA -# path in `release.yml` — this lane adds a route, it does not relax a gate. +# > objectui 每次 pin 的时候,对应的 changeset 应该带过来,但是发版本的时候不需要去 +# > 扫描 objectui 仓库的最新版 +# > +# > 更新到哪个版本是 objectstack 的 issue 自己决定的 +# +# So this lane READS `.objectui-sha` and builds against it. It does not resolve +# objectui `main`, does not compare the pin to anything in another repo, and does +# not move the pin. The pin moves only on a manual bump PR whose target revision +# was decided in an objectstack issue (#10129 is the pattern). +# +# ⛔ Do not re-add a currency check here or anywhere on a release path. It is not +# that liveness is expensive — it is that "the pin is behind objectui main" is +# not a defect. #3340's actual invariant is "everything published is covered by +# the changeset record", and `scripts/bump-objectui.sh` + +# `scripts/objectui-changeset-digest.mjs` deliver it at BUMP time by carrying +# objectui's own changesets across OLD..NEW. That carry-over is the mechanism; +# scanning objectui mid-release was a second, redundant attempt at the same goal +# that additionally reached into another repo to second-guess a decision. +# +# CONSEQUENCE, STATED PLAINLY: an rc ships whatever console the committed pin +# names. If the pin is old, the rc's frontend is old, and nothing here will warn +# about it. That is the design, not an oversight — the pin is a decision. # # SCOPE — rc PRERELEASES ONLY # --------------------------- @@ -71,9 +81,9 @@ on: version: description: >- The EXPECTED resulting version, e.g. 17.0.0-rc.6 — what the version pass - should compute after this run's pin bump, not what main carries now. - Typing it is the human confirmation the 2026-08-07 ruling requires: the - run fails before anything irreversible if the computed version differs. + should compute from the changesets pending on main, not what main carries + now. Typing it is the human confirmation the 2026-08-07 ruling requires: + the run fails before anything irreversible if the computed version differs. required: true type: string dry_run: @@ -236,60 +246,59 @@ jobs: echo "main currently carries @objectstack/cli@${CURRENT}; this run must compute ${REQUESTED}." # ──────────────────────────────────────────────────────────────────────── - # THE SNAPSHOT, HALF 2 — objectui, resolved EXACTLY ONCE. + # THE objectui HALF — READ FROM THE COMMITTED PIN, NOT RESOLVED (#10134). # - # Everything downstream reads $OBJECTUI_SHA, never `main` again. This single - # line is what ends the pin-chasing treadmill: objectui may merge freely for - # the rest of the run and this cut neither notices nor cares. + # `.objectui-sha` is the whole input. Everything downstream reads + # $OBJECTUI_SHA, which is that file and nothing else, so objectui may merge + # freely for the rest of the run and this cut neither notices nor cares — + # not because a snapshot froze a moving target, but because the target was + # never objectui `main`. See "THE objectui PIN IS AN INPUT HERE" up top. # ──────────────────────────────────────────────────────────────────────── - - name: Resolve objectui main HEAD (once) + - name: Read the committed objectui pin run: | - # Network failure is NEVER green — the rule check-objectui-pin-fresh.mjs - # states for the same lookup. An unresolvable remote fails the cut. - if ! OUT="$(git ls-remote https://github.com/objectstack-ai/objectui.git refs/heads/main 2>&1)"; then - echo "::error::could not reach objectstack-ai/objectui to resolve main: ${OUT}" - exit 1 - fi - OBJECTUI_SHA="$(printf '%s\n' "$OUT" | awk 'NR==1{print $1}')" + # Shape-checked, not trusted: `.objectui-sha` is hand-edited by + # bump-objectui.sh and by humans, and a truncated or empty file would + # otherwise surface much later as an unhelpful clone/build failure. + OBJECTUI_SHA="$(tr -d '[:space:]' < .objectui-sha)" if ! printf '%s' "$OBJECTUI_SHA" | grep -qE '^[0-9a-f]{40}$'; then - echo "::error::git ls-remote returned no usable sha for objectui refs/heads/main (got '${OBJECTUI_SHA}'). Refusing to cut against an unknown frontend revision." + echo "::error::.objectui-sha does not contain a 40-hex commit id (got '${OBJECTUI_SHA}'). Refusing to cut against an unreadable frontend revision." exit 1 fi echo "OBJECTUI_SHA=${OBJECTUI_SHA}" >> "$GITHUB_ENV" - echo "objectui snapshot: ${OBJECTUI_SHA} (main HEAD at $(date -u +%FT%TZ)); objectui may move freely from here." + echo "objectui pin (committed, NOT resolved): ${OBJECTUI_SHA}. This cut ships the console at that revision, however old it is — moving the pin is a separate, deliberate PR." - - name: Clone objectui at full depth + - name: Clone objectui at full depth, at the pin run: | OBJECTUI_ROOT="${RUNNER_TEMP}/objectui" - # FULL clone, not shallow, and this is a measured requirement rather - # than caution: scripts/objectui-changeset-digest.mjs walks - # OLD_PIN..OBJECTUI_SHA to build the @objectstack/console changeset, and - # it exits 2 on a range it cannot walk. bump-objectui.sh degrades to a - # tip-subject-only changeset in that case — a DEGRADED release record - # that still exits 0. A shallow clone would therefore cost the cut its - # frontend history without failing (#4731's whole lesson). + # FULL clone, not shallow, and still a measured requirement rather than + # caution. Two reasons, both surviving #10134: + # - the check below asks whether the PIN is a real, reachable commit + # of objectui main; a tip-only shallow clone cannot answer that and + # would answer "no" for every pin older than the tip; + # - build-console.sh builds from THIS clone (it honours + # $OBJECTUI_ROOT) by adding a worktree at the pin, which needs the + # pin's tree present. + # The #4731 lesson — a degraded changeset record and a complete one look + # alike, so never let history quietly go missing — now applies on the + # bump PR, which is where objectui-changeset-digest.mjs walks OLD..NEW. git clone --no-tags https://github.com/objectstack-ai/objectui.git "$OBJECTUI_ROOT" echo "OBJECTUI_ROOT=${OBJECTUI_ROOT}" >> "$GITHUB_ENV" - # The snapshot sha must be IN the clone. It normally is (the clone is a - # superset of the ls-remote moment); it is absent only if objectui - # rewrote main in between, which is exactly when continuing would pin a - # revision nobody can resolve later. + # The pin must be IN the clone. Still a real failure mode, and the only + # objectui-side one left: a pin taken from a branch that never merged, + # or a main that was rewritten, names a revision nobody can resolve + # later — and the cut would publish a console built from it. if ! git -C "$OBJECTUI_ROOT" cat-file -e "${OBJECTUI_SHA}^{commit}" 2>/dev/null; then - echo "::error::objectui ${OBJECTUI_SHA} is not present in a fresh full clone of main — main was rewritten mid-run. Re-dispatch." + echo "::error::the committed pin ${OBJECTUI_SHA} is not present in a fresh full clone of objectui main. It names a revision that is not reachable from main (unmerged branch, or main was rewritten). Fix .objectui-sha in its own PR; do not cut against it." exit 1 fi - OLD_PIN="$(tr -d '[:space:]' < .objectui-sha)" - if git -C "$OBJECTUI_ROOT" cat-file -e "${OLD_PIN}^{commit}" 2>/dev/null; then - AHEAD="$(git -C "$OBJECTUI_ROOT" rev-list --count "${OLD_PIN}..${OBJECTUI_SHA}")" - echo "objectui pin ${OLD_PIN:0:12} -> ${OBJECTUI_SHA:0:12} (${AHEAD} commit(s)); range is walkable, the changeset digest will be complete." - else - # Not fatal — bump-objectui.sh has a labelled degraded path — but it - # must never pass unremarked, because a degraded changeset and a - # complete one look alike in the release record. - echo "::warning::the current pin ${OLD_PIN:0:12} is not reachable in this objectui clone, so the console changeset will take bump-objectui.sh's DEGRADED path (tip subject only). The release record for this range will be incomplete — check the emitted .changeset/console-*.md before approving the publish." - fi + # Detach the clone AT THE PIN so nothing downstream can accidentally + # read a working tree that is objectui main. build-console.sh builds + # from its own worktree at the pin regardless; this makes the intent + # true of the clone itself rather than incidental. + git -C "$OBJECTUI_ROOT" checkout --detach --quiet "$OBJECTUI_SHA" + echo "objectui clone detached at ${OBJECTUI_SHA:0:12}." # ──────────────────────────────────────────────────────────────────────── # Toolchain. Mirrors release.yml's publish job step for step. @@ -350,38 +359,21 @@ jobs: run: pnpm exec turbo run build --filter=@objectstack/client... --filter=@objectstack/spec... --concurrency=4 # ──────────────────────────────────────────────────────────────────────── - # PIN BUMP — at the snapshot sha, never at "whatever main is now". + # ⛔ NO PIN BUMP HAPPENS HERE (#10134). + # + # This lane used to run `bash scripts/bump-objectui.sh "$OBJECTUI_SHA"` and + # then assert `.objectui-sha` equalled the sha it had just written — an + # assertion that was trivially true, and that existed only because the sha + # came from outside the repo. Both are gone: the pin is committed input, so + # there is nothing to bump to and nothing to reconcile. + # + # The `@objectstack/console` changeset for a pin range is emitted by the + # manual bump PR, which is the only place the pin moves. A cut therefore + # publishes a console the release record already describes, or it publishes + # the same console as last time — never a console nobody wrote a record for. # ──────────────────────────────────────────────────────────────────────── - - name: Bump the objectui pin to the snapshot - run: | - git config user.name 'github-actions[bot]' - git config user.email '41898282+github-actions[bot]@users.noreply.github.com' - - # The sha is passed EXPLICITLY. With no argument bump-objectui.sh pins - # the local checkout's HEAD (#7308 note 1) — which here is objectui main - # as of clone time, i.e. stale-by-construction the moment objectui - # merges anything. Passing $OBJECTUI_SHA is what makes this a snapshot. - # - # This commits, or exits 0 saying "already at " when objectui has - # not moved since the last cut. Both are correct; the assert below is - # what decides, not whether a commit happened. - bash scripts/bump-objectui.sh "$OBJECTUI_SHA" - - # THE pin assertion for this lane — self-consistency, deliberately NOT - # liveness. See "WHY NOT check:objectui-pin-fresh HERE" in the header. Do not - # replace this with `pnpm check:objectui-pin-fresh`: that would fail this cut - # for any objectui merge landing during the run, which is the exact race the - # snapshot exists to remove. - - name: Assert the pin equals this run's snapshot - run: | - PINNED="$(tr -d '[:space:]' < .objectui-sha)" - if [ "$PINNED" != "$OBJECTUI_SHA" ]; then - echo "::error::.objectui-sha is ${PINNED} but this run snapshotted objectui at ${OBJECTUI_SHA}. The bump did not take effect; refusing to build a console the release record does not describe." - exit 1 - fi - echo "pin is self-consistent at ${PINNED} — everything published by this run is covered by the changeset record for OLD_PIN..${PINNED:0:12} (#3340)." - - name: Build the vendored Console SPA at the new pin + - name: Build the vendored Console SPA at the committed pin run: pnpm objectui:build - name: Verify the Console dist stamp matches the pin @@ -389,9 +381,18 @@ jobs: # ──────────────────────────────────────────────────────────────────────── # ADR-0082 D4 declaration-parity ratchet — the mandatory SECOND HALF of - # every pin move (#5960). The pin bump is this ratchet's only trigger, which - # is precisely why it belongs on this lane: cut-rc is now a route that moves - # the pin, so a cut that skipped it would be a pin move with no ratchet. + # every pin move (#5960), run here against the pin as COMMITTED. + # + # ⚠️ Since #10134 this lane no longer moves the pin, so this is no longer + # "the second half of a move this run performed" — the move, and the + # ratchet that belongs to it, happen on the manual bump PR. Kept anyway, + # and deliberately: the ratchet is an on-demand gate by decision (ADR-0082 + # addendum), which means "unrun" is its live failure mode, and a bump PR + # that skipped it leaves a divergence that reaches a release unnoticed. + # This is the last place before publish that can still say so. It writes to + # a gitignored path and asserts against the committed baseline, so it adds + # no file to the version commit; on a pin that already ratcheted it simply + # passes. # # Installing a Playwright browser is fine HERE. The #5960 ruling that keeps # the ratchet off CI is about not putting an objectui build plus a browser @@ -412,7 +413,7 @@ jobs: fi pnpm --dir "$BUILD_ROOT" exec playwright install chromium-headless-shell - - name: 'Declaration-parity ratchet at the new pin (ADR-0082 D4)' + - name: 'Declaration-parity ratchet at the committed pin (ADR-0082 D4)' run: pnpm sdui:manifest # ──────────────────────────────────────────────────────────────────────── @@ -424,17 +425,25 @@ jobs: # are audits that always exit 0, and `--audit-stock` says in its own header # that a non-zero exit there would make it a gate, which it is not). So the # honest base here is $SNAPSHOT_SHA, and the diff they judge is this run's - # OWN contribution: the @objectstack/console changeset the pin bump just - # emitted. That is a real gate, not a formality — the digest resolves its - # bump level from objectui's declarations, so an objectui `major` would - # otherwise promote all ~70 packages, and a digest bug emitting empty - # frontmatter would stall the release silently and greenly (#4898). + # OWN contribution. + # + # ⚠️ Since #10134 that contribution is EMPTY by design — the lane no longer + # bumps the pin, so it emits no @objectstack/console changeset of its own, + # and these three assert exactly that. They are kept rather than deleted for + # two reasons: "this cut introduces nothing before versioning" is a real + # invariant to hold (it is what makes the allowlist assertion below a + # statement about `pnpm run version` alone), and each still runs its + # `--self-test`, so a broken gate is caught here rather than on the PR that + # broke it. The risk they used to cover on this lane — an objectui `major` + # promoting all ~70 packages, or a digest bug emitting empty frontmatter and + # stalling the release silently and greenly (#4898) — now lands on the + # manual bump PR, which is where the digest runs and where these same gates + # judge a non-empty diff. # # $SNAPSHOT_SHA is a pinned sha, which #6129 warns against — but that warning # is about pr-automation.yml's FROZEN `base.sha`, a value that goes stale as - # main moves under a PR. Here the snapshot is by construction the parent of - # the commit being judged, so it IS the merge base, and it cannot go stale - # because the run created the commit on top of it moments ago. + # main moves under a PR. Here the snapshot is by construction the checkout + # this run is judging, so it IS the merge base and cannot go stale. # ──────────────────────────────────────────────────────────────────────── - name: Gate — Changesets "fixed" group covers every public package run: node scripts/check-changeset-fixed.mjs @@ -562,8 +571,16 @@ jobs: REQUESTED: ${{ inputs.version }} DRY_RUN: ${{ inputs.dry_run }} run: | - # Collapse the pin-bump commit (if bump-objectui.sh made one) back into - # the index, so bump + version become one commit on top of the snapshot. + # The committer identity. It used to be set by the pin-bump step, which + # #10134 deleted; `actions/checkout` does not set one, so `git commit` + # below would fail with "empty ident name" without this. + git config user.name 'github-actions[bot]' + git config user.email '41898282+github-actions[bot]@users.noreply.github.com' + + # Structural guarantee that the version commit's parent is the snapshot. + # Since #10134 no step before this one commits anything, so this is a + # no-op in the normal case — kept because it is what makes "one commit + # on top of the snapshot" true by construction rather than by review. git reset --soft "$SNAPSHOT_SHA" # The doc surfaces `sync-docs-image-tags.mjs` rewrites, read from the same @@ -633,7 +650,7 @@ jobs: # which would land verbatim in the commit body. git commit -q \ -m "chore(release): ${REQUESTED}" \ - -m "objectui pin -> ${OBJECTUI_SHA}" \ + -m "objectui pin (unchanged by this cut): ${OBJECTUI_SHA}" \ -m "cut from ${SNAPSHOT_SHA} by ${GITHUB_ACTOR} via cut-rc (#7447)" COMMIT_SHA="$(git rev-parse HEAD)" @@ -647,7 +664,7 @@ jobs: echo "| | |" echo "|---|---|" echo "| objectstack snapshot | \`${SNAPSHOT_SHA}\` |" - echo "| objectui snapshot | \`${OBJECTUI_SHA}\` |" + echo "| objectui pin (committed) | \`${OBJECTUI_SHA}\` |" echo "| version commit | \`${COMMIT_SHA}\` |" echo "| files in commit | ${COUNT} |" echo "| dry run | ${DRY_RUN} |" @@ -674,7 +691,7 @@ jobs: echo "# cut-rc dry run — ${REQUESTED}" echo echo "- objectstack snapshot: ${SNAPSHOT_SHA}" - echo "- objectui snapshot: ${OBJECTUI_SHA}" + echo "- objectui pin: ${OBJECTUI_SHA} (committed, unchanged)" echo "- version commit: $(git rev-parse HEAD)" echo echo "Nothing was pushed and nothing was published." diff --git a/.github/workflows/objectui-pin-freshness.yml b/.github/workflows/objectui-pin-freshness.yml deleted file mode 100644 index 2eeaeebb71..0000000000 --- a/.github/workflows/objectui-pin-freshness.yml +++ /dev/null @@ -1,138 +0,0 @@ -name: Console Pin Freshness - -# Is `.objectui-sha` still CURRENT? (#3340 P0) -# -# ⚠️ NOT ci.yml's "Console Pin Gate" (#4290). The names are close and the -# questions are opposite ends of the same fact: -# -# Console Pin Gate (#4290) "does the PINNED SHA still BUILD?" — clones -# objectui at the pin and builds the SPA. -# Console Pin Freshness "is the PIN still CURRENT?" — compares the pin -# (this workflow) against objectui `main`. -# -# A two-month-old pin builds perfectly (Pin Gate green) while hiding two months -# of frontend releases from the release record (this gate red). Keep both. -# -# WHERE IT BLOCKS -# --------------- -# On the changesets **Version Packages / release PR** only. Between pin bumps an -# ordinary code PR sits behind objectui almost always — that is the normal state -# of the repo, not a defect, and failing every PR over it would train everyone to -# ignore this check. So the job runs everywhere and blocks only on the release -# lane, where a lagging pin silently drops frontend changes from the release -# record (#3340: four changes, two of them `minor` features, lost from v16). -# -# WHY THE JOB IS NEVER SKIPPED -# ---------------------------- -# It carries no job-level `if:` and no paths filter on purpose. A check that -# does not run reports nothing, and a *required* context that reports nothing -# leaves every PR stuck "Expected — waiting for status". Advisory mode is -# expressed in the EXIT CODE, not by skipping: the report is printed in full -# either way, so a green run on an ordinary PR still shows how far the pin has -# drifted. -# -# REQUIRED-CHECK ENFORCEMENT IS NOT SELF-DECLARED — AND IT HAS A PRECONDITION -# ---------------------------------------------------------------------------- -# A workflow cannot make itself required. A maintainer must add the -# `Console Pin Freshness` context to the branch-protection rule for `main` -# (Settings → Branches → main → Require status checks to pass). Until then this -# workflow REPORTS on the release PR without blocking the merge button. -# -# ⚠️ THE PRECONDITION IS THE `merge_group:` TRIGGER BELOW (#6121). Adding this -# context to a required set is safe ONLY BECAUSE that trigger now exists. The -# paragraph above states the PR half of the rule — "a required context that -# reports nothing leaves every PR stuck" — and until #6121 this file invited -# maintainers into the required set while missing the QUEUE half: branch -# protection is evaluated AGAIN on the `gh-readonly-queue/**` generation, so a -# required context that produces no run THERE parks every queue generation -# forever (#3622 is the prototype for that deadlock). -# -# FUTURE EDITORS: before this context is added to — or left in — any required -# set, confirm `merge_group:` is still in `on:` below. Deleting that trigger -# does not fail loudly; it deadlocks the merge queue for every PR in the repo. - -on: - pull_request: - branches: [main] - # Merge queue (see ci.yml for the full note): a required context must report - # on queue generations too, or queue builds wait forever on a check that never - # arrives. This is the precondition documented above (#6121). Which LANE the - # event lands in — blocking vs reporting — is decided in the pin-freshness - # step below, not here. - merge_group: - workflow_dispatch: - -concurrency: - group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} - cancel-in-progress: true - -jobs: - # Job name == the branch-protection context. Keep it stable: renaming it - # silently detaches the required check (the #3622 lesson ci.yml records). - pin-freshness: - name: Console Pin Freshness - runs-on: ubuntu-latest - timeout-minutes: 10 - permissions: - contents: read - - steps: - - name: Checkout repository - uses: actions/checkout@v7 - - - name: Setup Node.js - uses: actions/setup-node@v7 - with: - node-version: '22' - - # "A change to the guard runs the guard" — the rule this repo applies to - # every other scripts/ gate. No install: the script is dependency-free. - - name: Self-test the gate - run: node scripts/check-objectui-pin-fresh.mjs --self-test - - - name: Check objectui pin freshness - env: - # Only ITEMIZES an already-established lag (`git ls-remote` decides the - # verdict), but the token keeps the API off the 60/hr anonymous limit - # so the report names the commits and changesets instead of degrading. - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - # Read through env, never inlined into the shell: a PR title is - # attacker-controlled text. - HEAD_REF: ${{ github.event.pull_request.head.ref }} - PR_TITLE: ${{ github.event.pull_request.title }} - EVENT: ${{ github.event_name }} - run: | - # The changesets action opens the version PR from `changeset-release/` - # with the title configured in release.yml. Either identifies the lane; - # both are checked so a future rename of one does not silently disarm - # the gate. - # - # LANE BY EVENT — classify every new trigger HERE, explicitly (#6121). - # This test used to read `[ "$EVENT" != "pull_request" ]`, a negation - # that meant "workflow_dispatch" back when `on:` had exactly two - # entries. Adding `merge_group:` is the moment that proxy breaks: the - # negation would sweep every queue generation into the BLOCKING lane, - # where this gate goes red whenever the pin lags — the repo's normal - # state between bumps (see WHERE IT BLOCKS at the top) — and a required - # context that is systematically red EJECTS every PR from the queue. - # That would trade one deadlock for another rather than fix it. - # - # workflow_dispatch → BLOCKS. A human asked for the full check. - # merge_group → reports. The queue generation of an ordinary PR - # is an ordinary PR; the Version Packages PR is - # enforced on its own `pull_request` run, and - # release.yml runs this same script on the publish - # path — the backstop with no override input. - # pull_request → BLOCKS on the release lane only. - # - # HEAD_REF and PR_TITLE are empty on merge_group (no `pull_request` in - # the payload), so the two release-lane tests are false there anyway. - if [ "$EVENT" = "workflow_dispatch" ] \ - || [ "$HEAD_REF" = "changeset-release/main" ] \ - || [ "$PR_TITLE" = "chore: version packages" ]; then - echo "::notice::Release lane or manual run — the objectui pin-freshness gate BLOCKS here (#3340)." - node scripts/check-objectui-pin-fresh.mjs - else - echo "::notice::Not the release lane — pin freshness is reported but does not block (a pin lagging between bumps is normal). It blocks on the Version Packages PR and on the publish path." - node scripts/check-objectui-pin-fresh.mjs --advisory - fi diff --git a/.github/workflows/prerelease-pin-watch.yml b/.github/workflows/prerelease-pin-watch.yml index ff4a932536..38e27f7a4b 100644 --- a/.github/workflows/prerelease-pin-watch.yml +++ b/.github/workflows/prerelease-pin-watch.yml @@ -37,8 +37,10 @@ name: Prerelease Pin Watch # which is the one thing it must never do. `--strict` promotes exit 2 to exit 1 if # the warnings ever start being missed. # -# (Contrast `check:objectui-pin-fresh`, where an unreadable remote DOES fail: that -# one is a required gate on the release PR, so silence there ships the defect.) +# (Contrast a blocking release gate that reads a remote, where an unreadable +# remote must FAIL, because silence there ships the defect the gate exists to +# catch. The repo's example of that shape used to be `check:objectui-pin-fresh`; +# it was deleted in #10134 and the contrast is kept as a rule, not a pointer.) # # WHY NOT lint.yml / validate-deps.yml # ------------------------------------ diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 62a7a9b69a..dad96f6613 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -455,27 +455,23 @@ jobs: run: node scripts/check-changeset-fixed.mjs # ────────────────────────────────────────────────────────────────────── - # R4 (#6170) — the gate the old lane skipped, now ON the publish path. + # ⛔ TOMBSTONE — the #3340 pin-currency gate that used to sit here is gone + # (2026-08-20 ruling, #10134). Do not re-add one. # - # `check:objectui-pin-fresh` (#3340) was required on the Version Packages - # PR only, so a lane that published without going through that PR never - # ran it: rc.4 shipped `.objectui-sha = f995a452` and dropped the 7-changeset - # objectui window (#6159) out of the release record. A gate that is required - # on a PR the publishing lane can skip is not a gate. + # It compared `.objectui-sha` against objectui `main` at publish time and + # refused a release whose pin lagged. The ruling removed the question, not + # just the job: WHICH objectui revision this repo pins is a decision taken + # in an objectstack issue, never derived from another repo's HEAD, so + # "the pin is behind main" is not a defect a release lane may diagnose. # - # ENFORCING here, deliberately: a stale pin means the release record is - # about to be wrong, and the remedy is to bump the pin (`pnpm - # objectui:refresh`), never to bypass this. There is no override input on - # purpose. Note this runs the SCRIPT directly — it is NOT added to any - # branch-protection required set, which #6121 warns would deadlock the - # merge queue until objectui-pin-freshness.yml grows a `merge_group:` - # trigger. - - name: Console pin is fresh (#3340 — enforcing on the publish path) - env: - # Only ITEMIZES an already-established lag (`git ls-remote` decides the - # verdict); the token keeps the API off the 60/hr anonymous limit. - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - run: pnpm check:objectui-pin-fresh + # R4's lesson (#6170 — "a gate required on a PR the publishing lane can + # skip is not a gate") is untouched and does not resurrect this one: + # #3340's actual invariant is "everything published is covered by the + # changeset record", and that is carried by `scripts/bump-objectui.sh` + + # `scripts/objectui-changeset-digest.mjs` at BUMP time — on the manual pin + # PR, which is now the only way the pin ever moves. Publishing an old + # console is therefore a published decision, not an unnoticed gap. + # ────────────────────────────────────────────────────────────────────── - name: Build run: pnpm run build diff --git a/docs/releases-maintenance.md b/docs/releases-maintenance.md index 65497678cf..ccd7e54a6b 100644 --- a/docs/releases-maintenance.md +++ b/docs/releases-maintenance.md @@ -173,46 +173,51 @@ changesets also embed companion frontend notes inline ("Companion objectui PR ships…", renderer notes), which are enough to write an accurate Console section on their own. -### Pin freshness — the gate on the release PR (#3340) - -Everything above reads the range `OLD_PIN..NEW_PIN`. That is exact, and it is also the -whole blind spot: anything objectui merged **after** the current pin is outside every -range, so it reaches no changeset, no changelog and no release page — and a -complete-*looking* release record is indistinguishable from a complete one. Cutting v16 -that way lost four frontend changes, two of them `minor` features, while objectui `main` -sat 4 commits and 21 pending changesets ahead of the pin. - -`scripts/check-objectui-pin-fresh.mjs` (`pnpm check:objectui-pin-fresh`) closes it. It is -red when the pin is not objectui `main` (or the `--ref` you name), and it lists the -commits ahead plus the `.changeset/*.md` files that exist at `main` and not at the pin. - -```bash -pnpm check:objectui-pin-fresh # enforcing -node scripts/check-objectui-pin-fresh.mjs --advisory # report only -node scripts/check-objectui-pin-fresh.mjs --ref v17.0.0 --json -``` - -- **Where it blocks:** the changesets **Version Packages / release PR**, via - `.github/workflows/objectui-pin-freshness.yml`. The job runs on every PR — so the - context always reports and can be a branch-protection *required* check — but passes - `--advisory` outside the release lane, because a pin lagging between bumps is the - normal state of an ordinary code PR. -- **It does not run on the rc snapshot lane, deliberately.** `cut-rc` bumps the pin - *inside its own run* and then asserts self-consistency instead of liveness — see - "Cutting a release" below for why that preserves this gate's invariant rather than - bypassing it. This script is unchanged and still enforces on the GA publish path. -- **It is not the Console Pin Gate.** `ci.yml`'s **Console Pin Gate** (#4290) proves the - pinned SHA still **builds**; this one proves the pin is still **current**. Either can - be green while the other is red; neither replaces the other. -- **Network failure is never green.** `git ls-remote` alone decides the verdict, so the - GitHub API (which only itemizes an already-established lag) can be rate-limited or - down without turning red into green — the degradation is printed, not swallowed. An - unreachable remote is reported as `unreadable` and exits non-zero. -- **Fix when it fires:** `scripts/bump-objectui.sh` to move the pin (which writes the - `@objectstack/console` changeset for the crossed range), then `pnpm sdui:manifest` to - run the declaration-parity ratchet at the new pin (see "After the pin moves" above — - the bump is that ratchet's only trigger), then re-source the Console section with - `scripts/objectui-range.mjs`. +### Which objectui revision we pin is a decision, and nothing checks its currency + +Everything above reads the range `OLD_PIN..NEW_PIN`. That is exact, and the obvious +next question — "but is `NEW_PIN` objectui's latest?" — is deliberately **not asked +anywhere in this repo**. Maintainer ruling, 2026-08-20, verbatim and untranslated: + +> objectui 每次 pin 的时候,对应的 changeset 应该带过来,但是发版本的时候不需要去扫描 +> objectui 仓库的最新版 +> +> 更新到哪个版本是 objectstack 的 issue 自己决定的 + +So the process is: + +1. **The target revision is chosen in an objectstack issue.** Someone decides which + objectui commit this platform should ship and records the decision there. It is + never derived from objectui's `main` HEAD, and never by a release lane. +2. **The pin moves by hand, in its own PR**, with `scripts/bump-objectui.sh `. + That is the *only* way `.objectui-sha` ever changes. +3. **The changeset is carried over in that same PR.** The bump emits the + `@objectstack/console` changeset for `OLD_PIN..NEW_PIN` from objectui's own + declared changesets (`scripts/objectui-changeset-digest.mjs`). This is the + mechanism that keeps the release record honest, and it is the whole of it. +4. **Then the declaration-parity ratchet** — see "After the pin moves" above; the + bump is that ratchet's only trigger. +5. **Releases build against the pin as committed.** Both `cut-rc.yml` and + `release.yml` read `.objectui-sha` and build the Console SPA at it. Neither + resolves objectui `main`; neither moves the pin. + +**Stated plainly, because it is the point rather than a caveat:** a release ships +whatever console the committed pin names. If the pin is old, the release's frontend +is old, and **nothing will warn you**. The pin lagging objectui `main` is not a +defect — it is a decision that nobody has revisited yet. Revisit it by filing an +issue and bumping, not by teaching a release lane to second-guess it. + +There *was* a gate that asked the currency question — `Console Pin Freshness` +(`check:objectui-pin-fresh`, #3340), on the Version Packages PR and on the publish +path. The 2026-08-20 ruling removed it (#10134): the workflow, the script and the +`package.json` entry are all gone. Do not rebuild it. #3340's real invariant — +*everything shipped is covered by the changeset record* — is delivered by step 3 +above, at bump time, which is where the decision is actually taken. + +**What still checks the pin, and what it checks.** `ci.yml`'s **Console Pin Gate** +(#4290) clones objectui at the pin and builds the SPA, so it proves the pinned SHA +still **builds**. It has never had an opinion about whether the pin is current, and +that is now the only question anyone asks about the pin automatically. ## Rehearsing the version pass (throwaway clone) @@ -283,14 +288,16 @@ run would push, so the first real dispatch is never this workflow's first execut What it does, in order: -1. Checks out `main` and records that sha. Resolves objectui `main` HEAD **once**. - Those two values are the snapshot; nothing downstream re-reads either repo's - `main`, so **both repositories may keep moving for the whole run**. -2. Bumps the pin to the snapshot (`scripts/bump-objectui.sh ` — always with an - explicit sha; with no argument it pins the local checkout's HEAD, which is stale - by construction), builds the vendored Console, and runs the ADR-0082 D4 - declaration-parity ratchet (`pnpm sdui:manifest`) — the mandatory second half of - every pin move. Because this lane moves the pin, it owes the ratchet too. +1. Checks out `main` and records that sha — the snapshot. Reads `.objectui-sha` + for the objectui revision; it does **not** resolve objectui `main`. Nothing + downstream re-reads either repo's `main`, so **both repositories may keep + moving for the whole run**. +2. Clones objectui at the committed pin, builds the vendored Console there, and + runs the ADR-0082 D4 declaration-parity ratchet (`pnpm sdui:manifest`) against + it. ⛔ **No pin bump happens** — the pin is committed input (#10134). The + ratchet still runs because it is an on-demand gate whose live failure mode is + "unrun": this is the last place before publish that can catch a bump PR that + skipped it. 3. Runs the gates that read `.changeset/*`, before versioning consumes it. 4. Runs `pnpm run version` — the repo script, never a bare `changeset version` — and **fails unless the computed version equals the one you typed**. @@ -298,22 +305,20 @@ What it does, in order: commit**. Never the other way round: rc.3 and rc.4 tagged commits that lived only on `changeset-release/main`, which is #6170. -**Why this lane exists.** The Version-PR flow needs the pin fresh against a *moving* -objectui `main`, which on a busy day is a race the cutter cannot win — rc.6 was -chased across four pin-bump laps, every one overtaken before its CI finished, and -finishing would have needed ~40 minutes of coordinated freezes across two repos. The -standing Version Packages PR is also force-refreshed on every main push, so its CI -cannot converge while main is busy. A snapshot removes the race instead of asking -people to hold still. - -**On pin freshness.** `cut-rc` deliberately does **not** run -`pnpm check:objectui-pin-fresh`; it asserts `.objectui-sha` equals the sha *this run* -resolved. Liveness is exactly what a snapshot gives up, and re-checking it would -re-introduce the race — one objectui merge mid-run would fail an otherwise perfect -cut. #3340's real invariant ("everything shipped is covered by the changeset record") -still holds by construction: the bump changeset covers `OLD_PIN..SNAPSHOT`, and -objectui commits landing past the snapshot are the *next* release's record, not a gap -in this one. +**Why this lane exists.** The standing Version Packages PR is force-refreshed on +every main push, so its CI cannot converge while main is busy — and cutting through +it used to mean chasing a moving objectui pin as well (rc.6 was chased across four +pin-bump laps, every one overtaken before its CI finished, and finishing would have +needed ~40 minutes of coordinated freezes across two repos). A snapshot removes the +race instead of asking people to hold still. + +**On the pin.** `cut-rc` builds against `.objectui-sha` exactly as committed. It +does not resolve objectui `main`, does not compare the pin to anything, and does not +move it — which objectui revision we ship is a decision taken in an issue, not a +release-time lookup (#10134; see "Which objectui revision we pin is a decision" +above). What the lane still asserts about the pin is that the sha is well-formed and +that it is a real commit reachable in a full clone of objectui `main`; a pin naming a +revision nobody can resolve later fails the cut. **One-time admin prerequisite.** The lane pushes the version commit straight to `main`, so the pushing identity must be on main's ruleset **bypass** list — @@ -365,8 +370,11 @@ pending a human" and exits green either way. For an image immediately, dispatch ### Cutting a GA release — the Version Packages PR flow Unchanged, and everything that makes a GA release a *judgement* stays here: the -board-clearing pass, the #7275-A cut precondition, the human review of the generated -changelogs on the PR, and the pin-freshness gate above. Merge the `chore: version +board-clearing pass, the #7275-A cut precondition, and the human review of the +generated changelogs on the PR. ⚠️ There is no longer a pin-freshness gate on this +lane either (#10134) — a GA release ships the committed pin, same as an rc, and the +judgement about which console revision that should be belongs in an issue and a bump +PR *before* the cut. Merge the `chore: version packages` PR (#4935), then **Actions → Release → Run workflow** with the version `main` now carries. `release.yml`'s three lanes are untouched by the rc lane. diff --git a/package.json b/package.json index 53e15a5f4b..8269df6369 100644 --- a/package.json +++ b/package.json @@ -83,7 +83,6 @@ "check:console-injection": "node scripts/check-console-injection.mjs --self-test && node scripts/check-console-injection.mjs", "check:dev-prereqs": "node scripts/check-dev-prereqs.mjs --self-test && node scripts/check-dev-prereqs.mjs", "check:objectui-changeset": "node scripts/objectui-changeset-digest.mjs --self-test && node scripts/objectui-range.mjs --self-test", - "check:objectui-pin-fresh": "node scripts/check-objectui-pin-fresh.mjs --self-test && node scripts/check-objectui-pin-fresh.mjs", "check:prerelease-pins": "node scripts/check-prerelease-pin-watch.mjs --self-test && node scripts/check-prerelease-pin-watch.mjs", "check:empty-changeset": "node scripts/check-empty-changeset.mjs --self-test && node scripts/check-empty-changeset.mjs", "check:adr-0087-registration": "node scripts/check-adr-0087-registration.mjs --self-test && node scripts/check-adr-0087-registration.mjs", diff --git a/scripts/check-objectui-pin-fresh.mjs b/scripts/check-objectui-pin-fresh.mjs deleted file mode 100644 index a33ce9e83d..0000000000 --- a/scripts/check-objectui-pin-fresh.mjs +++ /dev/null @@ -1,856 +0,0 @@ -#!/usr/bin/env node -// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. -// -// check:objectui-pin-fresh — is `.objectui-sha` still CURRENT? (#3340 P0) -// -// node scripts/check-objectui-pin-fresh.mjs # enforcing: red when the pin lags -// node scripts/check-objectui-pin-fresh.mjs --advisory # report only, never blocks -// node scripts/check-objectui-pin-fresh.mjs --ref v17.0.0 # judge against a tag -// node scripts/check-objectui-pin-fresh.mjs --json -// node scripts/check-objectui-pin-fresh.mjs --self-test -// -// ⚠️ NOT the same gate as ci.yml's "Console Pin Gate" (#4290). The names are -// close and the questions are opposite ends of the same fact: -// -// Console Pin Gate (#4290) "does the PINNED SHA still BUILD?" -// → clones objectui AT the pin, builds the SPA. -// Green means the pin is usable. -// Console Pin Freshness (this) "is the PIN still CURRENT?" -// → compares the pin against objectui's `main`. -// Green means the pin has nothing left behind. -// -// Either can be green while the other is red: a two-month-old pin builds -// perfectly (Pin Gate green) while hiding two months of frontend releases from -// the release record (this gate red). Neither substitutes for the other; do not -// delete one because the other exists. -// -// WHY THIS EXISTS (#3340) -// ---------------------- -// The platform ships as one version-locked train, and the frontend enters the -// changesets pipeline exactly once: when `.objectui-sha` moves and -// `bump-objectui.sh` writes the `@objectstack/console` changeset for the range -// it crossed. Everything objectui merged AFTER the pin is, by construction, -// outside that range — it is in no changeset, no CHANGELOG, and no release page. -// -// Cutting v16 that way lost four frontend changes, two of them `minor` features -// (objectui#2701 / #2708 / #2707 / #2706): objectui `main` was 4 commits and 21 -// pending changesets ahead of the pin at release time, and nothing anywhere -// said so. #4731 and #4843 made the two consumers read what objectui DECLARED -// instead of guessing from commit titles — but a correct reader of a range that -// STOPS TOO EARLY still reports a complete-looking, incomplete list. That -// blind spot (a lagging pin at release time) is what this gate closes. -// -// WHERE IT RUNS -// ------------- -// On the **Version Packages / release PR**, as a required check — see -// `.github/workflows/objectui-pin-freshness.yml`. Deliberately NOT blocking on -// ordinary code PRs: between pin bumps the pin lags almost always, and that is -// normal, not a defect. The workflow still RUNS the gate on every PR (so the -// check context always reports and can therefore be *required* in branch -// protection) and passes `--advisory` outside the release lane. -// -// HOW IT READS objectui (no checkout required) -// -------------------------------------------- -// 1. `git ls-remote` resolves the judging ref (default `main`) → head SHA. -// THIS ALONE IS THE VERDICT: head === pin is fresh, head !== pin is stale. -// A fresh pin therefore costs zero API calls. -// 2. Three lightweight GitHub API calls ITEMIZE an already-established lag: -// `compare/...` for the relationship + the commits ahead, and -// the `.changeset` directory listing at each end to name what is -// declared-but-unbundled. Because they only itemize, an API that is -// rate-limited or unreachable degrades the report — loudly — and can never -// turn a red into a green. -// A local `../objectui` checkout is used only to ENRICH the report further (the -// complete log-walk via `classifyRange`, the shared #4731/#4843 criterion). -// It never decides the verdict either, and its absence is stated out loud -// rather than skipped in silence. -// -// (Behind an HTTPS proxy, node's `fetch` ignores HTTPS_PROXY unless node runs -// with NODE_USE_ENV_PROXY=1. GitHub Actions needs no such thing.) -// -// NETWORK FAILURE IS NEVER GREEN -// ------------------------------ -// An unreachable remote yields verdict `unreadable`, which exits 1 in enforcing -// mode and prints a `::warning::` in advisory mode. A freshness gate that goes -// green because the network hiccupped is worth exactly as much as no gate — it -// would have passed the v16 cut too. -import { execFileSync, spawnSync } from 'node:child_process'; -import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; -import { tmpdir } from 'node:os'; -import { dirname, join, resolve } from 'node:path'; -import { fileURLToPath } from 'node:url'; -import { classifyRange } from './objectui-changeset-digest.mjs'; - -const REPO_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..'); -const PIN_FILE = join(REPO_ROOT, '.objectui-sha'); -const USER_AGENT = 'objectstack-check-objectui-pin-fresh'; - -const short = (sha) => (sha ? String(sha).slice(0, 12) : ''); - -/** The remote could not be read. Never a green verdict — see the header. */ -export class RemoteUnreadable extends Error {} - -// --------------------------------------------------------------------------- -// Reading objectui's current state -// --------------------------------------------------------------------------- - -function apiHeaders() { - const token = process.env.GITHUB_TOKEN || process.env.GH_TOKEN || ''; - const headers = { - Accept: 'application/vnd.github+json', - 'User-Agent': USER_AGENT, - 'X-GitHub-Api-Version': '2022-11-28', - }; - if (token) headers.Authorization = `Bearer ${token}`; - return headers; -} - -async function apiJson(url, { allow404 = false } = {}) { - let res; - try { - res = await fetch(url, { headers: apiHeaders() }); - } catch (err) { - throw new RemoteUnreadable(`GET ${url} — ${err.message}`); - } - if (res.status === 404 && allow404) return null; - if (!res.ok) { - const exhausted = res.headers.get('x-ratelimit-remaining') === '0'; - throw new RemoteUnreadable( - `GET ${url} → HTTP ${res.status}` + - (exhausted ? ' (GitHub API rate limit exhausted — set GITHUB_TOKEN)' : ''), - ); - } - try { - return await res.json(); - } catch (err) { - throw new RemoteUnreadable(`GET ${url} → response was not JSON (${err.message})`); - } -} - -/** Resolve a branch or tag in the objectui remote to a commit SHA. */ -export function resolveRemoteRef(repoUrl, ref) { - let out; - try { - out = execFileSync('git', ['ls-remote', repoUrl, ref, `refs/heads/${ref}`, `refs/tags/${ref}`], { - encoding: 'utf8', - timeout: 120_000, - stdio: ['ignore', 'pipe', 'pipe'], - }); - } catch (err) { - const detail = String(err.stderr || err.message || '').trim().split('\n').pop(); - throw new RemoteUnreadable(`git ls-remote ${repoUrl} ${ref} — ${detail || 'failed'}`); - } - const refs = out - .split('\n') - .filter(Boolean) - .map((line) => { - const [sha, name] = line.split('\t'); - return { sha, name }; - }); - if (!refs.length) throw new RemoteUnreadable(`ref '${ref}' does not exist in ${repoUrl}`); - // Annotated tags: the peeled `^{}` entry is the commit the tag points AT. - const pick = - refs.find((r) => r.name === `refs/tags/${ref}^{}`) || - refs.find((r) => r.name === `refs/heads/${ref}`) || - refs.find((r) => r.name === `refs/tags/${ref}`) || - refs[0]; - return pick.sha; -} - -/** `.changeset/*.md` filenames in a listing, minus the non-changeset residents. */ -function changesetNames(listing) { - if (!Array.isArray(listing)) return new Set(); - return new Set( - listing - .filter((e) => e.type === 'file' && e.name.endsWith('.md') && e.name !== 'README.md') - .map((e) => e.name), - ); -} - -/** - * objectui's current state relative to the pin. - * - * THE VERDICT NEVER DEPENDS ON THE API. `git ls-remote` alone decides it: - * head === pin is fresh, head !== pin is stale. The three API calls only - * ITEMIZE a lag that is already established — how far, which commits, which - * changesets. So an API that is rate-limited, proxied away or down degrades the - * report and never the judgement, and it can never flip a red to green. When - * itemization fails, `itemizationError` carries the reason and the renderer - * prints it — a degradation that does not announce itself is the failure mode - * this whole gate exists to prevent. - * - * @returns {Promise<{ source: string, headSha: string, status: string, aheadBy: number, behindBy: number, commits: Array<{sha:string,subject:string}>, pendingChangesets: string[], itemized: boolean, itemizationError: string|null }>} - */ -export async function readRemoteState({ repo, repoUrl, ref, pin }) { - const headSha = resolveRemoteRef(repoUrl, ref); - - // Happy path: the pin IS the judging ref. Nothing can be behind it, so the - // verdict is settled without touching the API at all. - if (headSha === pin) { - return { - source: 'remote', - headSha, - status: 'identical', - aheadBy: 0, - behindBy: 0, - commits: [], - pendingChangesets: [], - itemized: true, - itemizationError: null, - }; - } - - const api = `https://api.github.com/repos/${repo}`; - try { - const cmp = await apiJson(`${api}/compare/${pin}...${headSha}`); - const [headListing, pinListing] = await Promise.all([ - apiJson(`${api}/contents/.changeset?ref=${headSha}`, { allow404: true }), - apiJson(`${api}/contents/.changeset?ref=${pin}`, { allow404: true }), - ]); - - const atHead = changesetNames(headListing); - const atPin = changesetNames(pinListing); - - return { - source: 'remote', - headSha, - status: cmp.status || 'unknown', - aheadBy: cmp.ahead_by ?? 0, - behindBy: cmp.behind_by ?? 0, - // compare returns oldest-first and caps at 250; `aheadBy` stays authoritative. - commits: (cmp.commits || []) - .map((c) => ({ sha: c.sha, subject: String(c.commit?.message || '').split('\n')[0] })) - .reverse(), - // Endpoint diff — a LOWER BOUND, see `localEnrichment`. - pendingChangesets: [...atHead].filter((n) => !atPin.has(n)).sort(), - itemized: true, - itemizationError: null, - }; - } catch (err) { - if (!(err instanceof RemoteUnreadable)) throw err; - return { - source: 'remote', - headSha, - status: 'not-itemized', - aheadBy: 0, - behindBy: 0, - commits: [], - pendingChangesets: [], - itemized: false, - itemizationError: err.message, - }; - } -} - -// --------------------------------------------------------------------------- -// The verdict -// --------------------------------------------------------------------------- - -/** - * Judge a pin against the remote state. The two red conditions of #3340 P0: - * 1. the judging ref is AHEAD of the pin (frontend commits are unbundled); - * 2. changesets exist at the judging ref that do not exist at the pin - * (frontend changes DECLARED after the pin — named, one per line). - * - * (2) is normally implied by (1) and exists to itemize it, but it is judged - * independently on purpose: the day the two disagree is the day one of them is - * wrong, and a gate that only ever consulted (1) would not notice. - */ -export function evaluate({ pin, ref, remote }) { - const reasons = []; - - if (remote.headSha !== pin) { - if (remote.status === 'ahead') { - reasons.push( - `objectui \`${ref}\` is ${remote.aheadBy} commit(s) AHEAD of the pin — ` + - `everything in that range is outside the @objectstack/console changeset range.`, - ); - } else if (remote.status === 'diverged') { - reasons.push( - `the pin and objectui \`${ref}\` have DIVERGED (${remote.aheadBy} ahead, ` + - `${remote.behindBy} behind) — the pin is not on \`${ref}\` at all.`, - ); - } else if (remote.status === 'behind') { - reasons.push( - `the pin is ${remote.behindBy} commit(s) ahead of objectui \`${ref}\` — it points ` + - `at something \`${ref}\` has not reached (an unmerged branch, or a rewritten history).`, - ); - } else if (remote.status === 'not-itemized') { - reasons.push( - `objectui \`${ref}\` (objectui@${short(remote.headSha)}) is not the pinned commit — ` + - `frontend changes exist that no @objectstack/console changeset covers. ` + - `How many, and which, could not be itemized (see the warning below).`, - ); - } else { - reasons.push( - `the pin does not equal objectui \`${ref}\` (compare status: ${remote.status}).`, - ); - } - } - - if (remote.pendingChangesets.length) { - reasons.push( - `${remote.pendingChangesets.length} changeset(s) declared in objectui after the pin are ` + - `not bundled into @objectstack/console at this pin.`, - ); - } - - return { - verdict: reasons.length ? 'stale' : 'fresh', - pin, - ref, - reasons, - ...remote, - }; -} - -/** - * OPTIONAL enrichment from a local checkout — never the verdict. - * - * The remote pending list is an ENDPOINT DIFF, so it cannot see a changeset - * that was added and then consumed by an objectui release inside the range. A - * local checkout can walk the log, which is what `classifyRange` (the shared - * #4731/#4843 criterion) does. When there is no usable checkout we say so — - * an unstated omission here is the exact failure mode this gate exists for. - */ -export function localEnrichment({ pin, headSha, objectuiRoot }) { - const root = objectuiRoot || process.env.OBJECTUI_ROOT || join(REPO_ROOT, '..', 'objectui'); - if (!existsSync(join(root, '.git'))) { - return { - available: false, - reason: - `no objectui checkout at ${root} — the pending list above is the endpoint-diff ` + - `LOWER BOUND. Set OBJECTUI_ROOT (or clone objectui as a sibling) for the complete log-walk.`, - }; - } - for (const sha of [pin, headSha]) { - try { - execFileSync('git', ['-C', root, 'cat-file', '-e', `${sha}^{commit}`], { stdio: 'ignore' }); - } catch { - return { - available: false, - reason: - `objectui checkout at ${root} does not contain ${short(sha)} — the pending list above ` + - `is the endpoint-diff LOWER BOUND. Refresh it: git -C ${root} fetch --all`, - }; - } - } - try { - const classified = classifyRange({ objectuiRoot: root, from: pin, to: headSha }); - return { available: true, root, ...classified }; - } catch (err) { - return { available: false, reason: `cannot walk the range in ${root} — ${err.message}` }; - } -} - -// --------------------------------------------------------------------------- -// Rendering -// --------------------------------------------------------------------------- - -const COUSIN_NOTE = - 'This is NOT ci.yml\'s "Console Pin Gate" (#4290). That gate proves the pinned SHA still\n' + - ' BUILDS; this one proves the pin is still CURRENT. Either can be green while the other is red.'; - -export function renderVerdict(result, { repo, enrichment, maxCommits = 10 } = {}) { - const out = []; - const { verdict, pin, ref, headSha } = result; - - if (verdict === 'fresh') { - out.push( - `✓ objectui pin is FRESH — .objectui-sha is objectui \`${ref}\` (objectui@${short(pin)}).`, - '', - ` Nothing objectui has merged is outside the @objectstack/console changeset range.`, - ` ${COUSIN_NOTE}`, - ); - return out.join('\n'); - } - - if (verdict === 'unreadable') { - out.push( - `✗ objectui pin freshness is UNKNOWN — could not read ${repo}.`, - '', - ` pinned (.objectui-sha): objectui@${short(pin)}`, - ` judging ref: ${repo} ${ref}`, - ` failure: ${result.error}`, - '', - ` Treated as a FAILURE, never as a pass: a freshness gate that goes green on a network`, - ` error would have passed the v16 cut this gate exists to prevent (#3340).`, - '', - ` If GitHub is reachable but the API is rate-limited, export GITHUB_TOKEN and re-run.`, - ); - return out.join('\n'); - } - - const label = (text) => ` ${text.padEnd(Math.max(26, `${repo} ${ref}`.length + 2))}`; - out.push( - `✗ objectui pin is STALE — .objectui-sha no longer describes objectui \`${ref}\`.`, - '', - `${label('pinned (.objectui-sha):')}objectui@${short(pin)}`, - `${label(`${repo} ${ref}:`)}objectui@${short(headSha)}`, - `${label('relationship:')}${result.status} (${result.aheadBy} ahead, ${result.behindBy} behind)`, - '', - ); - for (const reason of result.reasons) out.push(` • ${reason}`); - - if (result.itemized === false) { - out.push( - '', - ` ⚠ Itemization unavailable — the GitHub API could not be read:`, - ` ${result.itemizationError}`, - ` The VERDICT does not depend on it: \`git ls-remote\` already proved the pin is not`, - ` ${ref}. Only the "how far / which changesets" detail is missing.`, - ` (Behind an HTTPS proxy, node's fetch ignores HTTPS_PROXY unless you run it with`, - ` NODE_USE_ENV_PROXY=1.)`, - ); - } - - if (result.pendingChangesets.length) { - out.push( - '', - ` Frontend changes DECLARED after the pin — ${result.pendingChangesets.length} changeset(s)`, - ` present at ${ref} and absent at the pin:`, - ...result.pendingChangesets.map((n) => ` - ${n}`), - ); - } else if (result.headSha !== pin && result.itemized !== false) { - out.push( - '', - ` No changeset file is present at ${ref} and absent at the pin. That is NOT proof the`, - ` range ships nothing — see the lower-bound note below.`, - ); - } - - if (result.commits.length) { - const shown = result.commits.slice(0, maxCommits); - out.push( - '', - ` Commits ahead of the pin (newest first, ${shown.length} of ${result.aheadBy}):`, - ...shown.map((c) => ` - ${c.sha.slice(0, 9)} ${c.subject}`), - ); - } - - if (enrichment?.available) { - const levels = { major: 0, minor: 0, patch: 0 }; - for (const r of enrichment.releasing) levels[r.level] = (levels[r.level] || 0) + 1; - out.push( - '', - ` Complete log-walk (local checkout at ${enrichment.root}, the shared #4731/#4843 criterion):`, - ` ${enrichment.releasing.length} releasing changeset(s) — ` + - `${levels.major} major / ${levels.minor} minor / ${levels.patch} patch — ` + - `of ${enrichment.changesetsAdded} added across ${enrichment.totalCommits} non-merge commit(s);`, - ` ${enrichment.releaseNothing} release-nothing, ${enrichment.noChangeset} commit(s) with no changeset.`, - ...enrichment.releasing - .slice(0, maxCommits) - .map((r) => ` - [${r.level}] ${r.summary} (objectui \`${r.sha.slice(0, 9)}\`)`), - ); - } else if (enrichment) { - out.push('', ` ⚠ ${enrichment.reason}`); - } - - out.push( - '', - ` Why this blocks the release PR: the @objectstack/console changeset only ever covers`, - ` OLD_PIN..NEW_PIN. Everything above is outside that range, so it reaches no changeset, no`, - ` CHANGELOG and no release page — and a complete-looking release record is indistinguishable`, - ` from a complete one (#3340, the v16 cut).`, - '', - ` Fix — move the pin, then let the Version Packages PR rebuild:`, - ` scripts/bump-objectui.sh # bump to objectui ${ref}, writes the changeset`, - ` node scripts/objectui-range.mjs # the Console section for the release page`, - '', - ` ${COUSIN_NOTE}`, - ); - return out.join('\n'); -} - -// --------------------------------------------------------------------------- -// CLI -// --------------------------------------------------------------------------- - -function readPin() { - if (!existsSync(PIN_FILE)) { - throw new Error(`.objectui-sha is missing at ${PIN_FILE} — cannot determine the pinned commit.`); - } - const pin = readFileSync(PIN_FILE, 'utf8').trim(); - if (!/^[0-9a-f]{40}$/i.test(pin)) { - throw new Error(`.objectui-sha does not contain a full 40-char SHA (got '${pin}').`); - } - return pin; -} - -async function main(argv) { - const has = (f) => argv.includes(f); - const val = (f, d) => { - const i = argv.indexOf(f); - return i >= 0 && argv[i + 1] !== undefined ? argv[i + 1] : d; - }; - - if (has('-h') || has('--help')) { - console.log( - readFileSync(fileURLToPath(import.meta.url), 'utf8') - .split('\n') - .filter((l) => l.startsWith('//')) - .map((l) => l.slice(3)) - .join('\n'), - ); - return 0; - } - if (has('--self-test')) return selfTest(); - - const advisory = has('--advisory'); - const asJson = has('--json'); - const repo = val('--repo', process.env.OBJECTUI_REPO || 'objectstack-ai/objectui'); - const repoUrl = process.env.OBJECTUI_REPO_URL || `https://github.com/${repo}.git`; - const ref = val('--ref', 'main'); - // Self-test only: read the remote state from a JSON file instead of the - // network, so the real CLI (exit codes included) can be exercised. Every - // fixture run SAYS SO in its output — it can never pass for a real verdict. - const fixture = val('--fixture', ''); - - const pin = readPin(); - - let result; - let enrichment; - try { - const remote = fixture - ? JSON.parse(readFileSync(fixture, 'utf8')) - : await readRemoteState({ repo, repoUrl, ref, pin }); - if (remote.__throw) throw new RemoteUnreadable(remote.__throw); - result = evaluate({ pin, ref, remote }); - if (result.verdict === 'stale' && result.headSha && result.headSha !== pin && !fixture) { - enrichment = localEnrichment({ pin, headSha: result.headSha }); - } - } catch (err) { - if (!(err instanceof RemoteUnreadable)) throw err; - result = { - verdict: 'unreadable', - pin, - ref, - error: err.message, - reasons: [err.message], - headSha: null, - status: 'unreadable', - aheadBy: 0, - behindBy: 0, - commits: [], - pendingChangesets: [], - itemized: false, - itemizationError: err.message, - }; - } - - if (fixture) { - console.error(`⚠ REMOTE STATE READ FROM FIXTURE ${fixture} — self-test output, not a real verdict.`); - } - - if (asJson) { - console.log(JSON.stringify({ ...result, repo, advisory, enrichment: enrichment ?? null }, null, 2)); - } else { - console.log(renderVerdict(result, { repo, enrichment })); - } - - if (result.verdict === 'fresh') return 0; - - if (advisory) { - // Loud, and still not green-looking: the body above already printed the - // full STALE/UNKNOWN report. Only the exit code is relaxed, because a - // lagging pin between bumps is the normal state of an ordinary code PR. - console.error( - `\n::${result.verdict === 'unreadable' ? 'warning' : 'notice'}::objectui pin ` + - `${result.verdict === 'unreadable' ? 'freshness is UNKNOWN' : 'is STALE'} — advisory here ` + - `(this gate only blocks the Version Packages / release PR, where a lagging pin drops ` + - `frontend changes from the release record). See #3340.`, - ); - return 0; - } - console.error( - `\n::error::objectui pin ${result.verdict === 'unreadable' ? 'freshness could not be verified' : 'is stale'} ` + - `— refresh .objectui-sha before releasing (#3340).`, - ); - return 1; -} - -// --------------------------------------------------------------------------- -// Self-test — the repo idiom for a scripts/ gate: drive the real code (and the -// real CLI, exit codes included) over fabricated remote states. -// --------------------------------------------------------------------------- - -function selfTest() { - const failures = []; - const check = (name, cond, detail = '') => { - if (cond) { - console.log(` ✓ ${name}`); - } else { - failures.push(`${name}${detail ? ` — ${detail}` : ''}`); - console.log(` ✗ ${name}${detail ? ` — ${detail}` : ''}`); - } - }; - - console.log('check-objectui-pin-fresh --self-test'); - - const PIN = 'a'.repeat(40); - const HEAD = 'b'.repeat(40); - const repo = 'objectstack-ai/objectui'; - - const state = (over = {}) => ({ - source: 'remote', - headSha: HEAD, - status: 'ahead', - aheadBy: 4, - behindBy: 0, - commits: [ - { sha: 'c'.repeat(40), subject: 'feat(import): auto policy for the import wizard (#2701)' }, - { sha: 'd'.repeat(40), subject: 'feat(schema): key-value field editor (#2708)' }, - ], - pendingChangesets: [ - 'import-wizard-auto-policy.md', - 'schema-fields-keyvalue.md', - 'action-param-upload-guard.md', - ].sort(), - itemized: true, - itemizationError: null, - ...over, - }); - - // --- 1. a lagging pin is RED, and names what it left behind --------------- - const stale = evaluate({ pin: PIN, ref: 'main', remote: state() }); - const staleText = renderVerdict(stale, { repo }); - check('a pin behind objectui main is STALE', stale.verdict === 'stale', stale.verdict); - check( - 'the report states the lag in commits', - staleText.includes('4 commit(s) AHEAD of the pin'), - staleText, - ); - check( - 'every pending changeset is listed BY NAME, not just counted', - ['import-wizard-auto-policy.md', 'schema-fields-keyvalue.md', 'action-param-upload-guard.md'].every( - (n) => staleText.includes(`- ${n}`), - ), - staleText, - ); - check( - 'the report names the remedy (bump-objectui.sh)', - staleText.includes('scripts/bump-objectui.sh'), - staleText, - ); - check( - 'the report distinguishes itself from the #4290 Console Pin Gate', - staleText.includes('#4290') && staleText.includes('BUILDS') && staleText.includes('CURRENT'), - staleText, - ); - - // --- 2. a current pin is GREEN ------------------------------------------- - const fresh = evaluate({ - pin: PIN, - ref: 'main', - remote: state({ headSha: PIN, status: 'identical', aheadBy: 0, commits: [], pendingChangesets: [] }), - }); - check('a pin equal to objectui main is FRESH', fresh.verdict === 'fresh', fresh.verdict); - check( - 'the FRESH report still explains what it did NOT prove (#4290)', - renderVerdict(fresh, { repo }).includes('#4290'), - ); - - // --- 3. pending changesets are judged on their own ----------------------- - // Contrived (a current pin cannot have unbundled changesets), and that is the - // point: condition 2 must be a real condition, not decoration on condition 1. - const pendingOnly = evaluate({ - pin: PIN, - ref: 'main', - remote: state({ - headSha: PIN, - status: 'identical', - aheadBy: 0, - commits: [], - pendingChangesets: ['landed-not-bundled.md'], - }), - }); - check( - 'pending unbundled changesets alone make the verdict STALE', - pendingOnly.verdict === 'stale', - pendingOnly.verdict, - ); - check( - 'that verdict names the changeset', - renderVerdict(pendingOnly, { repo }).includes('- landed-not-bundled.md'), - ); - - // --- 4. diverged / behind are red too ------------------------------------ - check( - 'a diverged pin is STALE', - evaluate({ pin: PIN, ref: 'main', remote: state({ status: 'diverged', behindBy: 2 }) }).verdict === - 'stale', - ); - check( - 'a pin ahead of the judging ref is STALE (loudly, as its own case)', - renderVerdict( - evaluate({ - pin: PIN, - ref: 'main', - remote: state({ status: 'behind', aheadBy: 0, behindBy: 3, pendingChangesets: [] }), - }), - { repo }, - ).includes('has not reached'), - ); - - // --- 5. the API being unreadable degrades the REPORT, never the verdict -- - // `git ls-remote` succeeded, so the lag is already proved; only the "how far - // / which changesets" detail is missing. This must stay RED — an itemizer - // outage that turned a lagging pin green is the #3340 failure with extra - // steps. - const notItemized = evaluate({ - pin: PIN, - ref: 'main', - remote: state({ - status: 'not-itemized', - aheadBy: 0, - behindBy: 0, - commits: [], - pendingChangesets: [], - itemized: false, - itemizationError: 'GET https://api.github.com/… → HTTP 403 (rate limit)', - }), - }); - const notItemizedText = renderVerdict(notItemized, { repo }); - check( - 'an unreadable GitHub API still yields STALE when ls-remote proved the lag', - notItemized.verdict === 'stale', - notItemized.verdict, - ); - check( - 'and the degraded itemization is announced with its reason', - notItemizedText.includes('Itemization unavailable') && notItemizedText.includes('HTTP 403'), - notItemizedText, - ); - check( - 'while stating the verdict did not depend on it', - notItemizedText.includes('The VERDICT does not depend on it'), - notItemizedText, - ); - - // --- 6. the enrichment absence is STATED, never silent ------------------- - const missing = localEnrichment({ - pin: PIN, - headSha: HEAD, - objectuiRoot: join(tmpdir(), 'definitely-not-an-objectui-checkout'), - }); - check('a missing local checkout is reported, not skipped', missing.available === false); - check( - 'and it says the remote pending list is a LOWER BOUND', - /LOWER BOUND/.test(missing.reason), - missing.reason, - ); - - // --- 7. end-to-end through the real CLI, exit codes included ------------- - const tmp = mkdtempSync(join(tmpdir(), 'pin-fresh-selftest-')); - try { - const cli = fileURLToPath(import.meta.url); - const fixtureFile = (name, obj) => { - const p = join(tmp, `${name}.json`); - mkdirSync(dirname(p), { recursive: true }); - writeFileSync(p, JSON.stringify(obj)); - return p; - }; - const run = (args) => { - const r = spawnSync('node', [cli, ...args], { encoding: 'utf8' }); - return { code: r.status ?? 1, stdout: r.stdout || '', out: `${r.stdout || ''}${r.stderr || ''}` }; - }; - - // The CLI reads the REAL .objectui-sha; the fixture supplies only the - // remote side, so `headSha: ` is what "fresh" looks like. - const realPin = readPin(); - - const staleRun = run(['--fixture', fixtureFile('stale', state())]); - check('CLI exits 1 on a stale pin', staleRun.code === 1, `code ${staleRun.code}`); - check( - 'CLI output says STALE and names a pending changeset', - staleRun.out.includes('STALE') && staleRun.out.includes('import-wizard-auto-policy.md'), - staleRun.out, - ); - check( - 'a fixture run announces itself as a fixture run', - staleRun.out.includes('FIXTURE'), - staleRun.out, - ); - - const freshRun = run([ - '--fixture', - fixtureFile('fresh', { - headSha: realPin, - status: 'identical', - aheadBy: 0, - behindBy: 0, - commits: [], - pendingChangesets: [], - }), - ]); - check('CLI exits 0 on a current pin', freshRun.code === 0, `code ${freshRun.code}`); - check('CLI output says FRESH', freshRun.out.includes('FRESH'), freshRun.out); - - // --- the one that matters most: a network failure must never be green --- - const netFixture = fixtureFile('net', { __throw: 'getaddrinfo ENOTFOUND github.com' }); - const netRun = run(['--fixture', netFixture]); - check('CLI exits 1 when the remote cannot be read', netRun.code === 1, `code ${netRun.code}`); - check( - 'a network failure never renders as FRESH', - netRun.out.includes('UNKNOWN') && !netRun.out.includes('is FRESH'), - netRun.out, - ); - check( - 'the network failure reason is printed verbatim', - netRun.out.includes('ENOTFOUND'), - netRun.out, - ); - - const netAdvisory = run(['--advisory', '--fixture', netFixture]); - check( - 'advisory mode does not block on an unreadable remote…', - netAdvisory.code === 0, - `code ${netAdvisory.code}`, - ); - check( - '…but WARNS, and still never claims freshness', - netAdvisory.out.includes('::warning::') && !netAdvisory.out.includes('is FRESH'), - netAdvisory.out, - ); - - const staleAdvisory = run(['--advisory', '--fixture', fixtureFile('stale2', state())]); - check( - 'advisory mode does not block on a stale pin (ordinary code PRs)', - staleAdvisory.code === 0, - `code ${staleAdvisory.code}`, - ); - check( - '…while the body still reports STALE in full', - staleAdvisory.out.includes('STALE') && staleAdvisory.out.includes('::notice::'), - staleAdvisory.out, - ); - - const jsonRun = run(['--json', '--fixture', fixtureFile('stale3', state())]); - const parsed = JSON.parse(jsonRun.stdout); - check( - 'JSON output carries the verdict, the lag and the pending list', - parsed.verdict === 'stale' && parsed.aheadBy === 4 && parsed.pendingChangesets.length === 3, - jsonRun.out, - ); - } finally { - rmSync(tmp, { recursive: true, force: true }); - } - - if (failures.length) { - console.error(`\n⛔ check-objectui-pin-fresh --self-test: ${failures.length} failure(s)`); - for (const f of failures) console.error(` - ${f}`); - return 1; - } - console.log('✓ check-objectui-pin-fresh --self-test: all checks passed'); - return 0; -} - -if (resolve(process.argv[1] ?? '') === resolve(fileURLToPath(import.meta.url))) { - main(process.argv.slice(2)).then( - (code) => process.exit(code), - (err) => { - console.error(`✗ check:objectui-pin-fresh — ${err.message}`); - process.exit(1); - }, - ); -} diff --git a/scripts/check-prerelease-pin-watch.mjs b/scripts/check-prerelease-pin-watch.mjs index c314890857..20a3c82e64 100644 --- a/scripts/check-prerelease-pin-watch.mjs +++ b/scripts/check-prerelease-pin-watch.mjs @@ -81,9 +81,10 @@ // 1 AVAILABLE a stable release exists. Loud; names the follow-up card. // 2 UNKNOWN at least one registry read failed; no hit among the rest. // -// Exit 2 is deliberately NOT exit 1. Unlike `check:objectui-pin-fresh` — a -// required PR gate, where an unreadable remote must block because the alternative -// is shipping the thing the gate exists to catch — this is an unattended nightly, +// Exit 2 is deliberately NOT exit 1. Unlike a blocking release gate that reads a +// remote, where an unreadable remote must block because the alternative is +// shipping the thing the gate exists to catch (`check:objectui-pin-fresh` was +// this repo's example until #10134 deleted it) — this is an unattended nightly, // and its failure mode is asymmetric: a transient npm 5xx that turns the nightly // red teaches everyone to skim it, and a nightly nobody reads is precisely the // "no producer" state #5024 is about. Missing one night costs at most a day, diff --git a/scripts/check-required-contexts.mjs b/scripts/check-required-contexts.mjs index dce4b60373..c5cb5158bc 100644 --- a/scripts/check-required-contexts.mjs +++ b/scripts/check-required-contexts.mjs @@ -52,8 +52,9 @@ * an advisory gate wearing a required gate's name; * 6. its workflow carries a `merge_group:` trigger. Without it the queue * build never produces the context and the whole queue stalls waiting for - * it (#5617's audit lists `Console Pin Freshness` as exactly this shape: - * a file whose own comment invites required-ization it cannot survive); + * it (#5617's audit named `Console Pin Freshness` as exactly this shape at + * the time of the audit; that workflow has since been deleted outright — + * see the ⛔ exclusion note below, which outlives it); * 7. its workflow's `pull_request:` trigger exists and carries no `paths:` / * `paths-ignore:`, and, if it names `types:` at all, that list is a * superset of GitHub's default `[opened, synchronize, reopened]`. A @@ -194,8 +195,17 @@ import { fileURLToPath } from 'node:url'; * * ⛔ Names the #5617 audit ruled must STAY OUT of the required set, recorded so * a later reader does not enroll them here by symmetry: `Console Pin Freshness` - * (no `merge_group` trigger — required-izing it deadlocks the queue, and that - * file's own comment invites it), `Spec property liveness` (PR-side `paths:`, + * (the audit's stated reason was "no `merge_group` trigger — required-izing it + * deadlocks the queue, and that file's own comment invites it". ⚠️ Two things + * happened to that reason and neither reopens the question: #6121 added the + * trigger, which #6991 recorded as making the stated reason stale; then #10134 + * DELETED the workflow, the script and the `package.json` entry outright, so no + * check-run of this name reports at all — the 2026-08-20 ruling is that which + * objectui revision we pin is a decision taken in an objectstack issue, never + * derived from objectui `main`, so there is no currency question for a gate to + * ask. This entry STAYS for a reason the deletion CREATES: it is now the only + * thing standing between a future author and rebuilding a same-named gate and + * enrolling it here), `Spec property liveness` (PR-side `paths:`, * so PRs touching no spec/docs sit pending forever), `Validate Package * Dependencies` (both faults), `Check PR Size` / `Auto Label` / `Check * Changeset` (the `labeled` event republishes the same context as `skipped`, @@ -253,18 +263,23 @@ import { fileURLToPath } from 'node:url'; * these `name:` literals, and still publish these check-runs on every PR that * trips their filter. They lost REQUIRED status, which is a different fact. * Ledgering them anyway was measured before it was rejected (2026-08-18): - * `docs/releases-maintenance.md` names `Console Pin Gate` 2× in correct, - * current prose that exists to keep it apart from `Console Pin Freshness`, so + * `docs/releases-maintenance.md` names `Console Pin Gate` in correct, current + * prose describing what still checks the pin, so * the row reds the scan there and prints the diagnostic "a seat following this * text looks for a check-run that no longer reports" — false, about prose that * is right. Budgeting around it would only arm the trap for the next author * who legitimately names the live job. * * ⚠️ The cost of the drop, stated rather than discovered later: these two - * `name:` literals are now pinned by NOTHING, while five places still refer to - * the jobs by name (`docs/releases-maintenance.md`, `packages/console/README.md`, - * `scripts/check-objectui-pin-fresh.mjs`, `.github/workflows/objectui-pin-freshness.yml` - * and lint.yml's cross-reference). Renaming either job no longer detaches a + * `name:` literals are now pinned by NOTHING, while prose elsewhere still refers + * to the jobs by name — `docs/releases-maintenance.md`, + * `packages/console/README.md` and lint.yml's cross-reference among them. This + * list read FIVE until #10134 deleted `scripts/check-objectui-pin-fresh.mjs` and + * `.github/workflows/objectui-pin-freshness.yml`, both of which named + * `Console Pin Gate` only to tell it apart from `Console Pin Freshness`; the + * recorded debt shrank by two and did not close. ⚠️ It was never asserted by + * anything either way, so read it as examples and never as a census — the same + * trap the `carries` note below is about. Renaming either job no longer detaches a * required gate — that is the whole point — but it does silently falsify that * prose. Filed as its own card rather than solved here, since a pin for * "contract job names that are not required contexts" is a new mechanism and @@ -992,8 +1007,9 @@ export async function scanInstructionSurfaces( * Settings entry. A required check that reddened on registry-vs-settings * disagreement would therefore be red on precisely the PR carrying the repo * half, and could not go green before merging: it would deadlock the sitting it - * claims to protect, the same way required-izing `Console Pin Freshness` - * deadlocks the queue. Report-only is not timidity here, it is the only shape + * claims to protect, the same way required-izing a context with no `merge_group` + * trigger — the `Console Pin Freshness` shape — deadlocks the queue. Report-only + * is not timidity here, it is the only shape * that does not self-block. * * The posture is `check-governed-merges.mjs`'s, verbatim in behaviour: a diff --git a/scripts/check-skill-frame-freshness.mjs b/scripts/check-skill-frame-freshness.mjs index 10e0ef8448..a5204033b6 100644 --- a/scripts/check-skill-frame-freshness.mjs +++ b/scripts/check-skill-frame-freshness.mjs @@ -75,11 +75,12 @@ // branch would be exercised by nobody and would rot; this way the offline path is // the online path. // -// Note the contrast with check-objectui-pin-fresh.mjs, whose header says network -// failure is NEVER green. Both are right, because the audiences differ: that gate -// runs in CI on a release PR where the network is part of the contract; this one -// runs on a long-lived human/agent working tree where being offline is an -// ordinary Tuesday. Same word "freshness", different blast radius. +// Note the contrast with a CI release gate that reads a remote, for which network +// failure is NEVER green (check-objectui-pin-fresh.mjs was the local example until +// #10134 deleted it, and the rule outlives the file). Both are right, because the +// audiences differ: such a gate runs in CI where the network is part of the +// contract; this one runs on a long-lived human/agent working tree where being +// offline is an ordinary Tuesday. Same word "freshness", different blast radius. // // WHERE IT RUNS // ------------- diff --git a/scripts/check-skill-frame-sync.mjs b/scripts/check-skill-frame-sync.mjs index c042f627e3..b64c0ffbf7 100644 --- a/scripts/check-skill-frame-sync.mjs +++ b/scripts/check-skill-frame-sync.mjs @@ -854,7 +854,8 @@ function main() { // freshness gate's `import { COPIES, AXIS_MAP, analyzeCopy }` (#5866) would run // this whole gate — and its `process.exit(1)` — as an import side effect. Same // idiom as scripts/objectui-changeset-digest.mjs, which is imported the same way -// by scripts/check-objectui-pin-fresh.mjs. +// by scripts/objectui-range.mjs (and was by check-objectui-pin-fresh.mjs, until +// #10134 deleted it — the idiom is what carries over, not the importer). if (resolve(process.argv[1] ?? '') === resolve(fileURLToPath(import.meta.url))) { main(); }