Skip to content

docs(ui): add the report and theming guides, and the book spine half … #6634

docs(ui): add the report and theming guides, and the book spine half …

docs(ui): add the report and theming guides, and the book spine half … #6634

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
# ---------------------------
# push to main → `version-pr` keeps the "chore: version packages" PR
# (#4935) current. Carries NO publish
# 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.
# → `release-integrity` audits ONLY the version at
# `github.sha`. Never publishes, never
# pushes a tag. May backfill GitHub
# Releases / the ADR-0087 D4 asset /
# the runtime image — but only for a
# version ALREADY fully on npm, which
# is repair that cannot mint anything.
# → `publish` the ONLY job that runs
# `changeset publish` or pushes a
# version tag. It starts only when
# `release-integrity` reports that
# main's version is ABSENT from npm —
# i.e. the Version Packages PR has just
# merged — and it is then held, whole,
# at `environment: release` until a
# required reviewer approves it.
# workflow_dispatch → `publish` the repair lane. Takes no version;
# audits main exactly as the push lane
# does. Same environment gate.
#
# 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) stays on push runs —
# harmless bookkeeping, and #4935 must keep regenerating. 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, and that job cannot start without a human
# approving it.
on:
push:
branches:
- main
# 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
# ⛔ 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 main push 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.
jobs:
# ══════════════════════════════════════════════════════════════════════════
# PUSH LANE 1 — version-PR bookkeeping. Structurally cannot publish.
# ══════════════════════════════════════════════════════════════════════════
version-pr:
name: Version PR maintenance
if: github.event_name == 'push'
runs-on: ubuntu-latest
# Serialise against itself so two landings cannot race the force-push to
# `changeset-release/main`; never cancel in progress. 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: Enable Corepack
run: corepack enable
- name: Verify pnpm version
run: pnpm --version
- 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
# `pnpm run version` = changeset version + sync-protocol-version +
# sync-template-versions. All three are pure file rewrites; none reads a
# built artifact. The workspace build, the vendored Console build and the
# live hotcrm smoke that used to run here were PRE-PUBLISH gates — they
# moved to the `publish` job below, where the publish they gate now lives.
# Leaving them here would gate nothing and cost ~9 minutes of every main
# push.
# Quoted because the name embeds `: ` — YAML would otherwise read it as a
# nested mapping (caught by check:workflow-status-functions' self-test).
- name: 'Create or update the "chore: version packages" PR'
id: changesets
uses: changesets/action@v1
with:
# ⛔ THERE IS NO `publish:` INPUT HERE, AND THAT IS THE FIX. ⛔
#
# Not an oversight and not a style choice — it is what makes this lane
# structurally unable to publish, per #6170. changesets/action@v1
# branches on `hasPublishScript = !!publishScript` (src/index.ts):
#
# case !hasChangesets && !hasPublishScript:
# core.info("No changesets present or were removed by merging
# release PR. Not publishing because no publish
# script found.");
# return;
# case hasChangesets:
# await runVersion({...}); // version PR only
#
# With no publish script, `runPublish` is not reachable from any input
# state the action can observe. Adding one back here re-arms the exact
# lane that minted rc.3 and rc.4 without a human.
version: pnpm run version
commit: 'chore: version packages'
title: 'chore: version packages'
# No-op without a publish script. Kept so that re-adding one can never
# silently resurrect #4900: the action posts each package's raw
# CHANGELOG section as the Release body, and @objectstack/spec's
# section for a single v17 RC is ~343k characters against the API's
# 125,000 limit. Releases are created by scripts/release-github-releases.mjs.
createGithubReleases: false
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
# ══════════════════════════════════════════════════════════════════════════
# PUSH LANE 2 — release integrity audit. Reads github.sha ONLY. Never mints.
# ══════════════════════════════════════════════════════════════════════════
release-integrity:
name: Release integrity (audit + no-mint backfill)
# Runs on BOTH events now (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.
runs-on: ubuntu-latest
# Serialised: its backfills create GitHub Releases and push a runtime image,
# and two runs doing that at once is not a state worth reasoning about.
#
# ⚠️ Residual race, stated rather than hidden: GitHub keeps one PENDING run
# per group, so if the version-PR landing's audit is itself queued behind an
# earlier one and a THIRD push arrives, this job is cancelled — and `publish`
# needs it, so the release quietly does not queue. It is narrow (the landing
# run has to be the pending one, not the running one) and it is visible (no
# approval request arrives) and it is recoverable without any special
# handling: the version is still absent from npm, so the `workflow_dispatch`
# repair lane re-audits and queues the same deployment.
concurrency:
group: release-integrity-${{ github.ref }}
cancel-in-progress: false
permissions:
# `contents: write` is for GitHub Releases, never for refs: this job runs
# no `git push` of any kind.
contents: write
outputs:
# "the docker job must build" — set only when npm ALREADY has this
# version and its runtime image is missing.
published: ${{ steps.audit.outputs.image-missing }}
cli-version: ${{ steps.audit.outputs.version }}
# THE release predicate (ADR-0125 D1): 'true' exactly when main's
# @objectstack/cli version is absent from npm — i.e. the Version Packages
# PR has just merged and nothing has shipped it yet. False on every
# ordinary landing, which is why an ordinary merge queues no deployment.
publish-pending: ${{ steps.audit.outputs.publish-pending }}
steps:
- name: Checkout repository
uses: actions/checkout@v7
- name: Setup Node.js
uses: actions/setup-node@v7
with:
node-version: '22'
# ──────────────────────────────────────────────────────────────────────
# R2 — the probe may only ever see the version main ACTUALLY carries.
#
# This job deliberately does not contain the changesets action, so no step
# can re-version its workspace. Belt and braces on top of that: the version
# is read out of the OBJECT DATABASE at `github.sha`, not off disk, and a
# tripwire fails the run if the two ever disagree. Had this shape existed
# on 2026-08-03 the run would have gone red instead of publishing rc.3.
# ──────────────────────────────────────────────────────────────────────
- name: Audit the release that main actually carries
id: audit
env:
SHA: ${{ github.sha }}
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
version=$(git show "${SHA}:packages/cli/package.json" | jq -r '.version')
if [ -z "$version" ] || [ "$version" = "null" ]; then
echo "::error::could not read @objectstack/cli version at ${SHA}"
exit 1
fi
# Tripwire, not decoration: this is the exact assertion the old
# recover-publish step lacked. A workspace that disagrees with
# github.sha means something re-versioned the tree, and that is the
# #6170 mechanism — refuse to act on it rather than probe it.
tree_version=$(jq -r '.version' packages/cli/package.json)
if [ "$tree_version" != "$version" ]; then
echo "::error::workspace carries @objectstack/cli@${tree_version} but ${SHA} carries ${version} — something re-versioned this workspace (#6170). Refusing to audit a version main does not have."
exit 1
fi
echo "version=$version" >> "$GITHUB_OUTPUT"
echo "main (${SHA}) carries @objectstack/cli@${version}"
# ── npm ───────────────────────────────────────────────────────────
if ! npm view "@objectstack/cli@$version" version >/dev/null 2>&1; then
# THE predicate (ADR-0125 D1). Not on npm = the Version Packages PR
# has just merged and nothing has shipped it. This job still cannot
# publish anything — it says so and stays green; the `publish` job
# below reads this output, and IT stops dead on the `release`
# environment until a required reviewer approves it.
echo "publish-pending=true" >> "$GITHUB_OUTPUT"
echo "::notice::main carries @objectstack/cli@${version}, which is NOT on npm. A deployment is queued and is waiting for a maintainer to approve the 'release' environment."
{
echo "## Release ${version} is waiting for your approval"
echo
echo "main (\`${SHA}\`) carries **@objectstack/cli@${version}**, which is not on npm."
echo
echo "The **Publish ${version} to npm** job below is held at the \`release\`"
echo "environment gate. Nothing has been checked out, built or published —"
echo "GitHub holds the whole job until a required reviewer approves it."
echo
echo "**Review this before approving:** the version above is read from the"
echo "object database at \`${SHA}\`, so it is what main actually carries."
echo "Approving publishes exactly it."
} >> "$GITHUB_STEP_SUMMARY"
exit 0
fi
echo "publish-pending=false" >> "$GITHUB_OUTPUT"
echo "npm: @objectstack/cli@${version} is present."
# From here on, every repair is over an ALREADY-PUBLISHED version, so
# none of it can mint anything. This is the #4900 case — published,
# then died before the Releases / D4 asset / image existed.
# ── GitHub Releases + the ADR-0087 D4 asset ───────────────────────
# Two anchors, not all 69: @objectstack/cli is the fixed group's
# canary and @objectstack/spec is both the historical failure (its
# ~343k body hit the API's 125k limit) and D4's mount point. The
# backfill itself is idempotent create-or-update across the whole set.
releases_ok=true
gh release view "@objectstack/cli@${version}" >/dev/null 2>&1 || releases_ok=false
gh release view "@objectstack/spec@${version}" >/dev/null 2>&1 || releases_ok=false
if [ "$releases_ok" = true ]; then
gh release view "@objectstack/spec@${version}" --json assets \
--jq '.assets[].name' 2>/dev/null | grep -qx 'spec-changes.json' || releases_ok=false
fi
if [ "$releases_ok" = true ]; then
echo "GitHub Releases + ADR-0087 D4 asset are present for ${version}."
else
echo "::warning::@objectstack/cli@${version} is on npm but its GitHub Releases or the ADR-0087 D4 asset are incomplete (#4900) — backfilling."
echo "releases-missing=true" >> "$GITHUB_OUTPUT"
fi
# ── runtime image ─────────────────────────────────────────────────
# A failed probe counts as MISSING on purpose: a redundant rebuild
# costs a few minutes, a wrongly-skipped one leaves a published npm
# version with no image and nothing to say so.
if token=$(curl -fsS "https://ghcr.io/token?scope=repository:${GITHUB_REPOSITORY}:pull&service=ghcr.io" 2>/dev/null) \
&& token=$(node -p 'JSON.parse(process.argv[1]).token' "$token" 2>/dev/null) \
&& curl -fsS -o /dev/null -H "Authorization: Bearer $token" \
-H 'Accept: application/vnd.oci.image.index.v1+json' \
-H 'Accept: application/vnd.docker.distribution.manifest.list.v2+json' \
"https://ghcr.io/v2/${GITHUB_REPOSITORY}/manifests/$version" 2>/dev/null
then
echo "ghcr: image for $version is present — release is complete."
exit 0
fi
echo "::warning::No ghcr image for $version (or the registry could not be probed) — requesting the Docker job."
echo "image-missing=true" >> "$GITHUB_OUTPUT"
# Everything below is skipped on the overwhelmingly common path (nothing to
# repair), which is why the install is here rather than at the top of the job.
- name: Enable Corepack
if: steps.audit.outputs.releases-missing == 'true'
run: corepack enable
- name: Install dependencies
if: steps.audit.outputs.releases-missing == 'true'
run: pnpm install --frozen-lockfile
- name: Backfill GitHub Releases (bodies truncated to the API limit)
if: steps.audit.outputs.releases-missing == 'true'
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
# No publish happened in this run, so there is no publishedPackages
# JSON. RELEASE_VERSION drives the whole publishable workspace — the
# Changesets `fixed` group releases every public package at one
# version, which check-changeset-fixed.mjs gates.
RELEASE_VERSION: ${{ steps.audit.outputs.version }}
run: node scripts/release-github-releases.mjs
- name: Backfill spec-changes.json on the GitHub Release (ADR-0087 D4)
# Ordering is load-bearing: `gh release upload` needs the Release the
# step above creates.
if: steps.audit.outputs.releases-missing == 'true'
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
RELEASE_VERSION: ${{ steps.audit.outputs.version }}
run: bash scripts/release-spec-changes.sh
# ══════════════════════════════════════════════════════════════════════════
# HUMAN LANE — the ONLY job in this repository that publishes.
# ══════════════════════════════════════════════════════════════════════════
publish:
# The job NAME is the approval screen (ADR-0125 D2). GitHub shows the job
# name and the environment on the review prompt, so computing the name from
# the audited version is what replaces the typed one: the maintainer
# confirms a version they are SHOWN, read from the object database at
# github.sha, rather than one they recall. #10146 is why that matters — the
# version in that failure's log was @object-ui/console@17.5.0, the vendored
# objectui build, while the repo was publishing 17.1.0.
name: Publish ${{ needs.release-integrity.outputs.cli-version }} to npm (awaiting approval)
needs: [release-integrity]
# ⛔ THE barrier is now the environment, not the trigger (ADR-0125 D3).
#
# An environment with NO protection rules passes AUTOMATICALLY and
# SILENTLY, and a run that passed an unprotected gate is indistinguishable
# in the log from one a human approved. Under this trigger that is not a
# degraded gate, it is NO gate: the push lane would publish end to end
# with nobody deciding, which is exactly the rc.3 / rc.4 incident — 69
# packages, tags, Releases and a runtime image, twice in one week, no
# human in the trigger chain.
#
# Settings → Environments → release → Required reviewers was confirmed
# configured by the maintainer on 2026-08-20, and ADR-0125 is conditional
# on it staying that way. ⚠️ No file in this repo can assert it — not this
# YAML, not a CI gate, not the ADR. It is checked by a human opening the
# settings page. If those reviewers are ever removed, revert the `push`
# trigger to `workflow_dispatch` in the SAME change; do not leave this
# running.
environment: release
# `publish-pending` is the whole trigger predicate: true exactly when main's
# version is absent from npm. Unset (an audit that died before deciding)
# compares false, so this fails CLOSED. `force` is dispatch-only and exists
# for the partial-publish repair D4 describes.
#
# `success() && (...)`, with the parentheses load-bearing: `&&` binds tighter
# than `||`, so `success() && A || B` would let the force branch publish on
# top of a FAILED audit — and the audit is what computes the version this
# job's name, guard and tag all read. If the audit dies, nothing publishes.
if: >-
success() &&
(needs.release-integrity.outputs.publish-pending == 'true' ||
(github.event_name == 'workflow_dispatch' && inputs.force))
runs-on: ubuntu-latest
# One publish at a time per ref, and never cancelled — a run cancelled
# mid-`changeset publish` is the state that leaves a fixed group half on npm.
concurrency:
group: release-publish-${{ github.ref }}
cancel-in-progress: false
permissions:
contents: write
outputs:
published: ${{ steps.publish.outputs.published }}
cli-version: ${{ steps.guards.outputs.version }}
steps:
- name: Checkout repository
uses: actions/checkout@v7
- name: Setup Node.js
uses: actions/setup-node@v7
with:
# Cannot go below 22: the downstream hotcrm smoke below clones
# hotcrm@v1.2.0, whose manifest pins engines.node >=22. pnpm install
# aborts with ERR_PNPM_UNSUPPORTED_ENGINE under that.
node-version: '22'
# ──────────────────────────────────────────────────────────────────────
# The publish lane may only ever ship a commit that is ALREADY on main.
# That is the other half of #6170's title: rc.3 and rc.4 tagged commits
# that lived only on `changeset-release/main`, so main kept stale versions
# and every later release recomputed an npm-occupied number. Nothing here
# runs `changeset version`; this job publishes what the ref carries, or it
# fails.
# ──────────────────────────────────────────────────────────────────────
- name: Guard the approved release (branch, and the tree matches the commit)
id: guards
env:
# What release-integrity read out of the object database at
# github.sha, and what the approval screen named. Read through env,
# never interpolated into the shell.
AUDITED: ${{ needs.release-integrity.outputs.cli-version }}
run: |
if [ "${GITHUB_REF}" != "refs/heads/main" ]; then
echo "::error::the publish lane may only run on main (got ${GITHUB_REF}). Publishing from any other ref would tag and ship code that never landed."
exit 1
fi
# R2's tripwire, kept (ADR-0125 D1). The typed version is gone, so
# this is now the ONLY thing standing between an approval and a
# publish of something main does not carry. It is the exact assertion
# the 2026-08-03 recover-publish step lacked when it shipped rc.3 off
# a re-versioned workspace: read the version from the OBJECT DATABASE
# at github.sha and refuse if the checked-out tree disagrees.
committed=$(git show "${GITHUB_SHA}:packages/cli/package.json" | jq -r '.version')
declared=$(jq -r '.version' packages/cli/package.json)
if [ -z "$committed" ] || [ "$committed" = "null" ]; then
echo "::error::could not read @objectstack/cli version at ${GITHUB_SHA}"
exit 1
fi
if [ "$declared" != "$committed" ]; then
echo "::error::workspace carries @objectstack/cli@${declared} but ${GITHUB_SHA} carries ${committed} — something re-versioned this workspace (#6170). Refusing to publish a version main does not have."
exit 1
fi
# The approval was given against the audited number. If the tree has
# moved since, the human approved a different release than the one
# about to ship — refuse rather than ship the surprise.
if [ -n "$AUDITED" ] && [ "$AUDITED" != "$committed" ]; then
echo "::error::the release approved was @objectstack/cli@${AUDITED} but ${GITHUB_SHA} carries ${committed}. Refusing to publish a version nobody approved."
exit 1
fi
echo "version=$committed" >> "$GITHUB_OUTPUT"
echo "Publishing @objectstack/cli@${committed} from ${GITHUB_SHA} (approved on the 'release' environment)."
{
echo "## Publishing ${committed}"
echo
echo "- ref: \`${GITHUB_REF}\` @ \`${GITHUB_SHA}\`"
echo "- started by: \`${GITHUB_ACTOR}\` (\`${GITHUB_EVENT_NAME}\`)"
echo "- authorised by: the \`release\` environment approval on this run —"
echo " see the run's deployment history for the reviewer and timestamp"
} >> "$GITHUB_STEP_SUMMARY"
- name: Enable Corepack
run: corepack enable
- name: Verify pnpm version
run: pnpm --version
- name: Get pnpm store directory
shell: bash
run: |
echo "STORE_PATH=$(pnpm store path --silent)" >> $GITHUB_ENV
- name: Setup pnpm cache
uses: actions/cache@v6
with:
path: ${{ env.STORE_PATH }}
key: ${{ runner.os }}-pnpm-store-v3-${{ hashFiles('**/pnpm-lock.yaml') }}
restore-keys: |
${{ runner.os }}-pnpm-store-v3-
# Mostly a CONSUMER now, not a seeder: this job used to run on every main
# push and warmed the cache for everyone; it now runs only when a human
# publishes. lint.yml's "Save Turbo cache (main only)" is the seeder. The
# key is namespaced by `github.job`, which changed from `release` to
# `publish` — the first release after this PR builds cold once.
- name: Setup Turbo cache
uses: actions/cache@v6
with:
path: .turbo/cache
key: ${{ runner.os }}-turbo-${{ github.job }}-${{ github.ref_name }}-${{ github.sha }}
restore-keys: |
${{ runner.os }}-turbo-${{ github.job }}-${{ github.ref_name }}-
${{ runner.os }}-turbo-${{ github.job }}-
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Verify Changesets "fixed" group covers every public package
run: node scripts/check-changeset-fixed.mjs
# ──────────────────────────────────────────────────────────────────────
# ⛔ TOMBSTONE — the #3340 pin-currency gate that used to sit here is gone
# (2026-08-20 ruling, #10134). Do not re-add one.
#
# It compared `.objectui-sha` against objectui `main` at publish time and
# refused a release whose pin lagged. The ruling removed the question, not
# just the job: WHICH objectui revision this repo pins is a decision taken
# in an objectstack issue, never derived from another repo's HEAD, so
# "the pin is behind main" is not a defect a release lane may diagnose.
#
# R4's lesson (#6170 — "a gate required on a PR the publishing lane can
# skip is not a gate") is untouched and does not resurrect this one:
# #3340's actual invariant is "everything published is covered by the
# changeset record", and that is carried by `scripts/bump-objectui.sh` +
# `scripts/objectui-changeset-digest.mjs` at BUMP time — on the manual pin
# PR, which is now the only way the pin ever moves. Publishing an old
# console is therefore a published decision, not an unnoticed gap.
# ──────────────────────────────────────────────────────────────────────
- name: Build
run: pnpm run build
# ci.yml's Console Pin Gate (#4290) uses this exact key, so the pin bump's
# PR run and this job share one build — keep the two in step if either
# input set changes.
- name: Cache vendored Console dist (keyed on the objectui pin)
id: console-dist-cache
uses: actions/cache@v6
with:
path: packages/console/dist
key: ${{ runner.os }}-console-dist-${{ hashFiles('.objectui-sha', 'scripts/build-console.sh') }}
- name: Build vendored @objectstack/console SPA
# Clones objectstack-ai/objectui at the SHA pinned in .objectui-sha,
# builds @object-ui/console, and copies dist/ into
# packages/console/dist/. Must run before publish so the prepublishOnly
# guard in @objectstack/console passes.
if: steps.console-dist-cache.outputs.cache-hit != 'true'
run: bash scripts/build-console.sh
- name: Verify Console dist stamp matches pin
run: pnpm check:console-sha
- name: Downstream backward-compat smoke (live hotcrm)
# Pre-publish gate (#2035): the about-to-publish @objectstack/spec must
# not break a real third-party consumer pinned to a published release.
# The deterministic in-repo floor is @objectstack/downstream-contract;
# this is the live ceiling.
#
# ⚠️ CURRENTLY ADVISORY — it runs and reports on every publish, but a
# failure does NOT block. `BLOCKING` below is the whole switch.
#
# Why, and what changed (supersedes the #3600 amendment). #3600 made
# this advisory for the duration of the rc pre-mode window, keyed on
# `.changeset/pre.json` saying mode:"pre", with the reasoning: a major
# train exists precisely to ship deliberate surface removals, and a
# hotcrm release migrated off them cannot exist until the rc.N artifacts
# it would migrate against are published — blocking here deadlocks the
# train. It promised the gate would "re-arm by itself the moment
# `changeset pre exit` lands".
#
# It did exactly that (#8643, 2026-08-14) — and the deadlock was still
# on, because HOTCRM_REF was and is `v2.1.0`, i.e. ObjectStack 14.7. So
# the re-armed gate stood between the 17.0.0 GA publish and a migration
# nobody had done yet, which is the same deadlock #3600 named, one event
# later. The keying was the bug: pre-exit is not when a migrated hotcrm
# release starts existing. Shipping one is.
#
# So the posture is now tied to THAT event instead. Note this is a
# WEAKER gate than #2035 intended and it is meant to be temporary — the
# deterministic in-repo floor (@objectstack/downstream-contract, which
# still blocks) is what carries backward-compat enforcement until it is
# restored.
#
# TO RE-ARM: ship a hotcrm release migrated to v17, bump HOTCRM_REF to
# it, set BLOCKING to '1'. Nothing else changes. Because the smoke keeps
# running and reporting throughout, the run log shows the day it goes
# green — you flip the switch on evidence, not on hope.
env:
# v2.1.0: hotcrm upgraded to ObjectStack 14.7 (hotcrm#448) and
# dropped the agent `visibility` field that spec 15 removes as
# unenforced surface (ADR-0056 D8, #3216).
# Bump this ref whenever a deliberate spec surface removal ships a
# matching hotcrm release.
HOTCRM_REF: v2.1.0
# '1' = a failure fails the publish. '0' = reported only.
BLOCKING: '0'
run: |
if bash scripts/downstream-smoke.sh; then
echo "::notice::hotcrm@${HOTCRM_REF} is compatible with the about-to-publish @objectstack/spec."
exit 0
fi
if [ "$BLOCKING" = '1' ]; then
echo "::error::hotcrm@${HOTCRM_REF} is incompatible with the about-to-publish @objectstack/spec — refusing to publish a release that breaks a real downstream consumer."
exit 1
fi
echo "::warning::hotcrm@${HOTCRM_REF} is incompatible with the about-to-publish @objectstack/spec. ADVISORY ONLY — the publish continues. HOTCRM_REF is pre-v17; ship a migrated hotcrm release, bump it, and set BLOCKING=1 in .github/workflows/release.yml to re-arm this gate."
# ──────────────────────────────────────────────────────────────────────
# The publish itself. `pnpm run release` = build + build-console +
# scripts/release-publish.sh, which is `changeset publish` followed by ONE
# atomic `git push origin --tags` (#2191: the action's concurrent per-tag
# pushes raced GitHub's ref backend and lost ~half the tags).
#
# changesets/action is NOT used here, and that is deliberate: handed a
# workspace with pending changesets it would take the VERSION path and mint
# a commit. `changeset publish` can only ever publish the versions the
# checked-out package.json files already declare — the versions the guard
# step above proved main carries.
# ──────────────────────────────────────────────────────────────────────
- name: Publish to npm + push version tags
id: publish
env:
NPM_TOKEN: ${{ secrets.NPM_TOKEN }}
VERSION: ${{ steps.guards.outputs.version }}
run: |
printf '//registry.npmjs.org/:_authToken=%s\n' "$NPM_TOKEN" >> "$HOME/.npmrc"
git config user.name 'github-actions[bot]'
git config user.email '41898282+github-actions[bot]@users.noreply.github.com'
pnpm run release
# `changeset publish` skips versions already on the registry, so a
# re-dispatch over a partially-published release is a repair, not a
# duplicate. What is NOT optional is that the version is on npm when
# this step ends.
if ! npm view "@objectstack/cli@$VERSION" version >/dev/null 2>&1; then
echo "::error::publish ran but @objectstack/cli@$VERSION is still not on npm"
exit 1
fi
echo "published=true" >> "$GITHUB_OUTPUT"
- name: Create GitHub Releases (bodies truncated to the API limit)
# `!cancelled()` rather than the implicit success(): npm is already
# public by the time this runs, so a failure upstream must not be the
# reason the release record stays empty (#4900).
if: ${{ !cancelled() && steps.publish.outputs.published == 'true' }}
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
# The fixed group releases every public package at one version, so the
# version alone drives the whole publishable workspace.
RELEASE_VERSION: ${{ steps.guards.outputs.version }}
run: node scripts/release-github-releases.mjs
- name: Attach spec-changes.json to the GitHub Release (ADR-0087 D4)
# Ordering is load-bearing: `gh release upload` needs the Release the
# step above created.
if: ${{ !cancelled() && steps.publish.outputs.published == 'true' }}
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
RELEASE_VERSION: ${{ steps.guards.outputs.version }}
run: bash scripts/release-spec-changes.sh
# ══════════════════════════════════════════════════════════════════════════
# Runtime image — fed by either lane. Building an image for a version that is
# already on npm cannot mint anything, so the push lane may request it.
# ══════════════════════════════════════════════════════════════════════════
docker:
name: Docker image
needs: [release-integrity, publish]
# Publish the official runtime image (ghcr.io/objectstack-ai/objectstack).
# Called as a reusable workflow so the same build can be re-run manually via
# workflow_dispatch (e.g. base-image CVE rebuilds) — see docker-publish.yml.
#
# `!cancelled()` rather than the default implicit success(): exactly 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.
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 }}