Skip to content

chore: bump to v0.1.59 (#216) #29

chore: bump to v0.1.59 (#216)

chore: bump to v0.1.59 (#216) #29

Workflow file for this run

name: release
# Build, sign, notarize, and publish a Blurt release from CI rather than from
# the maintainer's Mac. The Developer ID key and the notary credential live in
# GitHub secrets (see RELEASE.md) instead of one laptop's keychain, so a release
# no longer depends on who is at which desk.
#
# Two ways in, and both are a deliberate act against one reviewed commit of
# main — never a side effect of an arbitrary push, and never a tag trigger:
#
# - Merging the version-bump PR from `release-bump.yml`. The push lands on main
# with a changed `CFBundleShortVersionString`, which is what the path filter
# and the `resolve` job below look for. Approving that PR is the deliberate
# act; it is reviewed, it ran `check`, and it is one specific commit. This is
# the normal path — it means a release needs no workflow dispatch at all.
# - A manual dispatch, for re-running a release whose build failed, for
# `republish`, and for the non-main build-only dry run.
#
# Either way the release still parks on the `release-publish` approval gate
# before anything reaches users, and both jobs pin `github.sha`.
on:
workflow_dispatch:
inputs:
version:
description: "Version to release (X.Y.Z) — must match project.yml on the dispatched ref"
required: true
type: string
republish:
description: "Overwrite an existing tag + release with fresh artifacts (never for a code bug — bump a patch)"
type: boolean
default: false
skip_checks:
description: "Skip scripts/check.sh (it already ran on this commit in the check workflow)"
type: boolean
default: false
skip_smoke:
description: "Skip the launch smoke test (use if the runner has no usable GUI session)"
type: boolean
default: false
staging:
description: "Update rehearsal: build for the sparkle-staging feed and stage it there instead of publishing (RELEASE.md → Rehearsing an update)"
type: boolean
default: false
push:
branches: [main]
# Narrow on purpose. project.yml is the only file a version bump touches
# that isn't generated, so this is "something about the app's version may
# have changed" — the `resolve` job then decides whether it actually did.
paths:
- App/Blurt/project.yml
# One release at a time, and never cancel one in flight: a half-run that has
# already notarized or tagged is worse than a queued one.
concurrency:
group: release
cancel-in-progress: false
permissions:
contents: read
jobs:
# Decide what — if anything — this run is releasing, before any macOS minutes
# or the signing key are spent. Cheap and Linux-only: it is bash over
# project.yml and the tag list.
#
# A dispatch says the version outright. A push has to be interrogated: the
# path filter fires for any project.yml edit (adding a source file, changing a
# build setting), and only a changed CFBundleShortVersionString with no tag
# behind it is a release.
resolve:
runs-on: ubuntu-latest
timeout-minutes: 10
outputs:
version: ${{ steps.resolve.outputs.version }}
release: ${{ steps.resolve.outputs.release }}
steps:
- name: Checkout blurt
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
path: blurt
ref: ${{ github.sha }}
# Tags decide whether this version already shipped, and the push's
# previous commit is what "the version changed" is measured against —
# a shallow clone has neither.
fetch-depth: 0
persist-credentials: false
- name: Resolve the release version
id: resolve
working-directory: blurt
env:
EVENT: ${{ github.event_name }}
INPUT_VERSION: ${{ inputs.version }}
BEFORE: ${{ github.event.before }}
run: |
set -euo pipefail
REPO_ROOT="$PWD"
# shellcheck disable=SC1091 # sourced at runtime from the checkout
source scripts/release-lib.sh
version="$(require_project_version App/Blurt/project.yml)"
if [ "$EVENT" = "workflow_dispatch" ]; then
# The dispatcher named a version; it must be the one this commit
# carries, and their say-so is the deliberate act — republish and
# re-runs of a failed build both have to work, so no tag check here.
is_semver "$INPUT_VERSION" || die "version input must be X.Y.Z (got: $INPUT_VERSION)"
[ "$version" = "$INPUT_VERSION" ] \
|| die "dispatched version $INPUT_VERSION but ${GITHUB_SHA:0:7} is at $version — dispatch the commit that carries the bump"
echo "version=$version" >>"$GITHUB_OUTPUT"
echo "release=true" >>"$GITHUB_OUTPUT"
info "dispatched: releasing v$version"
exit 0
fi
# --- push to main ---
skip() {
echo "release=false" >>"$GITHUB_OUTPUT"
echo "::notice::$1 — not releasing."
{ echo "### No release from this push"; echo; echo "$1."; } >>"$GITHUB_STEP_SUMMARY"
exit 0
}
# Did the version actually change in this push? Fail open if the
# previous commit can't be resolved (a force-push, a first push): an
# extra build costs runner time, and the publish gate still holds.
prev="$BEFORE"
git cat-file -e "${prev}^{commit}" 2>/dev/null || prev="$(git rev-parse --verify --quiet 'HEAD^' || true)"
if [ -n "$prev" ]; then
prev_version="$(git show "$prev:App/Blurt/project.yml" 2>/dev/null | parse_short_version || true)"
if [ "$prev_version" = "$version" ]; then
skip "project.yml changed but the version is still $version"
fi
info "version changed: ${prev_version:-unknown} → $version"
else
echo "::notice::could not resolve the previous commit — treating $version as new."
fi
if tag_exists_locally "v$version" || tag_exists_on_origin "v$version"; then
skip "v$version is already tagged"
fi
echo "version=$version" >>"$GITHUB_OUTPUT"
echo "release=true" >>"$GITHUB_OUTPUT"
info "bump merged: releasing v$version from $GITHUB_SHA"
{
echo "### Releasing v$version"
echo
echo "Triggered by the version bump landing on \`main\`. The build job signs and"
echo "notarizes; publishing then waits on the \`release-publish\` approval."
} >>"$GITHUB_STEP_SUMMARY"
# Build + sign + notarize + staple + DMG. Produces the exact artifacts the
# publish job uploads — nothing is rebuilt downstream, so what a human tests
# from this run's artifacts is byte-for-byte what ships.
build:
needs: resolve
if: needs.resolve.outputs.release == 'true'
runs-on: macos-26
timeout-minutes: 120
# Holds the signing + notary secrets. Restrict this environment to the
# `main` branch in repo settings so a fork or a stray branch can't reach the
# Developer ID key.
environment: release-build
steps:
- name: Checkout blurt
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
path: blurt
# Pin to the dispatched commit rather than the branch tip: the publish
# job checks out the same sha, so the tag can never land on a commit
# other than the one that was actually built.
ref: ${{ github.sha }}
# History and tags, for one path only: a staging build with no
# release-notes file falls back to the commits since the previous
# release tag (release-build.sh fails fast on a shallow clone there).
# A release reads its hand-written notes and needs neither.
fetch-depth: 0
persist-credentials: false
- name: Guard the release version
working-directory: blurt
# `resolve` already established this, on its own checkout. Re-checking it
# here on the runner that does the signing keeps the artifact name and
# the built binary provably the same version.
env:
WANT_VERSION: ${{ needs.resolve.outputs.version }}
run: |
set -euo pipefail
# shellcheck disable=SC1091 # sourced at runtime from the checkout
source scripts/release-lib.sh
GOT_VERSION="$(require_project_version App/Blurt/project.yml)"
is_semver "$WANT_VERSION" || die "resolved version must be X.Y.Z (got: $WANT_VERSION)"
[ "$GOT_VERSION" = "$WANT_VERSION" ] \
|| die "releasing $WANT_VERSION but ${GITHUB_SHA:0:7} is at $GOT_VERSION — release the commit that carries the bump"
info "releasing v$GOT_VERSION from $GITHUB_SHA"
- name: Check the signing secrets are configured
# Fail here rather than 25 minutes later at the signing step. Only
# presence is checked — the values are never echoed.
env:
P12: ${{ secrets.SIGNING_P12_BASE64 }}
P12_PASSWORD: ${{ secrets.SIGNING_P12_PASSWORD }}
NOTARY_KEY_P8: ${{ secrets.NOTARY_KEY_P8_BASE64 }}
NOTARY_APPLE_ID: ${{ secrets.NOTARY_APPLE_ID }}
SPARKLE_KEY: ${{ secrets.SPARKLE_ED_PRIVATE_KEY }}
run: |
set -euo pipefail
missing=""
[ -n "$SPARKLE_KEY" ] || missing="$missing SPARKLE_ED_PRIVATE_KEY"
[ -n "$P12" ] || missing="$missing SIGNING_P12_BASE64"
[ -n "$P12_PASSWORD" ] || missing="$missing SIGNING_P12_PASSWORD"
[ -n "$NOTARY_KEY_P8" ] || [ -n "$NOTARY_APPLE_ID" ] \
|| missing="$missing NOTARY_KEY_P8_BASE64-or-NOTARY_APPLE_ID"
if [ -n "$missing" ]; then
echo "::error::missing secret(s) on the release-build environment:$missing — see RELEASE.md"
exit 1
fi
- name: Note a build-only dry run
if: github.ref != 'refs/heads/main'
run: |
echo "::notice::Dispatched from ${GITHUB_REF}, not main — this is a build/sign/notarize dry run. The publish job will not run."
- name: Install build tools
working-directory: blurt
# create-dmg is release-only, so it is not in the Brewfile the check
# workflow installs on every PR.
run: |
brew bundle --file=Brewfile
brew install create-dmg
- name: Show toolchain
run: |
xcodebuild -version
swift --version
- name: Build, sign, notarize, staple
working-directory: blurt
env:
# Developer ID Application cert + key, exported as a .p12 and
# base64-encoded. Imported into an ephemeral keychain that is deleted
# when the script exits.
BLURT_SIGNING_P12_BASE64: ${{ secrets.SIGNING_P12_BASE64 }}
BLURT_SIGNING_P12_PASSWORD: ${{ secrets.SIGNING_P12_PASSWORD }}
# Notary credential. The App Store Connect API key is preferred (it is
# revocable on its own and never appears in a process list); the Apple
# ID pair below is the fallback for a team without API-key access.
BLURT_NOTARY_KEY_ID: ${{ secrets.NOTARY_KEY_ID }}
BLURT_NOTARY_ISSUER_ID: ${{ secrets.NOTARY_ISSUER_ID }}
BLURT_NOTARY_KEY_P8_BASE64: ${{ secrets.NOTARY_KEY_P8_BASE64 }}
BLURT_NOTARY_APPLE_ID: ${{ secrets.NOTARY_APPLE_ID }}
BLURT_NOTARY_PASSWORD: ${{ secrets.NOTARY_PASSWORD }}
# Sparkle's EdDSA private key (base64, as `generate_keys -x` exports
# it). Signs the DMG for the appcast; handed to sign_update on stdin.
BLURT_SPARKLE_ED_PRIVATE_KEY: ${{ secrets.SPARKLE_ED_PRIVATE_KEY }}
# Both are dispatch-only escape hatches, so both are empty (and the
# full checks run) on the merged-bump path. That is the right default:
# nobody is standing there to judge whether skipping was safe.
SKIP_CHECKS: ${{ inputs.skip_checks }}
SKIP_SMOKE: ${{ inputs.skip_smoke }}
STAGING: ${{ inputs.staging }}
run: |
set -euo pipefail
args=()
if [ "$SKIP_CHECKS" = "true" ]; then args+=(--skip-checks); fi
if [ "$SKIP_SMOKE" = "true" ]; then args+=(--skip-smoke); fi
if [ "$STAGING" = "true" ]; then args+=(--staging); fi
scripts/release-build.sh ${args[@]+"${args[@]}"}
- name: Upload release artifacts
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: release-${{ needs.resolve.outputs.version }}
# Explicit paths, not the whole build/release tree: derived/ and
# stage/ are multi-GB build scratch that nothing downstream reads.
path: |
blurt/build/release/Blurt-*.dmg
blurt/build/release/Blurt-*.app.dSYM.zip
blurt/build/release/SHA256SUMS
blurt/build/release/appcast.xml
blurt/build/release/build-info.txt
blurt/build/release/notary-*-log.json
if-no-files-found: error
# Tag, push, and publish the GitHub Release from the artifacts above.
#
# The `release-publish` environment is the ship gate that the local
# `release-install.sh` step used to be: configure it with required reviewers so
# this job parks until a human has downloaded the DMG from the build job's
# artifacts, installed it, and confirmed it works.
publish:
needs: [resolve, build]
# Releases ship from main. A dispatch from any other ref stops after the
# build job, which makes this workflow safe to exercise as a dry run. A
# staging build never publishes — `stage` below takes it instead (and
# release-publish.sh would refuse it anyway).
if: github.ref == 'refs/heads/main' && inputs.staging != true
runs-on: macos-26
timeout-minutes: 30
environment: release-publish
permissions:
# contents: write is what lets the tag push and the release create land.
contents: write
actions: read
steps:
- name: Checkout blurt
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
path: blurt
ref: ${{ github.sha }}
# The publish step pushes the release tag, so keep the token on the
# origin remote (contents: write above is what makes the push land).
persist-credentials: true
- name: Download release artifacts
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
name: release-${{ needs.resolve.outputs.version }}
path: blurt/build/release
- name: Identify the tagger
working-directory: blurt
run: |
git config user.name "github-actions[bot]"
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
- name: Tag + publish
working-directory: blurt
env:
GH_TOKEN: ${{ github.token }}
REPUBLISH: ${{ inputs.republish }}
run: |
set -euo pipefail
args=(--yes)
if [ "$REPUBLISH" = "true" ]; then args+=(--republish); fi
scripts/release-publish.sh "${args[@]}"
# Update rehearsal: put a --staging build on the sparkle-staging prerelease,
# the feed its app polls (RELEASE.md → Rehearsing an update). Runs from any
# ref the build job could — a rehearsal lives on a release-dry-run* branch,
# temporarily allowed on release-build — and needs no approval gate:
# the prerelease is never marked latest, so no shipped copy can see it. Holds
# no signing secrets; contents: write is only for the prerelease upload.
stage:
needs: [resolve, build]
if: inputs.staging == true
runs-on: ubuntu-latest
timeout-minutes: 15
permissions:
contents: write
steps:
- name: Checkout blurt
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
path: blurt
ref: ${{ github.sha }}
persist-credentials: false
- name: Download release artifacts
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
name: release-${{ needs.resolve.outputs.version }}
path: blurt/build/release
- name: Stage
working-directory: blurt
env:
GH_TOKEN: ${{ github.token }}
GH_REPO: ${{ github.repository }}
run: scripts/release-stage.sh