---
phase: 15-ci-skip-api-harness-jobs-for-doc-only-prs
verified: 2026-06-12T00:00:00Z
status: passed
score: 3/3 must-haves verified
overrides_applied: 0
---

# Phase 15: CI doc-only skip + markdown lint gate — Verification Report

**Phase Goal:** Aggregate-gate the slow api/harness CI jobs so doc-only PRs to main merge without running them (no branch-protection deadlock), and add markdownlint to `fast-checks` so docs get a fast format+lint gate.
**Verified:** 2026-06-12
**Status:** passed
**Re-verification:** No — initial verification

## Requirement Traceability Note

No formal REQ-IDs are assigned to this phase (promoted from backlog 999.17 as CI tooling/operability work). Requirement-traceability is N/A; this is noted here rather than flagged as a gap.

---

## Goal Achievement

### Observable Truths (Success Criteria)

| # | Truth | Status | Evidence |
|---|-------|--------|----------|
| SC-1 | A doc-only PR (only docs/\*\*.md or root \*.md) skips `api` and `harness` jobs and is still mergeable — no missing-required-check deadlock | VERIFIED | The `changes` job's `code` filter lists only code paths (`**/*.ts`, `apps/**`, `pnpm-lock.yaml`, `Dockerfile`, `docker-compose*.yml`, etc.) with no `docs/**` or root `*.md` entry. A doc-only PR yields `code=false` → `api`/`harness` skip. The `gate` job uses `if: always()` and accepts `skipped` as a passing state for api/harness. Branch protection requires `CI / fast-checks (pull_request)` + `CI / gate (pull_request)` only — both always-reporting. Verified live: PR #11 (code PR) ran all five jobs; the protection rule was confirmed via tea API PATCH + re-GET. |
| SC-2 | A code PR runs `fast-checks`, `api`, `harness`, and `gate`, and is mergeable only when all are green | VERIFIED | `api` and `harness` carry `needs: [changes]` and `if: github.event_name == 'pull_request' && needs.changes.outputs.code == 'true'`. Any file matching the positive code filter triggers all heavy jobs. The `gate` job fails (exit 1) if `needs.fast-checks.result != 'success'` or if `needs.api.result` / `needs.harness.result` is neither `success` nor `skipped`. Confirmed live by PR #11 (a code PR) where all five jobs ran and reported success. |
| SC-3 | Branch protection on `main` requires exactly `CI / fast-checks` + `CI / gate` and no longer requires `CI / api`/`CI / harness` | VERIFIED | tea API GET on `repos/luckberg/familysync/branch_protections/main` returns `status_check_contexts: ['CI / fast-checks (pull_request)', 'CI / gate (pull_request)']` with `enable_status_check: true`. `CI / api` and `CI / harness` are absent. Context suffix `(pull_request)` per decision D-15-03-CTX-SUFFIX — Gitea emits suffixed contexts; bare names would deadlock. |

**Score:** 3/3 truths verified

---

### Required Artifacts

| Artifact | Expected | Status | Details |
|----------|----------|--------|---------|
| `.markdownlint-cli2.jsonc` | markdownlint-cli2 config (extends prettier preset; content rules; globs + ignores) | VERIFIED | File exists. `config.extends = "markdownlint/style/prettier"`. MD001/MD024/MD040/MD031/MD051/MD052 enabled; MD041/MD034/MD036 disabled. `globs: ["docs/**/*.md", "*.md", "apps/**/*.md"]`. `ignores: [".planning/**", "node_modules/**", "**/node_modules/**", ".pnpm-store/**"]`. |
| `package.json` | `md:lint` script + `markdownlint-cli2` devDependency | VERIFIED | `scripts["md:lint"] = "markdownlint-cli2"` (no glob args — config-file-driven). `devDependencies["markdownlint-cli2"] = "0.22.1"` (exact pin, no `^`/`~`). |
| `.gitea/workflows/ci.yml` | changes job, md lint step in fast-checks, conditional api/harness, always-running gate | VERIFIED | All four structural elements present (detail in Key Link Verification below). YAML parses without error. |
| `.gitea/workflows/publish.yml` | Updated safety-gate comment naming new required checks | VERIFIED | Comment at lines 13–19 names `CI / fast-checks` and `CI / gate`; no longer asserts `CI / api`/`CI / harness` are required. Old "three required checks (…api, …harness)" clause is replaced. YAML parses without error. |

---

### Key Link Verification

| From | To | Via | Status | Details |
|------|----|-----|--------|---------|
| `.gitea/workflows/ci.yml` fast-checks job | `package.json` `md:lint` script | Step "Markdown lint" runs `pnpm md:lint` | WIRED | Step present at position 6 in fast-checks steps, after "Format check" (pos 5) and before "Typecheck" (pos 7). |
| `package.json` `md:lint` script | `.markdownlint-cli2.jsonc` | `markdownlint-cli2` auto-discovers root config | WIRED | Script is bare `markdownlint-cli2` with no CLI glob args; globs/ignores live in the config file. `pnpm md:lint` exits 0 scanning 12 files. |
| `ci.yml` `api`/`harness` jobs | `ci.yml` `changes` job output | `needs: [changes]` + `if: needs.changes.outputs.code == 'true'` | WIRED | Both heavy jobs carry `needs: [changes]` and the combined `if`. Confirmed via YAML parse. |
| `ci.yml` `gate` job | `ci.yml` `fast-checks`/`api`/`harness` results | `needs: [fast-checks, changes, api, harness]` + `if: always()` + individual `needs.X.result` checks | WIRED | `gate.needs` contains all four jobs. `gate.if = "always()"`. Bash script references `needs.fast-checks.result`, `needs.api.result`, `needs.harness.result` individually. No `needs.*.result` wildcard (Gitea bug #31007 avoided). |
| Gitea branch protection (main) | `ci.yml` `gate` job | Required status check `CI / gate (pull_request)` | WIRED | tea API GET confirms `status_check_contexts = ['CI / fast-checks (pull_request)', 'CI / gate (pull_request)']`. `enable_status_check = true`. `CI / api (pull_request)` and `CI / harness (pull_request)` are absent. |

---

### Behavioral Spot-Checks

| Behavior | Command | Result | Status |
|----------|---------|--------|--------|
| `pnpm md:lint` exits 0 on current tree | `pnpm md:lint` | "Summary: 0 error(s)" / exit 0 | PASS |
| 12 in-scope files scanned, `.planning/**` excluded | `pnpm md:lint` output | "Finding: … !.planning/** …; Linting: 12 file(s)" | PASS |
| `ci.yml` parses as valid YAML | `python3 -c "import yaml; yaml.safe_load(...)"` | No error | PASS |
| `publish.yml` parses as valid YAML | `python3 -c "import yaml; yaml.safe_load(...)"` | No error | PASS |
| Branch protection requires exactly fast-checks + gate, no api/harness | `tea api repos/luckberg/familysync/branch_protections/main` | `status_check_contexts: ['CI / fast-checks (pull_request)', 'CI / gate (pull_request)']` | PASS |
| Markdown lint step is between Format check and Typecheck | YAML parse of fast-checks steps | Format check @ idx 5, Markdown lint @ idx 6, Typecheck @ idx 7 | PASS |
| No `needs.*.result` wildcard in gate (Gitea #31007 guard) | `grep 'needs\.\*\.result' ci.yml` | No match | PASS |
| All three individual result refs present in gate | count of `needs.fast-checks.result`, `needs.api.result`, `needs.harness.result` | 3 matches | PASS |

---

### Anti-Patterns Found

| File | Line | Pattern | Severity | Impact |
|------|------|---------|----------|--------|
| — | — | — | — | None found |

No `TBD`, `FIXME`, or `XXX` markers in any file modified by this phase. No stub implementations. No hardcoded empty data. No orphaned artifacts.

---

### Human Verification Required

None. All success criteria are verifiable statically (YAML structure, config values, branch-protection API state) or behaviorally (`pnpm md:lint` exit code, file counts). The one item that was previously a human-action checkpoint (Task 2 of Plan 03 — branch protection update) was executed by the agent under operator authorization and independently confirmed via the tea API GET in this verification session.

---

### Requirements Coverage

Requirement-traceability is N/A for this phase per the phase brief. No formal REQ-IDs are assigned. This is documented here to distinguish it from a missing-coverage gap.

---

### Gaps Summary

None. All three success criteria are verified against live codebase and live admin state.

---

_Verified: 2026-06-12_
_Verifier: Claude (gsd-verifier)_
