Skip the test gate on docs-only branches #34

Merged
lilleman merged 3 commits from ci-docs-only-skip into main 2026-08-02 14:08:55 +02:00
5 changed files with 73 additions and 61 deletions
Showing only changes of commit 175717f04d - Show all commits
+4 -21
View File
@@ -9,27 +9,10 @@ jobs:
steps:
- uses: actions/checkout@v4.2.2
with:
fetch-depth: 0 # the merge-base below needs history; checkout defaults to depth 1
- name: Detect a docs-only branch
id: scope
run: |
git fetch --no-tags --quiet origin +refs/heads/main:refs/remotes/origin/main
base=$(git merge-base refs/remotes/origin/main HEAD) \
|| { echo 'No merge-base with main - running the gate'; echo 'docs_only=false' >> "$GITHUB_OUTPUT"; exit 0; }
changed=$(git diff --name-only "$base" HEAD)
printf '%s\n' "$changed"
# Skip only when there IS a diff and every path is *.md. An empty diff, an unreadable
# range, anything else - fall through to the gate.
if [ -n "$changed" ] && ! printf '%s\n' "$changed" | grep -qvE '\.md$'; then
echo 'docs_only=true' >> "$GITHUB_OUTPUT"
else
echo 'docs_only=false' >> "$GITHUB_OUTPUT"
fi
- name: Full gate
if: steps.scope.outputs.docs_only != 'true'
run: bash ci.sh
# Never gated on docs_only: release.yml re-tags this exact image, and ff-only merges make
# every branch head a main commit - a main commit without an image is an unreleasable one.
fetch-depth: 0 # ci.sh's docs-only check needs history; checkout defaults to depth 1
- run: bash ci.sh
# Runs even when ci.sh no-ops on a docs-only branch: release.yml re-tags this exact image, and
# ff-only merges make every branch head a main commit - one without an image is unreleasable.
- name: Push app image tagged with the commit hash
env:
IMAGE: gitea.larvit.se/${{ github.repository }}:${{ github.sha }}
+14 -8
View File
@@ -1166,6 +1166,12 @@ Each E2E suite **owns a clean stack** — never point two suites at one backend
revokes the admin's sessions; full-flow writes users/groups/roles to Keto), which is why the
gate runs them serially, one stack up/down per suite.
It **no-ops on a docs-only change** — when every path that differs from `main` (committed *and*
uncommitted) ends in `.md`, there is nothing here to break, so it prints why and exits 0.
Anything it can't determine — no reachable `main`, no merge-base, offline — runs the full gate,
never a skip. The check lives here rather than in the workflow, so `bash ci.sh` on your branch
tells you exactly what CI will do.
## CI/CD
Gitea Actions (`.gitea/workflows/`) runs the pipeline; the test job runs
@@ -1173,7 +1179,7 @@ Gitea Actions (`.gitea/workflows/`) runs the pipeline; the test job runs
| Workflow | Trigger | Does |
| --- | --- | --- |
| `ci.yml` | push, any branch except `main` | the full gate (`bash ci.sh`) — skipped on a docs-only branch then build + push the app image |
| `ci.yml` | push, any branch except `main` | the full gate (`bash ci.sh`, a no-op on a docs-only branch), then build + push the app image |
| `release.yml` | push of a `vX.Y.Z` tag | re-tag that commit's image as `X.Y.Z`, `X.Y`, `X`, `latest`; sync those tags to Docker Hub |
| `mirror.yml` | push to `main` or any tag, or manual | force-push `main` + tags to the [GitHub mirror](https://github.com/larvit/plainpages) |
| `registry-cleanup.yml` | nightly cron, or manual | delete registry images that are neither release-tagged nor a branch head |
@@ -1188,11 +1194,11 @@ no repo files involved): direct pushes are blocked, changes land via PR only, th
**fast-forward-only** — history stays linear and `main`'s head is the exact commit hash of
the merged branch, which is why the branch's push-triggered status carries over.
**Docs-only branches skip the gate** — when every path a branch changes against its merge-base
with `main` ends in `.md`, `ci.yml` skips `bash ci.sh`. The job itself still runs and still
builds + pushes the commit-hash image, so `CI / full-gate (push)` reports green and neither the
merge gate nor `release.yml` notices the difference. Everything else in a diff — code, compose,
Ory config, the workflows themselves — runs the full gate, as does an empty or unreadable diff.
**Docs-only branches skip the gate** — [`ci.sh` no-ops](#the-full-gate-one-command) when a branch
changes nothing but `*.md`. The job itself still runs and still builds + pushes the commit-hash
image, so `CI / full-gate (push)` reports green and neither the merge gate nor `release.yml`
notices the difference. This is why `ci.yml` checks out with `fetch-depth: 0` — a shallow clone
has no merge-base to compare against.
**Container images** — after a green gate, `ci.yml` builds the app image and pushes it to the
Gitea container registry as `gitea.larvit.se/larvit/plainpages:<full commit hash>`. Because
@@ -1499,8 +1505,8 @@ ory/ Ory service config (kratos/: identity schema, kratos.yml, o
plugins/ Drop-in plugin folders (scanned at /app/plugins; bind-mount or bake in). Ships empty (.gitkeep, git-ignored otherwise) — mount your own; the E2E suites bind-mount the example plugins onto /app/plugins/scheduling and /app/plugins/admin
examples/ Copy-in reference material, mirroring the mount dirs: plugins/scheduling/ (the reference plugin — list/form over an upstream + permission-gated nav), plugins/admin/ (the system-admin plugin — Users/Groups/Roles/OAuth2-clients over Ory via ctx.system), both copied into plugins/; and config/menu.ts (the menu/branding template copied into config/); shifts-upstream/ is the dev mock backend the scheduling plugin reads/writes (stand-in for your real service)
e2e-tests/ Playwright E2E: visual.spec (design system, Ory-free) + auth-refresh.spec (token timeout/re-mint) + oauth-login.spec (OAuth2 login + consent) + full-flow.spec (browser UI: password/SSO login, menu-by-role, admin CRUD, plugin page, logout) + devstack-login.spec (regression: login works from the banner's localhost URL and 127.0.0.1 is canonicalised, on the plain `docker compose up` topology); proxy.ts (same-origin gateway) + mock-oidc.ts (mock SSO provider) back full-flow. e2e-tests/Dockerfile + e2e-tests/compose.{visual,auth,oauth,full,devstack}.yml run them
ci.sh The full CI gate: typecheck → unit tests → every E2E suite, each on a fresh, always-torn-down stack (`bash ci.sh`)
.gitea/workflows/ Gitea Actions: ci.yml — the full gate (ci.sh) on every branch push except main, skipped for docs-only branches;
ci.sh The full CI gate: typecheck → unit tests → every E2E suite, each on a fresh, always-torn-down stack (`bash ci.sh`); no-ops on a docs-only change
.gitea/workflows/ Gitea Actions: ci.yml — the full gate (ci.sh) on every branch push except main;
mirror.yml — force-sync main + tags to the GitHub mirror; see CI/CD
README-dockerhub.md The Docker Hub repository description (docker.io/larvit/plainpages) — pasted into the Docker Hub overview by hand when it changes; see CI/CD
```
+21
View File
@@ -12,6 +12,27 @@ cd "$(dirname "$0")"
step() { printf '\n\033[1;34m==> %s\033[0m\n' "$1"; }
# Docs-only fast path: when nothing but *.md changed since main there is nothing here to break,
# so the gate no-ops. Committed changes AND the working tree both count — a dirty tree carrying
# real code must never skip. Anything that can't be determined (no git, no reachable main, no
# merge-base, offline with no origin/main) falls through to the full gate, never to a skip.
docs_only() {
local base changed
git rev-parse --git-dir >/dev/null 2>&1 || return 1
git fetch --no-tags --quiet origin +refs/heads/main:refs/remotes/origin/main 2>/dev/null || true
base=$(git merge-base refs/remotes/origin/main HEAD 2>/dev/null) || return 1
changed=$(
{ git diff --name-only "$base" HEAD && git status --porcelain --untracked-files=all | cut -c4-; } 2>/dev/null
) || return 1
[ -n "$changed" ] || return 1
! printf '%s\n' "$changed" | grep -qvE '\.md$'
}
if docs_only; then
step "Only *.md changed since main — nothing to test, skipping the gate"
exit 0
fi
# Pins that MUST move in lockstep: a browser/runner mismatch yields confusing E2E failures.
step "Playwright pin lockstep (e2e-tests/Dockerfile image == e2e-tests/package.json @playwright/test)"
# `|| true` so a no-match doesn't trip `set -e`/`pipefail` before the explicit check below can report.
+34
View File
@@ -0,0 +1,34 @@
// Guards the docs-only fast path: `ci.sh` no-ops when nothing but *.md changed since main. The
// decision lives in ci.sh alone so `bash ci.sh` reproduces CI locally, and the workflow must still
// push the commit-hash image when it no-ops — release.yml re-tags that exact image, and
// fast-forward-only merges make every branch head a main commit.
import { test } from "node:test";
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
const read = (p: string) => readFileSync(new URL(`../${p}`, import.meta.url), "utf8");
const workflow = read(".gitea/workflows/ci.yml");
const gate = read("ci.sh");
const step = (needle: string) => {
const found = workflow.split("\n - ").slice(1).filter((s) => s.includes(needle));
assert.equal(found.length, 1, `exactly one workflow step contains ${needle}`);
return found[0]!;
};
test("the skip decision lives in ci.sh, so the workflow only runs it", () => {
assert.match(gate, /docs_only\(\)/);
assert.doesNotMatch(workflow, /docs_only|merge-base|GITHUB_OUTPUT/);
});
test("checkout is unshallow — the docs-only check needs the branch's history", () => {
assert.match(step("actions/checkout"), /fetch-depth: 0/);
});
test("the commit-hash image is pushed even when the gate no-ops", () => {
assert.doesNotMatch(step("docker push"), /^\s*if:/m);
});
test("only *.md counts as docs, and a dirty working tree counts as changed", () => {
assert.ok(gate.includes("\\.md$"), "the non-docs match is a *.md suffix test");
assert.match(gate, /git status --porcelain/, "uncommitted code can never be skipped over");
});
-32
View File
@@ -1,32 +0,0 @@
// Guards the docs-only fast path in .gitea/workflows/ci.yml: a branch that only touches *.md
// skips `ci.sh`, but must still publish its commit-hash image — release.yml re-tags that exact
// image, and fast-forward-only merges make every branch head a main commit.
import { test } from "node:test";
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
const ci = readFileSync(new URL("../.gitea/workflows/ci.yml", import.meta.url), "utf8");
const steps = ci.split("\n - ").slice(1);
const step = (needle: string) => {
const found = steps.filter((s) => s.includes(needle));
assert.equal(found.length, 1, `exactly one step contains ${needle}`);
return found[0]!;
};
test("checkout is unshallow — the merge-base with main needs the branch's history", () => {
assert.match(step("actions/checkout"), /fetch-depth: 0/);
});
test("a docs-only branch skips the test gate", () => {
assert.match(step("bash ci.sh"), /if: steps\.scope\.outputs\.docs_only != 'true'/);
});
test("a docs-only branch still pushes its commit-hash image", () => {
assert.doesNotMatch(step("docker push"), /^\s*if:/m);
});
test("only *.md counts as docs, and anything unexpected runs the gate", () => {
const detect = step("docs_only=");
assert.ok(detect.includes("\\.md$"), "the non-docs match is a *.md suffix test");
assert.ok(detect.includes("docs_only=false"), "the fallback is the full gate, never a skip");
});