diff --git a/.gitea/workflows/release.yaml b/.gitea/workflows/release.yaml new file mode 100644 index 0000000..144dcd9 --- /dev/null +++ b/.gitea/workflows/release.yaml @@ -0,0 +1,58 @@ +name: release +on: + push: + branches: [main] + +# ponytail: only because Traefik still serves its default self-signed cert for +# git.kessinen.com. Remove once the LE-DNS01-cloudflare runbook has been run. +env: + GIT_SSL_NO_VERIFY: "true" + REGISTRY: git.kessinen.com + IMAGE: git.kessinen.com/kessinen/foodster + +jobs: + image: + runs-on: ubuntu-latest + steps: + # Full history and tags: the release number is derived by counting the + # tags already cut today. + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + + # The job container is node:22-bookworm and has no docker client. The + # static binary is one file; installing docker.io would pull a daemon + # that is never used, since the build runs against the host's. + - name: Install the docker client + run: | + curl -fsSL https://download.docker.com/linux/static/stable/x86_64/docker-27.3.1.tgz \ + | tar xz --strip-components=1 -C /usr/local/bin docker/docker + docker version --format '{{.Client.Version}}' + + - name: Work out the release tag + id: rel + run: | + day=$(date +%Y%m%d) + tag="v$day-$(( $(git tag -l "v$day-*" | wc -l) + 1 ))" + echo "tag=$tag" >> "$GITHUB_OUTPUT" + echo "==> $tag" + + - name: Tag the commit + run: | + git tag "${{ steps.rel.outputs.tag }}" + git push origin "${{ steps.rel.outputs.tag }}" + + - name: Log in to the registry + run: | + echo "${{ secrets.GITEA_TOKEN }}" \ + | docker login "$REGISTRY" -u "${{ gitea.actor }}" --password-stdin + + - name: Build and push + run: | + tag="${{ steps.rel.outputs.tag }}" + docker build --platform linux/amd64 --build-arg VERSION="$tag" \ + -f Containerfile \ + -t "$IMAGE:$tag" -t "$IMAGE:latest" . + docker push "$IMAGE:$tag" + docker push "$IMAGE:latest" + echo "pushed $IMAGE:$tag and :latest - pull it in dockge when ready" diff --git a/.gitignore b/.gitignore index 356629e..cf5bd37 100644 --- a/.gitignore +++ b/.gitignore @@ -4,9 +4,6 @@ # Build output /foodster -# The tag `make image` last built, handed to `make push`. -/.release-tag - # Generated by `templ generate` during the container build. *_templ.go diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 15a8a89..c95a2cc 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -29,9 +29,9 @@ git switch dev && git reset --hard main git push --force-with-lease origin dev ``` -`make image` refuses to run outside `main`. That check has to be local: the -tag and the image are made before anything reaches the remote, so branch -protection cannot catch a release built from the wrong branch. +The release workflow only triggers on `main`, and `main` only moves through a +pull request, so a release can never be built from the wrong branch. Nothing +needs to check for it. ## Commit messages @@ -71,7 +71,7 @@ release: repair the catalog 404 and stop the page jumping `main`'s log is then one line per deployment, which is what that branch is for, and the pull request body serves as the release notes. No version in the -title — the CalVer tag is not created until `make image` runs after the merge. +title — CI creates the CalVer tag after the merge, so it is not known yet. The types above are for `dev`, where a commit really does do one thing. diff --git a/Makefile b/Makefile index 0468112..a6cda68 100644 --- a/Makefile +++ b/Makefile @@ -3,18 +3,8 @@ COMPOSE ?= podman compose BIN := foodster PKG := ./cmd/foodster -STATIC := cmd/foodster/static -# What `make image` last built. push reads it rather than re-deriving the tag: -# sorting tags by date is ambiguous when two point at the same commit, and -# re-deriving is what let a parallel make push the wrong one. -TAGFILE := .release-tag - -# Vendored Datastar client. Bump, run `make vendor`, commit the result. -DATASTAR_VERSION ?= v1.0.3 -SEED ?= seeds/testi.json - -# Registry coordinates, shared password and TZ live here. Gitignored. +# Shared password and TZ live here. Gitignored. ifneq (,$(wildcard .env)) include .env export @@ -24,7 +14,7 @@ endif GOFILES = $(shell find . -name '*.go' -not -name '*_templ.go' 2>/dev/null) .DEFAULT_GOAL := help -.PHONY: help generate build run seed test smoke check lint fix icons vendor image push release up down logs clean +.PHONY: help generate build run test smoke check lint fix up down logs clean help: ## Show this help @grep -hE '^[a-zA-Z_-]+:.*## ' $(MAKEFILE_LIST) \ @@ -40,9 +30,6 @@ build: generate ## Build ./foodster run: generate ## Run locally on :8080 (database in ./data) PASSWORD=$${PASSWORD:-dev} ENV=dev go run $(PKG) -seed: ## Import a dish bundle (SEED=seeds/testi.json) - go run $(PKG) -import $(SEED) - test: generate ## Run unit tests go test ./... @@ -57,21 +44,6 @@ check: ## Everything that must pass before a commit @$(MAKE) --no-print-directory smoke @echo "check: all passed" -icons: ## Rasterise home-screen PNGs from assets/icon.svg and optimise them - rsvg-convert -w 180 -h 180 assets/icon.svg -o $(STATIC)/apple-touch-icon.png - rsvg-convert -w 192 -h 192 assets/icon.svg -o $(STATIC)/icon-192.png - rsvg-convert -w 512 -h 512 assets/icon.svg -o $(STATIC)/icon-512.png - # oxipng -o max alone loses to optipng on the 512; --zopfli wins at every - # size. Slow, but these are three tiny files built by hand. - oxipng -o max --zopfli --quiet \ - $(STATIC)/apple-touch-icon.png $(STATIC)/icon-192.png $(STATIC)/icon-512.png - @ls -l $(STATIC)/*.png - -vendor: ## Re-download the Datastar client (DATASTAR_VERSION=v1.0.3) - curl -sSfL -o $(STATIC)/datastar.js \ - "https://cdn.jsdelivr.net/gh/starfederation/datastar@$(DATASTAR_VERSION)/bundles/datastar.js" - @head -1 $(STATIC)/datastar.js - lint: generate ## go vet, gofmt check, golangci-lint when installed go vet ./... @bad=$$(gofmt -l $(GOFILES) 2>/dev/null); \ @@ -84,57 +56,6 @@ fix: ## Format Go and templ sources, tidy go.mod go tool templ fmt . go mod tidy -image: ## Build and tag an image as vYYYYMMDD-N. Creates a git tag. - @test -n "$(REPO)" || { echo "set REPO in .env"; exit 1; } - @# A release tag must point into main, or the tag records a commit that - @# was never released. - @branch=$$(git symbolic-ref --short HEAD); \ - if [ "$$branch" != "main" ]; then \ - echo "releases are cut from main, not $$branch:"; \ - echo " git switch main && git merge --ff-only dev"; \ - exit 1; \ - fi - @day=$$(date +%Y%m%d); \ - tag="v$$day-$$(( $$(git tag -l "v$$day-*" | wc -l) + 1 ))"; \ - echo "==> $$tag"; \ - git tag "$$tag"; \ - podman build --platform linux/amd64 --build-arg VERSION="$$tag" \ - -t "$(REPO):$$tag" -t "$(REPO):latest" . ; \ - echo "$$tag" > $(TAGFILE) - -# Pushing reported success while uploading the previous release once, because -# nothing compared what was built against what arrived. So afterwards, ask the -# registry what it actually serves for each tag and fail if it is not the -# image we just built. -push: ## Push the newest tag and :latest, then verify the registry - @test -n "$(REPO)" || { echo "set REPO in .env"; exit 1; } - @test -f $(TAGFILE) || { echo "nothing built - run make image"; exit 1; }; \ - tag=$$(cat $(TAGFILE)); \ - built=$$(podman image inspect "$(REPO):$$tag" --format '{{.Id}}' 2>/dev/null) || \ - { echo "no local image tagged $$tag - run make image"; exit 1; }; \ - podman push "$(REPO):$$tag"; \ - podman push "$(REPO):latest"; \ - echo "==> verifying $$tag"; \ - for ref in "$$tag" latest; do \ - podman pull -q "$(REPO):$$ref" >/dev/null 2>&1 || \ - { echo " FAIL $$ref is not in the registry"; exit 1; }; \ - served=$$(podman image inspect "$(REPO):$$ref" --format '{{.Id}}'); \ - if [ "$$served" != "$$built" ]; then \ - echo " FAIL $$ref serves $$served"; \ - echo " expected $$built"; \ - exit 1; \ - fi; \ - echo " ok $$ref"; \ - done - -# Sub-makes, not prerequisites. Under `make -j` — and -j16 is the default on -# at least one machine here — these run concurrently, so push resolves the -# newest tag and uploads :latest before image has finished building and -# tagging. That silently ships the previous release a second time. -release: ## Build, tag and push in one go - @$(MAKE) --no-print-directory image - @$(MAKE) --no-print-directory push - up: ## Start the stack @mkdir -p data # or the engine creates it root-owned and the app cannot write $(COMPOSE) up -d diff --git a/PRD.md b/PRD.md index c7716d2..3439bb0 100644 --- a/PRD.md +++ b/PRD.md @@ -372,22 +372,27 @@ Explicitly *not* React. ## 10. Deployment -Images are built locally, pushed to a private container registry, then pulled -on the server and run with Docker Compose. +Images are built by CI, pushed to a private container registry, then pulled on +the server and run with Docker Compose. - **Branches**: `main` carries released versions only, so its history is the deployment history and every release tag points into it. Development happens on `dev`, and `main` is protected on the remote: it accepts no direct - pushes, so a release arrives as a pull request from `dev`. `make image` - additionally refuses to run outside `main` — that one has to be local, - because the tag and the image are made before anything reaches the remote. + pushes, so a release arrives as a pull request from `dev`. The release + workflow runs only on `main`, so a release cannot be built from anywhere + else and nothing needs to check for it. - **Versioning**: CalVer `vYYYYMMDD-N`, where `N` is the Nth build of that - day. `make image` derives `N` by counting the day's existing git tags, - creates the new tag, and bakes the version into the binary through - `-ldflags -X main.version`. `make release` builds, tags and pushes. -- **Tooling**: a `Makefile` is the single entry point — `make` on its own - lists every target. Build, test, lint, format, image and compose commands - all live there rather than in loose scripts. + day. The release workflow derives `N` by counting the day's existing git + tags, creates the new tag, and bakes the version into the binary through + `-ldflags -X main.version`. +- **CI**: Gitea Actions, workflows in `.gitea/workflows/`. `check.yaml` runs + `make check` on every push to `dev`; `release.yaml` builds and pushes the + image when a pull request merges into `main`. Merging is the release — + there is no local build step. +- **Tooling**: a `Makefile` covers development — `make` on its own lists every + target. Build, test, lint, format and compose commands live there rather + than in loose scripts. Commands run a handful of times a year are written + out in the README instead of earning a target. - **Image**: a two-stage `Containerfile`. `golang:1.27-alpine` compiles a static binary; the runtime stage is `FROM scratch` holding only that binary, running as UID 65534. @@ -425,7 +430,7 @@ on the server and run with Docker Compose. - Pending migrations are applied on app start. - The Datastar client is vendored at `cmd/foodster/static/datastar.js` and served from the app's own origin — the SDK ships no browser asset, and a - CDN link would break an offline LAN. `make vendor` refreshes it; the pinned + CDN link would break an offline LAN. The README says how to refresh it; the pinned version lives in the `Makefile` and in the file's first line. - No internet exposure; the server binds to the LAN. diff --git a/README.md b/README.md index 886700a..646ca1e 100644 --- a/README.md +++ b/README.md @@ -11,35 +11,35 @@ See [PRD.md](PRD.md) for the full specification. ## Status -**Stage 1 — eating history: in development.** The meal catalog and the daily -log come first, because the suggester is worthless until there are a few -weeks of real history to weight against. +**Stage 1 — eating history: in use.** The meal catalog and the daily log came +first, because the suggester is worthless until there are a few weeks of real +history to weight against. Working: -- **Kirjaa** — log a dinner: pick a dish, tick sides, save. Dishes are ordered - and sized by how often they are eaten, so the likely answer is the biggest - target. The history sits on the same page underneath: every day back to the - first entry, unlogged days shown as explicit gaps, and every row a link that - loads that day into the logger above it. +- **Kirjaa** — log a dinner: pick a dish, tick sides, save. Dishes are grouped + by category, then ordered and sized by how often they are eaten, so the + likely answer is the biggest target. The history sits on the same page + underneath: every day back to the first entry, unlogged days shown as + explicit gaps, and every row opening that day's logger in place. Older days + arrive a window at a time. - **Ruuat** — add, edit and delete mains and sides, or import a whole bundle by paste or file upload. Grouped by category and alphabetical inside, since - this is a list you manage rather than one you pick from. Deletes are soft, - so old log entries keep showing the dish they used. + this is a list you manage rather than one you pick from. Edit and delete are + row icons, and a delete asks first. Deletes are soft, so old log entries + keep showing the dish they used. +- **Search as you type** on both tabs, debounced, patching just the list. +- **Category icons**, not colour dots: shape and colour together, so two marks + are told apart by more than hue. - **Light / dark**, remembered per device, dark by default. The button shows the theme that is on — moon while dark, sun while light — not the one a click would bring. +Nothing navigates. Every interaction patches the page through Datastar, so +the scroll position survives; links and forms still work with JavaScript off. + Still to build: -- Live search as you type, and paging for the history and catalog lists once - years of entries make them long. Both via Datastar. -- Category icons instead of plain colour dots — colour and shape together, so - a red blob and a yellow blob are told apart by more than hue. -- Edit and delete as icons in the catalog rows, and a confirmation step before - a delete actually happens. -- A background for the header. Something subtle; the palette gets overhauled - later. - Stage 2: the seven-meal suggester, which starts once there is history to weight against. @@ -74,20 +74,20 @@ no direct pushes, so a release arrives through a pull request. git switch dev # where the work happens # ... commits ... make check # lint, unit tests, smoke -git push origin dev +git push origin dev # CI runs make check too tea pr create --base main --head dev # or open it in the forge -# merge the pull request, then: - -git switch main && git pull --ff-only -make release # builds, tags vYYYYMMDD-N, pushes the image -git push origin --tags +# squash-merge the pull request — that is the whole release ``` -`make image` additionally refuses to run from any branch but `main`, so a -release tag can never point at a commit that was not released. That check -lives locally because it has to: tags and images are built before anything -reaches the remote, so protection there cannot catch it. +Merging is the release. CI builds the image, tags it `vYYYYMMDD-N` and +`latest`, pushes both to the registry, and creates the matching git tag. There +is nothing to run locally afterwards; pull the new image on the server when +you are ready. + +A release tag can therefore never point at a commit that was not released: +the workflow only runs on `main`, and `main` only moves through a pull +request. ## Quick start @@ -102,15 +102,39 @@ make run # http://localhost:8080 make fix gofmt, templ fmt, go mod tidy make lint go vet, gofmt check, golangci-lint when installed make test go test ./... +make smoke end-to-end check against a scratch server +make check lint + test + smoke — run before every commit make build ./foodster -make seed import a dish bundle (SEED=seeds/testi.json) -make vendor re-download the Datastar client -make image build and tag vYYYYMMDD-N (creates a git tag) -make push push the newest tag and :latest -make release image + push make up/down/logs compose ``` +Images are built by CI, not here — see [Deployment](#deployment). + +### Occasional commands + +Rare enough not to earn a `make` target. Both write into +`cmd/foodster/static/`, and the results are committed. + +Re-download the vendored Datastar client after bumping the version: + +```sh +curl -sSfL -o cmd/foodster/static/datastar.js \ + "https://cdn.jsdelivr.net/gh/starfederation/datastar@v1.0.3/bundles/datastar.js" +``` + +Re-rasterise the home-screen icons after editing `assets/icon.svg`: + +```sh +cd cmd/foodster/static +rsvg-convert -w 180 -h 180 ../../../assets/icon.svg -o apple-touch-icon.png +rsvg-convert -w 192 -h 192 ../../../assets/icon.svg -o icon-192.png +rsvg-convert -w 512 -h 512 ../../../assets/icon.svg -o icon-512.png +oxipng -o max --zopfli --quiet apple-touch-icon.png icon-192.png icon-512.png +``` + +`oxipng -o max` on its own loses to optipng on the 512; `--zopfli` wins at +every size. Slow, but these are three tiny files built by hand. + ## Importing dishes The **Ruuat** tab takes a bundle of mains and sides: paste the JSON or upload @@ -141,7 +165,7 @@ The same importer runs from the command line when you just want to repopulate a scratch database: ```sh -make seed # or: SEED=seeds/other.json make seed +go run ./cmd/foodster -import seeds/testi.json ``` ## Icons @@ -152,12 +176,8 @@ cannot be transparent and must not change with the theme; they are rasterised from `assets/icon.svg`, which is opaque and keeps the artwork inside the central 80% so Android can mask it to any shape. -```sh -make icons # rsvg-convert, then optipng -o7 -``` - -The PNGs are committed so the build needs no rasterizer. Re-run `make icons` -after editing `assets/icon.svg`. +The PNGs are committed so the build needs no rasterizer. The commands to +regenerate them are under [Occasional commands](#occasional-commands). ## Migrations @@ -182,25 +202,24 @@ Everything is environment variables. `.env` is gitignored; start from | `ADDR` | `:8080` | Listen address. Only useful for a second local instance. | | `PUID` / `PGID` | `1000` | Host owner of `./data`, for the bind mount. | | `TZ` | `Europe/Helsinki` | Used for every calendar-day calculation. | +| `REPO` | *required to run* | Image repository, no tag. Used by `compose.yaml`. | +| `TAG` | `latest` | Tag to run under compose. | +| `HOST` | *required to run* | Hostname Traefik routes to. | Names carry no prefix: the container gives them their own namespace already. `PUID`/`PGID` are the exception — `UID` is read-only in bash, so a value set in `.env` would be silently replaced by the invoking shell's own. -| `REPO` | *required to build* | Image repository, no tag. | -| `TAG` | `latest` | Tag to run under compose. | -| `HOST` | *required to run* | Hostname Traefik routes to. | Set `TZ` in development too. Under UTC the date rolls over three hours late, which is exactly when dinner gets logged. ## Deployment -Images are built with Podman and run under Docker Compose on a LAN server. -They are OCI images, so either engine works. +Images are built by CI when a pull request merges into `main`, and run under +Docker Compose on a LAN server. They are OCI images, so either engine works. ```sh -make release # build, tag, push -# on the server: +# on the server, once CI reports the build finished: docker compose pull && docker compose up -d ``` @@ -209,8 +228,15 @@ running version is served at `GET /healthz`, which is the one route outside authentication. There is no database container. SQLite lives in `./data`, bind-mounted into -the container, so a backup is `cp -r data` and you can inspect the file with -any sqlite client without going through the engine. +the container, so you can inspect the file with any sqlite client without +going through the engine. Back it up with + +```sh +sqlite3 data/foodster.db ".backup data/foodster-$(date +%F).db" +``` + +rather than copying the directory: the database runs in WAL mode, and a plain +copy of a live database can catch the `.db` and its `-wal` mid-write. That directory must exist and be owned by the user compose runs as — `make up` creates it, and `PUID`/`PGID` in `.env` tell the container who diff --git a/assets/icon.svg b/assets/icon.svg index 5560ce2..6ae433d 100644 --- a/assets/icon.svg +++ b/assets/icon.svg @@ -1,6 +1,6 @@ -