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
31 changes: 17 additions & 14 deletions .claude/skills/ci-cd/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,23 +9,25 @@ Each workflow has a per-file deep-dive doc next to it (`ci.md`, `cd-*.md`, `chro

## Workflow files

| File | Name | Trigger |
| ------------------- | ------------------------ | ---------------------------------------------------------------------------- |
| `ci.yml` | `Continuous Integration` | `push` to `main`, all `pull_request` |
| `ci-packages.yml` | CI · Packages | `workflow_call` from `ci.yml` |
| `ci-worker.yml` | CI · Workers | `workflow_call` from `ci.yml` |
| `ci-app.yml` | CI · Apps | `workflow_call` from `ci.yml` |
| `ci-web.yml` | CI · Web | `workflow_call` from `ci-app.yml` |
| `ci-editor.yml` | CI · Editor | `workflow_call` from `ci-app.yml` |
| `cd-web.yml` | Pages + Storybook deploy | `workflow_run` of CI (success, `main`) + dispatch |
| `cd-worker-api.yml` | Cloudflare Worker deploy | `workflow_run` of CI (success, `main`) + dispatch |
| `cd-packages.yml` | Publish Packages (npm) | manual `workflow_dispatch` only — see the `release-notes` skill |
| `cd-editor.yml` | CD · Editor | manual `workflow_dispatch` only — draft GitHub Release of the installers |
| `chromatic.yml` | Chromatic | `pull_request` + `push` to `main` + `workflow_dispatch` (main), non-blocking |
| `label-area.yml` | Label Affected Area | `issues: opened` |
| File | Name | Trigger |
| ------------------- | ------------------------------ | ---------------------------------------------------------------------------- |
| `ci.yml` | `CI` | `push` to `main`, all `pull_request` |
| `ci-packages.yml` | `CI · Packages` | `workflow_call` from `ci.yml` |
| `ci-worker.yml` | `CI · Workers` | `workflow_call` from `ci.yml` |
| `ci-app.yml` | `CI · Apps` | `workflow_call` from `ci.yml` |
| `ci-web.yml` | `CI · Web` | `workflow_call` from `ci-app.yml` |
| `ci-editor.yml` | `CI · Editor` | `workflow_call` from `ci-app.yml` |
| `cd-web.yml` | `CD · Web (Pages + Storybook)` | `workflow_run` of CI (success, `main`) + dispatch |
| `cd-worker-api.yml` | `CD · Worker (api)` | `workflow_run` of CI (success, `main`) + dispatch |
| `cd-packages.yml` | `CD · Packages (npm)` | manual `workflow_dispatch` only — see the `release-notes` skill |
| `cd-editor.yml` | `CD · Editor (release)` | manual `workflow_dispatch` only — draft GitHub Release of the installers |
| `chromatic.yml` | `Chromatic` | `pull_request` + `push` to `main` + `workflow_dispatch` (main), non-blocking |
| `label-area.yml` | `Label Affected Area` | `issues: opened` |

One CI entry workflow calling one per area; CD is separate and **gated on CI success** — never deploy on a raw `push`.

**Naming.** Every workflow is `CI · <Area>` or `CD · <Area> (<detail>)`, so the Actions sidebar groups into two blocks; the entry workflow is plain `CI`. Chromatic and the labeller stay **unprefixed on purpose** — neither is part of `ci-ok`, and prefixing them would say they gate PRs. **Renaming a workflow is never a one-file edit**: `workflow_run` matches on the workflow's `name:`, not its filename, so `cd-web` / `cd-worker-api` / `cd-worker-bench` all pin `workflows: ['CI']` and a rename that misses one silently stops that deploy for good. Branch protection is unaffected — it matches the **job** name `ci-ok`.

## Action pinning convention — the load-bearing rule

Pin every `uses:` by the action's **origin**. Getting this wrong fails review: CodeRabbit flags SHA-pinned `actions/*`; SonarQube flags anything else on a version tag.
Expand All @@ -40,6 +42,7 @@ Own-org used to sit with `actions/*` on a tag. It does not any more: what SHA-pi
`prepare` → `lint` → three **caller jobs** (`packages`, `worker`, `app`) → `ci-ok`. Each caller `uses:` an area workflow; `ci-app.yml` calls one workflow per app in turn, so adding an app never touches the entry file. Nesting is three of the four levels GitHub allows, and it stays one run with one `ci-ok`.

- **Detect once in `prepare`** (node version from `.nvmrc`, package manager, runner, changed areas), reuse via `needs.prepare.outputs.*`. Never hard-code the node version.
- **Every job that installs starts from `./.github/actions/setup`** — the composite action holding pnpm, Node and the install. **Never re-inline those steps**: the whole point is that the `pnpm/action-setup` pin and the store cache are a one-file edit. A new job is a checkout (the action cannot carry it — a local action is resolved from the working tree it checks out) plus a call with `node_version`/`manager`/`command`. Per-area caches (Playwright binaries, the Electron binary) stay in the job, just after the call. The action carries its own `# ci:validates all` marker, so editing it re-runs every job without setting `changes.root` — nothing deploys off a CI edit.
- **A file per area, so the gate can be narrower than everything.** Each workflow declares its scope on line 1 (`# ci:validates pkg__*`), read by `scripts/assemble-changes.mjs`; unmarked or unparseable means the whole workspace. A caller job cannot set `environment:`/`timeout-minutes:`/`runs-on:` (those belong to the inner jobs), and **`secrets: inherit` is mandatory, per hop** — naming an environment-scoped secret at the call site passes an empty string, and a middle layer that omits it starves the workflow below.
- **One job per shape, not per member.** Packages, workers and the editor's unit tier are the same job — install, `test:coverage`, upload the lcov — so packages are one matrix and workers another, both built from the tree in `scripts/assemble-changes.mjs`. **Adding a workspace member must need no edit to `ci.yml`**: if a new area needs a job, ask first whether it is really a different shape (`web` builds; `editor-e2e` drives Electron) or just another row.
- Heavy jobs are **change-gated** (`dorny/paths-filter`, no Nx/Turbo) so a package-only PR stays cheap. Dependency edges are **derived, never listed**: a member runs when it changed or when a package it declares as a `workspace:` dependency changed. Do not add a hand-written consumer list — it is a list to forget the day a dependency moves.
Expand Down
2 changes: 1 addition & 1 deletion .claude/skills/release-notes/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -133,7 +133,7 @@ Maintenance release — dependency refresh only. No public API or behavior chang
step fails again before publishing. That same check also fails a commit whose **staged**
package isn't ahead of the version already on npm, so an edit can't land without its bump. A
package you didn't touch may stay at its published version.
2. Dispatch — Actions → **Publish Packages (npm)** → pick `package`, **Run**. CLI:
2. Dispatch — Actions → **CD · Packages (npm)** → pick `package`, **Run**. CLI:
`gh workflow run cd-packages.yml -f package=<dir>`.
3. The job publishes to npm (skips if that version already exists) and cuts a GitHub Release
tagged/titled `<name>@<version>` from the notes file. Re-running repairs a missing Release
Expand Down
52 changes: 52 additions & 0 deletions .github/actions/setup/action.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
# ci:validates all
# Every job installs through this action, so editing it re-runs every one of them — and, being a
# CI file rather than a root file, ships nothing.
#
# The four steps every CI job used to repeat, minus the checkout: a local action is
# resolved from the working tree, so `uses: ./.github/actions/setup` cannot carry the
# checkout that puts it there. Jobs keep their own — which is also the step that varies
# (ci-web.yml's `web` job needs `fetch-depth: 0` for Codecov base detection).
#
# Per-area caches stay in the jobs that need them. This holds only what every job shares,
# so bumping the pnpm pin or changing the store cache is an edit to this file alone.
name: Setup
description: Set up pnpm and Node, then install the workspace's dependencies.

inputs:
node_version:
description: Node.js version to install — ci.yml's `prepare` job reads it from .nvmrc.
required: true
manager:
description: Detected package manager — pnpm, yarn or npm.
required: true
command:
description: The manager's install command, with its flags.
required: true

runs:
using: composite
steps:
- name: Setup pnpm
if: inputs.manager == 'pnpm'
uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v5

- name: Setup Node
uses: actions/setup-node@v5
with:
node-version: ${{ inputs.node_version }}
cache: ${{ inputs.manager }}

# Both inputs reach the shell through env and a fixed dispatch, never spliced into
# the run body — zizmor: template-injection. `$COMMAND` is deliberately unquoted: it
# is a flag list ("install --frozen-lockfile --ignore-scripts"), not a single word.
- name: Install dependencies
shell: bash
env:
CI: true
MANAGER: ${{ inputs.manager }}
COMMAND: ${{ inputs.command }}
run: |
case "$MANAGER" in
pnpm|yarn|npm) "$MANAGER" $COMMAND ;;
*) echo "Unsupported package manager: $MANAGER" >&2; exit 1 ;;
esac
65 changes: 39 additions & 26 deletions .github/workflows/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,21 +7,33 @@ Chromatic visual-review workflow, and an issue-labeling automation. The three de
raw `push`; package publishing (`cd-packages`) and the editor release (`cd-editor`) are
**manual `workflow_dispatch` only**.

| File | Name | Trigger |
| ---------------------------------------------- | ------------------------- | ------------------------------------------------------------ |
| [`ci.yml`](./ci.yml) | `Continuous Integration` | `push` to `main`, every `pull_request` |
| [`ci-packages.yml`](./ci-packages.yml) | CI · Packages | `workflow_call` from `ci.yml` |
| [`ci-worker.yml`](./ci-worker.yml) | CI · Workers | `workflow_call` from `ci.yml` |
| [`ci-app.yml`](./ci-app.yml) | CI · Apps | `workflow_call` from `ci.yml` |
| [`ci-web.yml`](./ci-web.yml) | CI · Web | `workflow_call` from `ci-app.yml` |
| [`ci-editor.yml`](./ci-editor.yml) | CI · Editor | `workflow_call` from `ci-app.yml` |
| [`cd-web.yml`](./cd-web.yml) | Pages + Storybook deploy | `workflow_run` of CI (success, `main`) + `workflow_dispatch` |
| [`cd-worker-api.yml`](./cd-worker-api.yml) | Cloudflare Worker deploy | `workflow_run` of CI (success, `main`) + `workflow_dispatch` |
| [`cd-worker-bench.yml`](./cd-worker-bench.yml) | Bench relay Worker deploy | `workflow_run` of CI (success, `main`) + `workflow_dispatch` |
| [`cd-packages.yml`](./cd-packages.yml) | Publish Packages (npm) | manual `workflow_dispatch` only |
| [`cd-editor.yml`](./cd-editor.yml) | CD · Editor | manual `workflow_dispatch` only |
| [`chromatic.yml`](./chromatic.yml) | Chromatic | `push` to `main` (paths) + `workflow_dispatch` |
| [`label-area.yml`](./label-area.yml) | Label Affected Area | `issues` `opened` |
| File | Name | Trigger |
| ---------------------------------------------- | ------------------------------ | ------------------------------------------------------------ |
| [`ci.yml`](./ci.yml) | `CI` | `push` to `main`, every `pull_request` |
| [`ci-packages.yml`](./ci-packages.yml) | `CI · Packages` | `workflow_call` from `ci.yml` |
| [`ci-worker.yml`](./ci-worker.yml) | `CI · Workers` | `workflow_call` from `ci.yml` |
| [`ci-app.yml`](./ci-app.yml) | `CI · Apps` | `workflow_call` from `ci.yml` |
| [`ci-web.yml`](./ci-web.yml) | `CI · Web` | `workflow_call` from `ci-app.yml` |
| [`ci-editor.yml`](./ci-editor.yml) | `CI · Editor` | `workflow_call` from `ci-app.yml` |
| [`cd-web.yml`](./cd-web.yml) | `CD · Web (Pages + Storybook)` | `workflow_run` of CI (success, `main`) + `workflow_dispatch` |
| [`cd-worker-api.yml`](./cd-worker-api.yml) | `CD · Worker (api)` | `workflow_run` of CI (success, `main`) + `workflow_dispatch` |
| [`cd-worker-bench.yml`](./cd-worker-bench.yml) | `CD · Worker (bench)` | `workflow_run` of CI (success, `main`) + `workflow_dispatch` |
| [`cd-packages.yml`](./cd-packages.yml) | `CD · Packages (npm)` | manual `workflow_dispatch` only |
| [`cd-editor.yml`](./cd-editor.yml) | `CD · Editor (release)` | manual `workflow_dispatch` only |
| [`chromatic.yml`](./chromatic.yml) | `Chromatic` | `push` to `main` (paths) + `workflow_dispatch` |
| [`label-area.yml`](./label-area.yml) | `Label Affected Area` | `issues` `opened` |

Names are `CI · <Area>` and `CD · <Area>`, so the Actions sidebar groups into two blocks — the
entry workflow is plain `CI`. Chromatic and the labeller are unprefixed because neither is part of
`ci-ok`. **Renaming one is never a one-file edit**: `workflow_run` matches on the `name:`, not the
filename, so the three deploys pin `workflows: ['CI']` and a rename that misses one stops that
deploy without a word. Branch protection matches the job name `ci-ok`, so it is unaffected.

Everything shared by the jobs that install — pnpm, Node, the install itself — is the composite
action [`.github/actions/setup`](../actions/setup/action.yml), called by every one of them.
**A new job starts from a checkout and a call to it**, so bumping the `pnpm/action-setup` pin
or changing the store cache is an edit to one file: see
[the setup action](./ci.md#the-setup-action).

**Per-workflow deep dives** (every step + caching):
[`ci.md`](./ci.md) · [`ci-packages.md`](./ci-packages.md) · [`ci-worker.md`](./ci-worker.md) · [`ci-app.md`](./ci-app.md) · [`ci-web.md`](./ci-web.md) · [`ci-editor.md`](./ci-editor.md) · [`cd-web.md`](./cd-web.md) · [`cd-worker-api.md`](./cd-worker-api.md) · [`cd-worker-bench.md`](./cd-worker-bench.md) · [`cd-packages.md`](./cd-packages.md) · [`cd-editor.md`](./cd-editor.md) · [`chromatic.md`](./chromatic.md) · [`label-area.md`](./label-area.md)
Expand All @@ -36,19 +48,19 @@ artifact.

```mermaid
flowchart LR
push["push to main"] --> ci["CI — Continuous Integration"]
push["push to main"] --> ci["CI"]
pr["pull_request"] --> ci
ci -->|"uploads artifact"| art[("changes.json<br/>apps · worker · packages<br/>workflows · root")]
ci -->|"workflow_run: completed + success on main"| cdweb["CD — Pages"]
ci -->|"workflow_run: completed + success on main"| cdworker["CD Worker API"]
ci -->|"workflow_run: completed + success on main"| cdbench["CD — Bench relay Worker"]
ci -->|"workflow_run: completed + success on main"| cdweb["CD · Web"]
ci -->|"workflow_run: completed + success on main"| cdworker["CD · Worker (api)"]
ci -->|"workflow_run: completed + success on main"| cdbench["CD · Worker (bench)"]
art -.->|"download-artifact"| cdweb
art -.->|"download-artifact"| cdworker
art -.->|"download-artifact"| cdbench
cdweb --> pages["GitHub Pages"]
cdworker --> cf["Cloudflare Worker"]
cdbench --> cfbench["Cloudflare Worker (bench relay)"]
disp["workflow_dispatch (manual)"] --> cdpkg["CD Packages"]
disp["workflow_dispatch (manual)"] --> cdpkg["CD · Packages"]
cdpkg --> npm["npm registry"]
```

Expand All @@ -60,7 +72,7 @@ deploys on `apps`/`packages`/`root`); the policy lives in CD, the facts in CI. I
artifact is missing (e.g. a manual `workflow_dispatch`), the deploy falls back to
deploying.

## `ci.yml` — Continuous Integration
## `ci.yml` — CI

A single `prepare` job detects everything once and exposes it as outputs; the heavy
jobs fan out from it and are **gated by change detection** so a package-only PR never
Expand Down Expand Up @@ -178,7 +190,7 @@ Codecov merges uploads by commit SHA. 100% coverage is enforced inside each
| `storybook` | Storybook test runner |
| `e2e` | web Playwright (`coverage/e2e`) |

## `cd-web.yml` — GitHub Pages + Storybook deploy
## `cd-web.yml` — CD · Web (Pages + Storybook)

```mermaid
flowchart TD
Expand All @@ -195,7 +207,7 @@ flowchart TD
abort each other. Build env (Vite vars, GitHub key, Turnstile sitekey) is injected
from repo secrets/vars; `APP_ENV=production`.

## `cd-worker-api.yml` — Cloudflare Worker deploy
## `cd-worker-api.yml` — CD · Worker (api)

```mermaid
flowchart TD
Expand All @@ -208,13 +220,14 @@ flowchart TD
`config:gen` renders `wrangler.json` from repo `vars` (worker name, D1, R2, honeypot);
`wrangler deploy` authenticates with `CLOUDFLARE_API_TOKEN` + `CLOUDFLARE_ACCOUNT_ID`.

## `cd-worker-bench.yml` — Bench relay Worker deploy
## `cd-worker-bench.yml` — CD · Worker (bench)

Structural mirror of `cd-worker-api.yml` for `workers/bench` (the bench-action comment
relay at `api.bench.soroush.tech`): deploys when `worker∋bench ∥ root`, in its own
relay at `api.bench.soroush.tech`): deploys when
`worker∋bench ∥ packages∋wrangler-tools ∥ root`, in its own
`cd-worker-bench` environment — see [`cd-worker-bench.md`](./cd-worker-bench.md).
Comment thread
coderabbitai[bot] marked this conversation as resolved.

## `cd-packages.yml` — npm publish
## `cd-packages.yml` — CD · Packages (npm)

**Manual only** — unlike the other two CD workflows, this one is **not** gated on CI and
never runs off a push, PR merge, or `workflow_run`. It publishes from `workflow_dispatch`,
Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/cd-editor.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[← Workflows overview](./README.md)

# `cd-editor.yml` — Package and release the desktop editor
# `cd-editor.yml` — CD · Editor (release)

Builds the editor's installers on both platforms and assembles them into **one
published GitHub Release** with a title and the notes from
Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/cd-editor.yml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# ci:validates nothing
# Packages the desktop editor and publishes one GitHub Release.
name: CD · Editor
name: CD · Editor (release)

# Manual only — a release is a decision, not a side effect of a merge, and
# approving the dispatch is the release act. The two build legs upload their
Expand Down
Loading