Releasing Lockra
For maintainers. The pipeline is the github-project-scaffold tauri profile (.github/scaffold.json): release-please owns the version, the tag and a draft GitHub Release, and one workflow run builds, verifies and publishes everything.
The flow
- Pull requests are squash-merged into
mainwith a Conventional Commit title (CONTRIBUTING.md). - Every push to
mainruns.github/workflows/release.yml; its first job runs release-please, which opens or updates the release pull requestchore: release X.Y.Z(version inpackage.json, an entry inCHANGELOG.md). - Before merging it, refresh what Lockra bundles from outside, in a
build(deps)pull request of its own: the latestpsl(the Public Suffix List compiled to code, released whenever the list changes: its=x.y.zpin inCargo.tomlandcargo update -p psl), and the data undercrates/lockra-autofill/data/(pointdata/SOURCESat the latest upstream commit, thenscripts/update-vendored-data.sh, which prints the new SHA-256 values to pin after reading the change). Unchanged upstreams need no pull request. - Merging the release pull request creates the tag
vX.Y.Zand a draft Release, and the same run continues:preflightchecks out the tag, asserts it is the released commit, checkstauri.conf.jsonagainst the version (.github/scripts/tauri-release.py check-config --expect-version) and renders the matrix from.github/release-targets.json;bundlebuilds each target with the lockfile's Tauri CLI:tauri build --no-bundlewithout any secret, thentauri bundlewith the update signing key (deb, rpm and AppImage on Linux x64 and arm64; the app archive and the dmg for Apple silicon and Intel; NSIS and MSI on Windows x64, NSIS on Windows arm64), which signs every package for the in-app update. ThebeforeBuildCommandbuilds the extension first: the app carries its Chromium build for the folder it keeps for the browsers, andLOCKRA_EXTENSION_REQUIRED=1stops the leg when that build is missing or of another version (apps/desktop/src-tauri/build.rs);bundle-androidbuilds the phone app's APK and AAB for arm64 unsigned, signs them with the Android key in a step of its own and checks them against the pinned certificate (below);bundle-relaybuilds the relay server's static Linux archives (docs/relay.md);bundle-extensionbuilds the browser extension's store packages twice, requiring the same bytes, and its source package (below);updatercollects every leg (the Android, relay and extension files checked against their legs' checksums), verifies every signature against the public key intauri.conf.json, writeslatest.json(below) andSHA256SUMS, attests the files (SLSA build provenance) and attaches them to the draft..github/scripts/test-tauri-release.pychecks every leg and the manifest's keys against the target manifest;publish-releasere-verifies every remote asset againstSHA256SUMSand flips the draft to public, marking it latest unless it is a prerelease.
- A failed leg leaves the draft unpublished. Rebuild it after a fix of the workflow or an outage with Run workflow (
workflow_dispatch,tag_name: vX.Y.Z); a fix of the source needs a new version.
Versions: package.json is the only source (tauri.conf.json points at it, Cargo.toml stays at 0.0.0). .release-please-manifest.json holds the last released version, 0.0.0 until the first release. The first release is 0.1.0 (initial-version in release-please-config.json): with no earlier release tag, release-please would otherwise start at 1.0.0.
History: on 2026-10-11 the repository was started again from one commit holding the 0.8.9 tree (the owner's request); the releases v0.1.1–v0.8.9 and every tag before it were deleted, and the versions go on from 0.9.0 so installed copies keep updating and the Android version code keeps rising. Those releases were immutable, so their tag names can never be used again here: do not release any of 0.1.1–0.8.9. The pull requests #1–#116 stay on GitHub with their commits.
release-please picks the next version from the commits since the last tag: a fix bumps the patch, a feat the minor (below 1.0 a breaking change too, bump-minor-pre-major). To release another version, for example only a patch after a feat, squash-merge a pull request whose commit message ends with the footer Release-As: X.Y.Z (pass the body to gh pr merge --body-file): the release pull request switches to that version. The footer counts for that release only; later releases follow the commits again.
Repository setup
The repository was created and configured on 2026-10-01 with these commands. They double as the audit list: read a setting back with the same path and GET.
gh repo create sunerpy/lockra --public --source . --remote origin \
--description "Two-factor codes on your own computer: an encrypted, offline TOTP/HOTP authenticator for Windows, macOS and Linux, with import and export for Google Authenticator and Microsoft Authenticator." \
--homepage https://firlab.app/lockra/
git push -u origin main # the only direct push the repository ever gets
gh repo edit sunerpy/lockra \
--add-topic authenticator --add-topic totp --add-topic hotp --add-topic two-factor-authentication \
--add-topic tauri --add-topic rust --add-topic react --add-topic desktop-app \
--add-topic google-authenticator --add-topic microsoft-authenticatorThe settings that make the pipeline fail closed:
R=sunerpy/lockra
# Squash only: the pull request title as the subject, its commits' messages as the body, branches
# deleted after the merge.
gh api -X PATCH repos/$R -F allow_squash_merge=true -F allow_merge_commit=false \
-F allow_rebase_merge=false -F delete_branch_on_merge=true -f squash_merge_commit_title=PR_TITLE
# A read-only default token; release-please may still open its pull request.
gh api -X PUT repos/$R/actions/permissions/workflow \
-f default_workflow_permissions=read -F can_approve_pull_request_reviews=true
# Every action must be pinned to a full commit SHA.
gh api -X PUT repos/$R/actions/permissions -F enabled=true -f allowed_actions=all -F sha_pinning_required=true
# Pull requests from outside contributors wait for approval before they run.
gh api -X PUT repos/$R/actions/permissions/fork-pr-contributor-approval \
-f approval_policy=all_external_contributors
# v* tags cannot be moved or deleted, by anyone. Creating one stays open: release-please creates
# the tag with GITHUB_TOKEN, and a repository owned by a user, not an organisation, cannot name the
# GitHub Actions app as the only actor allowed to.
gh api -X POST repos/$R/rulesets --input - <<'JSON'
{
"name": "release tags",
"target": "tag",
"enforcement": "active",
"conditions": { "ref_name": { "include": ["refs/tags/v*"], "exclude": [] } },
"rules": [{ "type": "update" }, { "type": "deletion" }],
"bypass_actors": []
}
JSON
# main: pull requests only, squash-merged, with CI Success from GitHub Actions on an up-to-date
# branch; no deletion, no force push, nobody on the bypass list. The release job refuses to run
# unless main is protected. Created before the first push; the checks do not apply to creating it.
gh api -X POST repos/$R/rulesets --input - <<'JSON'
{
"name": "main",
"target": "branch",
"enforcement": "active",
"conditions": { "ref_name": { "include": ["refs/heads/main"], "exclude": [] } },
"rules": [
{ "type": "deletion" },
{ "type": "non_fast_forward" },
{ "type": "pull_request", "parameters": {
"required_approving_review_count": 0, "dismiss_stale_reviews_on_push": false,
"require_code_owner_review": false, "require_last_push_approval": false,
"required_review_thread_resolution": false, "allowed_merge_methods": ["squash"] } },
{ "type": "required_status_checks", "parameters": {
"strict_required_status_checks_policy": true, "do_not_enforce_on_create": true,
"required_status_checks": [{ "context": "CI Success", "integration_id": 15368 }] } }
],
"bypass_actors": []
}
JSON
# The release environment deploys from main only.
gh api -X PUT repos/$R/environments/release --input - <<'JSON'
{ "deployment_branch_policy": { "protected_branches": false, "custom_branch_policies": true } }
JSON
gh api -X POST repos/$R/environments/release/deployment-branch-policies -f name=main -f type=branch
# Private vulnerability reporting (SECURITY.md), Dependabot alerts, immutable releases.
gh api -X PUT repos/$R/private-vulnerability-reporting
gh api -X PUT repos/$R/vulnerability-alerts
gh api -X PUT repos/$R/immutable-releasesrelease-please opens its pull request with GITHUB_TOKEN, so the CI and PR Title runs on it wait in action_required until approved; nothing else starts them:
gh run list --repo $R --branch release-please--branches--main--components--lockra-workspace \
--json databaseId,status,conclusion --jq '.[] | select(.conclusion == "action_required") | .databaseId' |
xargs -I{} gh api -X POST repos/$R/actions/runs/{}/approveSecrets
| Secret | Needed for | Notes |
|---|---|---|
| none | releases | GITHUB_TOKEN creates the tag, the draft and the assets |
TAURI_SIGNING_PRIVATE_KEY, TAURI_SIGNING_PRIVATE_KEY_PASSWORD | releases (the in-app update) | the minisign key every package is signed with; preflight fails without it (below) |
ANDROID_KEYSTORE_BASE64, ANDROID_KEYSTORE_PASSWORD, ANDROID_KEY_PASSWORD | releases (the Android app) | the PKCS12 keystore (base64, one line) and its password, twice (PKCS12 keeps one); preflight fails without them |
MACOS_CERTIFICATE, MACOS_CERTIFICATE_PASSWORD, MACOS_SIGNING_IDENTITY | releases (the macOS app) | the self-signed release certificate (.p12, base64, one line), its password, its SHA-1; see macOS signing |
FIRLAB_DOCS_TOKEN | publish-site.yml | a fine-grained token for sunerpy/firlab only, Contents read and write; see docs/site/README.md |
APPLE_CERTIFICATE, APPLE_CERTIFICATE_PASSWORD, APPLE_API_* | optional Developer ID and notarization | unset: the release certificate above signs, without notarization, and Gatekeeper warns once |
AZURE_TENANT_ID, AZURE_CLIENT_ID, AZURE_CLIENT_SECRET with bundle.windows.signCommand | optional Windows signing | without them SmartScreen warns |
FIRLAB_DOCS_TOKEN is the owner's fine-grained token lockra-docs-sync (Settings, Developer settings, Fine-grained tokens): repository access sunerpy/firlab only, permission Contents read and write. It expires on 2027-10-01. After that publish-site.yml fails at its push with an authentication error and publishes nothing. To renew it, open the token, choose Regenerate token with a new expiry (the old value stops working at once), and store the new value without printing it:
gh secret set FIRLAB_DOCS_TOKEN --repo sunerpy/lockra # paste the value at the promptThe next run that has something to push proves the new token; a run with nothing new to publish stops before it uses the token.
In-app updates
From 0.2.0 every release carries what the app's updater reads (docs/security.md, "Updates"):
bundle.createUpdaterArtifacts: truemakestauri bundlesign every package withTAURI_SIGNING_PRIVATE_KEY; the signature's trusted comment records the version (file:Lockra_0.2.0_amd64.deb version:0.2.0), whichplugins.updater.requireSignedVersionmakes the app check. Do not turn that off: it is what refuses an older package offered as new.latest.jsonnames the version, the release notes (the Release's body) and one entry per platform key, each a URL pinned to this release's asset and that asset's signature: an{os}-{arch}-{installer}key for every signed package (linux-x86_64-deb,linux-x86_64-rpm,linux-x86_64-appimage,windows-x86_64-nsis,windows-x86_64-msi,darwin-aarch64-app, …) and an{os}-{arch}key for the updater bundle of each target. The app asks for the key of the way it was installed first, so a copy installed from the.debupdates through a.deb.- The app asks
https://github.com/sunerpy/lockra/releases/latest/download/latest.json(plugins.updater.endpoints): only the release marked latest reaches installed copies, so a prerelease never does.
The key. It was generated with pnpm tauri signer generate (password protected) on 2026-10-01; its public half is plugins.updater.pubkey in tauri.conf.json, the private key and its password are the two secrets above, and the maintainer keeps an offline copy of both. Installed copies trust only this key: losing it ends updates (each copy would need a reinstall of a build with a new public key), and replacing it takes one release signed with the old key that ships the new public key. Keep the offline copy outside GitHub.
Local packages (make linux-x64, make windows-x64, make pre-ci) are built without the key and therefore unsigned: the scripts pass --config '{"bundle":{"createUpdaterArtifacts":false}}' when TAURI_SIGNING_PRIVATE_KEY is not set. A packaged build checks for updates against the real manifest; apps/desktop/src-tauri/tests/update.rs checks the updater against a local manifest instead, and docs/acceptance/updates.md is the update checked by hand on each system.
Android
From 0.7.0 every release carries the phone app for arm64: Lockra_X.Y.Z_android_arm64.apk to install from the releases page and Lockra_X.Y.Z_android_arm64.aab for Google Play, both in SHA256SUMS and the attestations, neither in latest.json (the phone has no in-app updater: a newer APK signed with the same key installs over the old one and keeps its vault).
bundle-android builds both unsigned with the lockfile's Tauri CLI, as CI's android job does, so the key is not there while the dependencies' build code runs. The next step decodes the keystore into the job's temporary directory, signs (.github/scripts/sign-android-package.sh: zipalign -P 16, then apksigner with schemes v2 and v3 for the APK, jarsigner for the AAB) and removes it. .github/scripts/check-android-package.sh then requires one signer, the pinned certificate on both packages, 16 KB alignment, and the release's package name, version and version code. CI runs the same signing and checks with a key made for each run.
The key. Generated with keytool on 2026-10-03 on the maintainer's machine: RSA 4096, SHA256withRSA, valid until 2126-09-09, alias lockra, CN=Lockra, O=Lockra, PKCS12. Its certificate's SHA-256, 5ac2ccffe00d12e80d13dbfc23425cd3b89eec77cd5d21adda4fdac1cf4dcc28, is pinned in .github/android-signing.json with the alias (which is not a secret; CI checks the file with .github/scripts/android-signing.sh). The keystore and its password are the three secrets above, which GitHub never gives back, so the maintainer keeps both in a password manager and an offline copy. Google Play keeps the same key: when Play App Signing is set up, choose to upload an existing key from a Java keystore (encrypted with the PEPK tool Play Console offers) instead of letting Google make one, so the app from Play and the APK from the releases page update each other.
- Lost: no APK can update an installed Lockra any more; users uninstall and install again, and uninstalling deletes the vault (the app is kept out of Android's backups), so they need a Lockra backup first. On Play, request a reset of the upload key; Play keeps signing with its copy.
- Leaked: rotate with APK Signature Scheme v3 (
apksigner rotate) and Play's app signing key upgrade, in a release that carries the lineage; to be designed when needed. - Checking a key:
keytool -list -v -keystore lockra-release.jks -alias lockrashows the SHA-256 above.
The browser extension
From the first release after 0.9.1, every release carries the browser extension (apps/extension) as four files, in SHA256SUMS and the attestations, in no updater manifest:
lockra-extension_X.Y.Z_chromium-store.zip, the package for Microsoft Edge Add-ons and for the Chrome Web Store, which take the same file;lockra-extension_X.Y.Z_chromium-unpacked.zip, the self-install package: the same extension with Edge Add-ons' public key in its manifest, which Chrome, Edge and Brave load unpacked under Edge Add-ons' id. Edge Add-ons refuses a package whose manifest has a key ("the manifest should not contain a key field"), hence two packages;lockra-extension_X.Y.Z_firefox.zip, the package for addons.mozilla.org (AMO);lockra-extension_X.Y.Z_source.zip, the tag's tree (git archive) with aBUILD.mdat its root: the environment the release built in, the commands, and the three packages' SHA-256. AMO asks for the source of an add-on that is bundled and minified; its reviewer rebuilds the add-on from it and compares the files.
bundle-extension runs scripts/package-extension.sh. It builds the packages twice, as make extension does (pnpm --filter @lockra/extension run package, with the lockfile's tools), and stops unless both builds give the same bytes; it checks their manifests (the release's version, no key in the store package, Edge Add-ons' key in the self-install one, the add-on id lockra@firlab.app in the Firefox one), then writes the source package and the four files' checksums, evidence/extension.sha256, which the updater job checks after downloading them (.sha256, not .json: tauri-release.py reads every evidence/*.json as a desktop leg's). make check and CI's repo job run the same script with --check, so a change that breaks the packaging fails in its pull request, not in a release.
The stores. The packages go to the stores by hand (publishing through the stores' APIs is planned, P1). Download them from the release and check them:
gh release download vX.Y.Z --pattern 'lockra-extension_*' --pattern SHA256SUMS &&
sha256sum --check --ignore-missing SHA256SUMS- Microsoft Edge Add-ons (Partner Center): the store zip. The item is a draft since 2026-10-15, with 0.9.1's package: replace the package with the release's and submit it once the listing is filled in.
- Chrome Web Store (the developer dashboard): not used for now; Chrome users install the self-install package. A listing needs the store's developer registration, which has a one-time fee, and its id would go into
CHROME_WEB_STORE. - AMO (the Developer Hub): the Firefox zip, and the source zip at the step that asks for the source code; the notes to the reviewer point at
BUILD.md.
A store account can push an update to every browser that has the extension: keep each one, like the GitHub account, behind two-factor authentication with a hardware key.
The ids. Firefox's is in the manifest, lockra@firlab.app, the same in every build. A Chromium store assigns its id when the first package is uploaded, as a draft, before any review, and the id stays with the store's item. Edge Add-ons gave flgfdomeabdfbebfjonaocddjcamgblk (Partner Center, the extension's overview: the CRX ID and the public key it comes from); that key is apps/extension/manifest/unpacked.json, so the self-install package has the same id wherever it is loaded unpacked. The ids are in crates/lockra-browser/src/ids.rs (EDGE_ADDONS, CHROME_WEB_STORE, None while unlisted): the native-messaging manifests of a release build allow those ids and no other Chromium extension (a debug build adds the development id).
The install scripts
scripts/install.sh (Linux, macOS) and scripts/install.ps1 (Windows) install the latest release from its assets, by the names the release workflow gives them, after checking SHA256SUMS; the documentation prints them as curl … | sh and irm … | iex from main. Change them together with any asset name. scripts/test-install.sh and scripts/test-install.ps1 test them offline (CI's repository gates, make check); .github/workflows/install-scripts.yml runs them against the real latest release on Linux (apt, dnf, AppImage), macOS and Windows (x64 and ARM64) when they change and every week.
Checking a release
gh run view RUN_ID --json jobs --jq '.jobs[] | [.name, .conclusion] | @tsv'
gh release view vX.Y.Z --json isDraft,isLatest,assets --jq '{isDraft, isLatest, assets: [.assets[].name]}'
gh release download vX.Y.Z --pattern SHA256SUMS && sha256sum -c SHA256SUMS --ignore-missing
gh attestation verify Lockra_X.Y.Z_x64-setup.exe --repo sunerpy/lockra \
--signer-workflow sunerpy/lockra/.github/workflows/release.yml
gh release download vX.Y.Z --pattern latest.json --output - | jq '.version, (.platforms | keys)'
gh release download vX.Y.Z --pattern '*_android_arm64.apk' &&
apksigner verify --print-certs Lockra_X.Y.Z_android_arm64.apk | grep 'SHA-256' # the pinned digestEvery bundle leg must appear by its target name; a green run with a skipped leg is not a release.
The documentation site
https://firlab.app/lockra/ goes live when sunerpy/firlab merges the change that adds its lockra/ sub-site. The pages describe the released app and link to the releases page, so that merge comes after the first release is public. Then add FIRLAB_DOCS_TOKEN (above): from there on, publish-site.yml updates the site after every merge that touches docs/site/ or a design document, and docs-site.yml builds it on pull requests (docs/site/README.md). Before that merge both workflows fail without publishing anything.
macOS signing
The macOS packages are signed with the project's own self-signed code signing certificate, "Lockra Code Signing", the same for every release, so each build can hand its keychain items to the next instead of making macOS ask again after an update (docs/security.md, "Keychain items across updates"; the method Voltip uses). It is not a Developer ID: Gatekeeper still asks once before the first start of a hand-installed copy.
The certificate. Made with OpenSSL on 2026-10-04 on the maintainer's machine: RSA 2048, SHA-256, valid until 2126-10-05, key usage digital signature, extended key usage code signing, exported as PKCS12 with -certpbe PBE-SHA1-3DES -keypbe PBE-SHA1-3DES -macalg sha1 (macOS's security import does not read OpenSSL 3's default algorithms). Its SHA-1, D660014E62B11FA7743E582E5FD52C110DA66855, is pinned in .github/macos-signing.json, and the app compiles in the same requirement (code_identity::RELEASE_REQUIREMENT; a test keeps the two equal). The .p12 and its password are two of the secrets above, which GitHub never gives back: the maintainer keeps both in a password manager and an offline copy.
In the release. After phase 1 (no signing material while dependency code runs), .github/scripts/macos-signing.sh import puts the certificate in a keychain of the job and trusts it for code signing; Tauri signs with APPLE_SIGNING_IDENTITY (the SHA-1); check then requires that the app and the app inside the updater's .app.tar.gz carry exactly the pinned designated requirement; remove deletes the job's keychain. Local and CI builds stay ad hoc, and an ad hoc build keeps the keyring store and hands nothing over.
In CI. macos-keychain runs on the login keychains of macOS 15 and 26, Apple silicon and Intel (a keychain made with security create-keychain skips the partition check). .github/scripts/check-keychain-handoff.sh: three builds of examples/keychain_harness.rs with different code, signed with certificates made for the run, show that a build cannot read another build's item without the dialog, that the hand-over before installation lets the new build read its own item without it, and that a build signed otherwise is handed nothing and gives nothing. .github/scripts/check-keychain-preinstall.sh does it as an update does: the real app, a debug build trusting the run's certificate (LOCKRA_DEV_RELEASE_REQUIREMENT), bundled and signed with the hardened runtime as the release is, packed as the updater's Lockra.app.tar.gz, staged by the updater's own prepare_update and installed at another path, reads its entry without asking; an ad hoc package is handed nothing.
- Lost: sign the next release with a new certificate (below); every Mac user allows the keychain once more after that update.
- Leaked or rotated: make a new certificate the same way, update the three secrets,
.github/macos-signing.jsonandRELEASE_REQUIREMENTin one pull request, and say in the release notes that this update asks for the keychain once. The old build refuses to hand over to the new one (another requirement), so the new build reads the old item once, with the dialog.
Manual checks on real devices
What the automated gates cannot cover, before announcing a release:
- macOS: open the app, create a vault, turn on Remember on this device (the Keychain prompts once), lock, unlock with it; the traffic lights sit in the title strip.
- Windows: install the NSIS package, repeat the steps with Credential Manager, copy a code and check that Win+V clipboard history does not show it; reveal a secret and check that a screenshot shows the window black. Update in the app from the previous release: after Update now Lockra closes and the new version opens a few seconds later, with no installer window in between (the update from 0.7.5 or earlier: a flash of it at most; an MSI copy shows the installer's progress and the administrator prompt).
- Google Authenticator: export two accounts from Lockra (one page), scan them with Transfer accounts → Import accounts, compare the codes; export eleven or more (two pages) and import both.
- Microsoft Authenticator: scan one exported code with + → Other account, compare the code.
- PhoneFactor: import the two files from a rooted phone; personal (8-digit) and third-party accounts arrive, work accounts are listed as unsupported.
- Linux keychain: on GNOME (Secret Service) and KDE, turn on Remember on this device, restart, unlock with it.
- Touch ID (a Mac with Touch ID): locked with nothing set up, the unlock screen points to Settings › Security; there, with Remember on this device off, turn on Unlock with Touch ID (the prompt comes once, and Remember on this device turns on with it); lock; Unlock with Touch ID opens the vault after the fingerprint, a cancelled prompt leaves it locked without a message, the master password still unlocks; turning the switch off asks for the master password and leaves Remember on this device on. With Default unlock on Touch ID (the default): quit and start Lockra, and the prompt comes by itself;
Ctrl Lbrings no prompt until you switch to another app and back; a cancelled prompt stays away until the same; an auto-lock (one minute) with Lockra in front prompts at once, and with Lockra behind another app only when you switch back. With Master password, nothing comes by itself. - Windows Hello (a PC with Windows Hello): the same steps; the Hello dialog comes up in front of Lockra, and its PIN is accepted as well as a fingerprint or the face.
- Keychain across an update (a Mac with Remember on this device or Touch ID on): updating from 0.7 or earlier to the first release with the hand-over asks once for the keychain item (the old build has no hand-over to give); from there on, Update now and the restart that follows open Lockra without the system's keychain dialog, and Touch ID still unlocks. A dmg installed by hand asks once. When an update ends with "could not take over the device key", nothing was installed:
~/Library/Logs/dev.lockra.desktop/lockra.logsays why. - Android (an arm64 phone): install the release's APK and go through
docs/acceptance/android.md; an APK from a pull request (signed with that run's key) has to be uninstalled first.