Skip to content

fix(release): give publish-release a repo context for gh #21

fix(release): give publish-release a repo context for gh

fix(release): give publish-release a repo context for gh #21

Workflow file for this run

name: Release
# Phase 8 bundling. A tag push `v*` builds + uploads the release artifacts:
# * Roost-<version>.dmg (macOS, arm64)
# * Roost-Iced-<version>.dmg (macOS, arm64 — experimental)
# * roost_<version>_amd64.deb / _arm64.deb (Linux, native per-arch)
# then fires repository_dispatch at charliek/apt-charliek so the .deb lands in
# the apt repo. The GTK / ghostty-cache patterns mirror ci.yml.
#
# Mac signing/notarization is GATED on secrets and inert until they're added:
# ROOST_DEVELOPER_ID_IDENTITY, MACOS_CERTIFICATE_P12_BASE64,
# MACOS_CERTIFICATE_PASSWORD, APPLE_ID, APPLE_TEAM_ID,
# APPLE_APP_SPECIFIC_PASSWORD. Without them the DMG ships ad-hoc-signed
# (Gatekeeper requires right-click → Open).
#
# The Release is created as a DRAFT and stays one until `publish-release`
# flips it, after both .debs and the DMG are attached, non-empty and named
# for the tag. Publishing up front let two things go wrong: a failed build
# left a public, empty release behind, and apt-charliek could consume it —
# its collect-debs.sh walks releases newest-first and only *warns* past one
# that carries no matching .deb, so it silently republishes the PREVIOUS
# version. A draft is invisible to it (GitHub lists drafts only to callers
# with push access, which its repo-scoped GITHUB_TOKEN doesn't have).
#
# Sparkle auto-update (issue #122): the mac job EdDSA-signs the DMG with
# SPARKLE_ED_PRIVATE_KEY and hands sign.txt forward; the `appcast` job then
# appends an entry to docs/appcast.xml and pushes that commit to main as the
# charliek-release-bot GitHub App. The App is in main's ruleset bypass list
# (the only way to push past the ci-success requirement on a bot-authored
# commit — see #136 for the GITHUB_TOKEN attempt that failed). docs.yml then
# redeploys Pages → appcast live. No manual post-release step. That job runs
# after publish-release because the enclosure URL it writes is a public
# /releases/download/ link, which a draft's assets don't answer.
# `mac-iced` + `appcast-iced` are the same two-part shape for the
# experimental Roost-Iced build, on a SEPARATE feed (docs/appcast-iced.xml)
# with a SEPARATE keypair (mac/keys/README.md) — the two apps must never be
# able to offer each other's updates.
on:
push:
tags:
- 'v*'
# Least privilege: default read; only the release-writing jobs get contents:write.
permissions:
contents: read
# Serialize release runs against the same tag: rare in practice (tags are unique),
# but a re-run via the Actions UI while the original is still building would
# otherwise race on the appcast push. `cancel-in-progress: false` keeps both
# runs alive — better to wait than to abort half-uploaded artifacts.
concurrency:
group: release-${{ github.ref }}
cancel-in-progress: false
jobs:
version-check:
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- uses: actions/checkout@v6
with:
persist-credentials: false
- name: Assert tag matches [workspace.package] version
run: |
set -euo pipefail
tag="${GITHUB_REF_NAME}"
tag_ver="${tag#v}"
base_ver="${tag_ver%%-*}" # strip any -rc1/-beta suffix
cargo_ver="$(grep -E '^version[[:space:]]*=' Cargo.toml | head -1 \
| sed -E 's/^version[[:space:]]*=[[:space:]]*"([^"]+)".*/\1/')"
echo "tag=${tag} (base ${base_ver}) Cargo.toml workspace version=${cargo_ver}"
if [ "${base_ver}" != "${cargo_ver}" ]; then
echo "::error::tag ${tag} does not match [workspace.package] version ${cargo_ver}. Bump Cargo.toml before tagging."
exit 1
fi
# Don't ship a tag whose code never passed CI. `ci-success` is the single
# required check on `main`; the tagged commit (pushed to main moments
# earlier by /release:release) gets its own ci.yml run, so poll that
# commit's `ci-success` check and refuse to release unless it's green.
# Poll because that run may still be in flight when the tag fires.
ci-gate:
runs-on: ubuntu-latest
timeout-minutes: 55
permissions:
contents: read
checks: read
steps:
- name: Require ci-success green on the tagged commit
env:
GH_TOKEN: ${{ github.token }}
run: |
set -euo pipefail
deadline=$(( SECONDS + 2700 )) # 45 min — releases aren't time-sensitive
while :; do
# Server-side check_name filter. Without it, the endpoint returns
# ≤30 runs per page; on busy commits ci-success can fall off the
# first page and the loop would spin until timeout looking for
# a check that exists.
latest="$(gh api \
"repos/${GITHUB_REPOSITORY}/commits/${GITHUB_SHA}/check-runs?check_name=ci-success" \
--jq '.check_runs | sort_by(.started_at) | last')"
status="$(echo "${latest}" | jq -r '.status // "missing"')"
conclusion="$(echo "${latest}" | jq -r '.conclusion // ""')"
echo "ci-success @ ${GITHUB_SHA}: status=${status} conclusion=${conclusion}"
if [ "${status}" = "completed" ]; then
[ "${conclusion}" = "success" ] && { echo "CI green — releasing."; exit 0; }
echo "::error::ci-success concluded '${conclusion}' for ${GITHUB_SHA} — not releasing."
exit 1
fi
if [ "${SECONDS}" -ge "${deadline}" ]; then
echo "::error::timed out waiting for ci-success on ${GITHUB_SHA} (status=${status}). Push the commit to main and let CI finish before tagging."
exit 1
fi
sleep 20
done
create-release:
needs: [version-check, ci-gate]
runs-on: ubuntu-latest
timeout-minutes: 15
permissions:
contents: write
steps:
- uses: actions/checkout@v6
with:
persist-credentials: false
- name: Create (or reuse) the draft GitHub Release
env:
GH_TOKEN: ${{ github.token }}
run: |
set -euo pipefail
tag="${GITHUB_REF_NAME}"
pre=""
case "${tag}" in *-*) pre="--prerelease";; esac
# Pull this tag's section out of CHANGELOG.md (## <tag> … up to the
# next ## v…). Field match so a trailing "— <date>" doesn't break it.
notes="$(awk -v tag="${tag}" '
$1=="##" && $2==tag {flag=1; next}
/^## v/ {if(flag) exit}
flag {print}
' CHANGELOG.md || true)"
if gh release view "${tag}" >/dev/null 2>&1; then
# Reuse is for the re-run case, where the earlier attempt left a
# draft behind. Reusing a PUBLISHED release is not: the build jobs
# would `--clobber` new assets straight into something users and
# apt-charliek can already see, mid-run. Fail closed and make a
# human decide (RELEASING.md covers the recovery).
if [ "$(gh release view "${tag}" --json isDraft -q .isDraft)" != "true" ]; then
echo "::error::Release ${tag} already exists and is PUBLISHED. Refusing to rebuild into a live release — delete it (gh release delete ${tag}) or cut a new tag."
exit 1
fi
echo "Draft release ${tag} already exists — reusing."
exit 0
fi
# --draft: no artifacts are attached yet. publish-release flips it.
# A draft does NOT create or move a git tag, and `gh release upload
# --clobber` works against it (verified empirically before this
# change landed), so the build jobs need no other adjustment.
if [ -n "${notes}" ]; then
printf '%s\n' "${notes}" | gh release create "${tag}" --draft --title "${tag}" --notes-file - ${pre}
else
gh release create "${tag}" --draft --title "${tag}" --generate-notes ${pre}
fi
linux:
needs: create-release
permissions:
contents: write
strategy:
fail-fast: false
matrix:
# `label` is the asset's display name on the Release page. Debian
# requires the file itself be `<pkg>_<version>_<arch>.deb` (apt keys
# off it), but "amd64" and "arm64" are near-identical at a glance in
# adjacent rows, so the label spells the target out. The download
# still lands as the real filename.
include:
- { runner: ubuntu-24.04, arch: amd64, label: "Linux — Intel / AMD 64-bit (.deb)" }
- { runner: ubuntu-24.04-arm, arch: arm64, label: "Linux — ARM 64-bit (.deb)" }
runs-on: ${{ matrix.runner }}
timeout-minutes: 90
steps:
- uses: actions/checkout@v6
with:
persist-credentials: false
# The .deb ships the iced UI as /usr/bin/roost (linux/scripts/
# build-deb.sh), not roost-linux — GTK dev packages (libgtk-4-dev,
# libadwaita-1-dev) are dead weight now that the package no longer
# contains the GTK UI. pkg-config + libclang-dev build roost-vt's
# bindgen; the rest mirrors ci.yml's `iced-release` job — the display
# + wgpu/vulkan stack the packaged-artifact smoke below needs to
# actually launch the binary under Xvfb.
- name: Install Iced + build deps
run: |
sudo apt-get update
sudo apt-get install -y \
pkg-config \
libclang-dev \
fonts-noto-cjk \
libxkbcommon-x11-0 \
libwayland-client0 \
mesa-vulkan-drivers \
xvfb \
xdotool \
zsh \
desktop-file-utils
- name: Install nfpm
env:
GH_TOKEN: ${{ github.token }}
run: |
set -euo pipefail
ver="2.46.3"
case "${{ matrix.arch }}" in
amd64) nfpm_arch="x86_64" ;;
arm64) nfpm_arch="arm64" ;;
esac
url="https://github.com/goreleaser/nfpm/releases/download/v${ver}/nfpm_${ver}_Linux_${nfpm_arch}.tar.gz"
curl -fsSL "${url}" -o /tmp/nfpm.tgz
sudo tar -C /usr/local/bin -xzf /tmp/nfpm.tgz nfpm
nfpm --version
- name: Install Rust toolchain (from rust-toolchain.toml)
uses: actions-rust-lang/setup-rust-toolchain@v1
- uses: jdx/mise-action@v4
- name: Cache vendored libghostty-vt
id: cache-ghostty
uses: actions/cache@v5
with:
path: |
third_party/ghostty/out
third_party/ghostty/src
key: vendored-ghostty-${{ runner.os }}-${{ matrix.arch }}-${{ hashFiles('third_party/ghostty/build.sh') }}
- name: Build libghostty-vt
if: steps.cache-ghostty.outputs.cache-hit != 'true'
run: ./third_party/ghostty/build.sh
- name: Cache cargo registry + target
uses: actions/cache@v5
with:
path: |
~/.cargo/registry
~/.cargo/git
target
key: cargo-release-${{ runner.os }}-${{ matrix.arch }}-${{ hashFiles('**/Cargo.toml', 'rust-toolchain.toml') }}
restore-keys: cargo-release-${{ runner.os }}-${{ matrix.arch }}-
- name: Build .deb
run: ./linux/scripts/build-deb.sh "${GITHUB_REF_NAME#v}"
# This is the only thing in the release path that actually launches the
# artifact being shipped. The assertions live in linux/scripts/
# smoke-deb.sh (payload contents, control metadata, and — the real
# point — that the packaged binary's compiled-in default lands on the
# production `roost` IPC namespace) so the same code can run on
# ordinary PRs in ci.yml instead of first executing during a release.
# `--expect-version` gets the nfpm-normalized string: nfpm rewrites `-`
# to `~` in Debian versions (v0.0.18-rc1 -> 0.0.18~rc1).
- name: Smoke the packaged artifact
run: |
set -euo pipefail
deb="$(./linux/scripts/resolve-one-deb.sh out)"
# Replace only the FIRST `-`, matching nfpm's semver schema: it
# splits version from prerelease at the first hyphen and joins them
# with `~`, leaving any later hyphen in the prerelease intact
# (v1.2.3-rc-2 -> 1.2.3~rc-2). A blanket `tr '-' '~'` would produce
# 1.2.3~rc~2 and fail this assertion *after* create-release has
# already published the Release.
raw="${GITHUB_REF_NAME#v}"
ver="${raw/-/\~}"
./linux/scripts/smoke-deb.sh "${deb}" \
--work-dir "${RUNNER_TEMP}/roost-smoke" \
--expect-version "${ver}"
# The step above extracts the .deb, so it validates the payload but NOT
# the dependency closure: this runner already carries the whole graphics
# stack from the build step, so a `Depends:` line that forgot a library
# would still launch here. See the script for the rest of the reasoning.
- name: Verify the dependency closure in a clean container
run: |
set -euo pipefail
deb="$(./linux/scripts/resolve-one-deb.sh out)"
./linux/scripts/verify-deb-closure.sh "${deb}"
- name: Upload .deb to the Release
env:
GH_TOKEN: ${{ github.token }}
ASSET_LABEL: ${{ matrix.label }}
ARCH: ${{ matrix.arch }}
run: |
set -euo pipefail
# Match on the arch this job's label claims, not any .deb: the
# `file#label` syntax needs one concrete path, and a bare glob would
# happily hand an arm64 package to the job that labels it Intel/AMD
# — the exact mislabeling the labels exist to prevent. resolve-one-deb
# also means a build that emitted nothing fails here, naming the arch,
# instead of passing an unexpanded glob down to gh.
deb="$(./linux/scripts/resolve-one-deb.sh out --arch "${ARCH}")"
gh release upload "${GITHUB_REF_NAME}" "${deb}#${ASSET_LABEL}" --clobber
mac:
needs: create-release
runs-on: macos-26 # arm64 — pinned; ships Xcode 26 by default, which
timeout-minutes: 90
# actool needs to compile mac/AppIcon.icon (the Tahoe
# glass icon). On older Xcode, bundle.sh still builds but
# falls back to the flat (framed) .icns. See
# packaging/icon/README.md.
permissions:
contents: write
env:
# Sign + notarize only when the FULL prerequisite set is present. Any
# missing secret → ad-hoc fallback: a Developer-ID-signed but un-notarized
# DMG is still Gatekeeper-blocked, so signing without notarizing has no
# value, and a partial secret set must not break the build or silently
# drop the first-launch note. The identity is handed to the bundle/DMG
# steps (below) only when this is true.
CAN_NOTARIZE: ${{ secrets.MACOS_CERTIFICATE_P12_BASE64 != '' && secrets.MACOS_CERTIFICATE_PASSWORD != '' && secrets.ROOST_DEVELOPER_ID_IDENTITY != '' && secrets.APPLE_ID != '' && secrets.APPLE_TEAM_ID != '' && secrets.APPLE_APP_SPECIFIC_PASSWORD != '' }}
steps:
- uses: actions/checkout@v6
with:
persist-credentials: false
- name: Install Rust toolchain (from rust-toolchain.toml)
uses: actions-rust-lang/setup-rust-toolchain@v1
- uses: jdx/mise-action@v4
- name: Install uv
uses: astral-sh/setup-uv@v7
- name: Cache vendored libghostty-vt
id: cache-ghostty
uses: actions/cache@v5
with:
path: |
third_party/ghostty/out
third_party/ghostty/src
key: vendored-ghostty-${{ runner.os }}-${{ hashFiles('third_party/ghostty/build.sh') }}
- name: Build libghostty-vt
if: steps.cache-ghostty.outputs.cache-hit != 'true'
run: ./third_party/ghostty/build.sh
- name: Cache SwiftPM artifacts
uses: actions/cache@v5
with:
path: |
mac/.build
~/Library/Caches/org.swift.swiftpm
key: swiftpm-release-${{ runner.os }}-${{ hashFiles('mac/Package.swift', 'mac/Package.resolved') }}
restore-keys: swiftpm-release-${{ runner.os }}-
- name: Cache cargo registry + target
uses: actions/cache@v5
with:
path: |
~/.cargo/registry
~/.cargo/git
target
key: cargo-release-${{ runner.os }}-${{ hashFiles('**/Cargo.toml', 'rust-toolchain.toml') }}
restore-keys: cargo-release-${{ runner.os }}-
# Inert until all six Apple secrets exist (the CAN_NOTARIZE gate). Imports
# the Developer ID Application cert into a throwaway keychain so codesign
# can use it.
- name: Import Developer ID certificate
if: env.CAN_NOTARIZE == 'true'
env:
MACOS_CERTIFICATE_P12_BASE64: ${{ secrets.MACOS_CERTIFICATE_P12_BASE64 }}
MACOS_CERTIFICATE_PASSWORD: ${{ secrets.MACOS_CERTIFICATE_PASSWORD }}
run: |
set -euo pipefail
# The decoded cert must never outlive this step, even on early failure.
# (The keychain itself must persist — later steps sign against it.)
trap 'rm -f "$RUNNER_TEMP/cert.p12"' EXIT
kc="$RUNNER_TEMP/roost-signing.keychain-db"
kp="$(openssl rand -base64 24)"
security create-keychain -p "$kp" "$kc"
security set-keychain-settings -lut 21600 "$kc"
security unlock-keychain -p "$kp" "$kc"
echo "$MACOS_CERTIFICATE_P12_BASE64" | base64 --decode > "$RUNNER_TEMP/cert.p12"
security import "$RUNNER_TEMP/cert.p12" -k "$kc" -P "$MACOS_CERTIFICATE_PASSWORD" -T /usr/bin/codesign
security set-key-partition-list -S apple-tool:,apple: -s -k "$kp" "$kc"
# Prepend our keychain to the user search list, keeping the existing
# ones; the command substitution must word-split into separate paths.
# shellcheck disable=SC2046
security list-keychains -d user -s "$kc" $(security list-keychains -d user | sed 's/"//g')
rm -f "$RUNNER_TEMP/cert.p12"
# Guard (issue #122): a STABLE release must not ship the throwaway spike
# SUPublicEDKey — builds signed with the real key would fail to verify
# against it, and users on a throwaway-key build can't auto-update. A
# prerelease (-beta/-rc) may keep it to validate the appcast round-trip
# with the matching throwaway private key. Mirrors create-release's
# `case "$tag" in *-*)` prerelease idiom.
- name: Guard against the throwaway Sparkle key on stable releases
run: |
set -euo pipefail
case "${GITHUB_REF_NAME}" in
*-*) echo "prerelease ${GITHUB_REF_NAME} — throwaway SUPublicEDKey allowed for validation."; exit 0 ;;
esac
throwaway='9n43stvrJeqWENXHYrisnIhByQ8N0OcjzSWAvhZj4fo='
if grep -q "${throwaway}" mac/Resources/Info.plist.template; then
echo "::error::mac/Resources/Info.plist.template still carries the THROWAWAY SUPublicEDKey (#122). Swap it for the maintainer's real public key and set SPARKLE_ED_PRIVATE_KEY before cutting a stable release."
exit 1
fi
echo "SUPublicEDKey is not the throwaway placeholder — ok."
# The identity reaches bundle.sh / make-dmg.sh only when CAN_NOTARIZE —
# otherwise empty, so they take the ad-hoc path (and make-dmg keeps the
# FIRST-LAUNCH note).
- name: Bundle Roost.app
env:
ROOST_DEVELOPER_ID_IDENTITY: ${{ env.CAN_NOTARIZE == 'true' && secrets.ROOST_DEVELOPER_ID_IDENTITY || '' }}
run: ROOST_VERSION="${GITHUB_REF_NAME#v}" ./mac/scripts/bundle.sh release
# Validate the *actual shipped artifact*: drive the release bundle
# through the E2E op set before it's packaged. ci-gate already
# confirmed the commit's required checks are green, but a version-bump
# commit can path-filter e2e-mac out of that run — this guarantees the
# binary in the DMG passes E2E regardless. Reuses the harness's
# pre-launch hygiene + scaled timeouts.
- name: E2E against the release bundle
env:
ROOST_TEST_TIMEOUT_SCALE: "3"
# Match the e2e-mac CI job's surface so the release bundle is
# exercised identically (test-only ops unlocked). --roost-fresh
# owns a hermetic instance with an isolated, throwaway state dir.
ROOST_TEST_MODE: "1"
run: uv run --group test pytest tools/roosttest --roost-target mac --roost-fresh -v
- name: Make DMG
env:
ROOST_DEVELOPER_ID_IDENTITY: ${{ env.CAN_NOTARIZE == 'true' && secrets.ROOST_DEVELOPER_ID_IDENTITY || '' }}
run: ./mac/scripts/make-dmg.sh "${GITHUB_REF_NAME#v}"
- name: Notarize + staple (skipped without credentials)
if: env.CAN_NOTARIZE == 'true'
env:
APPLE_ID: ${{ secrets.APPLE_ID }}
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
APPLE_APP_SPECIFIC_PASSWORD: ${{ secrets.APPLE_APP_SPECIFIC_PASSWORD }}
run: ./mac/scripts/notarize.sh "mac/build/Roost-${GITHUB_REF_NAME#v}.dmg"
# What governs Gatekeeper on a downloaded DMG is whether it's *notarized*
# (a stapled ticket) — not whether a signing cert exists. A Developer-ID-
# signed but un-notarized DMG is still blocked. So detect the real artifact
# state with `stapler validate` and gate the first-launch guidance on it;
# this can't drift out of sync with notarize.sh's credential checks.
- name: Detect DMG notarization status
id: dmg_notarized
run: |
if xcrun stapler validate "mac/build/Roost-${GITHUB_REF_NAME#v}.dmg" >/dev/null 2>&1; then
echo "value=true" >> "$GITHUB_OUTPUT"
else
echo "value=false" >> "$GITHUB_OUTPUT"
fi
- name: Note signing status
if: steps.dmg_notarized.outputs.value != 'true'
run: |
echo "::notice::Roost-${GITHUB_REF_NAME#v}.dmg is NOT notarized — Gatekeeper blocks first launch. Clear it with 'xattr -dr com.apple.quarantine /Applications/Roost.app', or System Settings > Privacy & Security > Open Anyway."
# The `#...` suffix sets the asset's display name on the Release page;
# the file still downloads as Roost-<version>.dmg, which is what the
# Sparkle appcast's enclosure URL is built from.
- name: Upload DMG to the Release
env:
GH_TOKEN: ${{ github.token }}
run: |
gh release upload "${GITHUB_REF_NAME}" \
"mac/build/Roost-${GITHUB_REF_NAME#v}.dmg#macOS (.dmg)" --clobber
# Sparkle appcast, part 1 of 2 (issue #122). The signing happens HERE
# because this job is the only one holding the Sparkle SPM artifacts
# (which carry sign_update) under mac/.build/artifacts — signing
# elsewhere would mean re-bootstrapping the whole Swift build. Writing
# the appcast is part 2, in the `appcast` job, which can't run until the
# Release is published: the enclosure URL is a public
# /releases/download/ link and a draft's assets don't answer it.
- name: Sign the DMG for Sparkle
env:
SPARKLE_ED_PRIVATE_KEY: ${{ secrets.SPARKLE_ED_PRIVATE_KEY }}
run: |
set -euo pipefail
# Fail fast on missing config BEFORE doing any work.
if [ -z "${SPARKLE_ED_PRIVATE_KEY}" ]; then
echo "::error::SPARKLE_ED_PRIVATE_KEY secret is unset — can't sign the appcast entry."
exit 1
fi
VER="${GITHUB_REF_NAME#v}"
DMG="mac/build/Roost-${VER}.dmg"
test -s "${DMG}" || { echo "::error::${DMG} missing — the DMG steps must run first."; exit 1; }
SIGN_UPDATE="$(find mac/.build/artifacts -path '*/bin/sign_update' -type f -not -path '*old_dsa*' | head -1)"
if [ -z "${SIGN_UPDATE}" ]; then
echo "::error::sign_update not found under mac/.build/artifacts. Did Bundle Roost.app (which triggers swift build) run?"
exit 1
fi
W="$(mktemp -d)"
chmod 700 "$W"
trap 'rm -rf "$W"' EXIT
printf '%s' "${SPARKLE_ED_PRIVATE_KEY}" | base64 --decode > "${W}/key"
mkdir -p "${RUNNER_TEMP}/appcast"
"${SIGN_UPDATE}" --ed-key-file "${W}/key" "${DMG}" > "${RUNNER_TEMP}/appcast/sign.txt"
rm -f "${W}/key"
# The whole line is what travels to the appcast job: it carries both
# sparkle:edSignature AND length, and update-appcast.py needs both to
# build the <enclosure>. Not a secret — both end up in the public feed.
cat "${RUNNER_TEMP}/appcast/sign.txt"
- name: Hand sign.txt to the appcast job
uses: actions/upload-artifact@v7
with:
name: sparkle-sign
path: ${{ runner.temp }}/appcast/sign.txt
if-no-files-found: error
# Artifacts are immutable, so without this a re-run of just the mac
# job (e.g. after the release-body step failed) dies on the existing
# name instead of recovering.
overwrite: true
# Interim only: while the DMG isn't notarized, append the Gatekeeper-bypass
# instructions to the release body so downloaders see them at the earliest
# touchpoint. Gated on the stapled-ticket check above, so it self-disables
# once notarization lands. A sentinel marker keeps re-runs idempotent.
- name: Append macOS first-launch note to release body
if: steps.dmg_notarized.outputs.value != 'true'
env:
GH_TOKEN: ${{ github.token }}
run: |
set -euo pipefail
tag="${GITHUB_REF_NAME}"
marker="<!-- roost-macos-firstlaunch -->"
body="$(gh release view "${tag}" --json body -q .body)"
case "${body}" in
*"${marker}"*) echo "First-launch note already present — skipping."; exit 0 ;;
esac
{
printf '%s\n\n' "${body}"
printf '%s\n' "${marker}"
printf '### macOS first launch\n\n'
printf 'The macOS DMG is not yet notarized (pending an Apple Developer account), so Gatekeeper blocks the first launch. Clear it once after dragging Roost to Applications:\n\n'
printf '```\nxattr -dr com.apple.quarantine /Applications/Roost.app\n```\n\n'
printf 'Or, on macOS 15+, use **System Settings → Privacy & Security → Open Anyway**. Full details: [Installation → First launch on macOS](https://github.com/charliek/roost/blob/main/docs/getting-started/installation.md#first-launch-on-macos).\n'
} > "${RUNNER_TEMP}/relbody.md"
gh release edit "${tag}" --notes-file "${RUNNER_TEMP}/relbody.md"
# The experimental Roost-Iced macOS build (plan 030). Mirrors `mac` above —
# same runner, same six-secret CAN_NOTARIZE gate, same certificate import,
# same notarize/sign/upload shape — with three deliberate carve-outs:
#
# * No SwiftPM cache: this bundle is a `cargo build`, not `swift build`,
# and no mac/.build exists here (which is also why the Sparkle signing
# step below uses the vendored third_party/sparkle/out/bin/sign_update
# instead of the SwiftPM artifact path the `mac` job finds).
# * No Swift throwaway-key grep: superseded by the key-file fail-fast
# below, which is strictly stronger (it also checks the secret).
# * No first-launch-note release-body edit: two jobs read-modify-writing
# the same release body would race, and the note already ships INSIDE
# the DMG as FIRST-LAUNCH.txt (make-dmg.sh writes it whenever there is
# no Developer ID identity).
mac-iced:
needs: create-release
runs-on: macos-26 # Same pin as `mac`: bundle-iced.sh shares that job's
timeout-minutes: 90 # icon pipeline (bundle-lib.sh), which wants Xcode 26.
permissions:
contents: write
env:
CAN_NOTARIZE: ${{ secrets.MACOS_CERTIFICATE_P12_BASE64 != '' && secrets.MACOS_CERTIFICATE_PASSWORD != '' && secrets.ROOST_DEVELOPER_ID_IDENTITY != '' && secrets.APPLE_ID != '' && secrets.APPLE_TEAM_ID != '' && secrets.APPLE_APP_SPECIFIC_PASSWORD != '' }}
steps:
- uses: actions/checkout@v6
with:
persist-credentials: false
- name: Install Rust toolchain (from rust-toolchain.toml)
uses: actions-rust-lang/setup-rust-toolchain@v1
- uses: jdx/mise-action@v4
- name: Install uv
uses: astral-sh/setup-uv@v7
- name: Cache vendored libghostty-vt
id: cache-ghostty
uses: actions/cache@v5
with:
path: |
third_party/ghostty/out
third_party/ghostty/src
key: vendored-ghostty-${{ runner.os }}-${{ hashFiles('third_party/ghostty/build.sh') }}
- name: Build libghostty-vt
if: steps.cache-ghostty.outputs.cache-hit != 'true'
run: ./third_party/ghostty/build.sh
# Own cache key, not the `mac` job's `cargo-release-*`: these two jobs
# run in parallel and would otherwise both try to SAVE the same key.
- name: Cache cargo registry + target
uses: actions/cache@v5
with:
path: |
~/.cargo/registry
~/.cargo/git
target
key: cargo-release-iced-${{ runner.os }}-${{ hashFiles('**/Cargo.toml', 'rust-toolchain.toml') }}
restore-keys: cargo-release-iced-${{ runner.os }}-
# Deliberately uncached (unlike ci.yml's iced lanes): fetch.sh
# re-verifies the pinned SHA256 on every download, and the release path
# is the one place that check is worth paying for. bundle-iced.sh would
# call it anyway; running it up front also guarantees
# out/bin/sign_update exists for the signing step below.
- name: Stage Sparkle
run: ./third_party/sparkle/fetch.sh
# Same throwaway keychain the `mac` job builds, for the same reason.
- name: Import Developer ID certificate
if: env.CAN_NOTARIZE == 'true'
env:
MACOS_CERTIFICATE_P12_BASE64: ${{ secrets.MACOS_CERTIFICATE_P12_BASE64 }}
MACOS_CERTIFICATE_PASSWORD: ${{ secrets.MACOS_CERTIFICATE_PASSWORD }}
run: |
set -euo pipefail
# The decoded cert must never outlive this step, even on early failure.
# (The keychain itself must persist — later steps sign against it.)
trap 'rm -f "$RUNNER_TEMP/cert.p12"' EXIT
kc="$RUNNER_TEMP/roost-signing.keychain-db"
kp="$(openssl rand -base64 24)"
security create-keychain -p "$kp" "$kc"
security set-keychain-settings -lut 21600 "$kc"
security unlock-keychain -p "$kp" "$kc"
echo "$MACOS_CERTIFICATE_P12_BASE64" | base64 --decode > "$RUNNER_TEMP/cert.p12"
security import "$RUNNER_TEMP/cert.p12" -k "$kc" -P "$MACOS_CERTIFICATE_PASSWORD" -T /usr/bin/codesign
security set-key-partition-list -S apple-tool:,apple: -s -k "$kp" "$kc"
# Prepend our keychain to the user search list, keeping the existing
# ones; the command substitution must word-split into separate paths.
# shellcheck disable=SC2046
security list-keychains -d user -s "$kc" $(security list-keychains -d user | sed 's/"//g')
rm -f "$RUNNER_TEMP/cert.p12"
# BEFORE the build, because every one of these failures is a config
# mistake that would otherwise surface 40 minutes later, at signing
# time — or worse, not at all: a bundle stamped with a public key whose
# private half never signed anything looks perfect and simply never
# updates. mac/keys/README.md is the install procedure this points at.
- name: Verify the iced Sparkle key material
env:
ICED_PRIVATE_KEY: ${{ secrets.ROOST_ICED_SPARKLE_ED_PRIVATE_KEY }}
run: |
set -euo pipefail
key_file="mac/keys/roost-iced-sparkle-ed-public-key.txt"
fixture="tools/roosttest/fixtures/sparkle/TEST-ONLY-public-ed-key.txt"
if [ ! -s "${key_file}" ]; then
echo "::error::${key_file} is missing or empty — generate the Roost-Iced Sparkle keypair and commit its public half before tagging (mac/keys/README.md)."
exit 1
fi
if [ ! -f "${fixture}" ]; then
echo "::error::${fixture} not found — the TEST-ONLY-key guard can no longer compare against it; update this step's path."
exit 1
fi
if cmp -s "${key_file}" "${fixture}"; then
echo "::error::${key_file} is the TEST-ONLY fixture key (${fixture}) — a shipped feed must carry the maintainer's real public key (mac/keys/README.md)."
exit 1
fi
# The committed public half must itself be base64 of a 32-byte
# ed25519 public key — a malformed value would be PlistBuddy-stamped
# verbatim and produce a bundle that looks fine and never updates.
pub_bytes="$(base64 --decode < "${key_file}" 2>/dev/null | wc -c | tr -d ' ')"
if [ "${pub_bytes}" != "32" ]; then
echo "::error::${key_file} does not decode to a 32-byte ed25519 public key (got ${pub_bytes} bytes) — re-run the export in mac/keys/README.md."
exit 1
fi
if [ -z "${ICED_PRIVATE_KEY}" ]; then
echo "::error::ROOST_ICED_SPARKLE_ED_PRIVATE_KEY secret is unset — the iced DMG can't be signed for appcast-iced (mac/keys/README.md)."
exit 1
fi
W="$(mktemp -d)"
chmod 700 "$W"
trap 'rm -rf "$W"' EXIT
# Two layers, matching the SPARKLE_ED_PRIVATE_KEY convention: the
# secret is base64 of the key FILE, whose own contents are base64 of
# the ed25519 seed. Decode both so a mangled paste fails here.
# macOS `base64 --decode` is lenient (exit 0 on garbage), so a
# mangled paste is caught by the byte-count below rather than the
# decode calls — the error message covers both.
printf '%s' "${ICED_PRIVATE_KEY}" | base64 --decode > "${W}/key" 2>/dev/null || true
seed_bytes="$(base64 --decode < "${W}/key" 2>/dev/null | wc -c | tr -d ' ')"
# 32 = the ed25519 seed generate_keys writes today; 96 = the older
# format. sign_update accepts exactly these two (its own error
# message claims 64/96, but its code takes 32/96 — verified against
# the vendored 2.9.5).
case "${seed_bytes}" in
32|96) ;;
*)
echo "::error::ROOST_ICED_SPARKLE_ED_PRIVATE_KEY does not decode to a Sparkle ed25519 key (expected 32 or 96 bytes after both base64 layers, got ${seed_bytes}) — set it to \`base64 < <the file generate_keys -x wrote>\` (mac/keys/README.md)."
exit 1
;;
esac
# The two halves are validated independently above, which cannot
# catch the failure that matters most: a secret holding a DIFFERENT
# valid key than the committed public half. That ships a bundle
# whose SUPublicEDKey rejects every signature we produce, and
# Sparkle reports it to users as nothing at all — just "no update".
# Sparkle's own `sign_update --verify` takes a private key, so it
# cannot prove the pair; derive the public half from the seed
# instead. 96-byte legacy keys carry their own public half and are
# not derivable this way, so they are skipped rather than guessed.
if [ "${seed_bytes}" = "32" ]; then
derive='import base64,sys; from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey as K; from cryptography.hazmat.primitives.serialization import Encoding,PublicFormat; print(base64.b64encode(K.from_private_bytes(sys.stdin.buffer.read()).public_key().public_bytes(Encoding.Raw,PublicFormat.Raw)).decode())'
# --no-project: without it uv syncs the repo's docs project first, so a
# stale uv.lock or an unreachable theme git tag would fail the KEY
# check with a message about keys. Pinned for the same reason the
# rest of the release path is pinned.
derived="$(base64 --decode < "${W}/key" | uv run --no-project --with 'cryptography==46.0.3' python3 -c "${derive}")"
if [ "${derived}" != "$(tr -d '[:space:]' < "${key_file}")" ]; then
echo "::error::ROOST_ICED_SPARKLE_ED_PRIVATE_KEY is not the private half of ${key_file}. Signing with it would ship updates no installed Roost-Iced can verify. Re-run the ceremony in mac/keys/README.md."
exit 1
fi
echo "keypair match confirmed (derived public half equals the committed one)"
fi
rm -f "${W}/key"
echo "iced Sparkle key material looks well-formed (public key committed, private secret decodes to ${seed_bytes} bytes)."
# Unlike the Swift bundle (whose SUFeedURL/SUPublicEDKey are baked into
# Info.plist.template), the iced bundle ships feedless by default and
# gets the pair injected here — this env pair IS the feed enablement.
- name: Bundle Roost-Iced.app
env:
ROOST_DEVELOPER_ID_IDENTITY: ${{ env.CAN_NOTARIZE == 'true' && secrets.ROOST_DEVELOPER_ID_IDENTITY || '' }}
ROOST_ICED_SPARKLE_FEED_URL: https://charliek.github.io/roost/appcast-iced.xml
run: |
set -euo pipefail
ROOST_VERSION="${GITHUB_REF_NAME#v}" \
ROOST_ICED_SPARKLE_ED_PUBLIC_KEY="$(cat mac/keys/roost-iced-sparkle-ed-public-key.txt)" \
./mac/scripts/bundle-iced.sh release
# The iced twin of the `mac` job's release-bundle E2E, deliberately a
# cheap subset: the point is that the binary we are about to ship gets
# launched at least once in the release path (boot, IPC, the Sparkle
# seam), not a second full functional suite. test_sparkle's bundle lane
# points the updater at a loopback feed via the test-mode delegate
# override, so the real feed URL stamped above is not consulted here.
- name: E2E against the release bundle
env:
ROOST_TEST_TIMEOUT_SCALE: "3"
ROOST_TEST_MODE: "1"
ROOST_ICED_APP: mac/build/Roost-Iced.app
run: >
uv run --group test pytest
tools/roosttest/test_smoke.py
tools/roosttest/test_sparkle.py
--roost-target iced --roost-fresh -v
# The overrides are what keep this DMG's contents named Roost-Iced.app:
# with defaults make-dmg.sh packages the Swift bundle, and an iced DMG
# containing a "Roost.app" would drag-install OVER the Swift app.
- name: Make DMG
env:
ROOST_DEVELOPER_ID_IDENTITY: ${{ env.CAN_NOTARIZE == 'true' && secrets.ROOST_DEVELOPER_ID_IDENTITY || '' }}
ROOST_DMG_APP_DIR: mac/build/Roost-Iced.app
run: |
set -euo pipefail
ROOST_DMG_BASENAME="Roost-Iced-${GITHUB_REF_NAME#v}" \
./mac/scripts/make-dmg.sh "${GITHUB_REF_NAME#v}"
- name: Notarize + staple (skipped without credentials)
if: env.CAN_NOTARIZE == 'true'
env:
APPLE_ID: ${{ secrets.APPLE_ID }}
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
APPLE_APP_SPECIFIC_PASSWORD: ${{ secrets.APPLE_APP_SPECIFIC_PASSWORD }}
run: ./mac/scripts/notarize.sh "mac/build/Roost-Iced-${GITHUB_REF_NAME#v}.dmg"
# A distinct label, because the two DMG rows sit next to each other on
# the Release page and only the label says which one is the experiment.
# The file still downloads as Roost-Iced-<version>.dmg, which is what
# appcast-iced's enclosure URL is built from.
- name: Upload DMG to the Release
env:
GH_TOKEN: ${{ github.token }}
run: |
gh release upload "${GITHUB_REF_NAME}" \
"mac/build/Roost-Iced-${GITHUB_REF_NAME#v}.dmg#macOS Iced, experimental (.dmg)" --clobber
# appcast-iced, part 1 of 2. The Swift twin signs with the SwiftPM
# artifact's sign_update; there is no SwiftPM build here, so this uses
# the vendored (pinned + SHA-verified) stage the bundle already needed.
- name: Sign the DMG for Sparkle
env:
ICED_PRIVATE_KEY: ${{ secrets.ROOST_ICED_SPARKLE_ED_PRIVATE_KEY }}
run: |
set -euo pipefail
VER="${GITHUB_REF_NAME#v}"
DMG="mac/build/Roost-Iced-${VER}.dmg"
test -s "${DMG}" || { echo "::error::${DMG} missing — the DMG steps must run first."; exit 1; }
SIGN_UPDATE="third_party/sparkle/out/bin/sign_update"
test -x "${SIGN_UPDATE}" || { echo "::error::${SIGN_UPDATE} missing — the Stage Sparkle step should have produced it."; exit 1; }
W="$(mktemp -d)"
chmod 700 "$W"
trap 'rm -rf "$W"' EXIT
printf '%s' "${ICED_PRIVATE_KEY}" | base64 --decode > "${W}/key"
mkdir -p "${RUNNER_TEMP}/appcast-iced"
"${SIGN_UPDATE}" --ed-key-file "${W}/key" "${DMG}" > "${RUNNER_TEMP}/appcast-iced/sign-iced.txt"
rm -f "${W}/key"
# Not a secret — signature and length both end up in the public feed.
cat "${RUNNER_TEMP}/appcast-iced/sign-iced.txt"
- name: Hand sign-iced.txt to the appcast-iced job
uses: actions/upload-artifact@v7
with:
name: sparkle-sign-iced
path: ${{ runner.temp }}/appcast-iced/sign-iced.txt
if-no-files-found: error
# Artifacts are immutable — without this, re-running just this job
# dies on the existing name instead of recovering.
overwrite: true
# The one irreversible step in the pipeline: everything before it lands on a
# draft nobody can see, everything after it consumes a public release. So the
# assertions belong HERE, where the fix is still "delete the draft and
# re-run" rather than "a broken release is live and apt-charliek already
# republished the previous version".
#
# `needs: [linux, mac, mac-iced]` (and deliberately no `if: always()`) is
# what makes a failed build leave the draft unpublished. mac-iced is in that
# list even though the iced DMG is experimental: publish-release rejects
# unexpected AND missing assets, so the artifact set has to be all-or-
# nothing. An iced build failure therefore blocks the whole release —
# deliberate; draft-until-complete stays absolute.
publish-release:
needs: [linux, mac, mac-iced]
runs-on: ubuntu-latest
timeout-minutes: 10
permissions:
contents: write
steps:
- name: Assert the artifact set, then publish
env:
GH_TOKEN: ${{ github.token }}
# This job deliberately has no checkout — it only talks to the API.
# But `gh release …` (unlike `gh api <path>`) resolves the repo from
# a git remote, so without this every call fails with "not a git
# repository" and the existence check below reports it as "no
# release exists". v0.0.18 died here on this job's first real run.
GH_REPO: ${{ github.repository }}
run: |
set -euo pipefail
tag="${GITHUB_REF_NAME}"
ver="${tag#v}"
# nfpm's Debian version: it splits at the FIRST hyphen and rejoins
# with `~` (v0.0.18-rc1 -> 0.0.18~rc1), leaving later hyphens alone.
# Same substitution the linux job's smoke step makes; a blanket
# `tr '-' '~'` would mangle v1.2.3-rc-2.
deb_ver="${ver/-/\~}"
# Explicit existence check first: `gh release view` failing inside a
# command substitution wouldn't trip `set -e`, so a missing release
# would otherwise read as an empty isDraft and be misreported below
# as "already published".
if ! gh release view "${tag}" >/dev/null 2>&1; then
echo "::error::no release ${tag} exists — create-release should have left a draft. Nothing to publish."
exit 1
fi
# Read the draft flag into a variable rather than inline in a test.
# A standalone assignment DOES propagate a `gh` failure under
# `set -e`; the same substitution inside `[ ... ]` does not, and a
# transient API error would then read as an empty string. Empty is
# not "true", so the old form treated a failed query as "already
# published", skipped the publish, and let apt dispatch fire against
# a still-draft release — the exact bug this job exists to prevent.
is_draft="$(gh release view "${tag}" --json isDraft -q .isDraft)"
# Idempotent: re-running this job against an already-published
# release is a no-op, not a failure. Publishing twice is harmless,
# but the asset assertions below would be re-litigating a decision
# that can no longer be acted on.
if [ "${is_draft}" = "false" ]; then
echo "::notice::Release ${tag} is already published — nothing to do."
exit 0
fi
# Anything that is neither "true" nor "false" means the query did
# not answer the question. Fail closed.
if [ "${is_draft}" != "true" ]; then
echo "::error::could not determine whether ${tag} is a draft (got '${is_draft}'). Refusing to publish or to assume it is already published."
exit 1
fi
assets="$(gh release view "${tag}" --json assets \
-q '.assets[] | [.name, (.size|tostring)] | @tsv')"
echo "assets currently on the ${tag} draft:"
printf '%s\n' "${assets}"
# Exactly one of each, plausibly sized, named for this tag, and
# NOTHING ELSE. The `#Display Label` suffix on the upload steps only
# sets the Release page's label — the filename is what apt-charliek
# globs and what the appcast's enclosure URL is built from, so that
# is what we check.
#
# The floor is 1 MiB rather than "non-empty": every one of these is
# multi-megabyte, and a truncated upload lands somewhere above zero,
# so `> 0` would wave through exactly the corruption worth catching.
min_bytes=1048576
fail=0
# Space-separated, then split by IFS. No heredoc: its terminator has
# to sit at column 0, which would end this YAML block scalar. None of
# these filenames can contain a space.
#
# GitHub REWRITES asset filenames on upload, replacing anything
# outside [A-Za-z0-9._-] with a dot — so the `~` nfpm puts in a
# prerelease version does not survive: `roost_0.0.18~rc1_arm64.deb`
# is stored as `roost_0.0.18.rc1_arm64.deb`. Verified empirically
# against this repo with a throwaway draft. Without this the checks
# below report `found 0` for every prerelease AND flag the same
# asset as unexpected, so `publish-release` would fail and the draft
# would never flip — for exactly the `-rc` tags used to validate the
# Sparkle round-trip. Stable tags are unaffected, which is why it
# would have hidden until the first prerelease.
expected_names=""
for raw in "roost_${deb_ver}_amd64.deb" \
"roost_${deb_ver}_arm64.deb" \
"Roost-${ver}.dmg" \
"Roost-Iced-${ver}.dmg"; do
expected_names="${expected_names}${expected_names:+ }$(printf '%s' "${raw}" | tr -c 'A-Za-z0-9._-' '.')"
done
echo "expected asset names (after GitHub's filename sanitization): ${expected_names}"
for expected in ${expected_names}; do
sizes="$(printf '%s\n' "${assets}" \
| awk -F'\t' -v e="${expected}" '$1 == e { print $2 }')"
count="$(printf '%s\n' "${sizes}" | grep -c '^[0-9]' || true)"
if [ "${count}" -ne 1 ]; then
echo "::error::expected exactly one asset named ${expected} on ${tag}, found ${count}."
fail=1
continue
fi
if [ "${sizes}" -lt "${min_bytes}" ]; then
echo "::error::asset ${expected} on ${tag} is only ${sizes} bytes (expected at least ${min_bytes}) — it looks truncated."
fail=1
fi
done
# Reject anything else on the release. A reused draft from an earlier
# attempt can still be carrying an asset from a PREVIOUS version —
# apt-charliek globs `roost_*.deb`, so a stale one there is a wrong
# package shipped, and the named checks above would not see it.
unexpected="$(printf '%s\n' "${assets}" | cut -f1 \
| grep -vxF "$(printf '%s' "${expected_names}" | tr ' ' '\n')" || true)"
if [ -n "${unexpected}" ]; then
echo "::error::unexpected assets on ${tag} (a stale upload from an earlier attempt?):"
printf '%s\n' "${unexpected}" | sed 's/^/::error:: /'
fail=1
fi
if [ "${fail}" -ne 0 ]; then
echo "::error::refusing to publish ${tag}. The draft is untouched — inspect it with 'gh release view ${tag}', then re-run the failed jobs or 'gh release delete ${tag}'."
exit 1
fi
gh release edit "${tag}" --draft=false
echo "::notice::Published ${tag} — assets are now publicly downloadable."
# Sparkle appcast, part 2 of 2 (issue #122). Split out of `mac` (where the
# DMG is signed) so it lands strictly after publish-release: the entry it
# writes points at https://github.com/<repo>/releases/download/<tag>/<dmg>,
# which only resolves once the Release leaves draft. A feed published ahead
# of that would point every macOS updater at a 404 until a human noticed.
#
# Consequence worth naming: this runs AFTER the irreversible publish, so a
# failure here leaves a good release with a stale appcast. That's recoverable
# (re-run this job) — the reverse order is not.
appcast:
needs: publish-release
runs-on: ubuntu-latest
timeout-minutes: 15
# Read-only on purpose. The appcast commit is pushed with the release-bot
# App token, not GITHUB_TOKEN: main's ruleset requires ci-success, and only
# the App is in its bypass list (#136).
permissions:
contents: read
steps:
# Commit onto main, not the tagged commit — docs/appcast.xml is served
# from main via Pages. fetch-depth: 0 so the push retry below can rebase
# if main moves under us.
- uses: actions/checkout@v6
with:
ref: main
fetch-depth: 0
persist-credentials: false
- name: Download the Sparkle signature
uses: actions/download-artifact@v7
with:
name: sparkle-sign
path: ${{ runner.temp }}/appcast
- name: Ensure xmllint is available
run: |
set -euo pipefail
command -v xmllint >/dev/null || {
sudo apt-get update
sudo apt-get install -y libxml2-utils
}
- name: Mint release-bot App token
id: bot-token
uses: actions/create-github-app-token@v3
with:
client-id: ${{ secrets.RELEASE_BOT_CLIENT_ID }}
private-key: ${{ secrets.RELEASE_BOT_APP_KEY }}
# The whole point of the job split, asserted rather than assumed. Sparkle
# has no fallback for a dead enclosure: a feed pointing at a 404 breaks
# in-app updates for every user until someone notices. Range-GET the
# first byte rather than the whole DMG; retry because the release CDN can
# lag the publish flip by a few seconds.
- name: Verify the published DMG URL resolves
run: |
set -euo pipefail
url="https://github.com/${GITHUB_REPOSITORY}/releases/download/${GITHUB_REF_NAME}/Roost-${GITHUB_REF_NAME#v}.dmg"
echo "checking ${url}"
if ! curl -fsSL --retry 6 --retry-delay 5 --retry-all-errors \
--range 0-0 -o /dev/null "${url}"; then
echo "::error::${url} does not resolve — refusing to publish an appcast entry that points at a 404. Confirm the DMG asset is on the published release, then re-run this job."
exit 1
fi
- name: Append signed appcast entry
run: |
set -euo pipefail
SIGN_FILE="${RUNNER_TEMP}/appcast/sign.txt"
test -s "${SIGN_FILE}" || { echo "::error::${SIGN_FILE} missing or empty — the mac job's signing step should have uploaded it."; exit 1; }
# ROOST_REPO must match the URL verified above; the script's default
# is charliek/roost, which would silently diverge on a fork.
ROOST_VERSION="${GITHUB_REF_NAME#v}" \
ROOST_TAG="${GITHUB_REF_NAME}" \
ROOST_REPO="${GITHUB_REPOSITORY}" \
ROOST_SIGN_FILE="${SIGN_FILE}" \
python3 mac/scripts/update-appcast.py
xmllint --noout docs/appcast.xml
- name: Push signed appcast to main as release-bot
env:
GH_TOKEN: ${{ steps.bot-token.outputs.token }}
BOT_SLUG: ${{ steps.bot-token.outputs.app-slug }}
run: |
set -euo pipefail
if git diff --quiet docs/appcast.xml; then
# Genuinely unchanged: re-running the same tag against an appcast
# that already carries it. update-appcast.py preserves the prior
# pubDate so the diff is empty when nothing else moved.
echo "::notice::docs/appcast.xml unchanged for ${GITHUB_REF_NAME} — nothing to push."
exit 0
fi
# Bot identity from the action's app-slug output + the public /users
# endpoint (installation tokens can't call /user — 403). This keeps
# the identity portable if the App is ever renamed.
bot_id="$(gh api "/users/${BOT_SLUG}[bot]" --jq '.id')"
git config user.name "${BOT_SLUG}[bot]"
git config user.email "${bot_id}+${BOT_SLUG}[bot]@users.noreply.github.com"
git add docs/appcast.xml
# Defensive: assert ONLY docs/appcast.xml is staged. The threat we
# guard against is a future build step writing into some other
# tracked path that then gets swept into the bot's commit. Working-
# tree dirt (e.g. Cargo.lock drift from cargo build) is intentionally
# tolerated — we never `git add -A`, so it can't sneak in.
staged="$(git diff --cached --name-only)"
if [ "${staged}" != "docs/appcast.xml" ]; then
echo "::error::unexpected staged paths beyond docs/appcast.xml:"
echo "${staged}"
exit 1
fi
git commit -m "chore(appcast): publish ${GITHUB_REF_NAME} (#122)"
# URL-embedded token at push time — the http.extraheader pattern
# via `git -c` is NOT honored for the outgoing HTTP request
# (reproduced in v0.0.6: git -c http.<URL>.extraheader=… still
# prompts "could not read Username"). Embedding the token in
# the push URL works — it stays in argv, never written to
# .git/config on disk, single-tenant runner so other processes
# don't see it.
TOKEN_URL="https://x-access-token:${GH_TOKEN}@github.com/${GITHUB_REPOSITORY}.git"
push() { git push "${TOKEN_URL}" HEAD:main; }
for attempt in 1 2 3; do
if push; then
echo "::notice::Appcast pushed for ${GITHUB_REF_NAME} — docs.yml will redeploy Pages."
exit 0
fi
echo "::warning::push attempt ${attempt} failed; rebasing onto origin/main and retrying"
git fetch "${TOKEN_URL}" main
git rebase FETCH_HEAD || {
echo "::error::rebase conflict on docs/appcast.xml — concurrent appcast write? Re-run after investigating."
exit 1
}
done
echo "::error::push failed after 3 attempts; main moved faster than the retry loop. Re-run the workflow."
exit 1
# The Roost-Iced twin of `appcast`, writing docs/appcast-iced.xml — a
# separate feed signed with a separate keypair, so the two bundles can never
# offer each other's updates (mac/keys/README.md).
#
# `needs: appcast`, not `needs: publish-release`: both jobs push a bot commit
# to main, and running them in parallel makes those pushes race for the same
# ref. The cost is that a failed Swift appcast strands this one — recover
# with "Re-run failed jobs", which reruns `appcast` and then this.
appcast-iced:
needs: appcast
runs-on: ubuntu-latest
timeout-minutes: 15
# Read-only for the same reason as `appcast`: the commit is pushed with
# the release-bot App token, not GITHUB_TOKEN (#136).
permissions:
contents: read
steps:
# `ref: main` and not the tag: docs/appcast-iced.xml is served from main
# via Pages, and starting from main means this checkout already carries
# the Swift appcast commit `appcast` just pushed, so the push below
# fast-forwards instead of needing a rebase.
- uses: actions/checkout@v6
with:
ref: main
fetch-depth: 0
persist-credentials: false
- name: Download the Sparkle signature
uses: actions/download-artifact@v7
with:
name: sparkle-sign-iced
path: ${{ runner.temp }}/appcast-iced
- name: Ensure xmllint is available
run: |
set -euo pipefail
command -v xmllint >/dev/null || {
sudo apt-get update
sudo apt-get install -y libxml2-utils
}
- name: Mint release-bot App token
id: bot-token
uses: actions/create-github-app-token@v3
with:
client-id: ${{ secrets.RELEASE_BOT_CLIENT_ID }}
private-key: ${{ secrets.RELEASE_BOT_APP_KEY }}
# Same assertion as the Swift job makes, against the ICED asset name:
# a feed entry pointing at a 404 breaks in-app updates for every user
# of that build until someone notices.
- name: Verify the published DMG URL resolves
run: |
set -euo pipefail
url="https://github.com/${GITHUB_REPOSITORY}/releases/download/${GITHUB_REF_NAME}/Roost-Iced-${GITHUB_REF_NAME#v}.dmg"
echo "checking ${url}"
if ! curl -fsSL --retry 6 --retry-delay 5 --retry-all-errors \
--range 0-0 -o /dev/null "${url}"; then
echo "::error::${url} does not resolve — refusing to publish an appcast-iced entry that points at a 404. Confirm the Roost-Iced DMG asset is on the published release, then re-run this job."
exit 1
fi
- name: Append signed appcast-iced entry
run: |
set -euo pipefail
SIGN_FILE="${RUNNER_TEMP}/appcast-iced/sign-iced.txt"
test -s "${SIGN_FILE}" || { echo "::error::${SIGN_FILE} missing or empty — the mac-iced job's signing step should have uploaded it."; exit 1; }
# ROOST_APPCAST + ROOST_DMG_NAME are what make this the iced feed;
# ROOST_REPO must match the URL verified above.
ROOST_VERSION="${GITHUB_REF_NAME#v}" \
ROOST_TAG="${GITHUB_REF_NAME}" \
ROOST_REPO="${GITHUB_REPOSITORY}" \
ROOST_APPCAST="docs/appcast-iced.xml" \
ROOST_DMG_NAME="Roost-Iced-${GITHUB_REF_NAME#v}.dmg" \
ROOST_SIGN_FILE="${SIGN_FILE}" \
python3 mac/scripts/update-appcast.py
xmllint --noout docs/appcast-iced.xml
- name: Push signed appcast-iced to main as release-bot
env:
GH_TOKEN: ${{ steps.bot-token.outputs.token }}
BOT_SLUG: ${{ steps.bot-token.outputs.app-slug }}
run: |
set -euo pipefail
if git diff --quiet docs/appcast-iced.xml; then
# Genuinely unchanged: re-running the same tag against a feed that
# already carries it. update-appcast.py preserves the prior
# pubDate, so the diff is empty when nothing else moved.
echo "::notice::docs/appcast-iced.xml unchanged for ${GITHUB_REF_NAME} — nothing to push."
exit 0
fi
bot_id="$(gh api "/users/${BOT_SLUG}[bot]" --jq '.id')"
git config user.name "${BOT_SLUG}[bot]"
git config user.email "${bot_id}+${BOT_SLUG}[bot]@users.noreply.github.com"
git add docs/appcast-iced.xml
# Defensive, as in `appcast`: ONLY the iced feed may be staged. The
# Swift feed is explicitly not ours to touch — if it shows up here,
# something wrote to it after the `appcast` job's own commit.
staged="$(git diff --cached --name-only)"
if [ "${staged}" != "docs/appcast-iced.xml" ]; then
echo "::error::unexpected staged paths beyond docs/appcast-iced.xml:"
echo "${staged}"
exit 1
fi
git commit -m "chore(appcast-iced): publish ${GITHUB_REF_NAME} (#303)"
# URL-embedded token — see the `appcast` job for why http.extraheader
# is not usable here.
TOKEN_URL="https://x-access-token:${GH_TOKEN}@github.com/${GITHUB_REPOSITORY}.git"
push() { git push "${TOKEN_URL}" HEAD:main; }
for attempt in 1 2 3; do
if push; then
echo "::notice::appcast-iced pushed for ${GITHUB_REF_NAME} — docs.yml will redeploy Pages."
exit 0
fi
echo "::warning::push attempt ${attempt} failed; rebasing onto origin/main and retrying"
git fetch "${TOKEN_URL}" main
git rebase FETCH_HEAD || {
echo "::error::rebase conflict on docs/appcast-iced.xml — concurrent appcast write? Re-run after investigating."
exit 1
}
done
echo "::error::push failed after 3 attempts; main moved faster than the retry loop. Re-run the workflow."
exit 1
# Notify charliek/apt-charliek to republish its package index. Uses a
# release-bot App token scoped to the receiver (no per-pipeline PAT). The
# App must be installed on apt-charliek (it was as of 2026-05-29 — verify
# via sanity-check-app.yml after any rotation).
#
# Legacy alternative: APT_DISPATCH_TOKEN PAT. To use it instead, drop the
# mint step and set GH_TOKEN: ${{ secrets.APT_DISPATCH_TOKEN }} on the
# dispatch step. Roost migrated to the App in this commit.
#
# `needs: publish-release`, not `needs: linux`: the receiver's collect-debs.sh
# walks releases newest-first and only *warns* past one with no matching .deb,
# so dispatching against a release the debs aren't visible on republishes the
# PREVIOUS version — silently, with a green run on both sides. Its
# repo-scoped GITHUB_TOKEN has no push access here and GitHub lists drafts
# only to callers that do, so until the flip our release doesn't exist to it.
# This also closes the older race where the dispatch could fire while the mac
# job was still building the DMG.
dispatch-apt-charliek:
needs: publish-release
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- name: Decide whether to dispatch
id: guard
env:
CLIENT_ID: ${{ secrets.RELEASE_BOT_CLIENT_ID }}
run: |
set -euo pipefail
tag="${GITHUB_REF_NAME}"
case "${tag}" in
*-*)
echo "::notice::${tag} is a prerelease — skipping apt-charliek dispatch (include_prerelease:false)."
echo "skip=true" >> "$GITHUB_OUTPUT"
exit 0
;;
esac
if [ -z "${CLIENT_ID}" ]; then
echo "::warning::RELEASE_BOT_CLIENT_ID not set — skipping dispatch. apt-charliek self-heals on its next publish run (collect-debs.sh re-scans every package)."
echo "skip=true" >> "$GITHUB_OUTPUT"
else
echo "skip=false" >> "$GITHUB_OUTPUT"
fi
- name: Mint an apt-charliek token
if: steps.guard.outputs.skip == 'false'
id: apt
uses: actions/create-github-app-token@v3
with:
client-id: ${{ secrets.RELEASE_BOT_CLIENT_ID }}
private-key: ${{ secrets.RELEASE_BOT_APP_KEY }}
owner: charliek
repositories: apt-charliek
- name: Dispatch apt-charliek publish
if: steps.guard.outputs.skip == 'false'
env:
GH_TOKEN: ${{ steps.apt.outputs.token }}
run: |
set -euo pipefail
tag="${GITHUB_REF_NAME}"
gh api repos/charliek/apt-charliek/dispatches --method POST \
-f event_type=publish \
-F "client_payload[package]=roost" \
-F "client_payload[tag]=${tag}"
echo "::notice::Dispatched apt-charliek publish for roost ${tag} — watch https://github.com/charliek/apt-charliek/actions"