From 14992ceebe5e298c21ae875fe8d3bcdc4d27d662 Mon Sep 17 00:00:00 2001 From: Diego Rodrigues de Sa e Souza Date: Thu, 30 Jul 2026 09:07:19 -0300 Subject: [PATCH] test(ci): stop the promote-latest guard from racing the script's early exit (#8977) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * test(ci): stop the promote-latest guard from racing the script's early exit `Unit Tests fast-path (2/4)` went red on two unrelated PRs today (#8953, #8966), always on the same case, always with `Error: spawnSync bash EPIPE` — never an assertion. The file passes locally in 1.8s, so it read as load-related noise. It is not: it is a race with a precise, reproducible mechanism. should-promote-latest.sh decides a pre-release VERSION and exits BEFORE reading stdin at all: case "$VERSION" in *-*) echo "false"; exit 0 ;; esac The test harness passed the candidate tags via execFileSync's `input:`, i.e. a pipe. The child exits, its read end closes, and the parent's write raises EPIPE — so the test throws before asserting anything. Whether the write lands first depends on the 64 KB pipe buffer and the scheduler, which is exactly why it failed intermittently under four concurrent shards while passing in isolation. Measured, not inferred — same version, growing payload: 2 tags ( 11 bytes) → ok 100 tags ( 689 bytes) → ok 5 000 tags ( 43 889 bytes) → ok 20 000 tags (188 889 bytes) → EPIPE, every time Fix: back stdin with a real FILE instead of a pipe. A file has no reader to lose, so the child may exit whenever it likes. The script's interface is untouched — it still reads candidate tags from stdin, exactly as docker-publish.yml pipes them. Production is NOT affected, and that was checked rather than assumed: docker-publish.yml short-circuits pre-releases before the helper is ever called (`elif printf '%s' "$VERSION" | grep -qE -- '-(rc|alpha|beta|pre|next)'`), so the early-exit branch is unreachable there. Only the test exercised it. No production file changes here. TDD — the new case is the guard for the harness itself, run against both helpers: node --import tsx/esm --test tests/unit/build/should-promote-latest-5301.test.ts # new helper (file-backed stdin): 9 pass, 0 fail # old helper (input: pipe): 8 pass, 1 fail → Error: spawnSync bash EPIPE * docs(changelog): fragment for #8977 --------- Co-authored-by: diegosouzapw --- .../8977-promote-latest-test-epipe.md | 1 + .../build/should-promote-latest-5301.test.ts | 63 +++++++++++++++++-- 2 files changed, 59 insertions(+), 5 deletions(-) create mode 100644 changelog.d/maintenance/8977-promote-latest-test-epipe.md diff --git a/changelog.d/maintenance/8977-promote-latest-test-epipe.md b/changelog.d/maintenance/8977-promote-latest-test-epipe.md new file mode 100644 index 0000000000..e563dda84f --- /dev/null +++ b/changelog.d/maintenance/8977-promote-latest-test-epipe.md @@ -0,0 +1 @@ +- **test(ci):** fixed the intermittent `spawnSync bash EPIPE` failure in the `:latest` promotion guard — the script exits on a pre-release version before reading stdin, so the harness's pipe-backed `input:` raced that exit; stdin is now file-backed, which makes the race structurally impossible ([#8977](https://github.com/diegosouzapw/OmniRoute/pull/8977)) diff --git a/tests/unit/build/should-promote-latest-5301.test.ts b/tests/unit/build/should-promote-latest-5301.test.ts index 9df1a8a6d8..61d6793ef8 100644 --- a/tests/unit/build/should-promote-latest-5301.test.ts +++ b/tests/unit/build/should-promote-latest-5301.test.ts @@ -16,18 +16,54 @@ import test from "node:test"; import assert from "node:assert/strict"; import { execFileSync } from "node:child_process"; +import { closeSync, mkdtempSync, openSync, rmSync, writeFileSync } from "node:fs"; +import os from "node:os"; import path from "node:path"; import { fileURLToPath } from "node:url"; const here = path.dirname(fileURLToPath(import.meta.url)); const SCRIPT = path.resolve(here, "../../../scripts/ci/should-promote-latest.sh"); -/** Run the helper with `version` as argv[1] and `tags` (joined by \n) on stdin. */ +/** + * Run the helper with `version` as argv[1] and `tags` (joined by \n) on stdin. + * + * stdin is a real FILE, not a pipe, and that is the whole point. The script + * short-circuits a pre-release VERSION and `exit 0`s WITHOUT ever reading stdin: + * + * case "$VERSION" in + * *-*) echo "false"; exit 0 ;; + * esac + * + * Feeding it through `input:` makes the parent write to a pipe whose reader has + * already gone away, so the write raises `spawnSync bash EPIPE` — the test throws + * before it can assert anything. It is a race, not load: whether the write lands + * before the child exits depends on the 64 KB pipe buffer and the scheduler, which + * is why it failed intermittently in CI (#8953, #8966) while passing locally. + * Measured deterministically: + * + * 2 tags ( 11 bytes) → ok + * 100 tags ( 689 bytes) → ok + * 5 000 tags ( 43 889 bytes) → ok + * 20 000 tags (188 889 bytes) → EPIPE, every time + * + * A regular file has no reader to lose, so the child may exit whenever it likes. + * The script's interface is unchanged — it still reads candidate tags from stdin, + * exactly as docker-publish.yml pipes them in. + */ function shouldPromote(version: string, tags: string[]): string { - return execFileSync("bash", [SCRIPT, version], { - input: tags.join("\n") + (tags.length ? "\n" : ""), - encoding: "utf8", - }).trim(); + const dir = mkdtempSync(path.join(os.tmpdir(), "should-promote-latest-")); + const stdinPath = path.join(dir, "tags"); + writeFileSync(stdinPath, tags.join("\n") + (tags.length ? "\n" : "")); + const fd = openSync(stdinPath, "r"); + try { + return execFileSync("bash", [SCRIPT, version], { + stdio: [fd, "pipe", "pipe"], + encoding: "utf8", + }).trim(); + } finally { + closeSync(fd); + rmSync(dir, { recursive: true, force: true }); + } } test("#5301 race: new tag not yet synced → still promotes latest", () => { @@ -67,3 +103,20 @@ test("numeric (not lexical) semver ordering", () => { test("candidate tags with a leading `v` are normalized", () => { assert.equal(shouldPromote("3.8.39", ["v3.8.38", "v3.8.37"]), "true"); }); + +test("a pre-release VERSION is decided without reading stdin, at any input size", () => { + // The regression guard for the harness itself. The script `exit 0`s on a + // pre-release VERSION before touching stdin; with a pipe-backed stdin this + // combination raised `spawnSync bash EPIPE` once the payload outgrew the 64 KB + // pipe buffer — deterministically at 20 000 tags, and intermittently at CI's + // real tag count, which is what reddened #8953 and #8966 on unrelated diffs. + // + // 20 000 tags ≈ 189 KB, comfortably past the buffer. If someone reverts the + // helper to `input:`, this case throws instead of asserting. + const manyTags = Array.from({ length: 20_000 }, (_, i) => `3.8.${i}`); + assert.equal(shouldPromote("3.8.40-rc.1", manyTags), "false"); + + // And the decision itself must not depend on the candidate set at all. + assert.equal(shouldPromote("3.8.40-rc.1", []), "false"); + assert.equal(shouldPromote("4.0.0-beta.2", ["3.8.39"]), "false"); +});