A release of MindMapVault used to be an afternoon. Build the Windows installer on a Windows machine, pack the Store package by hand, sign and notarise the Mac build, copy everything to the download server, update the store listings, rebuild the server image.

Today it is one thing: we publish a GitHub release. About fifteen minutes later the same version is on the download page, uploaded to the Mac App Store, the Microsoft Store and the Snap Store, and offered to Flathub as a pull request. The self-hosted server goes out as a multi-arch image on Docker Hub and GHCR from a single tag.

This is how that pipeline is put together, what it cost to set up, and the parts we still do by hand on purpose.

What one release produces

MindMapVault ships in two open-source repositories: the offline desktop app (MindMapVault FOSS, MIT) and the self-hosted server (AGPL-3.0). A release in each fans out like this:

Workflow Trigger Produces
desktop-build release published Windows setup.exe, signed and notarised universal macOS DMG, Linux AppImage, attached to the release
publish-r2 desktop-build succeeded the same files on downloads.mindmapvault.com, which the download page links to
mac-app-store release published sandboxed universal build, uploaded to App Store Connect
microsoft-store release published MSIX, attached to the release and uploaded to Partner Center as a draft
snap release published snap, attached to the release and uploaded to the Snap Store's candidate channel
flatpak release published offline Flatpak build, then a pull request against our Flathub repository
build-server-image tag v* linux/amd64 and linux/arm64 server image on GHCR and Docker Hub

Five of them listen to the same event, one chains on the build, and the server has its own tag. Nothing waits for a person except where we want it to.

How much work that is

The table hides how much compute one release takes. These are the job times from our last release, v0.6.7 of the desktop app and v0.6.6 of the server:

Job Runner Minutes
Windows installer windows-latest 5
Microsoft Store MSIX windows-latest 7
macOS DMG, universal: Apple silicon and Intel macos-15 9
Mac App Store build, universal macos-15 10
Linux AppImage ubuntu-22.04 6
Flatpak, built offline in a container ubuntu-24.04 9
Server image, amd64 ubuntu-latest 7
Server image, arm64 ubuntu-24.04-arm 6
Snap, built in snapcraft's own container ubuntu-24.04 10
Multi-arch manifest, R2 upload, Flathub PR ubuntu-latest 1

That is a little over an hour of build machines across three operating systems and two CPU architectures, and every universal Mac build compiles the Rust code twice, once per architecture. Because the jobs run side by side, the release is done in about fifteen minutes.

We pay nothing for any of it. GitHub's standard hosted runners are free for public repositories, the macOS, Windows and arm64 Linux ones included, and both repositories are open source. For a small project that settles the question of whether to own build machines: we would need a Mac, a Windows PC and an arm64 Linux box to do this by hand, and each of them would want updates.

Pattern 1: one trigger, many independent workflows

The obvious design is one big workflow with a job per store. We split it instead: every destination is its own file, listening to the same event.

on: release: types: [published] workflow_dispatch: inputs: tag: description: 'Existing release tag to build and upload (e.g. v0.6.7)' required: true 

Two things fall out of that. A store that rejects an upload does not fail the release; the DMG is still on the download page. And every workflow can be re-run by hand for an old tag, which is how we sent 0.6.7 to the Microsoft Store the day we wrote that workflow, without cutting a new release.

publish-r2 is the one exception. It chains on the build with workflow_run, and it only reads the release:

on: workflow_run: workflows: [desktop-build] types: [completed] 

It downloads the three packages from the GitHub release and copies them to a Cloudflare R2 bucket under a versioned path, foss/windows/0.6.7/…. Because it builds nothing, a failed upload is fixed by running it again, and the download server serves the same bytes as the GitHub release. The R2 token it uses can edit that storage and nothing else.

Pattern 2: skip, don't fail, when a secret is missing

Every store job starts by checking that its credentials exist:

- name: Check secrets id: secrets env: APP_CERT: ${{ secrets.MAS_APP_CERTIFICATE }} PROFILE: ${{ secrets.MAS_PROVISION_PROFILE }} run: | if [ -z "$APP_CERT" ] || [ -z "$PROFILE" ]; then echo "::notice::Mac App Store secrets not set; skipping." echo "ready=false" >> "$GITHUB_OUTPUT" else echo "ready=true" >> "$GITHUB_OUTPUT" fi 

Every later step carries if: steps.secrets.outputs.ready == 'true'. A fork of the repository, which has none of our secrets, gets a green run with a notice instead of a red one. The same check let us merge each workflow before the store account behind it was ready, and switch it on later by adding secrets.

Pattern 3: signing in a throwaway keychain

Mac signing needs certificates in a keychain, and a CI runner is a shared machine. The Mac App Store job creates a keychain that exists for one run:

kc="$RUNNER_TEMP/mas.keychain-db" kc_pass=$(openssl rand -hex 16) security create-keychain -p "$kc_pass" "$kc" security unlock-keychain -p "$kc_pass" "$kc" security import app.p12 -k "$kc" -P "$CERT_PASSWORD" -T /usr/bin/codesign -T /usr/bin/productbuild security set-key-partition-list -S apple-tool:,apple: -s -k "$kc_pass" "$kc" 

The password is random and never leaves the job, and a final step with if: always() deletes the keychain. The job then builds the app with the App Store config overlay, checks the sandbox entitlement is really in the signature, wraps it in an installer package and uploads it with xcrun altool. Notarisation is for the DMG only; the App Store build skips it on purpose.

Two things cost us real time here, and neither was in any guide:

  • A sandboxed WebKit app needs com.apple.security.network.client, even if it never talks to a network. Without it the window stays blank, because WebKit loads even the app's bundled pages through its own networking process. Our app is offline, so the App Review notes explain why it asks for the entitlement anyway.
  • Keychain Access exports the row you selected, not the certificate you meant. Two certificates from one signing request share one private key. Export the key row and you get whichever certificate Keychain pairs it with first. Our installer secret held the app certificate instead, and the job failed at the identity lookup.

Pattern 4: let the store sign it

The Microsoft Store job is the newest, and the simplest, because Microsoft re-signs every package it accepts. The MSIX is uploaded unsigned, so there is no code-signing certificate in CI at all:

msstore reconfigure --tenantId $env:TENANT --sellerId $env:SELLER `  --clientId $env:CLIENT --clientSecret $env:SECRET msstore publish $msix -id 9NMNK1P8D7CZ --noCommit 

--noCommit leaves the submission as a draft. Somebody still clicks Submit in Partner Center, which is a gate we want: certification takes a few days, and the listing text should be read by a person before it goes out.

One detail made manual runs useful. The workflow checks out the release tag to build the app, but takes the packaging script from its own commit:

- uses: actions/checkout@v5 with: ref: ${{ github.sha }} path: packaging sparse-checkout: | desktop/msix desktop/src-tauri/icons 

That way an old tag can be packaged with the current script. The first upload was 0.6.7, a tag older than the workflow that built it.

Pattern 5: native arm64 instead of emulation

The server image is Rust. Building linux/arm64 on an amd64 runner through QEMU compiles the whole backend under emulation and took most of an hour. GitHub's arm64 runners do it natively:

strategy: matrix: include: - platform: linux/amd64 runner: ubuntu-latest - platform: linux/arm64 runner: ubuntu-24.04-arm 

Each runner pushes its image by digest only. A second job collects the digests and joins them into one multi-arch tag, then pushes the same manifest to GHCR and Docker Hub, so both registries serve identical bytes. Docker Hub matters more than it sounds: it is what the default docker-compose.yml pulls, so it is where most self-hosters actually get the server.

Tag rules keep latest meaningful. A push to main publishes only sha-<commit>. Only a release tag publishes v0.6.6 and moves latest. And when a release once went out with the version file still on the previous number, a separate manual workflow pointed the tag at the right digest without rebuilding, because a rebuild would have produced different bytes under a version that had already shipped.

Pattern 6: check the package, not just the build

A green build only says the build finished. The snap job also opens the finished package and checks two things before uploading it:

unsquashfs -l "$snap" > contents.txt grep -q 'usr/bin/MindMapVault-foss$' contents.txt if grep -q 'libwebkit2gtk-4.1.so' contents.txt; then echo "::error::libwebkit2gtk is staged inside the snap" exit 1 fi 

The second check is there because of a bug we ran into once. The snap gets WebKit from the GNOME platform snap, whose helper processes live outside our package. If our own copy of the WebKit library ends up inside, it is a different WebKit release than those helpers, and the app opens to a blank window. The build log looks perfect either way, so the check reads the package instead. The upload goes to the candidate channel: we install it from there, and promoting it to stable is one click.

Can you trust GitHub with the keys?

This was the question we asked before putting store credentials in a repository. Our foss repository holds 17 secrets: Apple certificates and an App Store Connect key, Mac App Store certificates and a provisioning profile, a Cloudflare R2 token, a Snap Store token, and a Microsoft Partner Center client secret.

What GitHub gives you:

  • Secrets are encrypted at rest and only decrypted for a running job.
  • They are masked in logs, so an accidental echo prints ***.
  • Workflows triggered by pull requests from forks do not receive them. We also never use pull_request_target, the trigger that would hand them to code from a fork.

What it does not protect against:

  • Every step of a job can read every secret that job receives. That includes third-party actions. Our actions are pinned to version tags such as @v5, and a tag can be moved by whoever controls that action. Pinning to full commit hashes is the fix, and it is on our list.
  • Nobody approves a release run. GitHub environments can require a reviewer before a job sees its secrets. We do not use them yet; a published release is the approval.

What we do about the rest is scope and expiry. Each credential can do one thing: the R2 token edits one storage bucket, the Snap Store token can upload and release one snap and nothing else, and the Docker Hub token is an access token rather than the account password. The Partner Center app is the weak spot: Microsoft requires its Manager role for uploads, which is broader than we would like. The Mac App Store certificates and profile expire in a year, the Partner Center secret in at most two. If one leaks, we revoke that one credential and the rest keep working. So yes, we trust GitHub with them, the way you trust any build server: with keys that are narrow enough to lose.

What is still manual

  • Flathub. The workflow builds the Flatpak offline from generated Cargo and npm source lists, lints it, and on a release will open a pull request against the Flathub repository. That repository does not exist until Flathub accepts our first submission, so this one is ready but not live.
  • The final click. Submit for App Review, Submit in Partner Center. Both on purpose.
  • winget. The first community manifest waits for a volunteer moderator. Once it is merged, later versions can be a pull request from CI too.

What it saves

Between 2 September and 3 November we shipped twelve desktop releases, roughly one every five days. Done by hand, each one took two to three hours: the Windows Store package on a Windows machine, the snap in a Linux VM, signing and notarising on the Mac, copying files to the download server, uploading to each store. At that pace the manual route costs a working day or two every month, and that time came out of the app.

It also cost releases. When shipping takes an afternoon you batch fixes and wait. Now a small fix goes out the day it is made, and every store gets the same version at the same time.

Setting it all up took four working days, spread over a month: the R2 upload on 7 October, then Flatpak, the Mac App Store, the Microsoft Store and the Snap Store between 2 and 4 November. The Microsoft Store took about an hour and a half from the first question to the first upload, the Snap Store about an hour. The Mac App Store took most of a day, and most of that day was certificates, not code. That cost is paid once per project. It pays for itself within the first month.

Where the AI came in

We did this with an AI coding assistant, Claude Code, and it is a large part of why four days was enough. To be specific about what it did:

  • It wrote the workflows and the scripts, and adapted the existing MSIX script so CI and a local Windows machine share one manifest.
  • It read the documentation for us. Apple, Microsoft and Canonical each have their own vocabulary, and the useful line is often in a footnote: that msstore publish --noCommit leaves a draft, which token permissions snapcraft export-login needs, that a sandboxed WebKit app needs a network entitlement.
  • It read the failed logs. The Mac App Store job failed once at the identity lookup. Working out that the exported file held the wrong certificate took a few minutes, not an evening.
  • It walked us through the store consoles, screenshot by screenshot, including the parts no tutorial covers, like a Microsoft Entra tenant that asks for a billing address.
  • It wrote the documentation. Every workflow opens with a comment that says why it is built the way it is, the build guide has a section per store with the secrets it needs, and each section keeps the manual steps as a fallback. This is the part that usually gets skipped, and it is the part that matters when a certificate expires next year and nobody remembers how it was set up.

What it did not do: create the accounts, type passwords or 2FA codes, answer the export-compliance questions, or decide what goes to which store. It also got things wrong now and then. It once gave us the wrong token permissions for the Snap Store, and its first hero image for this article had padlocks on it. Checking its claims against the vendor's documentation and the actual runs stayed our job.

Was it worth it?

Yes. The setup was not quick, and most of it was not YAML but accounts: certificates, provisioning profiles, a Microsoft Entra tenant just to get a client ID, and an evening lost to one exported key. That cost is paid once. The YAML is the cheap part, and with an assistant writing it and the documentation around it, it is cheaper still.

If you are setting up something similar, three rules did most of the work for us: one workflow per destination, skip rather than fail when a secret is missing, and never rebuild something that has already shipped.

Now we are curious about yours. What is the one release step you still do by hand, and what would it take to automate it? Tell us in the comments. Ours was the Snap Store, until the afternoon we wrote this article.

MindMapVault is an end-to-end encrypted mind mapping tool. Every workflow described here is in the public repositories: mindmapvault-foss for the desktop app and mindmapvault-server for the self-hosted server. The packages they produce are on the download page. Part 9 of the builder series covers why we ship to stores at all.