Saltar al contenido principal

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.

  1. Sign in to developer.apple.com as the Account Holder for the Fixx, S.A. organisation account.
  2. Certificates → + → Developer ID Application. (Not "Mac App Distribution" — that is for the Mac App Store, which we deliberately do not use.)
  3. Generate a CSR from Keychain Access on a Mac, upload it, download the .cer.
  4. Import it into that Mac's keychain, then export as .p12 with a password.
  5. Create an app-specific password at appleid.apple.com → Sign-In and Security. This is for notarisation, and is not the Apple ID password.
  6. 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 asWhat it is
APPLE_CERTIFICATEthe .p12, base64-encoded: base64 -i cert.p12 | pbcopy
APPLE_CERTIFICATE_PASSWORDthe password you set on the .p12
APPLE_SIGNING_IDENTITYDeveloper ID Application: Fixx, S.A. (TEAMID)
APPLE_IDthe Apple ID email
APPLE_APP_SPECIFIC_PASSWORDfrom step 5
APPLE_TEAM_IDthe 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.

  1. In the Azure portal, create a Trusted Signing account and, under it, a certificate profile of type Public Trust.
  2. 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.
  3. 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 asWhat it is
AZURE_CODE_SIGNING_ENDPOINTregional endpoint, e.g. https://eus.codesigning.azure.net
AZURE_CODE_SIGNING_ACCOUNTthe Trusted Signing account name
AZURE_CODE_SIGNING_PROFILEthe certificate profile name
AZURE_TENANT_IDservice principal tenant
AZURE_CLIENT_IDservice principal app id
AZURE_CLIENT_SECRETservice 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=true in Doppler on prd_release_shell (a secret, not a GitHub variable) and the internal channel 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 variableVerifies
FLOWPOS_BUNDLE_PUBLIC_KEYweb bundles, in bundles/manifest.rs
FLOWPOS_RELEASE_PUBLIC_KEYnative 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_KEY
  • TAURI_UPDATER_SIGNING_KEY_PASSWORD
  • APPLE_CERTIFICATE, APPLE_CERTIFICATE_PASSWORD, APPLE_SIGNING_IDENTITY
  • APPLE_ID, APPLE_APP_SPECIFIC_PASSWORD, APPLE_TEAM_ID
  • AZURE_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:

TaskWhat 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.
T115Walk 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.