Phase 15: skip api/harness CI for doc-only PRs + markdown lint gate #11

Merged
luckberg merged 18 commits from gsd/phase-15-ci-skip-api-harness-jobs-for-doc-only-prs into main 2026-06-12 11:08:54 -04:00
Showing only changes of commit 899528eeb3 - Show all commits
@@ -0,0 +1,417 @@
# Phase 15: Doc-Only CI Skip + Markdown Lint - Pattern Map
**Mapped:** 2026-06-12
**Files analyzed:** 4 (2 modified, 1 created, N docs fixed)
**Analogs found:** 3 / 4 (`.markdownlint-cli2.jsonc` has no role-match analog — closest is `.prettierrc`)
## File Classification
| New/Modified File | Role | Data Flow | Closest Analog | Match Quality |
|---|---|---|---|---|
| `.gitea/workflows/ci.yml` | CI workflow | event-driven | itself (existing job blocks) | exact — new jobs modeled on existing jobs in same file |
| `.markdownlint-cli2.jsonc` | tool config | — | `.prettierrc` (root JSON tool config) | structural-match (same layer, no content analog) |
| `package.json` (root) | package manifest | — | itself (existing `format:check` script + `prettier` devDep) | exact — same script/dep pattern |
| `docs/**/*.md`, `README.md`, `apps/*/README.md` | docs content | — | the violating files themselves | exact — in-place fixes |
---
## Pattern Assignments
### `.gitea/workflows/ci.yml` — `changes` job (new)
**Analog:** The existing `fast-checks` job in the same file, lines 838.
**Job skeleton pattern** (ci.yml lines 811 — `fast-checks` header as template):
```yaml
fast-checks:
runs-on: ubuntu-latest
if: github.event_name == 'pull_request'
steps:
```
**New `changes` job — copy this structure, replacing steps with the paths-filter action:**
```yaml
changes:
runs-on: ubuntu-latest
if: github.event_name == 'pull_request'
permissions:
pull-requests: read
outputs:
code: ${{ steps.filter.outputs.code }}
steps:
- uses: dorny/paths-filter@v4
id: filter
with:
filters: |
code:
- '**/*.ts'
- '**/*.tsx'
- '**/*.js'
- '**/*.json'
- '**/*.yaml'
- '**/*.yml'
- 'apps/**'
- 'packages/**'
- 'pnpm-lock.yaml'
- 'Dockerfile'
- 'docker-compose*.yml'
```
**Key design notes:**
- No `actions/checkout` step — `dorny/paths-filter@v4` uses the Gitea/GitHub REST API on PR events (no fetch-depth needed).
- `permissions: pull-requests: read` is required by v4; declare it at job scope (not workflow scope).
- Define the positive `code` filter (not a `docs` filter) — `code == 'false'` means "only docs changed", and ambiguous new file types safely default to the full test run.
- Insert this job **before** `fast-checks` in the file so the job ordering in the UI is logical (changes → fast-checks / api / harness → gate).
---
### `.gitea/workflows/ci.yml` — `api` job modification
**Analog:** Existing `api` job, ci.yml lines 40125.
**Current header** (lines 4044):
```yaml
api:
runs-on: ubuntu-latest
if: github.event_name == 'pull_request'
# Runs in PARALLEL with fast-checks (D-03) — no needs: dependency.
services:
```
**Modified header — add `needs` and extend the `if` condition:**
```yaml
api:
runs-on: ubuntu-latest
needs: [changes]
if: github.event_name == 'pull_request' && needs.changes.outputs.code == 'true'
# Runs in PARALLEL with fast-checks (D-03) — skipped for doc-only PRs.
services:
```
**Nothing else in the `api` job body changes.** The existing comment at line 43 should be updated to reflect conditional skip behavior.
---
### `.gitea/workflows/ci.yml` — `harness` job modification
**Analog:** Existing `harness` job, ci.yml lines 126314.
**Current header** (lines 126129):
```yaml
harness:
runs-on: ubuntu-latest
if: github.event_name == 'pull_request'
# Runs in PARALLEL with fast-checks + api (D-03) — no needs: dependency.
services:
```
**Modified header — same pattern as `api`:**
```yaml
harness:
runs-on: ubuntu-latest
needs: [changes]
if: github.event_name == 'pull_request' && needs.changes.outputs.code == 'true'
# Runs in PARALLEL with fast-checks (D-03) — skipped for doc-only PRs.
services:
```
**Nothing else in the `harness` job body changes.**
---
### `.gitea/workflows/ci.yml` — `gate` job (new)
**Analog:** `publish.yml` line 94 — `if: always()` pattern on the `Docker logout` step; same construct used at job scope here.
**Insert after `harness` job, at end of file:**
```yaml
gate:
runs-on: ubuntu-latest
needs: [fast-checks, changes, api, harness]
if: always()
steps:
- name: Check all required jobs passed or were skipped
run: |
# fast-checks always runs — must be success
if [ "${{ needs.fast-checks.result }}" != "success" ]; then
echo "fast-checks: ${{ needs.fast-checks.result }}"
exit 1
fi
# api and harness are conditionally skipped — success OR skipped are both acceptable
for result in "${{ needs.api.result }}" "${{ needs.harness.result }}"; do
if [ "$result" != "success" ] && [ "$result" != "skipped" ]; then
echo "Heavy job failed or was cancelled: $result"
exit 1
fi
done
echo "Gate passed."
```
**Critical Gitea 1.26.2 constraint:** Do NOT use `contains(needs.*.result, 'success')` — Gitea issue #31007 confirms this returns `false` even when jobs succeed. Individual `needs.X.result` string comparisons are the only reliable approach on this instance.
**Why `if: always()`:** Without it, a skipped upstream job causes `gate` to also skip — branch protection waiting for `CI / gate` would deadlock. The `if: always()` deadlock bug (Gitea #27906) was fixed in 1.21.8; this instance runs 1.26.2.
---
### `.gitea/workflows/ci.yml` — Markdown lint step in `fast-checks`
**Analog:** Existing `Format check` step, ci.yml lines 3132:
```yaml
- name: Format check
run: pnpm format:check
```
**New step — insert after `Format check`, before `Typecheck`:**
```yaml
- name: Markdown lint
run: pnpm md:lint
```
**Slot:** After line 32 (`run: pnpm format:check`), before line 34 (`- name: Typecheck`). One line of YAML mirrors the `format:check` step pattern exactly — a named step calling a root pnpm script.
---
### `package.json` (root) — `md:lint` script + devDependency
**Analog:** Existing `format:check` script and `prettier` devDependency, package.json lines 15 and 21.
**Current scripts block** (lines 616):
```json
"scripts": {
"dev:api": "pnpm --filter @familysync/api dev",
"dev:pwa": "pnpm --filter @familysync/pwa dev",
"build": "pnpm --filter @familysync/api build && pnpm --filter @familysync/pwa build",
"test": "pnpm --filter @familysync/api test",
"test:e2e": "pnpm --filter @familysync/pwa test:e2e",
"lint": "pnpm -r --if-present lint",
"typecheck": "pnpm -r typecheck",
"format": "prettier --write .",
"format:check": "prettier --check ."
},
```
**Add `md:lint` after `format:check` (alphabetical/logical grouping matches existing order):**
```json
"format:check": "prettier --check .",
"md:lint": "markdownlint-cli2"
```
**Note:** No glob args on the CLI — globs and ignores live entirely in `.markdownlint-cli2.jsonc` (see Open Question #3 in RESEARCH.md resolved: config-file globs make `pnpm md:lint` and direct `npx markdownlint-cli2` behave identically).
**Current devDependencies block** (lines 1725):
```json
"devDependencies": {
"@eslint/js": "9.39.4",
"eslint": "9.39.4",
"eslint-config-prettier": "10.1.8",
"eslint-plugin-react": "7.37.5",
"eslint-plugin-react-hooks": "7.1.1",
"prettier": "3.8.4",
"typescript-eslint": "8.61.0"
}
```
**Add `markdownlint-cli2` (alphabetical, between `eslint-plugin-react-hooks` and `prettier`):**
```json
"markdownlint-cli2": "0.22.1",
"prettier": "3.8.4",
```
**Install command (must run at workspace root):**
```bash
pnpm add -D markdownlint-cli2@0.22.1 --workspace-root
```
---
### `.markdownlint-cli2.jsonc` (new file)
**Analog:** `.prettierrc` (root JSON tool config, lines 17) — same layer (repo root), same purpose (configure a formatting/lint tool), same JSON format. No `.markdownlint-cli2.jsonc` exists yet.
**`.prettierrc` structural pattern** (lines 17):
```json
{
"semi": true,
"singleQuote": true,
"tabWidth": 2,
"trailingComma": "all",
"printWidth": 100
}
```
**New file — use JSONC format (`.jsonc` extension supports comments), place at repo root:**
```jsonc
// .markdownlint-cli2.jsonc
{
"config": {
// Disable all rules that conflict with Prettier (23 rules — line-length, list-indent,
// blanks-around-fences, emphasis-style, etc.)
"extends": "markdownlint/style/prettier",
// Content rules to KEEP:
"MD001": true, // heading-increment: no skipping h1→h3
"MD024": true, // no-duplicate-heading
"MD040": true, // fenced-code-language: all fences must declare a language
"MD031": true, // blanks-around-fences (re-enabled — see Pitfall 4 in RESEARCH.md)
"MD051": true, // link-fragments: broken anchor links
"MD052": true, // reference-links-images: undefined link references
// Rules DISABLED (Prettier owns these OR they fire on non-author-controlled files):
"MD041": false, // first-line-h1: CLAUDE.md legitimately starts with ## Project
"MD034": false, // no-bare-urls: CLAUDE.md version table uses pkg@version syntax
"MD036": false // no-emphasis-as-heading: docs/API.md uses **Response 200** as label
},
"globs": [
"docs/**/*.md",
"*.md",
"apps/**/*.md"
],
"ignores": [
".planning/**",
"node_modules/**",
"**/node_modules/**",
".pnpm-store/**"
]
}
```
**Why `extends` resolves:** `markdownlint-cli2` depends on `markdownlint`, which ships `style/prettier.json`. After `pnpm add -D markdownlint-cli2 --workspace-root`, the file exists at `node_modules/markdownlint/style/prettier.json`.
**Glob placement in config, not CLI:** Putting `globs` and `ignores` in the config file means `pnpm md:lint` (just `markdownlint-cli2`, no args) and a developer running `npx markdownlint-cli2` directly both use the same scope automatically.
---
### Docs content fixes — 13 baseline violations (MD040 / MD031)
**Analog:** The violating files themselves. All fixes are mechanical one-liners.
**Files and fix type:**
| File | Rule | Fix |
|------|------|-----|
| `apps/api/README.md` | MD040 (1) | Add language tag to bare ` ``` ` |
| `apps/pwa/e2e/README.md` | MD040 (1) | Add language tag to bare ` ``` ` |
| `apps/pwa/README.md` | MD040 (1) | Add language tag to bare ` ``` ` |
| `docs/API.md` | MD040 (2) | Add language tags to 2 bare ` ``` ` blocks |
| `docs/ARCHITECTURE.md` | MD040 (2) | Add language tags to 2 bare ` ``` ` blocks |
| `docs/DEVELOPMENT.md` | MD040 (2) | Add language tags to 2 bare ` ``` ` blocks |
| `docs/GETTING-STARTED.md` | MD031 (2) | Add blank lines around 1 fence pair |
| `README.md` | MD040 (2) | Add language tags to 2 bare ` ``` ` blocks |
**MD040 fix pattern** — change:
````
```
some content
```
````
to (pick the language that matches: `bash`, `text`, `json`, `yaml`, etc.):
````
```bash
some content
```
````
**MD031 fix pattern** — change:
````
- list item
```bash
code
```
next paragraph
````
to:
````
- list item
```bash
code
```
next paragraph
````
**Verification command (run locally after fixes, before pushing):**
```bash
pnpm md:lint
pnpm format:check
```
Run both — see RESEARCH.md Pitfall 4: MD031 fixes and Prettier must not conflict.
---
## Shared Patterns
### `runs-on` label
**Source:** ci.yml line 9, 41, 127 — `runs-on: ubuntu-latest`
**Apply to:** All new jobs (`changes`, `gate`)
```yaml
runs-on: ubuntu-latest
```
The runner advertises `ubuntu-latest`. Never use `self-hosted`.
### `if: github.event_name == 'pull_request'` guard
**Source:** ci.yml lines 10, 41, 128 — existing jobs all carry this guard
**Apply to:** `changes` job (same guard); `api` and `harness` extend it with `&&`
```yaml
if: github.event_name == 'pull_request'
```
### Step ordering in `fast-checks`
**Source:** ci.yml lines 1138 — current step sequence:
1. `actions/checkout@v4`
2. `actions/setup-node@v4` (node 22)
3. `Enable pnpm` (corepack)
4. `Install dependencies` (pnpm install --frozen-lockfile)
5. `Lint`
6. `Format check`
7. **← insert `Markdown lint` here**
8. `Typecheck`
9. `PWA unit tests`
The new `Markdown lint` step slots between `Format check` and `Typecheck` — logically grouped with format/style checks, before compilation checks.
### `if: always()` usage
**Source:** publish.yml line 94 — `if: always()` on the `Docker logout` step
**Apply to:** `gate` job (job-level `if: always()`, not step-level)
```yaml
if: always()
```
This is the only safe way to ensure `gate` always reports a commit-status regardless of upstream job outcomes.
### No `actions/cache@v4`
**Source:** ci.yml lines 2223, 7576, 161162 — comment in every job:
```yaml
# actions/cache@v4 is intentionally omitted — probe (D-PROBE-04) showed it
# times out on this runner (socket hang-up between runner container and job
# container cache server). pnpm install without cache takes ~30s; acceptable.
```
**Apply to:** `changes` job has no install step so this is not relevant. `gate` job has no install step. No new jobs require caching.
---
## No Analog Found
| File | Role | Data Flow | Reason |
|------|------|-----------|--------|
| `.markdownlint-cli2.jsonc` | tool config | — | No existing markdownlint config in repo; `.prettierrc` used as structural analog only (JSON root tool config pattern) |
---
## publish.yml Comment Update (advisory)
**Source:** publish.yml lines 1617 — the file's safety-gate comment names `CI / api` and `CI / harness` as the required checks:
```yaml
# The PR test jobs (fast-checks, api, harness in ci.yml) run on pull_request — they never
# run in the same workflow invocation as publish.yml. Tests gate the PR; main is trusted to
# be green because direct push and force push are blocked and the three required checks
# (CI / fast-checks, CI / api, CI / harness) must pass before merge.
```
After this phase, the required checks change to `CI / fast-checks` + `CI / gate`. The planner should include a step to update this comment to reflect the new required-check names.
---
## Metadata
**Analog search scope:** `.gitea/workflows/`, repo root (config files), `package.json`
**Files scanned:** 4 (ci.yml, publish.yml, package.json, .prettierrc)
**Pattern extraction date:** 2026-06-12