From 4acc58e619132da65c694b90e1d47cad68ecb400 Mon Sep 17 00:00:00 2001 From: leemour Date: Fri, 16 Oct 2026 01:58:35 +0200 Subject: [PATCH] fix(release): use shared lock ownership diagnostics --- CHANGELOG.md | 8 ++++ bin/release | 3 +- config/wirecat/manifest.json | 8 +++- config/wirecat/release-lock-info.mjs | 71 ++++++++++++++++++++++++++++ config/wirecat/release-lock.sh | 25 ++++++++++ 5 files changed, 113 insertions(+), 2 deletions(-) create mode 100644 config/wirecat/release-lock-info.mjs create mode 100644 config/wirecat/release-lock.sh diff --git a/CHANGELOG.md b/CHANGELOG.md index 4632e43..664953d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,14 @@ Versions before 0.8.0 are in the [git tags](https://github.com/WireCatLabs/cli-b Every entry says what changed as a caller sees it, why, and what to watch for — the rules are [`docs/dev/CONVENTIONS.md`](docs/dev/CONVENTIONS.md#the-changelog). +## Unreleased + +### Fixed + +- **Release scripts report observed lock holders separately from stale release labels.** Shared + diagnostics no longer attribute a live lock to a previous run; wait for the holders to finish, then + retry. + ## 0.38.0 — 16.10.2026 ### Added diff --git a/bin/release b/bin/release index adad7da..b11becb 100755 --- a/bin/release +++ b/bin/release @@ -29,7 +29,8 @@ git merge -q --ff-only origin/main # The workflow publishes origin/main, so a local commit not pushed yet would silently not ship. [ "$(git rev-parse HEAD)" = "$(git rev-parse origin/main)" ] || { echo "main is ahead of origin/main — push first" >&2; exit 1; } # One release at a time on this machine, across every package: two sessions published at once on 2026-10-08. -lock="${XDG_RUNTIME_DIR:-/tmp}/leemour-release.lock"; exec 9>"$lock"; flock -n 9 || { echo "another release is running: $(cat "$lock.who" 2>/dev/null)" >&2; exit 1; }; echo "$(basename "$PWD") pid $$ since $(date -u +%H:%M)" > "$lock.who" +. ./config/wirecat/release-lock.sh +wirecat_release_lock version=$(node -p 'require("./package.json").version') # `npm view` rather than the registry document, which a CDN serves stale for minutes. diff --git a/config/wirecat/manifest.json b/config/wirecat/manifest.json index e7a821f..7400732 100644 --- a/config/wirecat/manifest.json +++ b/config/wirecat/manifest.json @@ -11,7 +11,9 @@ "no-shell-true.grit": "d3943cfa1d7ebbf7be0fa22c2aaf2b7d7aa9ff462f2eb14a7798632764e3875c", "no-unbounded-spread.grit": "f4178e5b74212fef58fec8916b6783d9f4f192ac618aa4ced041c4167bfbf81b", "rumdl.toml": "048b929176f13234324f5825d1e811397f1491b9cb6684a1953e268e2c2ed863", - "check.mjs": "34b266411eaab7707030915f645308e86784b1574b21fa44ca974861a90c9ce1" + "check.mjs": "34b266411eaab7707030915f645308e86784b1574b21fa44ca974861a90c9ce1", + "release-lock.sh": "f3309e569daea423c35f2a7b51cd55aec24c28160428b67ee2ece05e67fab877", + "release-lock-info.mjs": "4abab1f01ceee7d4c709222e923c9a6fc634bcf7b8b64d745a9ac495993310e0" }, "packageManager": "pnpm@11.20.0", "node": ">=22", @@ -23,5 +25,9 @@ "cspell": "10.3.6", "lefthook": "2.1.16", "markdownlint-cli2": "0.23.3" + }, + "fileRevisions": { + "release-lock.sh": "f5e184c78cb170158773d2270cecb14431315ed6", + "release-lock-info.mjs": "f5e184c78cb170158773d2270cecb14431315ed6" } } diff --git a/config/wirecat/release-lock-info.mjs b/config/wirecat/release-lock-info.mjs new file mode 100644 index 0000000..631e6ed --- /dev/null +++ b/config/wirecat/release-lock-info.mjs @@ -0,0 +1,71 @@ +import { execFileSync } from "node:child_process" +import { closeSync, openSync, readSync, realpathSync } from "node:fs" +import { fileURLToPath } from "node:url" + +const safe = (value) => JSON.stringify(String(value)) + +export function releaseLockInfo(path, { run = execFileSync, realpath = realpathSync, label = readLabel } = {}) { + const lines = [`another release holds ${safe(path)}`] + let rows + for (const columns of ["PID,COMMAND,PATH,TYPE,MODE,HOLDERS", "PID,COMMAND,PATH,TYPE,MODE"]) { + try { + const result = JSON.parse( + run("lslocks", ["--json", "--output", columns], { + encoding: "utf8", + timeout: 2000, + maxBuffer: 1024 * 1024, + stdio: ["ignore", "pipe", "ignore"], + }), + ) + rows = result.locks + break + } catch { + // Older util-linux lacks HOLDERS; diagnostics also work without lslocks. + } + } + const holders = new Set() + if (Array.isArray(rows)) { + for (const row of rows) { + try { + if (row.type !== "FLOCK" || row.mode?.endsWith("*")) continue + if (realpath(row.path) !== realpath(path)) continue + const before = holders.size + if (Array.isArray(row.holders)) { + for (const holder of row.holders) { + const match = /^(\d+),([^,]+),\d+$/.exec(holder) + if (match && Number(match[1]) > 0) holders.add(`pid ${match[1]} (${safe(match[2])})`) + } + } + if (holders.size === before && Number.isInteger(row.pid) && row.pid > 0) + holders.add(`pid ${row.pid} (${safe(row.command)})`) + } catch { + // A holder may exit while the snapshot is read, or hide its path. + } + } + } + lines.push( + holders.size + ? `Observed lock holders (may change): ${[...holders].join(", ")}` + : "Observed lock holders: unavailable", + ) + try { + const recorded = label(`${path}.who`) + if (recorded) lines.push(`Last recorded release (may be stale): ${safe(recorded)}`) + } catch { + // The label is advisory; only flock decides whether a release may proceed. + } + lines.push("Wait for the lock holders to finish, then retry. Do not remove the lock file.") + return `${lines.join("\n")}\n` +} + +function readLabel(path) { + const fd = openSync(path, "r") + try { + const bytes = Buffer.alloc(512) + return bytes.subarray(0, readSync(fd, bytes)).toString("utf8").trim() + } finally { + closeSync(fd) + } +} + +if (process.argv[1] === fileURLToPath(import.meta.url)) process.stderr.write(releaseLockInfo(process.argv[2])) diff --git a/config/wirecat/release-lock.sh b/config/wirecat/release-lock.sh new file mode 100644 index 0000000..3186e55 --- /dev/null +++ b/config/wirecat/release-lock.sh @@ -0,0 +1,25 @@ +# Source from the repository root; FD9 must stay in the release shell and its children. +wirecat_release_lock() { + _wirecat_release_wait=${1-0} + case "$_wirecat_release_wait" in + ''|*[!0-9]*) echo "release wait must be a nonnegative integer" >&2; return 2 ;; + esac + command -v flock >/dev/null 2>&1 || { + echo "release locking requires flock" >&2 + return 1 + } + _wirecat_release_lock="${XDG_RUNTIME_DIR:-/tmp}/leemour-release.lock" + exec 9>>"$_wirecat_release_lock" + if flock -E 75 -w "$_wirecat_release_wait" 9; then + printf '%s pid %s since %s\n' "$(basename "$PWD")" "$$" "$(date -u +%Y-%m-%dT%H:%M:%SZ)" > "$_wirecat_release_lock.who" + else + _wirecat_release_status=$? + if [ "$_wirecat_release_status" -ne 75 ]; then + printf 'could not acquire release lock %s (flock exit %s)\n' "$_wirecat_release_lock" "$_wirecat_release_status" >&2 + return 1 + fi + node ./config/wirecat/release-lock-info.mjs "$_wirecat_release_lock" || + printf 'another release holds %s; holder details unavailable\n' "$_wirecat_release_lock" >&2 + return 1 + fi +}