Files
familysync/.planning/milestones/v1.1-phases/15-ci-skip-api-harness-jobs-for-doc-only-prs/15-02-PLAN.md
T
2026-06-18 22:21:38 -04:00

14 KiB

phase, plan, type, wave, depends_on, files_modified, autonomous, requirements, must_haves
phase plan type wave depends_on files_modified autonomous requirements must_haves
15-ci-skip-api-harness-jobs-for-doc-only-prs 02 execute 2
15-01
.gitea/workflows/ci.yml
true
truths artifacts key_links
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
path provides contains
.gitea/workflows/ci.yml changes job (paths-filter), conditional api/harness, always-running gate aggregate dorny/paths-filter@v4
from to via pattern
ci.yml api/harness jobs ci.yml changes job output needs: [changes] + if needs.changes.outputs.code == 'true' needs.changes.outputs.code
from to via pattern
ci.yml gate job ci.yml fast-checks/api/harness results needs: [fast-checks, changes, api, harness] + if: always() + per-job needs.X.result checks 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.

<execution_context> @$HOME/.claude/gsd-core/workflows/execute-plan.md @$HOME/.claude/gsd-core/templates/summary.md </execution_context>

@.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.

<artifacts_produced> 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 </artifacts_produced>

<threat_model>

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.
</threat_model>
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.

<success_criteria> 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. </success_criteria>
Create `.planning/phases/15-ci-skip-api-harness-jobs-for-doc-only-prs/15-02-SUMMARY.md` when done.