|
5 | 5 | branches: |
6 | 6 | - main |
7 | 7 |
|
8 | | -concurrency: ${{ github.workflow }}-${{ github.ref }} |
| 8 | +# One concurrency group per COMMIT — never one shared by every push to `main`. |
| 9 | +# |
| 10 | +# A shared group does not queue. GitHub keeps at most ONE pending run per group and |
| 11 | +# cancels the previous pending run whenever a newer one arrives, so on a branch that |
| 12 | +# takes ~18 merges a day a long-running release job discards most of what follows it. |
| 13 | +# Measured over runs #3712-#3911 (2026-08-17 .. 2026-08-20, 200 runs): 93 were |
| 14 | +# `conclusion: cancelled` with an EMPTY jobs array — not one step ever ran — while the |
| 15 | +# 106 that did execute held the group for a median of 33m22s. Median wait for a run |
| 16 | +# that survived: 5m16s (objectui#5404, method and figures from objectui#5395). |
| 17 | +# |
| 18 | +# The last step of this workflow publishes to npm, so a discarded run is a discarded |
| 19 | +# publish. `cancel-in-progress: true` is NOT the fix: it keeps one pending slot by |
| 20 | +# killing the run that may be mid-`changeset publish`, which is strictly worse than |
| 21 | +# any wait. |
| 22 | +# |
| 23 | +# Keyed by `github.sha` the group holds exactly one run, so nothing is ever discarded. |
| 24 | +# Serialisation is not given up — it moves into the "Wait for older release runs" step |
| 25 | +# below, which queues runs for real and in order, and which can only ever delay a run, |
| 26 | +# never drop one. |
| 27 | +concurrency: |
| 28 | + group: ${{ github.workflow }}-${{ github.sha }} |
| 29 | + cancel-in-progress: false |
9 | 30 |
|
10 | 31 | permissions: |
| 32 | + # `actions: read` lets the queue step below list this workflow's own runs. Declaring |
| 33 | + # any `permissions:` block sets every unlisted scope to `none`, so it has to be named. |
| 34 | + actions: read |
11 | 35 | contents: write |
12 | 36 | pull-requests: write |
13 | 37 | id-token: write |
|
17 | 41 | name: Changeset Release |
18 | 42 | runs-on: ubuntu-latest |
19 | 43 | steps: |
| 44 | + # The queue the `concurrency:` key above cannot provide. GitHub offers exactly two |
| 45 | + # behaviours for a contended group — cancel the running one, or discard the pending |
| 46 | + # ones — and neither is acceptable on a workflow that publishes. So the ordering is |
| 47 | + # done here: a run waits until every release run older than itself has finished, then |
| 48 | + # proceeds. Ordering is by `run_number`, a total order, so the oldest run is always |
| 49 | + # free to proceed and this cannot deadlock. |
| 50 | + # |
| 51 | + # It is deliberately FAIL-OPEN. Every exit path is `exit 0`, and the step additionally |
| 52 | + # carries `continue-on-error: true`: if the API cannot be read, or an older run is |
| 53 | + # wedged past the cap, this step gives up waiting and lets the release proceed. A run |
| 54 | + # that never runs is the defect this queue exists to remove — degrading to today's |
| 55 | + # unserialised behaviour is the acceptable failure, blocking a publish is not. |
| 56 | + - name: Wait for older release runs to finish |
| 57 | + continue-on-error: true |
| 58 | + env: |
| 59 | + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} |
| 60 | + RUN_NUMBER: ${{ github.run_number }} |
| 61 | + # A release job is ~90s of work; this cap is a wedged-run escape hatch, not a |
| 62 | + # normal wait. |
| 63 | + MAX_WAIT_SECONDS: '900' |
| 64 | + POLL_SECONDS: '20' |
| 65 | + run: | |
| 66 | + set -uo pipefail |
| 67 | +
|
| 68 | + # A rename of this file makes the path 404, `gh` exits non-zero, and the step |
| 69 | + # fails open below — it does not silently stop serialising while looking green. |
| 70 | + runs_path="repos/${GITHUB_REPOSITORY}/actions/workflows/changeset-release.yml/runs?per_page=100" |
| 71 | + older_jq='[.workflow_runs[] | select(.status != "completed" and .run_number < '"${RUN_NUMBER}"')] |
| 72 | + | .[] | " waiting on run #\(.run_number) (\(.status)) \(.html_url)"' |
| 73 | +
|
| 74 | + deadline=$(( $(date +%s) + MAX_WAIT_SECONDS )) |
| 75 | + while : ; do |
| 76 | + if ! older=$(gh api "${runs_path}" --jq "${older_jq}"); then |
| 77 | + echo "::warning::Could not list this workflow's runs, so the release lane is not serialised for this run. Proceeding — this step may delay a release, never block one (objectui#5404)." |
| 78 | + exit 0 |
| 79 | + fi |
| 80 | +
|
| 81 | + count=$(printf '%s\n' "${older}" | grep -c 'waiting on run' || true) |
| 82 | + if [ "${count}" -eq 0 ]; then |
| 83 | + echo "No older release run is still in flight. Proceeding." |
| 84 | + exit 0 |
| 85 | + fi |
| 86 | +
|
| 87 | + if [ "$(date +%s)" -ge "${deadline}" ]; then |
| 88 | + echo "::warning::${count} older release run(s) still unfinished after ${MAX_WAIT_SECONDS}s. Proceeding without waiting further — a release run that never executes is the defect this queue exists to prevent (objectui#5404)." |
| 89 | + exit 0 |
| 90 | + fi |
| 91 | +
|
| 92 | + echo "${count} older release run(s) still in flight:" |
| 93 | + printf '%s\n' "${older}" |
| 94 | + sleep "${POLL_SECONDS}" |
| 95 | + done |
| 96 | +
|
20 | 97 | - name: Checkout Repo |
21 | 98 | uses: actions/checkout@v7 |
22 | 99 | with: |
@@ -54,8 +131,21 @@ jobs: |
54 | 131 | - name: Install dependencies |
55 | 132 | run: pnpm install --frozen-lockfile |
56 | 133 |
|
57 | | - - name: Run tests |
58 | | - run: pnpm test |
| 134 | + # ⛔ Do not add a `pnpm test` step back here. |
| 135 | + # |
| 136 | + # There used to be one, and it was 31m19s of a 33m22s job (medians over the 106 runs |
| 137 | + # of #3712-#3911 that executed; everything else combined is ~90s — install 6s, build |
| 138 | + # 35s, the changesets action 24s). It re-tested a commit that is ALREADY on `main`, so |
| 139 | + # it could not keep anything out; the only thing it could do was stop the release |
| 140 | + # afterwards, and it did: 2 of the last 27 release-PR merges (runs #3606 and #3901) |
| 141 | + # failed here and skipped the changesets step entirely. #3606 was the run that would |
| 142 | + # have published @object-ui/*@17.5.0, which reached npm 6h50m late, on a later push |
| 143 | + # that happened to still find `.changeset/` empty. |
| 144 | + # |
| 145 | + # The backstop for `main` is `ci.yml`'s push lane, which runs the whole suite under |
| 146 | + # coverage across four shards on every push and enforces the thresholds on the merged |
| 147 | + # report (objectui#5403). That is the copy that tests this commit; this one only ever |
| 148 | + # duplicated it, ~34 minutes later. |
59 | 149 |
|
60 | 150 | - name: Build packages |
61 | 151 | run: pnpm build |
|
0 commit comments