diff --git a/.changeset/objectui-pin-freshness-gate.md b/.changeset/objectui-pin-freshness-gate.md new file mode 100644 index 0000000000..16bcc43aab --- /dev/null +++ b/.changeset/objectui-pin-freshness-gate.md @@ -0,0 +1,4 @@ +--- +--- + +ci(release): add the objectui pin-freshness gate (#3340 P0). `scripts/check-objectui-pin-fresh.mjs` fails when `.objectui-sha` is not objectui `main` (or a named `--ref`), naming the commits ahead and the `.changeset/*.md` files declared after the pin — the blind spot that dropped four frontend changes, two of them `minor` features, from the v16 release page. Wired as `Console Pin Freshness` in `.github/workflows/objectui-pin-freshness.yml`: it runs on every PR so the context can be required in branch protection, but blocks only on the Version Packages / release PR. Distinct from ci.yml's `Console Pin Gate` (#4290), which proves the pin still *builds* rather than that it is still *current*. Tooling and CI only; releases nothing. diff --git a/.github/workflows/objectui-pin-freshness.yml b/.github/workflows/objectui-pin-freshness.yml new file mode 100644 index 0000000000..64436c3a21 --- /dev/null +++ b/.github/workflows/objectui-pin-freshness.yml @@ -0,0 +1,98 @@ +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 +# ----------------------------------------------- +# 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. + +on: + pull_request: + branches: [main] + 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. + if [ "$EVENT" != "pull_request" ] \ + || [ "$HEAD_REF" = "changeset-release/main" ] \ + || [ "$PR_TITLE" = "chore: version packages" ]; then + echo "::notice::Release lane — the objectui pin-freshness gate BLOCKS here (#3340)." + node scripts/check-objectui-pin-fresh.mjs + else + echo "::notice::Not the Version Packages PR — pin freshness is reported but does not block (a pin lagging between bumps is normal). It blocks on the release PR." + node scripts/check-objectui-pin-fresh.mjs --advisory + fi diff --git a/docs/releases-maintenance.md b/docs/releases-maintenance.md index 23a3eedc2c..a7eba80380 100644 --- a/docs/releases-maintenance.md +++ b/docs/releases-maintenance.md @@ -102,6 +102,41 @@ 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 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 re-source the Console + section with `scripts/objectui-range.mjs`. + ## Drift guard `scripts/check-release-notes.mjs` (run in CI as `pnpm check:release-notes`) fails the diff --git a/package.json b/package.json index 9cfa23fbb2..c150e8520a 100644 --- a/package.json +++ b/package.json @@ -49,6 +49,7 @@ "check:startup-registry-verdict": "node scripts/check-startup-registry-verdict.mjs --self-test && node scripts/check-startup-registry-verdict.mjs", "check:console-sha": "node scripts/check-console-sha.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:release-notes": "node scripts/check-release-notes.mjs", "check:node-version": "node scripts/check-node-version.mjs", "check:published-files": "node scripts/check-published-files.mjs --self-test && node scripts/check-published-files.mjs", diff --git a/scripts/check-objectui-pin-fresh.mjs b/scripts/check-objectui-pin-fresh.mjs new file mode 100644 index 0000000000..a33ce9e83d --- /dev/null +++ b/scripts/check-objectui-pin-fresh.mjs @@ -0,0 +1,856 @@ +#!/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); + }, + ); +}