# 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 8–38. **Job skeleton pattern** (ci.yml lines 8–11 — `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 40–125. **Current header** (lines 40–44): ```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 126–314. **Current header** (lines 126–129): ```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 31–32: ```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 6–16): ```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 17–25): ```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 1–7) — same layer (repo root), same purpose (configure a formatting/lint tool), same JSON format. No `.markdownlint-cli2.jsonc` exists yet. **`.prettierrc` structural pattern** (lines 1–7): ```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 11–38 — 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 22–23, 75–76, 161–162 — 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 16–17 — 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