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
72 changes: 72 additions & 0 deletions .github/workflows/copy-docs-freshness.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
# SPDX-FileCopyrightText: 2026 soundminds.ai
#
# SPDX-License-Identifier: Apache-2.0

name: copy-docs-freshness

# infra_generated_artifact_freshness_gate / Story 1.2.
#
# Catches the failure mode where a contributor edits a source guide under
# `docs/08_guides/` without re-running `node ui/scripts/copy-docs.mjs`,
# leaving the in-app `<MarkdownDoc>` reader serving stale `ui/public/docs/`
# bytes. Regenerates the public copies and fails the PR if
# `git status --porcelain -- ui/public/docs/` is non-empty (modified,
# untracked, or deleted) — `git diff --exit-code` would silently miss the
# untracked case (FR-1, FR-9).
#
# Lives in its OWN workflow file (mirrors `secrets-defense.yml`) on
# `pull_request:` with NO `paths` / `paths-ignore` filter, so a docs-only
# PR that pr.yml's `paths-ignore: ['docs/**']` filter skips still gets the
# freshness check (FR-3 — own-workflow-to-escape-paths-ignore precedent).
#
# Action SHAs pinned per the `chore_scorecard_pin_deps_postcss` posture
# (PR #430, 2026-06-03); Dependabot's github-actions ecosystem rotates the
# `# v6` comment + the SHA together.

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

permissions:
contents: read

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

jobs:
copy-docs-freshness:
name: copy-docs freshness (ui/public/docs sync to docs/08_guides)
runs-on: ubuntu-latest
timeout-minutes: 5
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
with:
# Enough history for the diagnostic `git status` to resolve cleanly.
fetch-depth: 50

- uses: pnpm/action-setup@0e279bb959325dab635dd2c09392533439d90093 # v6
with:
version: 9

- uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6
with:
node-version: 22
cache: pnpm
cache-dependency-path: ui/pnpm-lock.yaml

# `copy-docs.mjs` itself imports only `node:fs`/`node:path`/`node:url`,
# so it does NOT need `pnpm install`. The frozen install still runs
# so the runner caches `ui/node_modules` for any future step on this
# job that needs ESLint/TS/etc.; remove later if the job stays
# node-only.
- name: pnpm install (frozen)
run: pnpm --dir ui install --frozen-lockfile

- name: Self-test the freshness guard
run: bash scripts/ci/test_verify_copy_docs_fresh.sh

- name: Verify ui/public/docs/ is in sync with docs/08_guides/
run: bash scripts/ci/verify_copy_docs_fresh.sh
71 changes: 71 additions & 0 deletions .github/workflows/pr.yml
Original file line number Diff line number Diff line change
Expand Up @@ -208,6 +208,77 @@ jobs:
- name: Check license inventory
run: uv run python scripts/gen_license_inventory.py --check

# -------------------------------------------------------------------------
# Generated-artifact freshness — Phase 2 (`infra_generated_artifact_
# freshness_gate`). Runs the snapshot guard (Story 2.2) here; Story 2.3
# appends the types-guard step to this same job. The copy-docs gate
# (Story 1.2) lives in its OWN workflow file `copy-docs-freshness.yml`
# so it survives pr.yml's `docs/**` paths-ignore filter (FR-3 escape).
#
# This job is NOT under paths-ignore — backend (**/*.py) and ui (**/*.ts)
# changes can both invalidate the snapshot, so the gate must run on every
# code-bearing PR. Job structure mirrors `license-inventory` above — uv
# for the Python exporter, plus pnpm/node so the future types-guard
# step in Story 2.3 has the pinned `openapi-typescript` binary in
# `ui/node_modules`.
# -------------------------------------------------------------------------
generated-artifacts-fresh:
name: generated-artifacts-fresh
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
- name: Install uv
uses: astral-sh/setup-uv@37802adc94f370d6bfd71619e3f0bf239e1f3b78 # v7
with:
enable-cache: true
version: "0.5.7"
- name: Set up Python
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6
with:
python-version: "3.13"
- name: Install Python deps (frozen)
run: uv sync --frozen
- uses: pnpm/action-setup@0e279bb959325dab635dd2c09392533439d90093 # v6
with:
version: 9
- uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6
with:
node-version: 22
cache: "pnpm"
cache-dependency-path: ui/pnpm-lock.yaml
- name: Install pnpm deps (frozen)
# Needed so the future Story 2.3 types-guard step finds the pinned
# `openapi-typescript` binary in `ui/node_modules`. The exporter
# itself only needs Python, but installing pnpm here keeps the
# whole `generated-artifacts-fresh` job self-contained.
run: pnpm --dir ui install --frozen-lockfile
- name: Self-test snapshot guard
run: bash scripts/ci/test_verify_openapi_snapshot_fresh.sh
- name: Verify ui/openapi.json snapshot is fresh
run: bash scripts/ci/verify_openapi_snapshot_fresh.sh
- name: Self-test types guard
run: bash scripts/ci/test_verify_types_fresh.sh
- name: Verify ui/src/lib/types.ts is fresh
run: bash scripts/ci/verify_types_fresh.sh
- name: Clean-tree determinism assertion (AC-7)
# After both guards have run their regenerators, the working
# tree must be clean across all three artifacts. Catches a
# regenerator that is itself non-deterministic across runs
# (the failure mode `infra_generated_artifact_freshness_gate`
# FR-6 names) — distinct from drift against the committed
# snapshot, which the two guards above catch.
run: |
REGEN_NO_STAGE=1 bash scripts/regen-generated-artifacts.sh
DRIFT="$(git status --porcelain -- ui/openapi.json ui/src/lib/types.ts ui/public/docs)"
if [[ -n "${DRIFT}" ]]; then
echo "ERROR: regenerator output is non-deterministic across runs." >&2
echo "Drift after a fresh canonical regen:" >&2
printf '%s\n' "${DRIFT}" >&2
exit 1
fi
echo "OK: clean-tree determinism assertion passed."

# -------------------------------------------------------------------------
# Static checks — ALWAYS run (not gated by SKIP_HEAVY_CI). Split into two
# parallel jobs by toolchain: the independent Python and Node dependency
Expand Down
18 changes: 18 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -258,6 +258,24 @@ Additional workflows (deploy-staging, release, image-publish) ship at MVP3 + GA
- DB revision guard at API startup is **MVP2+** — MVP1 doesn't fail-fast on pending migrations (would crash the dev stack on first boot before `make migrate` runs).
- Revision IDs are ≤32 chars (Alembic's `version_num` column is `VARCHAR(32)`); the `0001_baseline` convention stays well under.

### Generated artifacts

`ui/openapi.json` (offline OpenAPI snapshot), `ui/src/lib/types.ts`
(generated by `openapi-typescript` from the snapshot), and
`ui/public/docs/*.md` (copied from `docs/08_guides/`) are
**CI-freshness-gated**. The `generated-artifacts-fresh` job in
`pr.yml` + the standalone `copy-docs-freshness` workflow regenerate
them on every PR and fail on `git status --porcelain` drift. The
single canonical fix:

```bash
bash scripts/regen-generated-artifacts.sh
```

Generated files are listed in `ui/.prettierignore` — the generator
is the source of truth, do not run prettier on them. See
[`docs/05_quality/testing.md` §"Generated-artifact freshness gates"](docs/05_quality/testing.md).

## Testing Conventions

| Layer | Location | DB? | Notes |
Expand Down
Loading
Loading