# How the OpenLogi Release Workflow Signs DMGs with minisign and Publishes Artifacts to Cloudflare R2

> Learn how the OpenLogi release workflow signs DMGs with minisign and publishes artifacts to Cloudflare R2. Discover the steps for secure and efficient artifact distribution.

- Repository: [Xuan Zhang/OpenLogi](https://github.com/AprilNEA/OpenLogi)
- Tags: how-to-guide
- Published: 2026-08-23

---

**TLDR:** The OpenLogi release pipeline builds and notarizes macOS DMGs, signs every distribution artifact with minisign using a temporary key file decoded from 1Password, generates a [`latest.json`](https://github.com/AprilNEA/OpenLogi/blob/main/latest.json) updater manifest via the `xtask` crate, then uploads all artifacts to Cloudflare R2 using the AWS CLI with an R2-compatible endpoint.

The open-source OpenLogi project (AprilNEA/OpenLogi) uses a fully automated GitHub Actions release workflow to ship signed installers. According to the source code in [`.github/workflows/release.yml`](https://github.com/AprilNEA/OpenLogi/blob/main/.github/workflows/release.yml), the entire release is orchestrated in a `publish` job that combines cryptographic signing with cloud storage. This article walks through how the workflow signs DMGs with **minisign** and publishes artifacts to **Cloudflare R2** — end to end.

## Workflow Overview: From Build to Cloud Storage

The release pipeline is split into two primary jobs. First, a reusable [`build.yml`](https://github.com/AprilNEA/OpenLogi/blob/main/build.yml) workflow is invoked with `sign: true` ([`.github/workflows/release.yml`](https://github.com/AprilNEA/OpenLogi/blob/main/.github/workflows/release.yml), lines 24–36), which triggers `codesign` and Apple notarization on the macOS build leg and produces the final `.dmg` files. Then, the `publish` job takes over on a fresh Ubuntu runner, where all minisign signing and R2 uploads occur.

```yaml
jobs:
  build:
    uses: ./.github/workflows/build.yml
    with:
      sign: true

```

This build-sign separation ensures artifacts are produced in an isolated environment before moving to the signing and publication stage.

## Installing minisign on the Ubuntu Runner

Before any signing happens, the workflow installs the `minisign` tool directly on the Ubuntu runner via `apt`. This is a lightweight, dependency-free binary designed specifically for signing files with Ed25519 keys.

```yaml
- name: Install minisign
  run: |
    sudo apt-get update
    sudo apt-get install -y minisign

```

This step, at lines 37–41 of [`release.yml`](https://github.com/AprilNEA/OpenLogi/blob/main/release.yml), sets up the signing environment. Because minisign is available in Ubuntu's default package repository, no manual binary download is necessary.

## Decoding the minisign Secret Key from 1Password

The minisign private key is not stored directly in GitHub Actions secrets. Instead, it lives in **1Password**, base64-encoded. The workflow decodes it into a temporary file with strict permissions, ensuring the key never persists on the runner.

```yaml
key_file="${RUNNER_TEMP}/openlogi-minisign.key"
umask 077
trap 'rm -f "$key_file"' EXIT
printf '%s' "$OPENLOGI_UPDATE_MINISIGN_SECRET_KEY" | tr -d '[:space:]' | base64 -d > "$key_file"

```

The key details here are critical:

- **umask 077** ensures only the current user can read or write the key file.
- A **trap on EXIT** guarantees the temporary key file is removed even if the job fails mid-signing.
- The `tr -d '[:space:]'` strips any newlines or whitespace from the environment variable before base64 decoding.

The secret value itself comes from a local GitHub Action (`.github/actions/github-app-token-from-1password`) that fetches the credentials needed for both minisign and Cloudflare R2 authentication.

## Signing Every Artifact with minisign

The signing loop iterates over all distribution packages — DMGs, ZIPs, MSIs, DEBs, RPMs, and Pacman archives — and generates a detached Ed25519 signature (`.minisig`) for each. Every signature is immediately verified against the public key, so a corrupted or failed signature fails the build right away.

```yaml
shopt -s nullglob
for artifact in dist/*.dmg dist/*.zip dist/*.msi dist/*.deb dist/*.rpm dist/*.pkg.tar.zst; do
  minisign -S -m "$artifact" -s "$key_file" -x "$artifact.minisig" -W
  minisign -V -m "$artifact" -P "$OPENLOGI_UPDATE_MINISIGN_PUBLIC_KEY" -x "$artifact.minisig"
done

```

Let's break down each flag:

- `-S` tells minisign to sign a file.
- `-m` specifies the input artifact to sign.
- `-s` points to the temporary secret key file.
- `-x` defines the output signature file (placed next to the artifact as `*.minisig`).
- `-W` disables the interactive password prompt, which is essential in a CI environment.
- The `-V` invocation in the very same line immediately verifies the created signature against your public key, catching any sign errors instantly.

The **`shopt -s nullglob`** setting ensures the loop doesn't error when a pattern matches no files (for example, no Windows installers on a non-Windows build).

## Generating the Updater Manifest with xtask

Once everything is signed, the workflow invokes the project's custom build tool, `xtask`, to generate a machine-readable manifest describing all released artifacts. This manifest is [`latest.json`](https://github.com/AprilNEA/OpenLogi/blob/main/latest.json) and drives the in-app auto-updater.

```yaml
- name: Generate latest.json
  run: |
    cargo run -p xtask -- release latest-json \
      --dist dist \
      --tag "$GITHUB_REF_NAME" \
      --base-url "$OPENLOGI_UPDATE_BASE_URL" \
      --include-windows \
      --output dist/latest.json

```

The `xtask` crate (found in [`xtask/src/main.rs`](https://github.com/AprilNEA/OpenLogi/blob/main/xtask/src/main.rs)) implements the `release latest-json` subcommand. It scans the `dist/` directory, extracts version and checksum metadata, and — because `--include-windows` is passed — includes the Windows installer package (from `packaging/windows/OpenLogi.wxs`) as well, when it exists.

## Uploading Artifacts to Cloudflare R2 with the AWS CLI

Cloudflare R2 is S3-compatible, so the workflow uses the standard `aws` CLI to upload files. The command copies everything under `dist/` into a version-prefixed path inside the R2 bucket, with sensible cache headers.

```yaml
- name: Upload static updater assets to R2
  env:
    AWS_ACCESS_KEY_ID: ${{ steps.r2_config.outputs.R2_ACCESS_KEY_ID }}
    AWS_SECRET_ACCESS_KEY: ${{ steps.r2_config.outputs.R2_SECRET_ACCESS_KEY }}
    R2_ACCOUNT_ID: ${{ steps.r2_config.outputs.R2_ACCOUNT_ID }}
    R2_BUCKET: ${{ steps.r2_config.outputs.R2_BUCKET }}
  run: |
    endpoint="https://${R2_ACCOUNT_ID}.r2.cloudflarestorage.com"
    release_prefix="s3://${R2_BUCKET}/releases/${GITHUB_REF_NAME}"
    aws s3 cp dist/ "$release_prefix/" \
      --recursive \
      --exclude latest.json \
      --exclude "*.exe" \
      --exclude "*.exe.minisig" \
      --cache-control "public, max-age=31536000, immutable" \
      --endpoint-url "$endpoint"

```

Key design points in this upload step:

- The **endpoint URL** is constructed from the R2 account ID, following the standard `https://{account_id}.r2.cloudflarestorage.com` pattern.
- The **cache-control header** sets `max-age=31536000` (one year) with `immutable`, since release artifacts are never overwritten once published.
- The `--exclude latest.json` flag prevents the manifest from being uploaded in the same recursibe pass, because it must be handled separately with different caching semantics.
- Raw `.exe` files and their signatures are excluded, since Windows users download the MSI installer instead of the raw binary.

## Publishing the Updater Manifest to the Stable Channel

The final storage step is uploading the updated [`latest.json`](https://github.com/AprilNEA/OpenLogi/blob/main/latest.json) to a channel-specific manifest location. Unlike the hashed release artifacts, the manifest must be cacheable but always fresh, which is why it uses `--cache-control "no-cache"`.

```yaml
aws s3 cp dist/latest.json "s3://${R2_BUCKET}/channels/stable/latest.json" \
  --content-type "application/json" \
  --cache-control "no-cache" \
  --endpoint-url "$endpoint"

```

This puts the manifest on the stable channel, and any OpenLogi client querying this URL retrieves the newest release information immediately.

## Creating the GitHub Release with Signed Artifacts

The workflow wraps up by attaching the signed DMGs, their `.minisig` signatures, and a SHA-256 checksum file to the GitHub release using `softprops/action-gh-release`. As mentioned in lines 121–133 of [`release.yml`](https://github.com/AprilNEA/OpenLogi/blob/main/release.yml), only the DMG files and signatures are guaranteed to appear; other artifact types are attached conditionally based on the build output.

```yaml
- name: Publish release
  uses: softprops/action-gh-release@v1
  with:
    files: |
      dist/*.dmg
      dist/*.dmg.minisig
      dist/*.zip
      dist/*.msi
      dist/*.sha256

```

This ensures the artifacts are both publicly accessible through the OpenLogi update system (via R2) and visible inside the GitHub UI.

## Key Source Files Behind the Workflow

| File | Role |
|------|------|
| [`.github/workflows/release.yml`](https://github.com/AprilNEA/OpenLogi/blob/main/.github/workflows/release.yml) | Orchestrates the entire release, including minisign signing and Cloudflare R2 uploads. |
| [`xtask/src/main.rs`](https://github.com/AprilNEA/OpenLogi/blob/main/xtask/src/main.rs) (xtask crate) | Implements the `release latest-json` subcommand that builds the [`latest.json`](https://github.com/AprilNEA/OpenLogi/blob/main/latest.json) manifest. |
| [`xtask/README.md`](https://github.com/AprilNEA/OpenLogi/blob/main/xtask/README.md) | Documents the `xtask` CLI used for release-time tasks. |
| `.github/actions/github-app-token-from-1password` | Retrieves the 1Password secrets needed for minisign and R2 authentication. |
| `packaging/windows/OpenLogi.wxs` | Defines the Windows installer that is also signed and uploaded for completeness. |

## Summary Points

- The release workflow invokes a reusable build with `sign: true` to produce codesigned and notarized DMGs.
- minisign is installed on an Ubuntu runner, and the secret key is decoded from 1Password into a temp file with `umask 077`.
- Every artifact (DMG, ZIP, MSI, DEB, RPM, Pacman) gets a detached `.minisig` file that is immediately verified.
- The `xtask` CLI generates the [`latest.json`](https://github.com/AprilNEA/OpenLogi/blob/main/latest.json) updater manifest with artifact metadata.
- The AWS CLI uploads artifacts to Cloudflare R2 under a tag-based release prefix, then publishes [`latest.json`](https://github.com/AprilNEA/OpenLogi/blob/main/latest.json) to the stable channel with `no-cache`.
- GitHub Release attachments provide a secondary artifact distribution channel.

## Frequently Asked Questions

### Why does the workflow use minisign instead of GPG for artifact signature?

minisign is a lightweight, auditable implementation of the Ed25519 signature scheme with a very small footprint, making it ideal for CI sandboxes that need fast, scriptable signing. It avoids the complexity of GPG's key management and interactive password prompts, and its `-W` flag disables those prompts entirely for automation scenarios.

### How does the workflow keep the signing key secure on a self-hosted runner?

The signing secret key is stored base64-encoded in 1Password, never directly in GitHub Secrets. The workflow decodes it into a temporary file guarded with `umask 077`, signs all artifacts, and uses a `trap ... EXIT` handler to delete that file immediately even if the job crashes mid-step, leaving no trace on the runner.

### Can I see only release artifacts without the manifest from the R2 bucket?

Yes. The recursive upload command excludes [`latest.json`](https://github.com/AprilNEA/OpenLogi/blob/main/latest.json), raw `.exe` installers, and their minisig files. You will still see all other artifacts (DMGs, DEBs, RPMs, zip files, and their `.minisig` signatures) under the `releases/<tag>/` path, while [`latest.json`](https://github.com/AprilNEA/OpenLogi/blob/main/latest.json) is stored separately under `channels/stable/`.

### Why is [`latest.json`](https://github.com/AprilNEA/OpenLogi/blob/main/latest.json) uploaded separately from the other artifacts?

The manifest must always be fresh for the auto-updater to pick up the new release. If it were uploaded alongside the artifacts with a one-year immutable cache header, CDN edge caches could serve stale metadata for days. The separate upload uses `--cache-control "no-cache"` and `--content-type "application/json"` to force clients to revalidate.