diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml index c257534..048b6cd 100644 --- a/.gitea/workflows/ci.yml +++ b/.gitea/workflows/ci.yml @@ -8,6 +8,8 @@ jobs: runs-on: docker-host steps: - uses: actions/checkout@v4.2.2 + with: + fetch-depth: 0 # ci.sh's docs-only check needs history; checkout defaults to depth 1 - run: bash ci.sh - name: Push app image tagged with the commit hash env: diff --git a/AGENTS.md b/AGENTS.md index 4f6d95c..21d3178 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -127,6 +127,14 @@ When editing: put content in the section it belongs to (don't prepend rationale start); keep the ToC in sync when you add/rename/remove an `H2`/`H3`; and state each fact in one home, linking to it rather than restating (credentials, env vars, rotation steps). +**Don't document internals here.** How a script reaches a decision, why one run behaved +differently from another, what a function guards — a developer doesn't need it day to day and +can read it off the code or a run's log in seconds. Prose like that only makes the README +longer and harder to consume, for humans and machines alike. It belongs in the code it +describes, or nowhere. The README earns its length on what you cannot dig out: how to use and +operate Plainpages, the external contracts, and one-time setup (secrets, accounts, tokens). +Same test before adding a row to a table or the file map — a clause, not a paragraph. + ## Rules - Node 24 runs `.ts` directly (type stripping). Keep all TypeScript **erasable** diff --git a/README.md b/README.md index 6450ea1..776b3aa 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`, 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 | @@ -1203,12 +1203,9 @@ this step runs inside the required gate, a missing/expired token (or registry outage) fails every branch's gate and blocks **all** merges until restored — set the secrets before this lands, and use a non-expiring token or track its expiry. Retention: hash tags accumulate one image per gated -push, so the nightly `registry-cleanup.yml` prunes them precisely -([`registry-cleanup/cleanup.ts`](registry-cleanup/cleanup.ts), run in a `node:24` container): -a hash tag survives only while its commit is a **branch head** or carries a **`vX.Y.Z` -release tag**; deleted alongside are the untagged `sha256:…` child manifests (arch image + -provenance) that no surviving tag references. Named tags (`1.2.3`, `latest`, …) are never -touched. It reuses `DOCKER_REGISTRY_USER`/`DOCKER_REGISTRY_TOKEN` — no extra setup. Don't +push, so the nightly `registry-cleanup.yml` prunes them +([`registry-cleanup/cleanup.ts`](registry-cleanup/cleanup.ts) defines what survives). +It reuses `DOCKER_REGISTRY_USER`/`DOCKER_REGISTRY_TOKEN` — no extra setup. Don't add a pattern-based org cleanup rule for this package (and remove it if one exists): its age/count heuristics can't see branch heads or release tags and would delete images the workflow protects. @@ -1259,12 +1256,10 @@ rejected). renovate`) cuts **one** `vX.Y.Z` tag per run covering the renovate-bot commits merged to `main` since the last tag (it targets `origin/main`, and **skips** when the tip isn't a Renovate commit — a human owns that release — or when nothing new merged). Renovate stamps every commit with a -`Release-Bump: ` trailer (`commitBody` in `renovate.json`); the job takes the highest -trailer on those commits — any dependency's `major`/`minor`/`patch` maps straight through, -defaulting to `patch`. -**Pre-1.0 the level shifts down** — a dep major bumps the `0.x` minor, dep minor/patch bump the -`0.x` patch (see [`auto-release/next-version.ts`](auto-release/next-version.ts), unit-tested) — so -routine bumps never auto-cross into `1.0.0`; `1.0.0` stays a deliberate hand-cut tag. It's +`Release-Bump: ` trailer (`commitBody` in `renovate.json`), and +[`auto-release/next-version.ts`](auto-release/next-version.ts) (unit-tested) turns the highest +trailer on those commits into the next version — pre-1.0 it never auto-crosses into `1.0.0`, +which stays a deliberate hand-cut tag. It's **tag-only** (no source commits): the tag hands off to `release.yml`, which promotes the already-built image, and is pushed with renovate-bot's PAT so `release.yml` actually fires (a tag pushed by the built-in Actions token wouldn't trigger it). The plugin-contract version diff --git a/ci.sh b/ci.sh index 0c1a048..f78f8d2 100755 --- a/ci.sh +++ b/ci.sh @@ -12,6 +12,26 @@ cd "$(dirname "$0")" step() { printf '\n\033[1;34m==> %s\033[0m\n' "$1"; } +# Docs-only fast path: nothing but *.md changed since main, so there is nothing here to break. +# The working tree counts too — a dirty tree carrying real code must never skip. Anything +# undeterminable (no git, no reachable main, no merge-base) falls through to the gate, never 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. diff --git a/json b/json new file mode 100644 index 0000000..7dd567a --- /dev/null +++ b/json @@ -0,0 +1 @@ +{"errors":null,"message":"not found","url":"https://gitea.larvit.se/api/swagger"} \ No newline at end of file diff --git a/src/ci-gate.test.ts b/src/ci-gate.test.ts new file mode 100644 index 0000000..884a0f5 --- /dev/null +++ b/src/ci-gate.test.ts @@ -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"); +});