Home

dev / openkara

publicthedavidweng/OpenKara· sync paused
Overview Code History Branches Pull requestsIssuesInsights
main
HomeOverview Code PRsIssues
dev/openkara/.github/workflows/ci.yml
Raw
1name: CI2 3on:4  push:5    branches: ["main"]6  pull_request:7  workflow_dispatch:8 9concurrency:
10
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
11 cancel-in-progress: true
12 
13permissions:
14 contents: read
15 
16env:
17 FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
18 
19jobs:
20 # ── Triage ───────────────────────────────────────────────────────────
21 # Runs the checked-in classifier (scripts/ci/classify-changes.mjs) to
22 # determine which jobs should run for this change set. The classifier is
23 # a pure function over filenames and event type — it is the single source
24 # of truth for path-based CI gating.
25 #
26 # Outputs:
27 # expected-jobs JSON array of job IDs that must run
28 # run_<job> "true"/"false" per job ID, consumed by `if:` conditions
29 # unknown "true" if any file is unmapped
30 # unknown-files JSON array of unmapped filenames
31 # categories JSON array of detected categories
32 #
33 # For push (to main) and workflow_dispatch, the classifier returns full CI
34 # as a deliberate safety path — the integration branch always gets full
35 # validation regardless of what changed.
36 triage:
37 name: Triage
38 runs-on: ubuntu-24.04
39 timeout-minutes: 5
40 permissions:
41 contents: read
42 pull-requests: write # labeler needs this to add/remove labels
43 outputs:
44 expected-jobs: ${{ steps.classify.outputs.expected-jobs }}
45 expected-skipped-heavy: ${{ steps.classify.outputs.expected-skipped-heavy }}
46 unknown: ${{ steps.classify.outputs.unknown }}
47 unknown-files: ${{ steps.classify.outputs.unknown-files }}
48 categories: ${{ steps.classify.outputs.categories }}
49 run_triage: ${{ steps.classify.outputs.run_triage }}
50 run_conventional-commits: ${{ steps.classify.outputs.run_conventional-commits }}
51 run_standards-reference: ${{ steps.classify.outputs.run_standards-reference }}
52 run_ci-gate: ${{ steps.classify.outputs.run_ci-gate }}
53 run_js-quality: ${{ steps.classify.outputs.run_js-quality }}
54 run_app-frontend: ${{ steps.classify.outputs.run_app-frontend }}
55 run_website: ${{ steps.classify.outputs.run_website }}
56 run_playwright-ui-smoke: ${{ steps.classify.outputs.run_playwright-ui-smoke }}
57 run_workflow-lint: ${{ steps.classify.outputs.run_workflow-lint }}
58 run_release-validation: ${{ steps.classify.outputs.run_release-validation }}
59 run_cargo-deny: ${{ steps.classify.outputs.run_cargo-deny }}
60 run_dependency-checks: ${{ steps.classify.outputs.run_dependency-checks }}
61 run_prepare-model: ${{ steps.classify.outputs.run_prepare-model }}
62 run_rust-test: ${{ steps.classify.outputs.run_rust-test }}
63 run_rust-test-windows-compile: ${{ steps.classify.outputs.run_rust-test-windows-compile }}
64 run_tauri-build-smoke: ${{ steps.classify.outputs.run_tauri-build-smoke }}
65 run_tauri-build: ${{ steps.classify.outputs.run_tauri-build }}
66 steps:
67 # paths-filter uses the GitHub API for PR events (no checkout needed);
68 # for push events it diffs via git, so checkout is required.
69 - name: Checkout
70 uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
71 with:
72 persist-credentials: false
73 
74 - name: Label PR by changed paths
75 # Only label same-repo PRs — fork PRs get a read-only GITHUB_TOKEN
76 # and the labeling API call 403s. Labeling is cosmetic (human
77 # triage aid); path detection below is what gates jobs.
78 uses: actions/labeler@bf12e9b00b37c5c0ca2b87b79b2daf7891dbda13 # v7.0.0
79 if: >
80 github.event_name == 'pull_request' &&
81 github.event.pull_request.head.repo.full_name == github.repository
82 with:
83 repo-token: ${{ secrets.GITHUB_TOKEN }}
84 sync-labels: true
85 
86 - name: Collect changed files
87 id: collect
88 env:
89 GH_TOKEN: ${{ github.token }}
90 EVENT_NAME: ${{ github.event_name }}
91 PR_NUMBER: ${{ github.event.pull_request.number }}
92 BEFORE: ${{ github.event.before }}
93 AFTER: ${{ github.event.after }}
94 run: |
95 set -euo pipefail
96 if [ "$EVENT_NAME" = "pull_request" ]; then
97 # Use the GitHub API with pagination to get the complete file list.
98 gh api --paginate --jq '.[].filename' \
99 "repos/${GITHUB_REPOSITORY}/pulls/${PR_NUMBER}/files" \
100 > changed-files.txt
101 elif [ "$EVENT_NAME" = "push" ]; then
102 # Fetch BEFORE so we can diff locally. The GitHub compare/commit
103 # APIs cap their `files` arrays at 300 entries, which would
104 # silently drop changed paths; a local git diff has no such
105 # limit. The checkout is shallow (fetch-depth: 1) so BEFORE is
106 # not in local history until we fetch it.
107 if [ "$BEFORE" = "0000000000000000000000000000000000000000" ]; then
108 # New branch — deepen by one to get the parent of AFTER.
109 git fetch --no-tags --deepen=1 origin
110 if git rev-parse --verify HEAD~1 >/dev/null 2>&1; then
111 git diff --name-only HEAD~1..HEAD > changed-files.txt
112 else
113 # Initial commit (no parent) — list all tracked files.
114 git ls-tree -r --name-only HEAD > changed-files.txt
115 fi
116 else
117 git fetch --no-tags --depth=1 origin "$BEFORE"
118 git diff --name-only "$BEFORE".."$AFTER" > changed-files.txt
119 fi
120 else
121 # workflow_dispatch — no changed files; classifier returns full CI.
122 : > changed-files.txt
123 fi
124 echo "count=$(wc -l < changed-files.txt | tr -d ' ')" >> "$GITHUB_OUTPUT"
125 
126 - name: Classify changes
127 id: classify
128 env:
129 EVENT_NAME: ${{ github.event_name }}
130 run: |
131 set -euo pipefail
132 node scripts/ci/classify-changes.mjs \
133 --files "$(cat changed-files.txt)" \
134 --event "$EVENT_NAME" \
135 > classification.json
136 
137 # Extract outputs for GITHUB_OUTPUT.
138 jq -r '
139 "expected-jobs=\(.expectedJobs | @json)",
140 "expected-skipped-heavy=\(.expectedSkippedHeavyJobs | @json)",
141 "unknown=\(if .unknownFiles | length > 0 then "true" else "false" end)",
142 "unknown-files=\(.unknownFiles | @json)",
143 "categories=\(.categories | @json)"
144 ' classification.json >> "$GITHUB_OUTPUT"
145 
146 # Per-job run_ booleans.
147 jq -r '.run | to_entries[] | "run_\(.key)=\(.value)"' \
148 classification.json >> "$GITHUB_OUTPUT"
149 
150 # Print classification to the step summary.
151 {
152 echo "## CI Triage"
153 echo ""
154 echo "**Event:** \`${EVENT_NAME}\`"
155 echo ""
156 echo "### Changed files"
157 echo ""
158 if [ -s changed-files.txt ]; then
159 while IFS= read -r file; do
160 cats=$(jq -r --arg f "$file" \
161 '.categoriesByFile[$f] // ["unknown"] | join(", ")' \
162 classification.json)
163 echo "- \`${file}\` → ${cats}"
164 done < changed-files.txt
165 else
166 echo "- _(none — full CI by event type)_"
167 fi
168 echo ""
169 echo "### Expected jobs"
170 echo ""
171 jq -r '.expectedJobs[] | "- \(.)"' classification.json
172 echo ""
173 echo "### Expected skipped heavy jobs"
174 echo ""
175 skipped=$(jq -r '.expectedSkippedHeavyJobs | length' classification.json)
176 if [ "$skipped" -gt 0 ]; then
177 jq -r '.expectedSkippedHeavyJobs[] | "- \(.)"' classification.json
178 else
179 echo "- _(none)_"
180 fi
181 if [ "$(jq '.unknownFiles | length' classification.json)" -gt 0 ]; then
182 echo ""
183 echo "### ⚠️ Unknown files (full CI safety fallback)"
184 echo ""
185 jq -r '.unknownFiles[] | "- `\(.)`"' classification.json
186 fi
187 } >> "$GITHUB_STEP_SUMMARY"
188 
189 prepare-model:
190 name: Prepare separation model
191 needs: triage
192 if: |
193 !cancelled() &&
194 needs.triage.outputs.run_prepare-model == 'true'
195 runs-on: ubuntu-24.04
196 timeout-minutes: 10
197 
198 steps:
199 - name: Checkout
200 uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
201 with:
202 persist-credentials: false
203 
204 - name: Setup Node.js
205 uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
206 with:
207 node-version: 24
208 package-manager-cache: false
209 
210 # Model identity comes from the pinned catalog snapshot
211 # (src-tauri/catalog/release-manifest.json) via the same resolver
212 # setup.sh uses, so CI, local setup, and the app share one contract.
213 - name: Resolve separation model from catalog snapshot
214 id: model
215 run: |
216 {
217 echo "url=$(node scripts/resolve-model.mjs --field url)"
218 echo "sha256=$(node scripts/resolve-model.mjs --field sha256)"
219 echo "file=$(node scripts/resolve-model.mjs --field filename)"
220 echo "file_sha256=$(node scripts/resolve-model.mjs --field file_sha256)"
221 echo "archived=$(node scripts/resolve-model.mjs --field archived)"
222 } >> "$GITHUB_OUTPUT"
223 
224 - name: Restore separation model cache
225 uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
226 with:
227 path: src-tauri/models/${{ steps.model.outputs.file }}
228 key: separation-model-${{ steps.model.outputs.file_sha256 }}
229 enableCrossOsArchive: true
230 
231 - name: Download and verify separation model
232 env:
233 MODEL_URL: ${{ steps.model.outputs.url }}
234 DOWNLOAD_SHA256: ${{ steps.model.outputs.sha256 }}
235 MODEL_FILE: ${{ steps.model.outputs.file }}
236 MODEL_FILE_SHA256: ${{ steps.model.outputs.file_sha256 }}
237 MODEL_ARCHIVED: ${{ steps.model.outputs.archived }}
238 run: |
239 if echo "$MODEL_FILE_SHA256 src-tauri/models/$MODEL_FILE" | sha256sum -c --status 2>/dev/null; then
240 echo "Model already present and verified"
241 exit 0
242 fi
243 mkdir -p src-tauri/models
244 curl -L --fail "$MODEL_URL" -o /tmp/model-download
245 echo "$DOWNLOAD_SHA256 /tmp/model-download" | sha256sum -c
246 if [ "$MODEL_ARCHIVED" = "true" ]; then
247 tar -xzf /tmp/model-download -C src-tauri/models "$MODEL_FILE"
248 else
249 mv /tmp/model-download "src-tauri/models/$MODEL_FILE"
250 fi
251 echo "$MODEL_FILE_SHA256 src-tauri/models/$MODEL_FILE" | sha256sum -c
252 
253 - name: Upload separation model
254 uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
255 with:
256 name: separation-model
257 path: src-tauri/models/${{ steps.model.outputs.file }}
258 if-no-files-found: error
259 retention-days: 1
260 
261 conventional-commits:
262 name: Conventional commits
263 if: github.event_name == 'pull_request'
264 runs-on: ubuntu-24.04
265 timeout-minutes: 5
266 
267 steps:
268 - name: Enforce conventional PR title
269 uses: amannn/action-semantic-pull-request@48f256284bd46cdaab1048c3721360e808335d50 # v6.1.1
270 env:
271 GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
272 
273 # ── Standards reference ─────────────────────────────────────────────
274 # Validates the mandatory AGENTS.md → index → profile route without
275 # running a full application build for a documentation-only change.
276 standards-reference:
277 name: Standards reference
278 needs: triage
279 if: |
280 !cancelled() &&
281 needs.triage.outputs.run_standards-reference == 'true'
282 runs-on: ubuntu-24.04
283 timeout-minutes: 5
284 
285 steps:
286 - name: Checkout
287 uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
288 with:
289 persist-credentials: false
290 
291 - name: Setup Node.js
292 uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
293 with:
294 node-version: 24
295 
296 - name: Verify standards route
297 run: node --run check:standards
298 
299 # ── JS quality ───────────────────────────────────────────────────────
300 # Format, lint, i18n, knip, schema drift, and dependency audit.
301 # Runs for any JS-touching category but does not build or run tests.
302 js-quality:
303 name: JS quality
304 needs: triage
305 if: |
306 !cancelled() &&
307 needs.triage.outputs.run_js-quality == 'true'
308 runs-on: ubuntu-24.04
309 timeout-minutes: 10
310 
311 steps:
312 - name: Checkout
313 uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
314 with:
315 persist-credentials: false
316 
317 - name: Setup pnpm
318 uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9
319 with:
320 version: 11.13.0
321 
322 - name: Setup Node.js
323 uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
324 with:
325 node-version: 24
326 cache: pnpm
327 cache-dependency-path: pnpm-lock.yaml
328 
329 - name: Install JavaScript dependencies
330 run: pnpm install --frozen-lockfile
331 
332 - name: Audit dependencies
333 run: pnpm audit --audit-level=high
334 
335 - name: Verify formatting
336 run: node --run format
337 
338 - name: Lint frontend
339 run: node --run lint
340 
341 - name: Check i18n keys
342 run: node --run check:i18n
343 
344 - name: Check db-schema drift
345 run: |
346 pnpm generate:db-schema
347 git diff --exit-code docs/references/generated/db-schema.md
348 
349 - name: knip (unused exports / dependencies)
350 run: pnpm knip --no-progress
351 
352 # ── App frontend ─────────────────────────────────────────────────────
353 # App typecheck, production build, and unit tests with coverage.
354 app-frontend:
355 name: App frontend build / test
356 needs: triage
357 if: |
358 !cancelled() &&
359 needs.triage.outputs.run_app-frontend == 'true'
360 runs-on: ubuntu-24.04
361 timeout-minutes: 15
362 
363 permissions:
364 contents: read
365 pull-requests: write # Needed by vitest-coverage-report-action to comment coverage on PRs.
366 
367 steps:
368 - name: Checkout
369 uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
370 with:
371 persist-credentials: false
372 
373 - name: Setup pnpm
374 uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9
375 with:
376 version: 11.13.0
377 
378 - name: Setup Node.js
379 uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
380 with:
381 node-version: 24
382 cache: pnpm
383 cache-dependency-path: pnpm-lock.yaml
384 
385 - name: Install JavaScript dependencies
386 run: pnpm install --frozen-lockfile
387 
388 - name: Build frontend
389 run: node --run build
390 
391 - name: Typecheck frontend
392 run: pnpm tsc --noEmit --project tsconfig.json
393 
394 - name: Run frontend tests with coverage
395 run: node --run test -- --coverage --reporter=junit --outputFile=test-results.junit.xml
396 
397 - name: Upload test results to Codecov
398 if: always()
399 uses: codecov/test-results-action@0fa95f0e1eeaafde2c782583b36b28ad0d8c77d3 # v1
400 with:
401 token: ${{ secrets.CODECOV_TOKEN }}
402 fail_ci_if_error: false
403 
404 - name: Test metrics summary
405 if: always()
406 run: |
407 {
408 echo "## Frontend Test Metrics"
409 echo ""
410 
411 # Parse coverage from json-summary if available
412 if [ -f coverage/coverage-summary.json ]; then
413 BRANCHES=$(jq '.total.branches.pct' coverage/coverage-summary.json)
414 FUNCTIONS=$(jq '.total.functions.pct' coverage/coverage-summary.json)
415 LINES=$(jq '.total.lines.pct' coverage/coverage-summary.json)
416 STATEMENTS=$(jq '.total.statements.pct' coverage/coverage-summary.json)
417 
418 echo "### Coverage"
419 echo "| Metric | Percentage |"
420 echo "|--------|------------|"
421 echo "| Branches | ${BRANCHES}% |"
422 echo "| Functions | ${FUNCTIONS}% |"
423 echo "| Lines | ${LINES}% |"
424 echo "| Statements | ${STATEMENTS}% |"
425 echo ""
426 fi
427 } >> "$GITHUB_STEP_SUMMARY"
428 
429 - name: Upload coverage reports
430 if: always()
431 uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
432 with:
433 name: frontend-coverage
434 path: coverage/
435 retention-days: 14
436 
437 - name: Report coverage on PR
438 if: always() && github.event_name == 'pull_request'
439 uses: davelosert/vitest-coverage-report-action@8b157684c6a6b259b97d45e72b44242865c0f6a5 # v2.12.2
440 with:
441 json-summary-path: ./coverage/coverage-summary.json
442 vite-config-path: ./vite.config.ts
443 
444 - name: Upload coverage to Codecov
445 if: always()
446 uses: codecov/codecov-action@0fb7174895f61a3b6b78fc075e0cd60383518dac # v5.5.5
447 with:
448 token: ${{ secrets.CODECOV_TOKEN }}
449 directory: coverage
450 flags: frontend
451 fail_ci_if_error: false
452 
453 # ── Website ──────────────────────────────────────────────────────────
454 # Website typecheck and docs build only. Does not run the full app
455 # test suite — website-only changes do not affect app behavior.
456 website:
457 name: Website build
458 needs: triage
459 if: |
460 !cancelled() &&
461 needs.triage.outputs.run_website == 'true'
462 runs-on: ubuntu-24.04
463 timeout-minutes: 10
464 
465 steps:
466 - name: Checkout
467 uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
468 with:
469 persist-credentials: false
470 
471 - name: Setup pnpm
472 uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9
473 with:
474 version: 11.13.0
475 
476 - name: Setup Node.js
477 uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
478 with:
479 node-version: 24
480 cache: pnpm
481 cache-dependency-path: pnpm-lock.yaml
482 
483 - name: Install JavaScript dependencies
484 run: pnpm install --frozen-lockfile
485 
486 - name: Typecheck landing page
487 run: pnpm tsc --noEmit --project website/tsconfig.json
488 
489 - name: Build landing page
490 run: node --run docs:build
491 
492 cargo-deny:
493 name: Cargo deny
494 needs: triage
495 if: |
496 !cancelled() &&
497 needs.triage.outputs.run_cargo-deny == 'true'
498 runs-on: ubuntu-24.04
499 timeout-minutes: 10
500 
501 steps:
502 - name: Checkout
503 uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
504 with:
505 persist-credentials: false
506 
507 - name: Check Rust dependencies
508 uses: EmbarkStudios/cargo-deny-action@3c6349835b2b7b196a839186cb8b78e02f7b5f25 # v2.1.1
509 with:
510 manifest-path: src-tauri/Cargo.toml
511 
512 workflow-lint:
513 name: Workflow lint
514 needs: triage
515 if: |
516 !cancelled() &&
517 needs.triage.outputs.run_workflow-lint == 'true'
518 runs-on: ubuntu-24.04
519 timeout-minutes: 5
520 
521 permissions:
522 contents: read
523 security-events: write # Needed by zizmor-action to upload SARIF results.
524 
525 steps:
526 - name: Checkout
527 uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
528 with:
529 persist-credentials: false
530 
531 - name: actionlint
532 uses: reviewdog/action-actionlint@6fb7acc99f4a1008869fa8a0f09cfca740837d9d # v1.72.0
533 with:
534 actionlint_flags: ""
535 
536 - name: zizmor
537 uses: zizmorcore/zizmor-action@6599ee8b7a49aef6a770f63d261d214911a7ce02 # v0.6.0
538 with:
539 persona: pedantic
540 min-severity: low
541 
542 # ── Release validation ───────────────────────────────────────────────
543 # Focused validation for release-workflow and release-metadata changes.
544 # Runs the release-workflow and release-metadata contract tests. Does not
545 # run app/frontend/Rust CI.
546 release-validation:
547 name: Release validation
548 needs: triage
549 if: |
550 !cancelled() &&
551 needs.triage.outputs.run_release-validation == 'true'
552 runs-on: ubuntu-24.04
553 timeout-minutes: 10
554 
555 steps:
556 - name: Checkout
557 uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
558 with:
559 persist-credentials: false
560 
561 - name: Setup pnpm
562 uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9
563 with:
564 version: 11.13.0
565 
566 - name: Setup Node.js
567 uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
568 with:
569 node-version: 24
570 cache: pnpm
571 cache-dependency-path: pnpm-lock.yaml
572 
573 - name: Install JavaScript dependencies
574 run: pnpm install --frozen-lockfile
575 
576 - name: Run release contract tests
577 run: |
578 pnpm vitest run \
579 tests/release-workflow.test.ts \
580 tests/release-metadata.test.ts
581 
582 dependency-checks:
583 name: Dependency surface checks
584 needs: triage
585 if: |
586 !cancelled() &&
587 needs.triage.outputs.run_dependency-checks == 'true'
588 runs-on: ubuntu-24.04
589 timeout-minutes: 10
590 
591 steps:
592 - name: Checkout
593 uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
594 with:
595 persist-credentials: false
596 
597 - name: Setup Rust
598 uses: dtolnay/rust-toolchain@e97e2d8cc328f1b50210efc529dca0028893a2d9 # v1
599 with:
600 toolchain: stable
601 
602 - name: Setup Node.js
603 uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
604 with:
605 node-version: 24
606 package-manager-cache: false
607 
608 - name: ort C API level vs catalog runtimes
609 run: node scripts/ci/check-ort-api-level.mjs
610 
611 # Prebuilt binary rather than `cargo install`, which rebuilt cargo-shear
612 # from source on every run of a job whose actual work takes seconds.
613 - name: Install cargo-shear
614 uses: taiki-e/install-action@a6b2e2dcd845ddd7f509ce4f3ed3d922b80cc5d9 # v2.84.0
615 with:
616 tool: cargo-shear@1.12.4
617 
618 - name: cargo-shear (unused Cargo dependencies)
619 working-directory: src-tauri
620 run: cargo shear --deny-warnings --locked
621 
622 rust-test:
623 name: Rust tests (${{ matrix.name }})
624 needs: [triage, prepare-model]
625 if: |
626 !cancelled() &&
627 needs.triage.outputs.run_rust-test == 'true' &&
628 needs.prepare-model.result == 'success'
629 runs-on: ${{ matrix.os }}
630 timeout-minutes: 30
631 strategy:
632 fail-fast: false
633 matrix:
634 include:
635 - name: macOS
636 os: macos-14
637 ort_target: aarch64-apple-darwin
638 - name: Linux
639 os: ubuntu-22.04
640 ort_target: x86_64-unknown-linux-gnu
641 
642 steps:
643 - name: Checkout
644 uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
645 with:
646 persist-credentials: false
647 
648 - name: Setup Node.js
649 uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
650 with:
651 node-version: 24
652 package-manager-cache: false
653 
654 - name: Verify Cargo is not broken
655 if: runner.os == 'macOS'
656 run: |
657 if cargo --version 2>&1 | grep -qi "rustup-init"; then
658 echo "Broken cargo detected, reinstalling toolchain..."
659 rm -rf "$HOME/.cargo"
660 fi
661 
662 - name: Setup Rust
663 uses: dtolnay/rust-toolchain@e97e2d8cc328f1b50210efc529dca0028893a2d9 # v1
664 with:
665 toolchain: stable
666 
667 - name: Verify Rust toolchain (macOS)
668 if: runner.os == 'macOS'
669 run: |
670 cargo --version
671 rustc --version
672 
673 - name: Restore Rust cache
674 uses: swatinem/rust-cache@c19371144df3bb44fab255c43d04cbc2ab54d1c4 # v2.9.1
675 with:
676 shared-key: ${{ github.workflow }}-${{ matrix.os }}-debug
677 workspaces: ./src-tauri -> target
678 cache-bin: false
679 key: ${{ matrix.os }}-debug
680 
681 - name: Install Linux test dependencies
682 if: runner.os == 'Linux'
683 run: |
684 sudo apt-get update
685 sudo apt-get install -y \
686 build-essential \
687 curl \
688 libasound2-dev \
689 libayatana-appindicator3-dev \
690 librsvg2-dev \
691 libssl-dev \
692 libwebkit2gtk-4.1-dev \
693 libxdo-dev
694 
695 - name: Download separation model
696 uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
697 with:
698 name: separation-model
699 path: src-tauri/models
700 
701 - name: Restore ONNX Runtime cache
702 uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
703 with:
704 path: src-tauri/generated/onnxruntime
705 key: onnxruntime-${{ matrix.ort_target }}-${{ hashFiles('scripts/prepare-onnx-runtime.mjs', 'src-tauri/catalog/release-manifest.json') }}
706 
707 - name: Prepare ONNX Runtime
708 env:
709 ORT_TARGET: ${{ matrix.ort_target }}
710 shell: bash
711 run: node scripts/prepare-onnx-runtime.mjs --target "${ORT_TARGET}"
712 
713 - name: Verify Rust formatting
714 working-directory: src-tauri
715 run: cargo fmt --check
716 
717 - name: Clippy
718 working-directory: src-tauri
719 run: cargo clippy --all-targets -- -D warnings
720 
721 - name: Install nextest
722 uses: taiki-e/install-action@a6b2e2dcd845ddd7f509ce4f3ed3d922b80cc5d9 # v2.84.0
723 with:
724 tool: nextest
725 
726 - name: Run Rust tests
727 working-directory: src-tauri
728 env:
729 # Parallel rust-lld of many heavy Tauri integration-test binaries has
730 # hit SIGBUS (Bus error) on GitHub Linux runners. Cap build jobs so
731 # concurrent linkers do not collide under memory pressure.
732 CARGO_BUILD_JOBS: "2"
733 run: |
734 if [ "$RUNNER_OS" = "Linux" ]; then
735 cargo nextest run --no-fail-fast
736 else
737 # phase5_perf asserts fixed latency thresholds for fixture audio. GitHub's
738 # macOS runners have noisy cold metadata probes; Linux remains the CI
739 # baseline, while macOS still runs the functional Rust suite.
740 # Filter by binary name — test(phase5_perf) does not match the binary
741 # and still runs backend_performance_report_stays_within_phase5_thresholds.
742 cargo nextest run --no-fail-fast -E '!binary(phase5_perf)'
743 fi
744 shell: bash
745 
746 rust-test-windows-compile:
747 name: Rust tests (Windows compile)
748 needs: triage
749 if: |
750 !cancelled() &&
751 needs.triage.outputs.run_rust-test-windows-compile == 'true'
752 runs-on: windows-latest
753 timeout-minutes: 30
754 steps:
755 - name: Checkout
756 uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
757 with:
758 persist-credentials: false
759 
760 - name: Setup Rust
761 uses: dtolnay/rust-toolchain@e97e2d8cc328f1b50210efc529dca0028893a2d9 # v1
762 with:
763 toolchain: stable
764 
765 - name: Restore Rust cache
766 uses: swatinem/rust-cache@c19371144df3bb44fab255c43d04cbc2ab54d1c4 # v2.9.1
767 with:
768 shared-key: ${{ github.workflow }}-windows-latest-debug
769 workspaces: ./src-tauri -> target
770 cache-bin: false
771 key: windows-latest-debug
772 
773 - name: Restore ONNX Runtime cache
774 uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
775 with:
776 path: src-tauri/generated/onnxruntime
777 key: onnxruntime-x86_64-pc-windows-msvc-${{ hashFiles('scripts/prepare-onnx-runtime.mjs', 'src-tauri/catalog/release-manifest.json') }}
778 
779 - name: Prepare ONNX Runtime
780 run: node scripts/prepare-onnx-runtime.mjs --target x86_64-pc-windows-msvc
781 
782 - name: Compile test binaries only
783 working-directory: src-tauri
784 run: cargo test -q --no-run
785 
786 # The installed-app CLI is feature-gated out of normal user builds, so
787 # compile it explicitly here. This is a cached compile-only check on the
788 # existing Windows Rust job, rather than a separate slow runner.
789 - name: Compile installed-app automation smoke entry point
790 working-directory: src-tauri
791 run: cargo check -q --features automation-smoke --bin openkara
792 
793 - name: Verify execution-provider platform contract
794 working-directory: src-tauri
795 shell: pwsh
796 run: |
797 $env:PATH = "$env:GITHUB_WORKSPACE\src-tauri\generated\onnxruntime;$env:PATH"
798 cargo test -q --lib execution_provider
799 
800 # Linux-only Tauri smoke build for frontend-only PRs. Verifies that the
801 # frontend bundles into Tauri and the build entry point works, without
802 # paying for the 3-platform matrix. Runs when frontend changed but rust
803 # did not (pure frontend, deps_js, or frontend_tooling).
804 tauri-build-smoke:
805 name: Tauri build (Linux smoke)
806 needs: [triage, app-frontend]
807 if: |
808 !cancelled() &&
809 needs.triage.outputs.run_tauri-build-smoke == 'true' &&
810 needs.app-frontend.result != 'failure'
811 runs-on: ubuntu-22.04
812 timeout-minutes: 30
813 
814 steps:
815 - name: Checkout
816 uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
817 with:
818 persist-credentials: false
819 
820 - name: Setup pnpm
821 uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9
822 with:
823 version: 11.13.0
824 
825 - name: Setup Node.js
826 uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
827 with:
828 node-version: 24
829 cache: pnpm
830 cache-dependency-path: pnpm-lock.yaml
831 
832 - name: Setup Rust
833 uses: dtolnay/rust-toolchain@e97e2d8cc328f1b50210efc529dca0028893a2d9 # v1
834 with:
835 toolchain: stable
836 
837 - name: Restore Rust cache
838 uses: swatinem/rust-cache@c19371144df3bb44fab255c43d04cbc2ab54d1c4 # v2.9.1
839 with:
840 shared-key: ${{ github.workflow }}-ubuntu-22.04-release
841 workspaces: ./src-tauri -> target
842 cache-bin: false
843 key: ubuntu-22.04-release
844 
845 - name: Install Linux build dependencies
846 run: |
847 sudo apt-get update
848 sudo apt-get install -y \
849 build-essential \
850 binutils \
851 curl \
852 file \
853 patchelf \
854 libasound2-dev \
855 libayatana-appindicator3-dev \
856 librsvg2-dev \
857 libssl-dev \
858 libwebkit2gtk-4.1-dev \
859 libxdo-dev \
860 wget
861 
862 - name: Install JavaScript dependencies
863 run: pnpm install --frozen-lockfile
864 
865 - name: Restore ONNX Runtime cache
866 uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
867 with:
868 path: src-tauri/generated/onnxruntime
869 key: onnxruntime-x86_64-unknown-linux-gnu-${{ hashFiles('scripts/prepare-onnx-runtime.mjs', 'src-tauri/catalog/release-manifest.json') }}
870 
871 - name: Prepare ONNX Runtime
872 env:
873 ORT_TARGET: x86_64-unknown-linux-gnu
874 shell: bash
875 run: node scripts/prepare-onnx-runtime.mjs --target "${ORT_TARGET}"
876 
877 - name: Validate Tauri build
878 env:
879 OPENKARA_GOOGLE_DRIVE_OAUTH_CLIENT_JSON: ${{ secrets.OPENKARA_GOOGLE_DRIVE_OAUTH_CLIENT_JSON }}
880 OPENKARA_DROPBOX_APP_KEY: ${{ secrets.OPENKARA_DROPBOX_APP_KEY }}
881 OPENKARA_DROPBOX_APP_SECRET: ${{ secrets.OPENKARA_DROPBOX_APP_SECRET }}
882 run: pnpm tauri build --ci --bundles deb
883 shell: bash
884 
885 # Full 3-platform Tauri build for Rust/deps/workflows/unknown changes.
886 # Waits for cheap checks (dependency-checks, windows-compile) before
887 # starting the expensive matrix so failures surface faster.
888 tauri-build:
889 name: Tauri build (${{ matrix.name }})
890 needs:
891 [
892 triage,
893 app-frontend,
894 rust-test,
895 cargo-deny,
896 dependency-checks,
897 rust-test-windows-compile,
898 ]
899 # Use !cancelled() so the job can run even when some upstream jobs were
900 # skipped (e.g. a rust-only PR skips app-frontend, but tauri-build still
901 # needs to verify the Rust side compiles into a Tauri bundle).
902 # The result != 'failure' guards ensure we don't build on top of a
903 # failed dependency. Only runs for rust/deps/unknown —
904 # frontend-only PRs use tauri-build-smoke (Linux) instead.
905 if: |
906 !cancelled() &&
907 needs.triage.outputs.run_tauri-build == 'true' &&
908 needs.app-frontend.result != 'failure' &&
909 needs.rust-test.result != 'failure' &&
910 needs.cargo-deny.result != 'failure' &&
911 needs.dependency-checks.result != 'failure' &&
912 needs.rust-test-windows-compile.result != 'failure'
913 runs-on: ${{ matrix.os }}
914 timeout-minutes: 60
915 strategy:
916 fail-fast: false
917 matrix:
918 include:
919 - name: macOS
920 os: macos-14
921 ort_target: aarch64-apple-darwin
922 - name: Windows
923 os: windows-latest
924 ort_target: x86_64-pc-windows-msvc
925 - name: Linux
926 os: ubuntu-22.04
927 ort_target: x86_64-unknown-linux-gnu
928 
929 steps:
930 - name: Checkout
931 uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
932 with:
933 persist-credentials: false
934 
935 - name: Setup pnpm
936 uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9
937 with:
938 version: 11.13.0
939 
940 - name: Setup Node.js
941 uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
942 with:
943 node-version: 24
944 cache: pnpm
945 cache-dependency-path: pnpm-lock.yaml
946 
947 - name: Verify Cargo is not broken
948 if: runner.os == 'macOS'
949 run: |
950 # Some macOS runner images ship with a broken toolchain where
951 # $HOME/.cargo/bin/cargo dispatches to rustup-init. Detect and
952 # repair without a full reinstall every time.
953 if cargo --version 2>&1 | grep -qi "rustup-init"; then
954 echo "Broken cargo detected, reinstalling toolchain..."
955 rm -rf "$HOME/.cargo"
956 fi
957 
958 - name: Setup Rust
959 uses: dtolnay/rust-toolchain@e97e2d8cc328f1b50210efc529dca0028893a2d9 # v1
960 with:
961 toolchain: stable
962 
963 - name: Repair toolchain if still broken
964 if: runner.os == 'macOS'
965 run: |
966 # rustup can leave cargo pointing to rustup-init if the
967 # proxy links got clobbered. Re-run the toolchain installer
968 # so it repairs the bin symlinks.
969 if cargo --version 2>&1 | grep -qi "rustup-init"; then
970 rustup update stable --force
971 fi
972 
973 - name: Restore Rust cache
974 uses: swatinem/rust-cache@c19371144df3bb44fab255c43d04cbc2ab54d1c4 # v2.9.1
975 with:
976 shared-key: ${{ github.workflow }}-${{ matrix.os }}-release
977 workspaces: ./src-tauri -> target
978 cache-bin: false
979 key: ${{ matrix.os }}-release
980 
981 - name: Install Linux build dependencies
982 if: runner.os == 'Linux'
983 run: |
984 sudo apt-get update
985 sudo apt-get install -y \
986 build-essential \
987 binutils \
988 curl \
989 file \
990 patchelf \
991 libasound2-dev \
992 libayatana-appindicator3-dev \
993 librsvg2-dev \
994 libssl-dev \
995 libwebkit2gtk-4.1-dev \
996 libxdo-dev \
997 wget
998 
999 - name: Install JavaScript dependencies
1000 run: pnpm install --frozen-lockfile
1001 
1002 # No `pnpm audit` here: js-quality already gates the same lockfile, and a
1003 # second run only adds a registry round trip to the slowest job in CI.
1004 - name: Restore ONNX Runtime cache
1005 uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
1006 with:
1007 path: src-tauri/generated/onnxruntime
1008 key: onnxruntime-${{ matrix.ort_target }}-${{ hashFiles('scripts/prepare-onnx-runtime.mjs', 'src-tauri/catalog/release-manifest.json') }}
1009 
1010 - name: Prepare ONNX Runtime
1011 env:
1012 ORT_TARGET: ${{ matrix.ort_target }}
1013 shell: bash
1014 run: node scripts/prepare-onnx-runtime.mjs --target "${ORT_TARGET}"
1015 
1016 - name: Validate Tauri build
1017 env:
1018 OPENKARA_GOOGLE_DRIVE_OAUTH_CLIENT_JSON: ${{ secrets.OPENKARA_GOOGLE_DRIVE_OAUTH_CLIENT_JSON }}
1019 OPENKARA_DROPBOX_APP_KEY: ${{ secrets.OPENKARA_DROPBOX_APP_KEY }}
1020 OPENKARA_DROPBOX_APP_SECRET: ${{ secrets.OPENKARA_DROPBOX_APP_SECRET }}
1021 run: |
1022 if [ "$RUNNER_OS" = "Linux" ]; then
1023 pnpm tauri build --ci --bundles deb
1024 else
1025 pnpm tauri build --debug --no-bundle --ci
1026 fi
1027 shell: bash
1028 
1029 - name: Verify Linux glibc floor
1030 if: runner.os == 'Linux'
1031 shell: bash
1032 run: |
1033 set -euo pipefail
1034 
1035 find_one() {
1036 local pattern="$1"
1037 find src-tauri/target/release -path "$pattern" -print -quit
1038 }
1039 
1040 max_glibc_version() {
1041 local binary="$1"
1042 # Exclude weak undefined symbols -- Rust's stdlib weak-imports
1043 # glibc helpers like pidfd_spawnp / pidfd_getpid that gracefully
1044 # fall back at runtime and do not create a hard dependency.
1045 # objdump -T flags field: " w DF" for weak, " DF" for strong.
1046 objdump -T "$binary" \
1047 | awk '
1048 / w .* \*UND\*/ { next }
1049 match($0, /GLIBC_[0-9.]+/) {
1050 print substr($0, RSTART + 6, RLENGTH - 6)
1051 }
1052 ' \
1053 | sort -V \
1054 | tail -1
1055 }
1056 
1057 assert_glibc_floor() {
1058 local binary="$1"
1059 local max_version
1060 max_version="$(max_glibc_version "$binary")"
1061 if [ -z "$max_version" ]; then
1062 echo "No GLIBC symbols found in $binary"
1063 return
1064 fi
1065 
1066 echo "$binary: highest strong GLIBC requirement is $max_version"
1067 
1068 if [ "$(printf '%s\n%s\n' "$max_version" "2.35" | sort -V | tail -1)" != "2.35" ]; then
1069 echo "::error::$binary requires GLIBC_$max_version, which exceeds Ubuntu 22.04's GLIBC_2.35 baseline"
1070 objdump -T "$binary" | grep "GLIBC_$max_version" | head -5
1071 exit 1
1072 fi
1073 }
1074 
1075 app_binary="$(find_one '*/bundle/deb/*/usr/bin/openkara')"
1076 test -n "$app_binary"
1077 
1078 assert_glibc_floor "$app_binary"
1079 
1080 playwright-ui-smoke:
1081 name: Playwright UI smoke
1082 needs: triage
1083 if: |
1084 !cancelled() &&
1085 needs.triage.outputs.run_playwright-ui-smoke == 'true'
1086 runs-on: ubuntu-24.04
1087 timeout-minutes: 15
1088 
1089 steps:
1090 - name: Checkout
1091 uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
1092 with:
1093 persist-credentials: false
1094 
1095 - name: Setup pnpm
1096 uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9
1097 with:
1098 version: 11.13.0
1099 
1100 - name: Setup Node.js
1101 uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
1102 with:
1103 node-version: 24
1104 cache: pnpm
1105 cache-dependency-path: pnpm-lock.yaml
1106 
1107 - name: Install JavaScript dependencies
1108 run: pnpm install --frozen-lockfile
1109 
1110 - name: Install Playwright browsers
1111 run: pnpm exec playwright install chromium webkit --with-deps
1112 
1113 - name: Run Playwright UI smoke tests
1114 run: node --run test:ui-smoke
1115 
1116 # Count flaky tests (passed only after a retry) from the JSON report and
1117 # surface them in the job summary. retries:1 keeps the job green when a
1118 # test flakes, so without this a flaking test would degrade silently.
1119 # Runs on always() so failures still get a flaky breakdown.
1120 - name: Report flaky tests
1121 if: always()
1122 run: |
1123 set -euo pipefail
1124 report="playwright-report/results.json"
1125 if [ ! -f "$report" ]; then
1126 {
1127 echo "## Playwright UI smoke — flaky tests"
1128 echo ""
1129 echo "_No JSON report at ${report}; skipping flaky analysis._"
1130 } >> "$GITHUB_STEP_SUMMARY"
1131 exit 0
1132 fi
1133 
1134 flaky=$(jq '.stats.flaky // 0' "$report")
1135 unexpected=$(jq '.stats.unexpected // 0' "$report")
1136 flaky_list=$(jq -r '
1137 [ .. | .specs? // empty | .[] ]
1138 | map(select(.tests | any(.status == "flaky")))
1139 | .[] | "- `\(.title)`"
1140 ' "$report")
1141 
1142 {
1143 echo "## Playwright UI smoke — flaky tests"
1144 echo ""
1145 echo "**Flaky (passed on retry):** ${flaky}"
1146 echo "**Failed:** ${unexpected}"
1147 echo ""
1148 if [ "$flaky" -gt 0 ]; then
1149 echo "These passed only after a retry — retries:1 masked them, so investigate:"
1150 echo ""
1151 echo "$flaky_list"
1152 else
1153 echo "No flaky tests detected."
1154 fi
1155 } >> "$GITHUB_STEP_SUMMARY"
1156 
1157 if [ "$flaky" -gt 0 ]; then
1158 echo "::warning::${flaky} flaky Playwright test(s) detected — passed only on retry (retries:1)."
1159 fi
1160 
1161 - name: Upload Playwright report
1162 if: always()
1163 uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
1164 with:
1165 name: playwright-report
1166 path: playwright-report/
1167 if-no-files-found: error
1168 retention-days: 7
1169 
1170 # ── CI Gate ──────────────────────────────────────────────────────────
1171 # Single required check for branch protection. Always runs after all
1172 # other jobs. Reads the expected-job set from triage and verifies that:
1173 # 1. Every expected job succeeded.
1174 # 2. Every non-expected job was skipped.
1175 # 3. No unexpected expensive job ran (gate regression detection).
1176 # This catches both triage misclassification (unexpected skip) and
1177 # gate regressions (unexpected execution).
1178 ci-gate:
1179 name: CI Gate
1180 if: always()
1181 needs:
1182 - triage
1183 - conventional-commits
1184 - standards-reference
1185 - js-quality
1186 - app-frontend
1187 - website
1188 - cargo-deny
1189 - workflow-lint
1190 - release-validation
1191 - dependency-checks
1192 - prepare-model
1193 - rust-test
1194 - rust-test-windows-compile
1195 - tauri-build-smoke
1196 - tauri-build
1197 - playwright-ui-smoke
1198 runs-on: ubuntu-24.04
1199 timeout-minutes: 5
1200 steps:
1201 - name: Verify expected jobs succeeded and non-expected jobs skipped
1202 env:
1203 EXPECTED_JOBS: ${{ needs.triage.outputs.expected-jobs }}
1204 TRIAGE: ${{ needs.triage.result }}
1205 CONVENTIONAL_COMMITS: ${{ needs.conventional-commits.result }}
1206 STANDARDS_REFERENCE: ${{ needs.standards-reference.result }}
1207 JS_QUALITY: ${{ needs.js-quality.result }}
1208 APP_FRONTEND: ${{ needs.app-frontend.result }}
1209 WEBSITE: ${{ needs.website.result }}
1210 CARGO_DENY: ${{ needs.cargo-deny.result }}
1211 WORKFLOW_LINT: ${{ needs.workflow-lint.result }}
1212 RELEASE_VALIDATION: ${{ needs.release-validation.result }}
1213 DEPENDENCY_CHECKS: ${{ needs.dependency-checks.result }}
1214 PREPARE_MODEL: ${{ needs.prepare-model.result }}
1215 RUST_TEST: ${{ needs.rust-test.result }}
1216 RUST_TEST_WINDOWS_COMPILE: ${{ needs.rust-test-windows-compile.result }}
1217 TAURI_BUILD_SMOKE: ${{ needs.tauri-build-smoke.result }}
1218 TAURI_BUILD: ${{ needs.tauri-build.result }}
1219 PLAYWRIGHT_UI_SMOKE: ${{ needs.playwright-ui-smoke.result }}
1220 UNKNOWN_FLAG: ${{ needs.triage.outputs.unknown }}
1221 run: |
1222 set -euo pipefail
1223 
1224 # All checkable jobs and their result env vars.
1225 declare -A JOBS=(
1226 [triage]="$TRIAGE"
1227 [conventional-commits]="$CONVENTIONAL_COMMITS"
1228 [standards-reference]="$STANDARDS_REFERENCE"
1229 [js-quality]="$JS_QUALITY"
1230 [app-frontend]="$APP_FRONTEND"
1231 [website]="$WEBSITE"
1232 [cargo-deny]="$CARGO_DENY"
1233 [workflow-lint]="$WORKFLOW_LINT"
1234 [release-validation]="$RELEASE_VALIDATION"
1235 [dependency-checks]="$DEPENDENCY_CHECKS"
1236 [prepare-model]="$PREPARE_MODEL"
1237 [rust-test]="$RUST_TEST"
1238 [rust-test-windows-compile]="$RUST_TEST_WINDOWS_COMPILE"
1239 [tauri-build-smoke]="$TAURI_BUILD_SMOKE"
1240 [tauri-build]="$TAURI_BUILD"
1241 [playwright-ui-smoke]="$PLAYWRIGHT_UI_SMOKE"
1242 )
1243 
1244 # Verify triage produced valid expected-jobs JSON.
1245 if ! echo "$EXPECTED_JOBS" | jq -e 'type == "array"' >/dev/null 2>&1; then
1246 echo "::error::Triage did not produce valid expected-jobs JSON: ${EXPECTED_JOBS:-<empty>}"
1247 exit 1
1248 fi
1249 
1250 fail=0
1251 
1252 for job in "${!JOBS[@]}"; do
1253 result="${JOBS[$job]}"
1254 is_expected=$(echo "$EXPECTED_JOBS" | jq --arg job "$job" 'any(. == $job)')
1255 
1256 echo "$job: result=$result expected=$is_expected"
1257 
1258 if [ "$is_expected" = "true" ]; then
1259 if [ "$result" != "success" ]; then
1260 echo "::error::Expected job '$job' did not succeed (result: $result)"
1261 fail=1
1262 fi
1263 else
1264 if [ "$result" != "skipped" ]; then
1265 echo "::error::Unexpected job '$job' ran (result: $result) — gate regression"
1266 fail=1
1267 fi
1268 fi
1269 done
1270 
1271 # Verify unknown files are printed and full CI is expected when
1272 # unknown files exist.
1273 UNKNOWN=$(echo "$EXPECTED_JOBS" | jq 'any(. == "tauri-build")')
1274 if [ "$UNKNOWN_FLAG" = "true" ] && [ "$UNKNOWN" != "true" ]; then
1275 echo "::error::Unknown files exist but full CI (tauri-build) was not expected"
1276 fail=1
1277 fi
1278 
1279 if [ "$fail" -ne 0 ]; then
1280 echo ""
1281 echo "Expected jobs: $(echo "$EXPECTED_JOBS" | jq -r '. | join(", ")')"
1282 fi
1283 
1284 exit $fail
1285