CI and releases
How GitHub Actions tests pull requests, and how server, cli, desktop, extension and docs releases are cut, built and published. Deploying a released server image: Self-hosting.
GitHub Actions runs CI on pull requests, on manual runs (Actions → CI → Run workflow) and on a release that asks for it, never
on pushes to main: a merge was already tested as its pull request. A new push to a pull request cancels its running CI.
Continuous integration
.github/workflows/ci.yml (Linux runners): gofmt, vet, all Go tests against Postgres (failing on any skipped test except the
TestIntegration… tests and TestRecordOpenSSHFixture), the CLI for every release platform, the end-to-end test of the built
binaries, the server Docker image for amd64 and arm64 (built into a throwaway registry and booted, never pushed), the desktop frontend
(typecheck, tests, bundle check) and the desktop app's Linux build (both only when the pull request changes the desktop app or what it builds from, as below), the extension (tests, Chromium end-to-end tests, zip),
the share link page in Chromium against the server, the docs site (typecheck, build, and its image built and served once), the admin dashboard (typecheck, tests, the admin/src/themes.css
freshness check and Chromium end-to-end tests against the server; only when the pull request changes admin/, server/,
core/, go.work or the workflow), and the Android app (HisarKit.aar, the ThemePalettes.kt freshness check, the debug and
release APKs, lint and the JVM tests in one job, and the instrumented tests on x86_64 API 36 emulators against a local server in
two more, split by the test classes' measured run times in scripts/ui-test-parts.sh; only when the pull request changes
mobile/android/, mobile/hisarkit/, mobile/go.mod, the Android scripts, the themes,
core/, go.work or the workflow, and not while the pull request has the label skip-android, for changes whose Android build
and tests already passed locally with make build-android-aar build-android test-android test-android-ui; add the label before
pushing).
.github/workflows/macos.yml builds the Mac app (Touch ID and Sparkle tests, bundle and signature checks) and the iPhone app
(HisarKit, Xcode project check, unit tests, then UI tests against a local server in three jobs that run the one build, split by
the test classes' measured run times in scripts/ui-test-parts.sh) on
GitHub's macos-26 runners with Xcode 26.6. macOS
minutes count ten times against the plan's included minutes, so each job runs only when the pull request, compared with its base
branch on every push, changes what it builds (scripts/detect-app-changes.sh):
- the Mac app when
desktop/,cli/(the app bundleshisar),core/,go.work, its packaging scripts or the workflow changes (scripts/app-paths.shlists them); - the iPhone app when
mobile/ios/,mobile/hisarkit/,mobile/go.mod,mobile/build-framework.sh,core/,go.workor the workflow changes; an Android-only change does not run it; - both, whatever changed, while the pull request has the label
run-macos; - not the iPhone app while the pull request has the label
skip-ios, for changes whose iPhone build, unit tests and UI tests already passed locally (make build-ios-framework build-ios test-ios test-ios-ui). Add the label before pushing: a run reads the labels the pull request has when it starts. The jobs then report as skipped; - or by hand: Actions → macOS and iOS → Run workflow, on a branch (tick "Build and test the iPhone app" to add the iPhone job).
A run takes roughly 15–25 minutes for the Mac app, and for the iPhone app about 10 minutes of build then 18 minutes of UI tests in three jobs, so about 150–250 included minutes for the Mac app alone and up to 900 with the iPhone app. A desktop release build costs about the same as the Mac app job.
Releases
Releases are tags without a v: server-X.Y.Z, cli-X.Y.Z, desktop-X.Y.Z, extension-X.Y.Z and docs-X.Y.Z. Each release is one run of
Actions → Release → Run workflow (or gh workflow run release.yml -f app=server -f type=minor), on main:
- App:
server,cli,desktop,extensionordocs. - Release type:
patch,minorormajorbumps the app's newest<app>-X.Y.Ztag (0.1.14→0.1.15,0.2.0,1.0.0; an app's first release is0.1.0), andcustomtakes Version as it is:0.2.1, or0.3.0-rc1for a custom build that is built the same way, marked "(custom build)" and can never be published. A custom build may run on any branch. - Dry run: only the plan.
The run then goes on by itself (scripts/plan-release.sh plans it):
- Plan: the next version, which must be newer than the previous release and not tagged yet, and the commits since the
previous release that change what the app is built from (
scripts/app-paths.sh, without tests, and without Markdown except for the docs site, which is made of it). It refuses a release without such commits, and warns about a patch release with a breaking change (feat(core)!: …or aBREAKING CHANGE:footer). The run summary shows the plan. - CI runs every
ci.ymljob on the exact event commit, plus the desktop jobs inmacos.ymlfor desktop releases. Successful tests are mandatory, including custom builds. There is norunTests=falsebypass. A dry run stops after the plan. - Tag: the annotated tag on that commit; its message is the title and the changes, which the desktop app's Sparkle
update shows as release notes. The deploy key in
RELEASE_DEPLOY_KEYpushes it, past the tag ruleset. - Build:
release-<app>.yml, which only the Release workflow calls. The desktop app builds the Mac DMG and Sparkle update and the Linux packages and Flatpak (amd64 and arm64); the server pushesghcr.io/tosunlab/hisar-server:X.Y.Zand the docs siteghcr.io/tosunlab/hisar-docs:X.Y.Z(both amd64 and arm64). Every builder checks out the immutable event SHA, not the release tag. - Prerelease contains every release file and
release-evidence.json. The evidence binds the commit, test results, workflow run, file digests, and the server or docs image digest. Keyless GitHub artifact attestations cover every final file and the image index after packaging and signing. The run verifies them and rejects a moved release tag before creating the prerelease. - Publish: waits in the environment
publish-<app>until a reviewer approves it (Review deployments on the run). A newer release of the app that reaches this step replaces an older one still waiting. It refuses a version older than the app's published release, verifies the evidence and attestations against the tag's full source SHA with the verifier from that commit, not from the default branch, then- server and docs: points
ghcr.io/tosunlab/hisar-serverorhisar-docs:X.Y.Z,:X.Yand:latestat the verified immutable image digest, so a version tag moved after the build is reset; - desktop: renames the release's
update-unpublished.jsontoupdate.json, which offers the version to every Mac app as an automatic update; - all: rewrites the notes to cover every change since the published release, turns the prerelease into a release and marks it latest.
- server and docs: points
A failed run continues with Re-run failed jobs; a new run refuses the tag it already pushed. Actions → Publish → Run workflow
publishes an existing prerelease by hand, through the same approval and attestation verification. Old prereleases without
evidence cannot use this path. Actions → Release server image (or Release docs image) → Run workflow on a branch first runs
mandatory exact-SHA CI, then pushes only ghcr.io/tosunlab/hisar-server:sha-<commit> (or hisar-docs:sha-<commit>), with
an image attestation, for testing.
Verify release artifacts
Use a recent GitHub CLI with gh attestation verify. Choose the full commit SHA from an independently reviewed source
commit, not only a downloaded evidence document or a movable tag. Set commit to that SHA.
For a downloaded CLI archive, desktop file, extension zip, checksum file, or release-evidence.json, run:
gh attestation verify <file> --repo tosunlab/hisar \
--signer-workflow tosunlab/hisar/.github/workflows/release.yml \
--signer-digest "$commit" --source-digest "$commit" --deny-self-hosted-runners
Verify every file you use. For the server, verify release-evidence.json with the command above, read its image.digest,
then verify and run that digest, not a mutable image tag:
digest=$(jq -r .image.digest release-evidence.json)
gh attestation verify "oci://ghcr.io/tosunlab/hisar-server@$digest" --repo tosunlab/hisar \
--signer-workflow tosunlab/hisar/.github/workflows/release-server.yml \
--signer-digest "$commit" --source-digest "$commit" --deny-self-hosted-runners
For the docs site, do the same with ghcr.io/tosunlab/hisar-docs and release-docs.yml.
The image signer is the reusable server or docs workflow. File signers are the release assembly workflow.
Verification constrains repository, workflow identity, source commit, and signer commit. A missing or invalid attestation
is a failure, not permission to trust a checksum instead. These checks identify the authorized workflow and its claims.
They do not prove that source, dependencies, or a compromised authorized builder are benign.
Update pinned build images
deploy/Dockerfile, docs/Dockerfile and the build and CI workflow container images use multiarchitecture SHA-256 manifest digests.
The BuildKit builder image is pinned too. No image layers are pulled by these checks.
- Run
python3 scripts/check-build-images.py --resolve-tagsto print current upstream tag digests. - Review upstream changes and replace the selected digest in every reference to that image.
Keep the Go tag synchronized with
go.workusingbash scripts/check-go-toolchain.sh. - Run
python3 scripts/check-build-images.py --registryto verify pinned manifest bytes and support for bothlinux/amd64andlinux/arm64. Runpython3 scripts/check-build-images.pyfor the offline pin check. - Run
bash scripts/test-app-paths.shand the affected image or packaging CI jobs before release.
The update process does not claim reproducible builds. Hosted runner images, package repositories, and other tools
remain separate build inputs. scripts/app-paths.sh includes the release workflows and evidence scripts, so changes to
these inputs select the affected releases.
Repository secrets and environments
Repository secrets: SPARKLE_PRIVATE_KEY, the Sparkle EdDSA private key file's content (gh secret set SPARKLE_PRIVATE_KEY --repo tosunlab/hisar < <key file>); RELEASE_DEPLOY_KEY, the private half of the deploy key "Release workflow tags" (write
access, a bypass actor of the ruleset "tags: only maintainers create"); and, for desktop releases, the team's Developer ID
Application identity and the App Store Connect API key that notarizes the app: MACOS_SIGNING_CERTIFICATE (the identity as a
base64 .p12: base64 -i <identity.p12> | gh secret set MACOS_SIGNING_CERTIFICATE --repo tosunlab/hisar),
MACOS_SIGNING_CERTIFICATE_PASSWORD (its export password), MACOS_PROVISIONING_PROFILE (the base64 macOS Developer ID
provisioning profile authorizing the app's keychain access group), APPLE_NOTARY_KEY (the base64 .p8), APPLE_NOTARY_KEY_ID and
APPLE_NOTARY_ISSUER_ID. Images go to the GitHub Container Registry with the
workflow's own GITHUB_TOKEN. The environments publish-server, publish-cli, publish-desktop and publish-extension have
the required reviewers and accept only main.