# Build in CI with GitHub Actions

> Build a Pyzed app for Windows, macOS and Linux on x64 and ARM on every release with GitHub Actions: the workflow pyzed build --workflow writes, publishing a GitHub Release, and signing with secrets.

Source: https://albakore.github.io/pyzed/packaging/ci/

Since each system builds its own app, the easiest way to ship them all is to let GitHub build them on its own
Windows, macOS and Linux machines ("runners"), on x64 and ARM, every time you publish a version.

## Write the workflow

**uv:**

```bash
uv run pyzed build --workflow
```

**pip:**

```bash title="macOS and Linux"
pyzed build --workflow          # or: python -m pyzed build --workflow
```

```powershell title="Windows (PowerShell)"
pyzed build --workflow          # or: py -m pyzed build --workflow
```

It writes `.github/workflows/build.yml`, with one job per target listed in `targets` of `[tool.pyzed.build]` (by
default Windows x64, macOS on Apple silicon and Linux x64). In Pyzed Design, check the platforms in
**File → Export app… → Platforms** and press **Write CI workflow** ([Exporting your app](https://albakore.github.io/pyzed/editor/exporting/#platforms-and-architectures)).

```yaml title=".github/workflows/build.yml"
name: Build

# Builds the app for each target with `pyzed build` and keeps the result as an
# artifact (Actions -> this run -> Artifacts). The backends cannot cross-compile, so
# every platform and architecture builds on its own runner.
on:
  push:
    tags: ["v*"]
  workflow_dispatch:

jobs:
  build:
    name: Build ${{ matrix.target }}
    runs-on: ${{ matrix.runner }}
    strategy:
      fail-fast: false
      matrix:
        include:
          - { target: windows-x64, runner: windows-latest }
          - { target: macos-arm64, runner: macos-latest }
          - { target: linux-x64, runner: ubuntu-22.04 }
    steps:
      - uses: actions/checkout@v4
      - uses: astral-sh/setup-uv@v6
      - run: uv sync --locked
      - run: uv run pyzed build --target ${{ matrix.target }}
      - uses: actions/upload-artifact@v4
        with:
          name: shop-${{ matrix.target }}
          path: dist/
```

`--target` makes each job check that its machine builds what the matrix says. The backend, the icon, the splash
screen and every other setting come from `pyproject.toml`, the same as on your computer.

## Run it

1. Commit the workflow and your `uv.lock` (the build uses `uv sync --locked`, so the lock file must be up to date).

2. Push a version tag:

```bash
git tag v1.0.0
git push origin v1.0.0
```

   Or start it by hand: **Actions → Build → Run workflow**.

3. When it finishes, download the builds from the run's page, under **Artifacts**.

## Which machines

| Target | Runner | Builds for |
| --- | --- | --- |
| `windows-x64` | `windows-latest` | Windows 10 and 11 on Intel and AMD |
| `windows-arm64` | `windows-11-arm` | Windows 11 on ARM |
| `macos-arm64` | `macos-latest` | Macs with Apple silicon |
| `macos-x64` | `macos-15-intel` | Intel Macs |
| `linux-x64` | `ubuntu-22.04` | Linux on Intel and AMD |
| `linux-arm64` | `ubuntu-22.04-arm` | Linux on ARM |

The Linux runners are the oldest Ubuntu GitHub offers on purpose: the app runs on the runner's glibc or newer
([Linux](https://albakore.github.io/pyzed/packaging/linux/#which-distribution-to-build-on)). With `backend = "nuitka"` every runner already has the
C compiler Nuitka needs.

## Publish a GitHub Release

Artifacts expire after a while and need a GitHub login to download. To give people a permanent download page, set
`archive` before writing the workflow:

```toml title="pyproject.toml"
[tool.pyzed.build]
archive = "zip"
```

Each build then packs the app as `<app>-<system>-<arch>.zip` (`shop-windows-x86_64.zip`, `shop-macos-arm64.zip`,
`shop-linux-x86_64.zip`), the names the [self-updater](https://albakore.github.io/pyzed/packaging/updates/) looks for, and the workflow gets a
last step that attaches it to the tag's release (and the permission to create it):

```yaml title=".github/workflows/build.yml (what archive adds)"
jobs:
  build:
    permissions:
      contents: write
    steps:
      # ... the build, then:
      - name: Attach to the GitHub Release
        if: startsWith(github.ref, 'refs/tags/v')
        shell: bash
        env:
          GH_TOKEN: ${{ github.token }}
        run: |
          # the first build to get here creates the release; the others only upload
          gh release create "$GITHUB_REF_NAME" --title "$GITHUB_REF_NAME" --verify-tag || true
          gh release upload "$GITHUB_REF_NAME" dist/*.zip --clobber
```

On macOS the zip is made with `ditto`, which keeps the bundle's symlinks and signature. If you
[sign](#signing-in-ci) the app, sign it before it is packed: set `archive` only after adding the signing step, or zip
after signing yourself.

## Signing in CI

Keep certificates in **GitHub secrets** (Settings → Secrets and variables → Actions), never in the repository. Then
decode them in a step before signing. This is what Pyzed's own release workflow does for Pyzed Design:

**Windows:**

Store the `.pfx` file as base64 in `WINDOWS_CERTIFICATE` and its password in `WINDOWS_CERTIFICATE_PASSWORD`:

```yaml
      - name: Sign
        if: runner.os == 'Windows'
        shell: bash
        env:
          CERT: ${{ secrets.WINDOWS_CERTIFICATE }}
          PASSWORD: ${{ secrets.WINDOWS_CERTIFICATE_PASSWORD }}
        run: |
          echo "$CERT" | base64 --decode > "$RUNNER_TEMP/cert.pfx"
          signtool=$(ls "/c/Program Files (x86)/Windows Kits/10/bin/"*/x64/signtool.exe | tail -1)
          "$signtool" sign /f "$RUNNER_TEMP/cert.pfx" /p "$PASSWORD" /fd sha256 \
            /tr http://timestamp.digicert.com /td sha256 dist/Shop/Shop.exe
```

**macOS:**

Store the Developer ID certificate (`.p12`) as base64 in `MACOS_CERTIFICATE` with its password, and your Apple ID
details for notarization:

```yaml
      - name: Sign and notarize
        if: runner.os == 'macOS'
        env:
          CERT: ${{ secrets.MACOS_CERTIFICATE }}
          CERT_PASSWORD: ${{ secrets.MACOS_CERTIFICATE_PASSWORD }}
          IDENTITY: ${{ secrets.MACOS_CODESIGN_IDENTITY }}
          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"
          echo "$CERT" | base64 --decode > "$RUNNER_TEMP/cert.p12"
          security create-keychain -p "" "$keychain"
          security unlock-keychain -p "" "$keychain"
          security import "$RUNNER_TEMP/cert.p12" -P "$CERT_PASSWORD" -A -t cert -f pkcs12 -k "$keychain"
          security set-key-partition-list -S apple-tool:,apple: -k "" "$keychain" >/dev/null
          security list-keychains -d user -s "$keychain"
          codesign --force --deep --options runtime --timestamp --sign "$IDENTITY" dist/Shop.app
          ditto -c -k --keepParent dist/Shop.app notarize.zip
          xcrun notarytool submit notarize.zip --wait \
            --apple-id "$APPLE_ID" --team-id "$APPLE_TEAM_ID" --password "$APPLE_APP_PASSWORD"
          xcrun stapler staple dist/Shop.app
```

Put the signing step **after** `uv run pyzed build` and **before** zipping. See
[Windows](https://albakore.github.io/pyzed/packaging/windows/#sign-it) and [macOS](https://albakore.github.io/pyzed/packaging/macos/#sign-and-notarize) for what each
command does.

**How Pyzed Design is released:**

The workflow that builds Pyzed Design itself,
[`.github/workflows/release.yml`](https://github.com/albakore/pyzed/blob/main/.github/workflows/release.yml), is a
complete real-world example: five platforms, optional signing and notarization, an Inno Setup installer on Windows, a
`.dmg` on macOS, `SHA256SUMS`, build provenance attestations and a GitHub Release with notes.
