fix(release): give publish-release a repo context for gh #21
Workflow file for this run
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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" |