-
Notifications
You must be signed in to change notification settings - Fork 1
369 lines (336 loc) · 18.6 KB
/
Copy pathchangeset-release.yml
File metadata and controls
369 lines (336 loc) · 18.6 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
name: Changeset release
# State machine triggered on every push to `main`. Three cases:
#
# A) `.changeset/*.md` files exist on main — someone just merged a
# develop → main PR that carried unconsumed changesets. Bot
# creates a `release/v<next>` branch off main, runs
# `changeset version` (consumes .changeset, bumps package.json +
# CHANGELOG), pushes, opens a PR `release/v<next> → main`. That
# PR is what the developer reviews + merges.
#
# B) No pending changesets, and the version in
# `ornn-api/package.json` does NOT yet have a matching
# `v<version>` git tag — i.e. the release-bump PR from case A
# was just merged. Bot creates the tag, cuts a GitHub Release
# with the combined CHANGELOG sections, and opens a
# `sync/post-release-v<version> → develop` PR so develop catches
# up with main's new version state.
#
# C) No pending changesets and the tag already exists — a normal
# hotfix / docs / CI push that shouldn't release anything. No-op.
#
# The bot authenticates as the `ornn-release-bot` GitHub App (issue #237)
# rather than the default GITHUB_TOKEN. GITHUB_TOKEN-driven pushes do
# not trigger downstream workflows (GitHub's recursive-run safeguard),
# which leaves required status checks on bot-opened PRs stuck in
# "Expected — waiting" forever. App tokens are a separate identity, so
# the safeguard does not apply.
#
# The app needs `contents: write` + `pull-requests: write` on the repo
# (granted at install time). Org-level "Allow GitHub Actions to create
# and approve pull requests" must stay enabled regardless of the token
# in use.
on:
push:
branches: [main]
concurrency:
group: changeset-release
cancel-in-progress: false
jobs:
release:
runs-on: ubuntu-latest
# The `permissions:` block governs the default GITHUB_TOKEN; this
# workflow doesn't rely on it for any writes (those go through the
# app token minted in the first step) but `contents: read` is
# needed by the checkout that runs before the app token exists.
permissions:
contents: read
steps:
- name: Mint app token
id: app-token
uses: actions/create-github-app-token@v3
with:
app-id: ${{ secrets.RELEASE_BOT_APP_ID }}
private-key: ${{ secrets.RELEASE_BOT_PRIVATE_KEY }}
- name: Discover app bot user id (for git commit email)
id: bot-id
env:
GH_TOKEN: ${{ steps.app-token.outputs.token }}
run: |
SLUG="${{ steps.app-token.outputs.app-slug }}"
# GitHub App bot accounts have a numeric user id used in the
# noreply email pattern:
# <user-id>+<slug>[bot]@users.noreply.github.com
# The id is stable for the lifetime of the app. Look it up
# rather than hardcoding so the workflow keeps working if
# the app is ever recreated with the same slug.
USER_ID=$(gh api "/users/${SLUG}%5Bbot%5D" --jq .id)
echo "slug=${SLUG}" >> "$GITHUB_OUTPUT"
echo "user-id=${USER_ID}" >> "$GITHUB_OUTPUT"
echo "Resolved ${SLUG}[bot] → user id ${USER_ID}"
- uses: actions/checkout@v6
with:
fetch-depth: 0
# Make git push, gh CLI, and downstream `pull_request` event
# fan-out all run under the app identity — this is the whole
# point of #237.
token: ${{ steps.app-token.outputs.token }}
- name: Detect state
id: state
run: |
PENDING=$(find .changeset -name "*.md" ! -name "README.md" 2>/dev/null | wc -l | xargs)
VERSION=$(node -p "require('./ornn-api/package.json').version")
WEB_VERSION=$(node -p "require('./ornn-web/package.json').version")
if [ "$VERSION" != "$WEB_VERSION" ]; then
echo "::error::ornn-api (${VERSION}) and ornn-web (${WEB_VERSION}) versions must match — they are fixed-linked."
exit 1
fi
TAG="v${VERSION}"
if git rev-parse "${TAG}" >/dev/null 2>&1; then
TAG_EXISTS=true
else
TAG_EXISTS=false
fi
echo "pending=${PENDING}" >> "$GITHUB_OUTPUT"
echo "version=${VERSION}" >> "$GITHUB_OUTPUT"
echo "tag=${TAG}" >> "$GITHUB_OUTPUT"
echo "tag_exists=${TAG_EXISTS}" >> "$GITHUB_OUTPUT"
echo "Detected: pending=${PENDING} version=${VERSION} tag=${TAG} tag_exists=${TAG_EXISTS}"
# ─────────────────────────── State A ─────────────────────────── #
- name: Setup Bun (A)
if: steps.state.outputs.pending != '0'
uses: oven-sh/setup-bun@v2
- name: Install deps (A)
if: steps.state.outputs.pending != '0'
run: bun install --frozen-lockfile
- name: Create release-bump PR (A)
if: steps.state.outputs.pending != '0'
env:
GITHUB_TOKEN: ${{ steps.app-token.outputs.token }}
GH_TOKEN: ${{ steps.app-token.outputs.token }}
GIT_AUTHOR_NAME: ${{ steps.bot-id.outputs.slug }}[bot]
GIT_AUTHOR_EMAIL: ${{ steps.bot-id.outputs.user-id }}+${{ steps.bot-id.outputs.slug }}[bot]@users.noreply.github.com
GIT_COMMITTER_NAME: ${{ steps.bot-id.outputs.slug }}[bot]
GIT_COMMITTER_EMAIL: ${{ steps.bot-id.outputs.user-id }}+${{ steps.bot-id.outputs.slug }}[bot]@users.noreply.github.com
run: |
bun run version-packages
NEW_VERSION=$(node -p "require('./ornn-api/package.json').version")
BRANCH="release/v${NEW_VERSION}"
git checkout -B "${BRANCH}"
git add -A
git commit -m "chore: version packages → v${NEW_VERSION}"
git push -fu origin "${BRANCH}"
# Re-open or replace the PR for this version if one already
# exists (e.g. a prior workflow run that failed mid-way).
EXISTING=$(gh pr list --head "${BRANCH}" --base main --state open --json number --jq '.[0].number // empty')
if [ -n "$EXISTING" ]; then
echo "PR #${EXISTING} already open for ${BRANCH} — force-pushed latest."
else
gh pr create \
--base main \
--head "${BRANCH}" \
--title "chore: version packages → v${NEW_VERSION}" \
--body "Auto-opened after a develop → main merge dropped unconsumed \`.changeset/*.md\` on main.
Consumed changesets, bumped \`ornn-api\` + \`ornn-web\` to \`v${NEW_VERSION}\`, appended CHANGELOG entries. Merging this PR triggers the next \`changeset-release\` run which will tag \`v${NEW_VERSION}\` + create the GitHub Release + open a \`sync/post-release-v${NEW_VERSION} → develop\` PR to bring the bump back to \`develop\`."
fi
# ─────────────────────────── State B ─────────────────────────── #
- name: Create tag + GitHub Release (B)
if: steps.state.outputs.pending == '0' && steps.state.outputs.tag_exists == 'false'
env:
GH_TOKEN: ${{ steps.app-token.outputs.token }}
GIT_AUTHOR_NAME: ${{ steps.bot-id.outputs.slug }}[bot]
GIT_AUTHOR_EMAIL: ${{ steps.bot-id.outputs.user-id }}+${{ steps.bot-id.outputs.slug }}[bot]@users.noreply.github.com
GIT_COMMITTER_NAME: ${{ steps.bot-id.outputs.slug }}[bot]
GIT_COMMITTER_EMAIL: ${{ steps.bot-id.outputs.user-id }}+${{ steps.bot-id.outputs.slug }}[bot]@users.noreply.github.com
run: |
VERSION="${{ steps.state.outputs.version }}"
TAG="${{ steps.state.outputs.tag }}"
git tag -a "${TAG}" -m "Release ${TAG}"
git push origin "${TAG}"
# Release-body resolution, in priority order (#431 / #435):
#
# 1. Most recent `.github/release-notes-<yyyymmdd>.md` file
# (filename sort, descending). If it exists and the
# author has filled it in (placeholder string
# `(write here)` removed), strip its HTML comment block
# and use the remaining prose as the release body.
# Footer with CHANGELOG links is always appended.
#
# 2. Short body that just links to the per-package
# CHANGELOG sections on the tag. Used when (1) wasn't
# filled in, or when the full inline notes would
# exceed GitHub's 125 000-char body cap (#429 / #430).
#
# `.github/release-notes-template.md` is the immutable
# template the dated file is copied from — never read here.
# The filename-pattern filter (8-digit date) excludes it
# from the dated-file search.
#
# Inline-CHANGELOG body was retired — even when it fits, the
# raw consumed changesets are engineer-speak that doesn't
# belong on a public release page. Authors curate the
# user-facing summary into the dated file before opening
# their develop → main PR, and the gate in
# `.github/workflows/check-release-notes.yml` enforces it
# on PRs to main.
REPO_URL="https://github.com/${GITHUB_REPOSITORY}"
CHANGELOG_FOOTER=$(cat <<EOF
---
Full per-PR detail: [\`ornn-api\` CHANGELOG](${REPO_URL}/blob/${TAG}/ornn-api/CHANGELOG.md#${VERSION//./}) · [\`ornn-web\` CHANGELOG](${REPO_URL}/blob/${TAG}/ornn-web/CHANGELOG.md#${VERSION//./})
EOF
)
RELEASE_NOTES_FILE=$(ls -1 .github/release-notes-*.md 2>/dev/null \
| grep -E '/release-notes-[0-9]{8}\.md$' \
| sort -r \
| head -1)
USE_CURATED=false
if [ -n "$RELEASE_NOTES_FILE" ] && [ -f "$RELEASE_NOTES_FILE" ] && ! grep -qF '(write here)' "$RELEASE_NOTES_FILE"; then
USE_CURATED=true
fi
if $USE_CURATED; then
echo "Using curated release notes from $RELEASE_NOTES_FILE"
# Strip the leading HTML comment block (instructions to the
# author). Everything between `<!--` and the matching `-->`
# on its own line is dropped.
CURATED=$(awk '
BEGIN { in_comment = 0 }
/^<!--/ { in_comment = 1; next }
/-->$/ { if (in_comment) { in_comment = 0; next } }
!in_comment { print }
' "$RELEASE_NOTES_FILE")
BODY="${CURATED}${CHANGELOG_FOOTER}"
else
echo "No curated dated release-notes file found (or placeholder still present) — using short-body fallback."
BODY=$(cat <<EOF
Release notes weren't curated for v${VERSION}. The full per-PR detail is in the in-repo CHANGELOGs on the \`${TAG}\` tag:
${CHANGELOG_FOOTER}
EOF
)
fi
# Final length safety check — even a curated body shouldn't
# exceed the cap, but guard against an author pasting in a
# 100k-char block of prose by accident.
if [ ${#BODY} -gt 120000 ]; then
echo "::warning::Resolved body is ${#BODY} chars (cap 120 000). Falling back to short link body."
BODY=$(cat <<EOF
Release notes body exceeded the size cap. Full per-PR detail is in the in-repo CHANGELOGs:
${CHANGELOG_FOOTER}
EOF
)
fi
gh release create "${TAG}" --title "${TAG}" --notes "${BODY}"
- name: Open sync PR main → develop (B)
if: steps.state.outputs.pending == '0' && steps.state.outputs.tag_exists == 'false'
env:
GITHUB_TOKEN: ${{ steps.app-token.outputs.token }}
GH_TOKEN: ${{ steps.app-token.outputs.token }}
GIT_AUTHOR_NAME: ${{ steps.bot-id.outputs.slug }}[bot]
GIT_AUTHOR_EMAIL: ${{ steps.bot-id.outputs.user-id }}+${{ steps.bot-id.outputs.slug }}[bot]@users.noreply.github.com
GIT_COMMITTER_NAME: ${{ steps.bot-id.outputs.slug }}[bot]
GIT_COMMITTER_EMAIL: ${{ steps.bot-id.outputs.user-id }}+${{ steps.bot-id.outputs.slug }}[bot]@users.noreply.github.com
run: |
VERSION="${{ steps.state.outputs.version }}"
SYNC_BRANCH="sync/post-release-v${VERSION}"
# Branch off the just-released main commit so the sync PR
# carries only the version bump + consumed-changesets delta
# relative to develop.
git checkout -B "${SYNC_BRANCH}"
git push -fu origin "${SYNC_BRANCH}"
SYNC_PR=$(gh pr list --head "${SYNC_BRANCH}" --base develop --state open --json number --jq '.[0].number // empty')
if [ -n "$SYNC_PR" ]; then
echo "Sync PR #${SYNC_PR} already open — skipping create."
else
SYNC_PR=$(gh pr create \
--base develop \
--head "${SYNC_BRANCH}" \
--title "chore: sync main → develop after v${VERSION}" \
--body "⚠️ **MUST be merged as a merge commit**, not squash.
Auto-opened + auto-merged after \`v${VERSION}\` was tagged on \`main\`.
Brings the version bump + consumed CHANGELOG entries back to \`develop\` so subsequent work starts from the right version. Only delta vs \`develop\` is the release-bump commit.
### Why merge commit (not squash)
A merge commit gives this PR two parents: the current \`develop\` tip **and main's version-bump commit**. That makes \`git merge-base(main, develop)\` = main's HEAD, so the next \`develop → main\` PR diff is clean (no \"version change\" that's already on both sides).
A squash-merge creates a brand-new commit on \`develop\` that has no parent link to main's bump commit. The two branches then carry the same file contents but diverged histories — every subsequent \`develop → main\` PR shows a phantom version bump until the histories rejoin.
The workflow's auto-merge step calls the GitHub merge API directly with \`merge_method: merge\` to enforce this. If you're ever merging this PR manually, click **Create a merge commit**, never **Squash and merge**." \
| tail -1 | grep -oE '[0-9]+$')
echo "Opened sync PR #${SYNC_PR}"
fi
# ── Hard safety rail on auto-merge ────────────────────────
# Code-level guarantee that this path only ever auto-merges a
# PR whose:
# - head branch matches `sync/post-release-v*`
# - base branch is `develop`
# - author is github-actions[bot]
# even if a future edit introduces a new auto-merge call site
# by mistake.
if [ -n "$SYNC_PR" ]; then
PR_META=$(gh pr view "$SYNC_PR" --json headRefName,baseRefName,author --jq '.headRefName + "|" + .baseRefName + "|" + .author.login')
PR_HEAD="${PR_META%%|*}"
REST="${PR_META#*|}"
PR_BASE="${REST%%|*}"
PR_AUTHOR="${REST#*|}"
case "$PR_HEAD" in
sync/post-release-v*) ;;
*)
echo "::error::refusing to auto-merge: head '$PR_HEAD' does not match sync/post-release-v*"
exit 1
;;
esac
if [ "$PR_BASE" != "develop" ]; then
echo "::error::refusing to auto-merge: base '$PR_BASE' is not 'develop'"
exit 1
fi
# Allow the previous github-actions identity (kept for old
# in-flight runs that started before the #237 cutover) and
# the new release-bot app identity. Anything else is
# refused so a hostile actor can't make this safety rail
# auto-merge an arbitrary PR.
case "$PR_AUTHOR" in
github-actions|app/github-actions|github-actions\[bot\]) ;;
ornn-release-bot|app/ornn-release-bot|ornn-release-bot\[bot\]) ;;
*)
echo "::error::refusing to auto-merge: author '$PR_AUTHOR' is not a trusted release bot"
exit 1
;;
esac
fi
# Auto-approve + auto-merge: the sync PR is a deterministic
# replay of a commit that already passed CI on main. No human
# review adds value here. Requires the org-level
# "Allow GitHub Actions to create and approve pull requests"
# setting to be on; otherwise approve is rejected and the PR
# waits for manual action.
#
# IMPORTANT — merge_method: merge is load-bearing.
# Squash-merging creates a new orphan commit on develop whose
# parent is the pre-bump develop tip, not main's bump commit.
# Subsequent develop → main PRs then show a phantom version
# bump diff because git merge-base walks back past the orphan.
# A proper merge commit gives develop two parents — previous
# develop HEAD + main's bump commit — making merge-base(main,
# develop) = main's HEAD after the sync. That keeps the
# histories joined.
if [ -n "$SYNC_PR" ]; then
gh pr review "$SYNC_PR" --approve \
--body "Auto-approved: sync PR carrying the release-bump commit that was already on main." || true
# Call the GitHub merge API directly with merge_method: merge.
# Bypasses `gh pr merge`'s auto-detection that can otherwise
# fall back to the repo's default strategy (often squash).
REPO_FULL="${GITHUB_REPOSITORY:-$(gh repo view --json nameWithOwner --jq .nameWithOwner)}"
MERGE_RESULT=$(gh api \
--method PUT \
"repos/${REPO_FULL}/pulls/${SYNC_PR}/merge" \
-f merge_method=merge \
-f commit_title="chore: sync main → develop after v${VERSION} (#${SYNC_PR})" \
-f commit_message="Auto-merged sync PR carrying main's v${VERSION} bump commit back onto develop." 2>&1) || {
echo "::warning::Direct API merge failed — sync PR left open for manual action. Output:"
echo "$MERGE_RESULT"
echo "$MERGE_RESULT" | grep -qi "Branch not mergeable\|Required status check\|pending review" && \
echo "::warning::Looks like a branch-protection rule is gating the merge. Merge manually with 'Create a merge commit' (NOT squash)." || true
}
echo "$MERGE_RESULT"
fi
# ─────────────────────────── State C ─────────────────────────── #
- name: No-op (C)
if: steps.state.outputs.pending == '0' && steps.state.outputs.tag_exists == 'true'
run: |
echo "v${{ steps.state.outputs.version }} is already tagged — nothing to do."