fix(product-intel): stop self-flagging — exclude PI workflow from failure count + treat missing Sentry token as degraded not failed (#117)
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
10
.github/workflows/product-intelligence.yml
vendored
10
.github/workflows/product-intelligence.yml
vendored
@@ -126,6 +126,16 @@ jobs:
|
|||||||
if-no-files-found: error
|
if-no-files-found: error
|
||||||
|
|
||||||
- name: Fail when a required source is unavailable
|
- name: Fail when a required source is unavailable
|
||||||
|
# scripts/product-intelligence.mjs only sets a non-zero exit code for
|
||||||
|
# genuinely required-source failures (GitHub unavailable, or Sentry
|
||||||
|
# unavailable for a reason other than the known missing/rejected
|
||||||
|
# SENTRY_PRODUCT_INTELLIGENCE_TOKEN gap). A Sentry-token-missing run
|
||||||
|
# is reported as "degraded" and this step does not fire for it — see
|
||||||
|
# isSentryProvisioningGap() in the collector. This avoids the job
|
||||||
|
# failing on its own known, human-gated gap and that failure being
|
||||||
|
# counted as a "repeated-workflow-failure" product signal on a later
|
||||||
|
# run (also guarded against directly via SELF_WORKFLOW_* exclusion
|
||||||
|
# in the collector's workflow-failure count).
|
||||||
if: steps.collect.outcome == 'failure'
|
if: steps.collect.outcome == 'failure'
|
||||||
run: |
|
run: |
|
||||||
echo "::error::A required product-intelligence source was unavailable. See the report summary for the exact source and error."
|
echo "::error::A required product-intelligence source was unavailable. See the report summary for the exact source and error."
|
||||||
|
|||||||
@@ -7,6 +7,13 @@ const DAY = 24 * 60 * 60 * 1000
|
|||||||
const WEEK = 7 * DAY
|
const WEEK = 7 * DAY
|
||||||
const GITHUB_API = "https://api.github.com"
|
const GITHUB_API = "https://api.github.com"
|
||||||
const SENTRY_API = "https://sentry.io/api/0"
|
const SENTRY_API = "https://sentry.io/api/0"
|
||||||
|
// This workflow's own runs must never feed the "repeated-workflow-failure"
|
||||||
|
// signal it files issues for — otherwise a known, human-gated gap (the
|
||||||
|
// missing Sentry read token) turns into a self-sustaining feedback loop
|
||||||
|
// where the job flags its own failures as a product signal. Matched by both
|
||||||
|
// path and name so a workflow file rename doesn't silently reopen the loop.
|
||||||
|
const SELF_WORKFLOW_PATH = ".github/workflows/product-intelligence.yml"
|
||||||
|
const SELF_WORKFLOW_NAME = "Daily Product Intelligence"
|
||||||
|
|
||||||
function args(values) {
|
function args(values) {
|
||||||
const result = {}
|
const result = {}
|
||||||
@@ -27,6 +34,20 @@ function available(data) {
|
|||||||
return { status: "available", data }
|
return { status: "available", data }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Sentry access for this pipeline is gated on a dedicated read token
|
||||||
|
// (SENTRY_PRODUCT_INTELLIGENCE_TOKEN / SENTRY_AUTH_TOKEN, see #60) that is
|
||||||
|
// provisioned by a human, not by this job. Until that happens, Sentry is
|
||||||
|
// unavailable either because no credentials are configured at all, or
|
||||||
|
// because the configured token is rejected (401/403). That is a known,
|
||||||
|
// persistent gap — not a transient mid-run error — so it must not fail the
|
||||||
|
// whole job; the report already renders it as "unavailable" and that's the
|
||||||
|
// correct, honest signal. Any other unavailability (5xx, network error,
|
||||||
|
// unexpected payload) is treated as a genuine problem and still hard-fails.
|
||||||
|
function isSentryProvisioningGap(sentry) {
|
||||||
|
if (sentry.status !== "unavailable") return false
|
||||||
|
return /is not set/.test(sentry.reason) || /HTTP 401/.test(sentry.reason) || /HTTP 403/.test(sentry.reason)
|
||||||
|
}
|
||||||
|
|
||||||
async function request(source, url, headers) {
|
async function request(source, url, headers) {
|
||||||
try {
|
try {
|
||||||
const response = await fetch(url, { headers })
|
const response = await fetch(url, { headers })
|
||||||
@@ -80,8 +101,12 @@ async function collectGithub(token, repo, now) {
|
|||||||
const issueCount = Number(issues.data.total_count ?? 0)
|
const issueCount = Number(issues.data.total_count ?? 0)
|
||||||
const runs = workflows.data.workflow_runs ?? []
|
const runs = workflows.data.workflow_runs ?? []
|
||||||
const failedWorkflowRuns7d = recent(runs, "created_at", now - WEEK).filter((run) => {
|
const failedWorkflowRuns7d = recent(runs, "created_at", now - WEEK).filter((run) => {
|
||||||
|
if (run.conclusion !== "failure") return false
|
||||||
const path = String(run.path ?? "")
|
const path = String(run.path ?? "")
|
||||||
return run.conclusion === "failure" && path !== ".github/workflows/product-intelligence.yml"
|
const name = String(run.name ?? "")
|
||||||
|
// Exclude this workflow itself (see SELF_WORKFLOW_* above).
|
||||||
|
if (path === SELF_WORKFLOW_PATH || name === SELF_WORKFLOW_NAME) return false
|
||||||
|
return true
|
||||||
}).length
|
}).length
|
||||||
|
|
||||||
return available({
|
return available({
|
||||||
@@ -136,9 +161,10 @@ async function collectSentry(token, organization, project, now) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
function sourceLine(name, source) {
|
function sourceLine(name, source, degraded) {
|
||||||
if (source.status === "available") return `| ${name} | available | current run |`
|
if (source.status === "available") return `| ${name} | available | current run |`
|
||||||
return `| ${name} | unavailable | ${source.reason} |`
|
const detail = degraded ? `token not provisioned — ${source.reason}` : source.reason
|
||||||
|
return `| ${name} | unavailable | ${detail} |`
|
||||||
}
|
}
|
||||||
|
|
||||||
function metric(value) {
|
function metric(value) {
|
||||||
@@ -154,17 +180,20 @@ function render(report) {
|
|||||||
const github = report.github.status === "available" ? report.github.data : null
|
const github = report.github.status === "available" ? report.github.data : null
|
||||||
const sentry = report.sentry.status === "available" ? report.sentry.data : null
|
const sentry = report.sentry.status === "available" ? report.sentry.data : null
|
||||||
const state = report.material ? "material signal detected" : "no material signal detected"
|
const state = report.material ? "material signal detected" : "no material signal detected"
|
||||||
|
const degradedNote = report.sentryDegraded
|
||||||
|
? "\n\n_Sentry is degraded, not failed: the dedicated read token (SENTRY_PRODUCT_INTELLIGENCE_TOKEN) is not provisioned. This is a known, human-gated gap — see #60 — and does not fail the job._"
|
||||||
|
: ""
|
||||||
const lines = [
|
const lines = [
|
||||||
`# Daily Product Intelligence - ${report.date}`,
|
`# Daily Product Intelligence - ${report.date}`,
|
||||||
"",
|
"",
|
||||||
`**Status:** ${state}`,
|
`**Status:** ${state}${degradedNote}`,
|
||||||
"",
|
"",
|
||||||
"## Source freshness",
|
"## Source freshness",
|
||||||
"",
|
"",
|
||||||
"| Source | Status | Detail |",
|
"| Source | Status | Detail |",
|
||||||
"| --- | --- | --- |",
|
"| --- | --- | --- |",
|
||||||
sourceLine("GitHub", report.github),
|
sourceLine("GitHub", report.github),
|
||||||
sourceLine("Sentry", report.sentry),
|
sourceLine("Sentry", report.sentry, report.sentryDegraded),
|
||||||
"",
|
"",
|
||||||
"## Current aggregate signals",
|
"## Current aggregate signals",
|
||||||
"",
|
"",
|
||||||
@@ -217,6 +246,7 @@ async function main() {
|
|||||||
])
|
])
|
||||||
const githubData = github.status === "available" ? github.data : null
|
const githubData = github.status === "available" ? github.data : null
|
||||||
const sentryData = sentry.status === "available" ? sentry.data : null
|
const sentryData = sentry.status === "available" ? sentry.data : null
|
||||||
|
const sentryDegraded = isSentryProvisioningGap(sentry)
|
||||||
const signals = []
|
const signals = []
|
||||||
if (sentryData?.newIssues24h >= 1) signals.push("new-sentry-issue")
|
if (sentryData?.newIssues24h >= 1) signals.push("new-sentry-issue")
|
||||||
if (githubData?.failedWorkflowRuns7d >= 2) signals.push("repeated-workflow-failure")
|
if (githubData?.failedWorkflowRuns7d >= 2) signals.push("repeated-workflow-failure")
|
||||||
@@ -227,6 +257,7 @@ async function main() {
|
|||||||
repo,
|
repo,
|
||||||
github,
|
github,
|
||||||
sentry,
|
sentry,
|
||||||
|
sentryDegraded,
|
||||||
material: signals.length > 0,
|
material: signals.length > 0,
|
||||||
signals,
|
signals,
|
||||||
}
|
}
|
||||||
@@ -237,8 +268,18 @@ async function main() {
|
|||||||
if (process.env.GITHUB_STEP_SUMMARY) await write(process.env.GITHUB_STEP_SUMMARY, markdown)
|
if (process.env.GITHUB_STEP_SUMMARY) await write(process.env.GITHUB_STEP_SUMMARY, markdown)
|
||||||
|
|
||||||
console.log(`Product intelligence report written for ${date}.`)
|
console.log(`Product intelligence report written for ${date}.`)
|
||||||
if (github.status !== "available" || sentry.status !== "available") {
|
// GitHub is required: this job has no way to compute either signal without
|
||||||
|
// it, so its unavailability is a genuine failure. Sentry unavailability is
|
||||||
|
// only a genuine failure when it isn't the known token-provisioning gap
|
||||||
|
// (see isSentryProvisioningGap above) — a missing/rejected Sentry token
|
||||||
|
// must not fail the job, or the job's own daily failure gets fed back in
|
||||||
|
// as a "the pipeline is broken" signal.
|
||||||
|
if (github.status !== "available") {
|
||||||
process.exitCode = 1
|
process.exitCode = 1
|
||||||
|
} else if (sentry.status !== "available" && !sentryDegraded) {
|
||||||
|
process.exitCode = 1
|
||||||
|
} else if (sentryDegraded) {
|
||||||
|
console.log("Sentry data source degraded (token not provisioned) — continuing without failing the job.")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user