GitHub Actions — Your Automated Factory¶
You know how to write Python. You know how to git push. But right now, every time you change your code, you are the one who runs the tests, builds the Docker images, and copies them to the server. That works for a hobby project. It does not scale, and it does not survive a bad night’s sleep — the day you forget to run the tests is the day the bug ships.
GitHub Actions is the tool that takes those chores off your hands. This chapter teaches it from first principles, assuming you have never written a line of YAML automation in your life.
💡 Intuition: Think of GitHub Actions as an automated factory / assembly line bolted onto your repository. Every time a part (your code) arrives at the loading dock (a
git push), the conveyor belt starts. Stations along the belt inspect it, assemble it, package it, and ship it — automatically, the same way, every single time. You designed the assembly line once; the factory runs it forever.
This is the engine of CI/CD:
CI (Continuous Integration) — the quality-control line. Every change is automatically tested and integrated, so broken code is caught in minutes, not in production.
CD (Continuous Delivery/Deployment) — the delivery truck. Once a change passes quality control, it is automatically packaged and shipped to your server.
GitHub Actions can do both. By the end of this chapter you will fully understand the workflow that builds and deploys our example app.
1. The vocabulary, from the outside in¶
GitHub Actions has a small set of nested concepts. Learn these five words and the rest is detail. We will go from the biggest box to the smallest.
Term |
Factory analogy |
What it is |
|---|---|---|
Workflow |
the whole assembly line |
A YAML file describing an automated process |
Event / trigger |
the loading-dock bell that starts the belt |
What causes a workflow to run (a push, a PR, a schedule…) |
Job |
a workstation (or a whole sub-line) |
A group of steps that run together on one machine |
Step |
a single task at a station |
One command or one reusable action |
Action |
a pre-built power tool |
A packaged, reusable unit you plug into a step |
Runner |
the worker + the workbench |
The machine that actually executes a job |
Let’s define each properly.
Workflow¶
A workflow is an automated process described in a single YAML file. It lives in your repository under the special directory .github/workflows/. You can have many workflows — one for tests, one for deployment, one for nightly chores. GitHub watches this directory and runs whatever it finds there.
Event (trigger)¶
A workflow does nothing until something triggers it. An event is the thing that rings the bell: someone pushes code, opens a pull request, the clock strikes midnight, or a human clicks a button. The on: key in the YAML lists which events the workflow listens for.
Job¶
A job is a set of steps that execute together on a single fresh machine. A workflow can contain several jobs. By default, jobs run in parallel — the factory opens several workstations at once to save time.
If you need order — “don’t try to deploy until the tests have passed” — you declare a dependency with the needs: keyword. That turns parallel stations into an ordered line.
Step¶
A step is one unit of work inside a job: run a shell command, or invoke a reusable action. Steps in a job run sequentially, top to bottom, on the same machine, so a file created by step 1 is still there for step 2.
Action¶
An action is a reusable, shareable unit of work — a pre-built power tool someone (often GitHub or Docker) wrote and published so you don’t have to. You plug it into a step with uses:. Examples: actions/checkout (clone your repo onto the runner), docker/build-push-action (build and push a Docker image). Actions take inputs via a with: block.
Runner¶
A runner is the actual computer that executes a job. There are two kinds:
GitHub-hosted runners — fresh virtual machines GitHub spins up for you on demand (
ubuntu-latest,windows-latest,macos-latest). Each job gets a clean, throwaway VM. You pay nothing for public repos and get free minutes for private ones. This is what we use, and what 95% of projects use.Self-hosted runners — a machine you own and register with GitHub (your own server, a beefy build box with a GPU, a machine inside a private network). You manage and secure it. Use these only when you need special hardware, very long jobs, or access to internal resources GitHub’s cloud can’t reach.
💡 Intuition: A GitHub-hosted runner is a rented workbench that is scrubbed clean and thrown away after every job. Nothing leaks between runs, which is wonderful for reproducibility — but it also means nothing persists. If you need data to survive, you must explicitly save it (caches, artifacts, or a registry).
Here is how the boxes nest:
flowchart TD
EV[Event: git push to main] --> WF[Workflow: ci-cd.yml]
WF --> J1[Job: test]
WF --> J2[Job: build-and-push]
WF --> J3[Job: deploy]
J1 --> S1[Step: checkout]
J1 --> S2[Step: setup-python]
J1 --> S3[Step: install deps]
J1 --> S4[Step: run pytest]
J2 -. needs: test .-> J1
J3 -. needs: build-and-push .-> J2
The dotted needs arrows are what turn three parallel jobs into an ordered pipeline: test → build-and-push → deploy.
2. The anatomy of a workflow file¶
Every workflow is YAML. Here is the smallest meaningful one — a workflow that does nothing but run the backend tests on every push. We will build up from here.
name: Tests # 1. Human-friendly label shown in the UI
on: push # 2. WHEN to run: on every push to any branch
jobs: # 3. WHAT to run
test: # 4. Job id (you choose this name)
runs-on: ubuntu-latest # 5. Which runner machine
steps: # 6. The ordered list of tasks
- uses: actions/checkout@v4 # 7. Pull the repo onto the runner
- uses: actions/setup-python@v5 # 8. Install Python on the runner
with:
python-version: "3.12" # 9. Input to the action above
- run: pip install -r requirements.txt # 10. Run a shell command
- run: pytest -q # 11. Run another shell command
Line by line:
name:— a label for humans. It shows up in the GitHub “Actions” tab. Optional but always include it.on:— the trigger. Here, the single wordpushmeans “run on every push to any branch.” We’ll refine this below.jobs:— the container for all jobs. Everything indented under it is a job.test:— the job id. You invent this name. It is how other jobs refer to this one (vianeeds: test).runs-on:— the runner.ubuntu-latestasks GitHub for a fresh, current Ubuntu VM.steps:— an ordered list. Each-is one step.uses: actions/checkout@v4— invoke thecheckoutaction. A fresh runner does not have your code on it — it is a blank machine. This action git-clones your repository onto it. The@v4pins the major version (more on pinning in best practices).uses: actions/setup-python@v5— install a specific Python onto the runner.with:— inputs for the action immediately above.python-version: "3.12"tellssetup-pythonwhich interpreter to install. (We pin 3.12 everywhere — see the example app.)run:— execute a shell command directly on the runner. This is the other way (besidesuses:) to do work in a step.A second
run:step. Steps execute top to bottom; ifpip installfails,pytestnever runs and the job is marked failed.
uses: vs run: — the two kinds of step¶
This distinction is the heart of GitHub Actions, so let’s make it crisp.
run:executes a shell command, exactly as if you typed it into a terminal on the runner. Use it for project-specific commands:pytest,pip install,npm run build.uses:plugs in a pre-built action — a reusable tool published in a repository or the marketplace. Use it for common, fiddly tasks someone has already solved: checking out code, setting up a language, logging into a registry, building a Docker image.
💡 Intuition:
run:is you, using your own two hands at the workbench.uses:is picking up a power tool that an expert built. You wouldn’t hand-carve a screw; you’d grab a screwdriver.actions/checkoutis the screwdriver for “get my code.”
3. Triggers — choosing when the belt starts¶
The on: key controls when a workflow runs. This is worth understanding deeply, because running the wrong job at the wrong time (e.g. deploying from a pull request) is a classic, dangerous mistake.
Push and pull_request, with branch filters¶
on:
push:
branches: [main] # run when commits land on main
pull_request:
branches: [main] # run when a PR TARGETS main
push:fires when commits are pushed. Thebranches: [main]filter narrows it to themainbranch only — pushing to a feature branch won’t trigger it.pull_request:fires when a pull request is opened or updated. Herebranches: [main]means “PRs whose target ismain.” This is how you run tests on a proposed change before it is merged — the core of the quality-control line.
The combination is the standard CI pattern: test every PR targeting main, and test again once code actually lands on main.
Manual and scheduled triggers¶
on:
workflow_dispatch: # adds a "Run workflow" button in the UI
schedule:
- cron: "0 3 * * *" # run every day at 03:00 UTC
workflow_dispatch:lets a human start the workflow by clicking a button in the Actions tab. Perfect for a deploy you want to trigger on demand, or a one-off maintenance task.schedule:runs the workflow on a timer usingcronsyntax. The five fields areminute hour day-of-month month day-of-week.0 3 * * *means “at minute 0 of hour 3, every day.” Great for nightly builds, dependency-update checks, or cleanup. Note: scheduled times are always UTC.
You can list several triggers at once — a workflow can run on push, on PR, on a button click, and on a schedule.
4. Execution flow: what happens, second by second, on a push to main¶
Let’s slow time down and watch the factory react when you run git push origin main. This is the single most important mental model in the chapter.
t = 0s — The push lands. Your commits arrive at GitHub. GitHub looks in .github/workflows/ for any workflow whose on: rules match a push to main. Our ci-cd.yml matches.
t ≈ 1s — GitHub plans the run. It reads the jobs: block and builds a dependency graph from the needs: keys. It sees test has no needs, so it can start immediately. build-and-push needs test. deploy needs build-and-push. So the plan is a strict chain.
t ≈ 2s — The test job is queued. GitHub requests a fresh ubuntu-latest VM. A clean machine boots — no code, no Python packages, nothing.
t ≈ 10s — test runs its steps in order. Checkout clones the repo. setup-python installs Python 3.12. pip install pulls the dependencies. pytest -q runs the test suite. If any step exits non-zero, the job fails and the whole chain stops — build-and-push and deploy never start. This is the quality-control line doing its job: bad code cannot proceed.
t ≈ 60s — test passes; build-and-push begins. Because test succeeded and this is the main branch (the if: condition we’ll see below), a new fresh VM is requested. It logs into the registry, builds the three Docker images, and pushes them to GHCR (GitHub Container Registry — see registries).
t ≈ 180s — build-and-push passes; deploy begins. A third fresh VM connects to the Ubuntu server over SSH and runs docker compose pull && docker compose up -d. The server downloads the new images and restarts the containers.
t ≈ 200s — The new version is live. Visitors hitting example.com now get the new code. You never touched a terminal.
sequenceDiagram
participant Dev as Developer
participant GH as GitHub
participant Runner as GitHub-hosted runner
participant GHCR as GHCR registry
participant Server as Ubuntu server
Dev->>GH: git push to main
GH->>GH: match on: rules, plan jobs
GH->>Runner: start "test" on fresh VM
Runner->>Runner: checkout, setup-python, pip install, pytest
Runner-->>GH: tests passed ✓
GH->>Runner: start "build-and-push" (needs test)
Runner->>GHCR: docker login + build + push 3 images
GHCR-->>Runner: images stored ✓
GH->>Runner: start "deploy" (needs build-and-push)
Runner->>Server: SSH: docker compose pull && up -d
Server->>GHCR: pull :latest images
GHCR-->>Server: images delivered
Server-->>Dev: application is live 🎉
5. The full canonical workflow — every line explained¶
Below is the exact workflow we ship for the example app. It is reproduced verbatim from the module’s source of truth. After it, we explain every field.
⚠️ Path caveat (read this!): GitHub only runs workflows located at the repository root in
.github/workflows/. This module keeps the file atexample-app/.github/workflows/ci-cd.ymlso it lives next to the app it builds, which is great for teaching. To actually run it, copy or move it to the repo-root.github/workflows/directory. TheAPP_DIRenvironment variable already points at the module path, so the workflow keeps working once relocated — nothing else needs to change.
name: CI/CD
on:
push:
branches: [main]
pull_request:
branches: [main]
env:
REGISTRY: ghcr.io
IMAGE_PREFIX: ghcr.io/${{ github.repository_owner }}/example-app
APP_DIR: 14_cicd/example-app
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: "3.12"
- name: Install dependencies
working-directory: ${{ env.APP_DIR }}/backend
run: |
python -m pip install --upgrade pip
pip install -r requirements.txt
- name: Run tests
working-directory: ${{ env.APP_DIR }}/backend
run: pytest -q
build-and-push:
needs: test
if: github.ref == 'refs/heads/main'
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
steps:
- uses: actions/checkout@v4
- uses: docker/setup-buildx-action@v3
- name: Log in to GHCR
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Build & push backend
uses: docker/build-push-action@v6
with:
context: ${{ env.APP_DIR }}/backend
push: true
tags: |
${{ env.IMAGE_PREFIX }}-backend:latest
${{ env.IMAGE_PREFIX }}-backend:${{ github.sha }}
- name: Build & push frontend
uses: docker/build-push-action@v6
with:
context: ${{ env.APP_DIR }}/frontend
push: true
tags: |
${{ env.IMAGE_PREFIX }}-frontend:latest
${{ env.IMAGE_PREFIX }}-frontend:${{ github.sha }}
- name: Build & push nginx
uses: docker/build-push-action@v6
with:
context: ${{ env.APP_DIR }}/nginx
push: true
tags: |
${{ env.IMAGE_PREFIX }}-nginx:latest
${{ env.IMAGE_PREFIX }}-nginx:${{ github.sha }}
deploy:
needs: build-and-push
if: github.ref == 'refs/heads/main'
runs-on: ubuntu-latest
steps:
- name: Deploy over SSH
uses: appleboy/ssh-action@v1
with:
host: ${{ secrets.SERVER_HOST }}
username: ${{ secrets.SERVER_USER }}
key: ${{ secrets.SERVER_SSH_KEY }}
script: |
cd /opt/example-app
docker compose pull
docker compose up -d
docker image prune -f
5.1 The header: name, on, env¶
name: CI/CD
The label shown in the Actions tab. Every run of this file is grouped under “CI/CD.”
on:
push:
branches: [main]
pull_request:
branches: [main]
Two triggers. Push to main drives the real pipeline (test → build → deploy). Pull requests targeting main run only the parts allowed on a PR (we’ll see that only test runs, thanks to the if: guards). This means: open a PR → tests run, giving you a green/red check before you merge; merge to main → the full deploy fires.
env:
REGISTRY: ghcr.io
IMAGE_PREFIX: ghcr.io/${{ github.repository_owner }}/example-app
APP_DIR: 14_cicd/example-app
env: at the top level defines environment variables visible to every job and step. Defining them once here avoids repeating long strings and keeps the file DRY (Don’t Repeat Yourself).
REGISTRY— the registry hostname,ghcr.io.IMAGE_PREFIX— the common stem of every image name.${{ github.repository_owner }}is a built-in context expression: GitHub substitutes the repo owner at runtime. For ownerChrisW09this resolves toghcr.io/chrisw09/example-app(GHCR lowercases owners). The three images then become…-backend,…-frontend,…-nginx, exactly matching the names in docker-compose.yml.APP_DIR— the path from the repo root to the app. This is the variable that lets the workflow live at the repo root while still finding the app inside the module. The${{ env.APP_DIR }}syntax reads it back later.
💡 What is
${{ … }}? That double-brace syntax is expression interpolation. GitHub evaluates whatever is inside before the command runs, substituting values from contexts likegithub(info about the event/repo),env(your variables),secrets(encrypted secrets), andjob/steps(results). It is GitHub Actions’ templating language.
5.2 Job 1 — test¶
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: "3.12"
A fresh Ubuntu runner. checkout@v4 clones the repo onto it. setup-python@v5 installs Python 3.12 (the with: block passes the version as an input).
- name: Install dependencies
working-directory: ${{ env.APP_DIR }}/backend
run: |
python -m pip install --upgrade pip
pip install -r requirements.txt
name:gives the step a readable label in the logs.working-directory:changes into the backend folder for this step only, sorequirements.txtis found without long paths. It expandsAPP_DIRto14_cicd/example-app/backend.run: |— the|is YAML for a multi-line block: every line below runs as a shell script. We upgrade pip, then install the backend dependencies.
- name: Run tests
working-directory: ${{ env.APP_DIR }}/backend
run: pytest -q
Runs the test suite quietly (-q). If a test fails, pytest exits non-zero, the step fails, the job fails, and — because the next jobs need this one — the pipeline halts. Broken code never gets built or deployed.
5.3 Job 2 — build-and-push¶
build-and-push:
needs: test
if: github.ref == 'refs/heads/main'
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
Three critical lines:
needs: test— this job will not start untiltestfinishes successfully. This is what creates the ordered chain. Without it, GitHub would try to build images in parallel with the tests — and might push broken images.if: github.ref == 'refs/heads/main'— a condition.github.refis the ref that triggered the run; on a push tomainit equalsrefs/heads/main. On a pull request it is something else (a PR ref). So this job runs only for pushes tomain, never for pull requests. That is exactly what you want: PRs should be tested, but they must not build and push images. (Anyone could open a PR; you don’t want a random PR publishing images under your name.)permissions:— the GitHub-issued token for this job is granted least privilege: read-only access to repocontents, andwriteaccess topackages(so it can push images to GHCR). By default the token is fairly locked down;packages: writeis the specific power this job needs and nothing more.
steps:
- uses: actions/checkout@v4
- uses: docker/setup-buildx-action@v3
Check out the code again (remember: a new, clean VM, so we must re-clone). setup-buildx-action installs Buildx, Docker’s modern, faster image builder, which build-push-action relies on.
- name: Log in to GHCR
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
Authenticate to the registry before pushing.
registry: ghcr.io— log into GitHub Container Registry.username: ${{ github.actor }}— the user who triggered the run.password: ${{ secrets.GITHUB_TOKEN }}—GITHUB_TOKENis a special, automatic secret. GitHub mints it freshly for each run and destroys it when the run ends; you never create or store it. Combined withpermissions: packages: writeabove, it grants exactly enough access to push images and nothing else. You do not need to create a Personal Access Token for this. (See registries for the manualdocker login ghcr.ioyou’d run on your laptop.)
- name: Build & push backend
uses: docker/build-push-action@v6
with:
context: ${{ env.APP_DIR }}/backend
push: true
tags: |
${{ env.IMAGE_PREFIX }}-backend:latest
${{ env.IMAGE_PREFIX }}-backend:${{ github.sha }}
The workhorse. docker/build-push-action@v6 builds an image and pushes it in one step.
context:— the build directory (where the Dockerfile lives), here the backend folder.push: true— after building, push to the registry. (Setfalseand it would only build, e.g. to test the build on a PR.)tags: |— a multi-line list of tags to apply. Two tags per image:…-backend:latest— the moving “newest” pointer.…-backend:${{ github.sha }}—github.shais the full commit hash of the build. This gives every image an immutable, traceable tag: you can always point to exactly the image built from exactly this commit. This dual-tag scheme (:latestplus:<sha>) is explained in depth in registries.
The frontend and nginx steps are identical in shape, just pointing at their own folders and image names. Three services → three build-and-push steps → six tags pushed.
5.4 Job 3 — deploy¶
deploy:
needs: build-and-push
if: github.ref == 'refs/heads/main'
runs-on: ubuntu-latest
steps:
- name: Deploy over SSH
uses: appleboy/ssh-action@v1
with:
host: ${{ secrets.SERVER_HOST }}
username: ${{ secrets.SERVER_USER }}
key: ${{ secrets.SERVER_SSH_KEY }}
script: |
cd /opt/example-app
docker compose pull
docker compose up -d
docker image prune -f
needs: build-and-push— deploy only after images exist in the registry.if: github.ref == 'refs/heads/main'— again, never deploy from a pull request. Only a real merge tomainships to production.appleboy/ssh-action@v1— a community action that opens an SSH connection to a server and runs a script there.host/username/keycome fromsecrets.SERVER_HOST,SERVER_USER,SERVER_SSH_KEY. These are repository secrets you create in Settings → Secrets and variables → Actions. They are encrypted and never printed in logs. The key is the private SSH key authorized to log into the Ubuntu server at203.0.113.10.The
script:runs on the server: change into/opt/example-app,docker compose pullto download the freshly-pushed:latestimages,docker compose up -dto recreate containers with the new images in the background, anddocker image prune -fto delete the now-unused old image layers and reclaim disk.
That is the full CI → CD journey: quality-control line (test) → packaging (build-and-push) → delivery truck (deploy).
6. ⚠️ Common mistakes¶
⚠️ Common mistake — Forgetting
permissions:. If the build job tries to push to GHCR withoutpackages: write, the push fails with a confusingdenied/403error. The default token can’t write packages. Grant the specific permission the job needs — no more.
⚠️ Common mistake — Leaking secrets in logs. Never
echo "$MY_SECRET", never paste a token into arun:for “debugging.” GitHub automatically masks values stored as secrets, but it cannot mask a secret you transform (e.g. base64-encode) or one you hardcoded into the YAML. Hardcoded credentials in a workflow are visible to anyone with repo read access.
⚠️ Common mistake — No
needs:ordering. Jobs run in parallel by default. Omitneeds: teston the build job and you may build and push images before the tests finish — shipping code you haven’t validated. Always makedeployneedbuild-and-push, andbuild-and-pushneedtest.
⚠️ Common mistake — Running deploy on pull requests. Without
if: github.ref == 'refs/heads/main', the deploy job fires on every PR. Since anyone can open a PR against a public repo, this could let an outsider trigger a production deployment — or just thrash your server. Test on PRs; build and deploy only frommain.
⚠️ Common mistake — Putting the workflow in the wrong folder. A workflow under
example-app/.github/workflows/will never run, because GitHub only reads.github/workflows/at the repository root. See the caveat in §5.
7. ✅ Production best practices¶
✅ Best practice — Least-privilege permissions. Add an explicit
permissions:block. Start fromcontents: readand add only what each job genuinely needs (packages: writefor the registry push). The narrower the token, the smaller the blast radius if something goes wrong.
✅ Best practice — Pin action versions. Use
@v4,@v6, etc. (or, for maximum safety, a full commit SHA likeactions/checkout@<sha>). Never depend on a floating@mainof someone else’s action — a surprise update could change behavior or, worse, run malicious code with access to your secrets.
✅ Best practice — Cache dependencies. Re-downloading pip/npm packages on every run is slow.
actions/setup-pythonsupports acache: pipinput;docker/build-push-actionsupports layer caching. Faster runs mean faster feedback.
✅ Best practice — Use a build matrix and branch protection. A matrix runs the same job across several configurations at once (e.g. Python 3.11, 3.12, 3.13) — parallel quality control. Pair this with branch protection rules on
mainthat require the CI checks to pass before a PR can merge: now broken code physically cannot reachmain.
✅ Best practice — Use Environments and required reviewers. GitHub Environments (e.g. a
productionenvironment) let you attach required reviewers and secrets scoped to that environment. Setenvironment: productionon thedeployjob, and a human must click “Approve” before the delivery truck leaves the depot — a safety gate for production.
8. Exercises¶
Try these against a fork of the example app. Answers are hidden — think first, then peek.
Exercise 1 — A lint-only workflow.
Write a brand-new workflow file lint.yml that runs on every pull request to main, checks out the code, sets up Python 3.12, installs ruff, and runs ruff check . on the backend.
Answer
name: Lint
on:
pull_request:
branches: [main]
jobs:
lint:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: "3.12"
- run: pip install ruff
- name: Run ruff
working-directory: 14_cicd/example-app/backend
run: ruff check .
Place it at the repo-root .github/workflows/lint.yml so GitHub actually runs it.
Exercise 2 — Why does build-and-push skip on a PR?
Trace exactly which jobs run when you open a pull request targeting main, and explain why.
Answer
Only test runs. The pull_request trigger matches, so the workflow starts and test (which has no if:) executes. But build-and-push and deploy each carry if: github.ref == 'refs/heads/main'. On a pull request, github.ref is a PR ref (e.g. refs/pull/42/merge), not refs/heads/main, so both jobs are skipped. Result: PRs get tested but never build or deploy — exactly the safety we want.
Exercise 3 — Add a manual deploy button. Modify the trigger so a maintainer can also start the pipeline by hand from the Actions tab, without pushing code.
Answer
Add workflow_dispatch: to the on: block:
on:
push:
branches: [main]
pull_request:
branches: [main]
workflow_dispatch:
A “Run workflow” button now appears in the Actions tab. Note that a manual run on main still satisfies github.ref == 'refs/heads/main', so build and deploy will fire.
Exercise 4 — Spot the security hole.
A teammate adds this step “to debug the deploy”: run: echo "Deploying with key ${{ secrets.SERVER_SSH_KEY }}". What’s wrong, and what should they do instead?
Answer
It tries to print a private SSH key into the build logs. Even though GitHub masks known secret values, this is a terrible habit: logs can be exported, the key could appear if transformed, and anyone with read access to the run could see it. Never echo secrets. To debug, log non-sensitive facts instead — e.g. echo "Deploying to $SERVER_HOST" only if the host isn’t sensitive, or simply rely on the action’s own (masked) output. Rotate any key that ever touched a log.