You have spent five modules using GitHub Actions: you have written ci.yml, cd.yml, rollback.yml and infra.yml, you have extracted the prepare-node composite action and the reusable-build-publish.yml reusable workflow, you have federated credentials with OIDC and you have set up concurrency and matrices with sharding. What you have not done is look at the tool as a tool: what exactly happens between somebody pushing a commit and a runner starting, what contexts exist and which are available when, what triggers there are beyond push and pull_request, how you write your own action when the existing ones fall short, what the real service limits are and what to do when you hit them. This lesson closes those gaps. It does not re-explain what a cache is or what an immutable artifact is — that is in 04-02 and 02-06 — it explains how GitHub Actions materialises them, where intuitions break, and what trade-offs come with the tool the course has been using by default. Because the module promised there would be no fanaticism, and that applies to the home tool too.
Contents
- The full life cycle of an event and where the state lives
- Contexts and expressions in depth
- All the triggers that were missing
pull_requestversuspull_request_targetGITHUB_TOKEN,permissionsand OIDC- The three types of action, with a complete JavaScript action
- Advanced matrices and dynamic matrices
- Flow control: outputs,
continue-on-error,timeout-minutes,concurrency - Cache and artifacts: keys, limits and eviction
- Self-hosted runners and autoscaling with ARC
- Summaries, annotations and workflow commands
- Environments, rules and deployments
- Real service limits and strategies
- Debugging and testing workflows
- Common Mistakes and Tips
- Exercises
- Conclusion
- The full life cycle of an event and where the state lives
flowchart TD
E["Event on GitHub<br/>push, PR, schedule, API"] --> F{"Is there a workflow<br/>with that on:?"}
F -->|no| X["Nothing"]
F -->|yes| C["The YAML is taken from a specific ref"]
C --> W["Workflow run<br/>the definition is frozen"]
W --> J["Jobs: needs and if are evaluated"]
J --> Q["Queue: a runner is looked for<br/>by runs-on"]
Q --> R["Runner: clean machine<br/>clones nothing by default"]
R --> S["Steps in sequence<br/>same file system"]
S --> O["Outputs, artifacts, caches, logs"]
O --> N["Checks on the commit / PR"]
Five details that explain behaviours many people find either magical or broken:
Which branch the workflow is read from. For push and pull_request, from the commit that triggers it. But for schedule, workflow_dispatch and workflow_run it is read from the default branch, always. That is why a new schedule does not run until the change reaches main, and why a workflow_dispatch you fix on a branch still misbehaves when you launch it.
The definition is frozen at start-up. If you push a change to the workflow while a run is in progress, that run carries on with the old version. It is useful for reasoning about in-flight runs.
Each job is a new machine. Nothing persists between jobs except what you pass explicitly: outputs, artifacts or cache. What does persist between steps of the same job is the file system — but not the shell process (each run is a fresh shell: an export in one step does not reach the next; that is what $GITHUB_ENV is for).
The runner arrives empty. There is no automatic checkout — unlike Travis or GitLab — which is why actions/checkout is always the first step, and why forgetting it produces the most repeated "no such file or directory" error in the tool.
The result is published as checks on the commit, and it is those checks that the branch protection from 02-07 requires. A job that does not run because of a false if ends up as skipped, and skipped counts as passing for branch protection: that asymmetry is the cause of the subtlest "false green" in Actions, and we will come back to it.
| GitHub Actions | Equivalent in the course |
|---|---|
| Workflow | Pipeline file |
| Job | Job (a machine) |
| Step | Step |
| Action | Packaged, reusable step |
| Runner | Agent |
| Artifact | Artifact |
| Environment | Environment with rules and approvals |
- Contexts and expressions in depth
Contexts are objects available inside ${{ }}. Knowing which ones exist and when they are available avoids half the errors.
| Context | Contains | Available in |
|---|---|---|
github |
Event, sha, ref, actor, repository, the full event |
Everywhere |
env |
Variables defined with env: |
Almost everywhere (not in env: at the same level) |
vars |
Configuration variables (non-secret) of the repo/org/environment | Everywhere |
secrets |
Secrets | Job and step; not in a workflow-level if |
job |
State of the current job, services and their ports | Steps |
jobs |
Job results (reusable workflows only, for outputs) |
The reusable's outputs |
steps |
Outputs and outcome/conclusion of steps with an id |
Later steps |
runner |
os, arch, temp, tool_cache, debug |
Steps |
needs |
Outputs and result of the jobs you depend on |
The dependent job |
matrix |
Values of the current combination | Job with a matrix |
inputs |
Inputs of workflow_dispatch, workflow_call or of an action |
Depending on the case |
strategy |
job-index, job-total, fail-fast |
Job with a matrix |
env:
ENVIRONMENT: staging
jobs:
example:
runs-on: ubuntu-22.04
steps:
- id: version
run: echo "value=1.4.2" >> "$GITHUB_OUTPUT" # 1
- name: Use the output
run: echo "Version ${{ steps.version.outputs.value }}"
- name: Variable for later steps
run: |
echo "SHORT_SHA=${GITHUB_SHA::7}" >> "$GITHUB_ENV" # 2
echo "$PWD/bin" >> "$GITHUB_PATH" # adds to the PATH
- name: Condition with functions
if: >-
github.event_name == 'push' &&
startsWith(github.ref, 'refs/tags/v') &&
!contains(github.event.head_commit.message, '[skip ci]')
run: ./scripts/publish.sh$GITHUB_OUTPUTis the current mechanism (the old::set-outputwas withdrawn for security reasons). For multi-line values you need a delimiter:{ echo "notes<<EOF" cat CHANGELOG.md echo "EOF" } >> "$GITHUB_OUTPUT"$GITHUB_ENVdefines variables for the later steps of the same job; an ordinaryexportdoes not leave the step because eachrunis a different shell.
Available functions, with their typical uses:
| Function | What it does | Typical use |
|---|---|---|
contains(a, b) |
Substring or list element | contains(github.event.pull_request.labels.*.name, 'urgent') |
startsWith / endsWith |
Prefix / suffix | startsWith(github.ref, 'refs/tags/') |
format('{0}-{1}', a, b) |
Interpolation | Building names |
join(list, ', ') |
Join | Messages |
toJSON(x) |
Serialise | Debugging contexts: run: echo '${{ toJSON(github) }}' |
fromJSON(s) |
Deserialise | Dynamic matrices and converting strings to numbers or booleans |
hashFiles('**/package-lock.json') |
Hash of files | Cache keys |
success(), failure(), cancelled(), always() |
State | Step and job conditionals |
And the details that bite:
Every matrix value and every workflow_dispatch input arrives as a string. if: inputs.force == true is always false if force comes from a workflow_dispatch; you have to compare against 'true' or use fromJSON(inputs.force).
A job-level if does not take ${{ }} (although it works with them); inside an expression, it does.
if: always() versus if: ${{ !cancelled() }}: always() runs the step even if the workflow was cancelled, which can leave resources half-finished or delay the cancellation. For "run even if it fails, but not if it is cancelled" — the usual case of publishing test reports — the correct form is if: ${{ !cancelled() }}.
Secrets are not available in a workflow-level if, and you cannot use them to decide whether a workflow runs either. The pattern for "only if there are credentials" is a preceding job that checks them and exposes a boolean output.
- All the triggers that were missing
Beyond push and pull_request:
on:
# Manual run with TYPED inputs
workflow_dispatch:
inputs:
environment:
description: Target environment
type: choice
options: [staging, production]
default: staging
digest:
description: Digest of the image to deploy
type: string
required: true
skip-smoke:
description: Skip smoke tests (emergencies only)
type: boolean
default: false
# Triggered from an external API
repository_dispatch:
types: [deploy-requested, contract-updated]
# Scheduled (UTC, always from the default branch)
schedule:
- cron: '17 3 * * *' # 03:17 UTC, not on the hour: see below
# Reacting to comments: the "/deploy" on a PR
issue_comment:
types: [created]
# Publication of a release
release:
types: [published]
# Chained after another workflow
workflow_run:
workflows: ["CI"]
types: [completed]
branches: [main]
# Merge queue
merge_group:
types: [checks_requested]| Trigger | When to use it | Trap |
|---|---|---|
workflow_dispatch |
Manual deployments, operations, rollback | It is read from the default branch; inputs are strings |
repository_dispatch |
Integration with external systems | Requires a token with write permission on contents |
schedule |
Nightlies, cleanup, scans | It does not fire punctually: the on-the-hour queue is saturated; use odd minutes. And it is disabled after ~60 days of repository inactivity |
issue_comment |
Commands like /deploy |
It fires on issues and on PRs; you have to filter on github.event.issue.pull_request. And the comment is third-party text: never interpolate it into run: |
release |
Publishing packages, notes | published versus created is not the same thing |
workflow_run |
CD after CI (Reservalia's cd.yml) |
The YAML is read from the default branch; completed includes failures: you have to check conclusion == 'success' |
merge_group |
Merge queue (02-07) | If the required check does not run on merge_group, the queue is blocked |
Two specific patterns that come up a lot:
# /deploy command on a pull request
on: { issue_comment: { types: [created] } }
jobs:
deploy:
if: >-
github.event.issue.pull_request &&
startsWith(github.event.comment.body, '/deploy') &&
contains(fromJSON('["OWNER","MEMBER"]'), github.event.comment.author_association)
runs-on: ubuntu-22.04
steps:
- run: ./scripts/deploy.sh preview # NEVER interpolate the comment bodyThe author_association check is essential: without it, anybody on the internet can trigger a deployment by writing a comment. And the comment body is never interpolated inside run:, because that is a direct script injection (04-03): if you need to read it, pass it through env: and treat it as data.
# CD chained after CI, checking the result
on:
workflow_run:
workflows: ["CI"]
types: [completed]
branches: [main]
jobs:
deploy:
if: github.event.workflow_run.conclusion == 'success' # without this, you deploy broken builds
runs-on: ubuntu-22.04
pull_request versus pull_request_target
pull_request versus pull_request_targetThe most important security distinction in the whole tool. Lesson 04-03 stated it; here is the full mechanism.
pull_request |
pull_request_target |
|
|---|---|---|
| Code that runs | That of the PR branch (possibly from a fork) | That of the base branch |
github.ref context |
The base branch with the merge | The base branch |
Access to secrets |
No, if the PR comes from a fork | Yes, always |
GITHUB_TOKEN permissions |
Read-only from forks | Write |
| Risk | Low | High if you check out the PR code |
# DANGEROUS: the combination that leaks secrets
on: pull_request_target
jobs:
build:
steps:
- uses: actions/checkout@v4
with:
ref: ${{ github.event.pull_request.head.sha }} # ← code from the fork
- run: npm ci # ← runs the attacker's scripts
# …with the repository's secrets availableAn npm ci runs the postinstall scripts from the PR's package.json. An attacker opens a PR with a postinstall that sends process.env to their server, and walks off with every secret. This is not theoretical: it is the most common way public repositories get compromised.
The rules, without nuance:
- Use
pull_requestby default. It covers 95 % of cases. - Use
pull_request_targetonly when you need secrets or write access from forks — labelling, commenting, formatting — and without checking out the PR code. - If you need both things (building a fork's code and commenting on the result), split it: a
pull_requestworkflow builds without secrets and uploads an artifact; a second workflow withworkflow_rundownloads it and comments. The second one has secrets but never runs the fork's code. GITHUB_TOKENwith minimumpermissionsalways, and above all here.
Applied to a private repository like Reservalia's, the risk is lower — only the team opens PRs — but not zero: one compromised account is enough. And if a repository is ever made public, the workflows travel with it.
GITHUB_TOKEN, permissions and OIDC
GITHUB_TOKEN, permissions and OIDCEach job receives an ephemeral GITHUB_TOKEN that dies with it. Its scope depends on the organisation's configuration — the default value can be permissive in older repositories — and on what you declare:
permissions: # at workflow level: applies to every job
contents: read # the minimum for checkout
jobs:
label:
permissions: # at job level: it REPLACES the workflow's, it does not add to it
contents: read
pull-requests: write # only this job can comment
runs-on: ubuntu-22.04
deploy:
permissions:
contents: read
id-token: write # issue the OIDC token
environment: production
runs-on: ubuntu-22.04
steps:
- uses: aws-actions/configure-aws-credentials@v4
with:
role-to-assume: arn:aws:iam::123456789012:role/reservalia-deploy-prod
aws-region: eu-west-1
# no long-lived keys: the OIDC token is exchanged for temporary credentialsPoints that matter:
- Declaring
permissionsat workflow level sets everything to what you declared, not only what is listed: if you putcontents: read, the rest are set tonone. That is what you want. - Job permissions replace, they do not accumulate. A job with
permissions: { pull-requests: write }losescontents: readand the checkout fails. GITHUB_TOKENdoes not trigger other workflows. A push made with it does not launch theon: push, by design, to avoid infinite loops. If you need to chain, use a GitHub App or a PAT — with the caveat that then you really can create a loop.- OIDC (
id-token: write) is what 03-02 established: the runner asks for a signed token with claims about repository, branch, environment and flow, and AWS exchanges it for temporary credentials. The IAM trust condition must bindsubprecisely:
Writing"StringLike": { "token.actions.githubusercontent.com:sub": "repo:reservalia/reservalia:environment:production" }repo:reservalia/*or leavingsubwith broad wildcards cancels out most of the protection: any branch of any repository in the organisation could assume the role. It is a frequent and serious configuration mistake.
- The three types of action, with a complete JavaScript action
| Type | How it runs | Speed | When |
|---|---|---|---|
| Composite | YAML steps on the job's runner | Very fast | Command sequences; the first thing to try (04-05) |
| JavaScript | Node.js on the runner, with @actions/* |
Fast | Real logic, API use, computed outputs |
| Docker | Container built or pulled | Slow: it builds or pulls the image | Non-JS tooling and system dependencies. Linux only |
You already wrote the composite one in 04-05. Here, a complete JavaScript action: it checks the bundle size budget and publishes the result, a real Reservalia case (05-01).
# .github/actions/bundle-budget/action.yml
name: 'Bundle budget'
description: 'Compares the bundle size against a budget and fails if it is exceeded'
author: 'Reservalia'
inputs:
path:
description: 'Build directory'
required: true
default: 'apps/web/dist'
budget-kb:
description: 'Budget in KB for the initial JS'
required: true
fail:
description: 'Fail the job if it is exceeded'
required: false
default: 'true'
outputs:
size-kb:
description: 'Measured size in KB'
exceeds:
description: 'true if it exceeds the budget'
runs:
using: 'node20'
main: 'dist/index.js' # bundled with @vercel/ncc, node_modules included// .github/actions/bundle-budget/src/index.js
const core = require('@actions/core');
const fs = require('node:fs');
const path = require('node:path');
function initialJsSize(dir) {
// Adds up the size of the top-level .js files (deferred chunks do not count)
return fs.readdirSync(dir)
.filter((f) => f.endsWith('.js'))
.reduce((total, f) => total + fs.statSync(path.join(dir, f)).size, 0);
}
async function run() {
try {
const buildPath = core.getInput('path', { required: true });
const budget = Number(core.getInput('budget-kb', { required: true }));
const fail = core.getBooleanInput('fail'); // 1 · correct boolean parsing
if (!fs.existsSync(buildPath)) {
core.setFailed(`The directory ${buildPath} does not exist. Did the build run?`);
return;
}
const kb = Math.round(initialJsSize(path.join(buildPath, 'assets')) / 1024);
const exceeds = kb > budget;
core.setOutput('size-kb', String(kb)); // 2
core.setOutput('exceeds', String(exceeds));
// 3 · Summary visible on the job tab
await core.summary
.addHeading('Bundle budget')
.addTable([
[{ data: 'Metric', header: true }, { data: 'Value', header: true }],
['Initial JS size', `${kb} KB`],
['Budget', `${budget} KB`],
['Headroom', `${budget - kb} KB`],
])
.write();
if (exceeds) {
// 4 · Annotation: it appears highlighted in the interface
const message = `The bundle weighs ${kb} KB and the budget is ${budget} KB`;
if (fail) core.setFailed(message);
else core.warning(message);
} else {
core.info(`Bundle within budget: ${kb}/${budget} KB`);
}
} catch (error) {
core.setFailed(`Unexpected error: ${error.message}`); // 5
}
}
run();getBooleanInputparses'true'/'false'correctly.getInput('fail') === truewould always be false: every input arrives as a string, which is the same trap as in section 2.core.setOutputpublishes outputs consumable withsteps.<id>.outputs.<name>.core.summarywrites to$GITHUB_STEP_SUMMARY: markdown that appears on the job page (section 11).core.setFailedmarks the step as failed with a message;core.warningandcore.noticecreate annotations without failing. Withfile,startLineandendLinethe annotation is anchored to a specific line of code, which is what makes linters useful in the diff view.- A global catch: without it, an exception produces a failure with a raw, hard-to-read stack trace.
One more function worth knowing: core.setSecret(value) registers a value so that it is masked in the logs from that moment on. It is essential when your action computes or receives a secret that did not come from secrets — a token obtained from an API, for example. And with the same warning as in 06-01 and 06-04: masking covers the literal match, not transformations of it.
# Usage
- uses: ./.github/actions/bundle-budget
id: budget
with:
path: apps/web/dist
budget-kb: '180'
fail: ${{ github.ref == 'refs/heads/main' }}
- run: echo "Bundle: ${{ steps.budget.outputs.size-kb }} KB"An operational detail that catches people out: a JavaScript action needs its dependencies committed to the repository, because the runner does not run npm install. It is bundled with @vercel/ncc (ncc build src/index.js -o dist) and CI checks that dist/ is in sync with src/, or you will publish an action that does not contain your changes.
How to choose: composite if it is commands; JavaScript if there is logic, an API or computed outputs; Docker only if you need awkward system dependencies — accepting that it only works on Linux runners and that it starts slowly.
- Advanced matrices and dynamic matrices
jobs:
test:
runs-on: ${{ matrix.os }}
strategy:
fail-fast: false # 1
max-parallel: 4 # 2
matrix:
os: [ubuntu-22.04, macos-14]
node: [20, 22]
include: # 3
- os: ubuntu-22.04
node: 22
coverage: true # adds a property to that combination
- os: windows-2022 # adds a whole combination
node: 22
exclude: # 4
- os: macos-14
node: 20
steps:
- run: npm test
- if: matrix.coverage
run: npm run coveragefail-fast: true(the default) cancels the whole matrix on the first failure. It saves minutes and hides information: if you wanted to know whether it fails on Node 20 and on Node 22, set it tofalse. Rule of thumb:falsefor compatibility matrices,truefor shards of the same suite.max-parallellimits the concurrency: useful when the matrix hits a shared resource (a test database, an API limit).includehas two behaviours and that is where the confusion lies: if its keys match an existing combination, it adds properties to that combination; if it does not match, it creates a new combination.excludeis applied afterwards, once everything has been expanded, including theincludes.
A dynamic matrix, which is the pattern that solves the monorepo from 04-04:
jobs:
detect:
runs-on: ubuntu-22.04
outputs:
packages: ${{ steps.compute.outputs.packages }}
has-changes: ${{ steps.compute.outputs.has-changes }}
steps:
- uses: actions/checkout@v4
with: { fetch-depth: 0 } # needed to compare against the base
- id: compute
run: |
# Emits a JSON array with the affected workspaces
PACKAGES=$(node scripts/affected.js --base "${{ github.event.pull_request.base.sha }}")
echo "packages=${PACKAGES}" >> "$GITHUB_OUTPUT"
[ "$PACKAGES" = "[]" ] && echo "has-changes=false" >> "$GITHUB_OUTPUT" \
|| echo "has-changes=true" >> "$GITHUB_OUTPUT"
test:
needs: [detect]
if: needs.detect.outputs.has-changes == 'true' # 1
runs-on: ubuntu-22.04
strategy:
matrix:
package: ${{ fromJSON(needs.detect.outputs.packages) }} # 2
steps:
- uses: ./.github/actions/prepare-node
- run: npm test --workspace ${{ matrix.package }}
gate: # 3
needs: [detect, test]
if: always()
runs-on: ubuntu-22.04
steps:
- name: Check the result
run: |
if [ "${{ needs.test.result }}" = "failure" ] || [ "${{ needs.test.result }}" = "cancelled" ]; then
echo "Tests failed"; exit 1
fi
echo "OK (tests: ${{ needs.test.result }})"- An empty matrix makes the job fail, it does not skip it. Hence the
has-changesoutput and theif. fromJSONturns the string into a real array for the matrix.- The
gatejob is the key piece and the one almost nobody adds. Because matrix jobs have dynamic names, they cannot be required by name in branch protection; and because a skipped job counts as passing, without this gate a PR where the tests are skipped due to a detection bug would sail through protection with everything green. It is the "false green" of 04-04 in its most treacherous form: there is nothing red to look at.gatedoes have a fixed name, always runs (if: always()) and checksneeds.test.resultexplicitly.
- Flow control: outputs,
continue-on-error, timeout-minutes, concurrency
continue-on-error, timeout-minutes, concurrencyjobs:
build:
runs-on: ubuntu-22.04
timeout-minutes: 20 # 1
outputs:
digest: ${{ steps.publish.outputs.digest }}
steps:
- id: publish
run: echo "digest=sha256:aaa..." >> "$GITHUB_OUTPUT"
- name: Optional analysis
continue-on-error: true # 2
id: analysis
run: ./scripts/experimental-analysis.sh
- name: Warn if the analysis failed
if: steps.analysis.outcome == 'failure' # 3
run: echo "::warning::The experimental analysis failed"
deploy:
needs: [build]
runs-on: ubuntu-22.04
concurrency: # 4
group: deployment-production
cancel-in-progress: false
steps:
- run: ./scripts/deploy.sh "${{ needs.build.outputs.digest }}"timeout-minuteson every job, without exception. The default value is 6 hours: a hung job burns billable minutes for that entire time and blocks a concurrency slot. It is a line that saves real money.continue-on-error: truelets the step fail without breaking the job. It also exists at job level.outcomeversusconclusion:outcomeis the result before applyingcontinue-on-error;conclusionis the final result. Withcontinue-on-error,conclusionissuccesseven thoughoutcomeisfailure. To detect that something failed but did not block, look atoutcome.- Two different uses of
concurrency, and it is best not to mix them. For CI:group: ci-${{ github.ref }}withcancel-in-progress: true, to cancel stale runs and save money (04-04). For deployments:cancel-in-progress: falseand a global group, to serialise and avoid two simultaneous deployments to the same environment. Cancelling a deployment halfway through is far worse than waiting.
One detail that confuses people: with concurrency, only the most recent waiting run is kept; the intermediate ones are cancelled. In a serialised deployment, if three commits pile up, the first and the last get deployed and the middle one never does. That is usually what you want, but you need to know it.
- Cache and artifacts: keys, limits and eviction
- uses: actions/cache@v4
id: cache
with:
path: |
~/.npm
apps/web/node_modules/.vite
key: npm-${{ runner.os }}-${{ hashFiles('**/package-lock.json') }} # 1
restore-keys: |
npm-${{ runner.os }}- # 2
- if: steps.cache.outputs.cache-hit != 'true' # 3
run: echo "Cold cache"- The key must contain everything that invalidates it: operating system, architecture if it varies, and the hash of the lockfiles.
hashFilesaccepts multiple patterns. restore-keysare fallback prefixes: without them, any lockfile change forces a full download.cache-hitis'true'only on an exact match; a partial restore viarestore-keysleavescache-hitasfalseeven though it restored files. Makingnpm ciconditional oncache-hitis a classic mistake.
Cache behaviour rules that are not obvious and explain a lot of oddities:
- The cache is scoped to the branch. A branch can read its own caches and those of its base branch; not those of sibling branches. That is why the first run of a PR tends to be slower and why it pays to have
mainpopulate the cache. - Caches are immutable: once a key is written, it is not overwritten. To invalidate, change the key (a versioned prefix helps).
- There is a total size limit per repository and least-recently-used eviction applies; on top of that, caches unused for a while are removed. Practical consequence: caching several gigabytes per branch makes them evict each other and the hit rate collapses. Less, better chosen, performs better.
- The cache is not a secure channel between branches. A PR can write to a cache that another run later restores. Do not cache built binaries that are then executed with privileges (04-03).
Artifacts:
- uses: actions/upload-artifact@v4
if: ${{ !cancelled() }}
with:
name: reports-${{ matrix.shard }} # 1 · unique names per shard
path: |
reports/
coverage/
retention-days: 7 # 2
compression-level: 9
- uses: actions/download-artifact@v4
with:
pattern: reports-* # 3
merge-multiple: true
path: reports/- In v4 artifact names must be unique within the run: uploading the same name from several matrix jobs fails. It is a change from v3 that breaks many inherited workflows.
retention-dayssaves billable storage; the policy from 02-06 in one line.patternwithmerge-multiplereassembles the reports from every shard in an aggregation job.
And one difference from the cache worth keeping in mind: artifacts are uploaded and downloaded over the network from the service, not from the runner, so large artifacts cost time at both ends. Passing node_modules between jobs as an artifact is usually slower than reinstalling from cache.
- Self-hosted runners and autoscaling with ARC
| GitHub-hosted | Self-hosted | |
|---|---|---|
| Maintenance | None | Yours: system, tooling, security |
| Cost | Per minute (free on public repos) | Your infrastructure + operation |
| Isolation | Clean, ephemeral machine per job | Depends on how you set it up |
| Access to a private network | No | Yes: the main reason |
| Special hardware | Limited to what is offered | Anything: GPU, ARM, lots of memory |
| Performance | Modest at the basic sizes | Whatever you pay for |
Runners are organised into groups (per organisation) to restrict which repositories can use them — essential if a runner has access to a sensitive network.
The serious risk, and it has to be said plainly: never use self-hosted runners on public repositories. Anybody can open a PR whose workflow runs arbitrary code on your machine, inside your network. And if the runner is persistent, that code can leave things behind for the next job — including another team's. GitHub warns about it explicitly and the warning is ignored far too often.
ARC (Actions Runner Controller) solves the persistent runner problem by running one ephemeral Pod per job on Kubernetes, which is exactly the model from 06-05:
# values.yaml of a scale set of runners
githubConfigUrl: https://github.com/reservalia
githubConfigSecret: arc-github-app # GitHub App, better than a PAT
minRunners: 1 # 1 · one warm for the first job
maxRunners: 30
containerMode:
type: kubernetes # 2 · no privileged DinD
template:
spec:
containers:
- name: runner
image: ghcr.io/actions/actions-runner:latest
resources:
requests: { cpu: "1", memory: "2Gi" }
limits: { cpu: "2", memory: "4Gi" }minRunners: 1keeps one runner warm: without it, the first job of the morning pays the full Pod start-up. It is the trade-off between cost and queue time that 04-04 asked you to measure separately.containerMode: kubernetesavoids privileged Docker-in-Docker and uses Pods for service containers, with the security implications from 06-05.
Honest trade-offs of ARC: it is one more component to operate and update; the Pod start-up adds queue time; and the cache does not persist between Pods, so you depend on a remote cache. In exchange: a clean environment per job, autoscaling, and cost proportional to usage on your own infrastructure.
- Summaries, annotations and workflow commands
A pipeline whose result you have to hunt for in 4,000 lines of log is a pipeline nobody looks at (03-06). Three mechanisms fix it:
# Job summary: markdown visible on the tab, without opening logs
{
echo "## Test results"
echo ""
echo "| Suite | Tests | Failures | Time |"
echo "|---|---|---|---|"
echo "| API | 412 | 0 | 2m 14s |"
echo "| Web | 188 | 1 | 1m 02s |"
echo ""
echo "<details><summary>Slow tests</summary>"
echo ""
echo '```'
cat reports/slow.txt
echo '```'
echo "</details>"
} >> "$GITHUB_STEP_SUMMARY"
# Annotations: they appear highlighted and, with file/line, anchored to the diff
echo "::error file=apps/api/src/appointments.ts,line=42::Date validation is missing"
echo "::warning::Coverage below target: 78 %"
echo "::notice::Image published as sha256:aaa..."
# Group long output into collapsible sections
echo "::group::npm ci output"
npm ci
echo "::endgroup::"
# Mask a value computed at run time
echo "::add-mask::$DERIVED_TOKEN"The job summary is the best readability investment you can make in a pipeline for little effort: a link to the preview, the published digest, the bundle budget result and the failing tests in a table save Nuria from opening logs every time. And ::add-mask:: is the equivalent of core.setSecret for shell scripts: if your script derives a token, mask it before it can appear in any output.
- Environments, rules and deployments
jobs:
deploy-production:
environment:
name: production
url: https://app.reservalia.example # 1
permissions: { contents: read, id-token: write }
runs-on: ubuntu-22.04
steps:
- run: ./scripts/deploy.sh production "${{ needs.build.outputs.digest }}"- The URL appears in the deployments interface and on the PR.
What an Environment gives you, which is more than it seems:
- Required reviewers: the job waits for human approval. It is the gate from 03-01, and a waiting job does not consume a runner — a notable difference from Jenkins'
input(06-01). - Wait timer: mandatory minutes before deploying, to give room to cancel.
- Allowed branches and tags: only
maincan deploy toproduction, even if somebody modifies the workflow on another branch. - Per-environment secrets and variables:
secrets.DATABASE_URLmeans one thing in staging and another in production, with no conditionals in the YAML. - Deployment history per environment with its commit.
- And the piece that matters most combined with OIDC: the token's
subclaim includes the environment, so the production IAM role can only be assumed from a job withenvironment: production, which in turn can only run frommainand after approval. Three chained controls instead of one.
- Real service limits and strategies
No fanaticism, not even with the home tool. The specific values change with the plan and over time — check them before designing — but the types of limit and their strategies are stable:
| Limit | Effect when you hit it | Strategy |
|---|---|---|
| Job concurrency per plan | Jobs queued: it feels like "CI is slow" even though execution is fast | Measure queue and execution separately (04-04); concurrency to cancel stale runs; your own runners for overflow |
| Included minutes (private repos) | Additional billing | paths and selective execution; timeout-minutes; smaller matrices |
| Total cache size per repository | Least-recently-used eviction: the hit rate collapses | Cache less and better; do not cache node_modules |
| Artifact and log retention | Billable storage | Low retention-days; small artifacts |
GITHUB_TOKEN API rate limit |
Intermittent failures in workflows that call the API heavily | Reduce calls, paginate properly, GitHub App with its own limits |
schedule does not fire punctually |
A cron at 0 * * * * can be delayed |
Odd minutes; do not assume punctuality; for precision, an external trigger with repository_dispatch |
schedule is disabled after ~60 days without repository activity |
The nightly silently stops running | An alert for "it has not run for X days" |
| Maximum job and workflow duration | Cancellation | Split into jobs; explicit timeout-minutes |
| Nesting of reusable workflows | Validation error | Flatten the hierarchy |
| Matrix: maximum number of jobs | Error | Bounded dynamic matrices |
Two conceptual limits, more important than the numeric ones:
- The control plane is GitHub's. If the service has an incident, you do not deploy — not even with your own runners. It pays to have a documented emergency path: a script that deploys from a machine with temporary credentials, rehearsed. It is the same reasoning as 03-05 applied to the tool.
- Depending on the third-party actions ecosystem is a supply surface. It is its greatest strength and a real risk: pin by SHA (04-03), review what you add, and prefer CLI commands when the action only wraps one.
- Debugging and testing workflows
# 1 · Debug logs: secrets ACTIONS_STEP_DEBUG and ACTIONS_RUNNER_DEBUG set to "true"
- run: echo "runner.debug = ${{ runner.debug }}" # '1' if it is enabled
# 2 · Dump a whole context to understand what arrives
- run: echo '${{ toJSON(github.event) }}'
# 3 · Interactive session on the runner (private repos only, and with judgement)
- uses: mxschmitt/action-tmate@v3
if: ${{ failure() && github.event_name == 'workflow_dispatch' }}
timeout-minutes: 15# 4 · Run workflows locally with act (an approximation, not an equivalence)
act pull_request -j quality --container-architecture linux/amd64
act -j test --secret-file .secrets.localNotes from real use: ACTIONS_STEP_DEBUG shows what inputs each action receives and what expressions evaluate to what, and it resolves most of the "why is this if not met?" cases. toJSON(github.event) is the fastest way to discover the exact name of a payload field. tmate opens an interactive session on a runner with the job's secrets: use it only in private repositories, with timeout-minutes and preferably in a job without production credentials; it is the same reasoning as SSH into build from 06-03.
And act: useful for iterating on a job's logic, but it does not reproduce the environment. It does not implement caches, services, token permissions, OIDC or environments the same way, and its images are not GitHub's. It is good for "does my script work?", not for "is my workflow correct?".
For the latter, the strategy from 04-05 is still the right one: actionlint in CI itself to catch syntax and expression errors before merging, a test branch where you exercise the whole workflow, and progressive changes with the new pipeline running alongside the old one.
# Validate the workflows as part of CI
- uses: actions/checkout@v4
- run: |
bash <(curl -s https://raw.githubusercontent.com/rhysd/actionlint/main/scripts/download-actionlint.bash)
./actionlint -colorCommon Mistakes and Tips
Forgetting actions/checkout. The runner arrives empty. It is everybody's first mistake.
Expecting export to travel between steps. Each run is a new shell: use $GITHUB_ENV.
Comparing boolean inputs against true. They arrive as strings. == 'true' or fromJSON(...).
Job-level permissions believing they add up. They replace: if you put pull-requests: write without contents: read, the checkout fails.
pull_request_target with a checkout of the PR code. It is the most direct way to leak all your secrets.
An OIDC sub with broad wildcards. repo:org/* lets any repository in the organisation assume your production role. Bind repository, branch or environment.
No timeout-minutes. The default is 6 hours of billable minutes for a hung job.
fail-fast: true in compatibility matrices. It cancels and hides the very information you were after.
A dynamic matrix without a gate job. Skipped jobs count as passing in branch protection: false green with nothing red to look at.
Making the install conditional on cache-hit. A partial restore leaves cache-hit as false; and even if it matched, npm ci is still necessary.
Caching node_modules. Binaries tied to a version and an architecture, and it eats the repository's cache budget, evicting useful caches.
Self-hosted runners on public repositories. Arbitrary code execution inside your network.
Third-party actions not pinned by SHA. A tag is mutable: whoever controls the action's repository controls your pipeline (04-03).
Repeated artifact names in a matrix with v4. The upload fails; use a suffix with the index.
if: always() to publish reports. It also runs on cancellation. Use if: ${{ !cancelled() }}.
Exercises
Exercise 1. Write a JavaScript action verify-migrations that, given a migrations directory, checks three things: that there are no two files with the same version number, that no new migration in the PR contains DROP COLUMN or DROP TABLE (the expand and contract rule from 04-06), and that every migration has its rollback file. It must expose outputs, write a job summary, generate annotations anchored to the file and be configurable as blocking or informational.
Exercise 2. A public Reservalia repository has this workflow. Find every security problem, explain the impact of each one and rewrite it:
on: pull_request_target
jobs:
comment-size:
runs-on: [self-hosted, linux]
steps:
- uses: actions/checkout@v3
with: { ref: ${{ github.event.pull_request.head.sha }} }
- run: npm install && npm run build
- run: |
SIZE=$(du -sh dist | cut -f1)
curl -X POST -H "Authorization: token ${{ secrets.PAT_ADMIN }}" \
-d "{\"body\":\"Bundle: $SIZE — ${{ github.event.pull_request.title }}\"}" \
"https://api.github.com/repos/${{ github.repository }}/issues/${{ github.event.number }}/comments"Exercise 3. Reservalia's ci.yml takes 11 minutes and the team complains that "GitHub Actions is slow". The data: average queue time is 4 minutes between 10:00 and 12:00; the six jobs restore cache but the hit rate is 40 %; the test matrix has fail-fast: true and often gets cancelled because of a flaky test; three jobs have no timeout-minutes and one of them hangs a couple of times a week; and the repository's total cache is at the limit with 9 GB, most of it node_modules from old branches. Diagnose each point and give an ordered plan.
Solutions
Solution 1.
# .github/actions/verify-migrations/action.yml
name: 'Verify migrations'
description: 'Checks unique numbering, absence of destructive operations and rollback files'
inputs:
directory: { description: 'Migrations directory', required: true, default: 'apps/api/migrations' }
base: { description: 'Base SHA for detecting new migrations', required: true }
fail: { description: 'Fail the job if there are problems', required: false, default: 'true' }
outputs:
issues: { description: 'Number of problems found' }
destructive: { description: 'true if there are destructive operations' }
runs:
using: 'node20'
main: 'dist/index.js'const core = require('@actions/core');
const exec = require('@actions/exec');
const fs = require('node:fs');
const path = require('node:path');
const DESTRUCTIVE = /\b(DROP\s+(COLUMN|TABLE)|TRUNCATE|ALTER\s+COLUMN\s+\w+\s+TYPE)\b/i;
async function newFiles(base, dir) {
let output = '';
await exec.exec('git', ['diff', '--name-only', '--diff-filter=A', `${base}...HEAD`, '--', dir],
{ listeners: { stdout: (d) => (output += d.toString()) } });
return output.split('\n').filter(Boolean);
}
async function run() {
try {
const dir = core.getInput('directory', { required: true });
const base = core.getInput('base', { required: true });
const fail = core.getBooleanInput('fail');
const issues = [];
// 1 · Duplicate numbering (over ALL the migrations, not only the new ones)
const byNumber = new Map();
for (const f of fs.readdirSync(dir).filter((f) => f.endsWith('.sql') && !f.endsWith('.down.sql'))) {
const num = f.split('_')[0];
if (byNumber.has(num)) {
issues.push({ file: path.join(dir, f), line: 1,
message: `Duplicate version number ${num} (collides with ${byNumber.get(num)})` });
} else {
byNumber.set(num, f);
}
}
// 2 and 3 · Only over the NEW migrations in the PR
let hasDestructive = false;
for (const filePath of await newFiles(base, dir)) {
if (filePath.endsWith('.down.sql')) continue;
const lines = fs.readFileSync(filePath, 'utf8').split('\n');
lines.forEach((line, i) => {
if (DESTRUCTIVE.test(line)) {
hasDestructive = true;
issues.push({ file: filePath, line: i + 1,
message: `Destructive operation. Use expand and contract: deploy the code that no longer uses the column, and drop it in a later migration` });
}
});
const rollback = filePath.replace(/\.sql$/, '.down.sql');
if (!fs.existsSync(rollback)) {
issues.push({ file: filePath, line: 1, message: `The rollback file ${path.basename(rollback)} is missing` });
}
}
for (const p of issues) {
core.error(p.message, { file: p.file, startLine: p.line, title: 'Migrations' });
}
core.setOutput('issues', String(issues.length));
core.setOutput('destructive', String(hasDestructive));
const summary = core.summary.addHeading('Migration checks');
if (issues.length === 0) {
summary.addRaw('No problems. Unique numbering, no destructive operations and rollback files present.');
} else {
summary.addTable([
[{ data: 'File', header: true }, { data: 'Line', header: true }, { data: 'Problem', header: true }],
...issues.map((p) => [p.file, String(p.line), p.message]),
]);
}
await summary.write();
if (issues.length > 0) {
const msg = `${issues.length} problem(s) in the migrations`;
fail ? core.setFailed(msg) : core.warning(msg);
}
} catch (e) {
core.setFailed(`Unexpected error: ${e.message}`);
}
}
run();Design decisions worth justifying. Duplicate numbering is checked across the whole directory, because the collision can come from two PRs open in parallel that are individually correct — it is an integration problem, not a file problem. Destructive operations and rollback files are checked only on the new migrations, because the historical ones have already been applied and flagging them would be noise that makes people ignore the tool. The fail input allows the progressive rollout from 04-05: informational for two weeks first, to measure how much noise it generates, then blocking. And the annotations anchored to file and line appear in the diff view, where the reviewer sees them without opening logs. Using it requires fetch-depth: 0 and passing base: ${{ github.event.pull_request.base.sha }}.
Solution 2. Six problems, three of them serious:
| # | Problem | Impact |
|---|---|---|
| 1 | pull_request_target + checkout of the PR code |
Anybody opens a PR and runs code with all the repository's secrets |
| 2 | npm install over the PR code |
Runs the attacker's postinstall scripts. Immediate consummation of point 1 |
| 3 | Self-hosted runner on a public repository | Arbitrary execution inside your network, and contamination of later jobs if it is persistent |
| 4 | secrets.PAT_ADMIN |
An admin PAT where GITHUB_TOKEN with pull-requests: write would do. Maximum privilege |
| 5 | Interpolating pull_request.title inside a curl |
Command injection: a title with quotes and $( ) runs whatever it likes |
| 6 | actions/checkout@v3 not pinned by SHA, no permissions, no timeout-minutes |
Mutable dependency, broad permissions, unbounded cost |
Rewritten as two workflows, which is the correct pattern:
# .github/workflows/pr-build.yml — NO secrets, runs untrusted code
name: PR · build
on: pull_request
permissions: { contents: read }
jobs:
build:
runs-on: ubuntu-22.04 # hosted, ephemeral runner
timeout-minutes: 15
steps:
- uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.1
- uses: actions/setup-node@v4
with: { node-version-file: .nvmrc, cache: npm }
- run: npm ci
- run: npm run build
- name: Save data for the comment
run: |
mkdir -p result
du -sk dist | cut -f1 > result/size-kb.txt
echo "${{ github.event.number }}" > result/pr.txt
- uses: actions/upload-artifact@v4
with: { name: result, path: result/, retention-days: 1 }# .github/workflows/pr-comment.yml — WITH permissions, does NOT run the PR code
name: PR · comment
on:
workflow_run:
workflows: ["PR · build"]
types: [completed]
permissions: { contents: read, pull-requests: write }
jobs:
comment:
if: github.event.workflow_run.conclusion == 'success'
runs-on: ubuntu-22.04
timeout-minutes: 5
steps:
- uses: actions/download-artifact@v4
with:
name: result
run-id: ${{ github.event.workflow_run.id }}
github-token: ${{ secrets.GITHUB_TOKEN }}
- id: data
run: |
echo "kb=$(cat size-kb.txt)" >> "$GITHUB_OUTPUT"
echo "pr=$(cat pr.txt)" >> "$GITHUB_OUTPUT"
- uses: actions/github-script@v7
env:
KB: ${{ steps.data.outputs.kb }} # via env, never interpolated into the body
PR: ${{ steps.data.outputs.pr }}
with:
script: |
await github.rest.issues.createComment({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: Number(process.env.PR),
body: `Bundle size: ${process.env.KB} KB`
});The central idea: separate privilege from the execution of untrusted code. The first workflow runs the PR's code with no secrets at all and read-only permissions; the second has write permission but only processes data that has already been generated and never runs the fork's code. Note as well that the PR title disappears from the comment: it added nothing and it was the injection vector. And that the data is passed through env, not interpolated into the script body, which is the general rule for any externally sourced data.
Solution 3. The diagnosis separates two things the team is conflating: 11 minutes of execution and 4 of queueing are different problems with different causes, and calling it all "GitHub Actions is slow" prevents fixing either.
| # | Symptom | Cause | Action | Effect |
|---|---|---|---|---|
| 1 | 3 jobs with no timeout-minutes, hanging twice a week |
The 6 h default | timeout-minutes on every job |
Stops consuming concurrency and minutes; partial cause of the queueing |
| 2 | Cache at the limit (9 GB) with a 40 % hit rate | node_modules cached per branch; least-recently-used eviction |
Cache ~/.npm with a lockfile-based key; delete old caches; do not cache node_modules |
Hit rate at 85-90 %; ~1.5 min per job |
| 3 | 4 min of queueing at peak times | The plan's concurrency limit, aggravated by 1 | concurrency with cancel-in-progress in CI; paths so that not everything runs every time; evaluate your own runners if it persists |
Queue down to ~1 min |
| 4 | Matrix cancelled by a flaky test | fail-fast: true on shards + a flaky test not quarantined |
fail-fast: false and, above all, quarantine the flaky test (02-04) |
Fewer full re-runs |
| 5 | 11 min of execution | To be determined once the above is done | Measure per job with the interface's timings before touching anything else | — |
Order and reasons. Point 1 first, because it costs five minutes of work and it is a partial cause of point 3: hung jobs occupying concurrency slots are part of the queue. Then 2, which is the biggest time saving per unit of effort and also frees up cache budget. Then 4, with the important caveat: fail-fast: false reduces the symptom but the real problem is the flaky test, and leaving it unquarantined while hiding the symptom is exactly what 02-04 warned against. And only then 3 and 5, once you can measure how much of each remains.
What to take away: of the five points, three are not about the tool — a flaky test without quarantine, jobs without timeouts, a badly chosen cache — but about how it is being used. The conclusion to take back to the team is that before changing tool or paying for more concurrency, you have to separate queueing from execution and fix your own side, which is literally the first rule of 04-04. If after that the queue is still the bottleneck, then there really is a legitimate conversation about the plan or your own runners, and that conversation is had with data.
Conclusion
GitHub Actions has been the course's tool since module 2, and this lesson has finally looked at it as an object of study. What closes the gaps that were left: the life cycle of an event and which branch the workflow is read from — which explains why schedule and workflow_run behave the way they do; contexts, with the trap that every input is a string; the triggers that were missing and the golden rule of pull_request versus pull_request_target; permissions that replace rather than accumulate, and the OIDC sub that has to be bound precisely; the three types of action and when to drop down to JavaScript; dynamic matrices with their mandatory gate job; the real behaviour of the cache with its per-branch scope and its eviction; ARC for ephemeral runners; and the service limits with their strategies.
The trade-offs, said plainly, because the module promised not to do marketing for any tool: the control plane is not yours and a service incident leaves you unable to deploy; the third-party actions ecosystem is its greatest strength and a real supply surface; the cache has a per-repository budget that punishes excess; schedule is not punctual and switches itself off; there are dangerous asymmetries such as a skipped job counting as passing; and pull_request_target is a permanent war footing in public repositories. None of that makes it a bad choice — for a team whose code is already on GitHub it remains the most reasonable default — but knowing these things is the difference between using it and suffering it.
That completes the tour of all six tools and the same Reservalia pipeline translated six times. The lesson that closes the module, Comparison and Criteria for Choosing a Tool, puts everything on the same table: the vocabulary equivalence table, the comparison by dimensions — execution model, hosting, reuse, identity, cost — the decision criteria ordered by their real weight (with one factor that decides 80 % of cases), a decision tree, a total cost of ownership exercise, the cost of switching tools and how to reduce it, and a phased migration guide. And after that, module 7: building your own end-to-end pipeline.
CI/CD Course: Continuous Integration and Deployment
Module 1: Introduction to CI/CD
- Basic CI/CD Concepts
- Benefits of CI/CD
- Popular CI/CD Tools
- The Course Project: the Application We Are Going to Automate
- DORA Metrics: How Software Delivery Is Measured
Module 2: Continuous Integration (CI)
- Introduction to Continuous Integration
- Setting Up a CI Environment
- Build Automation
- Automated Testing
- Code Quality and Static Analysis
- Artifacts, Versioning and Promotion
- Integration with Version Control
Module 3: Continuous Deployment (CD)
- Introduction to Continuous Deployment
- Deployment Automation
- Infrastructure as Code and Reproducible Environments
- Deployment Strategies
- Feature Flags, Rollback and Failure Recovery
- Monitoring and Feedback
Module 4: Advanced CI/CD Practices
- CI/CD Pipelines
- Dependency Management
- Security in CI/CD
- Scalability and Performance
- Pipeline as Code: Templates, Reuse and Testing the Pipeline
- Databases in the Pipeline: Safe Migrations
Module 5: Implementing CI/CD in Real Projects
- Case Study: Web Project
- Case Study: Mobile Application
- Case Study: Microservices
- Case Study: Modernising a Legacy Project
Module 6: Tools and Technologies
- Jenkins
- GitLab CI/CD
- CircleCI
- Travis CI
- Docker and Kubernetes
- GitHub Actions in Depth
- Comparison and Criteria for Choosing a Tool
Module 7: Practical Exercises
- Exercise 1: Setting Up a Basic Pipeline
- Exercise 2: Integrating Automated Tests
- Exercise 3: Deploying to a Production Environment
- Exercise 4: Monitoring and Feedback
- Exercise 5: Hardening the Pipeline with Security and Secrets
- Final Project: A Complete End-to-End Pipeline
