Generic host/repo/branch-aware CD on adnanh/webhook

Replaces the per-project deploy scripts and self-contained webhook
receivers with one manifest-driven implementation that lives outside the
application repositories and can be updated independently of them.

First targets: domaindingo test and prod on s5.
This commit is contained in:
fisher
2026-08-23 07:49:52 +00:00
commit 3a6fafb5c7
22 changed files with 1872 additions and 0 deletions
@@ -0,0 +1,57 @@
# Gitea workflow
## Branch flow
`main``test``dev` → feature branches.
`test` is the default branch, protected, merge-only.
For a brand-new empty repo: create `test` from `main`, then `dev` from `test`.
## Posting with `tea`
When posting to Gitea with `tea` (issues, comments, PRs):
- Run it in the **foreground**.
- **Use the right body argument** — there is no `--body` flag and no
read-from-file option:
- `tea issues create` / `tea pulls create`: body is `--description` / `-d`
- `tea comment <index> [<body>]`: body is a **positional** argument (no flag)
- **Never put `\n` escape sequences in the body.** `tea` stores the string
verbatim — it does not interpret escapes, so any `\n` you pass appears
*literally* in the posted text. The body must already contain real newlines:
write it to a file (the Write tool, or `printf` — never `echo "...\n..."`),
then pass it by command substitution, e.g. `-d "$(cat body.md)"` or
`tea comment 42 "$(cat body.md)"`. Inline heredocs and `\n`-laden argument
strings are what cause the literal `\n`.
- **Confirm it actually posted** — check the exit code *and* re-read the created
item; verify the body renders with real line breaks, not literal `\n`. `tea`
can fail silently.
- **Write subcommands hang on inherited stdin.** Even with the body passed as an
argument, `tea` (`comment`, `issues edit`, `issues close`, `pulls create`, …)
reads stdin; in a non-TTY shell the inherited stdin never sends EOF, so the
command blocks until it is killed. **Fix: redirect stdin — append `</dev/null`**
to every `tea` *write* subcommand (e.g. `tea comment 42 "$(cat body.md)"
</dev/null`). Plain reads are unaffected.
- `--repo` is optional inside the repo — it is not the fix for a silent failure.
## Reading and editing with `tea` (prefer `tea` over the API)
`tea` covers every issue/PR operation this workflow needs — there is **no
capability gap** versus the raw Gitea API, so reach for `tea` first and drop to
`curl` on `/api/v1/...` only if a genuinely new gap appears. Recent findings that
correct earlier assumptions:
- **Show a full issue/PR body:** `tea issue <n>` (note: `tea issues view <n>`
does *not* print the full body).
- **Show comments:** `tea issue <n> --comments`. Comments are hidden by default,
but this flag renders them — you do **not** need the API to read comments.
- **Edit an existing issue body:** `tea issues edit <n> -d "$(cat body.md)"
</dev/null`. `-d`/`--description` does a **full-body replace**. This subcommand
exists — earlier notes claiming `tea` could not edit bodies (and that the API
was required) were wrong. `tea issues edit` also sets title/labels/milestone/
assignees.
- The `curl` fallback has its own friction (the shell's proxy can mangle piped
`curl` output — write to a file then parse; comment POSTs need a
`{"body": "..."}` wrapper) and offers no capability upside, so it is a last
resort, not the default.
@@ -0,0 +1,110 @@
# Gitea → GitHub Release Distribution
Pattern for projects where **development lives privately in Gitea** and **public
releases ship to a separate GitHub repo**. GitHub only ever sees curated,
redacted release snapshots — a clean tagged history, **no Gitea history carried
over**. Repo/owner/package names below are placeholders; substitute per project.
## Placeholders
| Placeholder | Meaning |
| -------------- | ---------------------------------------------------- |
| `$DEV_REMOTE` | Gitea dev repo (`https://gitea.example/o/p.git`) |
| `$REL_REPO` | Local release git folder (publish staging repo) |
| `$REL_REMOTE` | Optional GitHub remote on `$REL_REPO`, added later |
| `$RELEASE_REF` | Gitea commit/branch/tag to cut from (e.g. `main`) |
| `$VERSION` | Version string from package metadata |
The release destination is a **local git folder** (`$REL_REPO`). Cutting a
release needs no remote, username, or password. Wiring `$REL_REMOTE` (GitHub)
and pushing is an optional later step, done once the public repo exists.
## Principles
1. **Two repos, two histories.** Never wired as upstream/downstream. Gitea is
the source of truth; GitHub sees only release snapshots.
2. **Allow-list, never deny-list.** Build the release tree from an explicit list
of paths to *include*. A blanket worktree copy (minus excludes) is how
secrets leak — forbidden. Anything not listed does not ship.
3. **Runtime-only minimal payload.** Ship what's needed to install and run:
source package (incl. bundled `locale/`, templates, assets), packaging
manifest (`pyproject.toml`/equiv), `README.md`, `LICENSE`, and *sanitized*
`*.example.*` config/service files. No tests, no CI by default.
4. **Redact real deployment data.** Real configs (hosts, channels, accounts,
credentials, customer data) must never reach a public repo. Treat any
non-example config as private until proven otherwise.
5. **Clean history.** One tagged commit (`v$VERSION`) per release; release diffs
visible on GitHub, Gitea dev history not.
6. **Deliberate and manual.** Cut from a known-good `$RELEASE_REF` by running a
checked-in script by hand. No push-on-commit.
Do not ship: internal docs, planning/spec material, agent/tooling instructions
(`AGENTS.md`, `CLAUDE.md`, copilot/agent dirs), ai-context notes, Gitea/runner
ops docs. (A Gitea self-hosted-runner workflow is meaningless on GitHub Actions —
if you want CI public, rewrite it GitHub-native, don't copy.)
## SSH auth (when the push is wired up)
Use a per-project SSH host alias backed by a dedicated deploy key in
`~/.ssh/config`:
```sshconfig
Host github-<project>-public
HostName github.com
User git
IdentityFile ~/.ssh/id_ed25519_<project>-public
IdentitiesOnly yes
```
Remote becomes `github-<project>-public:OWNER/REPO.git` — no URL/user/password at
release time, key scoped to one repo, `IdentitiesOnly yes` stops SSH offering
unrelated keys. `IdentityFile` may point at the `.pub` when the private key is in
`ssh-agent` (agent signs); otherwise point at the private key. The release script
should check the alias exists before pushing, failing early with guidance.
Once the alias and deploy key are in place for the project, remove this note's
entry from `docs/active-context.md` — the setup is one-time and shouldn't keep
occupying agent context.
## Release procedure
Keep `$REL_REPO` as a separate local folder from the dev clone. Each release:
1. Pick + verify `$RELEASE_REF` in Gitea (tests/CI green).
2. Read `$VERSION` from package metadata.
3. Rebuild `$REL_REPO`'s tracked content from the allow-list at `$RELEASE_REF`
via `git archive` (committed files only).
4. Commit as a single `Release $VERSION`, tag `v$VERSION` — all local.
5. Optionally push to GitHub once `$REL_REMOTE` exists, and optionally create a
GitHub Release. PyPI publishing, if any, is a separate explicit step.
### Allow-list export (sketch)
Keep the allow-list in the script, reviewed like code:
```bash
#!/usr/bin/env bash
set -euo pipefail
DEV_REF="${1:-main}"
STAGE="$(mktemp -d)"
PATHS=( # Anything not listed does NOT ship.
src pyproject.toml README.md LICENSE
examples/app.service examples/config.example.yaml examples/content.example.yaml
)
git archive "$DEV_REF" -- "${PATHS[@]}" | tar -x -C "$STAGE" # committed only
# Guard: refuse if a non-example file slipped into examples/
if find "$STAGE/examples" -type f ! -name '*.example.*' ! -name '*.service' | grep -q .; then
echo "Refusing: non-example file in examples/ — possible private data" >&2
exit 1
fi
# Sync into $REL_REPO, preserving its .git, then commit/tag (push later):
# rsync -a --delete --exclude='.git' "$STAGE"/ "$REL_REPO"/
# git -C "$REL_REPO" add -A
# git -C "$REL_REPO" commit -m "Release $VERSION"
# git -C "$REL_REPO" tag "v$VERSION"
# git -C "$REL_REPO" push -u --follow-tags origin main # once a remote exists
```
@@ -0,0 +1,95 @@
# Rootless Docker Gitea Runner Notes
Reusable setup for running Gitea Actions jobs on a **rootless** Docker host when
jobs need Docker access (Playwright, Buildx). The runner runs as a container;
job containers reach the host's rootless socket. Substitute the rootless socket
path `/run/user/<UID>/docker.sock` for your runner user (examples use `3100`).
## Runner compose
Mount the rootless socket into the runner container:
```yaml
services:
gitea-runner-1:
image: gitea/act_runner:latest
container_name: s4-runner-1
restart: unless-stopped
environment:
GITEA_INSTANCE_URL: https://gitea.example.com
GITEA_RUNNER_REGISTRATION_TOKEN: ${GITEA_RUNNER_REGISTRATION_TOKEN}
GITEA_RUNNER_NAME: s4-runner-1
CONFIG_FILE: /data/config.yaml
volumes:
- runner1_data:/data
- ./config.yaml:/data/config.yaml:ro
- /run/user/3100/docker.sock:/var/run/docker.sock
volumes:
runner1_data:
```
## Runner config
```yaml
container:
docker_host: "-" # critical
options: "-v /run/user/3100/docker.sock:/var/run/docker.sock"
valid_volumes:
- /run/user/3100/docker.sock # source path only
```
`docker_host: "-"` stops `act_runner` from adding its own socket mount on top of
the one in `options`, which otherwise fails job creation with
`Duplicate mount point: /var/run/docker.sock`. Keep `valid_volumes` as the host
source path only (not the `src:dst` bind form).
## Workflow patterns
- **Buildx**: use `docker/setup-buildx-action@v3` with `driver: docker`. The
default driver starts a separate BuildKit container that misbehaves on this
runner shape; the `docker` driver uses the mounted daemon directly.
- **No setup-node cache**: drop `cache: pnpm` from `actions/setup-node@v4`; the
job network often can't reach the Gitea Actions cache service (minutes of
restore/save timeouts). Use `pnpm install --frozen-lockfile` instead. Revisit
only once the cache-service network path is confirmed.
- **Playwright**: split browser E2E from validation — build + upload artifact in
a normal job, then run Playwright in the official image
(`mcr.microsoft.com/playwright:v1.58.2-noble`) against the downloaded artifact.
## Host checks
```sh
docker context show; echo "$DOCKER_HOST"; ls -l /run/user/3100/docker.sock; docker info
# DOCKER_HOST should be unix:///run/user/3100/docker.sock
docker run --rm -v /run/user/3100/docker.sock:/var/run/docker.sock docker:27-cli docker version
```
If that container can't use the socket, fix rootless Docker before touching the
runner.
## Troubleshooting
- **`Duplicate mount point: /var/run/docker.sock`** — set `docker_host: "-"`,
keep the bind only in `options`, `valid_volumes` = source path only, recreate.
- **`WARNING: IPv4 forwarding is disabled`** may persist even after host
`net.ipv4.ip_forward=1`. `systemctl --user restart docker` and re-check
`docker info`. If jobs create containers and reach their networks, treat it as
stale output, not a failure.
- **Buildx/build fails** — use `driver: docker`; confirm `docker version` works
through the mounted socket.
## Useful commands
```sh
docker ps --format '{{.ID}} {{.Image}} {{.Status}} {{.Names}}'
docker logs --tail 120 s4-runner-1
docker compose up -d --force-recreate gitea-runner-1
tea actions runs --repo owner/repo
tea actions runs logs <run-id> --job <job-id> --repo owner/repo
```
## Known-good (Grindex / current runner)
Host `s4.fisher.hu`; container `s4-runner-1`; image `gitea/act_runner:latest`;
socket `/run/user/3100/docker.sock`; workflows in `.gitea/workflows`; Buildx
`driver: docker`; setup-node cache disabled.
+58
View File
@@ -0,0 +1,58 @@
# Cloudflare R2 CI Setup
Upload a build artifact (e.g. a status JSON) to an R2 bucket from CI, using
`awscli` against R2's S3-compatible API.
## Cloudflare side
Create a bucket and a write-capable API token/key pair; note the account ID.
## CI config
Variables: `R2_BUCKET_NAME`, `R2_ACCOUNT_ID`.
Secrets: `R2_ACCESS_KEY_ID`, `R2_SECRET_ACCESS_KEY`.
The keys must be passed as `AWS_ACCESS_KEY_ID` / `AWS_SECRET_ACCESS_KEY`
`awscli` reads those names. Also required: `AWS_DEFAULT_REGION=auto` and
endpoint `https://${R2_ACCOUNT_ID}.r2.cloudflarestorage.com`.
## Job
```yaml
publish_status:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- uses: actions/setup-python@v6
with: { python-version: "3.12" }
- run: pip install awscli
- name: Publish to R2
env:
R2_BUCKET_NAME: ${{ vars.R2_BUCKET_NAME }}
R2_ACCOUNT_ID: ${{ vars.R2_ACCOUNT_ID }}
AWS_ACCESS_KEY_ID: ${{ secrets.R2_ACCESS_KEY_ID }}
AWS_SECRET_ACCESS_KEY: ${{ secrets.R2_SECRET_ACCESS_KEY }}
run: |
set -eu
# If publish is optional, skip on missing config; use exit 1 if mandatory.
[ -n "${R2_BUCKET_NAME:-}" ] && [ -n "${R2_ACCOUNT_ID:-}" ] \
&& [ -n "${AWS_ACCESS_KEY_ID:-}" ] && [ -n "${AWS_SECRET_ACCESS_KEY:-}" ] \
|| { echo "Skipping: R2 config not set."; exit 0; }
f="$(mktemp -d)/project-name.json"
cat > "$f" <<EOF
{"generated_at":"$(date -u +"%Y-%m-%d %H:%M UTC")","build":{"state":"passing"}}
EOF
AWS_DEFAULT_REGION=auto aws s3api put-object \
--bucket "$R2_BUCKET_NAME" --key "project-name.json" --body "$f" \
--content-type application/json \
--endpoint-url "https://${R2_ACCOUNT_ID}.r2.cloudflarestorage.com"
```
## Notes
- Keep object keys explicit and stable (`project-name.json`,
`status/project-name.json`). For dashboards, don't put commit SHAs in the
primary key without also keeping a stable "latest" pointer.
- Common failures: wrong account ID (bad endpoint), wrong bucket (auth ok but
write fails), missing AWS env vars (no auth), missing `AWS_DEFAULT_REGION=auto`
(inconsistent behaviour), using the standard AWS S3 endpoint instead of R2.
+48
View File
@@ -0,0 +1,48 @@
# ntfy CI Notification Setup
Send an `ntfy` message on workflow success and another on failure, for push
events only. Needs `curl` in the runner and the ntfy server reachable from CI.
- Base URL via workflow env: `NTFY_BASE_URL: https://ntfy.fisher.hu`
- Topic = repo name with `/``-`: `topic="${GITHUB_REPOSITORY//\//-}"`
(e.g. `webdev/domaindingo``webdev-domaindingo`)
```yaml
env:
NTFY_BASE_URL: https://ntfy.fisher.hu
jobs:
notify_ok:
runs-on: ubuntu-latest
needs: [build, publish_project_status] # replace with your pipeline jobs
if: ${{ always() && github.event_name == 'push' && needs.build.result == 'success' && needs.publish_project_status.result == 'success' }}
steps:
- name: Notify ntfy (success)
env: { COMMIT_MESSAGE: "${{ github.event.head_commit.message }}" }
run: |
topic="${GITHUB_REPOSITORY//\//-}"
curl -fsS -d "✅ Repo: ${GITHUB_REPOSITORY} | Branch: ${GITHUB_REF_NAME} | Message: ${COMMIT_MESSAGE}" \
"${NTFY_BASE_URL}/${topic}"
notify_failure:
runs-on: ubuntu-latest
needs: [build, publish_project_status]
if: ${{ always() && github.event_name == 'push' && (needs.build.result != 'success' || needs.publish_project_status.result != 'success') }}
steps:
- name: Notify ntfy (failure)
env: { COMMIT_MESSAGE: "${{ github.event.head_commit.message }}" }
run: |
topic="${GITHUB_REPOSITORY//\//-}"
curl -fsS -d "❌ Repo: ${GITHUB_REPOSITORY} | Branch: ${GITHUB_REF_NAME} | Message: ${COMMIT_MESSAGE}" \
"${NTFY_BASE_URL}/${topic}"
```
## Notes
- `needs:` must list the jobs that define "done"; the `if:` expressions decide
success vs. failure. Keep `always()` so the failure job still runs when an
upstream job fails.
- Limit to `push`: `github.event.head_commit.message` is absent on other events.
- `curl -fsS` fails the step on HTTP errors while still printing diagnostics.
- Auth: if the server needs a token, store it as a secret and add
`-H "Authorization: Bearer ${NTFY_TOKEN}"` to the curl call.
+32
View File
@@ -0,0 +1,32 @@
# Working Style Checklist
Decision ladder before writing code or tooling (from [Ponytail](https://github.com/DietrichGebert/ponytail)):
1. **Does this need to exist?** → No: skip it (YAGNI)
2. **Already in this codebase?** → Reuse, don't rewrite
3. **Stdlib does it?** → Use it
4. **Native platform feature?** → Use it
5. **Installed dependency?** → Use it
6. **One line?** → One line
7. **Only then:** the minimum that works
Lazy about the solution, never about reading the problem first.
## Math & Arithmetic
For numeric calculations in shell scripts, pick the simplest rung:
- Shell arithmetic `$(( ))` — fixed-width integers, one line
- `bc` — arbitrary precision when needed
- Python `python3 -c "print(...)"` — complex logic or exact precision
- Always validate edge cases (overflow, division by zero)
## Plain English
Write in plain English for system administrators:
- Prefer short, direct sentences.
- Use concrete verbs: check, read, write, test, stop, ask.
- Avoid management and policy terms such as leverage, align, facilitate, ensure, optimize, stakeholder, outcome, and workflow.
- Say who should act and under which condition.
- Keep necessary technical terms, but explain unfamiliar ones.
- Remove repetition and text that does not change the action.
- Preserve the exact meaning and all safety requirements.