diff --git a/.github/actions/setup/action.yml b/.github/actions/setup/action.yml new file mode 100644 index 00000000..290b981e --- /dev/null +++ b/.github/actions/setup/action.yml @@ -0,0 +1,48 @@ +name: Setup workspace +description: >- + pnpm + Node (pinned by .node-version) + a frozen-lockfile install, and + optionally the Turborepo local cache for a task. + +# Every CI job repeats the same setup after checkout. It lives here so a +# Node/pnpm change is one edit instead of one per job, and so each job's YAML +# is only what makes that job different. + +inputs: + turbo-cache: + description: >- + Cache namespace for this job's `.turbo/cache` (e.g. "unit"). Each job + gets its own so they don't contend for one exact-key save. Leave empty + for jobs that run no turbo task, or that need a bespoke key. + required: false + default: "" + +runs: + using: composite + steps: + # Container jobs check out as a different UID than the container user, so + # git rejects the workspace as "dubious ownership" and the `prepare` + # script (lefthook install) dies. No-op on host runners. + - run: git config --global --add safe.directory "$GITHUB_WORKSPACE" + shell: bash + + - uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9 + # version comes from package.json "packageManager" + + - uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 + with: + node-version-file: ".node-version" + cache: pnpm + + - run: pnpm install --frozen-lockfile + shell: bash + + # Exact-key save per commit, prefix fallback to the newest prior cache: + # unchanged turbo tasks replay instead of re-executing. + - if: inputs.turbo-cache != '' + uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 + with: + path: .turbo/cache + key: turbo-${{ inputs.turbo-cache }}-${{ runner.os }}-${{ hashFiles('pnpm-lock.yaml') }}-${{ github.sha }} + restore-keys: | + turbo-${{ inputs.turbo-cache }}-${{ runner.os }}-${{ hashFiles('pnpm-lock.yaml') }}- + turbo-${{ inputs.turbo-cache }}-${{ runner.os }}- diff --git a/.github/workflows/_workflow-lint.yaml b/.github/workflows/_workflow-lint.yaml new file mode 100644 index 00000000..3ccb45bb --- /dev/null +++ b/.github/workflows/_workflow-lint.yaml @@ -0,0 +1,96 @@ +# Reusable: lint the workflows themselves — actionlint (syntax + shellcheck), +# zizmor (security posture) and pinact (every action ref pinned to a full +# commit SHA). +# +# Called by workflow-lint.yml here, and callable from any other repository: +# +# jobs: +# workflows: +# uses: leon0399/llame/.github/workflows/_workflow-lint.yaml@master +# permissions: +# contents: read +# security-events: write # only when advanced-security is true +# +# The inputs exist because the repos sharing this differ in exactly two ways: +# whether GitHub Advanced Security is available (public repo, or enabled), and +# whether the repo ships a zizmor config. The actionlint container is NOT an +# input — step-level `uses:` cannot take an expression — so its digest is +# pinned here and bumped in one place for every caller. + +name: Workflow lint (reusable) + +on: + workflow_call: + inputs: + advanced-security: + description: >- + Upload zizmor findings as SARIF to the Security tab. Requires + `security-events: write` from the caller. Set false when Advanced + Security is unavailable (e.g. a private repo without it) — findings + then surface as inline annotations instead. + type: boolean + default: true + zizmor-config: + description: >- + Path to a zizmor config file. Left empty, no --config is passed. + type: string + default: "" + +# Per-job permissions narrow whatever the caller granted: only zizmor needs +# the security-events surface for its SARIF upload — actionlint and pinact +# read the checkout and nothing else. +jobs: + actionlint: + name: actionlint + runs-on: ubuntu-latest + timeout-minutes: 5 + permissions: + contents: read + steps: + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + with: + persist-credentials: false + + # Pinned by immutable image digest — a stronger pin than a commit SHA. + # The image ships shellcheck (/usr/local/bin/shellcheck), which + # actionlint auto-detects on PATH, so `run:` blocks are checked too. + - uses: docker://rhysd/actionlint:1.7.12@sha256:b1934ee5f1c509618f2508e6eb47ee0d3520686341fec936f3b79331f9315667 + with: + args: -color + + zizmor: + name: zizmor + runs-on: ubuntu-latest + timeout-minutes: 5 + permissions: + contents: read + security-events: write + steps: + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + with: + persist-credentials: false + + - uses: zizmorcore/zizmor-action@6fc4b006235f201fdab3722e17240ab420d580e5 # v0.6.1 + with: + advanced-security: ${{ inputs.advanced-security }} + # Mutually exclusive with advanced-security: annotations are the + # fallback surface when SARIF upload isn't available. + annotations: ${{ !inputs.advanced-security }} + # Empty string is a no-op: the action only passes --config when set. + config: ${{ inputs.zizmor-config }} + + pinact: + name: pinact + runs-on: ubuntu-latest + timeout-minutes: 5 + permissions: + contents: read + steps: + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + with: + persist-credentials: false + + # Validate only — never rewrites files in CI. + - uses: suzuki-shunsuke/pinact-action@896d595f299e71d65b9d28349d6956abe144390a # v3.0.0 + with: + fix: "false" diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 21a81813..906ceef2 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,7 +1,18 @@ -# Test CI (#70) — gate PRs on the checks that already pass locally: -# lint / build / unit tests across the workspace, plus apps/api/scripts/rls-test.sh -# (cross-tenant RLS isolation + the auth/chat e2e over real HTTP against a -# throwaway Postgres — same script as local, GitHub-hosted runners have Docker). +# The test pyramid, as a job graph (docs/testing.md is the contract). +# +# typecheck ─┐ +# unit ──────┼─→ build +# ├─→ storybook (component tests, real browser) +# └─→ integration ──→ browser-e2e +# +# One job per concern, so a red X names what broke instead of hiding inside a +# grab-bag. Cheap gates run first and unconditionally; each expensive layer +# waits for the cheaper layer that would have caught the same breakage — a +# type error never spends a runner on the full-stack browser suite, and the +# browser suite never boots a stack whose api contract is already failing. +# +# Static checks (lint, format, workflow linting) live in lint.yml — they need +# no build, browser or database, and report independently. # # Actions are pinned to commit SHAs (comment = the release tag they resolve to). @@ -12,6 +23,12 @@ on: push: branches: [master] +# A new push supersedes the old run: nothing here needs to finish for a commit +# that is already outdated. +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + permissions: contents: read @@ -19,86 +36,138 @@ env: TURBO_TELEMETRY_DISABLED: "1" jobs: - checks: - name: lint · build · unit tests + typecheck: + name: Typecheck runs-on: ubuntu-latest - timeout-minutes: 20 + timeout-minutes: 15 steps: - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: persist-credentials: false - - uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9 - # version comes from package.json "packageManager" - - - uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 + - uses: ./.github/actions/setup with: - node-version-file: ".node-version" - cache: pnpm + turbo-cache: typecheck - - run: pnpm install --frozen-lockfile + # Full-program check on tsgo (TypeScript 7 Go port) — covers tests, which + # `nest build` and Next's bundled check exclude. Emit stays on TS 5.x. + - run: pnpm exec turbo run typecheck - # Persist the Turborepo local cache across runs: exact-key restore per - # commit, prefix fallback to the newest prior cache. Unchanged tasks - # (hashed over inputs + env declared in turbo.json) replay from cache - # instead of re-executing. - - uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 + unit: + name: Unit tests + runs-on: ubuntu-latest + timeout-minutes: 15 + steps: + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: - path: .turbo/cache - key: turbo-${{ runner.os }}-${{ hashFiles('pnpm-lock.yaml') }}-${{ github.sha }} - restore-keys: | - turbo-${{ runner.os }}-${{ hashFiles('pnpm-lock.yaml') }}- - turbo-${{ runner.os }}- + persist-credentials: false - - run: pnpm exec turbo run lint + # No turbo-cache: the `test` task is `cache: false` (turbo.json), so a + # cache here would be written and never read. + - uses: ./.github/actions/setup - # Full-program type check on tsgo (TypeScript 7 Go port) — covers specs, - # which `nest build` (tsconfig.build.json) and Next's bundled check - # exclude. Emit/build stays on TypeScript 5.x. - - run: pnpm exec turbo run typecheck + # Strictly dependency-free by construction: DB-backed suites are their + # own `test:integration` task, evals are opt-in behind RUN_MODEL_EVALS. + - run: pnpm exec turbo run test - # Formatting is checked explicitly (not embedded in a lint pass). Covers - # the whole repo via the root .prettierignore — the same surface the - # lefthook pre-commit `format` job gates per staged file (#175). - - run: pnpm run format:check + build: + name: Build + runs-on: ubuntu-latest + timeout-minutes: 20 + needs: [typecheck, unit] + steps: + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + with: + persist-credentials: false + + - uses: ./.github/actions/setup + with: + turbo-cache: build - run: pnpm exec turbo run build + # The api build regenerates openapi.json — a drifted checked-in copy is + # a failure, not a silent fixup. - run: git diff --exit-code - # Unit tests via the turbo task graph (cached like every other task). - # DB-backed suites self-skip without POSTGRES_URL / TEST_DATABASE_URL; - # model evals stay opt-in behind RUN_MODEL_EVALS — all three are declared - # in apps/api/turbo.json's test.env so their presence busts the cache. - - run: pnpm exec turbo run test - - rls: - name: RLS isolation + e2e (real Postgres) + integration: + name: Integration tests (real Postgres) runs-on: ubuntu-latest timeout-minutes: 20 + needs: [typecheck, unit] steps: - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: persist-credentials: false - - uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9 + - uses: ./.github/actions/setup + + # Self-provisioning (Testcontainers in the project's globalSetup): a + # throwaway Postgres owned by a NON-superuser role that runs the + # migrations — the worst-case single-role self-hosted deployment, so a + # green RLS suite proves FORCE constrains even the table owner. Needs + # host Docker, hence no container image for this job. + - run: pnpm --filter api test:integration - - uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 + storybook: + name: Component tests (Storybook) + runs-on: ubuntu-latest + timeout-minutes: 20 + needs: [typecheck, unit] + # The Playwright image ships the browser and its system libraries, so this + # job skips `playwright install --with-deps` entirely. Safe here because + # component tests need a browser but no Docker. + container: + # Pinned by digest, like every action here: a retag would otherwise + # change the browser and base image for an already-reviewed commit. + # Refresh with the catalog's playwright version: + # docker buildx imagetools inspect mcr.microsoft.com/playwright:vX.Y.Z-noble + image: mcr.microsoft.com/playwright:v1.55.1-noble@sha256:2f29369043d81d6d69a815ceb80760f55e85f5020371ad06a4d996f18503ad1c + # Chromium renderers allocate from /dev/shm, and Docker's default 64 MB + # segment is a known crash source under parallel story runs; --ipc=host + # is Playwright's documented mitigation for Chromium in containers. + options: --ipc=host + env: + # The image ships its browsers at /ms-playwright, but the step's HOME is + # /github/home, so Playwright would look in ~/.cache and find nothing. + PLAYWRIGHT_BROWSERS_PATH: /ms-playwright + # Same locale pinning as the browser-e2e job: Node >=21 derives + # navigator.language from the process locale, and an invalid tag makes + # `new Intl.Locale(...)` throw during SSR. + LANG: en_US.UTF-8 + LC_ALL: en_US.UTF-8 + steps: + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: - node-version-file: ".node-version" - cache: pnpm + persist-credentials: false - - run: pnpm install --frozen-lockfile + - uses: ./.github/actions/setup - # Proves FORCE RLS cross-tenant isolation and runs the HTTP e2e suites - # against a throwaway Postgres owned by a non-superuser role — the same - # worst-case single-role deployment the script exercises locally. - - run: bash apps/api/scripts/rls-test.sh + # The workflow's own hash is part of the key: this task's turbo inputs + # don't include the container image, so bumping the Playwright digest + # would otherwise restore a cache whose stored PASS was produced by the + # OLD browser — green without ever exercising the new one. Costs one + # cold cache per ci.yml edit, which is the cheap side of that trade. + - uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 + with: + path: .turbo/cache + key: turbo-storybook-${{ runner.os }}-${{ hashFiles('pnpm-lock.yaml', '.github/workflows/ci.yml') }}-${{ github.sha }} + restore-keys: | + turbo-storybook-${{ runner.os }}-${{ hashFiles('pnpm-lock.yaml', '.github/workflows/ci.yml') }}- + + - run: pnpm exec turbo run test:storybook build --filter=storybook browser-e2e: - name: browser e2e (Playwright, worker mode) + name: Product e2e (Playwright) runs-on: ubuntu-latest timeout-minutes: 25 + # The top of the pyramid: everything below it has already passed, so a + # failure here is a genuine cross-stack defect rather than a unit-level + # bug wearing a browser costume. `integration` already needs + # [typecheck, unit], so naming them here too would be redundant — and + # neither `build` nor `storybook` belongs: this suite runs `next dev`, + # not the production build, and component tests are orthogonal to it. + needs: [integration] env: # Node >=21 derives navigator.language from the process locale; with # LANG=C/unset it reports an invalid tag and `new Intl.Locale(...)` @@ -110,24 +179,21 @@ jobs: with: persist-credentials: false - - uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9 - - - uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 - with: - node-version-file: ".node-version" - cache: pnpm - - - run: pnpm install --frozen-lockfile + - uses: ./.github/actions/setup + # Host runner (not the Playwright image): this suite boots a throwaway + # Postgres through the Docker CLI, which a container job cannot reach. - run: pnpm exec playwright install --with-deps chromium - # Full browser chat-flow + mid-run refresh resume (#49/#80): Playwright - # boots a throwaway Postgres (docker), applies migrations, starts api + - # web + the mock model server, and drives Chromium against the real - # stack. This is the ONLY place the browser suite runs — keep it green. + # Boots db + api + worker + the mock model server + web, then drives + # Chromium against the real stack. The ONLY place the browser suite + # runs — keep it green. - run: pnpm test:e2e - - if: failure() || cancelled() + # failure() || cancelled(), NOT !cancelled(): a timed-out or cancelled + # run is exactly when the trace is worth keeping, and a green run has + # nothing worth uploading. + - if: ${{ failure() || cancelled() }} uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 with: name: playwright-report @@ -135,41 +201,3 @@ jobs: playwright-report/ test-results/ retention-days: 7 - - storybook: - name: Storybook component tests - runs-on: ubuntu-latest - timeout-minutes: 15 - env: - LANG: en_US.UTF-8 - LC_ALL: en_US.UTF-8 - steps: - - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - with: - persist-credentials: false - - - uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9 - - - uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 - with: - node-version-file: ".node-version" - cache: pnpm - - - run: pnpm install --frozen-lockfile - - - run: pnpm exec playwright install --with-deps chromium - - # Same Turborepo local-cache persistence as the checks job, under its - # own key so the two jobs don't contend for the same exact-key save. - - uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 - with: - path: .turbo/cache - key: turbo-sb-${{ runner.os }}-${{ hashFiles('pnpm-lock.yaml') }}-${{ github.sha }} - restore-keys: | - turbo-sb-${{ runner.os }}-${{ hashFiles('pnpm-lock.yaml') }}- - turbo-sb-${{ runner.os }}- - - # Browser component tests + static build for the dedicated Storybook - # app (apps/storybook; stories stay co-located in packages/ui). Kept out - # of the checks job's `turbo run test` because they need Playwright. - - run: pnpm exec turbo run test:storybook build --filter=storybook diff --git a/.github/workflows/git-ai.yaml b/.github/workflows/git-ai.yaml index 3ad891cc..dd885632 100644 --- a/.github/workflows/git-ai.yaml +++ b/.github/workflows/git-ai.yaml @@ -12,7 +12,7 @@ jobs: - name: Install git-ai run: | curl -fsSL https://usegitai.com/install.sh | bash - echo "$HOME/.git-ai/bin" >> $GITHUB_PATH + echo "$HOME/.git-ai/bin" >> "$GITHUB_PATH" - name: Run git-ai id: run-git-ai env: diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml new file mode 100644 index 00000000..ab5b1a12 --- /dev/null +++ b/.github/workflows/lint.yml @@ -0,0 +1,57 @@ +# Static checks over the SOURCE that need no build, browser or database — the +# cheapest feedback in the pipeline, deliberately its own workflow so a +# formatting nit reports separately from a failing test. +# +# The workflows themselves are linted by workflow-lint.yml (actionlint, +# zizmor, pinact). +# +# Actions are pinned to commit SHAs (comment = the release tag they resolve to). + +name: Lint + +on: + pull_request: + push: + branches: [master] + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +permissions: + contents: read + +env: + TURBO_TELEMETRY_DISABLED: "1" + +jobs: + lint: + name: Lint + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + with: + persist-credentials: false + + - uses: ./.github/actions/setup + with: + turbo-cache: lint + + # oxlint per workspace; type-aware rules via tsgolint in apps/api. + - run: pnpm exec turbo run lint + + format: + name: Format + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + with: + persist-credentials: false + + - uses: ./.github/actions/setup + + # Whole repo via the root .prettierignore — the same surface the + # lefthook pre-commit hook gates per staged file (#175). + - run: pnpm run format:check diff --git a/.github/workflows/workflow-lint.yml b/.github/workflows/workflow-lint.yml new file mode 100644 index 00000000..7ced21bd --- /dev/null +++ b/.github/workflows/workflow-lint.yml @@ -0,0 +1,32 @@ +# Thin caller: the jobs live in _workflow-lint.yaml so other repositories can +# reuse the same three checks instead of each keeping its own copy (they had +# already drifted across tools and versions). + +name: Workflow lint + +on: + pull_request: + push: + branches: [master] + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +permissions: {} + +jobs: + workflows: + uses: ./.github/workflows/_workflow-lint.yaml + permissions: + contents: read + # SARIF upload to the Security tab — available because this repo is + # public. A private repo without Advanced Security passes + # `advanced-security: false` instead and gets inline annotations. + security-events: write + with: + # Fork PRs run with a read-only token (the permissions grant above is + # capped, not elevated), so the SARIF upload would 403 and fail the + # gate — fall back to inline annotations for them. Evaluates true for + # push and same-repo PR events. + advanced-security: ${{ github.event.pull_request.head.repo.fork != true }} diff --git a/.node-version b/.node-version index 1d9b7831..f9e7451e 100644 --- a/.node-version +++ b/.node-version @@ -1 +1 @@ -22.12.0 +22.23.1 diff --git a/.pinact.yaml b/.pinact.yaml new file mode 100644 index 00000000..10fda071 --- /dev/null +++ b/.pinact.yaml @@ -0,0 +1,13 @@ +# yaml-language-server: $schema=https://raw.githubusercontent.com/suzuki-shunsuke/pinact/main/json-schema/pinact.json +# pinact - https://github.com/suzuki-shunsuke/pinact +version: 3 +ignore_actions: + # actionlint runs from its official container, pinned by immutable image + # digest — a stronger pin than a commit SHA. pinact cannot parse docker:// + # refs (they surface as "action isn't pinned"), so this line is excluded + # rather than the pin weakened. + # Scoped to the docker:// form ONLY: making the prefix optional would also + # exempt a marketplace `uses: rhysd/actionlint@v1.7.12`, silently punching a + # hole in the very guarantee this job exists to enforce. + - name: docker://rhysd/actionlint(:.*)? + ref: .* diff --git a/AGENTS.md b/AGENTS.md index 78c3d4e1..1bb12776 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -16,11 +16,12 @@ The product overview (what llame is) is short and always relevant, so it is impo - [CHANGELOG.md](CHANGELOG.md) — shipped chronology - [`docs/research`](docs/research) — noncanonical evidence, alternatives, and decision provenance - [DESIGN.md](DESIGN.md) — design system reference (visual language, OKLCH tokens, component stylings); consult before building or restyling any UI +- [docs/testing.md](docs/testing.md) — the test pyramid, suffix-is-runner naming contract, placement rules, and CI mapping - [docs/scaling.md](docs/scaling.md) — horizontal-scaling topology, invariants, and the design constraints for the durable-run worker split (#48/#50) ## Monorepo layout -pnpm + Turborepo workspace, **TypeScript end-to-end** (Node >= 22.12, pinned in `.node-version`; `nix develop` or direnv gives a ready shell; pnpm 10). Workspaces: `apps/*`, `packages/*`. +pnpm + Turborepo workspace, **TypeScript end-to-end** (Node >= 22.19 (pinned to 22.23.1 in `.node-version`); `nix develop` or direnv gives a ready shell; pnpm 10). Workspaces: `apps/*`, `packages/*`. | Path | Role | Stack (details in its own `AGENTS.md`) | | ---------------------------- | ------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------ | @@ -48,7 +49,7 @@ pnpm test:e2e:report # playwright show-report ``` Scope to one workspace with `pnpm --filter web