mirror of
https://github.com/comfyanonymous/ComfyUI.git
synced 2026-05-24 07:57:29 +08:00
405 lines
17 KiB
YAML
405 lines
17 KiB
YAML
name: Backport Release
|
|
|
|
on:
|
|
workflow_dispatch:
|
|
inputs:
|
|
branch:
|
|
description: 'Source branch containing the backported commits (PR source branch into master)'
|
|
required: true
|
|
type: string
|
|
|
|
permissions:
|
|
contents: read
|
|
pull-requests: read
|
|
checks: read
|
|
|
|
jobs:
|
|
backport-release:
|
|
name: Create backport release
|
|
runs-on: ubuntu-latest
|
|
environment: backport release
|
|
|
|
steps:
|
|
- name: Generate GitHub App token
|
|
id: app-token
|
|
uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1
|
|
with:
|
|
app-id: ${{ secrets.FEN_RELEASE_APP_ID }}
|
|
private-key: ${{ secrets.FEN_RELEASE_PRIVATE_KEY }}
|
|
|
|
- name: Checkout repository
|
|
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd
|
|
with:
|
|
token: ${{ steps.app-token.outputs.token }}
|
|
fetch-depth: 0
|
|
fetch-tags: true
|
|
|
|
- name: Configure git
|
|
run: |
|
|
git config user.name "fen-release[bot]"
|
|
git config user.email "fen-release[bot]@users.noreply.github.com"
|
|
|
|
- name: Validate source branch exists
|
|
env:
|
|
SOURCE_BRANCH: ${{ inputs.branch }}
|
|
DEFAULT_BRANCH: ${{ github.event.repository.default_branch }}
|
|
run: |
|
|
set -euo pipefail
|
|
if [[ "${SOURCE_BRANCH}" == "${DEFAULT_BRANCH}" ]]; then
|
|
echo "::error::Source branch must not be the default branch ('${DEFAULT_BRANCH}')."
|
|
exit 1
|
|
fi
|
|
git fetch origin "refs/heads/${SOURCE_BRANCH}:refs/remotes/origin/${SOURCE_BRANCH}"
|
|
if ! git show-ref --verify --quiet "refs/remotes/origin/${SOURCE_BRANCH}"; then
|
|
echo "::error::Source branch '${SOURCE_BRANCH}' not found on origin."
|
|
exit 1
|
|
fi
|
|
|
|
- name: Determine latest stable release
|
|
id: latest
|
|
env:
|
|
GH_TOKEN: ${{ steps.app-token.outputs.token }}
|
|
run: |
|
|
set -euo pipefail
|
|
|
|
# List all tags matching vMAJOR.MINOR.PATCH and pick the highest by numeric
|
|
# comparison of each component. We DO NOT use `sort -V` because it treats
|
|
# v0.19.99 as higher than v0.20.1.
|
|
latest_tag="$(
|
|
git tag --list 'v[0-9]*.[0-9]*.[0-9]*' \
|
|
| grep -E '^v[0-9]+\.[0-9]+\.[0-9]+$' \
|
|
| awk -F'[v.]' '{ printf "%010d %010d %010d %s\n", $2, $3, $4, $0 }' \
|
|
| sort -k1,1n -k2,2n -k3,3n \
|
|
| tail -n1 \
|
|
| awk '{print $4}'
|
|
)"
|
|
|
|
if [[ -z "${latest_tag}" ]]; then
|
|
echo "::error::No stable release tags (vMAJOR.MINOR.PATCH) were found."
|
|
exit 1
|
|
fi
|
|
|
|
# Parse components
|
|
ver="${latest_tag#v}"
|
|
major="${ver%%.*}"
|
|
rest="${ver#*.}"
|
|
minor="${rest%%.*}"
|
|
patch="${rest#*.}"
|
|
|
|
new_patch=$((patch + 1))
|
|
new_version="v${major}.${minor}.${new_patch}"
|
|
release_branch="release/v${major}.${minor}"
|
|
|
|
latest_sha="$(git rev-list -n 1 "refs/tags/${latest_tag}")"
|
|
|
|
echo "latest_tag=${latest_tag}" >> "$GITHUB_OUTPUT"
|
|
echo "latest_sha=${latest_sha}" >> "$GITHUB_OUTPUT"
|
|
echo "major=${major}" >> "$GITHUB_OUTPUT"
|
|
echo "minor=${minor}" >> "$GITHUB_OUTPUT"
|
|
echo "patch=${patch}" >> "$GITHUB_OUTPUT"
|
|
echo "new_version=${new_version}" >> "$GITHUB_OUTPUT"
|
|
echo "new_version_no_v=${major}.${minor}.${new_patch}" >> "$GITHUB_OUTPUT"
|
|
echo "release_branch=${release_branch}" >> "$GITHUB_OUTPUT"
|
|
|
|
echo "Latest stable release: ${latest_tag} (${latest_sha})"
|
|
echo "New version will be: ${new_version}"
|
|
echo "Release branch: ${release_branch}"
|
|
|
|
- name: Validate source branch is cut directly from the latest stable release
|
|
env:
|
|
SOURCE_BRANCH: ${{ inputs.branch }}
|
|
LATEST_TAG_SHA: ${{ steps.latest.outputs.latest_sha }}
|
|
LATEST_TAG: ${{ steps.latest.outputs.latest_tag }}
|
|
run: |
|
|
set -euo pipefail
|
|
|
|
source_sha="$(git rev-parse "refs/remotes/origin/${SOURCE_BRANCH}")"
|
|
|
|
# Walking first-parent from the source tip must reach LATEST_TAG_SHA.
|
|
# We capture rev-list into a variable and grep against a here-string
|
|
# rather than piping `rev-list | grep -q`: under `set -o pipefail`,
|
|
# `grep -q` would exit on first match and SIGPIPE the still-streaming
|
|
# `rev-list`, propagating exit 141 as a spurious "not found".
|
|
first_parent_chain="$(git rev-list --first-parent "${source_sha}")"
|
|
if ! grep -Fxq "${LATEST_TAG_SHA}" <<< "${first_parent_chain}"; then
|
|
echo "::error::Source branch '${SOURCE_BRANCH}' is not cut from '${LATEST_TAG}'."
|
|
echo "::error::Its first-parent history does not include ${LATEST_TAG_SHA}."
|
|
exit 1
|
|
fi
|
|
|
|
# Additionally, every commit added on top of the tag (the set we are
|
|
# about to publish) must itself be a descendant of the tag along
|
|
# first-parent — i.e. no sibling commits from master sneak in via a
|
|
# non-first-parent path. Enforce by requiring that the symmetric
|
|
# difference is empty in one direction: commits in source that are
|
|
# NOT first-parent-reachable from source starting at the tag.
|
|
# We do this by intersecting:
|
|
# A = commits reachable from source but not from tag (full DAG)
|
|
# B = commits on the first-parent chain from source down to tag
|
|
# and requiring A == B.
|
|
all_added="$(git rev-list "${LATEST_TAG_SHA}..${source_sha}" | sort)"
|
|
first_parent_added="$(
|
|
git rev-list --first-parent "${LATEST_TAG_SHA}..${source_sha}" | sort
|
|
)"
|
|
|
|
if [[ "${all_added}" != "${first_parent_added}" ]]; then
|
|
echo "::error::Source branch '${SOURCE_BRANCH}' contains commits not on its first-parent chain from '${LATEST_TAG}'."
|
|
echo "::error::This usually means the branch was cut from master (not from the tag) or contains a merge from master."
|
|
echo "Commits reachable but not on first-parent chain:"
|
|
comm -23 <(printf '%s\n' "${all_added}") <(printf '%s\n' "${first_parent_added}") \
|
|
| while read -r sha; do
|
|
echo " $(git log -1 --format='%h %s' "${sha}")"
|
|
done
|
|
exit 1
|
|
fi
|
|
|
|
added_count="$(printf '%s\n' "${all_added}" | grep -c . || true)"
|
|
echo "Source branch is cut directly from ${LATEST_TAG} with ${added_count} commit(s) on top."
|
|
|
|
- name: Validate PR exists, is named correctly, and checks pass
|
|
env:
|
|
GH_TOKEN: ${{ steps.app-token.outputs.token }}
|
|
SOURCE_BRANCH: ${{ inputs.branch }}
|
|
NEW_VERSION: ${{ steps.latest.outputs.new_version }}
|
|
REPO: ${{ github.repository }}
|
|
run: |
|
|
set -euo pipefail
|
|
|
|
expected_title="ComfyUI backport release ${NEW_VERSION}"
|
|
|
|
# Find open PRs from this branch into master
|
|
pr_json="$(
|
|
gh pr list \
|
|
--repo "${REPO}" \
|
|
--state open \
|
|
--head "${SOURCE_BRANCH}" \
|
|
--base master \
|
|
--json number,title,headRefOid \
|
|
--limit 10
|
|
)"
|
|
|
|
pr_count="$(echo "${pr_json}" | jq 'length')"
|
|
if [[ "${pr_count}" -eq 0 ]]; then
|
|
echo "::error::No open PR found from '${SOURCE_BRANCH}' into 'master'."
|
|
exit 1
|
|
fi
|
|
|
|
# Pick the PR matching the expected title
|
|
pr_number="$(echo "${pr_json}" | jq -r --arg t "${expected_title}" '
|
|
map(select(.title == $t)) | .[0].number // empty
|
|
')"
|
|
pr_head_sha="$(echo "${pr_json}" | jq -r --arg t "${expected_title}" '
|
|
map(select(.title == $t)) | .[0].headRefOid // empty
|
|
')"
|
|
|
|
if [[ -z "${pr_number}" ]]; then
|
|
echo "::error::No open PR from '${SOURCE_BRANCH}' into 'master' is titled '${expected_title}'."
|
|
echo "Found PRs:"
|
|
echo "${pr_json}" | jq -r '.[] | " #\(.number): \(.title)"'
|
|
exit 1
|
|
fi
|
|
|
|
echo "Found PR #${pr_number} titled '${expected_title}' (head ${pr_head_sha})."
|
|
|
|
# Verify all check runs on the head commit have completed successfully.
|
|
# A check is considered passing if conclusion is success, neutral, or skipped.
|
|
checks_json="$(
|
|
gh api \
|
|
--paginate \
|
|
"repos/${REPO}/commits/${pr_head_sha}/check-runs" \
|
|
--jq '.check_runs[] | {name: .name, status: .status, conclusion: .conclusion}'
|
|
)"
|
|
|
|
if [[ -z "${checks_json}" ]]; then
|
|
echo "::error::No check runs found on PR head commit ${pr_head_sha}."
|
|
exit 1
|
|
fi
|
|
|
|
echo "Check runs on ${pr_head_sha}:"
|
|
echo "${checks_json}" | jq -s '.'
|
|
|
|
failing="$(echo "${checks_json}" | jq -s '
|
|
map(select(
|
|
.status != "completed"
|
|
or (.conclusion as $c
|
|
| ["success","neutral","skipped"]
|
|
| index($c) | not)
|
|
))
|
|
')"
|
|
|
|
failing_count="$(echo "${failing}" | jq 'length')"
|
|
if [[ "${failing_count}" -gt 0 ]]; then
|
|
echo "::error::One or more checks have not passed on PR head commit ${pr_head_sha}:"
|
|
echo "${failing}" | jq -r '.[] | " - \(.name): status=\(.status) conclusion=\(.conclusion)"'
|
|
exit 1
|
|
fi
|
|
|
|
echo "All checks have passed on ${pr_head_sha}."
|
|
|
|
- name: Prepare release branch
|
|
id: prepare
|
|
env:
|
|
GH_TOKEN: ${{ steps.app-token.outputs.token }}
|
|
REPO: ${{ github.repository }}
|
|
SOURCE_BRANCH: ${{ inputs.branch }}
|
|
RELEASE_BRANCH: ${{ steps.latest.outputs.release_branch }}
|
|
LATEST_TAG: ${{ steps.latest.outputs.latest_tag }}
|
|
LATEST_TAG_SHA: ${{ steps.latest.outputs.latest_sha }}
|
|
PATCH: ${{ steps.latest.outputs.patch }}
|
|
run: |
|
|
set -euo pipefail
|
|
|
|
# Try to fetch the release branch. If patch == 0, it shouldn't exist yet
|
|
# and we'll create it from the latest stable tag. If patch > 0, it must
|
|
# already exist and its tip must equal the latest stable tag commit (i.e.
|
|
# the previous patch release).
|
|
if git ls-remote --exit-code --heads origin "${RELEASE_BRANCH}" >/dev/null 2>&1; then
|
|
echo "Release branch '${RELEASE_BRANCH}' already exists on origin."
|
|
git fetch origin "refs/heads/${RELEASE_BRANCH}:refs/remotes/origin/${RELEASE_BRANCH}"
|
|
git checkout -B "${RELEASE_BRANCH}" "refs/remotes/origin/${RELEASE_BRANCH}"
|
|
|
|
current_tip="$(git rev-parse HEAD)"
|
|
if [[ "${current_tip}" != "${LATEST_TAG_SHA}" ]]; then
|
|
echo "::error::Release branch '${RELEASE_BRANCH}' tip (${current_tip}) is not at the latest stable release '${LATEST_TAG}' (${LATEST_TAG_SHA})."
|
|
echo "::error::Refusing to release on top of a divergent branch."
|
|
exit 1
|
|
fi
|
|
echo "branch_existed=true" >> "$GITHUB_OUTPUT"
|
|
else
|
|
if [[ "${PATCH}" != "0" ]]; then
|
|
echo "::error::Release branch '${RELEASE_BRANCH}' does not exist on origin, but the latest stable release '${LATEST_TAG}' has patch=${PATCH} (>0). This is inconsistent."
|
|
exit 1
|
|
fi
|
|
echo "Release branch '${RELEASE_BRANCH}' does not exist. Creating from ${LATEST_TAG}."
|
|
git checkout -B "${RELEASE_BRANCH}" "refs/tags/${LATEST_TAG}"
|
|
echo "branch_existed=false" >> "$GITHUB_OUTPUT"
|
|
fi
|
|
|
|
- name: Fast-forward merge source branch into release branch
|
|
env:
|
|
SOURCE_BRANCH: ${{ inputs.branch }}
|
|
RELEASE_BRANCH: ${{ steps.latest.outputs.release_branch }}
|
|
run: |
|
|
set -euo pipefail
|
|
|
|
# --ff-only guarantees no merge commit is created. If a fast-forward is
|
|
# not possible (i.e. the release branch has commits the source branch
|
|
# doesn't), the merge will fail and we abort. Because we already validated
|
|
# that the source branch is rooted on the latest stable tag, and the
|
|
# release branch tip equals that same tag, this fast-forward should
|
|
# always succeed for a well-formed backport branch.
|
|
if ! git merge --ff-only "refs/remotes/origin/${SOURCE_BRANCH}"; then
|
|
echo "::error::Cannot fast-forward '${RELEASE_BRANCH}' to '${SOURCE_BRANCH}'. A merge commit would be required. Aborting."
|
|
exit 1
|
|
fi
|
|
|
|
echo "Fast-forwarded '${RELEASE_BRANCH}' to tip of '${SOURCE_BRANCH}'."
|
|
|
|
- name: Bump version files
|
|
env:
|
|
NEW_VERSION_NO_V: ${{ steps.latest.outputs.new_version_no_v }}
|
|
run: |
|
|
set -euo pipefail
|
|
|
|
if [[ ! -f comfyui_version.py ]]; then
|
|
echo "::error::comfyui_version.py not found in repo root."
|
|
exit 1
|
|
fi
|
|
if [[ ! -f pyproject.toml ]]; then
|
|
echo "::error::pyproject.toml not found in repo root."
|
|
exit 1
|
|
fi
|
|
|
|
# Replace the version string in comfyui_version.py.
|
|
# Expected format: __version__ = "X.Y.Z"
|
|
python3 - "$NEW_VERSION_NO_V" <<'PY'
|
|
import re, sys, pathlib
|
|
new = sys.argv[1]
|
|
|
|
p = pathlib.Path("comfyui_version.py")
|
|
src = p.read_text()
|
|
new_src, n = re.subn(
|
|
r'(__version__\s*=\s*[\'"])[^\'"]+([\'"])',
|
|
lambda m: f'{m.group(1)}{new}{m.group(2)}',
|
|
src,
|
|
count=1,
|
|
)
|
|
if n != 1:
|
|
sys.exit("Could not find __version__ assignment in comfyui_version.py")
|
|
p.write_text(new_src)
|
|
|
|
p = pathlib.Path("pyproject.toml")
|
|
src = p.read_text()
|
|
# Replace the first `version = "..."` inside [project] or [tool.poetry].
|
|
new_src, n = re.subn(
|
|
r'(?m)^(version\s*=\s*")[^"]+(")',
|
|
lambda m: f'{m.group(1)}{new}{m.group(2)}',
|
|
src,
|
|
count=1,
|
|
)
|
|
if n != 1:
|
|
sys.exit("Could not find version assignment in pyproject.toml")
|
|
p.write_text(new_src)
|
|
PY
|
|
|
|
echo "Updated version to ${NEW_VERSION_NO_V} in comfyui_version.py and pyproject.toml."
|
|
git --no-pager diff -- comfyui_version.py pyproject.toml
|
|
|
|
- name: Commit version bump and tag release
|
|
env:
|
|
NEW_VERSION: ${{ steps.latest.outputs.new_version }}
|
|
run: |
|
|
set -euo pipefail
|
|
|
|
git add comfyui_version.py pyproject.toml
|
|
git commit -m "ComfyUI ${NEW_VERSION}"
|
|
|
|
if git rev-parse -q --verify "refs/tags/${NEW_VERSION}" >/dev/null; then
|
|
echo "::error::Tag ${NEW_VERSION} already exists locally."
|
|
exit 1
|
|
fi
|
|
git tag "${NEW_VERSION}"
|
|
|
|
- name: Verify tag does not already exist on origin
|
|
env:
|
|
NEW_VERSION: ${{ steps.latest.outputs.new_version }}
|
|
run: |
|
|
set -euo pipefail
|
|
if git ls-remote --exit-code --tags origin "refs/tags/${NEW_VERSION}" >/dev/null 2>&1; then
|
|
echo "::error::Tag ${NEW_VERSION} already exists on origin. Aborting."
|
|
exit 1
|
|
fi
|
|
|
|
- name: Push release branch and tag
|
|
env:
|
|
RELEASE_BRANCH: ${{ steps.latest.outputs.release_branch }}
|
|
NEW_VERSION: ${{ steps.latest.outputs.new_version }}
|
|
run: |
|
|
set -euo pipefail
|
|
|
|
# Push the branch first, then the tag. Atomic-ish: if the branch push
|
|
# fails we never publish the tag.
|
|
git push origin "refs/heads/${RELEASE_BRANCH}:refs/heads/${RELEASE_BRANCH}"
|
|
git push origin "refs/tags/${NEW_VERSION}"
|
|
|
|
echo "Released ${NEW_VERSION} on ${RELEASE_BRANCH}."
|
|
|
|
- name: Summary
|
|
if: always()
|
|
env:
|
|
NEW_VERSION: ${{ steps.latest.outputs.new_version }}
|
|
RELEASE_BRANCH: ${{ steps.latest.outputs.release_branch }}
|
|
LATEST_TAG: ${{ steps.latest.outputs.latest_tag }}
|
|
SOURCE_BRANCH: ${{ inputs.branch }}
|
|
run: |
|
|
{
|
|
echo "## Backport release"
|
|
echo ""
|
|
echo "| Field | Value |"
|
|
echo "|---|---|"
|
|
echo "| Source branch | \`${SOURCE_BRANCH}\` |"
|
|
echo "| Previous stable | \`${LATEST_TAG}\` |"
|
|
echo "| New version | \`${NEW_VERSION}\` |"
|
|
echo "| Release branch | \`${RELEASE_BRANCH}\` |"
|
|
} >> "$GITHUB_STEP_SUMMARY"
|