17 Commits

Author SHA1 Message Date
lilleman ffeec70f8f Update lockfile and regenerate icons.ejs for lucide-static 1.28.0
CI / full-gate (push) Successful in 2m44s
Mirror / github-mirror (push) Successful in 6s
2026-08-02 15:15:12 +02:00
renovate-bot 62d4c8b7cd fix(deps): update dependency lucide-static to v1.28.0
Release-Bump: minor
2026-08-02 15:15:12 +02:00
lilleman c64156a9d5 Update e2e-tests lockfile for @playwright/test 1.62.1
CI / full-gate (push) Successful in 2m42s
Mirror / github-mirror (push) Successful in 5s
2026-08-02 15:06:47 +02:00
renovate-bot de2ad42f5a chore(deps): update playwright to v1.62.1
Release-Bump: minor
2026-08-02 15:06:47 +02:00
lilleman 9213e5a0de Make the CI web-image rebuild its own step; note the shared-workspace image-tag race
CI / full-gate (push) Successful in 2m37s
Mirror / github-mirror (push) Successful in 5s
2026-08-02 15:06:21 +02:00
lilleman 45054db5e6 Build the web image in ci.sh so typecheck and tests run the branch's own deps 2026-08-02 15:06:21 +02:00
lilleman 23bafd247d Fix typos in the AGENTS.md comment rules
CI / full-gate (push) Successful in 6s
Mirror / github-mirror (push) Successful in 5s
2026-08-02 15:00:56 +02:00
lilleman 7c66599f35 Todo and agents updates
CI / full-gate (push) Successful in 6s
Mirror / github-mirror (push) Successful in 6s
2026-08-02 14:11:33 +02:00
lilleman 6db0f57bf4 Trim README and workflow prose that restates the code
CI / full-gate (push) Successful in 2m33s
Mirror / github-mirror (push) Successful in 6s
2026-08-02 13:55:50 +02:00
lilleman 175717f04d Move the docs-only decision into ci.sh so it runs locally
CI / full-gate (push) Successful in 2m33s
2026-08-02 13:38:32 +02:00
lilleman 6c850b8923 Skip the test gate on docs-only branches
CI / full-gate (push) Successful in 2m41s
2026-08-02 13:31:07 +02:00
renovate-bot af4a70d904 chore(deps): update renovate/renovate docker tag to v44.6.0
CI / full-gate (push) Successful in 2m44s
Mirror / github-mirror (push) Successful in 5s
Release-Bump: minor
2026-08-02 04:18:00 +00:00
renovate-bot a0244a32cd chore(deps): update renovate/renovate docker tag to v44.5.3
CI / full-gate (push) Successful in 4m4s
Mirror / github-mirror (push) Successful in 5s
Release-Bump: minor
2026-08-01 04:18:15 +00:00
renovate-bot 6559f40142 chore(deps): update renovate/renovate docker tag to v44
CI / full-gate (push) Successful in 2m38s
Mirror / github-mirror (push) Successful in 6s
Release-Bump: major
2026-07-31 04:18:12 +00:00
renovate-bot d3154819f8 chore(deps): update renovate/renovate docker tag to v43.288.0
CI / full-gate (push) Successful in 2m34s
Mirror / github-mirror (push) Successful in 6s
Release-Bump: minor
2026-07-30 04:17:51 +00:00
renovate-bot 1cba6d470c chore(deps): update axllent/mailpit docker tag to v1.30.6
CI / full-gate (push) Successful in 2m41s
Mirror / github-mirror (push) Successful in 5s
Release-Bump: patch
2026-07-29 04:18:24 +00:00
renovate-bot 194c090bd1 chore(deps): update renovate/renovate docker tag to v43.285.3
CI / full-gate (push) Successful in 2m37s
Mirror / github-mirror (push) Successful in 5s
Release-Bump: minor
2026-07-28 04:17:59 +00:00
15 changed files with 114 additions and 41 deletions
+2
View File
@@ -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:
+1 -1
View File
@@ -19,7 +19,7 @@ jobs:
-e RENOVATE_PLATFORM=gitea \
-e RENOVATE_REPOSITORIES=${{ github.repository }} \
-e RENOVATE_TOKEN \
renovate/renovate:43.281.1
renovate/renovate:44.6.0
# After the renovate job, cut ONE tag covering the renovate-bot commits merged to main since the
# last tag (batch per run). Targets origin/main — the real post-merge tip; the checkout SHA is the
+17 -3
View File
@@ -89,8 +89,10 @@ them. Revisit only if the stated reason stops holding.
- **CI docker logins share the runner host's Docker config.** The act_runner is host-mode, so
`docker login`/`logout` in the workflows mutate one shared `~/.docker/config.json`:
concurrent jobs can race (one job's logout can 401 another's push — recover by re-running),
and tokens sit in that file between login and logout. Accepted for a single-maintainer
cadence; serialize with a workflow `concurrency` group if it ever bites.
and tokens sit in that file between login and logout. Same class: concurrent runs share the
workspace dir, so ci.sh's web-image build races another run's container creation on the
`<project>-web` tag. Accepted for a single-maintainer cadence; serialize with a workflow
`concurrency` group if it ever bites.
## Docker only — no host tooling
@@ -127,6 +129,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**
@@ -142,6 +152,10 @@ one home, linking to it rather than restating (credentials, env vars, rotation s
- Tests use the built-in `node --test` runner — no test framework dependency.
- English everywhere. Keep code comments short and information-dense. Self explained code
without any comment at all is the preferred solution.
- Do not comment about history in the code or README. Like "This function included X before,
but it moved to Y".
- Do not comment about the absence of things, if it is not very unexpected. Banned is things
like "This function does not calculate pi, that is done in function Z".
- Pin all dependencies and Docker images to exact, human-readable **semantic
versions** — never ranges (`^`, `~`) and never digests/hashes. npm deps are kept
exact by `.npmrc` (`save-exact=true`) + `npm ci`; the base image by tag (e.g.
@@ -161,4 +175,4 @@ one home, linking to it rather than restating (credentials, env vars, rotation s
Skip this if the changes are purely documentation and/or comments.
- Use well formed, standard compliant, rich URIs. Prefer state in the URL over POST:ing in for
for example list pages with filters and pagination. Do: "ids=x&ids=y" and not "ids[]=x&ids[]=y"
and not "ids=x,y".
and not "ids=x,y".
+8 -13
View File
@@ -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: <updateType>` 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: <updateType>` 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
+26
View File
@@ -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.
@@ -20,6 +40,12 @@ pkg=$(grep -oE '"@playwright/test": "[0-9.]+"' e2e-tests/package.json | grep -oE
[ -n "$img" ] && [ "$img" = "$pkg" ] || { echo "Playwright pin mismatch/unreadable: image v$img vs @playwright/test $pkg"; exit 1; }
echo "ok ($img)"
# Explicit rebuild: without it a stale web image from a previous branch supplies node_modules
# (the source is bind-mounted but deps are baked in), so a dep bump gets typechecked/tested
# against the OLD packages. Cheap when deps are unchanged (npm ci layer is cache-keyed).
step "Build web image"
docker compose build web
step "Typecheck"
docker compose run --rm --no-deps web npm run typecheck
+1 -1
View File
@@ -36,7 +36,7 @@ services:
# Dev mail catcher — Kratos recovery/verification emails land here (web UI on 8025).
# kratos.yml points the courier at smtp://mailpit:1025; prod uses a real SMTP via env.
mailpit:
image: axllent/mailpit:v1.30.5
image: axllent/mailpit:v1.30.6
ports:
- "8025:8025"
restart: unless-stopped
+1 -1
View File
@@ -1,6 +1,6 @@
# Playwright runner — browsers preinstalled, pinned to match @playwright/test in e2e-tests/.
# Built/run via e2e-tests/compose.visual.yml; targets the `web` service over the network.
FROM mcr.microsoft.com/playwright:v1.49.1-noble
FROM mcr.microsoft.com/playwright:v1.62.1-noble
WORKDIR /e2e-tests
+15 -15
View File
@@ -8,23 +8,23 @@
"name": "plainpages-e2e",
"version": "0.1.0",
"devDependencies": {
"@playwright/test": "1.49.1"
"@playwright/test": "1.62.1"
}
},
"node_modules/@playwright/test": {
"version": "1.49.1",
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.49.1.tgz",
"integrity": "sha512-Ky+BVzPz8pL6PQxHqNRW1k3mIyv933LML7HktS8uik0bUXNCdPhoS/kLihiO1tMf/egaJb4IutXd7UywvXEW+g==",
"version": "1.62.1",
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.62.1.tgz",
"integrity": "sha512-DTcUc8qii+cpHvtOwggMtBRMjKZHXYWdw8syRYu2vtzuq4Wxphqq4NfCs5Zt44L6mA8rfDfj+PHnxFc/FeK6mQ==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"playwright": "1.49.1"
"playwright": "1.62.1"
},
"bin": {
"playwright": "cli.js"
},
"engines": {
"node": ">=18"
"node": ">=20"
}
},
"node_modules/fsevents": {
@@ -43,35 +43,35 @@
}
},
"node_modules/playwright": {
"version": "1.49.1",
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.49.1.tgz",
"integrity": "sha512-VYL8zLoNTBxVOrJBbDuRgDWa3i+mfQgDTrL8Ah9QXZ7ax4Dsj0MSq5bYgytRnDVVe+njoKnfsYkH3HzqVj5UZA==",
"version": "1.62.1",
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.62.1.tgz",
"integrity": "sha512-0M+L3LAD8/nm554LOla9Ayx0j0tmFZ0FBcoQ7F1VuVHpM/XpiC8RcDzBQB8W5+hA8L22THxELzeF+2WcUzvcLg==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"playwright-core": "1.49.1"
"playwright-core": "1.62.1"
},
"bin": {
"playwright": "cli.js"
},
"engines": {
"node": ">=18"
"node": ">=20"
},
"optionalDependencies": {
"fsevents": "2.3.2"
}
},
"node_modules/playwright-core": {
"version": "1.49.1",
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.49.1.tgz",
"integrity": "sha512-BzmpVcs4kE2CH15rWfzpjzVGhWERJfmnXmniSyKeRZUs9Ws65m+RGIi7mjJK/euCegfn3i7jvqWeWyHe9y3Vgg==",
"version": "1.62.1",
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.62.1.tgz",
"integrity": "sha512-wPYSwEBJY9GHraISXqyqtx0na0LpO3XEX7jNDhntbex7tzUS7kLnZsOlFruFJB4Hi/rhDMjXGqHewDZ68nYZVw==",
"dev": true,
"license": "Apache-2.0",
"bin": {
"playwright-core": "cli.js"
},
"engines": {
"node": ">=18"
"node": ">=20"
}
}
}
+1 -1
View File
@@ -8,6 +8,6 @@
"test": "playwright test"
},
"devDependencies": {
"@playwright/test": "1.49.1"
"@playwright/test": "1.62.1"
}
}
+1
View File
@@ -0,0 +1 @@
{"errors":null,"message":"not found","url":"https://gitea.larvit.se/api/swagger"}
+4 -4
View File
@@ -10,7 +10,7 @@
"dependencies": {
"@larvit/log": "2.3.0",
"ejs": "3.1.10",
"lucide-static": "1.18.0"
"lucide-static": "1.28.0"
},
"devDependencies": {
"@types/ejs": "3.1.5",
@@ -110,9 +110,9 @@
}
},
"node_modules/lucide-static": {
"version": "1.18.0",
"resolved": "https://registry.npmjs.org/lucide-static/-/lucide-static-1.18.0.tgz",
"integrity": "sha512-0WRXLQnjbte5SXuzom6yfeGlVSFsEsC9rzxn66DZN0pXows3+N34CQHy3BHI1qA3uH7u/SUzx8LQhjeAnxd8JQ==",
"version": "1.28.0",
"resolved": "https://registry.npmjs.org/lucide-static/-/lucide-static-1.28.0.tgz",
"integrity": "sha512-dC3VJwRFsjEVX7Iaq4rY88pm7Fi2OmOb8P0WRzXsUMgbt7sCmFX8bLhaDBeNW6JdRjuele+jKqqFaam4yr+Ygg==",
"license": "ISC"
},
"node_modules/minimatch": {
+1 -1
View File
@@ -20,7 +20,7 @@
"dependencies": {
"@larvit/log": "2.3.0",
"ejs": "3.1.10",
"lucide-static": "1.18.0"
"lucide-static": "1.28.0"
},
"devDependencies": {
"@types/ejs": "3.1.5",
+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");
});
+1
View File
@@ -15,6 +15,7 @@
- [x] CI/CD - When renovate updates a dependency - also release a new version of plainpages based on what got updated with Renovate. Major typescript? New apiVersion + new major. A tiny patch to ejs? Only patch release etc. Before implementing, explain in detail how you will solve this. (`renovate.yml` gains an `auto-release` job (`needs: renovate`) that cuts one `vX.Y.Z` tag per run for what Renovate merged; level = highest `Release-Bump:` trailer Renovate stamps via `commitBody`, any dep's major/minor/patch mapped straight through (default patch). Decoupled from `apiVersion` (tag-only, `HOST_API_VERSION` untouched — a "major" is just a bigger image tag, never a plugin break); pre-1.0 shifts down so nothing auto-crosses into 1.0.0. Pure `auto-release/next-version.ts` + unit tests; tag pushed with renovate-bot's PAT so `release.yml` fires; documented in README → CI/CD.)
- [ ] Add an e2e test for the admin plugin's OAuth2-clients (Hydra) screen. The full-flow e2e suite runs without Hydra (compose.full.yml), so /admin/clients register/detail/delete is only unit-covered (src/http/app.test.ts); wire Hydra into an e2e stack and drive the screen in the browser.
- [ ] Build and publish docker image as CI/CD.
- [ ] The human developer understands the security model in the auth in this project.
- [ ] Add i18n support.
## Architectural review findings (2026-07-02)
+1 -1
View File
@@ -4,7 +4,7 @@
<symbol id="i-arrow-left" viewBox="0 0 24 24"><path d="m12 19-7-7 7-7" /><path d="M19 12H5" /></symbol>
<symbol id="i-bell" viewBox="0 0 24 24"><path d="M10.268 21a2 2 0 0 0 3.464 0" /><path d="M3.262 15.326A1 1 0 0 0 4 17h16a1 1 0 0 0 .74-1.673C19.41 13.956 18 12.499 18 8A6 6 0 0 0 6 8c0 4.499-1.411 5.956-2.738 7.326" /></symbol>
<symbol id="i-box" viewBox="0 0 24 24"><path d="M21 8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16Z" /><path d="m3.3 7 8.7 5 8.7-5" /><path d="M12 22V12" /></symbol>
<symbol id="i-cal" viewBox="0 0 24 24"><path d="M8 2v4" /><path d="M16 2v4" /><rect width="18" height="18" x="3" y="4" rx="2" /><path d="M3 10h18" /></symbol>
<symbol id="i-cal" viewBox="0 0 24 24"><path d="M8 2v3" /><path d="M16 2v3" /><rect x="3" y="3" width="18" height="18" rx="2" /><path d="M3 9h18" /></symbol>
<symbol id="i-chart" viewBox="0 0 24 24"><path d="M5 21v-6" /><path d="M12 21V3" /><path d="M19 21V9" /></symbol>
<symbol id="i-check-circle" viewBox="0 0 24 24"><circle cx="12" cy="12" r="10" /><path d="m9 12 2 2 4-4" /></symbol>
<symbol id="i-chev" viewBox="0 0 24 24"><path d="m9 18 6-6-6-6" /></symbol>