Skip to content
Merged
Show file tree
Hide file tree
Changes from 21 commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
82aa0b4
ci(release): harden core-release workflow (fixes from 1.47.10)
christian-byrne Jul 23, 2026
77db22a
ci(release): guard command substitutions in release-done job
christian-byrne Jul 23, 2026
363c424
ci(release): add curl timeouts in release-done job
christian-byrne Jul 23, 2026
8492f85
ci(release): keep Latest correct and stop premature pin PRs
Jul 31, 2026
f17db6c
ci(release): address review on the Latest and PyPI-wait steps
Jul 31, 2026
7388de0
ci(release): bound the PyPI wait by wall clock, not attempt count
Jul 31, 2026
6e3cc75
ci(release): require strict semver before picking the Latest release
Jul 31, 2026
61a356d
ci(release): stop the tag timeout from asserting the bump PR is unmerged
Jul 31, 2026
6431d20
ci(release): record Latest-restoration failures instead of swallowing…
Jul 31, 2026
1ebaa00
ci(release): drop the Latest restoration, superseded on main
Aug 9, 2026
99d650d
Merge branch 'main' into ci__harden-core-release
christian-byrne Aug 9, 2026
996b12e
ci: invoke release-enforce-latest synchronously from release-draft-cr…
claude Aug 9, 2026
e6dc39d
Merge branch 'main' into ci__harden-core-release
christian-byrne Aug 9, 2026
1b11b34
ci: fix action pin version mismatch blocking validate-pins
claude Aug 10, 2026
669ce79
Merge branch 'main' into ci__harden-core-release
christian-byrne Aug 12, 2026
091820b
refactor(release): move release shell out of YAML into scripts/cicd
christian-byrne Aug 8, 2026
b64a65d
ci: pin actions/checkout@v6 in release-weekly-comfyui to a SHA
claude Aug 12, 2026
114f1e5
ci: use the allowlisted checkout v7 instead of a v6 SHA pin
christian-byrne Aug 12, 2026
b7bb296
Merge remote-tracking branch 'origin/main' into ci__harden-core-release
christian-byrne Aug 17, 2026
4ee25a5
test(release): cover reconcile-latest-release semver selection
christian-byrne Aug 17, 2026
0f2021a
Merge branch 'main' into ci__harden-core-release
DrJKL Aug 18, 2026
bc11798
ci: simplify release safeguards
DrJKL Aug 18, 2026
c54d805
fix(release): use PR_GH_TOKEN and harden release scripts per CodeRabb…
claude Aug 18, 2026
816ad9f
fix(release): pass PR_GH_TOKEN explicitly through the enforce_latest …
claude Aug 18, 2026
84ab31e
[automated] Apply ESLint and Oxfmt fixes
actions-user Aug 18, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions .github/workflows/release-draft-create.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,16 @@ jobs:
ref: ${{ github.event.pull_request.merge_commit_sha }}
secrets: inherit

# GITHUB_TOKEN releases emit no events, so the reconciler's own trigger never fires.
enforce_latest:
name: Enforce Latest Release
needs: draft_release
if: success()
permissions:
contents: write
uses: ./.github/workflows/release-enforce-latest.yaml
secrets: inherit

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

delete: if: success() is already the job default, and reusable workflows receive the special GITHUB_TOKEN automatically, so secrets: inherit is unnecessary here.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Kept it removed as a blanket secrets: inherit. Had to add back a scoped explicit pass-through for PR_GH_TOKEN specifically though (declared required on release-enforce-latest.yaml's workflow_call, passed explicitly here) — gh release edit --latest gets a 403 "Resource not accessible by integration" from the default GITHUB_TOKEN on core//cloud/ release branches, so the reconcile step needs PR_GH_TOKEN specifically, and without any pass-through that secret resolves to empty in the reusable workflow.

Created by Claude Code

@DrJKL DrJKL Aug 18, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Addressed in bc117987e4 and 816ad9f7ee: removed default if: success() and blanket inheritance, then passed only PR_GH_TOKEN, which the release edit requires.


comment_release_summary:
name: Comment Release Summary
needs:
Expand All @@ -125,6 +135,7 @@ jobs:
fetch-depth: 2

- name: Post release summary comment
continue-on-error: true
uses: ./.github/actions/comment-release-links
with:
issue-number: ${{ github.event.pull_request.number }}
Expand Down
102 changes: 15 additions & 87 deletions .github/workflows/release-enforce-latest.yaml
Original file line number Diff line number Diff line change
@@ -1,108 +1,36 @@
name: Enforce Greatest-Semver-Wins Latest Release

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

on:
release:
types: [published, edited, unpublished, deleted]
workflow_dispatch: {}
workflow_call: {}
schedule:
- cron: '0 4 * * *' # daily backstop in case a release webhook is dropped
- cron: '0 4 * * *'

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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

delete: cancel-in-progress: false only restates the GitHub Actions default.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in bc117987e4: removed the default cancel-in-progress: false while retaining the concurrency group.


jobs:
enforce-latest:
runs-on: ubuntu-latest
# Defense in depth: GH_TOKEN edits don't retrigger `release` events, but
# this guards against a future PAT/App-token swap that would.
if: github.triggering_actor != 'github-actions[bot]'
# On workflow_call the actor is whoever merged the release PR, often a bot.
if: github.event_name != 'release' || github.triggering_actor != 'github-actions[bot]'
permissions:
contents: write # required: gh release edit / releases API write access
contents: write
steps:
- name: Reconcile "latest" flag to the highest stable semver release
# workflow_call inherits the caller's refs/pull/N/merge, gone once it closes.
- uses: actions/checkout@v7
with:
ref: main
sparse-checkout: scripts/cicd/reconcile-latest-release.sh
sparse-checkout-cone-mode: false

- name: Reconcile Latest to the highest stable semver release
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
REPO: ${{ github.repository }}
run: |
set -euo pipefail

echo "Fetching all releases for $REPO..."
RELEASES_JSON=$(gh release list --repo "$REPO" --limit 1000 \
--json tagName,isDraft,isPrerelease,isLatest)

COUNT=$(echo "$RELEASES_JSON" | jq 'length')
if [ "$COUNT" -ge 1000 ]; then
echo "::warning::Release count hit --limit 1000 cap. Results may be truncated."
fi

STABLE_TAGS=$(echo "$RELEASES_JSON" | jq -r \
'.[] | select(.isDraft == false and .isPrerelease == false) | .tagName')

if [ -z "$STABLE_TAGS" ]; then
echo "::warning::No stable (non-draft, non-prerelease) releases found. '--front-end-version latest' will 404 until one exists."
exit 0
fi

# Defensive filter: only strict [v]X.Y.Z tags -- skips "-rc"/"-beta"
# tags mis-flagged as stable, and other monorepo tags (design-system,
# desktop-ui, npm-types, etc.) that aren't ours.
CANDIDATES=()
while IFS= read -r tag; do
if [[ "$tag" =~ ^v?[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
CANDIDATES+=("$tag")
fi
done <<< "$STABLE_TAGS"

if [ "${#CANDIDATES[@]}" -eq 0 ]; then
echo "::warning::No semver-shaped stable tags found among releases. '--front-end-version latest' will 404 until one exists."
exit 0
fi

# greatest-semver-wins: sort numerically (v-prefix stripped), not by
# tag name or publish date.
TRUE_LATEST_VER=$(printf '%s\n' "${CANDIDATES[@]}" | sed 's/^v//' | sort -V | tail -1)

TRUE_LATEST_TAG=""
for tag in "${CANDIDATES[@]}"; do
if [[ "${tag#v}" == "$TRUE_LATEST_VER" ]]; then
TRUE_LATEST_TAG="$tag"
break
fi
done

[ -n "$TRUE_LATEST_TAG" ] || { echo "::error::BUG: could not resolve TRUE_LATEST_TAG"; exit 1; }

echo "Highest stable semver release: $TRUE_LATEST_TAG"

CURRENT_LATEST_TAG=$(echo "$RELEASES_JSON" | jq -r \
'[.[] | select(.isLatest == true)][0].tagName // empty')
echo "GitHub-flagged 'latest' release: ${CURRENT_LATEST_TAG:-<none>}"

if [ "$CURRENT_LATEST_TAG" == "$TRUE_LATEST_TAG" ]; then
echo "OK: 'latest' already matches the highest stable semver release. No action needed."
exit 0
fi

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'."

gh release edit "$TRUE_LATEST_TAG" --repo "$REPO" --latest

{
echo "## Latest-release auto-correction"
echo ""
echo "- Previously flagged as \`latest\`: \`${CURRENT_LATEST_TAG:-<none>}\`"
echo "- Highest stable semver release: \`$TRUE_LATEST_TAG\`"
echo "- Action taken: re-assigned \`latest\` to \`$TRUE_LATEST_TAG\` via \`gh release edit --latest\`"
} >> "$GITHUB_STEP_SUMMARY"
run: ./scripts/cicd/reconcile-latest-release.sh
Comment thread
coderabbitai[bot] marked this conversation as resolved.
92 changes: 48 additions & 44 deletions .github/workflows/release-weekly-comfyui.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -179,37 +179,34 @@ jobs:

publish-pypi:
needs: [resolve-version, trigger-release-if-needed]
outputs:
pypi_confirmed: ${{ steps.pypi-wait.outputs.confirmed }}
if: >
always() &&
needs.resolve-version.result == 'success' &&
(needs.trigger-release-if-needed.result == 'success' ||
needs.trigger-release-if-needed.result == 'skipped')
runs-on: ubuntu-latest
steps:
- name: Wait for release PR to be created and merged
- name: Check out release scripts
uses: actions/checkout@v7
with:
sparse-checkout: scripts/cicd
sparse-checkout-cone-mode: false

# The tag checkout below replaces the workspace, and the tag predates these.
- name: Stage release scripts
run: cp -r scripts/cicd "$RUNNER_TEMP/cicd"

- name: Wait for the release tag
if: needs.trigger-release-if-needed.result == 'success'
env:
GH_TOKEN: ${{ secrets.PR_GH_TOKEN }}
run: |
set -euo pipefail

TARGET_VERSION="${{ needs.resolve-version.outputs.target_version }}"
TARGET_BRANCH="${{ needs.resolve-version.outputs.target_branch }}"
echo "Waiting for version bump PR for v${TARGET_VERSION} on ${TARGET_BRANCH} to be merged..."

# Poll for up to 30 minutes (a human or automation needs to merge the version bump PR)
for i in $(seq 1 60); do
# Check if the tag exists (release-draft-create creates a tag on merge)
if gh api "repos/Comfy-Org/ComfyUI_frontend/git/ref/tags/v${TARGET_VERSION}" --silent 2>/dev/null; then
echo "✅ Tag v${TARGET_VERSION} found — release PR has been merged"
exit 0
fi
echo "Attempt $i/60: Tag v${TARGET_VERSION} not found yet, waiting 30s..."
sleep 30
done

echo "❌ Timed out waiting for tag v${TARGET_VERSION}"
exit 1
REPO: ${{ github.repository }}
TARGET_VERSION: ${{ needs.resolve-version.outputs.target_version }}
TARGET_BRANCH: ${{ needs.resolve-version.outputs.target_branch }}
RUN_ID: ${{ github.run_id }}
run: '$RUNNER_TEMP/cicd/wait-for-release-tag.sh'

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

- name: Wait for PyPI propagation
run: |
set -euo pipefail

TARGET_VERSION="${{ needs.resolve-version.outputs.target_version }}"
PACKAGE="comfyui-frontend-package"
echo "Waiting for ${PACKAGE}==${TARGET_VERSION} to be available on PyPI..."

# Wait up to 15 minutes (polling every 30 seconds)
for i in $(seq 1 30); do
HTTP_CODE=$(curl -s -o /dev/null -w "%{http_code}" "https://pypi.org/pypi/${PACKAGE}/${TARGET_VERSION}/json")
if [ "$HTTP_CODE" = "200" ]; then
echo "✅ ${PACKAGE}==${TARGET_VERSION} is available on PyPI"
exit 0
fi
echo "Attempt $i/30: PyPI returned HTTP ${HTTP_CODE}, waiting 30s..."
sleep 30
done

echo "❌ Timed out waiting for ${PACKAGE}==${TARGET_VERSION} on PyPI"
exit 1
- name: Wait for the version to be installable
id: pypi-wait
env:
PACKAGE: comfyui-frontend-package
TARGET_VERSION: ${{ needs.resolve-version.outputs.target_version }}
run: '$RUNNER_TEMP/cicd/wait-for-pypi-version.sh'

- name: Summary
run: |
echo "## PyPI Publishing" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
echo "- Package: comfyui-frontend-package" >> $GITHUB_STEP_SUMMARY
echo "- Version: ${{ needs.resolve-version.outputs.target_version }}" >> $GITHUB_STEP_SUMMARY
echo "- Status: ✅ Published and confirmed available" >> $GITHUB_STEP_SUMMARY
echo "- Installable from PyPI: ${{ steps.pypi-wait.outputs.confirmed }}" >> $GITHUB_STEP_SUMMARY

create-comfyui-pr:
needs:
Expand Down Expand Up @@ -372,7 +354,11 @@ jobs:
EOF
)

PYPI_NOTE="✅ **PyPI package confirmed available** — \`comfyui-frontend-package==${{ needs.resolve-version.outputs.target_version }}\` has been published and verified."
if [ "${{ needs.publish-pypi.outputs.pypi_confirmed }}" = "true" ]; then
PYPI_NOTE="✅ **PyPI package confirmed available** — \`comfyui-frontend-package==${{ needs.resolve-version.outputs.target_version }}\` has been published and verified."
else
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."
fi
BODY=$''"${PYPI_NOTE}"$'\n\n'"${BODY}"

# Save to file for later use
Expand Down Expand Up @@ -459,3 +445,21 @@ jobs:
echo "" >> $GITHUB_STEP_SUMMARY
echo "### PR Body:" >> $GITHUB_STEP_SUMMARY
cat pr-body.txt >> $GITHUB_STEP_SUMMARY

release-done:
name: Verify release reached users
needs: [resolve-version, publish-pypi, create-comfyui-pr]
if: always() && needs.publish-pypi.result == 'success'
runs-on: ubuntu-latest
steps:
- name: Checkout ComfyUI_frontend
uses: actions/checkout@v7
with:
fetch-depth: 0

- name: Assert the release reached users
env:
PACKAGE: comfyui-frontend-package
TARGET_VERSION: ${{ needs.resolve-version.outputs.target_version }}
TARGET_BRANCH: ${{ needs.resolve-version.outputs.target_branch }}
run: ./scripts/cicd/assert-release-shipped.sh
96 changes: 96 additions & 0 deletions scripts/cicd/assert-release-shipped.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
#!/usr/bin/env bash
set -euo pipefail

: "${PACKAGE:?PACKAGE is required}"
: "${TARGET_VERSION:?TARGET_VERSION is required}"
: "${TARGET_BRANCH:?TARGET_BRANCH is required}"

readonly TAG="v${TARGET_VERSION}"
failed=0

summary() {
[[ -n "${GITHUB_STEP_SUMMARY:-}" ]] && printf '%s\n' "$@" >>"$GITHUB_STEP_SUMMARY"
return 0
}

fail() {
echo "::error title=$1::$2"
summary "- FAIL: $2"
failed=1
}

assert_nothing_stranded_past_the_tag() {
if ! git fetch --quiet --tags origin "$TARGET_BRANCH"; then
fail "Fetch failed" "Could not fetch ${TARGET_BRANCH} from origin."
return
fi
if ! git rev-parse -q --verify "refs/tags/${TAG}^{commit}" >/dev/null; then
fail "Release tag missing" "Tag ${TAG} not found."
return
fi

local stranded
stranded=$(git rev-list "${TAG}..FETCH_HEAD" --count) || stranded=""
if [[ ! "$stranded" =~ ^[0-9]+$ ]]; then
fail "Stranded count unavailable" "git rev-list failed or returned non-numeric output."
elif ((stranded != 0)); then
fail "Commits stranded past the release tag" \
"${stranded} commit(s) on ${TARGET_BRANCH} are newer than ${TAG}; the published release does not contain them."
else
summary "- OK: no commits stranded past \`${TAG}\`"
fi
}

assert_version_on_pypi() {
local http_code
http_code=$(curl -s --connect-timeout 10 --max-time 30 -o /dev/null -w '%{http_code}' \
"https://pypi.org/pypi/${PACKAGE}/${TARGET_VERSION}/json") || http_code="000"
if [[ "$http_code" == "200" ]]; then
summary "- OK: PyPI has \`${PACKAGE}==${TARGET_VERSION}\`"
else
fail "Target version missing on PyPI" "${PACKAGE}==${TARGET_VERSION} returned HTTP ${http_code}."
fi
}

# A core/* patch must not steal PyPI's `latest` from a higher minor.
assert_pypi_latest_only_for_main() {
local pypi_latest
pypi_latest=$(curl -sf --connect-timeout 10 --max-time 30 "https://pypi.org/pypi/${PACKAGE}/json" |
jq -r '.info.version // empty') || pypi_latest=""
Comment thread
coderabbitai[bot] marked this conversation as resolved.

if [[ -z "$pypi_latest" ]]; then
fail "PyPI latest unavailable" "Failed to query the PyPI info endpoint for ${PACKAGE}."
elif [[ "$TARGET_BRANCH" == "main" && "$pypi_latest" != "$TARGET_VERSION" ]]; then
fail "PyPI latest mismatch" "PyPI latest is ${pypi_latest}, expected ${TARGET_VERSION}."
else
summary "- Info: PyPI \`latest\` = \`${pypi_latest}\` (target branch \`${TARGET_BRANCH}\`)"
fi
}

warn_if_comfyui_pin_stale() {
local reqs pin=""
if reqs=$(curl -sf --connect-timeout 10 --max-time 30 \
"https://raw.githubusercontent.com/Comfy-Org/ComfyUI/master/requirements.txt"); then
pin=$(grep -oE "${PACKAGE}==[0-9.]+" <<<"$reqs" | head -1 | cut -d= -f3) || pin=""
fi

if [[ "$pin" == "$TARGET_VERSION" ]]; then
summary "- OK: ComfyUI \`master\` pins \`${TARGET_VERSION}\`"
else
echo "::warning title=ComfyUI pin not yet updated::ComfyUI master pins ${pin:-<none>}, target ${TARGET_VERSION}."
summary "- Warn: ComfyUI \`master\` pins \`${pin:-<none>}\`, target \`${TARGET_VERSION}\`"
fi
}

summary "## Release-done assertion" "" "Target: \`${TAG}\` on \`${TARGET_BRANCH}\`" ""

assert_nothing_stranded_past_the_tag
assert_version_on_pypi
assert_pypi_latest_only_for_main
warn_if_comfyui_pin_stale

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

delete: This pin check runs immediately after opening the pin PR, before anyone can merge it, so its warning is expected noise. Remove warn_if_comfyui_pin_stale, its invocation, and the create-comfyui-pr dependency from release-done.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in bc117987e4: removed the premature ComfyUI pin check and made release-done depend only on version resolution and PyPI publishing.


if ((failed != 0)); then
echo "release-done assertion FAILED — see annotations above."
exit 1
fi
echo "release-done assertion passed."
Loading
Loading