# 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
