From 08a9245e7fd99163351a0e2d3a2e9452b9c46b37 Mon Sep 17 00:00:00 2001 From: Jedrzej Kosinski Date: Fri, 8 May 2026 14:17:40 -0700 Subject: [PATCH 1/4] 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 --- .github/workflows/cut-release.yml | 223 ++++++++++++++++++++++++++++++ 1 file changed, 223 insertions(+) create mode 100644 .github/workflows/cut-release.yml diff --git a/.github/workflows/cut-release.yml b/.github/workflows/cut-release.yml new file mode 100644 index 000000000..da70c1ed2 --- /dev/null +++ b/.github/workflows/cut-release.yml @@ -0,0 +1,223 @@ +name: Cut Release + +# Promote a prepared "candidate" branch (cherry-picks + version bump +# already committed) into a real release: create the canonical +# `release/v` branch, create the `v` annotated tag, and +# (on success) delete the candidate branch. +# +# After this runs, kick off `release-stable-all.yml` manually with the new +# tag to build portable artifacts — same as previous backports. + +on: + workflow_dispatch: + inputs: + source_branch: + description: 'Candidate branch with cherry-picks + version bump (e.g. kosinkadink/release-v0.20.3-prep)' + required: true + type: string + version: + description: 'Target version (e.g. 0.20.3 — no leading v)' + required: true + type: string + release_branch: + description: 'Override target release branch name (default: release/v)' + required: false + type: string + default: '' + tag_name: + description: 'Override tag name (default: v)' + required: false + type: string + default: '' + dry_run: + description: 'Validate only — do not push branch, tag, or delete source' + required: false + type: boolean + default: false + +jobs: + cut-release: + runs-on: ubuntu-latest + permissions: + # GITHUB_TOKEN write access is unused for the actual push (we use + # RELEASE_BOT_TOKEN to bypass branch-protection rules), but kept + # write so subsequent automation hooks have it if needed. + contents: write + env: + VERSION: ${{ inputs.version }} + SOURCE_BRANCH: ${{ inputs.source_branch }} + DRY_RUN: ${{ inputs.dry_run }} + + steps: + - name: Compute names + id: names + run: | + set -euo pipefail + VERSION="${VERSION#v}" + if ! [[ "$VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then + echo "::error::Invalid version '$VERSION' — must match X.Y.Z" + exit 1 + fi + REL_BRANCH="${{ inputs.release_branch }}" + [ -z "$REL_BRANCH" ] && REL_BRANCH="release/v${VERSION}" + TAG_NAME="${{ inputs.tag_name }}" + [ -z "$TAG_NAME" ] && TAG_NAME="v${VERSION}" + echo "version=$VERSION" >> "$GITHUB_OUTPUT" + echo "release_branch=$REL_BRANCH" >> "$GITHUB_OUTPUT" + echo "tag_name=$TAG_NAME" >> "$GITHUB_OUTPUT" + echo "Computed: version=$VERSION, release_branch=$REL_BRANCH, tag_name=$TAG_NAME" + + - name: Pre-flight remote checks + env: + REL_BRANCH: ${{ steps.names.outputs.release_branch }} + TAG_NAME: ${{ steps.names.outputs.tag_name }} + run: | + set -euo pipefail + REPO_URL="https://github.com/${{ github.repository }}.git" + + # Source branch must exist. + if ! git ls-remote --heads --exit-code "$REPO_URL" "$SOURCE_BRANCH" >/dev/null; then + echo "::error::Source branch '$SOURCE_BRANCH' does not exist on origin." + exit 1 + fi + + # Release branch must NOT exist. + if git ls-remote --heads --exit-code "$REPO_URL" "$REL_BRANCH" >/dev/null 2>&1; then + echo "::error::Release branch '$REL_BRANCH' already exists on origin. Refusing to overwrite." + exit 1 + fi + + # Tag must NOT exist. + if git ls-remote --tags --exit-code "$REPO_URL" "$TAG_NAME" >/dev/null 2>&1; then + echo "::error::Tag '$TAG_NAME' already exists on origin. Refusing to overwrite." + exit 1 + fi + + - name: Checkout source branch + uses: actions/checkout@v4 + with: + ref: ${{ inputs.source_branch }} + fetch-depth: 0 + + - name: Verify version files at source HEAD + env: + EXPECTED: ${{ steps.names.outputs.version }} + run: | + set -euo pipefail + PYPROJECT_VERSION=$(python3 -c "import tomllib,sys; print(tomllib.load(open('pyproject.toml','rb'))['project']['version'])") + if [ "$PYPROJECT_VERSION" != "$EXPECTED" ]; then + echo "::error::pyproject.toml version is '$PYPROJECT_VERSION' but expected '$EXPECTED'." + exit 1 + fi + MODULE_VERSION=$(python3 -c "v={}; exec(open('comfyui_version.py').read(),v); print(v['__version__'])") + if [ "$MODULE_VERSION" != "$EXPECTED" ]; then + echo "::error::comfyui_version.py __version__ is '$MODULE_VERSION' but expected '$EXPECTED'." + exit 1 + fi + echo "Version files OK: $EXPECTED" + + - name: Identify previous tag and log commit range + id: range + env: + REL_BRANCH: ${{ steps.names.outputs.release_branch }} + run: | + set -euo pipefail + # Find latest tag reachable from this commit (the basis backport + # was branched from). Falls back to "(none)" gracefully. + PREV_TAG=$(git describe --tags --abbrev=0 2>/dev/null || echo "") + SOURCE_SHA=$(git rev-parse HEAD) + echo "prev_tag=$PREV_TAG" >> "$GITHUB_OUTPUT" + echo "source_sha=$SOURCE_SHA" >> "$GITHUB_OUTPUT" + { + echo "## Cut Release plan" + echo "" + echo "| Field | Value |" + echo "| --- | --- |" + echo "| Source branch | \`$SOURCE_BRANCH\` @ \`${SOURCE_SHA:0:12}\` |" + echo "| Target release branch | \`$REL_BRANCH\` |" + echo "| Tag | \`${{ steps.names.outputs.tag_name }}\` |" + echo "| Previous reachable tag | \`${PREV_TAG:-none}\` |" + echo "| Dry run | \`$DRY_RUN\` |" + echo "" + echo "### Commits to be tagged" + echo "" + if [ -n "$PREV_TAG" ]; then + echo "Range: \`${PREV_TAG}..${SOURCE_SHA}\`" + echo "" + echo '```' + git log --oneline --no-decorate "${PREV_TAG}..${SOURCE_SHA}" + echo '```' + else + echo "(no prior tag found — listing last 30 commits on source)" + echo "" + echo '```' + git log --oneline --no-decorate -30 "$SOURCE_SHA" + echo '```' + fi + } >> "$GITHUB_STEP_SUMMARY" + + - name: Configure git identity + if: ${{ !inputs.dry_run }} + run: | + git config --global user.name "github-actions[bot]" + git config --global user.email "github-actions[bot]@users.noreply.github.com" + + - name: Push release branch + if: ${{ !inputs.dry_run }} + env: + GH_TOKEN: ${{ secrets.RELEASE_BOT_TOKEN }} + REL_BRANCH: ${{ steps.names.outputs.release_branch }} + SOURCE_SHA: ${{ steps.range.outputs.source_sha }} + run: | + set -euo pipefail + if [ -z "${GH_TOKEN:-}" ]; then + echo "::error::secrets.RELEASE_BOT_TOKEN is not set." + exit 1 + fi + AUTH_URL="https://x-access-token:${GH_TOKEN}@github.com/${{ github.repository }}.git" + git push "$AUTH_URL" "${SOURCE_SHA}:refs/heads/${REL_BRANCH}" + + - name: Create and push annotated tag + if: ${{ !inputs.dry_run }} + env: + GH_TOKEN: ${{ secrets.RELEASE_BOT_TOKEN }} + TAG_NAME: ${{ steps.names.outputs.tag_name }} + VERSION: ${{ steps.names.outputs.version }} + SOURCE_SHA: ${{ steps.range.outputs.source_sha }} + run: | + set -euo pipefail + AUTH_URL="https://x-access-token:${GH_TOKEN}@github.com/${{ github.repository }}.git" + git tag -a "$TAG_NAME" "$SOURCE_SHA" -m "ComfyUI v${VERSION}" + git push "$AUTH_URL" "refs/tags/${TAG_NAME}" + + - name: Delete source branch (success only) + if: ${{ success() && !inputs.dry_run }} + env: + GH_TOKEN: ${{ secrets.RELEASE_BOT_TOKEN }} + run: | + set -euo pipefail + AUTH_URL="https://x-access-token:${GH_TOKEN}@github.com/${{ github.repository }}.git" + git push "$AUTH_URL" --delete "refs/heads/${SOURCE_BRANCH}" + + - name: Final summary + if: ${{ always() }} + env: + REL_BRANCH: ${{ steps.names.outputs.release_branch }} + TAG_NAME: ${{ steps.names.outputs.tag_name }} + run: | + set -euo pipefail + REPO="${{ github.repository }}" + { + echo "" + echo "### Result" + if [ "$DRY_RUN" = "true" ]; then + echo "🔍 **Dry run** — no branch, tag, or deletion was performed." + else + echo "✅ Promoted \`$SOURCE_BRANCH\` to \`$REL_BRANCH\` and tagged \`$TAG_NAME\`." + echo "" + echo "- Branch: " + echo "- Tag: " + echo "" + echo "Next: run [release-stable-all.yml](https://github.com/${REPO}/actions/workflows/release-stable-all.yml) with \`git_tag=${TAG_NAME}\`." + fi + } >> "$GITHUB_STEP_SUMMARY" From a8e11936f350f859774dc897bcfeb717d4d74d58 Mon Sep 17 00:00:00 2001 From: Jedrzej Kosinski Date: Fri, 8 May 2026 14:30:36 -0700 Subject: [PATCH 2/4] 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 --- .github/workflows/cut-release.yml | 116 ++++++++++++++++++------------ 1 file changed, 71 insertions(+), 45 deletions(-) diff --git a/.github/workflows/cut-release.yml b/.github/workflows/cut-release.yml index da70c1ed2..157c0ca02 100644 --- a/.github/workflows/cut-release.yml +++ b/.github/workflows/cut-release.yml @@ -44,27 +44,53 @@ jobs: # write so subsequent automation hooks have it if needed. contents: write env: - VERSION: ${{ inputs.version }} - SOURCE_BRANCH: ${{ inputs.source_branch }} - DRY_RUN: ${{ inputs.dry_run }} + # All workflow inputs are pulled in via env vars instead of being + # interpolated directly into bash, to avoid command injection. + INPUT_VERSION: ${{ inputs.version }} + INPUT_SOURCE_BRANCH: ${{ inputs.source_branch }} + INPUT_RELEASE_BRANCH: ${{ inputs.release_branch }} + INPUT_TAG_NAME: ${{ inputs.tag_name }} + INPUT_DRY_RUN: ${{ inputs.dry_run }} + REPO_FULL: ${{ github.repository }} steps: - name: Compute names id: names run: | set -euo pipefail - VERSION="${VERSION#v}" + VERSION="${INPUT_VERSION#v}" if ! [[ "$VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then echo "::error::Invalid version '$VERSION' — must match X.Y.Z" exit 1 fi - REL_BRANCH="${{ inputs.release_branch }}" + + REL_BRANCH="$INPUT_RELEASE_BRANCH" [ -z "$REL_BRANCH" ] && REL_BRANCH="release/v${VERSION}" - TAG_NAME="${{ inputs.tag_name }}" + + TAG_NAME="$INPUT_TAG_NAME" [ -z "$TAG_NAME" ] && TAG_NAME="v${VERSION}" - echo "version=$VERSION" >> "$GITHUB_OUTPUT" - echo "release_branch=$REL_BRANCH" >> "$GITHUB_OUTPUT" - echo "tag_name=$TAG_NAME" >> "$GITHUB_OUTPUT" + + # Validate the overrides too — they're effectively user-controlled + # ref names and end up in shell + git invocations. + REF_RE='^[A-Za-z0-9._/-]+$' + if ! [[ "$REL_BRANCH" =~ $REF_RE ]]; then + echo "::error::Invalid release_branch '$REL_BRANCH' — only [A-Za-z0-9._/-] allowed." + exit 1 + fi + if ! [[ "$TAG_NAME" =~ $REF_RE ]]; then + echo "::error::Invalid tag_name '$TAG_NAME' — only [A-Za-z0-9._/-] allowed." + exit 1 + fi + if ! [[ "$INPUT_SOURCE_BRANCH" =~ $REF_RE ]]; then + echo "::error::Invalid source_branch '$INPUT_SOURCE_BRANCH' — only [A-Za-z0-9._/-] allowed." + exit 1 + fi + + { + echo "version=$VERSION" + echo "release_branch=$REL_BRANCH" + echo "tag_name=$TAG_NAME" + } >> "$GITHUB_OUTPUT" echo "Computed: version=$VERSION, release_branch=$REL_BRANCH, tag_name=$TAG_NAME" - name: Pre-flight remote checks @@ -73,11 +99,11 @@ jobs: TAG_NAME: ${{ steps.names.outputs.tag_name }} run: | set -euo pipefail - REPO_URL="https://github.com/${{ github.repository }}.git" + REPO_URL="https://github.com/${REPO_FULL}.git" # Source branch must exist. - if ! git ls-remote --heads --exit-code "$REPO_URL" "$SOURCE_BRANCH" >/dev/null; then - echo "::error::Source branch '$SOURCE_BRANCH' does not exist on origin." + 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 @@ -104,7 +130,7 @@ jobs: EXPECTED: ${{ steps.names.outputs.version }} run: | set -euo pipefail - PYPROJECT_VERSION=$(python3 -c "import tomllib,sys; print(tomllib.load(open('pyproject.toml','rb'))['project']['version'])") + PYPROJECT_VERSION=$(python3 -c "import tomllib; print(tomllib.load(open('pyproject.toml','rb'))['project']['version'])") if [ "$PYPROJECT_VERSION" != "$EXPECTED" ]; then echo "::error::pyproject.toml version is '$PYPROJECT_VERSION' but expected '$EXPECTED'." exit 1 @@ -120,24 +146,27 @@ jobs: id: range env: REL_BRANCH: ${{ steps.names.outputs.release_branch }} + TAG_NAME: ${{ steps.names.outputs.tag_name }} run: | set -euo pipefail # Find latest tag reachable from this commit (the basis backport - # was branched from). Falls back to "(none)" gracefully. + # was branched from). Falls back to "" gracefully. PREV_TAG=$(git describe --tags --abbrev=0 2>/dev/null || echo "") SOURCE_SHA=$(git rev-parse HEAD) - echo "prev_tag=$PREV_TAG" >> "$GITHUB_OUTPUT" - echo "source_sha=$SOURCE_SHA" >> "$GITHUB_OUTPUT" + { + echo "prev_tag=$PREV_TAG" + echo "source_sha=$SOURCE_SHA" + } >> "$GITHUB_OUTPUT" { echo "## Cut Release plan" echo "" echo "| Field | Value |" echo "| --- | --- |" - echo "| Source branch | \`$SOURCE_BRANCH\` @ \`${SOURCE_SHA:0:12}\` |" + echo "| Source branch | \`$INPUT_SOURCE_BRANCH\` @ \`${SOURCE_SHA:0:12}\` |" echo "| Target release branch | \`$REL_BRANCH\` |" - echo "| Tag | \`${{ steps.names.outputs.tag_name }}\` |" + echo "| Tag | \`$TAG_NAME\` |" echo "| Previous reachable tag | \`${PREV_TAG:-none}\` |" - echo "| Dry run | \`$DRY_RUN\` |" + echo "| Dry run | \`$INPUT_DRY_RUN\` |" echo "" echo "### Commits to be tagged" echo "" @@ -157,16 +186,19 @@ jobs: } >> "$GITHUB_STEP_SUMMARY" - name: Configure git identity - if: ${{ !inputs.dry_run }} + if: inputs.dry_run != true && inputs.dry_run != 'true' run: | + set -euo pipefail git config --global user.name "github-actions[bot]" git config --global user.email "github-actions[bot]@users.noreply.github.com" - - name: Push release branch - if: ${{ !inputs.dry_run }} + - name: Push release branch and tag (atomic) + if: inputs.dry_run != true && inputs.dry_run != 'true' env: GH_TOKEN: ${{ secrets.RELEASE_BOT_TOKEN }} REL_BRANCH: ${{ steps.names.outputs.release_branch }} + TAG_NAME: ${{ steps.names.outputs.tag_name }} + VERSION: ${{ steps.names.outputs.version }} SOURCE_SHA: ${{ steps.range.outputs.source_sha }} run: | set -euo pipefail @@ -174,50 +206,44 @@ jobs: echo "::error::secrets.RELEASE_BOT_TOKEN is not set." exit 1 fi - AUTH_URL="https://x-access-token:${GH_TOKEN}@github.com/${{ github.repository }}.git" - git push "$AUTH_URL" "${SOURCE_SHA}:refs/heads/${REL_BRANCH}" - - name: Create and push annotated tag - if: ${{ !inputs.dry_run }} - env: - GH_TOKEN: ${{ secrets.RELEASE_BOT_TOKEN }} - TAG_NAME: ${{ steps.names.outputs.tag_name }} - VERSION: ${{ steps.names.outputs.version }} - SOURCE_SHA: ${{ steps.range.outputs.source_sha }} - run: | - set -euo pipefail - AUTH_URL="https://x-access-token:${GH_TOKEN}@github.com/${{ github.repository }}.git" + # 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 "$TAG_NAME" "$SOURCE_SHA" -m "ComfyUI v${VERSION}" - git push "$AUTH_URL" "refs/tags/${TAG_NAME}" + + AUTH_URL="https://x-access-token:${GH_TOKEN}@github.com/${REPO_FULL}.git" + git push --atomic "$AUTH_URL" \ + "${SOURCE_SHA}:refs/heads/${REL_BRANCH}" \ + "refs/tags/${TAG_NAME}" - name: Delete source branch (success only) - if: ${{ success() && !inputs.dry_run }} + if: success() && inputs.dry_run != true && inputs.dry_run != 'true' env: GH_TOKEN: ${{ secrets.RELEASE_BOT_TOKEN }} run: | set -euo pipefail - AUTH_URL="https://x-access-token:${GH_TOKEN}@github.com/${{ github.repository }}.git" - git push "$AUTH_URL" --delete "refs/heads/${SOURCE_BRANCH}" + AUTH_URL="https://x-access-token:${GH_TOKEN}@github.com/${REPO_FULL}.git" + git push "$AUTH_URL" --delete "refs/heads/${INPUT_SOURCE_BRANCH}" - name: Final summary - if: ${{ always() }} + if: always() env: REL_BRANCH: ${{ steps.names.outputs.release_branch }} TAG_NAME: ${{ steps.names.outputs.tag_name }} run: | set -euo pipefail - REPO="${{ github.repository }}" { echo "" echo "### Result" - if [ "$DRY_RUN" = "true" ]; then + if [ "$INPUT_DRY_RUN" = "true" ]; then echo "🔍 **Dry run** — no branch, tag, or deletion was performed." else - echo "✅ Promoted \`$SOURCE_BRANCH\` to \`$REL_BRANCH\` and tagged \`$TAG_NAME\`." + echo "✅ Promoted \`$INPUT_SOURCE_BRANCH\` to \`$REL_BRANCH\` and tagged \`$TAG_NAME\`." echo "" - echo "- Branch: " - echo "- Tag: " + echo "- Branch: " + echo "- Tag: " echo "" - echo "Next: run [release-stable-all.yml](https://github.com/${REPO}/actions/workflows/release-stable-all.yml) with \`git_tag=${TAG_NAME}\`." + echo "Next: run [release-stable-all.yml](https://github.com/${REPO_FULL}/actions/workflows/release-stable-all.yml) with \`git_tag=${TAG_NAME}\`." fi } >> "$GITHUB_STEP_SUMMARY" From 3566e6b6a6185df855035a50a4424f1295b9bb98 Mon Sep 17 00:00:00 2001 From: Jedrzej Kosinski Date: Fri, 8 May 2026 15:15:53 -0700 Subject: [PATCH 3/4] 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 --- .github/workflows/cut-release.yml | 24 ++++++++++-------------- 1 file changed, 10 insertions(+), 14 deletions(-) diff --git a/.github/workflows/cut-release.yml b/.github/workflows/cut-release.yml index 157c0ca02..878cc9d33 100644 --- a/.github/workflows/cut-release.yml +++ b/.github/workflows/cut-release.yml @@ -2,11 +2,14 @@ name: Cut Release # Promote a prepared "candidate" branch (cherry-picks + version bump # already committed) into a real release: create the canonical -# `release/v` branch, create the `v` annotated tag, and -# (on success) delete the candidate branch. +# `release/v` branch and the `v` annotated tag at the +# candidate's HEAD, atomically. # # After this runs, kick off `release-stable-all.yml` manually with the new # tag to build portable artifacts — same as previous backports. +# +# The candidate branch is left in place; clean it up manually once you've +# verified the release. on: workflow_dispatch: @@ -30,7 +33,7 @@ on: type: string default: '' dry_run: - description: 'Validate only — do not push branch, tag, or delete source' + description: 'Validate only — do not push branch or tag' required: false type: boolean default: false @@ -217,15 +220,6 @@ jobs: "${SOURCE_SHA}:refs/heads/${REL_BRANCH}" \ "refs/tags/${TAG_NAME}" - - name: Delete source branch (success only) - if: success() && inputs.dry_run != true && inputs.dry_run != 'true' - env: - GH_TOKEN: ${{ secrets.RELEASE_BOT_TOKEN }} - run: | - set -euo pipefail - AUTH_URL="https://x-access-token:${GH_TOKEN}@github.com/${REPO_FULL}.git" - git push "$AUTH_URL" --delete "refs/heads/${INPUT_SOURCE_BRANCH}" - - name: Final summary if: always() env: @@ -237,13 +231,15 @@ jobs: echo "" echo "### Result" if [ "$INPUT_DRY_RUN" = "true" ]; then - echo "🔍 **Dry run** — no branch, tag, or deletion was performed." + echo "🔍 **Dry run** — no branch or tag was created." else - echo "✅ Promoted \`$INPUT_SOURCE_BRANCH\` to \`$REL_BRANCH\` and tagged \`$TAG_NAME\`." + echo "✅ Created \`$REL_BRANCH\` and tagged \`$TAG_NAME\` from \`$INPUT_SOURCE_BRANCH\`." echo "" echo "- Branch: " echo "- Tag: " echo "" echo "Next: run [release-stable-all.yml](https://github.com/${REPO_FULL}/actions/workflows/release-stable-all.yml) with \`git_tag=${TAG_NAME}\`." + echo "" + echo "The candidate branch \`$INPUT_SOURCE_BRANCH\` was left in place; delete it manually once you've verified the release." fi } >> "$GITHUB_STEP_SUMMARY" From a0a65f51bc8ae8c459ee30f25dd3bdec9d103ef0 Mon Sep 17 00:00:00 2001 From: Jedrzej Kosinski Date: Fri, 8 May 2026 15:18:09 -0700 Subject: [PATCH 4/4] 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 --- .github/workflows/cut-release.yml | 27 +++++++++++++++++++++++++-- 1 file changed, 25 insertions(+), 2 deletions(-) diff --git a/.github/workflows/cut-release.yml b/.github/workflows/cut-release.yml index 878cc9d33..5635de49e 100644 --- a/.github/workflows/cut-release.yml +++ b/.github/workflows/cut-release.yml @@ -127,18 +127,38 @@ jobs: with: ref: ${{ inputs.source_branch }} 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: Verify version files at source HEAD env: EXPECTED: ${{ steps.names.outputs.version }} run: | set -euo pipefail + # pyproject.toml is parsed with tomllib (safe — pure data, no code). PYPROJECT_VERSION=$(python3 -c "import tomllib; print(tomllib.load(open('pyproject.toml','rb'))['project']['version'])") if [ "$PYPROJECT_VERSION" != "$EXPECTED" ]; then echo "::error::pyproject.toml version is '$PYPROJECT_VERSION' but expected '$EXPECTED'." exit 1 fi - MODULE_VERSION=$(python3 -c "v={}; exec(open('comfyui_version.py').read(),v); print(v['__version__'])") + # comfyui_version.py contains Python — never `exec()` it. A + # malicious candidate branch 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. + MODULE_VERSION=$(python3 - <<'PY' + import ast, pathlib, sys + tree = ast.parse(pathlib.Path("comfyui_version.py").read_text(encoding="utf-8")) + 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") + PY + ) if [ "$MODULE_VERSION" != "$EXPECTED" ]; then echo "::error::comfyui_version.py __version__ is '$MODULE_VERSION' but expected '$EXPECTED'." exit 1 @@ -225,12 +245,15 @@ jobs: env: REL_BRANCH: ${{ steps.names.outputs.release_branch }} TAG_NAME: ${{ steps.names.outputs.tag_name }} + JOB_STATUS: ${{ job.status }} run: | set -euo pipefail { echo "" echo "### Result" - if [ "$INPUT_DRY_RUN" = "true" ]; then + 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 \`$REL_BRANCH\` and tagged \`$TAG_NAME\` from \`$INPUT_SOURCE_BRANCH\`."