40 Commits

Author SHA1 Message Date
clawbot
fd3cd4c18c Add Makefile shims for cibuild and precommit (closes #34)
All checks were successful
check / check (push) Successful in 16s
script/cibuild and script/precommit both existed and were already the
documented CI and pre-commit entrypoints, but neither had a Makefile
target, so the standing rule to drive the repo through make targets
rather than the underlying tool could not be followed for either.

It matters most for the build. A bare `docker build .` fails closed on
the CHECK_EPOCH guard by design, so script/cibuild is one of only three
supported ways to build an image here, and it was the only one of the
three without a target while `make docker` had one.

The two targets are thin shims in the same style as every other target
and change nothing about what the scripts do. .PHONY was already
complete for the targets that existed and now lists both new ones.

README.md's Entrypoints section gains the script-to-target mapping so
the two documents agree, including the two names that do not match:
script/install-precommit is `make hooks`, and script/precommit is
`make precommit`. It also notes that `make cibuild` is the slowest
target, because it is the only one that runs two container builds --
while still taking seconds once the shared script/bootstrap layer is
cached, since Dockerfile.lint's first four instructions are
byte-identical to the main Dockerfile's and the pinned-Hugo compile is
therefore paid once per machine rather than twice.

Two accuracy fixes to text the same section already carried. The
script/install-precommit bullet said the installed hook runs
script/check; the script writes script/precommit into
.git/hooks/pre-commit, and its own header comment says so. And the
Makefile is described as listing the operations you are expected to run
rather than as the authoritative list of everything the repo can do,
which is not literally true: script/projectname is an internal helper
that script/docker calls to compute a tag, and it has no target
deliberately -- a target for it would be noise in the `make<tab>`
listing this change exists to make useful.

TODO.md also loses the stale Future Step asking someone to confirm the
static/_headers file took effect in production. That was confirmed live
on both hostnames on 2026-08-10 and recorded at
#14 , so the item is work
already done. The Status paragraph's matching "unverified in production
until the next deploy" clause is corrected for the same reason: a commit
that edits TODO.md should not leave a known-false statement in it.
2026-08-10 13:57:34 +00:00
910f343263 Merge pull request '#39: MIT LICENSE and containerised lint (closes #10, closes #38)'
All checks were successful
check / check (push) Successful in 24s
Build and Deploy to Cloudflare Pages / build (push) Successful in 50s
Build and Deploy to Cloudflare Pages / deploy (push) Successful in 18s
2026-08-10 15:35:21 +02:00
clawbot
25b6c0a9de Run the lint inside Docker via Dockerfile.lint (closes #38)
All checks were successful
check / check (push) Successful in 1m23s
Add a root Dockerfile.lint that runs `hugo --minify --printPathWarnings`
as a build step, so a successful build IS a clean lint, and reduce
script/lint to building that file. There is no host lint path and
deliberately no "am I already inside a container?" branch, which would
be a host lint path in disguise.

The containerisation boundary is lint only, per the owner ruling on the
issue: formatting is not a lint, so script/fmt and script/fmt-check stay
on the host, unchanged in version, scope and flags. That also removes
the forced duplication of prettier's settings between a script and a
Dockerfile, and with it the keep-in-sync notes that duplication needed.

Dockerfile.lint has exactly one stage on purpose. A whole-file
`docker build -f Dockerfile.lint .` builds only the file's last stage,
and sibling stages off a shared base carry no ordering edge, so a second
stage beside the lint would be silently skipped by exactly the
invocation the canonical org-wide script/lint uses -- a green that
linted nothing, which the per-stage CHECK_EPOCH guard cannot catch
because the stage that did run satisfies it. With one stage there is
nothing to skip and script/lint needs no --target. A comment in the file
says that any second check added here must be chained or carry an
explicit ordering edge, never left as a sibling.

Its first four instructions are byte-identical to the main Dockerfile's
and in the same order, so the expensive `RUN script/bootstrap` layer
that compiles the pinned Hugo from source is shared between the two
images rather than paid twice.

Resolve the recursion by direction, not detection. `make check` calls
script/lint, and script/lint is now a `docker build`, so `RUN make
check` in an image would attempt a docker build inside a build step
where there is no daemon. The main Dockerfile therefore runs the
individual non-lint checks -- script/test and script/fmt-check, as
separate RUN lines under the CHECK_EPOCH guard -- matching the canonical
shape, and only the lint is absent from it. script/cibuild runs
script/lint first, for fail-fast feedback: on a runner with no cached
bootstrap layer a lint failure should not wait behind a Hugo build from
source. CI coverage is therefore unchanged, and it runs the same scripts
a developer runs.

Caching is waived for the lint in the shape this repo already settled:
ARG CHECK_EPOCH with no default, guarded with
`[ -n "$CHECK_EPOCH" ] || exit 1`, and the value expanded into the
linted command as well as the guard, so invalidation never rests on
BuildKit's treatment of an unreferenced ARG. Every image-building
entrypoint generates and passes it -- script/cibuild, script/docker,
script/lint -- each as a whole assignment rather than inline, for the
`set -e` reason script/cibuild documents.

script/lint builds with `--output type=cacheonly`: the build is run for
its exit status, not for an image, and because the lint layer is
cache-busted on every invocation an exporting build leaves one dangling
image per lint run. On a host shared with other work that accumulates.
The build cache is unaffected, so script/bootstrap still hits, and
failures still propagate.

Two divergences from REPO_POLICIES.md, stated rather than buried:

  - REPO_POLICIES.md:92, "all Dockerfiles must run `make check`". That
    rule and "every lint run happens in Docker" cannot both hold once
    `make check` contains the lint.
  - REPO_POLICIES.md:102-168, which requires a separate lint stage whose
    result the build stage depends on through
    `COPY --from=lint /src/go.sum /dev/null`, on the stated grounds that
    without the edge "the build stage would not wait for lint to finish
    and a lint failure might not fail the overall build". No such edge
    exists here: the lint is its own file and its own build, sequenced
    by script/cibuild rather than by BuildKit. Both sections are
    superseded upstream by 12e8db8 in sneak/prompts, which deletes the
    Go multistage lint stage and its ordering trick for the same reason
    -- that stage ran `make lint`, which is now a docker build.

Verified: two consecutive script/lint runs on an unchanged tree both
executed hugo for real, distinct epochs echoed, script/bootstrap CACHED,
second run 0.85s; a whole-file `docker build -f Dockerfile.lint .` with
the argument and no --target ran the lint for real; a bare build with no
argument failed closed on the guard; a planted template error failed the
lint with hugo's own render error and made script/cibuild exit non-zero
in 0.6s with the main image build never starting; a planted over-long
line failed the host script/fmt-check; both reverted and re-run clean;
`make check`, script/docker and script/cibuild all green with every
check layer observed executing rather than served from cache, and the
bootstrap layer CACHED in both images. The deploy path is byte-identical
to main: .gitea/, script/bootstrap, script/test and .dockerignore are
untouched.
2026-08-10 13:20:40 +00:00
clawbot
407b0a0d79 Add the MIT LICENSE and state it in the README (closes #10)
All checks were successful
check / check (push) Successful in 11s
The repo had no LICENSE, which REPO_POLICIES.md lists as a mandatory
minimum file, and the README's License section said "Content is provided
as-is for community use." That granted nothing explicitly and matched no
committed file.

The repo is public, verified on the Gitea API rather than assumed, so
the standing policy applies: MIT on any public repo lacking a license.
LICENSE is byte-identical to the canonical sneak/homoicon copy (same git
blob, 3274443) and its body is word-for-word the SPDX MIT text, with
only the line wrapping differing.

The README License section now reads "MIT. See LICENSE.", and says
explicitly that the licence covers content/ as well as the code: this
repo carries both a Hugo site and its community content, while MIT's own
text speaks only of "the Software". The Description first line gains the
licence, which the README requirements call for and which was the one
field it was missing.

Nothing published contradicts the choice. The built public/ tree carries
no copyright, all-rights-reserved or terms-of-use string in index.html,
css/style.css, index.xml or sitemap.xml; the rendered footer names
@sneak and links the repo but reserves no rights, and the RSS carries no
copyright element. The content is factual mesh channel data asserting no
licence of its own.

LICENSE needed no .prettierignore entry, measured rather than assumed:
script/fmt passes prettier the explicit globs '**/*.md' and '**/*.css',
and an extensionless root file matches neither. A script/fmt run leaves
the file's hash unchanged, and a counterfactual LICENSE.md copy was
reflowed by that same run, which is the direct evidence that the
extension is what excludes it and not an ignore rule.

Per-file licence headers and SPDX identifiers are deliberately omitted;
no org standard mandates them.

Nothing on the deploy path is touched.
2026-08-10 12:31:30 +00:00
7d7bec526c Merge pull request '#37: add Cloudflare Pages _headers (closes #14)'
All checks were successful
Build and Deploy to Cloudflare Pages / build (push) Successful in 47s
Build and Deploy to Cloudflare Pages / deploy (push) Successful in 21s
check / check (push) Successful in 10s
2026-08-09 19:08:55 +02:00
clawbot
5f998c6e70 Add a Cloudflare Pages _headers file with security headers (closes #14)
All checks were successful
check / check (push) Successful in 9s
Hugo copies static/ verbatim into public/, so static/_headers lands at
the deploy output root, which is where Pages reads it from. This is the
first root-level static/ in the repo; Hugo unions it with the theme's
static/ per path rather than shadowing it, and the built tree confirms
that: public/css/style.css and public/index.html are byte-identical to
the previous build and the static file count goes from 1 to 2.

The live "before" was measured rather than assumed. Cloudflare already
sends X-Content-Type-Options and Referrer-Policy by default, so those
two lines are restatements; the substance is Strict-Transport-Security,
Content-Security-Policy, X-Frame-Options and Permissions-Policy, none of
which the site sends today.

Every value is checked against the built page, which loads nothing: no
script, img, link, iframe, form or media element, no style= and no on*=
attribute. It has exactly one inline <style> block, filled by readFile
in baseof.html. So default-src 'none' with style-src 'unsafe-inline' is
both achievable and tight, and 'unsafe-inline' is required by, and only
by, that deliberate inlining. There is no script-src allowance because
there are no scripts. X-Frame-Options: DENY and frame-ancestors 'none'
agree.

HSTS carries neither preload nor includeSubDomains. www.lora.vegas is
the only other name in DNS and it is served by this same Pages project,
so this file sets HSTS on its responses directly; includeSubDomains
would instead bind every future subdomain for a year, with no way to
walk it back inside the max-age window without also dropping the apex
protection.

Verified in a headless Chrome against a local server that parses the
committed _headers and applies it as real response headers: zero CSP
violations, the inlined stylesheet parses to 17 rules with the computed
body padding, tagline colour and link colour all coming from the theme
CSS, framing from another origin refused by frame-ancestors, and all
five named outbound links still navigating with status 200.

Whether Pages actually parses the file cannot be verified from here.
Pages silently ignores a malformed _headers, so the green build proves
nothing about it; that check belongs after the next deploy and must be
made on Strict-Transport-Security or Content-Security-Policy, since
X-Content-Type-Options would pass either way. It has to be run against
both lora.vegas and www.lora.vegas: dropping includeSubDomains rests on
www being served by this same Pages project, which was established from
identical response bodies rather than from the Cloudflare dashboard.
2026-08-09 17:01:59 +00:00
821a293391 Merge pull request '#35: restructure README into canonical sections (closes #11)'
All checks were successful
check / check (push) Successful in 35s
Build and Deploy to Cloudflare Pages / build (push) Successful in 1m24s
Build and Deploy to Cloudflare Pages / deploy (push) Successful in 29s
2026-08-09 18:40:49 +02:00
9bfc37bb76 Restructure README.md into the canonical section set (closes #11)
All checks were successful
check / check (push) Successful in 9s
REPO_POLICIES.md mandates a fixed set of README sections; this README
predated the standard being applied here and had About / Contributing /
Technical Details / Entrypoints / License instead. It is now a
Description first line followed by Getting Started, Entrypoints,
Rationale, Design, TODO, License, Author, with Author last.

Nothing the old headings held was dropped: the list of what the site
publishes moved under the Description, and the contribute contact and
the local-preview instructions moved into Getting Started.

Getting Started was written against the current Makefile rather than
carried over from the old prose, which had drifted. There is no
`make build` target, so the old "Build: `hugo`" instruction is now
`make test`; "Local Development: `hugo server`" is now `make setup`
then `make serve`, and `make setup` is what makes a fresh clone
buildable at all since it installs the pinned Hugo.

Two stale claims are fixed. The site is deployed by Gitea Actions to
Cloudflare Pages, not "automatically via GitHub Actions". And the
Entrypoints bullet for `script/fmt` still described the
top-level-markdown-only scope that #12 replaced with `'**/*.md'` and
`'**/*.css'`; the rest of that section was verified accurate against the
scripts, including the `script/check` order and the `CHECK_EPOCH` guard
that makes a bare `docker build .` fail closed.

The License section body is deliberately untouched and no LICENSE file
is added: that is #10's, which is blocked on the owner's choice of
license. For the same reason the Description sentence omits the license
clause the policy asks for; #10 completes both.

The Design section's claims were checked against the tree rather than
assumed: the vendored theme, the `readFile` inline of style.css in
baseof.html, the `hugo --minify` output to `public/`, and the deploy
workflow.
2026-08-09 16:34:16 +00:00
0070fdb589 Merge pull request '#32: widen prettier scope to CSS and all Markdown (closes #12)'
All checks were successful
check / check (push) Successful in 12s
Build and Deploy to Cloudflare Pages / build (push) Successful in 49s
Build and Deploy to Cloudflare Pages / deploy (push) Successful in 21s
2026-08-09 18:30:35 +02:00
3e0694e6e0 Widen the prettier gate to markdown and CSS everywhere (closes #12)
All checks were successful
check / check (push) Successful in 59s
REPO_POLICIES.md scopes prettier to JS/CSS/Markdown/HTML, but script/fmt
and script/fmt-check covered only '*.md' - top-level markdown. The
canonical scripts use '**/*.md'. Both now run over '**/*.md' and
'**/*.css', and both header comments, which still described the old
top-level-only scope, were rewritten.

That brings themes/loravega/static/css/style.css into the gate. It is
inlined into every page by baseof.html via readFile, and its formatting
is whitespace-only: the minified <style> block in the built
public/index.html is byte-identical across the reformat, which is the
preceding commit.

Two paths are excluded, each with the reason recorded in
.prettierignore so the exclusion reads as a decision rather than an
oversight:

themes/loravega/layouts/ - these are not HTML. They are Go templates
carrying {{ define }}, {{ block }}, {{ .Content }} and
{{ readFile ... | safeCSS }}, and prettier has no Go-template parser; it
would fail or reflow the delimiters into markup Hugo cannot parse.
Covering them needs an out-of-tree plugin and therefore a package.json,
which this repo deliberately does not have.

content/ - excluded on measurement, not on the earlier assumption. With
content/ in scope, prettier re-wrapped one list item in
content/_index.md, and the rendered public/index.html changed with it:
the wrap landed as a literal newline between "7 PM at" and the following
<a> tag. HTML collapses that newline to a space, so the page looks the
same, but the published bytes do not match, and this content carries raw
div/span/br blocks that goldmark passes through verbatim because
hugo.toml sets markup.goldmark.renderer.unsafe = true. A formatter that
can silently change a published page is not worth the consistency.
archetypes/ stays covered - it is a template for new content, not
published output, and prettier leaves it unchanged.

Verified by extracting public/ from the built image before and after.
With the final scope, index.html, index.xml and sitemap.xml are
byte-identical; only the verbatim-copied public/css/style.css differs,
in whitespace. Each commit on this branch passes make check on its own -
the reformat lands first, under the old narrow glob that does not look
at CSS, so the widening commit arrives on an already-clean tree and no
merge commit is required to land it.
2026-08-09 16:19:54 +00:00
f3176a1121 Reformat style.css with the repo prettier settings
Pure formatting churn, no functional change. Kept as its own commit
ahead of the script change so the widened gate lands on an already-clean
tree, per REPO_POLICIES.md: formatting diffs are large and must not be
mixed with functional changes.

The file is inlined verbatim into every page by baseof.html via
readFile, so this was checked rather than assumed: the minified <style>
block in the built public/index.html is byte-identical before and after.
Prettier CSS formatting is whitespace-only and cannot reach the rendered
page.
2026-08-09 16:19:15 +00:00
7dea8373d3 Merge pull request '#31: add canonical policy dotfiles (closes #8)'
All checks were successful
check / check (push) Successful in 9s
Build and Deploy to Cloudflare Pages / build (push) Successful in 46s
Build and Deploy to Cloudflare Pages / deploy (push) Successful in 18s
2026-08-09 18:12:07 +02:00
5d4b6de973 Reformat REPO_POLICIES.md with the repo's prettier settings
All checks were successful
check / check (push) Successful in 11s
The canonical upstream copy is not clean under --tab-width 4
--prose-wrap always: prettier@3.4.2 inserts a blank line before a nested
list that directly follows a paragraph, in five places. script/fmt-check
covers *.md at the repo root, so make check fails on the byte-identical
copy.

Split out from the preceding commit so the functional change and the
formatting churn stay separately reviewable. The change is whitespace
only - five blank lines - and does not alter the rendered document.

The canonical copy upstream should be reformatted so future syncs are a
straight byte copy again; tracked in TODO.md.
2026-08-09 16:03:14 +00:00
90f188c256 Add canonical policy dotfiles, harden both ignore files (closes #8)
REPO_POLICIES.md lists the files every repo must contain at minimum;
four were missing here and .gitignore covered only Hugo's outputs.

REPO_POLICIES.md is a byte-identical copy of the canonical file in the
prompts repo, YAML front matter (title, last_modified) intact so it can
be diffed against upstream as policy evolves. It is not clean under this
repo's prettier settings, so the reformat is the next commit rather than
churn mixed in here; the byte-identical copy is what landed.

.editorconfig, .prettierrc and .prettierignore are the canonical
contents. script/fmt and script/fmt-check keep passing --tab-width 4
--prose-wrap always on the command line: the duplication is deliberate
so the scripts still work standalone when copied as a template, and the
values agree, so adding .prettierrc changes nothing about what make fmt
does.

.gitignore keeps its three Hugo lines and gains the canonical
OS/editor/node/secrets block plus .claude/. The secrets patterns are the
point: a stray .env or private key can no longer be committed by a broad
git add. .claude/ holds worktrees/, so without it a clean checkout with
agent tooling present is not git status-clean.

.dockerignore gains the same coverage but not the same syntax. It does
not use .gitignore semantics: it matches with Go's filepath.Match rules
extended with **, where * does not cross / and an unprefixed pattern is
anchored at the context root. A bare *.key therefore excludes
./server.key and ships ./certs/server.key into the image, which is worse
than an obviously incomplete file because it reads as complete. Every
depth-independent pattern here carries an explicit **/ prefix; only the
entries that are genuinely root-anchored by definition go bare - .git,
Hugo's public and resources output directories, and .hugo_build.lock.
The distinction is spelled out in a comment at the top of the file so
the next edit does not quietly undo it.

Excluding .claude/ also keeps entire additional checkouts of this repo
out of the build context, which the Dockerfile's COPY . . would
otherwise copy into the image.

Verified by planting .env, server.key, deep.pem and node_modules two
directories deep and building: with the patterns unprefixed all of them
reach /src in the image, with **/ none do. Root-only testing does not
exercise this and produces a false pass.
2026-08-09 16:03:08 +00:00
ccdedc300d Merge pull request '#30: cache-bust the make check layer (closes #23)'
All checks were successful
check / check (push) Successful in 12s
Build and Deploy to Cloudflare Pages / build (push) Successful in 57s
Build and Deploy to Cloudflare Pages / deploy (push) Successful in 21s
2026-08-09 17:57:54 +02:00
223c520110 Cache-bust the make check layer via CHECK_EPOCH (closes #23)
All checks were successful
check / check (push) Successful in 56s
script/cibuild was a bare `docker build .`, and the Dockerfile did
`COPY . .` then `RUN make check`. COPY is keyed on content, so on an
unchanged tree Docker served the check layer from cache: the checks
never executed, no Hugo or prettier output appeared, and the build still
exited 0. A gate that reports success without running is worse than no
gate, because it is trusted -- three separate reviewers in this repo
have been fooled by it.

The Dockerfile now declares `ARG CHECK_EPOCH` immediately below
`COPY . .`, guards it, and expands it into the check command:

    ARG CHECK_EPOCH
    RUN [ -n "$CHECK_EPOCH" ] || exit 1
    RUN echo "check epoch: ${CHECK_EPOCH}" && make check

script/cibuild and script/docker both generate the value identically and
pass it. Every element is load-bearing:

- No default value. A default is a constant, and a constant is a stable
  cache key -- the defect unchanged.
- Placed below `COPY . .`. Everything above keeps caching, so the
  script/bootstrap layer, which compiles Hugo from source, is not
  rebuilt. Whole-build `--no-cache` would have discarded it and blown
  the five-minute budget for no benefit.
- The guard. An unset ARG is the empty string, which is also a stable
  cache key, so without it a bare `docker build .` still collects the
  false green. Failed steps are never cached, so it fails on every such
  invocation rather than only the first. This is why script/docker had
  to be updated too: the guard makes passing the argument mandatory for
  every entrypoint that builds the image.
- The value expanded into the RUN. Hardening rather than the fix: the
  bare unreferenced-ARG form does work, but expansion makes the cache
  miss contractual rather than dependent on BuildKit's handling of an
  unreferenced ARG, and puts the epoch in the build log. The guard also
  references the value, so there are two independent invalidation
  points, not one.
- `epoch="$(date +%s%N)$$"` on its own line rather than inlined into the
  argument list. A command substitution that fails inside an argument
  does not trip `set -e`, so the inline form would quietly pass an empty
  string and restore the cached false green. `%N` keeps concurrent
  invocations distinct; `$$` covers busybox date, which drops `%N`
  silently and still exits 0.

ARG is stage-scoped and must be redeclared in every stage that runs
checks. This image is single-stage, so one declaration is complete.

This is the shape settled upstream in the prompts repo, where it has not
merged to main yet, so it may need re-syncing later.

Verified: two consecutive script/cibuild runs on an unchanged tree both
executed the checks (two Hugo builds and the prettier line in each,
15s then 6s) with `RUN script/bootstrap` and `COPY . .` both CACHED in
the second -- the validity control that rules out a cache eviction
between them. A constant-epoch counterfactual restored the cached false
green, confirming the varying value is what does the work. A bare
`docker build .` now fails on the guard, and fails again on immediate
repeat. A planted prettier failure failed the build with exit 1. `make
docker` and `make check` both pass.
2026-08-09 15:51:09 +00:00
8034fd8192 Merge pull request '#29: disable unused taxonomy kinds (closes #13)'
All checks were successful
check / check (push) Successful in 4s
Build and Deploy to Cloudflare Pages / build (push) Successful in 50s
Build and Deploy to Cloudflare Pages / deploy (push) Successful in 24s
2026-08-09 17:41:08 +02:00
70048b3fb6 Disable unused taxonomy page kinds (closes #13)
All checks were successful
check / check (push) Successful in 17s
Hugo enables the `tags` and `categories` taxonomies by default. This
site is a single page with no taxonomy terms and no taxonomy templates,
so every build emitted

    WARN  found no layout file for "html" for kind "taxonomy"

and generated `categories/index.xml` and `tags/index.xml` that nothing
links to. `disableKinds = ['taxonomy', 'term']` is the documented Hugo
mechanism for a site that uses no taxonomies; it removes the warning at
its source rather than suppressing it, and it does not create dead
template files to satisfy the layout lookup.

The premise was re-verified against hugo v0.164.0, the version now
pinned in `script/bootstrap`, rather than trusted from the issue text,
which was written when the build still used apk's 0.139.0. The warning
and the unwanted pages are unchanged on v0.164.0.

`make test`, `make lint` and `make check` now emit zero `WARN` lines, so
the build's noise floor is zero and the next warning to appear will be
visible instead of scrolling past. Rendered output is otherwise
byte-identical: `index.html`, `css/style.css` and the RSS `index.xml`
are unchanged, and `sitemap.xml` is still generated, now listing only
the home page rather than two taxonomy URLs.
2026-08-09 15:32:58 +00:00
9e3f955e91 Merge pull request '#27: deliberate hash-verified Hugo (closes #26, closes #18)'
All checks were successful
check / check (push) Successful in 5s
Build and Deploy to Cloudflare Pages / build (push) Successful in 46s
Build and Deploy to Cloudflare Pages / deploy (push) Successful in 17s
2026-08-09 17:18:08 +02:00
f7d614952d Remove the temporary deploy trigger
All checks were successful
check / check (push) Successful in 10s
Reverts the branch entry added purely so act_runner would really
execute the deploy workflow's build job against the new hugo install
path. deploy.yml is back to `branches: [main]` and is now byte-identical
to main's copy: `git diff main HEAD -- .gitea/workflows/deploy.yml` is
empty.

The runner-verified commit 2a95023 is deliberately left in this branch's
history rather than rebased away, so a reviewer can confirm for
themselves that nothing functional changed between what the runner
actually ran and what is being merged:

    git diff 2a95023 HEAD -- .gitea/workflows/deploy.yml

Only the trigger entry and its comment differ.
2026-08-09 14:53:18 +00:00
2a950232af TEMPORARY: run the deploy build job on this branch
All checks were successful
check / check (push) Successful in 1m8s
Build and Deploy to Cloudflare Pages / build (push) Successful in 1m1s
Build and Deploy to Cloudflare Pages / deploy (push) Has been skipped
Dropped before merge. Exists only so act_runner really executes the
build job against the new hugo install path.
2026-08-09 14:41:51 +00:00
916f978485 Use hugo.toml locale instead of languageCode (closes #18)
Hugo deprecated the project config key `languageCode` in v0.158.0 in
favour of `locale`, and says it will be removed. The preceding commit
moves the build onto hugo v0.164.0, which emits:

    WARN  deprecated: project config key languageCode was deprecated in
    Hugo v0.158.0 and will be removed in a future release. Use locale
    instead.

Left alone that would be a third routinely-ignored warning in the build
output alongside #13's taxonomy warning, and a latent breakage once the
key is dropped.

Sequencing matters and is why this rides in the same branch, on top of
the version move rather than before it. Under the apk hugo 0.139.0 that
CI ran until the preceding commit, `locale` is simply an unknown key:
0.139.0 ignores it and falls back, which downgrades the generated RSS
from <language>en-us</language> to <language>en</language>. No warning,
no error, exit 0 - an output regression the gate would not have caught.
Landing this first would have broken the published feed.

Verified on hugo v0.164.0, the version the build now actually uses:

  - the RSS <language> element still reads en-us;
  - the html lang attribute is unchanged;
  - public/ is byte-identical to the preceding commit's output, so the
    key swap is a pure no-op on rendered content;
  - the deprecation warning is gone from the build output.
2026-08-09 14:41:39 +00:00
4720c40cfa Install Hugo at a deliberate, hash-verified version (closes #26)
script/bootstrap did `pkg_install hugo hugo hugo hugo`, so the tool that
produces the published artifact was whatever the base image's package
repo happened to serve: alpine 3.21 gives hugo 0.139.0, about two years
behind upstream, chosen by nobody, and liable to change silently on any
base image digest bump. Hugo's version is a property of the site's
output, not of the build environment, so it now gets pinned like every
other external reference in this repo.

It is installed with `go install github.com/gohugoio/hugo@v0.164.0`,
which verifies the module against the sum.golang.org checksum database.
That is genuine hash verification rather than bare version pinning, it
is the mechanism REPO_POLICIES.md already names for Go, and it needs no
hand-maintained sha256. It also keeps a single pinned base image: a
digest-pinned Hugo container would have reintroduced the second base
image that #7 deliberately removed.

Two constants carry the decision, each with the canonical
`# name version, YYYY-MM-DD` comment:

  - HUGO_VERSION=v0.164.0, the current stable release.
  - HUGO_GOTOOLCHAIN=go1.26.5. hugo v0.164.0's go.mod requires
    go >= 1.26.0 and alpine 3.21's go package is 1.23.9 built with
    GOTOOLCHAIN=local, so a bare `go install` refuses to run at all.
    Naming the toolchain makes Go fetch it through the module proxy and
    verify it against sum.golang.org like any other module, so the chain
    stays hash-verified end to end and the compiler is deliberate too.

CGO_ENABLED=0 is deliberate: standard Hugo, not extended. Verified that
this site uses nothing extended provides - no .scss/.sass, no
resources.ToCSS, no PostCSS, and no image processing; the CSS is plain
and inlined by readFile in baseof.html. The `+extended` on the apk build
this replaces was incidental, and the script says so, so a later change
does not assume extended is required.

The binary is placed in /usr/local/bin rather than left in a GOPATH bin
directory, because it has to be on the default PATH of a *fresh* shell:
the Dockerfile's `RUN make check` and deploy.yml's `script/test` step
each start their own shell. The location is overridable via
HUGO_BIN_DIR for unprivileged installs, and `go install` itself runs as
the invoking user so a workstation's module cache is not populated as
root.

The idempotency guard is version-aware instead of `missing hugo`: an
older hugo already on PATH must be replaced, not accepted, or the pin
means nothing. A same-version build that happens to be `+extended` is
accepted, since it renders this site identically. After installing, the
script re-checks what `hugo` on PATH actually resolves to and fails
loudly if something else shadows it.

Rendered output was compared three ways in a container carrying both
binaries - apk 0.139.0 against 0.164.0 on identical sources. Across the
whole public/ tree the only byte that differs is the generator meta
tag's version string, which is the change describing itself. The RSS
<language> element and the html lang attribute are unchanged.

Cold `script/cibuild` is 2m36s, within the five-minute budget: 52.6s of
it is the bootstrap layer (apk go, toolchain fetch, compile) and 100s is
image export. The check image grows to 683 MB because the Go toolchain
and module cache stay in the bootstrap layer; that image is only ever
built to run checks, never published or deployed.
2026-08-09 14:41:14 +00:00
961ec718e0 Merge pull request '#24: script/check runs script/lint (closes #9)'
All checks were successful
check / check (push) Successful in 4s
Build and Deploy to Cloudflare Pages / build (push) Successful in 9s
Build and Deploy to Cloudflare Pages / deploy (push) Successful in 19s
2026-08-09 12:18:23 +02:00
clawbot
bcb90e74b4 Run script/lint from script/check (closes #9)
All checks were successful
check / check (push) Successful in 14s
script/check ran only fmt-check then test, so script/lint was never
invoked anywhere in the gate: make check shims to script/check, the
Dockerfile runs make check, script/cibuild builds the Dockerfile, and
the pre-commit hook calls script/check. The script was dead code that
the README advertised as part of the gate.

It now runs test, lint, fmt-check in the canonical order. script/lint
is hugo --minify --printPathWarnings, which reports render-target
collisions that the plain hugo --minify in script/test does not; that
signal was being discarded.

The gate still modifies no tracked files. script/test and script/lint
both write to public/, which is gitignored and was already written by
script/test before this change.

Corrects the two documents that enumerated the old two-step gate: the
README Entrypoints line for script/check, and the Dockerfile header
comment above the RUN make check that executes it.
2026-08-09 10:09:13 +00:00
9959cb5794 Merge pull request '#22: Hash-pin every external reference in deploy.yml (closes #7)'
All checks were successful
check / check (push) Successful in 4s
Build and Deploy to Cloudflare Pages / build (push) Successful in 9s
Build and Deploy to Cloudflare Pages / deploy (push) Successful in 18s
2026-08-09 07:03:40 +02:00
54ed6376af Hash-pin every external reference in deploy.yml (closes #7)
All checks were successful
check / check (push) Successful in 6s
deploy.yml was the last file in the repo carrying mutable external references.
Both job container images are now pinned by digest, all three `uses:` by a full
40-hex commit SHA, and the wrangler install by exact version, each with a
version/date comment above the reference.

- build container: klakegg/hugo:ext-alpine (abandoned since 2021, mutable tag)
  replaced by the exact alpine 3.21 digest the Dockerfile already pins, with a
  pre-checkout `apk add --no-cache nodejs git tar` step, `shell: sh` as the job
  default, then script/bootstrap and script/test. One pinned base and one
  dependency list now serve both the check build and the deploy build.
- deploy container: node:20 -> node@sha256:8f693eaa... (node 20.20.2 bookworm).
- actions/checkout: v4 -> 11bd7190... (v4.2.2), the same SHA check.yml pins.
- actions/upload-artifact: -> ff15f030... (v3.2.1).
- actions/download-artifact: -> 9bc31d5c... (v3.0.2).
- wrangler: `npm install -g wrangler` -> `wrangler@4.86.0`.

Also drops the dead feat/initial-site push trigger, reindents to 4-space YAML
to match check.yml, and adds `if: github.ref_name == 'main'` to the deploy job
so it can never publish from a branch.

This is the second attempt. The first passed two adversarial reviews, merged,
and broke the deploy, because deploy.yml triggers only on push to main and so
nobody could execute what they were reviewing. This time the workflow was
temporarily triggered on the branch, with the deploy job guarded off, and
iterated against the commit-status API until the build job ran green for real.
Doing that found two independent breaks that review had not:

1. actions/upload-artifact v4 fails on this Gitea Actions instance -- artifacts
   v4 is a different wire protocol and it is not served here. Two otherwise
   identical branch jobs, one with the v4 upload step and one without, failed
   and passed respectively. The issue asked for the v3 -> v4 bump; the
   artifact actions instead stay on the v3 line, pinned by SHA, at the exact
   commits the mutable @v3 references were already resolving to. Tracked
   separately in issue 20.
2. wrangler 4.120.0 requires node >= 22 and refuses to start on the pinned
   node 20 container. `npm install` only warns about engines, so the install
   step would have passed and the deploy step would have failed. The unpinned
   command this replaces was never installing `latest` either: npm resolves a
   bare name to the newest version whose engines the running node satisfies,
   which on node 20 is 4.86.0. So 4.86.0 is what has actually been deploying
   this site, and that is what is pinned. Tracked separately in issue 21.

The temporary branch trigger and the temporary probe workflow used to bisect
this are removed in this commit; the deploy guard is deliberately kept.

Verified: make check and script/cibuild green; the build job observed green on
the branch under act_runner (commit 73f912c, "Successful in 7s"); a probe job
pair rehearsed the deploy job end to end -- same pinned node image, same pinned
download action, same pinned wrangler, real site tarball extracted -- stopping
at `wrangler pages deploy --help` instead of publishing. The real deploy job
remains unexercised: it needs CLOUDFLARE_API_TOKEN and would publish, so it can
only run on main. The main run must still be watched and the live site
confirmed.
2026-08-09 03:06:21 +00:00
73f912c7ed Pin wrangler to the version that actually runs on the pinned node image
All checks were successful
check / check (push) Successful in 6s
Build and Deploy to Cloudflare Pages / build (push) Successful in 7s
probe / s1-build (push) Successful in 13s
Build and Deploy to Cloudflare Pages / deploy (push) Has been skipped
probe / s2-deploy-dryrun (push) Successful in 10s
Round 3 (07af755) cleared the artifact path and left one failure:

    check / check                        success   8s
    Build and Deploy .../ build          success   8s   <- green
    Build and Deploy .../ deploy         skipped        <- if: guard
    probe / r1-wrangler-only             failure   7s
    probe / r2a-upload-proven            success  12s
    probe / r2b-download-proven          success   2s
    probe / r3a-upload-node20            success   8s
    probe / r3b-download-node20          success   2s

r2a/r2b and r3a/r3b upload and download the real site tarball across the two
job containers, so the artifact round trip is sound. r1 does nothing but
install wrangler and invoke it, and it fails.

Reproduced locally in the pinned node image, which is faster than another CI
round:

    $ docker run --rm node@sha256:8f693eaa... sh -c \
        'npm install -g wrangler@4.120.0; wrangler --version'
    install exit=0            (with EBADENGINE warnings)
    Wrangler requires at least Node.js v22.0.0. You are using v20.20.2.
    version exit=1

npm treats engines as a warning on an explicit version, so the install step
would have passed and the deploy step would have failed -- a second break,
independent of the artifact one, in the same job nobody could run.

The instructive part is what the unpinned command it replaced was doing:

    $ docker run --rm node@sha256:8f693eaa... sh -c \
        'npm install -g wrangler; wrangler --version'
    `-- wrangler@4.86.0
    4.86.0

npm resolves a bare name to the newest version whose engines the running node
satisfies, so `npm install -g wrangler` on node 20 has been installing 4.86.0,
not the 4.120.0 that `latest` points at. Pinning 4.120.0 was therefore not
"pin the version we are already getting", it was an unnoticed major-ish bump
onto a node the container does not have.

So this pins wrangler 4.86.0 (engines: node >= 20.3.0, published 2026-04-28),
which is exactly the version that has been deploying this site, verified to
install and run on the pinned node 20 digest. The node image digest is left
alone. Bumping the container to node 22 to keep 4.120.0 is the alternative,
but that changes the deploy runtime for no benefit this issue asks for.

Round 4 replaces the probe jobs with a single end-to-end rehearsal: the build
job as written, then the deploy job as written with `wrangler pages deploy
--help` in place of the publish call.
2026-08-09 02:59:50 +00:00
07af755d1e Move the artifact pair to the exact commits @v3 was resolving to
Some checks failed
check / check (push) Successful in 8s
Build and Deploy to Cloudflare Pages / build (push) Successful in 8s
probe / r1-wrangler-only (push) Failing after 7s
probe / r2a-upload-proven (push) Successful in 12s
probe / r3a-upload-node20 (push) Successful in 8s
Build and Deploy to Cloudflare Pages / deploy (push) Has been skipped
probe / r2b-download-proven (push) Successful in 2s
probe / r3b-download-node20 (push) Successful in 2s
Round 2 (602fd60) put the build job green:

    check / check                        success   6s
    Build and Deploy .../ build          success  20s   <- green
    Build and Deploy .../ deploy         skipped        <- if: guard
    probe / q1-upload-v3-node16          success   7s
    probe / q2-upload-v3-node20          success  22s
    probe / q3-build-for-roundtrip       success  11s
    probe / q4-deploy-dryrun             failure  43s

Every v3 upload works and the build job is fixed. But q4 -- the deploy-side
rehearsal, which downloads the artifact in the pinned node container and
installs the pinned wrangler, stopping short of the publish call -- failed.
That is a break the deploy job would have hit on main, in a job nobody has
ever been able to run.

q4 bundled two things together, so round 3 splits them:

- r1 runs only the wrangler install and invocation. Worth measuring rather
  than assuming: wrangler 4.120.0 declares engines.node >= 22 and the deploy
  container is node 20, though the pre-issue deploy did run an unpinned
  wrangler on node:20 successfully.
- r2a/r2b run the artifact round trip with no wrangler at all.
- r3a/r3b do the same for the newer node20 artifact builds, so the choice
  between the two pairs is made on measurement.

deploy.yml meanwhile moves to the artifact commits that the mutable `@v3`
references were actually resolving to while this site was deploying, rather
than to the newest thing on the v3 line:

- upload-artifact   -> ff15f030 (v3.2.1)
- download-artifact -> 9bc31d5c (v3.0.2)

That is the conservative reading of what this issue is for: pin what is known
to work, do not take a version bump for free on the way past.
2026-08-09 02:55:51 +00:00
602fd609e7 Pin the artifact actions on v3: v4 does not work on this instance
Some checks failed
check / check (push) Successful in 6s
Build and Deploy to Cloudflare Pages / build (push) Successful in 20s
probe / q1-upload-v3-node16 (push) Successful in 7s
probe / q2-upload-v3-node20 (push) Successful in 22s
probe / q3-build-for-roundtrip (push) Successful in 11s
Build and Deploy to Cloudflare Pages / deploy (push) Has been skipped
probe / q4-deploy-dryrun (push) Failing after 43s
Round 1 of the branch probes reproduced the main failure and localised it.
Observed commit-status output for 2d328e7:

    check / check                        success  10s
    Build and Deploy .../ build          failure  15s   <- reproduced
    Build and Deploy .../ deploy         skipped        <- if: guard working
    probe / p1-bare-alpine-checkout      failure   3s
    probe / p2-alpine-apk-checkout       success   5s
    probe / p3-alpine-apk-build          success  15s
    probe / p4-alpine-apk-upload         failure  11s
    probe / p5-node20alpine-checkout     success   8s
    probe / p6-node20slim-checkout       success  11s

Reading that:

- p1 vs p2: act_runner does not supply node for JavaScript actions, so the
  `apk add --no-cache nodejs git tar` prerequisite step is genuinely required
  and genuinely sufficient. checkout then runs on musl.
- p3: script/bootstrap and script/test complete inside the Actions container
  on the pinned alpine digest. The mandated image replacement was never the
  problem.
- p2 vs p4: the only difference is a trailing upload-artifact v4 step, and it
  is the difference between success and failure.
- p5/p6: musl is not the issue -- checkout runs on both musl and glibc images.

So what broke the deploy was not the image swap that everyone reviewed, it was
the v3 -> v4 artifact bump that nobody questioned. Gitea 1.25.4's artifact
backend and this runner do not serve the v4 protocol; the workflow used v3
before this issue and that is what worked.

The artifact actions therefore move back to the v3 line, still pinned by full
commit SHA, which satisfies the hash-pinning requirement this issue is actually
about. Both are the node20 builds rather than the node16 defaults, so nothing
depends on a node16 runtime:

- upload-artifact  -> c6a3b2bd (v3.2.2-node20)
- download-artifact -> ad191675 (v3.1.0-node20)

Round 2 probes: the two fallback v3 builds in case the node20 ones do not
resolve, plus a producer/consumer pair that rehearses the deploy job -- same
pinned node image, same pinned download action, same pinned wrangler version,
stopping short of `wrangler pages deploy` so it touches nothing external.
2026-08-09 02:50:36 +00:00
2d328e759b Re-apply deploy.yml pinning behind a deploy guard, and probe the failure
Some checks failed
check / check (push) Successful in 10s
Build and Deploy to Cloudflare Pages / build (push) Failing after 15s
probe / p1-bare-alpine-checkout (push) Failing after 3s
probe / p2-alpine-apk-checkout (push) Successful in 5s
probe / p3-alpine-apk-build (push) Successful in 15s
probe / p4-alpine-apk-upload (push) Failing after 11s
probe / p5-node20alpine-checkout (push) Successful in 8s
probe / p6-node20slim-checkout (push) Successful in 11s
Build and Deploy to Cloudflare Pages / deploy (push) Has been skipped
Restores the hash-pinning work reverted in 3d17e22 (originally 3f91a7c and
b157bfd) verbatim -- all six pinned values were independently re-resolved and
confirmed correct twice, so they are reused, not re-derived.

What is different this time is that the path is observable before it reaches
main. The previous attempt broke the deploy because deploy.yml triggers only on
push to main, so every pre-merge check simulated the runner instead of being
it, and two adversarial reviews could not catch what neither could execute.

Three changes on top of the restored work:

- A temporary development-only branch trigger on on.push.branches, so the
  build job actually executes under act_runner. Removed before merge.
- if: github.ref_name == 'main' on the deploy job. Without it, a branch push
  would run wrangler pages deploy against the real Cloudflare project with the
  real token on every iteration. This guard is permanent: it is one line and it
  makes any future branch trigger, deliberate or accidental, unable to reach
  Cloudflare.
- A temporary .gitea/workflows/probe.yml, also deleted before merge. The
  Actions jobs and logs API is not readable by this account; the commit-status
  API is, and it reports one entry per job. So the diagnosis is encoded as job
  topology rather than log output: six jobs, each isolating one hypothesis
  about the 22s failure (bare alpine vs apk prerequisites, checkout vs site
  build vs artifact upload, musl node vs glibc node), each surfacing as its own
  status context so a single push tests them all in parallel.

make check is green. No pinned value is touched.
2026-08-09 02:46:29 +00:00
3d17e22385 Revert "Merge pull request '#17: Hash-pin every external reference in deploy.yml (closes #7)'"
All checks were successful
check / check (push) Successful in 4s
Build and Deploy to Cloudflare Pages / build (push) Successful in 5s
Build and Deploy to Cloudflare Pages / deploy (push) Successful in 19s
This reverts commit 74c28c1d71, reversing
changes made to 7cad989724.
2026-08-09 02:37:18 +00:00
74c28c1d71 Merge pull request '#17: Hash-pin every external reference in deploy.yml (closes #7)'
Some checks failed
check / check (push) Successful in 3s
Build and Deploy to Cloudflare Pages / build (push) Failing after 22s
Build and Deploy to Cloudflare Pages / deploy (push) Has been skipped
2026-08-09 04:28:21 +02:00
b157bfd52c Install runner prerequisites in the pinned build container (closes #7)
All checks were successful
check / check (push) Successful in 10s
Replacing klakegg/hugo:ext-alpine with the Dockerfile's pinned alpine
digest satisfied the pinning requirement but dropped the runtime the
Actions runner itself depends on, which would have broken the deploy:

- act_runner executes JavaScript actions with `node` inside the job
  container and does not inject one. Stock alpine has no node, so
  actions/checkout - the job's first step - would fail with
  "node: not found", and script/bootstrap (which installs node) is step
  2 and never runs. The build job fails, deploy is skipped for
  `needs: build`, and the site stops publishing.
- Steps default to `bash`, which stock alpine does not ship either.

Fixes, both scoped to keeping the mandated image replacement runnable:

- A pre-checkout inline `run:` step (`apk add --no-cache nodejs git tar`)
  installs what the runner needs before the first `uses:` step. An
  inline run needs only a shell, so it works on the bare image. git is
  there for checkout's `submodules: recursive`; without it checkout
  degrades to a tarball download that cannot do submodules.
- `defaults.run.shell: sh` on the build job, so the shell is stated
  rather than left to a bash-to-sh fallback.

No pinned value is touched. The apk packages resolve at run time and are
not hash-pinned; that gap is repo-wide (script/bootstrap has it too) and
is tracked in #19.

Also moves each version/date comment to sit directly above the pinned
line rather than above the step's `- name:`, matching check.yml, and
dates the actions/checkout pin 2026-02-28 as check.yml already does for
the same SHA.

Verified by running the build job's step sequence inside the pinned
alpine digest: bare, `node` and `bash` are absent and the pinned
checkout bundle dies with "node: not found"; after the new apk step,
node 22.23.2, git 2.47.3 and GNU tar 1.35 are present, that same
checkout bundle runs under node and gets as far as "GITHUB_WORKSPACE not
defined", and script/bootstrap, script/test and the tar step all
complete. make check and script/cibuild (with the build cache pruned, so
nothing was CACHED) are green.
2026-08-09 02:15:44 +00:00
3f91a7c273 Hash-pin every external reference in deploy.yml (closes #7)
All checks were successful
check / check (push) Successful in 7s
deploy.yml was the last file in the repo carrying mutable external
references. Every image is now pinned by digest and every action by a
full 40-hex commit SHA, each with a version/date comment on the line
above. All values were resolved from upstream and verified to resolve.

- build container: klakegg/hugo:ext-alpine (abandoned since 2021,
  mutable tag) replaced by the exact alpine 3.21 digest the Dockerfile
  already pins, with script/bootstrap to install hugo and script/test
  to build. One pinned base and one dependency list now serve both the
  check build and the deploy build.
- deploy container: node:20 -> node@sha256:8f693eaa... (node 20.20.2,
  bookworm).
- actions/checkout: v4 -> 11bd7190... (v4.2.2), the same SHA check.yml
  pins, so the two workflows agree.
- actions/upload-artifact: v3 -> ea165f8d... (v4.6.2); v3 is deprecated.
- actions/download-artifact: v3 -> d3f86a10... (v4.3.0); v3 is
  deprecated.
- npm install -g wrangler -> wrangler@4.120.0, so the deploy no longer
  executes whatever the wrangler tag happens to point at.

Also drops the dead feat/initial-site push trigger (that branch is fully
merged into main) and reindents the file to 4-space YAML to match
check.yml and .editorconfig.

The two jobs are deliberately left separate so a deploy regression can
be attributed unambiguously.

Verified: make check and script/cibuild both green; the workflow parses
as YAML with the expected job/step structure. The Cloudflare Pages
deploy path itself cannot be exercised from a branch (it runs only on
push to main and needs CLOUDFLARE_API_TOKEN), so the deploy run on main
must be watched after merge.
2026-08-09 01:49:21 +00:00
7cad989724 Add scripts-to-rule-them-all scaffold (closes #4)
All checks were successful
check / check (push) Successful in 4s
Build and Deploy to Cloudflare Pages / build (push) Successful in 5s
Build and Deploy to Cloudflare Pages / deploy (push) Successful in 18s
Adopt the Scripts to Rule Them All standard for this Hugo site:

- script/ POSIX-sh entrypoints (bootstrap, setup, projectname, test,
  lint, fmt, fmt-check, check, docker, cibuild, precommit,
  install-precommit). The correctness check (test/lint) is a clean
  `hugo --minify` production build; fmt/fmt-check run prettier over the
  repo's own top-level markdown only, leaving content/ untouched.
- Makefile targets reduced to thin shims that call script/NAME, plus a
  convenience serve target for `hugo server`.
- Dockerfile on a sha256-pinned alpine base that installs deps via
  script/bootstrap and runs `make check`, so the image build fails on
  any formatting or Hugo build error; .dockerignore added.
- .gitea/workflows/check.yml runs script/cibuild on push.
- README Entrypoints section documenting the scripts.
2026-07-25 18:22:52 +07:00
612d15587b Add standard Workflow section to TODO.md
All checks were successful
Build and Deploy to Cloudflare Pages / build (push) Successful in 5s
Build and Deploy to Cloudflare Pages / deploy (push) Successful in 19s
2026-07-06 21:06:42 +02:00
f1cab64bd4 Merge branch 'TODO'
All checks were successful
Build and Deploy to Cloudflare Pages / build (push) Successful in 7s
Build and Deploy to Cloudflare Pages / deploy (push) Successful in 22s
2026-07-06 20:51:15 +02:00
20c133ee61 Add TODO.md 2026-07-06 20:35:49 +02:00
f993d36f0c add contact link in footer
All checks were successful
Build and Deploy to Cloudflare Pages / build (push) Successful in 5s
Build and Deploy to Cloudflare Pages / deploy (push) Successful in 21s
2026-02-14 05:51:08 +01:00
31 changed files with 1820 additions and 126 deletions

41
.dockerignore Normal file
View File

@@ -0,0 +1,41 @@
# NOTE: .dockerignore does NOT use .gitignore semantics. It matches with
# Go's filepath.Match rules extended with `**`: `*` does not cross `/`,
# and an unprefixed pattern is anchored at the context root. So a bare
# `*.key` would exclude ./server.key but happily ship ./certs/server.key
# into the image, and a bare `node_modules` would exclude only a
# top-level one. Every depth-independent pattern below therefore carries
# an explicit `**/` prefix. The only unprefixed entries are the ones that
# are genuinely root-anchored: the repo's own .git, Hugo's output
# directories, and Hugo's build lock, all of which exist at the context
# root by definition.
# Repo and Hugo outputs (root-anchored on purpose)
.git
public
resources
.hugo_build.lock
# OS
**/.DS_Store
**/Thumbs.db
# Editors
**/*.swp
**/*.swo
**/*~
**/*.bak
**/.idea
**/.vscode
**/*.sublime-*
# Node
**/node_modules
# Environment / secrets
**/.env
**/.env.*
**/*.pem
**/*.key
# Agent tooling (holds worktrees/, i.e. entire additional checkouts)
**/.claude

12
.editorconfig Normal file
View File

@@ -0,0 +1,12 @@
root = true
[*]
indent_style = space
indent_size = 4
end_of_line = lf
charset = utf-8
trim_trailing_whitespace = true
insert_final_newline = true
[Makefile]
indent_style = tab

View File

@@ -0,0 +1,9 @@
name: check
on: [push]
jobs:
check:
runs-on: ubuntu-latest
steps:
# actions/checkout v4.2.2, 2026-02-28
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683
- run: script/cibuild

View File

@@ -3,28 +3,64 @@ name: Build and Deploy to Cloudflare Pages
on: on:
push: push:
branches: branches:
- feat/initial-site
- main - main
jobs: jobs:
build: build:
runs-on: ubuntu-latest runs-on: ubuntu-latest
container: container:
image: klakegg/hugo:ext-alpine # Same digest the Dockerfile pins: one pinned base image and the
# same dependency list (script/bootstrap) for both the check build
# and the deploy build. The one extra thing this job needs on top
# of the Dockerfile is the Actions runner's own prerequisites --
# see the first step.
# alpine 3.21, 2026-02-28
image: alpine@sha256:c3f8e73fdb79deaebaa2037150150191b9dcbfba68b4a46d70103204c53f4709
defaults:
run:
# The default step shell is bash; this image has only busybox
# sh, so say so explicitly rather than rely on a fallback.
shell: sh
steps: steps:
# This image is bare busybox+musl. act_runner executes JavaScript
# actions (checkout, upload-artifact) with `node` *inside* the job
# container and does not inject one, so node has to exist before
# the first `uses:` step -- script/bootstrap runs too late. git is
# needed for checkout's `submodules: recursive` (without it
# checkout degrades to a tarball download that cannot do
# submodules). An inline `run:` needs only a shell, so this step
# works on the bare image. These apk packages resolve at run time
# and are not hash-pinned; that gap is repo-wide (script/bootstrap
# has it too) and is tracked in #19.
- name: Install runner prerequisites
run: apk add --no-cache nodejs git tar
- name: Checkout - name: Checkout
uses: actions/checkout@v4 # actions/checkout v4.2.2, 2026-02-28
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683
with: with:
submodules: recursive submodules: recursive
- name: Install build dependencies
run: script/bootstrap
- name: Build site - name: Build site
run: hugo --minify run: script/test
- name: Archive site - name: Archive site
run: tar -czf site.tar.gz public run: tar -czf site.tar.gz public
# v3, not v4: artifacts v4 is a different wire protocol and this
# Gitea Actions instance does not serve it. That is what broke the
# deploy in run 25 -- measured by running two otherwise identical
# jobs on a branch, one ending in upload-artifact v4 (failed) and
# one without that step (passed). Tracked in #20. This SHA is the
# exact commit the mutable `@v3` used to resolve to, i.e. the code
# that was already deploying this site, now pinned rather than
# floating.
- name: Upload artifact - name: Upload artifact
uses: actions/upload-artifact@v3 # actions/upload-artifact v3.2.1, 2026-08-09
uses: actions/upload-artifact@ff15f0306b3f739f7b6fd43fb5d26cd321bd4de5
with: with:
name: site name: site
path: site.tar.gz path: site.tar.gz
@@ -32,19 +68,41 @@ jobs:
deploy: deploy:
runs-on: ubuntu-latest runs-on: ubuntu-latest
needs: build needs: build
# Publishing guard. This job spends CLOUDFLARE_API_TOKEN and creates a
# real Cloudflare Pages deployment, so it must never run off main --
# not even if a branch is added to the push trigger above, deliberately
# or by accident. Costs one line; the build job stays exercisable from
# a branch without this job touching anything external.
if: github.ref_name == 'main'
container: container:
image: node:20 # node 20.20.2-bookworm, 2026-08-09
image: node@sha256:8f693eaa7e0a8e71560c9a82b55fd54c2ae920a2ba5d2cde28bac7d1c01c9ba5
steps: steps:
# Must match the upload-artifact major above -- v4 artifacts and
# v3 artifacts are different protocols and do not interoperate.
# Like the upload above, this is the exact commit `@v3` used to
# resolve to.
- name: Download artifact - name: Download artifact
uses: actions/download-artifact@v3 # actions/download-artifact v3.0.2, 2026-08-09
uses: actions/download-artifact@9bc31d5ccc31df68ecc42ccf4149144866c47d8a
with: with:
name: site name: site
- name: Extract site - name: Extract site
run: tar -xzf site.tar.gz run: tar -xzf site.tar.gz
# 4.86.0, not the 4.120.0 that `latest` points at: wrangler
# 4.120.0 requires node >= 22 and refuses to start on this
# container's node 20. Note that the unpinned `npm install -g
# wrangler` this replaces was never installing `latest` either --
# npm picks the newest version whose engines the running node
# satisfies, which on node 20 is exactly 4.86.0. So this pins the
# version that has actually been deploying this site, rather than
# silently changing it. Moving the container to node 22 so the
# wrangler pin can advance is tracked in #21.
- name: Install Wrangler - name: Install Wrangler
run: npm install -g wrangler # wrangler 4.86.0, 2026-08-09
run: npm install -g wrangler@4.86.0
- name: Deploy to Cloudflare Pages - name: Deploy to Cloudflare Pages
run: wrangler pages deploy public --project-name=lora-vegas --branch=${{ github.ref_name }} run: wrangler pages deploy public --project-name=lora-vegas --branch=${{ github.ref_name }}

View File

@@ -1,13 +0,0 @@
name: Security Recon
on:
push:
branches:
- security-audit
jobs:
recon:
runs-on: ubuntu-latest
steps:
- name: Placeholder
run: echo "Security audit complete. See issue #3."

26
.gitignore vendored
View File

@@ -1,3 +1,29 @@
# Hugo
/public/ /public/
/resources/ /resources/
.hugo_build.lock .hugo_build.lock
# OS
.DS_Store
Thumbs.db
# Editors
*.swp
*.swo
*~
*.bak
.idea/
.vscode/
*.sublime-*
# Node
node_modules/
# Environment / secrets
.env
.env.*
*.pem
*.key
# Agent tooling
.claude/

25
.prettierignore Normal file
View File

@@ -0,0 +1,25 @@
node_modules/
yarn.lock
# Site content. Measured, not assumed: formatting content/_index.md
# re-wrapped one list item, and the rendered public/index.html changed
# with it - the wrap landed as a literal newline between "7 PM at" and
# the following <a> tag. HTML collapses that newline to a space, so the
# page looks the same, but the published bytes are not the same, and
# this content carries raw HTML blocks (div/span/br) that goldmark
# passes through verbatim because hugo.toml sets
# markup.goldmark.renderer.unsafe = true. A formatter that can silently
# change a published page is not worth the consistency, so content/ is
# formatted by hand.
content/
# Hugo layout templates. REPO_POLICIES.md puts HTML in prettier's
# scope, but these files are not HTML: they are Go templates carrying
# {{ define }}, {{ block }}, {{ .Content }} and
# {{ readFile ... | safeCSS }}. Prettier has no Go-template parser, so
# it would either fail outright or reflow the delimiters into markup
# Hugo can no longer parse. Formatting them would require an
# out-of-tree prettier plugin, which in turn requires a package.json -
# deliberately out of scope for this repo. Excluded on purpose, not by
# oversight.
themes/loravega/layouts/

4
.prettierrc Normal file
View File

@@ -0,0 +1,4 @@
{
"tabWidth": 4,
"proseWrap": "always"
}

56
Dockerfile Normal file
View File

@@ -0,0 +1,56 @@
# Hugo static-site build image. The build runs the individual non-lint
# checks -- script/test, a clean `hugo --minify` production build, and
# script/fmt-check, the read-only prettier check -- so the image build
# fails on any template, content, config or formatting error.
#
# It deliberately does NOT run `make check`, and only the lint is
# missing from what it does run. `make check` calls script/lint, and
# script/lint is a `docker build` of Dockerfile.lint, so `RUN make
# check` here would attempt a docker build inside a build step, in a
# bare alpine with no docker client and no daemon socket. Putting
# `make check` (or a `make lint`) back reintroduces exactly that
# recursion. The lint is not skipped: script/cibuild runs script/lint
# first, in its own container, before this build starts.
#
# Build this only via script/cibuild or script/docker: both pass the
# CHECK_EPOCH build argument that this file requires, and a bare
# `docker build .` fails by design. See the guard below for why.
# alpine 3.21, 2026-02-28
FROM alpine@sha256:c3f8e73fdb79deaebaa2037150150191b9dcbfba68b4a46d70103204c53f4709
WORKDIR /src
# Install build dependencies first so the layer caches until the
# scripts change (script/bootstrap installs git, make, go, hugo,
# node/npm). Hugo is not an apk package here: script/bootstrap builds
# the exact pinned version with `go install`, hash-verified against
# sum.golang.org, so the published artifact does not depend on whatever
# hugo this base image's repos happen to serve.
COPY script/ script/
RUN script/bootstrap
COPY . .
# CHECK_EPOCH is a per-invocation nonce supplied by script/cibuild and
# script/docker. Without it an unchanged tree serves this layer from
# cache and the build reports a green it never ran. ARG is stage-scoped,
# so it must be redeclared in every stage that runs checks - this image
# has one stage, so one declaration. Declared with no default: a default
# would be a constant, and a constant is a stable cache key. The guard
# makes a bare `docker build .` fail loudly instead of silently reusing
# the empty (and therefore stable) cache key. Expand the value into the
# command so the cache miss does not depend on BuildKit's handling of an
# unreferenced ARG. Both the guard and the check RUN reference the value,
# so both are value-keyed: there are two independent invalidation points
# here, not one. Keep both.
#
# Everything above this point still caches, so the script/bootstrap
# layer - which compiles Hugo from source - is not rebuilt.
ARG CHECK_EPOCH
RUN [ -n "$CHECK_EPOCH" ] || exit 1
# The individual non-lint checks - build fails if either fails. Invoked
# as script/ entrypoints rather than `make check` for the reason in the
# header comment above.
RUN echo "check epoch: ${CHECK_EPOCH}" && script/test
RUN script/fmt-check

61
Dockerfile.lint Normal file
View File

@@ -0,0 +1,61 @@
# Lint-only image. `script/lint` builds this file and nothing else: the
# lint runs as a build step, so a successful build IS a clean lint.
#
# One stage, deliberately. A whole-file `docker build -f Dockerfile.lint .`
# builds only the file's LAST stage, and sibling stages off a shared base
# have no ordering edge between them, so a second stage sitting beside
# this one would be silently skipped by exactly the invocation the
# canonical org-wide `script/lint` uses -- a green that linted nothing,
# which is the failure mode this file exists to prevent. With a single
# stage there is nothing to skip and `script/lint` needs no `--target`.
# If a second check is ever added here it must be chained (`FROM lint AS
# ...`) or carry an explicit ordering edge, never left as a sibling.
#
# Only linting is containerised (owner ruling, 2026-08-10: "fmt and fmt
# check arent docker, just linting"). script/fmt and script/fmt-check run
# on the host, and the main Dockerfile runs the production build and the
# format check directly -- see the comment there.
#
# The lint is invoked directly below rather than through `make lint` or
# `script/lint`. That is not a style choice: `script/lint` IS this build,
# so calling it from inside would recurse into a docker build with no
# daemon.
#
# This repo's lint is a clean Hugo build that surfaces broken internal
# links and template path problems: `hugo` fails on build errors and
# --printPathWarnings reports render-target collisions.
# alpine 3.21, 2026-02-28
FROM alpine@sha256:c3f8e73fdb79deaebaa2037150150191b9dcbfba68b4a46d70103204c53f4709
WORKDIR /src
# Keep these four instructions byte-identical to the main Dockerfile's,
# in the same order: Docker keys layers on the instruction chain, not on
# the file they live in, so an identical prefix means this build is a
# cache hit against the main image's layers. script/bootstrap compiles
# the pinned Hugo from source, which is by far the most expensive step
# here, and it must not be paid twice. The dependency layer is also
# deliberately above the ARG below, so it stays cached and only the lint
# step re-runs on every invocation.
COPY script/ script/
RUN script/bootstrap
COPY . .
# CHECK_EPOCH is a per-invocation nonce supplied by script/lint. Without
# it an unchanged tree serves the lint layer from cache: the lint never
# executes and the build still exits 0, which is precisely the false
# green this repo already fixed once in the main Dockerfile. Caching is
# explicitly waived for lint, so the value is expanded into the linted
# command as well as the guard -- two independent value-keyed
# invalidation points, so a cache miss never depends on BuildKit's
# treatment of an unreferenced ARG, and the epoch is visible in the build
# log. Declared with no default: a default is a constant, and a constant
# is a stable cache key. The guard makes a bare
# `docker build -f Dockerfile.lint .` fail loudly instead of silently
# reusing the empty (and therefore stable) cache key. Keep both
# references.
ARG CHECK_EPOCH
RUN [ -n "$CHECK_EPOCH" ] || exit 1
RUN echo "lint epoch: ${CHECK_EPOCH}" && hugo --minify --printPathWarnings

21
LICENSE Normal file
View File

@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 Jeffrey Paul <sneak@sneak.berlin>
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

37
Makefile Normal file
View File

@@ -0,0 +1,37 @@
.PHONY: bootstrap setup test lint fmt fmt-check check docker cibuild precommit hooks serve
bootstrap:
@script/bootstrap
setup:
@script/setup
test:
@script/test
lint:
@script/lint
fmt:
@script/fmt
fmt-check:
@script/fmt-check
check:
@script/check
docker:
@script/docker
cibuild:
@script/cibuild
precommit:
@script/precommit
hooks:
@script/install-precommit
serve:
@hugo server

151
README.md
View File

@@ -1,40 +1,153 @@
# lora.vegas # lora.vegas
Las Vegas Meshtastic and LoRa community website. `lora.vegas` is the website of the Las Vegas Meshtastic and LoRa community: an
MIT-licensed single-page static site, built with Hugo, by
[@sneak](https://sneak.berlin).
## About It publishes what the local mesh needs in one linkable place:
This site provides information about the Las Vegas mesh networking community, including:
- Mesh channel configurations - Mesh channel configurations
- Community coordination (Discord, Signal) - Community coordination links (Discord, Signal)
- Meetup information - Meetup information
- Local resources - Local resources
## Contributing ## Getting Started
To contribute to this site, contact **sneak@sneak.berlin** for git repository access. From a fresh clone, `make setup` installs every build dependency (git, make, go,
the pinned Hugo, node/npm) and the git pre-commit hook, and `make serve` starts
## Technical Details the Hugo development server:
This is a static site built with Hugo. The site is deployed automatically via GitHub Actions.
### Local Development
```bash ```bash
hugo server git clone git@git.eeqj.de:sneak/lora.vegas.git
cd lora.vegas
make setup
make serve
``` ```
Visit http://localhost:1313 to preview. Then open <http://localhost:1313> to preview the site.
### Build To produce the production build, which writes the rendered site to `public/`:
```bash ```bash
hugo make test
``` ```
Output will be in the `public/` directory. Before committing, run the full check suite — the production build, the lint
build, and the formatting check:
```bash
make check
```
The lint runs inside Docker, so `make check` needs a working Docker daemon;
there is no host fallback. On a machine that has never built the image, the
first run compiles the pinned Hugo from source, which takes minutes; later runs
reuse that cached layer.
`make fmt` rewrites the repo's markdown and CSS to the project's prettier
settings; run it if `make check` fails on formatting.
To contribute to this site, contact **sneak@sneak.berlin** for git repository
access.
## Entrypoints
This repository adheres to the
[Scripts to Rule Them All](https://github.com/github/scripts-to-rule-them-all)
standard: normalized scripts in `script/` are the entrypoints for the
development workflow, and the Makefile targets are thin shims that call them. We
provide:
- `script/bootstrap` — install all build dependencies (git, make, go, hugo,
node/npm) idempotently. Hugo is pinned to an exact version and installed with
`go install`, which verifies it against `sum.golang.org`; the version is the
`HUGO_VERSION` constant at the top of the script
- `script/setup` — prepare a fresh clone: run `script/bootstrap` and install the
git pre-commit hook
- `script/test` — the correctness check: a clean `hugo --minify` production
build
- `script/lint` — a clean build that surfaces broken links and path collisions,
run inside Docker: it builds `Dockerfile.lint`, where the lint is a build
step, so a successful build is a clean lint
- `script/fmt` — format every markdown and CSS file in the repo with prettier;
the exclusions live in `.prettierignore` with the reason for each
- `script/fmt-check` — check that formatting (read-only)
- `script/check` — run `script/test`, `script/lint`, then `script/fmt-check`;
modifies no tracked files
- `script/docker` — build the Docker image tagged with the project name
- `script/cibuild` — the CI build: `script/lint` first, for fail-fast feedback,
then the main image, which runs the non-lint checks
- `script/install-precommit` — install the git pre-commit hook that runs
`script/precommit`
Each of those has a Makefile shim of the same name — `make bootstrap`,
`make setup`, `make test`, `make lint`, `make fmt`, `make fmt-check`,
`make check`, `make docker`, `make cibuild` — with one exception:
`script/install-precommit` is `make hooks`. `script/precommit`, which is what
the installed hook runs, is `make precommit`. Prefer the make targets; the
`Makefile` lists the operations you are expected to run.
`make cibuild` is the slowest target: it is the only one that runs two container
builds, the lint image first and then the main image. The two share the
`script/bootstrap` layer byte-for-byte, so the pinned-Hugo compile described
above is paid once per machine rather than twice, and once that layer is cached
a full `make cibuild` takes seconds. That is the cost of the CI build, not a
sign of a problem.
Every lint run for this repo happens inside a container, and only the lint does.
`script/lint` has no host path and no "already inside a container?" branch, so
what a developer runs and what CI runs are the same build. `script/fmt` and
`script/fmt-check` run on the host: a formatting check is not a lint.
That is also why the main `Dockerfile` runs `script/test` and `script/fmt-check`
rather than `make check`. `make check` calls `script/lint`, which is itself a
`docker build`, so a `make check` inside an image would attempt a docker build
in a bare Alpine with no docker client and no daemon socket. The lint is not
skipped — `script/cibuild` runs it first, in its own container, before the main
image build starts.
Build any image through `script/cibuild`, `script/docker` or `script/lint` only.
All three pass a per-invocation `CHECK_EPOCH` build argument that the
Dockerfiles require, so a check layer can never be served from cache — without
it Docker returns a green it did not earn. A bare `docker build` fails closed on
the `CHECK_EPOCH` guard rather than caching its way to a false success.
A convenience `make serve` target runs `hugo server` for local preview.
## Rationale
The Las Vegas Meshtastic and LoRa community needs one durable, linkable place
for its channel configurations and group links. Those details otherwise live
inside a Discord or Signal thread, where they scroll away, cannot be linked to
from outside, are invisible to anyone who has not already joined, and quietly go
stale. A static site at a stable domain is the opposite of that: one URL to hand
to a newcomer, and one place to correct when a channel changes.
## Design
The site is a single page. All of its content is one Hugo content file,
`content/_index.md`, rendered by a minimal theme vendored in-repo at
`themes/loravega/` — there is no upstream theme dependency and no submodule.
The theme's `layouts/_default/baseof.html` inlines
`themes/loravega/static/css/style.css` into a `<style>` block with Hugo's
`readFile`, so the whole site ships as a single HTML document with no external
CSS request and no second round trip.
`hugo --minify` builds the site into `public/`. Deployment is automatic: on push
to `main`, the Gitea Actions workflow `.gitea/workflows/deploy.yml` builds the
site and publishes `public/` to Cloudflare Pages.
## TODO
The live task list is in [TODO.md](TODO.md).
## License ## License
Content is provided as-is for community use. MIT. See [LICENSE](LICENSE). This covers everything in the repository — the Hugo
configuration, the `script/` entrypoints, the vendored `themes/loravega/`
templates and CSS, and the site content in `content/`.
## Author
[@sneak](https://sneak.berlin)

416
REPO_POLICIES.md Normal file
View File

@@ -0,0 +1,416 @@
---
title: Repository Policies
last_modified: 2026-08-07
---
This document covers repository structure, tooling, and workflow standards. Code
style conventions are in separate documents:
- [Code Styleguide](https://git.eeqj.de/sneak/prompts/raw/branch/main/prompts/CODE_STYLEGUIDE.md)
(general, bash, Docker)
- [Go](https://git.eeqj.de/sneak/prompts/raw/branch/main/prompts/CODE_STYLEGUIDE_GO.md)
- [JavaScript](https://git.eeqj.de/sneak/prompts/raw/branch/main/prompts/CODE_STYLEGUIDE_JS.md)
- [Python](https://git.eeqj.de/sneak/prompts/raw/branch/main/prompts/CODE_STYLEGUIDE_PYTHON.md)
- [Go HTTP Server Conventions](https://git.eeqj.de/sneak/prompts/raw/branch/main/prompts/GO_HTTP_SERVER_CONVENTIONS.md)
---
- Cross-project documentation (such as this file) must include
`last_modified: YYYY-MM-DD` in the YAML front matter so it can be kept in sync
with the authoritative source as policies evolve.
- **ALL external references must be pinned by cryptographic hash.** This
includes Docker base images, Go modules, npm packages, GitHub Actions, and
anything else fetched from a remote source. Version tags (`@v4`, `@latest`,
`:3.21`, etc.) are server-mutable and therefore remote code execution
vulnerabilities. The ONLY acceptable way to reference an external dependency
is by its content hash (Docker `@sha256:...`, Go module hash in `go.sum`, npm
integrity hash in lockfile, GitHub Actions `@<commit-sha>`). No exceptions.
This also means never `curl | bash` to install tools like pyenv, nvm, rustup,
etc. Instead, download a specific release archive from GitHub, verify its hash
(hardcoded in the Dockerfile or script), and only then install. Unverified
install scripts are arbitrary remote code execution. This is the single most
important rule in this document. Double-check every external reference in
every file before committing. There are zero exceptions to this rule.
- Every repo with software must have a root `Makefile` with these targets:
`make bootstrap`, `make setup`, `make test`, `make lint`, `make fmt` (writes),
`make fmt-check` (read-only), `make check` (runs `test`, `lint`, `fmt-check`),
`make docker`, and `make hooks` (installs pre-commit hook). A model Makefile
is at `https://git.eeqj.de/sneak/prompts/raw/branch/main/Makefile`.
- Repos follow the
[Scripts to Rule Them All](https://github.com/github/scripts-to-rule-them-all)
pattern: the implementation of each Makefile target lives in an executable
script in `script/` (`script/bootstrap`, `script/setup`, `script/test`,
`script/lint`, `script/fmt`, `script/fmt-check`, `script/check`,
`script/docker`), and the Makefile targets are thin shims that call them. The
scripts must be POSIX sh (`#!/bin/sh`, `set -eu`, no bashisms) so they run in
minimal containers (e.g. alpine images have no bash); locate the repo root
with `$(cd "$(dirname "$0")/.." && pwd -P)` and `cd` there before acting. From
the standard's canonical set we use `bootstrap`, `setup` (make the repo ready
for development after a fresh clone: runs `bootstrap`, then
`install-precommit`, plus any repo-specific initialization), `test`, and
`cibuild`. `script/bootstrap` installs all dependencies idempotently and
assumes nothing is present: base tools come from nix, apt, brew, or apk
(detected in that order; apt runs noninteractive). For node it uses the
installed node if present; otherwise it installs a PINNED node version via
nvm, first installing nvm itself if missing — from a hash-verified GitHub
release archive (never `curl | sh`), with bash installed as an explicit
prerequisite since nvm requires bash. yarn is then pinned via
`corepack prepare yarn@<version> --activate`. Never install "latest" or "lts";
always exact versions. `script/cibuild` runs the CI build: it changes to the
repo root and runs `docker build .`; the Gitea workflow calls it. Four further
scripts are our own extensions to the standard: `script/check` runs
`script/test`, `script/lint`, and `script/fmt-check`; `script/precommit` is
what the git pre-commit hook runs, and it calls `script/check`;
`script/install-precommit` installs the git pre-commit hook (the `make hooks`
target shims to it); and `script/projectname` (literally that filename) simply
outputs the project's name. Scripts that need the name call
`script/projectname` — e.g. `script/docker` assembles its image tag from it —
so those scripts stay byte-identical across all repos. Repo-type-specific
pre-commit extras (e.g. `go mod tidy` verification in Go repos) belong in
`script/precommit`, not in the hook itself. Model scripts are at
`https://git.eeqj.de/sneak/prompts/raw/branch/main/script/<name>`. The README
must document the provided scripts in an **Entrypoints** section (see the
README requirements below).
- Always use Makefile targets (`make fmt`, `make test`, `make lint`, etc.)
instead of invoking the underlying tools directly. The Makefile is the single
source of truth for how these operations are run.
- The Makefile is authoritative documentation for how the repo is used. Beyond
the required targets above, it should have targets for every common operation:
running a local development server (`make run`, `make dev`), re-initializing
or migrating the database (`make db-reset`, `make migrate`), building
artifacts (`make build`), generating code, seeding data, or anything else a
developer would do regularly. If someone checks out the repo and types
`make<tab>`, they should see every meaningful operation available. A new
contributor should be able to understand the entire development workflow by
reading the Makefile.
- Every repo should have a `Dockerfile`. All Dockerfiles must run `make check`
as a build step so the build fails if the branch is not green. For non-server
repos, the Dockerfile should bring up a development environment and run
`make check`. For server repos, `make check` should run as an early build
stage before the final image is assembled. Dockerfiles install development
prerequisites by running `script/bootstrap` rather than duplicating installs
inline; COPY `script/` and the dependency manifests (`package.json` +
`yarn.lock`, `go.mod` + `go.sum`, etc.) before running it so the bootstrap
layer stays cached until dependencies change.
- **Dockerfiles must use a separate lint stage for fail-fast feedback.** Go
repos use a multistage build where linting runs in an independent stage based
on the `golangci/golangci-lint` image (pinned by hash). This stage runs
`make fmt-check` and `make lint` before the full build begins. The build stage
then declares an explicit dependency on the lint stage via
`COPY --from=lint /src/go.sum /dev/null`, which forces BuildKit to complete
linting before proceeding to compilation and tests. This ensures lint failures
surface in seconds rather than minutes, without blocking on dependency
download or compilation in the build stage.
The standard pattern for a Go repo Dockerfile is:
```dockerfile
# Lint stage — fast feedback on formatting and lint issues
# golangci/golangci-lint:v2.x.x, YYYY-MM-DD
FROM golangci/golangci-lint@sha256:... AS lint
WORKDIR /src
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN make fmt-check
RUN make lint
# Build stage
# golang:1.x-alpine, YYYY-MM-DD
FROM golang@sha256:... AS builder
WORKDIR /src
# Force BuildKit to run the lint stage before proceeding
COPY --from=lint /src/go.sum /dev/null
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN make test
ARG VERSION=dev
RUN CGO_ENABLED=0 go build -trimpath \
-ldflags="-s -w -X main.Version=${VERSION}" \
-o /app ./cmd/app/
# Runtime stage
FROM alpine@sha256:...
COPY --from=builder /app /usr/local/bin/app
ENTRYPOINT ["app"]
```
Key points:
- The lint stage uses the `golangci/golangci-lint` image directly (it
includes both Go and the linter), so there is no need to install the
linter separately.
- `COPY --from=lint /src/go.sum /dev/null` is a no-op file copy that creates
a stage dependency. BuildKit runs stages in parallel by default; without
this line, the build stage would not wait for lint to finish and a lint
failure might not fail the overall build.
- If the project uses `//go:embed` directives that reference build artifacts
(e.g. a web frontend compiled in a separate stage), the lint stage must
create placeholder files so the embed directives resolve. Example:
`RUN mkdir -p web/dist && touch web/dist/index.html web/dist/style.css`.
The lint stage should not depend on the actual build output — it exists to
fail fast.
- If the project requires CGO or system libraries for linting (e.g.
`vips-dev`), install them in the lint stage with `apk add`.
- The build stage runs `make test` after compilation setup. Tests run in the
build stage, not the lint stage, because they may require compiled
artifacts or heavier dependencies.
- Every repo should have a Gitea Actions workflow (`.gitea/workflows/`) that
runs `script/cibuild` (which runs `docker build .`) on push. Since the
Dockerfile already runs `make check`, a successful build implies all checks
pass.
- Use platform-standard formatters: `black` for Python, `prettier` for
JS/CSS/Markdown/HTML, `go fmt` for Go. Always use default configuration with
two exceptions: four-space indents (except Go), and `proseWrap: always` for
Markdown (hard-wrap at 80 columns). Documentation and writing repos (Markdown,
HTML, CSS) should also have `.prettierrc` and `.prettierignore`.
- Pre-commit hook: runs `script/precommit`, which calls `script/check`. If local
testing is not possible in the repo, `script/precommit` may skip `script/test`
and run only `script/lint` and `script/fmt-check`. The hook is installed by
`script/install-precommit`; the Makefile must provide a `make hooks` target
that shims to it.
- All repos with software must have tests that run via the platform-standard
test framework (`go test`, `pytest`, `jest`/`vitest`, etc.). If no meaningful
tests exist yet, add the most minimal test possible — e.g. importing the
module under test to verify it compiles/parses. There is no excuse for
`make test` to be a no-op.
- `make test` must complete in under 20 seconds. Add a 30-second timeout in the
Makefile.
- **`make test` should use the conditional verbose rerun pattern.** Run tests
without `-v` (verbose) first. If tests fail, automatically rerun with `-v` to
show full output. This keeps CI logs and `docker build` output clean on
success (just package/suite summaries) while providing full diagnostic detail
on failure (every test case, every assertion). The general shell pattern:
```makefile
test:
@<test-command> || \
{ echo "--- Rerunning with -v for details ---"; \
<test-command-with-v>; exit 1; }
```
Go example:
```makefile
test:
@go test -timeout 30s -race -cover ./... || \
{ echo "--- Rerunning with -v for details ---"; \
go test -timeout 30s -race -v ./...; exit 1; }
```
Python example:
```makefile
test:
@python -m pytest || \
{ echo "--- Rerunning with -v for details ---"; \
python -m pytest -v; exit 1; }
```
The `exit 1` ensures the target always fails after a rerun — the first run
already proved the tests are broken, so the build must not pass even if a
flaky test happens to succeed on the second attempt. The rerun exists solely
for diagnostic output.
- Docker builds must complete in under 5 minutes.
- `make check` must not modify any files in the repo. Tests may use temporary
directories.
- `main` must always pass `make check`, no exceptions.
- Never commit secrets. `.env` files, credentials, API keys, and private keys
must be in `.gitignore`. No exceptions.
- `.gitignore` should be comprehensive from the start: OS files (`.DS_Store`),
editor files (`.swp`, `*~`), language build artifacts, and `node_modules/`.
Fetch the standard `.gitignore` from
`https://git.eeqj.de/sneak/prompts/raw/branch/main/.gitignore` when setting up
a new repo.
- **No build artifacts in version control.** Code-derived data (compiled
bundles, minified output, generated assets) must never be committed to the
repository if it can be avoided. The build process (e.g. Dockerfile, Makefile)
should generate these at build time. Notable exception: Go protobuf generated
files (`.pb.go`) ARE committed because repos need to work with `go get`, which
downloads code but does not execute code generation.
- Never use `git add -A` or `git add .`. Always stage files explicitly by name.
- Never force-push to `main`.
- Make all changes on a feature branch. You can do whatever you want on a
feature branch.
- `.golangci.yml` is standardized and must _NEVER_ be modified by an agent, only
manually by the user. Fetch from
`https://git.eeqj.de/sneak/prompts/raw/branch/main/.golangci.yml`. The
canonical golangci-lint version is v2.12.2 (released 2026-05-06), installed
commit-pinned via
`go install github.com/golangci/golangci-lint/v2/cmd/golangci-lint@c0d3ddc9cf3faa61a4e378e879ece580256d76e5`.
- When pinning images or packages by hash, add a comment above the reference
with the version and date (YYYY-MM-DD).
- Use `yarn`, not `npm`.
- Write all dates as YYYY-MM-DD (ISO 8601).
- Simple projects should be configured with environment variables.
- Dockerized web services listen on port 8080 by default, overridable with
`PORT`.
- **HTTP/web services must be hardened for production internet exposure before
tagging 1.0.** This means full compliance with security best practices
including, without limitation, all of the following:
- **Security headers** on every response:
- `Strict-Transport-Security` (HSTS) with `max-age` of at least one year
and `includeSubDomains`.
- `Content-Security-Policy` (CSP) with a restrictive default policy
(`default-src 'self'` as a baseline, tightened per-resource as
needed). Never use `unsafe-inline` or `unsafe-eval` unless
unavoidable, and document the reason.
- `X-Frame-Options: DENY` (or `SAMEORIGIN` if framing is required).
Prefer the `frame-ancestors` CSP directive as the primary control.
- `X-Content-Type-Options: nosniff`.
- `Referrer-Policy: strict-origin-when-cross-origin` (or stricter).
- `Permissions-Policy` restricting access to browser features the
application does not use (camera, microphone, geolocation, etc.).
- **Request and response limits:**
- Maximum request body size enforced on all endpoints (e.g. Go
`http.MaxBytesReader`). Choose a sane default per-route; never accept
unbounded input.
- Maximum response body size where applicable (e.g. paginated APIs).
- `ReadTimeout` and `ReadHeaderTimeout` on the `http.Server` to defend
against slowloris attacks.
- `WriteTimeout` on the `http.Server`.
- `IdleTimeout` on the `http.Server`.
- Per-handler execution time limits via `context.WithTimeout` or
chi/stdlib `middleware.Timeout`.
- **Authentication and session security:**
- Rate limiting on password-based authentication endpoints. API keys are
high-entropy and not susceptible to brute force, so they are exempt.
- CSRF tokens on all state-mutating HTML forms. API endpoints
authenticated via `Authorization` header (Bearer token, API key) are
exempt because the browser does not attach these automatically.
- Passwords stored using bcrypt, scrypt, or argon2 — never plain-text,
MD5, or SHA.
- Session cookies set with `HttpOnly`, `Secure`, and `SameSite=Lax` (or
`Strict`) attributes.
- **Reverse proxy awareness:**
- True client IP detection when behind a reverse proxy
(`X-Forwarded-For`, `X-Real-IP`). The application must accept
forwarded headers only from a configured set of trusted proxy
addresses — never trust `X-Forwarded-For` unconditionally.
- **CORS:**
- Authenticated endpoints must restrict `Access-Control-Allow-Origin` to
an explicit allowlist of known origins. Wildcard (`*`) is acceptable
only for public, unauthenticated read-only APIs.
- **Error handling:**
- Internal errors must never leak stack traces, SQL queries, file paths,
or other implementation details to the client. Return generic error
messages in production; detailed errors only when `DEBUG` is enabled.
- **TLS:**
- Services never terminate TLS directly. They are always deployed behind
a TLS-terminating reverse proxy. The service itself listens on plain
HTTP. However, HSTS headers and `Secure` cookie flags must still be
set by the application so that the browser enforces HTTPS end-to-end.
This list is non-exhaustive. Apply defense-in-depth: if a standard security
hardening measure exists for HTTP services and is not listed here, it is
still expected. When in doubt, harden.
- `README.md` is the primary documentation. Required sections:
- **Description**: First line must include the project name, purpose,
category (web server, SPA, CLI tool, etc.), license, and author. Example:
"µPaaS is an MIT-licensed Go web application by @sneak that receives
git-frontend webhooks and deploys applications via Docker in realtime."
- **Getting Started**: Copy-pasteable install/usage code block.
- **Entrypoints**: Opens by stating that the repo adheres to the
[Scripts to Rule Them All](https://github.com/github/scripts-to-rule-them-all)
standard (with that link), then documents each provided `script/`
entrypoint and its purpose.
- **Rationale**: Why does this exist?
- **Design**: How is the program structured?
- **TODO**: Update meticulously, even between commits. When planning, put
the todo list in the README so a new agent can pick up where the last one
left off.
- **License**: MIT, GPL, or WTFPL. Ask the user for new projects. Include a
`LICENSE` file in the repo root and a License section in the README.
- **Author**: [@sneak](https://sneak.berlin).
- First commit of a new repo should contain only `README.md`.
- Go module root: `sneak.berlin/go/<name>`. Always run `go mod tidy` before
committing.
- Use SemVer.
- Database migrations live in `internal/db/migrations/` and must be embedded in
the binary.
- `000_migration.sql` — contains ONLY the creation of the migrations
tracking table itself. Nothing else.
- `001_schema.sql` — the full application schema.
- **Pre-1.0.0:** never add additional migration files (002, 003, etc.).
There is no installed base to migrate. Edit `001_schema.sql` directly.
- **Post-1.0.0:** add new numbered migration files for each schema change.
Never edit existing migrations after release.
- All repos should have an `.editorconfig` enforcing the project's indentation
settings.
- Avoid putting files in the repo root unless necessary. Root should contain
only project-level config files (`README.md`, `Makefile`, `Dockerfile`,
`LICENSE`, `.gitignore`, `.editorconfig`, `REPO_POLICIES.md`, and
language-specific config). Everything else goes in a subdirectory. Canonical
subdirectory names:
- `bin/` — executable scripts and tools
- `cmd/` — Go command entrypoints
- `configs/` — configuration templates and examples
- `deploy/` — deployment manifests (k8s, compose, terraform)
- `docs/` — documentation and markdown (README.md stays in root)
- `internal/` — Go internal packages
- `internal/db/migrations/` — database migrations
- `pkg/` — Go library packages
- `share/` — systemd units, data files
- `static/` — static assets (images, fonts, etc.)
- `web/` — web frontend source
- When setting up a new repo, files from the `prompts` repo may be used as
templates. Fetch them from
`https://git.eeqj.de/sneak/prompts/raw/branch/main/<path>`.
- New repos must contain at minimum:
- `README.md`, `.git`, `.gitignore`, `.editorconfig`
- `LICENSE`, `REPO_POLICIES.md` (copy from the `prompts` repo)
- `Makefile`
- `script/` entrypoints (`bootstrap`, `setup`, `projectname`, `test`,
`lint`, `fmt`, `fmt-check`, `check`, `docker`, `cibuild`, `precommit`,
`install-precommit`)
- `Dockerfile`, `.dockerignore`
- `.gitea/workflows/check.yml`
- Go: `go.mod`, `go.sum`, `.golangci.yml`
- JS: `package.json`, `yarn.lock`, `.prettierrc`, `.prettierignore`
- Python: `pyproject.toml`

349
TODO.md Normal file
View File

@@ -0,0 +1,349 @@
# Workflow
- branch (from `main`)
- do the work in Next Step
- move Next Step to the top of Completed Steps
- move the top item of Future Steps into Next Step
- commit (`TODO.md` changes in the same commit as the work)
- merge to `main` if the branch is not protected, otherwise open a PR
- push
# Status
pre-1.0
No git tags. The site is live and now has the scripts-to-rule-them-all scaffold
(`Makefile`, `script/`, `Dockerfile`, `check.yml`), the canonical policy
dotfiles and `LICENSE`, so the mandated minimum file list is complete. Every
external reference in the repo is now pinned by cryptographic hash (or, for the
wrangler CLI install, an exact version), and the Hugo that builds the published
site is a deliberate pinned version rather than whatever the base image's
package repo serves. The site now ships a Cloudflare Pages `_headers` file, so
its response security headers are declared in the repo instead of being whatever
the edge defaults to, and confirmed live in production on both hostnames. The
lint now runs inside a container and nowhere else: `script/lint` is a build of
`Dockerfile.lint`, with no host path to fall back to. Formatting is not a lint
and stays on the host. Every entrypoint the README documents now has a
`Makefile` target, so the org-wide "use make targets, never the underlying tool"
rule is satisfiable for the CI build as well as for the everyday checks.
# Next Step
Make the prettier scope's exclusion of dot-directories explicit instead of
leaning on `.gitignore` (https://git.eeqj.de/sneak/lora.vegas/issues/33).
# Completed Steps
- 2026-08-10: added the `cibuild` and `precommit` targets to the `Makefile`
(https://git.eeqj.de/sneak/lora.vegas/issues/34). Both scripts already
existed, were already documented, and are the two entrypoints a contributor is
most likely to be told to run — the CI build and what the pre-commit hook runs
— yet neither had a make target, so the standing rule to use make targets
rather than the underlying tool could not be followed for either. It bit
reviewers twice, most recently on
https://git.eeqj.de/sneak/lora.vegas/pulls/32, and it bit hardest for the
build: since https://git.eeqj.de/sneak/lora.vegas/issues/30 a bare
`docker build .` fails closed on the `CHECK_EPOCH` guard, so `script/cibuild`
is one of only three supported ways to build an image and was the only one
without a target. The two targets are thin shims in the existing style and
change nothing about what the scripts do. `.PHONY` was already complete and
now lists both. `README.md`'s Entrypoints section gained the script-to-target
mapping, including the two names that do not match —
`script/install-precommit` is `make hooks`, and `script/precommit` is
`make precommit` — plus a note that `make cibuild` is the slowest target
because it is the only one that runs two container builds, while still taking
seconds once the shared `script/bootstrap` layer is cached. The section's
pre-existing `script/install-precommit` bullet, which claimed the installed
hook runs `script/check`, now says `script/precommit`, which is what the
script actually writes into `.git/hooks/pre-commit`. The stale Future Step
asking for post-deploy confirmation of `static/_headers` is dropped here: it
was confirmed live on both hostnames
(https://git.eeqj.de/sneak/lora.vegas/issues/14). Verified that `make cibuild`
earns its green rather than replaying a warm cache: one invocation ran both
builds for real in sequence, each with its own distinct `CHECK_EPOCH`, with
`RUN script/bootstrap` `CACHED` above and no check layer cached below it — the
`Dockerfile.lint` build echoed its epoch and printed hugo's own build table,
then the main image build echoed a different epoch, ran `script/test` for the
production build and `script/fmt-check` for the formatting check.
`make precommit` passes on a clean tree, and `make check` passes
- 2026-08-10: moved the lint into Docker
(https://git.eeqj.de/sneak/lora.vegas/issues/38). A new root `Dockerfile.lint`
runs `hugo --minify --printPathWarnings` as a build step, so a successful
build is a clean lint, and `script/lint` is nothing but a build of that file —
no host path and deliberately no "already inside a container?" branch, which
would be a host lint path in disguise. The containerisation boundary is lint
only, per the owner ruling of the same day: formatting is not a lint, so
`script/fmt` and `script/fmt-check` stay on the host. `Dockerfile.lint` has
exactly one stage on purpose. A whole-file `docker build -f Dockerfile.lint .`
builds only the file's last stage, and sibling stages off a shared base have
no ordering edge, so any second stage beside the lint would be silently
skipped by the invocation the canonical org-wide `script/lint` uses — a green
that linted nothing. With one stage there is nothing to skip and `script/lint`
needs no `--target`. Its first four instructions are byte-identical to the
main `Dockerfile`'s, so the expensive `RUN script/bootstrap` layer that
compiles Hugo from source is shared between the two images rather than paid
twice. The recursion this creates was resolved by direction, not detection:
`make check` calls `script/lint`, so the main `Dockerfile` can no longer
`RUN make check` — that would attempt a docker build inside a build step, in a
bare Alpine with no docker client and no daemon socket. It runs the individual
non-lint checks instead, `script/test` and `script/fmt-check`, matching the
canonical shape upstream, and `script/cibuild` runs `script/lint` first for
fail-fast feedback before the main image build starts. So CI still covers all
three checks and cannot drift from what a developer runs. Caching is waived
for the lint exactly as the main `Dockerfile` already does it:
`ARG CHECK_EPOCH` with no default, guarded with
`[ -n "$CHECK_EPOCH" ] || exit 1`, and the value expanded into the linted
command as well as the guard, so invalidation never rests on BuildKit's
handling of an unreferenced `ARG`. Every image-building entrypoint generates
and passes it — `script/cibuild`, `script/docker`, `script/lint` — which is
the failure mode this repo already hit once, a Dockerfile guard asserting a
property one entrypoint did not supply. `script/lint` builds with
`--output type=cacheonly`: the build is run for its exit status, not for an
image, and since the lint layer is cache-busted every run an exporting build
would leave one dangling image per lint on a host shared with other work.
Verified rather than assumed: two consecutive `script/lint` runs on an
unchanged tree both executed hugo for real (second run 0.85s wall,
`RUN script/bootstrap` `CACHED`, distinct epochs echoed, real build tables
printed); a whole-file `docker build -f Dockerfile.lint .` with the argument
and no `--target` ran the lint for real, which is the regression test for the
skipped-sibling hazard; a bare build with no argument failed closed on the
guard; a planted template error failed the lint with hugo's own render error
and made `script/cibuild` exit in 0.6s without the main image build starting
at all; a planted over-long line failed the host `script/fmt-check` with
`[warn] README.md`; both violations were reverted and re-run clean. Not
changed here, and still true: the lint fails on hugo build errors but not on
render-target collisions, which `--printPathWarnings` only prints
(https://git.eeqj.de/sneak/lora.vegas/issues/25) — containerising the run
neither fixes nor worsens that
- 2026-08-10: added the `LICENSE` file and made the README say what it says
(closes #10). The repo is public (`private: false` on the Gitea API, verified
rather than assumed), so the owner's standing policy — MIT on any public repo
lacking a license — applies. `LICENSE` is byte-identical to the canonical
`sneak/homoicon` copy, confirmed by git blob hash rather than by eye
(`3274443`), and its body is word-for-word the SPDX MIT text with only the
line wrapping differing. The README's "Content is provided as-is for community
use." — which granted nothing and matched no committed file — is replaced by
`MIT. See [LICENSE](LICENSE).` plus an explicit statement that the licence
covers the content in `content/` as well as the code, since this repo carries
both and MIT names only "the Software". The Description first line now carries
the licence, which `REPO_POLICIES.md` requires and which was the one field it
was missing. Nothing published contradicts the choice: the built `public/`
tree carries no copyright, all-rights-reserved or terms-of-use string
anywhere, in `index.html`, `css/style.css`, `index.xml` or `sitemap.xml` — the
footer `baseof.html` renders names `@sneak` and links the repo but asserts no
reservation of rights, and the content is factual mesh channel data with no
licence claim of its own. The fmt gate cannot reach `LICENSE` and needed no
`.prettierignore` entry: `script/fmt` passes prettier the explicit globs
`'**/*.md'` and `'**/*.css'`, and an extensionless root file matches neither.
Measured, not assumed — a `script/fmt` run leaves the file's hash unchanged,
and a counterfactual `LICENSE.md` copy was reflowed by the same run, which is
the direct evidence that it is the extension and not an ignore rule doing the
excluding. Deliberately not done, per the issue: per-file licence headers and
SPDX identifiers, which no org standard mandates
- 2026-08-09: added `static/_headers` so Cloudflare Pages serves baseline
response security headers (closes #14). Hugo copies `static/` verbatim into
`public/`, which is the deploy root Pages reads the file from; this is the
first root-level `static/` in the repo, and the built tree confirms it unions
with the theme's rather than shadowing it — `public/css/style.css` and
`public/index.html` are byte-identical to the previous build and the static
file count goes 1 to 2. The live "before" was measured, not assumed:
Cloudflare already sends `X-Content-Type-Options` and `Referrer-Policy` by
default, so the substance here is `Strict-Transport-Security`,
`Content-Security-Policy`, `X-Frame-Options` and `Permissions-Policy`. The CSP
is `default-src 'none'` with `style-src 'unsafe-inline'`, which the built page
supports exactly: it has no script, img, link, iframe, form or media element
and no `style=`/`on*=` attribute, only the one inline `<style>` block
`baseof.html` fills by `readFile`. Verified in a headless Chrome against a
local server that parses the committed `_headers` and applies it as real
response headers: zero CSP violations, the inlined stylesheet parses to 17
rules and the computed body padding, tagline colour and link colour all come
from the theme CSS, framing the page from another origin is refused by
`frame-ancestors 'none'` (consistent with `X-Frame-Options: DENY`), and all
five named outbound links still navigate with status 200. HSTS carries neither
`preload` nor `includeSubDomains`: `www.lora.vegas` is the only other name in
DNS and it is served by this same Pages project, so this file sets HSTS on its
responses directly, and `includeSubDomains` would instead bind every future
subdomain for a year with no way to walk it back inside the max-age window
without also dropping the apex protection.
- 2026-08-09: restructured `README.md` into the canonical section set (closes
#11): a Description first line, then Getting Started, Entrypoints, Rationale,
Design, TODO, License, Author. The non-standard About / Contributing /
Technical Details headings are gone, but nothing they held was dropped — the
bullet list of what the site publishes moved under the Description, the
contribute contact and the local-preview instructions moved into Getting
Started. Getting Started was written against the current `Makefile` rather
than the old prose: there is no `make build` target, so the former "Build:
`hugo`" instruction is now `make test`, and the former "Local Development:
`hugo server`" is `make setup` then `make serve`. Two stale claims fixed: the
site is deployed by Gitea Actions to Cloudflare Pages, not "GitHub Actions",
and the Entrypoints bullet for `script/fmt` still described the
top-level-markdown-only scope that #12 replaced with `'**/*.md'` and
`'**/*.css'`. The License section body is deliberately untouched — it is owned
by #10, which is blocked on the owner's choice of license, and the Description
sentence is likewise missing the license clause the policy calls for until #10
lands. Design section claims were verified against the tree, not assumed
- 2026-08-09: widened the prettier gate from top-level markdown to `'**/*.md'`
and `'**/*.css'` (closes #12). `themes/loravega/static/css/style.css` was
never formatted or gated even though it is inlined into every page; it is now
both, and the reformat landed as its own commit ahead of the script change so
no commit in the branch is red. `content/` is excluded in `.prettierignore`,
and that exclusion is measured rather than assumed: with `content/` in scope,
prettier re-wrapped one list item in `content/_index.md` and the rendered
`public/index.html` changed with it (the wrap became a literal newline between
`7 PM at` and the following `<a>`). HTML collapses that newline to a space so
the page looks identical, but the published bytes are not, and this content
carries raw HTML that goldmark passes through verbatim under `unsafe = true`.
`themes/loravega/layouts/` is excluded too, with the reason recorded: those
files are Go templates, not HTML, and prettier has no parser for `{{ ... }}`
covering them would need a plugin and therefore a `package.json`. Verified by
extracting `public/` from the built image before and after: with the final
scope, `index.html`, `index.xml` and `sitemap.xml` are byte-identical and only
the verbatim-copied `public/css/style.css` changes, in whitespace only — the
minified `<style>` block inlined into `index.html` is unchanged, which is the
direct evidence that CSS formatting cannot reach the rendered page
- 2026-08-09: added the canonical policy dotfiles and hardened both ignore files
(closes #8). `REPO_POLICIES.md` is a byte-identical copy of the canonical
`prompts` file, front matter intact; `.editorconfig`, `.prettierrc` and
`.prettierignore` are the canonical contents. `.gitignore` keeps its three
Hugo lines and gains the OS/editor/node/secrets block plus `.claude/`, so a
clean checkout with agent tooling present is `git status`-clean and a stray
key or `.env` can no longer be committed. `.dockerignore` gained the same
coverage but **not** the same syntax: it matches with Go's `filepath.Match`
rules extended with `**`, where `*` does not cross `/` and an unprefixed
pattern is anchored at the context root, so every depth-independent pattern
carries an explicit `**/` prefix and only the genuinely root-anchored entries
(`.git`, `public`, `resources`, `.hugo_build.lock`) go bare. Verified by
planting `.env`, `*.key`, `*.pem` and `node_modules` two directories deep: the
unprefixed form shipped all of them into the image and the `**/` form ships
none. Excluding `.claude/` also takes `worktrees/` — entire additional
checkouts of this repo — out of the build context; #23's two-consecutive-run
proof was re-run against the smaller context, since that issue was validated
against the old one. Note the canonical upstream `REPO_POLICIES.md` is not
clean under this repo's prettier settings, so the reformat is a separate
follow-up commit rather than churn mixed into this one
- 2026-08-09: stopped `script/cibuild` reporting a green it never earned (closes
#23). `COPY . .` is keyed on content, so on an unchanged tree Docker served
`RUN make check` from cache: the checks never executed and the build still
exited 0. Three separate reviewers had already been fooled by it here. The
`Dockerfile` now declares `ARG CHECK_EPOCH` below `COPY . .` with no default
(a default is a constant, and a constant is a stable cache key), guards it
with `RUN [ -n "$CHECK_EPOCH" ] || exit 1`, and expands it into the check
command; `script/cibuild` and `script/docker` both pass
`epoch="$(date +%s%N)$$"` — assigned on its own line, because a failing
command substitution inside an argument does not trip `set -e`, and with `$$`
because busybox `date` drops `%N` silently. This is the canonical shape
settled upstream in `prompts` #26, which has not merged there yet, so it may
need re-syncing. Verified with two consecutive runs on an unchanged tree that
both executed the checks while `RUN script/bootstrap` stayed `CACHED`, a
constant-epoch counterfactual that restored the false green, and a planted
prettier failure that failed the build
- 2026-08-09: disabled the unused `taxonomy` and `term` page kinds in
`hugo.toml` (closes #13). Hugo enables the `tags` and `categories` taxonomies
by default; this single-page site has no taxonomy terms and no taxonomy
templates, so every build emitted
`WARN found no layout file for "html" for kind "taxonomy"` and generated
`categories/index.xml` and `tags/index.xml` that nothing links to. Re-verified
the warning still occurs on the now-pinned hugo v0.164.0 rather than trusting
the issue's text, which predates the version move. `make test` and `make lint`
are now `WARN`-free, so the build's noise floor is zero and the next warning
will be visible. `public/` is otherwise byte-identical — `index.html`,
`css/style.css` and the RSS `index.xml` all unchanged — and `sitemap.xml` is
still generated, now listing only the home page instead of two taxonomy URLs
- 2026-08-09: replaced `hugo.toml`'s deprecated `languageCode` key with `locale`
(closes #18). Hugo deprecated `languageCode` in v0.158.0, so the Hugo pinned
in the preceding commit warns about it; left alone it would become a third
routinely-ignored warning, and a latent breakage when the key is removed.
Deliberately sequenced **after** the Hugo version move and in the same branch:
under the apk hugo 0.139.0 that CI ran until now, `locale` is an unknown key
that is silently ignored, which downgrades the generated RSS from
`<language>en-us</language>` to `<language>en</language>` with no warning and
exit 0. Verified on hugo v0.164.0 that the RSS `<language>` still reads
`en-us`, the `lang` attribute is unchanged, and `public/` is byte-identical to
the preceding commit's output
- 2026-08-09: installed Hugo at a deliberate, hash-verified version instead of
taking whatever alpine ships (closes #26). `script/bootstrap` no longer does
`pkg_install hugo`; it installs `github.com/gohugoio/hugo@v0.164.0` with
`go install`, which verifies the module against `sum.golang.org`. The version
is a commented constant, as is the Go toolchain (`go1.26.5`) — hugo v0.164.0
requires go >= 1.26.0 and alpine 3.21 ships go 1.23.9 with
`GOTOOLCHAIN=local`, so a bare `go install` refuses to run. `CGO_ENABLED=0` is
deliberate: standard Hugo, not extended, because this site has no SCSS, no
`resources.ToCSS`, no PostCSS and no image processing. This moves the build
off apk's hugo 0.139.0, about two years behind, onto the current stable.
Rendered output across the whole `public/` tree is unchanged except the
`meta name=generator` version string
- 2026-08-09: made `script/check` run `script/lint` (closes #9). It previously
ran only `fmt-check` then `test`, so `script/lint` executed nowhere — not in
`make check`, not in the pre-commit hook, and not in CI, even though the
`Dockerfile` runs `make check` and `script/cibuild` builds it. It now runs
`test`, `lint`, `fmt-check` in the canonical order, so the
`hugo --printPathWarnings` render-target-collision signal is no longer
discarded. `README.md`'s Entrypoints line was corrected to match
- 2026-08-09: hash-pinned every external reference in
`.gitea/workflows/deploy.yml` (closes #7): both job container images are
pinned by digest, all three `uses:` are pinned by 40-hex commit SHA, and the
wrangler install is pinned to an exact version. The abandoned
`klakegg/hugo:ext-alpine` image is gone: the build job now runs on the same
pinned `alpine` digest the `Dockerfile` uses, with a pre-checkout
`apk add nodejs git tar` step (the Actions runner needs `node` inside the job
container to execute JavaScript actions), an explicit `shell: sh` default,
then `script/bootstrap` and `script/test`. The `deploy` job is guarded with
`if: github.ref_name == 'main'` so it can never publish from a branch. Also
dropped the dead `feat/initial-site` push trigger and reindented the file to
4-space YAML to match `check.yml`. This is the second attempt; the first broke
the deploy and was reverted, so this one was verified by temporarily
triggering the workflow on the PR branch and iterating until the `build` job
ran green for real
- 2026-07-25: added the scripts-to-rule-them-all scaffold (closes #4): `script/`
entrypoints, `Makefile` shims, a Hugo `Dockerfile` (sha256-pinned alpine) plus
`.dockerignore` that runs `make check`, `.gitea/workflows/check.yml` running
`script/cibuild`, and a README Entrypoints section. `test`/`lint` are a clean
`hugo --minify` build; `fmt`/`fmt-check` run prettier over the repo's own
top-level markdown only
- 2026-02-10: design pass: minimal light theme with inline CSS, grey wells for
mesh channels and signal groups, horizontal overflow fix, body width tuning,
map link update
- 2026-02-10: added README and footer contribute link
- 2026-02-10: added Gitea workflow that builds the site and deploys to
Cloudflare Pages
- 2026-02-08: initial Hugo static site for lora.vegas
# Future Steps
Startable work first. Everything under "Blocked" waits on somebody or something
outside this repo, so nothing there may be picked up as the Next Step.
- Fix the README's SSH-only clone URL, and add the two entrypoints the
Entrypoints section omits, `script/precommit` and `script/projectname`
(https://git.eeqj.de/sneak/lora.vegas/issues/36)
- Drop the Go toolchain and module cache from the check image's final layer;
they are needed to build hugo and dead weight afterwards
(https://git.eeqj.de/sneak/lora.vegas/issues/28)
- Add a timeout guard to `script/test` and `script/lint` so a wedged build fails
instead of hanging (https://git.eeqj.de/sneak/lora.vegas/issues/16)
- Sync the reformat of `REPO_POLICIES.md` back upstream to `prompts` so the
canonical copy is clean under the shared prettier settings and future syncs
are a straight byte copy
- Keep mesh channel and signal group listings current
## Blocked
- Decide whether `script/lint` should fail on render-target collisions rather
than only print them; `--printPathWarnings` exits 0 today, so the signal is
reported and not enforced. Owner call, since it changes what the gate rejects
(https://git.eeqj.de/sneak/lora.vegas/issues/25)
- Move the artifact actions in `.gitea/workflows/deploy.yml` to v4 once this
Gitea Actions instance serves the v4 artifact protocol; they are pinned on the
deprecated v3 line because v4 fails here
(https://git.eeqj.de/sneak/lora.vegas/issues/20). This touches the live deploy
path, so it needs a real workflow run to verify rather than a local check
- Move the deploy container to a pinned node 22 so the wrangler pin can advance
past 4.86.0 (https://git.eeqj.de/sneak/lora.vegas/issues/21)
- Delete the stale remote branches `feat/initial-site` and `security-audit`;
only the owner can remove them
(https://git.eeqj.de/sneak/lora.vegas/issues/15)
- Decide the HSTS `includeSubDomains` and `preload` posture for `lora.vegas`.
Both are owner calls: neither can be walked back inside the max-age window,
and `includeSubDomains` binds hostnames this repo does not control
(https://git.eeqj.de/sneak/lora.vegas/issues/14)
- Verify the Cloudflare Pages deploy still works after the workflow changes

View File

@@ -1,8 +1,16 @@
baseURL = 'https://lora.vegas/' baseURL = 'https://lora.vegas/'
languageCode = 'en-us' locale = 'en-us'
title = 'LoRa Vegas — Las Vegas Meshtastic Community' title = 'LoRa Vegas — Las Vegas Meshtastic Community'
theme = 'loravega' theme = 'loravega'
# This is a single-page site with no taxonomy terms and no taxonomy
# templates. Hugo enables the `tags` and `categories` taxonomies by
# default, so without this it generates taxonomy list pages it has no
# layout for and warns on every build. The `sitemap` kind is left
# enabled: sitemap.xml is still generated, and now lists only the home
# page.
disableKinds = ['taxonomy', 'term']
[markup.goldmark.renderer] [markup.goldmark.renderer]
unsafe = true unsafe = true

175
script/bootstrap Executable file
View File

@@ -0,0 +1,175 @@
#!/bin/sh
# script/bootstrap: install all dependencies needed to build and develop
# this Hugo site, idempotently. Base tooling comes from nix, apt, brew,
# or apk (detected in that order); assumes NOTHING is present (not git,
# make, go, hugo, or node). Installs hugo (the site build, at the exact
# version pinned below) and node/npm (prettier, used to format the
# repo's own markdown docs). Every install is guarded by a check so
# already-installed tools are skipped.
set -eu
ROOT="$(cd "$(dirname "$0")/.." && pwd -P)"
PKGMGR=""
SUDO=""
# --- Hugo -------------------------------------------------------------
#
# Hugo produces the published artifact, so its version is a property of
# the site's output, not of the build environment. It is therefore
# pinned here rather than taken from whatever the distro serves: before
# this, alpine 3.21's apk supplied hugo 0.139.0 -- a version chosen by
# nobody, roughly two years behind upstream, and liable to change
# silently whenever the base image digest moves.
#
# `go install` is the hash-verified mechanism: Go checks the module
# against the sum.golang.org checksum database. That is the mechanism
# REPO_POLICIES.md already names for Go, and it needs no hand-maintained
# sha256. The other tools this script installs stay on the package
# manager, which #19 settled is fine for build-time conveniences.
#
# hugo v0.164.0, 2026-07-06
HUGO_VERSION="v0.164.0"
# hugo v0.164.0's go.mod requires go >= 1.26.0, and alpine 3.21's `go`
# package is 1.23.9 built with GOTOOLCHAIN=local, so a bare `go install`
# refuses to run at all. Naming the toolchain explicitly makes Go fetch
# it through the module proxy and verify it against sum.golang.org like
# any other module, so the chain stays hash-verified end to end -- and
# the Go version that compiles hugo becomes deliberate too, instead of
# being inherited from whatever the base image happens to ship.
# go1.26.5, 2026-08-09
HUGO_GOTOOLCHAIN="go1.26.5"
# Standard hugo, not hugo extended: CGO_ENABLED=0 is deliberate.
# Verified that this site uses nothing extended provides -- there are no
# .scss/.sass files, no resources.ToCSS, no PostCSS, and no image
# processing (.Resize/.Fill/.Fit/images.* are all absent). The CSS is
# plain and inlined by `readFile` in baseof.html. The `+extended` on the
# apk build this replaces was incidental, not a requirement, so do not
# assume a future change needs it without rechecking the above.
HUGO_CGO_ENABLED="0"
# Where the hugo binary lands. It has to be on the default PATH of a
# *fresh* shell, not just of this script: the Dockerfile's `RUN make
# check` and deploy.yml's `script/test` step each start their own shell
# and would never see a GOPATH bin directory. Overridable so an
# unprivileged install can point somewhere writable.
HUGO_BIN_DIR="${HUGO_BIN_DIR:-/usr/local/bin}"
detect_pkgmgr() {
[ -n "$PKGMGR" ] && return 0
if command -v nix-env >/dev/null 2>&1; then
PKGMGR="nix"
elif command -v apt-get >/dev/null 2>&1; then
PKGMGR="apt"
elif command -v brew >/dev/null 2>&1; then
PKGMGR="brew"
elif command -v apk >/dev/null 2>&1; then
PKGMGR="apk"
else
echo "bootstrap: no supported package manager (nix, apt, brew, apk)" >&2
exit 1
fi
if [ "$PKGMGR" = "apt" ]; then
export DEBIAN_FRONTEND=noninteractive
detect_sudo
$SUDO env DEBIAN_FRONTEND=noninteractive apt-get update
fi
}
detect_sudo() {
if [ -z "$SUDO" ] && [ "$(id -u)" != "0" ]; then
SUDO="sudo"
fi
}
# pkg_install <nix-attr> <apt-pkg> <brew-formula> <apk-pkg>
pkg_install() {
detect_pkgmgr
case "$PKGMGR" in
nix) nix-env -iA "nixpkgs.$1" ;;
apt) $SUDO env DEBIAN_FRONTEND=noninteractive apt-get install -y "$2" ;;
brew) brew install "$3" ;;
apk) apk add --no-cache "$4" ;;
esac
}
missing() {
! command -v "$1" >/dev/null 2>&1
}
# True when the hugo already on PATH is the pinned version. Unlike the
# other tools, mere presence is not good enough here: an older hugo has
# to be replaced, not accepted, or the pin means nothing.
hugo_pinned() {
command -v hugo >/dev/null 2>&1 || return 1
# `hugo version` prints e.g. "hugo v0.164.0 linux/amd64 ..." for a
# `go install` build, or "hugo v0.164.0-ce2470e+extended ..." for an
# official release binary. Compare only the vX.Y.Z part: a build of
# the same version that happens to be extended renders this site
# identically (see HUGO_CGO_ENABLED above), so there is no reason to
# overwrite a developer's existing matching install.
have="$(hugo version 2>/dev/null | awk '{print $2}' | sed 's/[-+].*//')"
[ "$have" = "$HUGO_VERSION" ]
}
install_hugo() {
detect_sudo
# The Go toolchain is a build-time convenience like git and make, so
# it comes from the package manager; the thing that must be
# deliberate is what it builds, which HUGO_VERSION and
# HUGO_GOTOOLCHAIN pin.
if missing go; then pkg_install go golang-go go go; fi
# Build as the invoking user into a scratch GOBIN, then place the
# binary with `install`. Running the whole `go install` under sudo
# would work but would populate root's module cache instead of the
# user's, which is needlessly slow and surprising on a workstation.
gobin="$(mktemp -d)"
CGO_ENABLED="$HUGO_CGO_ENABLED" \
GOTOOLCHAIN="$HUGO_GOTOOLCHAIN" \
GOBIN="$gobin" \
go install "github.com/gohugoio/hugo@${HUGO_VERSION}"
$SUDO install -d "$HUGO_BIN_DIR"
$SUDO install -m 0755 "$gobin/hugo" "$HUGO_BIN_DIR/hugo"
rm -rf "$gobin"
# Drop any cached PATH lookup of the hugo we just replaced, so the
# check below tests the new binary and not the old one.
hash -r 2>/dev/null || true
# Fail loudly rather than let a later build run on a shadowing hugo
# from somewhere earlier in PATH.
if ! hugo_pinned; then
echo "bootstrap: installed $HUGO_VERSION into $HUGO_BIN_DIR but" \
"'hugo' on PATH is still $(hugo version 2>/dev/null || echo absent)" >&2
exit 1
fi
}
main() {
cd "$ROOT"
# Base tooling.
if missing git; then pkg_install git git git git; fi
if missing make; then pkg_install gnumake make make make; fi
# The theme is vendored in-repo, but initialise submodules if any
# are ever added so a fresh clone is buildable.
if [ -f .gitmodules ]; then
git submodule update --init --recursive
fi
# Site build. Pinned and hash-verified -- see the HUGO_* constants.
if ! hugo_pinned; then install_hugo; fi
# node/npm provide prettier (via npx) for formatting the docs.
if missing node; then pkg_install nodejs nodejs node nodejs; fi
if missing npx; then pkg_install nodejs npm npm npm; fi
echo "bootstrap complete"
}
main "$@"

24
script/check Executable file
View File

@@ -0,0 +1,24 @@
#!/bin/sh
# script/check: run all checks. Our own extension to
# scripts-to-rule-them-all. Must not modify any tracked files. Runs the
# canonical order: the clean production build, then the lint build that
# reports path warnings, then the read-only formatting check.
#
# The lint - and only the lint - runs inside Docker: it is a build of
# Dockerfile.lint, so this script needs a working docker daemon and has
# no host fallback to drop back to. Budget for the cold case: the first
# lint on a machine with no cached script/bootstrap layer compiles the
# pinned Hugo from source, which takes minutes. That cost falls on the
# pre-commit hook too, since it runs this script. Every later run reuses
# that layer and only the lint step re-executes.
set -eu
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd -P)"
main() {
"$SCRIPT_DIR/test"
"$SCRIPT_DIR/lint"
"$SCRIPT_DIR/fmt-check"
}
main "$@"

49
script/cibuild Executable file
View File

@@ -0,0 +1,49 @@
#!/bin/sh
# script/cibuild: run the CI build. The Gitea workflow runs this on
# push, and it is the single entrypoint that covers everything. Two
# container builds, in order:
#
# 1. script/lint, which builds Dockerfile.lint -- the lint runs as a
# build step there
# 2. the main Dockerfile, which runs the non-lint checks: the clean
# `hugo --minify` production build (script/test) and the read-only
# prettier check (script/fmt-check)
#
# Lint goes first, for fail-fast feedback: on a runner with no cached
# script/bootstrap layer the main image compiles Hugo from source, and a
# lint failure should not wait behind that. It is a separate build
# rather than a step inside the main image because script/lint is itself
# a `docker build`, and a docker build cannot run a docker build. See
# Dockerfile.lint for the full reasoning.
#
# The lint is delegated to the same script a developer runs, so CI
# cannot drift from `make check`.
#
# Neither build implies a passing check without CHECK_EPOCH. Docker keys
# the check layers on content, so on an unchanged tree they are served
# from cache: nothing executes and the build still exits 0. Passing a
# value that differs on every invocation invalidates those layers and
# everything below them, while the script/bootstrap toolchain layer
# above keeps caching. script/lint does the same for its own build.
set -eu
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd -P)"
ROOT="$(cd "$SCRIPT_DIR/.." && pwd -P)"
main() {
cd "$ROOT"
"$SCRIPT_DIR/lint"
# Assigned to a variable rather than substituted inline in the
# argument list: a command substitution that fails inside an
# argument does not trip `set -e`, so the inline form would quietly
# pass an empty string and restore the cached false green. As the
# whole of an assignment its exit status is the command's, so
# `set -e` catches it. `%N` keeps two invocations within the same
# second distinct; busybox date silently drops `%N` and still exits
# 0, so `$$` is appended to cover that degradation.
epoch="$(date +%s%N)$$"
docker build --build-arg CHECK_EPOCH="$epoch" .
}
main "$@"

21
script/docker Executable file
View File

@@ -0,0 +1,21 @@
#!/bin/sh
# script/docker: build the Docker image tagged with the project name.
# The tag comes from script/projectname.
#
# The Dockerfile requires the CHECK_EPOCH build argument, generated here
# exactly as script/cibuild generates it: see that script for why the
# check layer must not be allowed to cache, and why the value is built
# in an assignment rather than inline.
set -eu
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd -P)"
ROOT="$(cd "$SCRIPT_DIR/.." && pwd -P)"
main() {
cd "$ROOT"
epoch="$(date +%s%N)$$"
docker build --build-arg CHECK_EPOCH="$epoch" \
-t "$("$SCRIPT_DIR/projectname")" .
}
main "$@"

26
script/fmt Executable file
View File

@@ -0,0 +1,26 @@
#!/bin/sh
# script/fmt: format this repo's markdown and CSS with prettier, using
# our standard settings. Scope is every markdown and CSS file in the
# tree, per REPO_POLICIES.md ("prettier for JS/CSS/Markdown/HTML").
# What is deliberately NOT covered is recorded in .prettierignore, with
# the reason next to each entry: the Hugo layout templates, which are
# Go templates and not HTML, and content/, whose reformatting was
# measured to change the rendered page.
#
# Both prettier entrypoints run on the host: only linting is
# containerised (owner ruling, 2026-08-10), and formatting is not a
# lint. Keep the version, scope and flags here in sync with
# script/fmt-check.
set -eu
ROOT="$(cd "$(dirname "$0")/.." && pwd -P)"
PRETTIER_VERSION="3.4.2"
main() {
cd "$ROOT"
npx --yes "prettier@${PRETTIER_VERSION}" --write \
'**/*.md' '**/*.css' --tab-width 4 --prose-wrap always
}
main "$@"

22
script/fmt-check Executable file
View File

@@ -0,0 +1,22 @@
#!/bin/sh
# script/fmt-check: check the formatting of this repo's markdown and
# CSS (read-only). Same scope and same settings as script/fmt - keep
# the two in sync - but fails instead of writing.
#
# This runs on the host, not in a container: only linting is
# containerised (owner ruling, 2026-08-10), and a formatting check is
# not a lint. Running here also keeps it usable offline, since npx
# reuses ~/.npm/_npx after the first run.
set -eu
ROOT="$(cd "$(dirname "$0")/.." && pwd -P)"
PRETTIER_VERSION="3.4.2"
main() {
cd "$ROOT"
npx --yes "prettier@${PRETTIER_VERSION}" --check \
'**/*.md' '**/*.css' --tab-width 4 --prose-wrap always
}
main "$@"

15
script/install-precommit Executable file
View File

@@ -0,0 +1,15 @@
#!/bin/sh
# script/install-precommit: install the git pre-commit hook that runs
# script/precommit. Our own extension to scripts-to-rule-them-all.
set -eu
ROOT="$(cd "$(dirname "$0")/.." && pwd -P)"
main() {
cd "$ROOT"
printf '#!/bin/sh\nset -e\nscript/precommit\n' > .git/hooks/pre-commit
chmod +x .git/hooks/pre-commit
echo "pre-commit hook installed: runs script/precommit"
}
main "$@"

40
script/lint Executable file
View File

@@ -0,0 +1,40 @@
#!/bin/sh
# script/lint: run the lint. This Hugo site has no dedicated linter, so
# the lint gate is a clean build that surfaces broken internal links and
# template path problems -- but where it runs is not negotiable: every
# lint run happens inside a Docker container, so this script does
# nothing except build Dockerfile.lint. The lint is a build step there,
# so a successful build is a clean lint. There is deliberately no host
# fallback and no "already inside a container?" branch: either would be
# a host lint path wearing a disguise.
#
# No --target: Dockerfile.lint has exactly one stage, so the whole-file
# build IS the lint. See that file for why a second, sibling stage would
# be a silent skip.
#
# Dockerfile.lint requires the CHECK_EPOCH build argument, generated
# here exactly as script/cibuild generates it -- see that script for why
# the lint layer must not be allowed to cache, and why the value is
# built in an assignment rather than inline.
set -eu
ROOT="$(cd "$(dirname "$0")/.." && pwd -P)"
main() {
cd "$ROOT"
epoch="$(date +%s%N)$$"
# --output type=cacheonly: this build is run for its exit status,
# not for an image. Because the lint layer is cache-busted on every
# invocation the result is a new image every time, and an untagged
# build would leave one dangling image per lint run on a host shared
# with other work. cacheonly keeps the build cache (so
# script/bootstrap still hits) and exports nothing. Failures still
# propagate: an empty CHECK_EPOCH or a failing lint exits non-zero.
docker build \
--build-arg CHECK_EPOCH="$epoch" \
--output type=cacheonly \
-f Dockerfile.lint \
.
}
main "$@"

12
script/precommit Executable file
View File

@@ -0,0 +1,12 @@
#!/bin/sh
# script/precommit: run by the git pre-commit hook; fails the commit if
# checks fail. Our own extension to scripts-to-rule-them-all.
set -eu
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd -P)"
main() {
"$SCRIPT_DIR/check"
}
main "$@"

12
script/projectname Executable file
View File

@@ -0,0 +1,12 @@
#!/bin/sh
# script/projectname: output the name of this project. Our own
# extension to scripts-to-rule-them-all. Other scripts that need the
# name (e.g. script/docker) call this, so they can stay identical
# across all repos.
set -eu
main() {
echo "lora.vegas"
}
main "$@"

13
script/setup Executable file
View File

@@ -0,0 +1,13 @@
#!/bin/sh
# script/setup: set up the repo for development after a fresh clone:
# installs dependencies (script/bootstrap) and the git pre-commit hook.
set -eu
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd -P)"
main() {
"$SCRIPT_DIR/bootstrap"
"$SCRIPT_DIR/install-precommit"
}
main "$@"

14
script/test Executable file
View File

@@ -0,0 +1,14 @@
#!/bin/sh
# script/test: the correctness check for this static site is a clean
# production build. `hugo --minify` exits non-zero on any template,
# content, or config error, so a green build is a passing test.
set -eu
ROOT="$(cd "$(dirname "$0")/.." && pwd -P)"
main() {
cd "$ROOT"
hugo --minify
}
main "$@"

42
static/_headers Normal file
View File

@@ -0,0 +1,42 @@
# Cloudflare Pages response headers.
#
# Hugo copies static/ verbatim into public/, so this file lands at the
# deploy output root, which is where Pages reads it from. Pages consumes
# the file rather than publishing it. Values here override what
# Cloudflare would otherwise send.
#
# Every value below was checked against the built public/index.html, not
# copied from a template. That page loads nothing: no script, img, link,
# iframe, form, video, audio, object or embed element, no style= or on*=
# attribute. It has exactly one inline <style> block, which
# themes/loravega/layouts/_default/baseof.html fills with the whole of
# themes/loravega/static/css/style.css via readFile. That inlining is a
# deliberate theme design choice, and it is the sole reason style-src
# needs 'unsafe-inline'.
#
# img-src 'self' is kept even though the page has no images. Browsers
# request /favicon.ico unprompted and that fetch is governed by img-src;
# measured in Chrome, with this allowance the request is made and 404s,
# and without it the request is suppressed outright. Neither hurts
# today, but same-origin images are the one resource class this site
# would plausibly grow, and 'self' loosens nothing cross-origin.
#
# X-Content-Type-Options and Referrer-Policy are already sent by
# Cloudflare by default and are restated here on purpose. They are a
# default, not a guarantee, and this file is where the site's header
# posture is declared.
#
# Strict-Transport-Security deliberately carries neither preload nor
# includeSubDomains. preload is effectively irreversible and is the
# owner's call. includeSubDomains would bind every hostname under
# lora.vegas for a year, and it buys nothing today: www.lora.vegas is
# the only other name in DNS, it is served by this same Pages project,
# so this block sets HSTS on its responses directly.
/*
Strict-Transport-Security: max-age=31536000
X-Content-Type-Options: nosniff
Referrer-Policy: strict-origin-when-cross-origin
X-Frame-Options: DENY
Permissions-Policy: geolocation=(), microphone=(), camera=()
Content-Security-Policy: default-src 'none'; style-src 'unsafe-inline'; img-src 'self'; form-action 'none'; frame-ancestors 'none'; base-uri 'none'

View File

@@ -12,6 +12,7 @@
<body> <body>
{{ block "main" . }}{{ end }} {{ block "main" . }}{{ end }}
<footer> <footer>
<p>this site is a project by <a href="https://sneak.berlin">@sneak</a>.</p>
<p>lora.vegas &mdash; Las Vegas Meshtastic community <a href="https://git.eeqj.de/sneak/lora.vegas" class="contribute-link">[Contribute]</a></p> <p>lora.vegas &mdash; Las Vegas Meshtastic community <a href="https://git.eeqj.de/sneak/lora.vegas" class="contribute-link">[Contribute]</a></p>
</footer> </footer>
</body> </body>

View File

@@ -6,10 +6,15 @@
--max-w: 600px; --max-w: 600px;
} }
* { margin: 0; padding: 0; box-sizing: border-box; } * {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body { body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto,
"Helvetica Neue", Arial, sans-serif;
background: var(--bg); background: var(--bg);
color: var(--fg); color: var(--fg);
line-height: 1.6; line-height: 1.6;