Skip to content

fix(tooling): merge 驱动不再绑定到「上一个装过依赖的 worktree」,并补上悬空判红的断言 (#4868) (#4… #4531

fix(tooling): merge 驱动不再绑定到「上一个装过依赖的 worktree」,并补上悬空判红的断言 (#4868) (#4…

fix(tooling): merge 驱动不再绑定到「上一个装过依赖的 worktree」,并补上悬空判红的断言 (#4868) (#4… #4531

Workflow file for this run

name: Release
on:
push:
branches:
- main
concurrency: ${{ github.workflow }}-${{ github.ref }}
jobs:
release:
name: Release
runs-on: ubuntu-latest
permissions:
contents: write
pull-requests: write
outputs:
# Either publish path counts (see the recovery step below) — the Docker
# image must ship for a release the fallback rescued just as much as for
# one the Changesets action published itself.
published: ${{ steps.changesets.outputs.published == 'true' || steps.recover-publish.outputs.published == 'true' }}
cli-version: ${{ steps.cli-version.outputs.version || steps.recover-publish.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'
- 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-
# This job ran the full workspace build cold on every main push (~4½
# min) — the same omission lint.yml's typecheck job had. Same key scheme
# as ci.yml. Runs only on main pushes, so plain save+restore is right
# here (this job is one of the cache seeders).
- 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
- name: Build
run: pnpm run build
# The vendored Console dist is a pure function of the objectui pin (and
# of the build script itself) — yet it was cloned + vite-built from
# scratch on every main push (~3 min). Cache the finished dist keyed on
# exactly those inputs; the stamp check below still verifies whatever
# dist ends up in place, restored or fresh.
#
# 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. That gate is now what proves the pin builds; by the
# time this job runs the SHA has already merged.
- 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. Skipped when the
# cache above restored a dist built from the same pin.
if: steps.console-dist-cache.outputs.cache-hit != 'true'
run: bash scripts/build-console.sh
# Belt-and-suspenders for the cache path: build-console.sh stamps the
# objectui SHA it built from into dist/.objectui-sha; check:console-sha
# fails loudly if the dist in place (restored or fresh) drifts from the
# pin.
- 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.
# Clones objectstack-ai/hotcrm@${HOTCRM_REF}, installs it (published
# deps), overlays the freshly-built spec dist, and runs hotcrm's own
# typecheck + `objectstack validate`. A red here blocks the publish.
# The deterministic in-repo floor is @objectstack/downstream-contract;
# this is the live ceiling.
#
# RC pre-mode amendment (#3600): while .changeset/pre.json says
# mode:"pre", the smoke still runs and reports but does NOT block. 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 (observed 2026-07-27: every Release run red on
# the v17 window's removals, so the changesets step never even created
# the Version Packages PR). The gate re-arms by itself the moment
# `changeset pre exit` lands (mode flips / pre.json is consumed) —
# exactly when a migrated hotcrm must exist and HOTCRM_REF gets bumped
# per the note below.
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
run: |
if [ "$(jq -r '.mode // empty' .changeset/pre.json 2>/dev/null)" = "pre" ]; then
echo "::notice::Changesets pre-mode active — the hotcrm smoke is advisory (reported, non-blocking) until 'changeset pre exit'."
if bash scripts/downstream-smoke.sh; then
echo "::notice::hotcrm@${HOTCRM_REF} is still compatible with the pre-release train."
else
echo "::warning::hotcrm@${HOTCRM_REF} is incompatible with the pre-release train — expected for the window's deliberate removals. Ship a migrated hotcrm release and bump HOTCRM_REF before 'changeset pre exit' re-arms this gate."
fi
else
bash scripts/downstream-smoke.sh
fi
- name: Create Release Pull Request or Publish to npm
id: changesets
uses: changesets/action@v1
with:
# publish (pnpm run release) ends in scripts/release-publish.sh,
# which pushes all new version tags in ONE atomic git push. This
# pre-empts changesets/action's own concurrent per-tag pushes, which
# otherwise race GitHub's ref backend (remote: fatal error in
# commit_refs) and reject ~half the tags on a large fixed-group bump (#2191).
publish: pnpm run release
version: pnpm run version
commit: 'chore: version packages'
title: 'chore: version packages'
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
NPM_TOKEN: ${{ secrets.NPM_TOKEN }}
- name: Ensure this version actually shipped (npm + runtime image)
id: recover-publish
# `!cancelled()` is load-bearing, not decoration. GitHub wraps an `if:`
# naming no status function in an IMPLICIT success(), so the first
# version of this step — `if: steps.changesets.outputs.published !=
# 'true'` — could not fire in one of the two cases it exists for: the
# changesets step itself failing. On 89d2a4e it did exactly that. npm
# publish and the atomic tag push both succeeded, then creating the
# @objectstack/spec GitHub Release failed on the API's 125k body limit
# (#4900); the step went red, `published` stayed false, this step was
# skipped, and the 17.0.0-rc.2 runtime image was silently lost.
if: ${{ !cancelled() && steps.changesets.outputs.published != 'true' }}
# It also guards an INVARIANT rather than performing an action: the
# version in this repo must be on npm AND must have a matching runtime
# image. "Publish whatever is missing" — the first contract — was not
# enough: 89d2a4e's version was already on npm, so a publish-only step
# would have no-opped and lost the image just the same.
#
# The two ways the invariant breaks, both observed within one day:
# - nothing published at all: changesets/action reaches its publish
# branch only with ZERO pending changesets, and an EMPTY changeset
# still counts, so a main carrying only empty ones prints "All
# changesets are empty; not creating PR" and returns — no version
# PR, no publish, and a GREEN run (#4898);
# - published, then died before reporting it (#4900, above).
# `changeset publish` skips versions already on the registry, so the
# repair is idempotent and the whole step is a no-op on the normal path,
# where main's version IS the last released one and its image exists.
env:
NPM_TOKEN: ${{ secrets.NPM_TOKEN }}
run: |
version=$(node -p "require('./packages/cli/package.json').version")
# ── npm ─────────────────────────────────────────────────────────────
if npm view "@objectstack/cli@$version" version >/dev/null 2>&1; then
echo "npm: @objectstack/cli@$version is present."
else
echo "::warning::@objectstack/cli@$version is versioned in this repo but absent from npm, and the Changesets action did not publish it (#4898) — publishing it now."
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
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 "::warning::Recovered npm packages and git tags. The GitHub Releases and the ADR-0087 D4 spec-changes attachment were NOT created — those only exist on the Changesets action's own publish path. Create them by hand if this release needs them."
fi
# ── runtime image ───────────────────────────────────────────────────
# Reported through the job outputs so the `docker` job builds it. 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 "published=true"
echo "version=$version"
} >> "$GITHUB_OUTPUT"
- name: Attach spec-changes.json to the GitHub Release (ADR-0087 D4)
# Rebuilds the change manifest with the api-surface diff against the
# previously PUBLISHED spec (reusing the ADR-0059 §3 gate artifact) and
# uploads it to the @objectstack/spec release the changesets action
# just created. The npm artifact carries the registry-derived copy.
if: steps.changesets.outputs.published == 'true'
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
PUBLISHED: ${{ steps.changesets.outputs.publishedPackages }}
run: bash scripts/release-spec-changes.sh
- name: Extract published @objectstack/cli version
id: cli-version
if: steps.changesets.outputs.published == 'true'
# The fixed group bumps every public package in lockstep, so the CLI
# is always in publishedPackages when a publish happened. Passed via
# env (not inline interpolation) to avoid shell-quoting the JSON.
env:
PUBLISHED: ${{ steps.changesets.outputs.publishedPackages }}
run: |
version=$(jq -r '.[] | select(.name=="@objectstack/cli") | .version' <<<"$PUBLISHED")
if [ -z "$version" ]; then
echo "::error::publish succeeded but @objectstack/cli is missing from publishedPackages"
exit 1
fi
echo "version=$version" >> "$GITHUB_OUTPUT"
docker:
name: Docker image
needs: release
# Publish the official runtime image (ghcr.io/objectstack-ai/objectstack)
# for every npm release. 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(): the release job
# can publish to npm and THEN fail (89d2a4e died creating the spec GitHub
# Release, #4900). A dependent job guarded by success() is skipped for any
# upstream failure, so the image was lost to a fault that happened after the
# packages were already public. The `published` output — which the recovery
# step above sets when an image is missing — is the real gate; the job's
# exit status is not.
if: ${{ !cancelled() && needs.release.outputs.published == 'true' }}
permissions:
contents: read
packages: write
uses: ./.github/workflows/docker-publish.yml
with:
version: ${{ needs.release.outputs.cli-version }}