Skip to content

fix(pm): convert the patrol's remaining count windows to announced ti… #7748

fix(pm): convert the patrol's remaining count windows to announced ti…

fix(pm): convert the patrol's remaining count windows to announced ti… #7748

Workflow file for this run

name: Release
# ══════════════════════════════════════════════════════════════════════════════
# TWO LANES, ONE INVARIANT: ONLY A HUMAN PUBLISHES. (#6170)
# ══════════════════════════════════════════════════════════════════════════════
#
# Maintainer ruling, 2026-08-07 (verbatim, do not translate):
#
# 「刚才我也没提出要求,是哪个ai自己替我发了 rc.4,版本发布必须是人工的。
# 这个要写入规范。」
#
# WHAT THIS FILE USED TO DO, AND WHY IT MINTED TWO RELEASES NOBODY ASKED FOR
# --------------------------------------------------------------------------
# One job, triggered `on: push: branches: [main]`, so EVERY merge-queue landing
# started it. Inside it, two steps in sequence:
#
# 1. changesets/action@v1 with a `publish:` script. With pending changesets it
# takes the version-PR path, which is (its own source, v1):
# git checkout -b changeset-release/main
# git reset --hard <github.context.sha>
# pnpm run version # ← bumps every package.json
# git add . && git commit -m 'chore: version packages'
# git push origin HEAD:changeset-release/main --force
# It never restores the workspace. The job therefore continues on the
# FRESHLY VERSIONED tree, not on main's state.
# 2. "Ensure this version actually shipped" (`recover-publish`) read
# `packages/cli/package.json` FROM THAT WORKSPACE. It documented itself as
# "a no-op on the normal path, where main's version IS the last released
# one" — but after step 1 the workspace carries the NEXT version, which is
# ALWAYS absent from npm. So its repair branch fired and ran the real
# publish: 69 packages to npm + an atomic tag push at a commit that only
# ever existed on `changeset-release/main`.
#
# Twice, platform-stamped, with no human anywhere in the trigger chain:
# 17.0.0-rc.3 — 2026-08-03, version commit c6a52d3 (cleanup #6135 → #6149)
# 17.0.0-rc.4 — 2026-08-07, version commit a10cbc77 (cleanup #6169)
# Run 31146224227 is the rc.4 receipt: event `push`, actor
# `github-merge-queue[bot]`. The 4 quiet days in between are the same mechanism
# reporting green — the computed next version happened to already be on npm.
#
# HOW THE LANES ARE SPLIT NOW
# ---------------------------
# schedule (6-hourly) → `version-pr` keeps the "chore: version packages" PR
# or a dispatch with (#4935) current. Carries NO publish
# `refresh_version_pr` capability: the changesets step is
# invoked WITHOUT a `publish:` script,
# so the action's publish branch is
# unreachable by construction, not by
# an `if:` someone can get wrong.
# NOT on push — see the section below.
# push to main → `release-integrity` audits ONLY the version at
# `github.sha`. Never publishes, never
# pushes a tag. May backfill GitHub
# Releases / the ADR-0087 D4 asset /
# the runtime image — but only for a
# version ALREADY fully on npm, which
# is repair that cannot mint anything.
# → `publish` the ONLY job that runs
# `changeset publish` or pushes a
# version tag. It starts only when
# `release-integrity` reports that
# main's version is ABSENT from npm —
# i.e. the Version Packages PR has just
# merged — and it is then held, whole,
# at `environment: release` until a
# required reviewer approves it.
# workflow_dispatch → `publish` the repair lane. Takes no version;
# (WITHOUT audits main exactly as the push lane
# `refresh_version_pr`) does. Same environment gate.
#
# WHY `version-pr` LEFT THE PUSH TRIGGER (#11233, 2026-08-23)
# ----------------------------------------------------------
# changesets/action's version path is `git reset --hard <github.context.sha>` →
# re-version → `git push --force origin HEAD:changeset-release/main` (its source
# is quoted at the top of this file). On push that recomputes and force-pushes
# #4935 on EVERY landing, and main takes ~18 merges a working day. The standing
# Version Packages PR therefore never held still long enough for its own branch
# CI to finish: every run was superseded by the next force-push, so the PR could
# not converge and was ejected from the merge queue on entry. That is a
# structural property of (action algorithm × trigger frequency), not of the CLI
# version — this repo is already on @changesets/cli ^3.0.0 and the churn was
# unchanged. The fix is the one the changesets project documents for exactly
# this: refresh on a SCHEDULE instead of on every push.
#
# Between refreshes `changeset-release/main` is a static branch. Its CI
# converges, and it merges through the ordinary queue like any other PR. A stale
# window of up to six hours is the whole cost, and it is bounded on demand:
# dispatch with `refresh_version_pr` when you want it current NOW (immediately
# before a GA cut, say). The bookkeeping is not time-critical — the changesets
# are already committed on main; #4935 is only their rendering.
#
# ⛔ THE DISPATCH COLLISION, AND WHY THERE IS AN INPUT FOR IT
# ----------------------------------------------------------
# `workflow_dispatch` was already taken: it is the publish REPAIR lane (D4).
# One event name now has to start two lanes that must never start each other —
# a refresh that also queued the publish audit would park a WAITING DEPLOYMENT
# at the `release` environment on every routine refresh, i.e. an approval prompt
# a maintainer must open and dismiss to keep the real ones meaningful. Approval
# noise is how an approval stops being read, and this file's whole barrier is
# that approval (see below).
#
# So the event is split by an INPUT rather than by a second workflow file (the
# maintainer does not want another lane to maintain, and a second file would
# duplicate the publish invariants where they can drift apart):
#
# dispatch WITH `refresh_version_pr` → version-pr only, no deployment
# dispatch WITHOUT `refresh_version_pr` → the repair lane, exactly as before
# schedule → version-pr only
# push to main → release-integrity (+ publish)
#
# Every job carries the half of that split it needs, in its own `if:`. No job
# infers its lane from another job's presence.
#
# ⚠️ The two inputs are INDEPENDENT, so `refresh_version_pr` + `force` is a
# reachable form, and it is refused rather than resolved: `publish`'s guard
# excludes any dispatch carrying `refresh_version_pr`, so that combination
# refreshes and publishes NOTHING. A dispatch that both refreshes bookkeeping
# and force-publishes is not a thing anyone means; the harmless reading is the
# one that runs.
#
# WHERE THE HUMAN IS, AFTER ADR-0125 (2026-08-20)
# -----------------------------------------------
# This file used to make the human confirmation a TYPED VERSION on a
# `workflow_dispatch` form, and its own comment called the dispatch event the
# guarantee: no push, no queue landing, no bot token, no schedule can synthesise
# it. That property is gone on purpose. The maintainer's ruling of 2026-08-20 is
# that merging the Version Packages PR is already the decision to release, and
# retyping the version afterwards confirms a decision they have just taken. So
# the two human acts are now:
#
# 1. merge the `chore: version packages` PR ← the decision
# 2. approve the `release` environment ← the authorisation
#
# The 2026-08-07 ruling 「版本发布必须是人工的」 is UNCHANGED and still binding.
# What changed is which act carries it.
#
# ⛔ THE GATE IS NOW A REPO SETTING, AND NOTHING HERE CAN CHECK IT.
# `environment: release` only creates the deployment gate. An environment with
# no protection rules passes AUTOMATICALLY and silently, and in the run log an
# unprotected gate is indistinguishable from an approved one. While the trigger
# was `workflow_dispatch` that was a weakness; now that the trigger is a push it
# is THE barrier — remove the reviewers and this file publishes 69 packages on
# every version-PR merge with nobody deciding, which is rc.3 / rc.4 exactly.
# Settings → Environments → release → Required reviewers
# Confirmed configured by the maintainer on 2026-08-20. ⚠️ Verified by a human
# opening that page — not by this YAML, not by a CI gate, not by ADR-0125. If
# the reviewers are ever removed, revert `publish` to a `workflow_dispatch`
# trigger in the SAME change rather than leaving this running.
#
# WHAT IS DELIBERATELY STILL AUTOMATIC
# ------------------------------------
# Version-PR maintenance (this file's `version-pr` job) still runs unattended —
# harmless bookkeeping, and #4935 must keep regenerating — it just runs on a
# 6-hourly schedule instead of on every push (#11233). A `schedule` trigger
# reaching this job is not a loosening: the job has no publish capability by
# construction, so the event that starts it cannot change what it is able to do.
# Release/D4/image backfill for an already-published version stays on push runs —
# it is the #4900 repair, and it cannot mint a version. `npm publish` and
# `git push --tags` still live in exactly one job, that job is reachable from
# `push` and from the repair dispatch ONLY — never from `schedule`, never from a
# refresh dispatch — and it cannot start without a human approving it.
on:
push:
branches:
- main
# Version-PR bookkeeping only (#11233). GitHub runs `schedule` on the DEFAULT
# BRANCH exclusively, which is the ref `version-pr` may ever regenerate from,
# so the trigger cannot reach a ref the job is not meant to touch. Six-hourly
# is the stale-window budget: #4935 renders changesets that are already
# committed on main, so lateness costs nothing that cannot be bought back on
# demand with `refresh_version_pr` below.
#
# ⚠️ Scheduled runs are queued, not guaranteed on the minute — GitHub delays
# or drops them under load, and disables them entirely after 60 days of
# repository inactivity. Both are acceptable HERE and would not be on a
# publishing lane: a refresh that arrives late leaves #4935 stale, which is
# visible on the PR and fixable by one dispatch. This is a second reason the
# publish lane must never be reachable from `schedule`.
schedule:
- cron: '0 */6 * * *'
# The repair lane (ADR-0125 D4). Takes NO version: both lanes audit main the
# same way. `force` exists for the one case the push lane's predicate cannot
# see — a publish that died having already shipped the @objectstack/cli canary
# but not every package in the fixed group. It is dispatch-only by
# construction, so no push can set it, and it widens what may be ATTEMPTED,
# never what may be published unattended: the `release` environment approval
# below applies to this lane identically.
workflow_dispatch:
inputs:
force:
description: >-
Publish even when @objectstack/cli is already on npm. Only for
finishing a partial publish — changeset publish skips versions the
registry already has, so this is a repair, never a duplicate.
required: false
default: false
type: boolean
# The on-demand half of #11233's schedule. Its ONLY effect is to move this
# dispatch onto the bookkeeping lane: `version-pr` requires it, and both
# `release-integrity` and `publish` refuse a dispatch that carries it. It
# therefore cannot widen anything — it is strictly subtractive, the one
# input in this file that can only ever cause LESS to run.
refresh_version_pr:
description: >-
Regenerate the "chore: version packages" PR (#4935) now instead of
waiting for the next 6-hourly refresh. Runs the bookkeeping lane ONLY:
no audit, no publish, no deployment queued at the `release`
environment. Leave unchecked to use the publish repair lane.
required: false
default: false
type: boolean
# ⛔ NO workflow-level concurrency — per-JOB groups below, deliberately
# (ADR-0125 D5).
#
# GitHub keeps at most ONE pending run per group: when a second run queues
# behind a running one, the older PENDING run is cancelled. The group this file
# used to carry was keyed on `github.event_name`, and the comment on it said the
# point was that "the two lanes can never displace each other". That key stopped
# separating anything the moment the publish lane moved onto `push` (D1) — both
# lanes are now the same event.
#
# Worse than not separating: a job waiting on the `release` environment approval
# holds its run IN PROGRESS for as long as the maintainer takes. Under one
# shared group every other run in that window would queue as pending and evict
# the one before it, so an hour spent deciding would silently stop the Version
# Packages PR from regenerating. Per-job groups keep the waiting publish from
# touching the bookkeeping lane at all. #11233 sharpened this rather than
# retiring it: the runs that would be evicted are now the 6-hourly refreshes and
# any on-demand one, and a refresh is exactly what someone reaches for while a
# release is mid-approval.
jobs:
# ══════════════════════════════════════════════════════════════════════════
# PUSH LANE 1 — version-PR bookkeeping. Structurally cannot publish.
# ══════════════════════════════════════════════════════════════════════════
version-pr:
name: Version PR maintenance
# ⛔ NOT `push` (#11233). On push this job force-pushed #4935 on every one of
# main's ~18 daily landings, so the PR's own CI could never converge and the
# PR could never merge. The scheduled tick is the refresh; the dispatch input
# is the same refresh on demand.
#
# `inputs.refresh_version_pr` is guarded by the event test rather than read
# bare: the `inputs` context exists only on `workflow_dispatch`, so on a
# `schedule` run it is null — and `null` is falsy, which would be the right
# answer by accident. Say which event we are on, so the guard states the lane
# split instead of leaning on a context's emptiness.
if: >-
github.event_name == 'schedule' ||
(github.event_name == 'workflow_dispatch' && inputs.refresh_version_pr)
runs-on: ubuntu-latest
# Serialise against itself so two refreshes cannot race the force-push to
# `changeset-release/main`; never cancel in progress. The races it covers
# changed with the trigger (#11233) but did not go away: a scheduled tick can
# still overlap the previous one if a refresh runs long, and an on-demand
# dispatch is most likely to be fired precisely when someone is impatient
# with a tick already in flight. An evicted PENDING run is harmless here —
# this job regenerates the PR from scratch, so the newest run's result is the
# one that was wanted anyway.
concurrency:
group: release-version-pr-${{ github.ref }}
cancel-in-progress: false
permissions:
contents: write
pull-requests: write
steps:
- name: Checkout repository
uses: actions/checkout@v7
- name: Setup Node.js
uses: actions/setup-node@v7
with:
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-
- name: Install dependencies
run: pnpm install --frozen-lockfile
# Decides WHAT the version pass will bump, so it belongs on this lane too
# (the publish lane runs it again — a gate on one lane is not a gate on
# the other).
- name: Verify Changesets "fixed" group covers every public package
run: node scripts/check-changeset-fixed.mjs
# ══════════════════════════════════════════════════════════════════════
# POST-VERSION VALIDATION — THE ONLY GATE THE RELEASE COMMIT EVER PASSES
# (#11945)
# ══════════════════════════════════════════════════════════════════════
# The "chore: version packages" PR runs NO CI and cannot be made to.
# GitHub does not start workflow runs from events raised by the default
# `GITHUB_TOKEN`, and the step below opens and force-pushes that PR with
# exactly that token — so it gets no `pull_request` build, and because the
# branch is force-pushed there is not even a stable head for a human to
# re-run checks against. Two files in this repository already state the
# mechanism in their own words rather than inheriting it from a sibling:
#
# lint.yml, on check:docs-image-tag — "`changeset version` bumps
# packages/cli on a release PR that (per sync-template-versions.mjs's
# own header) gets NO CI because changesets/action opens it with the
# default GITHUB_TOKEN — so the bump merged green and the gate reddened
# on the NEXT ordinary PR, naming files that author never touched."
#
# scripts/sync-protocol-version.mjs — "the lockstep guard
# (protocol-version.test.ts) exists, but release PRs opened by
# changesets/action with the default GITHUB_TOKEN do not trigger CI
# (GitHub's anti-recursion rule), so the guard only fired AFTER the
# merge. Fixing the value at version time is the only spot that cannot
# be skipped."
#
# That last sentence is the treadmill. Every version-time output has so
# far had to buy its own correctness with its own rewriter and its own
# self-test — sync-protocol-version.mjs (#2769), sync-template-versions.mjs,
# sync-docs-image-tags.mjs (#9064) — because nothing validated the tree as
# a whole. Maintainer ruling on the sibling card (objectui#5397,
# 2026-08-22, 「接受所有」) chose option A: validate the POST-VERSION tree
# HERE, after the version step and before the action opens or updates the
# PR. NOT by handing the PR a PAT / GitHub App token (widens the
# supply-chain trust surface, adds rotation obligations), and NOT one
# artifact at a time — per-artifact guarantees do not generalise, which is
# the half this repository has already paid for three times.
#
# THE SURFACE IS MEASURED HERE, NOT INHERITED (2026-08-25, at 3689991d2)
# ---------------------------------------------------------------------
# The sibling's scope conclusion does NOT transfer. There the version step
# moves no source byte at all. Here `pnpm run version` is FOUR rewriters
# and one of them writes TypeScript. Rendered against this repository's
# real tree — 158 pending changesets, @objectstack/cli 17.2.0 -> 17.3.0 —
# `pnpm run version` moved 313 paths, in four classes and no others:
#
# 158 .changeset/*.md deleted (consumed by the version)
# 76 */package.json 152 changed lines, every one `"version":`
# — not one dependency range moved
# 76 */CHANGELOG.md generated from the changeset bodies
# 3 doc surfaces 8 concrete image-tag / npm-pin lines, in
# content/docs/deployment/self-hosting.mdx,
# content/docs/upgrading.mdx, docker/README.md
#
# Two more classes exist and did NOT move on that train, because both are
# major-boundary only: packages/spec/src/kernel/protocol-version.ts, and
# per bundled template objectstack.config.ts / objectstack.manifest.json.
# A major cannot reach this lane while check-changeset-no-major.mjs holds
# (pr-automation.yml refuses a PR that introduces one), so they are dormant
# rather than covered — and the shape assertion below says so out loud on
# the first train that wakes them, instead of validating a surface with no
# gate behind it. cut-rc.yml's allowlist block carries the same
# measurement for the RC lane and agrees path-for-path.
#
# WHAT IS CHECKED, AND WHY EACH ONE (7 s total, measured on the rendered
# tree in this job's own state: install, NO build)
# ---------------------------------------------------------------------
# Two halves, because the classes above fail in two different ways.
#
# SHAPE — every moved path must fall inside the reviewed surface, and
# 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
# 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.
#
# CONTENT — the gates whose corpus is what the version step actually
# writes:
# check:docs-image-tag the 3 doc surfaces against packages/cli's
# NEW version. #9064's gate, and the one
# that structurally could not fire here.
# check:docs-image-tag-sync that rewriter's self-test — it just ran
# on this tree and a rewriter that has
# nothing to do cannot be observed working.
# check:template-version-sync the template stamper's, for the same
# reason.
# check:nul-bytes lint.yml's one UNCONDITIONAL gate whose
# corpus is every byte in the tree. The 76
# generated CHANGELOGs are the only prose
# the version step writes that it did not
# author, and this commit never runs
# lint.yml at all.
# check:release-notes, check:release-page-status,
# check-release-section-coverage.mjs, check:published-readme-links
# the four gates that READ CHANGELOG.md.
#
# ⛔ WHAT IS DELIBERATELY NOT RUN, AND WHY IT IS NOT AN OMISSION.
# The whole derived farm. Fed this 313-path change set,
# scripts/pm/dispatch-gates.mjs names 49 families; 44 of them run
# without a build, in 113 s, and every one is green on the post-version
# tree. The control that makes that number mean something: the identical
# sweep on the PRE-version tree returns the identical verdicts, so the
# version step changes no gate's mind. It is mostly restatement — a
# family is derived because a package's `package.json` moved, and what
# moved inside it was the `"version"` key, while the gate reads that
# package's SOURCE, which ci.yml judged on this very commit minutes ago.
# Running the selector here was drafted and withdrawn: it needs
# `scripts/check-self-test-wired.mjs` to grow a new ledger shape, and
# check-dispatch-gates.mjs's header already records the reviewed
# position that "there is no verdict in [the live derivation] for CI to
# hold". That is a maintainer's call, not a rider on this card.
# The five build-requiring families in that farm (check:i18n,
# check:i18n-coverage, check:dev-prereqs, check:doc-formula-expressions,
# check:doc-security-posture) would in any case re-add to this lane the
# ~9 minutes of build the comment below records as deliberately moved to
# the publish job, to gate a manifest's `"version"` key. What they read
# is not what the version step wrote: the two doc gates judge formula
# fences and security-posture prose, and the only content/** lines this
# step can move are the 8 concrete pins above.
#
# FAILURE SEMANTICS. A red validation fails the job, so the step below
# never runs and the standing PR keeps its last VALIDATED content instead
# of being force-pushed to a broken one. If the tree stays broken the PR
# goes stale — but loudly, on a red run every six hours, which is the
# opposite of this card's defect. Nothing here can reach the publish lane:
# this whole job is `schedule` / `workflow_dispatch` only.
- name: Render the post-version tree
id: post_version
run: |
set -euo pipefail
shopt -s nullglob
# `/^README\.md$/i` is how `@changesets/read` spells its exclusion, so
# the count is case-folded rather than matching one spelling.
count_pending() {
local n=0 file
for file in .changeset/*.md .changeset/pre/*.md; do
if [ "$(printf '%s' "${file##*/}" | tr '[:upper:]' '[:lower:]')" = 'readme.md' ]; then
continue
fi
n=$((n + 1))
done
printf '%s' "$n"
}
pending_before=$(count_pending)
echo "pending_before=${pending_before}" >> "$GITHUB_OUTPUT"
# The version PR was just merged, or nothing has landed since. The
# action will take its no-changesets branch and do nothing, so there is
# no post-version tree to render. Said out loud, because a validation
# that silently does nothing is this card's own failure class.
if [ "${pending_before}" -eq 0 ]; then
echo 'No pending changesets — the version step would move nothing. Nothing to render, nothing to validate.'
{
echo '### Post-version validation'
echo
echo '- pending changesets: `0` — nothing to render, nothing to validate.'
} >> "$GITHUB_STEP_SUMMARY"
exit 0
fi
# Everything below reads the tree as the version step's OUTPUT, and
# that reading is only true while nothing else is dirty first.
# Asserted rather than assumed: measured locally, a single unrelated
# edit left in the tree turned 313 moved paths into 314 and would have
# been validated as though the version pass had written it.
dirty_before=$(git status --porcelain | wc -l | tr -d ' ')
if [ "${dirty_before}" -ne 0 ]; then
git status --porcelain
echo "::error::the working tree is already dirty before the version step (${dirty_before} path(s), listed above). The post-version change set is read as everything dirty, so a pre-existing change would be validated as though the version pass had written it."
exit 1
fi
pnpm run version
# Tracked moves (deletions included) plus new untracked files. Read
# this way rather than by classifying `git status --porcelain` letters:
# a rename arrives as a delete/add pair whose halves are both named
# here, so no status code has to be interpreted.
{
git diff --name-only HEAD
git ls-files --others --exclude-standard
} | sort -u > "${RUNNER_TEMP}/post-version-paths.txt"
moved=$(wc -l < "${RUNNER_TEMP}/post-version-paths.txt" | tr -d ' ')
{
echo '### Post-version validation'
echo
echo "- rendered from \`${GITHUB_SHA}\`"
echo "- pending changesets consumed: \`${pending_before}\`"
echo "- paths the version step moved: \`${moved}\`"
} >> "$GITHUB_STEP_SUMMARY"
- name: Validate the post-version tree
if: steps.post_version.outputs.pending_before != '0'
run: |
set -euo pipefail
MOVED_FILE="${RUNNER_TEMP}/post-version-paths.txt"
moved=$(wc -l < "${MOVED_FILE}" | tr -d ' ')
if [ "${moved}" -eq 0 ]; then
echo "::error::changesets were pending but the version step moved no path. Either the version script did nothing, or it wrote outside the working tree — both are a broken version lane, never an empty diff."
exit 1
fi
# ── SHAPE ────────────────────────────────────────────────────────
# The doc surfaces the rewriter writes, read from the same declaration
# the rewriter itself reads. Import-safe by construction: that module
# carries an entry-point guard added for exactly this kind of consumer.
SURFACE_LIST="${RUNNER_TEMP}/post-version-doc-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 post-version surface is unknown. Refusing to call this tree validated."
exit 1
fi
# Unknown is a failure, never an empty allowlist: an empty list would
# make every doc the version pass rewrites read as an unexpected path.
if [ ! -s "${SURFACE_LIST}" ]; then
echo "::error::SURFACES in scripts/check-docs-image-tag.mjs resolved EMPTY, so the doc surfaces the version pass rewrites would read as unexpected paths. Refusing to call this tree validated."
exit 1
fi
# The template surfaces the stamper writes, on the same terms, from the
# same walk and table its own main() uses.
TEMPLATE_LIST="${RUNNER_TEMP}/post-version-template-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 post-version surface is unknown. Refusing to call this tree validated."
exit 1
fi
if [ ! -s "${TEMPLATE_LIST}" ]; then
echo "::error::stampedPaths() in scripts/sync-template-versions.mjs resolved EMPTY. It THROWS on a moved or empty template directory, so an empty file here is a resolution that reported nothing while still exiting 0. Refusing to call this tree validated."
exit 1
fi
# Same three filters cut-rc.yml applies to the same surface: the fixed
# release paths by pattern, then the two declared lists by WHOLE-LINE
# EXACT match, so neither derived filter can accept a path its
# declaration does not name and neither needs regex-escaping.
UNEXPECTED="$(grep -vE '(^|/)package\.json$|(^|/)CHANGELOG\.md$|^\.changeset/|^packages/spec/src/kernel/protocol-version\.ts$' "${MOVED_FILE}" \
| grep -vxF -f "${TEMPLATE_LIST}" \
| grep -vxF -f "${SURFACE_LIST}" || true)"
if [ -n "${UNEXPECTED}" ]; then
printf '%s\n' "${UNEXPECTED}" | sed 's/^/::error:: unexpected: /'
echo "::error::the version pass wrote outside the reviewed post-version surface (paths above). This is the treadmill guard: a new version-time output must arrive together with the gate that judges it. Add the surface to the declaration its rewriter reads, and its gate to the content half below — deliberately, in one reviewed diff."
exit 1
fi
# The two MAJOR-boundary classes. Both are INSIDE the surface above —
# protocol-version.ts by the pattern, the template stamps by
# `stampedPaths()` — so the assertion accepts them and they reach this
# check with a diagnosis of their own. Measured: with the pattern
# missing, protocol-version.ts fell into the "wrote outside the
# reviewed surface" branch instead, which is red for the right reason
# and wrong about why. What this lane does not have is a gate for
# either. protocol-version.ts is judged by
# packages/spec/src/kernel/protocol-version.test.ts, reachable only
# through the whole @objectstack/spec suite (measured: 424 files,
# 11273 tests, 5m26s — not a per-refresh cost), and the template stamps
# by a full template render. Unreachable while
# check-changeset-no-major.mjs holds; loud, not silent, the day it
# stops holding.
MAJOR_ONLY="$(grep -xF -f "${TEMPLATE_LIST}" "${MOVED_FILE}" || true)"
if grep -qxF 'packages/spec/src/kernel/protocol-version.ts' "${MOVED_FILE}"; then
MAJOR_ONLY="${MAJOR_ONLY}"$'\n'"packages/spec/src/kernel/protocol-version.ts"
fi
if [ -n "${MAJOR_ONLY//[[:space:]]/}" ]; then
printf '%s\n' "${MAJOR_ONLY}" | sed '/^$/d; s/^/::error:: unvalidated: /'
echo "::error::this version pass crossed a MAJOR boundary and wrote the surfaces above, which this lane has no gate for. A major was not reachable here when this step was written (pr-automation.yml refuses a PR introducing one), so the gates were left out rather than guessed at. Wire them in before letting this refresh through."
exit 1
fi
# ── CONTENT ──────────────────────────────────────────────────────
failed=()
run_gate() {
echo "::group::$*"
if "$@"; then
echo "green: $*"
else
failed+=("$*")
fi
echo "::endgroup::"
}
run_gate pnpm check:docs-image-tag
run_gate pnpm check:docs-image-tag-sync
run_gate pnpm check:template-version-sync
run_gate pnpm check:nul-bytes
run_gate pnpm check:release-notes
run_gate pnpm check:release-page-status
run_gate node scripts/check-release-section-coverage.mjs
run_gate pnpm check:published-readme-links
{
echo "- post-version surface: \`${moved}\` path(s), all inside the reviewed shape"
echo "- content gates failed: \`${#failed[@]}\`"
} >> "$GITHUB_STEP_SUMMARY"
if [ "${#failed[@]}" -ne 0 ]; then
printf '::error::the post-version tree fails %s gate(s) that no CI run would ever have judged: %s\n' \
"${#failed[@]}" "$(printf '%s; ' "${failed[@]}")"
exit 1
fi
echo 'Post-version tree validated.'
# ⚠️ LOAD-BEARING — DO NOT DROP THIS STEP. The steps above render the
# version into the runner's working tree, and changesets/action picks its
# branch from that tree. With `.changeset/` already consumed it finds
# `hasChangesets` false, and this lane deliberately passes no `publish:`
# input, so `hasPublishScript` is false too — v1 then takes
# `case !hasChangesets && !hasPublishScript` (src/index.ts), logs "No
# changesets present or were removed by merging release PR" and RETURNS.
# The refresh becomes a permanent no-op and #4935 fossilises with nothing
# red anywhere: this file's own failure class, one lane over. So the tree
# is put back, and the restoration is ASSERTED rather than assumed.
- name: Restore the pre-version tree
if: always()
env:
PENDING_BEFORE: ${{ steps.post_version.outputs.pending_before }}
run: |
set -euo pipefail
shopt -s nullglob
if [ -z "${PENDING_BEFORE}" ] || [ "${PENDING_BEFORE}" = '0' ]; then
echo 'Nothing was rendered, so there is nothing to restore.'
exit 0
fi
git checkout -- .
# Narrow on purpose, and measured: those are the only roots the version
# pass writes into. A `git clean` on a lane that can force-push a
# branch should never be able to reach further than the thing it undoes.
git clean -fdq -- .changeset packages examples content docker
count_pending() {
local n=0 file
for file in .changeset/*.md .changeset/pre/*.md; do
if [ "$(printf '%s' "${file##*/}" | tr '[:upper:]' '[:lower:]')" = 'readme.md' ]; then
continue
fi
n=$((n + 1))
done
printf '%s' "$n"
}
pending_after=$(count_pending)
dirty=$(git status --porcelain --untracked-files=no | wc -l | tr -d ' ')
if [ "${pending_after}" != "${PENDING_BEFORE}" ] || [ "${dirty}" -ne 0 ]; then
echo "::error::the pre-version tree was NOT restored (${PENDING_BEFORE} pending changeset(s) before, ${pending_after} after; ${dirty} tracked path(s) still modified). changesets/action would read this tree, find nothing pending and no publish script, and return without refreshing the PR — a silent permanent no-op (#11945)."
exit 1
fi
echo "Pre-version tree restored: ${pending_after} pending changeset(s), working tree clean."
# `pnpm run version` = changeset version + sync-protocol-version +
# sync-template-versions + sync-docs-image-tags — FOUR rewriters, read
# from the root `version` script, not three. The fourth joined it in #9064
# and this comment did not follow. Corrected here because the block above
# prices what that script moves, and a stale count of the rewriters is a
# stale statement of the surface being priced. All four are pure file
# rewrites; none reads a built artifact. The workspace build, the vendored Console build and the
# live hotcrm smoke that used to run here were PRE-PUBLISH gates — they
# moved to the `publish` job below, where the publish they gate now lives.
# Leaving them here would gate nothing and cost ~9 minutes of every main
# push.
# Quoted because the name embeds `: ` — YAML would otherwise read it as a
# nested mapping (caught by check:workflow-status-functions' self-test).
- name: 'Create or update the "chore: version packages" PR'
id: changesets
uses: changesets/action@v1
with:
# ⛔ THERE IS NO `publish:` INPUT HERE, AND THAT IS THE FIX. ⛔
#
# Not an oversight and not a style choice — it is what makes this lane
# structurally unable to publish, per #6170. changesets/action@v1
# branches on `hasPublishScript = !!publishScript` (src/index.ts):
#
# case !hasChangesets && !hasPublishScript:
# core.info("No changesets present or were removed by merging
# release PR. Not publishing because no publish
# script found.");
# return;
# case hasChangesets:
# await runVersion({...}); // version PR only
#
# With no publish script, `runPublish` is not reachable from any input
# state the action can observe. Adding one back here re-arms the exact
# lane that minted rc.3 and rc.4 without a human.
version: pnpm run version
commit: 'chore: version packages'
title: 'chore: version packages'
# No-op without a publish script. Kept so that re-adding one can never
# silently resurrect #4900: the action posts each package's raw
# CHANGELOG section as the Release body, and @objectstack/spec's
# section for a single v17 RC is ~343k characters against the API's
# 125,000 limit. Releases are created by scripts/release-github-releases.mjs.
createGithubReleases: false
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
# ══════════════════════════════════════════════════════════════════════════
# PUSH LANE 2 — release integrity audit. Reads github.sha ONLY. Never mints.
# ══════════════════════════════════════════════════════════════════════════
release-integrity:
name: Release integrity (audit + no-mint backfill)
# Runs on both RELEASE events (ADR-0125 D1): it is the single place that
# reads what main carries and asks npm whether that version exists, so the
# push lane and the repair lane converge on ONE predicate and one guard
# instead of two code paths that can drift.
#
# This `if:` is new with #11233 and is the reason that sentence still holds.
# The job used to carry no `if:` at all, which meant "every event this file
# has" — correct while the file had exactly the two release events, and
# wrong the moment a third arrived. Without it the 6-hourly tick and every
# on-demand refresh would run a full release audit, and each one that found
# main's version absent from npm would queue a deployment at the `release`
# environment for a maintainer to dismiss. The audit mints nothing, so this
# is not a safety guard; it is the noise guard the collision section above
# argues for. Bookkeeping events do not get a release audit.
if: >-
github.event_name == 'push' ||
(github.event_name == 'workflow_dispatch' && !inputs.refresh_version_pr)
runs-on: ubuntu-latest
# Serialised: its backfills create GitHub Releases and push a runtime image,
# and two runs doing that at once is not a state worth reasoning about.
#
# ⚠️ Residual race, stated rather than hidden: GitHub keeps one PENDING run
# per group, so if the version-PR landing's audit is itself queued behind an
# earlier one and a THIRD push arrives, this job is cancelled — and `publish`
# needs it, so the release quietly does not queue. It is narrow (the landing
# run has to be the pending one, not the running one) and it is visible (no
# approval request arrives) and it is recoverable without any special
# handling: the version is still absent from npm, so the `workflow_dispatch`
# repair lane re-audits and queues the same deployment.
concurrency:
group: release-integrity-${{ github.ref }}
cancel-in-progress: false
permissions:
# `contents: write` is for GitHub Releases, never for refs: this job runs
# no `git push` of any kind.
contents: write
outputs:
# "the docker job must build" — set only when npm ALREADY has this
# version and its runtime image is missing.
published: ${{ steps.audit.outputs.image-missing }}
cli-version: ${{ steps.audit.outputs.version }}
# THE release predicate (ADR-0125 D1): 'true' exactly when main's
# @objectstack/cli version is absent from npm — i.e. the Version Packages
# PR has just merged and nothing has shipped it yet. False on every
# ordinary landing, which is why an ordinary merge queues no deployment.
publish-pending: ${{ steps.audit.outputs.publish-pending }}
steps:
- name: Checkout repository
uses: actions/checkout@v7
- name: Setup Node.js
uses: actions/setup-node@v7
with:
node-version: '22'
# ──────────────────────────────────────────────────────────────────────
# R2 — the probe may only ever see the version main ACTUALLY carries.
#
# This job deliberately does not contain the changesets action, so no step
# can re-version its workspace. Belt and braces on top of that: the version
# is read out of the OBJECT DATABASE at `github.sha`, not off disk, and a
# tripwire fails the run if the two ever disagree. Had this shape existed
# on 2026-08-03 the run would have gone red instead of publishing rc.3.
# ──────────────────────────────────────────────────────────────────────
- name: Audit the release that main actually carries
id: audit
env:
SHA: ${{ github.sha }}
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
version=$(git show "${SHA}:packages/cli/package.json" | jq -r '.version')
if [ -z "$version" ] || [ "$version" = "null" ]; then
echo "::error::could not read @objectstack/cli version at ${SHA}"
exit 1
fi
# Tripwire, not decoration: this is the exact assertion the old
# recover-publish step lacked. A workspace that disagrees with
# github.sha means something re-versioned the tree, and that is the
# #6170 mechanism — refuse to act on it rather than probe it.
tree_version=$(jq -r '.version' packages/cli/package.json)
if [ "$tree_version" != "$version" ]; then
echo "::error::workspace carries @objectstack/cli@${tree_version} but ${SHA} carries ${version} — something re-versioned this workspace (#6170). Refusing to audit a version main does not have."
exit 1
fi
echo "version=$version" >> "$GITHUB_OUTPUT"
echo "main (${SHA}) carries @objectstack/cli@${version}"
# ── npm ───────────────────────────────────────────────────────────
if ! npm view "@objectstack/cli@$version" version >/dev/null 2>&1; then
# THE predicate (ADR-0125 D1). Not on npm = the Version Packages PR
# has just merged and nothing has shipped it. This job still cannot
# publish anything — it says so and stays green; the `publish` job
# below reads this output, and IT stops dead on the `release`
# environment until a required reviewer approves it.
echo "publish-pending=true" >> "$GITHUB_OUTPUT"
echo "::notice::main carries @objectstack/cli@${version}, which is NOT on npm. A deployment is queued and is waiting for a maintainer to approve the 'release' environment."
{
echo "## Release ${version} is waiting for your approval"
echo
echo "main (\`${SHA}\`) carries **@objectstack/cli@${version}**, which is not on npm."
echo
echo "The **Publish ${version} to npm** job below is held at the \`release\`"
echo "environment gate. Nothing has been checked out, built or published —"
echo "GitHub holds the whole job until a required reviewer approves it."
echo
echo "**Review this before approving:** the version above is read from the"
echo "object database at \`${SHA}\`, so it is what main actually carries."
echo "Approving publishes exactly it."
} >> "$GITHUB_STEP_SUMMARY"
exit 0
fi
echo "publish-pending=false" >> "$GITHUB_OUTPUT"
echo "npm: @objectstack/cli@${version} is present."
# From here on, every repair is over an ALREADY-PUBLISHED version, so
# none of it can mint anything. This is the #4900 case — published,
# then died before the Releases / D4 asset / image existed.
# ── GitHub Releases + the ADR-0087 D4 asset ───────────────────────
# Two anchors, not all 69: @objectstack/cli is the fixed group's
# canary and @objectstack/spec is both the historical failure (its
# ~343k body hit the API's 125k limit) and D4's mount point. The
# backfill itself is idempotent create-or-update across the whole set.
releases_ok=true
gh release view "@objectstack/cli@${version}" >/dev/null 2>&1 || releases_ok=false
gh release view "@objectstack/spec@${version}" >/dev/null 2>&1 || releases_ok=false
if [ "$releases_ok" = true ]; then
gh release view "@objectstack/spec@${version}" --json assets \
--jq '.assets[].name' 2>/dev/null | grep -qx 'spec-changes.json' || releases_ok=false
fi
if [ "$releases_ok" = true ]; then
echo "GitHub Releases + ADR-0087 D4 asset are present for ${version}."
else
echo "::warning::@objectstack/cli@${version} is on npm but its GitHub Releases or the ADR-0087 D4 asset are incomplete (#4900) — backfilling."
echo "releases-missing=true" >> "$GITHUB_OUTPUT"
fi
# ── runtime image ─────────────────────────────────────────────────
# A failed probe counts as MISSING on purpose: a redundant rebuild
# costs a few minutes, a wrongly-skipped one leaves a published npm
# version with no image and nothing to say so.
if token=$(curl -fsS "https://ghcr.io/token?scope=repository:${GITHUB_REPOSITORY}:pull&service=ghcr.io" 2>/dev/null) \
&& token=$(node -p 'JSON.parse(process.argv[1]).token' "$token" 2>/dev/null) \
&& curl -fsS -o /dev/null -H "Authorization: Bearer $token" \
-H 'Accept: application/vnd.oci.image.index.v1+json' \
-H 'Accept: application/vnd.docker.distribution.manifest.list.v2+json' \
"https://ghcr.io/v2/${GITHUB_REPOSITORY}/manifests/$version" 2>/dev/null
then
echo "ghcr: image for $version is present — release is complete."
exit 0
fi
echo "::warning::No ghcr image for $version (or the registry could not be probed) — requesting the Docker job."
echo "image-missing=true" >> "$GITHUB_OUTPUT"
# Everything below is skipped on the overwhelmingly common path (nothing to
# repair), which is why the install is here rather than at the top of the job.
- name: Setup pnpm
if: steps.audit.outputs.releases-missing == 'true'
uses: ./.github/actions/setup-pnpm
- name: Install dependencies
if: steps.audit.outputs.releases-missing == 'true'
run: pnpm install --frozen-lockfile
- name: Backfill GitHub Releases (bodies truncated to the API limit)
if: steps.audit.outputs.releases-missing == 'true'
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
# No publish happened in this run, so there is no publishedPackages
# JSON. RELEASE_VERSION drives the whole publishable workspace — the
# Changesets `fixed` group releases every public package at one
# version, which check-changeset-fixed.mjs gates.
RELEASE_VERSION: ${{ steps.audit.outputs.version }}
run: node scripts/release-github-releases.mjs
- name: Backfill spec-changes.json on the GitHub Release (ADR-0087 D4)
# Ordering is load-bearing: `gh release upload` needs the Release the
# step above creates.
if: steps.audit.outputs.releases-missing == 'true'
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
RELEASE_VERSION: ${{ steps.audit.outputs.version }}
run: bash scripts/release-spec-changes.sh
# ══════════════════════════════════════════════════════════════════════════
# HUMAN LANE — the ONLY job in this repository that publishes.
# ══════════════════════════════════════════════════════════════════════════
publish:
# The job NAME is the approval screen (ADR-0125 D2). GitHub shows the job
# name and the environment on the review prompt, so computing the name from
# the audited version is what replaces the typed one: the maintainer
# confirms a version they are SHOWN, read from the object database at
# github.sha, rather than one they recall. #10146 is why that matters — the
# version in that failure's log was @object-ui/console@17.5.0, the vendored
# objectui build, while the repo was publishing 17.1.0.
name: Publish ${{ needs.release-integrity.outputs.cli-version }} to npm (awaiting approval)
needs: [release-integrity]
# ⛔ THE barrier is now the environment, not the trigger (ADR-0125 D3).
#
# An environment with NO protection rules passes AUTOMATICALLY and
# SILENTLY, and a run that passed an unprotected gate is indistinguishable
# in the log from one a human approved. Under this trigger that is not a
# degraded gate, it is NO gate: the push lane would publish end to end
# with nobody deciding, which is exactly the rc.3 / rc.4 incident — 69
# packages, tags, Releases and a runtime image, twice in one week, no
# human in the trigger chain.
#
# Settings → Environments → release → Required reviewers was confirmed
# configured by the maintainer on 2026-08-20, and ADR-0125 is conditional
# on it staying that way. ⚠️ No file in this repo can assert it — not this
# YAML, not a CI gate, not the ADR. It is checked by a human opening the
# settings page. If those reviewers are ever removed, revert the `push`
# trigger to `workflow_dispatch` in the SAME change; do not leave this
# running.
environment: release
# `publish-pending` is the whole trigger predicate: true exactly when main's
# version is absent from npm. Unset (an audit that died before deciding)
# compares false, so this fails CLOSED. `force` is dispatch-only and exists
# for the partial-publish repair D4 describes.
#
# `success() && (...)`, with the parentheses load-bearing: `&&` binds tighter
# than `||`, so `success() && A || B` would let the force branch publish on
# top of a FAILED audit — and the audit is what computes the version this
# job's name, guard and tag all read. If the audit dies, nothing publishes.
#
# `!inputs.refresh_version_pr` on the force branch is #11233's half, and it
# is written even though it is currently redundant. A refresh dispatch skips
# `release-integrity`, and a SKIPPED `needs` job makes `success()` false — so
# the belt already holds (the `docker` job below documents that same GitHub
# behaviour from the other direction). Redundant is not the same as
# unnecessary: what makes the force branch safe would then be a fact about a
# DIFFERENT job's `if:`, discoverable only by reading it, and the next person
# to touch either guard gets no warning. This is the one job in the
# repository that publishes; its guard states its own preconditions.
if: >-
success() &&
(needs.release-integrity.outputs.publish-pending == 'true' ||
(github.event_name == 'workflow_dispatch' && inputs.force &&
!inputs.refresh_version_pr))
runs-on: ubuntu-latest
# One publish at a time per ref, and never cancelled — a run cancelled
# mid-`changeset publish` is the state that leaves a fixed group half on npm.
concurrency:
group: release-publish-${{ github.ref }}
cancel-in-progress: false
permissions:
contents: write
outputs:
published: ${{ steps.publish.outputs.published }}
cli-version: ${{ steps.guards.outputs.version }}
steps:
- name: Checkout repository
uses: actions/checkout@v7
- 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. pnpm install
# aborts with ERR_PNPM_UNSUPPORTED_ENGINE under that.
node-version: '22'
# ──────────────────────────────────────────────────────────────────────
# The publish lane may only ever ship a commit that is ALREADY on main.
# That is the other half of #6170's title: rc.3 and rc.4 tagged commits
# that lived only on `changeset-release/main`, so main kept stale versions
# and every later release recomputed an npm-occupied number. Nothing here
# runs `changeset version`; this job publishes what the ref carries, or it
# fails.
# ──────────────────────────────────────────────────────────────────────
- name: Guard the approved release (branch, and the tree matches the commit)
id: guards
env:
# What release-integrity read out of the object database at
# github.sha, and what the approval screen named. Read through env,
# never interpolated into the shell.
AUDITED: ${{ needs.release-integrity.outputs.cli-version }}
run: |
if [ "${GITHUB_REF}" != "refs/heads/main" ]; then
echo "::error::the publish lane may only run on main (got ${GITHUB_REF}). Publishing from any other ref would tag and ship code that never landed."
exit 1
fi
# R2's tripwire, kept (ADR-0125 D1). The typed version is gone, so
# this is now the ONLY thing standing between an approval and a
# publish of something main does not carry. It is the exact assertion
# the 2026-08-03 recover-publish step lacked when it shipped rc.3 off
# a re-versioned workspace: read the version from the OBJECT DATABASE
# at github.sha and refuse if the checked-out tree disagrees.
committed=$(git show "${GITHUB_SHA}:packages/cli/package.json" | jq -r '.version')
declared=$(jq -r '.version' packages/cli/package.json)
if [ -z "$committed" ] || [ "$committed" = "null" ]; then
echo "::error::could not read @objectstack/cli version at ${GITHUB_SHA}"
exit 1
fi
if [ "$declared" != "$committed" ]; then
echo "::error::workspace carries @objectstack/cli@${declared} but ${GITHUB_SHA} carries ${committed} — something re-versioned this workspace (#6170). Refusing to publish a version main does not have."
exit 1
fi
# The approval was given against the audited number. If the tree has
# moved since, the human approved a different release than the one
# about to ship — refuse rather than ship the surprise.
if [ -n "$AUDITED" ] && [ "$AUDITED" != "$committed" ]; then
echo "::error::the release approved was @objectstack/cli@${AUDITED} but ${GITHUB_SHA} carries ${committed}. Refusing to publish a version nobody approved."
exit 1
fi
echo "version=$committed" >> "$GITHUB_OUTPUT"
echo "Publishing @objectstack/cli@${committed} from ${GITHUB_SHA} (approved on the 'release' environment)."
{
echo "## Publishing ${committed}"
echo
echo "- ref: \`${GITHUB_REF}\` @ \`${GITHUB_SHA}\`"
echo "- started by: \`${GITHUB_ACTOR}\` (\`${GITHUB_EVENT_NAME}\`)"
echo "- authorised by: the \`release\` environment approval on this run —"
echo " see the run's deployment history for the reviewer and timestamp"
} >> "$GITHUB_STEP_SUMMARY"
- 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-
# Mostly a CONSUMER now, not a seeder: this job used to run on every main
# push and warmed the cache for everyone; it now runs only when a human
# publishes. lint.yml's "Save Turbo cache (main only)" is the seeder. The
# key is namespaced by `github.job`, which changed from `release` to
# `publish` — the first release after this PR builds cold once.
- name: Setup Turbo cache
uses: actions/cache@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 }}-
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Verify Changesets "fixed" group covers every public package
run: node scripts/check-changeset-fixed.mjs
# ──────────────────────────────────────────────────────────────────────
# ⛔ TOMBSTONE — the #3340 pin-currency gate that used to sit here is gone
# (2026-08-20 ruling, #10134). Do not re-add one.
#
# It compared `.objectui-sha` against objectui `main` at publish time and
# refused a release whose pin lagged. The ruling removed the question, not
# just the job: WHICH objectui revision this repo pins is a decision taken
# in an objectstack issue, never derived from another repo's HEAD, so
# "the pin is behind main" is not a defect a release lane may diagnose.
#
# R4's lesson (#6170 — "a gate required on a PR the publishing lane can
# skip is not a gate") is untouched and does not resurrect this one:
# #3340's actual invariant is "everything published is covered by the
# changeset record", and that is carried by `scripts/bump-objectui.sh` +
# `scripts/objectui-changeset-digest.mjs` at BUMP time — on the manual pin
# PR, which is now the only way the pin ever moves. Publishing an old
# console is therefore a published decision, not an unnoticed gap.
# ──────────────────────────────────────────────────────────────────────
- name: Build
run: pnpm run build
# ci.yml's Console Pin Gate (#4290) uses this exact key, so the pin bump's
# PR run and this job share one build — keep the two in step if either
# input set changes.
- name: Cache vendored Console dist (keyed on the objectui pin)
id: console-dist-cache
uses: actions/cache@v6
with:
path: packages/console/dist
key: ${{ runner.os }}-console-dist-${{ hashFiles('.objectui-sha', 'scripts/build-console.sh') }}
- name: Build vendored @objectstack/console SPA
# Clones objectstack-ai/objectui at the SHA pinned in .objectui-sha,
# builds @object-ui/console, and copies dist/ into
# packages/console/dist/. Must run before publish so the prepublishOnly
# guard in @objectstack/console passes.
if: steps.console-dist-cache.outputs.cache-hit != 'true'
run: bash scripts/build-console.sh
- name: Verify Console dist stamp matches pin
run: pnpm check:console-sha
- name: Downstream backward-compat smoke (live hotcrm)
# Pre-publish gate (#2035): the about-to-publish @objectstack/spec must
# not break a real third-party consumer pinned to a published release.
# The deterministic in-repo floor is @objectstack/downstream-contract;
# this is the live ceiling.
#
# ⚠️ CURRENTLY ADVISORY — it runs and reports on every publish, but a
# failure does NOT block. `BLOCKING` below is the whole switch.
#
# Why, and what changed (supersedes the #3600 amendment). #3600 made
# this advisory for the duration of the rc pre-mode window, keyed on
# `.changeset/pre.json` saying mode:"pre", with the reasoning: a major
# train exists precisely to ship deliberate surface removals, and a
# hotcrm release migrated off them cannot exist until the rc.N artifacts
# it would migrate against are published — blocking here deadlocks the
# train. It promised the gate would "re-arm by itself the moment
# `changeset pre exit` lands".
#
# It did exactly that (#8643, 2026-08-14) — and the deadlock was still
# on, because HOTCRM_REF was and is `v2.1.0`, i.e. ObjectStack 14.7. So
# the re-armed gate stood between the 17.0.0 GA publish and a migration
# nobody had done yet, which is the same deadlock #3600 named, one event
# later. The keying was the bug: pre-exit is not when a migrated hotcrm
# release starts existing. Shipping one is.
#
# So the posture is now tied to THAT event instead. Note this is a
# WEAKER gate than #2035 intended and it is meant to be temporary — the
# deterministic in-repo floor (@objectstack/downstream-contract, which
# still blocks) is what carries backward-compat enforcement until it is
# restored.
#
# TO RE-ARM: ship a hotcrm release migrated to v17, bump HOTCRM_REF to
# it, set BLOCKING to '1'. Nothing else changes. Because the smoke keeps
# running and reporting throughout, the run log shows the day it goes
# green — you flip the switch on evidence, not on hope.
env:
# v2.1.0: hotcrm upgraded to ObjectStack 14.7 (hotcrm#448) and
# dropped the agent `visibility` field that spec 15 removes as
# unenforced surface (ADR-0056 D8, #3216).
# Bump this ref whenever a deliberate spec surface removal ships a
# matching hotcrm release.
HOTCRM_REF: v2.1.0
# '1' = a failure fails the publish. '0' = reported only.
BLOCKING: '0'
run: |
if bash scripts/downstream-smoke.sh; then
echo "::notice::hotcrm@${HOTCRM_REF} is compatible with the about-to-publish @objectstack/spec."
exit 0
fi
if [ "$BLOCKING" = '1' ]; then
echo "::error::hotcrm@${HOTCRM_REF} is incompatible with the about-to-publish @objectstack/spec — refusing to publish a release that breaks a real downstream consumer."
exit 1
fi
echo "::warning::hotcrm@${HOTCRM_REF} is incompatible with the about-to-publish @objectstack/spec. ADVISORY ONLY — the publish continues. HOTCRM_REF is pre-v17; ship a migrated hotcrm release, bump it, and set BLOCKING=1 in .github/workflows/release.yml to re-arm this gate."
# ──────────────────────────────────────────────────────────────────────
# The publish itself. `pnpm run release` = build + build-console +
# scripts/release-publish.sh, which is `changeset publish` followed by ONE
# atomic `git push origin --tags` (#2191: the action's concurrent per-tag
# pushes raced GitHub's ref backend and lost ~half the tags).
#
# changesets/action is NOT used here, and that is deliberate: handed a
# workspace with pending changesets it would take the VERSION path and mint
# a commit. `changeset publish` can only ever publish the versions the
# checked-out package.json files already declare — the versions the guard
# step above proved main carries.
# ──────────────────────────────────────────────────────────────────────
- name: Publish to npm + push version tags
id: publish
env:
NPM_TOKEN: ${{ secrets.NPM_TOKEN }}
VERSION: ${{ steps.guards.outputs.version }}
run: |
printf '//registry.npmjs.org/:_authToken=%s\n' "$NPM_TOKEN" >> "$HOME/.npmrc"
git config user.name 'github-actions[bot]'
git config user.email '41898282+github-actions[bot]@users.noreply.github.com'
pnpm run release
# `changeset publish` skips versions already on the registry, so a
# re-dispatch over a partially-published release is a repair, not 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: ${{ steps.guards.outputs.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.
if: ${{ !cancelled() && steps.publish.outputs.published == 'true' }}
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
RELEASE_VERSION: ${{ steps.guards.outputs.version }}
run: bash scripts/release-spec-changes.sh
# ══════════════════════════════════════════════════════════════════════════
# Runtime image — fed by either lane. Building an image for a version that is
# already on npm cannot mint anything, so the push lane may request it.
# ══════════════════════════════════════════════════════════════════════════
docker:
name: Docker image
needs: [release-integrity, publish]
# Publish the official runtime image (ghcr.io/objectstack-ai/objectstack).
# Called as a reusable workflow so the same build can be re-run manually via
# workflow_dispatch (e.g. base-image CVE rebuilds) — see docker-publish.yml.
#
# `!cancelled()` rather than the default implicit success(): at most one of
# the two upstream jobs runs on any given event, so the other is always
# SKIPPED — under the implicit success() this job would then never run at
# all. It also survives a publish job that reached npm and then died
# (#4900). The outputs are the gate; the jobs' statuses are not.
#
# On #11233's bookkeeping events (`schedule`, or a dispatch carrying
# `refresh_version_pr`) NEITHER upstream job runs, which is why "at most"
# replaced "exactly". Nothing else here changes: both outputs are then unset,
# unset compares false against 'true', and this job stays skipped — the
# outputs were already the gate, so a third event needed no new condition.
if: ${{ !cancelled() && (needs.release-integrity.outputs.published == 'true' || needs.publish.outputs.published == 'true') }}
permissions:
contents: read
packages: write
uses: ./.github/workflows/docker-publish.yml
with:
version: ${{ needs.publish.outputs.cli-version || needs.release-integrity.outputs.cli-version }}