From c2f07bb48a56d5db11b0a6c9be6728bede3980d5 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Wed, 30 Sep 2026 23:34:47 -0700 Subject: [PATCH] fix(release): avoid SIGPIPE in artifact verification --- apps/desktop/scripts/macos-signing.test.mjs | 66 ++++++++++++++++++- .../scripts/verify-macos-server-release.sh | 6 +- docs/releasing.md | 15 +++++ 3 files changed, 84 insertions(+), 3 deletions(-) diff --git a/apps/desktop/scripts/macos-signing.test.mjs b/apps/desktop/scripts/macos-signing.test.mjs index 4113bf23..d133f3e6 100644 --- a/apps/desktop/scripts/macos-signing.test.mjs +++ b/apps/desktop/scripts/macos-signing.test.mjs @@ -1,6 +1,7 @@ import assert from "node:assert/strict"; import { execFileSync } from "node:child_process"; -import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +import { createHash } from "node:crypto"; +import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; import os from "node:os"; import path from "node:path"; import test from "node:test"; @@ -125,3 +126,66 @@ test("macOS release policy is fail-closed and Foundation-scoped", async () => { assert.match(verifier, /FWJYW4S8P8/); assert.match(workflow, /Verify signed macOS release assets/); }); + +test( + "server verification accepts long tool output without SIGPIPE", + { skip: process.platform === "win32" }, + async () => { + const scratch = await mkdtemp(path.join(os.tmpdir(), "clickclack-verifier-test-")); + try { + const bin = path.join(scratch, "bin"); + const payload = path.join(scratch, "payload"); + const release = path.join(scratch, "release"); + await Promise.all([bin, payload, release].map((directory) => mkdir(directory))); + await writeFile( + path.join(payload, "clickclack"), + "#!/bin/sh\necho 'clickclack 9.9.9 (synthetic)'\n", + { mode: 0o755 }, + ); + await writeFile( + path.join(bin, "lipo"), + '#!/bin/sh\ncase "$2" in */aarch64/*) echo arm64;; *) echo x86_64;; esac\n', + { mode: 0o755 }, + ); + await writeFile( + path.join(bin, "codesign"), + '#!/usr/bin/env python3\nimport sys\nif "-dvvv" in sys.argv:\n print("CodeDirectory v=20400 flags=0x10000(runtime)")\n print("padding\\n" * 200000)\n', + { mode: 0o755 }, + ); + await writeFile( + path.join(bin, "otool"), + '#!/usr/bin/env python3\nprint("cmd LC_BUILD_VERSION\\nminos 13.0")\nprint("padding\\n" * 200000)\n', + { mode: 0o755 }, + ); + const checksums = []; + for (const arch of ["amd64", "aarch64"]) { + const name = `clickclack_9.9.9_darwin_${arch}.tar.gz`; + const archive = path.join(release, name); + execFileSync("tar", ["-czf", archive, "-C", payload, "clickclack"]); + checksums.push( + `${createHash("sha256") + .update(await readFile(archive)) + .digest("hex")} ${name}`, + ); + } + await writeFile( + path.join(release, "ClickClack-9.9.9-mac-server-SHA256SUMS.txt"), + `${checksums.join("\n")}\n`, + ); + const output = execFileSync( + "bash", + [path.join(root, "scripts", "verify-macos-server-release.sh"), "v9.9.9", release], + { + env: { PATH: `${bin}${path.delimiter}${process.env.PATH}`, TMPDIR: scratch }, + encoding: "utf8", + }, + ); + assert.match( + output, + /verified signed and notarized ClickClack 9\.9\.9 macOS server archives/, + ); + } finally { + await rm(scratch, { recursive: true, force: true }); + } + }, +); diff --git a/apps/desktop/scripts/verify-macos-server-release.sh b/apps/desktop/scripts/verify-macos-server-release.sh index b63b54cb..eed430df 100755 --- a/apps/desktop/scripts/verify-macos-server-release.sh +++ b/apps/desktop/scripts/verify-macos-server-release.sh @@ -31,8 +31,10 @@ PY test "$(lipo -archs "$binary")" = "$expected_arch" codesign --verify --strict -R="$requirement" --verbose=2 "$binary" codesign --verify --strict --check-notarization -R=notarized --verbose=2 "$binary" - codesign -dvvv "$binary" 2>&1 | grep -Eq '^CodeDirectory .*flags=.*\([^)]*runtime[^)]*\)' - minos=$(otool -l "$binary" | awk '/LC_BUILD_VERSION/{build=1;next} build && /minos/{print $2;exit}') + signature=$(codesign -dvvv "$binary" 2>&1) + grep -Eq '^CodeDirectory .*flags=.*\([^)]*runtime[^)]*\)' <<<"$signature" + load_commands=$(otool -l "$binary") + minos=$(awk '/LC_BUILD_VERSION/{build=1;next} build && /minos/{print $2;exit}' <<<"$load_commands") test "$minos" = 13.0 if [[ "$(uname -m)" = "$expected_arch" ]]; then "$binary" version | grep -F "$version" diff --git a/docs/releasing.md b/docs/releasing.md index 1a653fcc..0a208b21 100644 --- a/docs/releasing.md +++ b/docs/releasing.md @@ -115,3 +115,18 @@ attached and the draft is published. GoReleaser reuses the existing draft and replaces matching server assets, while the desktop uploader replaces matching Windows and Linux assets. This makes a failed draft release safe to retry without making published releases mutable. + +If local packaging produced all archives but a verifier defect stopped completion, +preserve those candidates and fix the verifier through normal review and CI on +protected `main`. Verify the preserved artifacts with that trusted checkout, +then finish the desktop checksum manifest before creating the draft: + +```sh +"$VERIFY_TREE/apps/desktop/scripts/verify-macos-release.sh" v0.7.0 "$RELEASE_DIR" +node "$VERIFY_TREE/apps/desktop/scripts/release-artifacts.mjs" mac 0.7.0 "$RELEASE_DIR" +``` + +`VERIFY_TREE` is the clean, reviewed `main` checkout and `RELEASE_DIR` contains the +artifacts already built from the signed tag. Every signature, notarization, version, +and architecture check must pass. The signed tag and built code remain unchanged; +the workflow independently repeats verification from protected `main`.