From 3ca7fe8048da404388f60cd8839a37d487e2e4df Mon Sep 17 00:00:00 2001 From: Evan Hoffman Date: Tue, 18 Aug 2026 23:52:27 -0400 Subject: [PATCH] Read the release label from the merge, not from a lookup Four merges carried release:minor. Two shipped a minor version and two shipped a patch, and nothing distinguished them: the label was on every one of them, and querying the same endpoint by hand afterwards answered correctly for all four. The lookup asked GitHub which pull request a commit came from. That association is not reliably present at the moment this workflow runs, and the failure had nowhere to go -- `|| true` turned every refusal, timeout and empty answer into an empty label list, which is the same value as an unlabelled merge, which is a patch bump. Two feature releases went out as patches and the log recorded nothing about it, because every line in the step wrote to the job summary and the summaries come back empty through the API. So stop asking. A squash merge writes the pull request number into its own subject, so the number is read from there and the labels come from that pull request directly. A direct push to main has no number in its subject, which is simply no labels and still takes the patch default. The lookup no longer swallows failures. A number that is not a pull request now stops the release, which is the intended trade: a blocked release is a message, and a release that quietly picks the wrong version is not. permissions gains pull-requests: read. The block sets every scope it does not name to none, so with contents alone the lookup could only ever be refused -- which may well be what happened, and is exactly the kind of thing the old code could not tell anybody. Every decision the step makes now goes to stdout as well as the summary: the pull request number, the labels found, the bump chosen. No version is renumbered. v1.3.1 and v1.3.2 were feature releases that shipped as patches, and they stay as they are. Claude-Session: https://claude.ai/code/session_01UPbN2SqYq8t2vcx2YWQTcs --- .github/workflows/release.yml | 48 +++++++++++++++++++++++++++++------ AGENTS.md | 13 ++++++++++ 2 files changed, 53 insertions(+), 8 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index b8dd46e..ac6973d 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -40,6 +40,11 @@ concurrency: permissions: contents: write + # Reading the merge's labels needs this. The block sets every scope it does + # not name to `none`, so with `contents` alone the label lookup could only + # ever be refused — and a refusal used to look exactly like an unlabelled + # merge. + pull-requests: read jobs: # Works out this merge's version, writes it to Version.swift, and creates the @@ -83,18 +88,44 @@ jobs: echo "tag=v$current" >> "$GITHUB_OUTPUT" echo "sha=$GITHUB_SHA" >> "$GITHUB_OUTPUT" echo "Version.swift already says $current — releasing that." \ - >> "$GITHUB_STEP_SUMMARY" + | tee -a "$GITHUB_STEP_SUMMARY" exit 0 fi - # Labels of the pull request this commit came from. A direct push to - # main belongs to no pull request, which is simply no labels. - labels="$(gh api "repos/$GITHUB_REPOSITORY/commits/$GITHUB_SHA/pulls" \ - --jq '.[].labels[].name' | sort -u || true)" + # Labels of the pull request this merge came from. + # + # Read from the number in the commit subject rather than by asking + # GitHub which pull request a commit belongs to. That association is + # not reliably there at the moment this runs: the same query answered + # correctly for two merges and returned nothing for the next two, all + # four carrying the label, and every one of those shipped as a patch + # because an empty answer and "no labels" are the same value. A squash + # merge writes the number into its own subject, so this reads what the + # merge itself says. + # + # A direct push to main has no number in its subject, which is simply + # no labels — that path still gets the patch default, on purpose. + labels="" + number="$(git log -1 --pretty=%s \ + | sed -n 's/.*(#\([0-9][0-9]*\))$/\1/p')" + if [ -n "$number" ]; then + # No `|| true`. The previous version swallowed every failure into an + # empty label list, so a broken lookup was indistinguishable from an + # unlabelled merge and silently shipped the wrong version. A lookup + # that cannot answer should stop the release, not guess at it. + # A number in the subject that is not a pull request stops the + # release. That is the intended trade: a blocked release is a + # message, and a release that quietly picks the wrong version is + # not. + labels="$(gh pr view "$number" --json labels --jq '.labels[].name' \ + | sort -u)" + fi + echo "Pull request: ${number:-none}" + echo "Labels: ${labels:-none}" if grep -qx 'release:skip' <<<"$labels"; then echo "Labelled release:skip — publishing nothing." \ - >> "$GITHUB_STEP_SUMMARY" + | tee -a "$GITHUB_STEP_SUMMARY" exit 0 fi @@ -105,13 +136,14 @@ jobs: if [ -n "$changed" ] \ && ! grep -qvE '^(docs/|\.github/|[^/]*\.md$)' <<<"$changed"; then echo "Documentation only — publishing nothing." \ - >> "$GITHUB_STEP_SUMMARY" + | tee -a "$GITHUB_STEP_SUMMARY" exit 0 fi level="patch" if grep -qx 'release:minor' <<<"$labels"; then level="minor"; fi if grep -qx 'release:major' <<<"$labels"; then level="major"; fi + echo "Bump: $level" IFS=. read -r major minor patch <<<"$current" case "$level" in @@ -146,7 +178,7 @@ jobs: echo "tag=v$next" >> "$GITHUB_OUTPUT" echo "sha=$(git rev-parse HEAD)" >> "$GITHUB_OUTPUT" echo "Releasing v$next ($level bump from $current)" \ - >> "$GITHUB_STEP_SUMMARY" + | tee -a "$GITHUB_STEP_SUMMARY" # What the notes say about the first launch depends on whether the build # is notarized, and NOTARY_PROFILE is what decides that (see package.yml). diff --git a/AGENTS.md b/AGENTS.md index cee8ffc..954efa1 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -253,6 +253,13 @@ are no component-level AGENTS.md files. pull request says otherwise, so the size of a release is decided in review rather than remembered at merge time. Labels rather than Conventional Commit prefixes, because the commit style here is prose. + **The label is read from the number in the merge commit's subject**, not by + asking GitHub which pull request a commit came from — that association is + not reliably present when this runs, and an empty answer is + indistinguishable from an unlabelled merge. The lookup has no `|| true` + either: it must fail loudly rather than fall through to the default. + `permissions:` must keep `pull-requests: read`, since the block sets every + scope it does not name to `none`. - **Two ways to publish nothing:** a `release:skip` label, or a merge that touched only `docs/`, `.github/` and top-level `*.md`. A merge touching docs *and* code still ships. @@ -403,6 +410,12 @@ are no component-level AGENTS.md files. - **GPU card is greyed out**: this macOS version does not publish the `PerformanceStatistics` keys the source knows about. Add the new spelling to the candidate list in `GPUSource`. +- **A merge shipped a patch version when it was labelled `release:minor`**: + the label lookup came back empty. Check the "Version" step's log — it now + prints the pull request number, the labels it found and the bump it chose. A + number of `none` on a squash merge means the subject did not end in `(#N)`; + empty labels on a real number means the token could not read them, so check + `pull-requests: read` is still in the workflow's `permissions:`. - **A pull request reports no CI**: it came from a fork, and the jobs skip fork pull requests on purpose. Re-run from a branch in this repository. - **A pull request reports no CI and did not come from a fork**: its commit