Compare commits

..

29 Commits

Author SHA1 Message Date
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
27 changed files with 1244 additions and 57 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/

2
.prettierignore Normal file
View File

@@ -0,0 +1,2 @@
node_modules/
yarn.lock

4
.prettierrc Normal file
View File

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

45
Dockerfile Normal file
View File

@@ -0,0 +1,45 @@
# Hugo static-site build image. The build runs `make check` (a clean
# `hugo --minify` production build, the `--printPathWarnings` lint
# build, then the read-only prettier docs check), so the image build
# fails on any formatting or Hugo build error. This is what CI
# (script/cibuild) runs on every push.
#
# 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
# Run all checks - build fails if any check fails.
RUN echo "check epoch: ${CHECK_EPOCH}" && make check

31
Makefile Normal file
View File

@@ -0,0 +1,31 @@
.PHONY: bootstrap setup test lint fmt fmt-check check docker 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
hooks:
@script/install-precommit
serve:
@hugo server

View File

@@ -4,7 +4,8 @@ Las Vegas Meshtastic and LoRa community website.
## About ## About
This site provides information about the Las Vegas mesh networking community, including: This site provides information about the Las Vegas mesh networking community,
including:
- Mesh channel configurations - Mesh channel configurations
- Community coordination (Discord, Signal) - Community coordination (Discord, Signal)
@@ -13,11 +14,13 @@ This site provides information about the Las Vegas mesh networking community, in
## Contributing ## Contributing
To contribute to this site, contact **sneak@sneak.berlin** for git repository access. To contribute to this site, contact **sneak@sneak.berlin** for git repository
access.
## Technical Details ## Technical Details
This is a static site built with Hugo. The site is deployed automatically via GitHub Actions. This is a static site built with Hugo. The site is deployed automatically via
GitHub Actions.
### Local Development ### Local Development
@@ -35,6 +38,40 @@ hugo
Output will be in the `public/` directory. Output will be in the `public/` directory.
## 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
- `script/fmt` — format the repo's own top-level markdown docs with prettier
- `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; the Dockerfile runs `make check`
- `script/install-precommit` — install the git pre-commit hook that runs
`script/check`
Build the image through `script/cibuild` or `script/docker` only. Both pass a
per-invocation `CHECK_EPOCH` build argument that the Dockerfile requires, so the
`make 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 Dockerfile's
`CHECK_EPOCH` guard rather than caching its way to a false success.
A convenience `make serve` target runs `hugo server` for local preview.
## License ## License
Content is provided as-is for community use. Content is provided as-is for community use.

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`

152
TODO.md Normal file
View File

@@ -0,0 +1,152 @@
# 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`) and the canonical policy
dotfiles; `LICENSE` is the only mandated file still missing. 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.
# Next Step
Add `LICENSE` (#10) and replace the README's "content is provided as-is" note
with the committed license. Blocked on the owner's choice of license — the
remaining policy scaffold is otherwise complete.
# Completed Steps
- 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
- Move the artifact actions to v4 once this Gitea Actions instance serves the v4
artifact protocol; they are pinned on the deprecated v3 line because v4 fails
here (#20)
- Move the deploy container to a pinned node 22 so the wrangler pin can advance
past 4.86.0 (#21)
- Rework README.md into the standard sections: Description, Getting Started,
Rationale, Design, TODO, License, Author (currently About, Contributing,
Technical Details, License)
- 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
- Verify the Cloudflare Pages deploy still works after the workflow changes
- Keep mesh channel and signal group listings current

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 "$@"

16
script/check Executable file
View File

@@ -0,0 +1,16 @@
#!/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.
set -eu
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd -P)"
main() {
"$SCRIPT_DIR/test"
"$SCRIPT_DIR/lint"
"$SCRIPT_DIR/fmt-check"
}
main "$@"

30
script/cibuild Executable file
View File

@@ -0,0 +1,30 @@
#!/bin/sh
# script/cibuild: run the CI build. The Dockerfile runs `make check`,
# so a successful build implies all checks pass. The Gitea workflow
# runs this on push.
#
# That implication only holds because of CHECK_EPOCH. Docker keys the
# `RUN make check` layer on content, so on an unchanged tree it is
# served from cache: the checks never execute and the build still exits
# 0. Passing a value that differs on every invocation invalidates that
# layer and everything below it, while the script/bootstrap toolchain
# layer above it keeps caching.
set -eu
ROOT="$(cd "$(dirname "$0")/.." && pwd -P)"
main() {
cd "$ROOT"
# 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 "$@"

18
script/fmt Executable file
View File

@@ -0,0 +1,18 @@
#!/bin/sh
# script/fmt: format the repo's own top-level markdown docs (README.md,
# TODO.md, ...) with prettier, using our standard settings. Scope is
# deliberately limited to top-level docs: site content under content/
# is left untouched so rendered output cannot change.
set -eu
ROOT="$(cd "$(dirname "$0")/.." && pwd -P)"
PRETTIER_VERSION="3.4.2"
main() {
cd "$ROOT"
npx --yes "prettier@${PRETTIER_VERSION}" --write \
'*.md' --tab-width 4 --prose-wrap always
}
main "$@"

17
script/fmt-check Executable file
View File

@@ -0,0 +1,17 @@
#!/bin/sh
# script/fmt-check: check the formatting of the repo's own top-level
# markdown docs (read-only). Same scope as script/fmt, but fails
# instead of writing.
set -eu
ROOT="$(cd "$(dirname "$0")/.." && pwd -P)"
PRETTIER_VERSION="3.4.2"
main() {
cd "$ROOT"
npx --yes "prettier@${PRETTIER_VERSION}" --check \
'*.md' --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 "$@"

15
script/lint Executable file
View File

@@ -0,0 +1,15 @@
#!/bin/sh
# script/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. It is a real check: `hugo` fails on build errors, and
# --printPathWarnings reports render-target collisions.
set -eu
ROOT="$(cd "$(dirname "$0")/.." && pwd -P)"
main() {
cd "$ROOT"
hugo --minify --printPathWarnings
}
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 "$@"

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>