Compare commits

...

8 Commits

Author SHA1 Message Date
91fe94bb78 Merge branch 'master' into feat/cut-release-workflow 2026-05-18 09:43:02 -07:00
b7a09856e0 fix: address CodeRabbit findings on cut-release.yml
- approval gate now honors latest review state per reviewer; a reviewer
  who later submits CHANGES_REQUESTED or has their approval dismissed no
  longer counts as approved. Plain COMMENTED follow-ups don't override
  an existing approval (matches GitHub's own behavior).

- success summary links to the tag's tree URL instead of /releases/tag/,
  which would 404 because this workflow creates an annotated git tag,
  not a GitHub Release object.
2026-05-15 22:52:17 -07:00
8626a290c4 refactor: move release gating into cut-release.yml; bump from last stable tag
The workflow now does its own gating rather than trusting the operator
to pre-stage a clean candidate, and creates the version-bump commit
itself so the candidate branch can be a pure cherry-pick chain.

Operator flow (changed):
  - Locally cherry-pick the backport commits onto the previous stable
    tag. NO version-bump commit.
  - Push the candidate branch and open a PR against `master`.
  - Wait for CI green; get an APPROVED review from a user listed in the
    repo variable `STABLE_RELEASE_APPROVERS`.
  - Run the workflow with `source_branch` = candidate branch.

Workflow now verifies, in order:
  1. `source_branch` matches a safe ref-name regex.
  2. `STABLE_RELEASE_APPROVERS` repo variable is configured.
  3. Latest stable tag = highest semver `vX.Y.Z`; next version is
     `last_tag_patch + 1` (computed; no longer a workflow input).
  4. Source branch exists on origin; target release branch and tag
     do NOT exist (refuse to overwrite).
  5. Branch is rooted at the latest stable tag:
       * tag is an ancestor of source HEAD,
       * `merge-base(source, master) == tag commit` (no master commits
         sneaked in via merge/rebase),
       * no merge commits in `tag..source` (linear cherry-picks only).
  6. Version files on the candidate still equal the previous tag's
     version (operator must NOT include a version bump). Read via
     `git show | python3 -c '...'` so candidate code is never executed,
     and `comfyui_version.py` is statically AST-parsed for `__version__`.
  7. PR for the source branch exists, targets master, head SHA matches
     the candidate, has an APPROVED review on that exact SHA from an
     allow-listed user (stale approvals on older commits don't count),
     and all check-runs / commit-statuses on the SHA are success /
     neutral / skipped. `mergeable_state` is intentionally not used —
     backport branches by definition aren't "up-to-date with master".

After all gates pass, the workflow creates the version-bump commit
itself (edits `pyproject.toml` + `comfyui_version.py` via stdlib regex,
commits as `github-actions[bot]`), then atomically pushes
`release/v<version>` + `v<version>` annotated tag using
`RELEASE_BOT_TOKEN`.

Kept from the previous revision: atomic ref push, `persist-credentials:
false` checkout, AST-based version-file parse (never `exec()`), all
inputs flow through `env:` vars (no command injection), `dry_run`
short-circuits the push step, pre-flight existence checks, step summary
linking to `release-stable-all.yml`.

Verified end-to-end against Kosinkadink/ComfyUI with sentinel `v0.99.99`
→ `v0.99.100`: dry-run passed all 11 steps; real run created
`release/v0.99.100` + `v0.99.100` atomically. Negative cases for the
approval gate and version-file gate also exercised.
2026-05-15 22:44:10 -07:00
47417ce7ec Merge branch 'master' into feat/cut-release-workflow 2026-05-09 16:25:05 -07:00
a0a65f51bc fix: address coderabbit findings - replace exec() with AST parse, gate summary on job.status, persist-credentials false
Amp-Thread-ID: https://ampcode.com/threads/T-019e042d-d972-7559-b462-6e838c2da164
Co-authored-by: Amp <amp@ampcode.com>
2026-05-08 15:18:09 -07:00
3566e6b6a6 fix: remove auto-delete of source branch (footgun for non-ephemeral sources like master)
Amp-Thread-ID: https://ampcode.com/threads/T-019e042d-d972-7559-b462-6e838c2da164
Co-authored-by: Amp <amp@ampcode.com>
2026-05-08 15:15:53 -07:00
a8e11936f3 fix: address code review issues in cut-release.yml
- security: pass all inputs via env vars (no command injection)
- security: validate ref names against safe charset
- bug: use robust dry_run check (handle bool and 'true'/'false' string)
- robustness: combine branch+tag push into single atomic operation
- robustness: add set -euo pipefail to Configure git identity step

Amp-Thread-ID: https://ampcode.com/threads/T-019e042d-d972-7559-b462-6e838c2da164
Co-authored-by: Amp <amp@ampcode.com>
2026-05-08 14:30:36 -07:00
08a9245e7f feat: add cut-release.yml workflow for backport release promotion
Amp-Thread-ID: https://ampcode.com/threads/T-019e042d-d972-7559-b462-6e838c2da164
Co-authored-by: Amp <amp@ampcode.com>
2026-05-08 14:17:40 -07:00

456
.github/workflows/cut-release.yml vendored Normal file
View File

@ -0,0 +1,456 @@
name: Cut Release
# Promote a prepared "candidate" backport branch into a real release.
#
# Operator workflow:
# 1. Locally cherry-pick the desired commits onto the previous stable tag
# (e.g. v0.20.2). Do NOT include a version-bump commit.
# 2. Push the candidate branch to origin and open a PR against `master`.
# 3. Wait for CI to go green; get an APPROVED review from a user listed
# in the `STABLE_RELEASE_APPROVERS` repo variable (comma-separated
# GitHub logins).
# 4. Run this workflow with `source_branch` = the candidate branch.
#
# This workflow then verifies all gates, computes the next version
# (previous stable tag + patch+1), creates the version-bump commit on top
# of the candidate, and pushes `release/v<version>` + `v<version>`
# atomically using RELEASE_BOT_TOKEN.
#
# After this runs, kick off `release-stable-all.yml` manually with the new
# tag to build portable artifacts.
on:
workflow_dispatch:
inputs:
source_branch:
description: 'Candidate backport branch on origin (cherry-picks only — NO version bump)'
required: true
type: string
dry_run:
description: 'Validate only — do not push branch or tag'
required: false
type: boolean
default: false
jobs:
cut-release:
runs-on: ubuntu-latest
permissions:
# GITHUB_TOKEN is used only for read-only API calls (PR / review /
# check lookups). The actual ref creation uses RELEASE_BOT_TOKEN to
# bypass branch-protection rules on `release/*` and `v*`.
contents: read
pull-requests: read
checks: read
env:
# All workflow inputs are pulled in via env vars instead of being
# interpolated directly into bash, to avoid command injection.
INPUT_SOURCE_BRANCH: ${{ inputs.source_branch }}
INPUT_DRY_RUN: ${{ inputs.dry_run }}
REPO_FULL: ${{ github.repository }}
APPROVERS: ${{ vars.STABLE_RELEASE_APPROVERS }}
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
steps:
- name: Validate inputs and config
run: |
set -euo pipefail
REF_RE='^[A-Za-z0-9._/-]+$'
if ! [[ "$INPUT_SOURCE_BRANCH" =~ $REF_RE ]]; then
echo "::error::Invalid source_branch '$INPUT_SOURCE_BRANCH' — only [A-Za-z0-9._/-] allowed."
exit 1
fi
if [ -z "${APPROVERS:-}" ]; then
echo "::error::Repository variable STABLE_RELEASE_APPROVERS is not set. Configure it as a comma-separated list of GitHub logins permitted to approve stable backport releases."
exit 1
fi
- name: Check out master with full history + tags
uses: actions/checkout@v4
with:
ref: master
fetch-depth: 0
# Defense in depth: don't leave a usable git credential on disk
# for any code that runs against the checked-out branch.
persist-credentials: false
- name: Determine latest stable tag and next version
id: ver
run: |
set -euo pipefail
git fetch --tags --force origin
# Latest stable tag = highest semver vX.Y.Z (no pre-release suffix).
LATEST_TAG=$(git tag --list 'v[0-9]*.[0-9]*.[0-9]*' --sort=-v:refname \
| grep -E '^v[0-9]+\.[0-9]+\.[0-9]+$' \
| head -n1)
if [ -z "$LATEST_TAG" ]; then
echo "::error::Could not determine latest stable vX.Y.Z tag."
exit 1
fi
# Peel annotated tag → commit SHA.
LATEST_SHA=$(git rev-list -n1 "$LATEST_TAG")
BASE_VERSION="${LATEST_TAG#v}"
IFS=. read -r MAJ MIN PATCH <<<"$BASE_VERSION"
NEXT_PATCH=$((PATCH + 1))
NEXT_VERSION="${MAJ}.${MIN}.${NEXT_PATCH}"
NEXT_TAG="v${NEXT_VERSION}"
NEXT_BRANCH="release/${NEXT_TAG}"
{
echo "latest_tag=$LATEST_TAG"
echo "latest_sha=$LATEST_SHA"
echo "latest_version=$BASE_VERSION"
echo "next_version=$NEXT_VERSION"
echo "next_tag=$NEXT_TAG"
echo "next_branch=$NEXT_BRANCH"
} >> "$GITHUB_OUTPUT"
echo "Latest stable: $LATEST_TAG ($LATEST_SHA)"
echo "Next release : $NEXT_TAG"
- name: Pre-flight remote checks
env:
NEXT_BRANCH: ${{ steps.ver.outputs.next_branch }}
NEXT_TAG: ${{ steps.ver.outputs.next_tag }}
run: |
set -euo pipefail
REPO_URL="https://github.com/${REPO_FULL}.git"
if ! git ls-remote --heads --exit-code "$REPO_URL" "$INPUT_SOURCE_BRANCH" >/dev/null; then
echo "::error::Source branch '$INPUT_SOURCE_BRANCH' does not exist on origin."
exit 1
fi
if git ls-remote --heads --exit-code "$REPO_URL" "$NEXT_BRANCH" >/dev/null 2>&1; then
echo "::error::Release branch '$NEXT_BRANCH' already exists on origin. Refusing to overwrite."
exit 1
fi
if git ls-remote --tags --exit-code "$REPO_URL" "$NEXT_TAG" >/dev/null 2>&1; then
echo "::error::Tag '$NEXT_TAG' already exists on origin. Refusing to overwrite."
exit 1
fi
- name: Verify candidate branch is rooted at latest stable tag
id: root
env:
LATEST_SHA: ${{ steps.ver.outputs.latest_sha }}
LATEST_TAG: ${{ steps.ver.outputs.latest_tag }}
run: |
set -euo pipefail
git fetch origin "+${INPUT_SOURCE_BRANCH}:refs/remotes/origin/${INPUT_SOURCE_BRANCH}"
SOURCE_SHA=$(git rev-parse "refs/remotes/origin/${INPUT_SOURCE_BRANCH}")
# 1. Latest stable tag must be an ancestor of the candidate.
if ! git merge-base --is-ancestor "$LATEST_SHA" "$SOURCE_SHA"; then
echo "::error::Latest stable tag $LATEST_TAG is not an ancestor of $INPUT_SOURCE_BRANCH. The candidate branch must be branched from $LATEST_TAG."
exit 1
fi
# 2. merge-base(source, master) MUST equal the latest tag commit.
# If the operator merged or rebased master into the backport, the
# branch will share extra commits with master past the last release
# — that's not a clean backport.
MB=$(git merge-base "$SOURCE_SHA" "origin/master")
if [ "$MB" != "$LATEST_SHA" ]; then
echo "::error::Branch $INPUT_SOURCE_BRANCH is not rooted at $LATEST_TAG. merge-base with master is $MB, expected $LATEST_SHA. The candidate must contain only cherry-picked commits on top of $LATEST_TAG."
exit 1
fi
# 3. No merge commits in the cherry-pick range — backports must be
# linear so the diff being released is auditable in the PR.
if [ -n "$(git log --merges --format=%H "${LATEST_SHA}..${SOURCE_SHA}")" ]; then
echo "::error::Candidate branch contains merge commits between $LATEST_TAG and HEAD. Backport branches must be linear cherry-picks only."
exit 1
fi
echo "source_sha=$SOURCE_SHA" >> "$GITHUB_OUTPUT"
echo "Branch is rooted at $LATEST_TAG ✅"
- name: Verify version files are unchanged on candidate
env:
LATEST_VERSION: ${{ steps.ver.outputs.latest_version }}
SOURCE_SHA: ${{ steps.root.outputs.source_sha }}
run: |
set -euo pipefail
# Read version files at the candidate HEAD WITHOUT switching the
# worktree, so we don't run any candidate-supplied code.
PYPROJECT_RAW=$(git show "${SOURCE_SHA}:pyproject.toml")
PYPROJECT_VERSION=$(printf '%s' "$PYPROJECT_RAW" \
| python3 -c "import sys,tomllib; print(tomllib.loads(sys.stdin.read())['project']['version'])")
if [ "$PYPROJECT_VERSION" != "$LATEST_VERSION" ]; then
echo "::error::pyproject.toml version on candidate is '$PYPROJECT_VERSION' but should still be '$LATEST_VERSION' (the previous stable tag). Do not include a version-bump commit on the candidate — this workflow adds it."
exit 1
fi
# comfyui_version.py contains Python — never `exec()` it. A
# malicious candidate could replace it with arbitrary code that
# would then run in CI with RELEASE_BOT_TOKEN in scope. Statically
# parse the AST to extract __version__ instead.
# Note: piping into `python3 -c '...'` (NOT `python3 - <<EOF`,
# which would redirect stdin to the heredoc itself).
MODULE_VERSION=$(git show "${SOURCE_SHA}:comfyui_version.py" | python3 -c '
import ast, sys
tree = ast.parse(sys.stdin.read())
for node in tree.body:
if isinstance(node, ast.Assign):
for target in node.targets:
if isinstance(target, ast.Name) and target.id == "__version__":
if isinstance(node.value, ast.Constant) and isinstance(node.value.value, str):
print(node.value.value)
sys.exit(0)
sys.exit("Could not statically read __version__ from comfyui_version.py")
')
if [ "$MODULE_VERSION" != "$LATEST_VERSION" ]; then
echo "::error::comfyui_version.py __version__ on candidate is '$MODULE_VERSION' but should still be '$LATEST_VERSION'. Do not include a version-bump commit on the candidate — this workflow adds it."
exit 1
fi
echo "Version files unchanged on candidate ✅"
- name: Verify PR exists, is approved by a permitted user, and CI is green
id: pr
env:
SOURCE_SHA: ${{ steps.root.outputs.source_sha }}
run: |
set -euo pipefail
OWNER="${REPO_FULL%/*}"
# Locate the open PR with this branch as its head (same-repo only).
PR_LIST=$(gh api "repos/${REPO_FULL}/pulls" \
-X GET -f state=open -f "head=${OWNER}:${INPUT_SOURCE_BRANCH}" \
--jq '[.[] | {number,head_sha:.head.sha,base:.base.ref}]')
PR_COUNT=$(echo "$PR_LIST" | jq 'length')
if [ "$PR_COUNT" -ne 1 ]; then
echo "::error::Expected exactly 1 open PR with head '${INPUT_SOURCE_BRANCH}', found $PR_COUNT."
echo "$PR_LIST" | jq .
exit 1
fi
PR_NUMBER=$(echo "$PR_LIST" | jq -r '.[0].number')
PR_HEAD_SHA=$(echo "$PR_LIST" | jq -r '.[0].head_sha')
PR_BASE=$(echo "$PR_LIST" | jq -r '.[0].base')
if [ "$PR_BASE" != "master" ]; then
echo "::error::PR #${PR_NUMBER} targets '$PR_BASE', expected 'master'."
exit 1
fi
if [ "$PR_HEAD_SHA" != "$SOURCE_SHA" ]; then
echo "::error::PR #${PR_NUMBER} HEAD ($PR_HEAD_SHA) does not match candidate branch HEAD ($SOURCE_SHA). The branch may have been updated after the PR was approved — re-trigger after pushing the latest commits."
exit 1
fi
# ---- Approval check ----
# An approving review must be:
# * from a user in the STABLE_RELEASE_APPROVERS allow-list,
# * state == APPROVED,
# * on the current HEAD (commit_id == PR_HEAD_SHA) — newer
# commits invalidate older approvals.
REVIEWS=$(gh api "repos/${REPO_FULL}/pulls/${PR_NUMBER}/reviews" --paginate)
# Normalize allow-list and find an APPROVED review on the current
# head SHA, all inside jq so we don't trip over login chars like
# `[bot]` that bash globbing/word-splitting would mishandle.
#
# GitHub returns every review submission separately, so a reviewer
# who approved and then later submitted CHANGES_REQUESTED or had
# their approval dismissed will still have the old APPROVED entry
# in the list. To honor the *latest* state, filter to stateful
# reviews only (APPROVED / CHANGES_REQUESTED / DISMISSED — plain
# COMMENTED replies don't override an approval, matching GitHub's
# own behavior), group by reviewer, take each reviewer's last
# stateful submission, then accept if it's APPROVED.
APPROVED_BY=$(echo "$REVIEWS" | jq -r --arg list "$APPROVERS" --arg sha "$PR_HEAD_SHA" '
($list
| split(",")
| map(gsub("\\s"; "") | ascii_downcase)
| map(select(length > 0))
) as $allow
| [ .[]
| select(.commit_id == $sha)
| select(.user.login | ascii_downcase | IN($allow[]))
| select(.state == "APPROVED"
or .state == "CHANGES_REQUESTED"
or .state == "DISMISSED")
]
| group_by(.user.login | ascii_downcase)
| map(sort_by(.submitted_at, .id) | last)
| map(select(.state == "APPROVED"))
| .[0].user.login // ""
')
if [ -z "$APPROVED_BY" ]; then
echo "::error::PR #${PR_NUMBER} has no APPROVED review on commit ${PR_HEAD_SHA:0:12} from a permitted approver. Allowed approvers: ${APPROVERS}."
exit 1
fi
# ---- CI check ----
# All check-runs on PR_HEAD_SHA must be completed with conclusion
# success/neutral/skipped. We do NOT use mergeable_state because
# branch protection may require "up-to-date with master", which
# backport branches by definition are not (and shouldn't be).
CHECKS=$(gh api "repos/${REPO_FULL}/commits/${PR_HEAD_SHA}/check-runs" --paginate \
--jq '.check_runs')
PENDING=$(echo "$CHECKS" | jq -r '.[] | select(.status!="completed") | .name')
FAILED=$(echo "$CHECKS" | jq -r '.[] | select(.status=="completed" and (.conclusion!="success" and .conclusion!="neutral" and .conclusion!="skipped")) | "\(.name) (\(.conclusion))"')
TOTAL=$(echo "$CHECKS" | jq 'length')
if [ "$TOTAL" -eq 0 ]; then
echo "::error::No check-runs found on commit ${PR_HEAD_SHA:0:12}. CI must run before promoting a release."
exit 1
fi
if [ -n "$PENDING" ]; then
echo "::error::PR #${PR_NUMBER} has pending check-runs on ${PR_HEAD_SHA:0:12}:"
echo "$PENDING" | sed 's/^/ - /'
exit 1
fi
if [ -n "$FAILED" ]; then
echo "::error::PR #${PR_NUMBER} has failing check-runs on ${PR_HEAD_SHA:0:12}:"
echo "$FAILED" | sed 's/^/ - /'
exit 1
fi
# Also reject if any classic commit-status is failing/pending.
STATUSES=$(gh api "repos/${REPO_FULL}/commits/${PR_HEAD_SHA}/status" \
--jq '{state: .state, contexts: [.statuses[] | {context, state}]}')
STATE=$(echo "$STATUSES" | jq -r '.state')
if [ "$STATE" != "success" ] && [ "$STATE" != "pending" ]; then
# "pending" with zero contexts means "no statuses" (only check-runs) — allow.
CTX_COUNT=$(echo "$STATUSES" | jq '.contexts | length')
if [ "$STATE" = "pending" ] && [ "$CTX_COUNT" -eq 0 ]; then
:
else
echo "::error::PR #${PR_NUMBER} commit-status rollup is '$STATE' on ${PR_HEAD_SHA:0:12}:"
echo "$STATUSES" | jq -r '.contexts[] | " - \(.context): \(.state)"'
exit 1
fi
elif [ "$STATE" = "pending" ]; then
CTX_COUNT=$(echo "$STATUSES" | jq '.contexts | length')
if [ "$CTX_COUNT" -gt 0 ]; then
echo "::error::PR #${PR_NUMBER} has pending commit-statuses on ${PR_HEAD_SHA:0:12}:"
echo "$STATUSES" | jq -r '.contexts[] | select(.state!="success") | " - \(.context): \(.state)"'
exit 1
fi
fi
{
echo "pr_number=$PR_NUMBER"
echo "approved_by=$APPROVED_BY"
} >> "$GITHUB_OUTPUT"
echo "PR #${PR_NUMBER} approved by @${APPROVED_BY}, CI green ($TOTAL checks) ✅"
- name: Apply version bump on top of candidate
id: bump
env:
NEXT_VERSION: ${{ steps.ver.outputs.next_version }}
SOURCE_SHA: ${{ steps.root.outputs.source_sha }}
run: |
set -euo pipefail
# Materialize the candidate as a local branch so we can build a
# commit on top of it. We do NOT execute anything from the
# candidate's content — only edit two files via stdlib parsers.
git checkout -B _release_local "$SOURCE_SHA"
python3 - "$NEXT_VERSION" <<'PY'
import pathlib, re, sys
version = sys.argv[1]
# pyproject.toml — replace only the top-level `version = "..."`
# line under [project]. Use a strict, anchored regex.
p = pathlib.Path("pyproject.toml")
text = p.read_text(encoding="utf-8")
new = re.sub(r'(?m)^(version\s*=\s*")([^"]+)(")',
lambda m: m.group(1) + version + m.group(3),
text, count=1)
if new == text:
sys.exit("Failed to update version in pyproject.toml")
p.write_text(new, encoding="utf-8")
# comfyui_version.py — replace `__version__ = "..."`.
p = pathlib.Path("comfyui_version.py")
text = p.read_text(encoding="utf-8")
new = re.sub(r'__version__\s*=\s*"[^"]+"',
f'__version__ = "{version}"',
text, count=1)
if new == text:
sys.exit("Failed to update __version__ in comfyui_version.py")
p.write_text(new, encoding="utf-8")
PY
git config user.name "github-actions[bot]"
git config user.email "github-actions[bot]@users.noreply.github.com"
git add pyproject.toml comfyui_version.py
git commit -m "Bump version to ${NEXT_VERSION}"
BUMP_SHA=$(git rev-parse HEAD)
echo "bump_sha=$BUMP_SHA" >> "$GITHUB_OUTPUT"
echo "Created bump commit ${BUMP_SHA:0:12}"
- name: Plan summary
env:
LATEST_TAG: ${{ steps.ver.outputs.latest_tag }}
NEXT_TAG: ${{ steps.ver.outputs.next_tag }}
NEXT_BRANCH: ${{ steps.ver.outputs.next_branch }}
PR_NUMBER: ${{ steps.pr.outputs.pr_number }}
APPROVED_BY: ${{ steps.pr.outputs.approved_by }}
SOURCE_SHA: ${{ steps.root.outputs.source_sha }}
BUMP_SHA: ${{ steps.bump.outputs.bump_sha }}
run: |
set -euo pipefail
{
echo "## Cut Release plan"
echo ""
echo "| Field | Value |"
echo "| --- | --- |"
echo "| Source branch | \`$INPUT_SOURCE_BRANCH\` @ \`${SOURCE_SHA:0:12}\` |"
echo "| Source PR | [#${PR_NUMBER}](https://github.com/${REPO_FULL}/pull/${PR_NUMBER}) |"
echo "| Approved by | @${APPROVED_BY} |"
echo "| Previous stable tag | \`$LATEST_TAG\` |"
echo "| Next tag | \`$NEXT_TAG\` |"
echo "| Next branch | \`$NEXT_BRANCH\` |"
echo "| Version-bump commit | \`${BUMP_SHA:0:12}\` |"
echo "| Dry run | \`$INPUT_DRY_RUN\` |"
echo ""
echo "### Commits to be released (\`${LATEST_TAG}..${BUMP_SHA}\`)"
echo ""
echo '```'
git log --oneline --no-decorate "${LATEST_TAG}..${BUMP_SHA}"
echo '```'
} >> "$GITHUB_STEP_SUMMARY"
- name: Push release branch and tag (atomic)
if: inputs.dry_run != true && inputs.dry_run != 'true'
env:
RELEASE_TOKEN: ${{ secrets.RELEASE_BOT_TOKEN }}
NEXT_BRANCH: ${{ steps.ver.outputs.next_branch }}
NEXT_TAG: ${{ steps.ver.outputs.next_tag }}
NEXT_VERSION: ${{ steps.ver.outputs.next_version }}
BUMP_SHA: ${{ steps.bump.outputs.bump_sha }}
run: |
set -euo pipefail
if [ -z "${RELEASE_TOKEN:-}" ]; then
echo "::error::secrets.RELEASE_BOT_TOKEN is not set."
exit 1
fi
# Create the annotated tag locally first so we can push branch +
# tag in a single atomic operation: either both refs are created
# on origin, or neither — no half-promoted state.
git tag -a "$NEXT_TAG" "$BUMP_SHA" -m "ComfyUI v${NEXT_VERSION}"
AUTH_URL="https://x-access-token:${RELEASE_TOKEN}@github.com/${REPO_FULL}.git"
git push --atomic "$AUTH_URL" \
"${BUMP_SHA}:refs/heads/${NEXT_BRANCH}" \
"refs/tags/${NEXT_TAG}"
- name: Final summary
if: always()
env:
NEXT_BRANCH: ${{ steps.ver.outputs.next_branch }}
NEXT_TAG: ${{ steps.ver.outputs.next_tag }}
JOB_STATUS: ${{ job.status }}
run: |
set -euo pipefail
{
echo ""
echo "### Result"
if [ "$JOB_STATUS" != "success" ]; then
echo "❌ Workflow did not complete successfully (job status: \`$JOB_STATUS\`). See the run logs for details. No branch or tag should be assumed to have been created."
elif [ "$INPUT_DRY_RUN" = "true" ]; then
echo "🔍 **Dry run** — no branch or tag was created."
else
echo "✅ Created \`$NEXT_BRANCH\` and tagged \`$NEXT_TAG\` from \`$INPUT_SOURCE_BRANCH\` (with version bump)."
echo ""
echo "- Branch: <https://github.com/${REPO_FULL}/tree/${NEXT_BRANCH}>"
# /releases/tag/ would 404 — this workflow creates an annotated
# git tag, not a GitHub Release object.
echo "- Tag: <https://github.com/${REPO_FULL}/tree/${NEXT_TAG}>"
echo ""
echo "Next: run [release-stable-all.yml](https://github.com/${REPO_FULL}/actions/workflows/release-stable-all.yml) with \`git_tag=${NEXT_TAG}\`."
fi
} >> "$GITHUB_STEP_SUMMARY"