-
-
Notifications
You must be signed in to change notification settings - Fork 89
ops: escalate a workflow that has failed on consecutive runs #10147
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
+208
−29
Merged
Changes from all commits
Commits
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,116 @@ | ||
| #!/usr/bin/env node | ||
| // Escalate a workflow that has failed on CONSECUTIVE runs, once per outage (#10146, generalising #9951). | ||
| // | ||
| // #9951 built this for the publish workflows after they failed on every single main commit for as far back | ||
| // as the run history went -- a one-line missing build step -- while nobody noticed, because the only signal | ||
| // was a `::warning::` nobody reads and a red check that looks like release noise. | ||
| // | ||
| // The identical thing then happened to selfhost.yml. Migration 0209 shipped SQLite-only `AUTOINCREMENT` | ||
| // (#10138); the real-Postgres suite caught it correctly on the very first push, and the workflow stayed red | ||
| // across five consecutive runs while PRs kept merging, because a post-merge failure blocks nothing and pages | ||
| // no one. Two instances of one class is the point at which the mechanism belongs in one place instead of | ||
| // being reimplemented per workflow -- which is why this is a script both callers invoke rather than a second | ||
| // copy of the bash. | ||
| // | ||
| // ── A FLAKE AND AN OUTAGE ARE DIFFERENT THINGS ──────────────────────────────────────────────────────────── | ||
| // A deterministic failure fails identically every time, so a retry buys nothing and a single red run is not | ||
| // evidence of one. Consecutive failures at the HEAD of the run history are. Below the threshold this stays | ||
| // silent on purpose: an alert that fires on every transient red is the same unread noise in a new place. | ||
| // | ||
| // ── ONCE PER OUTAGE ─────────────────────────────────────────────────────────────────────────────────────── | ||
| // An open tracking issue is reused rather than a fresh one filed per commit, for the same reason. | ||
|
|
||
| import { execFileSync } from "node:child_process"; | ||
|
|
||
| /** | ||
| * PURE. How many runs at the head of the history did NOT succeed. | ||
| * | ||
| * `runs` is newest-first, as the GitHub API returns it. A window with no success anywhere means the whole | ||
| * window is bad -- that is the standing-outage case, and reporting `length` rather than 0 is what makes it | ||
| * escalate instead of silently reading as healthy. That distinction is the entire point: the naive | ||
| * `indexOf("success")` returns -1 there, and -1 treated as a count would report "no failures" for the worst | ||
| * possible state. | ||
| */ | ||
| export function leadingNonSuccessCount(runs: readonly (string | null | undefined)[]): number { | ||
| const firstSuccess = runs.findIndex((conclusion) => conclusion === "success"); | ||
| return firstSuccess === -1 ? runs.length : firstSuccess; | ||
| } | ||
|
|
||
| /** The tracking issue's title for a workflow. Stable, and derived from the workflow file name, so the | ||
| * reuse-an-open-issue lookup below can find the one this outage already filed. */ | ||
| export function outageIssueTitle(workflow: string): string { | ||
| return `workflow outage: ${workflow} has failed on consecutive runs`; | ||
| } | ||
|
|
||
| function gh(args: readonly string[]): string { | ||
| return execFileSync("gh", args, { encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] }).trim(); | ||
| } | ||
|
|
||
| function outageBody(workflow: string, streak: number, threshold: number): string { | ||
| return [ | ||
| `\`${workflow}\` has failed on **${streak} consecutive runs**.`, | ||
| "", | ||
| "That is no longer a flake being retried -- a deterministic failure fails identically every time, so this", | ||
| "has been broken for that entire stretch and every run since the first one was already telling us so.", | ||
| "", | ||
| "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.`, | ||
| "", | ||
| "Filed automatically by scripts/escalate-workflow-outage.ts (#10146).", | ||
| ].join("\n"); | ||
| } | ||
|
|
||
| function parseArg(name: string, fallback?: string): string { | ||
| const index = process.argv.indexOf(`--${name}`); | ||
| const value = index === -1 ? undefined : process.argv[index + 1]; | ||
| if (value === undefined || value.startsWith("--")) { | ||
| if (fallback !== undefined) return fallback; | ||
| console.error(`escalate-workflow-outage: --${name} is required`); | ||
| process.exit(2); | ||
| } | ||
| return value; | ||
| } | ||
|
|
||
| function main(): void { | ||
| const workflow = parseArg("workflow"); | ||
| const threshold = Number(parseArg("threshold", "3")); | ||
| const repo = process.env.GITHUB_REPOSITORY ?? ""; | ||
| if (!repo) { | ||
| console.error("escalate-workflow-outage: GITHUB_REPOSITORY is not set"); | ||
| process.exit(2); | ||
| } | ||
|
|
||
| let conclusions: (string | null)[] = []; | ||
| try { | ||
| conclusions = JSON.parse( | ||
| gh(["api", `repos/${repo}/actions/workflows/${workflow}/runs?per_page=10&status=completed`, "--jq", "[.workflow_runs[].conclusion]"]), | ||
| ) as (string | null)[]; | ||
| } catch (error) { | ||
| // Never fail the caller over the ALERTING path -- the workflow this runs in has already failed, and | ||
| // turning "could not check the streak" into a second red is pure noise on top of the real problem. | ||
| console.warn(`::warning::escalate-workflow-outage: could not read run history for ${workflow}: ${String(error)}`); | ||
| return; | ||
| } | ||
|
|
||
| const streak = leadingNonSuccessCount(conclusions); | ||
| if (streak < threshold) { | ||
| console.log(`${workflow}: ${streak} consecutive failure(s) -- below the ${threshold}-run escalation threshold, treating as transient.`); | ||
| return; | ||
| } | ||
|
|
||
| const title = outageIssueTitle(workflow); | ||
| try { | ||
| const existing = gh(["issue", "list", "--repo", repo, "--state", "open", "--search", `${title} in:title`, "--json", "number", "--jq", ".[0].number // empty"]); | ||
| if (existing) { | ||
| console.log(`${workflow}: standing outage already tracked in #${existing} -- not filing a duplicate.`); | ||
| return; | ||
| } | ||
| gh(["issue", "create", "--repo", repo, "--title", title, "--label", "maintainer-only", "--body", outageBody(workflow, streak, threshold)]); | ||
| console.log(`::error::${workflow} has failed ${streak} consecutive runs -- standing outage filed.`); | ||
| } catch (error) { | ||
| console.warn(`::warning::${workflow} standing outage detected but the tracking issue could not be filed: ${String(error)}`); | ||
| } | ||
| } | ||
|
|
||
| // Only run when invoked directly, so the pure helpers above stay importable by tests. | ||
| if (process.argv[1] && import.meta.url === `file://${process.argv[1]}`) main(); |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,58 @@ | ||
| import { describe, expect, it } from "vitest"; | ||
|
|
||
| import { leadingNonSuccessCount, outageIssueTitle } from "../../scripts/escalate-workflow-outage"; | ||
|
|
||
| // #10146: a post-merge workflow going red blocks nothing and pages no one. | ||
| // | ||
| // #9951 built this counting for the publish workflows after they failed on every main commit for as far back | ||
| // as the run history went, unnoticed. The same thing then happened to selfhost.yml: it caught migration | ||
| // 0209's SQLite-only AUTOINCREMENT (#10138) correctly on the FIRST push and stayed red for five consecutive | ||
| // runs while PRs kept merging. Two instances of one class is when the mechanism belongs in one place, so the | ||
| // bash moved into a script both workflows call -- and the arithmetic that decides "flake or outage" is the | ||
| // part worth pinning, because getting it wrong in either direction destroys the alert's usefulness. | ||
|
|
||
| describe("leadingNonSuccessCount (#10146)", () => { | ||
| it("counts the unbroken run of non-successes at the HEAD of the history", () => { | ||
| // Newest-first, as the GitHub API returns it. | ||
| expect(leadingNonSuccessCount(["failure", "failure", "success", "failure"])).toBe(2); | ||
| }); | ||
|
|
||
| it("is zero when the most recent run succeeded, however bad the history behind it", () => { | ||
| // A fixed workflow must stop alerting immediately -- an alert that persists after the fix gets muted, | ||
| // and then the NEXT real outage is invisible. | ||
| expect(leadingNonSuccessCount(["success", "failure", "failure", "failure", "failure"])).toBe(0); | ||
| }); | ||
|
|
||
| it("REGRESSION: a window with NO success anywhere reports the whole window, not zero", () => { | ||
| // The case the whole mechanism exists for, and the easiest to get backwards. `indexOf("success")` | ||
| // returns -1 here; treating that as a count reports "no failures" for the single worst possible state -- | ||
| // a workflow that has never once succeeded in its recorded history. That is precisely the shape #9951 | ||
| // found (publish red on every commit as far back as the history went) and the shape selfhost.yml was in | ||
| // for five runs. | ||
| expect(leadingNonSuccessCount(["failure", "failure", "failure"])).toBe(3); | ||
| expect(leadingNonSuccessCount(Array(10).fill("failure"))).toBe(10); | ||
| }); | ||
|
|
||
| it("treats cancelled, timed_out and null as non-successes — only an actual success breaks the streak", () => { | ||
| // A cancelled or still-unrecorded run is not evidence the workflow works. Counting it as a success would | ||
| // silently reset the streak and suppress the alert. | ||
| expect(leadingNonSuccessCount(["cancelled", "timed_out", null, undefined, "failure", "success"])).toBe(5); | ||
| }); | ||
|
|
||
| it("is zero for an empty history, so a brand-new workflow never alerts", () => { | ||
| expect(leadingNonSuccessCount([])).toBe(0); | ||
| }); | ||
|
|
||
| it("does not treat a non-'success' string as success on a prefix match", () => { | ||
| expect(leadingNonSuccessCount(["successful", "success"])).toBe(1); | ||
| }); | ||
| }); | ||
|
|
||
| describe("outageIssueTitle", () => { | ||
| it("is derived from the workflow file, so the reuse lookup finds the issue this outage already filed", () => { | ||
| // Filing once per outage instead of once per commit is the difference between an alert and noise, and it | ||
| // depends entirely on this string being stable and identifying. | ||
| expect(outageIssueTitle("selfhost.yml")).toBe("workflow outage: selfhost.yml has failed on consecutive runs"); | ||
| expect(outageIssueTitle("publish-mcp.yml")).not.toBe(outageIssueTitle("publish-miner.yml")); | ||
| }); | ||
| }); |
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.