Compare commits

...
2 Commits
Author SHA1 Message Date
notplantsandClaude Opus 4.8 52b59bbe00 docs(skills): add codeberg-pages site-publishing skill
Skill covering publishing a static site to Codeberg Pages, including
custom domains on the new git-pages server: pages branch, A/AAAA + CNAME
DNS, the _git-pages-repository TXT authorization record, per-domain
deploy webhooks (http:// for first deploy), Let's Encrypt issuance, and
the obsolete .domains file. Verified against docs.codeberg.org.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017k4W786WWd8yN1m4Ypuxzx
2026-08-01 21:58:47 +00:00
notplantsandClaude ef85d40a63 feat(secrets): path-bound secrets are symlinks into /secrets/files
A secret a third party reads from a fixed path (ssh key, systemd EnvironmentFile, nix
authKeyFile, TLS keypair) now lives ONCE as a real file in /secrets/files and is symlinked
from where the consumer expects it. The consumer is unchanged and unaware; the file exists
in one directory, at 0600, outside /srv and outside every git tree.

That makes the store and the file directory alternatives, not layers: a secret is a value in
store.yaml OR a file in /secrets/files, never both. The copies of the ssh keys, tailscale
auth key, incus keypair, LE cert and cc-ci testenv have been dropped from store.yaml now that
each has a single home.

Documented exception: an app that rewrites its own credential file (OAuth refresh via
write-temp+rename) replaces the symlink with a regular file and silently re-splits the home.
opencode's auth.json is one, so it stays put and is deliberately not centralised.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-01 17:06:18 +00:00
3 changed files with 184 additions and 27 deletions
+27 -13
View File
@@ -385,23 +385,37 @@ python3 engine/secrets.py materialize tangled-session # write a runtime file f
sops /secrets/store.yaml # add/edit: decrypts to $EDITOR, re-encrypts on save
```
**No second copies.** A secret must never be written to a second file "so something can read
it" — copies drift from the store, get committed, and widen what a stray `grep` or an attacker
finds. Our own code imports this module. Anything else gets the value at **run time**:
**One home per secret — two shapes.**
```sh
# a group as environment variables — nothing touches the disk
python3 engine/secrets.py exec-env cc_ci_testenv -- some-command
*Values our code reads* live **in the store**; import this module and ask for them. Nothing is
written to disk (`engine/.tangled-session` is gone — the tangled tools read `tangled.cookie`).
# a consumer that insists on a path: 0600 file in a private tmpdir, deleted when the command exits
python3 engine/secrets.py with-file ssh_keys.tangled-ed25519 -- ssh -i {} host
*Secrets a third party reads from a fixed path* (ssh keys, a systemd `EnvironmentFile`, nix's
`authKeyFile`, a TLS keypair) live as **real files in `/secrets/files/`, symlinked from the path
the consumer expects**:
```
~/.ssh/tangled-ed25519 -> /secrets/files/tangled-ed25519
/etc/ts-auth-key -> /secrets/files/ts-auth-key
/srv/cc-ci/.testenv -> /secrets/files/cc-ci.testenv
```
For **systemd**, wrap `ExecStart` in `exec-env` rather than using an `EnvironmentFile`: same
effect, no plaintext at rest. The genuine exceptions are OS-level paths that are read before any
of this exists — nix's `authKeyFile`, sshd host keys, and ssh client keys used by bare `git push`.
Those stay where the OS expects them; do not also copy them into the store, or you have two
sources of truth again.
The consumer is unchanged and unaware; the file exists once, in one directory, at 0600. Do **not**
also copy such a secret into `store.yaml` — that is two sources of truth again.
For a one-off where neither shape fits, inject at run time and leave nothing behind:
```sh
python3 engine/secrets.py exec-env <group> -- some-command # group as env vars
python3 engine/secrets.py with-file <group.key> -- cmd -i {} # 0600 file in a private
# tmpdir, deleted on exit
```
**The symlink exception: apps that rewrite their own credential file.** An app that refreshes an
OAuth token by writing `auth.json` atomically (write-temp + rename) **replaces the symlink with a
regular file**, silently splitting the home again. `~/.local/share/opencode/auth.json` is such a
file, so it stays where it is and is deliberately *not* centralised. Before symlinking a secret,
ask whether its owner ever writes it back.
**Rules of thumb**
+19 -14
View File
@@ -6,9 +6,15 @@ remote URLs (`https://user:pass@host/...`, which `git remote -v` happily prints)
in .env files, a private key at mode 0644. Anything in a repo is one `git add -A` away from
a push. So: ONE encrypted file, OUTSIDE every git tree, and a helper every project uses.
store: /secrets/store.yaml sops+age ciphertext, mode 0600
age key: ~/.config/sops/age/keys.txt the ONLY plaintext secret on disk, 0600
outside git by construction — /secrets is not a repo and has no remote.
/secrets/store.yaml sops+age ciphertext (0600) — values our code reads
/secrets/files/ real files (0600) SYMLINKED from the fixed path a third
party insists on: ~/.ssh keys, a systemd EnvironmentFile,
nix authKeyFile, a TLS keypair
~/.config/sops/age/keys.txt the age private key, 0600
One home per secret: a value is in the store OR a file in /secrets/files, never both.
/secrets is outside every git tree — not a repo, no remote — and outside /srv, which agents
grep and walk constantly.
USAGE (library):
from secrets import get, get_group
@@ -18,20 +24,19 @@ USAGE (library):
USAGE (CLI):
python3 engine/secrets.py list # group/key names only, never values
python3 engine/secrets.py get tangled.cookie # value to stdout (careful in logs)
python3 engine/secrets.py materialize <name> # write a runtime file a consumer needs
NO SECOND COPIES. A secret must not be written to a second file "so something can read it"
copies drift from the store, get committed, and multiply what an attacker (or a careless
`grep`) can find. Consumers read the store: our own code imports this module; anything else
gets the value injected at RUN TIME and nothing is left at rest.
NO SECOND COPIES. A secret is never written to a second file "so something can read it"
copies drift, get committed, and widen what a stray `grep` or an attacker finds. A consumer
that insists on a path gets a SYMLINK into /secrets/files (see above), so the file still
exists exactly once. For a one-off, inject at run time and leave nothing behind:
secrets.py exec-env cc_ci_testenv -- some-command # group as env vars, no file
secrets.py with-file ssh_keys.tangled-ed25519 -- ssh -i {} host # 0600 file in a private
# tmpdir, deleted when the command exits
secrets.py exec-env <group> -- some-command # group as env vars, no file
secrets.py with-file <group.key> -- cmd -i {} # 0600 file in a private tmpdir,
# deleted when the command exits
For systemd, wrap ExecStart in `exec-env` instead of using an EnvironmentFile — same effect,
no plaintext on disk. The few OS-level paths that genuinely cannot be taught this (nix's
`authKeyFile`, sshd host keys) are the exception, and are noted in engine/README.md.
Careful with symlinks: an app that rewrites its own credential file (an OAuth refresh writing
auth.json via write-temp+rename) REPLACES the symlink with a regular file and silently splits
the home again. Before symlinking, ask whether the owner ever writes it back.
ADDING A SECRET: sops /secrets/store.yaml (opens decrypted in $EDITOR, re-encrypts on save)
"""
+138
View File
@@ -0,0 +1,138 @@
---
name: codeberg-pages
description: >-
Publish a static site to Codeberg Pages, including custom domains on the new
"git-pages" server. Use when deploying a site to Codeberg Pages, setting up or
debugging a codeberg.page / custom-domain deployment, wiring the DNS records
(A/AAAA, CNAME), the _git-pages-repository TXT authorization record, or the
deploy webhook — or when a custom domain serves a TLS error / never gets a
certificate. Covers the 2025→2026 migration off the old Pages Server v2
(.domains file) to git-pages (webhook + TXT authorization).
---
# Publishing a site to Codeberg Pages
Codeberg Pages migrated from the old **Pages Server v2** (automatic deploy,
`.domains` file) to the new **git-pages** server. On git-pages a deployment is
**webhook-triggered** and a custom domain is authorized by a **TXT record**, not
by a file in the repo. If you're following older docs or a `.domains`-based
`deploy.sh`, that's why things silently don't work.
> All values below were verified against the official docs
> (<https://docs.codeberg.org/codeberg-pages/> and `.../using-custom-domain/`).
> Codeberg changes these; re-check the docs if something behaves unexpectedly.
## The mental model
1. Static site content lives on a branch named **`pages`** (per-repo site) — push
your built site there.
2. On git-pages, pushing alone does **not** deploy. A **webhook** on the repo,
pointed at the domain you want, is what triggers a deployment.
3. A custom domain is bound to the repo by a **TXT authorization record**
(`_git-pages-repository.<domain>`) plus the normal A/AAAA/CNAME records.
4. TLS (Let's Encrypt) is issued **only after the first successful deployment**.
Before that, browsers show a TLS error — that is expected, not a bug.
## Basic deploy (no custom domain, `*.codeberg.page`)
- Put the site on a `pages` branch and push it.
- Add a webhook: repo **Settings → Webhooks → Forgejo**, Target URL
`https://<username>.codeberg.page/<repository>/`, **Branch filter: `pages`**.
- (User/org site: name the repo `pages` and use Target URL
`https://<username>.codeberg.page/`.)
## Custom domain setup (git-pages)
Do all four. Missing #2 or #3 is the usual cause of "DNS looks right but the site
won't serve / no certificate."
### 1. DNS: point the domain at Codeberg
Exact values (verify against the docs — Codeberg has changed IPs before):
- **Apex domain** (`example.org`):
- `A``217.197.84.141`
- `AAAA``2a0a:4580:103f:c0de::2`
- **Subdomain** (`www.example.org`, `foo.example.org`):
- `CNAME``codeberg.page.`**note the trailing dot.**
**Trailing-dot trap:** in a zone file / most DNS UIs, a CNAME target *without* a
trailing dot is treated as relative and the zone is appended — e.g. entering
`codeberg.page` (or an old `<user>.codeberg.page`) can resolve to
`codeberg.page.example.org.`, which is broken. Always use the fully-qualified
`codeberg.page.` with the dot. (ALIAS/ANAME works where CNAME isn't allowed, but
conflicts with DNSSEC-signed zones.)
### 2. TXT authorization record (this is how git-pages maps domain → repo)
Create one **per domain** you serve:
```
_git-pages-repository.example.org. TXT "https://codeberg.org/<user>/<repo>.git"
```
- Name: the `_git-pages-repository.` prefix on the exact domain (including each
subdomain you serve — apex and `www` each need their own if both are used).
- Value: the **HTTPS clone URL** of the repo, ending in `.git`.
- (If you deploy via **Forgejo Actions** instead of a webhook, the record is
`_git-pages-forge-allowlist.<domain>` with the same clone-URL value.)
### 3. Deploy webhook (per domain)
Repo **Settings → Webhooks → Forgejo**:
- **Target URL:** the domain itself, and **`http://` (not `https://`) for the
first deployment** — this is documented, not a mistake (the cert doesn't exist
yet). One webhook per domain, e.g. `http://example.org`, `http://foo.example.org`.
- **Branch filter:** `pages`.
- After the first successful deploy and cert issuance, you may switch the Target
URLs to `https://`.
### 4. Trigger the first deploy
**Push to the `pages` branch** (re-run your deploy script / `git push origin pages`).
The push fires the webhook, git-pages pulls and deploys, then requests a
Let's Encrypt certificate.
- **Do NOT rely on the webhook "Test delivery" button** — the official docs say it
fails by design and is not a valid way to verify or trigger a deploy. Verify by
pushing and then checking the webhook's recent-deliveries log, or just load the
site. (This corrects a common misconception that "Test delivery" triggers a deploy.)
## The `.domains` file is obsolete
Under the old Pages Server v2, a `.domains` file in the branch listed the domains
and did apex-vs-alias redirects. On git-pages it is **no longer used** — authorization
comes from the TXT record. It's harmless to leave, but you can delete it (and drop any
`.domains` handling from `deploy.sh`). Bonus: on git-pages each domain gets its **own**
deployment, so a second domain serves the site directly instead of 301-redirecting to
the primary as the old `.domains` system did.
## TLS / certificate notes
- A cert is issued **only after the first successful webhook deployment**. A TLS
error before that is expected.
- If the domain has **CAA records**, they must allow Let's Encrypt (including the
staging issuer) or the cert request is refused.
- Cert still never issues after a successful deploy → confirm the `_git-pages-repository`
TXT value exactly matches the repo's HTTPS `.git` URL, and that the webhook Target
URL matches the domain.
## Quick troubleshooting checklist
- Browser TLS error, no cert → no successful deploy yet. Check webhook deliveries;
push to `pages`; confirm webhook Target URL used `http://` for the first deploy.
- "DNS is correct but site won't serve" → missing `_git-pages-repository` TXT, or
missing/mis-branch-filtered webhook.
- CNAME resolves to `codeberg.page.<yourzone>` → missing trailing dot; set target to
`codeberg.page.`.
- CAA present → ensure Let's Encrypt is allowed.
- Old `.domains` behavior expected (redirects) → gone on git-pages; each domain now
deploys independently.
## Sources
- Codeberg Pages: <https://docs.codeberg.org/codeberg-pages/>
- Using custom domains: <https://docs.codeberg.org/codeberg-pages/using-custom-domain/>
- pages-server (now in maintenance, superseded by git-pages):
<https://codeberg.org/Codeberg/pages-server>