Co-authored-by: Cursor <cursoragent@cursor.com>
tinqs/ci
CI toolchain for Tinqs Studio — composite Gitea Actions and a Lambda dispatcher that orchestrates ephemeral Spot runners.
This repo must stay public. act_runner (go-git) clones action repos without auth. All other tinqs repos are private.
Architecture
Push → Gitea webhook → Lambda (tinqs-ci-dispatch) → EC2 Spot → act_runner → job → self-terminate
Runners are ephemeral: one Spot instance per job, self-terminates on completion. Private repo clones are authenticated via git config url.insteadOf injected in the runner user-data.
Key design decisions
- Ephemeral Spot instances (not Fargate, not persistent runners) — cheapest, cleanest, no state to manage.
--ephemeralonact_runner register— runner exits after one job, triggeringshutdown -h now→ instance terminates. Without this, runners pile up as zombies.- No local action cache — act_runner uses go-git internally which ignores
~/.gitconfig. Theurl.insteadOftrick only works for the git binary (used by checkout action). tinqs.com— Gitea's ROOT_URL istinqs.com. The oldgit.tinqs.comsubdomain is retired.
Actions
| Action | What it does |
|---|---|
tinqs/ci/checkout@v1 |
Clone a repo from tinqs.com (sparse checkout, depth control, token auth) |
tinqs/ci/setup-go@v1 |
Install Go (skips if pre-baked in AMI) |
tinqs/ci/setup-node@v1 |
Install Node.js + pnpm (skips if pre-baked) |
tinqs/ci/setup-aws@v1 |
Install AWS CLI + optional ECR login |
steps:
- uses: tinqs/ci/checkout@v1
with:
sparse: 'cmd/tstudio'
- uses: tinqs/ci/setup-go@v1
- uses: tinqs/ci/setup-aws@v1
with:
ecr-login: 'true'
Dispatcher (Lambda)
orchestrator/dispatch/main.go — receives Gitea webhooks, evaluates workflow triggers (branch + path filters), launches Spot instances with the right label.
| Label | Instance | Use |
|---|---|---|
go |
t3.small | Go builds (tstudio, proxy, docgen) |
docker |
t3.medium | Docker image builds (platform, bot) |
deploy |
t3.micro | S3 sync, ECS update |
node |
t3.medium | Frontend builds |
godot |
t3.medium | Game exports (future) |
Runner user-data flow: boot → git auth config → act_runner register (ephemeral) → daemon → job → exit → shutdown → terminate.
Runner Images
Dockerfiles in images/ — lean, purpose-built. Push to ECR with images/build-all.sh v1.
| Image | Contents |
|---|---|
base |
Alpine + git + AWS CLI + SSH |
go |
base + Go 1.26 |
node |
base + Node 22 + pnpm |
docker |
docker:dind + Go + AWS CLI |
deploy |
base only (lightest) |
godot |
base + headless Godot 4.6 |
Deploying the dispatcher
The dispatcher Lambda can't CI itself — deploy manually:
cd orchestrator/dispatch
# Build
GOOS=linux GOARCH=amd64 CGO_ENABLED=0 go build -o bootstrap -ldflags "-s -w" .
# Zip
# Windows:
powershell -Command "Compress-Archive -Path bootstrap -DestinationPath function.zip -Force"
# Mac/Linux:
zip -j function.zip bootstrap
# Deploy
aws lambda update-function-code --region eu-west-1 \
--function-name tinqs-ci-dispatch \
--zip-file fileb://function.zip
# Trigger a test build
# Push any change to cmd/tstudio/ in tinqs/studio
Lambda env vars
Configured in AWS console, not in code:
| Var | Purpose |
|---|---|
GITEA_URL |
https://tinqs.com |
GITEA_TOKEN |
API token — used for fetching workflows AND runner git auth |
RUNNER_TOKEN |
act_runner registration token (from Gitea admin → Runners) |
RUNNER_AMI |
Pre-baked AMI ID (Go, Node, Docker, act_runner installed) |
SUBNET |
VPC subnet for Spot instances |
SECURITY_GROUP |
SG allowing outbound HTTPS |
DDB_TABLE |
DynamoDB table for run tracking (tinqs-ci-runs) |
INSTANCE_PROFILE |
IAM role for runner instances (S3, ECR, ECS access) |
Monitoring
# Zombie check (should be 0 except during active builds)
aws ec2 describe-instances --region eu-west-1 \
--filters "Name=tag:tinqs-ci,Values=true" "Name=instance-state-name,Values=running" \
--query 'Reservations[].Instances[].InstanceId'
# Lambda dispatch logs (use MSYS_NO_PATHCONV=1 on Windows/Git Bash)
MSYS_NO_PATHCONV=1 aws logs tail '/aws/lambda/tinqs-ci-dispatch' --region eu-west-1
# Build logs
TOKEN=<your-gitea-token>
curl -s "https://tinqs.com/api/v1/repos/tinqs/studio/actions/jobs/<JOB_ID>/logs" \
-H "Authorization: token $TOKEN"
# Runner instance logs (while instance is alive)
aws ssm send-command --region eu-west-1 --instance-ids <ID> \
--document-name "AWS-RunShellScript" \
--parameters 'commands=["cat /var/log/tinqs-ci.log"]'
# Stale DynamoDB runs
aws dynamodb scan --region eu-west-1 --table-name tinqs-ci-runs \
--filter-expression "#s = :r" \
--expression-attribute-names '{"#s":"status"}' \
--expression-attribute-values '{":r":{"S":"running"}}' \
--query Count
Full debug guide: tinqs/docs/.cursor/skills/ci-pipeline-discipline/SKILL.md
Contributing
Adding a new composite action
- Create
<action-name>/action.ymlwithusing: compositeandshell: bash - Keep it simple — no Node.js runtime, just bash
- Add a
<action-name>/README.mdwith inputs/outputs - Add to the Actions table in this README
- Push to main — actions resolve via
@v1(main branch)
Modifying the dispatcher
- Edit
orchestrator/dispatch/main.go - Build:
go build .(catches compile errors) - Deploy manually (see Deploying above)
- Verify: push a change to
tinqs/studioand watch the pipeline
Adding a new runner label
- Add entry to
labelToSpotmap inmain.go - Create
images/<label>/Dockerfileif needed - Build and push image:
cd images && ./build-all.sh v1 - Deploy updated Lambda
- Add
runs-on: <label>to the workflow that needs it
Updating the AMI
- Launch a t3.small from the current AMI (
RUNNER_AMIenv var) - SSH in, install/update tools
- Create AMI:
aws ec2 create-image --instance-id <ID> --name tinqs-ci-runner-v<N> - Update
RUNNER_AMILambda env var - Terminate the build instance
Incidents
- 25 May 2026: 18 zombie runners DDoS-ing Gitea. Root cause: no
--ephemeralon registration + no git auth after repos went private. Full post-mortem:tinqs/internal/incidents/ci-zombie-runners-2026-05-25.md