ops(ci): automate testing and container image releases

Add PostgreSQL-backed Go validation for application changes and semantic-version release publishing to GHCR.

Default deployments to the public image and prevent host setup from starting the application before deployment.
This commit is contained in:
cole-maxwell1
2026-08-07 11:38:23 -05:00
parent d118f52387
commit 2ff14e6221
6 changed files with 229 additions and 8 deletions
+55
View File
@@ -0,0 +1,55 @@
name: Go tests
on:
push:
branches: [main]
paths:
- 'app/**'
- '.github/workflows/go-tests.yml'
pull_request:
branches: [main]
paths:
- 'app/**'
- '.github/workflows/go-tests.yml'
permissions:
contents: read
jobs:
test:
name: Test Go app
runs-on: ubuntu-latest
services:
postgres:
image: postgres:17-alpine
env:
POSTGRES_DB: todo_test
POSTGRES_USER: todo_test
POSTGRES_PASSWORD: test-password
ports:
- 5432:5432
options: >-
--health-cmd "pg_isready -U todo_test -d todo_test"
--health-interval 10s
--health-timeout 5s
--health-retries 5
defaults:
run:
working-directory: app
env:
TEST_DATABASE_URL: postgres://todo_test:test-password@127.0.0.1:5432/todo_test?sslmode=disable
steps:
- name: Check out repository
uses: actions/checkout@v6
- name: Set up Go
uses: actions/setup-go@v6
with:
go-version-file: app/go.mod
cache-dependency-path: app/go.sum
- name: Run tests
run: go test ./...
- name: Run vet
run: go vet ./...
+130
View File
@@ -0,0 +1,130 @@
name: Publish container image
on:
release:
types: [published]
permissions:
contents: read
env:
REGISTRY: ghcr.io
IMAGE_NAME: ${{ github.repository }}
RELEASE_TAG: ${{ github.event.release.tag_name }}
jobs:
test:
name: Test release
runs-on: ubuntu-latest
services:
postgres:
image: postgres:17-alpine
env:
POSTGRES_DB: todo_test
POSTGRES_USER: todo_test
POSTGRES_PASSWORD: test-password
ports:
- 5432:5432
options: >-
--health-cmd "pg_isready -U todo_test -d todo_test"
--health-interval 10s
--health-timeout 5s
--health-retries 5
defaults:
run:
working-directory: app
env:
TEST_DATABASE_URL: postgres://todo_test:test-password@127.0.0.1:5432/todo_test?sslmode=disable
steps:
- name: Validate release tag
working-directory: .
run: |
if [[ ! "$RELEASE_TAG" =~ ^v(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)(-((0|[1-9][0-9]*|[0-9]*[A-Za-z-][0-9A-Za-z-]*)(\.(0|[1-9][0-9]*|[0-9]*[A-Za-z-][0-9A-Za-z-]*))*))?(\+([0-9A-Za-z-]+(\.[0-9A-Za-z-]+)*))?$ ]]; then
printf 'Release tag must use semantic versioning with a v prefix: %s\n' "$RELEASE_TAG" >&2
exit 1
fi
- name: Check out release
uses: actions/checkout@v6
with:
ref: ${{ env.RELEASE_TAG }}
- name: Set up Go
uses: actions/setup-go@v6
with:
go-version-file: app/go.mod
cache-dependency-path: app/go.sum
- name: Run tests
run: go test ./...
- name: Run vet
run: go vet ./...
publish:
name: Publish image
needs: test
runs-on: ubuntu-latest
concurrency:
group: publish-image-${{ github.event.release.tag_name }}
cancel-in-progress: false
permissions:
contents: read
packages: write
attestations: write
id-token: write
steps:
- name: Check out release
uses: actions/checkout@v6
with:
ref: ${{ env.RELEASE_TAG }}
- name: Set up QEMU
uses: docker/setup-qemu-action@v3
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Log in to GHCR
uses: docker/login-action@v3
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Generate image metadata
id: meta
uses: docker/metadata-action@v5
with:
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
tags: |
type=semver,pattern={{raw}},value=${{ env.RELEASE_TAG }}
type=semver,pattern={{version}},value=${{ env.RELEASE_TAG }}
type=semver,pattern={{major}}.{{minor}},value=${{ env.RELEASE_TAG }}
type=semver,pattern={{major}},value=${{ env.RELEASE_TAG }},enable=${{ !startsWith(env.RELEASE_TAG, 'v0.') }}
type=raw,value=latest,enable=${{ !github.event.release.prerelease }}
labels: |
org.opencontainers.image.description=Server-rendered Go todo application deployed to Hetzner Cloud
annotations: |
org.opencontainers.image.description=Server-rendered Go todo application deployed to Hetzner Cloud
env:
DOCKER_METADATA_ANNOTATIONS_LEVELS: manifest,index
- name: Build and push image
id: push
uses: docker/build-push-action@v6
with:
context: app
file: app/Dockerfile
platforms: linux/amd64,linux/arm64
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
annotations: ${{ steps.meta.outputs.annotations }}
- name: Attest image provenance
uses: actions/attest@v4
with:
subject-name: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
subject-digest: ${{ steps.push.outputs.digest }}
push-to-registry: true
+3
View File
@@ -3,6 +3,7 @@
## Boundaries
- `app/` is the Go 1.22 module. The server entrypoint is `app/cmd/server/main.go`; handlers own HTTP behavior, `internal/db` owns xorm persistence, and `app/web` embeds templates plus compiled CSS into the binary.
- `.github/workflows/go-tests.yml` gates app changes with PostgreSQL-backed tests. Published semantic-version GitHub Releases run `.github/workflows/publish-image.yml` and publish the multi-architecture image to GHCR.
- `infra/` is one OpenTofu root module and one shared state for both production and staging. `infra/environments/*` contain input files, not independent stacks.
- `ansible/playbooks/setup-node.yml` configures both hosts; `deploy-app.yml` pulls and restarts the rootless Podman/Quadlet app. Environment-specific behavior is selected by generated inventory groups and `deployment_environment`.
- Staging restores unchanged production data. Treat staging, backups, logs, real tfvars, Vault files, and state as production-sensitive; inspect examples instead of secret-bearing files.
@@ -12,6 +13,7 @@
- Run repository checks with `make validate`: Go tests, `go vet`, OpenTofu validation, then both Ansible syntax checks.
- Run one Go test from `app/`: `go test ./internal/handler -run '^TestName$'` or `go test ./internal/db -run '^TestStoreLifecycle$'`.
- `internal/db` integration tests silently skip unless `TEST_DATABASE_URL` points to PostgreSQL. Normal `make test` needs no database.
- GitHub CI supplies PostgreSQL so the `internal/db` integration test must run rather than skip.
- Format Go and OpenTofu files with `make format`; it runs `gofmt` and recursive `tofu fmt` (including real ignored tfvars), but does not format YAML or templates.
- Build the embedded app with `make build-app`. This first runs Tailwind and writes `app/web/static/app.css`; `make css` performs only that asset step.
- Build the deployment image with `docker build -t todo:local app`. Its CSS stage is self-contained and does not use the local `app/tailwindcss` binary.
@@ -31,4 +33,5 @@
- Run Ansible from `ansible/`, as the Make targets do, or set `ANSIBLE_CONFIG=ansible/ansible.cfg`; otherwise the repository role path is not loaded.
- `make deploy-ansible` is an operational deployment, not a verification command. It regenerates inventory, runs host setup before app deployment, requires decryptable Vault files, and acts on both production and staging.
- The Quadlet uses `Pull=never`; `deploy-app.yml` intentionally pulls `app_image` before restarting `app-todo.service`.
- The example Ansible configuration defaults to the public `ghcr.io/cole-maxwell1/todo-hetzner:latest` image. Prefer a version or digest override when reproducibility matters.
- Production WAL-G archives under a prefix ending in `/prod`. Staging's scheduled `db-sync` destructively replaces `/var/lib/pgsql/data` from `LATEST` and leaves the app stopped on failure; do not weaken its path/prefix guards or use it as a routine test.
+38 -5
View File
@@ -21,7 +21,7 @@ Staging contains an unchanged copy of production todo data. Treat staging hosts,
- Docker or Podman for image builds
- `curl` and `make`
- A Hetzner Cloud project, API token, SSH key, and Object Storage credentials
- A container registry if deploying a non-public image
- A container registry account only if replacing the public application image with a private image
The app intentionally uses `xorm.io/xorm` with `github.com/lib/pq`, which is the PostgreSQL driver supported by xorm in this stack.
@@ -50,6 +50,40 @@ docker build -t todo:local app
`make css` downloads the pinned standalone Tailwind CLI to `app/tailwindcss`. The Docker build independently downloads the CLI before compiling CSS, so it does not depend on a pre-existing generated stylesheet.
## Published Container Image
GitHub Releases publish the application image to GitHub Container Registry for Linux AMD64 and ARM64 hosts:
```sh
docker pull ghcr.io/cole-maxwell1/todo-hetzner:latest
```
The `latest` tag tracks the newest stable release and is the convenient default for this demonstration stack. Version tags are safer for reproducible deployments. Release `v1.2.3`, for example, publishes `v1.2.3`, `1.2.3`, `1.2`, and `1`; prereleases publish only versioned tags. Pin the registry digest when the deployed bytes must be immutable:
```yaml
app_image: ghcr.io/cole-maxwell1/todo-hetzner@sha256:replace-with-published-digest
```
After the initial visibility step below, the package is public and does not require registry credentials to pull. A fork can change `app_image` in `ansible/inventory/group_vars/all/main.yml` to use another public or private image.
## Continuous Integration And Releases
The `Go tests` workflow runs for pull requests targeting `main` and pushes to `main` when files under `app/` or the workflow itself change. It starts PostgreSQL, runs every Go test including `TestStoreLifecycle`, and runs `go vet`. Infrastructure-only changes do not trigger the workflow.
Configure a branch protection rule for `main` that requires pull requests. Do not make the path-filtered test job a globally required status check: GitHub leaves that check pending when an infrastructure-only pull request does not trigger the workflow. The repository cannot define branch protection in workflow YAML.
Published GitHub Releases build and publish the container. Release tags must use semantic versioning with a `v` prefix. Create a release after its commit is merged and CI succeeds:
```sh
git tag -a v0.1.0 -m "v0.1.0"
git push origin v0.1.0
gh release create v0.1.0 --verify-tag --generate-notes
```
The release workflow checks out the tagged commit, reruns tests and vet with PostgreSQL, builds the multi-architecture image, publishes its tags, and records a provenance attestation. Draft releases do not publish images. Stable releases update `latest`; prereleases do not.
GHCR creates the package as private on its first publication. After the first release workflow succeeds, open the package settings, change its visibility to public, and verify that an unauthenticated pull succeeds. This is a one-time repository-owner operation; package visibility cannot be declared by this repository's workflow files.
## Hetzner Credentials And Access
The stack needs two independent Hetzner credentials:
@@ -299,15 +333,14 @@ export ANSIBLE_VAULT_PASSWORD_FILE="$HOME/.config/ansible/todo-vault-password"
make deploy-ansible
```
Build and push the image, then run configuration in this order:
The default Ansible configuration pulls the public `latest` image. Run configuration in this order:
```sh
docker build -t registry.example.com/todo:2026-08-06 app
docker push registry.example.com/todo:2026-08-06
docker pull ghcr.io/cole-maxwell1/todo-hetzner:latest
make deploy-ansible
```
`setup-node.yml` installs and configures the host. `deploy-app.yml` optionally authenticates to a registry, pulls the image, reloads the user systemd manager, restarts `app-todo.service`, and checks health. Omit registry variables for a public image. Run `make generate-inventory` again after OpenTofu replaces a server or changes a public address.
The explicit local pull is optional and only verifies public access from the operator's machine. `setup-node.yml` installs and configures the host. `deploy-app.yml` optionally authenticates to a private registry, pulls `app_image` on each host, reloads the user systemd manager, restarts `app-todo.service`, and checks health. Leave the registry variables unset for the default public image. Run `make generate-inventory` again after OpenTofu replaces a server or changes a public address.
## Backup Operations
@@ -1,5 +1,5 @@
app_user: todo
app_image: registry.example.com/todo:latest
app_image: ghcr.io/cole-maxwell1/todo-hetzner:latest
app_port: 8080
app_db_name: todo
app_db_user: todo_app
+2 -2
View File
@@ -61,9 +61,9 @@
XDG_RUNTIME_DIR: "/run/user/{{ app_user_uid }}"
changed_when: false
- name: Enable application service
- name: Enable application service without starting it
ansible.builtin.command:
argv: [systemctl, --user, enable, --now, app-todo.service]
argv: [systemctl, --user, enable, app-todo.service]
become_user: "{{ app_user }}"
environment:
XDG_RUNTIME_DIR: "/run/user/{{ app_user_uid }}"