Files
familysync/.planning/phases/15-ci-skip-api-harness-jobs-for-doc-only-prs/15-RESEARCH.md
T

38 KiB
Raw Blame History

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):

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

Version verification:

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

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):

# 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:

  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):

  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"):

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

Or, preferred via a root script:

"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:

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

if [ "${{ needs.fast-checks.result }}" != "success" ]; then exit 1; fi

Not:

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

# 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

# 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

      - name: Markdown lint
        run: pnpm md:lint

Root package.json script addition

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

.markdownlint-cli2.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, 510 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, ~6075s 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)

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)