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
3 changed files with 62 additions and 3 deletions
Showing only changes of commit 6c850b8923 - Show all commits
+22 -1
View File
@@ -8,7 +8,28 @@ jobs:
runs-on: docker-host runs-on: docker-host
steps: steps:
- uses: actions/checkout@v4.2.2 - uses: actions/checkout@v4.2.2
- run: bash ci.sh with:
fetch-depth: 0 # the merge-base below needs history; checkout defaults to depth 1
- name: Detect a docs-only branch
id: scope
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.
run: |
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?
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.
- name: Push app image tagged with the commit hash - name: Push app image tagged with the commit hash
env: env:
IMAGE: gitea.larvit.se/${{ github.repository }}:${{ github.sha }} IMAGE: gitea.larvit.se/${{ github.repository }}:${{ github.sha }}
+8 -2
View File
1
@@ -1173,7 +1173,7 @@ Gitea Actions (`.gitea/workflows/`) runs the pipeline; the test job runs
| Workflow | Trigger | Does | | Workflow | Trigger | Does |
| --- | --- | --- | | --- | --- | --- |
| `ci.yml` | push, any branch except `main` | the full gate (`bash ci.sh`), then build + push the app image | | `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 |
| `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 | | `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) | | `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 | | `registry-cleanup.yml` | nightly cron, or manual | delete registry images that are neither release-tagged nor a branch head |
@@ -1188,6 +1188,12 @@ 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 **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. 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.
**Container images** — after a green gate, `ci.yml` builds the app image and pushes it to the **Container images** — after a green gate, `ci.yml` builds the app image and pushes it to the
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.
Gitea container registry as `gitea.larvit.se/larvit/plainpages:<full commit hash>`. Because Gitea container registry as `gitea.larvit.se/larvit/plainpages:<full commit hash>`. Because
merges are fast-forward-only, the image for any `main` commit already exists — it was built merges are fast-forward-only, the image for any `main` commit already exists — it was built
@@ -1494,7 +1500,7 @@ plugins/ Drop-in plugin folders (scanned at /app/plugins; bind-mount
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) 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 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`) 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; .gitea/workflows/ Gitea Actions: ci.yml — the full gate (ci.sh) on every branch push except main, skipped for docs-only branches;
mirror.yml — force-sync main + tags to the GitHub mirror; see CI/CD 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 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
``` ```
+32
View File
@@ -0,0 +1,32 @@
// 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");
});