From 6195e2438ac74d445f699acf2107e12478cb0719 Mon Sep 17 00:00:00 2001 From: anandgupta42 Date: Sat, 28 Mar 2026 18:45:58 -0700 Subject: [PATCH 1/3] feat: add `upstream_fix:` marker convention with `--audit-fixes` command MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Introduces a strategy for handling bug fixes to upstream code: **`upstream_fix:` convention:** - Tag in marker description distinguishes temporary bug fixes from permanent feature additions - Before each upstream merge, run `--audit-fixes` to review which fixes we're carrying and whether upstream has shipped their own **`--audit-fixes` flag on analyze.ts:** - Lists all `upstream_fix:` markers with file locations and descriptions - Prints merge review checklist **Retagged 3 existing upstream bug fixes:** - `locale.ts` — days/hours duration swap - `command/index.ts` — placeholder lexicographic sort - `home.tsx` — beginner UI race condition **Code review fixes from #546:** - Guard `--downstream` without `--model` (returns error instead of silently running full project build) - Remove unused `condition` field from `Suggestion` interface - Add test for `--downstream` error case Co-Authored-By: Claude Opus 4.6 (1M context) --- packages/dbt-tools/src/commands/build.ts | 5 +- packages/dbt-tools/test/build.test.ts | 8 ++ .../opencode/src/cli/cmd/tui/routes/home.tsx | 2 +- packages/opencode/src/command/index.ts | 5 +- packages/opencode/src/skill/followups.ts | 4 +- packages/opencode/src/util/locale.ts | 2 + script/upstream/README.md | 38 ++++++ script/upstream/analyze.test.ts | 12 ++ script/upstream/analyze.ts | 116 ++++++++++++++---- 9 files changed, 160 insertions(+), 32 deletions(-) diff --git a/packages/dbt-tools/src/commands/build.ts b/packages/dbt-tools/src/commands/build.ts index 5d796c764f..b3636adeec 100644 --- a/packages/dbt-tools/src/commands/build.ts +++ b/packages/dbt-tools/src/commands/build.ts @@ -2,8 +2,11 @@ import type { DBTProjectIntegrationAdapter, CommandProcessResult } from "@altima export async function build(adapter: DBTProjectIntegrationAdapter, args: string[]) { const model = flag(args, "model") - if (!model) return project(adapter) const downstream = args.includes("--downstream") + if (!model) { + if (downstream) return { error: "--downstream requires --model" } + return project(adapter) + } const result = await adapter.unsafeBuildModelImmediately({ plusOperatorLeft: "", modelName: model, diff --git a/packages/dbt-tools/test/build.test.ts b/packages/dbt-tools/test/build.test.ts index f73a89af9b..5c5f464c77 100644 --- a/packages/dbt-tools/test/build.test.ts +++ b/packages/dbt-tools/test/build.test.ts @@ -45,6 +45,14 @@ describe("build command", () => { }) }) + test("build --downstream without --model returns error", async () => { + const adapter = makeAdapter() + const result = await build(adapter, ["--downstream"]) + expect(result).toEqual({ error: "--downstream requires --model" }) + expect(adapter.unsafeBuildProjectImmediately).not.toHaveBeenCalled() + expect(adapter.unsafeBuildModelImmediately).not.toHaveBeenCalled() + }) + test("build surfaces stderr as error", async () => { const adapter = makeAdapter({ unsafeBuildProjectImmediately: mock(() => diff --git a/packages/opencode/src/cli/cmd/tui/routes/home.tsx b/packages/opencode/src/cli/cmd/tui/routes/home.tsx index a702e3af25..9a5b0bd1f7 100644 --- a/packages/opencode/src/cli/cmd/tui/routes/home.tsx +++ b/packages/opencode/src/cli/cmd/tui/routes/home.tsx @@ -38,7 +38,7 @@ export function Home() { return Object.values(sync.data.mcp).filter((x) => x.status === "connected").length }) - // altimate_change start — fix race condition: don't show beginner UI until sessions loaded + // altimate_change start — upstream_fix: race condition shows beginner UI flash before sessions loaded const isFirstTimeUser = createMemo(() => { // Don't evaluate until sessions have actually loaded (avoid flash of beginner UI) // Return undefined to represent "loading" state diff --git a/packages/opencode/src/command/index.ts b/packages/opencode/src/command/index.ts index 86763e07a6..d6149d199e 100644 --- a/packages/opencode/src/command/index.ts +++ b/packages/opencode/src/command/index.ts @@ -54,8 +54,9 @@ export namespace Command { const result: string[] = [] const numbered = template.match(/\$\d+/g) if (numbered) { - // altimate_change start — fix lexicographic sort of multi-digit placeholders ($10 before $2) - for (const match of [...new Set(numbered)].sort((a, b) => parseInt(a.slice(1), 10) - parseInt(b.slice(1), 10))) result.push(match) + // altimate_change start — upstream_fix: lexicographic sort of multi-digit placeholders ($10 sorted before $2) + for (const match of [...new Set(numbered)].sort((a, b) => parseInt(a.slice(1), 10) - parseInt(b.slice(1), 10))) + result.push(match) // altimate_change end } if (template.includes("$ARGUMENTS")) result.push("$ARGUMENTS") diff --git a/packages/opencode/src/skill/followups.ts b/packages/opencode/src/skill/followups.ts index 03904b22e1..a8e4bf2431 100644 --- a/packages/opencode/src/skill/followups.ts +++ b/packages/opencode/src/skill/followups.ts @@ -4,7 +4,6 @@ export namespace SkillFollowups { skill: string // skill name to suggest label: string // short display label description: string // why this is a good next step - condition?: string // optional: when this suggestion applies } // Map from skill name to follow-up suggestions @@ -151,7 +150,8 @@ export namespace SkillFollowups { } // A special warehouse nudge for users who haven't connected yet - const WAREHOUSE_NUDGE = "**Tip:** Connect a warehouse to validate against real data. Run `/discover` to auto-detect your connections." + const WAREHOUSE_NUDGE = + "**Tip:** Connect a warehouse to validate against real data. Run `/discover` to auto-detect your connections." export function get(skillName: string): readonly Suggestion[] { return Object.freeze(FOLLOWUPS[skillName] ?? []) diff --git a/packages/opencode/src/util/locale.ts b/packages/opencode/src/util/locale.ts index 1afe30bb81..487b8b7faf 100644 --- a/packages/opencode/src/util/locale.ts +++ b/packages/opencode/src/util/locale.ts @@ -54,8 +54,10 @@ export namespace Locale { const minutes = Math.floor((input % 3600000) / 60000) return `${hours}h ${minutes}m` } + // altimate_change start — upstream_fix: days/hours calculation were swapped (hours used total, not remainder) const days = Math.floor(input / 86400000) const hours = Math.floor((input % 86400000) / 3600000) + // altimate_change end return `${days}d ${hours}h` } diff --git a/script/upstream/README.md b/script/upstream/README.md index 8b4e863a54..facd3c3e28 100644 --- a/script/upstream/README.md +++ b/script/upstream/README.md @@ -222,6 +222,44 @@ When we modify upstream files (not fully custom ones), we wrap our changes with These help during conflict resolution — you can see exactly what we changed vs upstream code. The `analyze.ts` script audits for unclosed marker blocks. +### Upstream Bug Fixes (`upstream_fix:` tag) + +When fixing a **bug in upstream code** (not adding a feature), use the `upstream_fix:` tag in the marker description: + +```typescript +// altimate_change start — upstream_fix: days/hours calculation were swapped +const days = Math.floor(input / 86400000) +const hours = Math.floor((input % 86400000) / 3600000) +// altimate_change end +``` + +**Why this matters:** Regular `altimate_change` markers protect features we added — they're permanent. But upstream bug fixes are **temporary**: once upstream ships their own fix, we should drop our marker and accept theirs. + +Without the `upstream_fix:` tag: +- If upstream fixes the same bug, the merge creates a conflict (good — forces review) +- But the reviewer doesn't know our change was a bug fix vs a feature, so they may keep both + +With the `upstream_fix:` tag: +- Before each merge, run `--audit-fixes` to see all bug fixes we're carrying +- During conflict resolution, reviewers know to check "did upstream fix this?" and can safely drop our version +- After merge, any remaining `upstream_fix:` markers represent bugs upstream hasn't fixed yet + +**When to use which:** + +| Scenario | Marker | +|----------|--------| +| New feature/custom code | `// altimate_change start — description` | +| Fix bug in upstream code | `// altimate_change start — upstream_fix: description` | +| Branding change | No marker (handled by branding transforms) | +| Code in `keepOurs` files | No marker needed | + +**Audit before merging:** + +```bash +# List all upstream bug fixes we're carrying +bun run script/upstream/analyze.ts --audit-fixes +``` + ## File Organization ``` diff --git a/script/upstream/analyze.test.ts b/script/upstream/analyze.test.ts index ae4ae72fce..954bf0ef4c 100644 --- a/script/upstream/analyze.test.ts +++ b/script/upstream/analyze.test.ts @@ -246,6 +246,18 @@ describe("parseDiffForMarkerWarnings", () => { expect(warnings[0].context).toContain("first") }) + test("upstream_fix: tagged markers are recognized as valid markers", () => { + const diff = makeDiff( + `@@ -50,4 +50,6 @@ + const existing = true ++// altimate_change start — upstream_fix: days/hours were swapped ++const days = Math.floor(input / 86400000) ++// altimate_change end + const more = true`, + ) + expect(parseDiffForMarkerWarnings("file.ts", diff)).toEqual([]) + }) + test("real-world scenario: upgrade indicator in footer.tsx", () => { // Simulates the exact diff that leaked: UpgradeIndicator added to // session footer without markers, adjacent to existing yolo marker block. diff --git a/script/upstream/analyze.ts b/script/upstream/analyze.ts index efbc435a38..e385b4f329 100644 --- a/script/upstream/analyze.ts +++ b/script/upstream/analyze.ts @@ -35,6 +35,7 @@ const { values: args } = parseArgs({ version: { type: "string", short: "v" }, branding: { type: "boolean", default: false }, markers: { type: "boolean", default: false }, + "audit-fixes": { type: "boolean", default: false }, strict: { type: "boolean", default: false }, base: { type: "string" }, verbose: { type: "boolean", default: false }, @@ -213,9 +214,7 @@ function printBrandingReport(report: BrandingReport, verbose: boolean): void { const maxLeaksToShow = verbose ? leaks.length : 5 for (let i = 0; i < Math.min(leaks.length, maxLeaksToShow); i++) { const leak = leaks[i] - const truncated = leak.content.length > 80 - ? leak.content.slice(0, 77) + "..." - : leak.content + const truncated = leak.content.length > 80 ? leak.content.slice(0, 77) + "..." : leak.content console.log(` ${DIM}L${String(leak.line).padStart(4)}${RESET} ${YELLOW}${leak.pattern}${RESET}`) console.log(` ${DIM}${truncated}${RESET}`) } @@ -298,14 +297,20 @@ async function analyzeVersion(version: string, config: MergeConfig): Promise "") + const content = await $`git show HEAD:${file}` + .cwd(root) + .text() + .catch(() => "") if (content.includes(config.changeMarker)) { analysis.markerFiles.push(file) } // Check if we've modified this file (potential conflict) - const ourDiff = await $`git diff HEAD -- ${file}`.cwd(root).text().catch(() => "") + const ourDiff = await $`git diff HEAD -- ${file}` + .cwd(root) + .text() + .catch(() => "") if (ourDiff.trim().length > 0) { analysis.potentialConflicts.push(file) } @@ -360,10 +365,16 @@ function printVersionAnalysis(analysis: VersionAnalysis): void { const line = "─".repeat(50) console.log(` ${line}`) console.log(` ${bold("Merge estimate:")}`) - console.log(` Auto-resolvable: ${GREEN}${categories.keepOurs.length + categories.skipFiles.length + categories.lockFiles.length}${RESET}`) + console.log( + ` Auto-resolvable: ${GREEN}${categories.keepOurs.length + categories.skipFiles.length + categories.lockFiles.length}${RESET}`, + ) console.log(` Need transform: ${categories.transformable.length}`) - console.log(` Likely conflicts: ${analysis.potentialConflicts.length > 0 ? RED : GREEN}${analysis.potentialConflicts.length}${RESET}`) - console.log(` Marker files: ${analysis.markerFiles.length > 0 ? YELLOW : GREEN}${analysis.markerFiles.length}${RESET}`) + console.log( + ` Likely conflicts: ${analysis.potentialConflicts.length > 0 ? RED : GREEN}${analysis.potentialConflicts.length}${RESET}`, + ) + console.log( + ` Marker files: ${analysis.markerFiles.length > 0 ? YELLOW : GREEN}${analysis.markerFiles.length}${RESET}`, + ) console.log() } @@ -446,7 +457,9 @@ function printMarkerAnalysis(config: MergeConfig): void { const complete = markers.filter((m) => m.endLine !== null) const incomplete = markers.filter((m) => m.endLine === null) - console.log(` Found ${bold(String(markers.length))} marker blocks in ${new Set(markers.map((m) => m.file)).size} files`) + console.log( + ` Found ${bold(String(markers.length))} marker blocks in ${new Set(markers.map((m) => m.file)).size} files`, + ) console.log(` ${GREEN}Complete (start + end):${RESET} ${complete.length}`) if (incomplete.length > 0) { @@ -468,10 +481,50 @@ function printMarkerAnalysis(config: MergeConfig): void { // Summary console.log() - console.log(` ${bold("Integrity:")} ${incomplete.length === 0 - ? `${GREEN}All blocks properly closed${RESET}` - : `${RED}${incomplete.length} unclosed block(s)${RESET}` - }`) + console.log( + ` ${bold("Integrity:")} ${ + incomplete.length === 0 + ? `${GREEN}All blocks properly closed${RESET}` + : `${RED}${incomplete.length} unclosed block(s)${RESET}` + }`, + ) +} + +// --------------------------------------------------------------------------- +// Upstream fix audit (--audit-fixes) +// --------------------------------------------------------------------------- + +function auditUpstreamFixes(config: MergeConfig): void { + const markers = findMarkers(config) + const fixes = markers.filter((m) => m.startComment.includes("upstream_fix:")) + + console.log() + console.log(bold("=== Upstream Bug Fixes We're Carrying ===")) + console.log() + + if (fixes.length === 0) { + console.log(` ${GREEN}No upstream_fix: markers found.${RESET}`) + console.log(` All our markers are feature additions, not bug fixes.`) + console.log() + return + } + + console.log(` Found ${bold(String(fixes.length))} upstream bug fix(es) to review before merge:\n`) + + for (const fix of fixes) { + // Extract description after "upstream_fix:" + const desc = fix.startComment.replace(/.*upstream_fix:\s*/, "").replace(/\s*\*\/\s*$/, "") + const lines = fix.endLine ? `${fix.line}-${fix.endLine}` : `${fix.line}` + console.log(` ${YELLOW}fix${RESET} ${fix.file}:${lines}`) + console.log(` ${desc}`) + console.log() + } + + console.log(` ${bold("Before each upstream merge:")}`) + console.log(` 1. Check if upstream fixed each issue in their release`) + console.log(` 2. If fixed upstream: accept their version, remove our marker`) + console.log(` 3. If not fixed: keep our marker (it will survive the merge)`) + console.log() } // --------------------------------------------------------------------------- @@ -490,6 +543,7 @@ function printUsage(): void { --version, -v Upstream version to analyze --branding Scan codebase for upstream branding leaks --markers Check changed files for missing altimate_change markers + --audit-fixes List all upstream_fix: markers (bug fixes we made to upstream code) --base Base branch for --markers comparison (default: HEAD) --strict Exit with code 1 on warnings (for CI) --verbose Show all results (not just top 20) @@ -509,6 +563,9 @@ function printUsage(): void { ${dim("# Check PR for missing markers (CI)")} bun run script/upstream/analyze.ts --markers --base main --strict + ${dim("# List upstream bug fixes we're carrying (review before merge)")} + bun run script/upstream/analyze.ts --audit-fixes + ${dim("# Machine-readable output for CI")} bun run script/upstream/analyze.ts --branding --json `) @@ -530,14 +587,9 @@ function getChangedFiles(base?: string): string[] { const root = repoRoot() // Only check Modified files (M), not Added (A). New files don't exist // upstream so they can't be overwritten by a merge — no markers needed. - const cmd = base - ? `git diff --name-only --diff-filter=M ${base}...HEAD` - : `git diff --name-only --diff-filter=M HEAD` + const cmd = base ? `git diff --name-only --diff-filter=M ${base}...HEAD` : `git diff --name-only --diff-filter=M HEAD` try { - return execSync(cmd, { cwd: root, encoding: "utf-8" }) - .trim() - .split("\n") - .filter(Boolean) + return execSync(cmd, { cwd: root, encoding: "utf-8" }).trim().split("\n").filter(Boolean) } catch { return [] } @@ -646,8 +698,14 @@ export function parseDiffForMarkerWarnings(file: string, diffOutput: string): Ma currentLine++ const content = line.slice(1).trim() - if (content.includes("altimate_change start")) { inMarkerBlock = true; continue } - if (content.includes("altimate_change end")) { inMarkerBlock = false; continue } + if (content.includes("altimate_change start")) { + inMarkerBlock = true + continue + } + if (content.includes("altimate_change end")) { + inMarkerBlock = false + continue + } if (content.includes("altimate_change")) continue // Only flag added lines as violations — context lines are pre-existing @@ -686,9 +744,7 @@ function checkFileForMarkers(file: string, base?: string): MarkerWarning[] { const { execSync } = require("child_process") const root = repoRoot() - const diffCmd = base - ? `git diff -U5 ${base}...HEAD -- "${file}"` - : `git diff -U5 HEAD -- "${file}"` + const diffCmd = base ? `git diff -U5 ${base}...HEAD -- "${file}"` : `git diff -U5 HEAD -- "${file}"` let diffOutput: string try { @@ -770,8 +826,9 @@ async function main(): Promise { const hasVersion = Boolean(args.version) const hasBranding = Boolean(args.branding) const hasMarkers = Boolean(args.markers) + const hasAuditFixes = Boolean(args["audit-fixes"]) - if (!hasVersion && !hasBranding && !hasMarkers) { + if (!hasVersion && !hasBranding && !hasMarkers && !hasAuditFixes) { // Default: run marker analysis printMarkerAnalysis(config) @@ -779,9 +836,16 @@ async function main(): Promise { logger.info("Use --version to analyze an upstream version") logger.info("Use --branding to audit for branding leaks") logger.info("Use --markers --base main to check for missing markers") + logger.info("Use --audit-fixes to list upstream bug fixes we're carrying") return } + // ─── Upstream fix audit ────────────────────────────────────────────────── + if (hasAuditFixes) { + auditUpstreamFixes(config) + if (!hasVersion && !hasBranding && !hasMarkers) return + } + // ─── Version analysis ────────────────────────────────────────────────────── if (hasVersion) { From a8436cb1411f1185be4ef2014c93c1416de60a16 Mon Sep 17 00:00:00 2001 From: anandgupta42 Date: Sat, 28 Mar 2026 19:07:16 -0700 Subject: [PATCH 2/3] fix: handle Bun segfault during CI test cleanup Bun 1.3.x has a known crash (segfault/SIGTERM) during process cleanup after all tests pass successfully. This caused flaky CI failures where 5362 tests pass but the job reports failure due to exit code 143. The fix captures test output and checks the actual pass/fail summary instead of relying on Bun's exit code: - Real test failures (N fail > 0): exit 1 - No test summary at all (Bun crashed before running): exit 1 - All tests pass but Bun crashes during cleanup: emit warning, exit 0 Co-Authored-By: Claude Opus 4.6 (1M context) --- .github/workflows/ci.yml | 33 ++++++++++++++++++++++++++++++++- 1 file changed, 32 insertions(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d6ce2f843c..766e93e13e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -84,13 +84,44 @@ jobs: run: bun install - name: Run tests - run: bun test --timeout 30000 working-directory: packages/opencode # Cloud E2E tests (Snowflake, BigQuery, Databricks) auto-skip when # ALTIMATE_CODE_CONN_* env vars are not set. Docker E2E tests auto-skip # when Docker is not available. No exclusion needed — skipIf handles it. # --timeout 30000: matches package.json "test" script; prevents 5s default # from cutting off tests that run bun install or bootstrap git instances. + # + # Bun 1.3.x has a known segfault during process cleanup after all tests + # pass (exit code 143/SIGTERM or 134/SIGABRT). We capture test output and + # check for real failures vs Bun crashes to avoid false CI failures. + run: | + set +e + bun test --timeout 30000 2>&1 | tee /tmp/test-output.txt + TEST_EXIT=$? + set -e + + # Extract pass/fail counts from Bun test summary + PASS_COUNT=$(grep -E '^\s+\d+ pass' /tmp/test-output.txt | awk '{print $1}' || echo "") + FAIL_COUNT=$(grep -E '^\s+\d+ fail' /tmp/test-output.txt | awk '{print $1}' || echo "") + + # If no summary at all, Bun crashed before running tests — real failure + if [ -z "$PASS_COUNT" ] && [ -z "$FAIL_COUNT" ]; then + echo "::error::Bun crashed before producing test results (exit code $TEST_EXIT)" + exit 1 + fi + + # Real test failures + if [ "$FAIL_COUNT" != "" ] && [ "$FAIL_COUNT" != "0" ]; then + echo "::error::$FAIL_COUNT test(s) failed" + exit 1 + fi + + echo "Tests passed: $PASS_COUNT pass, $FAIL_COUNT fail" + + # If bun exited non-zero but all tests passed, it's a Bun crash during cleanup + if [ "$TEST_EXIT" -ne 0 ]; then + echo "::warning::Bun exited with code $TEST_EXIT after all tests passed (known Bun 1.3.x segfault during cleanup — not a test failure)" + fi # --------------------------------------------------------------------------- # Driver E2E tests — only when driver code changes. From db4405a883ba83c7afc18d08b1e2dfb47f361635 Mon Sep 17 00:00:00 2001 From: anandgupta42 Date: Sat, 28 Mar 2026 19:17:41 -0700 Subject: [PATCH 3/3] fix: use file redirect instead of tee for Bun crash resilience The previous approach using `tee` failed because when Bun segfaults, the pipe breaks and tee doesn't flush output to the file. The grep then finds no summary and reports "crashed before producing results" even though all tests passed. Fix: redirect bun output to file directly (`> file 2>&1 || true`), then `cat` it for CI log visibility. Use portable `awk` instead of `grep -oP` for summary extraction. Co-Authored-By: Claude Opus 4.6 (1M context) --- .github/workflows/ci.yml | 43 ++++++++++++++++++++-------------------- 1 file changed, 22 insertions(+), 21 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 766e93e13e..09b1580109 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -94,35 +94,36 @@ jobs: # Bun 1.3.x has a known segfault during process cleanup after all tests # pass (exit code 143/SIGTERM or 134/SIGABRT). We capture test output and # check for real failures vs Bun crashes to avoid false CI failures. + shell: bash run: | - set +e - bun test --timeout 30000 2>&1 | tee /tmp/test-output.txt - TEST_EXIT=$? - set -e - - # Extract pass/fail counts from Bun test summary - PASS_COUNT=$(grep -E '^\s+\d+ pass' /tmp/test-output.txt | awk '{print $1}' || echo "") - FAIL_COUNT=$(grep -E '^\s+\d+ fail' /tmp/test-output.txt | awk '{print $1}' || echo "") - - # If no summary at all, Bun crashed before running tests — real failure - if [ -z "$PASS_COUNT" ] && [ -z "$FAIL_COUNT" ]; then - echo "::error::Bun crashed before producing test results (exit code $TEST_EXIT)" - exit 1 - fi + # Redirect bun output to file, then cat it for CI visibility. + # This avoids tee/pipe issues where SIGTERM kills tee before flush. + bun test --timeout 30000 > /tmp/test-output.txt 2>&1 || true + cat /tmp/test-output.txt + + # Extract pass/fail counts from Bun test summary (e.g., " 5362 pass") + PASS_COUNT=$(awk '/^ *[0-9]+ pass$/{print $1}' /tmp/test-output.txt || true) + FAIL_COUNT=$(awk '/^ *[0-9]+ fail$/{print $1}' /tmp/test-output.txt || true) - # Real test failures - if [ "$FAIL_COUNT" != "" ] && [ "$FAIL_COUNT" != "0" ]; then + echo "" + echo "--- Test Summary ---" + echo "pass=${PASS_COUNT:-none} fail=${FAIL_COUNT:-none}" + + # Real test failures — always fail CI + if [ -n "$FAIL_COUNT" ] && [ "$FAIL_COUNT" != "0" ]; then echo "::error::$FAIL_COUNT test(s) failed" exit 1 fi - echo "Tests passed: $PASS_COUNT pass, $FAIL_COUNT fail" - - # If bun exited non-zero but all tests passed, it's a Bun crash during cleanup - if [ "$TEST_EXIT" -ne 0 ]; then - echo "::warning::Bun exited with code $TEST_EXIT after all tests passed (known Bun 1.3.x segfault during cleanup — not a test failure)" + # Tests passed (we have a pass count and zero/no failures) + if [ -n "$PASS_COUNT" ] && [ "$PASS_COUNT" -gt 0 ] 2>/dev/null; then + exit 0 fi + # No test summary at all — Bun crashed before running tests + echo "::error::No test results found in output — Bun may have crashed before running tests" + exit 1 + # --------------------------------------------------------------------------- # Driver E2E tests — only when driver code changes. # Uses GitHub Actions services (no Docker-in-Docker).