| name: Backport Release |
|
|
| on: |
| workflow_dispatch: |
| inputs: |
| commit: |
| description: 'Full 40-char SHA of the tip commit of the backport source branch (the PR head commit that passed tests). The branch is resolved from this SHA and must be unique.' |
| 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: Resolve source branch from commit SHA |
| id: resolve |
| env: |
| SOURCE_COMMIT: ${{ inputs.commit }} |
| DEFAULT_BRANCH: ${{ github.event.repository.default_branch }} |
| run: | |
| set -euo pipefail |
| |
| |
| |
| |
| if [[ ! "${SOURCE_COMMIT}" =~ ^[0-9a-f]{40}$ ]]; then |
| echo "::error::Input commit '${SOURCE_COMMIT}' is not a full 40-char lowercase hex SHA." |
| exit 1 |
| fi |
|
|
| |
| |
| |
| |
| git fetch --prune origin '+refs/heads/*:refs/remotes/origin/*' |
|
|
| |
| if ! git cat-file -e "${SOURCE_COMMIT}^{commit}" 2>/dev/null; then |
| echo "::error::Commit ${SOURCE_COMMIT} was not found in the repository." |
| exit 1 |
| fi |
|
|
| |
| |
| |
| |
| |
| mapfile -t matching_branches < <( |
| git for-each-ref \ |
| --format='%(refname:strip=3)' \ |
| --points-at="${SOURCE_COMMIT}" \ |
| refs/remotes/origin/ \ |
| | grep -vx 'HEAD' || true |
| ) |
|
|
| if [[ "${#matching_branches[@]}" -eq 0 ]]; then |
| echo "::error::No branch on origin has ${SOURCE_COMMIT} as its tip." |
| echo "::error::Either the branch was updated after you copied this SHA, or this commit was never the head of a branch." |
| exit 1 |
| fi |
|
|
| if [[ "${#matching_branches[@]}" -gt 1 ]]; then |
| echo "::error::More than one branch on origin has ${SOURCE_COMMIT} as its tip; cannot pick one:" |
| for b in "${matching_branches[@]}"; do |
| echo "::error:: - ${b}" |
| done |
| echo "::error::Refusing to proceed with an ambiguous source branch." |
| exit 1 |
| fi |
|
|
| source_branch="${matching_branches[0]}" |
|
|
| if [[ "${source_branch}" == "${DEFAULT_BRANCH}" ]]; then |
| echo "::error::Source branch must not be the default branch ('${DEFAULT_BRANCH}')." |
| exit 1 |
| fi |
|
|
| echo "Resolved commit ${SOURCE_COMMIT} to branch '${source_branch}'." |
| echo "source_branch=${source_branch}" >> "$GITHUB_OUTPUT" |
|
|
| - name: Determine latest stable release |
| id: latest |
| env: |
| GH_TOKEN: ${{ steps.app-token.outputs.token }} |
| run: | |
| set -euo pipefail |
| |
| |
| |
| |
| 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 |
|
|
| |
| 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: ${{ steps.resolve.outputs.source_branch }} |
| SOURCE_COMMIT: ${{ inputs.commit }} |
| LATEST_TAG_SHA: ${{ steps.latest.outputs.latest_sha }} |
| LATEST_TAG: ${{ steps.latest.outputs.latest_tag }} |
| run: | |
| set -euo pipefail |
| |
| |
| |
| |
| |
| source_sha="${SOURCE_COMMIT}" |
|
|
| |
| |
| |
| |
| |
| 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 |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| 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 open, named correctly, has latest commit, and checks pass |
| env: |
| GH_TOKEN: ${{ steps.app-token.outputs.token }} |
| SOURCE_BRANCH: ${{ steps.resolve.outputs.source_branch }} |
| SOURCE_COMMIT: ${{ inputs.commit }} |
| NEW_VERSION: ${{ steps.latest.outputs.new_version }} |
| REPO: ${{ github.repository }} |
| run: | |
| set -euo pipefail |
| |
| expected_title="ComfyUI backport release ${NEW_VERSION}" |
|
|
| |
| |
| |
| pr_json="$( |
| gh pr list \ |
| --repo "${REPO}" \ |
| --state open \ |
| --head "${SOURCE_BRANCH}" \ |
| --base master \ |
| --json number,title,headRefOid,state \ |
| --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'. The PR must exist and be open." |
| exit 1 |
| fi |
|
|
| |
| 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 |
|
|
| |
| |
| |
| |
| |
| |
| if [[ "${pr_head_sha}" != "${SOURCE_COMMIT}" ]]; then |
| echo "::error::PR #${pr_number} head commit is ${pr_head_sha}, but the operator-provided commit is ${SOURCE_COMMIT}." |
| echo "::error::The PR has new commits since this release was authorized. Re-run with the new head SHA after verifying its checks." |
| exit 1 |
| fi |
|
|
| echo "Found open PR #${pr_number} titled '${expected_title}' at head ${pr_head_sha} (matches operator-provided commit)." |
|
|
| |
| |
| 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 }} |
| 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 |
| |
| |
| |
| |
| |
| 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: ${{ steps.resolve.outputs.source_branch }} |
| SOURCE_COMMIT: ${{ inputs.commit }} |
| RELEASE_BRANCH: ${{ steps.latest.outputs.release_branch }} |
| run: | |
| set -euo pipefail |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| if ! git merge --ff-only "${SOURCE_COMMIT}"; then |
| echo "::error::Cannot fast-forward '${RELEASE_BRANCH}' to ${SOURCE_COMMIT} (tip of '${SOURCE_BRANCH}'). A merge commit would be required. Aborting." |
| exit 1 |
| fi |
|
|
| echo "Fast-forwarded '${RELEASE_BRANCH}' to ${SOURCE_COMMIT} (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 |
|
|
| |
| |
| 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() |
| |
| 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 |
| |
| |
| |
| 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: Delete remote source branch |
| env: |
| GH_TOKEN: ${{ steps.app-token.outputs.token }} |
| REPO: ${{ github.repository }} |
| SOURCE_BRANCH: ${{ steps.resolve.outputs.source_branch }} |
| SOURCE_COMMIT: ${{ inputs.commit }} |
| RELEASE_BRANCH: ${{ steps.latest.outputs.release_branch }} |
| DEFAULT_BRANCH: ${{ github.event.repository.default_branch }} |
| run: | |
| set -euo pipefail |
| |
| |
| |
| |
| if [[ "${SOURCE_BRANCH}" == "${DEFAULT_BRANCH}" || "${SOURCE_BRANCH}" == "${RELEASE_BRANCH}" ]]; then |
| echo "::error::Refusing to delete '${SOURCE_BRANCH}' (matches default or release branch)." |
| exit 1 |
| fi |
|
|
| |
| |
| |
| current_tip="$(git ls-remote origin "refs/heads/${SOURCE_BRANCH}" | awk '{print $1}')" |
| if [[ -z "${current_tip}" ]]; then |
| echo "Source branch '${SOURCE_BRANCH}' no longer exists on origin; nothing to delete." |
| exit 0 |
| fi |
| if [[ "${current_tip}" != "${SOURCE_COMMIT}" ]]; then |
| echo "::warning::Source branch '${SOURCE_BRANCH}' tip (${current_tip}) no longer matches released commit (${SOURCE_COMMIT}). Leaving it in place." |
| exit 0 |
| fi |
|
|
| git push origin --delete "refs/heads/${SOURCE_BRANCH}" |
| echo "Deleted remote branch '${SOURCE_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: ${{ steps.resolve.outputs.source_branch }} |
| SOURCE_COMMIT: ${{ inputs.commit }} |
| run: | |
| # SOURCE_BRANCH is empty if the resolve step never produced an output |
| # (e.g. the workflow failed in or before that step). Show a placeholder |
| # in that case so the summary table still renders cleanly. |
| source_branch_display="${SOURCE_BRANCH:-(unresolved)}" |
| { |
| echo "## Backport release" |
| echo "" |
| echo "| Field | Value |" |
| echo "|---|---|" |
| echo "| Source commit | \`${SOURCE_COMMIT}\` |" |
| echo "| Source branch | \`${source_branch_display}\` |" |
| echo "| Previous stable | \`${LATEST_TAG}\` |" |
| echo "| New version | \`${NEW_VERSION}\` |" |
| echo "| Release branch | \`${RELEASE_BRANCH}\` |" |
| } >> "$GITHUB_STEP_SUMMARY" |
| |