Skip to content

Merge pull request #5445 from cardstack/fix-compute-release-json-impo… #62

Merge pull request #5445 from cardstack/fix-compute-release-json-impo…

Merge pull request #5445 from cardstack/fix-compute-release-json-impo… #62

name: boxel-cli publish
# Single workflow file that owns both publish paths for @cardstack/boxel-cli:
#
# • `unstable` job
# - On push to main touching `packages/boxel-cli/**` — regenerate plugin
# skill content, decide per-surface version bumps from the merged PR's
# title (conventional-commit prefix), commit the bumps back to main,
# tag, and publish `@cardstack/boxel-cli@<v>-unstable.<n>` under
# dist-tag `unstable`.
# - Via "Run workflow" with the `confirm` field left blank — skip the
# PR-driven bump/commit/tag dance, derive the next free
# `<base>-unstable.<n>` from npm (npm is the source of truth; the bump
# is not committed back to main), and publish it under dist-tag
# `unstable`. Resolving the counter from npm keeps the manual path from
# clobbering an existing version or colliding with the push path.
#
# • `stable` job — "Run workflow" with `confirm = promote`. Strips
# `-unstable.<n>` from the current version and publishes the resulting
# clean semver under dist-tag `latest`. The deliberate "cut a release" step.
#
# Both flows live in one file so they share a single npm Trusted-Publisher rule
# (registered against this filename). Splitting them into separate workflow files
# would require a second trusted-publisher rule on npmjs.com.
#
# Loop safety (unstable job):
# - Bot commits end with [skip ci] so GitHub does not re-trigger workflows.
# - The job guards with `if: github.actor != 'github-actions[bot]'` (belt + suspenders).
#
# Concurrency:
# - `group: boxel-cli-release, cancel-in-progress: false` serializes back-to-back
# merges and any concurrent manual promotion so prerelease counters and stable
# tags can't race.
on:
push:
branches: [main]
paths:
- "packages/boxel-cli/**"
workflow_dispatch:
inputs:
confirm:
description: "Leave blank to publish current main as unstable. Type 'promote' to strip -unstable.N and publish as latest."
required: false
type: string
permissions:
contents: write
id-token: write
pull-requests: read
concurrency:
group: boxel-cli-release
cancel-in-progress: false
jobs:
unstable:
name: Publish unstable
# Manual trigger is restricted to "Use workflow from: main" so the workflow
# YAML interpreting the publish is always main's (defense in depth — the
# checkout step below also pins `ref: main`).
if: >-
github.actor != 'github-actions[bot]'
&& (
github.event_name == 'push'
|| (inputs.confirm == '' && github.ref == 'refs/heads/main')
)
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
ref: main
fetch-depth: 0
# Need fetch-tags so the script can read boxel-cli-v* tags.
fetch-tags: true
token: ${{ secrets.GITHUB_TOKEN }}
- uses: ./.github/actions/init
- name: Configure git
run: |
git config user.name "github-actions[bot]"
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
- name: Fetch PR title from merge SHA
id: pr
if: github.event_name == 'push'
env:
GH_TOKEN: ${{ github.token }}
run: |
set -euo pipefail
PR_JSON=$(gh api "repos/${{ github.repository }}/commits/${{ github.sha }}/pulls" --jq '.[0] // empty')
if [ -z "$PR_JSON" ]; then
echo "No PR associated with ${{ github.sha }} — likely a direct push. Skipping."
echo "skip=true" >> "$GITHUB_OUTPUT"
exit 0
fi
PR_TITLE=$(echo "$PR_JSON" | jq -r '.title')
PR_BODY=$(echo "$PR_JSON" | jq -r '.body // ""')
# Multiline-safe output via heredoc delimiter (GitHub Actions docs).
{
echo "title<<__PR_EOF__"
echo "$PR_TITLE"
echo "__PR_EOF__"
echo "body<<__PR_EOF__"
echo "$PR_BODY"
echo "__PR_EOF__"
} >> "$GITHUB_OUTPUT"
echo "skip=false" >> "$GITHUB_OUTPUT"
echo "PR title: $PR_TITLE"
- name: Regenerate plugin synopsis and skills
if: github.event_name == 'push' && steps.pr.outputs.skip != 'true'
working-directory: packages/boxel-cli
run: |
pnpm run build:plugin
pnpm run build:skills
- name: Compute release
id: release
if: github.event_name == 'push' && steps.pr.outputs.skip != 'true'
working-directory: packages/boxel-cli
env:
PR_TITLE: ${{ steps.pr.outputs.title }}
PR_BODY: ${{ steps.pr.outputs.body }}
run: |
set -euo pipefail
RESULT=$(NODE_NO_WARNINGS=1 pnpm exec node scripts/compute-release.ts)
echo "Compute result: $RESULT"
{
echo "json<<__REL_EOF__"
echo "$RESULT"
echo "__REL_EOF__"
} >> "$GITHUB_OUTPUT"
echo "npmBump=$(echo "$RESULT" | jq -r '.npmBump')" >> "$GITHUB_OUTPUT"
echo "pluginBump=$(echo "$RESULT" | jq -r '.pluginBump')" >> "$GITHUB_OUTPUT"
echo "nextNpm=$(echo "$RESULT" | jq -r '.nextNpm // ""')" >> "$GITHUB_OUTPUT"
echo "nextPlugin=$(echo "$RESULT" | jq -r '.nextPlugin // ""')" >> "$GITHUB_OUTPUT"
- name: Apply version bumps
if: github.event_name == 'push' && steps.pr.outputs.skip != 'true' && (steps.release.outputs.npmBump != 'none' || steps.release.outputs.pluginBump != 'none')
env:
NEXT_NPM: ${{ steps.release.outputs.nextNpm }}
NEXT_PLUGIN: ${{ steps.release.outputs.nextPlugin }}
run: |
set -euo pipefail
if [ -n "$NEXT_NPM" ]; then
node -e '
const fs = require("fs");
const p = "packages/boxel-cli/package.json";
const j = JSON.parse(fs.readFileSync(p, "utf8"));
j.version = process.env.NEXT_NPM;
fs.writeFileSync(p, JSON.stringify(j, null, 2) + "\n");
'
echo "package.json → $NEXT_NPM"
fi
if [ -n "$NEXT_PLUGIN" ]; then
node -e '
const fs = require("fs");
const p = "packages/boxel-cli/plugin/.claude-plugin/plugin.json";
const j = JSON.parse(fs.readFileSync(p, "utf8"));
j.version = process.env.NEXT_PLUGIN;
fs.writeFileSync(p, JSON.stringify(j, null, 2) + "\n");
'
echo "plugin.json → $NEXT_PLUGIN"
fi
- name: Generate release notes
id: notes
if: github.event_name == 'push' && steps.pr.outputs.skip != 'true' && steps.release.outputs.npmBump != 'none'
env:
GH_TOKEN: ${{ github.token }}
NEW_TAG: boxel-cli-v${{ steps.release.outputs.nextNpm }}
NEXT_NPM: ${{ steps.release.outputs.nextNpm }}
NEXT_PLUGIN: ${{ steps.release.outputs.nextPlugin }}
NPM_DIST_TAG: unstable
REPO: ${{ github.repository }}
RUNNER_TEMP_DIR: ${{ runner.temp }}
run: |
set -euo pipefail
# PREV_TAG is the prior boxel-cli-v* tag reachable from HEAD. Tags
# from earlier publishes point at the previous bot commit, which is
# HEAD's parent at this point in the run (we haven't committed yet).
# Use HEAD as the starting ref; git describe walks back to the
# nearest matching tag automatically.
if PREV_TAG=$(git describe --tags --abbrev=0 --match 'boxel-cli-v*' HEAD 2>/dev/null); then
echo "Previous tag: $PREV_TAG"
else
echo "No prior boxel-cli-v* tag found (bootstrap). Falling back to one-line release body."
echo "has=false" >> "$GITHUB_OUTPUT"
exit 0
fi
BODY_FILE="$RUNNER_TEMP_DIR/release-body.md"
# The script writes both a body file and a changelog fragment file.
# We only consume the body here — CHANGELOG.md is intentionally NOT
# updated on unstable publishes (it's reserved for stable cuts; the
# per-bump unstable detail is on the GitHub Releases page).
FRAGMENT_FILE="$RUNNER_TEMP_DIR/changelog-fragment.md"
PREV_TAG="$PREV_TAG" \
NEW_TAG="$NEW_TAG" \
NEXT_NPM="$NEXT_NPM" \
NEXT_PLUGIN="$NEXT_PLUGIN" \
NPM_DIST_TAG="$NPM_DIST_TAG" \
REPO="$REPO" \
RELEASE_BODY_FILE="$BODY_FILE" \
CHANGELOG_FRAGMENT_FILE="$FRAGMENT_FILE" \
NODE_NO_WARNINGS=1 pnpm --filter @cardstack/boxel-cli exec \
node scripts/generate-release-notes.ts
echo "has=true" >> "$GITHUB_OUTPUT"
echo "body_file=$BODY_FILE" >> "$GITHUB_OUTPUT"
- name: Commit, tag, and push
id: commit
if: github.event_name == 'push' && steps.pr.outputs.skip != 'true'
env:
NEXT_NPM: ${{ steps.release.outputs.nextNpm }}
NEXT_PLUGIN: ${{ steps.release.outputs.nextPlugin }}
run: |
set -euo pipefail
git add packages/boxel-cli/package.json \
packages/boxel-cli/plugin/.claude-plugin/plugin.json \
packages/boxel-cli/plugin/skills
if git diff --cached --quiet; then
echo "No changes to commit (regen produced no diff, bumps both none)."
echo "pushed=false" >> "$GITHUB_OUTPUT"
exit 0
fi
MSG="chore(release): boxel-cli"
[ -n "$NEXT_NPM" ] && MSG="$MSG npm=$NEXT_NPM"
[ -n "$NEXT_PLUGIN" ] && MSG="$MSG plugin=$NEXT_PLUGIN"
MSG="$MSG [skip ci]"
git commit -m "$MSG"
if [ -n "$NEXT_NPM" ]; then
TAG="boxel-cli-v${NEXT_NPM}"
# Annotated tag so `git push --follow-tags` actually pushes it.
# Lightweight tags are silently skipped by --follow-tags, which
# would leave the tag on the runner only and fail `gh release create`.
git tag -a "$TAG" -m "$TAG"
echo "tag=$TAG" >> "$GITHUB_OUTPUT"
fi
git push origin main --follow-tags
echo "pushed=true" >> "$GITHUB_OUTPUT"
- name: Bump to next unstable version
# Manual (`workflow_dispatch`, blank confirm) path only. The push path
# already computes a fresh `-unstable.<n>` via compute-release.ts; this
# step gives the manual path the same guarantee. The manual path
# deliberately doesn't commit the bump back to main, so the repo can't
# track the counter — npm is the source of truth. The script reads the
# published versions and picks the next free `<base>-unstable.<n>`, so a
# manual publish can never collide with an already-published version
# (the E403 this step exists to prevent). The push path resolves its
# counter from npm too (compute-release.ts), so they can't collide either.
if: github.event_name == 'workflow_dispatch'
working-directory: packages/boxel-cli
run: |
set -euo pipefail
node -e '
const { execSync } = require("child_process"), fs = require("fs");
const pkg = JSON.parse(fs.readFileSync("package.json", "utf8"));
// Drop any existing -unstable.<n> to get the base we iterate on.
const base = pkg.version.split("-unstable.")[0];
// `npm view ... versions --json` is an array, or a bare string when
// exactly one version is published; `[].concat` normalizes both.
// A non-zero exit (npm outage) is left to abort the run rather than
// be treated as "nothing published" — the latter would pick
// -unstable.0 and re-create the collision this step prevents.
const pub = [].concat(JSON.parse(execSync("npm view @cardstack/boxel-cli versions --json")));
const re = new RegExp("^" + base.replace(/\./g, "\\.") + "-unstable\\.(\\d+)$");
const ns = pub.filter((v) => typeof v === "string").map((v) => (v.match(re) || [])[1]).filter((x) => x != null).map(Number);
pkg.version = base + "-unstable." + (ns.length ? Math.max(...ns) + 1 : 0);
fs.writeFileSync("package.json", JSON.stringify(pkg, null, 2) + "\n");
console.log("next unstable version → " + pkg.version);
'
- name: Publish to npm
if: github.event_name == 'workflow_dispatch' || (steps.pr.outputs.skip != 'true' && steps.release.outputs.npmBump != 'none')
working-directory: packages/boxel-cli
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
run: |
set -euo pipefail
# `pnpm run build` chains `build:test-harness`, which copies
# `packages/host/dist/` into `packages/boxel-cli/bundled-test-harness/`
# (see `packages/boxel-cli/scripts/build-test-harness.ts`).
# Build the host's dev bundle first so that dist exists on
# disk — `vite build --mode=development` is the right mode
# because production strips the test entry (`tests/index.html`)
# entirely. Only sourcemaps are dropped; the resulting harness
# is ~60MB unpacked / ~15MB compressed.
#
# Build workspace deps in topological order before the host:
# * @cardstack/boxel-icons — boxel-ui's `build:types` resolves
# `@cardstack/boxel-icons/*` against its `declarations/`,
# which only exists after the icons package is built.
# * @cardstack/boxel-ui — the host's vite build imports
# `@cardstack/boxel-ui/styles/global.css`, which resolves
# to the addon's `dist/`.
# Plain `pnpm --filter <name>` does NOT include workspace
# dependencies; they must be built explicitly (or with the
# `<name>...` syntax). Without this chain the host build fails
# with "Rolldown failed to resolve import
# @cardstack/boxel-ui/styles/global.css" — and before that,
# boxel-ui's own type build can fail on missing boxel-icons
# declarations.
pnpm --filter @cardstack/boxel-icons build
pnpm --filter @cardstack/boxel-ui build
pnpm --filter @cardstack/host build
pnpm run build
# Use --no-git-checks because the workflow's commit + tag is already
# pushed; pnpm's pre-publish git-state check would otherwise complain.
pnpm publish --tag unstable --access public --provenance --no-git-checks
- name: Create GitHub Release (prerelease)
if: github.event_name == 'push' && steps.pr.outputs.skip != 'true' && steps.release.outputs.npmBump != 'none' && steps.commit.outputs.tag != ''
env:
GH_TOKEN: ${{ github.token }}
TAG: ${{ steps.commit.outputs.tag }}
NEXT_NPM: ${{ steps.release.outputs.nextNpm }}
NOTES_HAS: ${{ steps.notes.outputs.has }}
NOTES_BODY_FILE: ${{ steps.notes.outputs.body_file }}
run: |
set -euo pipefail
if [ "$NOTES_HAS" = "true" ] && [ -f "$NOTES_BODY_FILE" ]; then
gh release create "$TAG" \
--title "@cardstack/boxel-cli v${NEXT_NPM}" \
--notes-file "$NOTES_BODY_FILE" \
--prerelease
else
# Bootstrap fallback: no prior boxel-cli-v* tag, no auto-notes.
NPM_URL="https://www.npmjs.com/package/@cardstack/boxel-cli/v/${NEXT_NPM}"
gh release create "$TAG" \
--title "@cardstack/boxel-cli v${NEXT_NPM}" \
--notes "Auto-published to npm under dist-tag \`unstable\`: ${NPM_URL}" \
--prerelease
fi
stable:
name: Promote latest unstable to stable
# Any non-empty `confirm` routes here so the validation step below can
# error loudly on a typo. Gating on `confirm == 'promote'` here would make
# a typo skip both jobs and look like a successful no-op run.
if: github.event_name == 'workflow_dispatch' && inputs.confirm != ''
runs-on: ubuntu-latest
steps:
- name: Validate confirmation
run: |
if [ "${{ inputs.confirm }}" != "promote" ]; then
echo "::error::Pass 'promote' as the confirm input to proceed."
exit 1
fi
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
ref: main
fetch-depth: 0
fetch-tags: true
token: ${{ secrets.GITHUB_TOKEN }}
- uses: ./.github/actions/init
- name: Configure git
run: |
git config user.name "${{ github.actor }}"
git config user.email "${{ github.actor }}@users.noreply.github.com"
- name: Strip -unstable.N from package.json
id: strip
working-directory: packages/boxel-cli
run: |
set -euo pipefail
CURRENT=$(node -p "require('./package.json').version")
echo "Current version: $CURRENT"
# Match <base>-unstable.<n>. If no suffix, the package is already
# on a stable version — refuse to no-op so we don't accidentally
# re-publish identical bits under a new tag.
if [[ "$CURRENT" != *-unstable.* ]]; then
echo "::error::Version $CURRENT is already stable; nothing to promote."
exit 1
fi
STABLE="${CURRENT%-unstable.*}"
echo "Promoting to: $STABLE"
STABLE="$STABLE" node -e '
const fs = require("fs");
const j = JSON.parse(fs.readFileSync("package.json", "utf8"));
j.version = process.env.STABLE;
fs.writeFileSync("package.json", JSON.stringify(j, null, 2) + "\n");
'
echo "version=$STABLE" >> "$GITHUB_OUTPUT"
- name: Build boxel-cli
working-directory: packages/boxel-cli
run: |
set -euo pipefail
# See the unstable job's "Publish to npm" step for the full
# rationale on the dep ordering — `build:test-harness` reads from
# `packages/host/dist/`, so the host's dev bundle has to exist
# before we can build the bundled test harness, and the host's vite
# build in turn needs `@cardstack/boxel-ui`'s `dist/`, which itself
# needs `@cardstack/boxel-icons`'s `declarations/` for its type
# build. Plain `pnpm --filter <name>` does NOT include workspace
# deps, so they have to be built explicitly.
pnpm --filter @cardstack/boxel-icons build
pnpm --filter @cardstack/boxel-ui build
pnpm --filter @cardstack/host build
pnpm build
- name: Generate release notes
id: notes
env:
GH_TOKEN: ${{ github.token }}
VERSION: ${{ steps.strip.outputs.version }}
REPO: ${{ github.repository }}
RUNNER_TEMP_DIR: ${{ runner.temp }}
run: |
set -euo pipefail
NEW_TAG="boxel-cli-v${VERSION}"
# Find the previous stable boxel-cli-v* tag (skipping any -unstable.*
# tags). We diff against the prior stable so the release notes for a
# stable cut aggregate every PR since the previous stable, not just
# the most recent unstable's single PR.
PREV_TAG=$(git tag --list 'boxel-cli-v*' \
| grep -v -- '-unstable\.' \
| sort -V \
| grep -v -x "$NEW_TAG" \
| tail -n 1 || true)
if [ -z "$PREV_TAG" ]; then
echo "No prior stable boxel-cli-v* tag found (bootstrap). Falling back to one-line release body."
echo "has=false" >> "$GITHUB_OUTPUT"
exit 0
fi
echo "Previous stable tag: $PREV_TAG"
BODY_FILE="$RUNNER_TEMP_DIR/release-body.md"
FRAGMENT_FILE="$RUNNER_TEMP_DIR/changelog-fragment.md"
PREV_TAG="$PREV_TAG" \
NEW_TAG="$NEW_TAG" \
NEXT_NPM="$VERSION" \
NEXT_PLUGIN="" \
NPM_DIST_TAG="latest" \
REPO="$REPO" \
RELEASE_BODY_FILE="$BODY_FILE" \
CHANGELOG_FRAGMENT_FILE="$FRAGMENT_FILE" \
NODE_NO_WARNINGS=1 pnpm --filter @cardstack/boxel-cli exec \
node scripts/generate-release-notes.ts
FRAG_FILE="$FRAGMENT_FILE" node -e '
const fs = require("fs");
const path = "packages/boxel-cli/CHANGELOG.md";
const ANCHOR = "<!-- New entries are inserted directly below this line";
const fragment = fs.readFileSync(process.env.FRAG_FILE, "utf8");
const existing = fs.readFileSync(path, "utf8");
const idx = existing.indexOf(ANCHOR);
let head, tail;
if (idx === -1) {
head = "";
tail = existing;
} else {
const lineEnd = existing.indexOf("\n", idx);
head = existing.slice(0, lineEnd + 1);
tail = existing.slice(lineEnd + 1);
}
fs.writeFileSync(path, head + "\n" + fragment + "\n" + tail);
'
echo "has=true" >> "$GITHUB_OUTPUT"
echo "body_file=$BODY_FILE" >> "$GITHUB_OUTPUT"
- name: Commit and tag stable
env:
VERSION: ${{ steps.strip.outputs.version }}
run: |
set -euo pipefail
TAG="boxel-cli-v${VERSION}"
# Guard against an orphan tag from a prior failed promotion. If the
# tag exists, the previous run pushed it but failed before publish —
# operator needs to delete the orphan (`git push origin :refs/tags/$TAG`)
# and verify npm state before re-running.
if git rev-parse --verify --quiet "refs/tags/$TAG" >/dev/null; then
echo "::error::Tag $TAG already exists. A prior promotion likely failed mid-flight. Delete the orphan tag and verify npm state before re-running."
exit 1
fi
git add packages/boxel-cli/package.json packages/boxel-cli/CHANGELOG.md
git commit -m "Release @cardstack/boxel-cli v${VERSION} [skip ci]"
# Annotated tag so `git push --follow-tags` actually pushes it.
git tag -a "$TAG" -m "$TAG"
git push origin main --follow-tags
- name: Publish to npm under latest
working-directory: packages/boxel-cli
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
run: pnpm publish --access public --provenance --no-git-checks
- name: Create GitHub Release
env:
GH_TOKEN: ${{ github.token }}
VERSION: ${{ steps.strip.outputs.version }}
NOTES_HAS: ${{ steps.notes.outputs.has }}
NOTES_BODY_FILE: ${{ steps.notes.outputs.body_file }}
run: |
set -euo pipefail
TAG="boxel-cli-v${VERSION}"
if [ "$NOTES_HAS" = "true" ] && [ -f "$NOTES_BODY_FILE" ]; then
gh release create "$TAG" \
--title "@cardstack/boxel-cli v${VERSION}" \
--notes-file "$NOTES_BODY_FILE"
else
NPM_URL="https://www.npmjs.com/package/@cardstack/boxel-cli/v/${VERSION}"
gh release create "$TAG" \
--title "@cardstack/boxel-cli v${VERSION}" \
--notes "Promoted from unstable. Published to npm under dist-tag \`latest\`: ${NPM_URL}"
fi