# Phase 15: Doc-Only CI Skip + Markdown Lint - Research

**Researched:** 2026-06-12
**Domain:** Gitea Actions YAML / CI gate patterns / markdownlint-cli2
**Confidence:** HIGH

## Summary

This phase has two independent sub-problems: (1) skip the slow `api` and `harness` jobs when a PR only touches docs, without deadlocking branch protection; and (2) add `markdownlint-cli2` to `fast-checks` so docs get a real lint gate. Both problems have well-understood solutions, but each has a Gitea-specific wrinkle that must be handled correctly.

**Sub-problem 1 — doc-only skip + gate:** The mandatory pattern is a `changes` job (using `dorny/paths-filter@v4`) that detects doc-only PRs, combined with job-level `if:` conditions on `api`/`harness`, and an always-running `gate` aggregate job that branch protection requires instead of requiring `api`/`harness` directly. Gitea 1.26.2 (this instance) has a confirmed bug where `contains(needs.*.result, 'success')` returns `false` in certain contexts; the safe workaround is to check each job result individually using `&&` rather than the wildcard `contains()`. The `if: always()` deadlock bug (issue #27906) was fixed in Gitea 1.21.8; this instance at 1.26.2 is past that fix.

**Sub-problem 2 — markdownlint:** With the `markdownlint/style/prettier` preset disabling all formatting-rules that Prettier owns, 13 content violations remain across 7 files — all trivially fixable (add language tags to bare fenced blocks, add blank lines around one fence pair). The baseline cleanup is the same order of magnitude as a single commit.

**Primary recommendation:** Use `dorny/paths-filter@v4` in a `changes` job + individual `needs.X.result` checks in the `gate` job. Add `markdownlint-cli2` as a root devDependency. Update branch protection to require `CI / fast-checks` + `CI / gate` only (drop direct `CI / api` and `CI / harness` requirements).

## Architectural Responsibility Map

| Capability | Primary Tier | Secondary Tier | Rationale |
|------------|-------------|----------------|-----------|
| Doc-only detection | CI workflow | — | Pure YAML — a `changes` job runs `dorny/paths-filter` against the PR diff |
| Heavy-job skip | CI workflow | — | Job-level `if: needs.changes.outputs.code == 'true'` |
| Branch-protection gate | CI workflow | Gitea admin (manual) | `gate` job in YAML; required-check config is a Gitea UI/API action |
| Markdown linting | CI `fast-checks` job | Root workspace | A step in the existing job; markdownlint-cli2 installed at root |

## Standard Stack

### Core

| Library | Version | Purpose | Why Standard |
|---------|---------|---------|--------------|
| markdownlint-cli2 | 0.22.1 | Markdown lint runner | CLI2 variant is config-driven; faster startup than markdownlint-cli; actively maintained by DavidAnson [VERIFIED: npm registry] |
| dorny/paths-filter | v4 | Changed-files detection for conditional jobs | Official GitHub Action mirrored on Gitea; uses GitHub/Gitea REST API on PR events (no fetch-depth needed); v4 is the current major [CITED: github.com/dorny/paths-filter] |

### Supporting

| Library | Version | Purpose | When to Use |
|---------|---------|---------|-------------|
| markdownlint/style/prettier | (bundled with markdownlint) | Rule preset disabling all Prettier-owned rules | Use via `extends: "markdownlint/style/prettier"` in the config; resolves from `node_modules/markdownlint/style/prettier.json` since markdownlint is a transitive dep of markdownlint-cli2 [VERIFIED: confirmed present in npm registry bundle] |

### Alternatives Considered

| Instead of | Could Use | Tradeoff |
|------------|-----------|----------|
| dorny/paths-filter@v4 | workflow-level `on.paths` filter | `on.paths` triggers a completely different workflow run — a required check gated on it never reports for a non-matching PR, causing a permanent deadlock on branch protection. Never use for required checks. |
| dorny/paths-filter@v4 | `git diff --name-only` in a setup job | Works but requires careful fetch-depth setup and manual glob matching; paths-filter handles this cleanly with the API |
| gate job with `if: always()` | Marking api/harness directly as required + optional | Gitea does not support "optional when skipped" on required checks; skipped jobs may not emit a commit-status context at all |

**Installation (root workspace only):**

```bash
pnpm add -D markdownlint-cli2 --workspace-root
```

**Version verification:**

```bash
npm view markdownlint-cli2 version
# 0.22.1 — confirmed 2026-06-12
```

## Package Legitimacy Audit

| Package | Registry | Age | Downloads | Source Repo | Verdict | Disposition |
|---------|----------|-----|-----------|-------------|---------|-------------|
| markdownlint-cli2 | npm | ~4 yrs | ~3M/wk | github.com/DavidAnson/markdownlint-cli2 | OK | Approved |
| dorny/paths-filter | GitHub Action | ~5 yrs | widely used | github.com/dorny/paths-filter | OK | Approved — not a package, used via `uses:` |

**Packages removed due to [SLOP] verdict:** none
**Packages flagged as suspicious [SUS]:** none

## Architecture Patterns

### System Architecture Diagram

```
PR pushed to main
       │
       ▼
┌─────────────┐
│  changes     │  (new job — always runs)
│  job         │  dorny/paths-filter@v4
│  PR API diff │  outputs: code=true|false
└──────┬──────┘
       │
       ├──────────────────────┬──────────────────────┐
       │                      │                      │
       ▼                      ▼                      ▼
┌─────────────┐     ┌──────────────┐     ┌──────────────────┐
│ fast-checks  │     │    api        │     │    harness        │
│ (always)     │     │ if: code==   │     │ if: code==       │
│ lint/fmt/type│     │ 'true'       │     │ 'true'           │
│ + md-lint    │     │              │     │                  │
└──────┬──────┘     └──────┬───────┘     └────────┬─────────┘
       │                   │                      │
       └──────────┬────────┴──────────────────────┘
                  │
                  ▼
          ┌──────────────┐
          │    gate       │  (new job — if: always())
          │  needs: all 4 │  passes if each job is
          │  always runs  │  success OR skipped;
          │               │  fails if any failure/cancel
          └──────┬────────┘
                 │
                 ▼
     Branch protection requires:
       CI / fast-checks  (direct — always runs)
       CI / gate         (aggregate — always runs)
     DROP direct: CI / api, CI / harness
```

### Recommended Project Structure

No new directories. All changes are in:

```
.gitea/
└── workflows/
    └── ci.yml    # modified: add changes + gate jobs; add if: conditions to api/harness
.markdownlint-cli2.jsonc   # new: config at repo root
package.json               # modified: add markdownlint-cli2 devDep + md:lint script
```

### Pattern 1: Changes Job (dorny/paths-filter@v4)

**What:** A setup job that always runs, detects whether the PR touches code or only docs, and exports an output used by downstream jobs.

**When to use:** Any workflow where some jobs should be skippable based on which files changed.

**Example (exact YAML for this repo):**

```yaml
# Source: github.com/dorny/paths-filter (v4 README)
jobs:
  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 choice:** Define `code` as the positive filter (files that ARE code), not `docs`. This means `code == 'false'` means "only docs changed" — safe, because any ambiguous or new file type is treated as code and gets the full gate. A doc-only PR will have `code == 'false'` only if every changed file is excluded from the `code` pattern.

**No `actions/checkout` needed:** `dorny/paths-filter@v4` on a `pull_request` event uses the GitHub/Gitea REST API to fetch the diff, so no checkout step is required in the `changes` job. [CITED: github.com/dorny/paths-filter]

**No `fetch-depth` needed:** API-based detection for PR events does not depend on git history depth. [CITED: github.com/dorny/paths-filter]

### Pattern 2: Conditional Heavy Jobs

**What:** `api` and `harness` jobs add `needs: [changes]` and a job-level `if` condition.

**Example:**

```yaml
  api:
    runs-on: ubuntu-latest
    needs: [changes]
    if: github.event_name == 'pull_request' && needs.changes.outputs.code == 'true'
    # ... rest unchanged
```

**Important:** The existing `if: github.event_name == 'pull_request'` condition is already present; it must be combined with the new `&& needs.changes.outputs.code == 'true'` clause. The `needs: [changes]` declaration is new.

### Pattern 3: Always-Running Gate Job

**What:** An aggregate job that runs after all other jobs (`needs: [fast-checks, changes, api, harness]`), always executes regardless of upstream results, and fails if any non-skipped job failed or was cancelled.

**Gitea 1.26.2 specific:** Gitea has a known bug (issue #31007, still open as of mid-2025) where `contains(needs.*.result, 'success')` returns `false` even when jobs succeed. **Do NOT use `contains(needs.*.result, ...)`.** Instead check each job's result individually. [CITED: github.com/go-gitea/gitea/issues/31007]

**Recommended gate pattern (safe for Gitea 1.26.2):**

```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 failed or was cancelled: ${{ needs.fast-checks.result }}"
            exit 1
          fi
          # api and harness are conditionally skipped — success OR skipped are both acceptable
          for job_result in "${{ needs.api.result }}" "${{ needs.harness.result }}"; do
            if [ "$job_result" != "success" ] && [ "$job_result" != "skipped" ]; then
              echo "A required job failed or was cancelled: $job_result"
              exit 1
            fi
          done
          echo "All required jobs passed or were intentionally skipped."
```

**Why not `contains(needs.*.result, ...)`:** Gitea 1.26.2 has confirmed incompatibility with the wildcard expression for job result aggregation. Individual `needs.X.result` references work correctly. [CITED: github.com/go-gitea/gitea/issues/31007]

**Why `if: always()`:** Without this, if any upstream job is skipped or fails, the `gate` job is also skipped — and branch protection waiting for `CI / gate` would deadlock. The `always()` function ensures `gate` runs in every scenario. The `if: always()` deadlock bug (Gitea #27906) was fixed in 1.21.8; this instance runs 1.26.2. [CITED: github.com/go-gitea/gitea/issues/27906]

### Pattern 4: markdownlint-cli2 in fast-checks

**What:** A new step in the existing `fast-checks` job that runs after `format:check` and before `typecheck`. No separate job needed — it's fast (< 2s) and belongs in the same "format/style" bucket as Prettier.

**Example step (slots into fast-checks after "Format check"):**

```yaml
      - name: Markdown lint
        run: pnpm exec markdownlint-cli2 "docs/**/*.md" "*.md" "apps/**/*.md" "#.planning/**" "#node_modules/**" "#**/node_modules/**"
```

Or, preferred via a root script:

```json
"scripts": {
  "md:lint": "markdownlint-cli2 \"docs/**/*.md\" \"*.md\" \"apps/**/*.md\" \"#.planning/**\" \"#node_modules/**\" \"#**/node_modules/**\""
}
```

Then CI step is `pnpm md:lint`.

**Why root workspace, not per-app:** markdownlint-cli2 lints the whole repo's docs, not a specific app's source. The `fast-checks` job already runs `pnpm install --frozen-lockfile` at root, so a root devDependency is immediately available.

### Pattern 5: markdownlint-cli2 Config File (.markdownlint-cli2.jsonc)

**File:** `.markdownlint-cli2.jsonc` at repo root (takes highest priority over `.markdownlint.jsonc`).

**Recommended config:**

```jsonc
// .markdownlint-cli2.jsonc
{
  "config": {
    // Disable all rules that conflict with Prettier (line-length, list-indent,
    // blanks-around-fences, emphasis-style, etc. — 23 rules total)
    "extends": "markdownlint/style/prettier",

    // Content rules to KEEP:
    "MD001": true,    // heading-increment: no skipping h1→h3
    "MD024": true,    // no-duplicate-heading: duplicate headings in same section
    "MD040": true,    // fenced-code-language: all fences must declare a language
    "MD031": true,    // blanks-around-fences: blank lines around code fences
    "MD051": true,    // link-fragments: broken anchor links
    "MD052": true,    // reference-links-images: undefined link references

    // Rules to DISABLE (Prettier handles these OR they fire on CLAUDE.md/README
    // which is not author-controlled for the linting purposes of this gate):
    "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 bold for HTTP response labels
  }
}
```

**Why `extends: "markdownlint/style/prettier"` resolves at install time:** `markdownlint-cli2` depends on `markdownlint`, which ships `style/prettier.json`. When `markdownlint-cli2` is installed as a root devDep (`pnpm add -D markdownlint-cli2 --workspace-root`), the `markdownlint` package lands in the root `node_modules` and the `extends` path resolves correctly. [VERIFIED: confirmed via npx run — markdownlint found at `node_modules/markdownlint/style/prettier.json`]

**Key rule decisions:**

| Rule | Decision | Rationale |
|------|----------|-----------|
| MD013 line-length | DISABLED (via prettier preset) | Prettier sets `printWidth: 100`; markdownlint's 80-char default creates 700+ false violations |
| MD036 no-emphasis-as-heading | DISABLED | `docs/API.md` uses `**Response 200**` as a semantic label (not a heading); 25 violations; fixing changes doc structure significantly |
| MD041 first-line-h1 | DISABLED | `CLAUDE.md` legitimately starts with `## Project` (a project instructions file, not a user doc) |
| MD034 no-bare-urls | DISABLED | `CLAUDE.md` version-compatibility table uses `pkg@version` syntax that triggers this; these are not URLs |
| MD040 fenced-code-language | ENABLED | 11 fences missing language tags — easy fixes, real content rule |
| MD031 blanks-around-fences | ENABLED | 2 violations in `docs/GETTING-STARTED.md` — easy fix |
| MD001 heading-increment | ENABLED | Content rule: headings must not skip levels |
| MD024 no-duplicate-heading | ENABLED | Content rule: duplicate headings in same section |
| MD051 link-fragments | ENABLED | Catches broken anchor links |
| MD052 reference-links | ENABLED | Catches undefined reference-style links |

### Anti-Patterns to Avoid

- **Workflow-level `on: paths` filter on required jobs:** If `fast-checks` (or any required check) has an `on: pull_request: paths:` filter, PRs that don't match the path never trigger the workflow, the required check never reports, and the PR is permanently blocked. Never path-filter a required-check workflow. [CITED: github.com/go-gitea/gitea/issues/36895]
- **Marking `api`/`harness` directly as required after adding skip logic:** Once these jobs can be skipped, a skipped job may not emit a commit status in Gitea. Any PR that triggers a skip will find the required check "missing" and be blocked forever. The `gate` job is the only correct gating surface for skippable jobs.
- **`contains(needs.*.result, 'success')` in Gitea:** Returns `false` even when jobs succeed in Gitea 1.26.2. Use individual `needs.X.result` string comparisons. [CITED: github.com/go-gitea/gitea/issues/31007]
- **Not combining `github.event_name` check with `needs.changes.outputs.code`:** The `api`/`harness` jobs' existing `if` condition is `github.event_name == 'pull_request'`. This must be combined (&&) with the new output check, not replaced — otherwise the event type guard is lost.
- **Installing markdownlint-cli2 per-app instead of at root:** The lint covers cross-repo docs, not a specific app's source. Putting it in `apps/pwa` or `apps/api` packages adds unnecessary coupling.
- **Including `.planning/**` in the lint glob:** `.planning/**` files are written by GSD tooling on every workflow run, are never human-authored to a lint standard, and bypass branch protection via the Unprotected pattern. Linting them would add noise with no signal and pollute the baseline indefinitely.

## Don't Hand-Roll

| Problem | Don't Build | Use Instead | Why |
|---------|-------------|-------------|-----|
| Detecting changed files in a PR | Shell script parsing `git diff --name-only` | `dorny/paths-filter@v4` | Handles shallow clones, merge commits, API fallback, glob matching, and outputs wiring cleanly |
| Aggregate gate for conditional jobs | Complex `if:` expression checking `contains()` | Simple `bash` script in a `gate` job checking individual `needs.X.result` | Gitea has bugs with `contains(needs.*.result, ...)` wildcard; individual checks are reliable |
| Disabling Prettier-conflicting lint rules | Manually listing all 23 rules | `extends: "markdownlint/style/prettier"` | The preset is maintained by DavidAnson (markdownlint author) and tracks Prettier compatibility |

**Key insight:** The gate pattern is the only safe design for conditionally skipped jobs with branch protection. Anything that directly requires a skippable job as a "required status check" will deadlock.

## Runtime State Inventory

Not applicable — this phase is a CI workflow change only. No runtime state is involved.

## Common Pitfalls

### Pitfall 1: Required-check deadlock from skipped jobs

**What goes wrong:** `api` or `harness` is still listed as a required status check in Gitea branch protection. After the skip logic lands, a doc-only PR skips these jobs. Gitea does not emit a commit-status for a skipped job (behavior confirmed in issue #36895 pattern — the check simply never appears). Branch protection sees the required check as "missing" and blocks the PR permanently.

**Why it happens:** Gitea's branch protection model requires that the named status context appear with a `success` result. A skipped job may report nothing, or may report `skipped` — neither satisfies the requirement.

**How to avoid:** Remove `CI / api` and `CI / harness` from branch protection's required checks. Add `CI / gate` instead. The `gate` job always runs (`if: always()`), always reports, and passes only when heavy jobs are `success` or `skipped`.

**Warning signs:** Doc-only PR stuck at "Some required checks are missing" even though the workflow ran and `api`/`harness` show as skipped in the Actions UI.

### Pitfall 2: `contains(needs.*.result, 'success')` returns false on Gitea

**What goes wrong:** The gate job logic uses `contains(needs.*.result, 'success')` to check if any upstream job passed. On Gitea 1.26.2, this wildcard expression returns `false` even when jobs succeed, causing the gate to always fail.

**Why it happens:** Gitea's expression engine has an incompatibility with the GitHub Actions wildcard `needs.*.result` spread when used with `contains()`. Issue #31007 is labeled `type/upstream` (an act runner issue) and was open as of mid-2025. [CITED: github.com/go-gitea/gitea/issues/31007]

**How to avoid:** Check each upstream job individually:
```bash
if [ "${{ needs.fast-checks.result }}" != "success" ]; then exit 1; fi
```
Not:
```yaml
if: contains(needs.*.result, 'failure')
```

**Warning signs:** Gate job always fails even on a green code PR where all three jobs passed.

### Pitfall 3: `dorny/paths-filter@v4` needs `permissions: pull-requests: read`

**What goes wrong:** The `changes` job fails with a permission error when `dorny/paths-filter` tries to call the GitHub/Gitea REST API to list changed files.

**Why it happens:** v3+ of the action requires explicit `pull-requests: read` permission on the job. [CITED: github.com/dorny/paths-filter]

**How to avoid:** Add `permissions: pull-requests: read` to the `changes` job (not at the workflow level — keeping it scoped).

**Warning signs:** `changes` job fails with `403` or `Resource not accessible by integration` in the step log.

### Pitfall 4: Prettier and markdownlint both own blank-lines-around-fences (MD031)

**What goes wrong:** The `markdownlint/style/prettier` preset disables `blanks-around-fences` (MD031). If you re-enable it, markdownlint and Prettier may disagree on whether a blank line before a fence is required in certain contexts.

**Why it happens:** MD031 is in the prettier preset's disable list because Prettier has its own opinion about blank lines around code fences in markdown.

**How to avoid:** For this repo, MD031 is RE-ENABLED in the config because `docs/GETTING-STARTED.md` has a genuine structural issue (a fence immediately following a list item with no blank line), and fixing it (add blank lines) is also what Prettier would want. **Verify with `pnpm format:check` that fixing the MD031 violations does not introduce a Prettier conflict.** If conflicts emerge, disable MD031.

**Warning signs:** After fixing MD031 violations, `pnpm format:check` reports new failures on the same files.

### Pitfall 5: markdownlint-cli2 glob negation syntax

**What goes wrong:** Writing `!.planning/**` in the CLI invocation instead of `#.planning/**` causes a shell glob expansion error.

**Why it happens:** markdownlint-cli2 uses `#` as the glob negation prefix (not `!`). In shell, `!` is a history expansion character. On the command line, use `#`. In the config file `ignores:` array, `!` is also invalid — use the `ignores` array (no prefix needed, since ignores are always exclusions). [CITED: github.com/DavidAnson/markdownlint-cli2 README]

**How to avoid:** Use `pnpm md:lint` which calls the script from `package.json` where the `#` prefix is safely quoted. Or use the `globs`/`ignores` properties in `.markdownlint-cli2.jsonc`.

### Pitfall 6: `.pnpm-store/` in the markdown glob

**What goes wrong:** `pnpm`'s content-addressable store lands inside the workspace on this CI runner (confirmed in Phase 13 — Prettier's `--check .` hit 39 `.pnpm-store/` files). If the markdownlint glob is `**/*.md` without excluding `.pnpm-store/`, it will scan `.pnpm-store/` and either find no issues (luck) or find lint violations in third-party packages' markdown files.

**How to avoid:** The `ignores` list must include `node_modules/**` and `**/node_modules/**`. The pnpm store at `.pnpm-store/` does not contain `.md` files (it stores content-addressed binaries), but for safety, add `.pnpm-store/**` to ignores as well. [ASSUMED — the store likely doesn't contain .md files, but exclude it defensively]

**Warning signs:** `pnpm md:lint` in CI reports violations in paths starting with `.pnpm-store/` or `node_modules/`.

## Code Examples

### Complete `changes` job

```yaml
# Source: github.com/dorny/paths-filter (v4 README) — adapted for this repo
  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'
```

### Gate job

```yaml
# Source: devopsdirective.com/posts/2025/08/github-actions-required-checks-for-conditional-jobs
# Adapted: use individual needs.X.result (not contains()) due to Gitea #31007
  gate:
    runs-on: ubuntu-latest
    needs: [fast-checks, changes, api, harness]
    if: always()
    steps:
      - name: Check all required jobs passed or were skipped
        run: |
          if [ "${{ needs.fast-checks.result }}" != "success" ]; then
            echo "fast-checks: ${{ needs.fast-checks.result }}"
            exit 1
          fi
          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."
```

### Markdown lint step in fast-checks

```yaml
      - name: Markdown lint
        run: pnpm md:lint
```

### Root package.json script addition

```json
"md:lint": "markdownlint-cli2 \"docs/**/*.md\" \"*.md\" \"apps/**/*.md\" \"#.planning/**\" \"#node_modules/**\" \"#**/node_modules/**\""
```

### `.markdownlint-cli2.jsonc`

```jsonc
{
  "config": {
    "extends": "markdownlint/style/prettier",
    "MD001": true,
    "MD024": true,
    "MD040": true,
    "MD031": true,
    "MD051": true,
    "MD052": true,
    "MD041": false,
    "MD034": false,
    "MD036": false
  }
}
```

## State of the Art

| Old Approach | Current Approach | When Changed | Impact |
|--------------|------------------|--------------|--------|
| Require all 3 jobs directly in branch protection | Require `fast-checks` + always-running `gate` aggregate | This phase | Conditional jobs can be skipped without deadlocking branch protection |
| All PRs run full CI regardless of diff | Doc-only PRs skip `api`/`harness` | This phase | Doc PRs: ~30s instead of ~5min |
| No markdown lint | markdownlint-cli2 in fast-checks | This phase | Docs get a real format+lint gate |

**Deprecated/outdated:**

- `CI / api` and `CI / harness` as direct required checks — replaced by `CI / gate` aggregate after this phase.

## Baseline Violation Assessment

Running the recommended config against the current repo (12 files scanned, excluding `.planning/**`):

**Result: 13 violations across 7 files.** [VERIFIED: run 2026-06-12 via npx markdownlint-cli2]

| File | Rule | Count | 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 |

**Total: 13 violations, all mechanical one-liner fixes.** Sizing: one commit, 5–10 minutes of editing.

**Files NOT in scope (correctly excluded):**

- `.planning/**` — GSD tooling output, bypasses branch protection, excluded by design
- `CLAUDE.md` — project instructions file; MD041/MD034/MD036 violations exempted by rule config decisions above

**The baseline cleanup task is small and entirely mechanical** — it does not require judgment calls or restructuring.

## Gitea-Specific Behavior Notes

All items below are [CITED] from Gitea issue tracker or confirmed from Phase 8 probe results.

1. **Skipped job commit-status:** Gitea may not emit a commit-status context for a job skipped via job-level `if:`. A skipped job simply does not appear in the branch protection check list. This is the core reason the `gate` job is mandatory — it is the only always-reporting surface. [CITED: github.com/go-gitea/gitea/issues/36895]

2. **`if: always()` deadlock bug:** Fixed in Gitea 1.21.8 (PR #29464). This instance runs 1.26.2 — the fix is in place. `if: always()` on a job that `needs:` skipped upstream jobs will correctly run. [CITED: github.com/go-gitea/gitea/issues/27906]

3. **`contains(needs.*.result, ...)` bug:** Issue #31007 is labeled `type/upstream` and was still open as of mid-2025 with no confirmed fix in 1.26.2. Individual `needs.X.result` comparisons are safe and unaffected. [CITED: github.com/go-gitea/gitea/issues/31007]

4. **Branch protection required-check update is a MANUAL Gitea admin step:** The YAML changes alone are insufficient. An operator must visit Gitea → Repository Settings → Branches → Edit protection rule for `main`, remove `CI / api` and `CI / harness` from required checks, and add `CI / gate`. This cannot be done via a workflow file. (`tea` CLI can update branch protection via the API but requires `tea` setup and the relevant endpoint.) [ASSUMED — based on prior phase knowledge; confirm against the Gitea admin UI]

5. **`dorny/paths-filter@v4` resolution:** Actions in `.gitea/workflows/` using `uses: dorny/paths-filter@v4` resolve from `github.com` (confirmed in Phase 8 probe — `actions/checkout@v4` and `actions/setup-node@v4` clone from github.com on first run, ~60–75s cold start). No local mirror needed. [CITED: Phase 8 08-01-SUMMARY.md D-PROBE-08]

6. **`runs-on: ubuntu-latest`:** The runner advertises `ubuntu-latest`, not `self-hosted`. All jobs must use `runs-on: ubuntu-latest`. [CITED: Phase 8 08-01-SUMMARY.md D-PROBE-01]

## Open Questions

1. **`dorny/paths-filter@v4` requires `pull-requests: read` — does this repo's GITHUB_TOKEN have it?**
   - What we know: Gitea's `GITHUB_TOKEN` equivalent is auto-injected; the permission level depends on Gitea's token permission model.
   - What's unclear: Whether the default Gitea Actions token has `pull-requests: read` or if the `permissions:` declaration is required to elevate it.
   - Recommendation: Include `permissions: pull-requests: read` on the `changes` job explicitly regardless. If the token already has it, this is a no-op. If it doesn't, this is necessary.

2. **Does Gitea 1.26.2 emit a commit-status for `skipped` jobs?**
   - What we know: Issue #36895 documents the behavior where skipped jobs don't block but also don't report; issue #23599 (display-as-failed) was fixed in 1.19.1.
   - What's unclear: The exact behavior in 1.26.2 — skipped jobs may show in the UI as "skipped" but may not emit a commit-status that branch protection can read.
   - Recommendation: The `gate` job design is robust regardless of this answer. Do not rely on skipped-job status for branch protection.

3. **Should the `md:lint` script use the `globs` property in `.markdownlint-cli2.jsonc` instead of CLI args?**
   - What we know: Both work. Config-file globs are more portable and don't require shell quoting.
   - What's unclear: Whether having globs in the config file makes local developer runs (`npx markdownlint-cli2`) automatically scope correctly without extra args.
   - Recommendation: Put globs and ignores in the config file; make the root script just `markdownlint-cli2` (no args). This means `pnpm md:lint` and direct `npx markdownlint-cli2` both use the same scope.

## Environment Availability

| Dependency | Required By | Available | Version | Fallback |
|------------|------------|-----------|---------|----------|
| Gitea Actions runner | gate job execution | ✓ | ubuntu-latest (D-PROBE-01) | — |
| github.com resolution | `dorny/paths-filter@v4` | ✓ | Confirmed Phase 8 (D-PROBE-08) | — |
| `dorny/paths-filter@v4` | `changes` job | ✓ (inferred) | v4 | v3 (uses Node 20 instead of 24) |
| Node.js 22 | markdownlint-cli2 | ✓ | 22 LTS (via actions/setup-node) | — |
| markdownlint-cli2 | markdown lint step | ✓ | 0.22.1 (npm) | — |
| Gitea admin access | branch protection update | ✓ (operator) | — | tea CLI API |

**Missing dependencies with no fallback:** None.

## Validation Architecture

### Test Framework

| Property | Value |
|----------|-------|
| Framework | Gitea Actions CI (behavioral) |
| Config file | `.gitea/workflows/ci.yml` |
| Quick run command | Construct a doc-only PR; observe job statuses |
| Full suite command | Construct a code-touching PR; observe all jobs pass |

### Phase Requirements → Test Map

| Req ID | Behavior | Test Type | Automated Command | File Exists? |
|--------|----------|-----------|-------------------|-------------|
| SC-1 | Doc-only PR skips `api`/`harness`, runs `fast-checks` | CI behavioral | Open a PR with only `README.md` changed; observe Actions | ❌ Wave 0 (need test PR) |
| SC-2 | Code PR runs all three jobs; failure blocks merge | CI behavioral | Open a code PR; observe all three jobs run | ❌ Wave 0 (need test PR) |
| SC-3 | Branch protection requires `gate`, not `api`/`harness` directly | Manual verification | Check Gitea branch protection settings | Manual |
| SC-4 | markdownlint violation fails `fast-checks` | CI behavioral | Introduce a deliberate lint violation (bare code fence) in a PR | ❌ Wave 0 (need test) |
| SC-4b | Baseline passes (green gate) | Local | `pnpm md:lint` exits 0 after fixing 13 violations | ❌ Wave 0 (after baseline fix) |

### Wave 0 Gaps

- [ ] `.markdownlint-cli2.jsonc` — new file needed before lint step runs
- [ ] `package.json` root `md:lint` script — needed for CI step
- [ ] Fix 13 baseline violations — needed for `pnpm md:lint` to pass
- [ ] Gitea branch protection update — manual operator step, must be called out in the plan

*(If tests pass after these gaps are addressed: "Gate is green before Phase 16 starts")*

## Security Domain

The security surface of this phase is entirely CI workflow configuration. No user data or auth paths are involved.

| ASVS Category | Applies | Standard Control |
|---------------|---------|-----------------|
| V2 Authentication | no | — |
| V3 Session Management | no | — |
| V4 Access Control | no | — |
| V5 Input Validation | no | CI YAML is not user input |
| V6 Cryptography | no | — |

**CI security note:** `dorny/paths-filter@v4` is a third-party action that runs with `pull-requests: read` permission on the `GITHUB_TOKEN`. This is the minimal permission needed and does not grant write access to the repo or its secrets. The action does not receive any secrets beyond the token. This is standard for PR-triggered third-party actions and is consistent with the risk posture already accepted by `actions/checkout@v4` and `actions/setup-node@v4`. [ASSUMED — specific Gitea token permission model not verified; action's README confirms read-only usage]

## Assumptions Log

| # | Claim | Section | Risk if Wrong |
|---|-------|---------|---------------|
| A1 | `.pnpm-store/` does not contain `.md` files that would appear in the markdownlint glob | Pitfall 6 | markdownlint would scan store files; non-breaking but noisy |
| A2 | Gitea's default GITHUB_TOKEN has sufficient permission for dorny/paths-filter@v4 with explicit `permissions: pull-requests: read` declared | Pitfall 3 / Open Questions | If Gitea's token model ignores the permissions key, the action would need an explicit PAT — very unlikely given GitHub-compatibility goal |
| A3 | Updating required checks in branch protection requires a Gitea admin UI / API step (not automatable via workflow YAML) | Gitea-Specific Notes #4 | If Gitea has a workflow-driven protection update mechanism, the manual step could be automated — low risk |
| A4 | `dorny/paths-filter@v4` works on Gitea 1.26.2 (inferred from GitHub Actions compatibility + v3 confirmed mirrored on Gitea) | Standard Stack | If v4 has a GitHub-specific API call that Gitea 1.26.2 doesn't support, fall back to v3 |

**If this table is empty:** All claims in this research were verified or cited — no user confirmation needed. (It is not empty — A1-A4 are low risk but flagged.)

## Sources

### Primary (HIGH confidence)

- Phase 8 probe results (`08-01-SUMMARY.md`) — confirmed runner behavior: `ubuntu-latest`, Docker-executor, no mysql CLI, actions/cache unreliable, github.com action resolution works
- Gitea API `GET /api/v1/version` — confirmed Gitea 1.26.2
- `npx markdownlint-cli2` run (2026-06-12) — confirmed 13 violations with recommended config on current repo

### Secondary (MEDIUM confidence)

- [github.com/dorny/paths-filter README](https://github.com/dorny/paths-filter/blob/master/README.md) — PR event uses REST API; `permissions: pull-requests: read` required; no fetch-depth needed; v4 is current
- [github.com/DavidAnson/markdownlint Prettier.md](https://github.com/DavidAnson/markdownlint/blob/main/doc/Prettier.md) — 23 rules disabled by `markdownlint/style/prettier`
- [github.com/DavidAnson/markdownlint-cli2 README](https://github.com/DavidAnson/markdownlint-cli2/blob/main/README.md) — config file formats; `#` glob negation prefix
- [devopsdirective.com gate job pattern](https://devopsdirective.com/posts/2025/08/github-actions-required-checks-for-conditional-jobs/) — always-running aggregate gate job pattern
- [github.com/go-gitea/gitea #27906](https://github.com/go-gitea/gitea/issues/27906) — `if: always()` deadlock fixed in 1.21.8
- [github.com/go-gitea/gitea #31007](https://github.com/go-gitea/gitea/issues/31007) — `contains(needs.*.result, ...)` returns false on Gitea
- [github.com/go-gitea/gitea #36895](https://github.com/go-gitea/gitea/issues/36895) — skipped jobs via `on: paths` cause branch protection deadlock

### Tertiary (LOW confidence)

- General GitHub Actions `if: always()` gate pattern (training knowledge, confirmed by multiple secondary sources above)

## Metadata

**Confidence breakdown:**

- Doc-only detection mechanism: HIGH — `dorny/paths-filter@v4` is confirmed working and mirrored on Gitea; the PR API mode avoids fetch-depth issues
- Gate job pattern: HIGH — `if: always()` fix confirmed in 1.21.8; individual `needs.X.result` checks are the safe Gitea workaround for the wildcard bug
- markdownlint config: HIGH — config tested against actual repo, 13 violations confirmed, all fixable
- Branch protection update: MEDIUM — confirmed manual step; exact UI path not re-verified in this research session

**Research date:** 2026-06-12
**Valid until:** 2027-01-01 (stable tooling; Gitea bug status should be re-checked if the instance is upgraded past 1.26.x before execution)
