From 28c8bb951f803f4197ea1314d5f2edb1afb961f0 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 5 Aug 2026 14:31:22 +0000 Subject: [PATCH] chore(scripts): watch npm for the stable release that retires a prerelease pin (#5024) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `pnpm-workspace.yaml` pins the better-auth family to a 1.7.0 release candidate and promises, in a comment, to "revert to a stable `^1.7.x` line the moment one ships". #3002 (revert the family) and #3653 (the SCIM migration) are both gated on that event, and nothing watched for it — the promise had no producer, so redeeming it depended on someone remembering to check npm. Add `scripts/check-prerelease-pin-watch.mjs`, run nightly by `.github/workflows/prerelease-pin-watch.yml` at 06:00 UTC. The watch list is DERIVED from the pins (every override whose target is a prerelease version), so it cannot drift from the file it polices, a new prerelease pin is watched the day it lands, and the probe retires itself when the last one goes stable. The trigger is semver — a version with no prerelease segment at or above the pinned base — never the `latest` dist-tag: today better-auth's `latest` is 1.6.26 while the 1.7 line publishes rcs, so a tag-watching probe would sleep through the event it exists to catch. A stable release only in a LATER line is reported as its own case, so upstream stabilizing 1.8 and abandoning 1.7 cannot leave the probe silent. Three exit codes, because "npm was unreachable" and "no stable release yet" are different facts: 0 waiting (one quiet line), 1 a stable release exists (loud, names #3002/#3653 and their action lists), 2 a registry read failed (warning plus step summary, non-blocking — an unattended nightly that goes red on a transient 5xx is a nightly everyone learns to skim, and the event it watches for is permanent, so a missed night costs a day). `--strict` promotes 2 to 1. `--self-test` is offline and covers all three states, the empty watch list, and the CLI exit codes over fabricated registry responses; it runs on PRs that touch the probe, the workflow, or the pins. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01GX3sL71LFq8m2usg6VqTSE --- .changeset/prerelease-pin-watch-probe.md | 18 + .github/workflows/prerelease-pin-watch.yml | 128 +++ package.json | 1 + scripts/check-prerelease-pin-watch.mjs | 890 +++++++++++++++++++++ 4 files changed, 1037 insertions(+) create mode 100644 .changeset/prerelease-pin-watch-probe.md create mode 100644 .github/workflows/prerelease-pin-watch.yml create mode 100644 scripts/check-prerelease-pin-watch.mjs diff --git a/.changeset/prerelease-pin-watch-probe.md b/.changeset/prerelease-pin-watch-probe.md new file mode 100644 index 0000000000..9d47df2797 --- /dev/null +++ b/.changeset/prerelease-pin-watch-probe.md @@ -0,0 +1,18 @@ +--- +--- + +chore(scripts): watch npm for the stable release that retires a prerelease pin (#5024) + +`pnpm-workspace.yaml` pins the better-auth family to `1.7.0-rc.2` (scim to +`1.7.0-rc.1`) and promises, in a comment, to "revert to a stable `^1.7.x` line +the moment one ships". #3002 and #3653 are both gated on that event and nothing +watched for it — the promise had no producer. + +`scripts/check-prerelease-pin-watch.mjs` is that producer, run nightly by +`.github/workflows/prerelease-pin-watch.yml`. Its watch list is derived from the +pins themselves (every override whose target is a prerelease), so it cannot drift +from the file it polices and it retires itself when the last prerelease pin goes +stable. The trigger is semver — a version with no prerelease segment at or above +the pinned base — never the `latest` dist-tag, which today still sits on 1.6.26 +while the 1.7 line publishes release candidates. Tooling only; no runtime or +authorable surface changes. diff --git a/.github/workflows/prerelease-pin-watch.yml b/.github/workflows/prerelease-pin-watch.yml new file mode 100644 index 0000000000..ff4a932536 --- /dev/null +++ b/.github/workflows/prerelease-pin-watch.yml @@ -0,0 +1,128 @@ +name: Prerelease Pin Watch + +# Has upstream shipped a STABLE release that retires one of our prerelease +# `overrides` pins? (#5024) +# +# WHAT IT WATCHES, AND WHY IT IS A WORKFLOW AT ALL +# ----------------------------------------------- +# `pnpm-workspace.yaml` pins the better-auth family to `1.7.0-rc.2` (scim one rc +# behind at `1.7.0-rc.1`) and promises, in a comment: +# +# revert to a stable `^1.7.x` line the moment one ships. +# +# #3002 (revert the family) and #3653 (the SCIM migration) are both gated on that +# event, and until this workflow existed NOTHING watched for it — redeeming the +# promise depended on a person remembering to check npm. That is the repo's +# `declared != enforced` shape applied to a comment, and this workflow is the +# missing producer. It never edits a pin; it only makes the trigger arrive as a +# signal within a day instead of as a memory. +# +# The watch list is DERIVED from the pins (every override whose target is a +# prerelease version), so it cannot drift from the file it polices, and it empties +# itself when the last prerelease pin goes stable. See the script header. +# +# THREE EXIT CODES, ON PURPOSE +# ---------------------------- +# 0 no stable release yet → quiet, one line +# 1 a stable release EXISTS → job RED, report names #3002 / #3653 +# 2 a registry read failed → `::warning::` + step summary, job GREEN +# +# Exit 2 is the considered trade-off, not an oversight. This is an unattended +# nightly: a transient npm 5xx that turns it red teaches everyone to skim it, and +# a nightly nobody reads is exactly the "nobody is watching" state #5024 is about. +# Missing one night costs at most a day — the next run re-probes from scratch and +# a published version is never unpublished back into silence. So red keeps meaning +# exactly one thing here (a stable release is out), and an inconclusive probe is +# loud-but-non-blocking instead of being silently reported as "no release yet", +# 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.) +# +# WHY NOT lint.yml / validate-deps.yml +# ------------------------------------ +# Not `lint.yml`: the probe needs the network, and no required PR gate in this +# repo may depend on a third-party registry being up. Not `validate-deps.yml` +# either, close as it is in subject matter — it is scheduled WEEKLY (Mon 03:00 +# UTC), and the acceptance criterion here is a signal within the first working +# day of the release. A weekly slot can sit on the event for six days. +# +# On a PR that touches the probe, the pins, or this file, only the offline +# `--self-test` runs ("a change to the guard runs the guard"). No `pnpm install` +# anywhere: the script is dependency-free by design, which is what keeps this +# whole workflow a ~15-second job. + +on: + schedule: + # 06:00 UTC nightly — after Rerun Safety (04:00) and Spec Coverage (05:00), + # before Showcase Smoke (07:00). Nightly, not weekly: see the note above. + - cron: '0 6 * * *' + workflow_dispatch: + pull_request: + paths: + - 'scripts/check-prerelease-pin-watch.mjs' + - '.github/workflows/prerelease-pin-watch.yml' + # The pins ARE the watch list, so a PR that edits them re-runs the + # self-test that proves the real file still parses into one. + - 'pnpm-workspace.yaml' + +permissions: + contents: read + +concurrency: + group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + +jobs: + watch: + name: Stable release watch for prerelease pins + runs-on: ubuntu-latest + timeout-minutes: 10 + 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." Offline and hermetic — fabricated + # registry responses cover all three states plus the empty watch list — so + # it is safe on a PR where the probe itself would not be. + - name: Self-test the probe + run: node scripts/check-prerelease-pin-watch.mjs --self-test + + - name: Probe is nightly-only on PRs + if: github.event_name == 'pull_request' + run: | + echo "::notice::Self-test only on a PR — the probe itself reads registry.npmjs.org, and no PR gate here may depend on a third-party registry. It runs nightly at 06:00 UTC (#5024)." + + - name: Probe npm for a stable release + if: github.event_name != 'pull_request' + run: | + set -o pipefail + status=0 + node scripts/check-prerelease-pin-watch.mjs --verbose 2>&1 \ + | tee "$RUNNER_TEMP/prerelease-pin-watch.log" || status=$? + + { + echo '### Prerelease pin watch (#5024)' + echo + echo '```' + cat "$RUNNER_TEMP/prerelease-pin-watch.log" + echo '```' + } >> "$GITHUB_STEP_SUMMARY" + + if [ "$status" -eq 0 ]; then + exit 0 + elif [ "$status" -eq 2 ]; then + # Loud but non-blocking — see the header for why this is not red. + # The script has already printed its own ::warning:: with the reason. + echo "::warning::Prerelease pin watch was INCONCLUSIVE (a registry read failed). This is NOT 'no stable release yet'. It re-probes tomorrow; if it stays inconclusive, run this workflow with --strict wired in and investigate (#5024)." + exit 0 + fi + + echo "::error::A STABLE release now exists for a pin this repo holds at a prerelease. The trigger condition of #3002 / #3653 has arrived — see the step summary for the per-package verdicts and the action list (#5024)." + exit "$status" diff --git a/package.json b/package.json index 5fd689e34d..bc49a819f4 100644 --- a/package.json +++ b/package.json @@ -50,6 +50,7 @@ "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:prerelease-pins": "node scripts/check-prerelease-pin-watch.mjs --self-test && node scripts/check-prerelease-pin-watch.mjs", "check:release-notes": "node scripts/check-release-notes.mjs", "check:release-body": "node scripts/release-github-releases.mjs --self-test", "check:node-version": "node scripts/check-node-version.mjs", diff --git a/scripts/check-prerelease-pin-watch.mjs b/scripts/check-prerelease-pin-watch.mjs new file mode 100644 index 0000000000..7eed77c9ec --- /dev/null +++ b/scripts/check-prerelease-pin-watch.mjs @@ -0,0 +1,890 @@ +#!/usr/bin/env node +// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. +// +// check:prerelease-pins — has upstream shipped a STABLE release that retires one +// of our prerelease `overrides` pins? (#5024) +// +// node scripts/check-prerelease-pin-watch.mjs # the probe (needs network) +// node scripts/check-prerelease-pin-watch.mjs --verbose # one line per watched pin +// node scripts/check-prerelease-pin-watch.mjs --json +// node scripts/check-prerelease-pin-watch.mjs --strict # an unreadable registry exits 1 +// node scripts/check-prerelease-pin-watch.mjs --self-test # offline; proves all three states +// +// WHY THIS EXISTS (#5024) +// ---------------------- +// `pnpm-workspace.yaml` pins the whole better-auth family to `1.7.0-rc.2` +// (scim deliberately one rc behind, at `1.7.0-rc.1`), and the comment above +// those pins promises: +// +// revert to a stable `^1.7.x` line the moment one ships. +// +// Two issues are gated on exactly that event — #3002 (revert the family to a +// stable `^1.7.x`) and #3653 (the SCIM migration, which #3653 explicitly defers +// until `@better-auth/scim` ships a non-rc release). Neither had a PRODUCER: +// nothing anywhere watched npm, so redeeming the promise depended on somebody +// remembering to look. That is the repo's standard `declared != enforced` shape, +// applied to a promise in a comment — and every extra day on a prerelease is +// another day the auth surface runs on a version carrying no stability +// commitment. +// +// This is the producer. It is a WATCHER, not a fixer: it never edits a pin. Its +// entire job is to make the trigger condition of #3002 / #3653 arrive as an +// automatic signal within a day of the release, instead of as a memory. +// +// THE WATCH LIST IS DERIVED, NOT DECLARED +// --------------------------------------- +// The packages watched are not a hardcoded list — they are read out of +// `pnpm-workspace.yaml` `overrides`: every pin whose TARGET is a prerelease +// version. So the probe polices the very declaration it is derived from, and it +// cannot drift from it: +// +// * a new prerelease pin is watched the day it lands, with no edit here; +// * a pin reverted to a stable range leaves the watch list by itself; +// * when the last prerelease pin is gone the probe reports "nothing to watch" +// and exits 0 — it retires itself rather than needing to be deleted. +// +// A hardcoded list of three package names would have gone stale against the +// eleven pins that are actually in the file, in whichever direction the file +// moved first. +// +// THE CRITERION IS SEMVER, NOT THE `latest` TAG +// --------------------------------------------- +// A version triggers when it has NO prerelease segment and is at or above the +// pinned target's base version — NOT when npm's `latest` tag moves. The two are +// genuinely different facts today: as of 2026-08-05 better-auth's `latest` is +// `1.6.26` (the old line) while `rc` is `1.7.0-rc.4`, so upstream can publish a +// stable `1.7.0` and leave `latest` on 1.6 for days. A `latest`-watching probe +// would sleep straight through the event it exists to catch. `dist-tags` are +// printed in the report as context and never consulted for the verdict. +// +// Two shapes of hit are reported apart, because the remedy differs: +// +// in-line a stable release inside the pinned line (`1.7.x`) — the exact +// trigger #3002 / #3653 wrote down. Mechanical: move the pin. +// later a stable release only in a HIGHER line (`1.8.0`+) while the +// pinned line never stabilized. Also gets us off the prerelease, +// but crossing a minor is a decision, not a bump. Reported as its +// own case so it cannot hide: without it, upstream stabilizing +// 1.8 and abandoning 1.7 would leave this probe silent forever — +// #5024's defect one line over. +// +// NETWORK FAILURE IS NOT A GREEN — AND NOT A RED EITHER (the trade-off) +// --------------------------------------------------------------------- +// Three verdicts, three exit codes, because "npm was unreachable" and "no +// stable release yet" are different facts and a probe that blurs them is the +// thing it was built to replace: +// +// 0 WAITING every watched pin was read; no stable release yet. Quiet. +// 1 AVAILABLE a stable release exists. Loud; names #3002 / #3653. +// 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, +// 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, +// because the next run re-probes from scratch and the event it watches for is +// permanent — a published version is never unpublished back into silence. +// So the workflow surfaces exit 2 as a `::warning::` plus a step-summary line +// (visible, non-blocking) and keeps red meaning exactly one thing: a stable +// release is out. `--strict` promotes UNKNOWN to exit 1 for whoever needs the +// harder line (e.g. if the registry becomes persistently unreadable and the +// warnings are being missed) — the knob exists so escalating is a flag, not a +// rewrite. +// +// (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.) +// +// WHERE IT RUNS +// ------------- +// `.github/workflows/prerelease-pin-watch.yml`, nightly at 06:00 UTC, plus +// `--self-test` on any PR touching the probe, the workflow, or the pins +// ("a change to the guard runs the guard"). It is NOT wired into `lint.yml`: +// the probe needs the network, and no PR gate in this repo may depend on a +// third-party registry being up. +import { readFileSync, writeFileSync, mkdtempSync, rmSync } from 'node:fs'; +import { spawnSync } from 'node:child_process'; +import { tmpdir } from 'node:os'; +import { dirname, join, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const REPO_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..'); +const DEFAULT_WORKSPACE = join(REPO_ROOT, 'pnpm-workspace.yaml'); +const DEFAULT_REGISTRY = 'https://registry.npmjs.org'; +const USER_AGENT = 'objectstack-check-prerelease-pin-watch'; + +/** A registry read failed. Never a green verdict, never a red one — see the header. */ +export class RegistryUnreadable extends Error {} + +// --------------------------------------------------------------------------- +// The follow-up ledger — what a hit must TELL you to do. +// +// A probe that only says "a stable release exists" hands its reader a research +// task at the exact moment they have the least context (a nightly, out of +// band). Each watched package therefore declares the issues carrying its +// action list, so the signal arrives pointing at the work. +// +// Unmapped is legal and is itself reported: a prerelease pin with no recorded +// revert plan is the #5024 shape again, and saying so is cheaper than +// discovering it later. +// --------------------------------------------------------------------------- + +/** @type {Array<{ match: RegExp, issues: string[], note: string }>} */ +const FOLLOW_UPS = [ + { + // Held one prerelease BEHIND the rest of the family on purpose, and its + // stable release triggers a migration rather than a bump — rc.2 replaced the + // whole SCIM model set, so #3653 defers the work to the stable release. + match: /^@better-auth\/scim$/, + issues: ['#3002', '#3653'], + note: + 'SCIM is a MIGRATION, not a bump (#3653): rc.2 replaced the model set and moved ' + + 'connections from runtime rows to boot config. Do the migration against the STABLE ' + + 'models — do not "align" this pin with the family first.', + }, + { + match: /^(better-auth|@better-auth\/.+)$/, + issues: ['#3002'], + note: + 'The family moves together (mixing a 1.7 plugin with 1.6 core throws during init and ' + + "500s every auth endpoint). #3002 carries the action list; plugin-auth's own exact " + + 'declarations must move in the same PR — `check:override-consistency` holds them to it.', + }, +]; + +/** @returns {{ issues: string[], note: string } | null} */ +export function followUpFor(pkg) { + return FOLLOW_UPS.find((f) => f.match.test(pkg)) ?? null; +} + +// --------------------------------------------------------------------------- +// Reading the pins +// --------------------------------------------------------------------------- + +/** + * Minimal `overrides:` reader — the same block-scanning approach as + * `check-override-consistency.mjs` (this file only ever uses `key: value` + * scalars, so a YAML dependency is avoided and the probe stays runnable with + * no `pnpm install`). + * + * @returns {Array<{ name: string, selector: string | null, target: string }>} + */ +export function readOverrides(text) { + const overrides = []; + let inOverrides = false; + for (const raw of text.split(/\r?\n/)) { + const line = raw.replace(/\s+$/, ''); + if (!line.trim() || /^\s*#/.test(line)) continue; + if (/^overrides\s*:\s*$/.test(line)) { + inOverrides = true; + continue; + } + if (!inOverrides) continue; + if (/^\S/.test(line)) { + inOverrides = false; + continue; + } + const m = /^\s+["']?([^"':]+?)["']?\s*:\s*["']?([^"'#]+?)["']?\s*(#.*)?$/.exec(line); + if (!m) continue; + const key = m[1].trim(); + const target = m[2].trim(); + // Split `pkg@selector` on the LAST `@` so scoped names survive. + const at = key.lastIndexOf('@'); + if (at > 0) overrides.push({ name: key.slice(0, at), selector: key.slice(at + 1), target }); + else overrides.push({ name: key, selector: null, target }); + } + return overrides; +} + +// --------------------------------------------------------------------------- +// Semver, the two questions this probe actually asks +// --------------------------------------------------------------------------- + +const VERSION_RE = /^(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z.-]+))?(?:\+[0-9A-Za-z.-]+)?$/; + +/** + * @returns {{ major: number, minor: number, patch: number, prerelease: string | null } | null} + * `null` when the string is not a single exact version (a RANGE like `^8.5.10` + * or `>=0.28.1` is not a prerelease pin and must not be watched). + */ +export function parseVersion(v) { + const m = VERSION_RE.exec(String(v).trim()); + if (!m) return null; + return { + major: Number(m[1]), + minor: Number(m[2]), + patch: Number(m[3]), + prerelease: m[4] ?? null, + }; +} + +/** Numeric release-triple comparison. Prerelease segments are NOT ordered here: the + * probe only ever asks "is this stable, and is it at or above the base", and the + * base is always a `.0`-patch release triple. */ +function compareRelease(a, b) { + return a.major - b.major || a.minor - b.minor || a.patch - b.patch; +} + +/** + * The pins worth watching: every override whose TARGET is an exact prerelease + * version. Everything else — stable exact pins, ranges, selectors — is not a + * promise waiting to be redeemed. + * + * @returns {Array<{ name: string, target: string, base: string, line: string, followUp: object|null }>} + */ +export function buildWatchList(overrides) { + const seen = new Map(); + for (const o of overrides) { + const v = parseVersion(o.target); + if (!v || !v.prerelease) continue; + // The base release the prerelease is a candidate FOR: `1.7.0-rc.2` -> `1.7.0`. + const base = `${v.major}.${v.minor}.${v.patch}`; + const entry = { + name: o.name, + target: o.target, + base, + line: `${v.major}.${v.minor}`, + followUp: followUpFor(o.name), + }; + // The same package can be pinned by more than one selector; the LOWEST base + // is the one whose promise is outstanding first. + const prev = seen.get(o.name); + if (!prev || compareRelease(parseVersion(base), parseVersion(prev.base)) < 0) { + seen.set(o.name, entry); + } + } + return [...seen.values()].sort((a, b) => a.name.localeCompare(b.name)); +} + +// --------------------------------------------------------------------------- +// Reading the registry +// --------------------------------------------------------------------------- + +/** + * The abbreviated packument (`application/vnd.npm.install-v1+json`) — orders of + * magnitude smaller than the full document and it still carries every published + * version key plus `dist-tags`. + */ +export async function readPackument(name, { registry = DEFAULT_REGISTRY } = {}) { + const url = `${registry.replace(/\/$/, '')}/${name.replace('/', '%2F')}`; + let res; + try { + res = await fetch(url, { + headers: { + Accept: 'application/vnd.npm.install-v1+json', + 'User-Agent': USER_AGENT, + }, + }); + } catch (err) { + throw new RegistryUnreadable(`GET ${url} — ${err.message}`); + } + if (!res.ok) throw new RegistryUnreadable(`GET ${url} → HTTP ${res.status}`); + let body; + try { + body = await res.json(); + } catch (err) { + throw new RegistryUnreadable(`GET ${url} → response was not JSON (${err.message})`); + } + if (!body || typeof body !== 'object' || typeof body.versions !== 'object') { + throw new RegistryUnreadable(`GET ${url} → no 'versions' map in the response`); + } + return { versions: Object.keys(body.versions), distTags: body['dist-tags'] ?? {} }; +} + +// --------------------------------------------------------------------------- +// The verdict +// --------------------------------------------------------------------------- + +/** + * Judge one watched pin against what the registry published. + * + * @param {{ name: string, base: string, line: string }} entry + * @param {{ versions: string[], distTags: object } | { error: string }} read + */ +export function judge(entry, read) { + if (read && read.error) { + return { ...entry, verdict: 'unknown', error: read.error, stableInLine: [], stableLater: [] }; + } + const base = parseVersion(entry.base); + const stableInLine = []; + const stableLater = []; + for (const raw of read.versions) { + const v = parseVersion(raw); + if (!v || v.prerelease) continue; // prerelease -> not the trigger, by definition + if (compareRelease(v, base) < 0) continue; // below the pin: reverting there undoes the fix + if (v.major === base.major && v.minor === base.minor) stableInLine.push(raw); + else stableLater.push(raw); + } + const byRelease = (a, b) => compareRelease(parseVersion(a), parseVersion(b)); + stableInLine.sort(byRelease); + stableLater.sort(byRelease); + return { + ...entry, + verdict: stableInLine.length || stableLater.length ? 'available' : 'waiting', + trigger: stableInLine.length ? 'in-line' : stableLater.length ? 'later' : null, + stableInLine, + stableLater, + distTags: read.distTags ?? {}, + error: null, + }; +} + +/** + * @param {Array} judged + * @returns {{ verdict: 'available'|'unknown'|'waiting'|'nothing-to-watch', packages: object[], issues: string[] }} + */ +export function evaluate(judged) { + const hits = judged.filter((j) => j.verdict === 'available'); + const unknown = judged.filter((j) => j.verdict === 'unknown'); + const issues = [ + ...new Set(hits.flatMap((h) => h.followUp?.issues ?? [])), + ].sort(); + let verdict; + if (!judged.length) verdict = 'nothing-to-watch'; + else if (hits.length) verdict = 'available'; + else if (unknown.length) verdict = 'unknown'; + else verdict = 'waiting'; + return { verdict, packages: judged, hits, unknown, issues }; +} + +// --------------------------------------------------------------------------- +// Rendering +// --------------------------------------------------------------------------- + +export function render(result, { verbose = false } = {}) { + const out = []; + const { verdict, packages, hits, unknown } = result; + + if (verdict === 'nothing-to-watch') { + out.push( + '✓ No prerelease pin left in pnpm-workspace.yaml `overrides` — nothing to watch.', + '', + ' This probe derives its watch list from the pins themselves (#5024), so an empty', + ' list is the SUCCESS state, not a misconfiguration: every prerelease promise in the', + ' file has been redeemed. When the last pin went stable this probe retired itself.', + ); + return out.join('\n'); + } + + if (verdict === 'available') { + out.push( + `⛔ A STABLE release is available for ${hits.length} prerelease pin(s) — the trigger ` + + `condition of ${result.issues.join(' / ') || 'the follow-up work'} has ARRIVED.`, + '', + ); + for (const h of hits) { + out.push(` ${h.name}`); + out.push(` pinned: ${h.target} (prerelease; watched line ${h.line}.x)`); + if (h.stableInLine.length) { + out.push( + ` STABLE in the pinned line: ${h.stableInLine.join(', ')}` + + ` → revert the pin to \`^${h.line}.x\`.`, + ); + } + if (h.stableLater.length) { + out.push( + ` STABLE only in a LATER line: ${h.stableLater.join(', ')}`, + h.stableInLine.length + ? ' (also available above the pinned line.)' + : ` → the pinned ${h.line} line never stabilized. Getting off the prerelease means ` + + `crossing a minor, which is a DECISION, not a bump — read the action list first.`, + ); + } + if (h.followUp) { + out.push(` action list: ${h.followUp.issues.join(', ')}`); + out.push(` ${h.followUp.note}`); + } else { + out.push( + ' ⚠ NO follow-up issue is declared for this pin. Whoever added it recorded no', + ' revert plan, which is the #5024 defect shape again — file the follow-up issue', + ' and add it to FOLLOW_UPS in this script.', + ); + } + // Context only. The verdict above was decided by semver, never by a tag. + const tags = Object.entries(h.distTags ?? {}) + .filter(([t]) => ['latest', 'rc', 'beta', 'next'].includes(t)) + .map(([t, v]) => `${t}=${v}`); + if (tags.length) out.push(` dist-tags (context only, NOT the criterion): ${tags.join(' ')}`); + out.push(''); + } + out.push( + ' Why this is loud: the pins in `pnpm-workspace.yaml` carry a promise in their comment —', + ' "revert to a stable `^1.7.x` line the moment one ships" — and until this probe existed', + ' nothing watched for that moment: redeeming it depended on somebody remembering to check', + ' npm (#5024). Every extra day on a prerelease is another day the auth surface runs on a', + ' version with no stability commitment behind it.', + '', + ' This probe does NOT move any pin. Do the work on the issues above; the red goes away', + ' when the pins do.', + ); + if (unknown.length) { + out.push( + '', + ` ⚠ ${unknown.length} further pin(s) could not be read, so this list may be INCOMPLETE:`, + ...unknown.map((u) => ` ${u.name} — ${u.error}`), + ); + } + return out.join('\n'); + } + + if (verdict === 'unknown') { + out.push( + `⚠ Prerelease pin watch is INCONCLUSIVE — ${unknown.length} of ${packages.length} ` + + `registry read(s) failed.`, + '', + ...unknown.map((u) => ` ${u.name} — ${u.error}`), + '', + ' This is NOT "no stable release yet" and it is NOT reported as one. It is also not a', + ' red: the next run re-probes from scratch and a published version is never unpublished', + ' back into silence, so a missed night costs at most a day — while a nightly that goes', + ' red on a transient registry error is a nightly everyone learns to skim, which is the', + ' "nobody is watching" state this probe exists to end (#5024).', + '', + ' Persistently inconclusive? Run with `--strict` (exit 1) to make it block.', + ' Behind an HTTPS proxy, node ignores HTTPS_PROXY unless run with NODE_USE_ENV_PROXY=1.', + ); + const readable = packages.filter((p) => p.verdict === 'waiting'); + if (readable.length) { + out.push( + '', + ` The ${readable.length} pin(s) that WERE readable have no stable release yet:`, + ...readable.map((p) => ` ${p.name} — pinned ${p.target}, no stable ${p.line}.x`), + ); + } + return out.join('\n'); + } + + // WAITING — the quiet path. One line, by design: a nightly that prints a + // report every night trains its reader to skip the night it matters. + out.push( + `✓ No stable release yet for ${packages.length} prerelease pin(s) ` + + `(${[...new Set(packages.map((p) => `${p.line}.x`))].join(', ')}) — nothing to do.`, + ); + if (verbose) { + for (const p of packages) { + const tags = p.distTags?.latest ? ` [latest=${p.distTags.latest}]` : ''; + out.push(` ${p.name.padEnd(32)} pinned ${p.target} → no stable ${p.line}.x${tags}`); + } + } + return out.join('\n'); +} + +// --------------------------------------------------------------------------- +// CLI +// --------------------------------------------------------------------------- + +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 strict = has('--strict'); + const asJson = has('--json'); + const verbose = has('--verbose'); + const registry = val('--registry', DEFAULT_REGISTRY); + const workspace = val('--workspace', DEFAULT_WORKSPACE); + // Self-test only: registry responses come from a JSON file instead of the + // network, so the real CLI (exit codes included) is what gets exercised. + // Every fixture run SAYS SO — it can never be mistaken for a real verdict. + const fixture = val('--fixture', ''); + + const watch = buildWatchList(readOverrides(readFileSync(workspace, 'utf8'))); + + const reads = fixture ? JSON.parse(readFileSync(fixture, 'utf8')) : null; + const judged = []; + for (const entry of watch) { + if (reads) { + const r = reads[entry.name]; + if (!r) judged.push(judge(entry, { error: `no fixture entry for ${entry.name}` })); + else if (r.__throw) judged.push(judge(entry, { error: r.__throw })); + else judged.push(judge(entry, { versions: r.versions ?? [], distTags: r.distTags ?? {} })); + continue; + } + try { + judged.push(judge(entry, await readPackument(entry.name, { registry }))); + } catch (err) { + if (!(err instanceof RegistryUnreadable)) throw err; + judged.push(judge(entry, { error: err.message })); + } + } + + const result = evaluate(judged); + + if (fixture) { + console.error( + `⚠ REGISTRY READ FROM FIXTURE ${fixture} — self-test output, not a real verdict.`, + ); + } + + if (asJson) console.log(JSON.stringify({ ...result, registry, strict }, null, 2)); + else console.log(render(result, { verbose })); + + if (result.verdict === 'available') { + console.error( + `\n::error::A stable release retires a prerelease pin — act on ` + + `${result.issues.join(' / ') || 'the follow-up issues'} (#5024 probe).`, + ); + return 1; + } + if (result.verdict === 'unknown') { + console.error( + `\n::warning::Prerelease pin watch could not read ${result.unknown.length} package(s) — ` + + `INCONCLUSIVE, not "no release yet". Re-runs tomorrow; --strict to make it block (#5024).`, + ); + return strict ? 1 : 2; + } + return 0; +} + +// --------------------------------------------------------------------------- +// Self-test — the repo idiom for a scripts/ gate: drive the real code and the +// real CLI (exit codes included) over fabricated registry responses. Fully +// offline, so it is safe as a PR gate where the probe itself is not. +// --------------------------------------------------------------------------- + +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-prerelease-pin-watch --self-test'); + + // --- 1. the watch list is derived from the pins --------------------------- + const YAML = [ + 'packages:', + ' - packages/*', + 'overrides:', + " esbuild: '>=0.28.1'", + " 'minimatch@<10.2.3': '10.2.3'", + " # a comment line that must not parse", + " 'better-auth@<1.7.0-rc.2': '1.7.0-rc.2'", + " '@better-auth/core@<1.7.0-rc.2': '1.7.0-rc.2'", + " '@better-auth/scim@<1.7.0-rc.1': '1.7.0-rc.1'", + " 'postcss@<8.5.10': '^8.5.10'", + " svelte: '^5.55.7'", + 'onlyBuiltDependencies:', + ' - esbuild', + ].join('\n'); + + const watch = buildWatchList(readOverrides(YAML)); + check( + 'only the prerelease pins are watched (stable exacts and ranges are not)', + watch.map((w) => w.name).join(',') === '@better-auth/core,@better-auth/scim,better-auth', + watch.map((w) => w.name).join(','), + ); + check( + 'an exact STABLE pin is not watched (minimatch 10.2.3)', + !watch.some((w) => w.name === 'minimatch'), + ); + check( + 'a RANGE target is not watched (^8.5.10, >=0.28.1)', + !watch.some((w) => w.name === 'postcss' || w.name === 'esbuild'), + ); + check( + 'the watched line comes from the pin, not from a constant', + watch.find((w) => w.name === 'better-auth')?.line === '1.7' && + watch.find((w) => w.name === 'better-auth')?.base === '1.7.0', + ); + check( + 'scim carries BOTH follow-up issues (#3002 and #3653)', + ['#3002', '#3653'].every((i) => + watch.find((w) => w.name === '@better-auth/scim').followUp.issues.includes(i), + ), + ); + check( + 'the rest of the family carries #3002', + watch.find((w) => w.name === 'better-auth').followUp.issues.join() === '#3002', + ); + check( + 'a prerelease pin with no declared follow-up is watched anyway (unmapped is legal)', + buildWatchList(readOverrides("overrides:\n 'nobody@<2.0.0-rc.1': '2.0.0-rc.1'\n")).length === 1, + ); + + // --- 2. the three registry states ---------------------------------------- + const entry = watch.find((w) => w.name === 'better-auth'); + + const onlyPre = judge(entry, { + versions: ['1.6.26', '1.7.0-beta.10', '1.7.0-rc.2', '1.7.0-rc.4'], + distTags: { latest: '1.6.26', rc: '1.7.0-rc.4' }, + }); + check('only prereleases in the line → WAITING', onlyPre.verdict === 'waiting', onlyPre.verdict); + check( + 'a newer prerelease (rc.4 vs the pinned rc.2) does NOT trigger', + onlyPre.stableInLine.length === 0, + ); + + const stable = judge(entry, { + versions: ['1.6.26', '1.7.0-rc.4', '1.7.0', '1.7.1'], + distTags: { latest: '1.6.26', rc: '1.7.0-rc.4' }, + }); + check('a stable release in the line → AVAILABLE', stable.verdict === 'available', stable.verdict); + check('and it is classified in-line', stable.trigger === 'in-line', String(stable.trigger)); + check( + 'every stable version in the line is listed, in order', + stable.stableInLine.join(',') === '1.7.0,1.7.1', + stable.stableInLine.join(','), + ); + + const unreadable = judge(entry, { error: 'getaddrinfo ENOTFOUND registry.npmjs.org' }); + check('a failed read → UNKNOWN', unreadable.verdict === 'unknown', unreadable.verdict); + check( + 'UNKNOWN never reports stable versions it did not see', + unreadable.stableInLine.length === 0 && unreadable.stableLater.length === 0, + ); + + // --- 3. the criterion is semver, NOT the `latest` tag -------------------- + // The live shape on 2026-08-05: `latest` sits on the OLD line while the new + // line is published. A `latest`-watching probe sleeps through this. + const latestStillOld = judge(entry, { + versions: ['1.6.26', '1.7.0'], + distTags: { latest: '1.6.26', rc: '1.7.0-rc.4' }, + }); + check( + 'a stable 1.7.0 triggers even while dist-tag `latest` is still 1.6.26', + latestStillOld.verdict === 'available' && latestStillOld.trigger === 'in-line', + JSON.stringify({ v: latestStillOld.verdict, t: latestStillOld.trigger }), + ); + check( + 'a stable release BELOW the pinned base never triggers (reverting there undoes the fix)', + judge(entry, { versions: ['1.6.26', '1.5.9'], distTags: {} }).verdict === 'waiting', + ); + check( + 'build metadata does not make a version a prerelease', + parseVersion('1.7.0+build.5')?.prerelease === null, + ); + + // --- 4. a stable release only in a LATER line is its own case ----------- + const later = judge(entry, { versions: ['1.7.0-rc.4', '1.8.0', '2.0.0'], distTags: {} }); + check('stable only above the pinned line → AVAILABLE', later.verdict === 'available'); + check('…classified `later`, not `in-line`', later.trigger === 'later', String(later.trigger)); + const laterText = render(evaluate([later])); + check( + 'and the report says crossing a minor is a DECISION', + laterText.includes('DECISION') && laterText.includes('never stabilized'), + laterText, + ); + + // --- 5. the reports say what they must ---------------------------------- + const hitText = render(evaluate([judge(entry, { versions: ['1.7.0'], distTags: {} })])); + check('the AVAILABLE report names #3002', hitText.includes('#3002'), hitText); + check( + 'the AVAILABLE report names the remedy (revert to ^1.7.x)', + hitText.includes('^1.7.x'), + hitText, + ); + // Printed only when the packument carried tags, so it is asserted on the + // `latest`-still-old case — the report that shows `latest=1.6.26` next to a + // stable 1.7.0 is exactly the one that must disclaim the tag. + const latestStillOldText = render(evaluate([latestStillOld])); + check( + 'the AVAILABLE report prints dist-tags as context and states they are NOT the criterion', + latestStillOldText.includes('latest=1.6.26') && + latestStillOldText.includes('NOT the criterion'), + latestStillOldText, + ); + const scimHit = render( + evaluate([judge(watch.find((w) => w.name === '@better-auth/scim'), { versions: ['1.7.0'], distTags: {} })]), + ); + check('a scim hit names #3653 and calls it a migration', scimHit.includes('#3653') && scimHit.includes('MIGRATION'), scimHit); + const unmappedHit = render( + evaluate([ + judge(buildWatchList(readOverrides("overrides:\n 'nobody@<2.0.0-rc.1': '2.0.0-rc.1'\n"))[0], { + versions: ['2.0.0'], + distTags: {}, + }), + ]), + ); + check( + 'an unmapped pin hit says so instead of pointing nowhere', + unmappedHit.includes('NO follow-up issue is declared'), + unmappedHit, + ); + const waitingText = render(evaluate([onlyPre])); + check( + 'the WAITING report is ONE line (zero noise when nothing shipped)', + waitingText.trim().split('\n').length === 1, + waitingText, + ); + check( + 'the UNKNOWN report refuses to read as "no release yet"', + render(evaluate([unreadable])).includes('INCONCLUSIVE'), + ); + check( + 'an empty watch list is the SUCCESS state and says why', + render(evaluate([])).includes('nothing to watch') && + render(evaluate([])).includes('retired itself'), + ); + + // --- 6. a hit alongside an unreadable pin stays a hit, and says it may be + // incomplete (the itemization lesson from check:objectui-pin-fresh) -- + const mixed = evaluate([judge(entry, { versions: ['1.7.0'], distTags: {} }), unreadable]); + check('a hit is not diluted by a sibling read failure', mixed.verdict === 'available'); + check( + 'and the report admits the hit list may be incomplete', + render(mixed).includes('INCOMPLETE'), + render(mixed), + ); + + // --- 7. end-to-end through the real CLI, exit codes included ------------- + const tmp = mkdtempSync(join(tmpdir(), 'prerelease-pin-watch-selftest-')); + try { + const cli = fileURLToPath(import.meta.url); + const file = (name, obj) => { + const p = join(tmp, name); + writeFileSync(p, typeof obj === 'string' ? obj : JSON.stringify(obj)); + return p; + }; + const ws = file('pnpm-workspace.yaml', YAML); + const run = (args) => { + const r = spawnSync('node', [cli, ...args], { encoding: 'utf8' }); + return { code: r.status ?? 1, stdout: r.stdout || '', out: `${r.stdout || ''}${r.stderr || ''}` }; + }; + + const pre = { versions: ['1.6.26', '1.7.0-rc.2', '1.7.0-rc.4'], distTags: { latest: '1.6.26' } }; + const waitingRun = run([ + '--workspace', + ws, + '--fixture', + file('waiting.json', { + 'better-auth': pre, + '@better-auth/core': pre, + '@better-auth/scim': pre, + }), + ]); + check('CLI exits 0 while only prereleases exist', waitingRun.code === 0, `code ${waitingRun.code}`); + check( + 'the quiet run emits no ::error:: and no ::warning::', + !waitingRun.out.includes('::error::') && !waitingRun.out.includes('::warning::'), + waitingRun.out, + ); + check('a fixture run announces itself as a fixture run', waitingRun.out.includes('FIXTURE')); + + const hitRun = run([ + '--workspace', + ws, + '--fixture', + file('hit.json', { + 'better-auth': { versions: ['1.7.0'], distTags: { latest: '1.6.26' } }, + '@better-auth/core': pre, + '@better-auth/scim': pre, + }), + ]); + check('CLI exits 1 when a stable release exists', hitRun.code === 1, `code ${hitRun.code}`); + check( + 'the hit run emits ::error:: naming the follow-up issue', + hitRun.out.includes('::error::') && hitRun.out.includes('#3002'), + hitRun.out, + ); + + const netFixture = file('net.json', { + 'better-auth': { __throw: 'getaddrinfo ENOTFOUND registry.npmjs.org' }, + '@better-auth/core': { __throw: 'HTTP 503' }, + '@better-auth/scim': pre, + }); + const netRun = run(['--workspace', ws, '--fixture', netFixture]); + check('CLI exits 2 (not 0, not 1) when reads fail', netRun.code === 2, `code ${netRun.code}`); + check( + 'a read failure never renders as "no stable release yet"', + netRun.out.includes('INCONCLUSIVE') && !netRun.out.includes('nothing to do'), + netRun.out, + ); + check('the failure reason is printed verbatim', netRun.out.includes('ENOTFOUND'), netRun.out); + check('and it warns rather than passing silently', netRun.out.includes('::warning::')); + + const strictRun = run(['--workspace', ws, '--strict', '--fixture', netFixture]); + check('--strict promotes an unreadable registry to exit 1', strictRun.code === 1, `code ${strictRun.code}`); + + const emptyRun = run([ + '--workspace', + file('empty-workspace.yaml', "overrides:\n 'minimatch@<10.2.3': '10.2.3'\n"), + '--fixture', + file('empty.json', {}), + ]); + check('CLI exits 0 with no prerelease pins left', emptyRun.code === 0, `code ${emptyRun.code}`); + check( + '…and says that is the success state, not a misconfiguration', + emptyRun.out.includes('nothing to watch'), + emptyRun.out, + ); + + const jsonRun = run([ + '--workspace', + ws, + '--json', + '--fixture', + file('hit2.json', { + 'better-auth': { versions: ['1.7.0', '1.7.1'], distTags: {} }, + '@better-auth/core': pre, + '@better-auth/scim': { versions: ['1.7.0'], distTags: {} }, + }), + ]); + const parsed = JSON.parse(jsonRun.stdout); + check( + 'JSON carries the verdict, the per-package rows and the issue list', + parsed.verdict === 'available' && + parsed.packages.length === 3 && + parsed.issues.join(',') === '#3002,#3653', + jsonRun.stdout, + ); + check( + 'JSON reports each package separately (per-package conclusions)', + parsed.packages.filter((p) => p.verdict === 'available').length === 2 && + parsed.packages.filter((p) => p.verdict === 'waiting').length === 1, + jsonRun.stdout, + ); + + // The REAL pins must parse — a self-test that only ever reads its own + // fixture would pass while the file it polices had moved out from under it. + const realWatch = buildWatchList(readOverrides(readFileSync(DEFAULT_WORKSPACE, 'utf8'))); + check( + `the repo's own pnpm-workspace.yaml parses to a non-empty watch list (${realWatch.length} pin(s))`, + realWatch.length > 0, + 'no prerelease pin found — if that is genuinely true, this probe has retired itself', + ); + check( + 'every watched pin from the real file has a base version and a line', + realWatch.every((w) => /^\d+\.\d+\.\d+$/.test(w.base) && /^\d+\.\d+$/.test(w.line)), + JSON.stringify(realWatch.map((w) => [w.name, w.base])), + ); + } finally { + rmSync(tmp, { recursive: true, force: true }); + } + + if (failures.length) { + console.error(`\n⛔ check-prerelease-pin-watch --self-test: ${failures.length} failure(s)`); + for (const f of failures) console.error(` - ${f}`); + return 1; + } + console.log('✓ check-prerelease-pin-watch --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:prerelease-pins — ${err.message}`); + process.exit(1); + }, + ); +}