-
Notifications
You must be signed in to change notification settings - Fork 12
513 lines (487 loc) · 23.2 KB
/
Copy pathboxel-cli-publish.yml
File metadata and controls
513 lines (487 loc) · 23.2 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
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 \
packages/boxel-cli/plugin/commands \
packages/boxel-cli/plugin/README.md \
packages/boxel-cli/scripts/.boxel-skills-manifest.json
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