Files
small-packages/luci-theme-footstrap/docs/ci.md
T
2026-09-20 01:22:33 +08:00

626 lines
45 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# CI and packaging
How footstrap is turned into packages and shipped. The theme is noarch
(`LUCI_PKGARCH:=all`) and supports **OpenWrt 24.10** (`.ipk`/opkg) and **25.12+** (`.apk`/apk).
What the Makefile and the install scripts do: [package.md](package.md). The release runbook:
[releasing.md](releasing.md).
## The job graph
`.github/workflows/build.yml`. Triggers: push to `main`, a `v*` tag, any pull request, or a manual
dispatch.
```
check ─┐ ┌─→ verify ─────┐
├─→ build ─┼─→ live ───────┼─→ release ─→ playground-asset ─→ pages (release and after: tags only)
lint ──┘ └─→ playground ─┴─→ pages-manual (workflow_dispatch, publish-pages=true)
```
| Job | What it is |
|---|---|
| `check` | static gates that need no node |
| `lint` | the npm gates: eslint, stylelint, axe-core, the ratchets |
| `build` | both package formats, via owfeed |
| `verify` | installs this very build on real 25.12 and 24.10 userlands and renders its pages |
| `live` | opens every page of the menu on those userlands and measures it — layout, navigation parity, and the assumptions this theme makes about luci-base |
| `playground` | records a real router's pages into a static, replayable site and proves it offline |
| `release` | signs, generates the notes, attaches the assets |
| `playground-asset` | uploads `playground.tar.gz` to the tag's release, once one exists |
| `pages` | refreshes the GitHub Pages portal and the release mirror, from the latest release |
| `pages-manual` | same refresh, off a manual dispatch, from THIS run's own playground instead of a release — see `pages`, below |
`permissions: contents: read` at workflow level; only `release` and `playground-asset` declare
write, both gated to a `v*` tag. It used to be workflow-wide, which handed it to every
`pull_request` run — including `npm ci` in lint, and therefore to the lifecycle scripts of every
dev dependency.
**Every job has a `timeout-minutes`, and every network install runs under `tools/ci-retry.sh`.**
The default job timeout is six hours, and the two things these jobs fetch from somebody else's
server — apt and the Playwright CDN — stall rather than fail: on the 0.13.2 tag one apt step and two
playwright steps sat for 68, 68 and 17 minutes with GitHub reporting every system operational, and
nothing retried them because nothing had failed. Each attempt now gets its own deadline (six minutes
for apt, seven for playwright, three attempts), so a stall is a retry rather than a blocked release,
and the job timeout is the backstop behind that rather than the only clock in the job. Each attempt runs in a session of its own and is killed by process GROUP, because `timeout` signals only the child it started: the first version of the helper killed `playwright install --with-deps` and left the `apt-get` beneath it holding the lists lock, after which both retries died in seconds on that lock.
And apt is asked for as little as possible, because retrying a stalled apt is still waiting for a
stalled apt: a run spent its whole 20-minute budget doing exactly that. `gettext` is installed only
when `msgfmt`/`msgmerge`/`xgettext` are missing from the image, and `tools/ci-playwright.sh` fetches
Chromium from Playwright's CDN and then LAUNCHES it — the claim the gates actually need — going to
apt for the system libraries only if that launch fails. `--with-deps` went through apt every time,
and apt is the half that stalls.
## `check` — gates without node
Needs only `sh`, `awk`, `python3` and `perl`. Seconds to run, and it cannot break the OpenWrt
buildbot, where node does not exist and never will.
1. **`sh -n`** over `luci-theme-footstrap/*.sh`, `install.sh` and `tools/*.sh` — the scripts that
never reach a router. `tools/` is in the glob because `release-notes.sh` runs only in the
`release` job, so a syntax error there used to surface at the most expensive possible moment.
The *payload* scripts are parsed elsewhere: `owfeed doctor` (OWF213) parses everything under
`files:` in the `build` job, which covers `/etc/uci-defaults/*` once `tools/stage.sh` has staged
it.
2. **The scan marker.** `include/scan.mk` finds packages by grepping for `call BuildPackage`, which
this Makefile only reaches through `luci.mk`'s include — so the literal in its trailing comment
is what makes the SDK see the package at all. It has been deleted as boilerplate once.
3. **The ACL is valid JSON, and grants something.** rpcd skips an unreadable file in `acl.d` and
says nothing: a stray comma means the grant is issued to nobody, and nothing else notices. A
document that parses but is a list, or an entry with neither `read` nor `write`, is the same
silent outcome by another route, so `tools/check-acl.sh` checks the shape too.
4. **`build-css.sh`** into a temp file — the script brace-balances its own output and refuses to
write a suspiciously short file. That is a broken-build floor (80 KB), a correctness gate,
not a size budget. There is no upper CSS budget any more.
5. **`audit.py --strict`** — undefined `var()`, shadowed declarations, export-tier reads from
`styles/`, dead base declarations, stray `!important`, colour literals.
6. **i18n**: `update-po.sh --check` fails if the `.pot` is stale or any `msgstr` is empty. A string
in `_()` with no translation renders in English silently — which is how the whole Footstrap
tab stayed English on a Russian LuCI.
**Template compilation is not here** — it runs in `verify`, on the router, with the real `ucode`.
It used to clone the interpreter at a pinned commit and build it with cmake, with the router runtime
stubbed out through `-L`; the container has both for free and stubs nothing.
## `lint` — the npm gates
They live in CI only: the buildbot has no node and does not need it. Nothing in `package.json`
ships. Locally it is all one command, `npm run check`; the full table of what each gate holds is in
[conventions.md](conventions.md). The ones worth naming here:
| Step | What it catches |
|---|---|
| `eslint` | including `wrap-regex`, which forbids `return /re/…`, the form jsmin breaks on |
| `stylelint` | correctness and project invariants only — not a formatter |
| `a11y-gallery.mjs` | axe-core, WCAG 2.2 AA over `docs/gallery.html`, {light,dark} × {footstrap,hicontrast,bootstrap,2020,forum} × {untinted,60°,260°} — **30 combinations** |
| `export-tier.mjs` | the `--*-color-*` contract with foreign apps — axe cannot see it, their widgets are not in the gallery. 70 palette × mode × tint combinations, then the ten untinted ones again with `prefers-contrast: more` emulated, since that query re-states the ink tokens and so publishes a second tier |
| `css-metrics.mjs` | ratchet: `!important` ≤ 27, max specificity, empty rules |
| `css-floor.mjs` | the browser floor derived from the built sheet against the one stated in `docs/css.md`, plus the two shapes that break below it: a `:has()` compound sharing a selector list with one that has none, and a CSS feature nobody has classified |
| `fs-orphans.mjs` | dead `fs-*` selectors (safe only inside our namespace) |
| `css-dup.mjs` | identical declaration bodies under different guards — no linter calls this an error |
| `mirror.mjs` | `@mirror`-pinned copies still byte-identical (CSS **and** shell) |
| `axes.mjs` | the pre-paint in `head.ut` agrees with the live appearance appliers |
| `page-modules.mjs` | the page → module map agrees with the modules, and neither is `require`d at eval |
| `table-contract.mjs` | the break points, and the rule that hides an unanswered data table still names every root `fs-select` scans, guarded on `:root[data-fs-fit]` — the selector half of what `table-tick.mjs` then proves on a page |
| `chrome-fence.mjs` | the `[data-fs-chrome]` marker, fence and pin still match the chrome |
| `conffiles.mjs` | every shipped `/etc/config/*` is declared a conffile — else the manager replaces it on upgrade |
| `bang-ok.mjs` | the `!important` allowlist in `audit.py` and `.stylelintrc.json` still say the same thing |
| `changelog.mjs` | the changelog contract: sections, order, RU mirror, bold leads |
| `jsmin-verify.mjs` | the **only** check that catches jsmin's silent corruption (exit 0) |
| `npm test` | the unit suite — no browser, ~100 ms, and first in the job for that reason. It holds the branches a stand cannot enter: a luci-base missing a surface the router calls, an alias loop or a `firstchild` tie in a menu tree nobody ships |
| `size-budget.mjs` | ratchet on the **artefact**: `build-css.sh` + the token mangle for the sheet, terser over a copy of `htdocs/luci-static/resources` for the JS. `--show` prints the per-module table into the log, so a jump is attributable without re-running anything |
| `build-icons.mjs --check` | the committed app-icon rasters still match `logo.svg` — per channel with a tolerance, plus the maskable invariants (declared size, no alpha, nothing outside the safe zone, a mark in the middle). Not byte equality: that tested the renderer, and a Chromium bump reddened it on a commit that never touched the logo |
**About `jsmin-verify`:** jsmin corrupts a file silently and exits 0. The source shape it breaks on,
and why eslint's `wrap-regex` stands beside this gate, are in [conventions.md](conventions.md); the
two minification paths are in [package.md](package.md). What CI adds is the proof: it builds that
same jsmin from `luci-base/src/jsmin.c` at the pinned commit and compares the token stream of the
output against the source (acorn). The list of shipped JS is not written by hand — it is
`find htdocs -name '*.js'`, because luci.mk copies `htdocs/` wholesale, so that *is* the shipped set.
Both gates stay mandatory **for the source**, even though the release build minifies with terser
instead: a build without node still hands the untouched source to jsmin.
**About `size-budget`:** the numeric budgets were dropped once, and the reason they are back is what
they measure now. The old ones were counted on the SOURCE, which is not what anyone downloads — the
sheet is concatenated and token-mangled and the JS goes through terser, all of it inside the package
build, so a developer's edit-and-check loop never sees the artefact at all. That is the shape a
number drifts in: one feature at a time, each too small to argue with, none of them measured. This
gate reproduces the package build's asset half and weighs the result. uhttpd serves `/www` with no
compression, so identity bytes are wire bytes, read off flash by a core that is also routing packets.
Raising a limit is a decision and wants a comment saying what it bought; lowering one after a cleanup
is how the slack stops being spendable in silence.
Most of what the other jobs run lives in `tools/*.sh` rather than inline in the YAML —
`check-shell.sh`, `scan-marker.sh`, `check-acl.sh`, `build-jsmin.sh`,
`check-packages.sh`, `feed-key.sh`, `stage-release.sh`. A step that is a script can be run by hand
when it fails, and its reasoning lives beside the code instead of inside a workflow nobody reads.
## `build` — owfeed, not the SDK
Both formats are built by [owfeed](https://github.com/owfeed/owfeed) from one staged rootfs.
The SDK legs are gone: the theme is noarch (CSS, templates, browser JS, fonts — not one byte of
compiled code), and each leg downloaded, verified and unpacked a cross toolchain in order to run
`cp`. Twice, because 24.10 is opkg and 25.12 is apk.
| Format | Line | noarch spelling | What owfeed does |
|---|---|---|---|
| apk | 25.12+ | `noarch` (`all` is rejected as uninstallable) | `apk mkpkg` + `.conffiles` sidecars |
| ipk | 24.10 | `all` | an `ipkg-build` container + `CONTROL/conffiles` |
Three things the SDK really provided, owfeed does itself: it compiles `.po` → `.lmo` with its own
compiler (**byte-identical to `po2lmo`** — checked on release 0.11.6), emits the same sidecars as
`package-pack.mk`, and wraps the scripts in `default_postinst`/`default_prerm`, without which
`/etc/uci-defaults/*` never runs at all.
**The SDK check did not leave with the SDK.** owfeed extracts the host `apk` from a release SDK
tarball and keeps it on the same chain this workflow used to spell out: an ed25519 signature over
`sha256sums`, the branch key pinned from a *different* host (`github.com/openwrt/keyring`), and the
sha256 read only out of an already-authenticated file.
**owfeed itself is installed by its own action** — `owfeed/owfeed/setup`, pinned by commit — which
downloads the binary and **verifies it against GitHub's build attestation before it reaches PATH**.
That is why it is preferred to `go install …@<sha>`, which compiles the tool in every job and trusts
whatever the module proxy returns. A `SHA256SUMS` next to a release is not a check either — the same
host serves both the binary and the sum (the same argument this project makes about GitHub's asset
digest). An attestation is signed by GitHub's identity, sits in a public transparency log, and the
action pins the workflow that was allowed to issue it. Verified locally on a release binary:
genuine → exit 0; one byte appended → exit 1; against a foreign repository → exit 1.
The steps:
1. **Resolve the version** — from the tag (`v0.3.6` → `0.3.6`) or a fallback `0.<date>.<run>`. Plus
`SOURCE_DATE_EPOCH` = the commit timestamp: both containers write mtimes, and a package's
identity is a hash over its payload, so without a fixed epoch byte-identical content rebuilds
into a package that *claims* to be new.
2. **`./tools/stage.sh`** — the half owfeed deliberately does not do ("packages a directory; does
not build one"). It is `Build/Prepare` from the Makefile, step for step: build `cascade.css`,
mangle the private `--fs-*` names, minify the JS with terser, strip comments out of the templates
and the shell, stamp `FS_VERSION`. It also extracts postinst/postrm — by awk over the
Makefile's `define`s, not as a second copy: a script that runs on somebody else's router months
later is the worst possible place for two copies to diverge.
3. **`owfeed build`** — deliberately without `--frozen-lock`. That flag re-derives `owfeed.lock`
from `downloads.openwrt.org` and fails if it moved, which is right for a repository that
**publishes a feed**, where the covered architecture set is part of the contract with
subscribers. Here two noarch packages ride as release assets and expand into no architectures, so
the flag buys nothing and costs a red release on every OpenWrt point release. The committed lock
still pins which SDK the host `apk` comes from, so the toolchain does not float.
4. **Assert the catalogues.** A `.lmo` is compiled in and absent from git, and a silently missing
one means every `_()` renders the English msgid with nobody reporting anything. Each language
ships as its own `luci-i18n-footstrap-<lang>`, so the assertion is one catalogue named
`footstrap.<lang>.lmo` per language package, a uci-defaults line registering that language, and
**none in the theme** — two packages owning one path is an install apk refuses. Counted against
the languages in `po/`, so adding one and forgetting to declare its package is a red build
rather than quiet English for some users.
5. **Flatten the packages into `dist/`** (owfeed writes `dist/noarch` and `dist/all`; apk derives
the filename from name and version, so two architectures cannot share a directory). CI requires
that the theme resolve to exactly one asset per format under a name-anchored regex
(`luci-theme-footstrap[-_]…`). That is not hygiene, it is protection for old routers: see issue
#6 in [conventions.md](conventions.md).
## `verify` — the package works on a real userland
Downloads the artifact and installs this build on real 25.12 and 24.10 containers through the
owlab action, then renders its pages. On non-PR runs it additionally fetches the feed's public key
and asserts the published feed serves a working theme.
That the build "produced an ipk" proves nothing about the ipk; this job is what does.
**When the feed does not answer, the assertion is skipped and says so.** `tools/feed-key.sh` still
reports `reachable=false` rather than failing — the claim is about a channel this repository does not
own — but the run now ends with "Report whether the published feed was measured", which writes
*measured and passed*, *measured and failed* or *NOT MEASURED* into the run's annotations and job
summary. A green `verify` no longer looks the same whether the feed was tested or never asked;
`tools/ci-local.sh verify` prints the same sentence from the same script.
**Five assertions per leg**, and the fifth is the template gate:
```
package luci-theme-footstrap
file /www/luci-static/footstrap/cascade.css
http 200 /cgi-bin/luci/admin/status/overview
http 200 /cgi-bin/luci/admin/system/system
exec for f in …/themes/footstrap/*.ut; do ucode -T -c -o /dev/null "$f" || exit 1; done
```
`ucode -T -c` is LuCI's own trycompile, and it runs here against the installed templates with
the router's real `luci.core` and `uci` — no interpreter of ours, no stubs. It runs on both
legs: the templates are identical but the interpreters are not, and a construct 25.12's ucode
accepts is no proof that 24.10's does. Without it a stray brace in `header.ut` ships green and
silently moves every user's LuCI to another theme, because luci.mk copies `ucode/` verbatim and
nothing parses it on the way.
The same assertions are what `owlab test` runs locally — see [development.md](development.md), and
keep the two in step.
## `live` — the pages behave, not just the package
`verify` proves the package installs and two pages answer 200. That is not the same claim as "the
pages are right", and the difference is where every field report has lived: #11 a column shredded to
one character per line, #22 a clipped submenu title, #12 a doubled scrollbar in one engine, #8/#33/#36
a third-party app laid out wrong, and two pages that came back empty after a client navigation — all
green under every static gate at the time.
The job boots the same owlab containers a developer runs locally (`owfeed/owlab/setup` puts the CLI
on PATH; the binary is checked against its build attestation), installs the artifact `build` just
produced, and runs the live gates.
**It is three jobs, not one.** Once `anchors` moved out, this was the release's critical path, so it
became three slices (`parity`, `audit`, `motion`), each booting its own routers for 157s it does not
share — the wall clock is the longest slice and not the sum. `fail-fast` is off: a parity failure
says nothing about whether the reader stays put. Where two gates share a slice they also share the
page shapes they read, through `FS_SHAPES` — classifying is one load and a 1200ms settle per page of
the menu, and both gates used to do it back to back (measured: live-audit 421s alone, 303s after
spa-parity had already read the same router). **Splitting them onto separate runners gave that
saving back**: `FS_SHAPES` points inside each runner's own temp, so `parity` and `audit` each walk
the whole menu now — ~90s a router, and the price of the split.
Measured on the last run where every slice finished (`7ff9e56`, push, three routers): the gates are
**485s** (spa-parity), **450s** (live-audit) and **754s** (`motion`'s six). `motion` carried a
seventh until task liveslice — chromium's own scroll-anchor sweep, which grew from 145s to **1434s**
when `--full` started crossing its density axis (`e48434c`) and put the slice at 39 of its 45
minutes. That sweep is a shard of `anchors` now, beside firefox and webkit, where the cap is 75
minutes and the one-job-per-engine rule already lives; the cells are the same cells.
**A number here only holds while every page still answers.** `page.evaluate()` has no deadline in
Playwright, so a page that pins the browser's main thread does not cost a page's worth of seconds —
it costs the slice its whole budget. That is what happened for a day: `/admin/system/filemanager`
under `13e9864` (`fs-fit.js`), `parity` and `audit` both cancelled at 45 minutes on every run, with
nothing in either log. Both gates now print a line per page with the clock on it, so the next one
names the page instead. **Read which page a slice last named; never raise the cap.**
Printing a line is only half of it: a slice that names the page and is then killed at 45 minutes
still measured nothing about the pages behind it. Each shape probe in `classify()`
(`tools/lib/page-shapes.mjs`) is capped at `PROBE_DEADLINE_MS` (10s) — about 2000x the 4-5ms a live
page answers in, and wide enough that even a menu where every path froze costs 16 minutes rather
than the whole slice (96 paths, the widest measured, `owrt2512b` with the third-party fixtures). A
page that loaded and then stopped answering is printed as a FINDING naming the router and the path,
the walk continues on a fresh tab — a frozen page stays poisoned, every later `goto` on it burning
its own 20s, while `close()` + `newPage()` costs 0.5s and the login lives on the context — and
`parity` and `audit` exit non-zero. A page that never LOADED still says nothing, on purpose: that is
the runner or the stand, not the theme, and the two must not read alike.
Cheapest-first still holds inside `motion`, which opens with upstream-contract at 7s; it can no
longer come before the other two slices, which are on runners of their own.
1. `tools/upstream-contract.mjs` — the assumptions about luci-base. It runs first because if one of
them has moved, every finding below is downstream of it.
2. `tools/spa-parity.mjs` — every page opened by a click and by a full load, compared.
3. `tools/live-audit.mjs` — one page per shape at six widths (resized into) plus one ENTERED with a
load of its own — and the arrival records only what the resize at that width did not already
say, since a fault both passes see is one fault and a second copy of it is a baseline entry
that carries no information and only exists where that app is installed. Ratcheted against
`tools/baselines/live-audit.json`. `--update` UNIONS into that
file rather than replacing a router's set: the baseline is a union across platforms, and a
machine that does not install mwan3 must not delete mwan3's findings.
4. `tools/scroll-jank.mjs` — a real wheel, long enough to meet a poll tick, in both layouts: nothing
may re-decide or jump while the page moves.
5. `tools/table-tick.mjs` — the poll tick performed deliberately, with the layout forced inside the
window fs-select answers in: a replaced data table may not be laid out before it has an answer.
6. `tools/scroll-anchor.mjs` — content grows above the reader and the page must not move under them,
with and without the engine's own scroll anchoring. It runs in its OWN job, **one shard per
engine — chromium, firefox and webkit** (`anchors`), not here: at 52 minutes against this job's 17
it was the whole release's critical path, and the three engines share nothing, so the shards run
at once. Chromium stayed in `motion` while it was the cheap engine at 145s and left when `--full`
took it to 1434s (task liveslice); it is now the shortest of the three shards and costs the
pipeline no wall clock at all. Its sweep is narrow on a pull request and `--full` on a push — the
axes it drops were measured not to change its answer, and a push is where being wrong about that
must still be caught.
7. `tools/install-check.sh` — `install.sh` twice on each router, fresh and over its own result. It
goes last in its slice because it replaces the build under test with the published release; #16,
#28 and #30 were all this script, and all on the second run. That replacement is also why it may
not share a router with a gate still measuring the build — which a slice of its own guarantees.
**Three routers on a push, one on a pull request.** The push set is the OpenWrt lines the theme
supports — 25.12/apk, 24.10/opkg and the snapshot box, which tracks luci-base's master and so fails
on an upstream change before a user reports it. ImmortalWrt is not in it: same luci-base, different
brand and app set, never the leg that caught something first, and no gate measures it locally
either (`tools/lib/stands.mjs`). What each gate holds, and how
to run it by hand, is in [conventions.md](conventions.md) and [development.md](development.md).
**The snapshot box carries no third-party extras (`owlab.yaml`, `owrtsnap`).** openclash and
ssclash pull `kmod-tun`, which resolves through a kmods index keyed to the box's exact kernel git
hash, and the box's baked kernel is never the one `downloads.openwrt.org/snapshots` is currently
publishing kmods for — permanent drift, not a stale image. Measured 2026-09-07: `apk add` 404s the
kmods `packages.adb` for the running `6.18.33` hash and both apps fail; justclash (no kernel module)
installs cleanly on the same box, which is why it was never in the failure list. `owlab`'s own
`extra_packages` merge is additive only (`defaults:` plus what a router adds, no subtraction), so
the three apps are declared once, on `owrt2512`, and aliased onto the other release routers instead
of living in `defaults:` — `owrtsnap` gets none. The snapshot leg still runs every other gate; only
these three apps are untested there.
## `playground` — a real router, recorded and replayed
Turns `admin/status/overview` and seven other pages of an owlab stand into a static site any browser
can open with no router behind it, and proves that site offline. Three scripts, `tools/playground/`:
1. `capture.mjs` — boots nothing itself, talks to the `owrt2512` this job already installed the
build on: logs in, fetches every page in `pages.json`, and records the server's own document, the
`/luci-static/**` assets those pages fetched, every distinct ubus call and the ACL-filtered menu.
2. `build.mjs` — offline, deterministic: rewrites the recorded paths under `--base`, scrubs tokens
and the stand's hostname, splices in `replay.js` (patches `XMLHttpRequest`/`fetch` to answer from
the recording instead of a router), and tars the result.
3. `verify.mjs` — opens the built site in a real Chromium and fails it on a page error, a replay
miss, a 404, or an empty `#view`; also proves the one client navigation this theme has and that an
Appearance change survives a reload.
Runs on a tag, a pull request and `workflow_dispatch` — not only on release, because a regression in
`fs-*.js` against a real ubus answer is a real fault and the cheapest place to catch it is before a
tag exists to publish from. It replaces the 475 KB hand-written snapshot that used to live here,
which imitated `fs-*.js` inline and had already gone 13 releases stale (`CHANGELOG.md`): this build
runs the theme's own JS, so it cannot drift from it the same way.
Local run: `tools/ci-local.sh playground` (needs `--force`, same stand-safety rule as `live`); by
hand, `docs/development.md`.
## `release` — signing and publication
Tags only, and **it is a call into owfeed's own reusable workflow** rather than steps of ours:
```yaml
release:
needs: [build, verify, live, anchors, lint, playground]
if: startsWith(github.ref, 'refs/tags/v')
uses: owfeed/owfeed/.github/workflows/package.yml@v0.5.1
secrets: { sign-key: …OWFEED_AUTHOR_KEY, usign-key: …FOOTSTRAP_USIGN_KEY }
with: { owfeed-version: v0.5.1, pre-release: sh tools/stage-release.sh,
sign-also: install.sh, notes-file: release-notes.md, verify-with: release.pub }
```
**This is the only job that holds a key**, and this `needs` list is what stops a tag publishing a
package no router has installed, no page has behaved on, or no recording has proven runs the real
JS — `playground` is in it for the same reason as `live` and `anchors`: a fault it catches is the
theme's, not a docs nit, and it does not ship with one unfound.
`tools/stage-release.sh` is our half — the `pre-release` hook. It writes the release notes where the
workflow reads them and puts the one non-package asset into `dist/`, before the manifest is written,
because whatever is in `dist/` is signed with everything else:
- **the notes**, from `tools/release-notes.sh` — the tag's changelog section, one bold lead per
bullet, grouped by category. They fill the release page and are **not** an asset: the only reader
that ever fetched `notes.md` from a release was the self-update package, which is retired and its
repository archived;
- **the installer**, because `raw.githubusercontent.com` is rate-limited for unauthenticated callers
— so the user whose address has run out of budget (CGNAT, a shared exit) fails to download the
installer meant to rescue them (issue #17) — and because a signed copy is the only one that can be
checked before it runs as root. The README points at `main` and names this one as the fallback.
`sign-also: install.sh` gets it a signature of its own (it is not a package, so owfeed would not see
it otherwise), and `verify-with: release.pub` re-checks every signature against the key that is in
the repository and baked into the installer.
Result: one theme asset per format plus its `.sig`, the manifest plus its `.sig`, and the installer
plus its `.sig`. Each has a reader: the packages are what a router installs, the manifest is what
`install.sh` installs *from* when the feed cannot be read and where it reads the newest version for
its "the feed has not caught up yet" line, and the installer is the mirror the README names.
**About the manifest and readers in the field.** owfeed's format was copied from this project's own
manifest, with two differences, both safe by construction: the first line became
`owfeed-manifest 1` (nobody parses it), and the package architecture was appended to the end of
the `pkg` line. The order of the first six fields is untouchable: `install.sh` reads them positionally,
and a copy already on somebody's router cannot be fixed remotely — a field inserted before them
would make it fetch a URL that 404s. The workflow checks that order on every release rather than
relying on it.
## `playground-asset` — attaching the built playground to the release
Tags only, `needs: [release, playground]`. `dist/*` rides the manifest `release` signs, and
`playground.tar.gz` is a demo site rather than router payload — owfeed's `package.yml` has no input
for a foreign artifact, and putting one there would sign it as if it were one — so this is a plain
`gh release upload` against the tag `release` just published, in a job of its own with
`permissions: contents: write` and nothing else. `pages` waits on this job, not on `release` directly,
so its own fetch of `playground.tar.gz` (below) never races the upload that puts it there.
## `pages`
Publishes the developer portal to GitHub Pages: `docs/devkit.html` (generated by
`tools/devkit-build.mjs`, never committed) and `docs/gallery.html`, with a freshly built
`cascade.css` and `logo.svg` beside them. It also carries a **full mirror of the
latest release** — the manifest, its signature, the installer **and the packages** — on a different
host from github.com, so an outage or a block covering one need not cover the other. It is called by
`release` (by way of `playground-asset`) rather than triggered by `on: release`, because an event
raised by `GITHUB_TOKEN` does not trigger another workflow.
The devkit assembles itself from files that already exist — the real stylesheet, the export tier
parsed out of `02-tokens.css`, the widget markup from `gallery.html` — so nothing is hand-copied and
nothing can drift.
**The playground is not built here at all.** It is not source that lives in this repository; it is
fetched, already built, from `releases/latest/download/playground.tar.gz` — the same
`playground-asset` upload above — and unpacked into `_site/playground/`. Fails OPEN like the release
mirror above it: a repository with no playground-carrying release yet (or a `workflow_dispatch` run
on a branch, `docs/development.md`) publishes the rest of the portal and says so in the log rather
than failing the build. `_site/playground.html` is kept as a redirect to `playground/` for the links
the README and `devkit.src.html` already carry.
**A maintainer can publish Pages from a single run's own recording, without a tag:**
```sh
gh workflow run build.yml --ref <ref> -f publish-pages=true
```
This runs `build.yml`'s `pages-manual` job (`needs: playground`, off by default), which calls
`pages.yml` with `playground-source: artifact` — everything above is unchanged except the playground
fetch, which reads the `playground` artifact this same run uploaded instead of
`releases/latest/download/playground.tar.gz`, and FAILS CLOSED on a miss rather than publishing the
rest of the portal. Two things to know before using it:
- **The `github-pages` environment accepts only `main` or a `v*` tag as a deployment branch**
(repo Settings -> Environments -> github-pages; checked 2026-09-14 with
`gh api repos/…/environments/github-pages/deployment-branch-policies`:
`custom_branch_policies` = `[main, v*]`). `--ref` naming anything else reaches `deploy` — `build`
and the artifact fetch already ran — and is refused there.
- **The next push to `main` under the paths `pages.yml` watches overwrites a manual publish.** That
run fires from `pages.yml`'s own push trigger, `playground-source` defaults back to `release`
there, and Pages goes back to mirroring the latest tag.
## Installation and the trust chain
`install.sh` does one thing: it adds the owfeed-packages feed (key, repository entry, and a
`keep.d` entry so a sysupgrade does not lose the key) and installs the theme from there.
`apk upgrade` / `opkg upgrade` carries it forward afterwards, which is the whole reason to install
from a feed rather than from a file.
It then installs the catalogue for `luci.main.lang`, and **the feed not carrying that package is not
a refusal**: a release reaches owfeed-packages through a pull request, so a NEW package — which
`luci-i18n-footstrap-<lang>` was in 0.14.4 — is missing from the feed until that merges, while the
signed asset for it is already in the release. The fallback takes it from the release through the
chain below, pinned to the tag the installed theme came from, and `apk upgrade` picks the package up
from the feed once it lands there.
```sh
wget -qO- https://github.com/VizzleTF/luci-theme-footstrap/releases/latest/download/install.sh | sh
```
**The repository line goes into the manager's own customfeeds file**, `customfeeds.list` for apk and
`customfeeds.conf` for opkg — not into a file of the theme's own. apk reads every `*.list` under
`repositories.d/`, so a private file installs and upgrades just as well; what it cannot do is be
seen. LuCI's package manager reads exactly three apk paths — `repositories`,
`repositories.d/distfeeds.list`, `repositories.d/customfeeds.list` — in its rpcd ACL *and* hardcoded
in `package-manager.js`, so a feed anywhere else is absent from "Configure APK" and cannot be edited
or removed there. An installer from before this wrote `repositories.d/owfeed-packages.list`, which
is why the apk branch deletes that file after writing its own line — the same repository configured
twice, once where the admin can see it and once where they cannot, is worse than either. Neither
customfeeds file needs a `keep.d` entry: both are conffiles of their manager (`apk-mbedtls`, `opkg`),
sysupgrade backs up every conffile whose checksum has moved, and `build_list_of_backup_overlay_files`
was already dropping the duplicate entry the script used to add.
**The line is MOVED to be the first non-comment line, never merely appended or left wherever it
already was** — measured on real apk: its customfeeds reader stops at the first line it cannot
parse, so an admin's own unrelated line (or a stale one from an older run) sitting above where the
correct line used to land could cut the feed off with no error at all, and a router this project's
own OLDER installer had already configured has exactly that shape (the line appended, after
whatever else was there) — so "is the line present" has to become "is it first", checked and
repaired on every run, not only when the line is missing outright; the closing message distinguishes
the three outcomes ("Adding"/"Feed added" when the line was missing, "Moving … to the top" when it
only had to be repositioned, "already configured" when nothing changed). Every other ACTIVE line
naming the feed's host (case-insensitive) or, on opkg, its src name, is commented out and reported
rather than left to shadow the correct one; a line for any other host is untouched.
Both this and the stale-line cleanup write the result atomically and never `mv` a fresh file over the
customfeeds path directly: they resolve it with `readlink -f`, `cp -p` its mode and owner onto a temp
file in the SAME directory (so the rename stays on one filesystem), write the new content into that
copy, and only then `mv -f` it over the resolved target. A customfeeds file that is itself a symlink
keeps being one — the rename replaces what it points at, never the link — and a write that fails
partway (a full overlay, OOM, a `cp`/`cat`/`mv` a hostile PATH entry shadows) leaves the original
byte-identical, because nothing is truncated until the copy is complete and the rename is what
actually takes effect. **The failure itself is checked, not inferred**: `disable_other_lines`'s
"disabled:" lines wait until the write actually lands before printing, and `ensure_first` reports
its outcome through a real exit status the caller tests with `if`, never through `$(…)`, whose own
exit code `set -e` cannot see — a failure to write the feeds file now exits 1 with one error naming
the file, leaving it unchanged and no temporary files behind, instead of printing "Adding the
feed…"/"disabled: …" over a write that silently never happened (measured on both package managers
with `cp`, `mv`, and — on opkg — `cat` shimmed to fail; on apk a failing `cat` is intercepted
earlier, at reading `/etc/apk/arch`, and reported there instead).
**A snapshot router is served the newest release branch.** The feed publishes one branch per
OpenWrt minor and has no snapshot channel — owfeed-packages lists exactly two release lines and they
*are* the package-format split (apk from 25.12, ipk on 24.10), not a build of the theme per release.
So `SNAPSHOT`, which parses to no branch, gets the newest branch its own package manager can read:
`FALLBACK_BRANCHES_APK` / `_OPKG` in the script, probed newest-first against
`releases/<branch>/<arch>/<index>` rather than assumed, so a branch listed before it is published —
or one that does not carry this router's architecture — falls through instead of writing a
repository entry that 404s on every update. What makes it sound here and not in general: the theme
is noarch and `+luci-base` is its whole dependency list, so nothing in it was compiled against the
branch it comes from. The probe's bytes are discarded; the index it found is still verified below.
When no candidate answers, the script installs the signed release instead (below) rather than
refusing — measured in an `openwrt/rootfs` snapshot container (`apk add` from the 25.12 branch, no
`--allow-untrusted`, theme registered) and with the feed host pointed at a name that does not
resolve.
**Every downloader on the box is tried, not the first one that exists.** `uclient-fetch` needs
libustream-mbedtls to speak https at all, and an image that ships wget-ssl or curl instead carries
the binary without the library — so choosing by existence reported "the feed carries no branch for
this router" when the truth was "this one tool cannot do TLS here". Reproduced with a
`uclient-fetch` stub that always fails: the old script printed exactly the field report, the current
one falls through to `wget` and installs from the feed. None of the three is ever asked to skip
certificate verification; falling through to the next TOOL is not a downgrade.
**What verifies the bytes on the feed path is the package manager**, against the feed key pinned in
the script: apk checks the index against `owfeed-packages.pem`, opkg against usign key
`9040356b214084da`.
**The theme is now also carried by the official openwrt/luci feed, and a bare `apk add` picks that
build instead of owfeed's.** apk resolves a name it is given with no version constraint to the
HIGHEST version across every configured repository, not the one the repository line just added
serves — and luci.mk stamps a LuCI-carried package from git's commit date rather than this
project's own numbering. Measured on owrt2512 (OpenWrt 25.12.4, apk-tools 3.0.5):
```
# apk list luci-theme-footstrap
luci-theme-footstrap-0.14.10-r1 noarch {luci-theme-footstrap} ← owfeed
luci-theme-footstrap-26.246.70755~4fd72fd noarch {feeds/luci/…} ← official feed
```
`install.sh`'s `apk add --upgrade "$PKG"` moved a router onto the second line — reported as
"Upgraded luci-theme-footstrap 0.14.9-r1 -> 26.246.70755~4fd72fd", which is not an upgrade: that
build predates this release (no rpcd-reload fix, no checkbox-tooltip fix) and comes from a
different publisher. The fix is a version constraint the script now applies to both the theme and
its `luci-i18n-footstrap-*` catalogues, `apk add --upgrade "$PKG<26"`, which both picks owfeed's
build over the official one and REPAIRS a router that already has the official one (`--upgrade`
still applies, so it downgrades, measured: "Downgrading luci-theme-footstrap
(26.246.70755~4fd72fd -> 0.14.10-r1)"). `26` is a ceiling, not a version pin: `apk version -t`
confirms `26.246.70755~4fd72fd` and `27.1.1~abc` both compare `>` against `26` — the LuCI stamp
only grows with the calendar — while `0.14.10-r1`, `1.0.0-r1` and `25.99.99-r1` all compare `<`,
so the constraint excludes every LuCI-stamped build for good and leaves this project's own
numbering majors 1 through 25 to grow into. opkg has no `world` file and no version-constraint
syntax on `install`/`upgrade`, so this has no opkg equivalent; left alone because the official
24.10 feed does not carry the theme today (checked on owrt2410, `opkg info luci-theme-footstrap`
names only this project's own `0.14.9-r1`) — if that changes, the fallback is the signed-release
install path this script already has (`install_from_release`), which bypasses feed resolution
entirely.
**There is no pinned-tag install, and the release-asset fallback is automatic but never the first
choice.** A router that cannot read the feed index at all — an architecture owfeed does not publish,
a resolver that does not answer, a network that intercepts the host — is installed from the release
instead of being sent away with a URL, and told plainly that `apk upgrade` will not carry the theme
forward until the feed works.
That path picks the artifact **from the signed manifest**, never by guessing an asset's name: issue
#6 was a self-update script, shipped at the time and retired since, that resolved the theme by
name and took `head -1`, which on a release
carrying per-language packages installed a catalogue. `manifest.txt` names exactly one file per
format with its size and digest, and the chain fails closed in this order — verified TLS, then
`usign -V` against the release key pinned in the script (the same key as `release.pub`), then the
manifest's own sha256 over the downloaded artifact. A missing `usign`, a signature that does not
verify, or a digest that does not match is a refusal, and the script then prints the by-hand URL as
before. `apk`'s `--allow-untrusted` on this path says only that the `.apk` carries no APK signature
of its own; what is trusted is the usign signature over the manifest, checked before the file is
handed to the manager. Measured on 25.12/apk and 24.10/opkg containers, including the refusal: a
one-character change to the pinned key leaves nothing installed.
A router that wants an OLDER version still does it by hand — the feed carries one version per
branch — with the **raw file from a release**, never the zip artifact from Actions:
```sh
apk add --allow-untrusted luci-theme-footstrap-*.apk # 25.12+
opkg install luci-theme-footstrap_*.ipk # 24.10
```
`--allow-untrusted` means the package manager holds no key of ours. The release publishes a signed
`manifest.txt` and a detached `.sig` per asset, so a by-hand install can be checked with
`usign -V -m <pkg> -x <pkg>.sig -p release.pub` — which is what the installer's own fallback does
for you.
## Package formats, and the usual mistake
- **apk** (25.12+) — the Alpine apk-tools format, which OpenWrt adopted in 25.12.
- **ipk** (24.10) — gzip-tar: `./debian-binary` (2.0) + `./control.tar.gz` + `./data.tar.gz` with
ustar headers. Identical to native OpenWrt ipks.
**"Malformed package file" from opkg** is almost always the wrong file: a GitHub artifact's zip
wrapper, or an `.apk` where an `.ipk` belongs.
An `.ipk` can be checked with a real opkg by hand — there is no such run in CI, and there was no
real proof before either, since "an SDK 24.10 produced it" was the same non-proof:
```sh
docker run --rm -v /path/pkg.ipk:/tmp/p.ipk openwrt/rootfs:x86-64-24.10.4 \
sh -c 'mkdir -p /var/lock; opkg install --nodeps /tmp/p.ipk'
```
**All four lifecycle scripts are present in both containers.** The first version of owfeed emitted
only `postinst` and `prerm` into the ipk — the pair `package-pack.mk` generates, and easy to mistake
for the whole set — so `postrm` (unregistering the theme, putting `mediaurlbase` back) reached apk
and not opkg. Fixed before the first release built with it, and checked against the 0.11.6 SDK build:
all four agree in meaning, with only cosmetic differences.