A pipeline is a promise: what passed the tests is what reaches production
Deploying without interruptions means three distinct problems, and confusing them is the reason "zero downtime" pipelines break the first time a schema changes. The first is changing the version of the code without the site failing to respond for an instant. The second is keeping the database compatible with two versions of the application running together for a few minutes. The third is taking an instance out of traffic without interrupting whoever is still using its services. In this article you build a GitHub Actions workflow with three environments (test, staging, production) and, alongside it, three small in-process tests: the atomic switch of version, the health checks of an instance and the two-phase migration. I validated the workflow in its form against the public schema, I did not run it: running it would need a repository on GitHub, and no deploy was launched.
The workflow: three environments, a single artifact
The rule that holds the workflow together is "build once and distribute the same artifact to every environment". The test job installs, tests, builds and saves the dist/ folder as an artifact; staging and production download it instead of rebuilding, so what you tried in staging is, byte for byte, what goes to production. The versions of the actions are the current ones as of 2026-11-13, read from the public releases on GitHub: actions/checkout v7.0.1, actions/setup-node v7.1.0, actions/upload-artifact v7.0.2 and actions/download-artifact v8.0.2. Each one is pinned to the full commit SHA, with the version in a comment. The artifact is called release.
name: ci-cd
on:
push:
branches: [main]
pull_request:
permissions:
contents: read
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0
with:
node-version: 24
cache: npm
- run: npm ci
- run: npm test
- run: npm run build
- uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
with:
name: release
path: dist/
retention-days: 7
staging:
if: github.event_name == 'push'
needs: test
runs-on: ubuntu-latest
environment: staging
concurrency:
group: deploy-staging
cancel-in-progress: false
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2
with:
name: release
path: dist
- run: ./deploy.sh staging
env:
DEPLOY_HOST: ${{ secrets.DEPLOY_HOST }}
DEPLOY_KEY: ${{ secrets.DEPLOY_KEY }}
production:
needs: staging
runs-on: ubuntu-latest
environment: production
concurrency:
group: deploy-production
cancel-in-progress: false
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2
with:
name: release
path: dist
- run: ./deploy.sh production
env:
DEPLOY_HOST: ${{ secrets.DEPLOY_HOST }}
DEPLOY_KEY: ${{ secrets.DEPLOY_KEY }}
Five lines matter. permissions: contents: read removes from the workflow's token everything that is not needed: the GitHub documentation recommends least privilege for the credentials used in workflows. The SHA instead of the tag protects against an action modified after you chose it: GitHub's security guide explains that forging a SHA would need a SHA-1 collision. needs draws the chain test then staging then production: if a job fails, the following ones do not start. environment links the job to a GitHub environment, and the documentation says that a job referencing it must respect the environment's protection rules before starting or accessing its secrets: it is there, in the repository settings and not in the YAML file, that the manual approval before production is configured. Finally concurrency: on the deploys it has cancel-in-progress: false, because interrupting a deploy halfway is worse than making another one wait.
Two warnings on the perimeter. First: according to the environments documentation (consulted on 2026-11-13), with the Free, Pro and Team plans protection rules such as required reviewers are available only for public repositories; for a private repository higher plans are needed, and the plan table may change, so check before basing manual approval on it. Second: the default behaviour of concurrency for queued jobs, described in the same documentation, is that a waiting job is replaced by the most recent one; for tests on a pull request it makes sense to cancel the ones in progress (that is what the expression on the workflow group does), for deploys it does not.
Validating a workflow without GitHub
A typo in a YAML file is usually discovered at the push, after a few minutes of queue. You can anticipate it: the SchemaStore project maintains the JSON schema of the workflow format (github-workflow.json, file last modified on 2026-11-09). The program below downloads it separately, applies it with Ajv (8.20.0) to the file read with the yaml library (2.9.1), and adds two checks of its own that the schema cannot do: that every needs points to an existing job and that every uses is pinned to a SHA of 40 hexadecimal characters. Then it introduces three faults on purpose, to verify that the check is not empty.
import { readFileSync } from 'node:fs';
import Ajv from 'ajv';
import { parse } from 'yaml';
const schema = JSON.parse(readFileSync('schema.json', 'utf8'));
const validate = new Ajv({ strict: false, validateFormats: false, allErrors: false }).compile(schema);
function check(text) {
const wf = parse(text);
const problems = [];
if (!validate(wf)) problems.push(`schema: ${validate.errors[0].instancePath || '/'} ${validate.errors[0].message}`);
for (const [name, job] of Object.entries(wf.jobs ?? {})) {
for (const dep of [].concat(job.needs ?? [])) if (!wf.jobs[dep]) problems.push(`${name}: needs "${dep}" does not exist`);
for (const step of job.steps ?? []) {
if (step.uses && !/@[0-9a-f]{40}$/.test(step.uses)) problems.push(`${name}: ${step.uses} is not pinned to a SHA`);
}
}
return { wf, problems };
}
const original = readFileSync('workflow.yml', 'utf8');
const { wf, problems } = check(original);
console.log('workflow.yml: jobs =', Object.keys(wf.jobs).join(' -> '), '| problems =', problems.length);
const faults = {
'nonexistent key (run-on)': original.replace('runs-on: ubuntu-latest', 'run-on: ubuntu-latest'),
'needs pointing to a missing job': original.replace('needs: staging', 'needs: testing'),
'action pinned to a tag': original.replace(/actions\/checkout@[0-9a-f]{40} # v7.0.1/, 'actions/checkout@v7'),
};
for (const [name, text] of Object.entries(faults)) console.log(`fault "${name}":`, check(text).problems[0]);
// Output:
// workflow.yml: jobs = test -> staging -> production | problems = 0
// fault "nonexistent key (run-on)": schema: /jobs/test must have required property 'runs-on'
// fault "needs pointing to a missing job": production: needs "testing" does not exist
// fault "action pinned to a tag": test: actions/checkout@v7 is not pinned to a SHA
The first line says that the workflow has the three expected jobs and no problems. The following three lines show that each fault is seen: the run-on key in place of runs-on is rejected by the schema, because that job lacks the required property; the needs towards a job that does not exist and the action pinned to a tag are instead caught by the checks of my own. This is a check of form: it does not prove that the workflow works on GitHub, that the secrets exist or that deploy.sh does what it should. For richer checks there are dedicated tools, which I did not use here.
Switching version without an instant of void
The simplest way to have an atomic deploy on a single server is to keep each version in a folder (releases/r1, releases/r2) and have the web server serve the symbolic link current. Changing version means moving that link. The trick is how you move it: deleting it and recreating it are two operations, and between the two the path does not exist. The manual page of rename(2) says instead that when the new name already exists it is replaced atomically, and that there is no moment at which another process finds it missing. So you create a temporary link and move it with rename over the old one.
import { mkdtempSync, mkdirSync, writeFileSync, symlinkSync, renameSync, unlinkSync, readlinkSync, existsSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
const root = mkdtempSync(join(tmpdir(), 'releases-'));
const current = join(root, 'current');
const state = () => (existsSync(current) ? readlinkSync(current).split('/').pop() : 'MISSING');
for (const id of ['r1', 'r2']) {
mkdirSync(join(root, 'releases', id), { recursive: true });
writeFileSync(join(root, 'releases', id, 'index.html'), `version ${id}`);
}
symlinkSync(join(root, 'releases', 'r1'), current);
console.log('start : current ->', state());
// Naive switch: two operations. Between the first and the second the path does not exist.
unlinkSync(current);
console.log('naive, after unlink : current ->', state());
symlinkSync(join(root, 'releases', 'r2'), current);
console.log('naive, after symlink : current ->', state());
// Atomic switch: prepare a new link and move it over the old one with rename.
function activate(id) {
const temporary = join(root, 'current.new');
symlinkSync(join(root, 'releases', id), temporary);
console.log(`atomic, link ready : current ->`, state());
renameSync(temporary, current);
}
activate('r1');
console.log('atomic, after rename : current ->', state(), '(rollback = the same operation towards the previous version)');
rmSync(root, { recursive: true });
// Output:
// start : current -> r1
// naive, after unlink : current -> MISSING
// naive, after symlink : current -> r2
// atomic, link ready : current -> r2
// atomic, after rename : current -> r1 (rollback = the same operation towards the previous version)
The output shows the difference state by state. With the naive method, after unlink the path is MISSING: at that instant any request would find a non-existent folder. With the atomic method current points to r2 while the new link is ready, and switches to r1 in a single step with rename. Rollback is the same operation towards the previous version, which is why the latest releases are kept instead of being overwritten. The program shows the states, it does not measure the duration of the window: the size of the void in the naive case depends on the machine, and is irrelevant compared with the guarantee that with the atomic method it does not exist.
Before moving traffic: "alive" does not mean "ready"
An instance can be alive (the process is running) and not ready (it has not yet loaded the configuration, it is shutting down). Whoever decides where to send traffic, a load balancer or your deploy script, must be able to tell the two cases apart: that is why two paths are exposed. By convention, the liveness one answers 200 as long as the process is not blocked, and if it fails the instance must be restarted; the readiness one answers 200 only when the instance can serve requests, and when shutdown approaches it switches to 503 to take the instance out of traffic, while the requests already in progress finish.
import Fastify from 'fastify';
const wait = (ms) => new Promise((ok) => setTimeout(ok, ms));
const app = Fastify();
const proc = { shuttingDown: false };
app.get('/livez', async () => ({ alive: true }));
app.get('/readyz', async (req, reply) =>
proc.shuttingDown ? reply.code(503).send({ ready: false }) : { ready: true });
app.get('/slow', async () => { await wait(50); return { completed: true }; });
const status = async (url) => (await app.inject({ url })).statusCode;
console.log('startup : livez', await status('/livez'), '| readyz', await status('/readyz'));
const inFlight = app.inject({ url: '/slow' }); // a request is already in flight
proc.shuttingDown = true; // SIGTERM arrives: stop declaring ready
console.log('after SIGTERM : livez', await status('/livez'), '| readyz', await status('/readyz'));
console.log('in-flight request ends with', (await inFlight).statusCode, (await inFlight).body);
// Output:
// startup : livez 200 | readyz 200
// after SIGTERM : livez 200 | readyz 503
// in-flight request ends with 200 {"completed":true}
The model reproduces the contract, not the operating system: a flag that changes when the termination signal arrives. At startup both paths answer 200; after the signal livez stays 200 (the process is alive) and readyz becomes 503 (do not send me more traffic); the slow request already started ends with 200. I did not test the real part: listening for the signal, the orderly closing of the HTTP server and the time the load balancer takes to stop sending traffic after the first 503. That time must be measured on your load balancer before deciding how long to wait between the first 503 and shutdown.
The database: expand, then contract
During a gradual deploy the old and the new version run together against the same database. If the migration renames a column, one of the two breaks at that moment. The solution is to split the change into two phases, called expand and contract: first the new columns are added, the data is copied and the new code is distributed; only when no old instance runs any more is the old column removed. The program below shows it with node:sqlite (SQLite 3.50.4 included in Node 24.11), splitting the full_name column into first_name and last_name, with the two versions of the application reduced to their query.
import { DatabaseSync } from 'node:sqlite';
const db = new DatabaseSync(':memory:');
db.exec("CREATE TABLE users (id INTEGER PRIMARY KEY, full_name TEXT); INSERT INTO users VALUES (1, 'Ada Lovelace');");
// The two application versions, reduced to their query.
const v1 = () => db.prepare('SELECT full_name AS n FROM users WHERE id = 1').get().n;
const v2 = () => { const r = db.prepare('SELECT first_name, last_name FROM users WHERE id = 1').get(); return `${r.first_name} ${r.last_name}`; };
const attempt = (name, f) => { try { return `${name}: ${f()}`; } catch (e) { return `${name}: ERROR ${e.message}`; } };
console.log('1. initial schema ', attempt('v1', v1));
// "Expand" phase: additions only. The old code does not notice, the new one can start.
db.exec('ALTER TABLE users ADD COLUMN first_name TEXT; ALTER TABLE users ADD COLUMN last_name TEXT;');
db.exec("UPDATE users SET first_name = substr(full_name, 1, instr(full_name, ' ') - 1), last_name = substr(full_name, instr(full_name, ' ') + 1);");
console.log('2. after expand + backfill ', attempt('v1', v1), '|', attempt('v2', v2));
// "Contract" phase: remove the old column only when no v1 instance is running any more.
db.exec('ALTER TABLE users DROP COLUMN full_name;');
console.log('3. after contract ', attempt('v2', v2), '|', attempt('v1 (still running?)', v1));
// Output:
// 1. initial schema v1: Ada Lovelace
// 2. after expand + backfill v1: Ada Lovelace | v2: Ada Lovelace
// 3. after contract v2: Ada Lovelace | v1 (still running?): ERROR no such column: full_name
The second line of the output is the point of the technique: after the columns are added and the data copied, version 1 and version 2 both read "Ada Lovelace" from the same schema, and so you can distribute 2 calmly and roll back to 1 without losing anything. The third line shows what happens if the contract phase arrives too early: version 1, still running, fails with SQLite's real error no such column: full_name. A consequence to declare: after the contraction the rollback of the code is no longer enough, because the column is gone. That is why the contraction is a release of its own, later, when you are sure you will not go back. Keep in mind that SQLite is not PostgreSQL or MySQL: the shape of the two phases holds in general, but the costs of ALTER TABLE on large tables change from one engine to another and must be checked on yours.
What this test does not cover
I did not run the workflow on GitHub, I did not configure environments or secrets, and I did not launch a deploy on a server. I did not cover blue-green with two complete environments behind a load balancer, nor the canary that sends a percentage of traffic to the new version: they are natural extensions of the above (same idea of moving traffic only after readiness) but they require an infrastructure that is not here. I did not measure deploy times or build durations. And the atomic switch with the symbolic link holds on a single server: with several servers they must be coordinated, and that is a different problem. The checks of the previous articles, such as the security headers of the article Web security or the field data collection of Core Web Vitals, are the natural candidates for the npm test step of the test job: written as tests with inject(), they run at every push without opening a port.
What you can do now, and the series in eight pieces
You can write a three-environment workflow that builds once and distributes the same artifact, with actions pinned to a SHA, minimal permissions and deploys that do not cancel each other; you can validate it in its form before the push; you can move a version atomically and go back; you can tell "alive" from "ready"; and you can split a migration into two releases. With this the series closes. The eight articles, in order:
- TypeScript essentials for fullstack devs: structural types, discriminated unions, generics and the boundary that types do not cross.
- Angular SSR: signals, standalone components, server-side rendering and routing with guards.
- Spring Boot: Clean Architecture with four folders and REST API.
- GraphQL versus REST: the trade-offs in choosing the contract between client and server.
- Core Web Vitals: LCP, INP and CLS, and what the server decides about them.
- Web security: each OWASP risk assigned to the layer that must stop it.
- Authentication: session, JWT, refresh token and OAuth with PKCE.
- Multi-environment CI/CD: this article.
If you follow the order, the first three articles build the product (language, interface, service), the fourth chooses the contract between the two halves, and the last four make it usable by real people: fast, defended, with an identity and releasable without stopping the service. It can be read out of order, but the proposed order goes from the product to its real use. All the articles are collected on the series page.
Frequently asked questions
Why pin actions to a SHA and not to a tag?
Because a tag can be moved to another commit, while a full SHA identifies precise content. GitHub's security guide recommends it: forging a SHA would need a SHA-1 collision. The comment with the version keeps the file readable.
Why not cancel the deploys in progress when a new one starts?
Because a deploy interrupted halfway can leave the environment in a mixed state. With cancel-in-progress: false the running deploy finishes and the new one waits; for the tests of a pull request, instead, cancelling is convenient and safe.
How do I roll back after a migration that removes a column?
You cannot, if the column is no longer there: the old code would fail. That is why the migration is split into two releases, with the contraction only after the new version is stable and rollback is no longer needed.
Sources
- Workflow syntax for GitHub Actions (GitHub Docs) - the job keys
needs,environment,concurrencyandpermissions, consulted on 2026-11-13 - Control the concurrency of workflows and jobs (GitHub Docs) - behaviour of waiting jobs and
cancel-in-progress, consulted on 2026-11-13 - Managing environments for deployment (GitHub Docs) - environment protection rules and availability by plan, consulted on 2026-11-13
- Secure use reference (GitHub Docs) - least privilege and pinning actions to a full SHA, consulted on 2026-11-13
- SchemaStore, github-workflow.json - JSON schema of the workflow format used for the validation of form; file last modified 2026-11-09, downloaded on 2026-11-13
- rename(2), Linux manual page - atomic replacement of the destination name, consulted on 2026-11-13
- actions/checkout, releases - version v7.0.1 and corresponding commit, read from the public releases API on 2026-11-13; same method for setup-node v7.1.0, upload-artifact v7.0.2 and download-artifact v8.0.2
- Node.js v24, node:sqlite - module used for the migration test, stability 1.2 (release candidate), consulted on 2026-11-13
The Node listings (atomic switch, health checks, migration, validation) were run with Node 24.11.0 and Fastify 5.12.8 (inject(), no port opened), twice with identical output; the atomic switch operates on a temporary folder created and removed by the program. The workflow is a schema to adapt: validated in its form, not run on GitHub. No deploy, container or external service was used.
Commenti
Caricamento commenti...
Sign in to comment