diff --git a/.claude/settings.json b/.claude/settings.json index 9527680..86468cf 100644 --- a/.claude/settings.json +++ b/.claude/settings.json @@ -23,6 +23,9 @@ "Bash(wget:*)", "Bash(nc:*)", "Bash(ssh:*)", + "Bash(git commit --no-verify:*)", + "Bash(git commit -n:*)", + "Bash(git push --no-verify:*)", "Read(**/.env*)", "Read(**/secrets/**)" ], diff --git a/.githooks/pre-commit b/.githooks/pre-commit new file mode 100755 index 0000000..d51cb82 --- /dev/null +++ b/.githooks/pre-commit @@ -0,0 +1,17 @@ +#!/bin/sh +# Fast checks before every commit; the heavier ones run in pre-push, and CI runs +# all of them again. Enabled by `pnpm install` (the root `prepare` script) or +# `git config core.hooksPath .githooks`. Never bypass with --no-verify: fix the +# failure instead. +# +# format:check and lint read the working tree, not the staged snapshot, so +# unstaged edits can make them pass or fail. Stage everything you mean to commit. +set -eu + +pnpm format:check +pnpm lint + +# Scan only what is staged. This fails closed: when neither gitleaks nor Docker +# is available, secrets:scan exits 2 and the commit is refused, since the point +# of this hook is to stop a secret before it reaches the remote. +pnpm secrets:scan --staged diff --git a/.githooks/pre-push b/.githooks/pre-push new file mode 100755 index 0000000..c02000e --- /dev/null +++ b/.githooks/pre-push @@ -0,0 +1,9 @@ +#!/bin/sh +# Slower checks before every push; pre-commit already ran format, lint and the +# staged secrets scan. Turbo's cache replays unchanged packages. Build is left +# to CI. Never bypass with --no-verify: fix the failure instead. +set -eu + +pnpm typecheck +pnpm test +pnpm knip diff --git a/.github/scripts/secrets-scan.sh b/.github/scripts/secrets-scan.sh index e393edb..e0329c4 100644 --- a/.github/scripts/secrets-scan.sh +++ b/.github/scripts/secrets-scan.sh @@ -35,6 +35,23 @@ if command -v docker >/dev/null 2>&1 && docker info >/dev/null 2>&1; then "$repo_root"/*) ;; *) set -- -v "$git_common_dir:$git_common_dir:ro" "$@" ;; esac + # Inside a commit hook, `git commit -a` and `git commit ` stage the + # commit in a temporary index named by GIT_INDEX_FILE. A local gitleaks + # inherits it but the container does not, so forward it as an absolute path, + # or `--staged` scans the ordinary index instead. It normally lives in the git + # dir, which the mounts above cover; mount its directory when it does not. + if [ -n "${GIT_INDEX_FILE:-}" ]; then + case "$GIT_INDEX_FILE" in + /*) index_file=$GIT_INDEX_FILE ;; + *) index_file=$PWD/$GIT_INDEX_FILE ;; + esac + index_dir=$(dirname "$index_file") + case "$index_dir" in + "$repo_root" | "$repo_root"/* | "$git_common_dir" | "$git_common_dir"/*) ;; + *) set -- -v "$index_dir:$index_dir:ro" "$@" ;; + esac + set -- -e "GIT_INDEX_FILE=$index_file" "$@" + fi exec docker run --rm "$@" fi diff --git a/.github/workflows/ci.yml b/.github/workflows/checks.yml similarity index 56% rename from .github/workflows/ci.yml rename to .github/workflows/checks.yml index 6f0056d..c4ecd61 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/checks.yml @@ -1,19 +1,13 @@ -name: CI +name: Checks +# Every check, defined once. `feature.yml` (PRs) and `main.yml` (push to main) +# call it; a called workflow cannot raise the caller's token, so each caller job +# grants the permissions the jobs below ask for. on: - pull_request: - branches: [main] - push: - branches: [main] + workflow_call: permissions: {} -concurrency: - group: ${{ github.workflow }}-${{ github.ref }} - # PR runs are superseded by a newer push. A *running* main run (which may later - # deploy) is never cancelled; a *pending* one is still replaced by a newer push. - cancel-in-progress: ${{ github.event_name == 'pull_request' }} - jobs: check: name: ${{ matrix.task }} @@ -32,8 +26,10 @@ jobs: persist-credentials: false fetch-depth: 1 + # zizmor prefers the `$/` self-repository syntax, but actionlint 1.7.12 + # rejects it; switch once actionlint accepts `$/`. - name: Setup - uses: ./.github/actions/setup + uses: ./.github/actions/setup # zizmor: ignore[self-repository] # Turbo's local cache, restored from the latest run of the same task so an # unchanged package replays instead of rerunning. Only typecheck, test and @@ -81,9 +77,8 @@ jobs: strategy: fail-fast: false matrix: - # `actions` scans the workflow files themselves (injection, unpinned - # third-party actions, over-broad permissions). - language: [javascript-typescript, actions] + # The workflow files are checked by the `workflow-lint` job instead. + language: [javascript-typescript] steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: @@ -91,7 +86,7 @@ jobs: fetch-depth: 1 # Advanced setup: turn OFF "default setup" under Settings → Code security, - # or GitHub rejects these uploads. Neither language needs a build step. + # or GitHub rejects these uploads. No build step is needed. - name: Initialize CodeQL uses: github/codeql-action/init@24c54180a607b1449ed407dd24f251e4e9147c8d # v4.38.3 with: @@ -104,47 +99,9 @@ jobs: with: category: "/language:${{ matrix.language }}" - # The one check to require in branch protection. `if: always()` makes it run - # even when a needed job failed, so it reports a failure instead of skipping. - all-passed: - name: all-passed - runs-on: ubuntu-latest - timeout-minutes: 1 - if: always() - needs: [check, secrets-scan, codeql] - permissions: {} - steps: - - name: Check results - env: - RESULTS: ${{ join(needs.*.result, ' ') }} - run: | - for r in $RESULTS; do - [ "$r" = success ] || { echo "a required job ended with: $r"; exit 1; } - done - - # Example deploy job. The `if:` only skips PR runs; the real security boundary - # is the `production` environment's deployment-branch rule (main only), set - # under Settings → Environments. Hand build output over from `build` with - # actions/upload-artifact there and actions/download-artifact here. - # - # deploy: - # name: deploy - # runs-on: ubuntu-latest - # timeout-minutes: 10 - # needs: all-passed - # if: github.event_name == 'push' && github.ref == 'refs/heads/main' - # environment: production - # permissions: - # contents: read - # id-token: write - # steps: - # - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - # with: - # persist-credentials: false - # fetch-depth: 1 - # - # - name: Setup - # uses: ./.github/actions/setup - # - # - name: Deploy - # run: echo "deploy here" + # actionlint (with shellcheck on `run:` scripts) and zizmor over the workflow + # and action files. + workflow-lint: + permissions: + contents: read + uses: valuecodes/automation/.github/workflows/workflow-lint.yml@c207ce5866c0f7893ecc481f596edd7fe7cfae62 # v0.4.0 diff --git a/.github/workflows/feature.yml b/.github/workflows/feature.yml new file mode 100644 index 0000000..1a1cacc --- /dev/null +++ b/.github/workflows/feature.yml @@ -0,0 +1,36 @@ +name: Feature branch CI + +on: + pull_request: + branches: [main] + +permissions: {} + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + # A newer push to the PR supersedes the running checks. + cancel-in-progress: true + +jobs: + checks: + permissions: + actions: read + contents: read + security-events: write + uses: ./.github/workflows/checks.yml # zizmor: ignore[self-repository] + + # The one check to require in branch protection. `if: always()` makes it run + # even when a check failed, so it reports a failure instead of skipping. + all-passed: + name: all-passed + runs-on: ubuntu-latest + timeout-minutes: 1 + if: always() + needs: [checks] + permissions: {} + steps: + - name: Check results + env: + RESULT: ${{ needs.checks.result }} + run: | + [ "$RESULT" = success ] || { echo "checks ended with: $RESULT"; exit 1; } diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml new file mode 100644 index 0000000..3a89dcf --- /dev/null +++ b/.github/workflows/main.yml @@ -0,0 +1,45 @@ +name: Main branch CI + +on: + push: + branches: [main] + +permissions: {} + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + # A *running* main run (which may be deploying) is never cancelled; a + # *pending* one is still replaced by a newer push. + cancel-in-progress: false + +jobs: + checks: + permissions: + actions: read + contents: read + security-events: write + uses: ./.github/workflows/checks.yml # zizmor: ignore[self-repository] + + # Example deploy job, run once every check passed. The real security boundary + # is the `production` environment's deployment-branch rule (main only), set + # under Settings → Environments. + # + # deploy: + # name: deploy + # runs-on: ubuntu-latest + # timeout-minutes: 10 + # needs: [checks] + # environment: production + # permissions: + # contents: read + # steps: + # - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + # with: + # persist-credentials: false + # fetch-depth: 1 + # + # - name: Setup + # uses: ./.github/actions/setup # zizmor: ignore[self-repository] + # + # - name: Deploy + # run: echo "deploy here" diff --git a/.github/workflows/maintenance-dashboard.yml b/.github/workflows/maintenance-dashboard.yml index 79b6304..2e87ab8 100644 --- a/.github/workflows/maintenance-dashboard.yml +++ b/.github/workflows/maintenance-dashboard.yml @@ -2,8 +2,7 @@ name: Maintenance dashboard on: schedule: - # Daily, after the Node.js update run. An off-peak minute avoids the - # top-of-the-hour queue. + # Daily. An off-peak minute avoids the top-of-the-hour queue. - cron: "41 5 * * *" workflow_dispatch: @@ -17,4 +16,4 @@ jobs: permissions: contents: read issues: write - uses: valuecodes/automation/.github/workflows/maintenance-dashboard.yml@53132eebb05a206f7ecf60ab0add5dcf5470f4b6 # v0.2.2 + uses: valuecodes/automation/.github/workflows/maintenance-dashboard.yml@c207ce5866c0f7893ecc481f596edd7fe7cfae62 # v0.4.0 diff --git a/.github/workflows/node-update.yml b/.github/workflows/node-update.yml deleted file mode 100644 index 4ce218f..0000000 --- a/.github/workflows/node-update.yml +++ /dev/null @@ -1,19 +0,0 @@ -name: Node.js update - -on: - schedule: - # Daily, so a security release gets its PR within a day. An off-peak minute - # avoids the top-of-the-hour queue. - - cron: "17 5 * * *" - workflow_dispatch: - -permissions: {} - -jobs: - # Opens a PR bumping `.nvmrc` when a newer Node.js release is out on its major. - # A called workflow cannot raise the caller's token, so the job grants it here. - node-update: - permissions: - contents: write - pull-requests: write - uses: valuecodes/automation/.github/workflows/node-update.yml@53132eebb05a206f7ecf60ab0add5dcf5470f4b6 # v0.2.2 diff --git a/AGENTS.md b/AGENTS.md index b9e8e96..6346648 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -66,40 +66,49 @@ To add a package, copy an existing one's `package.json`, `tsconfig.json` and `src/.ts` directly through `exports` (no barrel `index.ts`), run `pnpm install` and add its row to the Packages table above. -There is no post-edit formatting hook: run `pnpm format` yourself before committing. +Nothing formats on edit, and the pre-commit hook only checks formatting: run +`pnpm format` yourself before committing. + +Git hooks live in `.githooks/` and are plain shell scripts, with no hook manager. +`pnpm install` enables them through the root `prepare` script. The setting goes in +the clone's shared git config, so it covers every worktree, and a branch without +`.githooks/` simply runs no hooks. If you cloned before the hooks existed and have +not run `pnpm install` since, run `git config core.hooksPath .githooks` once. `pre-commit` runs `format:check`, `lint` and `secrets:scan --staged`. The +secrets scan fails closed, so the commit is refused when neither gitleaks nor +Docker is available. `pre-push` runs `typecheck`, `test` and `knip`. CI still +runs every check. `format:check` and `lint` read the working tree, not the staged +snapshot, so stage everything you mean to commit. `secrets:scan` runs gitleaks (`.github/scripts/secrets-scan.sh`, version pinned there) using a local `gitleaks` v8.19+ if one is on PATH, otherwise the pinned Docker image. It exits 0 when clean and 1 when it finds a leak. -CI (`.github/workflows/ci.yml`) runs typecheck, lint, knip, format:check, test, build, -secrets-scan and CodeQL code scanning (`javascript-typescript` and `actions`) on PRs and -on push to `main`. `all-passed` fans those in and is the one check to require in branch -protection. The typecheck, test and build jobs restore Turbo's `.turbo` cache from the -previous run of the same task, so unchanged packages replay instead of rerunning. +CI checks are defined once in the reusable `.github/workflows/checks.yml`: typecheck, +lint, knip, format:check, test, build, secrets-scan, CodeQL code scanning +(`javascript-typescript`) and workflow-lint. `feature.yml` calls it on PRs and +`main.yml` on push to `main` (deploy jobs go there). workflow-lint calls the reusable +workflow from `valuecodes/automation`: actionlint (with shellcheck) and zizmor over the +workflow and action files. Each tool reads `.github/actionlint.yaml` / `zizmor.yml` if +added. `all-passed` in `feature.yml` fans the checks in and is the one check to require +in branch protection. The typecheck, test and build +jobs restore Turbo's `.turbo` cache from the previous run of the same task, so +unchanged packages replay instead of rerunning. Dependabot (`.github/dependabot.yml`) bumps the SHA-pinned actions and reusable workflows monthly in one grouped PR; npm dependencies are updated by hand. -`node-update.yml` calls the reusable workflow from `valuecodes/automation` (pinned to a -commit SHA) daily: when a newer Node.js release is out on the `.nvmrc` major, it opens a -`chore: bump Node.js to …` PR. Security releases open one right away; others wait 14 -days. Its PRs start CI only after someone clicks **Approve workflows to run** (if no -runs show up, close and reopen the PR). Keep the exact Node version only in `.nvmrc`, -so that PR is the whole bump; the Cloudflare `NODE_VERSION` build variable of the -`apps/web` deploy is the one pin to update by hand, before the PR merges. A newer -release closes the older PR and deletes its branch, so push any follow-up commits just -before merging. - `maintenance-dashboard.yml` calls the maintenance dashboard workflow from `valuecodes/automation` daily. It keeps one issue, **Maintenance dashboard** (label `maintenance-dashboard`), listing what is due: `catalog:` updates, pnpm (`packageManager`), gitleaks (`GITLEAKS_VERSION` in the secrets-scan script), Node.js (`.nvmrc`) and `pnpm audit` findings. Like -`minimumReleaseAge`, a release shows up only once it is 14 days old. It never opens -PRs or commits; doing the updates stays manual. If `GITLEAKS_VERSION` moves out of -`.github/scripts/secrets-scan.sh`, pass the new path as the `gitleaks-script` input. -Both callers pin the same `valuecodes/automation` commit, so bump them together. +`minimumReleaseAge`, a release shows up only once it is 14 days old; a Node.js security +release shows up right away and is flagged. It never opens PRs or commits; doing the +updates stays manual. For a Node.js bump, change only `.nvmrc` (the one place that +holds the exact version) and the Cloudflare `NODE_VERSION` build variable of the +`apps/web` deploy. If `GITLEAKS_VERSION` moves out of `.github/scripts/secrets-scan.sh`, +pass the new path as the `gitleaks-script` input. The dashboard and workflow-lint +callers pin the same `valuecodes/automation` commit, so bump them together. --- @@ -107,6 +116,7 @@ Both callers pin the same `valuecodes/automation` commit, so bump them together. - Keep diffs tight and focused; no drive-by refactors or new tooling without discussion. - Never commit secrets, credentials, or `.env` files. All code must be public-safe. +- Never bypass the git hooks with `--no-verify`; fix the failure instead. - Add dependencies to the correct workspace with `pnpm --filter add `. Every external version lives in the `catalog:` block of `pnpm-workspace.yaml` and manifests reference it as `catalog:`; `@repo/*` deps stay `workspace:*`. diff --git a/README.md b/README.md index ded737d..a5781e5 100644 --- a/README.md +++ b/README.md @@ -76,6 +76,10 @@ pnpm dev Then open http://localhost:3000. +`pnpm install` also turns on the git hooks: format, lint and a staged secrets scan +before each commit; typecheck, tests and Knip before each push. Committing needs +`gitleaks` v8.19+ on PATH or a running Docker daemon for the staged secrets scan. + ## How it works Parsing and building run in the browser tab. Code decides which rooms and doors exist. @@ -134,7 +138,9 @@ pnpm secrets:scan # gitleaks secret scan over git history oxlint prints nothing when there are no findings, so silent output means clean. CI runs typecheck, lint, knip, format:check, test, build and a gitleaks secret scan on -PRs and on push to `main`, plus CodeQL code scanning. The `all-passed` job fans them in: +PRs and on push to `main`, plus CodeQL code scanning and actionlint + zizmor over the +workflow files. The checks live once in `checks.yml`, called by `feature.yml` (PRs) and +`main.yml` (push to `main`). The `all-passed` job fans them in: require only that check in branch protection. `secrets:scan` needs either `gitleaks` v8.19+ on PATH or a running Docker daemon. @@ -143,14 +149,11 @@ The repository settings this CI expects: 1. **Rules → Rulesets**: the ruleset for `main` requires a pull request and the `all-passed` status check, which fans in every CI job. 2. **Code security → Code scanning**: _default setup_ is **off**. CodeQL runs from - `ci.yml`, and GitHub rejects its uploads while default setup is on. + `checks.yml`, and GitHub rejects its uploads while default setup is on. 3. **Code security → Dependabot**: **alerts** on, **security updates** off. `.github/dependabot.yml` only bumps the SHA-pinned GitHub Actions monthly; npm dependencies are updated by hand. -4. **Actions → General**: _Allow GitHub Actions to create and approve pull requests_ - is on, so the daily `Node.js update` workflow can open a PR when a newer Node.js - release is out on the `.nvmrc` major. -5. **Issues**: the `Maintenance dashboard` workflow keeps one pinned issue listing the +4. **Issues**: the `Maintenance dashboard` workflow keeps one pinned issue listing the updates that are due. Run it once by hand (**Actions → Maintenance dashboard → Run workflow**) to create it. diff --git a/package.json b/package.json index 41e010f..582ec78 100644 --- a/package.json +++ b/package.json @@ -17,7 +17,8 @@ "format:check": "oxfmt --check", "knip": "knip", "secrets:scan": "sh .github/scripts/secrets-scan.sh", - "clean": "turbo run clean" + "clean": "turbo run clean", + "prepare": "git rev-parse --git-dir >/dev/null 2>&1 && git config --local core.hooksPath .githooks || true" }, "devDependencies": { "@types/node": "catalog:",