-
Notifications
You must be signed in to change notification settings - Fork 0
267 lines (241 loc) · 13 KB
/
Copy pathchangeset-release.yml
File metadata and controls
267 lines (241 loc) · 13 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
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
# `sisyphus-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 needs `contents: write` + `pull-requests: write` so it can
# push branches and open PRs. The org-level "Allow GitHub Actions to
# create and approve pull requests" must be enabled for the PR-create
# step to succeed.
on:
push:
branches: [main]
concurrency:
group: changeset-release
cancel-in-progress: false
jobs:
release:
runs-on: ubuntu-latest
permissions:
contents: write
pull-requests: write
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- uses: actions/setup-node@v4
with:
node-version: '22'
- name: Detect state
id: state
run: |
PENDING=$(find .changeset -name "*.md" ! -name "README.md" 2>/dev/null | wc -l | xargs)
VERSION=$(node -p "require('./sisyphus-api/package.json').version")
WEB_VERSION=$(node -p "require('./sisyphus-web/package.json').version")
if [ "$VERSION" != "$WEB_VERSION" ]; then
echo "::error::sisyphus-api (${VERSION}) and sisyphus-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: Install deps (A)
if: steps.state.outputs.pending != '0'
run: npm install --no-audit --no-fund --ignore-scripts --workspaces=false
- name: Create release-bump PR (A)
if: steps.state.outputs.pending != '0'
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
GIT_AUTHOR_NAME: github-actions[bot]
GIT_AUTHOR_EMAIL: 41898282+github-actions[bot]@users.noreply.github.com
GIT_COMMITTER_NAME: github-actions[bot]
GIT_COMMITTER_EMAIL: 41898282+github-actions[bot]@users.noreply.github.com
run: |
npm run version-packages
NEW_VERSION=$(node -p "require('./sisyphus-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 \`sisyphus-api\` + \`sisyphus-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: ${{ secrets.GITHUB_TOKEN }}
GIT_AUTHOR_NAME: github-actions[bot]
GIT_AUTHOR_EMAIL: 41898282+github-actions[bot]@users.noreply.github.com
GIT_COMMITTER_NAME: github-actions[bot]
GIT_COMMITTER_EMAIL: 41898282+github-actions[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}"
extract_section() {
awk -v v="$1" '
/^## / {
if (found) { exit }
if ($0 ~ "^## " v "$") { found=1; next }
}
found { print }
' "$2"
}
API_NOTES=$(extract_section "${VERSION}" sisyphus-api/CHANGELOG.md)
WEB_NOTES=$(extract_section "${VERSION}" sisyphus-web/CHANGELOG.md)
BODY=$(cat <<EOF
## \`sisyphus-api\` ${VERSION}
${API_NOTES}
## \`sisyphus-web\` ${VERSION}
${WEB_NOTES}
EOF
)
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: ${{ secrets.GITHUB_TOKEN }}
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
GIT_AUTHOR_NAME: github-actions[bot]
GIT_AUTHOR_EMAIL: 41898282+github-actions[bot]@users.noreply.github.com
GIT_COMMITTER_NAME: github-actions[bot]
GIT_COMMITTER_EMAIL: 41898282+github-actions[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
case "$PR_AUTHOR" in
github-actions|app/github-actions|github-actions\[bot\]) ;;
*)
echo "::error::refusing to auto-merge: author '$PR_AUTHOR' is not the github-actions 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."