Skip to content

Commit 58e9ea4

Browse files
christian-byrneclaudeConnor ByrneDrJKLampagent
authored
ci(release): harden core-release workflow (fixes from 1.47.10) (#14039)
Hardens the core-release automation against concrete failures hit shipping core **1.47.9 / 1.47.10 / 1.47.11**. All changes are additive — they change no existing job's happy path. Implements the SAFE, high-value items from #14033. The invasive redesigns in that issue are intentionally left for owner input (see *Left for owners* below). > Rebased onto `main`. The workflow this PR hardens was renamed `release-biweekly-comfyui.yaml` → `release-weekly-comfyui.yaml` while this sat open; the changes moved with it. ## Changes ### 1. `release-draft-create.yaml` — a cosmetic comment can no longer fail a good release Added `continue-on-error: true` to the *"Post release summary comment"* step. **Evidence:** across six release runs (1.47.8/9/10, 1.48.3/4/5) `build`, `draft_release` and `publish_types` all succeeded — tag cut, GH release created, npm types published — but the comment step returned `403 Unable to create comment because issue is locked` (the org-wide lock) and marked the **whole run `failure`**. A false "release failed" signal on six shipped releases. A comment is a nice-to-have; it must never gate release success. The step still surfaces any crash in its own logs. ### 2. `release-draft-create.yaml` — Latest is reconciled in the same run `make_latest: false` only declines to *claim* Latest; GitHub still surfaces the newest published release, so a patch on an older line takes it anyway. v1.47.11 displaced v1.49.1 this way and had to be restored by hand — three times in one release session. #14618 landed `release-enforce-latest.yaml`, which owns the greatest-semver-wins policy. Its own `release` trigger **cannot** cover the automated path: the release is created with the repository's `GITHUB_TOKEN`, and `GITHUB_TOKEN`-driven actions never emit events that start a new workflow run — so on this path it would only self-heal at the next daily cron, leaving `--front-end-version latest` pointing at an older release for up to a day. Rather than ship a second implementation of the same policy, this PR exposes that workflow via `workflow_call`, declares only the required `PR_GH_TOKEN`, and invokes it as a new `enforce_latest` job after `draft_release`. The job recomputes the true-latest release from scratch and has no dependency on `github.event.release.*` context. One implementation, corrected immediately, in the same run — zero delay instead of up to 24h. ### 3. `release-weekly-comfyui.yaml` — tag-wait timeout no longer silently drops the pin PR The `publish-pypi` *"Wait for release PR to be created and merged"* step polled for the tag for **30 min** then hard-failed. Because `create-comfyui-pr` has `needs: publish-pypi` + `if: … publish-pypi.result == 'success'`, that timeout also **skipped `create-comfyui-pr`**, silently losing the ComfyUI pin PR. **Evidence:** run [`29979988134`](https://github.com/Comfy-Org/ComfyUI_frontend/actions/runs/29979988134) — the bump PR wasn't merged within 30 min; recovery was a manual `gh run rerun 29979988134 --failed` after the tag existed. - **Raise the poll window to 4h** — a human merging a `Release`-labeled bump PR realistically isn't a 30-min operation. - **Make the timeout self-documenting** — on timeout the step emits a `::error::` annotation *and* a job-summary block naming both possible causes in order, plus the exact recovery command. ### 4. `release-weekly-comfyui.yaml` — wait for PyPI before opening the pin PR PyPI's index lags the upload by a minute or two, so a pin PR opened immediately fails its own CI on `No matching distribution found` and reads as a bad bump. Adds a wall-clock-bounded (15m) probe; on timeout the pin PR still opens, with the body flagging the availability as unconfirmed. ### 5. `release-weekly-comfyui.yaml` — new `release-done` assertion job Fails loudly (annotations + job summary) if a just-published release didn't actually reach users: - **Stranded commits** (hard fail): `git rev-list vTAG.. --count != 0` — the **1.47.9** case, where `v1.47.9` published while 19 QA-fix commits sat unreleased past the tag and only a manual `git rev-list` caught it. - **PyPI** (hard fail): the target version must be present on PyPI. For `main`-line releases it must also be PyPI's `latest`; `core/*` patches are intentionally non-`latest`, so that sub-check is informational there. ## Left for owners (issue discussion, not this PR) - **Full decoupling** of the PyPI publish into a `release:`-triggered workflow. Bigger design change; would eliminate the poll entirely. Note the `GITHUB_TOKEN` constraint above — a `release:` trigger needs a PAT/App token to fire at all. - **Getting real CI onto the release branches** — policy/infra decision. Addresses the safe parts of #14033. --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Co-authored-by: Connor Byrne <c.byrne@comfy.org> Co-authored-by: Alexander Brown <drjkl@comfy.org> Co-authored-by: DrJKL <DrJKL0424@gmail.com> Co-authored-by: Amp <amp@ampcode.com> Co-authored-by: GitHub Action <action@github.com>
1 parent 222ccc9 commit 58e9ea4

8 files changed

Lines changed: 453 additions & 133 deletions

.github/workflows/release-draft-create.yaml

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -106,6 +106,16 @@ jobs:
106106
ref: ${{ github.event.pull_request.merge_commit_sha }}
107107
secrets: inherit
108108

109+
# GITHUB_TOKEN releases emit no events, so the reconciler's own trigger never fires.
110+
enforce_latest:
111+
name: Enforce Latest Release
112+
needs: draft_release
113+
permissions:
114+
contents: write
115+
uses: ./.github/workflows/release-enforce-latest.yaml
116+
secrets:
117+
PR_GH_TOKEN: ${{ secrets.PR_GH_TOKEN }}
118+
109119
comment_release_summary:
110120
name: Comment Release Summary
111121
needs:
@@ -125,6 +135,7 @@ jobs:
125135
fetch-depth: 2
126136

127137
- name: Post release summary comment
138+
continue-on-error: true
128139
uses: ./.github/actions/comment-release-links
129140
with:
130141
issue-number: ${{ github.event.pull_request.number }}
Lines changed: 19 additions & 89 deletions
Original file line numberDiff line numberDiff line change
@@ -1,108 +1,38 @@
11
name: Enforce Greatest-Semver-Wins Latest Release
22

3-
# Keeps GitHub's "latest release" flag pinned to the highest stable semver
4-
# tag, so `--front-end-version latest` can never resolve to an older release
5-
# than what's already shipped.
6-
#
7-
# Un-publishing the current latest release is treated as an implicit
8-
# rollback: latest reassigns to the next-highest published stable release.
9-
# This is intentional.
3+
# Un-publishing the latest release rolls Latest back on purpose.
104

115
on:
126
release:
137
types: [published, edited, unpublished, deleted]
148
workflow_dispatch: {}
9+
workflow_call:
10+
secrets:
11+
PR_GH_TOKEN:
12+
required: true
1513
schedule:
16-
- cron: '0 4 * * *' # daily backstop in case a release webhook is dropped
14+
- cron: '0 4 * * *'
1715

18-
# Serialize runs so two near-simultaneous release edits can't race each
19-
# other's `gh release edit --latest` calls. Do NOT cancel-in-progress: a
20-
# queued run still needs to re-check state after the run ahead of it finishes.
2116
concurrency:
2217
group: enforce-latest-release
23-
cancel-in-progress: false
2418

2519
jobs:
2620
enforce-latest:
2721
runs-on: ubuntu-latest
28-
# Defense in depth: GH_TOKEN edits don't retrigger `release` events, but
29-
# this guards against a future PAT/App-token swap that would.
30-
if: github.triggering_actor != 'github-actions[bot]'
22+
# On workflow_call the actor is whoever merged the release PR, often a bot.
23+
if: github.event_name != 'release' || github.triggering_actor != 'github-actions[bot]'
3124
permissions:
32-
contents: write # required: gh release edit / releases API write access
25+
contents: write
3326
steps:
34-
- name: Reconcile "latest" flag to the highest stable semver release
27+
# workflow_call inherits the caller's refs/pull/N/merge, gone once it closes.
28+
- uses: actions/checkout@v7
29+
with:
30+
ref: main
31+
sparse-checkout: scripts/cicd/reconcile-latest-release.sh
32+
sparse-checkout-cone-mode: false
33+
34+
- name: Reconcile Latest to the highest stable semver release
3535
env:
36-
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
36+
GH_TOKEN: ${{ secrets.PR_GH_TOKEN }}
3737
REPO: ${{ github.repository }}
38-
run: |
39-
set -euo pipefail
40-
41-
echo "Fetching all releases for $REPO..."
42-
RELEASES_JSON=$(gh release list --repo "$REPO" --limit 1000 \
43-
--json tagName,isDraft,isPrerelease,isLatest)
44-
45-
COUNT=$(echo "$RELEASES_JSON" | jq 'length')
46-
if [ "$COUNT" -ge 1000 ]; then
47-
echo "::warning::Release count hit --limit 1000 cap. Results may be truncated."
48-
fi
49-
50-
STABLE_TAGS=$(echo "$RELEASES_JSON" | jq -r \
51-
'.[] | select(.isDraft == false and .isPrerelease == false) | .tagName')
52-
53-
if [ -z "$STABLE_TAGS" ]; then
54-
echo "::warning::No stable (non-draft, non-prerelease) releases found. '--front-end-version latest' will 404 until one exists."
55-
exit 0
56-
fi
57-
58-
# Defensive filter: only strict [v]X.Y.Z tags -- skips "-rc"/"-beta"
59-
# tags mis-flagged as stable, and other monorepo tags (design-system,
60-
# desktop-ui, npm-types, etc.) that aren't ours.
61-
CANDIDATES=()
62-
while IFS= read -r tag; do
63-
if [[ "$tag" =~ ^v?[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
64-
CANDIDATES+=("$tag")
65-
fi
66-
done <<< "$STABLE_TAGS"
67-
68-
if [ "${#CANDIDATES[@]}" -eq 0 ]; then
69-
echo "::warning::No semver-shaped stable tags found among releases. '--front-end-version latest' will 404 until one exists."
70-
exit 0
71-
fi
72-
73-
# greatest-semver-wins: sort numerically (v-prefix stripped), not by
74-
# tag name or publish date.
75-
TRUE_LATEST_VER=$(printf '%s\n' "${CANDIDATES[@]}" | sed 's/^v//' | sort -V | tail -1)
76-
77-
TRUE_LATEST_TAG=""
78-
for tag in "${CANDIDATES[@]}"; do
79-
if [[ "${tag#v}" == "$TRUE_LATEST_VER" ]]; then
80-
TRUE_LATEST_TAG="$tag"
81-
break
82-
fi
83-
done
84-
85-
[ -n "$TRUE_LATEST_TAG" ] || { echo "::error::BUG: could not resolve TRUE_LATEST_TAG"; exit 1; }
86-
87-
echo "Highest stable semver release: $TRUE_LATEST_TAG"
88-
89-
CURRENT_LATEST_TAG=$(echo "$RELEASES_JSON" | jq -r \
90-
'[.[] | select(.isLatest == true)][0].tagName // empty')
91-
echo "GitHub-flagged 'latest' release: ${CURRENT_LATEST_TAG:-<none>}"
92-
93-
if [ "$CURRENT_LATEST_TAG" == "$TRUE_LATEST_TAG" ]; then
94-
echo "OK: 'latest' already matches the highest stable semver release. No action needed."
95-
exit 0
96-
fi
97-
98-
echo "::warning::'latest' is currently '${CURRENT_LATEST_TAG:-<none>}' but the highest stable semver release is '$TRUE_LATEST_TAG'. Reassigning 'latest' to '$TRUE_LATEST_TAG'."
99-
100-
gh release edit "$TRUE_LATEST_TAG" --repo "$REPO" --latest
101-
102-
{
103-
echo "## Latest-release auto-correction"
104-
echo ""
105-
echo "- Previously flagged as \`latest\`: \`${CURRENT_LATEST_TAG:-<none>}\`"
106-
echo "- Highest stable semver release: \`$TRUE_LATEST_TAG\`"
107-
echo "- Action taken: re-assigned \`latest\` to \`$TRUE_LATEST_TAG\` via \`gh release edit --latest\`"
108-
} >> "$GITHUB_STEP_SUMMARY"
38+
run: ./scripts/cicd/reconcile-latest-release.sh

.github/workflows/release-weekly-comfyui.yaml

Lines changed: 48 additions & 44 deletions
Original file line numberDiff line numberDiff line change
@@ -179,37 +179,34 @@ jobs:
179179
180180
publish-pypi:
181181
needs: [resolve-version, trigger-release-if-needed]
182+
outputs:
183+
pypi_confirmed: ${{ steps.pypi-wait.outputs.confirmed }}
182184
if: >
183185
always() &&
184186
needs.resolve-version.result == 'success' &&
185187
(needs.trigger-release-if-needed.result == 'success' ||
186188
needs.trigger-release-if-needed.result == 'skipped')
187189
runs-on: ubuntu-latest
188190
steps:
189-
- name: Wait for release PR to be created and merged
191+
- name: Check out release scripts
192+
uses: actions/checkout@v7
193+
with:
194+
sparse-checkout: scripts/cicd
195+
sparse-checkout-cone-mode: false
196+
197+
# The tag checkout below replaces the workspace, and the tag predates these.
198+
- name: Stage release scripts
199+
run: cp -r scripts/cicd "$RUNNER_TEMP/cicd"
200+
201+
- name: Wait for the release tag
190202
if: needs.trigger-release-if-needed.result == 'success'
191203
env:
192204
GH_TOKEN: ${{ secrets.PR_GH_TOKEN }}
193-
run: |
194-
set -euo pipefail
195-
196-
TARGET_VERSION="${{ needs.resolve-version.outputs.target_version }}"
197-
TARGET_BRANCH="${{ needs.resolve-version.outputs.target_branch }}"
198-
echo "Waiting for version bump PR for v${TARGET_VERSION} on ${TARGET_BRANCH} to be merged..."
199-
200-
# Poll for up to 30 minutes (a human or automation needs to merge the version bump PR)
201-
for i in $(seq 1 60); do
202-
# Check if the tag exists (release-draft-create creates a tag on merge)
203-
if gh api "repos/Comfy-Org/ComfyUI_frontend/git/ref/tags/v${TARGET_VERSION}" --silent 2>/dev/null; then
204-
echo "✅ Tag v${TARGET_VERSION} found — release PR has been merged"
205-
exit 0
206-
fi
207-
echo "Attempt $i/60: Tag v${TARGET_VERSION} not found yet, waiting 30s..."
208-
sleep 30
209-
done
210-
211-
echo "❌ Timed out waiting for tag v${TARGET_VERSION}"
212-
exit 1
205+
REPO: ${{ github.repository }}
206+
TARGET_VERSION: ${{ needs.resolve-version.outputs.target_version }}
207+
TARGET_BRANCH: ${{ needs.resolve-version.outputs.target_branch }}
208+
RUN_ID: ${{ github.run_id }}
209+
run: '$RUNNER_TEMP/cicd/wait-for-release-tag.sh'
213210

214211
- name: Checkout code at target version
215212
uses: actions/checkout@v7
@@ -262,35 +259,20 @@ jobs:
262259
password: ${{ secrets.PYPI_TOKEN }}
263260
packages-dir: comfyui_frontend_package/dist
264261

265-
- name: Wait for PyPI propagation
266-
run: |
267-
set -euo pipefail
268-
269-
TARGET_VERSION="${{ needs.resolve-version.outputs.target_version }}"
270-
PACKAGE="comfyui-frontend-package"
271-
echo "Waiting for ${PACKAGE}==${TARGET_VERSION} to be available on PyPI..."
272-
273-
# Wait up to 15 minutes (polling every 30 seconds)
274-
for i in $(seq 1 30); do
275-
HTTP_CODE=$(curl -s -o /dev/null -w "%{http_code}" "https://pypi.org/pypi/${PACKAGE}/${TARGET_VERSION}/json")
276-
if [ "$HTTP_CODE" = "200" ]; then
277-
echo "✅ ${PACKAGE}==${TARGET_VERSION} is available on PyPI"
278-
exit 0
279-
fi
280-
echo "Attempt $i/30: PyPI returned HTTP ${HTTP_CODE}, waiting 30s..."
281-
sleep 30
282-
done
283-
284-
echo "❌ Timed out waiting for ${PACKAGE}==${TARGET_VERSION} on PyPI"
285-
exit 1
262+
- name: Wait for the version to be installable
263+
id: pypi-wait
264+
env:
265+
PACKAGE: comfyui-frontend-package
266+
TARGET_VERSION: ${{ needs.resolve-version.outputs.target_version }}
267+
run: '$RUNNER_TEMP/cicd/wait-for-pypi-version.sh'
286268

287269
- name: Summary
288270
run: |
289271
echo "## PyPI Publishing" >> $GITHUB_STEP_SUMMARY
290272
echo "" >> $GITHUB_STEP_SUMMARY
291273
echo "- Package: comfyui-frontend-package" >> $GITHUB_STEP_SUMMARY
292274
echo "- Version: ${{ needs.resolve-version.outputs.target_version }}" >> $GITHUB_STEP_SUMMARY
293-
echo "- Status: ✅ Published and confirmed available" >> $GITHUB_STEP_SUMMARY
275+
echo "- Installable from PyPI: ${{ steps.pypi-wait.outputs.confirmed }}" >> $GITHUB_STEP_SUMMARY
294276
295277
create-comfyui-pr:
296278
needs:
@@ -372,7 +354,11 @@ jobs:
372354
EOF
373355
)
374356
375-
PYPI_NOTE="✅ **PyPI package confirmed available** — \`comfyui-frontend-package==${{ needs.resolve-version.outputs.target_version }}\` has been published and verified."
357+
if [ "${{ needs.publish-pypi.outputs.pypi_confirmed }}" = "true" ]; then
358+
PYPI_NOTE="✅ **PyPI package confirmed available** — \`comfyui-frontend-package==${{ needs.resolve-version.outputs.target_version }}\` has been published and verified."
359+
else
360+
PYPI_NOTE="⚠️ **PyPI availability unconfirmed** — \`comfyui-frontend-package==${{ needs.resolve-version.outputs.target_version }}\` was not installable from PyPI before the wait expired. CI here may fail until the index catches up."
361+
fi
376362
BODY=$''"${PYPI_NOTE}"$'\n\n'"${BODY}"
377363
378364
# Save to file for later use
@@ -459,3 +445,21 @@ jobs:
459445
echo "" >> $GITHUB_STEP_SUMMARY
460446
echo "### PR Body:" >> $GITHUB_STEP_SUMMARY
461447
cat pr-body.txt >> $GITHUB_STEP_SUMMARY
448+
449+
release-done:
450+
name: Verify release reached users
451+
needs: [resolve-version, publish-pypi]
452+
if: always() && needs.publish-pypi.result == 'success'
453+
runs-on: ubuntu-latest
454+
steps:
455+
- name: Checkout ComfyUI_frontend
456+
uses: actions/checkout@v7
457+
with:
458+
fetch-depth: 0
459+
460+
- name: Assert the release reached users
461+
env:
462+
PACKAGE: comfyui-frontend-package
463+
TARGET_VERSION: ${{ needs.resolve-version.outputs.target_version }}
464+
TARGET_BRANCH: ${{ needs.resolve-version.outputs.target_branch }}
465+
run: ./scripts/cicd/assert-release-shipped.sh
Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
#!/usr/bin/env bash
2+
set -euo pipefail
3+
4+
: "${PACKAGE:?PACKAGE is required}"
5+
: "${TARGET_VERSION:?TARGET_VERSION is required}"
6+
: "${TARGET_BRANCH:?TARGET_BRANCH is required}"
7+
8+
readonly TAG="v${TARGET_VERSION}"
9+
failed=0
10+
11+
summary() {
12+
[[ -n "${GITHUB_STEP_SUMMARY:-}" ]] && printf '%s\n' "$@" >>"$GITHUB_STEP_SUMMARY"
13+
return 0
14+
}
15+
16+
fail() {
17+
echo "::error title=$1::$2"
18+
summary "- FAIL: $2"
19+
failed=1
20+
}
21+
22+
assert_nothing_stranded_past_the_tag() {
23+
if ! git fetch --quiet --tags origin "$TARGET_BRANCH"; then
24+
fail "Fetch failed" "Could not fetch ${TARGET_BRANCH} from origin."
25+
return
26+
fi
27+
if ! git rev-parse -q --verify "refs/tags/${TAG}^{commit}" >/dev/null; then
28+
fail "Release tag missing" "Tag ${TAG} not found."
29+
return
30+
fi
31+
32+
local stranded
33+
stranded=$(git rev-list "${TAG}..FETCH_HEAD" --count) || stranded=""
34+
if [[ ! "$stranded" =~ ^[0-9]+$ ]]; then
35+
fail "Stranded count unavailable" "git rev-list failed or returned non-numeric output."
36+
elif ((stranded != 0)); then
37+
fail "Commits stranded past the release tag" \
38+
"${stranded} commit(s) on ${TARGET_BRANCH} are newer than ${TAG}; the published release does not contain them."
39+
else
40+
summary "- OK: no commits stranded past \`${TAG}\`"
41+
fi
42+
}
43+
44+
assert_version_on_pypi() {
45+
local http_code
46+
http_code=$(curl -s --connect-timeout 10 --max-time 30 --retry 3 --retry-all-errors -o /dev/null -w '%{http_code}' \
47+
"https://pypi.org/pypi/${PACKAGE}/${TARGET_VERSION}/json") || http_code="000"
48+
if [[ "$http_code" == "200" ]]; then
49+
summary "- OK: PyPI has \`${PACKAGE}==${TARGET_VERSION}\`"
50+
else
51+
fail "Target version missing on PyPI" "${PACKAGE}==${TARGET_VERSION} returned HTTP ${http_code}."
52+
fi
53+
}
54+
55+
# A core/* patch must not steal PyPI's `latest` from a higher minor.
56+
assert_pypi_latest_only_for_main() {
57+
local pypi_latest
58+
pypi_latest=$(curl -sf --connect-timeout 10 --max-time 30 --retry 3 --retry-all-errors "https://pypi.org/pypi/${PACKAGE}/json" |
59+
jq -r '.info.version // empty') || pypi_latest=""
60+
61+
if [[ -z "$pypi_latest" ]]; then
62+
fail "PyPI latest unavailable" "Failed to query the PyPI info endpoint for ${PACKAGE}."
63+
elif [[ "$TARGET_BRANCH" == "main" && "$pypi_latest" != "$TARGET_VERSION" ]]; then
64+
fail "PyPI latest mismatch" "PyPI latest is ${pypi_latest}, expected ${TARGET_VERSION}."
65+
else
66+
summary "- Info: PyPI \`latest\` = \`${pypi_latest}\` (target branch \`${TARGET_BRANCH}\`)"
67+
fi
68+
}
69+
70+
summary "## Release-done assertion" "" "Target: \`${TAG}\` on \`${TARGET_BRANCH}\`" ""
71+
72+
assert_nothing_stranded_past_the_tag
73+
assert_version_on_pypi
74+
assert_pypi_latest_only_for_main
75+
76+
if ((failed != 0)); then
77+
echo "release-done assertion FAILED — see annotations above."
78+
exit 1
79+
fi
80+
echo "release-done assertion passed."

0 commit comments

Comments
 (0)