Skip to content

Read the Xcode version without a pipe, and guard the shape that aborted it - #499

Merged
mishamyte merged 2 commits into
mainfrom
fix/xcode-version-read
Nov 14, 2026
Merged

mishamyte merged 2 commits into
mainfrom
fix/xcode-version-read

Conversation

@mishamyte

@mishamyte mishamyte commented Nov 14, 2026 •

Copy link
Copy Markdown
Member

have="$(xcodebuild -version | head -n 1)" killed the release run on
2ca67ade. head exits once it has the line it wanted, the next write finds
the pipe closed, and xcodebuild answers that by raising
NSFileHandleOperationException, printing a backtrace and aborting with 134.
pipefail makes that the step's status, so Select Xcode 26 or newer failed
before it had selected anything, the macOS and iOS builds never ran, and
Assert the release published failed behind them. Android, Linux and Windows
were green on that same push, so the win32 chain merged a push earlier does
have three platforms' worth of evidence - just not Apple's.

Two commits: the fix, then the ratchet and the five other instances of the
same shape.

The fix

-          have="$(xcodebuild -version | head -n 1)"
+          have="$(xcodebuild -version)"
+          have="${have%%$'\n'*}"

It is a race, and that is the argument for deleting the pipe rather than
hardening around it.
It had passed six times before it fired - every push
since the step shipped in #490. Counting green runs understates it: the step
also passed on d10fe8ab and 2bfa773c, whose runs failed later at
Build macOS release on a stale committed Podfile.lock, which #495 then
fixed. Checked by grepping each run log for the step's own closing line - the
six print Xcode 26.6 from /Applications/Xcode_26.6.app, of 14 candidate(s).
and 2ca67ade prints only the echoed script.

xcodebuild -version prints two lines, and whether its second write lands
before head's first read is the runner's business, not ours.

No behaviour change otherwise. The six version strings that decided the old
case decide the new one identically - 26, 26.1, 26.10.1 accepted, 30.0 and
16.4 rejected - and so do a further twelve inputs, empty output and CRLF among
them.

The ratchet, because this was the fourth warning and the first cost

The repository had already warned about the shape in prose four times -
derive_version.sh, check_arb_locales.sh, summarize_l10n_failure.sh and
the step itself. ADR 0004's argument is that a recurring mistake earns a
ratchet, so the second commit adds one and retires the rest of the instances.

The guard sits in lint_workflows.sh beside the cond && 0 || 1 grep added
for #292 - also added because nothing but a publish run could find that
mistake. Same shape, same file, no new script and no new ci.yml step, so it
runs on every pull request through the actionlint step already there.

It reads the workflows, the composite actions and .github/scripts, and
catches | head, | grep -m and | sed -n 1q. Measured against a fixture:
all four real pre-fix lines and three other early-exit spellings are caught;
prose quoting the forbidden shape, a | cut | paste chain, the correct
sed -n '1,60p', the ${v%%$'\n'*} idiom, and code whose trailing comment
mentions | head are not. ^[^#]* is what keeps it off the prose, including
the comment directly above it.

Its bound is stated where it is written: it is textual, so a
while read … break is the same trap with no keyword to grep, and the comment
says to add a case rather than an exemption.

The five siblings, now pipe-free

  • build/{android,ios,linux,macos}.sh each read the version from pubspec
    through sed … | head -n 1. Latent rather than live - the producer is a
    sed over a small file - but derive_version.sh calls exactly that "a
    silent exit waiting for a longer file". Extraction verified unchanged
    against the real pubspec and a synthetic one with build metadata and a
    second version: line: 0.17.3 and 1.2.3 either way.
  • upload_r8_mapping.sh's read_slug captured through a herestring and
    piped to head. Now captures and narrows in the shell.
  • sentry_proguard_uuid.sh ended unzip | sed | head -n 1 || true. The
    || true stays - unzip -p exits non-zero on an absent entry, and that
    is one of the two absences this function answers with silence - but its
    comment claimed the || true was for the SIGPIPE, which is no longer a
    reason, so it now says which of the two it is.

The shell: bash paragraph was wrong before this branch

Fixing only its line number would have left the claim wrong, so both are
rewritten.

shell: bash supplies -e and -o pipefail, not -u - the failed run
prints the resolved /bin/bash --noprofile --norc -e -o pipefail {0} - and
the -u comes from the step's own set line. Its cross-reference was a bare
line number into this same file, the only one of its kind here, and it was
never once correct: written as `:1014` when the paragraph sat at 1057,
found at 1111 on main, arithmetically 1130 today. It now names the step it
means, and cites ci.yml's note, which is the one that actually argues for
shell: bash.

With no pipeline left anywhere in the step, pipefail there is
belt-and-braces. What shell: bash is now load-bearing for is $'\n', which
a POSIX sh would not parse - new with this change, so it is stated where a
future editor will see it.

No changelog entry

docs/releasing.md names this case directly - "Most should not - a CI fix, a
Crowdin sync, a submodule bump or a test-only branch has nothing a user would
read"
- and #495 is the precedent beside it.

Gates

upload_r8_mapping_test.sh, verify_apks_test.sh and
derive_version_test.sh pass; the first two cover the two edited files that
have behaviour in them. bash -n on all seven scripts, check_format.sh
clean, check_changelog.sh OK, the workflow YAML parses, and the guard is
green across .github.

What this does not prove

Nothing here ran on a macOS runner, and the guard is the one part that
could not be run end to end locally: lint_workflows.sh downloads a
checksum-pinned actionlint before reaching it, so its first full execution
is this pull request's own lint-workflows job. The grep was run directly
instead.

actionlint does check the YAML on every pull request, but shellcheck is
absent from the runner and skipped, and no job on a pull request compiles iOS
or macOS. The step's own last paragraph has said so since it was written. The
first evidence for the Xcode fix is the next push to main.

Also found, not fixed here

  • brew install automake libtool in the Apple job installs four formulae that
    nothing in the tree uses - no configure.ac, Makefile.am or autogen.sh
    anywhere, and no autotools line in a full release log. About 7s per release
    run. Its verification is a release, which is why it is not in a fix to an
    unpublishable one.
  • t11 stands, and this incident is not its justification: a
    check_apple_sdk.sh with a stubbed xcodebuild provably could not have
    caught this - a stub's single printf completes before head is scheduled,
    so the shipped code passes green against it. The extraction buys the glob
    shapes, the beta-sorts-first case, the zero-candidate branch and the three
    copies of the 2[6-9] bound. Worth doing, on its own merits.
  • Installing shellcheck so actionlint's embedded-bash pass runs would owe
    actionlint's own pin-and-checksum ceremony, and has no rule for pipefail
    plus an early-exit consumer - so it would not have caught this either. Its
    own issue, on its own motivation.

`have="$(xcodebuild -version | head -n 1)"` killed the release run on
2ca67ad. `head` exits once it has the line it wanted, the next write finds
the pipe closed, and `xcodebuild` answers that by raising
`NSFileHandleOperationException`, printing a backtrace and aborting with
134. `pipefail` makes that the step's status, so "Select Xcode 26 or newer"
failed before it had selected anything, the macOS and iOS builds never ran,
and `Assert the release published` failed behind them. Android, Linux and
Windows were green on that same push.

It is a race, not a constant break, and it had passed six times before it
fired - every push since the step shipped in #490. Counting green runs
understates that: the step also passed on d10fe8a and 2bfa773, whose runs
failed later at `Build macOS release` on a stale committed Podfile.lock,
which #495 then fixed. Checked by grepping each run log for the step's own
closing line; the six print `Xcode 26.6 from /Applications/Xcode_26.6.app,
of 14 candidate(s).` and 2ca67ad prints only the echoed script.

`xcodebuild -version` prints two lines, and whether its second write lands
before `head`'s first read is the runner's business. Taking the first line
in the shell removes the pipe instead of hardening around it.

No behaviour change otherwise: the six version strings that decided the old
`case` decide the new one the same way - 26, 26.1, 26.10.1 accepted, 30.0
and 16.4 rejected - and so do a further twelve inputs, empty output and
CRLF among them.

This repository warns about the construct in `derive_version.sh`,
`check_arb_locales.sh` and `summarize_l10n_failure.sh`, and that is now
four warnings and no guard. A six-line grep appended to
`lint_workflows.sh:50`, in the shape of the `cond && 0 || 1` guard already
there, is green across the workflows at HEAD and red on this step at
2ca67ad. Deliberately not in this commit, which is a five-minute fix to an
unpublishable release; it is the obvious next one.

Also rewrites the `shell: bash` paragraph, which was wrong before this
branch and which fixing a line number would have left wrong. `shell: bash`
supplies `-e` and `-o pipefail`, not `-u` - the failed run prints the
resolved `/bin/bash --noprofile --norc -e -o pipefail {0}` - and the `-u`
comes from the step's own `set` line. Its cross-reference was a bare line
number into this file, the only one of its kind here and never once
correct: written as `:1014` when the paragraph sat at 1057, found at 1111,
and arithmetically 1130 today. It now names the step it means, and the
authority is `ci.yml`'s note, which is the one that actually argues for
`shell: bash`. With no pipeline left in the step, `pipefail` here is
belt-and-braces; what `shell: bash` is now load-bearing for is `$'\n'`,
which a POSIX `sh` would not parse.

No changelog entry. docs/releasing.md names this case directly - "Most
should not - a CI fix, a Crowdin sync, a submodule bump or a test-only
branch has nothing a user would read" - and #495 is the precedent beside it.

Nothing here ran on a macOS runner. `actionlint` does run on every pull
request through `lint_workflows.sh`, so the YAML is checked, but shellcheck
is absent from the runner and skipped, and no job on a pull request
compiles iOS or macOS - the step's own last paragraph has said so since it
was written. The first evidence is the next push to main.
@mishamyte
mishamyte requested a review from apfxtech as a code owner November 14, 2026 13:05
@mishamyte mishamyte added this to the 0.17.3 milestone Nov 14, 2026
@mishamyte mishamyte self-assigned this Nov 14, 2026
@github-actions github-actions Bot added the area/ci-build Workflows, scripts and build configuration label Nov 14, 2026
The previous commit removed one `| head -n 1` and left a fourth prose
warning about the shape. ADR 0004's argument is that a recurring mistake
earns a ratchet instead, so this is the ratchet plus the rest of the
instances.

The guard goes in `lint_workflows.sh`, beside the `cond && 0 || 1` grep
added for #292 - also added because nothing but a publish run could find
that mistake. Same shape, same file, no new script and no new ci.yml step,
so it runs on every pull request through the `actionlint` step that is
already there.

It reads the workflows, the composite actions and `.github/scripts`, and it
catches more than one spelling: `| head`, `| grep -m`, and `| sed -n 1q`.
Checked against a fixture of all four real pre-fix lines and three other
early-exit spellings - all seven caught - and against prose quoting the
forbidden shape, a `| cut | paste` chain, the correct `sed -n '1,60p'`, the
`${v%%$'\n'*}` idiom, and a line of code whose trailing comment mentions
`| head`: none of those match. `^[^#]*` is what keeps it off the prose,
including the comment directly above it.

Its bound is stated where it is written: it is textual, so a
`while read … break` is the same trap with no keyword to grep, and the
comment says to add a case rather than an exemption.

The five remaining instances, all now pipe-free:

- `build/{android,ios,linux,macos}.sh` each took the version from pubspec
  with `sed … | head -n 1`. The producer is a `sed` over a small file, so
  this was latent, not live - `derive_version.sh` calls it "a silent exit
  waiting for a longer file". Verified the extraction is unchanged against
  the real pubspec and against a synthetic one carrying build metadata and
  a second `version:` line: `0.17.3` and `1.2.3` either way.
- `upload_r8_mapping.sh`'s `read_slug` captured through a herestring and
  then piped to `head`. Now captures and narrows in the shell.
- `sentry_proguard_uuid.sh` ended `unzip | sed | head -n 1 || true`. The
  `|| true` stays, because `unzip -p` exits non-zero on an absent entry and
  that is one of the two absences this function answers with silence - but
  its comment claimed the `|| true` was there for the SIGPIPE, which is no
  longer a reason, so it now says which of the two it is.

Gates: `upload_r8_mapping_test.sh`, `verify_apks_test.sh` and
`derive_version_test.sh` all pass - the first two cover the two files with
behaviour in them. `bash -n` on all seven. `check_format.sh` clean,
`check_changelog.sh` OK, and the guard itself is green across `.github`.

No changelog entry, for the same reason as the commit before it.

Not verified on a runner, and the guard is the part that cannot be: it runs
in `lint_workflows.sh`, which downloads a checksum-pinned `actionlint`
before reaching it, so the first full execution is this pull request's own
`lint-workflows` job. The grep was run directly here instead.
@mishamyte mishamyte changed the title Read the Xcode version without a pipe, which aborted xcodebuild Read the Xcode version without a pipe, and guard the shape that aborted it Nov 14, 2026
@mishamyte
mishamyte merged commit 388331c into main Nov 14, 2026
3 checks passed
@mishamyte
mishamyte deleted the fix/xcode-version-read branch November 14, 2026 13:57
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area/ci-build Workflows, scripts and build configuration

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant