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

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 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, 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 workflow is invoked with sign: true (.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.

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.

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

This step, at lines 37–41 of 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.

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.

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 and drives the in-app auto-updater.

- 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) 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.

- 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 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".

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, only the DMG files and signatures are guaranteed to appear; other artifact types are attached conditionally based on the build output.

- 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 Orchestrates the entire release, including minisign signing and Cloudflare R2 uploads.
xtask/src/main.rs (xtask crate) Implements the release latest-json subcommand that builds the latest.json manifest.
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 updater manifest with artifact metadata.
  • The AWS CLI uploads artifacts to Cloudflare R2 under a tag-based release prefix, then publishes 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, 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 is stored separately under channels/stable/.

Why is 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.

Have a question about this repo?

These articles cover the highlights, but your codebase questions are specific. Give your agent direct access to the source. Share this with your agent to get started:

Share the following with your agent to get started:
curl -s "https://instagit.com/install.md"

Works with
Claude Codex Cursor VS Code OpenClaw Any MCP Client

Maintain an open-source project? Get it listed too →