-
-
Notifications
You must be signed in to change notification settings - Fork 2
497 lines (458 loc) · 21.5 KB
/
release-recover-cli.yml
File metadata and controls
497 lines (458 loc) · 21.5 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
name: Release Recover (moltnet-cli)
# Recovers a stuck `apps/moltnet-cli` release cycle. Handles two scenarios:
#
# A) goreleaser-failure: cli was released in the same cycle as one or
# more libs it depends on. release-please bumped the lib versions in
# the manifest but did NOT sync `apps/moltnet-cli/go.mod`, so
# goreleaser built against a stale pin. Needs manifest revert +
# CHANGELOG drop + go.mod sync.
#
# B) downstream-publish-failure: goreleaser succeeded but one of the
# npm publish jobs (or a sibling tag creation) failed transiently.
# The cli tag + release exist as draft and/or the 7 linked-versions
# sibling tags are half-created, blocking the next release-please
# run. Needs manifest revert + CHANGELOG drop + draft/tag cleanup.
# No go.mod sync — no libs were bumped in this cycle.
#
# The workflow auto-detects which mode applies by diffing the cli's
# direct libs/* dependencies in `apps/moltnet-cli/go.mod` against the
# manifest before/after versions.
#
# Usage: trigger this workflow with the number of the merged release-please
# PR that caused the failure (the "chore: release main" PR). Everything
# else — previous/stuck cli versions, which libs to re-pin — is inferred by
# diffing `.release-please-manifest.json` between the PR's merge commit and
# its first parent, then filtering against the cli's actual `libs/` deps in
# `apps/moltnet-cli/go.mod`.
#
# The recipe:
# 1. Resolve merge commit + parent from the PR number
# 2. Diff .release-please-manifest.json between parent and merge commit
# 3. Extract previous + stuck cli versions; collect bumped libs that the
# cli depends on (auto-discovered from apps/moltnet-cli/go.mod)
# 4. Revert cli version in the manifest to the previous released version
# for every member of the linked cli group (apps/moltnet-cli,
# packages/cli, and the 6 packages/cli/npm/* platform packages) so the
# manifest stays consistent with the linked-versions invariant
# 5. Drop the premature `## [x.y.z]` section from cli CHANGELOG
# 6. `GOWORK=off go get <lib>@<version>` for each bumped lib + go mod tidy
# 7. Commit on a branch, open PR (squash-merge it manually)
# 8. Delete the stuck draft `cli-vX.Y.Z` GitHub release
#
# After merging the PR, release-please will re-propose the cli bump with a
# synced go.mod on main, and the next release-cli run will succeed.
on:
workflow_dispatch:
inputs:
release-pr:
description: 'Number of the merged release-please PR that caused the failure (e.g. 766)'
required: true
type: string
delete-draft-release:
description: 'Delete the stuck draft GitHub release cli-v<stuck>'
required: false
type: boolean
default: true
permissions:
contents: write
pull-requests: write
jobs:
recover:
name: Recover cli release cycle
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
with:
fetch-depth: 0
token: ${{ secrets.GITHUB_TOKEN }}
- uses: actions/setup-go@v6
with:
go-version-file: apps/moltnet-cli/go.mod
cache: false
- name: Configure git
run: |
git config user.name 'github-actions[bot]'
git config user.email '41898282+github-actions[bot]@users.noreply.github.com'
- name: Infer recovery parameters from release PR
id: infer
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
PR_NUMBER: ${{ inputs.release-pr }}
run: |
set -euo pipefail
# 1. Fetch PR metadata. Must be merged.
pr_json=$(gh pr view "$PR_NUMBER" --json state,mergeCommit,title,url)
state=$(echo "$pr_json" | jq -r '.state')
merge_sha=$(echo "$pr_json" | jq -r '.mergeCommit.oid // empty')
pr_title=$(echo "$pr_json" | jq -r '.title')
pr_url=$(echo "$pr_json" | jq -r '.url')
if [ "$state" != 'MERGED' ]; then
echo "ERROR: PR #$PR_NUMBER is $state, expected MERGED" >&2
exit 1
fi
if [ -z "$merge_sha" ]; then
echo "ERROR: PR #$PR_NUMBER has no merge commit" >&2
exit 1
fi
echo "PR: #$PR_NUMBER ($pr_url)"
echo "Title: $pr_title"
echo "Merge SHA: $merge_sha"
# 2. Parent commit = manifest state before the release PR merged.
# We use ^1 (first parent) rather than baseRefOid because the
# base branch may have advanced since the merge.
parent_sha=$(git rev-parse "${merge_sha}^1")
echo "Parent SHA: $parent_sha"
# 3. Read manifest at both commits.
manifest_path='.release-please-manifest.json'
git show "${parent_sha}:${manifest_path}" > /tmp/manifest.before.json
git show "${merge_sha}:${manifest_path}" > /tmp/manifest.after.json
previous_cli=$(jq -r '."apps/moltnet-cli"' /tmp/manifest.before.json)
stuck_cli=$(jq -r '."apps/moltnet-cli"' /tmp/manifest.after.json)
if [ "$previous_cli" = 'null' ] || [ "$stuck_cli" = 'null' ]; then
echo "ERROR: apps/moltnet-cli missing from manifest at one of the commits" >&2
exit 1
fi
if [ "$previous_cli" = "$stuck_cli" ]; then
echo "ERROR: apps/moltnet-cli version unchanged in PR #$PR_NUMBER ($previous_cli)" >&2
echo " This PR did not bump the cli — nothing to recover." >&2
exit 1
fi
echo "Previous cli: $previous_cli"
echo "Stuck cli: $stuck_cli"
# 4. Auto-discover cli's direct libs/ dependencies from go.mod.
# We only look at the first `require (...)` block (direct deps);
# indirect deps live in a second block marked `// indirect`.
cli_libs=$(awk '
/^require \(/ { in_block=1; next }
in_block && /^\)/ { in_block=0; next }
in_block && /github\.com\/getlarge\/themoltnet\/libs\// && !/\/\/ indirect/ {
# Line format: "\tgithub.com/getlarge/themoltnet/libs/<name> v<ver>"
sub(/^[[:space:]]+/, "", $0)
split($0, parts, " ")
sub(/^github\.com\/getlarge\/themoltnet\/libs\//, "", parts[1])
print parts[1]
}
' apps/moltnet-cli/go.mod)
# It is legitimate for cli_libs to be empty (cli has no direct
# libs/* deps) or for none of them to be bumped in this PR. That
# corresponds to the "downstream-publish-failure" recovery path:
# goreleaser already succeeded but some npm publish job failed,
# so there's no go.mod to sync — we only need to revert the
# manifest + CHANGELOG + drop draft/tags so release-please can
# re-propose the same cli bump.
pairs=()
if [ -n "$cli_libs" ]; then
echo 'Cli libs/* deps:'
echo "$cli_libs" | sed 's/^/ - /'
# 5. For each cli lib dep, compare before/after manifest versions.
while IFS= read -r lib; do
[ -n "$lib" ] || continue
before=$(jq -r --arg k "libs/${lib}" '.[$k] // empty' /tmp/manifest.before.json)
after=$(jq -r --arg k "libs/${lib}" '.[$k] // empty' /tmp/manifest.after.json)
if [ -z "$after" ]; then
echo " libs/${lib}: not tracked in manifest (skipping)"
continue
fi
if [ "$before" = "$after" ]; then
echo " libs/${lib}: unchanged (${after})"
continue
fi
echo " libs/${lib}: ${before:-<none>} -> ${after}"
pairs+=("${lib}=${after}")
done <<< "$cli_libs"
else
echo 'No direct libs/* dependencies in apps/moltnet-cli/go.mod'
fi
if [ ${#pairs[@]} -eq 0 ]; then
echo 'No cli lib dependencies were bumped in this PR.'
echo 'Recovery mode: downstream-publish-failure (manifest + changelog only, no go.mod sync).'
bumped_libs=''
else
bumped_libs=$(IFS=,; echo "${pairs[*]}")
echo "Recovery mode: goreleaser-failure (go.mod sync required)."
echo "Bumped libs: $bumped_libs"
fi
# Export outputs for downstream steps.
{
echo "previous-cli-version=${previous_cli}"
echo "stuck-cli-version=${stuck_cli}"
echo "bumped-libs=${bumped_libs}"
echo "pr-url=${pr_url}"
} >> "$GITHUB_OUTPUT"
# Safety guard: confirm the cli-v<stuck> release is actually stuck
# before we revert anything. Previously this was covered implicitly
# by the "at least one lib must be bumped" check, but that check
# missed the downstream-publish-failure path. Now we check the
# release/tag/npm state directly.
#
# A release is stuck iff ANY of:
# a) GitHub release cli-v<stuck> is a draft (goreleaser didn't
# finalize it, or the workflow died before the release job
# flipped it).
# b) GitHub release is published but one or more of the 7
# linked-versions sibling tags (cli-wrapper-v<x>,
# cli-<platform>-v<x>) is missing.
# c) GitHub release is published but one or more of the 7
# @themoltnet/cli* npm versions is missing on the registry.
#
# If none hold, recovery would destroy a healthy release — refuse.
- name: Verify cli release is stuck
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
STUCK_VERSION: ${{ steps.infer.outputs.stuck-cli-version }}
run: |
set -euo pipefail
TAG="cli-v${STUCK_VERSION}"
reasons=()
# a) Draft release check.
release_state='missing'
if release_json=$(gh release view "$TAG" --json isDraft,isPrerelease 2>/dev/null); then
if [ "$(echo "$release_json" | jq -r '.isDraft')" = 'true' ]; then
release_state='draft'
reasons+=("GitHub release $TAG is a draft (goreleaser did not finalize)")
else
release_state='published'
fi
else
reasons+=("GitHub release $TAG does not exist (goreleaser never ran or failed early)")
fi
echo "Release state: $release_state"
# b) Sibling tag completeness check (only meaningful if release exists).
missing_tags=()
for COMPONENT in cli-wrapper cli-darwin-arm64 cli-darwin-x64 cli-linux-arm64 cli-linux-x64 cli-win32-arm64 cli-win32-x64; do
SIB_TAG="${COMPONENT}-v${STUCK_VERSION}"
if ! gh api "repos/${GITHUB_REPOSITORY}/git/refs/tags/${SIB_TAG}" >/dev/null 2>&1; then
missing_tags+=("$SIB_TAG")
fi
done
if [ ${#missing_tags[@]} -gt 0 ]; then
reasons+=("Missing ${#missing_tags[@]} sibling tag(s): ${missing_tags[*]}")
fi
# c) npm publish completeness check. Registry lookup is public
# and read-only, so no auth is needed. `npm view <pkg>@<ver>`
# exits 0 if the version exists and prints E404 to stderr
# otherwise.
missing_npm=()
for PKG in \
'@themoltnet/cli' \
'@themoltnet/cli-darwin-arm64' \
'@themoltnet/cli-darwin-x64' \
'@themoltnet/cli-linux-arm64' \
'@themoltnet/cli-linux-x64' \
'@themoltnet/cli-win32-arm64' \
'@themoltnet/cli-win32-x64'; do
if ! npm view "${PKG}@${STUCK_VERSION}" version >/dev/null 2>&1; then
missing_npm+=("${PKG}@${STUCK_VERSION}")
fi
done
if [ ${#missing_npm[@]} -gt 0 ]; then
reasons+=("Missing ${#missing_npm[@]} npm publish(es): ${missing_npm[*]}")
fi
if [ ${#reasons[@]} -eq 0 ]; then
echo "ERROR: cli-v${STUCK_VERSION} looks healthy — refusing to recover." >&2
echo " Release is published, all 7 sibling tags exist, and all 7" >&2
echo " npm packages are on the registry. If you believe this PR is" >&2
echo " genuinely stuck, investigate manually before re-running." >&2
exit 1
fi
echo 'Stuckness evidence:'
for r in "${reasons[@]}"; do
echo " - $r"
done
- name: Create recovery branch
id: branch
env:
STUCK_VERSION: ${{ steps.infer.outputs.stuck-cli-version }}
run: |
BRANCH="chore/recover-cli-v${STUCK_VERSION}"
git checkout -b "$BRANCH"
echo "name=$BRANCH" >> "$GITHUB_OUTPUT"
- name: Revert manifest cli version
env:
PREVIOUS_VERSION: ${{ steps.infer.outputs.previous-cli-version }}
run: |
# The cli is part of an 8-component linked-versions group
# (apps/moltnet-cli + packages/cli + 6 platform packages). All
# members must move in lockstep, so the revert has to touch every
# path — otherwise the manifest contradicts the linked-versions
# invariant and release-please gets confused on the next run.
jq --arg v "$PREVIOUS_VERSION" '
."apps/moltnet-cli" = $v
| ."packages/cli" = $v
| ."packages/cli/npm/darwin-arm64" = $v
| ."packages/cli/npm/darwin-x64" = $v
| ."packages/cli/npm/linux-arm64" = $v
| ."packages/cli/npm/linux-x64" = $v
| ."packages/cli/npm/win32-arm64" = $v
| ."packages/cli/npm/win32-x64" = $v
' .release-please-manifest.json \
> .release-please-manifest.json.tmp
mv .release-please-manifest.json.tmp .release-please-manifest.json
git diff --stat .release-please-manifest.json
- name: Drop premature CHANGELOG section
env:
STUCK_VERSION: ${{ steps.infer.outputs.stuck-cli-version }}
run: |
python3 - <<'PY'
import os, re, sys, pathlib
p = pathlib.Path('apps/moltnet-cli/CHANGELOG.md')
text = p.read_text()
stuck = os.environ['STUCK_VERSION']
pattern = re.compile(
r'^## \[?' + re.escape(stuck) + r'\]?.*?(?=^## \[)',
re.MULTILINE | re.DOTALL,
)
new = pattern.sub('', text, count=1)
if new == text:
print(f'ERROR: no ## [{stuck}] section found in CHANGELOG', file=sys.stderr)
sys.exit(1)
p.write_text(new)
PY
git diff --stat apps/moltnet-cli/CHANGELOG.md
- name: Sync apps/moltnet-cli/go.mod to bumped libs
if: ${{ steps.infer.outputs.bumped-libs != '' }}
working-directory: apps/moltnet-cli
env:
GOWORK: off
BUMPED_LIBS: ${{ steps.infer.outputs.bumped-libs }}
run: |
IFS=',' read -ra PAIRS <<< "$BUMPED_LIBS"
for raw in "${PAIRS[@]}"; do
# Trim whitespace
pair="$(echo "$raw" | xargs)"
if [[ "$pair" != *=* ]]; then
echo "ERROR: invalid bumped-libs entry '$raw' (expected name=version)" >&2
exit 1
fi
name="${pair%%=*}"
version="${pair##*=}"
if [ -z "$name" ] || [ -z "$version" ]; then
echo "ERROR: empty name or version in '$raw'" >&2
exit 1
fi
module="github.com/getlarge/themoltnet/libs/${name}"
echo "::group::go get ${module}@v${version}"
go get "${module}@v${version}"
resolved=$(go list -m "$module" | awk '{print $2}')
if [ "$resolved" != "v${version}" ]; then
echo "ERROR: requested v${version} but MVS resolved ${resolved}" >&2
exit 1
fi
echo "::endgroup::"
done
go mod tidy
go build ./...
- name: Commit and push
env:
STUCK_VERSION: ${{ steps.infer.outputs.stuck-cli-version }}
BRANCH_NAME: ${{ steps.branch.outputs.name }}
run: |
git add \
.release-please-manifest.json \
apps/moltnet-cli/CHANGELOG.md \
apps/moltnet-cli/go.mod \
apps/moltnet-cli/go.sum
if git diff --cached --quiet; then
echo 'No changes to commit — nothing to recover.'
exit 1
fi
git commit -m "fix(release): recover cli v${STUCK_VERSION} release cycle"
git push -u origin "$BRANCH_NAME"
- name: Open recovery PR
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
STUCK_VERSION: ${{ steps.infer.outputs.stuck-cli-version }}
PREVIOUS_VERSION: ${{ steps.infer.outputs.previous-cli-version }}
BUMPED_LIBS: ${{ steps.infer.outputs.bumped-libs }}
SOURCE_PR_URL: ${{ steps.infer.outputs.pr-url }}
BRANCH_NAME: ${{ steps.branch.outputs.name }}
run: |
if [ -n "$BUMPED_LIBS" ]; then
GOMOD_LINE="- Syncs \`apps/moltnet-cli/go.mod\` to: \`${BUMPED_LIBS}\`"
else
GOMOD_LINE="- No go.mod sync required (no libs bumped in source PR — downstream-publish-failure recovery)"
fi
BODY=$(cat <<EOF
Automated recovery for a stuck \`apps/moltnet-cli\` release cycle.
**Triggered by:** ${SOURCE_PR_URL}
**What this PR does:**
- Reverts all 8 linked cli group entries in \`.release-please-manifest.json\` from \`${STUCK_VERSION}\` back to \`${PREVIOUS_VERSION}\` (\`apps/moltnet-cli\`, \`packages/cli\`, and the 6 platform packages — they must move in lockstep under \`linked-versions\`)
- Drops the premature \`## [${STUCK_VERSION}]\` section from \`apps/moltnet-cli/CHANGELOG.md\`
${GOMOD_LINE}
**Next steps:**
1. Squash-merge this PR
2. release-please will re-propose the cli bump with a synced go.mod
3. Merging that PR triggers a clean release-cli run
The recovery recipe is documented in the workflow header at \`.github/workflows/release-recover-cli.yml\`.
EOF
)
gh pr create \
--base main \
--head "$BRANCH_NAME" \
--title "fix(release): recover cli v${STUCK_VERSION} release cycle" \
--body "$BODY"
- name: Delete stuck draft release
if: ${{ inputs.delete-draft-release }}
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
STUCK_VERSION: ${{ steps.infer.outputs.stuck-cli-version }}
run: |
TAG="cli-v${STUCK_VERSION}"
# Query isDraft so we never delete a published release.
# Don't suppress stderr — surface 403/429/network errors.
if ! IS_DRAFT="$(gh release view "$TAG" --json isDraft --jq '.isDraft' 2>/tmp/gh-err)"; then
if grep -qi 'release not found' /tmp/gh-err; then
echo "No release $TAG to delete"
exit 0
fi
echo "ERROR: gh release view failed:" >&2
cat /tmp/gh-err >&2
exit 1
fi
if [ "$IS_DRAFT" = 'true' ]; then
# Delete the draft release WITHOUT --cleanup-tag. Draft releases
# don't have a real git tag ref backing them, so `gh release
# delete --cleanup-tag` tries to DELETE /git/refs/tags/<tag>,
# gets HTTP 422 "Reference does not exist", and exits non-zero
# even though the release itself was deleted cleanly.
gh release delete "$TAG" --yes
echo "Deleted draft release $TAG"
# Best-effort tag cleanup: only delete the ref if it actually
# exists. A stray published tag with the same name would be a
# separate bug, so we warn but do not hard-fail on delete errors.
if gh api "repos/${GITHUB_REPOSITORY}/git/refs/tags/${TAG}" >/dev/null 2>&1; then
if gh api -X DELETE "repos/${GITHUB_REPOSITORY}/git/refs/tags/${TAG}" >/dev/null 2>&1; then
echo "Deleted leftover tag ref $TAG"
else
echo "WARN: failed to delete leftover tag ref $TAG (continuing)" >&2
fi
else
echo "No leftover tag ref $TAG to clean up"
fi
else
echo "Release $TAG exists but is not a draft; skipping deletion"
fi
# The linked-versions cli group has 7 sibling tags
# (cli-wrapper-v<x>, cli-<platform>-v<x>) created by the
# `tag-cli-siblings` job in release.yml once `release-cli` succeeds.
# They exist independently of the cli-v<x> GitHub release state, so
# clean them up with the same toggle as the draft release — if
# release-cli never succeeded, they won't exist and delete is a no-op.
- name: Delete stuck sibling tags
if: ${{ inputs.delete-draft-release }}
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
STUCK_VERSION: ${{ steps.infer.outputs.stuck-cli-version }}
run: |
for COMPONENT in cli-wrapper cli-darwin-arm64 cli-darwin-x64 cli-linux-arm64 cli-linux-x64 cli-win32-arm64 cli-win32-x64; do
SIB_TAG="${COMPONENT}-v${STUCK_VERSION}"
if gh api "repos/${GITHUB_REPOSITORY}/git/refs/tags/${SIB_TAG}" >/dev/null 2>&1; then
if gh api -X DELETE "repos/${GITHUB_REPOSITORY}/git/refs/tags/${SIB_TAG}" >/dev/null 2>&1; then
echo " - deleted ${SIB_TAG}"
else
echo " ! WARN: failed to delete ${SIB_TAG} (continuing)" >&2
fi
else
echo " = ${SIB_TAG} does not exist"
fi
done