How to buildOctober 11, 202614 min read
Docker and deploymentStep 8 of 8

CI/CD with GitHub Actions: checks on every pull request

In short

CI on GitHub Actions is a YAML file in .github/workflows/ that, on every pull request, installs dependencies with npm ci and runs the linter, type checking, tests and the build on a clean GitHub machine. To keep broken code out of main, you make the final check required in branch protection, and move publishing and deployment into a separate tag-triggered workflow — with a least-privilege token, actions pinned to a SHA and secrets stored in the production environment.

TutorialLevel: IntermediateCI/CDPractice: ~1.5–3 h

After this you will be able to

  • write a ci.yml for a Node.js project: npm ci with caching, lint, type checking, unit tests across a Node.js version matrix, integration tests with PostgreSQL in a service container, and a Docker image build
  • restrict GITHUB_TOKEN permissions, pin actions to a SHA and pass untrusted input to run only through environment variables
  • make a single final check required for merging and explain why a skipped job does not block a pull request
  • check a workflow before pushing with actionlint and act, and set up image publishing and a tag-triggered deployment through an environment

Verified with

GitHub Actions (runner ubuntu-24.04)actions/checkout 7.0.1actions/setup-node 7.1.0actions/upload-artifact 7.0.2docker/build-push-action 7.4.0Node.js 22 / 24 / 26actionlint 1.7.12act 0.2.89

Checked against docs: October 11, 2026

Contents
  1. 01CI and CD: what to check automatically
  2. 02How a workflow is built: events, jobs, steps
  3. 03Pull request checks: Node.js, cache, matrix, PostgreSQL
  4. 04Workflow security: permissions, action versions, untrusted input
  5. 05Checking before you push: actionlint and a local run with act
  6. 06Required checks: branch protection
  7. 07Publishing the image and deploying on a tag
  8. 08Exercises

CI and CD: what to check automatically

CI (continuous integration) is the practice of automatically building and checking every change before it is merged into the main branch. CD (continuous delivery/deployment) is the next step: the checked version is published and rolled out to a server just as automatically. In GitHub Actions both are described by YAML files in the repository's own .github/workflows/ directory, so the pipeline lives next to the code and changes through the same pull requests.

The practice project is the leads API from the backend testing lesson: Node.js, Express, PostgreSQL, unit and integration tests with Vitest. We put it on a pipeline: every pull request goes through the linter, type checking, unit tests on three Node.js versions, integration tests with a real PostgreSQL and a Docker image build, and a tag like v1.2.0 publishes the image and deploys it to production.

Pull requestLint and typesTestsDocker imageDeploy on tag
CI — every pull requestCD
The lesson pipeline: four CI stages on every pull request and a deployment only on a version tag.

pull_request

What runs
ci.yml: all checks; the image is built but not published
Why
keep code that fails the checks out of main

push to main

What runs
ci.yml
Why
check the merge result and refresh the main cache that every pull request uses

tag v*.*.*

What runs
release.yml: CI → publish image → deploy
Why
release a version the same checked way, not by hand from a laptop

How a workflow is built: events, jobs, steps

A workflow is one YAML file. The on key lists the events it reacts to, jobs lists the jobs. GitHub runs each job on a separate clean virtual machine — a runner — chosen by runs-on. Jobs run in parallel unless you link them with needs. Inside a job, steps run in order: either a shell command (run) or a ready-made action (uses) — for example, actions/checkout downloads the repository code.

Event (on)

What it is
what triggers the workflow
In our ci.yml
pull_request, push to main, workflow_call — a call from another workflow

Job

What it is
a set of steps on one machine; each job is a separate check on the pull request
In our ci.yml
checks, unit, integration, image, ci-ok

Runner

What it is
the machine that runs a job
In our ci.yml
ubuntu-24.04 — GitHub's Ubuntu image with Node.js and Docker

Action

What it is
a reusable ready-made step from another repository
In our ci.yml
actions/setup-node installs Node.js and caches npm

Matrix

What it is
one job multiplied over a set of values
In our ci.yml
node: [22, 24, 26] — three unit test jobs

Service container

What it is
a Docker container that lives next to the job
In our ci.yml
postgres:17-alpine for integration tests

Two properties of the pull_request event are worth knowing up front. First, it checks not the tip of the branch but the result of merging it into the target branch: GITHUB_REF is refs/pull/<number>/merge, and actions/checkout downloads exactly that merge commit. Second, if the pull request has a merge conflict, pull_request workflows do not run at all — resolve the conflict first. By default the event fires when a pull request is opened, reopened or receives new commits (opened, reopened, synchronize).

Pull request checks: Node.js, cache, matrix, PostgreSQL

A workflow should call the same commands a developer runs locally — then "works on my machine" and "works in CI" mean the same thing. So we start with the scripts in package.json:

package.json
{
  "scripts": {
    "lint": "oxlint --deny-warnings src tests",
    "typecheck": "tsc -p .",
    "test:unit": "vitest run --project unit",
    "test:integration": "vitest run --project integration",
    "test": "vitest run"
  }
}
Fragment: the scripts section. Dependencies are as in the backend testing lesson, plus oxlint in devDependencies

The linter is oxlint: the project uses TypeScript 7.0, and typescript-eslint 8.71 declared compatibility only with typescript <6.1.0 at the time of checking. On TypeScript 5.x the familiar ESLint plus typescript-eslint setup works — for CI it makes no difference, the step calls npm run lint either way. The workflow itself:

.github/workflows/ci.yml
name: CI

on:
  pull_request:
  push:
    branches: [main]
  workflow_call:

# Least privilege: the token can only read the code
permissions:
  contents: read

# A new push to the same PR cancels the previous, now useless run
concurrency:
  group: ci-${{ github.ref }}
  cancel-in-progress: ${{ github.event_name == 'pull_request' }}

jobs:
  checks:
    name: Lint and typecheck
    runs-on: ubuntu-24.04
    timeout-minutes: 10
    steps:
      - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
        with:
          persist-credentials: false
      - uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0
        with:
          node-version: 24
          cache: npm
      - run: npm ci
      - run: npm run lint
      - run: npm run typecheck

  unit:
    name: Unit tests (Node ${{ matrix.node }})
    runs-on: ubuntu-24.04
    timeout-minutes: 10
    strategy:
      fail-fast: false
      matrix:
        node: [22, 24, 26]
    steps:
      - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
        with:
          persist-credentials: false
      - uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0
        with:
          node-version: ${{ matrix.node }}
          cache: npm
      # Node 22 ships npm 10, which rejects a lockfile written by npm 11
      - run: npm install --global npm@11
      - run: npm ci
      - run: npm run test:unit

  integration:
    name: Integration tests
    runs-on: ubuntu-24.04
    timeout-minutes: 15
    services:
      postgres:
        image: postgres:17-alpine
        env:
          POSTGRES_PASSWORD: test # a throwaway database: not a secret
          POSTGRES_DB: leads_test
        ports:
          - 5432:5432
        options: >-
          --health-cmd pg_isready
          --health-interval 5s
          --health-timeout 5s
          --health-retries 10
    env:
      TEST_DATABASE_URL: postgres://postgres:test@localhost:5432/leads_test
    steps:
      - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
        with:
          persist-credentials: false
      - uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0
        with:
          node-version: 24
          cache: npm
      - run: npm ci
      - run: npm run test:integration -- --reporter=default --reporter=junit --outputFile.junit=reports/integration.xml
      - name: Upload test report
        if: ${{ !cancelled() }}
        uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
        with:
          name: integration-report
          path: reports/
          retention-days: 7

  image:
    name: Docker image
    needs: [checks, unit, integration]
    runs-on: ubuntu-24.04
    timeout-minutes: 15
    steps:
      - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
        with:
          persist-credentials: false
      - uses: docker/setup-buildx-action@f87e5991a6d7451dcb8d9637bfbc97413f497069 # v4.4.1
      - uses: docker/build-push-action@c3c9e263c25d99ce0380d002d59b67737d91b0dc # v7.4.0
        with:
          context: .
          push: false
          tags: leads-api:ci

  # The single check to mark as required in branch protection
  ci-ok:
    name: CI passed
    if: ${{ always() }}
    needs: [checks, unit, integration, image]
    runs-on: ubuntu-24.04
    steps:
      - name: Fail if any job failed or was cancelled
        if: ${{ contains(needs.*.result, 'failure') || contains(needs.*.result, 'cancelled') }}
        run: exit 1
The full CI: checked with actionlint 1.7.12 and a local run with act 0.2.89
  • npm ci installs packages strictly from package-lock.json and fails if the lockfile does not match package.json. That is exactly what CI needs: it builds precisely what was checked locally.
  • cache: npm in setup-node saves the global npm cache (~/.npm), not node_modules; the cache key is a hash of package-lock.json. Packages are not downloaded again, but npm ci still builds node_modules from scratch.
  • A cache created in a pull request is scoped to its merge ref and can only be restored by re-runs of that same pull request. That is why CI also runs on push to main: every pull request into main can restore the main cache.
  • The matrix node: [22, 24, 26] creates three unit jobs. According to the release schedule, Node.js 22 is supported until 30 April 2027, 24 is LTS, and 26 becomes LTS on 28 October 2026. fail-fast defaults to true — when one version fails, the rest are cancelled; with false you see results for all versions at once.
  • The linter and integration tests stay out of the matrix: their result barely depends on the Node.js version, while runner time costs money and quota minutes.
  • services.postgres starts PostgreSQL 17 next to the job. The health check (pg_isready) holds the steps until the database is ready, and ports maps the port to the runner — hence the localhost:5432 address. TEST_DATABASE_URL points global-setup.ts from the testing lesson at this database instead of Testcontainers. The test password is not a secret: the database lives a few minutes and is not reachable from outside.
  • The JUnit report is uploaded as an artifact with the !cancelled() condition: also when tests fail — that is exactly when you need it — but not when the run is cancelled. retention-days: 7 instead of the default 90 days saves storage.
  • concurrency with cancel-in-progress only for pull_request: a new push to the branch cancels the outdated run, while runs on main always finish. timeout-minutes matters: by default a hung job lives for 360 minutes.

The npm install --global npm@11 line in the matrix appeared after the very first local run. The lockfile was written by npm 11, and Node.js 22 ships npm 10, which considers that lockfile out of sync: the project uses msw 3, while an optional peer dependency of @vitest/mocker expects msw 2. On Node.js 24 and 26 the same npm ci passes. The real output of the Node.js 22 job before the fix:

terminal
[CI/Unit tests (Node 22)-1] ⭐ Run Main npm ci
[CI/Unit tests (Node 22)-1]   | npm error code EUSAGE
[CI/Unit tests (Node 22)-1]   | npm error `npm ci` can only install packages when your package.json and package-lock.json or npm-shrinkwrap.json are in sync. Please update your lock file with `npm install` before continuing.
[CI/Unit tests (Node 22)-1]   | npm error Missing: msw@2.15.0 from lock file
[CI/Unit tests (Node 22)-1]   | npm error Missing: @mswjs/interceptors@0.41.9 from lock file
…
[CI/Unit tests (Node 22)-1]   ❌  Failure - Main npm ci [4.8119949s]
[CI/Unit tests (Node 22)-1] 🏁  Job failed
…
[CI/Unit tests (Node 24)-2]   ✅  Success - Main npm run test:unit [1.3380536s]
[CI/Unit tests (Node 26)-3]   ✅  Success - Main npm run test:unit [671.0905ms]
Real act output (shortened at …): the matrix catches differences not only in Node.js but also in the bundled npm

Workflow security: permissions, action versions, untrusted input

A pull_request workflow runs the code from that pull request: anything the workflow can access, that code can access too. So the rules from GitHub's "Secure use reference" belong in the very first workflow file, not "some day later".

  1. Least-privilege token

    GitHub gives every job a GITHUB_TOKEN. permissions: contents: read at the workflow level leaves only read access to the code: once any permission is specified, all unspecified ones become none. Widen permissions per job — in release.yml only the publishing job has packages: write.

  2. Actions pinned to a full SHA

    A tag like @v7 can be moved by the action's owner, and if the repository is compromised, the new version silently reaches every workflow. According to GitHub's documentation, a full commit SHA is the only way to reference an immutable version. The # v7.0.1 comment is for humans. To keep pinned versions from going stale, GitHub recommends Dependabot with package-ecosystem: github-actions — it keeps track of action references in workflows. Take the SHA from the action's own repository, not from a fork.

  3. No unnecessary credentials

    actions/checkout keeps the token by default so later steps can run git commands. Our CI does not need git push, hence persist-credentials: false.

  4. Untrusted input only via env

    The pull request title, branch name and comment text are written by the pull request author. A ${{ … }} expression is substituted into the script before the shell starts, so a title with quotes and ; becomes a command. Pass such values through env and use them as ordinary variables.

  5. Careful with privileged events

    pull_request_target and workflow_run run in the context of the base repository — with secrets and write access. Do not download or run pull request code in them; starting with v7, actions/checkout refuses to do so by default.

.github/workflows/pr-title.yml
name: PR title

on:
  pull_request:
    types: [opened, edited, synchronize, reopened]

permissions: {}

jobs:
  title:
    name: PR title format
    runs-on: ubuntu-24.04
    steps:
      - name: Check PR title
        env:
          TITLE: ${{ github.event.pull_request.title }} # untrusted input: only via env
        run: |
          pattern='^(feat|fix|docs|chore): '
          if [[ ! "$TITLE" =~ $pattern ]]; then
            echo "PR title must start with feat:, fix:, docs: or chore:"
            exit 1
          fi
          echo "PR title is OK"
A title format check: the title reaches the script as the TITLE variable, not as script text. permissions: {} — the token needs nothing

We ran this workflow through act with the title a"; touch /tmp/pwned; echo ": the format check failed, and the command from the title did not run — there is no /tmp/pwned in the container. In a control variant with ${{ github.event.pull_request.title }} placed directly in run, the same title created the file. Secrets from Settings → Secrets and variables → Actions (or gh secret set NAME) are read as ${{ secrets.NAME }}. What matters for CI: a workflow triggered by a pull request from a fork receives no secrets (except a read-only GITHUB_TOKEN), and Dependabot pull request runs behave like forks. So pull request checks must work without secrets — like our ci.yml. How to store and rotate secrets is covered in the material on secrets and environment variables.

Checking before you push: actionlint and a local run with act

GitHub reports a YAML mistake only after a push — and while you fix it, the branch history fills up with "fix ci" commits. actionlint is a static checker for workflows: it validates syntax, ${{ }} expressions, needs references, context types and known security pitfalls, and runs run scripts through shellcheck. The easiest way is to run it in Docker from the repository root:

bash
docker run --rm -v "$PWD:/repo" -w /repo rhysd/actionlint:1.7.12
actionlint checks every file in .github/workflows; empty output and exit code 0 mean no errors

Here is what it finds in a workflow with three typical mistakes — a typo in a matrix variable name, the pull request title placed directly in a script and a reference to a job that does not exist:

.github/workflows/ci.yml
name: CI

on: pull_request

jobs:
  unit:
    runs-on: ubuntu-24.04
    strategy:
      matrix:
        node: [22, 24]
    steps:
      - uses: actions/checkout@v7
      - uses: actions/setup-node@v7
        with:
          node-version: ${{ matrix.nodes }}
      - run: echo "Testing ${{ github.event.pull_request.title }}"
      - run: npm ci && npm test

  image:
    needs: [checks]
    runs-on: ubuntu-24.04
    steps:
      - run: docker build .
A deliberately broken workflow — do not copy it
terminal
$ docker run --rm -v "$PWD:/repo" -w /repo rhysd/actionlint:1.7.12
.github/workflows/ci.yml:15:29: property "nodes" is not defined in object type {node: number} [expression]
   |
15 |           node-version: ${{ matrix.nodes }}
   |                             ^~~~~~~~~~~~
.github/workflows/ci.yml:16:32: "github.event.pull_request.title" is potentially untrusted. avoid using it directly in inline scripts. instead, pass it through an environment variable. see https://docs.github.com/en/actions/reference/security/secure-use#good-practices-for-mitigating-script-injection-attacks for more details [expression]
   |
16 |       - run: echo "Testing ${{ github.event.pull_request.title }}"
   |                                ^~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
.github/workflows/ci.yml:19:3: job "image" needs job "checks" which does not exist in this workflow [job-needs]
   |
19 |   image:
   |   ^~~~~~
$ echo $?
1
Real actionlint 1.7.12 output: all three mistakes found before pushing

actionlint does not run jobs. That is what act is for — it reads the same files and runs the jobs in Docker containers that imitate a GitHub runner. We ran ci.yml on a pull_request event with the catthehacker/ubuntu:act-24.04 image:

bash
act pull_request -e pr-event.json \
  -P ubuntu-24.04=catthehacker/ubuntu:act-24.04 \
  --artifact-server-path /tmp/artifacts
pr-event.json is a minimal event body: the pull request number, title, head and base
terminal
$ act pull_request -e pr-event.json \
    -P ubuntu-24.04=catthehacker/ubuntu:act-24.04 \
    --artifact-server-path /tmp/artifacts
…
[CI/Integration tests     ] container health of 966c061c5bbf… (postgres:17-alpine) is healthy
…
[CI/Lint and typecheck    ]   ✅  Success - Main npm run lint [1.144679915s]
[CI/Lint and typecheck    ]   ✅  Success - Main npm run typecheck [1.023296871s]
[CI/Lint and typecheck    ] 🏁  Job succeeded
[CI/Unit tests (Node 22)-1]   |       Tests  9 passed (9)
[CI/Unit tests (Node 22)-1] 🏁  Job succeeded
[CI/Unit tests (Node 26)-3] 🏁  Job succeeded
[CI/Unit tests (Node 24)-2] 🏁  Job succeeded
[CI/Integration tests     ]   |       Tests  10 passed (10)
[CI/Integration tests     ]   | Artifact integration-report has been successfully uploaded! Final size is 750 bytes. Artifact ID is 3500521754
[CI/Integration tests     ] 🏁  Job succeeded
[CI/Docker image          ]   ✅  Success - Main docker/build-push-action@c3c9e263c25d99ce0380d002d59b67737d91b0dc [19.041554739s]
[CI/Docker image          ] 🏁  Job succeeded
[CI/CI passed             ] 🏁  Job succeeded
Real act 0.2.89 output (shortened at …): every job is green, the test report is uploaded as an artifact

syntax, expressions, needs, shellcheck

Where it is checked
actionlint — locally and in CI

steps, matrix, npm ci, tests, the PostgreSQL service, image build, artifact

Where it is checked
act — locally, in Docker (with the caveats below)

GITHUB_TOKEN permissions, secrets, environment, branch protection, publishing to ghcr.io

Where it is checked
only on GitHub: act injects its own token and knows nothing about repository settings

Required checks: branch protection

On its own, CI only reports a result: a red cross does not stop anyone from pressing Merge. Merging is blocked by a branch protection rule or a ruleset with "Require status checks to pass before merging" — what else branch protection includes is covered in the pull request material. The question is which checks to make required.

Every job is a separate check, and a matrix produces one check per version: Unit tests (Node 22), Unit tests (Node 24) and so on. Listing them all is inconvenient: adding a version to the matrix would mean changing repository settings. Worse, according to GitHub's documentation a job that depends on a failed job is skipped, and a skipped job counts as successful — so a required Docker image check may not block the merge after the tests fail.

That is why ci.yml has a final ci-ok job named CI passed. It depends on all the other jobs, always runs (if: ${{ always() }}) and fails if any job ended with failure or was cancelled. Only this check is made required.

  • Do not put paths or branches filters on a required workflow: if a filter skips the workflow, its checks stay "Pending" and block merging forever.
  • Job names must be unique across all workflows in the repository — identical names produce an ambiguous check status.
  • The strict mode "Require branches to be up to date before merging" is on by default: the branch must be updated from the base, and the checks run again. With a high volume of pull requests, a merge queue gives the same guarantee without manual updates.

Publishing the image and deploying on a tag

Deployment is triggered by a version tag, not by every merge: production gets a deliberately released version, and it is always clear which one. The image is built from the Dockerfile from the Docker basics lesson:

Dockerfile
FROM node:24-alpine
WORKDIR /app
ENV NODE_ENV=production
COPY package.json package-lock.json ./
RUN npm ci --omit=dev
COPY src ./src
USER node
EXPOSE 3000
CMD ["node", "src/server.ts"]
The API image: production dependencies only, the process runs as the node user. Node.js 24 runs .ts directly
.github/workflows/release.yml
name: Release

on:
  push:
    tags: ['v*.*.*']

permissions:
  contents: read

# Never run two deployments at once and never cancel one halfway
concurrency:
  group: release-production
  cancel-in-progress: false

jobs:
  ci:
    name: CI
    uses: ./.github/workflows/ci.yml

  publish:
    name: Publish image
    needs: ci
    runs-on: ubuntu-24.04
    timeout-minutes: 15
    permissions:
      contents: read
      packages: write # push to ghcr.io with GITHUB_TOKEN
    outputs:
      digest: ${{ steps.push.outputs.digest }}
    steps:
      - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
        with:
          persist-credentials: false
      - uses: docker/setup-buildx-action@f87e5991a6d7451dcb8d9637bfbc97413f497069 # v4.4.1
      - uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0
        with:
          registry: ghcr.io
          username: ${{ github.actor }}
          password: ${{ secrets.GITHUB_TOKEN }}
      - id: meta
        uses: docker/metadata-action@dc802804100637a589fabce1cb79ff13a1411302 # v6.2.0
        with:
          images: ghcr.io/${{ github.repository }}
          tags: |
            type=semver,pattern={{version}}
            type=semver,pattern={{major}}.{{minor}}
      - id: push
        uses: docker/build-push-action@c3c9e263c25d99ce0380d002d59b67737d91b0dc # v7.4.0
        with:
          context: .
          push: true
          tags: ${{ steps.meta.outputs.tags }}
          labels: ${{ steps.meta.outputs.labels }}

  deploy:
    name: Deploy to production
    needs: publish
    runs-on: ubuntu-24.04
    timeout-minutes: 10
    # Environment secrets, required reviewers and allowed tags are set in Settings → Environments
    environment:
      name: production
      url: https://leads.example.com
    steps:
      - name: Trigger the hosting deploy hook
        env:
          DEPLOY_HOOK_URL: ${{ secrets.DEPLOY_HOOK_URL }}
          REPOSITORY: ${{ github.repository }}
          DIGEST: ${{ needs.publish.outputs.digest }}
        run: |
          image="ghcr.io/${REPOSITORY,,}@${DIGEST}"
          echo "Deploying $image"
          curl --fail-with-body --silent --show-error -X POST "$DEPLOY_HOOK_URL" \
            --data-urlencode "image=$image"
Release: the same CI, publishing the image to GitHub Container Registry and deploying to the production environment
  • uses: ./.github/workflows/ci.yml calls CI as a reusable workflow (that is what the workflow_call event in ci.yml is for) on the same commit as the tag. According to the documentation, token permissions in such a chain can only be kept or reduced, never elevated.
  • The publish job is the only one with packages: write: GITHUB_TOKEN logs in to ghcr.io, docker/metadata-action derives the 1.2.0 and 1.2 image tags from the git tag and lowercases the image name, and build-push-action builds and pushes the image and returns its digest.
  • environment: production is an environment from Settings → Environments. Its secrets, such as DEPLOY_HOOK_URL, are available only to jobs that reference it. The same page configures required deployment reviewers and the tags and branches allowed to deploy to it.
  • The deployment references the image by digest (@sha256:…) rather than by tag: a tag can be overwritten, a digest cannot. ${REPOSITORY,,} in bash lowercases the repository name, as the registry requires.
  • concurrency with cancel-in-progress: false: two releases never run at once, and a started deployment is never cut off halfway.
bash
git switch main && git pull
git tag -a v1.2.0 -m "Lead source field"
git push origin v1.2.0
Releasing a version: an annotated tag on an up-to-date main

Exercises

How this was verified5 · passed3 · partialchecked 11 October 2026

“Run” means the code was executed in a scratch project or container and the output in the article comes from that run. “Partial” means it was checked against stubs instead of external services. “Not run” means it needs a device, an account or a key; the steps were checked against the official documentation. The verification project files and run logs are kept in the InterLAB archive.

Tell us about your task — we'll review your request and get back to you

Got a project?
We'll scope and estimate it — for free.

Tell us about the product or the problem. After reviewing the task we'll come back with an architecture, a timeline and a price for the first version.

  • Scope and risks estimated before we start
  • NDA — yours or ours
  • Work starts within a week

Please do not send passwords, access keys or source code through this form — we will agree a safe way to share access separately.

By clicking the button you agree to the processing of your personal data.