Guides
GitHub Actions
A workflow that builds, signs and notarizes the apps of every platform, and publishes them with their updates, when you push a tag.
A GitHub Actions workflow can build the apps of every platform when you push a tag, sign them, and upload them with their updates to a draft release, which you publish. Each platform builds on a runner of its own, at the same time. MyGo needs no cgo, so Ubuntu runners make the Windows apps as well as the Linux ones; the macOS app needs a macOS runner, because signing, notarization and disk images need macOS.
The workflow
With updates.github in the configuration, save this as
.github/workflows/release.yml:
name: Release
on:
push:
tags: ["v*"]
permissions:
contents: write # the draft release, and the uploads to it
jobs:
draft-release:
runs-on: ubuntu-latest
steps:
- name: Create the draft release
env:
GH_TOKEN: ${{ github.token }}
run: |
if ! gh release view "$GITHUB_REF_NAME" --repo "$GITHUB_REPOSITORY" > /dev/null 2>&1; then
gh release create "$GITHUB_REF_NAME" --repo "$GITHUB_REPOSITORY" \
--draft --verify-tag --title "$GITHUB_REF_NAME" --generate-notes
fi
build:
needs: draft-release
name: ${{ matrix.name }}
runs-on: ${{ matrix.os }}
strategy:
fail-fast: false
matrix:
include:
- { name: macOS, os: macos-latest, platform: "darwin/arm64,darwin/amd64" }
- { name: Windows, os: ubuntu-latest, platform: "windows/amd64,windows/arm64" }
- { name: Linux, os: ubuntu-latest, platform: "linux/amd64,linux/arm64" }
steps:
- uses: actions/checkout@v7
- uses: actions/setup-go@v7
with:
go-version-file: go.mod
- uses: oven-sh/setup-bun@v2
- run: bun install --frozen-lockfile
- name: Install NSIS, for the Windows installers
if: matrix.name == 'Windows' && runner.os == 'Linux'
run: sudo apt-get update && sudo apt-get install -y nsis
- name: Build and upload
env:
GH_TOKEN: ${{ github.token }}
MYGO_UPDATER_PRIVATE_KEY: ${{ secrets.MYGO_UPDATER_PRIVATE_KEY }}
run: bun run build -- -platform "${{ matrix.platform }}" -uploadA project of native UI has no frontend, so no Bun: leave out the
setup-bun and bun install steps, and build with the CLI that go.mod
pins as a tool:
run: go tool mygo build -platform "${{ matrix.platform }}" -uploadAdd the secret key that signs updates, the contents of mygo-update.key
(see auto-updates), to the repository's secrets:
gh secret set MYGO_UPDATER_PRIVATE_KEY < path/to/mygo-update.keyThen bump version in the configuration, commit, and push its tag:
git tag v1.2.0
git push origin v1.2.0The draft release job creates the draft release v1.2.0. Then the build
job runs once for each row of its matrix, at the same time, and each
uploads to the draft: the macOS disk images, the Windows installers, and
the Linux archives, Debian packages and install script, each with their
updates.
Review the draft and publish it: apps then update to it.
mygo builduploads to the release of the configuration's version, with thetagPrefixofupdates(vby default), whichever tag started the workflow. A step can check that they agree, as below.- The draft exists before the builds start, so they upload to it: without
it, each
mygo build -uploadwould create a draft of its own. Running the workflow again keeps the draft, and replaces its files. - With
fail-fast: false, the other platforms finish when one fails. "Re-run failed jobs" builds that one again, and it uploads to the same draft. - A row's
platformmay list several targets, whichmygo buildbuilds one after the other. A row for each target, such aswindows/amd64andwindows/arm64, builds them at the same time, on more runners. - The release page gets the notes that GitHub generates from the pull
requests since the last release, which you can edit in the draft. The
update window shows the version's section of
CHANGELOG.md, which the update manifests carry. - The
GITHUB_TOKENof the workflow letsgh, which the runners have, create the release and upload to it. With atagPrefix, it also letsmygo buildfind the earlier releases that delta updates start from.
As it is, the workflow makes apps that work but are not signed for other machines: macOS apps signed ad hoc, which Gatekeeper blocks on other Macs, and Windows apps without a signature, for which SmartScreen warns users. The next sections sign them.
Signing and notarizing macOS apps
The macOS row needs your Developer ID certificate and the credentials of
Apple's notary service (see signing and notarization).
Export the "Developer ID Application" certificate, with its private key,
from Keychain Access as a .p12 file with a password, and add the
secrets:
base64 -i certificate.p12 | gh secret set MACOS_CERTIFICATE
gh secret set MACOS_CERTIFICATE_PASSWORD # the password of the .p12
gh secret set APPLE_ID # the Apple ID of your developer account
gh secret set APPLE_TEAM_ID # your team ID, as in the identity's (TEAMID)
gh secret set APPLE_APP_PASSWORD # an app-specific password of the Apple IDCreate the app-specific password at account.apple.com, under Sign-In and Security. Then, before "Build and upload", put them in a keychain of the macOS runner:
- name: Import the certificate and the notary credentials
if: runner.os == 'macOS'
env:
MACOS_CERTIFICATE: ${{ secrets.MACOS_CERTIFICATE }}
MACOS_CERTIFICATE_PASSWORD: ${{ secrets.MACOS_CERTIFICATE_PASSWORD }}
APPLE_ID: ${{ secrets.APPLE_ID }}
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
APPLE_APP_PASSWORD: ${{ secrets.APPLE_APP_PASSWORD }}
run: |
keychain="$RUNNER_TEMP/signing.keychain-db"
password="$(openssl rand -base64 24)"
security create-keychain -p "$password" "$keychain"
security set-keychain-settings -lut 21600 "$keychain"
security unlock-keychain -p "$password" "$keychain"
echo "$MACOS_CERTIFICATE" | base64 --decode > "$RUNNER_TEMP/certificate.p12"
security import "$RUNNER_TEMP/certificate.p12" -k "$keychain" \
-P "$MACOS_CERTIFICATE_PASSWORD" -T /usr/bin/codesign
rm "$RUNNER_TEMP/certificate.p12"
security set-key-partition-list -S apple-tool:,apple: -s -k "$password" "$keychain"
security list-keychains -d user -s "$keychain" login.keychain
xcrun notarytool store-credentials notary --keychain "$keychain" \
--apple-id "$APPLE_ID" --team-id "$APPLE_TEAM_ID" --password "$APPLE_APP_PASSWORD"
echo "NOTARY_KEYCHAIN=$keychain" >> "$GITHUB_ENV"The keychain lives as long as the job, and codesign finds the certificate
in it. The configuration signs with the certificate and notarizes with
the profile, from that keychain in the workflow and from the login
keychain on your Mac, where NOTARY_KEYCHAIN is not set:
export default defineConfig({
macos: {
signingIdentity: "Developer ID Application: Jane Doe (TEAMID)",
notarize: { keychainProfile: "notary", keychain: process.env.NOTARY_KEYCHAIN },
},
});mygo build then signs the app, submits the disk image to the notary
service and waits for it, usually a few minutes, and staples the ticket.
An App Store Connect API key works instead of the Apple ID: give
notarytool store-credentials the key's file, ID and issuer with --key,
--key-id and --issuer.
Signing Windows apps
On the Ubuntu runner, osslsigncode signs the Windows apps and their
installers with a .pfx certificate. Add it and its password to the
secrets:
base64 -i code-signing.pfx | gh secret set WINDOWS_CERTIFICATE
gh secret set WINDOWS_CERTIFICATE_PASSWORDFor the Windows row, install osslsigncode with NSIS, write the
certificate to a file, and give its path and password to the build:
- name: Install NSIS and osslsigncode
if: matrix.name == 'Windows' && runner.os == 'Linux'
run: sudo apt-get update && sudo apt-get install -y nsis osslsigncode
- name: Write the certificate
if: matrix.name == 'Windows'
env:
WINDOWS_CERTIFICATE: ${{ secrets.WINDOWS_CERTIFICATE }}
run: |
echo "$WINDOWS_CERTIFICATE" | base64 --decode > "$RUNNER_TEMP/code-signing.pfx"
echo "WINDOWS_CERTIFICATE_FILE=$RUNNER_TEMP/code-signing.pfx" >> "$GITHUB_ENV"
- name: Build and upload
env:
GH_TOKEN: ${{ github.token }}
MYGO_UPDATER_PRIVATE_KEY: ${{ secrets.MYGO_UPDATER_PRIVATE_KEY }}
MYGO_WINDOWS_CERTIFICATE_PASSWORD: ${{ secrets.WINDOWS_CERTIFICATE_PASSWORD }}
run: bun run build -- -platform "${{ matrix.platform }}" -uploadexport default defineConfig({
windows: { certificate: process.env.WINDOWS_CERTIFICATE_FILE },
});The first step replaces the one that installs NSIS. Builds on your machine, without the variable, are not signed.
Signing on Windows
Certificates whose keys stay in a hardware token or a cloud service, such
as Azure Trusted Signing, sign with signCommand (see
code signing), which often needs
signtool. Build the Windows apps on a Windows runner then, which has
signtool, and where mygo build downloads NSIS itself; the step that
installs NSIS is for Linux runners only:
- { name: Windows, os: windows-latest, platform: "windows/amd64,windows/arm64" }Sign in to the signing service in a step for the Windows row, before
"Build and upload", as its documentation says. Steps run in PowerShell on
Windows runners unless they set shell: bash.
mygo build downloads NSIS on every run of a fresh runner. A step before
"Build and upload" keeps it in the Actions cache instead, so builds need
neither the download nor the hosts it comes from. The lockfile pins
mygo-cli, which pins NSIS:
- name: Cache NSIS
if: runner.os == 'Windows'
uses: actions/cache@v6
with:
path: ~\AppData\Local\mygo\nsis-*
key: nsis-${{ hashFiles('bun.lock') }}In a project of native UI, go.mod pins the CLI: key the cache on
hashFiles('go.sum').
Checking the version
A tag that does not match the configuration's version would publish the build under another version. Keep the version in package.json, which the configuration reads (see computed configuration):
import pkg from "./package.json" with { type: "json" };
export default defineConfig({
version: pkg.version,
});and check it in the draft release job, before it creates the draft, so that a wrong tag builds nothing:
- uses: actions/checkout@v7
- name: Check the version
run: test "$GITHUB_REF_NAME" = "v$(jq -r .version package.json)"A project of native UI has the version in mygo.json: check
jq -r .version mygo.json instead.
Publishing the release
Publish the draft yourself after looking at it, or let the workflow publish it once every job uploaded:
publish:
needs: build
runs-on: ubuntu-latest
steps:
- env:
GH_TOKEN: ${{ github.token }}
run: gh release edit "$GITHUB_REF_NAME" --repo "$GITHUB_REPOSITORY" --draft=falseWith a tagPrefix such as desktop-v, trigger the workflow on its tags
(tags: ["desktop-v*"]), check them against desktop-v and the version,
and create and publish the release with --latest=false, so the
repository's latest release stays the other releases' (see
build and publish).
Publishing to S3
With updates.s3, mygo build -upload uploads to the bucket instead (see
publishing to S3). Give the jobs the
bucket's credentials in place of GH_TOKEN:
env:
AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }}
AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
MYGO_UPDATER_PRIVATE_KEY: ${{ secrets.MYGO_UPDATER_PRIVATE_KEY }}There is no draft release: leave out the draft release job, and the
needs of the build job. The update of each platform is out as soon as
its job uploaded its manifest, and the workflow needs no contents: write
permission.
Without a release
Without updates, -upload has nowhere to upload to. Build without it and
keep the installers as artifacts of the run, which works on every push too,
to check that the apps build:
- run: bun run build -- -platform "${{ matrix.platform }}"
- uses: actions/upload-artifact@v7
with:
name: ${{ matrix.name }}
path: |
build/*/*.dmg
build/*/*Setup*.exe
build/*/*.deb
build/*/*.tar.gzbuild is the out of new projects' configuration.