38 KiB
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
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):
# 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: pathsfilter on required jobs: Iffast-checks(or any required check) has anon: 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/harnessdirectly 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. Thegatejob is the only correct gating surface for skippable jobs. contains(needs.*.result, 'success')in Gitea: Returnsfalseeven when jobs succeed in Gitea 1.26.2. Use individualneeds.X.resultstring comparisons. [CITED: github.com/go-gitea/gitea/issues/31007]- Not combining
github.event_namecheck withneeds.changes.outputs.code: Theapi/harnessjobs' existingifcondition isgithub.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/pwaorapps/apipackages 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 / apiandCI / harnessas direct required checks — replaced byCI / gateaggregate 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 designCLAUDE.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.
-
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 thegatejob is mandatory — it is the only always-reporting surface. [CITED: github.com/go-gitea/gitea/issues/36895] -
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 thatneeds:skipped upstream jobs will correctly run. [CITED: github.com/go-gitea/gitea/issues/27906] -
contains(needs.*.result, ...)bug: Issue #31007 is labeledtype/upstreamand was still open as of mid-2025 with no confirmed fix in 1.26.2. Individualneeds.X.resultcomparisons are safe and unaffected. [CITED: github.com/go-gitea/gitea/issues/31007] -
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, removeCI / apiandCI / harnessfrom required checks, and addCI / gate. This cannot be done via a workflow file. (teaCLI can update branch protection via the API but requiresteasetup and the relevant endpoint.) [ASSUMED — based on prior phase knowledge; confirm against the Gitea admin UI] -
dorny/paths-filter@v4resolution: Actions in.gitea/workflows/usinguses: dorny/paths-filter@v4resolve fromgithub.com(confirmed in Phase 8 probe —actions/checkout@v4andactions/setup-node@v4clone from github.com on first run, ~60–75s cold start). No local mirror needed. [CITED: Phase 8 08-01-SUMMARY.md D-PROBE-08] -
runs-on: ubuntu-latest: The runner advertisesubuntu-latest, notself-hosted. All jobs must useruns-on: ubuntu-latest. [CITED: Phase 8 08-01-SUMMARY.md D-PROBE-01]
Open Questions
-
dorny/paths-filter@v4requirespull-requests: read— does this repo's GITHUB_TOKEN have it?- What we know: Gitea's
GITHUB_TOKENequivalent 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: reador if thepermissions:declaration is required to elevate it. - Recommendation: Include
permissions: pull-requests: readon thechangesjob explicitly regardless. If the token already has it, this is a no-op. If it doesn't, this is necessary.
- What we know: Gitea's
-
Does Gitea 1.26.2 emit a commit-status for
skippedjobs?- 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
gatejob design is robust regardless of this answer. Do not rely on skipped-job status for branch protection.
-
Should the
md:lintscript use theglobsproperty in.markdownlint-cli2.jsoncinstead 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 meanspnpm md:lintand directnpx markdownlint-cli2both 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 runspackage.jsonrootmd:lintscript — needed for CI step- Fix 13 baseline violations — needed for
pnpm md:lintto 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-cli2run (2026-06-12) — confirmed 13 violations with recommended config on current repo
Secondary (MEDIUM confidence)
- github.com/dorny/paths-filter README — PR event uses REST API;
permissions: pull-requests: readrequired; no fetch-depth needed; v4 is current - github.com/DavidAnson/markdownlint Prettier.md — 23 rules disabled by
markdownlint/style/prettier - github.com/DavidAnson/markdownlint-cli2 README — config file formats;
#glob negation prefix - devopsdirective.com gate job pattern — always-running aggregate gate job pattern
- github.com/go-gitea/gitea #27906 —
if: always()deadlock fixed in 1.21.8 - github.com/go-gitea/gitea #31007 —
contains(needs.*.result, ...)returns false on Gitea - github.com/go-gitea/gitea #36895 — skipped jobs via
on: pathscause 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@v4is 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; individualneeds.X.resultchecks 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)