- Makefile 40.1%
- Go Template 31.7%
- Dockerfile 14.1%
- TypeScript 14.1%
| Filename | Latest commit message | Latest commit date |
|---|---|---|
| .forgejo | ||
| deploy/chart | ||
| docs | ||
| src | ||
| Dockerfile | ||
| Makefile | ||
| package.json | ||
| README.md | ||
buntastic
Minimal Bun/TypeScript HTTP API. Returns {"status":"ok","tag":"...","commit":"..."}.
Stack
- Bun — runtime + package manager
- Docker — local containerisation
- Helm — Kubernetes deployment
- Forgejo Actions — build, test, and promote across environments
- ArgoCD — GitOps delivery (pulls from this repo into the cluster)
Repo Structure
├── src/
│ └── index.ts # API entrypoint
├── deploy/
│ └── chart/
│ ├── Chart.yaml
│ ├── values.yaml # defaults (image.repository, pull secret)
│ ├── values-dev.yaml
│ ├── values-sit.yaml
│ ├── values-uat.yaml
│ ├── values-prd.yaml
│ └── templates/
│ ├── _helpers.tpl
│ ├── deployment.yaml
│ ├── service.yaml
│ └── ingress.yaml
├── .forgejo/
│ ├── workflows/
│ │ └── ci.yml # the pipeline
│ └── actions/
│ └── bump-tier-values/ # composite action: write image.tag + push to main
│ └── action.yml
├── docs/
│ └── DEPLOYMENT.md # deployment guide + audit/evidence model
├── Dockerfile
└── Makefile
Local Development
Prerequisites
- Bun ≥ 1.0
- Docker Desktop
Run without Docker
bun run dev # hot-reload watch mode
bun run start # production mode
Make targets
make # show help
make build # build Docker image
make run # build + run container on :3000
make test # curl http://localhost:3000
make nuke # remove container and image
Test the endpoint
curl http://localhost:3000
# {"status":"ok","tag":"dev","commit":"abc1234"}
Docker
Image is built with two build args baked in at build time:
| Build arg | Source in CI | Source in make build |
Default |
|---|---|---|---|
IMAGE_TAG |
${FORGEJO_SHA} (first 8 chars) |
git describe --tags |
dev |
COMMIT_SHA |
${FORGEJO_SHA} (first 8 chars) |
git rev-parse --short HEAD |
local |
docker build \
--build-arg IMAGE_TAG=v1.0.0 \
--build-arg COMMIT_SHA=$(git rev-parse --short HEAD) \
-t buntastic .
Helm Chart
Chart lives at deploy/chart/. Values are layered — base defaults plus a per-tier override file.
The base deploy/chart/values.yaml sets the registry and pull secret:
image:
repository: git.i.tbase.pro/include/buntastic
imagePullSecrets:
- name: forgejo-registry
The per-tier files (values-<tier>.yaml) override only image.tag (set by CI), replicaCount, and ingress.
Deploy manually (for debugging only — production uses ArgoCD, not helm)
helm upgrade --install buntastic deploy/chart \
-f deploy/chart/values.yaml \
-f deploy/chart/values-sit.yaml \
--set image.tag=abc1234 \
--namespace buntastic-sit \
--create-namespace
Values per tier
| File | Replicas | image.tag |
Ingress |
|---|---|---|---|
values-dev.yaml |
1 | set by CI promotion | on |
values-sit.yaml |
2 | set by CI promotion | on |
values-uat.yaml |
2 | set by CI promotion | on |
values-prd.yaml |
3 | set by CI promotion | on |
image.tagin every tier file is owned by the CI promotion gates — humans never edit it by hand (except during a rollback revert, seedocs/DEPLOYMENT.md).
Ingress host strategy
This is a GitOps repo: CI never runs helm, so the ingress host cannot be injected at deploy time. The host belongs in the values files (ingress.host per tier) or as a Helm parameter on the ArgoCD Application. ArgoCD renders the chart from those files when it syncs.
Each tier sets a concrete
ingress.host(buntastic.<tier>.i.tbase.pro, prd at the apexbuntastic.i.tbase.pro). Never leave it empty: an Ingress rule with no host is a wildcard catch-all that hijacks all unmatched traffic on thekongclass.
Kong-specific routing (plugins, rate limits, auth, TLS) belongs in the platform/infra repo, not here. This chart only declares the ingress host and ingressClassName: kong.
Add a new tier
- Copy an existing
values-<tier>.yaml; adjust replicas, ingress host, and image tag default. - Add the tier to the
tierchoice list in.forgejo/workflows/promote.ymland to the source-tiercase(which tier it promotes from). - Add a
<TIER>_OPERATORSrepo variable listing who may promote it.
Branching model (trunk-based)
main is the only permanent branch — protected, merge-request-only for humans. There is no develop, no release/*, no environment branches.
| Branch | Trigger | Behaviour |
|---|---|---|
feature/* |
pull request | lint only — fast feedback, no build |
main |
merge (source change) | ci.yml: build once → test → deploy:dev (automatic). Stops at dev. |
| sit / uat / prd | operator clicks Promote | promote.yml: manual workflow_dispatch, copies the image from the tier below |
main |
tag vX.Y.Z |
release.yml labels the image running in prd (no rebuild, no deploy) |
Build once, promote by SHA
The image is built once when code merges to main, tagged with the commit SHA, and auto-deployed to dev. That same SHA is promoted unchanged to sit → uat → prd by an operator clicking the Promote workflow — never rebuilt. :latest is pushed as a convenience pointer only — never used for promotion or release.
CI/CD Pipeline (Forgejo Actions + GitOps with ArgoCD)
How it works
Forgejo Actions has no environment approval gates (the environment: key does not pause for reviewers), so promotion is not a chained pipeline — that would cascade dev→prd automatically. Instead it is split across three workflows. ArgoCD watches main and syncs the cluster. CI never touches the cluster — no kubeconfig, no helm upgrade, no kubectl, no ArgoCD API.
merge to main (source change) ── ci.yml ──────────────────────────────
├─ lint → bun build src/index.ts (compile check)
├─ build → push image :sha → built ONCE
├─ test → smoke test → the exact image that will be promoted
└─ deploy:dev (AUTOMATIC) → commits :sha to values-dev.yaml → ArgoCD syncs dev
operator: Actions → Promote ── promote.yml (workflow_dispatch) ─────────
├─ pick tier (sit|uat|prd)
├─ authorize: actor ∈ <TIER>_OPERATORS (else reject, fail-closed)
├─ copy image tag from tier below (sit←dev, uat←sit, prd←uat)
└─ bump-tier-values → commits :sha to values-<tier>.yaml → ArgoCD syncs <tier>
tag main vX.Y.Z ── release.yml ─────────────────────────────
└─ re-tag the prd image as :vX.Y.Z (label only — no rebuild, no deploy)
dev is automatic; sit/uat/prd require an operator to click Promote (the manual trigger is the gate). Order is enforced by construction — each tier copies the image from the tier below.
Every promotion commit uses the standardised message (the authorisation evidence — see docs/DEPLOYMENT.md):
chore(deploy/<tier>): bump image to <sha> pipeline=<id> by=<user> [skip ci]
Bump commits don't trigger pipelines — both via [skip ci] and the paths filter on ci.yml (a values-only change never rebuilds).
Two safety controls on promotion
The bump-tier-values composite action and the workflow add two independent guards:
- 3-attempt retry loop — absorbs concurrent-push races between gates (two pipelines pushing to
mainat once). Handles write contention. - Monotonic guard — before writing, it checks
git merge-base --is-ancestor: if the incoming SHA is an ancestor of the SHA already in the tier file, a newer image is already promoted, so it refuses to roll the tier backward and exits 0 (benign). Handles ordering. Intentional rollback (via PR, seedocs/DEPLOYMENT.md§5) bypasses this — it is not a CI promotion.
The build job also has a job-scoped concurrency group (cancel-in-progress: true) so a newer commit cancels a redundant in-flight build. Scoped to build only. Pure efficiency — correctness is owned by the monotonic guard.
What runs where
ci.yml: lint → build → test → deploy:dev. promote.yml: promote (manual). release.yml: release (on tag).
| Job | Workflow | Image | What it does |
|---|---|---|---|
lint |
ci.yml | oven/bun:1-alpine |
Compiles src/index.ts via bun build |
build |
ci.yml | docker:27 |
Builds and pushes image to registry (main, source change only) |
test |
ci.yml | docker:27 |
Spins up built image, curls /, asserts 200 |
deploy:dev |
ci.yml | alpine:3 (+ git, yq) |
Auto-bumps values-dev.yaml via the composite action |
promote |
promote.yml | alpine:3 (+ git, yq) |
Manual: authorize actor, copy tag from tier below, bump values-<tier>.yaml |
release |
release.yml | docker:27 |
Re-tags the prd image (from values-prd.yaml) as vX.Y.Z |
(Docker for build/test/release comes from the runner's docker-in-docker, not an in-workflow service — see the runner setup.)
Image tags pushed
| Event | Tags |
|---|---|
Merge to main |
:<sha> (first 8 of FORGEJO_SHA) + :latest (convenience pointer only) |
Tag vX.Y.Z |
:vX.Y.Z — re-tag of the SHA recorded in values-prd.yaml, never :latest |
Repo protections
- Branch protection on
main— humans change it only via reviewed pull requests; no force-push; history cannot be rewritten. Use Forgejo branch-protection approval rules to restrict who may approve changes todeploy/chart/values-prd.yamland.forgejo/workflows/ci.yml. - The
GIT_PUSH_TOKENbot user must be in "Allowed to push" for the protectedmain, otherwise CI's own bump commits are blocked.
Required Forgejo configuration
Settings → Secrets:
| Name | Value |
|---|---|
GIT_PUSH_TOKEN |
Forgejo Personal Access Token for a bot user — write:repository + write:package scope, allowed to push to protected main. Used both for docker login (registry push) and the bump git push. |
REGISTRY_USER |
The bot user's username (the docker login username; GIT_PUSH_TOKEN is the password). |
Settings → Variables — per-tier operator allowlists (comma-separated Forgejo usernames). The Promote workflow checks the clicking operator against the target tier's list and fails closed (unset = nobody may promote):
| Variable | Who may promote |
|---|---|
SIT_OPERATORS |
e.g. alice,bob |
UAT_OPERATORS |
e.g. bob,carol |
PRD_OPERATORS |
e.g. carol |
Forgejo has no GitHub-style environment required-reviewers — that's why the gate is the manual Promote trigger plus this allowlist, not an
environment:setting. Seedocs/DEPLOYMENT.md§3, §7.
Operators access (Settings → Collaborators, or Org → Teams): operators need repo write — that is the minimum role that can trigger a workflow_dispatch ("Run workflow"). Grant it via an operators team with Write permission (org repos) or per-user Write collaborators (user repos). Do not grant Admin. Branch protection (below) stops this write role from pushing to main directly.
Branch protection (Settings → Branches → add rule for main):
| Setting | Value | Why |
|---|---|---|
| Enable Push (whitelist) | ON — whitelist the bot user only (GIT_PUSH_TOKEN's user) |
Bot's dev-bump and promote-bump pushes succeed; humans cannot direct-push |
| Require pull requests | ON | Human code changes land via PR |
| Required approvals | 1+ |
Code review gate |
| Block force push | ON | History cannot be rewritten |
(optional) Require status check lint |
ON | PRs must pass lint to merge |
(optional) Restrict approvals on .forgejo/** |
ON | Hardens the soft actor-allowlist — stops operators editing promote.yml |
Critical: the bot user must be in the push whitelist. If it isn't,
deploy:devand every promotion fail — the bumpgit push origin HEAD:mainis rejected by branch protection. This is the #1 misconfiguration to check.
The model rests on one asymmetry: operators hold write (to run actions) but are blocked from pushing main; the bot is the only identity on the push whitelist. Promotion flows only through the workflow because that is the one path with a credential allowed to write main.
Verify the round-trip once configured:
- Merge a trivial source change →
ci.ymlruns →deploy:devbump lands onmain(proves the bot whitelist works). - As an allowed operator: Actions → Promote → sit → Run → the bump lands on
main, ArgoCD syncs sit. - As a non-allowed user: the Promote run is rejected at the
Authorize actor for tierstep (fail-closed).
Runner: a docker-capable runner exposing the ubuntu-latest label (matches runs-on: ubuntu-latest). On this instance that label maps to catthehacker/ubuntu:act-latest, but every job overrides the image via container: (bun / docker+dind / alpine), so the label only selects the runner. List runners at /{owner}/{repo}/settings/actions/runners.
No
KUBECONFIGneeded. The deploy jobs never contact the cluster — they only write the image tag into the tier values file andgit pushit back to this repo. ArgoCD, running inside the cluster, reaches out to the repo, pulls the change, and syncs. Access flows cluster → repo, never repo → cluster.
ArgoCD setup (cluster/infra repo)
ArgoCD is configured in your cluster/infra repo, not here. This repo only needs to be reachable by ArgoCD.
ArgoCD Application CRDs
Create one Application per environment in your cluster repo. ArgoCD watches deploy/chart/ in this repo and re-syncs whenever a values file changes.
All environments use auto-sync. The approval gate is enforced in Forgejo before the bump commit ever lands on main — once a SHA is committed, it is authorised, and ArgoCD applies it without further human action. Humans only ever click in Forgejo; ArgoCD is an observability and rollback tool, not an approval step.
Template (swap dev for the tier):
apiVersion: argoproj.io/v1alpha1
kind: Application
metadata:
name: buntastic-dev
namespace: argocd
spec:
project: default
source:
repoURL: https://git.i.tbase.pro/include/buntastic.git
targetRevision: main
path: deploy/chart
helm:
valueFiles:
- values.yaml
- values-dev.yaml
destination:
server: https://kubernetes.default.svc
namespace: buntastic-dev
syncPolicy:
automated:
prune: true
selfHeal: true
Forgejo Actions setup
1. Enable Forgejo Actions
In app.ini on your Forgejo instance:
[actions]
ENABLED = true
2. Register a runner (exposing the ubuntu-latest label)
The workflow uses runs-on: ubuntu-latest. Register a docker-backed runner whose
config maps that label to a host image — e.g. in the runner's config.yaml:
labels:
- ubuntu-latest:docker://catthehacker/ubuntu:act-latest
- ubuntu-22.04:docker://catthehacker/ubuntu:act-22.04
- ubuntu-24.04:docker://catthehacker/ubuntu:act-24.04
- node-20:docker://node:20-bookworm
- node-22:docker://node:22-bookworm
Only ubuntu-latest is required by this pipeline; the jobs override the actual
step image via container:, so the mapped host image just selects the runner.
forgejo-runner register \
--instance https://git.i.tbase.pro \
--token <runner-token> \
--name buntastic-runner \
--labels ubuntu-latest:docker://catthehacker/ubuntu:act-latest
3. Allow external actions
The workflow uses actions/checkout@v4. By default Forgejo may block fetching actions from GitHub. Either mirror actions/checkout into your instance, or allow GitHub in app.ini:
[actions]
DEFAULT_ACTIONS_URL = https://github.com
4. Set secrets and variables
See Required Forgejo configuration above — GIT_PUSH_TOKEN + REGISTRY_USER secrets, and the SIT_OPERATORS / UAT_OPERATORS / PRD_OPERATORS variables.
dev deploys automatically on merge. To promote to sit/uat/prd, an authorised operator runs Actions → Promote → Run workflow and picks the tier — the manual trigger is the gate.