Skip to content

docs(roadmap): turn Phase 02b from a diagnosis into a gated plan #118

docs(roadmap): turn Phase 02b from a diagnosis into a gated plan

docs(roadmap): turn Phase 02b from a diagnosis into a gated plan #118

Workflow file for this run

# LearnStack — CI baseline.
#
# Per docs/roadmap/phase-01-repository-tooling.md § CI Baseline. Runs on
# every push to `main` and every pull request. Required status checks on
# `main` are listed in `.github/CONTRIBUTING.md` § Branch protection so
# the GitHub Settings → Branches page matches the corpus.
#
# What lights up in Phase 01:
# - backend : `dotnet build` + unit + architecture + contract tests, and
# from Phase 02a Packet 4 the WebApplicationFactory HTTP tests
# in LearnStack.Tests.Integration, which need no Docker
# - frontend : pnpm install + typecheck + lint + build + Vitest
# - meta : `make lint`-style format verification
#
# Active since Phase 02a Packet 6:
# - backend-integration : Testcontainers needs a real Docker socket inside the
# runner, which `ubuntu-latest` has natively. Brought forward from Packet 7,
# because Packet 6 ships the first Docker-bound test — the four-role
# provisioning suite — and is therefore the packet that has to split them.
# The HTTP tests in LearnStack.Tests.Integration stay in the `backend` job:
# they use WebApplicationFactory and need no Docker. The split is by
# [Trait("Requires","Docker")] and the two jobs' filters are exact
# complements, so every test runs in exactly one of them. Making it a
# REQUIRED check is a repository setting — see .github/CONTRIBUTING.md
# § Branch protection.
#
# Still deferred to later phases. Each is scaffolded behind a repository variable
# (`vars.ENABLE_*`), unset by default, so activation is one variable plus the
# real steps. A constant `if: false` would be simpler but actionlint rejects it
# ([if-cond] constant expression). Activation is never *only* the variable —
# see .github/CONTRIBUTING.md § Branch protection for the three edits:
# - openapi-diff : oasdiff against the prior `main` spec. Activates
# in Phase 02d, which ships the first real `/api/v1/*` read endpoints and
# retires `/healthz` as the only documented surface.
# - lighthouse-budget : LHCI against the built Next.js app. Activates
# in Phase 02d, which ships the first content-bearing public pages (the
# two tenant catalog / lesson pages a visitor actually loads).
name: ci
on:
push:
branches: [main]
pull_request:
branches: [main]
workflow_dispatch:
# Cancel in-progress runs on the same ref so PR force-pushes don't queue.
concurrency:
group: ci-${{ github.workflow }}-${{ github.ref }}
# Cancel stale PR builds on force-push; KEEP main builds running so
# back-to-back merges don't lose the signal from the older one.
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
permissions:
contents: read
env:
# No DOTNET_SDK_VERSION: both .NET jobs install the SDK backend/global.json pins, so a
# workstation, this workflow and a release build cannot disagree about it. It moved there
# when a security bump landed here alone and left every local build on the runtime the
# advisories were about (Packet 9's fifth review).
NODE_VERSION: "20.11.0"
PNPM_VERSION: "9.12.3"
DOTNET_NOLOGO: "true"
DOTNET_CLI_TELEMETRY_OPTOUT: "true"
DOTNET_SKIP_FIRST_TIME_EXPERIENCE: "true"
jobs:
# ─── Backend ────────────────────────────────────────────────────────────
backend:
name: backend (build + unit + arch + contract)
runs-on: ubuntu-latest
timeout-minutes: 20
steps:
- name: Checkout
uses: actions/checkout@v4
with:
persist-credentials: false
# The one SDK pin is backend/global.json — 10.0.112, whose runtime carries the .NET
# security fixes 10.0.0 did not (GHSA-cvvh-rhrc-wg4q among them). The path is the
# backend's own; there is no repo-root global.json to point at.
- name: Set up .NET SDK
uses: actions/setup-dotnet@v4
with:
global-json-file: backend/global.json
- name: Cache NuGet packages
uses: actions/cache@v4
with:
path: ~/.nuget/packages
key: nuget-${{ runner.os }}-${{ hashFiles('backend/**/*.csproj', 'backend/Directory.Packages.props') }}
restore-keys: |
nuget-${{ runner.os }}-
- name: Restore
working-directory: backend
# CI=true here too, not only on the build: NuGet's audit reports at RESTORE, and
# TreatWarningsAsErrors is what makes an advisory of any severity — transitive ones
# included (Directory.Build.props) — fail the job. A build with --no-restore only
# replays those findings as warnings. The runner sets CI=true on every step
# already; it is written down because the gate depends on it.
env:
CI: "true"
run: dotnet restore LearnStack.slnx
- name: Format verify (dotnet format)
working-directory: backend
run: dotnet format LearnStack.slnx --verify-no-changes --no-restore
- name: Build (TreatWarningsAsErrors)
working-directory: backend
env:
CI: "true"
run: dotnet build LearnStack.slnx --no-restore --configuration Release
# The filter EXCLUDES a set, it does not name one. LearnStack.Tests.Integration
# holds WebApplicationFactory tests that need no Docker and carry rules the
# structural tests cannot — no-op'ing VersionedRouteConvention leaves every
# architecture test green and turns most of that assembly red — so excluding
# the assembly wholesale once let a broken route convention ship green. What
# is excluded here is only what carries [Trait("Requires","Docker")], and
# backend-integration runs exactly that complement, so every test runs once.
# Phase 02a Packet 6 brought this forward from Packet 7: it ships the first
# Docker-bound test, so it is the packet that has to split them.
# This job's NAME is a required check and does not change.
- name: Test (unit + architecture + contract + HTTP integration)
working-directory: backend
run: |
dotnet test LearnStack.slnx \
--no-restore --no-build --configuration Release \
--filter "Requires!=Docker" \
--logger trx \
--results-directory ../artifacts/backend-tests
# A filter that matches nothing exits 0, so a renamed trait or a project that fell
# out of the solution would leave this job green having run nothing. All four
# assemblies carry tests that need no Docker, so each must have executed some.
- name: Refuse a test run that executed nothing
run: |
python3 scripts/assert-tests-ran.py artifacts/backend-tests \
LearnStack.Tests.Unit LearnStack.Tests.Architecture \
LearnStack.Tests.Contract LearnStack.Tests.Integration
# `--logger trx` without a LogFileName, deliberately. A fixed name makes all
# four projects write the SAME path in the SAME results directory: measured,
# one 874 KB file where four should be, so three assemblies' outcomes were
# silently overwritten and the artifact showed only the last to finish.
- name: Upload test results
if: always()
uses: actions/upload-artifact@v4
with:
name: backend-test-results
path: artifacts/backend-tests
if-no-files-found: warn
# ─── Backend integration (Testcontainers — active since Phase 02a Packet 6) ────
backend-integration:
name: backend integration (Testcontainers)
runs-on: ubuntu-latest
# ubuntu-latest carries a real Docker socket natively, which is why this job
# exists separately rather than the `backend` job growing a service container:
# Testcontainers manages its own lifecycle and needs the daemon, not a
# pre-declared service.
#
# The filter is the exact complement of the `backend` job's, so every test in
# the solution runs in exactly one of the two — including a test whose trait
# value is mistyped, which `Requires!=Docker` matches and which therefore runs
# in `backend`. That is NOT a loud failure: this runner and that one both carry
# a Docker socket, so the mis-traited test starts its container, passes, and
# the Docker suite quietly stops being where the Docker tests live. The value
# is a constant (LearnStack.Tests.Integration.Database.RequiresDocker) to make
# the typo impossible, and `Every_Database_Test_Carries_The_Docker_Trait`
# catches a class that forgets the attribute altogether.
timeout-minutes: 20
steps:
- name: Checkout
uses: actions/checkout@v4
with:
persist-credentials: false
# Same shape as the `backend` job: the SDK comes from backend/global.json, by that
# path — a repo-root path does not exist, and setup-dotnet pointed at one fails the
# job before a single test runs.
- name: Set up .NET SDK
uses: actions/setup-dotnet@v4
with:
global-json-file: backend/global.json
- name: Restore
working-directory: backend
# CI=true here too, not only on the build: NuGet's audit reports at RESTORE, and
# TreatWarningsAsErrors is what makes an advisory of any severity — transitive ones
# included (Directory.Build.props) — fail the job. A build with --no-restore only
# replays those findings as warnings. The runner sets CI=true on every step
# already; it is written down because the gate depends on it.
env:
CI: "true"
run: dotnet restore LearnStack.slnx
- name: Build
working-directory: backend
env:
CI: "true"
run: dotnet build LearnStack.slnx --no-restore --configuration Release
- name: Test (Testcontainers)
working-directory: backend
run: |
dotnet test LearnStack.slnx \
--no-restore --no-build --configuration Release \
--filter "Requires=Docker" \
--logger trx \
--results-directory ../artifacts/backend-integration
# The job this matters most for: `Requires=Docker` matching nothing — the trait
# constant renamed, the filter mistyped — exits 0 and leaves the whole Docker suite
# unrun behind a green check. Only the integration assembly carries the trait.
- name: Refuse a test run that executed nothing
run: python3 scripts/assert-tests-ran.py artifacts/backend-integration LearnStack.Tests.Integration
- name: Upload test results
if: always()
uses: actions/upload-artifact@v4
with:
name: backend-integration-results
path: artifacts/backend-integration
if-no-files-found: warn
# ─── Frontend ───────────────────────────────────────────────────────────
frontend:
name: frontend (typecheck + lint + build + test)
runs-on: ubuntu-latest
timeout-minutes: 20
defaults:
run:
working-directory: frontend
steps:
- name: Checkout
uses: actions/checkout@v4
with:
persist-credentials: false
- name: Set up Node.js
uses: actions/setup-node@v4
with:
node-version: ${{ env.NODE_VERSION }}
- name: Set up pnpm
uses: pnpm/action-setup@v4
with:
version: ${{ env.PNPM_VERSION }}
run_install: false
- name: Cache pnpm store
uses: actions/cache@v4
with:
path: ~/.local/share/pnpm/store
key: pnpm-${{ runner.os }}-${{ hashFiles('frontend/pnpm-lock.yaml') }}
restore-keys: |
pnpm-${{ runner.os }}-
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Typecheck
run: pnpm -r typecheck
- name: Lint
run: pnpm -r lint
- name: Build
run: pnpm -r build
- name: Test (Vitest)
run: pnpm -r test
# ─── OpenAPI breaking-change check (deferred — Phase 02d) ─────────────
openapi-diff:
name: openapi diff (deferred to Phase 02d)
runs-on: ubuntu-latest
if: vars.ENABLE_OPENAPI_DIFF == 'true'
steps:
- run: echo "Placeholder — Phase 02d wires oasdiff against the prior main spec."
# ─── Lighthouse budget (deferred — Phase 02d) ─────────────────────────
lighthouse-budget:
name: lighthouse budget (deferred to Phase 02d)
runs-on: ubuntu-latest
if: vars.ENABLE_LIGHTHOUSE_BUDGET == 'true'
steps:
- run: echo "Placeholder — Phase 02d wires LHCI against the built Next.js app."
# ─── Meta (commit-message format, link audit) ─────────────────────────
meta:
name: meta (compose + commit hygiene + link audit)
runs-on: ubuntu-latest
timeout-minutes: 5
steps:
- name: Checkout
uses: actions/checkout@v4
with:
fetch-depth: 0 # link audit walks the full tree
persist-credentials: false
- name: Compose stack validates, with and without the gated profile
run: |
# Nothing validated these files before, and the profile they now carry
# has one failure mode that is not local to the service involved:
# MEASURED, a DEFAULT service declaring `depends_on` a PROFILED one is
# not a warning but a whole-project error —
# service "x" depends on undefined service "y": invalid compose project
# — so `config`, `up`, `down` and `ps` all refuse to run and the entire
# local development loop stops, for every developer, on a one-line edit.
# The default projection is therefore the one that must be checked; the
# gated one is checked too, since a broken edge there is invisible to it.
cp .env.example .env
for profile in "" "gated"; do
label="${profile:-<default>}"
echo "==> validating profile: ${label}"
COMPOSE_PROFILES="$profile" docker compose --env-file .env \
-f infra/compose/dev.yml config -q
COMPOSE_PROFILES="$profile" docker compose --env-file .env \
-f infra/compose/dev.yml -f infra/compose/e2e.yml config -q
done
rm -f .env
echo "Compose files validate in every profile projection."
- name: Commit hygiene (Conventional Commits, subject <= 72)
# The job has advertised "commit hygiene" in its name since Phase 01 and
# ran no such check. Measured on the branch that added this step: two of
# its own subjects were over the limit, at 77 and 81 characters, and
# nothing had said so — the only hook on disk is `.githooks/pre-commit`,
# which cannot see a message.
#
# The verdict is the commit-msg hook's, run on each commit's stored message —
# not a second copy of its grammar. Two copies drifted: the types, the scope
# characters, and which text each one judged (the hook read the file's first
# line, this step read git's joined first paragraph), so subjects passed
# locally and failed here. `Commit_Subject_Grammar_Is_Stated_Once` fails the
# build if this step stops running the hook.
#
# Scoped to the pull request's own commits. On a push event there is no
# base to diff against, so the step reports and passes rather than
# re-judging history that is already merged.
if: github.event_name == 'pull_request'
env:
BASE_SHA: ${{ github.event.pull_request.base.sha }}
HEAD_SHA: ${{ github.event.pull_request.head.sha }}
run: |
set -euo pipefail
fail=0
# Materialized to a file FIRST, so the exit status is checked. Inside
# `< <(...)` a failing `git rev-list` cannot fail the step — `pipefail`
# does not reach a process substitution — so an unresolvable BASE_SHA
# would feed the loop nothing and the check would report success on a
# range it never read.
if ! git rev-list --no-merges --reverse \
"${BASE_SHA}..${HEAD_SHA}" > "${RUNNER_TEMP}/commits"; then
echo "::error::could not read ${BASE_SHA}..${HEAD_SHA}"
exit 1
fi
while IFS= read -r sha; do
git log -1 --format=%B "$sha" > "${RUNNER_TEMP}/message"
# GIT_EDITOR=: because a stored message is already clean, so a `#`
# line in it is text, as git itself treats it; STRICT refuses the
# autosquash markers the hook admits locally.
if ! verdict=$(GIT_EDITOR=: LEARNSTACK_COMMIT_MSG_STRICT=1 \
bash .githooks/commit-msg "${RUNNER_TEMP}/message" 2>&1); then
echo "::error::$(git log -1 --format=%h "$sha"): ${verdict//$'\n'/ | }"
fail=1
fi
done < "${RUNNER_TEMP}/commits"
if [ "$fail" -ne 0 ]; then
exit 1
fi
echo "Every commit subject is Conventional Commits and within 72 characters."
- name: Markdown link audit (changed docs)
# Template values from `github.event.*` are passed through `env:` so
# they expand into shell variables AT THE SHELL'S quoting boundary,
# never as raw substitution inside the `run:` block. This is the
# GHA-documented script-injection defense — even though `base.ref`
# and `before` are not user-controlled here, defense-in-depth keeps
# the pattern consistent across every step that consumes context.
env:
EVENT_NAME: ${{ github.event_name }}
PR_BASE_REF: ${{ github.event.pull_request.base.ref }}
PUSH_BEFORE_SHA: ${{ github.event.before }}
run: |
# Walk every relative-path link in the changed Markdown files of
# this PR. Externals (http(s):, mailto:) and pure-anchor links
# (#section) are skipped explicitly so the check stays narrow.
# Relative links are accepted in any shape the project uses:
# [x](./foo.md) — current-dir prefixed
# [x](../foo.md) — parent-dir prefixed
# [x](docs/foo.md) — bare repo-relative (CLAUDE.md convention)
# Anchors (#frag) are stripped before the file-existence check.
if [[ "$EVENT_NAME" == "pull_request" ]]; then
base="origin/${PR_BASE_REF}"
git fetch --no-tags --depth=1 origin "${PR_BASE_REF}"
else
base="${PUSH_BEFORE_SHA}"
fi
# Resolve the base before diffing against it. `|| true` on the diff
# made an unresolvable base indistinguishable from a clean one: the
# step printed "No changed Markdown files" and exited 0. Reachable on
# two of the three triggers — workflow_dispatch leaves
# github.event.before empty, and a force-push to main leaves it
# pointing at a commit no ref reaches — so a manually dispatched run
# audited nothing, silently, every time.
if ! git rev-parse --verify --quiet "${base}^{commit}" >/dev/null; then
echo "Base '${base}' does not resolve; auditing every tracked Markdown file."
base=""
fi
if [[ -n "$base" ]]; then
changed=$(git diff --name-only "$base"...HEAD -- '*.md')
else
changed=$(git ls-files -- '*.md')
fi
if [[ -z "$changed" ]]; then
echo "No changed Markdown files (base: ${base:-<full tree>})."
exit 0
fi
echo "Auditing $(printf '%s\n' "$changed" | wc -l | tr -d ' ') file(s) against base '${base:-<full tree>}'."
broken=0
while IFS= read -r f; do
# `]\(([^)#][^)]*)\)` — capture every non-anchor link target.
# Then filter out externals.
while IFS= read -r link; do
# Skip external schemes.
case "$link" in
http://*|https://*|mailto:*|tel:*|ftp://*) continue ;;
esac
# Strip anchor + query suffixes for the existence check.
link_path="${link%%#*}"
link_path="${link_path%%\?*}"
[[ -z "$link_path" ]] && continue
# Markdown resolves relative links against the source
# file's directory by default; the project ALSO uses
# repo-relative shapes (`docs/foo.md`) per CLAUDE.md
# § Cross-link. Try source-relative first, fall back to
# repo-relative — a link that resolves either way is ok.
source_relative="$(dirname "$f")/$link_path"
if [[ -e "$source_relative" || -e "$link_path" ]]; then
:
else
echo "BROKEN: $f → $link"
broken=$((broken + 1))
fi
# Fenced code blocks are stripped first. `](…)` inside a fence
# is sample text — no renderer linkifies it, so no target has to
# exist — and the audit flagged two of them: the ADR template's
# `NNNN-related.md` placeholders and ADR-0041's erratum shape.
# Working around it in the documents made both templates worse
# at being templates, which is the sign the check was the thing
# that was wrong.
done < <(awk '/^[[:space:]]*```/ { fence = !fence; next } !fence' "$f" \
| grep -oE '\]\(([^)#][^)]*)\)' \
| sed -E 's/^\]\((.+)\)$/\1/')
done <<< "$changed"
if [[ $broken -gt 0 ]]; then
echo "::error::$broken broken relative link(s) in changed Markdown."
exit 1
fi
- name: Accepted ADR disclosure
env:
EVENT_NAME: ${{ github.event_name }}
PR_BASE_REF: ${{ github.event.pull_request.base.ref }}
PUSH_BEFORE_SHA: ${{ github.event.before }}
run: |
# ADR-0041: an Accepted ADR's body changes only by inline erratum or
# bounded in-place replacement, and BOTH owe a dated Amendment **in
# that ADR's own file**. The class test is semantic and no check can
# make it; this makes the disclosure.
#
# Three filters, each load-bearing:
# - Status on the DIFF BASE, not on HEAD. An ADR introduced by this
# pull request has no accepted record to violate, and flipping a
# Proposed ADR to Accepted is a lifecycle event, not a correction.
# - The file must exist on the base at all.
# - Link retargeting is explicitly not a correction (ADR-0041
# § What is not a correction at all), so a diff that changes only
# link targets is skipped.
if [[ "$EVENT_NAME" == "pull_request" ]]; then
base="origin/${PR_BASE_REF}"
else
base="${PUSH_BEFORE_SHA}"
fi
if ! git rev-parse --verify --quiet "${base}^{commit}" >/dev/null; then
echo "Base '${base}' does not resolve; nothing to compare an ADR against."
exit 0
fi
undisclosed=0
while IFS= read -r f; do
[[ -n "$f" ]] || continue
# New in this PR, or deleted — no accepted record either way.
git cat-file -e "${base}:${f}" 2>/dev/null || continue
[[ -f "$f" ]] || continue
adr_status=$(git show "${base}:${f}" \
| awk '/^## Status/ { want = 1; next } want && NF { print; exit }')
# Prefix, not equality: three Accepted ADRs carry a prose Status
# line ("Accepted (Amendment 1: … )", "Accepted with two
# amendments — …"), and an equality test skipped every one of them.
[[ "$adr_status" == Accepted* ]] || continue
# Link-target-only edits owe nothing.
if diff -q \
<(git show "${base}:${f}" | sed -E 's/\]\([^)]*\)/](LINK)/g') \
<(sed -E 's/\]\([^)]*\)/](LINK)/g' "$f") >/dev/null; then
continue
fi
# A dated amendment heading, added by this diff, in this file.
if git diff "$base"...HEAD -- "$f" \
| grep -qE '^\+#{2,3} Amendment|^\+#{2,3} [0-9]{4}-[0-9]{2}-[0-9]{2}'; then
echo "ok: $f discloses its change"
else
echo "UNDISCLOSED: $f — an Accepted ADR changed with no dated Amendment in its own file."
undisclosed=$((undisclosed + 1))
fi
done < <(git diff --name-only "$base"...HEAD -- 'docs/decisions/*.md' \
| grep -vE '^docs/decisions/(README|template)\.md$')
if [[ $undisclosed -gt 0 ]]; then
echo "::error::$undisclosed Accepted ADR(s) changed without disclosure. See docs/standards/13-documentation.md § Correcting and Amending ADRs."
exit 1
fi
echo "Accepted ADR disclosure: clean."
- name: docs/analysis residual scan
run: |
# Per CLAUDE.md: docs/analysis/ is gitignored and MUST NOT be
# *referenced* from committed files. Distinguish:
# - illegal: `[text](docs/analysis/...)` Markdown link, OR any
# code-import shape that resolves to docs/analysis/:
# `from "..."`, `require("...")`, `import("...")`,
# `using docs.analysis.*;`
# - legal: any mention inside backticks (`docs/analysis/`),
# inline code, or prose about the rule itself
# Restrict to link / import shapes so meta-references in CLAUDE.md
# / standards / roadmap pass cleanly. Extension coverage matches
# what the codebase actually ships (no Python / Java code paths).
residual=$(grep -rnE \
'\]\(docs/analysis/|(from|import|require)[ (]["'"'"']docs/analysis/' \
--include='*.md' --include='*.cs' \
--include='*.ts' --include='*.tsx' \
--include='*.js' --include='*.jsx' \
--include='*.mjs' --include='*.cjs' \
. 2>/dev/null || true)
if [[ -n "$residual" ]]; then
echo "::error::Illegal references to docs/analysis/ (link target or import):"
echo "$residual"
exit 1
fi
# ─── Secret scan (Leakwatch; gates per Standards 12 § Secrets Management) ─
# Leakwatch is the project's chosen scanner — MIT licensed, verifier-
# equipped, hybrid Aho-Corasick + regex + entropy engine, YAML custom
# rules. Config lives at `.leakwatch.yaml` + `.leakwatchignore`.
#
# We install the CLI via `go install` (not the third-party action wrapper)
# so we control the version pin and the verification posture explicitly.
# `--no-verify` skips the live-API verifier because CI runners must stay
# hermetic — dev credentials are filtered out via entropy threshold +
# `.leakwatchignore`; production secrets never reach the repo.
secret-scan:
name: secret scan (leakwatch)
runs-on: ubuntu-latest
timeout-minutes: 5
steps:
- name: Checkout
uses: actions/checkout@v4
with:
fetch-depth: 0 # full history so push-event scans see prior commits
persist-credentials: false
- name: Set up Go
uses: actions/setup-go@v5
with:
go-version: "1.25"
- name: Install Leakwatch
# HodeTech/, not cemililik/: the module renamed its path at v1.6.0, so the
# old path is only installable at v1.5.0 and earlier — and v1.5.0 does not
# understand `leakwatch:ignore`. Measured: the pinned v1.5.0 reports seven
# CRITICAL findings on a tree the local 1.8.0 scans clean, because every
# one of them is an inline-ignored test input. A required check that
# disagrees with the pre-commit hook is worse than no check.
run: go install github.com/HodeTech/leakwatch@v1.8.0
- name: Scan
run: |
leakwatch scan fs . \
--config .leakwatch.yaml \
--format sarif \
--output results.sarif \
--min-severity medium \
--no-verify
- name: Upload SARIF artifact
if: always()
uses: actions/upload-artifact@v4
with:
name: leakwatch-results
path: results.sarif
if-no-files-found: warn