|
1 | 1 | name: Enforce Greatest-Semver-Wins Latest Release |
2 | 2 |
|
3 | | -# Keeps GitHub's "latest release" flag pinned to the highest stable semver |
4 | | -# tag, so `--front-end-version latest` can never resolve to an older release |
5 | | -# than what's already shipped. |
6 | | -# |
7 | | -# Un-publishing the current latest release is treated as an implicit |
8 | | -# rollback: latest reassigns to the next-highest published stable release. |
9 | | -# This is intentional. |
| 3 | +# Un-publishing the latest release rolls Latest back on purpose. |
10 | 4 |
|
11 | 5 | on: |
12 | 6 | release: |
13 | 7 | types: [published, edited, unpublished, deleted] |
14 | 8 | workflow_dispatch: {} |
| 9 | + workflow_call: |
| 10 | + secrets: |
| 11 | + PR_GH_TOKEN: |
| 12 | + required: true |
15 | 13 | schedule: |
16 | | - - cron: '0 4 * * *' # daily backstop in case a release webhook is dropped |
| 14 | + - cron: '0 4 * * *' |
17 | 15 |
|
18 | | -# Serialize runs so two near-simultaneous release edits can't race each |
19 | | -# other's `gh release edit --latest` calls. Do NOT cancel-in-progress: a |
20 | | -# queued run still needs to re-check state after the run ahead of it finishes. |
21 | 16 | concurrency: |
22 | 17 | group: enforce-latest-release |
23 | | - cancel-in-progress: false |
24 | 18 |
|
25 | 19 | jobs: |
26 | 20 | enforce-latest: |
27 | 21 | runs-on: ubuntu-latest |
28 | | - # Defense in depth: GH_TOKEN edits don't retrigger `release` events, but |
29 | | - # this guards against a future PAT/App-token swap that would. |
30 | | - if: github.triggering_actor != 'github-actions[bot]' |
| 22 | + # On workflow_call the actor is whoever merged the release PR, often a bot. |
| 23 | + if: github.event_name != 'release' || github.triggering_actor != 'github-actions[bot]' |
31 | 24 | permissions: |
32 | | - contents: write # required: gh release edit / releases API write access |
| 25 | + contents: write |
33 | 26 | steps: |
34 | | - - name: Reconcile "latest" flag to the highest stable semver release |
| 27 | + # workflow_call inherits the caller's refs/pull/N/merge, gone once it closes. |
| 28 | + - uses: actions/checkout@v7 |
| 29 | + with: |
| 30 | + ref: main |
| 31 | + sparse-checkout: scripts/cicd/reconcile-latest-release.sh |
| 32 | + sparse-checkout-cone-mode: false |
| 33 | + |
| 34 | + - name: Reconcile Latest to the highest stable semver release |
35 | 35 | env: |
36 | | - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} |
| 36 | + GH_TOKEN: ${{ secrets.PR_GH_TOKEN }} |
37 | 37 | REPO: ${{ github.repository }} |
38 | | - run: | |
39 | | - set -euo pipefail |
40 | | -
|
41 | | - echo "Fetching all releases for $REPO..." |
42 | | - RELEASES_JSON=$(gh release list --repo "$REPO" --limit 1000 \ |
43 | | - --json tagName,isDraft,isPrerelease,isLatest) |
44 | | -
|
45 | | - COUNT=$(echo "$RELEASES_JSON" | jq 'length') |
46 | | - if [ "$COUNT" -ge 1000 ]; then |
47 | | - echo "::warning::Release count hit --limit 1000 cap. Results may be truncated." |
48 | | - fi |
49 | | -
|
50 | | - STABLE_TAGS=$(echo "$RELEASES_JSON" | jq -r \ |
51 | | - '.[] | select(.isDraft == false and .isPrerelease == false) | .tagName') |
52 | | -
|
53 | | - if [ -z "$STABLE_TAGS" ]; then |
54 | | - echo "::warning::No stable (non-draft, non-prerelease) releases found. '--front-end-version latest' will 404 until one exists." |
55 | | - exit 0 |
56 | | - fi |
57 | | -
|
58 | | - # Defensive filter: only strict [v]X.Y.Z tags -- skips "-rc"/"-beta" |
59 | | - # tags mis-flagged as stable, and other monorepo tags (design-system, |
60 | | - # desktop-ui, npm-types, etc.) that aren't ours. |
61 | | - CANDIDATES=() |
62 | | - while IFS= read -r tag; do |
63 | | - if [[ "$tag" =~ ^v?[0-9]+\.[0-9]+\.[0-9]+$ ]]; then |
64 | | - CANDIDATES+=("$tag") |
65 | | - fi |
66 | | - done <<< "$STABLE_TAGS" |
67 | | -
|
68 | | - if [ "${#CANDIDATES[@]}" -eq 0 ]; then |
69 | | - echo "::warning::No semver-shaped stable tags found among releases. '--front-end-version latest' will 404 until one exists." |
70 | | - exit 0 |
71 | | - fi |
72 | | -
|
73 | | - # greatest-semver-wins: sort numerically (v-prefix stripped), not by |
74 | | - # tag name or publish date. |
75 | | - TRUE_LATEST_VER=$(printf '%s\n' "${CANDIDATES[@]}" | sed 's/^v//' | sort -V | tail -1) |
76 | | -
|
77 | | - TRUE_LATEST_TAG="" |
78 | | - for tag in "${CANDIDATES[@]}"; do |
79 | | - if [[ "${tag#v}" == "$TRUE_LATEST_VER" ]]; then |
80 | | - TRUE_LATEST_TAG="$tag" |
81 | | - break |
82 | | - fi |
83 | | - done |
84 | | -
|
85 | | - [ -n "$TRUE_LATEST_TAG" ] || { echo "::error::BUG: could not resolve TRUE_LATEST_TAG"; exit 1; } |
86 | | -
|
87 | | - echo "Highest stable semver release: $TRUE_LATEST_TAG" |
88 | | -
|
89 | | - CURRENT_LATEST_TAG=$(echo "$RELEASES_JSON" | jq -r \ |
90 | | - '[.[] | select(.isLatest == true)][0].tagName // empty') |
91 | | - echo "GitHub-flagged 'latest' release: ${CURRENT_LATEST_TAG:-<none>}" |
92 | | -
|
93 | | - if [ "$CURRENT_LATEST_TAG" == "$TRUE_LATEST_TAG" ]; then |
94 | | - echo "OK: 'latest' already matches the highest stable semver release. No action needed." |
95 | | - exit 0 |
96 | | - fi |
97 | | -
|
98 | | - 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'." |
99 | | -
|
100 | | - gh release edit "$TRUE_LATEST_TAG" --repo "$REPO" --latest |
101 | | -
|
102 | | - { |
103 | | - echo "## Latest-release auto-correction" |
104 | | - echo "" |
105 | | - echo "- Previously flagged as \`latest\`: \`${CURRENT_LATEST_TAG:-<none>}\`" |
106 | | - echo "- Highest stable semver release: \`$TRUE_LATEST_TAG\`" |
107 | | - echo "- Action taken: re-assigned \`latest\` to \`$TRUE_LATEST_TAG\` via \`gh release edit --latest\`" |
108 | | - } >> "$GITHUB_STEP_SUMMARY" |
| 38 | + run: ./scripts/cicd/reconcile-latest-release.sh |
0 commit comments