Skip the test gate on docs-only branches #34
@@ -9,27 +9,10 @@ jobs:
|
|||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4.2.2
|
- uses: actions/checkout@v4.2.2
|
||||||
with:
|
with:
|
||||||
fetch-depth: 0 # the merge-base below needs history; checkout defaults to depth 1
|
fetch-depth: 0 # ci.sh's docs-only check needs history; checkout defaults to depth 1
|
||||||
- name: Detect a docs-only branch
|
- run: bash ci.sh
|
||||||
id: scope
|
# 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
|
|||||||
run: |
|
# ff-only merges make every branch head a main commit - one without an image is unreleasable.
|
||||||
|
lilleman marked this conversation as resolved
Outdated
lilleman
commented
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 }}
|
||||||
|
|||||||
@@ -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
|
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.
|
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
lilleman
commented
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
|
## CI/CD
|
||||||
|
|
||||||
Gitea Actions (`.gitea/workflows/`) runs the pipeline; the test job runs
|
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 |
|
| 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 |
|
| `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,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
|
**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
|
**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
lilleman
commented
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.
|
|||||||
with `main` ends in `.md`, `ci.yml` skips `bash ci.sh`. The job itself still runs and still
|
changes nothing but `*.md`. The job itself still runs and still builds + pushes the commit-hash
|
||||||
builds + pushes the commit-hash image, so `CI / full-gate (push)` reports green and neither the
|
image, so `CI / full-gate (push)` reports green and neither the merge gate nor `release.yml`
|
||||||
merge gate nor `release.yml` notices the difference. Everything else in a diff — code, compose,
|
notices the difference. This is why `ci.yml` checks out with `fetch-depth: 0` — a shallow clone
|
||||||
Ory config, the workflows themselves — runs the full gate, as does an empty or unreadable diff.
|
has no merge-base to compare against.
|
||||||
|
|
||||||
**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
|
||||||
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
|
||||||
@@ -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
|
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)
|
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`); no-ops on a docs-only change
|
||||||
.gitea/workflows/ Gitea Actions: ci.yml — the full gate (ci.sh) on every branch push except main, skipped for docs-only branches;
|
.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
|
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
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -12,6 +12,27 @@ cd "$(dirname "$0")"
|
|||||||
|
|
||||||
step() { printf '\n\033[1;34m==> %s\033[0m\n' "$1"; }
|
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.
|
# 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)"
|
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.
|
# `|| true` so a no-match doesn't trip `set -e`/`pipefail` before the explicit check below can report.
|
||||||
|
|||||||
@@ -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");
|
||||||
|
});
|
||||||
@@ -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");
|
|
||||||
});
|
|
||||||
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.