Перейти до вмісту

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.Z in the public repository tabula-cms/tabula builds ghcr.io/tabula-cms/tabula:X.Y.Z and :latest, publishes a GitHub Release, and makes every school’s dashboard that follows it say «Доступна версія X.Y.Z». (release.yml names the image after github.repository, so the same workflow in another repository publishes there.)
  • Deploys of the first installation — every push to prod in 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).
feature/…, fix/…, chore/… → PR → develop → stage → prod
  • develop — integration branch; every PR targets it. A PR merged into develop does not auto-close its issue (GitHub only does that on the default branch) — close it by hand.
  • stage — pre-release review; merged from develop when enough has accumulated.
  • prod — what runs on the first installation (deployment repository only). Accepts merges from stage only (guard-prod-merge.yml). A push to it runs deploy-prod.yml.
  • develop → stage and stage → prod are always a plain merge, never squash, and stage is never deleted: a squash creates a commit with no shared ancestor, and the next merge conflicts where nothing changed.

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.js and src/lib/changelog.js find a section by the first word of its heading.
  • Between releases, «Що нового» in a running image shows a non-empty «Не випущено» as «Нове після версії X.Y.Z».
  1. On the release branch, in one commit:

    • package.json version → X.Y.Z, and the two version fields at the top of package-lock.json;
    • in CHANGELOG.md, rename ## Не випущено to ## X.Y.Z — YYYY-MM-DD and put a new, empty ## Не випущено above it.
  2. Merge as usual: develop → stage → prod.

  3. On the commit that carries the new version:

    Terminal window
    git tag vX.Y.Z && git push --tags

release.yml then:

  1. checks that the tag equals v + package.json version and both version fields of package-lock.json (stops with an explanation otherwise) and that CHANGELOG.md has that version’s section;
  2. runs lint, format check and tests;
  3. refuses to go on when ghcr.io/<owner>/<repo>:X.Y.Z already 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;
  4. builds and pushes ghcr.io/<owner>/<repo>:X.Y.Z and :latest (the repository name comes from github.repository, lower-cased — it is never written into the code);
  5. 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.

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.

  • 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», /health reports version.
  • CHANGELOG.md must stay in the image. .dockerignore excludes every *.md and re-includes it with !CHANGELOG.md; test/scripts/changelogSection.test.js evaluates the patterns the way Docker does (last match wins).
  • ghcr.io/tabula-cms/tabula is public (publishing-checklist.md, section 6): docker pull needs no login. A private deployment repository’s package is private: its servers need docker login ghcr.io with a read:packages token, and an update check pointed at it gets a 404 and stays silent.

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.

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.