This page explains every GitHub Actions concept used by FunnelLens'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 and data tests, coverage, reports, parity, dashboard, 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.
runs-on: ubuntu-latest: the machine. It starts empty apart from common tools (Python, Docker, git).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.r-lib/actions/setup-r-dependencies@v2caches the installed R library, keyed byDESCRIPTION. A changed file means a new key and a fresh install. - Binary packages:
setup-rwithuse-public-rspm: trueinstalls CRAN packages as pre-built Linux binaries from Posit Public Package Manager: minutes instead of compiling the tidyverse. - Docker layer cache:
cache-from/cache-to: type=ghaindeploy.ymlstores image layers in the Actions cache, one scope per image. The Dockerfiles copy the code last so the dependency layer stays cached.
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 · report); (3) inside it, inputs.track is r, and steps with if: inputs.track == 'r' set up R
instead of Python. It works because every track exposes the same Makefile targets (install, lint,
test, coverage, run, report). 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@v5(python and sql tracks; the SQL track's runner is a thin Python program);r-lib/actions/setup-r@v2+setup-r-dependencies@v2(installsDESCRIPTION's packages pluslintr,testthat,covr,rmarkdown) +setup-pandoc@v2(R Markdown needs pandoc to knit HTML). The versions are pinned in the workflow (Python 3.12, R 4.5), so CI does not drift when a runner image changes.
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 narrator's key is never needed in CI: its tests use a fake
LLM server.
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,,}/funnellens-<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/funnellens-dashboard: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,dashboard). A change tocontract/,data/orscripts/isshared: it re-runs every track, because the answers 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: answers-${{ inputs.track }}
path: tracks/${{ inputs.track }}/out/answers.json
if-no-files-found: error
retention-days: 7Files saved from a job, downloadable from the run page for 7 days. Each track uploads its
answers.json (answers-python, answers-sql, answers-r); the parity job downloads them all with
actions/download-artifact@v4 and pattern: answers-*, reads the export name from one of them and
compares all three with contract/golden/answers-<export>.json. 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). - Parity red? Download the
answers-*artefacts and runpython3 scripts/parity.pyon them: it prints the first differing JSON path for each file. - Green locally, red in CI? Compare versions (
python --version,R --version), 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 (dashboard, python, sql, r). 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/funnellens-dashboard:1.1.0→ poll$RENDER_APP_URL/_stcore/healthevery 15 s for up to 10 min (cold starts) → runscripts/smoke-test.shagainst the live URL.