No description
  • Makefile 40.1%
  • Go Template 31.7%
  • Dockerfile 14.1%
  • TypeScript 14.1%
Find a file
Repository files (latest commit first)
Filename Latest commit message Latest commit date
2026-06-19 22:37:53 +00:00
.forgejo chore(ci): remove temporary docker daemon debug step 2026-06-19 23:36:01 +01:00
deploy/chart chore(deploy/dev): bump image to 9ea18180 pipeline=17 by=include [skip ci] 2026-06-19 22:37:53 +00:00
docs ci: split deployment into auto-dev + manual click-promote 2026-06-19 22:46:53 +01:00
src test 2026-06-19 23:05:56 +01:00
Dockerfile refactor from gitlab to forgejo 2026-06-18 21:55:25 +01:00
Makefile refactor from gitlab to forgejo 2026-06-18 21:55:25 +01:00
package.json refactor from gitlab to forgejo 2026-06-18 21:55:25 +01:00
README.md docs(readme): add operator access & branch-protection setup 2026-06-19 23:00:42 +01:00

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.tag in every tier file is owned by the CI promotion gates — humans never edit it by hand (except during a rollback revert, see docs/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 apex buntastic.i.tbase.pro). Never leave it empty: an Ingress rule with no host is a wildcard catch-all that hijacks all unmatched traffic on the kong class.

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

  1. Copy an existing values-<tier>.yaml; adjust replicas, ingress host, and image tag default.
  2. Add the tier to the tier choice list in .forgejo/workflows/promote.yml and to the source-tier case (which tier it promotes from).
  3. Add a <TIER>_OPERATORS repo 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:

  1. 3-attempt retry loop — absorbs concurrent-push races between gates (two pipelines pushing to main at once). Handles write contention.
  2. 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, see docs/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: lintbuildtestdeploy: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 to deploy/chart/values-prd.yaml and .forgejo/workflows/ci.yml.
  • The GIT_PUSH_TOKEN bot user must be in "Allowed to push" for the protected main, 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. See docs/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:dev and every promotion fail — the bump git push origin HEAD:main is 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:

  1. Merge a trivial source change → ci.yml runs → deploy:dev bump lands on main (proves the bot whitelist works).
  2. As an allowed operator: Actions → Promote → sit → Run → the bump lands on main, ArgoCD syncs sit.
  3. As a non-allowed user: the Promote run is rejected at the Authorize actor for tier step (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 KUBECONFIG needed. The deploy jobs never contact the cluster — they only write the image tag into the tier values file and git push it 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.