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 }} run: | set -euo pipefail 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}")" # The source branch must be cut directly off the latest stable tag. # "Cut directly off" means: walking first-parent from the source tip # eventually reaches LATEST_TAG_SHA. This rejects branches that were # cut from master after the tag (which would carry unrelated commits), # while accepting a branch rooted at the tag with N backport commits # on top (each of which may itself be a merge — first-parent walks # through the mainline of the branch). if ! git rev-list --first-parent "${source_sha}" \ | grep -qx "${LATEST_TAG_SHA}"; 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"