diff --git a/.github/workflows/release-draft-create.yaml b/.github/workflows/release-draft-create.yaml index 2c562ac4afe..526548ec494 100644 --- a/.github/workflows/release-draft-create.yaml +++ b/.github/workflows/release-draft-create.yaml @@ -106,6 +106,16 @@ jobs: ref: ${{ github.event.pull_request.merge_commit_sha }} secrets: inherit + # GITHUB_TOKEN releases emit no events, so the reconciler's own trigger never fires. + enforce_latest: + name: Enforce Latest Release + needs: draft_release + permissions: + contents: write + uses: ./.github/workflows/release-enforce-latest.yaml + secrets: + PR_GH_TOKEN: ${{ secrets.PR_GH_TOKEN }} + comment_release_summary: name: Comment Release Summary needs: @@ -125,6 +135,7 @@ jobs: fetch-depth: 2 - name: Post release summary comment + continue-on-error: true uses: ./.github/actions/comment-release-links with: issue-number: ${{ github.event.pull_request.number }} diff --git a/.github/workflows/release-enforce-latest.yaml b/.github/workflows/release-enforce-latest.yaml index 63fa821cd49..aa65b0f97df 100644 --- a/.github/workflows/release-enforce-latest.yaml +++ b/.github/workflows/release-enforce-latest.yaml @@ -1,108 +1,38 @@ name: Enforce Greatest-Semver-Wins Latest Release -# Keeps GitHub's "latest release" flag pinned to the highest stable semver -# tag, so `--front-end-version latest` can never resolve to an older release -# than what's already shipped. -# -# Un-publishing the current latest release is treated as an implicit -# rollback: latest reassigns to the next-highest published stable release. -# This is intentional. +# Un-publishing the latest release rolls Latest back on purpose. on: release: types: [published, edited, unpublished, deleted] workflow_dispatch: {} + workflow_call: + secrets: + PR_GH_TOKEN: + required: true schedule: - - cron: '0 4 * * *' # daily backstop in case a release webhook is dropped + - cron: '0 4 * * *' -# Serialize runs so two near-simultaneous release edits can't race each -# other's `gh release edit --latest` calls. Do NOT cancel-in-progress: a -# queued run still needs to re-check state after the run ahead of it finishes. concurrency: group: enforce-latest-release - cancel-in-progress: false jobs: enforce-latest: runs-on: ubuntu-latest - # Defense in depth: GH_TOKEN edits don't retrigger `release` events, but - # this guards against a future PAT/App-token swap that would. - if: github.triggering_actor != 'github-actions[bot]' + # On workflow_call the actor is whoever merged the release PR, often a bot. + if: github.event_name != 'release' || github.triggering_actor != 'github-actions[bot]' permissions: - contents: write # required: gh release edit / releases API write access + contents: write steps: - - name: Reconcile "latest" flag to the highest stable semver release + # workflow_call inherits the caller's refs/pull/N/merge, gone once it closes. + - uses: actions/checkout@v7 + with: + ref: main + sparse-checkout: scripts/cicd/reconcile-latest-release.sh + sparse-checkout-cone-mode: false + + - name: Reconcile Latest to the highest stable semver release env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + GH_TOKEN: ${{ secrets.PR_GH_TOKEN }} REPO: ${{ github.repository }} - run: | - set -euo pipefail - - echo "Fetching all releases for $REPO..." - RELEASES_JSON=$(gh release list --repo "$REPO" --limit 1000 \ - --json tagName,isDraft,isPrerelease,isLatest) - - COUNT=$(echo "$RELEASES_JSON" | jq 'length') - if [ "$COUNT" -ge 1000 ]; then - echo "::warning::Release count hit --limit 1000 cap. Results may be truncated." - fi - - STABLE_TAGS=$(echo "$RELEASES_JSON" | jq -r \ - '.[] | select(.isDraft == false and .isPrerelease == false) | .tagName') - - if [ -z "$STABLE_TAGS" ]; then - echo "::warning::No stable (non-draft, non-prerelease) releases found. '--front-end-version latest' will 404 until one exists." - exit 0 - fi - - # Defensive filter: only strict [v]X.Y.Z tags -- skips "-rc"/"-beta" - # tags mis-flagged as stable, and other monorepo tags (design-system, - # desktop-ui, npm-types, etc.) that aren't ours. - CANDIDATES=() - while IFS= read -r tag; do - if [[ "$tag" =~ ^v?[0-9]+\.[0-9]+\.[0-9]+$ ]]; then - CANDIDATES+=("$tag") - fi - done <<< "$STABLE_TAGS" - - if [ "${#CANDIDATES[@]}" -eq 0 ]; then - echo "::warning::No semver-shaped stable tags found among releases. '--front-end-version latest' will 404 until one exists." - exit 0 - fi - - # greatest-semver-wins: sort numerically (v-prefix stripped), not by - # tag name or publish date. - TRUE_LATEST_VER=$(printf '%s\n' "${CANDIDATES[@]}" | sed 's/^v//' | sort -V | tail -1) - - TRUE_LATEST_TAG="" - for tag in "${CANDIDATES[@]}"; do - if [[ "${tag#v}" == "$TRUE_LATEST_VER" ]]; then - TRUE_LATEST_TAG="$tag" - break - fi - done - - [ -n "$TRUE_LATEST_TAG" ] || { echo "::error::BUG: could not resolve TRUE_LATEST_TAG"; exit 1; } - - echo "Highest stable semver release: $TRUE_LATEST_TAG" - - CURRENT_LATEST_TAG=$(echo "$RELEASES_JSON" | jq -r \ - '[.[] | select(.isLatest == true)][0].tagName // empty') - echo "GitHub-flagged 'latest' release: ${CURRENT_LATEST_TAG:-}" - - if [ "$CURRENT_LATEST_TAG" == "$TRUE_LATEST_TAG" ]; then - echo "OK: 'latest' already matches the highest stable semver release. No action needed." - exit 0 - fi - - echo "::warning::'latest' is currently '${CURRENT_LATEST_TAG:-}' but the highest stable semver release is '$TRUE_LATEST_TAG'. Reassigning 'latest' to '$TRUE_LATEST_TAG'." - - gh release edit "$TRUE_LATEST_TAG" --repo "$REPO" --latest - - { - echo "## Latest-release auto-correction" - echo "" - echo "- Previously flagged as \`latest\`: \`${CURRENT_LATEST_TAG:-}\`" - echo "- Highest stable semver release: \`$TRUE_LATEST_TAG\`" - echo "- Action taken: re-assigned \`latest\` to \`$TRUE_LATEST_TAG\` via \`gh release edit --latest\`" - } >> "$GITHUB_STEP_SUMMARY" + run: ./scripts/cicd/reconcile-latest-release.sh diff --git a/.github/workflows/release-weekly-comfyui.yaml b/.github/workflows/release-weekly-comfyui.yaml index 5aad8b0b72e..522bb5fcf04 100644 --- a/.github/workflows/release-weekly-comfyui.yaml +++ b/.github/workflows/release-weekly-comfyui.yaml @@ -179,6 +179,8 @@ jobs: publish-pypi: needs: [resolve-version, trigger-release-if-needed] + outputs: + pypi_confirmed: ${{ steps.pypi-wait.outputs.confirmed }} if: > always() && needs.resolve-version.result == 'success' && @@ -186,30 +188,25 @@ jobs: needs.trigger-release-if-needed.result == 'skipped') runs-on: ubuntu-latest steps: - - name: Wait for release PR to be created and merged + - name: Check out release scripts + uses: actions/checkout@v7 + with: + sparse-checkout: scripts/cicd + sparse-checkout-cone-mode: false + + # The tag checkout below replaces the workspace, and the tag predates these. + - name: Stage release scripts + run: cp -r scripts/cicd "$RUNNER_TEMP/cicd" + + - name: Wait for the release tag if: needs.trigger-release-if-needed.result == 'success' env: GH_TOKEN: ${{ secrets.PR_GH_TOKEN }} - run: | - set -euo pipefail - - TARGET_VERSION="${{ needs.resolve-version.outputs.target_version }}" - TARGET_BRANCH="${{ needs.resolve-version.outputs.target_branch }}" - echo "Waiting for version bump PR for v${TARGET_VERSION} on ${TARGET_BRANCH} to be merged..." - - # Poll for up to 30 minutes (a human or automation needs to merge the version bump PR) - for i in $(seq 1 60); do - # Check if the tag exists (release-draft-create creates a tag on merge) - if gh api "repos/Comfy-Org/ComfyUI_frontend/git/ref/tags/v${TARGET_VERSION}" --silent 2>/dev/null; then - echo "✅ Tag v${TARGET_VERSION} found — release PR has been merged" - exit 0 - fi - echo "Attempt $i/60: Tag v${TARGET_VERSION} not found yet, waiting 30s..." - sleep 30 - done - - echo "❌ Timed out waiting for tag v${TARGET_VERSION}" - exit 1 + REPO: ${{ github.repository }} + TARGET_VERSION: ${{ needs.resolve-version.outputs.target_version }} + TARGET_BRANCH: ${{ needs.resolve-version.outputs.target_branch }} + RUN_ID: ${{ github.run_id }} + run: '$RUNNER_TEMP/cicd/wait-for-release-tag.sh' - name: Checkout code at target version uses: actions/checkout@v7 @@ -262,27 +259,12 @@ jobs: password: ${{ secrets.PYPI_TOKEN }} packages-dir: comfyui_frontend_package/dist - - name: Wait for PyPI propagation - run: | - set -euo pipefail - - TARGET_VERSION="${{ needs.resolve-version.outputs.target_version }}" - PACKAGE="comfyui-frontend-package" - echo "Waiting for ${PACKAGE}==${TARGET_VERSION} to be available on PyPI..." - - # Wait up to 15 minutes (polling every 30 seconds) - for i in $(seq 1 30); do - HTTP_CODE=$(curl -s -o /dev/null -w "%{http_code}" "https://pypi.org/pypi/${PACKAGE}/${TARGET_VERSION}/json") - if [ "$HTTP_CODE" = "200" ]; then - echo "✅ ${PACKAGE}==${TARGET_VERSION} is available on PyPI" - exit 0 - fi - echo "Attempt $i/30: PyPI returned HTTP ${HTTP_CODE}, waiting 30s..." - sleep 30 - done - - echo "❌ Timed out waiting for ${PACKAGE}==${TARGET_VERSION} on PyPI" - exit 1 + - name: Wait for the version to be installable + id: pypi-wait + env: + PACKAGE: comfyui-frontend-package + TARGET_VERSION: ${{ needs.resolve-version.outputs.target_version }} + run: '$RUNNER_TEMP/cicd/wait-for-pypi-version.sh' - name: Summary run: | @@ -290,7 +272,7 @@ jobs: echo "" >> $GITHUB_STEP_SUMMARY echo "- Package: comfyui-frontend-package" >> $GITHUB_STEP_SUMMARY echo "- Version: ${{ needs.resolve-version.outputs.target_version }}" >> $GITHUB_STEP_SUMMARY - echo "- Status: ✅ Published and confirmed available" >> $GITHUB_STEP_SUMMARY + echo "- Installable from PyPI: ${{ steps.pypi-wait.outputs.confirmed }}" >> $GITHUB_STEP_SUMMARY create-comfyui-pr: needs: @@ -372,7 +354,11 @@ jobs: EOF ) - PYPI_NOTE="✅ **PyPI package confirmed available** — \`comfyui-frontend-package==${{ needs.resolve-version.outputs.target_version }}\` has been published and verified." + if [ "${{ needs.publish-pypi.outputs.pypi_confirmed }}" = "true" ]; then + PYPI_NOTE="✅ **PyPI package confirmed available** — \`comfyui-frontend-package==${{ needs.resolve-version.outputs.target_version }}\` has been published and verified." + else + PYPI_NOTE="⚠️ **PyPI availability unconfirmed** — \`comfyui-frontend-package==${{ needs.resolve-version.outputs.target_version }}\` was not installable from PyPI before the wait expired. CI here may fail until the index catches up." + fi BODY=$''"${PYPI_NOTE}"$'\n\n'"${BODY}" # Save to file for later use @@ -459,3 +445,21 @@ jobs: echo "" >> $GITHUB_STEP_SUMMARY echo "### PR Body:" >> $GITHUB_STEP_SUMMARY cat pr-body.txt >> $GITHUB_STEP_SUMMARY + + release-done: + name: Verify release reached users + needs: [resolve-version, publish-pypi] + if: always() && needs.publish-pypi.result == 'success' + runs-on: ubuntu-latest + steps: + - name: Checkout ComfyUI_frontend + uses: actions/checkout@v7 + with: + fetch-depth: 0 + + - name: Assert the release reached users + env: + PACKAGE: comfyui-frontend-package + TARGET_VERSION: ${{ needs.resolve-version.outputs.target_version }} + TARGET_BRANCH: ${{ needs.resolve-version.outputs.target_branch }} + run: ./scripts/cicd/assert-release-shipped.sh diff --git a/scripts/cicd/assert-release-shipped.sh b/scripts/cicd/assert-release-shipped.sh new file mode 100755 index 00000000000..981f7a7f917 --- /dev/null +++ b/scripts/cicd/assert-release-shipped.sh @@ -0,0 +1,80 @@ +#!/usr/bin/env bash +set -euo pipefail + +: "${PACKAGE:?PACKAGE is required}" +: "${TARGET_VERSION:?TARGET_VERSION is required}" +: "${TARGET_BRANCH:?TARGET_BRANCH is required}" + +readonly TAG="v${TARGET_VERSION}" +failed=0 + +summary() { + [[ -n "${GITHUB_STEP_SUMMARY:-}" ]] && printf '%s\n' "$@" >>"$GITHUB_STEP_SUMMARY" + return 0 +} + +fail() { + echo "::error title=$1::$2" + summary "- FAIL: $2" + failed=1 +} + +assert_nothing_stranded_past_the_tag() { + if ! git fetch --quiet --tags origin "$TARGET_BRANCH"; then + fail "Fetch failed" "Could not fetch ${TARGET_BRANCH} from origin." + return + fi + if ! git rev-parse -q --verify "refs/tags/${TAG}^{commit}" >/dev/null; then + fail "Release tag missing" "Tag ${TAG} not found." + return + fi + + local stranded + stranded=$(git rev-list "${TAG}..FETCH_HEAD" --count) || stranded="" + if [[ ! "$stranded" =~ ^[0-9]+$ ]]; then + fail "Stranded count unavailable" "git rev-list failed or returned non-numeric output." + elif ((stranded != 0)); then + fail "Commits stranded past the release tag" \ + "${stranded} commit(s) on ${TARGET_BRANCH} are newer than ${TAG}; the published release does not contain them." + else + summary "- OK: no commits stranded past \`${TAG}\`" + fi +} + +assert_version_on_pypi() { + local http_code + http_code=$(curl -s --connect-timeout 10 --max-time 30 --retry 3 --retry-all-errors -o /dev/null -w '%{http_code}' \ + "https://pypi.org/pypi/${PACKAGE}/${TARGET_VERSION}/json") || http_code="000" + if [[ "$http_code" == "200" ]]; then + summary "- OK: PyPI has \`${PACKAGE}==${TARGET_VERSION}\`" + else + fail "Target version missing on PyPI" "${PACKAGE}==${TARGET_VERSION} returned HTTP ${http_code}." + fi +} + +# A core/* patch must not steal PyPI's `latest` from a higher minor. +assert_pypi_latest_only_for_main() { + local pypi_latest + pypi_latest=$(curl -sf --connect-timeout 10 --max-time 30 --retry 3 --retry-all-errors "https://pypi.org/pypi/${PACKAGE}/json" | + jq -r '.info.version // empty') || pypi_latest="" + + if [[ -z "$pypi_latest" ]]; then + fail "PyPI latest unavailable" "Failed to query the PyPI info endpoint for ${PACKAGE}." + elif [[ "$TARGET_BRANCH" == "main" && "$pypi_latest" != "$TARGET_VERSION" ]]; then + fail "PyPI latest mismatch" "PyPI latest is ${pypi_latest}, expected ${TARGET_VERSION}." + else + summary "- Info: PyPI \`latest\` = \`${pypi_latest}\` (target branch \`${TARGET_BRANCH}\`)" + fi +} + +summary "## Release-done assertion" "" "Target: \`${TAG}\` on \`${TARGET_BRANCH}\`" "" + +assert_nothing_stranded_past_the_tag +assert_version_on_pypi +assert_pypi_latest_only_for_main + +if ((failed != 0)); then + echo "release-done assertion FAILED — see annotations above." + exit 1 +fi +echo "release-done assertion passed." diff --git a/scripts/cicd/reconcile-latest-release.sh b/scripts/cicd/reconcile-latest-release.sh new file mode 100755 index 00000000000..265805d6708 --- /dev/null +++ b/scripts/cicd/reconcile-latest-release.sh @@ -0,0 +1,47 @@ +#!/usr/bin/env bash +# Whatever release GitHub flags "latest" is what `--front-end-version latest` resolves to. +set -euo pipefail + +: "${REPO:?REPO is required}" + +summary() { + [[ -n "${GITHUB_STEP_SUMMARY:-}" ]] && printf '%s\n' "$@" >>"$GITHUB_STEP_SUMMARY" + return 0 +} + +releases_json=$(gh release list --repo "$REPO" --limit 1000 \ + --json tagName,isDraft,isPrerelease,isLatest) + +if [[ "$(jq 'length' <<<"$releases_json")" -ge 1000 ]]; then + echo "::warning::Release count hit the --limit 1000 cap; results may be truncated." +fi + +# design-system, desktop-ui and npm-types cut their own tags in this repo. +readonly FRONTEND_TAG='^v[0-9]+\.[0-9]+\.[0-9]+$' + +mapfile -t stable_tags < <( + jq -r '.[] | select(.isDraft == false and .isPrerelease == false) | .tagName' \ + <<<"$releases_json" | grep -E "$FRONTEND_TAG" || true +) + +if [[ ${#stable_tags[@]} -eq 0 ]]; then + echo "::warning::No stable semver releases found; '--front-end-version latest' will 404 until one exists." + exit 0 +fi + +highest_tag=$(printf '%s\n' "${stable_tags[@]}" | sort -V | tail -1) + +current_tag=$(jq -r '[.[] | select(.isLatest)][0].tagName // empty' <<<"$releases_json") +echo "Highest stable semver: $highest_tag — GitHub's latest: ${current_tag:-}" + +if [[ "$current_tag" == "$highest_tag" ]]; then + echo "OK: latest already matches the highest stable semver release." + exit 0 +fi + +echo "::warning::latest is '${current_tag:-}' but the highest stable release is '$highest_tag'; reassigning." +gh release edit "$highest_tag" --repo "$REPO" --latest + +summary "## Latest-release auto-correction" "" \ + "- Previously flagged \`latest\`: \`${current_tag:-}\`" \ + "- Reassigned \`latest\` to: \`$highest_tag\`" diff --git a/scripts/cicd/reconcile-latest-release.test.ts b/scripts/cicd/reconcile-latest-release.test.ts new file mode 100644 index 00000000000..2a9fdc70144 --- /dev/null +++ b/scripts/cicd/reconcile-latest-release.test.ts @@ -0,0 +1,162 @@ +import { spawnSync } from 'node:child_process' +import fs from 'node:fs' +import os from 'node:os' +import path from 'node:path' +import { describe, expect, it } from 'vitest' + +const SCRIPT = path.join(import.meta.dirname, 'reconcile-latest-release.sh') + +type Release = { + tagName: string + isDraft?: boolean + isPrerelease?: boolean + isLatest?: boolean +} + +// Drives the real script with `gh` stubbed on PATH, so the semver-selection and +// reassignment logic runs for real without touching the network or a live repo. +function runReconcile( + releases: Release[], + options: { repo?: string | null } = {} +) { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'reconcile-latest-')) + const binDir = path.join(dir, 'bin') + fs.mkdirSync(binDir) + const editLog = path.join(dir, 'edits.log') + + const releasesJson = JSON.stringify( + releases.map((r) => ({ + tagName: r.tagName, + isDraft: r.isDraft ?? false, + isPrerelease: r.isPrerelease ?? false, + isLatest: r.isLatest ?? false + })) + ) + + // Minimal `gh` shim: serve the release list, record `release edit --latest`. + const ghStub = [ + '#!/usr/bin/env bash', + 'if [[ "$1" == "release" && "$2" == "list" ]]; then', + ' printf %s "$STUB_RELEASES_JSON"', + ' exit 0', + 'fi', + 'if [[ "$1" == "release" && "$2" == "edit" ]]; then', + ' printf "%s\\n" "$*" >> "$STUB_EDIT_LOG"', + ' exit 0', + 'fi', + 'echo "unexpected gh invocation: $*" >&2', + 'exit 99', + '' + ].join('\n') + fs.writeFileSync(path.join(binDir, 'gh'), ghStub, { mode: 0o755 }) + + const env: NodeJS.ProcessEnv = { + ...process.env, + PATH: `${binDir}:${process.env.PATH ?? ''}`, + STUB_RELEASES_JSON: releasesJson, + STUB_EDIT_LOG: editLog, + GITHUB_STEP_SUMMARY: '' + } + if (options.repo === null) { + delete env.REPO + } else { + env.REPO = options.repo ?? 'Comfy-Org/ComfyUI_frontend' + } + + const result = spawnSync('bash', [SCRIPT], { + cwd: dir, + encoding: 'utf8', + env + }) + + const edits = fs.existsSync(editLog) + ? fs + .readFileSync(editLog, 'utf8') + .split('\n') + .filter((line) => line.length > 0) + : [] + + fs.rmSync(dir, { recursive: true, force: true }) + return { + status: result.status, + output: `${result.stdout}${result.stderr}`, + edits + } +} + +describe('reconcile-latest-release.sh', () => { + it('reassigns latest to the highest stable semver when it lags', () => { + const { status, edits } = runReconcile([ + { tagName: 'v1.47.9', isLatest: true }, + { tagName: 'v1.48.0' } + ]) + + expect(status).toBe(0) + expect(edits).toEqual([ + 'release edit v1.48.0 --repo Comfy-Org/ComfyUI_frontend --latest' + ]) + }) + + it('is a no-op when latest already matches the highest release', () => { + const { status, output, edits } = runReconcile([ + { tagName: 'v1.47.9' }, + { tagName: 'v1.48.0', isLatest: true } + ]) + + expect(status).toBe(0) + expect(edits).toEqual([]) + expect(output).toContain('already matches') + }) + + it('orders versions numerically, not lexically (v1.47.10 > v1.47.9)', () => { + // A plain string sort would pick v1.47.9; sort -V must win here. + const { status, edits } = runReconcile([ + { tagName: 'v1.47.9', isLatest: true }, + { tagName: 'v1.47.10' } + ]) + + expect(status).toBe(0) + expect(edits).toEqual([ + 'release edit v1.47.10 --repo Comfy-Org/ComfyUI_frontend --latest' + ]) + }) + + it('ignores drafts, prereleases, and other packages’ tags', () => { + const { status, edits } = runReconcile([ + { tagName: 'v1.48.0', isLatest: true }, + { tagName: 'v2.0.0', isDraft: true }, + { tagName: 'v1.49.0-rc.1', isPrerelease: true }, + { tagName: 'design-system@3.1.0' }, + { tagName: 'desktop-ui@1.0.0' }, + { tagName: '1.99.0' }, + { tagName: 'v1.48.1' } + ]) + + expect(status).toBe(0) + // Highest *stable frontend* semver is v1.48.1, not the draft v2.0.0. + expect(edits).toEqual([ + 'release edit v1.48.1 --repo Comfy-Org/ComfyUI_frontend --latest' + ]) + }) + + it('exits cleanly with a warning when no stable semver release exists', () => { + const { status, output, edits } = runReconcile([ + { tagName: 'v2.0.0', isDraft: true }, + { tagName: 'design-system@3.1.0' } + ]) + + expect(status).toBe(0) + expect(edits).toEqual([]) + expect(output).toContain('No stable semver releases found') + }) + + it('exits non-zero with a clear error when REPO is unset', () => { + const { status, output, edits } = runReconcile([{ tagName: 'v1.48.0' }], { + repo: null + }) + + expect(status).not.toBe(0) + expect(output).toContain('REPO is required') + expect(edits).toEqual([]) + }) +}) diff --git a/scripts/cicd/wait-for-pypi-version.sh b/scripts/cicd/wait-for-pypi-version.sh new file mode 100755 index 00000000000..ca552f7d5a8 --- /dev/null +++ b/scripts/cicd/wait-for-pypi-version.sh @@ -0,0 +1,35 @@ +#!/usr/bin/env bash +# Reports `confirmed` rather than failing: a slow index must annotate the pin PR, not skip it. +set -euo pipefail + +: "${PACKAGE:?PACKAGE is required}" +: "${TARGET_VERSION:?TARGET_VERSION is required}" + +timeout_seconds=${TIMEOUT_SECONDS:-900} +poll_seconds=${POLL_SECONDS:-20} +readonly DIST_NAME="${PACKAGE//-/_}" + +installable() { + curl -sf --connect-timeout 10 --max-time 20 \ + "https://pypi.org/pypi/${PACKAGE}/${TARGET_VERSION}/json" -o /dev/null || return 1 + # pip resolves against the simple index, which lags the JSON API. + curl -sf --connect-timeout 10 --max-time 20 "https://pypi.org/simple/${PACKAGE}/" | + grep -qF "${DIST_NAME}-${TARGET_VERSION}" || return 1 +} + +echo "Waiting up to $((timeout_seconds / 60))m for ${PACKAGE}==${TARGET_VERSION}..." + +deadline=$((SECONDS + timeout_seconds)) +while ((SECONDS < deadline)); do + if installable; then + echo "${PACKAGE}==${TARGET_VERSION} is installable." + echo "confirmed=true" >>"${GITHUB_OUTPUT:-/dev/null}" + exit 0 + fi + remaining=$((deadline - SECONDS)) + ((remaining > 0)) || break + sleep "$((remaining < poll_seconds ? remaining : poll_seconds))" +done + +echo "::warning::${PACKAGE}==${TARGET_VERSION} still not installable after $((timeout_seconds / 60))m." +echo "confirmed=false" >>"${GITHUB_OUTPUT:-/dev/null}" diff --git a/scripts/cicd/wait-for-release-tag.sh b/scripts/cicd/wait-for-release-tag.sh new file mode 100755 index 00000000000..91c60a788b7 --- /dev/null +++ b/scripts/cicd/wait-for-release-tag.sh @@ -0,0 +1,51 @@ +#!/usr/bin/env bash +# Failing here skips create-comfyui-pr, so the timeout has to hand over its own recovery. +set -euo pipefail + +: "${REPO:?REPO is required}" +: "${TARGET_VERSION:?TARGET_VERSION is required}" +: "${TARGET_BRANCH:?TARGET_BRANCH is required}" +: "${RUN_ID:?RUN_ID is required}" +: "${GH_TOKEN:?GH_TOKEN is required}" + +timeout_seconds=${TIMEOUT_SECONDS:-14400} +poll_seconds=${POLL_SECONDS:-30} +readonly TAG="v${TARGET_VERSION}" + +echo "Waiting up to $((timeout_seconds / 3600))h for ${TAG} on ${TARGET_BRANCH}..." + +deadline=$((SECONDS + timeout_seconds)) +while ((SECONDS < deadline)); do + if api_error=$(gh api "repos/${REPO}/git/ref/tags/${TAG}" --silent 2>&1); then + echo "${TAG} found — the bump PR has been merged." + exit 0 + fi + if [[ "$api_error" != *"HTTP 404"* ]]; then + echo "::error title=Unexpected error polling for release tag::${api_error}" + exit 1 + fi + echo "${TAG} not found yet; $((deadline - SECONDS))s of budget left." + sleep "$poll_seconds" +done + +readonly RECOVERY="gh run rerun ${RUN_ID} --failed" +echo "::error title=Release tag never appeared::${TAG} was not created in time. Check whether the Release-labeled bump PR for ${TARGET_BRANCH} is merged; if it is, open its release-draft-create run and check the individual jobs. Recover with: ${RECOVERY}" + +if [[ -n "${GITHUB_STEP_SUMMARY:-}" ]]; then + cat >>"$GITHUB_STEP_SUMMARY" <