-
Notifications
You must be signed in to change notification settings - Fork 672
ci(release): harden core-release workflow (fixes from 1.47.10) #14039
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from 21 commits
82aa0b4
77db22a
363c424
8492f85
f17db6c
7388de0
6e3cc75
61a356d
6431d20
1ebaa00
99d650d
996b12e
e6dc39d
1b11b34
669ce79
091820b
b64a65d
114f1e5
b7bb296
4ee25a5
0f2021a
bc11798
c54d805
816ad9f
84ab31e
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,108 +1,36 @@ | ||
| 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: {} | ||
| 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 | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. delete:
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Fixed in |
||
|
|
||
| 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 }} | ||
| 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:-<none>}" | ||
|
|
||
| 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:-<none>}' 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:-<none>}\`" | ||
| 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 | ||
|
coderabbitai[bot] marked this conversation as resolved.
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,96 @@ | ||
| #!/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 -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 "https://pypi.org/pypi/${PACKAGE}/json" | | ||
| jq -r '.info.version // empty') || pypi_latest="" | ||
|
coderabbitai[bot] marked this conversation as resolved.
|
||
|
|
||
| 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 | ||
| } | ||
|
|
||
| warn_if_comfyui_pin_stale() { | ||
| local reqs pin="" | ||
| if reqs=$(curl -sf --connect-timeout 10 --max-time 30 \ | ||
| "https://raw.githubusercontent.com/Comfy-Org/ComfyUI/master/requirements.txt"); then | ||
| pin=$(grep -oE "${PACKAGE}==[0-9.]+" <<<"$reqs" | head -1 | cut -d= -f3) || pin="" | ||
| fi | ||
|
|
||
| if [[ "$pin" == "$TARGET_VERSION" ]]; then | ||
| summary "- OK: ComfyUI \`master\` pins \`${TARGET_VERSION}\`" | ||
| else | ||
| echo "::warning title=ComfyUI pin not yet updated::ComfyUI master pins ${pin:-<none>}, target ${TARGET_VERSION}." | ||
| summary "- Warn: ComfyUI \`master\` pins \`${pin:-<none>}\`, target \`${TARGET_VERSION}\`" | ||
| 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 | ||
| warn_if_comfyui_pin_stale | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. delete: This pin check runs immediately after opening the pin PR, before anyone can merge it, so its warning is expected noise. Remove
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Fixed in |
||
|
|
||
| if ((failed != 0)); then | ||
| echo "release-done assertion FAILED — see annotations above." | ||
| exit 1 | ||
| fi | ||
| echo "release-done assertion passed." | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
delete:
if: success()is already the job default, and reusable workflows receive the specialGITHUB_TOKENautomatically, sosecrets: inheritis unnecessary here.There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Kept it removed as a blanket
secrets: inherit. Had to add back a scoped explicit pass-through forPR_GH_TOKENspecifically though (declaredrequiredonrelease-enforce-latest.yaml'sworkflow_call, passed explicitly here) —gh release edit --latestgets a 403 "Resource not accessible by integration" from the defaultGITHUB_TOKENon core//cloud/ release branches, so the reconcile step needsPR_GH_TOKENspecifically, and without any pass-through that secret resolves to empty in the reusable workflow.Created by Claude Code
Uh oh!
There was an error while loading. Please reload this page.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Addressed in
bc117987e4and816ad9f7ee: removed defaultif: success()and blanket inheritance, then passed onlyPR_GH_TOKEN, which the release edit requires.