Merge pull request #1068 from kaitranntt/kai/chore/ci-contributor-friendly

chore(ci): contributor-friendly CI remediation
This commit is contained in:
Kai (Tam Nhu) Tran
2026-04-22 17:02:43 -04:00
committed by GitHub
17 changed files with 527 additions and 77 deletions
+4 -2
View File
@@ -6,8 +6,9 @@
Use what applies. If you skipped something, add a short note instead of forcing it.
- [ ] `bun run validate`
- [ ] `bun run validate:ci-parity`
- [ ] `bun run format && bun run lint:fix && bun run validate`
- [ ] `bun run validate:ci-parity` before requesting review
- [ ] `bun run test:e2e` if this PR touches command routing, proxy flows, or workflow/release logic
- [ ] `cd ui && bun run validate` if UI changed
- [ ] Not run
@@ -20,6 +21,7 @@ Check what applies. Not every item is relevant for every PR.
- [ ] Relevant `--help` output updated if CLI behavior changed
- [ ] Tests added or updated if behavior changed
- [ ] README or local docs updated if user-facing behavior changed
- [ ] If a check failed, the PR body explains what failed and what changed to fix it
- [ ] No secrets, tokens, or private config data are included
## Docs Impact
-6
View File
@@ -50,8 +50,6 @@ jobs:
path: |
${{ env.BUN_INSTALL_CACHE_DIR }}
key: ${{ runner.os }}-bun-cache-v2-${{ hashFiles('bun.lock', 'ui/bun.lock') }}
restore-keys: |
${{ runner.os }}-bun-cache-v2-
- name: Ensure dependencies
run: bash scripts/ensure-deps.sh
@@ -84,8 +82,6 @@ jobs:
path: |
${{ env.BUN_INSTALL_CACHE_DIR }}
key: ${{ runner.os }}-bun-cache-v2-${{ hashFiles('bun.lock', 'ui/bun.lock') }}
restore-keys: |
${{ runner.os }}-bun-cache-v2-
- name: Ensure dependencies
run: bash scripts/ensure-deps.sh
@@ -127,8 +123,6 @@ jobs:
path: |
${{ env.BUN_INSTALL_CACHE_DIR }}
key: ${{ runner.os }}-bun-cache-v2-${{ hashFiles('bun.lock', 'ui/bun.lock') }}
restore-keys: |
${{ runner.os }}-bun-cache-v2-
- name: Ensure dependencies
run: bash scripts/ensure-deps.sh
+4 -1
View File
@@ -47,9 +47,12 @@ jobs:
- name: Build
run: bun run build:all
- name: Validate (typecheck + lint + format + tests)
- name: Validate fast gate
run: bun run validate
- name: Test slow bucket
run: bun run test:slow
- name: Test CLI e2e
env:
CCS_E2E_SKIP_BUILD: '1'
+134
View File
@@ -0,0 +1,134 @@
name: Push CI
on:
push:
branches: [dev]
concurrency:
group: push-ci-${{ github.ref }}
cancel-in-progress: true
jobs:
validate:
runs-on: [self-hosted, linux, x64]
env:
BUN_INSTALL_CACHE_DIR: ${{ github.workspace }}/.bun/install/cache
strategy:
fail-fast: false
matrix:
check:
- { name: typecheck, cmd: 'bun run typecheck' }
- { name: lint, cmd: 'bun run lint' }
- { name: format, cmd: 'bun run format:check' }
name: ${{ matrix.check.name }}
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Setup Bun
uses: oven-sh/setup-bun@v2
with:
bun-version: '1.3.9'
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '22'
- name: Restore bun package cache
uses: actions/cache@v4
with:
path: |
${{ env.BUN_INSTALL_CACHE_DIR }}
key: ${{ runner.os }}-bun-cache-v2-${{ hashFiles('bun.lock', 'ui/bun.lock') }}
- name: Ensure dependencies
run: bash scripts/ensure-deps.sh
- name: Run ${{ matrix.check.name }}
run: ${{ matrix.check.cmd }}
build:
runs-on: [self-hosted, linux, x64]
name: build
env:
BUN_INSTALL_CACHE_DIR: ${{ github.workspace }}/.bun/install/cache
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Setup Bun
uses: oven-sh/setup-bun@v2
with:
bun-version: '1.3.9'
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '22'
- name: Restore bun package cache
uses: actions/cache@v4
with:
path: |
${{ env.BUN_INSTALL_CACHE_DIR }}
key: ${{ runner.os }}-bun-cache-v2-${{ hashFiles('bun.lock', 'ui/bun.lock') }}
- name: Ensure dependencies
run: bash scripts/ensure-deps.sh
- name: Build
run: bun run build:all
- name: Upload dist artifact
uses: actions/upload-artifact@v4
with:
name: dist
path: dist/
retention-days: 1
if-no-files-found: error
test:
runs-on: [self-hosted, linux, x64]
name: test
needs: [build]
env:
BUN_INSTALL_CACHE_DIR: ${{ github.workspace }}/.bun/install/cache
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Setup Bun
uses: oven-sh/setup-bun@v2
with:
bun-version: '1.3.9'
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '22'
- name: Restore bun package cache
uses: actions/cache@v4
with:
path: |
${{ env.BUN_INSTALL_CACHE_DIR }}
key: ${{ runner.os }}-bun-cache-v2-${{ hashFiles('bun.lock', 'ui/bun.lock') }}
- name: Ensure dependencies
run: bash scripts/ensure-deps.sh
- name: Download dist artifact
uses: actions/download-artifact@v4
with:
name: dist
path: dist/
- name: Test
run: bun run test:all
- name: Test CLI e2e
env:
CCS_E2E_SKIP_BUILD: '1'
run: bun run test:e2e
+4 -1
View File
@@ -42,9 +42,12 @@ jobs:
- name: Build package
run: bun run build:all
- name: Validate (typecheck + lint + format + tests)
- name: Validate fast gate
run: bun run validate
- name: Test slow bucket
run: bun run test:slow
- name: Test CLI e2e
env:
CCS_E2E_SKIP_BUILD: '1'
+2 -2
View File
@@ -45,9 +45,9 @@ echo " branch: $CURRENT_BRANCH"
echo " base: $BASE_BRANCH"
bun run typecheck
bun run lint:fix
bun run lint
bun run format:check
bun run build:all
bun run test:fast
git fetch origin "$BASE_BRANCH" --quiet || true
DIFF_RANGE="HEAD"
+29 -35
View File
@@ -41,6 +41,15 @@ gh pr checks <n>
### Absolute rule
AI MUST NOT declare a task done, close a session, or move to the next task while CI is red or still running. Leaving a PR red and moving on is the primary failure mode this protocol prevents.
### Dev Release vs Push CI
- `CI` is the pull-request quality gate for contributor branches.
- `Push CI` is the post-merge quality signal for `dev`.
- `Dev Release` publishes the `@dev` package after `dev` changes land.
- A red `Dev Release` does **not** automatically mean contributor code failed. Check `Push CI` first.
- Verified on `2026-04-22` via `gh api repos/kaitranntt/ccs/branches/dev/protection`: `dev` currently requires `typecheck`, `lint`, `format`, `build`, and `test`, has no branch restrictions, and has no required PR-review gate.
- `dev-release.yml` currently pushes with `PAT_TOKEN` because `dev` is protected by those required status checks. Do not switch it back to `github.token` unless branch protection changes with it.
## Core Function
Multi-provider profile and runtime manager for Claude Code, Factory Droid,
@@ -87,7 +96,7 @@ broader topic.
| Mistake | Consequence | Correct Action |
|---------|-------------|----------------|
| Running `validate` without `format` first | format:check fails | Run `bun run format` BEFORE validate |
| Assuming maintainability check is always strict | PR/feature branches run warning mode by default | Use `bun run maintainability:check:strict` before merge when touching debt-sensitive code |
| Treating `Dev Release` as the contributor quality signal | Publish failures on `dev` look like broken code | Check PR `CI` on the branch and `Push CI` on `dev` first |
| Using `chore:` for dev→main PR | No npm release triggered | Use `feat:` or `fix:` prefix |
| Committing directly to `main` or `dev` | Bypasses CI/review | Always use PRs |
| Manual version bump or git tag | Conflicts with semantic-release | Let CI handle versioning |
@@ -208,8 +217,8 @@ Quality gates MUST pass before pushing. **Both projects have identical workflow.
# Main project (from repo root)
bun run format # Step 1: Fix formatting
bun run lint:fix # Step 2: Fix lint issues
bun run validate # Step 3: Full gate (typecheck + lint + format + maintainability + tests)
bun run validate:ci-parity # Step 4: full CI parity gate (build + validate + base branch check)
bun run validate # Step 3: Fast gate (typecheck + lint + format + test:fast)
bun run validate:ci-parity # Step 4: PR-CI parity gate (branch check + build + full tests + e2e)
# UI project (if UI changed)
cd ui
@@ -221,17 +230,17 @@ bun run validate # Step 3: Final check (must pass)
**WHY THIS ORDER:**
- `validate` runs `format:check` which only VERIFIES—won't fix
- If format:check fails, you skipped step 1
- CI runs `validate` only (no auto-fix)—local must be clean
- `validate` now uses read-only `lint`, so autofix still belongs in step 2
- PR CI and `validate:ci-parity` both run non-mutating checks only
### What Validate Runs
### What Each Gate Runs
| Project | Command | Runs |
|---------|---------|------|
| Main | `bun run validate` | typecheck + lint:fix + format:check + test:all |
| Main | `bun run validate` | typecheck + lint + format:check + test:fast |
| Main | `bun run validate:ci-parity` | base branch check + typecheck + lint + format:check + build:all + test:all + test:e2e |
| UI | `bun run validate` | typecheck + lint:fix + format:check |
**Note:** `maintainability:check` is a SEPARATE gate — not part of `validate`. Run it explicitly via `bun run maintainability:check[:strict|:warn]` when touching debt-sensitive code or before merging to protected branches.
### ESLint Rules (ALL errors)
| Rule | Level | Notes |
@@ -255,33 +264,19 @@ bun run validate # Step 3: Final check (must pass)
### Automatic Enforcement
- `prepublishOnly` / `prepack` runs `build:all` + `validate` + `sync-version.js`
- CI/CD runs `bun run validate` on every PR (maintainability is warning mode on PR events)
- `prepack` runs `build:all`
- PR `CI` runs `typecheck`, `lint`, `format`, `build`, `test:all`, and `test:e2e`
- `Push CI` runs the same quality suite on `dev` after merge, separate from release publishing
- `Dev Release` still runs build + fast validation + slow tests + e2e before publishing and still requires `PAT_TOKEN` to push back to protected `dev`
- husky `pre-commit` runs quick lint/type/format checks
- husky `pre-push` runs the full `bun run validate:ci-parity` gate on `main`/`dev`/hotfix branches
- husky `pre-push` runs a faster feature-branch gate (`typecheck` + `lint:fix` + `format:check` + targeted checks based on changed files) before GitHub CI handles the full matrix
- husky `pre-push` runs a faster feature-branch gate (`typecheck` + `lint` + `format:check` + `test:fast`) plus targeted checks based on changed files
### Maintainability Baseline Gate
### Maintainability Gate Status
- Baseline file: `docs/metrics/maintainability-baseline.json`
- Metric collector/check script: `scripts/maintainability-baseline.js`
- Branch-aware gate wrapper: `scripts/maintainability-check.js`
- Enforcement path: `bun run maintainability:check` (run separately — NOT part of `bun run validate`; invoked by `validate:ci-parity` on protected branches)
- Gate modes:
- `strict`: protected branches (`main`, `dev`, `hotfix/*`, `kai/hotfix-*`) and equivalent CI refs
- `warn`: pull request CI and non-protected local branches (non-blocking for parallel PR workflow)
- override commands:
- `bun run maintainability:check:strict`
- `bun run maintainability:check:warn`
- Gated metrics (must not increase vs baseline):
- `processExitReferenceCount`
- `synchronousFsApiReferenceCount`
- Informational metrics (collected but not gated):
- `largeFileCountOver350Loc`
- Baseline update policy:
1. Prefer reducing the metric and keeping the baseline unchanged.
2. On protected-branch integration (strict mode), if increase is intentional and accepted, run `bun run maintainability:baseline`.
3. Commit both the code change and `docs/metrics/maintainability-baseline.json`, and state reason in PR description.
- The historical maintainability baseline gate is retired from the active CCS workflow.
- `validate`, `validate:ci-parity`, PR `CI`, `Push CI`, and release workflows do **not** invoke `maintainability:check`.
- Older roadmap references to `maintainability:baseline` / `maintainability:check` are historical context, not current repo commands.
## Critical Constraints (NEVER VIOLATE)
@@ -532,17 +527,16 @@ rm -rf ~/.ccs # Clean environment
Optimized for iterative push-then-review workflow. Do NOT run the full gate on every push — CI is the safety net. Run the full gate once before asking for review / merge.
### Tier 1 — Iterative push (feature branch)
Husky `pre-push` auto-runs: `typecheck + lint:fix + format:check + build:all` plus targeted tests based on changed files. AI does **nothing extra** at push time.
Husky `pre-push` auto-runs: `typecheck + lint + format:check + test:fast` plus targeted checks based on changed files. AI does **nothing extra** at push time.
**After push (MANDATORY):** follow the [CI-First Protocol](#ci-first-protocol-mandatory) — watch CI until green. Do not move on while CI is red.
### Tier 2 — Before requesting review / merge
Run ONCE, not per push:
- [ ] `bun run validate:ci-parity`full build + validate matches CI
- [ ] `bun run validate:ci-parity`branch freshness + build + full non-e2e tests + e2e
- [ ] `gh pr checks <n>` — all checks green
- [ ] If touching debt-sensitive code: `bun run maintainability:check:strict`
- [ ] If strict mode fails and increase is intentional: `bun run maintainability:baseline` and commit `docs/metrics/maintainability-baseline.json`
- [ ] If UI changed: `cd ui && bun run format && bun run validate`
- [ ] If touching command routing, proxy flows, workflows, or release logic: `bun run test:e2e`
### Code / Docs / Standards (verify before merge)
- [ ] Conventional commit format (`feat:`, `fix:`, etc.)
+49 -5
View File
@@ -76,14 +76,29 @@ Rules:
- Treat `hotfix/*` as maintainer-only emergency flow from `main`.
- Delete your branch after merge.
## CI and Release Flow
CCS now uses three separate automation lanes:
- `CI` runs on pull requests to `dev` and `main`. This is the review gate for contributor branches.
- `Push CI` runs after a merge lands on `dev`. This is the code-quality signal for the shared `dev` branch.
- `Dev Release` publishes the `@dev` package after `dev` changes land. It is release automation, not the primary contributor quality signal.
If `Dev Release` is red but your PR checks were green, check `Push CI` before assuming the merged code is broken.
If `CI` or `Push CI` stays queued for a long time, it is a maintainer infrastructure issue, not a contributor mistake. Leave a comment on your PR and a maintainer will address it.
## AI Agent Rules
`CONTRIBUTING.md` is the human entry point. For AI agents working in this repo, the authoritative automation and workflow rules live in [CLAUDE.md](./CLAUDE.md).
## AI Review Lane
CCS PR review no longer depends on `anthropics/claude-code-action`. The repository review lane is self-hosted PR-Agent:
- The retained `.github/workflows/ai-review.yml` runs PR-Agent in GitHub Actions.
- PR-Agent reviews run on the existing self-hosted `cliproxy` runner.
- Use `/review` on the PR when you need a fresh pass after follow-up commits.
- Only the trusted `/review` comment path is enabled on the privileged self-hosted runner.
- Only the trusted `/review` comment path is enabled.
- Keep repository-level reviewer instructions in the root `.pr_agent.toml`.
- Keep runtime wiring and defaults in `ai-review.yml`, which still maps the existing `AI_REVIEW_BASE_URL`, `AI_REVIEW_MODEL`, and `AI_REVIEW_API_KEY` integrations onto PR-Agent's `OPENAI.*` and `config.*` settings.
- If you change review defaults, update the workflow or `.pr_agent.toml` alongside the contributor or architecture docs in the same PR.
@@ -129,16 +144,35 @@ Use `bun run dev` from the repo root when working on the local dashboard experie
## Validation
If you can, run these before you open or update a PR:
Run this fast local gate before you open or update a PR:
```bash
bun run format
bun run lint:fix
bun run validate
```
`bun run validate` is the day-to-day contributor gate. It runs:
- `typecheck`
- `lint`
- `format:check`
- `test:fast`
Before you ask for review, or whenever you want the closest local equivalent to PR CI, run:
```bash
bun run validate:ci-parity
```
If you changed the dashboard:
`bun run validate:ci-parity` adds:
- branch freshness check against `origin/dev` or `origin/main`
- `build:all`
- full non-e2e test suite via `test:all`
- `test:e2e` with `CCS_E2E_SKIP_BUILD=1`
If you changed the dashboard, run the UI gate too:
```bash
cd ui
@@ -155,7 +189,17 @@ bun run test:native
bun run test:e2e
```
`bun run validate` is the main gate. It covers typechecking, linting, format checks, maintainability checks, and automated tests for the main project.
Use `bun run test:e2e` locally before review if you touch command routing, proxy flows, release automation, or workflow wiring and want to reproduce the same CLI e2e lane that PR CI runs.
### Why Did CI Fail?
| Symptom | Likely cause | Fix |
| --- | --- | --- |
| `format` fails in PR CI | `bun run format` was skipped locally | Run `bun run format`, recommit, push again |
| `lint` fails in PR CI | `validate` now uses read-only `lint` | Run `bun run lint:fix`, then rerun `bun run validate` |
| `test` fails in PR CI but `validate` passed | The failure is in `test:slow` or `test:e2e` | Run `bun run validate:ci-parity` locally |
| Checks stay queued for >10 min | Self-hosted runner is offline | Wait for maintainer intervention; rerunning usually does not help |
| `Dev Release` is red on `dev` after merge | Release-only failure or publish problem | Check `Push CI` first to confirm code quality |
If you cannot run the full suite, that is still fine for early or docs-only PRs. Just say what you did run, or what blocked you, in the PR.
+3
View File
@@ -177,6 +177,9 @@ ccs ollama "summarize these logs"
## Contribute And Report Safely
- Contributing guide: [CONTRIBUTING.md](./CONTRIBUTING.md)
- Daily local gate: `bun run format && bun run lint:fix && bun run validate` (`validate` is the fast path only)
- Before review or merge confidence: `bun run validate:ci-parity`
- If PR checks stay queued for more than 10 minutes, assume the self-hosted runner is offline and notify a maintainer instead of retrying blindly
- Starter work:
[good first issue](https://github.com/kaitranntt/ccs/labels/good%20first%20issue),
[help wanted](https://github.com/kaitranntt/ccs/labels/help%20wanted)
+2 -1
View File
@@ -599,6 +599,7 @@ return (
bun run format
bun run lint:fix
bun run validate
bun run validate:ci-parity
# UI project (if changed)
cd ui
@@ -611,7 +612,7 @@ bun run validate
| Project | Command | Checks |
|---------|---------|--------|
| Main | `bun run validate` | typecheck + lint + format:check + test |
| Main | `bun run validate` | typecheck + lint + format:check + test:fast |
| UI | `bun run validate` | typecheck + lint + format:check |
---
+5 -20
View File
@@ -242,28 +242,13 @@ All criteria achieved:
- [x] Clear domain boundaries
- [x] Consistent naming conventions
## Maintainability Gate (Issue #539 Foundation)
## Historical Maintainability Gate (Retired)
- Baseline metrics artifact: `docs/metrics/maintainability-baseline.json`
- Branch-aware gate wrapper: `scripts/maintainability-check.js`
- Generate or refresh baseline:
- `bun run maintainability:baseline`
- `npm run maintainability:baseline`
- Run regression check gate:
- `bun run maintainability:check`
- `npm run maintainability:check`
- `bun run maintainability:check:strict` (force strict locally)
This section is preserved as historical context from the original Issue `#539` work.
The baseline/check scripts enumerate git-tracked files under `src` for deterministic results and fail fast if git file listing is unavailable.
Default gate behavior:
- strict mode on protected branches (`main`, `dev`, `hotfix/*`, `kai/hotfix-*`)
- warning mode on PR CI and non-protected branches (parallel PR friendly)
The check mode supports a maintainability regression gate that blocks increases in:
- `process.exit` references
- synchronous fs API references
- TypeScript files over 350 LOC
- The maintainability baseline gate is no longer part of the active CCS workflow.
- Current contributor and CI gates are documented in `CLAUDE.md`, `CONTRIBUTING.md`, and the GitHub workflow files.
- Do not assume `maintainability:baseline` or `maintainability:check` exist unless they are reintroduced in a future follow-up.
---
+4 -2
View File
@@ -70,12 +70,14 @@
"lint:fix": "eslint src/ --fix",
"format": "prettier --write src/",
"format:check": "prettier --check src/",
"validate": "bun run typecheck && bun run lint:fix && bun run format:check && bun run test:all",
"validate": "bun run typecheck && bun run lint && bun run format:check && bun run test:fast",
"validate:ci-parity": "bash scripts/ci-parity-gate.sh",
"verify:bundle": "node scripts/verify-bundle.js",
"test": "bun run build && bun run test:all",
"test:ci": "bun run test:all",
"test:all": "bun test --max-concurrency=1 tests/unit tests/integration tests/npm",
"test:fast": "node scripts/run-test-bucket.js fast",
"test:slow": "node scripts/run-test-bucket.js slow",
"test:all": "node scripts/run-test-bucket.js all",
"test:unit": "bun test tests/unit",
"test:npm": "bun test tests/npm/",
"test:native": "bash tests/native/unix/edge-cases.sh",
+8 -2
View File
@@ -55,8 +55,14 @@ if git show-ref --verify --quiet "refs/remotes/origin/$BASE_BRANCH"; then
fi
fi
echo "[i] Running CI-equivalent local checks..."
echo "[i] Running CI-parity local checks..."
# `set -euo pipefail` above makes every step fail fast. Keep these commands
# explicit so parity drift is visible when CI changes.
bun run typecheck
bun run lint
bun run format:check
bun run build:all
bun run validate
bun run test:all
CCS_E2E_SKIP_BUILD=1 bun run test:e2e
echo "[OK] CI parity gate passed."
+181
View File
@@ -0,0 +1,181 @@
#!/usr/bin/env node
const fs = require('node:fs');
const path = require('node:path');
const { spawnSync } = require('node:child_process');
const rootDir = path.resolve(__dirname, '..');
const candidateRoots = ['tests/unit', 'tests/integration', 'tests/npm'];
// Add a `.ts` test to `slowTests` when ANY of these apply:
// 1. It spawns a child process (CLI, bun test, node, gh, etc.).
// 2. It binds a port, starts a server, or talks to localhost.
// 3. It reads a real file from `dist/` or the repo root at runtime.
// 4. It waits on a timer > 500ms or a filesystem watcher.
// 5. A single run consistently takes > 1500ms on reference hardware.
// Tests that literally reference `dist/` in source are auto-forced slow by
// `readsBuiltDist`. This list is the manual catch-all for `.ts` tests that
// meet the criteria above without the literal `dist/` string.
// `tests/unit/scripts/run-test-bucket.test.js` verifies every path here exists
// (catches deletion drift) but CANNOT detect new undeclared slow tests.
// Automated perf-budget enforcement tracked in issue #1071.
const slowTests = [
'tests/integration/cursor-daemon-lifecycle.test.ts',
'tests/integration/proxy/daemon-lifecycle.test.ts',
'tests/unit/commands/persist-command-handler.test.ts',
'tests/unit/hooks/ccs-browser-mcp-server.test.ts',
'tests/unit/targets/codex-runtime-integration.test.ts',
'tests/unit/targets/codex-settings-bridge-launch.test.ts',
'tests/unit/targets/droid-command-routing-integration.test.ts',
'tests/unit/targets/droid-config-manager.test.ts',
'tests/unit/targets/settings-profile-browser-launch.test.ts',
'tests/unit/targets/settings-profile-image-analysis-launch.test.ts',
'tests/unit/targets/settings-profile-websearch-launch.test.ts',
'tests/unit/web-server/cursor-routes.test.ts',
'tests/unit/web-server/websearch-routes.test.ts',
];
// CommonJS-heavy JS suites stay slow by default because many of them mutate
// module cache or process state. Opt them into `test:fast` only after they are
// proven stable in the mixed fast bucket.
const fastJsTests = new Set([
'tests/unit/flag-parsing-simple.test.js',
]);
const filePattern = /(\.test\.(c|m)?[jt]s|\.spec\.(c|m)?[jt]s|-test\.(c|m)?[jt]s)$/;
function collectFiles(dir, files = []) {
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
const fullPath = path.join(dir, entry.name);
if (entry.isDirectory()) {
collectFiles(fullPath, files);
continue;
}
if (filePattern.test(entry.name)) {
files.push(path.relative(rootDir, fullPath).split(path.sep).join('/'));
}
}
return files;
}
function readsBuiltDist(relativePath) {
const source = fs.readFileSync(path.join(rootDir, relativePath), 'utf8');
return source.includes('dist/');
}
function getDiscoveredTests() {
return candidateRoots
.flatMap((relativeDir) => collectFiles(path.join(rootDir, relativeDir)))
.sort();
}
function shouldForceSlow(file) {
if (file.startsWith('tests/npm/')) {
return true;
}
if (/\.(c|m)?js$/.test(file) && !fastJsTests.has(file)) {
return true;
}
return readsBuiltDist(file);
}
function getSlowSet() {
const discovered = getDiscoveredTests();
const forceSlow = discovered.filter((file) => shouldForceSlow(file));
return new Set([...slowTests, ...forceSlow]);
}
function selectBucket(name) {
const discovered = getDiscoveredTests();
const slowSet = getSlowSet();
return name === 'slow'
? [...slowSet].sort()
: discovered.filter((file) => !slowSet.has(file));
}
function ensureBuildForSlowBucket() {
if (fs.existsSync(path.join(rootDir, 'dist', 'ccs.js'))) {
return 0;
}
const build = spawnSync('bun', ['run', 'build'], {
cwd: rootDir,
stdio: 'inherit',
shell: process.platform === 'win32',
});
return build.status ?? 1;
}
function runBucket(name) {
const selected = selectBucket(name);
if (selected.length === 0) {
console.error(`[X] No tests matched the '${name}' bucket.`);
return 1;
}
if (name === 'slow') {
const buildStatus = ensureBuildForSlowBucket();
if (buildStatus !== 0) {
return buildStatus;
}
}
// Slow bucket forces sequential execution because it spawns subprocesses,
// binds ports, and touches shared state — parallelism causes flakes.
// Fast bucket keeps bun's default parallelism for speed.
const bunArgs = name === 'slow'
? ['test', '--max-concurrency=1', ...selected]
: ['test', ...selected];
const result = spawnSync('bun', bunArgs, {
cwd: rootDir,
stdio: 'inherit',
shell: process.platform === 'win32',
});
return result.status ?? 1;
}
function main(args = process.argv.slice(2)) {
const bucket = args[0];
if (!['fast', 'slow', 'all'].includes(bucket)) {
console.error('[X] Usage: node scripts/run-test-bucket.js <fast|slow|all>');
return 1;
}
if (bucket === 'all') {
let exitCode = 0;
for (const name of ['fast', 'slow']) {
const status = runBucket(name);
if (status !== 0) {
exitCode = status;
}
}
return exitCode;
}
return runBucket(bucket);
}
if (require.main === module) {
process.exit(main());
}
module.exports = {
slowTests,
fastJsTests,
readsBuiltDist,
shouldForceSlow,
getDiscoveredTests,
getSlowSet,
selectBucket,
main,
};
@@ -0,0 +1,36 @@
import { describe, expect, test } from 'bun:test';
import * as fs from 'node:fs';
import * as path from 'node:path';
function resolvePath(relativePath: string) {
return path.resolve(import.meta.dir, relativePath);
}
describe('pr ci workflow', () => {
test('keeps full coverage on pull requests', () => {
const workflowPath = resolvePath('../../../../.github/workflows/ci.yml');
expect(fs.existsSync(workflowPath)).toBe(true);
const workflow = fs.readFileSync(workflowPath, 'utf8');
expect(workflow).toContain('name: CI');
expect(workflow).toContain('pull_request:');
expect(workflow).toContain('branches: [main, dev]');
expect(workflow).toContain('group: ci-${{ github.ref }}');
expect(workflow).toContain('cancel-in-progress: true');
expect(workflow).toContain('fail-fast: false');
expect(workflow).toContain('runs-on: [self-hosted, linux, x64]');
expect(workflow).toContain("cmd: 'bun run typecheck'");
expect(workflow).toContain("cmd: 'bun run lint'");
expect(workflow).toContain("cmd: 'bun run format:check'");
expect(workflow).toContain("key: ${{ runner.os }}-bun-cache-v2-${{ hashFiles('bun.lock', 'ui/bun.lock') }}");
expect(workflow).not.toContain('restore-keys:');
expect(workflow).toContain('name: dist');
expect(workflow).toContain('path: dist/');
expect(workflow).toContain('needs: [build]');
expect(workflow).toContain('run: bun run test:all');
expect(workflow).toContain("CCS_E2E_SKIP_BUILD: '1'");
expect(workflow).toContain('run: bun run test:e2e');
});
});
@@ -0,0 +1,34 @@
import { describe, expect, test } from 'bun:test';
import * as fs from 'node:fs';
import * as path from 'node:path';
function resolvePath(relativePath: string) {
return path.resolve(import.meta.dir, relativePath);
}
describe('push ci workflow', () => {
test('keeps dev push quality checks separate from release automation', () => {
const workflowPath = resolvePath('../../../../.github/workflows/push-ci.yml');
expect(fs.existsSync(workflowPath)).toBe(true);
const workflow = fs.readFileSync(workflowPath, 'utf8');
expect(workflow).toContain('name: Push CI');
expect(workflow).toContain('push:');
expect(workflow).toContain('branches: [dev]');
expect(workflow).toContain('group: push-ci-${{ github.ref }}');
expect(workflow).toContain('cancel-in-progress: true');
expect(workflow).toContain('runs-on: [self-hosted, linux, x64]');
expect(workflow).toContain("key: ${{ runner.os }}-bun-cache-v2-${{ hashFiles('bun.lock', 'ui/bun.lock') }}");
expect(workflow).not.toContain('restore-keys:');
expect(workflow).toContain("name: ${{ matrix.check.name }}");
expect(workflow).toContain("cmd: 'bun run typecheck'");
expect(workflow).toContain("cmd: 'bun run lint'");
expect(workflow).toContain("cmd: 'bun run format:check'");
expect(workflow).toContain('run: bun run build:all');
expect(workflow).toContain('run: bun run test:all');
expect(workflow).toContain("CCS_E2E_SKIP_BUILD: '1'");
expect(workflow).toContain('run: bun run test:e2e');
});
});
@@ -0,0 +1,28 @@
const { describe, expect, test } = require('bun:test');
const path = require('node:path');
const bucket = require('../../../scripts/run-test-bucket.js');
describe('run-test-bucket', () => {
test('all declared slow tests still exist on disk', () => {
for (const relativePath of bucket.slowTests) {
const absolutePath = path.resolve(__dirname, '../../../', relativePath);
expect(Bun.file(absolutePath).exists()).resolves.toBe(true);
}
});
test('forces npm tests into the slow bucket', () => {
expect(bucket.shouldForceSlow('tests/npm/cli.test.js')).toBe(true);
});
test('keeps dist-independent javascript tests in the fast bucket', () => {
expect(bucket.shouldForceSlow('tests/unit/flag-parsing-simple.test.js')).toBe(false);
});
test('keeps non-allowlisted javascript tests in the slow bucket', () => {
expect(bucket.shouldForceSlow('tests/unit/commands/persist-command.test.js')).toBe(true);
});
test('still forces dist-dependent tests into the slow bucket', () => {
expect(bucket.shouldForceSlow('tests/unit/config-dir-override.test.js')).toBe(true);
});
});