Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
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
92 changes: 92 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
# Full CI — the prod gate. main is what users clone and what self-update
# pulls, so nothing lands on main without the complete suite passing on the
# oldest supported Node (20, per engines) and current (24), on Linux and
# macOS (where Caliper actually runs day-to-day).
#
# Branch protection requires the single "full-ci-gate" check, which fans in
# from the whole matrix — add/remove matrix entries without touching the
# protection rule.
name: full-ci

on:
pull_request:
branches: [main]
push:
branches: [main]

concurrency:
group: full-ci-${{ github.ref }}
cancel-in-progress: true

jobs:
test:
name: test (${{ matrix.os }}, node ${{ matrix.node }})
runs-on: ${{ matrix.os }}
timeout-minutes: 10
strategy:
fail-fast: false
matrix:
include:
- { os: ubuntu-latest, node: 20 }
- { os: ubuntu-latest, node: 24 }
- { os: macos-latest, node: 24 }
steps:
# Actions pinned to full commit SHAs (same supply-chain stance as the
# exactly-pinned npm dependencies).
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
with:
node-version: ${{ matrix.node }}
cache: npm
cache-dependency-path: packages/*/package-lock.json

# workflow-lens ships a real dep (acorn) that control-tower reaches via
# relative imports; both installs are lockfile-exact and take ~1s.
- name: Install (lockfile-exact)
run: |
npm ci --prefix packages/workflow-lens
npm ci --prefix packages/control-tower

- name: workflow-lens suite
working-directory: packages/workflow-lens
run: npm test

- name: control-tower suite
working-directory: packages/control-tower
run: npm test
# 11 observer tests skip without WFLENS_TEST_SESSION_DIR — env-gated by
# design (AGENTS.md rule 5). Any other skip or failure fails the gate.

- name: Server boots and answers /v1/health
working-directory: packages/control-tower
run: |
PORT=48787 node server.mjs &
SERVER_PID=$!
for i in $(seq 1 20); do
if curl -fsS http://127.0.0.1:48787/v1/health > /dev/null 2>&1; then
echo "health OK"
kill "$SERVER_PID"
exit 0
fi
sleep 0.5
done
echo "server never answered /v1/health" >&2
kill "$SERVER_PID" || true
exit 1

# Single fan-in check for branch protection: green only when every matrix
# job is green. Runs even when a job fails (so the check reports red, not
# "missing").
gate:
name: full-ci-gate
runs-on: ubuntu-latest
needs: [test]
if: always()
steps:
- name: All matrix jobs green?
run: |
if [ "${{ needs.test.result }}" != "success" ]; then
echo "matrix result: ${{ needs.test.result }}" >&2
exit 1
fi
echo "all green"
60 changes: 60 additions & 0 deletions .github/workflows/smoke.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
# Smoke gate for the dev integration branch — fast by design (< 1 minute).
#
# dev answers one question: "is this obviously broken?" — the server boots,
# /v1/health answers, and the launch-number test files pass. The full matrix
# (both suites, Node 20+24, Linux+macOS) runs only when dev is promoted to
# main (prod) — see ci.yml.
name: smoke

on:
pull_request:
branches: [dev]
push:
branches: [dev]

concurrency:
group: smoke-${{ github.ref }}
cancel-in-progress: true

jobs:
smoke:
name: smoke
runs-on: ubuntu-latest
timeout-minutes: 5
steps:
# Actions pinned to full commit SHAs (same supply-chain stance as the
# exactly-pinned npm dependencies).
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
with:
node-version: 24
cache: npm
cache-dependency-path: packages/*/package-lock.json

# workflow-lens ships a real dep (acorn) that control-tower reaches via
# relative imports; both installs are lockfile-exact and take ~1s.
- name: Install (lockfile-exact)
run: |
npm ci --prefix packages/workflow-lens
npm ci --prefix packages/control-tower

- name: Launch-number tests (the headline stat must never regress)
working-directory: packages/control-tower
run: node --test test/fallbacks.test.mjs test/sessions-fallbacks.test.mjs test/fallback-invariants.test.mjs

- name: Server boots and answers /v1/health
working-directory: packages/control-tower
run: |
PORT=48787 node server.mjs &
SERVER_PID=$!
for i in $(seq 1 20); do
if curl -fsS http://127.0.0.1:48787/v1/health > /dev/null 2>&1; then
echo "health OK"
kill "$SERVER_PID"
exit 0
fi
sleep 0.5
done
echo "server never answered /v1/health" >&2
kill "$SERVER_PID" || true
exit 1
33 changes: 33 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -103,6 +103,39 @@ Work is not done until:
- Test results are reported faithfully — actual counts, actual failures, no
"should pass".

## Branches and CI — dev → prod

`main` is prod: it is what users clone, and what the self-update route
(`git pull --ff-only`) and the version check pull. It must always be
releasable. `dev` is the integration branch. The flow:

1. Feature/fix branches are cut from `dev` and PR back into `dev`.
Gate: the **smoke** workflow (< 1 min) — launch-number test files +
server boot + `/v1/health`. Fast on purpose; it answers "is this
obviously broken?", nothing more.
2. Promotion is a PR `dev` → `main`.
Gate: the **full-ci** workflow — both package suites + the health smoke
on Node 20 (oldest supported per `engines`) and 24, Linux and macOS.
Branch protection on `main` requires the `full-ci-gate` fan-in check.
3. Hotfixes may PR straight to `main` in an emergency — they get the same
full gate; merge the same change back to `dev` immediately after.

CI conventions (keep it this lean):

- Workflows live in `.github/workflows/` (`smoke.yml`, `ci.yml`). Actions
are pinned to full commit SHAs — same supply-chain stance as the
exactly-pinned npm dependencies. Bump the SHA and the version comment
together.
- CI runs the same `npm test` a developer runs — no CI-only test paths, no
CI-only env besides what GitHub provides. If it is green locally it is
green in CI, or the difference is a real bug.
- The 11 env-gated observer skips are expected in CI; any other skip or
failure fails the gate (rule 8).
- Don't grow the smoke gate. If dev needs more confidence, promote to main
more often instead of making every dev push slower.
- CI is the enforcement of "done means green", not a substitute for running
tests locally before pushing.

## Running tests

```
Expand Down
11 changes: 11 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,17 @@ advisory. If this file and `AGENTS.md` disagree, `AGENTS.md` wins.
are env-gated by design; any OTHER skip or failure is your problem to
resolve or report.

## Branches — dev → prod

- `main` is PROD (users clone it; self-update pulls it). Never push work
straight to `main`: feature branches PR into `dev` (fast smoke gate), and
`dev` promotes to `main` via PR (full CI gate: both suites × Node 20/24 ×
Linux/macOS, `full-ci-gate` required by branch protection).
- Hotfix exception: PR straight to `main` gets the same full gate; merge the
fix back into `dev` right after.
- See AGENTS.md "Branches and CI" for the full conventions (SHA-pinned
actions, no CI-only test paths, keep the smoke gate small).

## Commands

```
Expand Down
5 changes: 5 additions & 0 deletions packages/control-tower/test/sessions.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -142,6 +142,11 @@ test('buildHomeData: cross-folder recents + bounded folder spend rollups', () =>
const projB = join(root, '-Users-x-develop-beta')
mkdirSync(projB, { recursive: true })
writeFileSync(join(projB, `${PLAIN_ID}.jsonl`), [uLine('2026-06-02T00:00:00.000Z', 'beta work'), aLine('2026-06-02T00:00:03.000Z')].join('\n'))
// Pin mtimes explicitly — back-to-back writes can land in the same mtime
// tick on ext4 (Linux CI), making the newest-first sort order arbitrary.
const now = Date.now() / 1000
utimesSync(join(projB, `${PLAIN_ID}.jsonl`), now, now)
utimesSync(join(projA, `${RICH_ID}.jsonl`), now - 1000, now - 1000)

const home = buildHomeData(root, { folders: 5, perFolder: 8, recents: 10 })
assert.ok(home.projects.length >= 2) // full picker list
Expand Down
Loading