diff --git a/.github/workflows/cut-rc.yml b/.github/workflows/cut-rc.yml deleted file mode 100644 index 4fef6bf9a31..00000000000 --- a/.github/workflows/cut-rc.yml +++ /dev/null @@ -1,877 +0,0 @@ -name: Cut RC - -# ══════════════════════════════════════════════════════════════════════════════ -# ONE DISPATCH, ONE SNAPSHOT, ZERO FREEZES. (#7447) -# ══════════════════════════════════════════════════════════════════════════════ -# -# Read `release.yml`'s header first — its 2026-08-07 ruling ("版本发布必须是人工的") -# and its lane split are binding context for this file, and this lane preserves -# both. Nothing here weakens them: -# -# - `workflow_dispatch` is the ONLY trigger. No push, bot, merge-queue landing -# or schedule can synthesise the event; a human types the exact version. -# - `environment: release` gates it exactly as the publish lane is gated. -# - The publish still only ever ships a commit that is ALREADY on main -# (#6170): this lane PUSHES the version commit to main first and publishes -# from it second. It never publishes from a ref that only exists in the run. -# -# WHAT THIS SOLVES THAT `release.yml` CANNOT -# ------------------------------------------ -# 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 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. -# -# THE CONSOLE IS PART OF THE SNAPSHOT (objectstack#25711) -# --------------------------------------------------------- -# There is no objectui pin to read. The Console source is `ui/apps/console` in -# this repository (@object-ui/console, ruling D1 「同一个版本号组」): the snapshot -# above freezes it together with the spec it builds against, `pnpm run -# console:build` builds it and copies its dist into `@objectstack/console` -# (packages/console/build-dist.sh), and the 39 published -# @object-ui/* packages are members of the one Changesets `fixed` group, so the -# version pass below numbers them with everything else. Nothing here reaches -# into another repository, and nothing moves a pin, because neither exists. -# -# SCOPE — rc PRERELEASES ONLY -# --------------------------- -# The guard step below refuses anything that is not `X.Y.Z-rc.N`, and refuses to -# run at all unless `.changeset/pre.json` is in `mode: pre` with `tag: rc`. GA cuts -# keep the `release.yml` + Version-PR flow, where board-clearing, the #7275-A cut -# precondition and human changelog review live. See `docs/releases-maintenance.md`. -# -# THE RUNTIME IMAGE IS DELIBERATELY NOT BUILT HERE -# ------------------------------------------------ -# This lane publishes to npm and pushes tags; it does not call `docker-publish.yml`. -# That is not an oversight and not a gap that stays open: `release.yml`'s -# `release-integrity` lane runs on EVERY push to main, and once this lane's version -# is on npm the next main push finds the image missing and requests the build — the -# #4900 repair path, doing exactly the job it was written for. On a repo with ~18 -# merges a working day that is minutes, not days. For an image immediately, dispatch -# `docker-publish.yml` with the version. Duplicating the job here would be a second -# copy of a repair path that already exists. - -on: - # The human lane, and the only one. Deliberately no `push:`, no `schedule:`. - workflow_dispatch: - inputs: - version: - description: >- - The EXPECTED resulting version, e.g. 17.0.0-rc.6 — what the version pass - 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: - description: >- - Stop after building the version commit locally and upload it as a patch - artifact. Nothing is pushed and nothing is published. Run this first — - it means the first REAL dispatch is not this workflow's first execution. - required: false - type: boolean - default: false - -# ONE group for the whole lane, so two cuts can never interleave two snapshots -# over one main. Note the eviction property `release.yml` documents: GitHub keeps -# at most one PENDING run per group, so dispatching a third cut while one runs and -# one waits silently drops the middle one. That is acceptable here and nowhere near -# the hazard it would be on a shared group — every run of this workflow is a -# deliberate human act, and a dropped one is visibly absent from the Actions list. -# No `cancel-in-progress`: a cut that has begun publishing must never be killed. -concurrency: - group: cut-rc - cancel-in-progress: false - -permissions: - contents: read - -jobs: - cut: - name: Cut an rc from one snapshot - runs-on: ubuntu-latest - # ⚠️ An environment with NO protection rules passes AUTOMATICALLY and silently - # — the same one-time setup note `release.yml`'s header carries applies here - # verbatim (Settings -> Environments -> release -> Required reviewers). The - # load-bearing guarantee until then is the `workflow_dispatch` trigger itself. - environment: release - permissions: - # For `git push origin main` and the tag push. See "THE ADMIN PREREQUISITE" - # on the checkout step: this permission is necessary and NOT sufficient when - # main carries a protection ruleset. - contents: write - timeout-minutes: 120 - - steps: - # ──────────────────────────────────────────────────────────────────────── - # GUARDS THAT NEED NO CHECKOUT. Cheapest possible failure for the mistakes - # that are most likely: wrong event, wrong branch, wrong version shape. - # ──────────────────────────────────────────────────────────────────────── - - name: Guard the dispatch (event, ref, version shape) - env: - # Human-supplied text: read through env, never interpolated into the - # shell. Same rule as `release.yml`'s guard step. - REQUESTED: ${{ inputs.version }} - DRY_RUN: ${{ inputs.dry_run }} - run: | - # Belt and braces against a FUTURE edit adding a second trigger to this - # file. Today `workflow_dispatch` is the only one, so this cannot fire; - # the day someone adds `push:` for convenience, it fires instead of - # publishing. A structural guard is cheap; re-earning #6170 is not. - if [ "${GITHUB_EVENT_NAME}" != "workflow_dispatch" ]; then - echo "::error::cut-rc ran on event '${GITHUB_EVENT_NAME}'. This lane publishes to npm and may ONLY be started by a human dispatch (#6170, 2026-08-07 ruling). Remove whatever trigger produced this event." - exit 1 - fi - - if [ "${GITHUB_REF}" != "refs/heads/main" ]; then - echo "::error::dispatch cut-rc from main (got ${GITHUB_REF}). This workflow snapshots and pushes main; running it from another ref would execute that branch's workflow file against main's code." - exit 1 - fi - - # rc ONLY. A GA cut goes through release.yml + the Version Packages PR, - # where the human changelog review and the #7275-A precondition live. - if ! printf '%s' "$REQUESTED" | grep -qE '^[0-9]+\.[0-9]+\.[0-9]+-rc\.[0-9]+$'; then - echo "::error::'${REQUESTED}' is not an rc version. cut-rc cuts PRERELEASES only and expects the exact shape X.Y.Z-rc.N (e.g. 17.0.0-rc.6). For a GA release use the Release workflow and the 'chore: version packages' PR — see docs/releases-maintenance.md." - exit 1 - fi - - echo "Requested: ${REQUESTED} (dry_run=${DRY_RUN}), dispatched by ${GITHUB_ACTOR}." - - # ──────────────────────────────────────────────────────────────────────── - # THE SNAPSHOT, HALF 1 — objectstack. - # - # `ref: main` is explicit rather than inherited: the guard above already - # requires the dispatch ref to be main, and pinning it here means the two - # can never drift apart in a later edit. - # - # `fetch-depth: 0` is required, not tidiness: the changeset gates below diff - # against the snapshot sha, and `changesets` reads history. - # - # ⚠️ THE ADMIN PREREQUISITE — ONE-TIME, MAINTAINER-ONLY, AND THIS LANE - # CANNOT CREATE IT FOR ITSELF. - # This job pushes the version commit straight to `main`. If main carries a - # protection ruleset (it does), the pushing identity must be on that - # ruleset's BYPASS list, and `contents: write` alone does not put it there. - # Either of these satisfies it: - # - # (a) add the GitHub Actions app to the ruleset's bypass list, ideally - # scoped to this workflow, and leave RELEASE_PUSH_TOKEN unset; or - # (b) create a fine-grained PAT with `contents: write` on this repository - # for an account that IS on the bypass list, and store it as the - # repository secret RELEASE_PUSH_TOKEN. - # - # The token below picks (b) when the secret exists and (a) when it does not, - # so configuring either one is enough and neither needs a workflow edit. The - # push step names this prerequisite in its failure message, because a - # rejected push is otherwise indistinguishable from a network fault. - # - # Whichever route is taken, the credential is persisted on `origin` here so - # the version-commit push AND `release-publish.sh`'s atomic tag push both use - # it — one credential, no second remote to keep in step. - # ──────────────────────────────────────────────────────────────────────── - - name: Checkout main (full history) - uses: actions/checkout@v7 - with: - ref: main - fetch-depth: 0 - token: ${{ secrets.RELEASE_PUSH_TOKEN || github.token }} - - - name: Record the objectstack snapshot + guard the release train - id: snapshot - env: - REQUESTED: ${{ inputs.version }} - # PRESENCE, not the value: `!= ''` is evaluated by the expression engine - # and yields 'true'/'false', so the secret itself never reaches the - # shell. Interpolating `secrets.X` into a `run:` block would put it in - # the process's argv — masked in the log, but still the pattern - # release.yml refuses ("read through env, never interpolated"). - HAS_PUSH_TOKEN: ${{ secrets.RELEASE_PUSH_TOKEN != '' }} - run: | - SNAPSHOT_SHA="$(git rev-parse HEAD)" - echo "SNAPSHOT_SHA=${SNAPSHOT_SHA}" >> "$GITHUB_ENV" - echo "snapshot-sha=${SNAPSHOT_SHA}" >> "$GITHUB_OUTPUT" - - # Report credential state on EVERY run, before anything can early-exit. - # Otherwise a repo with the secret and one without look identical until a - # push happens to be rejected, and "is it configured?" stays unanswerable - # (the pattern cross-repo-issue-closer.yml uses). Presence only — the - # value is never read into the log. - if [ "$HAS_PUSH_TOKEN" = "true" ]; then - echo "push credential: RELEASE_PUSH_TOKEN is configured (route b)." - else - echo "push credential: RELEASE_PUSH_TOKEN is NOT set — pushing as the Actions app (route a). That app must be on main's ruleset bypass list or the push below is rejected." - fi - - # rc-only, asserted against committed state rather than trusted from the - # version string. Pre mode is what makes `changeset version` compute - # `-rc.N` at all; outside it this lane would silently cut a GA release - # through a path that has none of GA's review steps. - MODE="$(jq -r '.mode // empty' .changeset/pre.json 2>/dev/null || true)" - TAG="$(jq -r '.tag // empty' .changeset/pre.json 2>/dev/null || true)" - if [ "$MODE" != "pre" ] || [ "$TAG" != "rc" ]; then - echo "::error::.changeset/pre.json is mode='${MODE:-}' tag='${TAG:-}', but cut-rc requires mode='pre' tag='rc'. Outside pre mode 'pnpm run version' computes a FINAL version, and this lane has none of the GA review steps. Enter pre mode ('changeset pre enter rc') or use the Release workflow." - exit 1 - fi - - CURRENT="$(jq -r '.version' packages/cli/package.json)" - if [ "$CURRENT" = "$REQUESTED" ]; then - echo "::error::main already carries @objectstack/cli@${CURRENT}. cut-rc computes the NEXT version from the pending changesets — asking it for the version main already has means the cut has already happened. To (re)publish an existing version, use the Release workflow." - exit 1 - fi - - echo "objectstack snapshot: ${SNAPSHOT_SHA}" - echo "main currently carries @objectstack/cli@${CURRENT}; this run must compute ${REQUESTED}." - - # ──────────────────────────────────────────────────────────────────────── - # Toolchain. Mirrors release.yml's publish job step for step. - # ──────────────────────────────────────────────────────────────────────── - - name: Setup Node.js - uses: actions/setup-node@v7 - with: - # Cannot go below 22 — the downstream hotcrm smoke below clones - # hotcrm@v1.2.0, whose manifest pins engines.node >=22. - node-version: '22' - - - name: Setup pnpm - uses: ./.github/actions/setup-pnpm - - - name: Get pnpm store directory - shell: bash - run: | - echo "STORE_PATH=$(pnpm store path --silent)" >> $GITHUB_ENV - - - name: Setup pnpm cache - uses: actions/cache@v6 - with: - path: ${{ env.STORE_PATH }} - key: ${{ runner.os }}-pnpm-store-v3-${{ hashFiles('**/pnpm-lock.yaml') }} - restore-keys: | - ${{ runner.os }}-pnpm-store-v3- - - # Restore-only, as ci.yml's retired Console Pin Gate was: this workflow runs a handful - # of times a month so its own namespace is almost always cold, and the - # build-core fallbacks seeded from main are the ones that actually hit. - # Saving is lint.yml's job. - - name: Restore Turbo cache - uses: actions/cache/restore@v6 - with: - path: .turbo/cache - key: ${{ runner.os }}-turbo-${{ github.job }}-${{ github.ref_name }}-${{ github.sha }} - restore-keys: | - ${{ runner.os }}-turbo-${{ github.job }}-${{ github.ref_name }}- - ${{ runner.os }}-turbo-${{ github.job }}- - ${{ runner.os }}-turbo-build-core-${{ github.ref_name }}- - ${{ runner.os }}-turbo-build-core- - - - name: Install dependencies - run: pnpm install --frozen-lockfile - - # The whole workspace, both scopes, then the Console: @object-ui/console - # builds against this snapshot's @objectstack/* (`workspace:^`), and - # `@objectstack/console#build:dist` (root turbo.json) copies its dist with - # the TRACKED repo-root sdui.manifest.json and runs the single-zod canary on - # what it ships (packages/console/build-dist.sh). Built HERE, before the - # version commit, so a Console that cannot build stops the cut before - # anything is written; `pnpm run release` below builds both again, as turbo - # cache hits. - - name: Build the workspace (both scopes, the Console included) - run: | - pnpm run build - pnpm run console:build - - # ──────────────────────────────────────────────────────────────────────── - # PRE-VERSION GATES. Every one of these reads `.changeset/*`, so all of them - # must run BEFORE `pnpm run version` consumes it. - # - # ON THE `--base` VALUE: these three are FORWARD-ONLY DIFF gates — they judge - # what a change INTRODUCES, never the standing stock (their `--list` modes - # 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. - # - # ⚠️ That contribution is EMPTY by design — the lane writes no 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. Every changeset this cut versions, @object-ui/* included, was - # judged by these same gates on the PR that wrote it. - # - # $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 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 - - - name: Gate — no control bytes anywhere in the tree - run: pnpm check:nul-bytes - - - name: Gate — this cut introduces no empty-frontmatter changeset - run: | - node scripts/check-empty-changeset.mjs --self-test - node scripts/check-empty-changeset.mjs --base "$SNAPSHOT_SHA" - - - name: Gate — this cut introduces no unregistered breaking change (ADR-0087) - run: | - node scripts/check-adr-0087-registration.mjs --self-test - node scripts/check-adr-0087-registration.mjs --base "$SNAPSHOT_SHA" - - - name: Gate — this cut introduces no major bump - run: | - node scripts/check-changeset-no-major.mjs --self-test - node scripts/check-changeset-no-major.mjs --base "$SNAPSHOT_SHA" - - # ──────────────────────────────────────────────────────────────────────── - # VERSION. `pnpm run version`, never a bare `changeset version`: the repo - # script is `changeset version && sync-protocol-version.mjs && - # sync-template-versions.mjs`, and the two sync steps are what keep - # PROTOCOL_VERSION and the scaffolder templates in lockstep with the number - # being cut. Pre mode (`.changeset/pre.json`, tag rc) is committed state and - # applies automatically — the guard step already proved it is active. - # ──────────────────────────────────────────────────────────────────────── - - name: Version the workspace - run: pnpm run version - - - name: Assert the computed version is the one that was dispatched - env: - REQUESTED: ${{ inputs.version }} - run: | - COMPUTED="$(jq -r '.version' packages/cli/package.json)" - if [ "$COMPUTED" != "$REQUESTED" ]; then - echo "::error::you dispatched '${REQUESTED}' but the pending changesets compute @objectstack/cli@${COMPUTED}. Nothing has been pushed or published. Re-dispatch with ${COMPUTED} if that is the intended cut, or find the changeset that moved the number." - exit 1 - fi - echo "computed version ${COMPUTED} matches the dispatch." - - # ──────────────────────────────────────────────────────────────────────── - # THE SINGLE VERSION COMMIT. - # - # Squashed onto $SNAPSHOT_SHA so main receives exactly one commit for the - # cut: the version output is one atomic unit, and there is never a main - # commit carrying half of it. - # - # WHAT MAY BE IN IT — re-measured under @changesets/cli v3 on the - # 17.0.0 -> 17.1.0-rc.0 train (a full local `pnpm run version` in a throwaway - # clone over 209 pending changesets), not assumed. 365 staged paths: - # 76 modified package.json, 76 modified CHANGELOG.md, 209 changesets MOVED - # into `.changeset/pre/`, `.changeset/pre.json`, and the 3 doc surfaces - # below. `.changeset/pre.json` is UNTRACKED on the first cut of a train - # (`changeset pre enter` just created it) and modified on later ones; - # `git add -A` covers both. - # The 3 major-boundary paths below did NOT appear on this train and are - # allowed for the train that does move the major — as is the blank - # template's own package.json, which is why the v2 measurement this block - # used to carry counted 77 package.json against this one's 76: that train - # was 17.0.0 -> 18.0.0-rc.0, so `sync-template-versions.mjs` had to rewrite - # the template's `^17.0.0` pins. On a minor train it logs "already pins" and - # writes nothing. The counts move with the SHAPE of the train; the - # allowlist below is what does not have to. - # - # THE CONSUMED CHANGESETS MOVE — new in v3 (changesets#2190), and the one - # thing about a cut that this file's prose used to get wrong. v2 left every - # consumed `.changeset/*.md` on disk and recorded it in `pre.json`; v3 moves - # it to `.changeset/pre/NAME.md` VERBATIM (measured: a moved file diffs - # identical to its original, and git reports all 209 as `R100` renames), and - # `pre.json` is `{"mode","tag"}` only — `changeset version` never rewrites - # it. So in the worktree a cut is 209 deletions plus a new untracked - # `.changeset/pre/` directory, and the claim this block used to make — "a - # cut never races a lane PR over a `.changeset/*.md` file it wants to keep" - # — is FALSE under v3. The cut moves tracked changeset paths, so a lane PR - # that edits or deletes one of them conflicts; the rebase guard further down - # is what catches it, and it already names `.changeset` in its conflict - # surface. What does NOT change is this allowlist: `^\.changeset/` covers - # `.changeset/pre/…` and `git add -A -- … .changeset …` stages the deletions - # and the new directory together (measured: all 365 staged paths accepted, - # nothing tracked left unstaged). - # Three more paths can appear at a major boundary and are allowed for that - # reason, all written by `sync-protocol-version.mjs` / - # `sync-template-versions.mjs`: packages/spec/src/kernel/protocol-version.ts - # and, per bundled template, objectstack.config.ts (engines.protocol) and - # objectstack.manifest.json (specVersion). protocol-version.ts is named - # literally because that one file IS the whole of its surface. The template - # paths are not, and the reason is the one the doc half states below. - # - # THE DOC SURFACES ARE READ, NOT RESTATED. `pnpm run version` ends with - # `sync-docs-image-tags.mjs`, which rewrites the doc surfaces that pin a - # concrete image tag / CLI version. That rewriter does not own the list — it - # imports `SURFACES` from `scripts/check-docs-image-tag.mjs`, the gate whose - # findings it exists to clear, on the stated principle "one list, two - # consumers". This step is the THIRD consumer and joins on the same terms: - # it resolves `SURFACES` at run time rather than copying the paths, because a - # fourth literal is a fourth contract and the two drifting apart is exactly - # the defect that reached this assertion in the first place — the rewriter - # joined the `version` script while this allowlist kept describing a train - # measured before it existed, and every cut since would have refused to push. - # Note what this deliberately is NOT: a wildcard over `content/docs/**`. Only - # the paths a reviewed declaration names are allowed, so a version pass that - # writes anywhere else — a new sync script, or this rewriter growing an - # output outside `SURFACES` — still fails loudly here, which is the whole - # point of the assertion. Onboarding a surface stays a one-line edit to - # `SURFACES` that a reviewer sees in the diff. - # - # THE TEMPLATE SURFACES ARE READ ON THE SAME TERMS. They used to be two - # literals, both hard-coding the template name `blank`, and this block used - # to record why: `sync-template-versions.mjs` declared its targets (a - # `TEXT_STAMPS` table plus a walk of `src/templates/`) but exported none of - # them and ran the sync at module scope, so importing it to ask "which files - # does the version pass stamp?" would have rewritten the templates instead of - # answering. #9648 gave it the export and the entry-point guard this block - # asked for, so the restatement is retired here rather than re-seeded: - # `stampedPaths()` derives its answer from the same walk and the same table - # the stamper's own `main()` uses. - # - # This is a COLLAPSE OF TWO COPIES OF ONE LIST, not a widening. The literals - # were never a deliberately narrower allowlist — they were the same set, - # spelled by hand, and they equal it only while `blank` is the only bundled - # template. `findTemplateDirs()` exists BECAUSE the template set is not - # curated ("a template added tomorrow is covered on the day it lands"), so - # the day a second template ships, the walk stamps it, the literal pair does - # not cover it, and the unstaged-files assertion below refuses the cut — with - # nothing red until someone attempts a release. `stampedPaths()` also reports - # each template's own package.json, which `'*package.json'` already permits, - # so the resolved list is a superset of the two literals and never a - # narrowing. - # - # The allowlist is ENFORCED rather than described. Only allowlisted paths are - # staged, and then every staged path is re-checked against the same rule and - # the worktree is asserted clean. If `pnpm run version` ever grows a new - # output, this fails loudly here instead of pushing a surprise to main — the - # "disjoint from lane PRs by construction" claim is only worth anything if - # something actually checks it. - # ──────────────────────────────────────────────────────────────────────── - - name: Build the single version commit - id: commit - env: - REQUESTED: ${{ inputs.version }} - DRY_RUN: ${{ inputs.dry_run }} - run: | - # The committer identity: `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 - # declaration the rewriter reads. Import-safe by construction: the module - # carries an entry-point guard added for exactly this kind of consumer. - SURFACE_LIST="${RUNNER_TEMP:-/tmp}/cut-rc-docs-image-tag-surfaces.txt" - if ! node --input-type=module \ - -e 'import { SURFACES } from "./scripts/check-docs-image-tag.mjs"; for (const s of SURFACES) console.log(s.file);' \ - > "$SURFACE_LIST"; then - echo "::error::could not resolve SURFACES from scripts/check-docs-image-tag.mjs, so the doc half of the release file surface is unknown. Refusing to push." - exit 1 - fi - # An empty list would silently re-open the hole this replaced: the docs the - # version pass rewrites would go unstaged and the assertion below would - # blame them. Unknown is a failure, never an empty allowlist. - if [ ! -s "$SURFACE_LIST" ]; then - echo "::error::SURFACES in scripts/check-docs-image-tag.mjs resolved EMPTY, so no doc surface would be staged even though the version pass rewrites them. Refusing to push." - exit 1 - fi - mapfile -t DOCS_SURFACES < "$SURFACE_LIST" - echo "doc surfaces declared by SURFACES (${#DOCS_SURFACES[@]}):" - sed 's/^/ /' "$SURFACE_LIST" - - # The template surfaces `sync-template-versions.mjs` stamps, read from the - # same walk and the same table the stamper itself uses. Import-safe by - # construction: that module carries the entry-point guard and the exports - # added for exactly this consumer. - TEMPLATE_LIST="${RUNNER_TEMP:-/tmp}/cut-rc-template-version-surfaces.txt" - if ! node --input-type=module \ - -e 'import { stampedPaths } from "./scripts/sync-template-versions.mjs"; for (const p of stampedPaths()) console.log(p);' \ - > "$TEMPLATE_LIST"; then - echo "::error::could not resolve stampedPaths() from scripts/sync-template-versions.mjs, so the template half of the release file surface is unknown. Refusing to push." - exit 1 - fi - # Same rule as the doc half: unknown is a failure, never an empty - # allowlist. `stampedPaths()` THROWS rather than returning [] on a moved - # or empty template directory, so an empty file here means a resolution - # that reported nothing while still exiting 0 — which would stage no - # template path at all and then blame the files it left unstaged. - if [ ! -s "$TEMPLATE_LIST" ]; then - echo "::error::stampedPaths() in scripts/sync-template-versions.mjs resolved EMPTY, so no template surface would be staged even though the version pass stamps them. Refusing to push." - exit 1 - fi - mapfile -t TEMPLATE_SURFACES < "$TEMPLATE_LIST" - echo "template surfaces declared by stampedPaths() (${#TEMPLATE_SURFACES[@]}):" - sed 's/^/ /' "$TEMPLATE_LIST" - - # The release-index surface `sync-release-index-currency.mjs` stamps, read - # from `syncedPaths()` — which derives it from `INDEX_PATH` in the gate - # whose finding that rewriter clears. Same terms as the two lists above, and - # for the same reason: a third literal here would be a third contract. On an - # RC cut this rewriter writes NOTHING (an `-rc` heading is not a GA version, - # so the newest GA of the major does not move), so this list normally stages - # nothing — it is declared so that the one cut where the index IS stale gets - # a complete commit instead of the refusal below. - INDEX_LIST="${RUNNER_TEMP:-/tmp}/cut-rc-release-index-surfaces.txt" - if ! node --input-type=module \ - -e 'import { syncedPaths } from "./scripts/sync-release-index-currency.mjs"; for (const p of syncedPaths()) console.log(p);' \ - > "$INDEX_LIST"; then - echo "::error::could not resolve syncedPaths() from scripts/sync-release-index-currency.mjs, so the release-index half of the release file surface is unknown. Refusing to push." - exit 1 - fi - if [ ! -s "$INDEX_LIST" ]; then - echo "::error::syncedPaths() in scripts/sync-release-index-currency.mjs resolved EMPTY, so the release index would go unstaged even though the version pass can rewrite it. Refusing to push." - exit 1 - fi - mapfile -t INDEX_SURFACES < "$INDEX_LIST" - echo "release-index surfaces declared by syncedPaths() (${#INDEX_SURFACES[@]}):" - sed 's/^/ /' "$INDEX_LIST" - - # The SKILL.md stamps `check-skill-compatibility-version.mjs --fix` writes - # (the `version` script's last step), read from `stampedSkillPaths()` — - # derived from the walk that `--fix` writes from — on the same terms. - SKILL_LIST="${RUNNER_TEMP:-/tmp}/cut-rc-skill-surfaces.txt" - if ! node --input-type=module \ - -e 'import { stampedSkillPaths } from "./scripts/check-skill-compatibility-version.mjs"; for (const p of stampedSkillPaths()) console.log(p);' \ - > "$SKILL_LIST"; then - echo "::error::could not resolve stampedSkillPaths() from scripts/check-skill-compatibility-version.mjs, so the skill half of the release file surface is unknown. Refusing to push." - exit 1 - fi - if [ ! -s "$SKILL_LIST" ]; then - echo "::error::stampedSkillPaths() in scripts/check-skill-compatibility-version.mjs resolved EMPTY, so no SKILL.md stamp would be staged even though the version pass writes them. Refusing to push." - exit 1 - fi - mapfile -t SKILL_SURFACES < "$SKILL_LIST" - echo "skill surfaces declared by stampedSkillPaths() (${#SKILL_SURFACES[@]}):" - sed 's/^/ /' "$SKILL_LIST" - - git add -A -- \ - '*package.json' \ - '*CHANGELOG.md' \ - .changeset \ - packages/spec/src/kernel/protocol-version.ts \ - "${TEMPLATE_SURFACES[@]}" \ - "${DOCS_SURFACES[@]}" \ - "${INDEX_SURFACES[@]}" \ - "${SKILL_SURFACES[@]}" - - STAGED="$(git diff --cached --name-only)" - if [ -z "$STAGED" ]; then - echo "::error::the version pass produced no changes to commit. Nothing to cut." - exit 1 - fi - - # Re-check every staged path against the allowlist. The pathspec above is - # convenience; THIS is the guarantee. Five filters, same allowlist the - # pathspec used: the fixed release paths by pattern, then the declared - # template, doc, release-index and skill surfaces by WHOLE-LINE EXACT match - # (`-xF`) against the very lists that were staged — so no derived filter - # can accept a path its declaration does not name, and none needs - # regex-escaping of the paths to stay exact. - BAD="$(printf '%s\n' "$STAGED" \ - | grep -vE '(^|/)package\.json$|(^|/)CHANGELOG\.md$|^\.changeset/|^packages/spec/src/kernel/protocol-version\.ts$' \ - | grep -vxF -f "$TEMPLATE_LIST" \ - | grep -vxF -f "$SURFACE_LIST" \ - | grep -vxF -f "$INDEX_LIST" \ - | grep -vxF -f "$SKILL_LIST" || true)" - if [ -n "$BAD" ]; then - echo "::error::the version commit would carry paths outside the release file surface. Refusing to push. Offending paths follow; if the version pass legitimately grew a new output, widen the allowlist in this workflow deliberately." - printf '%s\n' "$BAD" | sed 's/^/::error:: unexpected: /' - exit 1 - fi - - # Nothing tracked may be left behind: a modified-but-unstaged file means - # the version pass wrote somewhere the allowlist does not cover, and the - # commit would silently ship a partial cut. - LEFT="$(git diff --name-only)" - if [ -n "$LEFT" ]; then - echo "::error::tracked files were modified but fall outside the release file surface, so the version commit would be incomplete. Refusing to push." - printf '%s\n' "$LEFT" | sed 's/^/::error:: unstaged: /' - exit 1 - fi - - # Separate -m flags rather than one multi-line string: inside a YAML - # block scalar every continuation line carries the block's indentation, - # which would land verbatim in the commit body. - git commit -q \ - -m "chore(release): ${REQUESTED}" \ - -m "cut from ${SNAPSHOT_SHA} by ${GITHUB_ACTOR} via cut-rc (#7447)" - - COMMIT_SHA="$(git rev-parse HEAD)" - COUNT="$(printf '%s\n' "$STAGED" | wc -l | tr -d ' ')" - echo "commit-sha=${COMMIT_SHA}" >> "$GITHUB_OUTPUT" - echo "file-count=${COUNT}" >> "$GITHUB_OUTPUT" - echo "built ${COMMIT_SHA} on top of ${SNAPSHOT_SHA}: ${COUNT} file(s)." - { - echo "## Cut ${REQUESTED}" - echo - echo "| | |" - echo "|---|---|" - echo "| objectstack snapshot | \`${SNAPSHOT_SHA}\` |" - echo "| version commit | \`${COMMIT_SHA}\` |" - echo "| files in commit | ${COUNT} |" - echo "| dry run | ${DRY_RUN} |" - } >> "$GITHUB_STEP_SUMMARY" - - # ──────────────────────────────────────────────────────────────────────── - # DRY RUN STOPS HERE. - # - # It stops AFTER the local commit rather than after the version step, which - # is strictly more of the pipeline than #7447's acceptance criterion asks - # for: the squash, the allowlist enforcement and the version assertion have - # all really executed, and the artifact is the exact commit a real run would - # push. Everything downstream of this point is irreversible. - # ──────────────────────────────────────────────────────────────────────── - - name: Export the would-be version commit (dry run) - if: ${{ inputs.dry_run }} - env: - REQUESTED: ${{ inputs.version }} - run: | - mkdir -p "${RUNNER_TEMP}/cut-rc" - git format-patch -1 --stdout > "${RUNNER_TEMP}/cut-rc/version-commit.patch" - git show --stat --oneline HEAD > "${RUNNER_TEMP}/cut-rc/version-commit.stat.txt" - { - echo "# cut-rc dry run — ${REQUESTED}" - echo - echo "- objectstack snapshot: ${SNAPSHOT_SHA}" - echo "- version commit: $(git rev-parse HEAD)" - echo - echo "Nothing was pushed and nothing was published." - echo "version-commit.patch is byte-for-byte what a real dispatch would push to main." - } > "${RUNNER_TEMP}/cut-rc/README.md" - - - name: Upload the dry-run artifact - if: ${{ inputs.dry_run }} - uses: actions/upload-artifact@v7 - with: - name: cut-rc-${{ inputs.version }}-dry-run - path: ${{ runner.temp }}/cut-rc - if-no-files-found: error - - - name: Stop (dry run) - if: ${{ inputs.dry_run }} - run: | - echo "::notice::dry run complete — the version commit was built and verified locally, then discarded. Nothing was pushed, nothing was published. Re-dispatch with dry_run unchecked to cut for real." - echo "- **dry run**: stopped before the push. Nothing was published." >> "$GITHUB_STEP_SUMMARY" - - # ──────────────────────────────────────────────────────────────────────── - # LAND ON MAIN FIRST, PUBLISH SECOND (#6170). - # - # rc.3 and rc.4 tagged commits that lived only on `changeset-release/main`, - # so main kept a stale version and every later release recomputed an - # npm-occupied number. The ordering below is the structural fix: by the time - # anything reaches npm, the exact commit it was built from is on main. - # - # THE DRY-RUN GUARD IS WRITTEN TWICE, ON PURPOSE. The step-level `if:` is the - # primary control; the in-shell assert is there because a dropped `if:` in a - # future edit would otherwise publish from a run a human asked NOT to - # publish. Two languages, one decision — the same belt-and-braces reasoning - # release.yml applies to its own publish path. - # ──────────────────────────────────────────────────────────────────────── - - name: Push the version commit to main - if: ${{ !inputs.dry_run }} - env: - DRY_RUN: ${{ inputs.dry_run }} - REQUESTED: ${{ inputs.version }} - run: | - if [ "$DRY_RUN" = "true" ]; then - echo "::error::refusing to push: this run was dispatched as a dry run. A step-level 'if:' has been dropped in an edit to this workflow." - exit 1 - fi - - # Bounded fetch-rebase-retry. main is busy by assumption — that is the - # premise of this whole workflow — so losing the push race is expected, - # not exceptional. - ATTEMPTS=5 - for attempt in $(seq 1 "$ATTEMPTS"); do - if git push origin HEAD:main; then - echo "pushed $(git rev-parse HEAD) to main on attempt ${attempt}." - echo "PUSHED_SHA=$(git rev-parse HEAD)" >> "$GITHUB_ENV" - exit 0 - fi - - if [ "$attempt" -eq "$ATTEMPTS" ]; then - echo "::error::could not push the version commit to main after ${ATTEMPTS} attempts. Nothing has been published, so this is safe to retry." - echo "::error::If the rejection was 'protected branch' / 'refusing to allow ... to update', this is the ONE-TIME ADMIN PREREQUISITE and no re-run will clear it: the pushing identity must be on main's ruleset BYPASS list. Either add the GitHub Actions app to the ruleset bypass list (ideally scoped to this workflow), or create a fine-grained PAT with contents:write for an account already on that list and store it as the repository secret RELEASE_PUSH_TOKEN. See this workflow's checkout step for the full note." - exit 1 - fi - - echo "push rejected (attempt ${attempt}/${ATTEMPTS}) — refreshing main and rebasing the version commit." - git fetch origin main - if ! git rebase origin/main; then - git rebase --abort || true - echo "::error::the version commit conflicts with main. Nothing has been published. A commit landing during the cut touched the release file surface (package.json / CHANGELOG.md / .changeset) — resolve it and re-dispatch." - exit 1 - fi - - # The rebase moved the commit onto newer main, so the version - # assertion must be re-earned rather than inherited. If another - # version commit landed underneath us the number is now wrong, and - # that must stop the cut before npm, not after. - COMPUTED="$(jq -r '.version' packages/cli/package.json)" - if [ "$COMPUTED" != "$REQUESTED" ]; then - echo "::error::after rebasing onto refreshed main the tree carries @objectstack/cli@${COMPUTED}, not the dispatched ${REQUESTED}. Something else versioned main during this cut. Nothing has been published." - exit 1 - fi - - sleep $(( attempt * 5 )) - done - - # From here the commit IS on main, so everything published is a commit main - # already carries — #6170's invariant, kept to the letter. If the loop above - # rebased, main also carries whatever landed during the cut; those commits - # passed their own CI, and the publish below builds from this exact tree, so - # the artifacts and the main commit still describe each other exactly. - - name: Confirm the pushed commit is main - if: ${{ !inputs.dry_run }} - run: | - git fetch origin main - REMOTE="$(git rev-parse origin/main)" - LOCAL="$(git rev-parse HEAD)" - if [ "$REMOTE" != "$LOCAL" ]; then - echo "::warning::main has already moved past the version commit (${LOCAL} -> ${REMOTE}). That is normal on a busy repository; the version commit itself is on main, which is what the publish requires." - if ! git merge-base --is-ancestor "$LOCAL" "$REMOTE"; then - echo "::error::the version commit ${LOCAL} is NOT an ancestor of origin/main. Refusing to publish a commit main does not carry (#6170)." - exit 1 - fi - fi - echo "@objectstack/cli@$(jq -r '.version' packages/cli/package.json) is on main at ${LOCAL}." - - # Pre-publish gate #2035, kept in step with release.yml's publish job rather - # than dropped for speed. This step is advisory by construction — it has no - # BLOCKING switch — because this lane only ever runs in pre mode (the guard - # step above proved it active). It reports; it cannot block. For why the - # equivalent gate in release.yml is currently advisory too, and what re-arms - # it, see that step's own comment. - - name: Downstream backward-compat smoke (live hotcrm, advisory in pre mode) - if: ${{ !inputs.dry_run }} - env: - HOTCRM_REF: v2.1.0 - run: | - if bash scripts/downstream-smoke.sh; then - echo "::notice::hotcrm@${HOTCRM_REF} is still compatible with the pre-release train." - else - echo "::warning::hotcrm@${HOTCRM_REF} is incompatible with the pre-release train — expected for this window's deliberate removals. Ship a migrated hotcrm release, bump it, and set BLOCKING=1 in .github/workflows/release.yml to re-arm this gate." - fi - - # ──────────────────────────────────────────────────────────────────────── - # ADR-0087 D4 — the two generated projections, under the #22449 B′ ruling. - # - # spec-changes.json and the protocol upgrade guide are generated from the - # registries at publish and nowhere else that ships; no committed copy is - # kept current any more. This lane used to pack whatever copy the tree - # held, so it now generates both into the package (`--generate`: the - # registry projections and NO per-release section — what an rc tarball has - # always carried) and refuses a tarball whose copies differ from a fresh - # generation (`--verify`), before anything reaches npm. Same order, same - # reason as release.yml's publish job: after `changeset publish` the - # tarball is immutable. - # ──────────────────────────────────────────────────────────────────────── - - name: Generate the ADR-0087 projections into the package (ADR-0087 D4) - if: ${{ !inputs.dry_run }} - env: - RELEASE_VERSION: ${{ inputs.version }} - run: bash scripts/release-spec-changes.sh --generate - - - name: Correctness gate — the packed projections must match the registries - if: ${{ !inputs.dry_run }} - env: - RELEASE_VERSION: ${{ inputs.version }} - run: bash scripts/release-spec-changes.sh --verify - - # ──────────────────────────────────────────────────────────────────────── - # PUBLISH. `pnpm run release` = build (both scopes) + release-publish.sh, - # which is `changeset publish` followed by ONE atomic `git push origin --tags` - # (#2191: the changesets action's concurrent per-tag pushes raced GitHub's - # ref backend and lost ~half the tags). - # - # changesets/action is NOT used, exactly as in release.yml: handed a - # workspace with pending changesets it would take the VERSION path and mint a - # commit. `changeset publish` can only publish what the checked-out - # package.json files already declare — which is what main now carries. - # ──────────────────────────────────────────────────────────────────────── - - name: Publish to npm + push version tags - id: publish - if: ${{ !inputs.dry_run }} - env: - NPM_TOKEN: ${{ secrets.NPM_TOKEN }} - DRY_RUN: ${{ inputs.dry_run }} - VERSION: ${{ inputs.version }} - run: | - if [ "$DRY_RUN" = "true" ]; then - echo "::error::refusing to publish: this run was dispatched as a dry run. A step-level 'if:' has been dropped in an edit to this workflow." - exit 1 - fi - - printf '//registry.npmjs.org/:_authToken=%s\n' "$NPM_TOKEN" >> "$HOME/.npmrc" - - pnpm run release - - # `changeset publish` skips versions already on the registry, so a - # re-dispatch over a partially-published release is a repair rather than - # a duplicate. What is NOT optional is that the version is on npm when - # this step ends. - if ! npm view "@objectstack/cli@$VERSION" version >/dev/null 2>&1; then - echo "::error::publish ran but @objectstack/cli@$VERSION is still not on npm" - exit 1 - fi - echo "published=true" >> "$GITHUB_OUTPUT" - - - name: Create GitHub Releases (bodies truncated to the API limit) - # `!cancelled()` rather than the implicit success(): npm is already public - # by the time this runs, so a failure upstream must not be the reason the - # release record stays empty (#4900). - if: ${{ !cancelled() && steps.publish.outputs.published == 'true' }} - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - # The fixed group releases every public package at one version, so the - # version alone drives the whole publishable workspace. - RELEASE_VERSION: ${{ inputs.version }} - run: node scripts/release-github-releases.mjs - - - name: Attach spec-changes.json to the GitHub Release (ADR-0087 D4) - # Ordering is load-bearing: `gh release upload` needs the Release the step - # above created. Uploads BOTH projections `--generate` wrote and `--verify` - # proved — spec-changes.json and protocol-upgrade-guide.md — so the Release - # assets and the tarball are the same bytes. - if: ${{ !cancelled() && steps.publish.outputs.published == 'true' }} - env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - RELEASE_VERSION: ${{ inputs.version }} - run: bash scripts/release-spec-changes.sh - - - name: Summarise the cut - if: ${{ !cancelled() && steps.publish.outputs.published == 'true' }} - env: - REQUESTED: ${{ inputs.version }} - run: | - { - echo - echo "**${REQUESTED} is published.**" - echo - echo "- npm: every package in the fixed group, dist-tag \`rc\`" - echo "- git: version tags pushed atomically" - echo "- main: carries the version commit (nothing was published that main does not have)" - echo - echo "### Two things that are expected, not defects" - echo - echo "1. **The standing \`chore: version packages (rc)\` PR (#6208) may look stale.**" - echo " Its changesets were consumed by this cut and moved to \`.changeset/pre/\` (v3)." - echo " Whether it refreshes now depends on the push credential: pushes made with the" - echo " Actions \`GITHUB_TOKEN\` trigger no workflow runs (GitHub's recursion guard), so" - echo " \`version-pr\` does not fire until some later push to main; with a" - echo " \`RELEASE_PUSH_TOKEN\` PAT it fires now and the PR self-corrects. **Do not fix it" - echo " by hand** — see docs/releases-maintenance.md, 'What happens to the standing" - echo " Version Packages PR'." - echo "2. **No runtime image yet.** This lane does not build one; \`release-integrity\` in" - echo " \`release.yml\` requests it on a later push to main, or dispatch" - echo " \`docker-publish.yml\` with version \`${REQUESTED}\` for one immediately." - } >> "$GITHUB_STEP_SUMMARY" diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index 0b1f21fa21a..36d7888795c 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -1810,7 +1810,7 @@ jobs: # from a primary checkout, a detached HEAD and a non-repo cwd with # identical results, so neither this checkout's depth nor its branch is an # input. This step itself needs only `jq` and `git` (both in the runner - # image; `jq` is already relied on bare by cut-rc.yml and release.yml), + # image; `jq` is already relied on bare by release.yml), # plus whatever each discovered matrix declares in its own header — # discovery above is by glob, so a matrix needing more (node, pnpm, a # built tree, a generator) must say so there; this comment is not the diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 5cbdc5a7321..96a87038a69 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -387,8 +387,7 @@ jobs: # surface, 4 major-only — the three blank-template files and # protocol-version.ts. The template half is now judged by the gates below; # protocol-version.ts is still refused, for the measured reason given at - # the refusal. cut-rc.yml's allowlist block carries the same surface for - # the RC lane and agrees path-for-path. + # the refusal. # # WHAT IS CHECKED, AND WHY EACH ONE (7 s total, measured on the rendered # tree in this job's own state: install, NO build. With the major-boundary @@ -401,8 +400,8 @@ jobs: # the doc and template halves of that surface are RESOLVED AT RUN TIME # from the very declarations the rewriters read (`SURFACES` in # check-docs-image-tag.mjs, `stampedPaths()` in - # sync-template-versions.mjs), on the principle cut-rc.yml states for - # the same two lists: one list, N consumers, because "a fourth literal + # sync-template-versions.mjs), on the principle those two declarations + # exist for: one list, N consumers, because "a fourth literal # is a fourth contract". This is the half that ends the treadmill: a # fifth rewriter, or an existing one growing an output, reddens this # lane on the day it lands instead of shipping unvalidated. @@ -658,7 +657,7 @@ jobs: exit 1 fi - # Same five filters cut-rc.yml applies to the same surface: the fixed + # Five filters over the reviewed surface: the fixed # release paths by pattern, then the four declared lists by WHOLE-LINE # EXACT match, so no derived filter can accept a path its declaration # does not name and none needs regex-escaping. diff --git a/docs/releases-maintenance.md b/docs/releases-maintenance.md index 17c945cb970..8aafcaad5d7 100644 --- a/docs/releases-maintenance.md +++ b/docs/releases-maintenance.md @@ -47,8 +47,8 @@ turbo task `@objectstack/console#build:dist`, which depends on `@object-ui/console#build` and then runs `packages/console/build-dist.sh`: it copies `ui/apps/console/dist` into `packages/console/dist`, adds the tracked repo-root `sdui.manifest.json`, and runs the single-zod canary on the bytes it ships. -`pnpm run release` and `cut-rc.yml` both run it after `pnpm run build`, so a release -ships the Console from the same commit as the spec it builds against. +`pnpm run release` runs it after `pnpm run build`, so a release ships the Console +from the same commit as the spec it builds against. > objectui's pending changesets from before the merge still sit under > `ui/.changeset/`, where the root config does not read them. Moving them into the @@ -183,132 +183,45 @@ looks like a real checkout. Its `--self-test` runs in `lint.yml` and pins both directions, so the refusal cannot rot into a guard that fires on everything or on nothing. -**This is a local-rehearsal trap only.** `cut-rc.yml` and `release.yml` check out -with `fetch-depth: 0`, so a real cut has full history and never enters that -branch. +**This is a local-rehearsal trap only.** `release.yml` checks out with +`fetch-depth: 0`, so a real cut has full history and never enters that branch. ## Cutting a release -Two routes, and the choice is not a preference — an rc and a GA release need -different things, so they get different lanes. +### Release channels -| | **rc prerelease** | **GA** | +There are two npm channels, and both are published by `release.yml`: + +| dist-tag | Carries | Published by | |---|---|---| -| Workflow | `.github/workflows/cut-rc.yml` | `.github/workflows/release.yml` | -| Trigger | one `workflow_dispatch` | merge the Version Packages PR, then approve the `release` environment | -| Console | built from `ui/apps/console` at the snapshot | built from `ui/apps/console` at the version commit | -| Changelog review | none (the record is generated) | human review on the PR | -| Board-clearing / #7275-A precondition | not required | required | - -Both lanes are gated by `environment: release`, and neither publishes anything until -a human approves the deployment there. What differs since ADR-0125 (2026-08-20) is -the *start*: `cut-rc.yml` is still `workflow_dispatch`-only — an event no push, bot, -merge-queue landing or schedule can synthesise — while `release.yml`'s publish lane -**is** started by the push that lands the Version Packages PR (D1), and is then held, -**whole**, at the environment gate until a required reviewer approves it (D2). The -approval is the release act on that lane, so read ADR-0125 D3 before touching it: an -environment with no required reviewers passes automatically and silently, which on a -push-started lane is not a weakened gate but no gate at all. (`release.yml` also +| `latest` | the stable release (GA) | `release.yml`, outside pre mode — the Version Packages PR flow below | +| `next` | the pre-release (`X.Y.Z-next.N`) | `release.yml` in pre mode: `.changeset/pre.json` says `mode: pre`, `tag: next`, and `changeset publish` publishes under that tag | + +There is no `rc` channel. The Cut RC lane that cut `X.Y.Z-rc.N` is retired and its +workflow deleted, and a pre-release cycle enters pre mode with tag `next`, never `rc`. +npm may still carry the `rc` dist-tag from that lane (`17.0.0-rc.6` when the lane was +retired). `scripts/release-dist-tags.mjs` lists `rc` as a retired tag: after +every stable publish it removes that tag (`npm dist-tag rm`) and moves a stale `next` +up to `latest`. Run with no flags, it prints that plan and writes nothing. + +`release.yml`'s publish lane is gated by `environment: release`, and it publishes +nothing until a human approves the deployment there. Since ADR-0125 (2026-08-20) the +lane **is** started by the push that lands the Version Packages PR (D1), and is then +held, **whole**, at the environment gate until a required reviewer approves it (D2). +The approval is the release act on that lane, so read ADR-0125 D3 before touching it: +an environment with no required reviewers passes automatically and silently, which on +a push-started lane is not a weakened gate but no gate at all. (`release.yml` also carries a 6-hourly `schedule`, added by #11233; it reaches only the bookkeeping `version-pr` job, which has no publish capability by construction.) That is the 2026-08-07 ruling (「版本发布必须是人工的」) re-implemented, not relaxed, and it is not negotiable in either direction — see `release.yml`'s header for the two releases that were minted without a human and the lane split that ended it. -### Cutting an rc — the snapshot flow (#7447) - -**Actions → Cut RC → Run workflow**, branch `main`, `version` = the version you -expect to come out (e.g. `17.0.0-rc.6`). Run it once with **`dry_run` checked** -first; that stops before anything irreversible and uploads the exact commit a real -run would push, so the first real dispatch is never this workflow's first execution. - -What it does, in order: - -1. Checks out `main` and records that sha — the snapshot. Nothing downstream - re-reads `main`, so **`main` may keep moving for the whole run**. -2. Builds the workspace, both scopes, then the Console (`pnpm run build`, then - `pnpm run console:build`) from that snapshot. The dist carries the tracked - `sdui.manifest.json`, whose freshness the required `Build & E2E UI` job already - held on the PR that changed it and in the merge queue. A Console that cannot build - stops the cut before anything is written. -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**. -5. Pushes one squashed version commit to `main`, then publishes **from that landed - 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 standing Version Packages PR *was* force-refreshed on -every main push, so its CI could not converge while main was 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. - -⚠️ **The first half of that premise no longer holds** (#11233, 2026-08-23): -`release.yml`'s `version-pr` job moved off `push` onto a 6-hourly `schedule` plus an -on-demand `workflow_dispatch` (`refresh_version_pr`), so between refreshes -`changeset-release/main` is static and its CI does converge. This section is left -standing because the *snapshot* lane was never only about that, and `cut-rc` is still -the rc route. What it does mean is that "the -version PR can never converge" is no longer a reason to reach for it. - -**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 — -`contents: write` alone does not put it there. Either add the GitHub Actions app to -the bypass list (ideally scoped to this workflow), or store a fine-grained PAT for an -account already on that list as the repository secret `RELEASE_PUSH_TOKEN`. The -workflow uses the secret when present and the Actions identity otherwise, so -configuring either one is enough and neither needs a workflow edit. Until one is -configured the push step fails with that message and **nothing is published**. - -**What happens to the standing Version Packages PR.** After a cut, the standing -`chore: version packages (rc)` PR ([#6208](https://github.com/objectstack-ai/objectstack/pull/6208), -branch `changeset-release/main`) comes to rest **stale**, showing an -already-consumed version bump, until the next refresh of the version-PR lane. Since -#11233 that refresh is the 6-hourly `schedule` (or one `workflow_dispatch` with -`refresh_version_pr`, if you want it now) — **not** the cut's own push, under either -push credential, because `version-pr` no longer runs on `push` at all. - -The push credential still decides whether the cut's push fires `release.yml` at all, -which matters for the `release-integrity` lane below — so it is written down here -rather than rediscovered at 2am: - -- **Route (a), the Actions identity.** GitHub does not create workflow runs from - events triggered by the automatic `GITHUB_TOKEN` — the documented recursion guard, - whose only exceptions are `workflow_dispatch` and `repository_dispatch`. So the - version-commit push does **not** fire `release.yml`'s `push` lane at all. This repo - already depends on that guard elsewhere and says so: see `docker-publish.yml`'s - header, which explains that a `push: tags:` trigger "would never fire" because the - release workflow pushes its tags with `GITHUB_TOKEN`. -- **Route (b), a PAT in `RELEASE_PUSH_TOKEN`.** A PAT is not the `GITHUB_TOKEN`, so - the push triggers normally and `release-integrity` audits the pushed commit. - -**A stale #6208 after an rc cut is EXPECTED AND HARMLESS — do not "fix" it by hand.** -Its changesets were consumed by the cut and MOVED into `.changeset/pre/` — under -`@changesets/cli` v3 that move is what "consumed" looks like on disk, and -`.changeset/pre.json` carries `{"mode","tag"}` and nothing else, so it is not a -record of what was consumed (it was, under v2). The PR is bookkeeping, it carries no -publish capability by construction (`release.yml` passes the changesets action no -`publish:` script), and it regenerates correctly at the next scheduled refresh (at -most six hours; sooner if you dispatch one) or the next GA cut. Editing or -force-refreshing it manually only risks putting a version commit somewhere the -publish lane can reach. - -**The runtime image is not built here.** `release.yml`'s `release-integrity` lane runs -on every push to `main` and requests the image once the version is on npm, so it -follows within the next merge or two. Note that this is a *later* push under **both** -routes, not the cut's own: on route (a) the cut's push triggers nothing at all, and on -route (b) it does trigger, but `release-integrity` runs its audit long before the -publish step further down the cut has put the version on npm — so it reports "release -pending a human" and exits green either way. For an image immediately, dispatch -`docker-publish.yml` with the version. - ### 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, and the human review of the -generated changelogs on the PR. Like an rc, a GA release ships the Console built from +generated changelogs on the PR. A GA release ships the Console built from `ui/apps/console` at the commit it releases. Merge the `chore: version packages` PR (#4935); that push is what starts the publish lane, and the run then waits at `environment: release` for a required reviewer to approve a job named @@ -317,7 +230,6 @@ never one you supply (ADR-0125 D1/D2). ⛔ **Actions → Release → Run workflo **not** the GA publish path: since ADR-0125 D4 that dispatch takes no version input at all, and left unchecked it starts the repair lane for a publish that died partway — checked, it starts the bookkeeping lane described next. -`release.yml`'s three lanes are untouched by the rc lane. **First, check #4935 is current** (#11233). It is regenerated on a 6-hourly schedule rather than on every push, so at cut time it can be up to one refresh window behind @@ -370,8 +282,8 @@ later tree and the gate rightly ignores it. **This is a wait, not a merge freeze.** `main` keeps moving the whole time: the approval publishes the version commit's tree and nothing that landed after it, so -runs on later commits are not what you are waiting for. The snapshot flow above -chose against coordinated freezes, and a Version Packages PR merged behind `main` is +runs on later commits are not what you are waiting for. The release lanes chose +against coordinated freezes, and a Version Packages PR merged behind `main` is already handled by `annotate-shipped` (#23818); neither changes. **After a stable `@objectstack/spec` publish, refresh the retired-after census** (#20390): run `pnpm --filter @objectstack/spec exec tsx scripts/build-retired-after-census.ts` (prefix `NODE_USE_ENV_PROXY=1` behind a proxy) and commit the rewritten `packages/spec/src/conversions/retired-after.census.json` in an ordinary PR — until it lands, the census test holds an unpublished entry's `retiredAfter` only to the range from the last censused release to the label, not to the label exactly. diff --git a/scripts/check-changeset-no-major.mjs b/scripts/check-changeset-no-major.mjs index f6625466120..5ce32833f47 100644 --- a/scripts/check-changeset-no-major.mjs +++ b/scripts/check-changeset-no-major.mjs @@ -1861,7 +1861,7 @@ export function declarationFromPullRequest(pr) { * * unreadable-diff the diff could not be computed -> exit 1 (#4690) * payload-unreadable a `pull_request` run whose payload would not read -> exit 1 (#4690) - * no-pull-request not a PR run at all (RC cut, local run) -> exit 0 + * no-pull-request not a PR run at all (a local run) -> exit 0 * not-measured-material a reading that DECIDES this verdict is unreadable: * the `Clause-②:` line over a moved package graded * `patch` with no raise (#16776, unchanged), or the @@ -1937,9 +1937,9 @@ export function judgeLevel({ levels, touched, declaration, prEvent = false, base // NO PR TO READ A DECLARATION FROM. This is a different fact from "a PR that // did not declare", and #16776 is what happens when the two share an exit - // code, so they do not share a verdict either. Two callers reach it and - // neither is a PR: the RC cut (`cut-rc.yml`, `workflow_dispatch`, no - // `--event`) and a developer running this script in a checkout. The + // code, so they do not share a verdict either. One caller reaches it, and + // it is not a PR: a developer running this script in a checkout. Every + // workflow call site is a PR run (pinned in the wiring battery). The // declaration lives on a pull request; where there is none, this axis has no // input by construction rather than by omission, and it stands down. // @@ -2236,8 +2236,8 @@ export function renderLevel(result) { case 'no-pull-request': stdout.push( 'ℹ️ LEVEL AXIS: NOT APPLICABLE — this run has no `pull_request` to read a declaration from, so the ' + - 'clause-② axis has no input by construction rather than by omission. It is a PR-scoped reading: the RC cut ' + - '(`cut-rc.yml`) and a local run reach here, and neither is a PR that could have declared.', + 'clause-② axis has no input by construction rather than by omission. It is a PR-scoped reading: a local run ' + + 'reaches here, and it is not a PR that could have declared.', ...readings, ...unreadableNote, ); @@ -3766,20 +3766,19 @@ function selfTest() { 'and the refusal states the non-inference out loud: a `yes` is not read as `added` and a `(narrowing)` arm is not read as `narrowed` — the REST widening and the driver narrowing are exactly what ruling A grades `patch`', ); - // The two contexts that are NOT a pull request, and the one that only - // looks like it. `cut-rc.yml` runs this script on a `workflow_dispatch` - // with no `--event` at all, over a whole RC snapshot range that certainly - // contains `patch` bumps on packages whose src moved; a rule that reddened - // there would have made the material refusal above unshippable. + // The context that is NOT a pull request, and the one that only looks + // like it. A local run hands over no `--event` at all, over a range that + // can contain `patch` bumps on packages whose src moved; a rule that + // reddened there would refuse a developer who has no PR body to fix. const noPayload = declarationFromPullRequest(readEventPullRequest(null)); assert(noPayload.payload === false && noPayload.value === null, 'no payload at all reports `payload: false` beside the null value — the two facts are read separately'); assert( judgeLevel({ levels: levelsFor(PATCH_HEAD), touched: touchedCli, declaration: noPayload, prEvent: false }).verdict === 'no-pull-request', - 'a run with no pull request is NOT APPLICABLE, never an unread declaration: the RC cut and a local run reach here and neither could have declared', + 'a run with no pull request is NOT APPLICABLE, never an unread declaration: a local run reaches here and could not have declared', ); assert( renderLevel(judgeLevel({ levels: levelsFor(PATCH_HEAD), touched: touchedCli, declaration: noPayload, prEvent: false })).exitCode === 0, - 'and it exits 0 — `cut-rc.yml` gates a whole snapshot range through this script with no event payload, and reddening it would be a rule that cannot ship', + 'and it exits 0 — a local run has no event payload, and reddening it would refuse a developer who has no PR body to fix', ); assert( judgeLevel({ levels: levelsFor(PATCH_HEAD), touched: touchedCli, declaration: noPayload, prEvent: true }).verdict === 'payload-unreadable', @@ -5118,28 +5117,38 @@ function selfTest() { `wiring: naming \`types:\` REPLACES GitHub's default set, so the five this job already needed must all still be listed beside \`edited\` — the label carriers are read on \`labeled\`/\`unlabeled\` and the diff on \`opened\`/\`synchronize\`/\`reopened\`. Got ${JSON.stringify(types)}`, ); - // The OTHER consumer, and why the refusal above may exit 1 at all: the RC cut - // runs this same script over a whole snapshot range on a `workflow_dispatch`, - // where there is no pull request and therefore no declaration to read. It - // reaches the `no-pull-request` lane BECAUSE it hands over no `--event` and - // GitHub sets no `pull_request` payload there. A `--event` grown onto that - // call site, or a second one that is a PR run, would put an RC cut into the - // lane that can refuse — so the shape is pinned where the refusal lives. - const cutRcPath = join(REPO_ROOT, '.github/workflows/cut-rc.yml'); - assert(existsSync(cutRcPath), 'wiring: .github/workflows/cut-rc.yml must exist — it is this script\'s other consumer, and the one the level axis must never red'); - const cutRc = uncommented(existsSync(cutRcPath) ? readFileSync(cutRcPath, 'utf8') : ''); - const cutRcCalls = [...cutRc.matchAll(/node scripts\/check-changeset-no-major\.mjs([^\n]*)/g)].map((m) => m[1]); + // Why the refusal above may exit 1 at all: every workflow call site of this + // script is a PR run, so on CI the `no-pull-request` lane is unreachable and + // only a local run stands down there. The retired RC lane, a + // `workflow_dispatch` over a whole snapshot range with no `--event`, was the + // one CI caller that took that lane; it left with the rc channel. Pinned + // over every workflow, not one file, so a NEW non-PR caller cannot arrive + // unread: it would stand down silently, and its PR has to move this pin and + // say why the stand-down lane is right for it. + const workflowDir = join(REPO_ROOT, '.github/workflows'); + const workflowFiles = existsSync(workflowDir) + ? readdirSync(workflowDir).filter((f) => /\.ya?ml$/.test(f)).sort() + : []; + assert( + workflowFiles.length > 1, + `wiring: .github/workflows must list the workflow files this pin walks (found ${workflowFiles.length}) — a walk over nothing passes every assertion below`, + ); + const workflowText = (f) => uncommented(readFileSync(join(workflowDir, f), 'utf8')); + const callSites = workflowFiles.flatMap((f) => + [...workflowText(f).matchAll(/node scripts\/check-changeset-no-major\.mjs([^\n]*)/g)].map((m) => ({ file: f, args: m[1].trim() })), + ); assert( - cutRcCalls.length === 2 && cutRcCalls.some((c) => /^\s*--self-test\s*$/.test(c)), - `wiring: cut-rc.yml is expected to invoke this script exactly twice — \`--self-test\` then the real scan (found ${cutRcCalls.length}: ${JSON.stringify(cutRcCalls)})`, + callSites.some((c) => c.file === 'pr-automation.yml'), + `control: the walk finds the real scan in pr-automation.yml — otherwise the two pins below judge an empty list (found ${JSON.stringify(callSites)})`, ); assert( - cutRcCalls.every((c) => !/--event\b/.test(c)), - 'wiring: no cut-rc.yml call site may pass `--event` — the RC cut is a `workflow_dispatch` with no pull request, and the level axis stands down there by having no declaration carrier at all. Handing it one would put a whole snapshot range into the lane that can refuse (#16776)', + callSites.every((c) => c.file === 'pr-automation.yml'), + `wiring: every workflow call site of this script must be in pr-automation.yml — a call site anywhere else is a caller the level axis has not been read against, and a non-PR one stands down without a word (found ${JSON.stringify(callSites)})`, ); + const onBlock = (f) => (workflowText(f).match(/^on:[\s\S]*?\njobs:/m) ?? [''])[0]; assert( - !/pull_request/.test((cutRc.match(/^on:[\s\S]*?\njobs:/m) ?? [''])[0]), - 'wiring: cut-rc.yml must stay off `pull_request` triggers — its `no-pull-request` lane is what keeps the #16776 refusal shippable, and a PR trigger there would make it a PR run with a payload', + [...new Set(callSites.map((c) => c.file))].every((f) => /\bpull_request\b/.test(onBlock(f))), + 'wiring: every workflow that runs this script must be triggered by `pull_request` — on a PR run the runner writes the payload, so an unreadable one fails closed instead of taking the stand-down lane (#16776)', ); } diff --git a/scripts/check-docs-image-tag.mjs b/scripts/check-docs-image-tag.mjs index 8dc33074efe..d0d97f41f9c 100644 --- a/scripts/check-docs-image-tag.mjs +++ b/scripts/check-docs-image-tag.mjs @@ -1648,7 +1648,7 @@ async function selfTest() { 'mode "exit" (after `changeset pre exit`, before the version pass that deletes pre.json) still expects the ' + `newest GA -- keying on "pre" alone reds every PR in that window. Got ${JSON.stringify(inExit)}`, ); - // 6 -- an rc line (cut-rc.yml runs only in pre mode, tag rc) is a prerelease line too. + // 6 -- an rc line (pre mode, tag rc) is a prerelease line too. const inRc = expectedVersion(preTree('pre-rc', { cli: '17.8.0-rc.0', pre: '{"mode":"pre","tag":"rc"}' })); assert( inRc.version === '17.7.0' && inRc.preMode?.tag === 'rc', diff --git a/scripts/check-self-test-wired.mjs b/scripts/check-self-test-wired.mjs index 89cf1f59144..9a396842afe 100644 --- a/scripts/check-self-test-wired.mjs +++ b/scripts/check-self-test-wired.mjs @@ -243,9 +243,9 @@ const ROOT_DIR_WATCH_HINTS = ['scripts/**/*.mjs', 'scripts/**/*.mts', 'scripts/* * * - a leading `./` is stripped rather than kept, because it is the same file * under a different spelling and paths are compared by EXACT equality here. - * `cut-rc.yml` and `release.yml` both import from `"./scripts/check-docs- - * image-tag.mjs"`; keying those to `./scripts/…` would silently drop two - * live invocations the old pattern did credit. + * `release.yml` imports from `"./scripts/check-docs-image-tag.mjs"`; + * keying that to `./scripts/…` would silently drop a live invocation the + * old pattern did credit. * - a segment must START with an alphanumeric or `_`, so `..` is not a * prefix segment. A `../…` path cannot be resolved against this ROOT * without knowing what it is relative to, and inventing a key for it is how @@ -1148,8 +1148,8 @@ function selfTest() { } { // Control 2 — `./scripts/…` still normalises onto the walk's key. Live - // spelling: `cut-rc.yml` and `release.yml` import from `"./scripts/…"`, - // and keying those anywhere else silently drops two real invocations. + // spelling: `release.yml` imports from `"./scripts/…"`, and keying that + // anywhere else silently drops a real invocation. const got = collectInvocations( wf(' - run: node --input-type=module -e \'import { X } from "./scripts/g.mjs";\'\n'), {}, diff --git a/scripts/check-skill-compatibility-version.mjs b/scripts/check-skill-compatibility-version.mjs index 42eb5120099..b3336030885 100644 --- a/scripts/check-skill-compatibility-version.mjs +++ b/scripts/check-skill-compatibility-version.mjs @@ -595,7 +595,7 @@ export function readSkillFiles(root = REPO_ROOT) { * Every repo-relative path `--fix` may write, in the order the walk visits them. * * This is the export the post-version shape guard reads (`release.yml`'s - * `Validate the post-version tree`, `cut-rc.yml`'s release-file allowlist), on the + * `Validate the post-version tree`), on the * terms of `stampedPaths()` in sync-template-versions.mjs and `syncedPaths()` in * sync-release-index-currency.mjs: the root `version` script ends with this * script's `--fix`, so the SKILL.md files it restamps are a version-time output, diff --git a/scripts/docs-audit/README.md b/scripts/docs-audit/README.md index c9a098359b2..e64b64a6729 100644 --- a/scripts/docs-audit/README.md +++ b/scripts/docs-audit/README.md @@ -901,8 +901,8 @@ monthly / per-release) catches drift the CI gate missed — it runs the `coverage-nightly`, `engine-split-metric`, `prerelease-pin-watch`, `publish-smoke`, `rerun-safety-nightly` (retired since), `scaffold-e2e`, `showcase-smoke`, `stale`, `validate-deps`) were all unrelated. Derive that list with `grep -rl '^\s*schedule:' .github/workflows/` - rather than copying it: an unanchored grep for `schedule:` also matches `cut-rc.yml`, - whose only hit is a comment saying it deliberately has no schedule. + rather than copying it: an unanchored grep for `schedule:` also matches comment lines, + such as one saying a workflow deliberately has no schedule. ⚠️ **Read the registered workflow list and its run history, not the YAML on disk.** The two disagree in *both* directions: a workflow can be registered in Actions with no file on `main` (`matrix-aggregate-experiment.yml` is, today), and a scheduled diff --git a/scripts/pm/clause2-line.mjs b/scripts/pm/clause2-line.mjs index 26bd1c57aa4..92ce31d5874 100644 --- a/scripts/pm/clause2-line.mjs +++ b/scripts/pm/clause2-line.mjs @@ -39,7 +39,7 @@ * * So the reader moved out from under the sweep. `check-changeset-no-major.mjs` * and `check-adr-0087-registration.mjs` run in `lint.yml` and - * `pr-automation.yml` on every PR, and in `cut-rc.yml` on a dispatched cut. + * `pr-automation.yml` on every PR. * `release.yml` names them in PROSE only (`:358`, `:590` — why a major cannot * reach that lane), runs neither, and therefore never loads this module. Before * this extraction each gate pulled in `pm/check-clause2-carriers.mjs` — ten diff --git a/scripts/pm/dispatch-gates.self-test.mjs b/scripts/pm/dispatch-gates.self-test.mjs index 22af658b7c1..3cdf5da2b37 100644 --- a/scripts/pm/dispatch-gates.self-test.mjs +++ b/scripts/pm/dispatch-gates.self-test.mjs @@ -5486,8 +5486,8 @@ export function selfTest({ tier = 'full' } = {}) { ); // The complement, so the zero above cannot be the union quietly hiding a // member: a family reached by NO PR-time event at all must come from a - // workflow that declares no `schedule` either. Today that is `cut-rc.yml`, - // the human release lane, which is `workflow_dispatch`-only. + // workflow that declares no `schedule` either (a `workflow_dispatch`-only + // lane, say); the set may be empty. const noPRTime = triggerFamilies.filter((e) => !reachesPRTime(e.workflows)); t( `the complement agrees: all ${noPRTime.length} famil(ies) reached by no PR-time event at all come from workflows that declare no schedule`, diff --git a/scripts/pm/release-rehearsal-clone.mjs b/scripts/pm/release-rehearsal-clone.mjs index ef88cbcec94..ee5af953869 100755 --- a/scripts/pm/release-rehearsal-clone.mjs +++ b/scripts/pm/release-rehearsal-clone.mjs @@ -78,7 +78,7 @@ * * ## Not a production defect, and not wired into `pnpm run version` * - * `cut-rc.yml` and `release.yml` check out with `fetch-depth: 0`, so the real + * `release.yml` checks out with `fetch-depth: 0`, so the real * cut has full history and never enters this branch. This is a LOCAL rehearsal * trap — but a throwaway-clone rehearsal is the prescribed verification route * for release-machinery changes, so it sits on the path of every card in that diff --git a/scripts/release-dist-tags.mjs b/scripts/release-dist-tags.mjs index de1a7739ea6..43bd38ee52e 100644 --- a/scripts/release-dist-tags.mjs +++ b/scripts/release-dist-tags.mjs @@ -3,10 +3,11 @@ /** * release-dist-tags -- after a STABLE publish, move every pre-release npm - * dist-tag that a release has overtaken up to `latest`. + * dist-tag that a release has overtaken up to `latest`, and remove every tag of + * a retired channel. * - * node scripts/release-dist-tags.mjs # plan: print every move, write nothing - * node scripts/release-dist-tags.mjs --apply # move them (`npm dist-tag add`) + * node scripts/release-dist-tags.mjs # plan: print every move and removal, write nothing + * node scripts/release-dist-tags.mjs --apply # perform them (`npm dist-tag add` / `rm`) * RELEASE_VERSION=V node scripts/release-dist-tags.mjs --apply * # from release.yml's `publish` job * node scripts/release-dist-tags.mjs --self-test # verify this logic @@ -38,12 +39,18 @@ * `next` = 19.0.0-next.1 over `latest` = 18.1.0) and is left alone; * - a tag on a STABLE version is not a pre-release tag -- a maintenance line * someone pinned on purpose -- and is left alone, as is anything that is - * not semver at all; `latest` itself is never written. + * not semver at all; `latest` itself is never written; + * - a tag in `RETIRED_TAGS` is REMOVED (`npm dist-tag rm`), whatever version + * it points at. The release channels are `latest` (stable) and `next` + * (pre-release, published by release.yml in pre mode); `rc` is retired, so + * moving it would keep a channel alive that nothing publishes to any more. + * The rule for the live tags above is unchanged: `next` still moves. * * ## What it refuses, and why * - * - A pre-release REFERENCE moves nothing. A pre-release publish carries its - * own tag and leaves `latest` where it was, so there is nothing to catch up. + * - A pre-release REFERENCE moves nothing, and removes nothing. A pre-release + * publish carries its own tag and leaves `latest` where it was, so there is + * nothing to catch up; a retired tag goes at the next stable publish. * - With `RELEASE_VERSION` given, a package whose registry `latest` is not * that version is SKIPPED with a warning, after a short bounded re-read for * the registry's read path to settle. Moving `next` to a version that is @@ -113,6 +120,14 @@ export const SWEEP_STEP_NAME = 'Move stale pre-release dist-tags up to latest'; const DEFAULT_REGISTRY = 'https://registry.npmjs.org'; const USER_AGENT = 'objectstack-release-dist-tags'; +/** + * Dist-tags of retired release channels. Removed, never moved: a moved `rc` + * would still resolve, and a reader of the registry would take it for a live + * channel. The live channels are `latest` and `next` (docs/releases-maintenance.md, + * "Release channels"); a channel retired later is added here. + */ +export const RETIRED_TAGS = Object.freeze(['rc']); + /** Per-request ceiling, so one hung socket cannot hold the step. */ export const REQUEST_TIMEOUT_MS = 15 * 1000; @@ -137,11 +152,12 @@ const SELF_TEST_BATTERIES = Object.freeze({ '4. Failures are named, counted and never stop the rest': 7, '5. End to end: a stub registry and a stub npm on PATH': 7, '6. The release.yml wiring: after the publish, non-blocking, no token': 9, + '7. Retired channels: removed, never moved, and only those': 9, }); // Deleting an entry silences that battery's floor exactly as effectively as // zeroing it, so the roster's own size is pinned too. -const SELF_TEST_BATTERY_FLOOR = 6; +const SELF_TEST_BATTERY_FLOOR = 7; const UNATTRIBUTED_BATTERY = '(no battery open)'; const batteryCases = new Map(); @@ -196,26 +212,34 @@ let selfTestReachedVerdict = false; export class RegistryUnreadable extends Error {} /** - * Which of `tags` are stale against `reference`, and which pre-release tags are - * kept because a cycle is in progress. + * Which of `tags` are stale against `reference`, which belong to a retired + * channel, and which pre-release tags are kept because a cycle is in progress. * - * Pure: no registry, no npm. `latest` is never in either list. + * Pure: no registry, no npm. `latest` is never in any list. * * @param {Record} tags the package's dist-tags * @param {string} reference a stable semver version - * @returns {{ moves: { tag: string, from: string, to: string }[], kept: { tag: string, version: string }[] }} + * @param {readonly string[]} [retired] tags to remove rather than move + * @returns {{ moves: { tag: string, from: string, to: string }[], removes: { tag: string, from: unknown }[], kept: { tag: string, version: string }[] }} */ -export function planTags(tags, reference) { +export function planTags(tags, reference, retired = RETIRED_TAGS) { const moves = []; + const removes = []; const kept = []; for (const [tag, version] of Object.entries(tags ?? {}).sort(([a], [b]) => a.localeCompare(b))) { if (tag === 'latest') continue; + // Before any version test: a retired tag goes whatever it points at, a + // stable version or a value that is not semver at all. + if (retired.includes(tag)) { + removes.push({ tag, from: version }); + continue; + } if (typeof version !== 'string' || !semver.valid(version)) continue; if (semver.prerelease(version) === null) continue; if (semver.lt(version, reference)) moves.push({ tag, from: version, to: reference }); else kept.push({ tag, version }); } - return { moves, kept }; + return { moves, removes, kept }; } /** @@ -272,6 +296,22 @@ export async function npmDistTagAdd({ name, tag, to }) { return null; } +/** + * Remove one tag with the npm CLI. Resolves `null` on success, or the failure text. + * + * @param {{ name: string, tag: string }} removal + * @returns {Promise} + */ +export async function npmDistTagRm({ name, tag }) { + const result = spawnSync('npm', ['dist-tag', 'rm', name, tag], { encoding: 'utf8' }); + if (result.error) return `could not run npm: ${result.error.message}`; + if (result.status !== 0) { + const tail = `${result.stderr ?? ''}${result.stdout ?? ''}`.trim().split('\n').slice(-3).join(' | '); + return `npm dist-tag rm exited ${result.status}${tail ? `: ${tail}` : ''}`; + } + return null; +} + const realSleep = (ms) => new Promise((resolve_) => { setTimeout(resolve_, ms); }); /** @@ -287,10 +327,11 @@ const realSleep = (ms) => new Promise((resolve_) => { setTimeout(resolve_, ms); * @param {boolean} [opts.apply] * @param {(name: string) => Promise | null>} opts.read * @param {(move: { name: string, tag: string, to: string }) => Promise} opts.write + * @param {(removal: { name: string, tag: string }) => Promise} [opts.remove] * @param {(ms: number) => Promise} [opts.sleep] * @param {readonly number[]} [opts.settleDelays] * @param {(line: string) => void} [opts.log] - * @returns {Promise<{ moved: object[], planned: object[], kept: object[], skipped: object[], failures: object[] }>} + * @returns {Promise<{ moved: object[], planned: object[], removed: object[], plannedRemovals: object[], kept: object[], skipped: object[], failures: object[] }>} */ export async function sweep({ names, @@ -298,11 +339,12 @@ export async function sweep({ apply = false, read, write, + remove = npmDistTagRm, sleep = realSleep, settleDelays = LATEST_SETTLE_DELAYS_MS, log = (line) => console.log(line), }) { - const out = { moved: [], planned: [], kept: [], skipped: [], failures: [] }; + const out = { moved: [], planned: [], removed: [], plannedRemovals: [], kept: [], skipped: [], failures: [] }; for (const name of names) { let tags; try { @@ -336,7 +378,7 @@ export async function sweep({ }); continue; } - const { moves, kept } = planTags(tags, latest); + const { moves, removes, kept } = planTags(tags, latest); for (const k of kept) { out.kept.push({ name, ...k }); log(` kept ${name} ${k.tag}: ${k.version} (ahead of latest ${latest}, a pre-release cycle in progress)`); @@ -356,6 +398,21 @@ export async function sweep({ out.moved.push(row); log(` moved ${name} ${move.tag}: ${move.from} → ${move.to}`); } + for (const removal of removes) { + const row = { name, ...removal }; + if (!apply) { + out.plannedRemovals.push(row); + log(` plan ${name} ${removal.tag}: ${removal.from} → removed (retired channel)`); + continue; + } + const failure = await remove(row); + if (failure) { + out.failures.push({ name, reason: `${removal.tag}: ${removal.from} not removed — ${failure}` }); + continue; + } + out.removed.push(row); + log(` removed ${name} ${removal.tag}: ${removal.from} (retired channel)`); + } } return out; } @@ -372,6 +429,7 @@ export async function main({ env = process.env, read = (name) => readDistTags(name), write = npmDistTagAdd, + remove = npmDistTagRm, sleep = realSleep, log = (line) => console.log(line), warn = (line) => console.error(line), @@ -412,11 +470,13 @@ export async function main({ `${apply ? 'Sweeping' : 'Planning (read-only; pass --apply to write)'} the dist-tags of ${names.length} package(s)` + ` against ${releaseVersion ? `the release ${releaseVersion}` : "each package's own latest"}.`, ); - const result = await sweep({ names, releaseVersion, apply, read, write, sleep, log }); + const result = await sweep({ names, releaseVersion, apply, read, write, remove, sleep, log }); for (const s of result.skipped) log(` skip ${s.name}: ${s.reason}`); for (const f of result.failures) warn(`::warning::dist-tags of ${f.name}: ${f.reason}`); - const done = apply ? `${result.moved.length} tag(s) moved` : `${result.planned.length} tag(s) would move`; + const done = apply + ? `${result.moved.length} tag(s) moved, ${result.removed.length} retired tag(s) removed` + : `${result.planned.length} tag(s) would move, ${result.plannedRemovals.length} retired tag(s) would be removed`; log( `${done}, ${result.kept.length} kept ahead of latest, ${result.skipped.length} package(s) skipped, ` + `${result.failures.length} failure(s).`, @@ -472,15 +532,15 @@ export async function selfTest() { battery('1. The plan: stale pre-release tags move, everything else stays'); { const filed = planTags(FILED, '18.0.0'); - t('the filed state: next AND rc move to latest', JSON.stringify(filed.moves.map((m) => m.tag)) === '["next","rc"]', JSON.stringify(filed)); + t('the filed state: next moves to latest', JSON.stringify(filed.moves.map((m) => m.tag)) === '["next"]', JSON.stringify(filed)); t('each move lands on the reference', filed.moves.every((m) => m.to === '18.0.0'), JSON.stringify(filed.moves)); t('a move records where the tag was', filed.moves.find((m) => m.tag === 'next')?.from === '18.0.0-next.5'); - t('latest itself is never planned', !filed.moves.some((m) => m.tag === 'latest') && !filed.kept.some((k) => k.tag === 'latest')); + t('latest itself is never planned', ![...filed.moves, ...filed.removes, ...filed.kept].some((r) => r.tag === 'latest')); const now = planTags(NOW, '18.1.0'); - t('the state after 18.1.0: both still move', now.moves.length === 2 && now.moves.every((m) => m.to === '18.1.0'), JSON.stringify(now)); - const ahead = planTags({ latest: '18.1.0', next: '19.0.0-next.1', rc: '18.0.0-rc.2' }, '18.1.0'); + t('the state after 18.1.0: next still moves', now.moves.length === 1 && now.moves[0].tag === 'next' && now.moves[0].to === '18.1.0', JSON.stringify(now)); + const ahead = planTags({ latest: '18.1.0', next: '19.0.0-next.1', beta: '18.0.0-beta.2' }, '18.1.0'); t('a pre-release AHEAD of latest is a cycle in progress, and is kept', ahead.kept.length === 1 && ahead.kept[0].tag === 'next', JSON.stringify(ahead)); - t('... while a stale sibling beside it still moves', ahead.moves.length === 1 && ahead.moves[0].tag === 'rc', JSON.stringify(ahead)); + t('... while a stale sibling beside it still moves', ahead.moves.length === 1 && ahead.moves[0].tag === 'beta', JSON.stringify(ahead)); t('a pre-release of the SAME version as latest is below it, and moves', planTags({ latest: '18.1.0', next: '18.1.0-next.3' }, '18.1.0').moves.length === 1); t('a tag on a STABLE version is a maintenance line, never touched', planTags({ latest: '18.1.0', v17: '17.5.0' }, '18.1.0').moves.length === 0); const odd = planTags({ latest: '18.1.0', canary: 'not-a-version', next: 42 }, '18.1.0'); @@ -492,20 +552,21 @@ export async function selfTest() { { const names = ['@objectstack/cli']; const write = async () => null; - const pre = await main({ argv: ['--apply'], env: { RELEASE_VERSION: '19.0.0-next.1' }, read: async () => { throw new Error('read'); }, write, log: quiet, warn: quiet }); + const remove = async () => null; + const pre = await main({ argv: ['--apply'], env: { RELEASE_VERSION: '19.0.0-next.1' }, read: async () => { throw new Error('read'); }, write, remove, log: quiet, warn: quiet }); t('a pre-release RELEASE_VERSION is a no-op, exit 0, nothing read', pre === 0); - const bad = await main({ argv: ['--apply'], env: { RELEASE_VERSION: 'v-latest' }, read: async () => NOW, write, log: quiet, warn: quiet }); + const bad = await main({ argv: ['--apply'], env: { RELEASE_VERSION: 'v-latest' }, read: async () => NOW, write, remove, log: quiet, warn: quiet }); t('a non-semver RELEASE_VERSION is refused', bad === 1); - const match = await sweep({ names, releaseVersion: '18.1.0', apply: true, read: async () => NOW, write, sleep: async () => {}, log: quiet }); - t('latest == RELEASE_VERSION: the stale tags move', match.moved.length === 2 && match.failures.length === 0, JSON.stringify(match)); + const match = await sweep({ names, releaseVersion: '18.1.0', apply: true, read: async () => NOW, write, remove, sleep: async () => {}, log: quiet }); + t('latest == RELEASE_VERSION: next moves and rc is removed', match.moved.length === 1 && match.removed.length === 1 && match.failures.length === 0, JSON.stringify(match)); let reads = 0; const lagging = await sweep({ - names, releaseVersion: '18.1.0', apply: true, write, sleep: async () => {}, log: quiet, + names, releaseVersion: '18.1.0', apply: true, write, remove, sleep: async () => {}, log: quiet, read: async () => { reads += 1; return reads < 3 ? { ...NOW, latest: '18.0.0' } : NOW; }, }); - t('a lagging latest is re-read until it agrees, then the tags move', lagging.moved.length === 2 && reads === 3, `reads=${reads} ${JSON.stringify(lagging)}`); + t('a lagging latest is re-read until it agrees, then the tags move', lagging.moved.length === 1 && lagging.removed.length === 1 && reads === 3, `reads=${reads} ${JSON.stringify(lagging)}`); reads = 0; const slept = []; @@ -513,12 +574,13 @@ export async function selfTest() { names, releaseVersion: '18.1.0', apply: true, log: quiet, read: async () => { reads += 1; return { ...NOW, latest: '18.0.0' }; }, write: async () => 'must not be called', + remove: async () => 'must not be called', sleep: async (ms) => { slept.push(ms); }, }); - t('a latest that never agrees moves NOTHING and is a named failure', behind.moved.length === 0 && behind.failures.length === 1 && /latest is 18\.0\.0, not 18\.1\.0/.test(behind.failures[0].reason), JSON.stringify(behind)); + t('a latest that never agrees moves NOTHING, removes nothing, and is a named failure', behind.moved.length === 0 && behind.removed.length === 0 && behind.failures.length === 1 && /latest is 18\.0\.0, not 18\.1\.0/.test(behind.failures[0].reason), JSON.stringify(behind)); t('... after exactly the bounded settle schedule', JSON.stringify(slept) === JSON.stringify(LATEST_SETTLE_DELAYS_MS) && reads === LATEST_SETTLE_DELAYS_MS.length + 1, `${JSON.stringify(slept)} reads=${reads}`); - const preLatest = await sweep({ names, apply: true, read: async () => ({ latest: '18.0.0-rc.1', next: '17.0.0-next.1' }), write, log: quiet }); + const preLatest = await sweep({ names, apply: true, read: async () => ({ latest: '18.0.0-rc.1', next: '17.0.0-next.1' }), write, remove, log: quiet }); t('by hand, a package whose latest is a pre-release is skipped', preLatest.moved.length === 0 && preLatest.skipped.length === 1, JSON.stringify(preLatest)); } @@ -528,13 +590,15 @@ export async function selfTest() { const names = ['@objectstack/cli', '@objectstack/spec']; const writes = []; const write = async (m) => { writes.push(`${m.name}@${m.to} ${m.tag}`); return null; }; - const plan = await sweep({ names, read: async () => NOW, write, log: quiet }); - t('plan mode calls no writer', writes.length === 0, JSON.stringify(writes)); - t('plan mode reports every move it would make', plan.planned.length === 4 && plan.moved.length === 0, JSON.stringify(plan)); - const applied = await sweep({ names, apply: true, read: async () => NOW, write, log: quiet }); - t('--apply writes each planned move once', writes.length === 4 && applied.moved.length === 4, JSON.stringify(writes)); + const removals = []; + const remove = async (r) => { removals.push(`${r.name} ${r.tag}`); return null; }; + const plan = await sweep({ names, read: async () => NOW, write, remove, log: quiet }); + t('plan mode calls no writer and no remover', writes.length === 0 && removals.length === 0, JSON.stringify({ writes, removals })); + t('plan mode reports every move and every removal it would make', plan.planned.length === 2 && plan.plannedRemovals.length === 2 && plan.moved.length === 0 && plan.removed.length === 0, JSON.stringify(plan)); + const applied = await sweep({ names, apply: true, read: async () => NOW, write, remove, log: quiet }); + t('--apply performs each planned move and removal once', writes.length === 2 && applied.moved.length === 2 && removals.length === 2 && applied.removed.length === 2, JSON.stringify({ writes, removals })); t('--apply writes the next tag of each package to latest', writes.includes('@objectstack/cli@18.1.0 next') && writes.includes('@objectstack/spec@18.1.0 next'), JSON.stringify(writes)); - const ahead = await sweep({ names: ['x'], apply: true, read: async () => ({ latest: '18.1.0', next: '19.0.0-next.1' }), write: async () => 'must not be called', log: quiet }); + const ahead = await sweep({ names: ['x'], apply: true, read: async () => ({ latest: '18.1.0', next: '19.0.0-next.1' }), write: async () => 'must not be called', remove: async () => 'must not be called', log: quiet }); t('--apply never writes a tag that is ahead of latest', ahead.moved.length === 0 && ahead.failures.length === 0 && ahead.kept.length === 1, JSON.stringify(ahead)); } @@ -547,20 +611,20 @@ export async function selfTest() { b: async () => null, c: async () => NOW, }; - const mixed = await sweep({ names, apply: true, read: (n) => readers[n](), write: async (m) => (m.tag === 'rc' ? 'E403 forbidden' : null), log: quiet }); + const mixed = await sweep({ names, apply: true, read: (n) => readers[n](), write: async () => null, remove: async () => 'E403 forbidden', log: quiet }); t('an unreadable registry is a failure naming the package', mixed.failures.some((f) => f.name === 'a' && /503/.test(f.reason)), JSON.stringify(mixed.failures)); t('a package not on the registry is a skip, not a failure', mixed.skipped.some((s) => s.name === 'b') && !mixed.failures.some((f) => f.name === 'b')); - t('a refused write is a failure naming the tag and the reason', mixed.failures.some((f) => f.name === 'c' && /^rc: .*E403/.test(f.reason)), JSON.stringify(mixed.failures)); + t('a refused write is a failure naming the tag and the reason', mixed.failures.some((f) => f.name === 'c' && /^rc: .*not removed.*E403/.test(f.reason)), JSON.stringify(mixed.failures)); t('... and the other tag of the same package still moves', mixed.moved.length === 1 && mixed.moved[0].tag === 'next', JSON.stringify(mixed.moved)); const warned = []; - const code = await main({ argv: ['--apply'], env: {}, read: (n) => (n === '@objectstack/cli' ? readers.a() : NOW), write: async () => null, log: quiet, warn: (l) => warned.push(l) }); + const code = await main({ argv: ['--apply'], env: {}, read: (n) => (n === '@objectstack/cli' ? readers.a() : NOW), write: async () => null, remove: async () => null, log: quiet, warn: (l) => warned.push(l) }); t('main exits non-zero when anything failed', code === 1); t('... and prints one ::warning:: per failure', warned.filter((l) => l.startsWith('::warning::dist-tags of @objectstack/cli')).length === 1, JSON.stringify(warned)); const empty = mkdtempSync(join(tmpdir(), 'dist-tags-empty-')); try { writeFileSync(join(empty, 'pnpm-workspace.yaml'), "packages:\n - 'packages/*'\n"); - const vacuous = await main({ argv: ['--root', empty], env: {}, read: async () => NOW, write: async () => null, log: quiet, warn: quiet }); + const vacuous = await main({ argv: ['--root', empty], env: {}, read: async () => NOW, write: async () => null, remove: async () => null, log: quiet, warn: quiet }); t('an empty package set is refused, not a green sweep over nothing', vacuous === 1); } finally { rmSync(empty, { recursive: true, force: true }); @@ -623,12 +687,12 @@ export async function selfTest() { try { npmCalls = readFileSync(npmLog, 'utf8').trim().split('\n').sort(); } catch { npmCalls = ['(npm never called)']; } const want = [ 'dist-tag add @objectstack/cli@18.1.0 next', - 'dist-tag add @objectstack/cli@18.1.0 rc', - 'dist-tag add create-objectstack@18.1.0 rc', + 'dist-tag rm @objectstack/cli rc', + 'dist-tag rm create-objectstack rc', ]; t('--apply exits 0', applied.status === 0, `${applied.status} ${applied.stderr}`); t('npm is called with exactly the moves, in its own argv form', JSON.stringify(npmCalls) === JSON.stringify(want), JSON.stringify(npmCalls)); - t('the log names each move', /moved {2}@objectstack\/cli next: 18\.0\.0-next\.5 → 18\.1\.0/.test(applied.stdout), applied.stdout); + t('the log names each move and each removal', /moved {2}@objectstack\/cli next: 18\.0\.0-next\.5 → 18\.1\.0/.test(applied.stdout) && /removed @objectstack\/cli rc: 17\.0\.0-rc\.6 \(retired channel\)/.test(applied.stdout), applied.stdout); t('the package not yet on npm is a logged skip', /skip {3}@objectstack\/new: not on the registry/.test(applied.stdout), applied.stdout); } finally { await registry.close(); @@ -660,6 +724,29 @@ export async function selfTest() { t('it is bounded: timeout-minutes is set', Number.isInteger(step['timeout-minutes']) && step['timeout-minutes'] > 0, JSON.stringify(step['timeout-minutes'])); } + // ── 7. Retired channels ─────────────────────────────────────────────────── + battery('7. Retired channels: removed, never moved, and only those'); + { + t('the retired list is exactly rc — the live channels are latest and next', JSON.stringify(RETIRED_TAGS) === '["rc"]' && Object.isFrozen(RETIRED_TAGS), JSON.stringify(RETIRED_TAGS)); + const now = planTags(NOW, '18.1.0'); + t('the registry today: rc is a removal and next is a move, never the other way', JSON.stringify(now.removes) === '[{"tag":"rc","from":"17.0.0-rc.6"}]' && JSON.stringify(now.moves.map((m) => m.tag)) === '["next"]', JSON.stringify(now)); + t('a retired tag on a STABLE version is still removed', planTags({ latest: '18.1.0', rc: '18.1.0' }, '18.1.0').removes.length === 1); + const aheadRc = planTags({ latest: '18.1.0', rc: '19.0.0-rc.1' }, '18.1.0'); + t('a retired tag AHEAD of latest is removed, not kept as a cycle in progress', aheadRc.removes.length === 1 && aheadRc.kept.length === 0, JSON.stringify(aheadRc)); + t('a retired tag on a non-semver value is removed, not skipped', planTags({ latest: '18.1.0', rc: 'garbage' }, '18.1.0').removes.length === 1); + const live = planTags({ latest: '18.1.0', next: '18.1.0-next.3' }, '18.1.0'); + t('next is never retired: a stale next moves and nothing is removed', live.moves.length === 1 && live.removes.length === 0, JSON.stringify(live)); + const unlisted = planTags(NOW, '18.1.0', []); + t('control: the list decides — with no retired tags, rc is a stale pre-release and moves', unlisted.removes.length === 0 && JSON.stringify(unlisted.moves.map((m) => m.tag)) === '["next","rc"]', JSON.stringify(unlisted)); + const lines = []; + await sweep({ names: ['x'], read: async () => NOW, write: async () => 'must not be called', remove: async () => 'must not be called', log: (l) => lines.push(l) }); + t('the plan output names the removal as a removal', lines.some((l) => /^ {2}plan {3}x rc: 17\.0\.0-rc\.6 → removed \(retired channel\)$/.test(l)) && lines.some((l) => /^ {2}plan {3}x next: 18\.0\.0-next\.5 → 18\.1\.0$/.test(l)), JSON.stringify(lines)); + const adds = []; + const rms = []; + await sweep({ names: ['x'], apply: true, read: async () => NOW, write: async (m) => { adds.push(m.tag); return null; }, remove: async (r) => { rms.push(r.tag); return null; }, log: quiet }); + t('--apply removes rc through the remover and never adds it', JSON.stringify(rms) === '["rc"]' && !adds.includes('rc') && JSON.stringify(adds) === '["next"]', JSON.stringify({ adds, rms })); + } + for (const message of batteryFloorFailures()) cases.push({ name: message, ok: false }); const failed = cases.filter((c) => !c.ok); @@ -670,7 +757,7 @@ export async function selfTest() { } console.log( `OK release-dist-tags self-test: ${cases.length} cases pass across ${Object.keys(SELF_TEST_BATTERIES).length} batteries ` - + '(stale next/rc moved to latest, a cycle in progress kept, nothing moved below a latest the run did not set, ' + + '(stale next moved to latest, retired rc removed, a cycle in progress kept, nothing moved below a latest the run did not set, ' + 'failures named without stopping the rest, and the release.yml step wired after the publish, non-blocking).', ); selfTestReachedVerdict = true; diff --git a/scripts/release-spec-changes.sh b/scripts/release-spec-changes.sh index d9cde2bfbe9..f68579aff00 100755 --- a/scripts/release-spec-changes.sh +++ b/scripts/release-spec-changes.sh @@ -3,7 +3,7 @@ # `spec-changes.json` and the protocol upgrade guide. # # bash scripts/release-spec-changes.sh --prepare # BEFORE `changeset publish` (release.yml) -# bash scripts/release-spec-changes.sh --generate # BEFORE `changeset publish` (cut-rc.yml) +# bash scripts/release-spec-changes.sh --generate # BEFORE `changeset publish` # bash scripts/release-spec-changes.sh --verify # after either, BEFORE `changeset publish` # bash scripts/release-spec-changes.sh --attach # AFTER the GitHub Release exists # diff --git a/scripts/release-verify-npm.mjs b/scripts/release-verify-npm.mjs index 7f259d67ff6..ff223634c84 100644 --- a/scripts/release-verify-npm.mjs +++ b/scripts/release-verify-npm.mjs @@ -77,9 +77,11 @@ * that finds the last target, so a good publish pays only its own settle time * (18.0.0, replayed on this schedule, goes green at +17m15s -- battery 14). * Only a GENUINELY broken publish spends all 35 minutes before it is reported. - * Whoever raises it re-checks the caller: the publish job declares no - * `timeout-minutes`, so GitHub's 360-minute job default bounds this step - * together with everything before it (17m23s on 18.0.0, build included). + * Whoever raises it re-checks the caller: the publish job's `timeout-minutes: + * 120` in release.yml bounds this step together with everything before it + * (17m23s on 18.0.0, build included), and that 120 was set from the sum of the + * job's step ceilings, this 35-minute budget among them. Raising the budget + * means re-checking that sum: a job timeout here kills a publish half done. * * ## ⛔ It still fails CLOSED, and that is the whole point * diff --git a/scripts/sync-release-index-currency.mjs b/scripts/sync-release-index-currency.mjs index b694b3cde4a..c36bd2f29bd 100644 --- a/scripts/sync-release-index-currency.mjs +++ b/scripts/sync-release-index-currency.mjs @@ -63,8 +63,7 @@ // -- the gate whose finding this file exists to clear. A second copy of any of them // would be a second contract, and the two silently disagreeing is the original defect // one layer up: the rewriter would stamp one sentence while the gate judged another. -// One list, two consumers -- the principle #9064 wrote down and cut-rc.yml joined as a -// third consumer. +// One list, two consumers -- the principle #9064 wrote down. // // The rewrite is therefore defined as "make the gate's assertion-2 findings go away", // and the verdict at the end is literally `indexCurrencyFindings()` -- the gate's own @@ -166,7 +165,7 @@ // It is also held out of this file's verdict, and that departs from "the verdict is the // gate's own function" on purpose. The gate still reports the field -- advisory on `main`, // red under `--strict` -- and nothing here changes that. But a STOP here fails `pnpm run -// version`: every `cut-rc.yml` prerelease cut and every `version-pr` refresh in pre mode +// version`: every `version-pr` refresh in pre mode // would go red until the GA, for a state no hand-written sentence improves and the GA's // own run resolves. The exit-1 ladder below is for the shapes that need a SENTENCE. // @@ -207,9 +206,9 @@ export function scriptRepoRoot() { /** * The surface this rewriter writes, for the consumers that must know it WITHOUT - * restating it -- release.yml's post-version shape assertion and cut-rc.yml's staging - * allowlist, which resolve `SURFACES` and `stampedPaths()` from their rewriters on - * exactly these terms ("a fourth literal is a fourth contract"). + * restating it -- release.yml's post-version shape assertion, which resolves + * `SURFACES` and `stampedPaths()` from their rewriters on exactly these terms + * ("a fourth literal is a fourth contract"). * * Derived from the gate's `INDEX_PATH`, never a copy of it: if the gate is ever pointed * at a different index, this rewriter and both workflow allowlists follow in the same @@ -349,8 +348,8 @@ export function rewriteStatusField(field, newestVersion, date, { gaVersionRun = * * On the `version-pr` lane `HEAD` is the main commit being rendered: release.yml's * post-version step and changesets/action both run `pnpm run version` on a tree reset - * to it, and the step diffs its output against `HEAD` the same way. cut-rc.yml runs - * it on its snapshot commit. A shallow checkout still holds `HEAD`'s own tree. + * to it, and the step diffs its output against `HEAD` the same way. A shallow + * checkout still holds `HEAD`'s own tree. * * `gitFreeEnv()`: the repository is the one `root` names, never one an inherited * GIT_DIR names (`GIT_ENV_ISOLATION_RULE`), so the self-test can hand this a throwaway @@ -937,8 +936,8 @@ export function selfTest() { // ── Control F ───────────────────────────────────────────────────────────── battery('Control F: the surface and the scope are the gate\'s, not copies'); { - expect('F — syncedPaths() is exactly the gate\'s INDEX_PATH, so release.yml and cut-rc.yml ' - + 'resolve the same one path this file can write', + expect('F — syncedPaths() is exactly the gate\'s INDEX_PATH, so release.yml ' + + 'resolves the same one path this file can write', syncedPaths().length === 1 && syncedPaths()[0] === INDEX_PATH); expect('F — and that surface really exists in this tree; an allowlist naming a path that is ' + 'gone is an allowlist that stages nothing', diff --git a/scripts/sync-template-versions.mjs b/scripts/sync-template-versions.mjs index 32c2123d3fa..a58bde0deb8 100644 --- a/scripts/sync-template-versions.mjs +++ b/scripts/sync-template-versions.mjs @@ -67,7 +67,7 @@ // sync executed at module scope, so a consumer that imported this file to ask // "which files does the version pass stamp?" would have rewritten the templates // instead of getting an answer. The only available option was to restate the -// paths, and `cut-rc.yml`'s release-file allowlist did exactly that: two +// paths, and the retired RC lane's release-file allowlist did exactly that: two // literals, both hard-coding the template name `blank`, in a workflow whose own // comment recorded that it would rather read this list. // @@ -232,11 +232,11 @@ export function findTemplateDirs(templateRoot = TEMPLATE_ROOT) { /** * Every repo-relative path this script may write, across ALL template dirs. * - * This is the export `cut-rc.yml`'s release-file allowlist is for: the paths a + * This is the export `release.yml`'s post-version shape guard is for: the paths a * version pass may touch under `src/templates/`, derived from the same walk and * the same table the sync itself uses, so the workflow cannot describe a * different set than the one that gets written. `sync-docs-image-tags.mjs` and - * that allowlist already read the doc half from `check-docs-image-tag.mjs`'s + * that guard already read the doc half from `check-docs-image-tag.mjs`'s * `SURFACES`; this is the template half on the same terms. * * Zero templates THROWS rather than returning `[]`, for the same reason the run