---
phase: 15-ci-skip-api-harness-jobs-for-doc-only-prs
plan: 02
type: execute
wave: 2
depends_on:
- 15-01
files_modified:
- .gitea/workflows/ci.yml
autonomous: true
requirements: []
must_haves:
truths:
- "A doc-only PR to main skips the api and harness jobs but still runs fast-checks"
- "A PR touching code runs fast-checks, api, and harness; a failure in any blocks the gate"
- "An always-running gate job reports CI / gate and passes only when fast-checks succeeded and each heavy job is success OR skipped"
- "The gate fails (exit 1) when fast-checks fails or when api/harness fails or is cancelled"
artifacts:
- path: ".gitea/workflows/ci.yml"
provides: "changes job (paths-filter), conditional api/harness, always-running gate aggregate"
contains: "dorny/paths-filter@v4"
key_links:
- from: "ci.yml api/harness jobs"
to: "ci.yml changes job output"
via: "needs: [changes] + if needs.changes.outputs.code == 'true'"
pattern: "needs.changes.outputs.code"
- from: "ci.yml gate job"
to: "ci.yml fast-checks/api/harness results"
via: "needs: [fast-checks, changes, api, harness] + if: always() + per-job needs.X.result checks"
pattern: "needs.fast-checks.result"
---
Restructure `.gitea/workflows/ci.yml` so doc-only PRs skip the slow `api` and `harness` jobs without deadlocking branch protection: add a `changes` job (`dorny/paths-filter@v4`) that emits a `code` output, gate `api`/`harness` on `needs.changes.outputs.code == 'true'`, and add an always-running `gate` aggregate job that branch protection can require in place of the heavy jobs directly.
This delivers Success Criteria 1 and 2 fully, and lands the `CI / gate` status that Success Criterion 3 (Plan 03's branch-protection checkpoint) requires to exist first.
Purpose: Doc-only PRs go from ~5 min to ~30s while a single always-reporting `gate` keeps the merge gated. The conditional skip and the gate are interdependent and ship together in one PR so the heavy jobs can actually be skipped while the gate still reports.
Output: One modified file — `ci.yml` with a new `changes` job, `needs`/`if` on `api` and `harness`, and a new `gate` job.
@$HOME/.claude/gsd-core/workflows/execute-plan.md
@$HOME/.claude/gsd-core/templates/summary.md
@.planning/PROJECT.md
@.planning/ROADMAP.md
@.planning/STATE.md
@.planning/phases/15-ci-skip-api-harness-jobs-for-doc-only-prs/15-RESEARCH.md
@.planning/phases/15-ci-skip-api-harness-jobs-for-doc-only-prs/15-PATTERNS.md
Task 1: Add the changes job and gate api/harness on the code output
- .gitea/workflows/ci.yml (FULL file — current job names `fast-checks`/`api`/`harness`; api header lines 40-44 with existing `if: github.event_name == 'pull_request'`; harness header lines 126-129; runner label `ubuntu-latest`; both heavy jobs run in parallel with no `needs:` today)
- .planning/phases/15-ci-skip-api-harness-jobs-for-doc-only-prs/15-RESEARCH.md (Pattern 1 changes job + Pattern 2 conditional heavy jobs + "Code Examples → Complete changes job"; Pitfall 3 permissions; Gitea-Specific Notes #5/#6 action resolution + runner label)
- .planning/phases/15-ci-skip-api-harness-jobs-for-doc-only-prs/15-PATTERNS.md ("`changes` job (new)", "`api` job modification", "`harness` job modification" — exact YAML and insertion ordering: changes BEFORE fast-checks)
Insert a new `changes` job (use the exact YAML from RESEARCH "Code Examples → Complete changes job" / PATTERNS "`changes` job (new)") positioned BEFORE `fast-checks` so the UI ordering reads changes → fast-checks/api/harness → gate. The job: `runs-on: ubuntu-latest`; `if: github.event_name == 'pull_request'`; `permissions: pull-requests: read` (job-scoped, required by paths-filter v4 — Pitfall 3); `outputs.code: ${{ steps.filter.outputs.code }}`; a single step `uses: dorny/paths-filter@v4` with `id: filter`. The `code` filter must list exactly the positive code patterns from RESEARCH: `**/*.ts`, `**/*.tsx`, `**/*.js`, `**/*.json`, `**/*.yaml`, `**/*.yml`, `apps/**`, `packages/**`, `pnpm-lock.yaml`, `Dockerfile`, `docker-compose*.yml`. Define the POSITIVE `code` filter (not a `docs` filter) so `code == 'false'` means doc-only and any new/ambiguous file type defaults to the full gate. No `actions/checkout` and no `fetch-depth` — paths-filter uses the PR REST API on `pull_request` events.
Modify the `api` job header: add `needs: [changes]` and change its `if` to `github.event_name == 'pull_request' && needs.changes.outputs.code == 'true'`. EXTEND the existing event guard with `&&` — do not replace it (replacing loses the event-type guard). Update the inline comment to say the job is skipped for doc-only PRs. Change nothing else in the api job body (services, env, steps, mariadb wait, migrate, test).
Modify the `harness` job header identically: `needs: [changes]` + the same combined `if`. Update its comment; change nothing else in the harness body.
Pin the third-party action to the `@v4` tag exactly as written (`dorny/paths-filter@v4`); do not float to a branch.
command -v yq >/dev/null 2>&1 && yq -e '.jobs.changes' .gitea/workflows/ci.yml >/dev/null && yq -e '.jobs.changes.outputs.code' .gitea/workflows/ci.yml >/dev/null && yq -e '.jobs.api.needs | contains(["changes"])' .gitea/workflows/ci.yml >/dev/null && yq -e '.jobs.harness.needs | contains(["changes"])' .gitea/workflows/ci.yml >/dev/null && grep -q 'dorny/paths-filter@v4' .gitea/workflows/ci.yml && grep -q "needs.changes.outputs.code == 'true'" .gitea/workflows/ci.yml && echo OK
- ci.yml contains a job named `changes` using `dorny/paths-filter@v4` with `id: filter`
- the `changes` job declares `permissions: pull-requests: read` (job-scoped) and `outputs.code: ${{ steps.filter.outputs.code }}`
- the `changes` job has NO `actions/checkout` step
- the `code` filter lists the positive code patterns (`**/*.ts`, `apps/**`, `pnpm-lock.yaml`, `Dockerfile`, `docker-compose*.yml`, etc.) — not a `docs` filter
- the `api` job has `needs: [changes]` and its `if` is `github.event_name == 'pull_request' && needs.changes.outputs.code == 'true'`
- the `harness` job has `needs: [changes]` and the same combined `if`
- the api/harness `services`, `env`, and step bodies are unchanged from the pre-edit file
- ci.yml parses as valid YAML (`yq` exits 0)
The `changes` job classifies each PR; `api` and `harness` skip on doc-only PRs and run on code PRs. SC-1/SC-2 job-skip behavior is wired.
Task 2: Add the always-running gate aggregate job (Gitea-safe per-job result checks)
- .gitea/workflows/ci.yml (post-Task-1 state — confirm final job names `fast-checks`/`changes`/`api`/`harness`; gate goes at end of file)
- .planning/phases/15-ci-skip-api-harness-jobs-for-doc-only-prs/15-RESEARCH.md (Pattern 3 gate job + "Code Examples → Gate job"; Pitfall 2 — `contains(needs.*.result, ...)` is BROKEN on Gitea 1.26.2 issue #31007; Gitea-Specific Notes #1 skipped-status quirk, #2 if:always() fixed in 1.21.8, #3 contains bug)
- .planning/phases/15-ci-skip-api-harness-jobs-for-doc-only-prs/15-PATTERNS.md ("`gate` job (new)" — exact YAML and the publish.yml `if: always()` analog)
Append a new `gate` job at the end of ci.yml using the exact YAML from RESEARCH "Code Examples → Gate job" / PATTERNS "`gate` job (new)". The job: `runs-on: ubuntu-latest`; `needs: [fast-checks, changes, api, harness]`; `if: always()` (job-scoped — without it a skipped upstream skips the gate and branch protection on `CI / gate` deadlocks; the if:always() deadlock bug was fixed in Gitea 1.21.8 and this instance is 1.26.2). One step `Check all required jobs passed or were skipped` running a bash script that: (1) fails with exit 1 if `${{ needs.fast-checks.result }}` is not `success` (fast-checks always runs — never skipped); (2) loops over `${{ needs.api.result }}` and `${{ needs.harness.result }}` and fails with exit 1 if either is neither `success` NOR `skipped`; (3) echoes a pass message otherwise.
CRITICAL Gitea 1.26.2 constraint: do NOT use `contains(needs.*.result, 'success')` or any `needs.*.result` wildcard — issue #31007 makes the wildcard return false even when jobs succeed. Reference each job result individually via `needs.fast-checks.result`, `needs.api.result`, `needs.harness.result`. Treat `skipped` as acceptable ONLY for `api`/`harness` (the conditionally-skippable jobs), never for `fast-checks`. Do not add `changes` to the pass/fail evaluation logic — it is in `needs` for ordering only; its result is not gated (a failure there already fails downstream `if` evaluation).
This is the change-set that, once merged, makes Gitea start emitting a `CI / gate` commit-status — the prerequisite for Plan 03's branch-protection update. Do not touch branch protection here.
command -v yq >/dev/null 2>&1 && yq -e '.jobs.gate' .gitea/workflows/ci.yml >/dev/null && yq -e '.jobs.gate.needs | contains(["fast-checks","changes","api","harness"])' .gitea/workflows/ci.yml >/dev/null && yq -e '.jobs.gate.if == "always()"' .gitea/workflows/ci.yml >/dev/null && grep -q 'needs.fast-checks.result' .gitea/workflows/ci.yml && grep -q 'needs.api.result' .gitea/workflows/ci.yml && grep -q 'needs.harness.result' .gitea/workflows/ci.yml && ! grep -q 'needs.\*.result' .gitea/workflows/ci.yml && echo OK
- ci.yml contains a job named `gate` with `if: always()` and `needs: [fast-checks, changes, api, harness]`
- the gate references `needs.fast-checks.result`, `needs.api.result`, and `needs.harness.result` individually
- the gate does NOT contain `contains(needs.*.result` or any `needs.*.result` wildcard
- the gate fails (exit 1) when fast-checks != success
- the gate accepts `success` OR `skipped` for api and harness, and fails on any other result
- ci.yml parses as valid YAML (`yq` exits 0)
An always-running `gate` job aggregates the four jobs using Gitea-safe per-job result checks, passing when fast-checks succeeds and each heavy job is success-or-skipped. Once merged it emits `CI / gate`, unblocking Plan 03. SC-3's gating surface exists in YAML.
This phase introduces the following new symbols (Plan 02 portion). The plan-review source-grounding pass must treat these as newly-created, not drift:
- `changes` — new ci.yml job name (dorny/paths-filter@v4)
- `code` — new paths-filter output name consumed by `api`/`harness` `if:`
- `gate` — new ci.yml aggregate job name (the always-running required surface)
- `CI / gate` — new commit-status context Gitea emits once this lands (required by Plan 03)
- `dorny/paths-filter@v4` — newly-referenced third-party action
## Trust Boundaries
| Boundary | Description |
|----------|-------------|
| PR author → CI gating decision | a PR's changed-file set decides whether the heavy code jobs run — the core security-relevant invariant of this phase |
| github.com → CI runner | `dorny/paths-filter@v4` is a third-party action resolved and executed on the runner with a scoped token |
## STRIDE Threat Register
| Threat ID | Category | Component | Disposition | Mitigation Plan |
|-----------|----------|-----------|-------------|-----------------|
| T-15-04 | Elevation of Privilege / bypass (CORE) | a misconfigured gate passes when fast-checks fails, letting unreviewed code merge | mitigate | gate fails (exit 1) on `fast-checks != success`; `skipped` accepted ONLY for api/harness, never fast-checks; uses individual `needs.X.result` (not the broken Gitea `contains(needs.*.result)`) so it cannot silently always-pass (Pitfall 2). |
| T-15-05 | Spoofing (skip-detection) | a PR that actually changes code is misclassified doc-only and skips api/harness | mitigate | `code` is the POSITIVE filter — any new/ambiguous file type matches `code` and runs the full gate; doc-only requires EVERY changed file to fall outside the code patterns; `code` includes `**/*.ts`, `apps/**`, lockfile, Dockerfile, compose. |
| T-15-06 | Tampering (supply chain) | `dorny/paths-filter@v4` third-party action | mitigate | Pinned to the `@v4` tag; RESEARCH Package Legitimacy Audit verdict OK (~5 yrs, widely used, github.com/dorny/paths-filter); runs with `pull-requests: read` only — no write, no secrets access (RESEARCH Security Domain). |
| T-15-07 | Denial of Service (deadlock) | a required check that never reports blocks the PR forever | mitigate | api/harness are NEVER added as required checks (Plan 03); the only gating surfaces are `fast-checks` (always runs) and `gate` (`if: always()`, always reports); no workflow-level `on: paths` filter on any required job. |
| T-15-SC | Tampering | npm/action installs | mitigate | No package-manager install task in this plan (paths-filter is a `uses:` action, not an npm dep; markdownlint-cli2 install is Plan 01). RESEARCH Package Legitimacy Audit present; no `[ASSUMED]`/`[SUS]` packages → no blocking-human checkpoint required. |
Behavioral (observable in CI after this PR merges and on a follow-up test PR — see 15-VALIDATION.md):
- Doc-only PR: `changes` emits `code=false`; `api`/`harness` show skipped; `fast-checks` runs; `gate` passes.
- Code PR: `changes` emits `code=true`; all three run; `gate` passes when green, fails when any heavy job fails.
- Gate-fail path: a deliberately failing fast-checks (or api/harness) makes `gate` exit 1 → merge blocked.
Static (pre-merge):
- `yq` parses ci.yml; `changes`/`gate` jobs present; api/harness carry `needs: [changes]` + combined `if`; gate uses individual `needs.X.result`, no wildcard.
Maps to Phase 15 Success Criteria 1, 2, and the YAML half of 3:
- SC-1: doc-only PR skips api/harness, still runs fast-checks.
- SC-2: code PR runs all three; a failure blocks the merge (via the gate).
- SC-3 (YAML half): an always-running `CI / gate` aggregate exists that passes when each heavy job is success-or-skipped; the branch-protection required-check change is Plan 03.