Release process
Full procedure in Ukrainian: DEPLOYMENT.md
→ «Як випустити версію», «Ролбек»
Two things ship from this code, and they are easy to confuse:
- Releases for every installation — a tag
vX.Y.Zin the public repositorytabula-cms/tabulabuildsghcr.io/tabula-cms/tabula:X.Y.Zand:latest, publishes a GitHub Release, and makes every school’s dashboard that follows it say «Доступна версія X.Y.Z». (release.ymlnames the image aftergithub.repository, so the same workflow in another repository publishes there.) - Deploys of the first installation — every push to
prodin its own deployment repository builds an image tagged with the commit and puts it on that school’s server. No version number changes. The workflows that do it are not part of the public repository (public-snapshot.md).
Branches
Section titled “Branches”feature/…, fix/…, chore/… → PR → develop → stage → proddevelop— integration branch; every PR targets it. A PR merged intodevelopdoes not auto-close its issue (GitHub only does that on the default branch) — close it by hand.stage— pre-release review; merged fromdevelopwhen enough has accumulated.prod— what runs on the first installation (deployment repository only). Accepts merges fromstageonly (guard-prod-merge.yml). A push to it runsdeploy-prod.yml.develop → stageandstage → prodare always a plain merge, never squash, andstageis never deleted: a squash creates a commit with no shared ancestor, and the next merge conflicts where nothing changed.
The changelog
Section titled “The changelog”CHANGELOG.md at the root is written for the school’s administrator, in Ukrainian, in plain
words: what changed for the person using the site, not how it was built. The admin panel shows it
on «Що нового» and the release workflow uses it as the GitHub Release text.
- Every merged change that a school would notice adds a bullet under
## Не випущено, in the same PR. - A heading is
##+ version +—+ date (## 1.2.0 — 2026-10-15);scripts/changelog-section.jsandsrc/lib/changelog.jsfind a section by the first word of its heading. - Between releases, «Що нового» in a running image shows a non-empty «Не випущено» as «Нове після версії X.Y.Z».
Cutting a release
Section titled “Cutting a release”-
On the release branch, in one commit:
package.jsonversion→X.Y.Z, and the twoversionfields at the top ofpackage-lock.json;- in
CHANGELOG.md, rename## Не випущеноto## X.Y.Z — YYYY-MM-DDand put a new, empty## Не випущеноabove it.
-
Merge as usual:
develop→stage→prod. -
On the commit that carries the new version:
Terminal window git tag vX.Y.Z && git push --tags
release.yml then:
- checks that the tag equals
v+package.jsonversion and bothversionfields ofpackage-lock.json(stops with an explanation otherwise) and thatCHANGELOG.mdhas that version’s section; - runs lint, format check and tests;
- refuses to go on when
ghcr.io/<owner>/<repo>:X.Y.Zalready exists (docker manifest inspect): a published version is immutable — installations have pulled those bits, and a re-run after a moved tag must not put different code under the same number. The fix is a new version. Pre-releases (X.Y.Z-rc.N) are exempt; - builds and pushes
ghcr.io/<owner>/<repo>:X.Y.Zand:latest(the repository name comes fromgithub.repository, lower-cased — it is never written into the code); - creates the GitHub Release with the changelog section as its body.
Dry run: a pre-release tag such as v1.2.0-rc.1 (with package.json at 1.2.0-rc.1) builds
:1.2.0-rc.1 and a Release marked pre-release; :latest is untouched and installations are not
notified. Its notes fall back to «Не випущено» when the version has no section.
Native dependencies: allowScripts in package.json names the exact versions of
argon2, better-sqlite3 and sharp whose install scripts npm 11 may run, while
dependencies uses ^ ranges — npm has no range form for that field. A release (or any commit)
that bumps one of the three, even by a patch through npm update, updates its allowScripts
entry to the version now in package-lock.json in the same commit; otherwise npm 11 blocks the
native build on the next clean install.
A bad tag: git push --delete origin vX.Y.Z, git tag -d vX.Y.Z, delete the Release on
GitHub, fix, tag again — as long as the image was not pushed yet. Once :X.Y.Z is in GHCR the
workflow will not overwrite it: release X.Y.(Z+1) instead.
What the workflows do
Section titled “What the workflows do”| Workflow | Trigger | Jobs |
|---|---|---|
ci.yml |
every PR; push to develop/stage/prod |
test: npm ci, lint, format:check, npm audit --omit=dev --audit-level=high, tests with a coverage floor (90 % lines, 75 % branches of src/+scripts/, scripts/check-coverage.js over an lcov report — Node 20 has no --test-coverage-* flags). a11y (after test): npm run test:a11y — axe-core in Playwright’s Chromium, WCAG 2.2 AA, browser cached in ~/.cache/ms-playwright by Playwright version (testing.md → «Accessibility»). docker: builds the image (no push, GHA cache), docker compose config of docker-compose.prod.yml, and a .env written by scripts/deploy-env.sh with awkward values read back through compose unchanged |
guard-prod-merge.yml |
PR into prod |
fails unless the source branch is stage (make it a required check) |
deploy-prod.yml |
push to prod |
source (refuses unless origin/stage is an ancestor of HEAD — the interim stand-in for branch protection) + test → build (image :prod-<sha> and :prod, build args APP_VERSION, GIT_SHA, SOURCE_URL) → deploy: on the server git reset --hard origin/prod, .env from secrets via envs: + scripts/deploy-env.sh, validated by docker compose config before it replaces the old one (kept as .env.previous), pull, chown -R app:app /data /backups, up -d, wait ≤ 60 s for healthy — else automatic rollback to .env.previous and the previous commit, and the job fails; nginx restart only when nginx/ or the compose file changed, otherwise nginx -s reload; docker image prune -af --filter until=168h. Then an external /health check from the runner (with HEALTH_TOKEN: a warning when the last backup failed or the disk has < 2 GB) |
release.yml |
tag v*.*.* |
as described above |
cleanup-images.yml |
weekly; manual (dry run by default) | deletes prod-<sha> versions from GHCR beyond the newest 10; never prod, latest, X.Y.Z or untagged versions (children of tagged images). Needs the package to grant this repository the Admin role (Package settings → Manage Actions access) |
monitor.yml |
every 30 min; manual | skipped without the SITE_DOMAIN variable; /health must say ok (and, with HEALTH_TOKEN, the last backup must not have failed), the certificate must be valid for 7 more days; otherwise it opens (or comments on) an issue labelled monitoring, and closes it when all is well again |
Third-party actions are pinned by full commit SHA with a # vX.Y.Z comment; base images
(Dockerfile, docker-compose.prod.yml) by version and digest. Dependabot
(.github/dependabot.yml: npm, github-actions, docker, docker-compose) proposes the
updates weekly, grouped.
Installation-specific values of a deployment are repository variables (APP_DIR,
SITE_DOMAIN, SITE_SLUG) and secrets (SSH, mail, session, S3) — never files in the
repository. See DEPLOYMENT.md.
Images and GHCR
Section titled “Images and GHCR”- The image is built once in CI and only pulled on servers. The version (
package.json) and the commit are baked in: the admin sidebar shows «Версія X.Y.Z»,/healthreportsversion. CHANGELOG.mdmust stay in the image..dockerignoreexcludes every*.mdand re-includes it with!CHANGELOG.md;test/scripts/changelogSection.test.jsevaluates the patterns the way Docker does (last match wins).ghcr.io/tabula-cms/tabulais public (publishing-checklist.md, section 6):docker pullneeds no login. A private deployment repository’s package is private: its servers needdocker login ghcr.iowith aread:packagestoken, and an update check pointed at it gets a 404 and stays silent.
Update notification
Section titled “Update notification”src/services/UpdateChecker.js asks https://api.github.com/repos/<UPDATE_REPO>/releases/latest
once a day (first check a minute after start). Pre-releases are ignored; a 404 means “nothing to
announce”. Nothing is downloaded or installed. UPDATE_REPO has no default in code; the deploy
workflow writes its own repository, other installations set it in .env.
Rollback
Section titled “Rollback”A deploy of the first installation whose new image does not become healthy within 60 s is
rolled back by deploy-prod.yml itself (previous .env and commit) and the job fails — see
DEPLOYMENT.md, «Ролбек».
By hand: every deploy and every release leaves its image in GHCR, so going back is one line of
.env: APP_IMAGE=…:prod-<older sha> (first installation) or …:<older X.Y.Z> (other
schools), then docker compose pull,
docker compose run --rm --no-deps --user root app chown -R app:app /data /backups (the app’s uid
changed to 10001 in 2026-09; app:app resolves to whatever the image uses) and
docker compose up -d. For the first installation the next push to prod rewrites .env and
brings the fresh image back.
Migrations are forward-only and are not rolled back: an older image runs against the newer database. That is safe only as long as migrations stay additive — see writing-a-migration.md. When it is not, the way back is restoring the backup taken before the update.