Skip to content

Release

Release #58

Workflow file for this run

name: Release
on:
push:
tags:
- 'v*'
# Manual fallback for when the tag was created by release-please
# using the default GITHUB_TOKEN -- GitHub deliberately does not
# fire workflows on refs authored by GITHUB_TOKEN (loop guard), so
# the push:tags trigger above never runs for those tags. From the
# command line: `task release-fire TAG=vX.Y.Z` (wraps the gh CLI),
# or from the UI: Release -> Run workflow -> pick the tag in the
# "Use workflow from" dropdown. Every job in this file already
# keys off github.ref_name, which is the tag name for both
# triggers. No inputs declared, so nothing untrusted enters the
# workflow context. See RELEASES.md "When release.yml does not
# auto-fire" for the long-term fix.
workflow_dispatch:
permissions: {} # deny all by default; each job declares only what it needs
# Improvement #4 (audit): serialise concurrent runs against the same tag.
# Two ways the same tag could fire this workflow concurrently: (a) push
# trigger races with workflow_dispatch when a maintainer fires it manually
# after the auto-trigger; (b) re-runs from the UI. cancel-in-progress is
# false because a release run is non-idempotent — softprops creates the
# release once, then races with itself if cancelled mid-upload.
concurrency:
group: release-${{ github.ref_name }}
cancel-in-progress: false
jobs:
# ── Job 1: Cross-platform builds, matrix-parallel ──
# Improvement #1: each of the four Docker-driven targets that used to
# run sequentially inside one `build-docker` job now runs on its own
# runner. Wall-clock drops from ~5m40s (sum) to ~max(individual_build).
#
# Improvement #2: every matrix entry reads/writes the same GHA cache
# scope (`release-build`), so the shared `go-base` / `server-base`
# Dockerfile stages (with their cached `go mod download` and apt
# installs) are materialised once across release runs. The
# `publish-container` job below uses the same scope, so its container-
# image build inherits the layer cache from the binary builds.
build:
strategy:
# Don't cancel the other four targets if one fails — easier to
# diagnose a per-target regression with everything else still in
# the artifact set.
fail-fast: false
matrix:
include:
- target: wails-linux
dockerfile-target: linux-export
output-dir: dist/linux
package-script: package-wails-linux.sh
artifact-glob: |
recall-*-linux-amd64.tar.gz
recall-*-linux-amd64.deb
- target: server-linux
dockerfile-target: server-linux-export
output-dir: dist/server-linux
package-script: package-server-linux.sh
artifact-glob: |
recall-server-*-linux-amd64.tar.gz
recall-server-*-linux-amd64.deb
- target: server-windows
dockerfile-target: server-windows-export
output-dir: dist/server-windows
package-script: package-server-windows.sh
artifact-glob: |
recall-server-*-windows-amd64.exe
- target: server-mac
dockerfile-target: server-mac-export
output-dir: dist/server-mac
package-script: package-server-mac.sh
artifact-glob: |
recall-server-*-darwin-arm64.tar.gz
runs-on: ubuntu-latest
permissions:
contents: read
id-token: write # needed by Sigstore to mint a short-lived cert
attestations: write # needed to write SLSA provenance to the repo
env:
DOCKER_BUILDKIT: "1"
steps:
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
- uses: ./.github/actions/docker-build-env
# docker/build-push-action handles `--cache-from` / `--cache-to`
# natively (the Taskfile's `docker build` invocation does not),
# which is why this step bypasses `task ${{ matrix.target }}`
# and calls the Dockerfile target directly. Same outputs, same
# build-args — local `task build-*` remains the un-changed
# canonical local-build entry point.
- name: Build ${{ matrix.target }}
uses: docker/build-push-action@f9f3042f7e2789586610d6e8b85c8f03e5195baf # v7
with:
context: .
file: Dockerfile.build
target: ${{ matrix.dockerfile-target }}
outputs: type=local,dest=${{ matrix.output-dir }}
build-args: |
VERSION=${{ github.ref_name }}
cache-from: type=gha,scope=release-build
cache-to: type=gha,mode=max,scope=release-build
- name: Package ${{ matrix.target }}
env:
VERSION: ${{ github.ref_name }}
run: bash scripts/release/${{ matrix.package-script }}
# SLSA provenance per matrix entry. Each entry's artifact set is
# bounded by its package script's outputs; reusing one glob across
# all entries would over-claim (e.g. wails-linux claiming the
# server-mac tarball). Subject-paths below mirror each script's
# documented outputs.
- name: Attest build provenance
uses: actions/attest-build-provenance@0f67c3f4856b2e3261c31976d6725780e5e4c373 # v4
with:
subject-path: |
recall-*-linux-amd64.tar.gz
recall-*-linux-amd64.deb
recall-server-*-linux-amd64.tar.gz
recall-server-*-linux-amd64.deb
recall-server-*-windows-amd64.exe
recall-server-*-darwin-arm64.tar.gz
- name: Upload ${{ matrix.target }} artifacts
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
with:
name: build-${{ matrix.target }}
path: |
recall-*-linux-amd64.tar.gz
recall-*-linux-amd64.deb
recall-server-*-linux-amd64.tar.gz
recall-server-*-linux-amd64.deb
recall-server-*-windows-amd64.exe
recall-server-*-darwin-arm64.tar.gz
if-no-files-found: ignore
# ── Job 2: macOS Wails .app bundles (requires Apple SDK / Xcode CLT) ──
# Stays a single job (not in the matrix above) because it MUST run on
# macos-latest — the Apple SDK isn't redistributable, so cross-build
# from Linux is impossible.
build-mac:
runs-on: macos-latest
permissions:
contents: read
id-token: write
attestations: write
steps:
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
- uses: ./.github/actions/wails-build-env
- name: Build macOS Wails app
run: task build-mac
# DMG staging + hdiutil call lives in scripts/release/make-dmg.sh.
# The in-DMG README.txt is sourced from docs/dmg/README.txt
# (single-source-of-truth instead of the previous heredoc paired
# with docs/install-macos.md sections 2-3).
- name: Create DMG
env:
VERSION: ${{ github.ref_name }}
run: bash scripts/release/make-dmg.sh
- name: Attest build provenance
uses: actions/attest-build-provenance@0f67c3f4856b2e3261c31976d6725780e5e4c373 # v4
with:
subject-path: recall-*-darwin-arm64.dmg
- name: Upload macOS artifacts
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
with:
name: build-mac
path: |
recall-*-darwin-arm64.dmg
# ── Job 3: Windows app + NSIS installer (native cross-compile, no Docker) ──
# v3's WebView2 loader is pure Go, so this cross-compiles CGO_ENABLED=0 from
# Linux — no mingw, no Docker. wails3 generate syso embeds the icon/version;
# create:nsis:installer fetches the WebView2 bootstrapper + runs makensis.
build-windows:
runs-on: ubuntu-latest
permissions:
contents: read
id-token: write
attestations: write
steps:
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
# Building the wails3 CLI itself (mise/go install) pulls in v3's CGo GTK4
# webview, so the Ubuntu runner needs the WebKit dev libs — even though the
# Windows target is CGO_ENABLED=0.
- uses: ./.github/actions/linux-webview-deps
- uses: ./.github/actions/wails-build-env
- name: Install makensis
run: |
sudo apt-get update
sudo apt-get install -y --no-install-recommends nsis
- name: Build Windows app + NSIS installer
run: task build-windows
- name: Package
env:
VERSION: ${{ github.ref_name }}
run: bash scripts/release/package-wails-windows.sh
- name: Attest build provenance
uses: actions/attest-build-provenance@0f67c3f4856b2e3261c31976d6725780e5e4c373 # v4
with:
subject-path: recall-*-windows-amd64-installer.exe
- name: Upload Windows artifacts
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
with:
name: build-wails-windows
path: |
recall-*-windows-amd64-installer.exe
# ── Job 4: Generate SBOM from BUILT ARTIFACTS ────────────────────────
# Improvement #3 (audit): SBOM now describes the shipped bits, not
# just the source tree. Downloads every build artifact, untars the
# tarballs so syft can scan the actual Go binaries (catches build-info
# entries the source-tree scan would miss — indirect deps that vendor
# brought in, plus any compile-time-resolved version pins). The repo
# checkout is still in the scan path so syft's go.mod / package.json
# scanners run too — the resulting SBOM is the union of both passes.
sbom:
needs: [build, build-mac]
runs-on: ubuntu-latest
permissions:
contents: read
steps:
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
- uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8
with:
path: artifacts
merge-multiple: true
- name: Extract tarballs (so syft scans their binaries)
run: |
set -euo pipefail
mkdir -p artifacts-extracted
for tar in artifacts/*.tar.gz; do
[ -e "$tar" ] || continue
tar -xzf "$tar" -C artifacts-extracted/
done
- name: Compute artifact version
id: ver
run: echo "version=${GITHUB_REF_NAME#v}" >> "$GITHUB_OUTPUT"
- name: Generate SBOM (SPDX JSON)
uses: anchore/sbom-action@e22c389904149dbc22b58101806040fa8d37a610 # v0
with:
# Scans repo source AND the extracted built binaries that
# were downloaded into the workspace.
path: .
format: spdx-json
output-file: recall-${{ steps.ver.outputs.version }}-sbom.spdx.json
upload-artifact: false
upload-release-assets: false
- name: Upload SBOM artifact
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
with:
name: sbom
path: recall-${{ steps.ver.outputs.version }}-sbom.spdx.json
# ── Job 5: Create GitHub Release with all binaries attached ──
# github.ref_name is used only in an `actions/` `with:` expression, not
# interpolated into any run: shell command — safe from injection.
release:
needs: [build, build-mac, build-windows, sbom]
runs-on: ubuntu-latest
permissions:
contents: write
id-token: write # needed by Sigstore to mint a short-lived cert
attestations: write # needed to write SLSA provenance for sha256 files
steps:
# Checkout is required even though this job only consumes artifacts
# from earlier jobs — the "Generate SHA256 checksums" step shells
# out to scripts/release/compute-sha256.sh, which lives in the
# source tree. Without checkout the script is absent from the
# runner filesystem and the step dies with exit 127.
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
# Download all artifacts into the current directory (merge-multiple flattens them).
- uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8
with:
merge-multiple: true
# Stage the parser's reference data (heroes.yaml + maps.yaml) as
# release-versioned assets. The frontend's "verify reference data"
# affordance (next feature) downloads these to compare the local
# parser corpus against the canonical version. Renaming with the
# tag in the filename keeps each release's data self-describing
# and prevents an old asset from being mistaken for a newer one
# if a verifier downloads from the wrong release page.
- name: Compute artifact version
id: ver
run: echo "version=${GITHUB_REF_NAME#v}" >> "$GITHUB_OUTPUT"
- name: Stage reference data YAMLs
env:
# Route the tag through an env var rather than interpolating
# ${{ … }} directly into the run: shell — project convention
# (see .claude/rules/ci-cd.md "Fixing CI on a remote-authored
# PR" / build-tooling notes). VERSION is bounded by git's
# ref-naming rules + the v* push trigger, so there's no
# injection vector today, but the env-var shape keeps the
# workflow safe against a future change to the trigger.
VERSION: ${{ steps.ver.outputs.version }}
run: |
cp pkg/parser/heroes.yaml "recall-${VERSION}-heroes.yaml"
cp pkg/parser/maps.yaml "recall-${VERSION}-maps.yaml"
cp pkg/parser/screenshot_sources.yaml "recall-${VERSION}-screenshot_sources.yaml"
# The Windows DB-reset helper (also bundled in the installer). A
# checked-in source file copied straight into the workspace, like
# the reference YAMLs — so it's signed + attested the same way.
- name: Stage Windows reset-DB helper script
env:
VERSION: ${{ steps.ver.outputs.version }}
run: |
cp scripts/windows/Reset-Database.bat "recall-${VERSION}-Reset-Database.bat"
# SLSA build provenance on the reference-data YAMLs. The binary
# build jobs attested their own outputs in-job; the YAMLs aren't
# built by any earlier job (they're checked-in source copied
# straight into the workspace), so the attestation has to fire
# here in the release job. Verify with:
# gh attestation verify recall-X.Y.Z-heroes.yaml \
# --repo sound-barrier/recall
- name: Attest reference data provenance
uses: actions/attest-build-provenance@0f67c3f4856b2e3261c31976d6725780e5e4c373 # v4
with:
subject-path: |
recall-*-heroes.yaml
recall-*-maps.yaml
recall-*-screenshot_sources.yaml
recall-*-Reset-Database.bat
# Per-artifact .sha256 files for every release binary, package,
# and reference-data YAML. Each file contains the SHA256 hash of
# exactly one artifact.
# Users verify with: sha256sum --check recall-<version>-linux-amd64.tar.gz.sha256
# On macOS: shasum -a 256 --check recall-<version>-darwin-arm64.dmg.sha256
- name: Generate SHA256 checksums
run: bash scripts/release/compute-sha256.sh
# Attest the sha256 files so users can prove the checksums themselves
# came from CI, not a tampered release page. Chain:
# gh attestation verify <file>.sha256 → sha256sum --check <file>.sha256
# This closes the loop: even the fallback verification path is signed.
- name: Attest SHA256 checksums
uses: actions/attest-build-provenance@0f67c3f4856b2e3261c31976d6725780e5e4c373 # v4
with:
subject-path: |
recall-*.tar.gz.sha256
recall-*.deb.sha256
recall-*.exe.sha256
recall-*.dmg.sha256
recall-*.yaml.sha256
recall-*.bat.sha256
- name: Create GitHub Release
uses: softprops/action-gh-release@718ea10b132b3b2eba29c1007bb80653f286566b # v3
with:
# Tags like v0.0.1-beta or v1.0.0-rc1 contain a hyphen → prerelease.
prerelease: ${{ contains(github.ref_name, '-') }}
generate_release_notes: true
files: |
recall-*.tar.gz
recall-*.tar.gz.sha256
recall-*.deb
recall-*.deb.sha256
recall-*.exe
recall-*.exe.sha256
recall-*.dmg
recall-*.dmg.sha256
recall-*.yaml
recall-*.yaml.sha256
recall-*.bat
recall-*.bat.sha256
recall-*-sbom.spdx.json
# ── Job 6: Build and push Linux server container image to GHCR ──
# Improvement #5 (audit): now depends on `release`. Previously this
# ran in parallel with the binary builds, sometimes producing a
# container image even when the actual GitHub Release failed
# (e.g. v0.2.0 / v0.2.1 had container images on GHCR but no usable
# release assets). Gating on `release` means GHCR never has a tag
# users can `docker pull` unless the matching downloadable release
# exists.
#
# Uses GITHUB_TOKEN (auto-injected, repo-scoped, expires after run — no PAT needed).
# Image: ghcr.io/<owner>/recall-server:<version> (always), plus :<major>.<minor>
# and :latest on stable releases only (prerelease tags skip both — see the
# `enable=` guards in the metadata step below).
publish-container:
needs: [release]
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
id-token: write # required by cosign for keyless OIDC signing
env:
DOCKER_BUILDKIT: "1"
steps:
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
- uses: ./.github/actions/docker-build-env
with:
ghcr-login: 'true'
github-token: ${{ secrets.GITHUB_TOKEN }}
- name: Extract image metadata
id: meta
uses: docker/metadata-action@80c7e94dd9b9319bd5eb7a0e0fe9291e23a2a2e9 # v6
with:
images: ghcr.io/${{ github.repository_owner }}/recall-server
# Stable releases get the exact :{{version}} plus rolling :{{major}}.{{minor}}
# and :latest tags. Prerelease tags (those containing a hyphen, e.g.
# v0.1.0-beta.0) MUST get ONLY :{{version}} — the rolling tags are
# supposed to track the most-recent stable, so a `docker pull
# recall-server:latest` always lands on a non-prerelease build.
tags: |
type=semver,pattern={{version}}
type=semver,pattern={{major}}.{{minor}},enable=${{ !contains(github.ref_name, '-') }}
type=raw,value=latest,enable=${{ !contains(github.ref_name, '-') }}
- name: Build and push server container
id: push
uses: docker/build-push-action@f9f3042f7e2789586610d6e8b85c8f03e5195baf # v7
with:
context: .
file: Dockerfile.build
target: server-container
platforms: linux/amd64
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
# Improvement #2: same cache scope as the `build` matrix
# above, so the `go-base` / `server-base` layers materialised
# during the binary builds are reused here. Materially
# faster than the old standalone cache (~2m saved).
cache-from: type=gha,scope=release-build
cache-to: type=gha,mode=max,scope=release-build
# Sign each pushed tag with cosign keyless (Sigstore OIDC). The
# workflow's own GitHub Actions identity is the signing identity;
# users verify with:
# cosign verify ghcr.io/sound-barrier/recall-server:<tag> \
# --certificate-identity-regexp 'https://github.com/sound-barrier/recall/\.github/workflows/release\.yml@refs/tags/v.*' \
# --certificate-oidc-issuer 'https://token.actions.githubusercontent.com'
# See docs/docker.md for the full verification recipe.
- uses: sigstore/cosign-installer@6f9f17788090df1f26f669e9d70d6ae9567deba6 # v4.1.2
with:
cosign-release: 'v2.4.1'
- name: Sign container image (keyless via OIDC)
env:
DIGEST: ${{ steps.push.outputs.digest }}
TAGS: ${{ steps.meta.outputs.tags }}
run: bash scripts/release/sign-image.sh
# GHCR packages default to private for new packages. Try to set
# public so users can pull without auth — script handles the
# org-vs-user owner-type branching + retries. continue-on-error
# because GITHUB_TOKEN lacks write:packages OAuth scope for
# visibility changes; the package must be set public once
# manually via GitHub Package settings.
- name: Set container image visibility to public
continue-on-error: true
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: bash scripts/release/flip-package-public.sh