Shipping
hearth ship is the entry point. Everything downstream is GitHub-driven, not laptop-driven. This page is the canonical reference for how shipping works across every KATforge service.
Quick start
hearth ship api # → prod, tail CI run
hearth ship api --qa # → qa namespace (release marked prerelease)
hearth ship api --draft # build only, don't deploy
hearth promote api v0.0.28-rc.1 # promote a qa prerelease to prod
hearth ship # ships every service in SHIP_ALL
What happens in 10 seconds:
hearth ship api
├─ pre-ship tests run locally (~5 min for api, instant for frontends)
├─ imp ship --patch --stable (commit + version bump + tag + push)
├─ POST /releases (creates GitHub Release)
└─ tail the CI run (step transitions stream into your terminal)
│
▼ release.published
GitHub Actions kicks off
You walk away when CI prints success. No alt-tabs.
The pipeline
Every shippable repo has the same shape: a local pre-ship gate, a GitHub Release, a workflow that fires on release.published.
your laptop GitHub k3s
─────────── ────── ───
hearth ship api
pre-ship tests ──────────────────────────────────────────────────────────────► pass / fail (blocks)
imp ship --patch ─────────────────────────► refs/tags/v0.0.28 (push)
release.create ─────────────────────────► Release(tag=v0.0.28, latest)
│
▼
.github/workflows/release.yml fires
│ (calls katforge/hearth template)
┌────────────────────┴────────────────────┐
▼ ▼
docker build azure/setup-kubectl
docker push ─────────► ghcr.io/katforge/api:v0.0.28
│
▼
kubectl set image -n default ──► pod restart
kubectl rollout status init: migrate
container: v0.0.28
◄─── tail run via GitHub API ─── (status of each step streamed back)
Five clean responsibilities:
| Stage | Who runs it | Why there |
|---|---|---|
| Pre-ship tests | Your laptop | Fastest possible feedback. No CI minutes burned on broken code. |
imp ship | Your laptop | Commit + tag + push is naturally local; you're the author. |
| Release publish | Your laptop (via API) | Explicit "this should deploy" gate, not implicit on tag push. |
| Build + push | GitHub Actions | Reproducible clean room. Doesn't depend on your laptop's docker daemon, node_modules, or branch state. |
| Apply to k3s | GitHub Actions | One audited path to prod. No kubectl from anyone's laptop. |
Stage routing via the prerelease flag
The same image can deploy to qa or default based on a single boolean on the Release.
| Hearth invocation | imp ship flag | Tag shape | Release flag | CI deploys to |
|---|---|---|---|---|
hearth ship api | --stable | v0.0.28 | latest | default namespace (prod) |
hearth ship api --qa | --rc | v0.0.28-rc.1 | prerelease=true | qa namespace |
hearth ship api --draft | --stable | v0.0.28 | draft=true | nothing (workflow doesn't fire on drafts) |
hearth promote api v0.0.28-rc.1 | (no version bump) | (existing tag) | prerelease=false, latest=true | default namespace (prod) |
Promoting QA to prod (no rebuild)
hearth promote api v0.0.28-rc.1
Edits the release: prerelease=false, make_latest=true. The same release.published event fires, but with prerelease=false now. CI deploys the same image to default. Zero rebuild.
This is the cleanest "promote what you tested" flow possible without GitOps. The hearth promote command is just a thin wrapper around the GitHub API edit; the underlying mechanism is fully GitHub-native.
Service registry: hearth/services.yaml
Every shippable service lives in hearth/services.yaml:
api:
directory: api.katforge.com
image: ghcr.io/katforge/api.katforge.com
dockerfile: Dockerfile.prod
k8s_name: api
dev_host: api.dev.katforge.com
pre_ship_cmd: ./bin/phpunit --testsuite Feature
geargoblins:
directory: geargoblins.com/ui
image: ghcr.io/katforge/geargoblins.com
k8s_name: geargoblins
shippable: false
shippable_reason: No Dockerfile in geargoblins.com/ui yet.
To add a service: add a block to services.yaml. To exclude one from hearth ship all: set shippable: false. No Python edits.
All projects, same workflow
Every shippable repo has .github/workflows/release.yml that calls a single reusable template at katforge/hearth/.github/workflows/release-template.yml. The caller stub is ~12 lines:
# api.katforge.com/.github/workflows/release.yml
on:
release:
types: [published]
jobs:
ship:
uses: katforge/hearth/.github/workflows/release-template.yml@main
with:
service: api
image: ghcr.io/katforge/api.katforge.com
deployment: api
dockerfile: Dockerfile.prod
secrets:
KATFORGE_GITHUB_TOKEN: ${{ secrets.KATFORGE_GITHUB_TOKEN }}
K3S_KUBECONFIG: ${{ secrets.K3S_KUBECONFIG }}
The template handles checkout, optional sibling multi-checkout, optional npmrc generation, optional monorepo manifest pull, docker build/push, kubectl set image, and rollout. Drift between repos becomes structurally impossible — the only per-repo state is the inputs.
What inputs each project passes
| Input | api | katforge | stumper | lextris |
|---|---|---|---|---|
service_path | (root) | katforge.com | stumper.gg | lextris.com |
dockerfile | Dockerfile.prod | katforge.com/Dockerfile | stumper.gg/Dockerfile | lextris.com/Dockerfile |
siblings | none | sdk, spark | sdk, spark | 9 sibling repos |
needs_npmrc | false | true | false | false |
monorepo_manifests | false | false | false | true |
build_args | none | none | VITE_API_URL, VITE_MERCURE_URL | none |
Inputs reflect each project's existing build setup. The mechanism is uniform.
Migrations
Schema changes ship through a Doctrine migrate init container declared in hearth/k8s/api/deployment.yaml. The init image is pinned to the same tag as the main container, so the code that authored the migrations is the code that runs them — no risk of a newer migration applying against an older codebase. A failed migration fails the rollout, so kubectl rollout status is the migration health check too. Bad schema → new ReplicaSet stuck pending → old pod keeps serving traffic.
Pre-ship test gate
Pre-ship tests run on your laptop, before any git mutation. Configured per service in hearth/services.yaml:
api:
pre_ship_cmd: ./bin/phpunit --testsuite Feature
The frontends have no pre-ship gate yet. Add one if you want by setting pre_ship_cmd in services.yaml.
If you need to ship past a failing or hung test (production hotfix), --skip-tests bypasses the gate. The gate is local-only — CI doesn't re-run it. Trust your tests.
CI watch (default behavior)
hearth ship tails the GitHub Actions run by default. Each step transitions print to your terminal as they happen — final summary looks like:
✓ api v0.0.28 → release published as latest (prod)
✓ Build and push image
✓ Deploy to k3s
✓ api v0.0.28 deployed
Each ▸ step prints when it begins and the matching ✓ lands when it completes, so you see progress in real time without alt-tabbing to GitHub. Use --no-watch to skip the tail and exit immediately after the release POST.
First-time CI setup
hearth setup --ci
This bootstraps the entire CI plumbing in one command:
- Applies
hearth/k8s/ci/rbac.yaml(createscinamespace,github-deployerSA, scoped RBAC, long-lived token Secret). - Reads the SA token + cluster CA + server URL, builds a kubeconfig, base64-encodes it.
- Sets two repo secrets across every shippable repo:
K3S_KUBECONFIG(the kubeconfig) andKATFORGE_GITHUB_TOKEN(the value of your laptop'sGITHUB_KATFORGE_TOKEN, used for ghcr push and sibling repo checkouts). The secret name flips word order because GitHub reserves theGITHUB_prefix for stored secrets.
After this completes, hearth ship works end-to-end. Re-run when adding a new shippable service or rotating the kubeconfig.
Required tooling
hearth ship needs three tools on your laptop:
| Tool | Role |
|---|---|
imp | The "split commits, bump version, tag, push" half. Required. |
git | Used internally by hearth to read tags and remotes. |
GITHUB_KATFORGE_TOKEN env var | Used to POST /releases, watch CI, and seed the KATFORGE_GITHUB_TOKEN repo secret during hearth setup --ci. |
Hearth no longer requires docker or kubectl for shipping. They stay required for hearth status, hearth logs, and hearth rollback.
Failure modes
The pipeline has natural checkpoints; failure at each one stops cleanly without partial state.
| Failure | Where | What's left behind | Recovery |
|---|---|---|---|
| Pre-ship tests fail | Laptop | Nothing. No commit. | Fix the tests. Or --skip-tests if it's an emergency. |
imp ship fails | Laptop | Possibly a partial commit. Inspect with git status. | Fix manually, then hearth ship --no-version to release the existing tag. |
| Release POST fails | Laptop | Tag exists on GitHub but no Release. | hearth ship --no-version to retry just the release step. |
| Workflow fails at build | GitHub Actions | Tag + Release exist. No image in ghcr. | Delete the Release in the UI, fix the issue, hearth ship again. |
| Workflow fails at deploy | GitHub Actions | Image is in ghcr but k3s wasn't updated. | Re-run the workflow from the GitHub UI, or hearth rollback if a partial roll happened. |
| Migration fails in init container | k3s | New ReplicaSet stuck pending. Old pod still serving traffic. | hearth rollback api and investigate the migration. Prod stays green. |
The migration init container is the load-bearing safety net. A bad schema change fails the rollout, the new ReplicaSet never goes ready, the old pod keeps serving traffic. No manual rollback needed for the half-deployed case.
Manual deploys
Sometimes you need to redeploy without changing code. Examples: a config-only secret rotation, a botched first deploy, a workflow that crashed mid-flight.
hearth ship api --no-version # POST a Release for the current tag, no version bump
This skips imp ship entirely and creates a Release for whatever the most recent tag is. CI fires as if nothing else happened.
If the Release already exists, the POST will 422. Either delete it first in the UI or bump the version with a regular hearth ship.
Rollback
Two paths.
hearth rollback api # fast: kubectl rollout undo
Reverts the cluster, leaves the broken Release marked latest on GitHub. Fine if you'll re-ship a fix soon.
For a clean record: in the GitHub Releases UI, edit the broken release, mark it as draft (or delete), then "Set as latest" on the previous good release. The workflow fires again with the older tag, deploying the same image to k3s. Cluster and "what's in prod on GitHub" stay in sync.
Appendix: idiomatic vs. pragmatic
The pipeline is well-engineered for a single-instance k3s with a small team. A few deliberate non-best-practices worth knowing about:
| Choice | More idiomatic alternative | Why we don't | When to revisit |
|---|---|---|---|
kubectl set image | Kustomize + kubectl apply | Imperative is fine for one cluster, one deployment per service. Declarative would be overkill until we run multiple environments. | Adding a second cluster, or going multi-region. |
| Image tag pinning | Pin by digest (@sha256:...) | Tag immutability is sufficient with our :$TAG pattern (:$TAG is never overwritten; only :latest moves). | Adding image signing or SLSA provenance. |
| PAT for ghcr push | OIDC (permissions: packages: write + GitHub's auto-mint) | Tried it; GitHub orgs can deny default GITHUB_TOKEN access to packages, and ours did. PAT is the simpler fix. | If/when GitHub fixes default ghcr permissions for org-owned packages. |
| Long-lived SA token | Bound service-account tokens (kubectl create token --duration=1h) | Bound tokens require a token-refresh step in CI, adding complexity. Long-lived is acceptable when scoped tightly (our SA can only patch deployments in default + qa). | If we ever leak a kubeconfig and need fast rotation. |
release.published trigger | ArgoCD watching a manifest repo | Requires a separate manifest repo and ArgoCD install. Worth it past ~3 services per cluster. | When you'd rather not run kubectl even from CI. |
| Multi-checkout for frontends | Publish workspace packages to npm registry | Publishing requires a separate release process per package. Multi-checkout works today. | When packages/* velocity is high enough that publish-on-change pays for itself. |
What still uses local docker
hearth up, hearth dev, and hearth status still use your local docker daemon and kubectl. Only hearth ship is CI-driven now.