Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 6 additions & 14 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
# WorldScript Studio – CI/CD Pipeline
# security → quality (lint, i18n, tsgo, vitest+coverage) → build (+chunk budget, rollup analyze)
# ├→ e2e | storybook (parallel) ; lighthouse after build
# deploy (main): needs build + e2e → GitHub Pages
# deploy (main): needs ci-success + build artifact → GitHub Pages
# ============================================================

name: CI / CD
Expand Down Expand Up @@ -85,7 +85,7 @@ jobs:
uses: actions/dependency-review-action@a1d282b36b6f3519aa1f3fc636f609c47dddb294 # v5

# ----------------------------------------------------------
# 0b. CHANGE DETECTION: path-scopes rust-tauri so a docs/frontend-only PR
# 0b. CHANGE DETECTION: path-scopes Rust gates so a docs/frontend-only PR
# doesn't pay for a Rust toolchain + apt-get(libgtk/libwebkit) build it can't
# affect. Fails OPEN (tauri=true) on any ambiguity — base SHA missing/unreachable
# — so a detection error runs the real gate instead of silently skipping it.
Expand All @@ -106,6 +106,7 @@ jobs:
- name: Detect src-tauri / crates changes
id: filter
run: |
node scripts/check-ci-invariants.mjs --self-test --check-workflow
if [ "${{ github.event_name }}" = "pull_request" ]; then
BASE="${{ github.event.pull_request.base.sha }}"
else
Expand All @@ -118,16 +119,7 @@ jobs:
exit 0
fi
CHANGED=$(git diff --name-only "$BASE" "${{ github.sha }}")
if grep -qE '^(src-tauri/|\.github/workflows/ci\.yml$)' <<< "$CHANGED"; then
echo "tauri=true" >> "$GITHUB_OUTPUT"
else
echo "tauri=false" >> "$GITHUB_OUTPUT"
fi
if grep -qE '^(crates/|tests/fixtures/project-golden-masters/|\.github/workflows/ci\.yml$)' <<< "$CHANGED"; then
echo "crates=true" >> "$GITHUB_OUTPUT"
else
echo "crates=false" >> "$GITHUB_OUTPUT"
fi
printf '%s\n' "$CHANGED" | node scripts/check-ci-invariants.mjs >> "$GITHUB_OUTPUT"

# ----------------------------------------------------------
# 1. QUALITY GATE: Lint + Typecheck + Tests (parallel matrix)
Expand Down Expand Up @@ -454,8 +446,8 @@ jobs:
name: 🚀 Deploy to GitHub Pages
runs-on: ubuntu-latest
timeout-minutes: 10
needs: [build, e2e]
if: github.ref == 'refs/heads/main' && github.event_name != 'pull_request'
needs: [ci-success, build]
if: github.ref == 'refs/heads/main' && github.event_name != 'pull_request' && needs.ci-success.result == 'success'
permissions:
contents: read
pages: write
Expand Down
6 changes: 3 additions & 3 deletions docs/CI.md
Original file line number Diff line number Diff line change
Expand Up @@ -83,7 +83,7 @@ e2e ──────┤
vrt ──────┘

build (main, non-PR) ──► upload-pages-artifact
deploy (main, non-PR) needs: build + e2e ──► GitHub Pages
ci-success + build artifact (main, non-PR) ──► deploy ──► GitHub Pages
```

Mutation testing (Stryker) is **not** in this graph — it runs only via manual `workflow_dispatch` on [`mutation.yml`](../.github/workflows/mutation.yml). See [Mutation testing status](#mutation-testing-status).
Expand All @@ -98,8 +98,8 @@ Mutation testing (Stryker) is **not** in this graph — it runs only via manual
| `lighthouse` | `build` | LHCI (mobile): **accessibility error gate** `minScore: 0.95`; **CLS error** ≤ 0.1; performance/SEO warn. Desktop run: `continue-on-error: true` until baselines stabilise. Timeout 25 min. |
| `storybook` | `quality` | Cloud-first — Storybook build + test-runner only run in CI (not locally); Playwright browser cache `v5`; `--maxWorkers=2 --junit` (non-blocking, `continue-on-error: true` — see [exit criteria](#non-blocking-gates--exit-criteria-f-13)); artifacts uploaded always. Debug: manual `storybook-debug.yml` workflow. |
| `vrt` | `build` | Visual regression against production `dist`; `toHaveScreenshot()` with committed PNG baselines (4 views × Chromium); artifacts uploaded always |
| `ci-success` | `security`, `quality`, `rust-tauri`, `build`, `e2e`, `vrt` | Required-status **aggregator** — `if: always()`, fails if any required release-safety job does not resolve to `success`; Storybook, Lighthouse and deep-E2E remain informational until their stability criteria are met. |
| `deploy` | `build`, `e2e` | **Only** `main` push (not PR): `deploy-pages` |
| `ci-success` | `security`, `quality`, `changes`, `rust-tauri`, `core-rust`, `build`, `e2e`, `vrt` | Required-status **aggregator** — `if: always()`, fails if any required release-safety job does not resolve to `success`; Storybook, Lighthouse and deep-E2E remain informational until their stability criteria are met. Rust jobs are legitimately skipped when their paths are untouched. |
| `deploy` | `ci-success`, `build` | **Only** `main` push (not PR), and only after the aggregate gate succeeds; retains the direct `build` dependency for the Pages artifact. |
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated

> **Desktop:** On-demand / tag-driven Tauri bundles live in [`tauri-build.yml`](../.github/workflows/tauri-build.yml); **`v*` tags** additionally publish installers on a **GitHub Release**. See [`docs/TAURI-CI.md`](TAURI-CI.md). Desktop CI does not block the web deploy graph above.
>
Expand Down
144 changes: 144 additions & 0 deletions scripts/check-ci-invariants.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,144 @@
#!/usr/bin/env node
import { strict as assert } from 'node:assert';
import { readFileSync } from 'node:fs';
import { dirname, relative, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';

const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..');
const TAURI_MANIFEST = resolve(ROOT, 'src-tauri/Cargo.toml');
const WORKFLOW = resolve(ROOT, '.github/workflows/ci.yml');
const WORKFLOW_PATH = '.github/workflows/ci.yml';
const CORE_ROOT = 'crates/';
const PROJECT_FIXTURES_ROOT = 'tests/fixtures/project-golden-masters/';

function normalizePath(filePath) {
return filePath.replaceAll('\\', '/').replace(/^\.\//, '');
}

function isWithin(filePath, root) {
return filePath === root.replace(/\/$/, '') || filePath.startsWith(root);
}

function localTauriDependencyRoots() {
const manifest = readFileSync(TAURI_MANIFEST, 'utf8');
const pathValues = [...manifest.matchAll(/\bpath\s*=\s*"([^"]+)"/g)].map(([, value]) => value);
const roots = new Set(['src-tauri/']);

for (const value of pathValues) {
const dependencyPath = normalizePath(relative(ROOT, resolve(dirname(TAURI_MANIFEST), value)));
if (!dependencyPath || dependencyPath.startsWith('../')) {
throw new Error(`Tauri path dependency escapes the repository: ${value}`);
}
roots.add(`${dependencyPath.split('/')[0]}/`);
}

return [...roots];
}

function classifyChangedFiles(files) {
const changedFiles = files.map(normalizePath).filter(Boolean);
const tauriRoots = localTauriDependencyRoots();
const tauri = changedFiles.some(
(filePath) => filePath === WORKFLOW_PATH || tauriRoots.some((root) => isWithin(filePath, root)),
);
const crates = changedFiles.some(
(filePath) =>
filePath === WORKFLOW_PATH ||
isWithin(filePath, CORE_ROOT) ||
isWithin(filePath, PROJECT_FIXTURES_ROOT),
);

return { tauri, crates };
}

function jobBlock(workflow, jobName) {
const lines = workflow.split('\n');
const start = lines.indexOf(` ${jobName}:`);
if (start === -1) throw new Error(`Could not find CI job: ${jobName}`);
const end = lines.findIndex((line, index) => index > start && /^ {2}[\w-]+:$/.test(line));
return lines.slice(start, end === -1 ? lines.length : end).join('\n');
}

function needsFor(workflow, jobName) {
const block = jobBlock(workflow, jobName);
const match = block.match(/^ {4}needs:\s*\[([^\]]+)\]/m);
if (!match) throw new Error(`CI job ${jobName} must use an inline needs list`);
return new Set(
match[1]
.split(',')
.map((item) => item.trim())
.filter(Boolean),
);
}

function checkWorkflowContract() {
const workflow = readFileSync(WORKFLOW, 'utf8');
const changes = jobBlock(workflow, 'changes');
const aggregateNeeds = needsFor(workflow, 'ci-success');
const deployNeeds = needsFor(workflow, 'deploy');

for (const requiredJob of [
'security',
'quality',
'changes',
'rust-tauri',
'core-rust',
'build',
'e2e',
'vrt',
]) {
assert(aggregateNeeds.has(requiredJob), `ci-success must include ${requiredJob}`);
}
assert(deployNeeds.has('ci-success'), 'deploy must depend on ci-success');
assert(deployNeeds.has('build'), 'deploy must retain the build artifact dependency');
assert.match(
jobBlock(workflow, 'deploy'),
/needs\.ci-success\.result\s*==\s*'success'/,
'deploy must require a successful ci-success result',
);
assert.match(
changes,
/node scripts\/check-ci-invariants\.mjs --self-test --check-workflow/,
'changes must execute the CI invariant self-check',
);
assert.match(
changes,
/node scripts\/check-ci-invariants\.mjs\s*>>\s*"\$GITHUB_OUTPUT"/,
'changes must use the dependency-aware classifier for job outputs',
);
}

function runSelfTests() {
const cases = [
[['src-tauri/src/lib.rs'], { tauri: true, crates: false }],
[['crates/worldscript-project/src/lib.rs'], { tauri: true, crates: true }],
[['crates/worldscript-project/Cargo.toml'], { tauri: true, crates: true }],
[['crates/Cargo.lock'], { tauri: true, crates: true }],
[[WORKFLOW_PATH], { tauri: true, crates: true }],
[['components/App.tsx'], { tauri: false, crates: false }],
];

for (const [files, expected] of cases) {
assert.deepEqual(classifyChangedFiles(files), expected, `classification failed for ${files}`);
}
}

function main() {
const args = new Set(process.argv.slice(2));
if (args.has('--self-test')) runSelfTests();
if (args.has('--check-workflow')) checkWorkflowContract();
if (args.has('--self-test') || args.has('--check-workflow')) return;

const files = readFileSync(0, 'utf8').split(/\r?\n/);
const result = classifyChangedFiles(files);
process.stdout.write(`tauri=${result.tauri}\ncrates=${result.crates}\n`);
}

try {
main();
} catch (error) {
process.stderr.write(
`[ci-invariants] ${error instanceof Error ? error.message : String(error)}\n`,
);
process.exitCode = 1;
}
Loading