This page explains every GitHub Actions concept used by ChurnCast's workflows, in the order you meet them. It is shorter than a full course: each section is what you need to read our YAML and change it safely. Keep the files open next to it:
| File | Appears in | Purpose |
|---|---|---|
.github/workflows/ci.yml |
starter (simple), Phase 5 (full), Phase 6 (+ Docker) | lint, unit, data and model tests, coverage, the evaluation gate, reports, parity, the API, image smoke test |
.github/workflows/reusable-track-ci.yml |
Phase 5 | the full check for one track, called three times |
.github/workflows/deploy.yml |
Phase 6 | push images to GHCR on version tags; optional Render deploy |
.github/dependabot.yml |
Phase 5-6 | weekly dependency update PRs |
Official reference: https://docs.github.com/actions. When this page and the docs disagree, the docs win; please open an issue so we can fix this page.
- Workflow: a YAML file in
.github/workflows/. It runs when an event happens. - Job: a set of steps on one fresh virtual machine (a runner, here
ubuntu-latest). Jobs run in parallel unless oneneeds:another. - Step: a shell command (
run:) or a packaged action (uses: actions/checkout@v4). - Run: one execution of a workflow, with a page, logs and a status (the green tick or red cross on a commit).
- Check: each job's result as shown on a pull request; branch protection can require some of them.
on:
push:
branches: [main, solution] # every push to these branches
pull_request: # every PR, whatever its branch
workflow_dispatch: # a "Run workflow" button on the Actions tabdeploy.yml uses push: tags: ["v*.*.*"], so it runs only when you push a version tag. ci.yml
does not run for tags: a tag points to a commit CI has already checked on its branch. (Maintainers: pushing many
tags at once starts one deploy run per version tag, so the course repository pushes its tags with Actions off.)
runs-on: ubuntu-latest: the machine. It starts empty apart from common tools (Python, Docker, git, shellcheck).needs: [python, sql, r]: wait for those jobs; their outputs and results become available.if:: run the job or step only when a condition holds (if: failure()runs only after a failed step).timeout-minutes:: kill a stuck job. The default is 6 hours, which is a lot of free minutes.defaults.run.working-directory: tracks/python: everyrun:starts there.concurrency:withcancel-in-progress: true: a new push to a branch cancels the old run of that branch.
GitHub replaces ${{ … }} before the step runs. The contexts we use: github (event, ref, actor),
inputs (of a reusable workflow), needs (results and outputs of earlier jobs), steps (outputs of
earlier steps), matrix, secrets, vars and env. Two examples from our files:
if: needs.changes.outputs.sql == 'true' || needs.changes.outputs.shared == 'true'
working-directory: tracks/${{ inputs.track }}Prefer environment variables over pasting ${{ }} into a shell script: env: { RESULTS: ${{ join(needs.*.result, ' ') }} }
then $RESULTS in the script. It avoids quoting bugs and script injection.
The starter's ci.yml runs one job per track with a matrix:
strategy:
fail-fast: false # one red track does not cancel the others
matrix:
track: [python, sql, r]
name: ${{ matrix.track }} · lint + unitThree jobs, three checks: python · lint + unit, sql · lint + unit, r · lint + unit. These are
the names branch protection requires on main. deploy.yml uses a matrix with include: to give
each image its own Dockerfile.
- Package caches:
actions/setup-python@v5withcache: piprestores pip's download cache, keyed by the hash ofrequirements-dev.txt. A changed file means a new key and a fresh install. The expressioncache: ${{ inputs.track != 'r' && 'pip' || '' }}switches it off for the R track, which has no such file. - Binary packages: the R image installs CRAN packages as pre-built Linux binaries from a dated Posit
Package Manager snapshot (
p3m.dev/cran/__linux__/noble/2026-09-15): minutes instead of compiling tidymodels, and the same versions on every machine, every day. - Docker layer cache:
cache-from/cache-to: type=ghastores image layers in the Actions cache, one scope per image (r-devfor the R toolchain, one per image indeploy.yml). The Dockerfiles copy the code last so the dependency layers stay cached; a warm R image builds in well under a minute.
Definition (reusable-track-ci.yml):
on:
workflow_call:
inputs:
track: { description: "python | sql | r", required: true, type: string }Call site (ci.yml):
r:
needs: changes
if: needs.changes.outputs.r == 'true' || needs.changes.outputs.shared == 'true'
uses: ./.github/workflows/reusable-track-ci.yml # a whole workflow instead of runs-on/steps
with:
track: rStep by step: (1) the caller job has no runs-on or steps, only uses + with; (2) GitHub runs
the reusable workflow's job as a child of the caller (shown as r / lint · unit · data · coverage · run · gate · report); (3) inside it, inputs.track is r, and steps with if: inputs.track == 'r' build the R
image instead of installing a virtualenv. It works because every track exposes the same Makefile targets
(install, lint, test, coverage, run, report, gate). Secrets are not passed automatically (secrets: inherit would);
our CI needs none.
Why bother? The check is written once. Fix it once and all three tracks get the fix.
No database server is needed: DuckDB runs inside the Python process, and the exports are CSV files in the repository. The reusable workflow sets up what the track needs:
actions/setup-python@v5for every track (the SQL track's runner is a thin Python program; the R job uses it only for the shared scripts: schema validation and the gate);- for R,
docker/setup-buildx-action@v3+docker/build-push-action@v6withtarget: devandload: truebuildtracks/r/Dockerfile(R 4.6.1, tidymodels, lintr, testthat, covr, rmarkdown, pandoc), then everymaketarget runs inside it:run: $IN make coverage # IN = "docker run --rm --network none -u <uid>:<gid> -v <repo>:/repo -w /repo/tracks/r churncast-r-dev:ci"
Why a container instead of installing R on the runner? A model is only reproducible if its toolchain is: a newer
lintr flags new style rules, a newer parsnip or glm can move the 7th digit of a coefficient. With the image, your
laptop and CI run byte-for-byte the same R. --network none proves the tests need no internet.
Every run gets an automatic, short-lived token, secrets.GITHUB_TOKEN, valid only for this repository
and only while the job runs. Its powers are set by permissions::
permissions:
contents: read # top of every workflow: least privilege by default
jobs:
image:
permissions:
contents: read
packages: write # only this job may push to GHCRAnything not listed is none. changes also asks for pull-requests: read because the path filter
lists a PR's changed files through the API.
Secrets (encrypted, masked as *** in logs) vs variables (plain configuration, visible):
| Name | Kind | Scope | Used by |
|---|---|---|---|
GITHUB_TOKEN |
secret | automatic | GHCR login |
RENDER_DEPLOY_HOOK_URL |
secret | environment production |
deploy.yml → render |
RENDER_APP_URL |
variable | environment production |
health poll + smoke test |
Add one: repository → Settings → Environments → New environment production → Add
environment secret. Or with the GitHub CLI: gh secret set RENDER_DEPLOY_HOOK_URL --env production
(it prompts for the value, so it never lands in your shell history).
Secrets cannot be used in if: conditions. deploy.yml copies the secret into env: (an empty
string when it is not set) and tests env.RENDER_DEPLOY_HOOK_URL == '': no secret means a notice on
the run summary and a green job. The AI explainer's key is never needed in CI: its tests use a fake
LLM server, and the Docker job points it at an unreachable address to prove the fallback.
render:
environment:
name: production
url: ${{ vars.RENDER_APP_URL || 'https://render.com' }}An environment is a named deployment target with its own secrets and protection rules: required
reviewers (the job waits for an approval), allowed branches or tags (e.g. only v*), a wait timer.
Its secrets are given only to jobs that declare it, which makes them safer than repository secrets.
- Name:
ghcr.io/<owner>/<image>, lower-case. Our org isAICanCode-org, henceghcr.io/${GITHUB_REPOSITORY_OWNER,,}/churncast-<name>(bash,,= to lower case). - Login:
docker/login-action@v3withregistry: ghcr.io,username: ${{ github.actor }},password: ${{ secrets.GITHUB_TOKEN }}; the job needspackages: write(§9). - Tags:
docker/metadata-action@v5turns git tagv1.1.0into image tags1.1.0,1.1,sha-1a2b3c4andlatest, and adds OCI labels that link the package to the repository. - Push:
docker/build-push-action@v6withpush: true. - Visibility: a new package is private. To let Render or anyone pull it: the package page → Package settings → Change visibility → Public.
- Pull:
docker pull ghcr.io/aicancode-org/churncast-api:1.1.0.
GitHub's built-in on: push: paths: skips the whole workflow, and a required check that never runs
blocks a PR forever. So the workflow always runs and decides per job:
changesrunsdorny/paths-filter@v3and outputs'true'/'false'per area (shared,docs,python,sql,r,serving). A change tocontract/,data/,scripts/ormodels/isshared: it re-runs every track, because the numbers of all three depend on it.- Each job has an
if:on those outputs. ci-okhasif: always(),needs:on everything, and fails only if a result isfailureorcancelled; skipped jobs are fine. In your own fork,ci-okis the one check to require.
- uses: actions/upload-artifact@v4
with:
name: run-${{ inputs.track }}
path: |
tracks/${{ inputs.track }}/out/metrics.json
tracks/${{ inputs.track }}/out/model.json
tracks/${{ inputs.track }}/out/features.csv
if-no-files-found: error
retention-days: 7Files saved from a job, downloadable from the run page for 7 days. Each track uploads its run outputs
(run-python, run-sql, run-r); the parity job downloads them all with actions/download-artifact@v4 and
pattern: run-*, reads the export name from one metrics.json, compares every metrics.json and model.json
with contract/golden/metrics-<export>.json and model-<export>.json (1e-6), and the features.csv files with
each other (1e-9). Jobs never share files any other way:
each runs on a fresh machine. The report-<track> artefact holds the executed notebook, the SQL
report or the knitted R Markdown HTML, uploaded with if: always() so you get it even when a test failed.
uses: actions/checkout@v4follows thev4tag, which the publisher can move. For maximum supply-chain safety, pin a full commit SHA (@<40-char-sha> # v4.2.2). We use major tags for readability, and Dependabot (package-ecosystem: github-actions, pluspipanddocker) proposes updates.actionlint(https://github.com/rhysd/actionlint) checks workflows, expressions and embedded shell (via shellcheck). Run it before pushing; our workflows pass it with no findings.
- Open the failed step and read the last error, then scroll up to the first one.
- Reproduce locally with the same command:
cd tracks/<track> && make coverage(ormake report,make gate), or everything at once withscripts/check-all.sh --full. - Parity red? Download the
run-*artefacts and runpython3 scripts/parity.pyon them: it prints every differing JSON path (or CSV cell) for each file. - Gate red? The step prints one line per check: which number is not good enough. Do not lower the threshold in the contract to make it green; that is a decision for the people who agreed it in Phase 1.
- Green locally, red in CI? Compare versions (
python --version; for R, did you rebuild the image after the Dockerfile changed?), and remember CI starts from a clean checkout: an untracked file or a cached notebook output on your laptop does not exist there. - Still unclear? Re-run jobs → Enable debug logging, or add a step
run: env | sort(secrets stay masked).
- Event
pushwithgithub.ref = refs/tags/v1.1.0→deploy.ymlmatchesv*.*.*. - Job
imageexpands into 4 matrix jobs (api, python, sql, r; the R image usestarget: runtime). Each: checkout → lower-case name → buildx → GHCR login withGITHUB_TOKEN→ metadata (1.1.0,1.1,sha-…,latest) → build with the GHA cache → push. - Job
renderwaits for all four (needs: image), enters environmentproduction, reads the secret intoenv.- No secret → notice, success. Done.
- Secret →
curlthe deploy hook withimgURL=ghcr.io/aicancode-org/churncast-api:1.1.0→ poll$RENDER_APP_URL/healthevery 15 s for up to 10 min (cold starts) → runscripts/smoke-test.shagainst the live URL.