Part 4 — Wire up CI/CD
We’ll add a GitHub Actions workflow that, on every push to main:
- Detects which components changed (from
.github/deploy-config.yml). - Builds and pushes Docker images to GHCR.
- Templates the cast with the per-environment values.
- Calls
rune castvia therunestack/rune-cast-action.
If you want the bare-bones workflow without auto-detection, the Deploy from GitHub Actions guide is shorter.
1. Mint a scoped service token
Section titled “1. Mint a scoped service token”From your laptop (still logged in as admin from part 2):
rune admin service create ci-myapp \ --namespace dev \ --permissions cast \ --ttl 90d \ --description "GitHub Actions deploys to dev" \ --out-file ci-myapp.tokenThis creates a service-type subject whose policy is pinned to namespace: dev. The token (looks like rune_<uuid>.<uuid>) is shown only once. Repeat with --namespace prod if you have a prod environment.
2. Push the token + host to GitHub
Section titled “2. Push the token + host to GitHub”gh secret set RUNE_TOKEN --body "$(cat ci-myapp.token)"gh variable set RUNED_HOST --body "$(terraform -chdir=infra/terraform/do output -raw grpc_endpoint)"
shred -u ci-myapp.token # or `rm -P` on macOSFor a prod environment, use GitHub Environments so the prod job physically can’t see the dev token:
gh secret set RUNE_TOKEN --body "$(cat ci-prod.token)" --env production3. Declare what’s deployable — .github/deploy-config.yml
Section titled “3. Declare what’s deployable — .github/deploy-config.yml”This file is the single place that lists components, their Dockerfiles, and the paths that trigger a rebuild:
global: registry: ghcr.io repo: your-org/myapp
# Branch → environment mappingenvironments: main: dev
components: web: dockerfile: apps/web/Dockerfile context: . cast: infra/runeset/casts/web.yaml paths: - apps/web/** - infra/runeset/casts/web.yaml - infra/runeset/values/**Add an entry per component. The workflow rebuilds + redeploys only the components whose paths matched the diff.
4. The workflow — .github/workflows/deploy.yml
Section titled “4. The workflow — .github/workflows/deploy.yml”name: Deploy
on: push: branches: [main] paths: - 'apps/**' - 'infra/runeset/**' - '.github/deploy-config.yml' - '.github/workflows/deploy.yml'
concurrency: group: deploy-${{ github.ref }} cancel-in-progress: false
jobs: # ── Detect what changed ───────────────────────────────────────── detect: runs-on: ubuntu-latest outputs: components: ${{ steps.detect.outputs.components }} environment: ${{ steps.detect.outputs.environment }} steps: - uses: actions/checkout@v4 with: { fetch-depth: 0 }
- id: detect env: { BRANCH: ${{ github.ref_name }} } shell: python run: | import yaml, os, fnmatch, subprocess, json cfg = yaml.safe_load(open('.github/deploy-config.yml')) env = cfg.get('environments', {}).get(os.environ['BRANCH'], os.environ['BRANCH']) try: base = subprocess.check_output( ['git','merge-base','HEAD',f'origin/{os.environ["BRANCH"]}'], text=True).strip() except Exception: base = 'HEAD^' changed = subprocess.check_output(['git','diff','--name-only',base,'HEAD'], text=True).splitlines() detected = [] for name, comp in sorted(cfg.get('components', {}).items()): for cf in changed: if any(fnmatch.fnmatch(cf, p) for p in comp.get('paths', [])): detected.append(name); break with open(os.environ['GITHUB_OUTPUT'], 'a') as out: out.write(f'environment={env}\n') out.write(f'components={json.dumps(detected)}\n')
# ── Build + push each changed component ───────────────────────── build: runs-on: ubuntu-latest needs: detect if: needs.detect.outputs.components != '[]' && needs.detect.outputs.components != '' strategy: fail-fast: false matrix: component: ${{ fromJson(needs.detect.outputs.components) }} permissions: contents: read packages: write steps: - uses: actions/checkout@v4
- id: cfg shell: python run: | import yaml, os c = yaml.safe_load(open('.github/deploy-config.yml')) g = c['global']; comp = c['components']['${{ matrix.component }}'] with open(os.environ['GITHUB_OUTPUT'], 'a') as o: o.write(f"registry={g['registry']}\n") o.write(f"repo={g['repo']}\n") o.write(f"context={comp['context']}\n") o.write(f"dockerfile={comp['dockerfile']}\n")
- uses: docker/metadata-action@v5 id: meta with: images: ${{ steps.cfg.outputs.registry }}/${{ steps.cfg.outputs.repo }}/${{ matrix.component }} tags: | type=sha,format=short type=raw,value=${{ needs.detect.outputs.environment }}
- uses: docker/setup-buildx-action@v3 - uses: docker/login-action@v3 with: registry: ghcr.io username: ${{ github.actor }} password: ${{ secrets.GITHUB_TOKEN }}
- uses: docker/build-push-action@v6 with: context: ${{ steps.cfg.outputs.context }} file: ${{ steps.cfg.outputs.dockerfile }} push: true tags: ${{ steps.meta.outputs.tags }} labels: ${{ steps.meta.outputs.labels }} cache-from: type=gha cache-to: type=gha,mode=max
# ── Cast each changed component to Rune ───────────────────────── cast: runs-on: ubuntu-latest needs: [detect, build] if: needs.detect.outputs.components != '[]' && needs.detect.outputs.components != '' strategy: fail-fast: false matrix: component: ${{ fromJson(needs.detect.outputs.components) }} steps: - uses: actions/checkout@v4
# Lift values.<component>.component → top-level component: # so the shared cast template renders. - id: resolve shell: python run: | import yaml, os, re cfg = yaml.safe_load(open('.github/deploy-config.yml')) env = '${{ needs.detect.outputs.environment }}' comp = '${{ matrix.component }}' values = yaml.safe_load(open(f'infra/runeset/values/{env}.yaml'))
def flatten(d, p=''): out = {} for k,v in d.items(): key = f'{p}.{k}' if p else k if isinstance(v, dict): out.update(flatten(v, key)) else: out[key] = '' if v is None else str(v) return out flat = flatten(values)
# Promote component.* keys inner = values.get(comp, {}).get('component', {}) for k,v in inner.items(): flat[f'component.{k}'] = '' if v is None else str(v) # Tag default if not flat.get('app.tag'): flat['app.tag'] = env
content = open(cfg['components'][comp]['cast']).read() content = re.sub(r'\{\{\s*values:([\w.]+)\s*\}\}', lambda m: flat.get(m.group(1).strip(), f'__UNSET:{m.group(1)}__'), content) open('/tmp/cast.yaml','w').write(content) print(content)
- uses: runestack/rune-cast-action@v1 with: server: ${{ vars.RUNED_HOST }} token: ${{ secrets.RUNE_TOKEN }} source: /tmp/cast.yaml namespace: ${{ needs.detect.outputs.environment }} create-namespace: true5. What the workflow does
Section titled “5. What the workflow does”detectlooks atgit diffsince the merge-base, matches changed paths againstcomponents.*.pathsindeploy-config.yml, and emits a JSON list.buildruns once per changed component — same Docker matrix you’d write by hand, taggedsha-<short>and<env>.castrenders the sharedcasts/web.yamltemplate againstvalues/<env>.yamlwithvalues.<component>.componentpromoted to top-levelcomponent:, then callsrune castvia the action.
The rune-cast-action:
- Installs the matching
runeCLI (cached per version). - Calls
::add-mask::on the token so any echo is***. - Pipes the token over stdin (
--token-stdin) so it never appears in argv.
6. Push and watch it run
Section titled “6. Push and watch it run”git add infra/ apps/ .github/git commit -m "infra: terraform + runeset + ci"git push origin mainOpen the workflow in GitHub Actions. The first run typically:
| Step | ~time |
|---|---|
detect | <10s |
build (cold cache) | 1-3 min per component |
cast | 10-30s including image pull on the droplet |
7. Token rotation
Section titled “7. Token rotation”Calendar a rotation a week before TTL:
rune admin token listrune admin service create ci-myapp \ --namespace dev --permissions cast --ttl 90d \ --out-file ci-myapp.tokengh secret set RUNE_TOKEN --body "$(cat ci-myapp.token)"shred -u ci-myapp.token# revoke the old onerune admin token revoke <old-token-id>Next: Part 5 — Deploy & verify → · Back: Part 3 — Runeset