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
lilleman marked this conversation as resolved Outdated
Outdated
Review

Remove this comment, it is the inner workings of ci.sh and should be obvious if you read the shell scripts code. It is also not very important to know when you are day to day developing.

Remove this comment, it is the inner workings of ci.sh and should be obvious if you read the shell scripts code. It is also not very important to know when you are day to day developing.
# ff-only merges make every branch head a main commit - one without an image is unreleasable.
lilleman marked this conversation as resolved Outdated
Outdated
Review

There is a bunch of scripts here in "run". Could this be written in the ci.sh script instead, to make this all testable locally?

There is a bunch of scripts here in "run". Could this be written in the ci.sh script instead, to make this all testable locally?
- 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*
lilleman marked this conversation as resolved Outdated
Outdated
Review

Does this really help anyone? If someone really wants to know why some runs went faster than others, then can quickliy find out why by looking at the logs or reading the ci.sh script. This big blob of text in the README only makes it cluttered and harder to consume for both humans and machines. Please fix this and also adjust the projects AGENTS.md to not do this kind of documentation in these places again.

Does this really help anyone? If someone really wants to know why some runs went faster than others, then can quickliy find out why by looking at the logs or reading the ci.sh script. This big blob of text in the README only makes it cluttered and harder to consume for both humans and machines. Please fix this and also adjust the projects AGENTS.md to not do this kind of documentation in these places again.
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
lilleman marked this conversation as resolved Outdated
Outdated
Review

More of the same as the previous comment. Look through the README to see if there are more stuff like this we can remove that is not relevant for the day to day developer and can be easily found out if you dig into the code.

More of the same as the previous comment. Look through the README to see if there are more stuff like this we can remove that is not relevant for the day to day developer and can be easily found out if you dig into the code.
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");
});