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
7 changes: 7 additions & 0 deletions .githooks/commit-msg
Original file line number Diff line number Diff line change
Expand Up @@ -20,4 +20,11 @@ if grep -qiE '^[^[:alnum:]]*Generated with' "$msg_file"; then
exit 1
fi

# Reject session trailers and links that coding assistants append, such as
# "Claude-Session: https://claude.ai/code/session_...".
if grep -qiE '^[[:space:]]*Claude-Session:|claude\.ai/code/session' "$msg_file"; then
echo "commit-msg: rejected -- a coding-assistant session line is not allowed in this repo." >&2
exit 1
fi

exit 0
109 changes: 109 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
# CI for every pull request and every push to main (spec section 14).
# A push to a PR branch already runs through pull_request, so pushes to other
# branches are left out to save Actions minutes.
name: ci

on:
pull_request:
push:
branches: [main]

permissions: {}

concurrency:
group: ci-${{ github.ref }}
cancel-in-progress: ${{ github.event_name == 'pull_request' }}

env:
# CI tool, not a Go dependency: installed with go install at this version,
# which verifies every module against the Go checksum database.
GOLANGCI_LINT_VERSION: v2.15.0

jobs:
checks:
name: ci-checks
runs-on: ubuntu-24.04
timeout-minutes: 15
permissions:
contents: read
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0
with:
go-version-file: go.mod
- uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0
with:
node-version-file: web/.nvmrc
cache: npm
cache-dependency-path: web/package-lock.json

- name: gofmt
run: |
out="$(gofmt -l .)"
if [ -n "$out" ]; then echo "not gofmt-formatted:"; echo "$out"; exit 1; fi
- name: frontend type check and build
run: scripts/build-web.sh
- name: go vet
run: |
go vet ./...
go vet -tags webdist ./...
- name: golangci-lint
run: |
go install "github.com/golangci/golangci-lint/v2/cmd/golangci-lint@${GOLANGCI_LINT_VERSION}"
golangci-lint run ./...
golangci-lint run --build-tags webdist ./...
- name: shellcheck
run: |
shellcheck --version
shellcheck scripts/*.sh .githooks/commit-msg exercises/*/*.sh
- name: commit-msg hook cases
run: bash scripts/test-commit-msg.sh
- name: i18n key parity
run: |
go test -count=1 -run 'TestLocaleParity' ./internal/i18n/
for d in exercises/*/; do go run ./cmd/rootcause validate "$d"; done

test:
name: ci-test
runs-on: ubuntu-24.04 # cgroup v2, which the init: systemd exercises need
timeout-minutes: 30
permissions:
contents: read
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
fetch-depth: 0 # origin/main, for the no-test-files check
persist-credentials: false
- uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0
with:
go-version-file: go.mod
- uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0
with:
node-version-file: web/.nvmrc
cache: npm
cache-dependency-path: web/package-lock.json

- name: build the dashboard for -tags webdist
run: scripts/build-web.sh
# Before any container starts: the restart switches Docker's storage.
- name: enable Docker userns-remap
run: sudo scripts/ci-enable-userns.sh
- name: start Postgres with rootcause init
run: |
pw="$(openssl rand -hex 24)"
echo "::add-mask::$pw"
printf 'POSTGRES_USER=rootcause\nPOSTGRES_DB=rootcause\nPOSTGRES_PASSWORD=%s\n' "$pw" > deploy/compose/.env
go run ./cmd/rootcause init
docker exec rootcause-postgres-1 createdb -U rootcause rootcause_test
echo "RC_TEST_DATABASE_URL=postgres://rootcause:${pw}@127.0.0.1:5432/rootcause_test?sslmode=disable" >> "$GITHUB_ENV"
- name: go test (fails on any skip)
env:
RC_REQUIRE_DOCKER: "1"
run: >-
scripts/ci-go-test.sh
--require TestScriptedSelection
--require TestBuildRecoversFromKilledEngine
--require TestPlaySvc01
-tags webdist ./...
32 changes: 32 additions & 0 deletions .github/workflows/commit-policy.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
# Commit policy (spec sections 14 and 15): every commit in a pull request has
# no Co-authored-by trailer or tool attribution line, the owner's author and
# committer email, and a signature GitHub verifies.
name: commit-policy

on:
pull_request:

permissions: {}

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

jobs:
commit-policy:
name: commit-policy
runs-on: ubuntu-24.04
timeout-minutes: 5
permissions:
contents: read # git history, and the commits API for signature status
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
fetch-depth: 0
persist-credentials: false
- name: check every commit in the pull request
env:
GH_TOKEN: ${{ github.token }}
BASE: ${{ github.event.pull_request.base.sha }}
HEAD: ${{ github.event.pull_request.head.sha }}
run: scripts/check-commit-policy.sh --signatures api "$BASE" "$HEAD"
93 changes: 93 additions & 0 deletions .github/workflows/dbt.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
# dbt build against a Postgres service container with seed data (spec
# section 14). The seed data is TestScriptedSelection's series of sessions,
# played through the real engine; the dbt package tests add a killed-engine
# recovery and a failed build that keeps its log.
#
# The dbt container reaches the service container over the job's docker
# network by its service name, set through RC_DBT_NETWORK and RC_DBT_HOST
# (tests and CI only; the engine itself uses the compose Postgres).
#
# The workflow always runs and checks the changed paths itself, so it can
# become a required check (see exercises.yml).
name: dbt

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

permissions: {}

concurrency:
group: dbt-${{ github.ref }}
cancel-in-progress: ${{ github.event_name == 'pull_request' }}

jobs:
build:
name: dbt
runs-on: ubuntu-24.04
timeout-minutes: 20
permissions:
contents: read
services:
postgres:
image: postgres:17
env:
POSTGRES_USER: rootcause
POSTGRES_DB: rootcause_test
# A throwaway database that lives only for this job.
POSTGRES_PASSWORD: ci-only-throwaway
ports:
- 127.0.0.1:5432:5432
options: >-
--health-cmd "pg_isready -U rootcause -d rootcause_test"
--health-interval 2s --health-timeout 3s --health-retries 30
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
fetch-depth: 0
persist-credentials: false

- name: did the dbt project or its inputs change?
id: changed
env:
EVENT: ${{ github.event_name }}
PR_BASE: ${{ github.event.pull_request.base.sha }}
PR_HEAD: ${{ github.event.pull_request.head.sha }}
PUSH_BEFORE: ${{ github.event.before }}
PUSH_AFTER: ${{ github.sha }}
run: |
case "$EVENT" in
workflow_dispatch) run=true ;;
pull_request) range="$PR_BASE $PR_HEAD" ;;
*) if [ -z "$PUSH_BEFORE" ] || [ "$PUSH_BEFORE" = 0000000000000000000000000000000000000000 ]; then run=true; else range="$PUSH_BEFORE $PUSH_AFTER"; fi ;;
esac
if [ -z "${run:-}" ]; then
# shellcheck disable=SC2086 # two commits, split on purpose
if git diff --name-only $range | grep -qE '^(dbt/|internal/dbt/|internal/store/migrations/|internal/selector/|internal/testenv/|\.github/workflows/dbt\.yml$)'; then
run=true
else
run=false
fi
fi
echo "run dbt: $run"
echo "run=$run" >> "$GITHUB_OUTPUT"

- uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0
if: steps.changed.outputs.run == 'true'
with:
go-version-file: go.mod
- name: dbt build with seed data (fails on any skip)
if: steps.changed.outputs.run == 'true'
env:
RC_TEST_DATABASE_URL: postgres://rootcause:ci-only-throwaway@127.0.0.1:5432/rootcause_test?sslmode=disable
RC_DBT_NETWORK: ${{ job.services.postgres.network }}
RC_DBT_HOST: postgres
RC_REQUIRE_DOCKER: "1"
run: >-
scripts/ci-go-test.sh
--require TestScriptedSelection
--require TestBuildRecoversFromKilledEngine
--require TestBuildFailureKeepsLog
./internal/dbt/ ./internal/selector/
78 changes: 78 additions & 0 deletions .github/workflows/exercises.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
# Exercise validation (spec section 14): for each changed exercise and seeds
# 1, 2 and 3, build the image; break then check must fail; break, solution,
# check must pass. Pull requests and pushes to main validate only the changed
# exercises; tags and manual runs validate all of them.
#
# The workflow always runs and finds the changed exercises itself, instead of
# using a paths filter, so it can become a required check: a filtered
# workflow that never starts leaves a required check pending forever.
name: exercises

on:
pull_request:
push:
branches: [main]
tags: ["v*"]
workflow_dispatch:

permissions: {}

concurrency:
group: exercises-${{ github.ref }}
cancel-in-progress: ${{ github.event_name == 'pull_request' }}

jobs:
validate:
name: exercises
runs-on: ubuntu-24.04 # cgroup v2, which the init: systemd exercises need
timeout-minutes: 60
permissions:
contents: read
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
fetch-depth: 0
persist-credentials: false

- name: find the exercises to validate
id: list
env:
EVENT: ${{ github.event_name }}
REF_TYPE: ${{ github.ref_type }}
PR_BASE: ${{ github.event.pull_request.base.sha }}
PR_HEAD: ${{ github.event.pull_request.head.sha }}
PUSH_BEFORE: ${{ github.event.before }}
PUSH_AFTER: ${{ github.sha }}
run: |
if [ "$EVENT" = workflow_dispatch ] || [ "$REF_TYPE" = tag ]; then
ids="$(scripts/ci-changed-exercises.sh all)"
elif [ "$EVENT" = pull_request ]; then
ids="$(scripts/ci-changed-exercises.sh "$PR_BASE" "$PR_HEAD")"
elif [ -z "$PUSH_BEFORE" ] || [ "$PUSH_BEFORE" = 0000000000000000000000000000000000000000 ]; then
ids="$(scripts/ci-changed-exercises.sh all)"
else
ids="$(scripts/ci-changed-exercises.sh "$PUSH_BEFORE" "$PUSH_AFTER")"
fi
echo "exercises to validate: ${ids:-none}"
# One line, space-separated; empty when nothing changed.
echo "ids=$(tr '\n' ' ' <<<"$ids" | xargs)" >> "$GITHUB_OUTPUT"

- uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0
if: steps.list.outputs.ids != ''
with:
go-version-file: go.mod
- name: enable Docker userns-remap
if: steps.list.outputs.ids != ''
run: sudo scripts/ci-enable-userns.sh
- name: validate, seeds 1 2 3
if: steps.list.outputs.ids != ''
env:
IDS: ${{ steps.list.outputs.ids }}
run: |
for id in $IDS; do
for seed in 1 2 3; do
echo "::group::$id seed $seed"
scripts/validate-exercise.sh "$id" "$seed"
echo "::endgroup::"
done
done
14 changes: 14 additions & 0 deletions .golangci.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
# golangci-lint configuration. CI installs the version pinned in
# .github/workflows/ci.yml.
version: "2"

linters:
default: standard
exclusions:
# Unchecked errors from Close, Flush, os.Remove and fmt printing, the
# standard idioms whose errors carry nothing to act on.
presets: [std-error-handling]
rules:
# A deferred Rollback after Commit returns pgx.ErrTxClosed by design.
- linters: [errcheck]
source: 'defer tx\.Rollback\('
41 changes: 41 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,47 @@ The server listens on 127.0.0.1 only. WSL2 forwards `localhost` from Windows to

Exercises that boot systemd (such as svc-02) need a host with cgroup v2. WSL2 uses cgroup v2 by default. Check with `docker info --format '{{.CgroupVersion}}'`, which must print `2`.

## Tests

Unit tests need nothing: `go test ./...`. Integration tests need a database whose name ends in `_test`, and Docker with userns-remap:

```bash
docker exec rootcause-postgres-1 createdb -U rootcause rootcause_test
export RC_TEST_DATABASE_URL="postgres://rootcause:<password>@127.0.0.1:5432/rootcause_test?sslmode=disable"
go test -count=1 ./...
```

- A test skips only when `RC_TEST_DATABASE_URL` is unset, or when Docker is missing. If the variable is set but the database is unreachable or not a `_test` database, the test fails.
- `RC_REQUIRE_DOCKER=1` makes a missing Docker fail the tests too. CI sets it.
- Tests that run dbt create and drop their own database (for example `rootcause_selector_test`), because a dbt build reads every row in its database.
- `RC_DBT_STRESS=1 go test -run TestBuildStress ./internal/dbt/` kills dbt builds at random points and checks that the next build succeeds. `RC_DBT_STRESS_ROUNDS` sets the rounds (default 10).
- A failed dbt build saves its full `dbt.log` under `~/.cache/rootcause/dbt-logs/` and prints the path. The newest 20 are kept.

## CI

GitHub Actions runs four workflows (spec section 14). Every action is pinned by commit SHA, and every job gets read-only access to the repository and nothing else.

| Workflow | Check name | Does |
|---|---|---|
| `ci.yml` | `ci-checks`, `ci-test` | gofmt, go vet, golangci-lint, shellcheck, i18n parity, the dashboard build, and every Go test with Postgres and Docker. Fails if any test is skipped |
| `exercises.yml` | `exercises` | Validates the changed exercises with seeds 1, 2 and 3. All of them on tags `v*` and on manual runs |
| `dbt.yml` | `dbt` | dbt build against a Postgres service container, when the dbt project or its inputs changed |
| `commit-policy.yml` | `commit-policy` | Rejects Co-authored-by trailers, tool attribution lines, author emails other than the owner's, and commits GitHub does not show as Verified |

Run the same commit check locally with `scripts/check-commit-policy.sh origin/main HEAD`.

### When the repo goes public

On the free plan, branch protection does not apply to private repositories, so a red check does not block the merge button. The rule until then: never merge a PR with a red check. When the repo goes public, add a ruleset under Settings, Rules, Rulesets:

- Target: the default branch (`main`). Enforcement: Active. No bypass list.
- Restrict deletions, and block force pushes.
- Require a pull request before merging, with merge commits only (squash and rebase drop the SSH signatures).
- Require signed commits.
- Require status checks to pass, with the branch up to date: `ci-checks`, `ci-test`, `exercises`, `dbt`, `commit-policy`. Every workflow starts on every PR and decides inside the job what to run, so none of these checks stays pending.

Known gap to close when the ruleset goes on: the workflows, `scripts/check-commit-policy.sh` and `.githooks/commit-msg` run from the PR's own commits, so a PR could weaken the checks that judge it. Before the repo goes public, make `commit-policy.yml` check out the base branch's copy of the script and hook, and require review of changes under `.github/`, `scripts/` and `.githooks/`.

## License

PolyForm Noncommercial 1.0.0. See [`LICENSE`](LICENSE).
Loading