Push auto-release tags as renovate-bot so release.yml fires, and give the Docker Hub page its short description #196

Merged
lilleman merged 6 commits from item-1 into main 2026-10-31 11:24:19 +01:00
10 changed files with 85 additions and 25 deletions
Showing only changes of commit b6afdf1ecc - Show all commits
+2 -1
View File
@@ -81,7 +81,8 @@ jobs:
- uses: actions/checkout@v7.0.1 - uses: actions/checkout@v7.0.1
if: github.event_name == 'push' if: github.event_name == 'push'
# Publish the named release's own tree, so the page never pairs one Plainpages tag with another # Publish the named release's own tree, so the page never pairs one Plainpages tag with another
# release's sidecar pins. A version that was never released fails here. # release's sidecar pins, which is also why it never syncs on a push to main. A version that
# was never released fails here.
- uses: actions/checkout@v7.0.1 - uses: actions/checkout@v7.0.1
if: github.event_name == 'workflow_dispatch' if: github.event_name == 'workflow_dispatch'
with: with:
+3
View File
@@ -38,6 +38,9 @@ jobs:
- uses: actions/checkout@v7.0.1 - uses: actions/checkout@v7.0.1
with: with:
fetch-depth: 0 fetch-depth: 0
# A persisted token rides every request as an extraheader, outranking the PAT in the push
# URL: Gitea then sees the Actions bot push the tag and starts no workflow for it.
persist-credentials: false
- name: Tag a release for what Renovate merged - name: Tag a release for what Renovate merged
env: env:
RENOVATE_TOKEN: ${{ secrets.RENOVATE_TOKEN }} RENOVATE_TOKEN: ${{ secrets.RENOVATE_TOKEN }}
+5 -4
View File
@@ -340,10 +340,11 @@ Revisit only if the stated reason stops holding.
*text* guard — the test image ships neither `git` nor `bash`. This is why the Docker Hub overview is *text* guard — the test image ships neither `git` nor `bash`. This is why the Docker Hub overview is
`release-tooling/dockerhub-overview.md.tmpl` and not a `.md`: a release reads it and a unit test `release-tooling/dockerhub-overview.md.tmpl` and not a `.md`: a release reads it and a unit test
guards it, so giving it a `.md` name would let a broken `{{VERSION}}` merge with its own guard guards it, so giving it a `.md` name would let a broken `{{VERSION}}` merge with its own guard
skipped. `README.md` is the one markdown a test reads — `release-tooling/contract-version.test.ts` skipped. `README.md` is the one markdown tests read — `release-tooling/contract-version.test.ts`
checks its `apiVersion` samples — and a README-only change skips that check; accepted, because checks its `apiVersion` samples, `release-tooling/dockerhub-overview.test.ts` that the Hub page
those samples are illustrative and the copies that matter (`examples/`, `views/`, the template) are says only what it says — and a README-only change skips both; accepted, because those samples are
gated. **Valid while no markdown file is rendered or executed.** illustrative, the copies that matter (`examples/`, `views/`, the template) are gated, and the next
gated change catches a README that dropped a Hub command. **Valid while no markdown file is rendered or executed.**
- **CI docker logins share the runner host's Docker config.** The act_runner is host-mode, so - **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 `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 can race (one job's logout can 401 another's push — recover by re-running), and tokens sit in that
+6
View File
@@ -6,6 +6,12 @@ at boot. Entries start at 0.3.0.
## Unreleased ## Unreleased
### Every release reaches Docker Hub, and the Hub page has a short description
Releases 0.4.1 to 0.4.20 were tagged in git but never published to Docker Hub or as semver tags in
the Gitea registry; they are now, and every later release is. The Hub page gains a one-line
description.
### The admin example stops an administrator locking themselves out through a group ### The admin example stops an administrator locking themselves out through a group
On the Groups screen, unticking a permission on a group you are in, removing yourself (or a nested On the Groups screen, unticking a permission on a group you are in, removing yourself (or a nested
+14 -3
View File
@@ -1591,10 +1591,11 @@ tags sync to [Docker Hub](https://hub.docker.com/r/larvit/plainpages), releases
The [contract check](#contract-versioning) guards the tag before anything is published, refusing one The [contract check](#contract-versioning) guards the tag before anything is published, refusing one
whose `major.minor` disagrees with `HOST_API_VERSION` and naming the value to set. whose `major.minor` disagrees with `HOST_API_VERSION` and naming the value to set.
**The Docker Hub overview** is published by a separate `publish-overview` job from **The Docker Hub overview** and short description are published by a separate `publish-overview`
[`release-tooling/dockerhub-overview.md.tmpl`](release-tooling/dockerhub-overview.md.tmpl), with job from [`release-tooling/dockerhub-overview.md.tmpl`](release-tooling/dockerhub-overview.md.tmpl), with
`{{VERSION}}` rendered to the release, so the Plainpages tag it tells adopters to pull cannot go `{{VERSION}}` rendered to the release, so the Plainpages tag it tells adopters to pull cannot go
stale. Its sidecar pins are Renovate-managed and gated against this repo's own compose files, so the stale. The description must open a sentence of this README, and every command the overview shows
must appear here too. Its sidecar pins are Renovate-managed and gated against this repo's own compose files, so the
quick start stays a topology CI has actually run. quick start stays a topology CI has actually run.
It is its own job for two reasons: the images are already pushed and irreversible by then, so a Hub It is its own job for two reasons: the images are already pushed and irreversible by then, so a Hub
outage leaves the promotion green and the images untouched; and the page has its own door — run the outage leaves the promotion green and the images untouched; and the page has its own door — run the
@@ -1666,6 +1667,16 @@ the one-shot bootstrap) — and mounts no source. Secrets come from the environm
running insecure. Before going live, supply the production secrets and any SSO credentials — the running insecure. Before going live, supply the production secrets and any SSO credentials — the
**only** manual prep ([What you must supply](#what-you-must-supply-the-only-manual-prep)). **only** manual prep ([What you must supply](#what-you-must-supply-the-only-manual-prep)).
**From the published image**, without a clone: save the `compose.yml` the
[Docker Hub page](https://hub.docker.com/r/larvit/plainpages) shows, then extract the Ory config the
image ships and start, with `X.Y.Z` the release you deploy:
```bash
docker run --rm larvit/plainpages:X.Y.Z tar -cf - ory | tar -xf -
mkdir -p plugins
docker compose up -d
```
**Back up the `pgdata` volume.** Once a plugin declares [storage](#plugin-storage), Postgres holds **Back up the `pgdata` volume.** Once a plugin declares [storage](#plugin-storage), Postgres holds
business data that exists nowhere else, alongside Ory's identities — the stack stops being business data that exists nowhere else, alongside Ory's identities — the stack stops being
reproducible from the image and config alone. Snapshot the volume, or `pg_dump` each database on a reproducible from the image and config alone. Snapshot the volume, or `pg_dump` each database on a
+17
View File
@@ -192,3 +192,20 @@ Read most (opens and searches):
- `ci.sh` 1 - `ci.sh` 1
- `examples/plugins/admin/admin-clients.test.ts` 1 - `examples/plugins/admin/admin-clients.test.ts` 1
- `examples/plugins/admin/admin-grants.test.ts` 1 - `examples/plugins/admin/admin-grants.test.ts` 1
## Reading cost, chunk 4a8c9037acde, finished 2026-10-31T10:07:56Z
126616 new input tokens before the first commit.
Read most (opens and searches):
- `src/http/app-admin.test.ts` 7
- `examples/plugins/admin/admin-groups.ts` 4
- `README.md` 3
- `todo.md` 3
- `AGENTS.md` 2
- `examples/plugins/admin/README.md` 2
- `examples/plugins/admin/admin-grants.ts` 2
- `examples/plugins/admin/i18n/en-US.ts` 2
- `CHANGELOG.md` 1
- `examples/plugins/admin/admin-groups.test.ts` 1
+10
View File
@@ -0,0 +1,10 @@
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import { test } from "node:test";
test("auto-release's checkout persists no credentials, so the tag push authenticates as renovate-bot", () => {
const workflow = readFileSync(".gitea/workflows/renovate.yml", "utf8");
const job = workflow.slice(workflow.indexOf("\n auto-release:"));
const checkout = job.slice(job.indexOf("actions/checkout@"), job.indexOf("\n - name:"));
assert.match(checkout, /^\s+persist-credentials: false$/m);
});
+22 -1
View File
@@ -1,7 +1,7 @@
import assert from "node:assert/strict"; import assert from "node:assert/strict";
import { readFileSync } from "node:fs"; import { readFileSync } from "node:fs";
import { test } from "node:test"; import { test } from "node:test";
import { jwtFrom, leftoverPlaceholders, renderOverview } from "./dockerhub-overview.ts"; import { DESCRIPTION, jwtFrom, leftoverPlaceholders, renderOverview } from "./dockerhub-overview.ts";
const TEMPLATE = "release-tooling/dockerhub-overview.md.tmpl"; const TEMPLATE = "release-tooling/dockerhub-overview.md.tmpl";
const template = () => readFileSync(TEMPLATE, "utf8"); const template = () => readFileSync(TEMPLATE, "utf8");
@@ -46,3 +46,24 @@ test("jwtFrom accepts only a non-empty string token, never throwing on a hostile
assert.equal(jwtFrom({ token: "" }), null); assert.equal(jwtFrom({ token: "" }), null);
assert.equal(jwtFrom({ token: 42 }), null); assert.equal(jwtFrom({ token: 42 }), null);
}); });
test("the short description opens a README sentence and fits Docker Hub's 100 characters", () => {
const readme = readFileSync("README.md", "utf8").replaceAll("**", "");
assert.ok(DESCRIPTION.length <= 100, `${DESCRIPTION.length} characters`);
const escaped = DESCRIPTION.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
assert.match(readme, new RegExp(`^${escaped}(?=[\\s.:,])`, "m"));
});
test("every command the overview shows, the README shows too", () => {
const commands = (source: string) =>
[...source.matchAll(/```bash\n(.*?)```/gs)].flatMap((block) =>
(block[1] ?? "")
.split("\n")
.map((line) => (line.split(" #")[0] ?? "").trim().replace(/larvit\/plainpages:\S+/g, "larvit/plainpages:X.Y.Z"))
.filter((line) => line !== ""),
);
const shown = new Set(commands(readFileSync("README.md", "utf8")));
const published = commands(template());
assert.ok(published.length > 0, `${TEMPLATE} should show commands`);
for (const command of published) assert.ok(shown.has(command), `${TEMPLATE} runs ${command}, which README.md doesn't show`);
});
+6 -4
View File
@@ -1,5 +1,5 @@
// Publishes the Docker Hub repository overview from dockerhub-overview.md.tmpl, rendering // Publishes the Docker Hub repository overview from dockerhub-overview.md.tmpl, rendering
// `{{VERSION}}` to the release being published. // `{{VERSION}}` to the release being published, and the short description.
import { readFileSync } from "node:fs"; import { readFileSync } from "node:fs";
import { join } from "node:path"; import { join } from "node:path";
@@ -8,6 +8,8 @@ const HUB = "https://hub.docker.com/v2";
const TIMEOUT_MS = 30_000; const TIMEOUT_MS = 30_000;
const VERSION = /^\d+\.\d+\.\d+$/; const VERSION = /^\d+\.\d+\.\d+$/;
export const DESCRIPTION = "A self-hostable foundation for server-rendered web applications";
export function renderOverview(source: string, version: string): string { export function renderOverview(source: string, version: string): string {
return source.replaceAll("{{VERSION}}", version); return source.replaceAll("{{VERSION}}", version);
} }
@@ -81,21 +83,21 @@ async function main(): Promise<number> {
if (!jwt) return fail("Docker Hub login returned no token"); if (!jwt) return fail("Docker Hub login returned no token");
const res = await post(`${HUB}/repositories/${repo}/`, { const res = await post(`${HUB}/repositories/${repo}/`, {
body: JSON.stringify({ full_description: body }), body: JSON.stringify({ description: DESCRIPTION, full_description: body }),
headers: { authorization: `Bearer ${jwt}`, "content-type": "application/json" }, headers: { authorization: `Bearer ${jwt}`, "content-type": "application/json" },
method: "PATCH", method: "PATCH",
}); });
if ("error" in res) return fail(`Docker Hub unreachable: ${res.error}`); if ("error" in res) return fail(`Docker Hub unreachable: ${res.error}`);
if (!res.ok) { if (!res.ok) {
return fail( return fail(
`Docker Hub overview PATCH failed: ${res.status} ${res.text}` + `Docker Hub description and overview PATCH failed: ${res.status} ${res.text}` +
(res.status === 403 (res.status === 403
? "\n403 means DOCKERHUB_TOKEN lacks the delete scope — editing the overview needs " + ? "\n403 means DOCKERHUB_TOKEN lacks the delete scope — editing the overview needs " +
"read/write/delete, which pushing images alone does not (README -> CI/CD)." "read/write/delete, which pushing images alone does not (README -> CI/CD)."
: ""), : ""),
); );
} }
process.stdout.write(`Docker Hub overview updated for ${repo} at ${version}\n`); process.stdout.write(`Docker Hub description and overview updated for ${repo} at ${version}\n`);
return 0; return 0;
} }
-12
View File
@@ -12,7 +12,6 @@
| ID | Release | Blocked by | Exempt | Item | L | E | I | R | S | Moves | Score | | ID | Release | Blocked by | Exempt | Item | L | E | I | R | S | Moves | Score |
|---|---|---|---|---|---|---|---|---|---|---|---| |---|---|---|---|---|---|---|---|---|---|---|---|
| 1 | 0.4.21 | | owner | **Publish every release to Docker Hub, and give the Hub page its short description.** | 3 | 3 | 2 | 6 | 5 | 1 by 3 | 1.3 |
| 32 | 0.4.21 | | | **Fail the gate on a stylesheet feature the browser floor lacks, unless it is guarded.** | 4 | 2 | 2 | 6 | 7 | code health by 2 | 1.1 | | 32 | 0.4.21 | | | **Fail the gate on a stylesheet feature the browser floor lacks, unless it is guarded.** | 4 | 2 | 2 | 6 | 7 | code health by 2 | 1.1 |
| 13 | 0.4.21 | | | **Accept a verification code pasted with surrounding whitespace.** | 4 | 2 | 3 | 7 | 7 | 2 by 8 | 1.0 | | 13 | 0.4.21 | | | **Accept a verification code pasted with surrounding whitespace.** | 4 | 2 | 3 | 7 | 7 | 2 by 8 | 1.0 |
| 19 | 0.5.0 | | question | **Run the documented Docker commands on macOS and fix whatever misbehaves.** | 2 | 2 | 1 | 3 | 3 | 1 by 3 | 3.3 | | 19 | 0.5.0 | | question | **Run the documented Docker commands on macOS and fix whatever misbehaves.** | 2 | 2 | 1 | 3 | 3 | 1 by 3 | 3.3 |
@@ -28,17 +27,6 @@
## Details ## Details
### 1. Publish every release to Docker Hub, and give the Hub page its short description.
Hub's newest tag is 0.4.0, while git has v0.4.1 onwards, and the overview still says 0.4.0. No
`release.yml` run exists for the tags auto-release pushes as `renovate-bot`; find out why Gitea
doesn't fire `push: tags` for them, fix it, and backfill the missing tags. fejkdata did this
(`../fejkdata`, commits `51d924a` and `34c8234`), and two parts port directly: a one-line
description riding the existing PATCH as its `description` field, and a unit test that the
description opens a README sentence and every command the overview shows appears in the README.
fejkdata also syncs the page on any push to `main`, with its own token in an environment only `main`
may use; decide whether that fits here, where `{{VERSION}}` must render a released tag.
### 32. Fail the gate on a stylesheet feature the browser floor lacks, unless it is guarded. ### 32. Fail the gate on a stylesheet feature the browser floor lacks, unless it is guarded.
Nothing checks README → "Two browser floors", and no E2E engine is old enough to show a break. Nothing checks README → "Two browser floors", and no E2E engine is old enough to show a break.