Thalix VaultDocumentation
Browse documentation
Operators
publishedplannedEdit this page

Runbook: GitHub Actions cost control

The CI workflow was manually disabled on 2026-08-20 after the organization's included GitHub Actions allocation was exhausted — every push and every pull request had been running the full Windows, Supabase, and Ubuntu test suite. This runbook is the cost-controlled replacement (#30): it is planned, not yet active. CI still reports disabled_manually and the expensive candidate workflow is not even a file GitHub can discover (see Activation). Nothing here runs automatically until the repository owner completes the activation steps below.

Local verification is the primary loop

CI is a merge gate, never an iterative debugger. Run checks locally before pushing:

bun install --frozen-lockfile
bun run format:check && bun run lint && bun run typecheck
bun run test:unit && bun run test:contract && bun run test:contract:device
bun run test:security && bun run test:security:device
bun run test:docs && bun run test:credential-leaks
bun run build

Batch commits into a single push once the batch passes locally, rather than pushing after every edit to let a CI run tell you what's wrong. For anything touching the database or Supabase Auth, run the Supabase suite locally the same way .github/workflows/ci-expensive.yml.disabled's supabase job does (supabase start, supabase db reset, bun run test:db, bun run test:mvp004c:local-auth, supabase stop --no-backup) before it's worth spending Actions minutes to confirm it in CI too.

The two-tier workflow design

FileTriggerRunner(s)Est. minutesEst. cost
.github/workflows/ci.yml (changes)every pull_request → mainUbuntu≤ 3≈ $0.02
.github/workflows/ci.yml (fast-checks)pull_request → main, code/config changedUbuntu≤ 8≈ $0.05
.github/workflows/ci.yml (docs-only-check)pull_request → main, only docs/**/*.md changedUbuntu≤ 3≈ $0.02
ci-expensive.yml.disabled → windows-credentialsmanual workflow_dispatch onlyWindows≤ 15≈ $0.15
ci-expensive.yml.disabled → supabasemanual workflow_dispatch onlyUbuntu≤ 25≈ $0.15
ci-expensive.yml.disabled → full-rcmanual workflow_dispatch onlyUbuntu≤ 30≈ $0.18

Rates (private-repo standard GitHub-hosted runners, observed 2026-08-26): $0.006/minute for a 2-core Linux runner, $0.010/minute for a 2-core Windows runner — see About billing for GitHub Actions and Per-minute rates (plan-included minutes reduce the effective rate further — check the organization's plan before assuming these are out-of-pocket dollars). Every minute figure in this table and in the monthly model below is a target estimate, not a guarantee: each timeout-minutes value is set to the target itself, so a run that legitimately needs more time fails loudly (a visible signal to recalibrate) rather than silently drifting the budget.

Fast PR gate design (ci.yml):

  • pull_request only, main only — no push trigger, so pushes to feature branches never spend Actions minutes on their own; only the PR against main does.
  • concurrency: group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} with cancel-in-progress: true — pushing a new commit to an open PR cancels its own superseded, still-running check instead of paying for both.
  • A changes job runs first on every PR (a git diff against the PR base, no install/build) and gates the two downstream jobs: fast-checks (code/config changed) and docs-only-check (only docs/** or *.md changed). A docs-only PR never reaches fast-checks, so it never starts the build, the runtime identity-boundary check, or any database job.
  • fast-checks is Ubuntu only, one bun install --frozen-lockfile, and the focused set of checks that fit an 8-minute budget: credential-leak scan (before and after the build), format, lint, typecheck, unit, contract (+ device variants), security (+ device variants), docs, the pgTAP output-validator unit test, the production runtime identity-boundary check, and the CLI + web build. It deliberately excludes the full integration suite, test:mvp004c:assurance, the Windows job, and the Supabase-backed database/runtime suite — those run in ci-expensive.yml.disabled's full-rc/windows/supabase targets instead.

Expensive candidates (ci-expensive.yml.disabled, see Activation before this file is even a real workflow):

  • workflow_dispatch only, with a target choice input (windows / supabase / full-rc / all) — never automatic, never on push or pull_request.
  • windows-credentials and supabase are separate jobs gated independently by target; they never both run just because one was requested.
  • full-rc is the superset of fast-checks plus the full integration suite and test:mvp004c:assurance — the one-time, explicitly-approved gate to run against a release candidate ref before cutting a tag (see Releasing tv), never as a PR loop.
  • concurrency: group: ci-expensive-${{ github.ref }} with cancel-in-progress: false — an in-flight expensive/RC run reports a real result instead of being silently cancelled by a second dispatch.

Path filters and the branch-protection caveat

Docs-only changes are recognized inside the workflow (the changes job's diff), not via a top-level on.pull_request.paths filter. That's deliberate: if the whole workflow were path-filtered at the trigger and never ran at all for a docs-only PR, a branch-protection rule that requires the fast-checks status check would show "Expected — waiting for status to be reported" forever, blocking merge. Because the workflow always runs and only the job is skipped via if:, GitHub reports a real (skipped) conclusion for fast-checks on a docs-only PR, which branch protection treats as satisfied.

When branch protection is configured for this workflow (still future work — CI is disabled today), require the job names that always produce a conclusion for every PR shape:

  • Require changes (always runs).
  • Do not require both fast-checks and docs-only-check as separate required checks unless the branch-protection rule is configured to accept a skipped conclusion as passing for whichever one didn't apply — verify this against GitHub's current required-checks semantics before flipping it on, since this behavior has changed across GitHub versions.

Activation

CI stays exactly as disabled as it is today until every step below is an explicit, reviewed, owner-approved action — none of this happens as a side effect of merging this runbook or the workflow files it documents.

  1. Review this runbook and the two workflow files in a normal reviewed PR.
  2. Rename the expensive candidate so GitHub can discover it, in that same or a follow-up PR: ``sh git mv .github/workflows/ci-expensive.yml.disabled .github/workflows/ci-expensive.yml ``
  3. Merge to main. Neither workflow runs yet — a repository-disabled or newly-added workflow file does not activate itself.
  4. Owner enables the workflows explicitly: ``sh gh workflow enable CI gh workflow enable "CI Expensive" ``
  5. Set the monthly budget guardrail (Settings → Billing → Actions, or the organization equivalent): a $5/month spending limit with an alert at that threshold. At the rates above, $5 covers roughly 600 Linux minutes or a mixed budget with substantial headroom over the modeled workload below — the limit exists to catch a runaway loop or a misconfigured trigger, not because the modeled workload is expected to approach it.
  6. Dispatch the expensive workflow manually when actually needed: ``sh gh workflow run "CI Expensive" -f target=windows gh workflow run "CI Expensive" -f target=supabase gh workflow run "CI Expensive" -f target=full-rc ``

Monthly cost model

Modeled workload: 10 pull requests a month that each trip the changes + fast-checks path (a code/config PR, the common case), plus 2 runs each of the supabase and full-rc expensive targets and 2 runs of windows-credentials.

ItemRuns/monthMinutes eachLinux minutesWindows minutes
changes (every PR)10330—
fast-checks10880—
supabase22550—
full-rc23060—
windows-credentials215—30
Total22030

At $0.006/Linux-minute and $0.010/Windows-minute: 220 × $0.006 = $1.32 (Linux) + 30 × $0.010 = $0.30 (Windows) ≈ $1.62/month before any plan-included Actions minutes are applied. That sits well under the $5 guardrail — the guardrail exists as a runaway-loop backstop, not because this model is expected to approach it.

These are targets, not guarantees. Recalibrate every timeout-minutes value and this table from real gh run list --workflow=CI --json databaseId,conclusion,startedAt,updatedAt / gh api /repos/thalixinc/thalix-vault/actions/runs/<id>/timing data after the first 10 real fast-PR runs and the first real run of each expensive target, and update this page whenever the observed numbers diverge meaningfully from the estimate.

Artifact and cache retention

Neither workflow currently uploads build artifacts or configures a dependency cache — bun install --frozen-lockfile runs cold every time by design, trading a small amount of predictable install time for zero cache-storage cost and zero cache-poisoning risk. If install time becomes a meaningful share of the fast-checks budget after real-run calibration, revisit adding actions/cache for Bun's cache directory with an explicit retention policy, rather than defaulting it in speculatively now. release.yml (unaffected by this runbook) uploads real release artifacts via actions/upload-artifact/actions/download-artifact with GitHub's default 90-day repository retention; that workflow is rare by design (tag-triggered only) and out of scope here.

Rollback / disable

To go back to fully disabled at any point after activation:

gh workflow disable CI
gh workflow disable "CI Expensive"

This is the same manual, explicit action that put CI into disabled_manually on 2026-08-20 — no workflow file change is required to disable, and no workflow file change is required to keep it disabled today.

See Releasing tv for the separate, tag-triggered release.yml, and Failed release and rollback for the client/publish rollback story once a release has actually shipped.