Count both paths of a rename in the docs-only CI skip; pause auto-release #55
@@ -12,7 +12,10 @@ jobs:
|
|||||||
- uses: actions/checkout@v7.0.1
|
- uses: actions/checkout@v7.0.1
|
||||||
with:
|
with:
|
||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
|
fetch-tags: true # load-bearing for --prune below: no local tags would delete every remote one
|
||||||
|
# --prune so a tag deleted here doesn't live on at GitHub forever. It only removes refs a
|
||||||
|
# refspec DESTINATION matches — so tags; main is a non-glob dst, other branches match nothing.
|
||||||
- run: |
|
- run: |
|
||||||
git push --force \
|
git push --force --prune \
|
||||||
"https://x-access-token:${{ secrets.MIRROR_GITHUB_TOKEN }}@github.com/larvit/plainpages.git" \
|
"https://x-access-token:${{ secrets.MIRROR_GITHUB_TOKEN }}@github.com/larvit/plainpages.git" \
|
||||||
refs/remotes/origin/main:refs/heads/main 'refs/tags/*:refs/tags/*'
|
refs/remotes/origin/main:refs/heads/main 'refs/tags/*:refs/tags/*'
|
||||||
|
|||||||
@@ -30,9 +30,12 @@ jobs:
|
|||||||
# authorship on the tip, so the author checks are reliable. Level = highest `Release-Bump:` trailer;
|
# authorship on the tip, so the author checks are reliable. Level = highest `Release-Bump:` trailer;
|
||||||
# pre-1.0 shifts down (auto-release/next-version.ts). Tag-only — release.yml promotes the
|
# pre-1.0 shifts down (auto-release/next-version.ts). Tag-only — release.yml promotes the
|
||||||
# already-built image; pushed with renovate-bot's PAT so release.yml fires (the built-in token won't).
|
# already-built image; pushed with renovate-bot's PAT so release.yml fires (the built-in token won't).
|
||||||
|
# Off until the Actions variable AUTO_RELEASE is set to 'true': Plainpages is pre-announcement and
|
||||||
|
# deliberately carries no tags, so an automated bump would only invent a version nobody consumes.
|
||||||
auto-release:
|
auto-release:
|
||||||
runs-on: docker-host
|
runs-on: docker-host
|
||||||
needs: renovate
|
needs: renovate
|
||||||
|
if: vars.AUTO_RELEASE == 'true'
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v7.0.1
|
- uses: actions/checkout@v7.0.1
|
||||||
with:
|
with:
|
||||||
|
|||||||
@@ -168,6 +168,26 @@ them. Revisit only if the stated reason stops holding.
|
|||||||
workspace dir, so ci.sh's web-image build races another run's container creation on 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
|
`<project>-web` tag. Accepted for a single-maintainer cadence; serialize with a workflow
|
||||||
`concurrency` group if it ever bites.
|
`concurrency` group if it ever bites.
|
||||||
|
- **The docs-only CI skip is `*.md` anywhere in the tree, not just the root.** No test, build step or
|
||||||
|
workflow reads a markdown file (`README-dockerhub.md` is pasted into Docker Hub by hand), so a
|
||||||
|
nested `examples/plugins/admin/README.md` edit is as safe to skip as `README.md`, and narrowing it
|
||||||
|
would spend the full gate on one. Both git channels in `ci.sh`'s `docs_only()` pass `--no-renames`:
|
||||||
|
rename detection names only the destination, so `git mv src/app.ts notes.md` otherwise read as docs
|
||||||
|
and skipped the gate over a source file that was gone. `src/ci-gate.test.ts` locks the flags as a
|
||||||
|
*text* guard — the test image (`node:24.19.0-alpine3.24`) ships neither `git` nor `bash`, so it
|
||||||
|
cannot exercise the function; behaviour was verified against a scratch repo across ten scenarios.
|
||||||
|
Revisit if a `.md` ever becomes load-bearing. Decided 2026-08-05.
|
||||||
|
- **Plainpages is pre-announcement: no tags, no releases.** The repo carried tags up to `v0.2.2` from
|
||||||
|
the `auto-release` job; all of them — and the semver container tags — were deleted 2026-08-05, and
|
||||||
|
the job is gated behind the `AUTO_RELEASE` Actions variable (unset ⇒ skipped, the fail-safe
|
||||||
|
direction on every unknown-`vars` path). A version only communicates to consumers, and there are
|
||||||
|
none; same reasoning that freezes `HOST_API_VERSION` at 1.0.0. Note the coupling:
|
||||||
|
`registry-cleanup` keeps a hash image only while its commit is a branch head *or* release-tagged,
|
||||||
|
so with zero tags only branch heads survive the nightly prune — a hand-cut tag must sit on `main`'s
|
||||||
|
tip. `mirror.yml` pushes tags with `--prune` so the deletions actually reach the public GitHub
|
||||||
|
mirror; that makes the runner's tag view load-bearing (hence `fetch-tags: true`) and means a tag
|
||||||
|
or Release created on GitHub is swept away, so releases are cut on Gitea only. Valid until the
|
||||||
|
maintainer says Plainpages is ready to show people.
|
||||||
- **A dropdown is a `<button popovertarget>` + `[popover]`, never a `<details>`.** The browser then
|
- **A dropdown is a `<button popovertarget>` + `[popover]`, never a `<details>`.** The browser then
|
||||||
owns open/close, which is the only zero-JS way to dismiss a menu by clicking outside it (the whole
|
owns open/close, which is the only zero-JS way to dismiss a menu by clicking outside it (the whole
|
||||||
point), and the panel sits in the top layer so a row kebab is no longer clipped by `.table-wrap`'s
|
point), and the panel sits in the top layer so a row kebab is no longer clipped by `.table-wrap`'s
|
||||||
|
|||||||
@@ -1524,9 +1524,9 @@ Gitea Actions (`.gitea/workflows/`) runs the pipeline; the test job runs
|
|||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
| `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 |
|
| `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 (pruning deleted ones) 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 |
|
||||||
| `renovate.yml` | nightly cron, or manual | open dependency-update PRs, automerge them once the gate is green, then cut one release tag for the run |
|
| `renovate.yml` | nightly cron, or manual | open dependency-update PRs, automerge them once the gate is green; the release-tag job only runs when `AUTO_RELEASE` is `true` |
|
||||||
|
|
||||||
`main` is not re-tested on push — its commits are meant to arrive already green from a
|
`main` is not re-tested on push — its commits are meant to arrive already green from a
|
||||||
gated branch, so the status check to gate a merge on is `CI / full-gate (push)`.
|
gated branch, so the status check to gate a merge on is `CI / full-gate (push)`.
|
||||||
@@ -1578,9 +1578,15 @@ Docker Hub when it changes.
|
|||||||
|
|
||||||
**GitHub mirror** — [github.com/larvit/plainpages](https://github.com/larvit/plainpages) is a
|
**GitHub mirror** — [github.com/larvit/plainpages](https://github.com/larvit/plainpages) is a
|
||||||
read-only mirror; after every merge, `mirror.yml` force-pushes `main` and all tags there,
|
read-only mirror; after every merge, `mirror.yml` force-pushes `main` and all tags there,
|
||||||
overwriting any drift (refs deleted on Gitea are not pruned). One-time setup: a dedicated
|
overwriting any drift. Tags are pushed with `--prune`, so deleting one here deletes it there on
|
||||||
|
the next mirror run — ref deletions don't trigger the workflow themselves — and the mirror can't
|
||||||
|
go on advertising a version the source dropped; branches other than `main` are matched by no
|
||||||
|
refspec and are left alone. The same sweep removes a tag *created* on GitHub, so cut releases on
|
||||||
|
Gitea, never on the mirror — a GitHub Release made there loses its tag and does not come back.
|
||||||
|
One-time setup: a dedicated
|
||||||
GitHub machine account with write access to the GitHub repo (whose `main` must not block
|
GitHub machine account with write access to the GitHub repo (whose `main` must not block
|
||||||
force-pushes), and a fine-grained PAT scoped to that repo (Contents: read & write), stored
|
force-pushes, and which must carry no tag protection — that would reject the prune and fail every
|
||||||
|
run), and a fine-grained PAT scoped to that repo (Contents: read & write), stored
|
||||||
as the Gitea Actions secret `MIRROR_GITHUB_TOKEN` (repo Settings → Actions → Secrets; Gitea
|
as the Gitea Actions secret `MIRROR_GITHUB_TOKEN` (repo Settings → Actions → Secrets; Gitea
|
||||||
rejects secret names starting with `GITHUB_`/`GITEA_`). Trigger the workflow manually for
|
rejects secret names starting with `GITHUB_`/`GITEA_`). Trigger the workflow manually for
|
||||||
the first sync — until the secret exists, the mirror job fails loud on each merge.
|
the first sync — until the secret exists, the mirror job fails loud on each merge.
|
||||||
@@ -1604,9 +1610,17 @@ prefix is rejected). Also store a **scopeless** (read-only) github.com PAT as th
|
|||||||
lookups of github.com-hosted deps (actions, Playwright, changelogs) run authenticated
|
lookups of github.com-hosted deps (actions, Playwright, changelogs) run authenticated
|
||||||
instead of tripping the anonymous 60-requests/hour limit.
|
instead of tripping the anonymous 60-requests/hour limit.
|
||||||
|
|
||||||
|
**Releases are paused.** Plainpages is pre-announcement: the repository carries **no tags**, and
|
||||||
|
neither `release.yml` nor Docker Hub has a version to promote. Turn releasing back on by setting the
|
||||||
|
Actions **variable** `AUTO_RELEASE` to `true` (that alone re-enables the job below), or cut a
|
||||||
|
`vX.Y.Z` tag by hand **on `main`'s tip** — with nothing tagged, the nightly registry cleanup keeps
|
||||||
|
only branch-head images, so an older commit's image is already gone and `release.yml` would fail
|
||||||
|
loud with nothing to promote.
|
||||||
|
|
||||||
**Auto-release on dependency updates** — a second job in `renovate.yml` (`auto-release`, `needs:
|
**Auto-release on dependency updates** — a second job in `renovate.yml` (`auto-release`, `needs:
|
||||||
renovate`) cuts **one** `vX.Y.Z` tag per run covering the renovate-bot commits merged to `main`
|
renovate`, gated on `AUTO_RELEASE` above) cuts **one** `vX.Y.Z` tag per run covering the
|
||||||
since the last tag (it targets `origin/main`, and **skips** when the tip isn't a Renovate commit —
|
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
|
a human owns that release — or when nothing new merged). Renovate stamps every commit with a
|
||||||
`Release-Bump: <updateType>` trailer (`commitBody` in `renovate.json`), and
|
`Release-Bump: <updateType>` trailer (`commitBody` in `renovate.json`), and
|
||||||
[`auto-release/next-version.ts`](auto-release/next-version.ts) (unit-tested) turns the highest
|
[`auto-release/next-version.ts`](auto-release/next-version.ts) (unit-tested) turns the highest
|
||||||
|
|||||||
@@ -15,13 +15,17 @@ 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.
|
# 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
|
# 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.
|
# undeterminable (no git, no reachable main, no merge-base) falls through to the gate, never a skip.
|
||||||
|
# --no-renames on both channels: rename detection names only the destination, so `git mv src/app.ts
|
||||||
|
# notes.md` reads as a lone *.md — under --porcelain as one `R src/app.ts -> notes.md` line still
|
||||||
|
# ending in .md after cut -c4- — and the gate would skip over a source file that is gone.
|
||||||
docs_only() {
|
docs_only() {
|
||||||
local base changed
|
local base changed
|
||||||
git rev-parse --git-dir >/dev/null 2>&1 || return 1
|
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
|
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
|
base=$(git merge-base refs/remotes/origin/main HEAD 2>/dev/null) || return 1
|
||||||
changed=$(
|
changed=$(
|
||||||
{ git diff --name-only "$base" HEAD && git status --porcelain --untracked-files=all | cut -c4-; } 2>/dev/null
|
{ git diff --name-only --no-renames "$base" HEAD \
|
||||||
|
&& git status --porcelain --no-renames --untracked-files=all | cut -c4-; } 2>/dev/null
|
||||||
) || return 1
|
) || return 1
|
||||||
[ -n "$changed" ] || return 1
|
[ -n "$changed" ] || return 1
|
||||||
! printf '%s\n' "$changed" | grep -qvE '\.md$'
|
! printf '%s\n' "$changed" | grep -qvE '\.md$'
|
||||||
|
|||||||
+5
-3
@@ -8,7 +8,8 @@ import { readFileSync } from "node:fs";
|
|||||||
|
|
||||||
const read = (p: string) => readFileSync(new URL(`../${p}`, import.meta.url), "utf8");
|
const read = (p: string) => readFileSync(new URL(`../${p}`, import.meta.url), "utf8");
|
||||||
const workflow = read(".gitea/workflows/ci.yml");
|
const workflow = read(".gitea/workflows/ci.yml");
|
||||||
const gate = read("ci.sh");
|
// Comments stripped: the flags below must be asserted against the code, not against prose naming them.
|
||||||
|
const gate = read("ci.sh").split("\n").filter((l) => !l.trimStart().startsWith("#")).join("\n");
|
||||||
const step = (needle: string) => {
|
const step = (needle: string) => {
|
||||||
const found = workflow.split("\n - ").slice(1).filter((s) => s.includes(needle));
|
const found = workflow.split("\n - ").slice(1).filter((s) => s.includes(needle));
|
||||||
assert.equal(found.length, 1, `exactly one workflow step contains ${needle}`);
|
assert.equal(found.length, 1, `exactly one workflow step contains ${needle}`);
|
||||||
@@ -28,7 +29,8 @@ test("the commit-hash image is pushed even when the gate no-ops", () => {
|
|||||||
assert.doesNotMatch(step("docker push"), /^\s*if:/m);
|
assert.doesNotMatch(step("docker push"), /^\s*if:/m);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("only *.md counts as docs, and a dirty working tree counts as changed", () => {
|
test("only *.md counts as docs; a dirty tree and a rename both count as changed", () => {
|
||||||
assert.ok(gate.includes("\\.md$"), "the non-docs match is a *.md suffix test");
|
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");
|
assert.match(gate, /git status --porcelain --no-renames/, "uncommitted code and a staged rename can never be skipped over");
|
||||||
|
assert.match(gate, /git diff --name-only --no-renames/, "a rename must list both of its paths");
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -2,7 +2,6 @@
|
|||||||
|
|
||||||
## Unfinnished work
|
## Unfinnished work
|
||||||
|
|
||||||
- [ ] Don't run tests when only markdown files in the root have changed.
|
|
||||||
- [ ] In Playwright tests, check for warnings and errors in all browsers on all the steps. If they exist, that is a failure we need to fix.
|
- [ ] In Playwright tests, check for warnings and errors in all browsers on all the steps. If they exist, that is a failure we need to fix.
|
||||||
- [ ] In Playwright tests, try different resolutions and sizes, from BIG desktop down to tiny phone.
|
- [ ] In Playwright tests, try different resolutions and sizes, from BIG desktop down to tiny phone.
|
||||||
- [ ] Record the browser floor Plainpages actually requires, and whether the fallback is the contract or a courtesy. The stylesheet already needs `:has()` (Dec 2023); the menus now need the popover API (Safari 17, Sep 2023) and CSS anchor positioning for placement (newer still, and unguarded — the `@supports` test covers popover only). An iPadOS 16 tablet — capped at Safari 16, and exactly the "tablet on a factory floor, old thin client at a reception desk" README → Overview sells the zero-JS stance on — therefore gets panels flowing inline rather than working menus. Either state a supported floor in the README or accept the fallback as the answer for those devices; nobody has rendered that path on real hardware. Raised by the architecture review 2026-08-05.
|
- [ ] Record the browser floor Plainpages actually requires, and whether the fallback is the contract or a courtesy. The stylesheet already needs `:has()` (Dec 2023); the menus now need the popover API (Safari 17, Sep 2023) and CSS anchor positioning for placement (newer still, and unguarded — the `@supports` test covers popover only). An iPadOS 16 tablet — capped at Safari 16, and exactly the "tablet on a factory floor, old thin client at a reception desk" README → Overview sells the zero-JS stance on — therefore gets panels flowing inline rather than working menus. Either state a supported floor in the README or accept the fallback as the answer for those devices; nobody has rendered that path on real hardware. Raised by the architecture review 2026-08-05.
|
||||||
@@ -28,6 +27,7 @@ Prioritized. Overall verdict: architecture is sound (contract-first plugin API,
|
|||||||
|
|
||||||
## Finnished work
|
## Finnished work
|
||||||
|
|
||||||
|
- [x] Don't run tests when only markdown files in the root have changed. (Already shipped for *any* `*.md`, anywhere in the tree — `ci.sh`'s `docs_only()` no-ops the gate when every path changed since `main` ends in `.md`, and the workflow still pushes the commit-hash image so a merged docs commit stays releasable. Kept wider than "in the root" deliberately: no test reads a markdown file, so a nested `examples/plugins/admin/README.md` edit is as safe to skip as `README.md`, and narrowing it would spend the full gate on one. What was actually broken was rename detection — `git mv src/app.ts notes.md` names only the destination under `git diff --name-only`, and collapses to a single `R src/app.ts -> notes.md` line under `git status --porcelain`, so **moving code onto a `.md` path skipped the gate over a source file that was gone**. Both channels now pass `--no-renames`; verified against a scratch repo across ten scenarios — docs-only, mixed, empty diff, dirty tree, untracked code, deleted doc, and the rename staged *and* committed — the last two failing before the fix and passing after. `src/ci-gate.test.ts` locks both flags; it stays a text guard because the test image is `node:alpine` with neither `git` nor `bash`.)
|
||||||
- [x] The little menues, like when choosing language or clicking my username, they do not dissapear when clicking outside them, I must click the original trigger or choose something. See if there are more modern ways of handling this with HTML and CSS. I think there is a modal-thing or something? (The modern thing is the **Popover API**. All three popup menus — language picker, profile, row kebab — are now a `<button popovertarget>` plus a `[popover]` panel instead of `<details>`/`<summary>`, so the browser owns open/close: clicking anywhere outside dismisses one, `Esc` dismisses it and returns focus to the trigger, opening one closes the others, and the panel sits in the top layer where `.table-wrap`'s `overflow` can no longer clip a row kebab. Placement is CSS anchor positioning; the panel needs `position-anchor: auto` to bind to the button that opened it — a bare `anchor()` resolves to nothing in Chromium, Firefox and WebKit alike, measured in all three before picking the approach. `data-table.ejs` stopped hand-rolling its kebab and calls the `menu` partial, so the pattern lives in one file. Each panel is named by its caller (`locale-menu`, `profile-menu`, `row-actions-1`) and the partial fails loud without an `id`, since `popovertarget` is an idref — generated ids were tried first and dropped for being unreadable and nondeterministic. `<details>` stays in the nav tree, where it means disclosure rather than popup. A browser older than the popover API flows each panel inline under its trigger, so Sign out is never stranded behind an inert button. `e2e-tests/visual.spec.ts` drives the whole behaviour — opens, anchored to its trigger, outside-click, Esc — and is tagged `@engines` so it runs in Firefox and WebKit as well as Chromium, because CSS anchor positioning is the newest thing in the app and every popup rests on it. Decisions recorded in AGENTS.md.)
|
- [x] The little menues, like when choosing language or clicking my username, they do not dissapear when clicking outside them, I must click the original trigger or choose something. See if there are more modern ways of handling this with HTML and CSS. I think there is a modal-thing or something? (The modern thing is the **Popover API**. All three popup menus — language picker, profile, row kebab — are now a `<button popovertarget>` plus a `[popover]` panel instead of `<details>`/`<summary>`, so the browser owns open/close: clicking anywhere outside dismisses one, `Esc` dismisses it and returns focus to the trigger, opening one closes the others, and the panel sits in the top layer where `.table-wrap`'s `overflow` can no longer clip a row kebab. Placement is CSS anchor positioning; the panel needs `position-anchor: auto` to bind to the button that opened it — a bare `anchor()` resolves to nothing in Chromium, Firefox and WebKit alike, measured in all three before picking the approach. `data-table.ejs` stopped hand-rolling its kebab and calls the `menu` partial, so the pattern lives in one file. Each panel is named by its caller (`locale-menu`, `profile-menu`, `row-actions-1`) and the partial fails loud without an `id`, since `popovertarget` is an idref — generated ids were tried first and dropped for being unreadable and nondeterministic. `<details>` stays in the nav tree, where it means disclosure rather than popup. A browser older than the popover API flows each panel inline under its trigger, so Sign out is never stranded behind an inert button. `e2e-tests/visual.spec.ts` drives the whole behaviour — opens, anchored to its trigger, outside-click, Esc — and is tagged `@engines` so it runs in Firefox and WebKit as well as Chromium, because CSS anchor positioning is the newest thing in the app and every popup rests on it. Decisions recorded in AGENTS.md.)
|
||||||
- [x] Organize the files in src in to folders so it is easier to understand the structure of the code.
|
- [x] Organize the files in src in to folders so it is easier to understand the structure of the code.
|
||||||
- [x] Move docs/plugin-contract.md into README.md and remove the docs folder.
|
- [x] Move docs/plugin-contract.md into README.md and remove the docs folder.
|
||||||
|
|||||||
Reference in New Issue
Block a user