CodeIssuesPull RequestsActionsSecurityInsights
✨ AI
More
Settings

fix(gates+ci): security scan actually runs; workflow branch filters honored; deploy workflow verifies reality #5465

Merged⚡ AI-generatedXSccantynz wants to mergefix/security-scan-and-ci-honestymainopened 24d ago
5 changed files+118−17
Modified.gluecron/workflows/deploy.yml+29−13View fileUnifiedSplit
1name: Deploy gluecron to itself
2# BLOCK W — Self-host. When Gluecron.com receives a push to main, the
3# post-receive hook in src/hooks/post-receive.ts forks scripts/self-deploy.sh
4# directly. This workflow file remains as an OPTIONAL alternative path for
5# operators who prefer the workflow runner over the post-receive hook, or
6# as a manual dispatch escape hatch.
1name: Verify deploy to production
2# BLOCK W, rewritten 2026-08-08. The old version tried to RUN
3# /opt/gluecron/scripts/self-deploy.sh from inside the workflow runner —
4# a path that has never existed there, so every run failed in ~500ms
5# (254 consecutive junk failures). Deploys actually ship via the
6# gluecron-update.timer on the host (see docs/ops/OPERATIONS.md); a
7# workflow cannot and should not perform them.
8#
9# What a workflow CAN do is verify the deploy happened: this job reads
10# its own commit sha from the checkout and polls /api/version until
11# production serves it. Success = "this exact commit is live." Failure
12# after 6 minutes = the deploy timer is stuck — a signal worth being red
13# about (check `systemctl list-timers 'gluecron-*'` on the host).
714
815on:
916 push:
1118 workflow_dispatch: {}
1219
1320jobs:
14 deploy:
21 verify-deploy:
1522 runs-on: self
1623 steps:
17 - name: Run self-deploy.sh inline
24 - name: Wait for production to serve this commit
1825 run: |
19 if [ ! -x /opt/gluecron/scripts/self-deploy.sh ]; then
20 echo "self-deploy.sh missing — run scripts/self-host-bootstrap.ts first"
21 exit 1
22 fi
23 /opt/gluecron/scripts/self-deploy.sh --inline
26 SHA=$(git rev-parse HEAD)
27 echo "verifying deploy of $SHA"
28 for i in $(seq 1 36); do
29 LIVE=$(curl -fsS --max-time 10 https://gluecron.com/api/version | sed -n 's/.*"shaFull":"\([a-f0-9]*\)".*/\1/p' || true)
30 if [ "$LIVE" = "$SHA" ]; then
31 echo "deploy verified: production serves $SHA (after ~$((i * 10))s)"
32 exit 0
33 fi
34 echo "attempt $i/36 — production serves ${LIVE:-unknown}, waiting..."
35 sleep 10
36 done
37 echo "DEPLOY NOT VERIFIED after 6 minutes — the gluecron-update timer may be stuck."
38 echo "On the host: systemctl list-timers 'gluecron-*' && journalctl -u gluecron-update"
39 exit 1
Modifiedsrc/__tests__/gate-security-scan-availability.test.ts+14−2View fileUnifiedSplit
136136 expect(result.securityIssues).toHaveLength(1);
137137 });
138138
139 it("is skipped with 'no diff provided' when scanSecurity is requested but no diffText is given", async () => {
139 it("is skipped honestly when scanSecurity is requested but no diffText is given", async () => {
140140 const result = await runSecretAndSecurityScan("o", "r", "refs/heads/main", "a".repeat(40), {
141141 scanSecrets: false,
142142 scanSecurity: true,
143143 });
144144 expect(result.securityResult.skipped).toBe(true);
145145 expect(result.securityResult.passed).toBe(true);
146 expect(result.securityResult.details).toBe("Skipped — no diff provided");
146 // "no diff provided" was retired 2026-08-08: runAllGateChecks now
147 // computes the diff itself, so an empty diff is the only reason left
148 // to skip when the scan is enabled — and the message says so.
149 expect(result.securityResult.details).toBe("Skipped — no changes to scan");
150 });
151
152 it("reports 'Disabled in settings' when scanSecurity is off", async () => {
153 const result = await runSecretAndSecurityScan("o", "r", "refs/heads/main", "a".repeat(40), {
154 scanSecrets: false,
155 scanSecurity: false,
156 });
157 expect(result.securityResult.skipped).toBe(true);
158 expect(result.securityResult.details).toBe("Disabled in settings");
147159 });
148160});
149161
Modifiedsrc/lib/gate.ts+39−2View fileUnifiedSplit
368368 // provider outage indistinguishably).
369369 const notConfigured = !opts.scanSecurity || !opts.diffText;
370370 const securityDetails = notConfigured
371 ? "Skipped — no diff provided"
371 ? !opts.scanSecurity
372 ? "Disabled in settings"
373 : "Skipped — no changes to scan"
372374 : aiOutcome.skipped
373375 ? `AI security scan unavailable — scan skipped, not blocking: ${aiOutcome.error ?? "unknown error"}`
374376 : securityIssues.length === 0
426428 const runAiReview = settings?.aiReviewEnabled !== false;
427429 const enableRepair = opts.enableAutoRepair !== false && settings?.autoFixEnabled !== false;
428430
431 // The AI security scan only runs when it has the PR's diff — and no call
432 // site in the codebase ever passed `opts.diffText`, so the gate showed
433 // "Skipped — no diff provided" on EVERY pull request since the feature
434 // shipped (owner caught it 2026-08-08: "why is the security scan
435 // completely turned off"). Compute the diff here, at the chokepoint, so
436 // all three merge paths and the PR page get a real scan without each
437 // caller having to remember. Bounded: a diff beyond 150k chars is
438 // truncated (the scan sees the head of it and says so), and any git
439 // failure degrades to the old skipped state rather than blocking gates.
440 let diffText = opts.diffText;
441 if (runSecurityScan && !diffText) {
442 try {
443 const { getRepoPath } = await import("../git/repository");
444 const proc = Bun.spawn(
445 ["git", "diff", `${baseBranch}...${headBranch}`],
446 { cwd: getRepoPath(owner, repo), stdout: "pipe", stderr: "pipe" }
447 );
448 const killer = setTimeout(() => proc.kill(), 20_000);
449 try {
450 const raw = await new Response(proc.stdout).text();
451 await proc.exited;
452 if (raw.trim()) {
453 diffText =
454 raw.length > 150_000
455 ? `${raw.slice(0, 150_000)}\n\n[diff truncated at 150k chars for scanning]`
456 : raw;
457 }
458 } finally {
459 clearTimeout(killer);
460 }
461 } catch {
462 /* scan degrades to skipped — never block the gate run on diff errors */
463 }
464 }
465
429466 const [gateTestResult, mergeResult, scanResults] = await Promise.all([
430467 runGateTest
431468 ? runGateTestScan(owner, repo, `refs/heads/${headBranch}`, headSha)
439476 runSecretAndSecurityScan(owner, repo, `refs/heads/${headBranch}`, headSha, {
440477 scanSecrets: runSecretScan,
441478 scanSecurity: runSecurityScan,
442 diffText: opts.diffText,
479 diffText,
443480 }),
444481 ]);
445482
Modifiedsrc/lib/push-workflow-sync.ts+6−0View fileUnifiedSplit
143143 const row = savedByPath.get(item.path);
144144 if (!row || row.disabled) continue;
145145 if (!item.workflow.on.includes("push")) continue;
146 // Honor `on: { push: { branches: [...] } }`. This filter was parsed and
147 // DISCARDED until 2026-08-08 — the deploy workflow declared
148 // `branches: [main]` yet fired (and failed) on every feature-branch
149 // push: 254 consecutive junk failure runs. No filter = all branches.
150 const filter = item.workflow.pushBranches ?? [];
151 if (filter.length > 0 && !filter.includes(opts.branch)) continue;
146152 try {
147153 await deps.enqueueRun({
148154 workflowId: row.id,
Modifiedsrc/lib/workflow-parser.ts+30−0View fileUnifiedSplit
4747 * the scheduler tries to parse them via `src/lib/cron.ts`.
4848 */
4949 schedules?: string[];
50 /**
51 * Branch filter from `on: { push: { branches: [...] } }`. Empty/absent =
52 * run on every branch. Was parsed-and-DISCARDED until 2026-08-08, which
53 * made every `branches: [main]` workflow fire on every feature-branch
54 * push (254 consecutive junk failure runs on the deploy workflow).
55 */
56 pushBranches?: string[];
5057 jobs: WorkflowJob[];
5158};
5259
514521 *
515522 * Pure helper — exported alongside the existing `__test` bundle.
516523 */
524/**
525 * Extract the push-branch filter from `on: { push: { branches: [...] } }`.
526 * Returns [] for every other shape (string/list `on`, no push mapping, no
527 * branches key) — meaning "no filter, all branches".
528 */
529function extractPushBranches(rawOn: unknown): string[] {
530 if (!rawOn || typeof rawOn !== "object" || Array.isArray(rawOn)) return [];
531 const push = (rawOn as Record<string, unknown>).push;
532 if (!push || typeof push !== "object" || Array.isArray(push)) return [];
533 const branches = (push as Record<string, unknown>).branches;
534 const list = Array.isArray(branches)
535 ? branches
536 : typeof branches === "string"
537 ? [branches]
538 : [];
539 return list
540 .filter((b): b is string => typeof b === "string")
541 .map((b) => b.trim())
542 .filter(Boolean);
543}
544
517545function extractSchedules(rawOn: unknown): string[] {
518546 if (!rawOn || typeof rawOn !== "object" || Array.isArray(rawOn)) return [];
519547 const m = rawOn as Record<string, unknown>;
621649 return { ok: false, error: "workflow missing 'on' trigger" };
622650 }
623651 const schedules = extractSchedules(doc.on);
652 const pushBranches = extractPushBranches(doc.on);
624653
625654 const jobsRaw = doc.jobs;
626655 if (
642671
643672 const workflow: ParsedWorkflow = { name, on, jobs };
644673 if (schedules.length > 0) workflow.schedules = schedules;
674 if (pushBranches.length > 0) workflow.pushBranches = pushBranches;
645675 return { ok: true, workflow };
646676}
647677
648678
c comment · e edit title · m merge · a approve · r request changes · ? shortcuts