From 6c850b892383b7f49db0aa3ea32b6dcf6537564f Mon Sep 17 00:00:00 2001 From: lilleman Date: Sun, 2 Aug 2026 13:31:07 +0200 Subject: [PATCH] Skip the test gate on docs-only branches --- .gitea/workflows/ci.yml | 23 ++++++++++++++++++++++- README.md | 10 ++++++++-- src/ci-workflow.test.ts | 32 ++++++++++++++++++++++++++++++++ 3 files changed, 62 insertions(+), 3 deletions(-) create mode 100644 src/ci-workflow.test.ts diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml index c257534..b28fb5a 100644 --- a/.gitea/workflows/ci.yml +++ b/.gitea/workflows/ci.yml @@ -8,7 +8,28 @@ jobs: runs-on: docker-host steps: - 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 + 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. - name: Push app image tagged with the commit hash env: IMAGE: gitea.larvit.se/${{ github.repository }}:${{ github.sha }} diff --git a/README.md b/README.md index 6450ea1..b52dac9 100644 --- a/README.md +++ b/README.md @@ -1173,7 +1173,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`), 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 | | `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,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 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 Gitea container registry as `gitea.larvit.se/larvit/plainpages:`. Because 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) 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; +.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 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 ``` diff --git a/src/ci-workflow.test.ts b/src/ci-workflow.test.ts new file mode 100644 index 0000000..839d60a --- /dev/null +++ b/src/ci-workflow.test.ts @@ -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"); +});