Skip to content

docs(protocol): the actions.mdx outputSchema bullet says the cloud AI… #10769

docs(protocol): the actions.mdx outputSchema bullet says the cloud AI…

docs(protocol): the actions.mdx outputSchema bullet says the cloud AI… #10769

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`, and names the VERSION
# COMMIT — the landing that brought
# main to that version. 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 on the
# push that CARRIES the version commit,
# while that version is absent from
# npm (ADR-0125 D1 as amended
# 2026-09-29), is then held, whole, at
# `environment: release` until a
# required reviewer approves it, and
# checks out and publishes THE VERSION
# COMMIT — never a later push's head.
# → `stale-prompts` cancels a push-lane publish prompt
# still waiting for a version that is
# already on npm. Mints nothing; never
# touches a job that has started.
# workflow_dispatch → `publish` the repair lane. Takes no version;
# (WITHOUT audits main the same way, minus the
# `refresh_version_pr`) push range (a dispatch is one human
# act, not one per landing), and
# publishes the same version commit.
# 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:release-index-currency-sync
# the release-index stamper's (#15332), for the
# same reason again — on a train whose index is
# already current it rewrites nothing, and a
# rewriter with nothing to do cannot be observed
# working.
# 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
# The release-index surface the currency stamper writes, on the same terms
# again: resolved from `syncedPaths()` in the rewriter, which derives it
# from `INDEX_PATH` in the gate whose finding it clears. Third rewriter,
# third resolved list, zero restated literals.
INDEX_LIST="${RUNNER_TEMP}/post-version-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 post-version surface is unknown. Refusing to call this tree validated."
exit 1
fi
if [ ! -s "${INDEX_LIST}" ]; then
echo "::error::syncedPaths() in scripts/sync-release-index-currency.mjs resolved EMPTY, so the release index the version pass rewrites would read as an unexpected path. Refusing to call this tree validated."
exit 1
fi
# Same four filters cut-rc.yml applies to the same surface: the fixed
# release paths by pattern, then the three 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.
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}" \
| grep -vxF -f "${INDEX_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:release-index-currency-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. Never mints. Reads github.sha and the
# version commit it selects, both out of the object database; every backfill
# builds from that version commit's tree, as the publish job does.
# ══════════════════════════════════════════════════════════════════════════
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 every later push's audit says in its notice
# that main's version is unpublished and was not queued THERE) 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, on the same version commit. ⚠️ Until 2026-09-29 the next
# landing re-queued it by accident — that accident was the defect the
# amended D1 removes (every landing queued a publish of its OWN head), so
# the dispatch is now the only recovery, by design.
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's WHOLE fixed group and its runtime image is missing.
published: ${{ steps.audit.outputs.image-missing }}
cli-version: ${{ steps.audit.outputs.version }}
# The commit `publish` checks out: the newest first-parent commit at which
# @objectstack/cli's version differs from its parent's — the landing that
# brought main to the version above (ADR-0125 D1 as amended 2026-09-29).
# Computed on every audited event, because it is what the approval
# authorises on both lanes.
version-commit: ${{ steps.audit.outputs.version-commit }}
# THE release predicate (ADR-0125 D1, amended 2026-09-29): on a push,
# 'true' exactly when THIS push carries the version commit and that
# version is absent from npm; on the repair dispatch, exactly when the
# version is absent from npm. The old predicate — "absent from npm" alone
# — stayed true on every landing until the publish finished, so each one
# queued its own publish of its own head (17.5.0 shipped 8 PRs past its
# version commit). A later landing now queues nothing and evicts nothing.
publish-pending: ${{ steps.audit.outputs.publish-pending }}
steps:
# `fetch-depth: 0` is load-bearing: the version commit is found by walking
# main's first-parent history, and the push range by ancestry against
# `github.event.before`. On a shallow clone the graft boundary would read
# as "the version changed here" — scripts/release-pending-publish.mjs
# refuses a shallow clone rather than answer from one.
- name: Checkout repository
uses: actions/checkout@v7
with:
fetch-depth: 0
- 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 }}
EVENT: ${{ github.event_name }}
# On a push, main's tip before this push landed (all zeros on a branch
# creation); empty on a dispatch, which has no push range.
BEFORE: ${{ github.event.before }}
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}"
# ── the version commit, and whether THIS event queues its publish ─
# ADR-0125 D1 as amended 2026-09-29. The logic, and the pins that hold
# it (a version commit then two landings, a merge-queue batch, a
# published version, a force-push, a shallow clone), live in
# scripts/release-pending-publish.mjs, whose --self-test lint.yml
# runs. It also asks npm (present / absent / unknown), so the npm
# branch below reads its answer rather than asking twice.
if ! selection=$(node scripts/release-pending-publish.mjs select --event "$EVENT" --head "$SHA" ${BEFORE:+--before "$BEFORE"}); then
echo "::error::could not select the version commit for ${SHA} (reason above). Refusing to decide whether a release is pending."
exit 1
fi
jq . <<<"$selection"
selected_version=$(jq -r '.version' <<<"$selection")
version_commit=$(jq -r '.versionCommit' <<<"$selection")
npm_state=$(jq -r '.npm' <<<"$selection")
pending=$(jq -r '.pending' <<<"$selection")
reason=$(jq -r '.reason' <<<"$selection")
range_detail=$(jq -r '.range.detail' <<<"$selection")
if [ "$selected_version" != "$version" ]; then
echo "::error::the version-commit walk read @objectstack/cli@${selected_version} at ${SHA}, this step read ${version}. Two readers of one object disagree; refusing to act on either."
exit 1
fi
echo "version-commit=${version_commit}" >> "$GITHUB_OUTPUT"
echo "version commit: ${version_commit} — the landing that brought main to ${version}"
# ── npm ───────────────────────────────────────────────────────────
# `unknown` (npm could not be read) keeps this step's historical
# reading, "not on npm": no backfill runs off a guess, and a pending
# publish is a prompt a human still has to approve.
if [ "$npm_state" != 'present' ]; then
if [ "$pending" = 'true' ]; then
# THE predicate (ADR-0125 D1, amended). This push carries the
# version commit 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; this ${EVENT} queues its publish on the version commit ${version_commit}. 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 "Its version commit is \`${version_commit}\`."
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:** approving publishes the tree of the"
echo "version commit \`${version_commit}\` — the commit that brought main to"
echo "${version}, i.e. what the Version Packages PR decided — and nothing that"
echo "landed after it, whatever main's head is by then."
} >> "$GITHUB_STEP_SUMMARY"
exit 0
fi
echo "publish-pending=false" >> "$GITHUB_OUTPUT"
case "$reason" in
version-commit-landed-earlier)
echo "::notice::main carries @objectstack/cli@${version}, not on npm, and its version commit ${version_commit} landed before this push (${range_detail}). Its publish prompt belongs to the push that carried it; this push queues none and evicts none. If no 'Publish ${version} to npm' prompt is waiting, dispatch this workflow (the repair lane) to queue it on ${version_commit}."
;;
range-unreadable)
echo "::warning::main carries @objectstack/cli@${version}, not on npm, and this push's range cannot be read (${range_detail}), so it queues no publish rather than guess whether it carried the version commit ${version_commit}. If no 'Publish ${version} to npm' prompt is waiting, dispatch this workflow (the repair lane) to queue it."
;;
*)
echo "::error::the predicate answered '${reason}' for an unpublished version; this step has no branch for it. Refusing to guess."
exit 1
;;
esac
{
echo "## Release ${version} is not queued by this push"
echo
echo "main (\`${SHA}\`) carries **@objectstack/cli@${version}**, which is not on npm."
echo "Its version commit is \`${version_commit}\`; ${range_detail}."
echo "Only the push that carries the version commit queues its publish."
} >> "$GITHUB_STEP_SUMMARY"
exit 0
fi
echo "publish-pending=false" >> "$GITHUB_OUTPUT"
echo "npm: @objectstack/cli@${version} is present."
# Every repair below 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. "Published" means
# the WHOLE fixed group, and that is read further down, before any
# backfill is requested: `cli` on npm only says a publish STARTED.
# ── 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
# ── 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.
image_ok=false
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
image_ok=true
fi
if [ "$releases_ok" = true ] && [ "$image_ok" = true ]; then
echo "GitHub Releases + ADR-0087 D4 asset are present for ${version}."
echo "ghcr: image for $version is present — nothing to backfill."
exit 0
fi
# ── the WHOLE fixed group, before any backfill (#20627) ──────────
# `cli` on npm is not "the publish is complete": `changeset publish`
# commits the 69 packages over minutes and cli is not the last. npm's
# own `time` on 17.5.0: cli 07:58:57Z, console 08:05:24Z, spec — the
# last — 08:09:33Z. A landing audited at 08:05:53Z read cli, took this
# backfill, and its image build went red installing a group npm did
# not have yet. So no backfill is requested until every package is on
# npm, read by the ONE definition of "published" the publish job's
# own post-publish check uses — scripts/release-verify-npm.mjs, asked
# once (`--probe`) instead of waited on for 15 minutes. Its set is the
# VERSION COMMIT's workspace, the tree the publish job checked out and
# verified; this push's tree may carry a package no release of
# ${version} contains, which would read as absent forever.
#
# Asked only when something IS missing, so the common path (nothing
# to repair) pays no 69 registry reads. The cli reading above stays
# the PUBLISH predicate on purpose: which push queues a publish is
# ADR-0125 D1's question, and a publish that stopped partway already
# has its repair — the `force` dispatch (D4) — not a push.
vc_tree="${RUNNER_TEMP}/release-version-commit"
git worktree add --quiet --detach "$vc_tree" "$version_commit"
# The backfill steps below build from this tree, not from this
# checkout (github.sha), so it is handed to them by name.
echo "version-tree=${vc_tree}" >> "$GITHUB_OUTPUT"
if ! group=$(RELEASE_VERSION="$version" node scripts/release-verify-npm.mjs --probe --root "$vc_tree"); then
echo "::error::could not read whether the whole fixed group of ${version} is on npm (reason above). Refusing to request a backfill nobody measured."
exit 1
fi
jq -c . <<<"$group"
group_state=$(jq -r '.state' <<<"$group")
group_checked=$(jq -r '.checked' <<<"$group")
group_short=$(jq -r '.missing | length' <<<"$group")
group_names=$(jq -r '[.missing[] | "\(.name)@\(.version)"] | (.[:5] | join(", ")) + (if length > 5 then ", and \(length - 5) more" else "" end)' <<<"$group")
case "$group_state" in
published)
echo "npm: all ${group_checked} package(s) of the ${version} fixed group are present."
;;
unpublished)
echo "::notice::@objectstack/cli@${version} is on npm, but ${group_short} of ${group_checked} package(s) of its fixed group are not yet (${group_names}). A publish of ${version} is still shipping, or one stopped partway. Nothing is backfilled from a partly published group: the publish job creates the Releases, the ADR-0087 D4 asset and the runtime image itself, and a landing after the group is complete backfills whatever it did not. A publish that stopped partway is red in its own run, naming what is missing; dispatch this workflow with 'force' to finish it (ADR-0125 D4)."
{
echo "## Release ${version} is not fully on npm yet"
echo
echo "main (\`${SHA}\`) carries **@objectstack/cli@${version}**, which is on npm, but"
echo "${group_short} of ${group_checked} packages of its fixed group are not: ${group_names}."
echo "No GitHub Release, ADR-0087 D4 asset or runtime image is backfilled from a"
echo "partly published group."
} >> "$GITHUB_STEP_SUMMARY"
exit 0
;;
unknown)
echo "::warning::@objectstack/cli@${version} is on npm, but npm could not be read for ${group_short} of ${group_checked} package(s) of its fixed group (${group_names}), so whether the whole group is published is unknown. Nothing is backfilled off a guess; the next landing reads again."
exit 0
;;
*)
echo "::error::the group probe answered '${group_state}'; this step has no branch for it. Refusing to guess."
exit 1
;;
esac
if [ "$releases_ok" = true ]; then
echo "GitHub Releases + ADR-0087 D4 asset are present for ${version}."
else
echo "::warning::${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
if [ "$image_ok" = true ]; then
echo "ghcr: image for $version is present."
else
echo "::warning::No ghcr image for $version (or the registry could not be probed) — requesting the Docker job."
echo "image-missing=true" >> "$GITHUB_OUTPUT"
fi
# 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.
#
# ── Every backfill builds from the VERSION COMMIT's tree ─────────────
# The publish job checks out the version commit and runs that commit's
# own scripts over that commit's own tree (ADR-0125 D1 as amended
# 2026-09-29). A repair describes the same release, so the steps below
# run in the worktree the audit made of the version commit (its
# `version-tree` output), with that commit's lockfile and tooling, and
# never in this checkout. This checkout is `github.sha`, the head of
# whichever push is being audited. A landing after the version commit
# that touched packages/spec/src would otherwise make the D4 asset
# describe a tree npm did not ship, and a Release created here would
# point its CHANGELOG link, and any tag it has to create, at that landing.
#
# The checkout itself is NOT swapped: the audit above reads `github.sha`
# and its tripwire reads this workspace, and both stay as they are. Each
# step re-asserts that its tree is the version commit and refuses
# otherwise, because an empty `version-tree` would leave the step in
# this checkout, which is the defect, with nothing said.
#
# Pinned by battery 13 of `node scripts/release-verify-npm.mjs
# --self-test` (lint.yml runs it): these steps' own text runs after the
# audit's, and a landing that moved packages/spec/src is the fixture.
- name: Setup pnpm
if: steps.audit.outputs.releases-missing == 'true'
uses: ./.github/actions/setup-pnpm
- name: Install dependencies (the version commit's tree)
if: steps.audit.outputs.releases-missing == 'true'
env:
VERSION_COMMIT: ${{ steps.audit.outputs.version-commit }}
VERSION_TREE: ${{ steps.audit.outputs.version-tree }}
run: |
if [ -z "$VERSION_TREE" ] || [ "$(git -C "$VERSION_TREE" rev-parse HEAD 2>/dev/null)" != "$VERSION_COMMIT" ]; then
echo "::error::the version commit's tree ('${VERSION_TREE}') is not at the version commit '${VERSION_COMMIT}'. Refusing to backfill from any other tree."
exit 1
fi
cd "$VERSION_TREE"
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 }}
VERSION_COMMIT: ${{ steps.audit.outputs.version-commit }}
VERSION_TREE: ${{ steps.audit.outputs.version-tree }}
# The script reads every package and CHANGELOG.md from the tree it
# lives in, and takes `GITHUB_SHA` as each Release's `target_commitish`
# and as the ref of its CHANGELOG permalink. Both must be the version
# commit. GitHub ignores `target_commitish` when the tag already exists,
# but a publish that died before its tag push leaves no tag, and the
# Release then CREATES it at `target_commitish`. So the version
# commit's own script runs in its own tree, with GITHUB_SHA set for that
# one process only.
run: |
if [ -z "$VERSION_TREE" ] || [ "$(git -C "$VERSION_TREE" rev-parse HEAD 2>/dev/null)" != "$VERSION_COMMIT" ]; then
echo "::error::the version commit's tree ('${VERSION_TREE}') is not at the version commit '${VERSION_COMMIT}'. Refusing to backfill from any other tree."
exit 1
fi
cd "$VERSION_TREE"
GITHUB_SHA="$VERSION_COMMIT" 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.
#
# `--prepare` regenerates the manifest against the previously published
# tarball exactly as the publish job does: in the version commit's
# tree, with that commit's generator. So the asset this repair uploads
# carries the same per-release section the npm artifact does. It cannot
# repair the npm artifact itself — that tarball is immutable — and this
# lane never publishes one.
if: steps.audit.outputs.releases-missing == 'true'
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
RELEASE_VERSION: ${{ steps.audit.outputs.version }}
VERSION_COMMIT: ${{ steps.audit.outputs.version-commit }}
VERSION_TREE: ${{ steps.audit.outputs.version-tree }}
run: |
if [ -z "$VERSION_TREE" ] || [ "$(git -C "$VERSION_TREE" rev-parse HEAD 2>/dev/null)" != "$VERSION_COMMIT" ]; then
echo "::error::the version commit's tree ('${VERSION_TREE}') is not at the version commit '${VERSION_COMMIT}'. Refusing to backfill from any other tree."
exit 1
fi
cd "$VERSION_TREE"
bash scripts/release-spec-changes.sh --prepare
bash scripts/release-spec-changes.sh --attach
# ══════════════════════════════════════════════════════════════════════════
# PUSH LANE 3 — stale approval prompts. Cancels; never publishes, never
# approves, never touches a job that has started.
# ══════════════════════════════════════════════════════════════════════════
stale-prompts:
name: Cancel publish prompts for versions already on npm
# ADR-0125 D1 as amended 2026-09-29: a prompt for a version that is already
# published does not stay waiting.
#
# WHY IT MUST BE CANCELLED, NOT MERELY REPORTED — measured on the runs, not
# assumed: the publish job that holds `release-publish-<ref>` waits at the
# `release` environment WHILE HOLDING the group, and every later publish
# job pends behind it. So a prompt left waiting after its version shipped
# is not just a lookalike, it HIDES the next real one: 17.4.0's run
# 34308599522 waited 20 days, was the only visible prompt when 17.5.0 was
# queued, and was approved by mistake; 17.5.0 then left run 36539819278
# waiting at 08:11Z, after cli@17.5.0 reached npm at 07:58:57Z.
#
# What is cancelled, exactly (scripts/release-pending-publish.mjs `sweep`,
# pinned by its --self-test): a WAITING run of this workflow, not this run,
# started by `push`, whose `Publish VERSION to npm (awaiting approval)` job
# is waiting at the `release` environment, with VERSION present on npm.
# - A dispatch run is never touched: it is a human's own act, and the D4
# `force` repair legitimately waits for a version whose CLI is on npm.
# - A job GitHub holds at an environment has run no step, so this never
# interrupts a publish; the one it cancels could publish nothing — and
# if an approval lands in the second before the cancel, `publish`'s own
# guard refuses a push-lane release that is already on npm.
# Rejecting instead was measured and is not available: the review endpoint
# answers only a REQUIRED REVIEWER of the environment, which the workflow
# token is not (and must not become).
#
# Same two events as `release-integrity`, for the same reason: bookkeeping
# events get no release machinery. Not `needs`-ed by `publish` and needing
# nothing itself: a failed read here must be loud (a red job on main) but
# must never be able to hold up a release, and the order does not matter —
# a publish job queued behind a stale holder takes the group the moment
# the holder is cancelled.
if: >-
github.event_name == 'push' ||
(github.event_name == 'workflow_dispatch' && !inputs.refresh_version_pr)
runs-on: ubuntu-latest
permissions:
# `actions: write` is the cancel (POST .../runs/{id}/cancel); it also
# covers the reads (the waiting-runs list, the runs at each version
# commit's push, each named run, its jobs and its pending deployments).
# `contents: read` is the checkout.
actions: write
contents: read
steps:
# `fetch-depth: 0`: a push-lane prompt can only hang on the push that
# carried a version commit, and the sweep finds those on main's
# first-parent history; it refuses a shallow clone.
- name: Checkout repository
uses: actions/checkout@v7
with:
fetch-depth: 0
- name: Setup Node.js
uses: actions/setup-node@v7
with:
node-version: '22'
- name: Cancel waiting publish prompts whose version is already on npm
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: node scripts/release-pending-publish.mjs sweep --workflow release.yml
# ══════════════════════════════════════════════════════════════════════════
# 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: on a push, true exactly
# when that push carries the version commit and the version is absent from
# npm; on the repair dispatch, when the version is absent from npm (ADR-0125
# D1 as amended 2026-09-29). 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.
#
# ⚠️ ADR-0125 D5's eviction applies to THIS group too, and did, until the
# 2026-09-29 amendment of D1. Measured on the runs: the job holding the
# group waits at the `release` environment WHILE HOLDING it; every later
# publish job pends behind it and evicts the pending one before it. While
# the predicate was "absent from npm", every landing queued one, so the
# prompt moved under the maintainer and whichever was pending last shipped
# ITS head. Now only the push carrying the version commit queues a publish,
# so the group holds one job per version and nothing evicts it; and a job
# left holding the group for a version already on npm is cancelled by
# `stale-prompts` above, never by `cancel-in-progress`.
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:
# THE VERSION COMMIT, not `github.sha` (ADR-0125 D1 as amended
# 2026-09-29). `github.sha` is the head of whichever push queued this run;
# the version commit is the landing that brought main to the version the
# approval screen names — the tree the Version Packages PR decided to
# release. On 17.5.0 the two were eight PRs apart, one of them breaking.
#
# `fetch-depth: 0` is load-bearing for the guard below: "the version
# commit is on main" is an ancestry test against the freshly fetched
# `origin/main`, and on a shallow clone a non-ancestor answer is not
# evidence of anything.
- name: Checkout repository
uses: actions/checkout@v7
with:
ref: ${{ needs.release-integrity.outputs.version-commit }}
fetch-depth: 0
- 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, version commit, and the tree matches it)
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 }}
# The commit the checkout above was asked for. Empty would make
# `ref:` fall back to github.sha — the very defect — so it is refused.
VERSION_COMMIT: ${{ needs.release-integrity.outputs.version-commit }}
# 'true' only on the dispatch-only D4 repair, whose whole point is a
# version whose CLI is already on npm.
FORCE: ${{ github.event_name == 'workflow_dispatch' && inputs.force }}
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
# ── the version commit (ADR-0125 D1 as amended 2026-09-29) ────────
# Every assertion here fails closed: the checkout must be the commit
# the audit selected, that commit must already be on main, and it must
# be the commit that CHANGED the version — not merely one carrying it.
if ! printf '%s' "$VERSION_COMMIT" | grep -Eqx '[0-9a-f]{40}'; then
echo "::error::release-integrity named no version commit ('${VERSION_COMMIT}'). An empty ref would check out github.sha — the head of whichever push queued this run — so refusing."
exit 1
fi
head=$(git rev-parse HEAD)
if [ "$head" != "$VERSION_COMMIT" ]; then
echo "::error::the workspace is at ${head}, not the version commit ${VERSION_COMMIT}. Refusing to publish a tree nobody selected."
exit 1
fi
if [ "$(git rev-parse --is-shallow-repository)" != 'false' ]; then
echo "::error::shallow clone: an ancestry answer here would not be evidence. The checkout above must keep fetch-depth: 0."
exit 1
fi
if ! git merge-base --is-ancestor "$VERSION_COMMIT" refs/remotes/origin/main; then
echo "::error::the version commit ${VERSION_COMMIT} is not on main as fetched just now. Publishing it would tag and ship code that never landed."
exit 1
fi
if ! git merge-base --is-ancestor "$VERSION_COMMIT" "$GITHUB_SHA"; then
echo "::error::the version commit ${VERSION_COMMIT} is not an ancestor of ${GITHUB_SHA}, the head it was selected from."
exit 1
fi
# R2's tripwire, kept (ADR-0125 D1), now against the version commit.
# The typed version is gone, so this is the 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 the commit being published and refuse if the
# checked-out tree disagrees.
committed=$(git show "${VERSION_COMMIT}: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 ${VERSION_COMMIT}"
exit 1
fi
if [ "$declared" != "$committed" ]; then
echo "::error::workspace carries @objectstack/cli@${declared} but ${VERSION_COMMIT} carries ${committed} — something re-versioned this workspace (#6170). Refusing to publish a version main does not have."
exit 1
fi
parent=$(git show "${VERSION_COMMIT}^1:packages/cli/package.json" 2>/dev/null | jq -r '.version' || true)
if [ "$parent" = "$committed" ]; then
echo "::error::${VERSION_COMMIT} carries @objectstack/cli@${committed} but so does its parent, so it is not the commit that changed the version. Refusing to publish from anything but the version commit."
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 ${VERSION_COMMIT} carries ${committed}. Refusing to publish a version nobody approved."
exit 1
fi
# ── a stale prompt, approved anyway ───────────────────────────────
# A prompt can outlive its release: queued while the version was
# absent, approved after some other run shipped it. The 17.4.0 prompt
# of run 34308599522 was approved that way, in place of 17.5.0's.
# Approving one must publish nothing and say so. `force` is exempt —
# a partial publish leaves the CLI canary on npm by definition. An
# unreadable registry is not evidence of staleness: `changeset
# publish` skips an already-published version by itself.
#
# The same three-way reading as classifyNpmView() in
# scripts/release-pending-publish.mjs, INLINED on purpose: this guard
# runs before the checked-out tree is trusted, so it executes nothing
# from that tree — git, jq and npm only, as the rest of it does. (The
# version commit's tree may also predate the script.)
if [ "$FORCE" != 'true' ]; then
if npm_out=$(npm view "@objectstack/cli@${committed}" version 2>"${RUNNER_TEMP}/npm-view.err"); then
if [ "$(printf '%s' "$npm_out" | tr -d '[:space:]')" = "$committed" ]; then
echo "::error::@objectstack/cli@${committed} is already on npm, so this approval prompt was stale when it was approved — nothing is published from it. If a newer release is due, its own 'Publish VERSION to npm' prompt is the one to approve; if ${committed} is only partly published, dispatch this workflow with 'force' (ADR-0125 D4)."
exit 1
fi
elif ! grep -q 'E404' "${RUNNER_TEMP}/npm-view.err"; then
cat "${RUNNER_TEMP}/npm-view.err"
echo "::warning::npm could not be read to confirm @objectstack/cli@${committed} is still unpublished; continuing, since changeset publish skips any version the registry already has."
fi
fi
echo "version=$committed" >> "$GITHUB_OUTPUT"
echo "Publishing @objectstack/cli@${committed} from its version commit ${VERSION_COMMIT} (approved on the 'release' environment; this run was queued by ${GITHUB_SHA})."
{
echo "## Publishing ${committed}"
echo
echo "- version commit: \`${VERSION_COMMIT}\` — the tree that is built, tagged and published"
echo "- queued by: \`${GITHUB_REF}\` @ \`${GITHUB_SHA}\` (\`${GITHUB_EVENT_NAME}\`)"
echo "- started by: \`${GITHUB_ACTOR}\`"
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. Keyed on
# the VERSION COMMIT, the tree actually built, not on `github.sha`
# (ADR-0125 D1 as amended 2026-09-29); turbo re-hashes its inputs either
# way, so the key names the entry and decides nothing about correctness.
#
# ⚠️ From the checkout on, the steps are this FILE at `github.sha` while
# the scripts they run are the VERSION COMMIT's. On the version push the
# two are the same commit or a merge-queue batch apart; on a repair
# dispatch they can be further apart. A step naming a script the version
# commit predates fails on the missing file, which is the right failure:
# a release is built by the version commit's own tooling.
- name: Setup Turbo cache
uses: actions/cache@v6
with:
path: .turbo/cache
key: ${{ runner.os }}-turbo-${{ github.job }}-${{ github.ref_name }}-${{ needs.release-integrity.outputs.version-commit }}
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
- name: Read the spec's declared zod range (Console dist key input)
id: spec-zod
# Fails rather than keying on an empty or `undefined` range: a key that
# silently stopped carrying the range would restore across a zod move.
run: |
node -e '
const range = require("./packages/spec/package.json").dependencies?.zod;
if (typeof range !== "string" || range.trim() === "") {
console.error("packages/spec/package.json declares no dependencies.zod, so the Console dist key cannot carry it.");
process.exit(1);
}
console.log("range=" + range.trim());
' >> "$GITHUB_OUTPUT"
# This key hashes the pin and the build script, and carries the spec's
# DECLARED zod range (the step above). ci.yml's Console Pin Gate (#4290)
# hashes a SUPERSET of the files: those two, the probe scripts and the
# spec's entry layout (#20765). So the two workflows no longer share dist
# entries, and each one builds its own on a miss. Keeping this key narrower
# is deliberate.
# Following ci.yml would rebuild the console on every Version Packages
# merge, and publishing a cached console whose spec content lags is the
# existing cache design.
#
# The zod range is the one spec input the cached dist is NOT allowed to
# lag (#21108). The console bundles a single zod instance, the console's
# own copy, and its build refuses a console zod outside the injected spec's
# declared `zod` range (objectui#11353). A dist restored after that range
# moved would publish a console the build never judged against it. The
# range is read out of packages/spec/package.json, not hashed with it: the
# file's `version` moves on every Version Packages merge, and hashing the
# whole file would turn every publish into a cold console rebuild.
#
# Split restore/save, matching ci.yml, and not the combined actions/cache
# action: the combined form's post-step saves even when the job FAILED,
# and build-console.sh writes the SHA stamp BEFORE it asserts the bundle
# canary — so a canary failure here would seed this shared, repo-scoped
# key with a stamped-but-broken dist that later runs restore and sail
# through. See ci.yml's split-restore note for the full rationale.
- name: Restore vendored Console dist (keyed on the objectui pin)
id: console-dist-cache
uses: actions/cache/restore@v6
with:
path: packages/console/dist
key: ${{ runner.os }}-console-dist-${{ hashFiles('.objectui-sha', 'scripts/build-console.sh') }}-zod-${{ steps.spec-zod.outputs.range }}
- 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
# The spec-injection half of the same question, spelled exactly as
# ci.yml's Console Pin Gate spells it — both consumers of one cache key
# apply one check. check:console-sha answers only "which objectui SHA",
# and scripts/assert-console-spec-injection.mjs runs INSIDE
# build-console.sh, which the `if: cache-hit != 'true'` above skips — so
# on a cache hit nothing had asked whether the dist this job is about to
# PUBLISH bundles this tree's @objectstack/spec rather than the published
# tarball. --require-stamp because the vacuity check:console-sha tolerates
# (no dist, or a dist no build ever stamped) is wrong on a publish lane.
- name: Verify the restored Console dist bundles this tree's spec
env:
CONSOLE_DIST_CACHE_KEY: ${{ runner.os }}-console-dist-${{ hashFiles('.objectui-sha', 'scripts/build-console.sh') }}-zod-${{ steps.spec-zod.outputs.range }}
run: pnpm check:console-injection --require-stamp
# Reached only with every console assertion above green: Actions steps
# default to `success()`, and nothing between the restore and here carries
# `if: always()` or `continue-on-error`. Saving only on a miss keeps an
# immutable entry from being rewritten on every release run.
- name: Save vendored Console dist
if: steps.console-dist-cache.outputs.cache-hit != 'true'
uses: actions/cache/save@v6
with:
path: packages/console/dist
key: ${{ runner.os }}-console-dist-${{ hashFiles('.objectui-sha', 'scripts/build-console.sh') }}-zod-${{ steps.spec-zod.outputs.range }}
- 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."
# ──────────────────────────────────────────────────────────────────────
# ADR-0087 D4 — the per-release section, and the gate that proves it.
#
# Both steps run BEFORE `changeset publish`, and that ordering is the
# whole design. The section has to be in the tree when the tarball is
# packed (a consumer's tooling reads `node_modules`, not a Release page),
# and a delta that disagrees with the artifacts must stop the release
# while stopping it is still free — after `changeset publish` the tarball
# is immutable and the only remaining repair is another version.
#
# `--no-git-checks` is what `changeset publish` passes to `pnpm publish`
# (its own source), so the working-tree edit `--prepare` makes does not
# block the publish. The committed copy is untouched: it stays the
# registry-only projection `check:spec-changes` gates on every PR.
# ──────────────────────────────────────────────────────────────────────
- name: Generate the per-release spec-changes section (ADR-0087 D4)
env:
RELEASE_VERSION: ${{ steps.guards.outputs.version }}
run: bash scripts/release-spec-changes.sh --prepare
- name: Correctness gate — the delta must match both tarballs
# ⛔ A failure here fails the release, by design (#17080): a wrong change
# file is worse than none, because a consumer stops looking once it has
# one. The gate names the disagreeing exports and the direction of each
# disagreement, so a held release arrives with its own diagnosis.
env:
RELEASE_VERSION: ${{ steps.guards.outputs.version }}
run: bash scripts/release-spec-changes.sh --verify
# ──────────────────────────────────────────────────────────────────────
# 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 EVERY package is on npm when
# this step ends.
#
# ⚠️ This checks what happens AFTER a publish, never WHETHER one runs.
# The gating above it — `environment: release`, the `publish-pending`
# predicate and its parenthesisation, `force`'s dispatch-only
# semantics — is the "ONLY A HUMAN PUBLISHES" invariant and is not
# this check's business.
#
# It used to be one `npm view` of @objectstack/cli, and #15321 is the
# two ways that failed on the 17.3.0 release, which had succeeded
# completely:
#
# ① It raced the registry. npm committed cli@17.3.0 at 10:53:25.108
# and this read at 10:53:32 — seven seconds later — and got an
# absence. The write path and the CDN-fronted read path are
# eventually consistent; a single shot immediately after a
# 69-package burst bets the release on how fast reads settle.
# scripts/release-verify-npm.mjs retries with bounded backoff for
# 15 minutes, derived from that release's own npm `time` field:
# the last package (@objectstack/runtime) committed 7m07s after
# the first read.
#
# ② It looked at 1 package of 69. Had a package genuinely failed to
# publish, this step could not have seen it — and the false red
# on `cli` would have MASKED it. The verifier now derives the
# whole publishable set from the workspace (never a transcribed
# list) and names, on failure, exactly which packages are absent,
# so the `force` repair dispatch has something to act on.
#
# ⛔ It still fails CLOSED. The retry absorbs LATENCY, never absence:
# a package still missing when the budget is spent exits non-zero, and
# so does a registry that could not be read at all. No `|| true`, no
# downgrade to a warning — the value of this step is that it reds.
# `published=true` below is written only on its success.
#
# `$VERSION` is handed over as RELEASE_VERSION — the spelling the two
# steps below already use for the same value — so the verifier refuses
# outright if the workspace it derives targets from disagrees with the
# version this run was approved for.
if ! RELEASE_VERSION="$VERSION" node scripts/release-verify-npm.mjs; then
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 }}
# Uploads the file the two steps above generated and verified — the
# Release asset and the npm artifact are the same bytes by construction,
# not by two runs agreeing.
run: bash scripts/release-spec-changes.sh --attach
# ══════════════════════════════════════════════════════════════════════════
# 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 }}