Getting to the first desktop release
The five things left in feature 054-desktop-runtime-shell, in the order to start
them. Ordered by lead time, not by effort: two of these wait on other people for
weeks, and three take an afternoon.
Everything in the code is done. Nothing below is blocked on engineering.
Start today — these wait on other people
1. Apple Developer ID Application certificate (T005)
Lead time: days to weeks. Needs the Apple Account Holder — not an admin, unless your team has a cloud-managed exception.
- Sign in to developer.apple.com as the Account Holder for the Fixx, S.A. organisation account.
- Certificates → + → Developer ID Application. (Not "Mac App Distribution" — that is for the Mac App Store, which we deliberately do not use.)
- Generate a CSR from Keychain Access on a Mac, upload it, download the
.cer. - Import it into that Mac's keychain, then export as
.p12with a password. - Create an app-specific password at appleid.apple.com → Sign-In and Security. This is for notarisation, and is not the Apple ID password.
- Note the Team ID (Membership page) and the signing identity string, which
looks like
Developer ID Application: Fixx, S.A. (TEAMID).
You will end up with six values:
| Goes into GitHub as | What it is |
|---|---|
APPLE_CERTIFICATE | the .p12, base64-encoded: base64 -i cert.p12 | pbcopy |
APPLE_CERTIFICATE_PASSWORD | the password you set on the .p12 |
APPLE_SIGNING_IDENTITY | Developer ID Application: Fixx, S.A. (TEAMID) |
APPLE_ID | the Apple ID email |
APPLE_APP_SPECIFIC_PASSWORD | from step 5 |
APPLE_TEAM_ID | the 10-character Team ID |
2. Windows code-signing identity (T006)
Lead time: one to two weeks — organisation validation is the slow part, and it is the CA's timeline, not ours.
The workflow is now wired for Azure Trusted Signing (~$10/month): no hardware
token, the certificate never leaves Azure, and it is the only shape that fits a CI
runner. The .pfx route it previously expected is no longer purchasable — since the
2023 CA/B Forum requirements an OV code-signing key must live on FIPS 140-2 Level 2
hardware, so no CA will issue a downloadable .pfx.
- In the Azure portal, create a Trusted Signing account and, under it, a certificate profile of type Public Trust.
- Complete organisation identity validation for Fixx, S.A. This is the slow part — it asks for company registration documents and a verifiable phone listing, and it is the CA's timeline, not ours. Start it before you need it.
- Create a service principal (app registration + client secret) and grant it the Trusted Signing Certificate Profile Signer role on the account.
You will end up with six values:
| Goes into GitHub as | What it is |
|---|---|
AZURE_CODE_SIGNING_ENDPOINT | regional endpoint, e.g. https://eus.codesigning.azure.net |
AZURE_CODE_SIGNING_ACCOUNT | the Trusted Signing account name |
AZURE_CODE_SIGNING_PROFILE | the certificate profile name |
AZURE_TENANT_ID | service principal tenant |
AZURE_CLIENT_ID | service principal app id |
AZURE_CLIENT_SECRET | service principal secret |
All six go into Doppler on prd_release_shell, which syncs into the release-shell
GitHub Environment. They are secrets by transport, not by sensitivity.
Piloting before the certificate clears. Set
FLOWPOS_ALLOW_UNSIGNED_INTERNAL=truein Doppler onprd_release_shell(a secret, not a GitHub variable) and theinternalchannel will build and publish unsigned. Production is never excused. Merchants installing an unsigned build see "Windows protected your PC" and must click More info → Run anyway — a real cost, taken knowingly, for a pilot. Delete the variable the day the certificate is issued. See signing.md.
3. The application icon (T100a)
Lead time: whatever your designer needs. Not blocking any other step, but it must land before the first installer reaches anyone — it is baked into signed artefacts, and reissuing those is tedious.
Give the designer: one square PNG, 1024×1024, transparent background. That is all
tauri icon needs; it generates every platform size from it.
# Replace the placeholder, then regenerate the whole set:
cp ~/Downloads/flowpos-icon-1024.png apps/desktop/icon-source.png
pnpm --filter @flowpos-workspace/desktop exec tauri icon icon-source.png
git add apps/desktop/icon-source.png apps/desktop/src-tauri/icons
The current file is a plain blue square that exists only because the Windows build
cannot compile without icon.ico.
An afternoon — do these while the certificates are in flight
4. Reconcile the signing keys, and publish their public halves
This one has a catch worth reading before you start.
The two Doppler configs hold the private keys. The code needs the public halves compiled into the build, as GitHub Actions variables (not secrets — a public key is not a secret, and storing it as one makes it harder to inspect when a signature fails):
| GitHub variable | Verifies |
|---|---|
FLOWPOS_BUNDLE_PUBLIC_KEY | web bundles, in bundles/manifest.rs |
FLOWPOS_RELEASE_PUBLIC_KEY | native updates, in updater/verify.rs |
Both must be the raw 32-byte Ed25519 public key, base64-encoded — not a minisign public key file, not PEM. The verifier decodes exactly 32 bytes and rejects anything else.
If the Doppler keys are minisign format, they need converting. Minisign is Ed25519 underneath, so the key material is compatible, but the encoding is not. The publisher also expects a PKCS#8 PEM private key — it deliberately does not accept a raw minisign secret key, because converting one is a decision somebody should make on purpose rather than a guess a script makes.
To check what you have:
doppler secrets get WEB_BUNDLE_SIGNING_KEY --plain --config prd_release_web | head -1
-----BEGIN PRIVATE KEY-----→ PKCS#8 PEM, ready to use.untrusted comment: minisign encrypted secret key→ needs converting; tell me and I will write the conversion and a test that proves the converted key still verifies.
To generate the matching public half from a PKCS#8 PEM:
node -e '
const {createPublicKey}=require("node:crypto");
const pem=require("node:fs").readFileSync(process.argv[1],"utf8");
const raw=createPublicKey(pem).export({format:"der",type:"spki"}).subarray(-32);
console.log(raw.toString("base64"));
' /path/to/private-key.pem
Do this for both keys. Put each result in Settings → Secrets and variables → Actions → Variables.
5. Create the two environments, and empty the repository secrets (T007a)
Fifteen minutes, and the last step is the one that matters.
In Settings → Environments, create two:
release-web — used by deploy-production.yml → publish-desktop-bundle:
WEB_BUNDLE_SIGNING_KEY
release-shell — used by desktop-release.reusable.yml → release, called by
desktop-release-production.yml for both the production and internal channels:
TAURI_UPDATER_SIGNING_KEYTAURI_UPDATER_SIGNING_KEY_PASSWORDAPPLE_CERTIFICATE,APPLE_CERTIFICATE_PASSWORD,APPLE_SIGNING_IDENTITYAPPLE_ID,APPLE_APP_SPECIFIC_PASSWORD,APPLE_TEAM_IDAZURE_TENANT_ID,AZURE_CLIENT_ID,AZURE_CLIENT_SECRET(Windows signing)
The release job also needs GCP_WORKLOAD_IDENTITY_PROVIDER and
GCP_SERVICE_ACCOUNT_EMAIL to publish the installer where merchants can reach it — the
repository is private, so a GitHub release asset is not downloadable by any merchant.
Then — and this is the step that does the work — go to Settings → Secrets and variables → Actions → Repository secrets and delete both signing keys from there.
A repository secret is visible to every job in the repository. Leave either key there
and the environments are decorative: the bundle-publishing job would still be able to
read the native release key without ever declaring release-shell, which is exactly
the separation FR-073 exists to create.
Getting the values in
Doppler holds them; the environments need them. If the Doppler → GitHub Actions integration is not set up for these two configs, copy them directly:
node scripts/sync-release-secrets.mjs --dry-run # show what would be copied
node scripts/sync-release-secrets.mjs # do it
It copies only the secrets each job actually reads — 13 into release-shell, 9 into
release-web — rather than the whole 120-secret config. A job that builds an installer
has no business holding DATABASE_URL. It refuses outright if the plan would put the
web signing key in release-shell or a native signing credential in release-web, and
it passes values to gh on stdin rather than as --body, because an argument is
visible in the process list and lands in shell history.
This is a snapshot, not a sync: rotate a value in Doppler and it does not reach GitHub until this is run again. Re-run it when the Apple certificate and the Azure service principal arrive — both are listed as pending until then.
Verify:
node scripts/check-signing-key-separation.mjs
It fails if any job references both keys, if a job touching a key declares the wrong environment or none, or if a private key is committed. It runs in CI on every desktop and release workflow run.
Then — the first release unblocks three tasks at once
Once the certificates are in and the environments exist:
git tag desktop-v0.1.0
git push origin desktop-v0.1.0
desktop-release-production.yml builds four installers — windows/internal,
windows/production, macos/internal, macos/production — signs and notarises them,
and refuses to publish any build whose signature does not verify. On macOS it runs
spctl --assess, which is Gatekeeper's own answer rather than ours.
A fifth and sixth installer, windows/staging and macos/staging, come from
desktop-release-staging.yml — manual dispatch only. They talk to the staging API
and are unsigned. They are for our own QA and must never be handed to a merchant: once
installed, a staging build looks exactly like a merchant build and writes a real day's
trade into a test database.
That produces the installers three remaining tasks are waiting on:
| Task | What to do with the installer |
|---|---|
| T107 (SC-022) | Run the clean-machine checklist in install-verification.md on a fresh VM of each OS, and fill in the recorded-runs table. Any warning is a failed run — not one to click through. |
| T115 | Walk specs/054-desktop-runtime-shell/quickstart.md's install steps on those same machines. Its developer half is already verified and corrected. |
| T049 (SC-001) | Run the scripted merchant journeys against a browser and against the installed application, and compare outcomes. |
Start with the internal channel. That is what it is for: a defective bundle or release on internal is identifiable from telemetry within minutes and reaches no merchant.
What is not on this list, and why
T044a — the absolute session lifetime. The policy and its nine tests are written
and passing. Enforcement is blocked on binding a desktop session to the installation
identity, which spec.md defers as a follow-up feature: a POS window sends the
merchant's Firebase token exactly as a browser does, so nothing on the request
distinguishes them. A client-volunteered header is not an alternative — a caller that
could name itself browser would walk straight out of the limit.
This is a scope decision, not an outstanding task.