Skip to content

Commit dcce55c

Browse files
authored
ops: escalate a workflow that has failed on consecutive runs (#10147)
selfhost.yml is push-to-main only. It caught migration 0209's SQLite-only AUTOINCREMENT (#10138) correctly on the very first push -- the real-Postgres "applies every migration" test failed exactly as designed -- and then stayed red across five consecutive runs while PRs kept merging. The breakage was found by deploying the resulting image and watching the Orb crash-loop. Nothing was wrong with the test. A post-merge failure blocks nothing and pages no one, so a red run is indistinguishable from one somebody is already on. #9951 solved this exact class for the publish workflows, whose own comment says why it went unnoticed: the only signal was a ::warning:: nobody reads and a red check that looks like release noise. It landed as ~30 lines of inline bash. Needing it in a second place is the point at which it belongs in one place, so the mechanism moves to scripts/escalate-workflow-outage.ts and mcp-release-please.yml now calls that instead of carrying its own copy. Consecutive failures only, threshold 3: a deterministic failure fails identically every time, so one red run is a flake and alerting on it is how an alert gets muted. One open tracking issue is reused rather than a new one filed per commit, for the same reason. The escalation never fails its caller -- that workflow has already failed, and a second red over "could not read the run history" is noise on top of the real problem. leadingNonSuccessCount is unit-tested on the case that is easy to get exactly backwards: a window with no success anywhere. indexOf returns -1 there, and -1 as a count reports "no failures" for a workflow that has never once succeeded -- the precise shape #9951 found and the shape selfhost.yml was in. Cancelled, timed_out and null count as non-successes so a cancelled run cannot silently reset the streak. Behaviour change for the existing caller: the tracking issue title is now workflow-generic rather than publish-specific. No open issue used the old title, so no reuse is broken. Closes #10146
1 parent 1807ff4 commit dcce55c

4 files changed

Lines changed: 208 additions & 29 deletions

File tree

.github/workflows/mcp-release-please.yml

Lines changed: 4 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -343,36 +343,11 @@ jobs:
343343
# number of leading non-successes; no success in the window at all means the whole window is bad.
344344
# Escalates ONCE per outage by reusing an open issue instead of filing a new one per commit -- an
345345
# alert that repeats per attempt is the same unread noise in a different place.
346+
# #10146: this was 30 lines of inline bash duplicating what selfhost.yml now also needs. Same class,
347+
# same threshold, one implementation -- scripts/escalate-workflow-outage.ts. Behaviour is unchanged
348+
# except the tracking issue's title, which is now workflow-generic rather than publish-specific.
346349
escalate_persistent_publish_failure() {
347-
local workflow="$1" threshold=3 leading
348-
# $c below is a JQ variable bound by `as $c`, so the single quotes are required -- letting the
349-
# shell expand it would hand jq an empty filter.
350-
# shellcheck disable=SC2016
351-
leading=$(gh api "repos/${GITHUB_REPOSITORY}/actions/workflows/${workflow}/runs?per_page=10&status=completed" \
352-
--jq '[.workflow_runs[].conclusion] as $c | (($c | index("success")) // ($c | length))' 2>/dev/null || echo 0)
353-
if [ "${leading:-0}" -lt "$threshold" ]; then
354-
echo "$workflow: ${leading:-0} consecutive failure(s) -- below the $threshold-commit escalation threshold, treating as transient."
355-
return 0
356-
fi
357-
local body
358-
body=$(printf '%s\n' \
359-
"\`$workflow\` has failed on **${leading} consecutive runs**." \
360-
"" \
361-
"That is no longer a flake being retried -- a deterministic failure fails identically every time, so the package has not been publishing at all for that entire stretch. Retrying it further buys nothing." \
362-
"" \
363-
"Check the most recent run's logs, fix the cause, and close this issue; it is re-filed automatically only if the failure streak reaches ${threshold} again after a success." \
364-
"" \
365-
"Filed automatically by the release workflow (#9951).")
366-
local existing
367-
existing=$(gh issue list --state open --search "publish outage ${workflow} in:title" --json number --jq '.[0].number // empty' 2>/dev/null || echo "")
368-
if [ -n "$existing" ]; then
369-
echo "$workflow: standing outage already tracked in #$existing -- not filing a duplicate."
370-
else
371-
gh issue create --title "publish outage: $workflow has failed on consecutive commits" \
372-
--label maintainer-only --body "$body" >/dev/null \
373-
&& echo "::error::$workflow has failed $leading consecutive runs -- standing outage filed." \
374-
|| echo "::warning::$workflow standing outage detected but the tracking issue could not be filed."
375-
fi
350+
node --experimental-strip-types "${GITHUB_WORKSPACE}/scripts/escalate-workflow-outage.ts" --workflow "$1"
376351
}
377352
378353
# packages/loopover-mcp and packages/loopover-miner carry REAL runtime `dependencies` entries on

.github/workflows/selfhost.yml

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -172,3 +172,33 @@ jobs:
172172
curl -sf http://127.0.0.1:8787/metrics | grep -q 'loopover_uptime_seconds'
173173
docker logs gt 2>&1 | grep -q 'selfhost_migrations_applied'
174174
echo "self-host smoke test passed"
175+
176+
# #10146: a post-merge workflow going red blocks nothing and pages no one. This one caught migration 0209's
177+
# SQLite-only AUTOINCREMENT correctly on the very first push (#10138) and then stayed red across five
178+
# consecutive runs while PRs kept merging, because the only signal was a red check on a branch nobody was
179+
# watching. #9951 had already solved this exact class for the publish workflows; the escalation is now a
180+
# shared script rather than a second copy of it.
181+
#
182+
# Deliberately `failure()` and not `always()`: a success must never file anything. Deliberately consecutive
183+
# -- one red run is a flake and alerting on it is how an alert gets muted.
184+
escalate-persistent-failure:
185+
name: escalate persistent failure
186+
needs: build-boot
187+
if: ${{ failure() }}
188+
runs-on: ubuntu-latest
189+
timeout-minutes: 5
190+
permissions:
191+
contents: read
192+
# The one thing this job does that build-boot cannot: file the tracking issue.
193+
issues: write
194+
steps:
195+
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
196+
with:
197+
persist-credentials: false
198+
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7
199+
with:
200+
node-version-file: .nvmrc
201+
- name: Escalate if this workflow has failed on consecutive runs
202+
env:
203+
GH_TOKEN: ${{ github.token }}
204+
run: node --experimental-strip-types scripts/escalate-workflow-outage.ts --workflow selfhost.yml
Lines changed: 116 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,116 @@
1+
#!/usr/bin/env node
2+
// Escalate a workflow that has failed on CONSECUTIVE runs, once per outage (#10146, generalising #9951).
3+
//
4+
// #9951 built this for the publish workflows after they failed on every single main commit for as far back
5+
// as the run history went -- a one-line missing build step -- while nobody noticed, because the only signal
6+
// was a `::warning::` nobody reads and a red check that looks like release noise.
7+
//
8+
// The identical thing then happened to selfhost.yml. Migration 0209 shipped SQLite-only `AUTOINCREMENT`
9+
// (#10138); the real-Postgres suite caught it correctly on the very first push, and the workflow stayed red
10+
// across five consecutive runs while PRs kept merging, because a post-merge failure blocks nothing and pages
11+
// no one. Two instances of one class is the point at which the mechanism belongs in one place instead of
12+
// being reimplemented per workflow -- which is why this is a script both callers invoke rather than a second
13+
// copy of the bash.
14+
//
15+
// ── A FLAKE AND AN OUTAGE ARE DIFFERENT THINGS ────────────────────────────────────────────────────────────
16+
// A deterministic failure fails identically every time, so a retry buys nothing and a single red run is not
17+
// evidence of one. Consecutive failures at the HEAD of the run history are. Below the threshold this stays
18+
// silent on purpose: an alert that fires on every transient red is the same unread noise in a new place.
19+
//
20+
// ── ONCE PER OUTAGE ───────────────────────────────────────────────────────────────────────────────────────
21+
// An open tracking issue is reused rather than a fresh one filed per commit, for the same reason.
22+
23+
import { execFileSync } from "node:child_process";
24+
25+
/**
26+
* PURE. How many runs at the head of the history did NOT succeed.
27+
*
28+
* `runs` is newest-first, as the GitHub API returns it. A window with no success anywhere means the whole
29+
* window is bad -- that is the standing-outage case, and reporting `length` rather than 0 is what makes it
30+
* escalate instead of silently reading as healthy. That distinction is the entire point: the naive
31+
* `indexOf("success")` returns -1 there, and -1 treated as a count would report "no failures" for the worst
32+
* possible state.
33+
*/
34+
export function leadingNonSuccessCount(runs: readonly (string | null | undefined)[]): number {
35+
const firstSuccess = runs.findIndex((conclusion) => conclusion === "success");
36+
return firstSuccess === -1 ? runs.length : firstSuccess;
37+
}
38+
39+
/** The tracking issue's title for a workflow. Stable, and derived from the workflow file name, so the
40+
* reuse-an-open-issue lookup below can find the one this outage already filed. */
41+
export function outageIssueTitle(workflow: string): string {
42+
return `workflow outage: ${workflow} has failed on consecutive runs`;
43+
}
44+
45+
function gh(args: readonly string[]): string {
46+
return execFileSync("gh", args, { encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] }).trim();
47+
}
48+
49+
function outageBody(workflow: string, streak: number, threshold: number): string {
50+
return [
51+
`\`${workflow}\` has failed on **${streak} consecutive runs**.`,
52+
"",
53+
"That is no longer a flake being retried -- a deterministic failure fails identically every time, so this",
54+
"has been broken for that entire stretch and every run since the first one was already telling us so.",
55+
"",
56+
"Check the most recent run's logs, fix the cause, and close this issue. It is re-filed automatically only",
57+
`if the failure streak reaches ${threshold} again after a success.`,
58+
"",
59+
"Filed automatically by scripts/escalate-workflow-outage.ts (#10146).",
60+
].join("\n");
61+
}
62+
63+
function parseArg(name: string, fallback?: string): string {
64+
const index = process.argv.indexOf(`--${name}`);
65+
const value = index === -1 ? undefined : process.argv[index + 1];
66+
if (value === undefined || value.startsWith("--")) {
67+
if (fallback !== undefined) return fallback;
68+
console.error(`escalate-workflow-outage: --${name} is required`);
69+
process.exit(2);
70+
}
71+
return value;
72+
}
73+
74+
function main(): void {
75+
const workflow = parseArg("workflow");
76+
const threshold = Number(parseArg("threshold", "3"));
77+
const repo = process.env.GITHUB_REPOSITORY ?? "";
78+
if (!repo) {
79+
console.error("escalate-workflow-outage: GITHUB_REPOSITORY is not set");
80+
process.exit(2);
81+
}
82+
83+
let conclusions: (string | null)[] = [];
84+
try {
85+
conclusions = JSON.parse(
86+
gh(["api", `repos/${repo}/actions/workflows/${workflow}/runs?per_page=10&status=completed`, "--jq", "[.workflow_runs[].conclusion]"]),
87+
) as (string | null)[];
88+
} catch (error) {
89+
// Never fail the caller over the ALERTING path -- the workflow this runs in has already failed, and
90+
// turning "could not check the streak" into a second red is pure noise on top of the real problem.
91+
console.warn(`::warning::escalate-workflow-outage: could not read run history for ${workflow}: ${String(error)}`);
92+
return;
93+
}
94+
95+
const streak = leadingNonSuccessCount(conclusions);
96+
if (streak < threshold) {
97+
console.log(`${workflow}: ${streak} consecutive failure(s) -- below the ${threshold}-run escalation threshold, treating as transient.`);
98+
return;
99+
}
100+
101+
const title = outageIssueTitle(workflow);
102+
try {
103+
const existing = gh(["issue", "list", "--repo", repo, "--state", "open", "--search", `${title} in:title`, "--json", "number", "--jq", ".[0].number // empty"]);
104+
if (existing) {
105+
console.log(`${workflow}: standing outage already tracked in #${existing} -- not filing a duplicate.`);
106+
return;
107+
}
108+
gh(["issue", "create", "--repo", repo, "--title", title, "--label", "maintainer-only", "--body", outageBody(workflow, streak, threshold)]);
109+
console.log(`::error::${workflow} has failed ${streak} consecutive runs -- standing outage filed.`);
110+
} catch (error) {
111+
console.warn(`::warning::${workflow} standing outage detected but the tracking issue could not be filed: ${String(error)}`);
112+
}
113+
}
114+
115+
// Only run when invoked directly, so the pure helpers above stay importable by tests.
116+
if (process.argv[1] && import.meta.url === `file://${process.argv[1]}`) main();
Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
import { describe, expect, it } from "vitest";
2+
3+
import { leadingNonSuccessCount, outageIssueTitle } from "../../scripts/escalate-workflow-outage";
4+
5+
// #10146: a post-merge workflow going red blocks nothing and pages no one.
6+
//
7+
// #9951 built this counting for the publish workflows after they failed on every main commit for as far back
8+
// as the run history went, unnoticed. The same thing then happened to selfhost.yml: it caught migration
9+
// 0209's SQLite-only AUTOINCREMENT (#10138) correctly on the FIRST push and stayed red for five consecutive
10+
// runs while PRs kept merging. Two instances of one class is when the mechanism belongs in one place, so the
11+
// bash moved into a script both workflows call -- and the arithmetic that decides "flake or outage" is the
12+
// part worth pinning, because getting it wrong in either direction destroys the alert's usefulness.
13+
14+
describe("leadingNonSuccessCount (#10146)", () => {
15+
it("counts the unbroken run of non-successes at the HEAD of the history", () => {
16+
// Newest-first, as the GitHub API returns it.
17+
expect(leadingNonSuccessCount(["failure", "failure", "success", "failure"])).toBe(2);
18+
});
19+
20+
it("is zero when the most recent run succeeded, however bad the history behind it", () => {
21+
// A fixed workflow must stop alerting immediately -- an alert that persists after the fix gets muted,
22+
// and then the NEXT real outage is invisible.
23+
expect(leadingNonSuccessCount(["success", "failure", "failure", "failure", "failure"])).toBe(0);
24+
});
25+
26+
it("REGRESSION: a window with NO success anywhere reports the whole window, not zero", () => {
27+
// The case the whole mechanism exists for, and the easiest to get backwards. `indexOf("success")`
28+
// returns -1 here; treating that as a count reports "no failures" for the single worst possible state --
29+
// a workflow that has never once succeeded in its recorded history. That is precisely the shape #9951
30+
// found (publish red on every commit as far back as the history went) and the shape selfhost.yml was in
31+
// for five runs.
32+
expect(leadingNonSuccessCount(["failure", "failure", "failure"])).toBe(3);
33+
expect(leadingNonSuccessCount(Array(10).fill("failure"))).toBe(10);
34+
});
35+
36+
it("treats cancelled, timed_out and null as non-successes — only an actual success breaks the streak", () => {
37+
// A cancelled or still-unrecorded run is not evidence the workflow works. Counting it as a success would
38+
// silently reset the streak and suppress the alert.
39+
expect(leadingNonSuccessCount(["cancelled", "timed_out", null, undefined, "failure", "success"])).toBe(5);
40+
});
41+
42+
it("is zero for an empty history, so a brand-new workflow never alerts", () => {
43+
expect(leadingNonSuccessCount([])).toBe(0);
44+
});
45+
46+
it("does not treat a non-'success' string as success on a prefix match", () => {
47+
expect(leadingNonSuccessCount(["successful", "success"])).toBe(1);
48+
});
49+
});
50+
51+
describe("outageIssueTitle", () => {
52+
it("is derived from the workflow file, so the reuse lookup finds the issue this outage already filed", () => {
53+
// Filing once per outage instead of once per commit is the difference between an alert and noise, and it
54+
// depends entirely on this string being stable and identifying.
55+
expect(outageIssueTitle("selfhost.yml")).toBe("workflow outage: selfhost.yml has failed on consecutive runs");
56+
expect(outageIssueTitle("publish-mcp.yml")).not.toBe(outageIssueTitle("publish-miner.yml"));
57+
});
58+
});

0 commit comments

Comments
 (0)