diff --git a/.github/workflows/cut-release.yml b/.github/workflows/cut-release.yml new file mode 100644 index 000000000..b7f55c71e --- /dev/null +++ b/.github/workflows/cut-release.yml @@ -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` + `v` +# 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 - < 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: " + # /releases/tag/ would 404 — this workflow creates an annotated + # git tag, not a GitHub Release object. + echo "- 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"