Skip to content
Closed
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
57 changes: 57 additions & 0 deletions .github/workflows/prod-smoke.yml
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,9 @@ jobs:
name: Production Smoke
runs-on: ubuntu-latest
timeout-minutes: 15
permissions:
contents: read
actions: read
env:
PLAYWRIGHT_BASE_URL: https://www.linejam.app
PLAYWRIGHT_REQUIRE_AUTH_SMOKE: '1'
Expand Down Expand Up @@ -43,13 +46,19 @@ jobs:
run: pnpm exec playwright install chromium --with-deps

- name: Run production smoke
id: smoke
run: |
mkdir -p "$RUNNER_TEMP/linejam-smoke"
set +e
pnpm canary:smoke > "$RUNNER_TEMP/linejam-smoke/stdout.log" 2> "$RUNNER_TEMP/linejam-smoke/stderr.log"
code=$?
cat "$RUNNER_TEMP/linejam-smoke/stdout.log"
cat "$RUNNER_TEMP/linejam-smoke/stderr.log" >&2
{
echo "detail<<SMOKE_DETAIL_EOF"
tail -c 2000 "$RUNNER_TEMP/linejam-smoke/stderr.log"
echo "SMOKE_DETAIL_EOF"
} >> "$GITHUB_OUTPUT"
Comment on lines +49 to +61

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔒 Security & Privacy | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Inspect the workflow around the referenced lines
file=".github/workflows/prod-smoke.yml"
wc -l "$file"
sed -n '1,160p' "$file"

echo
echo "---- search for LINEJAM_SMOKE_FAILURE_DETAIL and steps.smoke.outputs.detail ----"
rg -n "LINEJAM_SMOKE_FAILURE_DETAIL|steps\.smoke\.outputs\.detail|SMOKE_DETAIL_EOF|GITHUB_OUTPUT" .github/workflows . -g '*.yml' -g '*.yaml' -g '*.md'

Repository: misty-step/linejam

Length of output: 5521


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Inspect GitHub Actions docs locally? (repo files only)
rg -n "multiline|GITHUB_OUTPUT|delimiter|random" .github README.md docs -g '*.md' -g '*.yml' -g '*.yaml' || true

Repository: misty-step/linejam

Length of output: 837


Use a unique GITHUB_OUTPUT delimiter here. A fixed SMOKE_DETAIL_EOF marker can appear in the captured stderr and truncate the multiline value, letting extra text be parsed as new outputs. Generate a per-run delimiter instead.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In @.github/workflows/prod-smoke.yml around lines 49 - 61, The smoke job output
block uses a fixed multiline delimiter, which can be collided with by captured
stderr and corrupt the GitHub Actions output parsing. Update the `id: smoke`
step in the workflow to generate a unique per-run delimiter before writing to
`GITHUB_OUTPUT`, and use that dynamic marker for the `detail` multiline value
instead of the hardcoded `SMOKE_DETAIL_EOF`.

exit "$code"

- name: Upload production smoke logs
Expand All @@ -70,3 +79,51 @@ jobs:
playwright-report/
if-no-files-found: ignore
retention-days: 14

# linejam-913 (2026-07-04 outage postmortem): Production Smoke was RED
# for ~15 hours before the operator found the outage by hand -- the
# gate worked, nothing wired the red signal to a human or to BB
# triage. These two steps close that wire: count the consecutive
# failure streak (so one blip is an annotation, not a page), then
# report status to the `linejam-production-smoke` Canary monitor,
# whose `error` check-in maps directly to Canary's Down health state
# and opens/holds an incident that BB triage and the bridge feed both
# already consume. A passing run always reports `ok`, which resolves
# the incident.
- name: Determine consecutive-failure streak
id: streak
if: always()
env:
GITHUB_TOKEN: ${{ github.token }}
run: |
count="$(node scripts/ops/count-consecutive-prod-smoke-failures.mjs '${{ steps.smoke.outcome }}')"
echo "count=$count" >> "$GITHUB_OUTPUT"

- name: Report status to Canary
if: always()
env:
NEXT_PUBLIC_CANARY_API_KEY: ${{ secrets.NEXT_PUBLIC_CANARY_API_KEY }}
NEXT_PUBLIC_CANARY_ENDPOINT: ${{ secrets.NEXT_PUBLIC_CANARY_ENDPOINT }}
LINEJAM_SMOKE_OUTCOME: ${{ steps.smoke.outcome }}
LINEJAM_SMOKE_CONSECUTIVE_FAILURES: ${{ steps.streak.outputs.count }}
LINEJAM_SMOKE_RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
LINEJAM_SMOKE_FAILURE_DETAIL: ${{ steps.smoke.outputs.detail }}
run: node scripts/ops/report-prod-smoke-status.mjs

- name: Annotate failure in the step summary
if: steps.smoke.outcome == 'failure'
env:
STREAK_COUNT: ${{ steps.streak.outputs.count }}
RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
run: |
{
echo "## Production Smoke failed"
echo
echo "- Consecutive failures: ${STREAK_COUNT}"
echo "- Run: ${RUN_URL}"
if [ "${STREAK_COUNT}" -ge 2 ]; then
echo "- Escalated: reported to the \`linejam-production-smoke\` Canary monitor as Down (opens/holds an incident)."
else
echo "- Not yet escalated: below the 2-run threshold. Recorded on the monitor without opening an incident."
fi
} >> "$GITHUB_STEP_SUMMARY"
20 changes: 20 additions & 0 deletions docs/ops/canary-responder.md
Original file line number Diff line number Diff line change
Expand Up @@ -99,3 +99,23 @@ follow-up artifact is intentionally required for an incident or release
decision.

Canary itself treats generic signed webhooks as the stable product contract, so the responder should stay thin: verify, fetch context, store evidence, trigger the smoke harness, then hand off to follow-on agents.

## Production Smoke Failure Wire (linejam-913)

The `Production Smoke` workflow (`.github/workflows/prod-smoke.yml`) runs hourly and, before 2026-07-04, a red run just sat in the Actions tab: it failed hourly for ~15 hours before the operator found the outage by hand. The gate was working; nothing wired the red signal to a human or to BB triage.

Two scripts close that wire, running as the last steps of the job regardless of outcome (`if: always()`):

- `scripts/ops/count-consecutive-prod-smoke-failures.mjs` walks the workflow's recent completed-run history (via the GitHub REST API, `GITHUB_TOKEN`) to compute the consecutive-failure streak ending at the current run. A single blip does not escalate; only a genuine repeat does. On an API error it fails OPEN toward escalation rather than silence.
- `scripts/ops/report-prod-smoke-status.mjs` reports the outcome to the `linejam-production-smoke` Canary TTL monitor (`expected_every_ms=3600000`, `grace_ms=1800000`, created via `POST /api/v1/monitors`) via `POST /api/v1/check-ins`:
- Success → `status: "ok"` (Canary's Up health state; resolves any open incident).
- Failure, streak < 2 → `status: "alive"` (still Up; recorded in the monitor's check-in history and the run's step summary, but does not open an incident).
- Failure, streak >= 2 → `status: "error"` (Canary maps `error` check-ins directly to its Down health state, which opens/holds a `health_transition` incident that BB triage and the bridge feed already consume — no linejam-side webhook or bridge-specific code needed).

Reuses the existing `NEXT_PUBLIC_CANARY_API_KEY`/`NEXT_PUBLIC_CANARY_ENDPOINT` repository secrets (already ingest-scoped for client-side error reporting); no new secret was provisioned.

Live-verified end to end against the deployed monitor: a first simulated failure stayed `up`/`alive`; a second consecutive simulated failure flipped the monitor to `down` and opened `INC-bhg3g284flhp` (`signal_type: health_transition`); a simulated recovery run immediately resolved it. Query current state any time with:

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Minor: hyphenate "end-to-end."

LanguageTool flags this as a compound-modifier hyphenation opportunity.

✏️ Proposed fix
-Live-verified end to end against the deployed monitor: a first simulated failure stayed `up`/`alive`; a second consecutive simulated failure flipped the monitor to `down` and opened `INC-bhg3g284flhp` (`signal_type: health_transition`); a simulated recovery run immediately resolved it. Query current state any time with:
+Live-verified end-to-end against the deployed monitor: a first simulated failure stayed `up`/`alive`; a second consecutive simulated failure flipped the monitor to `down` and opened `INC-bhg3g284flhp` (`signal_type: health_transition`); a simulated recovery run immediately resolved it. Query current state any time with:
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
Live-verified end to end against the deployed monitor: a first simulated failure stayed `up`/`alive`; a second consecutive simulated failure flipped the monitor to `down` and opened `INC-bhg3g284flhp` (`signal_type: health_transition`); a simulated recovery run immediately resolved it. Query current state any time with:
Live-verified end-to-end against the deployed monitor: a first simulated failure stayed `up`/`alive`; a second consecutive simulated failure flipped the monitor to `down` and opened `INC-bhg3g284flhp` (`signal_type: health_transition`); a simulated recovery run immediately resolved it. Query current state any time with:
🧰 Tools
🪛 LanguageTool

[grammar] ~117-~117: Use a hyphen to join words.
Context: ...cret was provisioned. Live-verified end to end against the deployed monitor: a firs...

(QB_NEW_EN_HYPHEN)

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@docs/ops/canary-responder.md` at line 117, The phrase in the canary responder
documentation should use the standard compound modifier form; update the
sentence containing “end to end” in the docs text to “end-to-end.” Keep the
surrounding wording unchanged and make the edit in the same prose block so the
language is consistent throughout the canary-responder.md content.

Source: Linters/SAST tools


```bash
curl -fsS "$CANARY_ENDPOINT/api/v1/report?window=24h" -H "Authorization: Bearer $CANARY_API_KEY" | jq '.monitors[] | select(.name=="linejam-production-smoke")'
```
34 changes: 24 additions & 10 deletions next.config.ts
Original file line number Diff line number Diff line change
@@ -1,21 +1,34 @@
import type { NextConfig } from 'next';
import { validateEnv } from './lib/env';
import { deriveClerkFrontendOrigin } from './scripts/lib/clerk-domain.mjs';

// Validate required env vars during production builds
// This prevents deploying with missing configuration
if (process.env.NODE_ENV === 'production') {
validateEnv();
}

const STATIC_CLERK_SOURCES = [
// Clerk's Frontend API is served from *.clerk.accounts.dev for dev/preview
// keys, but a live key can point at a custom domain instead (production:
// clerk.linejam.app). A custom domain is invisible to any generic wildcard,
// so it must be derived from NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY at config
// time rather than hand-listed — the 2026-07-04 outage was exactly this
// list going stale: PR #291 hand-listed domains, missed the production
// custom domain, and CSP blocked auth site-wide for ~16h because preview
// smoke only ever exercised the (allowed) dev domain.
const GENERIC_CLERK_SOURCES = [
'https://*.clerk.accounts.dev',
'https://*.clerk.com',
'https://api.clerk.com',
// Production Clerk serves clerk-js and its frontend API from the custom
// domain; omitting it blocks auth entirely and dead-ends every room flow.
'https://clerk.linejam.app',
];

function resolveClerkSources(): string[] {
const derivedOrigin = deriveClerkFrontendOrigin(
process.env.NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY
);
return compactSources([...GENERIC_CLERK_SOURCES, derivedOrigin]);
}

const LOCAL_CONNECT_SOURCES = [
'http://localhost:*',
'https://localhost:*',
Expand Down Expand Up @@ -58,13 +71,14 @@ export function buildContentSecurityPolicy() {
originFrom(process.env.CANARY_ENDPOINT) ||
'https://canary-obs.fly.dev';
const posthogOrigin = originFrom(process.env.NEXT_PUBLIC_POSTHOG_HOST);
const clerkSources = resolveClerkSources();

const directives: Array<[string, string[]]> = [
['default-src', ["'self'"]],
['base-uri', ["'self'"]],
['object-src', ["'none'"]],
['frame-ancestors', ["'none'"]],
['form-action', ["'self'", ...STATIC_CLERK_SOURCES]],
['form-action', ["'self'", ...clerkSources]],
[
'script-src',
compactSources([
Expand All @@ -73,7 +87,7 @@ export function buildContentSecurityPolicy() {
// inline scripts today. Nonces are the follow-up once app wiring exists.
"'unsafe-inline'",
isDevelopment ? "'unsafe-eval'" : null,
...STATIC_CLERK_SOURCES,
...clerkSources,
'https://challenges.cloudflare.com',
'https://us-assets.i.posthog.com',
'https://va.vercel-scripts.com',
Expand All @@ -85,7 +99,7 @@ export function buildContentSecurityPolicy() {
"'self'",
"'unsafe-inline'",
'https://fonts.googleapis.com',
...STATIC_CLERK_SOURCES,
...clerkSources,
]),
],
[
Expand All @@ -96,7 +110,7 @@ export function buildContentSecurityPolicy() {
'blob:',
'https://img.clerk.com',
'https://images.clerk.dev',
...STATIC_CLERK_SOURCES,
...clerkSources,
]),
],
[
Expand All @@ -111,7 +125,7 @@ export function buildContentSecurityPolicy() {
convexWsOrigin,
'https://*.convex.cloud',
'wss://*.convex.cloud',
...STATIC_CLERK_SOURCES,
...clerkSources,
'https://challenges.cloudflare.com',
'https://us.i.posthog.com',
'https://us-assets.i.posthog.com',
Expand All @@ -127,7 +141,7 @@ export function buildContentSecurityPolicy() {
'frame-src',
compactSources([
"'self'",
...STATIC_CLERK_SOURCES,
...clerkSources,
'https://challenges.cloudflare.com',
]),
],
Expand Down
24 changes: 2 additions & 22 deletions scripts/ci/bootstrap-convex-env.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

import { spawnSync } from 'node:child_process';
import { fileURLToPath } from 'node:url';
import { deriveClerkFrontendOrigin } from '../lib/clerk-domain.mjs';

/**
* @typedef {Record<string, string | undefined>} EnvShape
Expand All @@ -19,28 +20,7 @@ export function deriveClerkIssuerDomain(env = process.env) {
env.NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY?.trim() ||
env.CLERK_PUBLISHABLE_KEY?.trim() ||
'';
if (!publishableKey) {
return '';
}

const encodedDomain = publishableKey.split('_').at(-1);
if (!encodedDomain) {
return '';
}

try {
const decoded = Buffer.from(encodedDomain, 'base64url')
.toString('utf8')
.replace(/\$+$/, '');

if (!decoded) {
return '';
}

return decoded.startsWith('https://') ? decoded : `https://${decoded}`;
} catch {
return '';
}
return deriveClerkFrontendOrigin(publishableKey);
}

function normalizeIssuer(value) {
Expand Down
39 changes: 39 additions & 0 deletions scripts/lib/clerk-domain.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
/**
* Derive the Clerk Frontend API origin encoded in a publishable key.
*
* Clerk publishable keys are `pk_(test|live)_<base64url(frontendApiHost + "$")>`.
* Decoding the trailing segment recovers the exact host Clerk serves clerk-js
* and its Frontend API from. For a custom domain (e.g. `clerk.linejam.app`)
* this is the only source of truth for that host — nothing else in env
* encodes it.
*
* This is the canonical implementation. `next.config.ts` (CSP allowlist) and
* `scripts/ci/bootstrap-convex-env.mjs` (Convex JWT issuer domain) both
* derive from it, so a Clerk domain change can never silently diverge
* between the two again (2026-07-04 outage: CSP hand-listed domains and
* missed the production custom domain, blocking auth site-wide for ~16h
* while preview smoke stayed green on the dev Clerk domain).
*
* @param {string | undefined | null} publishableKey
* @returns {string} the origin (e.g. "https://clerk.linejam.app"), or '' if
* it cannot be derived (missing/malformed key).
*/
export function deriveClerkFrontendOrigin(publishableKey) {
const key = publishableKey?.trim() ?? '';
if (!key) return '';

const encodedDomain = key.split('_').at(-1);
if (!encodedDomain) return '';
Comment on lines +33 to +34

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
node -e '
const hosts = ["clerk.linejam.app","auth.example-tenant.com","great-moose-1.clerk.accounts.dev"];
// brute-force a few synthetic hosts to prove _ is reachable in base64url output
for (let i=0;i<5000;i++){ hosts.push("clerk-"+i+".linejam.app"); }
let hit=null;
for (const h of hosts){
  const enc = Buffer.from(h+"$").toString("base64url");
  if (enc.includes("_")){ hit={h,enc}; break; }
}
console.log("example host whose key segment contains _ :", hit);
if (hit){
  const key = "pk_live_"+hit.enc;
  const tail = key.split("_").at(-1);
  console.log("split-based decode:", Buffer.from(tail,"base64url").toString("utf8"));
  console.log("prefix-strip decode:", Buffer.from(hit.enc,"base64url").toString("utf8"));
}
'

Repository: misty-step/linejam

Length of output: 205


🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf '\n== scripts/lib/clerk-domain.mjs ==\n'
sed -n '1,220p' scripts/lib/clerk-domain.mjs

printf '\n== related references ==\n'
rg -n "deriveClerkFrontendOrigin|clerk-domain|frontendApiHost|Convex JWT issuer|bootstrap-convex-env|next.config" scripts . -g '!**/node_modules/**' -g '!**/dist/**' -g '!**/build/**' || true

printf '\n== tests around clerk domain ==\n'
rg -n "clerk.*domain|frontendApiHost|base64url|pk_(test|live)_" scripts test tests . -g '!**/node_modules/**' -g '!**/dist/**' -g '!**/build/**' || true

Repository: misty-step/linejam

Length of output: 14127


🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf '\n== file inventory ==\n'
git ls-files | rg '^scripts/lib/clerk-domain\.mjs$|^scripts/.*(bootstrap-convex-env|next\.config|clerk).*|(^|/)(test|tests)/.*clerk|.*clerk.*test.*'

printf '\n== source around the target ==\n'
nl -ba scripts/lib/clerk-domain.mjs | sed -n '1,120p'

Repository: misty-step/linejam

Length of output: 473


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Inspect the target file and nearby callers/tests without executing repository code.
printf '\n== scripts/lib/clerk-domain.mjs ==\n'
nl -ba scripts/lib/clerk-domain.mjs | sed -n '1,200p'

printf '\n== references to the helper and related derivation ==\n'
rg -n "deriveClerkFrontendOrigin|clerk-domain|frontendApiHost|base64url|pk_(test|live)_" scripts . \
  -g '!**/node_modules/**' -g '!**/dist/**' -g '!**/build/**' || true

printf '\n== test files referencing clerk domain ==\n'
rg -n "clerk.*domain|frontendApiHost|base64url|pk_(test|live)_" tests test scripts \
  -g '!**/node_modules/**' -g '!**/dist/**' -g '!**/build/**' || true

Repository: misty-step/linejam

Length of output: 232


🏁 Script executed:

#!/bin/bash
set -euo pipefail

python3 - <<'PY'
import base64
import itertools
import string

alphabet = string.ascii_lowercase + string.digits + "-."
# Search short, syntactically plausible host labels first.
def enc(s: str) -> str:
    return base64.urlsafe_b64encode(s.encode()).decode().rstrip("=")

for n in range(1, 7):
    for s in itertools.product(alphabet, repeat=n):
        host = "".join(s)
        e = enc(host)
        if "_" in e:
            print("found_host=", host)
            print("encoded=", e)
            key = "pk_live_" + e
            tail = key.split("_")[-1]
            print("split_tail=", tail)
            print("split_decoded=", base64.urlsafe_b64decode(tail + "==").decode("utf-8", "replace"))
            print("full_decoded=", base64.urlsafe_b64decode(e + "==").decode("utf-8", "replace"))
            raise SystemExit(0)
print("no host found in search space")
PY

Repository: misty-step/linejam

Length of output: 148


split('_').at(-1) can truncate the encoded segment

pk_(test|live)_<base64url(...)> keys can carry _ inside the base64url payload, so splitting on every underscore can drop everything before the last _ and decode the wrong host. Strip the pk_(test|live)_ prefix instead.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@scripts/lib/clerk-domain.mjs` around lines 25 - 26, The domain extraction in
clerk-domain.mjs is using split('_').at(-1), which can lose part of the
base64url payload when the encoded segment contains underscores. Update the
parsing logic in the key-handling flow so the function that derives
encodedDomain strips the pk_(test|live)_ prefix directly instead of splitting on
every underscore, then continue decoding the remaining payload as before.


try {
const decoded = Buffer.from(encodedDomain, 'base64url')
.toString('utf8')
.replace(/\$+$/, '');

if (!decoded) return '';

return decoded.startsWith('https://') ? decoded : `https://${decoded}`;
} catch {
return '';
}
}
97 changes: 97 additions & 0 deletions scripts/ops/count-consecutive-prod-smoke-failures.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
#!/usr/bin/env node
import { pathToFileURL } from 'node:url';

const WORKFLOW_FILE = 'prod-smoke.yml';

/**
* Count the consecutive failure streak ending at the current run.
*
* `priorConclusions` is the conclusions of earlier completed runs of the
* same workflow, ordered most-recent-first, excluding the current run.
* Runs whose conclusion is neither `success` nor `failure` (cancelled,
* skipped, stale, action_required, ...) are ignored — they carry no signal
* about whether the underlying check passed — rather than breaking or
* extending the streak.
*
* @param {'success' | 'failure'} currentOutcome
* @param {Array<string | null>} priorConclusions
* @returns {number}
*/
export function countConsecutiveFailures(currentOutcome, priorConclusions) {
if (currentOutcome !== 'failure') return 0;

let streak = 1;
for (const conclusion of priorConclusions) {
if (conclusion === 'failure') {
streak += 1;
continue;
}
if (conclusion === 'success') break;
// else: ignore and keep looking further back
}
return streak;
}

/**
* @param {{
* owner: string,
* repo: string,
* excludeRunId: string | number,
* perPage?: number,
* token: string,
* fetchImpl?: typeof fetch,
* }} params
* @returns {Promise<Array<string | null>>}
*/
export async function fetchPriorRunConclusions({
owner,
repo,
excludeRunId,
perPage = 10,
token,
fetchImpl = globalThis.fetch,
}) {
const url = `https://api.github.com/repos/${owner}/${repo}/actions/workflows/${WORKFLOW_FILE}/runs?status=completed&per_page=${perPage}`;
const response = await fetchImpl(url, {
headers: {
Authorization: `Bearer ${token}`,
Accept: 'application/vnd.github+json',
'X-GitHub-Api-Version': '2022-11-28',
},
});

if (!response.ok) {
throw new Error(
`Failed to list ${WORKFLOW_FILE} runs: HTTP ${response.status}`
);
}

const body = await response.json();
return (body.workflow_runs ?? [])
.filter((run) => String(run.id) !== String(excludeRunId))
.sort((a, b) => new Date(b.created_at) - new Date(a.created_at))
.map((run) => run.conclusion);
}

if (import.meta.url === pathToFileURL(process.argv[1]).href) {
const currentOutcome = process.argv[2];
const [owner, repo] = (process.env.GITHUB_REPOSITORY || '').split('/');

fetchPriorRunConclusions({
owner,
repo,
excludeRunId: process.env.GITHUB_RUN_ID,
token: process.env.GITHUB_TOKEN,
})
.then((conclusions) => {
console.log(countConsecutiveFailures(currentOutcome, conclusions));
})
.catch((error) => {
console.error(error instanceof Error ? error.message : String(error));
// Fail OPEN toward escalation, never toward silence: the 2026-07-04
// outage was caused by a red signal nobody saw for ~15 hours, not by
// an over-eager page. If we cannot read history on a failing run,
// assume the threshold is already met rather than assuming zero.
console.log(currentOutcome === 'failure' ? 2 : 0);
});
}
Loading
Loading