Skip to content

Commit 9b74683

Browse files
committed
fix(observability-map): stop audit-trail leaking into the PR-comment fix list and never fail the CI job on a render or upsert error
1 parent 9ba1910 commit 9b74683

5 files changed

Lines changed: 132 additions & 4 deletions

File tree

.github/workflows/observability-map.yml

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,15 +56,22 @@ jobs:
5656
echo "base scan failed or the worktree could not be added; falling back to no base" >&2
5757
fi
5858
59+
# continue-on-error: this job must never block a PR. A malformed head.json or a rendering
60+
# bug would otherwise turn the job red the same way a base-scan failure would not, since that
61+
# step already falls back to "-" instead of failing.
5962
- name: 📝 Render comment
63+
continue-on-error: true
6064
run: |
6165
if [ "$(cat /tmp/base.json)" = "-" ]; then
6266
pnpm --filter @internal/observability-map exec tsx src/report/prCommentCli.ts /tmp/head.json - > /tmp/comment.md
6367
else
6468
pnpm --filter @internal/observability-map exec tsx src/report/prCommentCli.ts /tmp/head.json /tmp/base.json > /tmp/comment.md
6569
fi
6670
71+
# continue-on-error for the same reason: a transient gh api failure (rate limit, network)
72+
# must not fail the job either. Worst case, the PR gets no comment this run.
6773
- name: 💬 Upsert PR comment
74+
continue-on-error: true
6875
env:
6976
GH_TOKEN: ${{ github.token }}
7077
PR_NUMBER: ${{ github.event.pull_request.number }}

internal-packages/observability-map/src/report/prComment.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,17 @@
11
import type { MapReport, ScoredEntry } from "../score.js";
2+
import { SCORED_CHECK_IDS } from "../checks/index.js";
23
import { auditLine, contextLine, contextOnly, scoredFailures } from "./terminal.js";
34

45
/** First line of every comment this job posts, so the upsert step can find its own comment again. */
56
export const MARKER = "<!-- observability-map-report -->";
67

78
const MAX_CHANGED_ROWS = 15;
89

9-
const failingIds = (e: ScoredEntry) => e.checks.filter((c) => c.status === "fail").map((c) => c.id);
10+
// Scored checks only, same exclusion terminal.ts's scoredFailures makes: audit-trail fails almost
11+
// every sensitive mutation today, so listing it per route would nag with something unfixable
12+
// instead of surfacing the route-specific gaps this column exists for.
13+
const failingIds = (e: ScoredEntry) =>
14+
e.checks.filter((c) => SCORED_CHECK_IDS.includes(c.id) && c.status === "fail").map((c) => c.id);
1015

1116
function scoreLine(head: MapReport, base: MapReport | null): string {
1217
const headline =

internal-packages/observability-map/src/report/prCommentCli.ts

Lines changed: 25 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,22 @@ const processIo: Io = {
1212
err: (s) => process.stderr.write(s),
1313
};
1414

15+
/** Reads and parses one report file, raising a message naming the file rather than letting an
16+
* unreadable path or malformed JSON surface as a stack trace. */
17+
function readReport(path: string, label: string): MapReport {
18+
let raw: string;
19+
try {
20+
raw = readFileSync(path, "utf8");
21+
} catch {
22+
throw new Error(`cannot read ${label}: ${path}`);
23+
}
24+
try {
25+
return JSON.parse(raw) as MapReport;
26+
} catch {
27+
throw new Error(`${label} is not valid JSON: ${path}`);
28+
}
29+
}
30+
1531
/** `-` or a missing second arg means no base: the CI job falls back to this when the base scan
1632
* itself failed, so the comment still renders rather than the job going red. */
1733
export function main(argv: string[], io: Io = processIo): number {
@@ -24,9 +40,15 @@ export function main(argv: string[], io: Io = processIo): number {
2440
return 1;
2541
}
2642

27-
const head = JSON.parse(readFileSync(headPath, "utf8")) as MapReport;
28-
const base: MapReport | null =
29-
!basePath || basePath === "-" ? null : JSON.parse(readFileSync(basePath, "utf8"));
43+
let head: MapReport;
44+
let base: MapReport | null;
45+
try {
46+
head = readReport(headPath, "head report");
47+
base = !basePath || basePath === "-" ? null : readReport(basePath, "base report");
48+
} catch (error) {
49+
io.err(`${error instanceof Error ? error.message : String(error)}\n`);
50+
return 1;
51+
}
3052

3153
io.out(renderPrComment(head, base));
3254
io.out("\n");

internal-packages/observability-map/test/prComment.test.ts

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,74 @@ describe("renderPrComment", () => {
7070
expect(out).toMatch(/\| \/api\/v1\/new \| new \| \d+ \|/);
7171
});
7272

73+
// Mirrors the guard report.test.ts has for the terminal renderer: audit-trail fails almost
74+
// every sensitive mutation today (no audit helper exists), so it is a headline figure, not a
75+
// per-route nag. A regression here previously let it leak into the "now failing" column.
76+
it("does not list audit-trail among a new sensitive entry's failing checks", () => {
77+
const sensitiveMutation = scanFile(
78+
"api.v1.envvars.ts",
79+
`import { prisma } from "~/db.server";
80+
export async function action() {
81+
try {
82+
return await prisma.envVar.update({ where: {}, data: {} });
83+
} catch (e) {
84+
return null;
85+
}
86+
}`
87+
)!;
88+
const head = buildReport([sensitiveMutation], []);
89+
const base = buildReport([], []);
90+
const out = renderPrComment(head, base);
91+
92+
const row = out.split("\n").find((l) => l.startsWith("| /api/v1/envvars |"))!;
93+
expect(row).toBeDefined();
94+
expect(row).toContain("new");
95+
expect(row).not.toContain("audit-trail");
96+
expect(row).toMatch(/error-classification|auth-boundary|request-context/);
97+
});
98+
99+
it("sorts a sensitive entry with a small drop above a non-sensitive entry with a large drop", () => {
100+
const sensitiveSmallDropBase = scanFile("api.v1.auth.tokens.ts", cleanSource)!;
101+
const sensitiveSmallDropHead = scanFile(
102+
"api.v1.auth.tokens.ts",
103+
`import { requireUserId } from "~/services/session.server";
104+
import { logger } from "~/services/logger.server";
105+
import { prisma } from "~/db.server";
106+
export async function action({ request }) {
107+
const userId = await requireUserId(request);
108+
try { return await prisma.token.create({ data: { userId } }); }
109+
catch (error) { logger.error("token create failed", { error }); throw error; }
110+
}`
111+
)!;
112+
113+
const notSensitiveLargeDropBase = scanFile(
114+
"resources.busy.ts",
115+
`import { logger } from "~/services/logger.server";
116+
import { prisma } from "~/db.server";
117+
export async function loader({ params }) {
118+
try { return await prisma.thing.findMany(); }
119+
catch (error) { logger.error("failed", { environmentId: params.envId, error }); throw error; }
120+
}`
121+
)!;
122+
const notSensitiveLargeDropHead = scanFile(
123+
"resources.busy.ts",
124+
`import { prisma } from "~/db.server";
125+
export async function loader() {
126+
try { return await prisma.thing.findMany(); } catch (e) { return null; }
127+
}`
128+
)!;
129+
130+
const head = buildReport([sensitiveSmallDropHead, notSensitiveLargeDropHead], []);
131+
const base = buildReport([sensitiveSmallDropBase, notSensitiveLargeDropBase], []);
132+
const out = renderPrComment(head, base);
133+
134+
const sensitiveIndex = out.indexOf("/api/v1/auth/tokens");
135+
const notSensitiveIndex = out.indexOf("/resources/busy");
136+
expect(sensitiveIndex).toBeGreaterThan(-1);
137+
expect(notSensitiveIndex).toBeGreaterThan(-1);
138+
expect(sensitiveIndex).toBeLessThan(notSensitiveIndex);
139+
});
140+
73141
it("reports a removed entry as a count line, not a row", () => {
74142
const head = buildReport([scanFile("api.v1.auth.tokens.ts", cleanSource)!], []);
75143
const base = buildReport(

internal-packages/observability-map/test/prCommentCli.test.ts

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,4 +54,30 @@ describe("prCommentCli", () => {
5454
expect(r.code).toBe(1);
5555
expect(r.err).toContain("usage:");
5656
});
57+
58+
it("exits 1 with a one-line message, not a stack trace, when head.json does not exist", () => {
59+
const r = run(join(dir, "does-not-exist.json"));
60+
expect(r.code).toBe(1);
61+
expect(r.err.split("\n").filter(Boolean)).toHaveLength(1);
62+
expect(r.err).toContain("cannot read head report");
63+
expect(r.err).not.toContain(" at ");
64+
});
65+
66+
it("exits 1 with a one-line message, not a stack trace, when head.json is malformed", () => {
67+
const malformedPath = join(dir, "malformed.json");
68+
writeFileSync(malformedPath, "{ not json");
69+
const r = run(malformedPath);
70+
expect(r.code).toBe(1);
71+
expect(r.err.split("\n").filter(Boolean)).toHaveLength(1);
72+
expect(r.err).toContain("head report is not valid JSON");
73+
expect(r.err).not.toContain(" at ");
74+
});
75+
76+
it("exits 1 with a one-line message when base.json is malformed", () => {
77+
const malformedBasePath = join(dir, "malformed-base.json");
78+
writeFileSync(malformedBasePath, "not json at all");
79+
const r = run(headPath, malformedBasePath);
80+
expect(r.code).toBe(1);
81+
expect(r.err).toContain("base report is not valid JSON");
82+
});
5783
});

0 commit comments

Comments
 (0)