Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
100 changes: 79 additions & 21 deletions .github/ci-cd.md
Original file line number Diff line number Diff line change
Expand Up @@ -45,21 +45,76 @@ Woodpecker, Drone, GitLab CI, etc.

## Pipeline stages

Single `ci.yml` workflow triggered on PR and push to `main`:

| # | Stage | Command | Purpose |
| --- | -------------------------------- | ---------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| 1 | Setup Nix + cache | `cachix/install-nix-action` + `cachix/cachix-action` | Avoid rebuilding toolchains every run |
| 2 | Verify generated code is in sync | `just ci::codegen-check` | Catch PRs that forgot to regenerate stubs; uses `default` shell (buf + codegen tools not in CI shell); pnpm install required because `protoc-gen-ts_proto` is a node_modules binary |
| 3 | Format check | `just ci::run just check::format` | Backstop for developers who haven't opted into the githooks framework |
| 4 | Lint | `just ci::run bash -c "just check::lint -c backend && just check::lint -c frontend"` | Delegates to quitsh → golangci-lint, eslint, svelte-check, typos, yamllint |
| 5 | Build | `just ci::run bash -c "just build -c backend && just build -c frontend"` | All components via quitsh; catches compilation errors before running tests |
| 6 | Test | `just ci::run bash -c "just check::test -c backend && just check::test -c frontend"` | Go unit tests + Vitest; coverage upload per [codecov.yaml](../tools/configs/codecov/codecov.yaml) |
| 7 | Container images _(main only)_ | `nix build ./tools/nix#backend-service-dev` and `#frontend-service-dev` | Reproducible OCI images |
| 8 | Publish _(tag/main only)_ | Push images to registry; attach image digests or registry references to the GitHub release via `gh release edit` | Gated behind release trigger; links the release to its artifacts |

Stages 2–6 are PR-blocking. Stage 7 runs on `main` only. Stage 8 only on tagged
releases.
Single `ci.yml` workflow triggered on PR, push to `main`, and `v*` tags:

| # | Stage | Command | Purpose |
| --- | --------------------------- | ------------------------------------------ | ------------------------------------------------------------------------------------------ |
| 1 | Setup Nix + cache | `install-nix-action` + `cachix-action` | Avoid rebuilding toolchains every run |
| 2 | Codegen in sync | `just ci::codegen-check` | Catch PRs that forgot to regenerate stubs; uses the `default` shell, where buf lives |
| 3 | Format check | `just ci::run just check::format` | Backstop for developers who haven't opted into the githooks framework |
| 4 | Lint | `just ci::run just check::lint -c <comp>` | golangci-lint, eslint, svelte-check, typos, yamllint |
| 5 | Version consistency | `just ci::run just version::check` | `VERSION` of the app, both `.component.yaml` versions and `package.json` must agree |
| 6 | Helm chart lint | `just ci::run just helm::lint` | `helm lint` plus a full render — the chart cannot render with its own defaults |
| 7 | Chart version bumped _(PR)_ | `just ci::run just helm::check-bump` | A chart change without a version bump can never reach a cluster |
| 8 | Build | `just ci::run just check::build -c <comp>` | All components via quitsh |
| 9 | Test | `just ci::run just check::test -c <comp>` | Go unit tests + Vitest; coverage per [codecov.yaml](../tools/configs/codecov/codecov.yaml) |
| 10 | Container images | `quitsh image` (the `images` job) | PR: build only. `main`: push to `temporary/`. Tag: push to `release/` |
| 11 | Publish chart | `just helm::publish` | Pushes the chart when Chart.yaml holds an unpublished version; no-ops otherwise |

Stages 2–9 are PR-blocking. Stage 10 builds on every PR and pushes on `main` and
on tags. Stage 11 runs on every push and does nothing unless the chart version
has moved.

## Two release trains

The app and the chart version are independent from each other: a chart fix with
no app change should not force an app release, and an app release should not
republish an unchanged chart.

**The app** is versioned by `VERSION` at the repo root. `just version::bump`
moves it along with `.component.yaml` versions and `package.json`, commits, and
tags. Pushing that `v*` tag builds and pushes:

```
ghcr.io/swissdatasciencecenter/hackagon/release/backend-service:X.Y.Z
ghcr.io/swissdatasciencecenter/hackagon/release/frontend-service:X.Y.Z
```

Stage 5 refuses a tree where those files disagree, because the component version
_is_ the image tag.

**The chart** is versioned by `helm-chart/Chart.yaml`, by hand:

- `version` — the chart's own release. Bump it whenever anything under
`helm-chart/` changes; stage 7 fails the PR if you forget.
- `appVersion` — the app release this chart deploys. The deployment templates
fall back to it for the image tag, so it decides what a cluster actually runs.

`just helm::publish` reads both from Chart.yaml and injects nothing. It pushes
to `oci://ghcr.io/swissdatasciencecenter/hackagon/charts/hackagon` and gives up
early in two cases, both reported and neither a failure:

- **the chart version is already published** — the normal case on most pushes
- **`appVersion` has no published images** — the chart would not be installable

The second is what makes "bump appVersion to an unreleased version" safe: the
chart waits, and the run that finally builds those images publishes it.

Two consequences worth knowing:

- **A new GHCR package is private by default.** The first `v*` tag creates
`release/*`, and the first chart publish creates `charts/hackagon`; until
someone sets those to public in the org's package settings, a cluster needs an
`imagePullSecret`. The existing `temporary/*` packages are already public.
- **A release image is never overwritten.** quitsh refuses to push a release tag
that already exists ([upload.go](../tools/quitsh/pkg/image/upload.go)), so
re-tagging a version fails rather than replacing an artifact someone has
deployed. Bump the version instead.

To deploy the head of `main` rather than a release, override the image
repository and tag to the `temporary/` package — see the Images note in

[helm-chart/values.yaml](../helm-chart/values.yaml).

## Parallelism

Expand Down Expand Up @@ -88,10 +143,10 @@ Run the full CI pipeline locally from the repo root:
just ci::all
```

This mirrors stages 2–6 exactly as they run on GitHub Actions (generate check →
format → lint → build → test). Works from a bare shell or from a direnv-managed
shell — `ci::all` handles the Nix shell itself, so do **not** call it from
inside `nix develop`.
This mirrors stages 2–9 exactly as they run on GitHub Actions (generate check →
format → lint → version → helm → chart → build → test). Works from a bare shell
or from a direnv-managed shell — `ci::all` handles the Nix shell itself, so do
**not** call it from inside `nix develop`.

Before pushing: `just ci::all` → if it passes locally, CI passes.

Expand Down Expand Up @@ -120,8 +175,11 @@ Individual stages can also be isolated from the repo root:
just ci::codegen-check # stage 2 — uses default shell
just ci::run just check::format # stage 3
just ci::run just check::lint -c backend # stage 4 (one component)
just ci::run just build -c backend # stage 5 (one component)
just ci::run just check::test -c backend # stage 6 (one component)
just ci::run just version::check # stage 5
just ci::run just helm::lint # stage 6
just ci::run just helm::check-bump # stage 7
just ci::run just build -c backend # stage 8 (one component)
just ci::run just check::test -c backend # stage 9 (one component)
```

On CI failure: copy the failing command from the workflow YAML, run it locally,
Expand Down
77 changes: 77 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,11 @@ on:
tags: ["v*"]
workflow_dispatch:

# Permissions default to read-only, so PRs from forks cannot push packages.
# The `images` jobs that publish packages overwrite this.
permissions:
contents: read

# Cancel in-progress runs on the same branch/PR to avoid wasted minutes.
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
Expand All @@ -18,7 +23,10 @@ jobs:
timeout-minutes: 30

steps:
# `fetch-depth: 0` is required for `just version::check` to see the tags.
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
with:
fetch-depth: 0

- uses: cachix/install-nix-action@ba0dd844c9180cbf77aa72a116d6fbc515d0e87b # v27
with:
Expand Down Expand Up @@ -63,6 +71,21 @@ jobs:
just ci::run just check::lint -c backend
just ci::run just check::lint -c frontend

# Make sure the version in AppVersion is consistent in all app components.
- name: Version consistency
run: just ci::run just version::check

# Make sure the helm chart renders correctly and passes `helm lint`.
- name: Helm chart lint
run: just ci::run just helm::lint

# Make sure the Chart version is raised when the helm chart changes.
- name: Helm chart version bumped
if: github.event_name == 'pull_request'
run: |
just ci::run just helm::check-bump \
"origin/${{ github.event.pull_request.base.ref }}"

- name: Build
run: |
just ci::run just check::build -c backend
Expand All @@ -81,6 +104,9 @@ jobs:
runs-on: ubuntu-24.04
timeout-minutes: 30
needs: [ci]
permissions:
contents: read
packages: write

steps:
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
Expand Down Expand Up @@ -135,3 +161,54 @@ jobs:
--credential-user-env GH_USERNAME \
--credential-token-env GHCR_TOKEN
fi

# Publishes the chart as a versioned OCI artifact so a deployment can pin
# `oci://.../charts/hackagon --version X.Y.Z`.
#
# Deliberately not tag-driven. The chart carries its own version and its own
# appVersion, and the recipe reads both from Chart.yaml.
#
# An app release can be published without a chart bump, but a chart bump
# cannot be published without an app release.
# The `needs: [images]` ensures that the chart cannot be published unless
# the images for its appVersion are already published.
publish-chart:
if: github.event_name == 'push'
runs-on: ubuntu-24.04
timeout-minutes: 30
needs: [images]
permissions:
contents: read
packages: write

steps:
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2

- uses: cachix/install-nix-action@ba0dd844c9180cbf77aa72a116d6fbc515d0e87b # v27
with:
extra_nix_config: |
experimental-features = nix-command flakes

- uses: cachix/cachix-action@ad2ddac53f961de1989924296a1f236fcfbaa4fc # v15
with:
name: hackagon
authToken: ${{ secrets.CACHIX_AUTH_TOKEN }}

- name: Install bootstrap tools
run: nix profile install --accept-flake-config ./tools/nix#bootstrap

# Two logins, two tools: helm pushes the chart, skopeo answers "is this
# already published?". They keep separate credential stores.
# Credentials are passed through env.
- name: Log in to GHCR
env:
GH_USERNAME: ${{ github.actor }}
GHCR_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
just ci::run bash -c '
echo "$GHCR_TOKEN" | helm registry login ghcr.io -u "$GH_USERNAME" --password-stdin
echo "$GHCR_TOKEN" | skopeo login ghcr.io -u "$GH_USERNAME" --password-stdin
'

- name: Publish chart if its version is unpublished
run: just ci::run just helm::publish
29 changes: 23 additions & 6 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -67,7 +67,9 @@ components/backend/
├── internal/logx/logx.go # slog setup + Fatal helper
└── Schema.md # human-readable DB reference (auto-generated)
components/frontend/ # SvelteKit; generated gRPC clients under src/lib/server/grpc/generated/
helm-chart/ # Helm chart; published as an OCI artifact on a v* tag
mydocs/docs/backend-tickets/ # known gaps, one file per issue (README inside)
tools/helm/lint-values.yaml # throwaway values so the chart can be rendered in CI
tools/nix/ # Nix flake + process-compose config (toolchain.nix)
tools/just/*.just # just modules — see Dev commands
justfile # root justfile, imports the modules above
Expand Down Expand Up @@ -98,7 +100,13 @@ just check::lint -c backend # also: build, test, format; -c frontend
just ci::all # everything CI runs, locally

just version::show # declared version + what a build stamps in the footer
just version::bump patch|minor|major # edit VERSION, commit, annotated tag
just version::check # fail if a component version has drifted from VERSION
just version::bump patch|minor|major # edit VERSION everywhere, commit, annotated tag

just helm::lint # helm lint + full render against tools/helm/lint-values.yaml
just helm::template [args] # render the chart to stdout
just helm::check-bump # fail if helm-chart/ changed without a version bump
just helm::publish # push the chart if Chart.yaml's version is unpublished
```

Backend listens on **:3000**, frontend on **:8081**. Dev users (Keycloak
Expand Down Expand Up @@ -139,11 +147,20 @@ These hold across the whole codebase; the skills explain the mechanisms.
running server — see **backend-api-explore**. Any inventory committed to a
markdown file is a snapshot that starts rotting immediately, this one
included.
- **`VERSION` at the repo root is the only declared version.** Bump it through
`just version::bump`, never by hand — the recipe is what also commits it, tags
it, and keeps `components/frontend/package.json` in step. The frontend reads
it at build time (`vite.config.ts` → `$lib/version` → the footer), so a
version edited without a rebuild will not show up.
- **`VERSION` at the repo root is the app's only declared version.** Bump it
with `just version::bump`, never by hand — the recipe moves `VERSION`, both
`components/*/.component.yaml` versions and `components/frontend/package.json`
together, then commits and tags. A component version _is_ its published image
tag, so `just version::check` (a CI stage) fails a tree where these disagree.
The frontend reads `VERSION` at build time (`vite.config.ts` → `$lib/version`
→ the footer), so editing it without a rebuild changes nothing.
- **The chart versions itself; `VERSION` never touches it.**
`helm-chart/Chart.yaml` holds two hand-edited numbers: `version` is the
chart's own release, `appVersion` is the app release it deploys.
`just helm::publish` reads both and injects nothing, so a chart is published
only when its own `version` changes — never as a side effect of an app
release. It skips without failing when that version is already published, or
when `appVersion` has no images yet.

## Don't

Expand Down
2 changes: 1 addition & 1 deletion VERSION
Original file line number Diff line number Diff line change
@@ -1 +1 @@
0.1.0
0.8.0
2 changes: 1 addition & 1 deletion components/backend/.component.yaml
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
name: backend
version: 0.1.0
version: 0.8.0
language: go

targets:
Expand Down
2 changes: 1 addition & 1 deletion components/frontend/.component.yaml
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
name: frontend
version: 0.7.0
version: 0.8.0
language: typescript

targets:
Expand Down
2 changes: 1 addition & 1 deletion components/frontend/package.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"name": "frontend-v3",
"private": true,
"version": "0.0.1",
"version": "0.8.0",
"type": "module",
"scripts": {
"dev": "vite dev",
Expand Down
8 changes: 6 additions & 2 deletions helm-chart/Chart.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,12 @@ description: A Helm chart for Hackagon on Kubernetes

type: application

version: 0.1.0
appVersion: "1.0.0"
# The chart's own version. Bumped by hand when the chart changes, and
# independent of the app: the CI publishes whatever it finds here.
version: 0.2.0
# The app release this chart deploys. A new app release does
# not become deployable until someone points the chart at it.
appVersion: "0.8.0"

dependencies:
- name: postgresql
Expand Down
2 changes: 1 addition & 1 deletion helm-chart/templates/backend-deployment.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ spec:
spec:
containers:
- name: backend
image: "{{ .Values.backend.image.repository }}:{{ .Values.backend.image.tag }}"
image: "{{ .Values.backend.image.repository }}:{{ .Values.backend.image.tag | default .Chart.AppVersion }}"
imagePullPolicy: {{ .Values.backend.image.pullPolicy }}
args:
- "--config-dir=/etc/hackagon/"
Expand Down
2 changes: 1 addition & 1 deletion helm-chart/templates/frontend-deployment.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ spec:
spec:
containers:
- name: frontend
image: "{{ .Values.frontend.image.repository }}:{{ .Values.frontend.image.tag }}"
image: "{{ .Values.frontend.image.repository }}:{{ .Values.frontend.image.tag | default .Chart.AppVersion }}"
imagePullPolicy: {{ .Values.frontend.image.pullPolicy }}
args:
- "--config-dir=/etc/hackagon"
Expand Down
Loading
Loading