mirror of
https://github.com/caiwx86/small-packages.git
synced 2026-10-29 15:45:13 +08:00
update 2026-09-20 01:22:33
This commit is contained in:
1 parent
42e0cfbec4
commit
deb27aee9d
864 files changed
+180566
-42
No files matched your search
+629
@@ -0,0 +1,629 @@
|
||||
# luci-theme-footstrap is noarch: one build, two containers (apk for 25.12+, ipk for 24.10), both
|
||||
# out of owfeed rather than an OpenWrt SDK. docs/conventions.md has why.
|
||||
#
|
||||
# The release pipeline itself is owfeed's `package.yml`, shared by every repository in this
|
||||
# ecosystem. What is here is this theme's own gates: each one line, each a script under tools/ whose
|
||||
# header says what it is for.
|
||||
|
||||
name: Build LuCI theme packages
|
||||
|
||||
on:
|
||||
push:
|
||||
tags: ['v*']
|
||||
branches: [main]
|
||||
pull_request:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
# Off by default: the ordinary path is a v* tag through the `pages` job below, and this
|
||||
# exists to preview a run's OWN recording before one exists. The github-pages environment
|
||||
# accepts only `main` or a `v*` tag as a deployment branch (checked via `gh api
|
||||
# repos/…/environments/github-pages`, 2026-09-14: custom_branch_policies [main, v*]), so a
|
||||
# dispatch on any other ref reaches `pages-manual` but `deploy` inside it still refuses.
|
||||
publish-pages:
|
||||
description: >-
|
||||
Deploy GitHub Pages with THIS run's own playground instead of the latest release's.
|
||||
type: boolean
|
||||
default: false
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
# EVERY JOB CARRIES A TIMEOUT, because the default is six hours and a hung step is not a failed one:
|
||||
# an apt mirror and a playwright download stalled for 68 minutes on two jobs of the 0.13.2 tag with
|
||||
# GitHub reporting everything operational. The values are the green-run times with room for a slow
|
||||
# runner — 18s, 1m24s, 1m14s, 1m28s, and the live job, which is the only long one — so a stall goes
|
||||
# red in minutes and a re-run is one click.
|
||||
jobs:
|
||||
check:
|
||||
name: Static checks
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
- run: sh tools/check-shell.sh
|
||||
- run: sh tools/scan-marker.sh
|
||||
- run: sh tools/check-acl.sh
|
||||
- run: luci-theme-footstrap/build-css.sh /tmp/cascade.css
|
||||
- run: python3 tools/audit.py --strict
|
||||
# gettext for update-po; the ucode gate needs only docker. Asked for only when the image does
|
||||
# not already carry it, and under tools/ci-retry.sh: apt does not fail when its mirror is
|
||||
# unwell, it stalls, and a stalled step is nothing's problem until the job timeout.
|
||||
- run: |
|
||||
if command -v msgfmt >/dev/null && command -v msgmerge >/dev/null && command -v xgettext >/dev/null; then
|
||||
echo 'gettext already on this image'
|
||||
else
|
||||
sh tools/ci-retry.sh 300 sudo -n apt-get update
|
||||
sh tools/ci-retry.sh 300 sudo -n apt-get install -y gettext
|
||||
fi
|
||||
- run: luci-theme-footstrap/update-po.sh --check
|
||||
|
||||
# npm-based gates, and they live here and nowhere else: the OpenWrt buildbot has no node, luci.mk
|
||||
# never needs one, and nothing in package.json is shipped.
|
||||
lint:
|
||||
name: Lint + accessibility
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 20
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
- uses: actions/setup-node@v7
|
||||
with:
|
||||
node-version: '22'
|
||||
cache: npm
|
||||
- run: npm ci
|
||||
|
||||
# Through package.json, not a second copy of the globs: a glob restated here goes on linting
|
||||
# the old path after package.json moves.
|
||||
- run: npm run lint:js
|
||||
- run: npm run lint:css
|
||||
|
||||
# The unit suite: pure logic, no browser, ~100 ms. Before the browser install because it is
|
||||
# the fastest thing here and the first to fail on a broken module.
|
||||
- run: npm test
|
||||
|
||||
# The browser off Playwright's CDN, then a launch to prove it works; apt only if that
|
||||
# fails. `--with-deps` went through apt unconditionally, and apt is the half that stalls.
|
||||
- run: sh tools/ci-playwright.sh
|
||||
- run: node tools/a11y-gallery.mjs
|
||||
|
||||
# What the router actually sends, weighed on the real artefact: build-css plus the token mangle
|
||||
# for the sheet, terser over a copy of the resources for the JS. And the app icons, which are
|
||||
# committed rasters — this proves they still match logo.svg.
|
||||
- run: node tools/size-budget.mjs --show
|
||||
- run: node tools/build-icons.mjs --check
|
||||
- run: node tools/export-tier.mjs
|
||||
- run: node tools/css-metrics.mjs
|
||||
# The browser floor, derived from the built sheet rather than asserted in a doc: it fails
|
||||
# when docs/css.md and the stylesheet disagree, when a `:has()` compound shares a selector
|
||||
# list with one that has none (the list is not forgiving, so the whole rule dies), and when
|
||||
# a CSS feature nobody has classified appears.
|
||||
- run: node tools/css-floor.mjs
|
||||
- run: node tools/fs-orphans.mjs
|
||||
- run: node tools/css-dup.mjs
|
||||
- run: node tools/css-i18n.mjs
|
||||
- run: node tools/mirror.mjs
|
||||
- run: node tools/axes.mjs
|
||||
- run: node tools/chrome-fence.mjs
|
||||
|
||||
# Two derived contracts that were in `npm run check` and in no workflow. Both fail the same way
|
||||
# if nobody runs them: the stylesheet's "an unanswered table takes no room" rule stops naming a
|
||||
# root fs-select scans, or the page-module map names a page that no longer exists — and the
|
||||
# page then renders with something missing and says nothing about it.
|
||||
- run: node tools/table-contract.mjs
|
||||
- run: node tools/page-modules.mjs
|
||||
|
||||
# These three were enforced only when a human remembered to run the whole suite locally. Each
|
||||
# guards a failure nothing observable reports: a conffile the package manager replaces on
|
||||
# somebody else's upgrade, an `!important` that drifted out of the allowlist, a changelog
|
||||
# bullet with no bold lead, which release-notes.sh drops from the release page in silence.
|
||||
- run: node tools/conffiles.mjs
|
||||
# po/, tools/stage.sh and owfeed.yml each repeat the language list, and a language
|
||||
# missing from the last one stages into a directory nothing packages: the build stays
|
||||
# green and that translation simply does not ship.
|
||||
- run: node tools/i18n-packages.mjs
|
||||
- run: node tools/bang-ok.mjs
|
||||
- run: node tools/changelog.mjs
|
||||
|
||||
# jsmin-verify finds the shipped JS itself; JSMIN names the binary to test.
|
||||
- run: echo "JSMIN=$(sh tools/build-jsmin.sh)" >> "$GITHUB_ENV"
|
||||
- run: node tools/jsmin-verify.mjs
|
||||
|
||||
# No key anywhere in this job. `owfeed check` is build, sign, index and doctor on keys owfeed
|
||||
# generates and throws away, so a pull request exercises the pipeline that publishes rather than a
|
||||
# second one that drifts from it.
|
||||
build:
|
||||
name: Build the packages
|
||||
timeout-minutes: 25
|
||||
# `check` alone, not `lint`. Nothing the linter says changes what this job packs, and it sat in
|
||||
# front of every live gate: 107 seconds of the critical path for an answer none of them reads.
|
||||
# `release` names `lint` itself, so a red linter still publishes nothing — that guarantee moved
|
||||
# rather than went.
|
||||
needs: check
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- uses: actions/setup-node@v7
|
||||
with:
|
||||
node-version: '22'
|
||||
cache: npm
|
||||
# --ignore-scripts: a dev dependency's lifecycle script has no business running in the job that
|
||||
# produces the released bytes.
|
||||
- run: npm ci --ignore-scripts
|
||||
|
||||
- uses: owfeed/owfeed/setup@v0.5.1
|
||||
with:
|
||||
version: v0.5.1
|
||||
|
||||
# The commit's timestamp, not the job's: a package's identity is a hash over its payload and
|
||||
# mkpkg records mtimes, so re-running a release over the same commit must produce the same
|
||||
# bytes.
|
||||
- run: echo "SOURCE_DATE_EPOCH=$(git log -1 --format=%ct)" >> "$GITHUB_ENV"
|
||||
|
||||
- run: ./tools/stage.sh
|
||||
- run: owfeed plan
|
||||
- run: owfeed check
|
||||
- run: owfeed build
|
||||
- run: sh tools/check-packages.sh
|
||||
|
||||
# dist/root and dist/scripts are staging, not output.
|
||||
- uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: owfeed-packages
|
||||
path: |
|
||||
dist/noarch
|
||||
dist/all
|
||||
if-no-files-found: error
|
||||
|
||||
# Everything above proves the package was BUILT correctly. None of it proves what a user cares
|
||||
# about: that installing it produces a router whose pages render. owlab boots a real OpenWrt,
|
||||
# installs the package and logs into LuCI to fetch the theme's own pages — an unauthenticated
|
||||
# request is a redirect to the login form, which is why a hand-written curl reports 403 on a
|
||||
# healthy page.
|
||||
#
|
||||
# No key exists anywhere in this job: it asserts that a package works, never that anyone should
|
||||
# trust it.
|
||||
verify:
|
||||
name: The package works on a real router
|
||||
timeout-minutes: 25
|
||||
needs: build
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
- uses: actions/download-artifact@v8
|
||||
with:
|
||||
name: owfeed-packages
|
||||
path: dist/
|
||||
|
||||
# ONE RELEASE PER STEP: 25.12 is apk and 24.10 is opkg, and neither installs the other's file.
|
||||
- name: Install this build on 25.12 and render its pages
|
||||
uses: owfeed/owlab/action@v0.6.1
|
||||
with:
|
||||
version: v0.6.1
|
||||
releases: "25.12.4"
|
||||
install: dist/noarch/luci-theme-footstrap-*.apk
|
||||
# `exec ucode -T -c` is LuCI's own trycompile, run against the templates as INSTALLED
|
||||
# and with the router's real luci.core and uci, so it needs no stubs. A stray brace here
|
||||
# is the one way this repository can ship a completely broken UI with everything else
|
||||
# green: LuCI falls back to another theme and says nothing.
|
||||
assert: |
|
||||
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 /usr/share/ucode/luci/template/themes/footstrap/*.ut; do ucode -T -c -o /dev/null "$f" || exit 1; done
|
||||
|
||||
- name: Install this build on 24.10 and render its pages
|
||||
uses: owfeed/owlab/action@v0.6.1
|
||||
with:
|
||||
version: v0.6.1
|
||||
releases: "24.10.8"
|
||||
install: dist/all/luci-theme-footstrap_*.ipk
|
||||
# The same compile as the 25.12 leg, and not redundant: the templates are identical but
|
||||
# the interpreters are not, and what 25.12's ucode accepts 24.10's may not.
|
||||
assert: |
|
||||
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 /usr/share/ucode/luci/template/themes/footstrap/*.ut; do ucode -T -c -o /dev/null "$f" || exit 1; done
|
||||
|
||||
# And the published channel, by NAME, out of the signed index — a different claim: that the
|
||||
# index parses, the URL does not redirect somewhere apk will not follow, and the key on the
|
||||
# router matches the one that signed it.
|
||||
#
|
||||
# It tests what is ALREADY LIVE rather than this build, so it never fails the job: a feed
|
||||
# mid-DNS-migration would otherwise block a theme release on a channel this repository does
|
||||
# not own.
|
||||
- id: feed-key
|
||||
name: Fetch the published feed's key
|
||||
if: github.event_name != 'pull_request'
|
||||
run: sh tools/feed-key.sh
|
||||
|
||||
- id: feed-assert
|
||||
name: Assert the feed serves a working theme
|
||||
if: github.event_name != 'pull_request' && steps.feed-key.outputs.reachable == 'true'
|
||||
continue-on-error: true
|
||||
uses: owfeed/owlab/action@v0.6.1
|
||||
with:
|
||||
version: v0.6.1
|
||||
releases: "25.12.4"
|
||||
feed: https://repo.owfeed.org/releases/25.12/x86_64/packages.adb
|
||||
feed-key: ${{ runner.temp }}/owfeed-packages.pem
|
||||
feed-name: owfeed-packages
|
||||
install: luci-theme-footstrap
|
||||
assert: |
|
||||
package luci-theme-footstrap
|
||||
http 200 /cgi-bin/luci/admin/status/overview
|
||||
|
||||
# The step above is the only one in this file whose `if` can turn it off from OUTSIDE the
|
||||
# repository — an unreachable feed switches it off, and a skipped step leaves nothing behind
|
||||
# except a green job that looks exactly like one where the feed was measured and passed. This
|
||||
# step always runs on a push and writes which of the three it was into the run's annotations
|
||||
# and job summary, where a green run is actually read. It never fails either: the whole point
|
||||
# of the pair is that a channel this repository does not own cannot redden a theme build.
|
||||
- name: Report whether the published feed was measured
|
||||
if: always() && github.event_name != 'pull_request'
|
||||
env:
|
||||
FEED_REACHABLE: ${{ steps.feed-key.outputs.reachable }}
|
||||
FEED_OUTCOME: ${{ steps.feed-assert.outcome }}
|
||||
run: sh tools/feed-key.sh report
|
||||
|
||||
# Everything above measures a FILE. This one measures a PAGE, which is where every field report has
|
||||
# come from: a column shredded to one character per line (#11), a clipped submenu title (#22), a
|
||||
# doubled scrollbar in one engine (#12), a third-party app laid out wrong (#8, #33, #36), a client
|
||||
# navigation that painted less than a full load (upstream review). None can fail a static gate.
|
||||
#
|
||||
# owlab boots the same containers a developer runs locally, so the gate and the local command are
|
||||
# the same command. Three routers on a push, one on a pull request: the other release lines double
|
||||
# the wall clock and have never been the one to catch something first.
|
||||
live:
|
||||
# THREE SLICES, because this job was the release's critical path once `anchors` stopped being it.
|
||||
# Each slice boots its own routers — 157s it does not share — so the job's wall clock is its
|
||||
# longest slice rather than their sum, and fail-fast is off: a parity failure says nothing about
|
||||
# whether the reader stays put.
|
||||
#
|
||||
# Re-measured on the last run where every slice finished (7ff9e56, push, three routers): the
|
||||
# gates are 485s (spa-parity), 450s (live-audit) and 754s (everything `motion` still runs), on
|
||||
# top of 157s of checkout/npm/playwright/boot each slice pays for itself. `motion` used to carry
|
||||
# a fourth number — chromium's own scroll-anchor sweep, 1434s once `--full` crossed the density
|
||||
# axis (e48434c) — which alone put the slice at 39 of its 45 minutes. That sweep is now a shard
|
||||
# of `anchors`, where the one-job-per-engine rule already lives and the cap is 75 minutes; the
|
||||
# cells did not change, only which runner draws them.
|
||||
#
|
||||
# These numbers only hold while a page still answers. A page that pins the browser's main thread
|
||||
# costs a slice its whole budget rather than a page's worth of seconds, because `page.evaluate()`
|
||||
# has no deadline of its own — task liveslice, `/admin/system/filemanager` under `13e9864`, both
|
||||
# measuring slices cancelled at 45 minutes for a day. A slice that runs long is read by looking
|
||||
# at WHICH page each gate last named, not by raising this cap.
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
slice: [ parity, audit, motion ]
|
||||
name: The pages behave on a real router (${{ matrix.slice }})
|
||||
timeout-minutes: 45
|
||||
needs: build
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
- uses: actions/setup-node@v7
|
||||
with:
|
||||
node-version: '22'
|
||||
cache: npm
|
||||
- run: npm ci
|
||||
# The browser off Playwright's CDN, then a launch to prove it works; apt only if that
|
||||
# fails. `--with-deps` went through apt unconditionally, and apt is the half that stalls.
|
||||
- run: sh tools/ci-playwright.sh
|
||||
- uses: actions/download-artifact@v8
|
||||
with:
|
||||
name: owfeed-packages
|
||||
path: dist/
|
||||
- uses: owfeed/owlab/setup@v0.6.1
|
||||
with:
|
||||
version: v0.6.1
|
||||
|
||||
- name: Boot the routers and install this build
|
||||
run: |
|
||||
set -eu
|
||||
# NOT `. "$GITHUB_ENV"` — that file is a KEY=VALUE list, not a script, so a value with a
|
||||
# space in it ("owrt2512 owrt2410") runs its second word as a command.
|
||||
# The three OpenWrt lines the theme supports: both package managers plus the snapshot
|
||||
# box, which tracks luci-base master and so fails on an upstream change before a user
|
||||
# reports it. ImmortalWrt is deliberately absent — same luci-base, different brand,
|
||||
# never the leg that caught something first; run it locally with --all when curious.
|
||||
if [ "${{ github.event_name }}" = "pull_request" ]; then ROUTERS="owrt2512"; else ROUTERS="owrt2512 owrt2410 owrtsnap"; fi
|
||||
echo "ROUTERS=$ROUTERS" >> "$GITHUB_ENV"
|
||||
# Where a gate leaves the page shapes it read, so a second one in the same slice does not
|
||||
# walk the whole menu again — one load and a 1200ms settle per page. Set here rather than
|
||||
# in the job's `env`, where $RUNNER_TEMP is not available: the `runner` context exists
|
||||
# inside a step. Under the runner's temp, so it cannot outlive the install it describes
|
||||
# (tools/lib/page-shapes.mjs).
|
||||
echo "FS_SHAPES=$RUNNER_TEMP/page-shapes" >> "$GITHUB_ENV"
|
||||
owlab up $ROUTERS
|
||||
for r in $ROUTERS; do
|
||||
case "$r" in
|
||||
*2512|*snap) owlab install "$r" dist/noarch/luci-theme-footstrap-*.apk ;;
|
||||
*) owlab install "$r" dist/all/luci-theme-footstrap_*.ipk ;;
|
||||
esac
|
||||
done
|
||||
|
||||
# Each gate measures one representative per PAGE SHAPE by default (tools/lib/page-shapes.mjs)
|
||||
# and names the pages it stood in for; a release takes the whole menu on all four routers
|
||||
# instead (`--all --pages-all`, docs/releasing.md).
|
||||
#
|
||||
# `motion` opens with upstream-contract at 7s, so a broken assumption about luci-base is still
|
||||
# reported before anything spends minutes rendering pages against it — it cannot come before
|
||||
# the other two slices any more, since they are on runners of their own.
|
||||
- name: The gates of this slice
|
||||
run: |
|
||||
set -eu
|
||||
O="$(echo $ROUTERS | tr ' ' ,)"
|
||||
case "${{ matrix.slice }}" in
|
||||
parity)
|
||||
node tools/spa-parity.mjs --only "$O"
|
||||
;;
|
||||
audit)
|
||||
node tools/live-audit.mjs --only "$O"
|
||||
;;
|
||||
motion)
|
||||
node tools/upstream-contract.mjs --only "$O"
|
||||
# Nothing may re-decide or jump while the page moves. Chromium only here: WebKit needs
|
||||
# system libraries (gtk-4, gstreamer) this job would install on every run, and the
|
||||
# engine differences it covers are checked locally before a release.
|
||||
node tools/scroll-jank.mjs --only "$O"
|
||||
# The two things a poll tick can do to a page nobody is touching: lay out a replaced
|
||||
# table before anything answered for it, and move the page under the reader.
|
||||
node tools/table-tick.mjs --only "$O"
|
||||
# …and the third: hold the page open with a floor that is the wrong height, or that
|
||||
# nothing ever takes off. Both shipped (0.14.4 and 0.14.6) and neither is visible in a
|
||||
# file or on a page that is only loaded.
|
||||
node tools/floor-contract.mjs --only "$O"
|
||||
# …and the fourth is the chrome doing it to itself: the bar strips its layout classes
|
||||
# to measure whether the menu fits a row, and is a row shorter while it asks. Reported
|
||||
# from an iPhone, where no engine anchoring puts the reader back.
|
||||
node tools/fit-quiet.mjs --only "$O"
|
||||
# Chromium's own scroll-anchor sweep is NOT here any more: it is a shard of `anchors`
|
||||
# below, beside firefox and webkit. It sat here while it was the cheap engine — 145s
|
||||
# against the other two's 52 minutes — and stopped being one when `--full` started
|
||||
# crossing chromium's DENSITIES axis too (e48434c): 34 runs became 184 and 145s became
|
||||
# 1434s, which is 65% of everything this slice does and put it at 39 of its 45 minutes.
|
||||
# Moving it changes no cell and costs the pipeline nothing, because `anchors` already
|
||||
# runs one job per engine and its other two shards are longer than this one.
|
||||
# LAST, because it installs the PUBLISHED release over the build everything above
|
||||
# measured — and that is the claim: what a user runs is install.sh against the feed,
|
||||
# not `apk add` against a local file. Three field reports (#16, #28, #30) were this
|
||||
# script and nothing else, all on the re-install-over-an-existing-copy path, which a
|
||||
# fresh install cannot exercise. It replaces the build, so it may not share a router
|
||||
# with a slice still measuring one — which is exactly what a slice of its own buys.
|
||||
sh tools/install-check.sh $ROUTERS
|
||||
;;
|
||||
esac
|
||||
|
||||
- if: always()
|
||||
run: owlab down || true
|
||||
|
||||
# EVERY ENGINE'S ANCHOR SWEEP, on a clock of its own.
|
||||
#
|
||||
# This was one more step inside `live` and it did not fit: three engines across two routers, two
|
||||
# page shapes, two widths, both layouts and all three densities is ~290 measurements, and the job
|
||||
# was cancelled at its 45-minute limit. Splitting it runs the whole matrix beside the rest rather
|
||||
# than inside somebody else's budget.
|
||||
#
|
||||
# Why the other engines at all: the fault this gate hunts lives on the path an engine takes when it
|
||||
# DOES anchor. WebKit shipped anchoring, so chromium-with-anchoring-suppressed no longer stands in
|
||||
# for Safari, and on that path WebKit overshoots a section swap by 60px per tick. Measured on
|
||||
# 24.10, both layouts, 390 and 1440.
|
||||
anchors:
|
||||
# ONE JOB PER ENGINE, and it is the release's critical path that pays for it: this gate ran
|
||||
# 52 minutes against live's 17 on the same three routers, so the whole pipeline was as slow as
|
||||
# its slowest job. The three engines share nothing — separate browsers, separate runs of the
|
||||
# same sweep — so a job each measures exactly as much at a third of the wall clock.
|
||||
#
|
||||
# Chromium joined them in task liveslice. It had stayed in `live`'s `motion` slice as the cheap
|
||||
# engine (145s), and `--full` crossing its DENSITIES axis (e48434c) took it to 1434s — 65% of
|
||||
# that slice, which was then at 39 of its 45 minutes. Here it is the SHORTEST of the three and
|
||||
# the job's wall clock does not move.
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
engine: [ chromium, firefox, webkit ]
|
||||
name: The reader stays put (${{ matrix.engine }})
|
||||
timeout-minutes: 75
|
||||
needs: build
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
- uses: actions/setup-node@v7
|
||||
with:
|
||||
node-version: '22'
|
||||
cache: npm
|
||||
- run: npm ci
|
||||
- run: sh tools/ci-playwright.sh
|
||||
# Only the engine this shard drives: installing all three cost ~1 minute of every run for
|
||||
# browsers it never launches. Skipped for chromium, which the step above has already fetched
|
||||
# off the CDN and proved launches — `--with-deps` goes through apt, and apt is the half that
|
||||
# stalls (see ci-retry.sh); the other two genuinely need gtk-4/gstreamer from it.
|
||||
- if: matrix.engine != 'chromium'
|
||||
run: npx playwright install --with-deps ${{ matrix.engine }}
|
||||
- uses: actions/download-artifact@v8
|
||||
with:
|
||||
name: owfeed-packages
|
||||
path: dist/
|
||||
- uses: owfeed/owlab/setup@v0.6.1
|
||||
with:
|
||||
version: v0.6.1
|
||||
- name: Boot the routers and install this build
|
||||
run: |
|
||||
# The three OpenWrt lines the theme supports: both package managers plus the snapshot
|
||||
# box, which tracks luci-base master and so fails on an upstream change before a user
|
||||
# reports it. ImmortalWrt is deliberately absent — same luci-base, different brand,
|
||||
# never the leg that caught something first; run it locally with --all when curious.
|
||||
if [ "${{ github.event_name }}" = "pull_request" ]; then ROUTERS="owrt2512"; else ROUTERS="owrt2512 owrt2410 owrtsnap"; fi
|
||||
echo "ROUTERS=$ROUTERS" >> "$GITHUB_ENV"
|
||||
owlab up $ROUTERS
|
||||
for r in $ROUTERS; do
|
||||
case "$r" in
|
||||
*2512|*snap) owlab install "$r" dist/noarch/luci-theme-footstrap-*.apk ;;
|
||||
*) owlab install "$r" dist/all/luci-theme-footstrap_*.ipk ;;
|
||||
esac
|
||||
done
|
||||
# A pull request crosses the axes that were measured to change this gate's answer — three
|
||||
# width/layout cells (1440 side and 390 side, plus 390 top for the one WebKit-only tick
|
||||
# misfire that measures 0px at 390 side and 41px at 390 top, task wkanchor), both anchoring
|
||||
# modes, all three page shapes. A push and a tag add back the ones that were measured not to
|
||||
# (every width against every layout, all three densities), because that measurement is of
|
||||
# today's theme and a release is where being wrong about it is expensive. 18 cells per router
|
||||
# against 72.
|
||||
- name: The sweep
|
||||
run: |
|
||||
if [ "${{ github.event_name }}" = "pull_request" ]; then FULL=""; else FULL="--full"; fi
|
||||
node tools/scroll-anchor.mjs --engines ${{ matrix.engine }} \
|
||||
--only "$(echo $ROUTERS | tr ' ' ,)" $FULL
|
||||
- if: always()
|
||||
run: owlab down || true
|
||||
|
||||
# Builds a real router's `fs-*.js` and `luci.js` into a static site (capture → build → verify) and
|
||||
# proves it in a real Chromium — never the theme's OWN JS against fixtures, since capture.mjs talks
|
||||
# to nothing but the router. Runs on a pull request too, not only a tag: the earliest point a
|
||||
# regression against a real ubus answer can be caught is before a tag exists to publish from.
|
||||
# `--base` matches the path `pages.yml` serves it under, `/<repo>/playground`, so a link the built
|
||||
# HTML carries resolves the same way here and there.
|
||||
playground:
|
||||
name: Record and verify the playground
|
||||
timeout-minutes: 20
|
||||
needs: build
|
||||
if: startsWith(github.ref, 'refs/tags/v') || github.event_name == 'pull_request' || github.event_name == 'workflow_dispatch'
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
- uses: actions/setup-node@v7
|
||||
with:
|
||||
node-version: '22'
|
||||
cache: npm
|
||||
- run: npm ci
|
||||
# The browser off Playwright's CDN, then a launch to prove it works; apt only if that
|
||||
# fails. `--with-deps` went through apt unconditionally, and apt is the half that stalls.
|
||||
- run: sh tools/ci-playwright.sh
|
||||
- uses: actions/download-artifact@v8
|
||||
with:
|
||||
name: owfeed-packages
|
||||
path: dist/
|
||||
- uses: owfeed/owlab/setup@v0.6.1
|
||||
with:
|
||||
version: v0.6.1
|
||||
- run: owlab up owrt2512
|
||||
- run: owlab install owrt2512 dist/noarch/luci-theme-footstrap-*.apk
|
||||
|
||||
# Under $RUNNER_TEMP, the same reason FS_SHAPES lives there in the `live` job: it cannot
|
||||
# outlive the install it describes, and it keeps the recording out of the checkout.
|
||||
- name: Record, build and verify
|
||||
run: |
|
||||
set -eu
|
||||
PG="$RUNNER_TEMP/playground"
|
||||
node tools/playground/capture.mjs --recording "$PG/recording"
|
||||
node tools/playground/build.mjs --recording "$PG/recording" --out "$PG/out" \
|
||||
--base "/${GITHUB_REPOSITORY#*/}/playground"
|
||||
# 600 KB: the tar.gz measured 479 KB on owrt2512 with all 29 icons, ~25% headroom.
|
||||
node tools/playground/verify.mjs --out "$PG/out" --base "/${GITHUB_REPOSITORY#*/}/playground" \
|
||||
--budget-kb 600
|
||||
|
||||
- if: always()
|
||||
run: owlab down || true
|
||||
|
||||
# Only the built tarball, not recording/: its raw server documents carry pre-scrub session
|
||||
# tokens and the stand's hostname, and this artifact is downloadable on a public repo.
|
||||
- uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: playground
|
||||
path: ${{ runner.temp }}/playground/playground.tar.gz
|
||||
if-no-files-found: error
|
||||
|
||||
# The only job that holds a key, and its `needs` is what keeps a tag from publishing a package no
|
||||
# router has installed. `lint` is named here rather than inherited through `build`, which stopped
|
||||
# waiting for it: the gate has to hold a release, not the packing step. `playground` joined the
|
||||
# list for the same reason as the others: a recording that fails to build or verify is a real fault
|
||||
# in the theme's own `fs-*.js` against a real router, caught by capture.mjs talking to nothing else.
|
||||
release:
|
||||
name: Sign and publish
|
||||
needs: [build, verify, live, anchors, lint, playground]
|
||||
if: startsWith(github.ref, 'refs/tags/v')
|
||||
permissions:
|
||||
contents: write
|
||||
uses: owfeed/owfeed/.github/workflows/package.yml@v0.5.1
|
||||
secrets:
|
||||
# The repository already has this key, and owfeed-packages pins its public half.
|
||||
sign-key: ${{ secrets.OWFEED_AUTHOR_KEY }}
|
||||
usign-key: ${{ secrets.FOOTSTRAP_USIGN_KEY }}
|
||||
with:
|
||||
owfeed-version: v0.5.1
|
||||
# Writes dist/install.sh and release-notes.md. The installer is an asset because
|
||||
# raw.githubusercontent is rate-limited for the very user who most needs it (issue #17) and
|
||||
# because a signed copy is the only one checkable before it runs as root; the notes fill the
|
||||
# release page and are not an asset, hence a path beside dist/ rather than in it.
|
||||
#
|
||||
# A WORKSPACE PATH, not `${{ runner.temp }}`: at job level a reusable workflow's `with:` may
|
||||
# only read github/needs/vars/inputs, and naming `runner` there is not an error at edit time —
|
||||
# it fails the whole run at STARTUP, with zero jobs and no log to read (measured: run
|
||||
# 31903926488, "failure" with an empty job list).
|
||||
pre-release: sh tools/stage-release.sh
|
||||
sign-also: install.sh
|
||||
notes-file: release-notes.md
|
||||
verify-with: release.pub
|
||||
|
||||
# A separate job, not a `package.yml` input: `dist/*` rides the manifest `release` signs, and
|
||||
# playground.tar.gz is a demo site, never router payload — owfeed's own pipeline has no input for
|
||||
# a foreign artifact anyway, and putting it in `dist/` would sign it as if it were one. Needs both
|
||||
# `release` (the tag it attaches to) and `playground` (this run's own recording).
|
||||
playground-asset:
|
||||
name: Attach the playground to the release
|
||||
needs: [release, playground]
|
||||
if: startsWith(github.ref, 'refs/tags/v')
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: write
|
||||
steps:
|
||||
- uses: actions/download-artifact@v8
|
||||
with:
|
||||
name: playground
|
||||
path: pg/
|
||||
- env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
run: gh release upload "$GITHUB_REF_NAME" pg/playground.tar.gz --clobber --repo "$GITHUB_REPOSITORY"
|
||||
|
||||
# Refresh the Pages mirror of the release metadata. Called rather than triggered: `on: release`
|
||||
# never fires for a release created with GITHUB_TOKEN. Waits on `playground-asset`, not `release`
|
||||
# directly, so the mirror step pages.yml runs next (releases/latest/download/playground.tar.gz)
|
||||
# never races the upload that puts it there.
|
||||
pages:
|
||||
name: Refresh the Pages mirror
|
||||
needs: playground-asset
|
||||
if: startsWith(github.ref, 'refs/tags/v')
|
||||
uses: ./.github/workflows/pages.yml
|
||||
permissions:
|
||||
contents: read
|
||||
pages: write
|
||||
id-token: write
|
||||
|
||||
# Lets a maintainer see a run's OWN recording on Pages before a tag exists to publish
|
||||
# `playground-asset` from — `publish-pages` defaults to false, so the tag path above is
|
||||
# unchanged. `uses: ./…` runs a local reusable workflow's jobs as jobs of THIS SAME run, so
|
||||
# `playground-source: artifact` can fetch the `playground` artifact `playground` just uploaded
|
||||
# here — no `actions: read` needed beyond the permissions below: `download-artifact` reads a
|
||||
# same-run artifact through the runner's own token, not a `permissions:` scope, which is why the
|
||||
# `build`/`verify`/`live`/`anchors`/`playground` jobs above never declare it either despite each
|
||||
# downloading `owfeed-packages`.
|
||||
#
|
||||
# A LATER PAGES DEPLOY OVERWRITES THIS: pages.yml's own push trigger (a push to `main` touching its
|
||||
# `paths:`) or the tag `pages` job both run with `playground-source` at its `release` default and
|
||||
# refresh the site from the latest RELEASE. This job previews a recording; it does not pin one.
|
||||
pages-manual:
|
||||
name: Publish Pages from this run
|
||||
needs: playground
|
||||
if: github.event_name == 'workflow_dispatch' && inputs.publish-pages
|
||||
uses: ./.github/workflows/pages.yml
|
||||
with:
|
||||
playground-source: artifact
|
||||
permissions:
|
||||
contents: read
|
||||
pages: write
|
||||
id-token: write
|
||||
+172
@@ -0,0 +1,172 @@
|
||||
# Publishes the developer portal to GitHub Pages. devkit.html is generated (like cascade.css), so it
|
||||
# is built here, never committed. The gallery is published too — it needs a real cascade.css next to
|
||||
# it, so that is built in. The playground is not built here at all: it is recorded from a real router
|
||||
# by build.yml's `playground` job and fetched below from the latest release, the same way the release
|
||||
# mirror is.
|
||||
#
|
||||
# One-time setup by a maintainer: repo Settings -> Pages -> Source = "GitHub Actions".
|
||||
name: pages
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
paths:
|
||||
- 'docs/devkit.src.html'
|
||||
- 'docs/gallery.html'
|
||||
- 'luci-theme-footstrap/styles/**'
|
||||
- 'tools/devkit-build.mjs'
|
||||
- 'tools/lib/gallery.mjs'
|
||||
- '.github/workflows/pages.yml'
|
||||
workflow_dispatch:
|
||||
# Called by build.yml's release job once the release is published, which is what refreshes the
|
||||
# release-metadata mirror below. It cannot be `on: release`: an event raised by GITHUB_TOKEN does
|
||||
# not trigger another workflow. Also called by build.yml's `pages-manual` job, a manual publish
|
||||
# from a single run rather than a tag.
|
||||
workflow_call:
|
||||
inputs:
|
||||
# 'release' (default): mirror releases/latest/download/playground.tar.gz, same as a direct
|
||||
# workflow_dispatch or push trigger of this file — `inputs` is populated only for
|
||||
# workflow_call, so those two read it empty and the `!= 'artifact'` checks below still pick
|
||||
# the release path. 'artifact': build.yml's `pages-manual` job asking for THIS run's own
|
||||
# `playground` artifact instead of the newest tag's.
|
||||
playground-source:
|
||||
type: string
|
||||
default: release
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
pages: write
|
||||
id-token: write
|
||||
|
||||
concurrency:
|
||||
group: pages
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
# 22, matching build.yml: eslint 10 / stylelint 17 need Node >= 20.19, and a bare `20` floats
|
||||
# on the 20.x line that straddles that floor. Nothing here lints, but `npm ci` installs them
|
||||
# and would EBADENGINE.
|
||||
- uses: actions/setup-node@v7
|
||||
with: { node-version: '22' }
|
||||
- run: npm ci
|
||||
- name: Build the portal
|
||||
run: |
|
||||
set -e
|
||||
npm run devkit
|
||||
MEDIA=luci-theme-footstrap/htdocs/luci-static/footstrap
|
||||
# `mkdir -p _site` is not decoration: nothing else here creates the directory. It used to
|
||||
# be a side effect of `mkdir -p _site/fonts`, and removing that line with the webfonts
|
||||
# took the portal's whole output directory with it.
|
||||
mkdir -p _site
|
||||
cp docs/devkit.html _site/index.html # portal is the Pages root
|
||||
cp docs/devkit.html _site/devkit.html
|
||||
cp docs/gallery.html _site/gallery.html
|
||||
luci-theme-footstrap/build-css.sh _site/cascade.css # gallery loads this by <link>
|
||||
# No fonts to copy: the theme carries none. The stylesheet names Manrope and JetBrains
|
||||
# Mono first and the system stack after, so the portal renders in whatever the visitor
|
||||
# has — the same thing a router does.
|
||||
cp "$MEDIA"/logo.svg _site/
|
||||
# A FULL MIRROR OF THE LATEST RELEASE — the manifest, its signature AND THE PACKAGES, on a
|
||||
# different host from github.com, so a block or an outage covering one need not cover the
|
||||
# other.
|
||||
#
|
||||
# The packages are the point: a release asset URL redirects THROUGH github.com, so a router
|
||||
# that cannot reach github.com cannot fetch the package either, and mirroring only the
|
||||
# manifest moves the failure two lines later. What makes mirroring the packages safe is that
|
||||
# the manifest is signed and carries their sha256, so mirror bytes are held to the same hash
|
||||
# as GitHub's: the mirror is never trusted, only used.
|
||||
#
|
||||
# Pulled from the release on every Pages build, never committed and never assembled here:
|
||||
# this workflow also fires on a docs push, and a build that did not carry the release forward
|
||||
# would silently empty the mirror.
|
||||
#
|
||||
# Fails OPEN — a repo with no manifest release yet publishes the portal without a mirror.
|
||||
- name: Mirror the latest release
|
||||
run: |
|
||||
set -eu
|
||||
if ! curl -fsSL --retry 2 -o _site/manifest.txt \
|
||||
"https://github.com/$GITHUB_REPOSITORY/releases/latest/download/manifest.txt"; then
|
||||
rm -f _site/manifest.txt
|
||||
echo "the latest release publishes no manifest — mirror skipped"
|
||||
exit 0
|
||||
fi
|
||||
curl -fsSL --retry 2 -o _site/manifest.txt.sig \
|
||||
"https://github.com/$GITHUB_REPOSITORY/releases/latest/download/manifest.txt.sig"
|
||||
# The installer too: a router that cannot reach github.com cannot fetch install.sh from
|
||||
# there either, so a mirror without it leaves the user with a verified way to install and
|
||||
# no way to obtain the installer. Its signature rides along so the script can be checked
|
||||
# before it runs as root — the one link the trust chain cannot cover on its own.
|
||||
for f in install.sh install.sh.sig; do
|
||||
curl -fsSL --retry 2 -o "_site/$f" \
|
||||
"https://github.com/$GITHUB_REPOSITORY/releases/latest/download/$f" || rm -f "_site/$f"
|
||||
done
|
||||
# Every package the manifest names, under the name the manifest gives it — the same
|
||||
# string a router asks this mirror for. Reading the file list out of the manifest rather
|
||||
# than globbing the release is what keeps the two in step.
|
||||
tag=$(awk '$1=="tag"{print $2; exit}' _site/manifest.txt)
|
||||
awk '$1=="pkg"{print $4}' _site/manifest.txt | while read -r f; do
|
||||
curl -fsSL --retry 2 -o "_site/$f" \
|
||||
"https://github.com/$GITHUB_REPOSITORY/releases/download/$tag/$f"
|
||||
echo "mirrored: $f ($(wc -c < "_site/$f") bytes)"
|
||||
done
|
||||
echo "mirror of $tag complete."
|
||||
|
||||
# The playground is recorded from a real router by build.yml's `playground` job, not built
|
||||
# here — Pages has no owlab stand. Same fail-open shape as the mirror step above: a repo with
|
||||
# no release-attached playground.tar.gz yet (or one still mid-upload the run before its own
|
||||
# `pages` call) publishes the rest of the portal rather than failing the whole build.
|
||||
- name: Fetch the latest playground
|
||||
if: inputs.playground-source != 'artifact'
|
||||
run: |
|
||||
set -eu
|
||||
if ! curl -fsSL --retry 2 -o /tmp/playground.tar.gz \
|
||||
"https://github.com/$GITHUB_REPOSITORY/releases/latest/download/playground.tar.gz"; then
|
||||
echo "the latest release publishes no playground — playground skipped"
|
||||
exit 0
|
||||
fi
|
||||
mkdir -p _site/playground
|
||||
tar -xzf /tmp/playground.tar.gz -C _site/playground
|
||||
echo "playground: $(wc -c < /tmp/playground.tar.gz) bytes"
|
||||
|
||||
# A MANUAL PUBLISH (build.yml's `pages-manual`), and it FAILS CLOSED, unlike the fetch above:
|
||||
# that step exists to preview THIS run's own recording, so falling back to the release's (or
|
||||
# to none) on a miss would publish a preview that previewed nothing and say it succeeded.
|
||||
# `download-artifact` reads the artifact `playground` uploaded earlier in this same run — see
|
||||
# build.yml's `pages-manual` for why no `actions: read` is needed for that.
|
||||
- name: Fetch this run's own playground
|
||||
if: inputs.playground-source == 'artifact'
|
||||
uses: actions/download-artifact@v8
|
||||
with:
|
||||
name: playground
|
||||
path: ${{ runner.temp }}/pg-artifact
|
||||
- name: Unpack this run's own playground
|
||||
if: inputs.playground-source == 'artifact'
|
||||
run: |
|
||||
set -eu
|
||||
mkdir -p _site/playground
|
||||
tar -xzf "$RUNNER_TEMP/pg-artifact/playground.tar.gz" -C _site/playground
|
||||
echo "playground (this run): $(wc -c < "$RUNNER_TEMP/pg-artifact/playground.tar.gz") bytes"
|
||||
# Old links (README, devkit.src.html) pointed at playground.html directly; keep them working.
|
||||
- run: |
|
||||
printf '%s' '<!doctype html><meta http-equiv="refresh" content="0; url=playground/"><a href="playground/">Continue</a>' \
|
||||
> _site/playground.html
|
||||
- uses: actions/upload-pages-artifact@v5
|
||||
with: { path: _site }
|
||||
|
||||
deploy:
|
||||
needs: build
|
||||
runs-on: ubuntu-latest
|
||||
# `github-pages` accepts only `main` or a `v*` tag as a deployment branch (repo Settings ->
|
||||
# Environments -> github-pages; `gh api repos/…/environments/github-pages/deployment-branch-
|
||||
# policies`, checked 2026-09-14: custom_branch_policies [main, v*]) — a `pages-manual` dispatch
|
||||
# from any other ref reaches this job but is refused HERE, after `build` already spent its time.
|
||||
environment:
|
||||
name: github-pages
|
||||
url: ${{ steps.deploy.outputs.page_url }}
|
||||
steps:
|
||||
- id: deploy
|
||||
uses: actions/deploy-pages@v5
|
||||
Reference in new issue
Block a user