Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
188 changes: 188 additions & 0 deletions .github/workflows/boxel-cli-on-main.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,188 @@
name: Auto-publish boxel-cli unstable

# On every merge to main that touches 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>` to npm under dist-tag `unstable`.
#
# Stable promotion is the manual-boxel-cli-publish.yml workflow.
#
# Loop safety:
# - 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 so prerelease counters don't collide.

on:
push:
branches: [main]
paths:
- "packages/boxel-cli/**"

permissions:
contents: write
id-token: write
pull-requests: read

concurrency:
group: boxel-cli-release
cancel-in-progress: false

jobs:
release-unstable:
name: Regen, bump, publish unstable
if: github.actor != 'github-actions[bot]'
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
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: 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: 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 ts-node --transpileOnly 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: 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: Commit, tag, and push
id: commit
if: 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}"
git tag "$TAG"
echo "tag=$TAG" >> "$GITHUB_OUTPUT"
fi
git push origin main --follow-tags
echo "pushed=true" >> "$GITHUB_OUTPUT"

- name: Publish to npm
if: 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
# Build the dist/ bundle that gets published.
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: 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 }}
run: |
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
43 changes: 43 additions & 0 deletions .github/workflows/boxel-cli-pr-title.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
name: PR Title Check [boxel-cli]

# Validates that PRs touching packages/boxel-cli/** use a conventional-commit
# title (feat:, fix:, chore:, etc.). The boxel-cli-on-main.yml workflow reads
# the merged PR's title to decide the unstable version bump level, so this
# check is the contract that keeps that flow working.
#
# Path-scoped — does NOT run for PRs that don't touch boxel-cli. Don't mark
# this as a required status check in branch protection: required checks on
# path-filtered workflows leave non-boxel-cli PRs pending indefinitely.

on:
pull_request:
types: [opened, edited, synchronize, reopened]
paths:
- "packages/boxel-cli/**"
merge_group:

permissions:
pull-requests: read

jobs:
validate:
name: Validate PR title
runs-on: ubuntu-latest
steps:
- uses: amannn/action-semantic-pull-request@0723387faaf9b38adef4775cd42cfd5155ed6017 # v5.5.3
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with:
types: |
feat
fix
perf
refactor
chore
docs
test
build
ci
style
requireScope: false
subjectPattern: ^.+$
97 changes: 0 additions & 97 deletions .github/workflows/ci-lint.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -125,100 +125,3 @@ jobs:
if: ${{ !cancelled() }}
run: pnpm run lint
working-directory: packages/boxel-cli
- name: Verify Boxel CLI plugin synopsis is fresh
# Regenerates the `<!-- generated:commands -->` blocks in plugin/skills/*/SKILL.md
# from the Commander tree and fails if the working tree changed — i.e. someone
# added or changed a CLI command without running `pnpm build:plugin`.
if: ${{ !cancelled() }}
run: |
pnpm run build:plugin
if ! git diff --exit-code -- plugin/skills; then
echo "::error::plugin/skills synopsis is stale. Run 'pnpm build:plugin' in packages/boxel-cli and commit the result."
exit 1
fi
working-directory: packages/boxel-cli
- name: Verify boxel-skills sync
# Regenerates plugin/skills/boxel-development and plugin/skills/boxel-design from
# cardstack/boxel-skills at the pinned tag in scripts/build-skills.ts. Fails if
# the working tree changed — i.e. someone hand-edited boxel-skills-derived
# content without bumping the pin and re-running `pnpm build:skills`.
if: ${{ !cancelled() }}
run: |
pnpm run build:skills
if ! git diff --exit-code -- plugin/skills; then
echo "::error::plugin/skills is out of sync with cardstack/boxel-skills. Bump BOXEL_SKILLS_VERSION in scripts/build-skills.ts (or fix upstream), run 'pnpm build:skills' in packages/boxel-cli, and commit the result."
exit 1
fi
working-directory: packages/boxel-cli
- name: Verify plugin version bumped when synopsis changed
# If a PR's diff against main touches any generated:commands block, the plugin's
# version must also bump in the same diff. Otherwise marketplace consumers won't
# see the update — Claude Code caches by version string.
if: ${{ !cancelled() && github.event_name == 'pull_request' }}
run: |
set -euo pipefail
BASE="${{ github.event.pull_request.base.sha }}"
HEAD="${{ github.event.pull_request.head.sha }}"
# `actions/checkout` uses default depth, so $BASE (a `main` SHA) usually
# isn't local. Fetch both explicitly so `git diff`/`git show` succeed.
git fetch --no-tags --depth=1 origin "$BASE" "$HEAD"
# Did the generated-commands block change in any SKILL.md? Compare the
# block at $BASE vs $HEAD per-file. Any difference inside the markers
# (heading, description, argument, blank line) flips SYNOPSIS_CHANGED.
# `git cat-file -e` is the explicit existence probe — using `git show`
# with `2>/dev/null` would still propagate exit 128 and trip pipefail.
extract_block() {
local rev="$1" path="$2"
if git cat-file -e "$rev:$path" 2>/dev/null; then
git show "$rev:$path" \
| awk '/<!-- generated:commands:start -->/,/<!-- generated:commands:end -->/'
fi
}
SYNOPSIS_CHANGED=
while IFS= read -r f; do
base_block=$(extract_block "$BASE" "$f")
head_block=$(extract_block "$HEAD" "$f")
if [ "$base_block" != "$head_block" ]; then
SYNOPSIS_CHANGED=1
break
fi
done < <(git ls-tree -r --name-only "$HEAD" -- packages/boxel-cli/plugin/skills | grep '/SKILL\.md$' || true)
if [ -z "$SYNOPSIS_CHANGED" ]; then
echo "No generated synopsis changes detected; skipping version-bump check."
exit 0
fi
# Was the plugin manifest version touched?
if ! git diff "$BASE" "$HEAD" -- packages/boxel-cli/plugin/.claude-plugin/plugin.json \
| grep -qE '^[+-]\s*"version"'; then
echo "::error::plugin/skills synopsis changed but plugin.json version was not bumped. Marketplace consumers won't see the update without a new version. Bump 'version' in packages/boxel-cli/plugin/.claude-plugin/plugin.json."
exit 1
fi
echo "Synopsis changed and plugin.json version was bumped — OK."
working-directory: .
- name: Verify plugin version bumped when boxel-skills content changed
# Mirrors the synopsis-bump check above, but gates on changes to skill folders
# generated by `pnpm build:skills` (boxel-development/, boxel-design/). Any
# content change there requires a plugin.json bump so marketplace consumers
# actually pick up the new content — Claude Code caches by version string.
if: ${{ !cancelled() && github.event_name == 'pull_request' }}
run: |
set -euo pipefail
BASE="${{ github.event.pull_request.base.sha }}"
HEAD="${{ github.event.pull_request.head.sha }}"
# See note in the previous step — fetch base/head explicitly because
# `actions/checkout` uses default depth and $BASE often isn't local.
git fetch --no-tags --depth=1 origin "$BASE" "$HEAD"
SKILLS_CHANGED=$(git diff --name-only "$BASE" "$HEAD" -- \
'packages/boxel-cli/plugin/skills/boxel-development/**' \
'packages/boxel-cli/plugin/skills/boxel-design/**')
if [ -z "$SKILLS_CHANGED" ]; then
echo "No boxel-skills-derived changes detected; skipping version-bump check."
exit 0
fi
if ! git diff "$BASE" "$HEAD" -- packages/boxel-cli/plugin/.claude-plugin/plugin.json \
| grep -qE '^[+-]\s*"version"'; then
echo "::error::boxel-skills-derived content changed but plugin.json version was not bumped. Marketplace consumers won't see the update without a new version. Bump 'version' in packages/boxel-cli/plugin/.claude-plugin/plugin.json."
exit 1
fi
echo "boxel-skills content changed and plugin.json version was bumped — OK."
working-directory: .
Loading
Loading