Merge and Release Workflow
Two long-lived branches, one merge request that matters, and a tag that ships.
push merge request tag
─────────► dev ───────────────────► main ────► release
▲
hotfix/* ──────┘
(then back-merge to dev)Branches
| Branch | Allowed to push | Allowed to merge | Force push | What it is |
|---|---|---|---|---|
main | No one | Maintainers | off | Released code. Only advances through a merge request. |
dev | Developers+ | Developers+ | off | Integration branch. Day-to-day work lands here directly. |
| anything else | anyone | — | — | Unprotected. Use for work you want reviewed. |
Protecting dev does more than gate access: GitLab refuses to delete a protected branch when a merge request merges, so the project-wide "delete source branch" default (which you want for hotfix/*) can't eat dev when the release MR lands.
Tags matching v* can only be created by Maintainers. That protection is load-bearing — build-image triggers on $CI_COMMIT_TAG and pushes to the container registry, so an unprotected tag namespace is a publish button for anyone with Developer access.
Daily work
Push straight to dev. No merge request needed.
git checkout dev && git pull
# ...work...
git push origin devEvery push runs typecheck, lint, format, audit, and the unit tests. The Playwright suites are in the pipeline but manual — start them from the pipeline page when you've touched gestures, the week view, or anything the server persists.
This is a deliberate trade: nothing reviews dev before release time, so keeping it green is on whoever pushed. Want review or full CI on work in progress? Branch off dev and open a merge request back into it.
What gates a merge
A merge request into main cannot merge until:
- ✅ the pipeline succeeds
- ✅ every discussion thread is resolved
- ✅ there are no conflicts
There is deliberately no required-approval count. Approval rules are a GitLab Premium feature, and with a single maintainer they deadlock — GitLab won't let you approve your own merge request. Review by comment still binds: an unresolved thread blocks the merge. The Approve button exists and is useful; it just isn't a gate.
Everything in this setup is Free-tier. No approval rules, no CODEOWNERS, no push rules — nothing here stops working when a trial lapses.
Cutting a release
Open a merge request
dev→main. TheDefault.mdtemplate fills in the release checklist automatically.Let the full pipeline run — this is where the Playwright suites become mandatory rather than manual.
Work the checklist, resolve every thread, merge.
Tag
main:bashgit checkout main && git pull node scripts/check-version.mjs vX.Y.Z # the same check CI runs git tag -s vX.Y.Z -m "hyper-calendar X.Y.Z Notes here — this message becomes the GitLab Release description." git push origin vX.Y.Z-ssigns the tag. Check it shows Verified in GitLab afterwards — an unverified tag means the signature did not check out, which reads worse than no signature at all. The fingerprint users verify against is published in Verifying a release.
The tag pipeline is version-check → build-image → release:
version-checkrefuses a tag whose version disagrees with any of the six places the version is written — the fivepackage.jsonfiles plusversionNameinandroid/app/build.gradle. It runs beforebuild-image, so a bad tag never produces an image. Run it locally first.build-imagebuilds and pushes$CI_REGISTRY_IMAGE:<tag>and:latest.releasecreates the GitLab Release from the annotated tag message, which makes thegit tagmessage the single source of release notes. Write it properly.
versionCode in android/app/build.gradle is not checked by CI — F-Droid needs it to increase monotonically and it doesn't track the semantic version. Bump it by hand; the MR template reminds you.
build-apk fails if the signing variables are missing, rather than publishing an APK nobody can install — see Release signing in CI.
Signed releases
Release tags are GPG-signed. For an application people run on the open internet, that is what ties a release to a key rather than to whoever happened to hold GitLab credentials that day — and it is the only part of the chain a compromise of GitLab itself cannot forge.
Users verifying a release are pointed at Verifying a release, which carries the fingerprint. Maintainers need this once per clone:
git config user.signingkey 7616A94E6FEC9EAB682790D0414534E1D89CB2B2
git config user.email ticket.grapple368@passmail.net
git config tag.gpgSign trueThis repository sets tag.gpgSign = true locally, so git tag -a signs as well and the -s distinction only matters in a fresh clone.
Three things must agree or GitLab shows Unverified, which looks worse than no signature at all: the key must be on the GitLab account, the key's UID email must be a confirmed email on that account, and the tagger email must match it. That is why user.email is set above rather than left to the global config.
To sign ordinary commits too, add git config commit.gpgsign true. That is optional — the release tag is what carries the provenance.
Hotfixes
For something that can't wait for the next release off dev:
git checkout -b hotfix/short-description mainOpen a merge request into main and pick the Hotfix template from the description dropdown. Once it merges:
git checkout dev && git pull
git merge origin/main
git push origin devDo not skip the back-merge. main and dev diverge the moment a hotfix lands only on main, and the next release MR silently reverts it.
The pipeline, by trigger
| Trigger | typecheck, lint, format, audit | unit-tests | gui-tests, e2e-tests | version-check, build-image, release |
|---|---|---|---|---|
push to dev | ✅ | ✅ | manual | — |
| merge request | ✅ | ✅ | ✅ (skipped on Draft) | — |
push to main | ✅ | ✅ | ✅ | — |
tag v* | — | — | — | ✅ |
Two things worth knowing:
- Tags don't re-run the test suite. The tagged commit is already on
mainand already passed there. Re-running cost roughly 15 runner minutes per release against a 400 minute/month allowance and proved nothing new. workflow:rules give one pipeline per change. Without them, adding merge request pipelines means every push gets both a branch pipeline and an MR pipeline. The one exception is pushing a branch before opening its MR — the branch pipeline already started, so you briefly get both. Cancel the branch one if you care about the minutes.
Security scanning and the gate
secret_detection, semgrep-sast and dependency-scanning run on non-draft merge requests, on main, and on the weekly Weekly security scan schedule. They are kept off dev pushes so the inner loop stays fast.
The analyzers never fail on a finding — they exit 0 and write it to a report. security-gate is what makes them a gate: it reads those reports and fails the pipeline on any secret, or anything high or above from SAST or Dependency Scanning, plus a license denylist over the SBOM. A missing report fails too, so a scanner that silently stops running cannot pass as a clean scan.
To accept a finding — a test fixture that looks like a key, an advisory with no fix published — copy the [fingerprint] the job prints into .gitlab/security-baseline.json with a why. That is a commit, reviewed like any other change. Dismissing it in GitLab's Vulnerability Report does not work, because the script cannot see those dismissals.
You can run the gate against reports downloaded from a pipeline:
node scripts/check-security-reports.mjs --dir=path/to/artifactsThe weekly schedule runs audit, audit-dev and the scanners — not the test suite. If an advisory lands with no fix available, raise AUDIT_LEVEL rather than leaving the pipeline red. It has to be set as a project CI/CD variable (Settings → CI/CD → Variables), because this project sets pipeline variables to "no one allowed" — you cannot override it when running a pipeline by hand.
audit-dev runs on the schedule only, over the dev dependency tree. Those findings don't gate a merge: what ships is the container image, built from production dependencies, and dev advisories are almost always denial-of-service via input fed to build tooling. A red audit-dev on a Monday is information, not a blocked release — there is no merge for a scheduled pipeline to block.
Gating is done by this script rather than by a merge request approval policy for the same headcount reason there are no required approvals, above.
Operational note on audit
The audit job (npm audit --omit=dev --audit-level=high) is blocking, and it is now a merge gate as well as a pipeline job. It is the one check that can go red without anyone touching the repo, when a new advisory lands upstream — and when that happens it blocks releases, not just pipelines.
If there's no published fix yet, raise the threshold or pin the advisory. Don't leave the pipeline red, and don't disable the merge gate to work around it.