BangSo/.github/workflows/release-desktop.yml

325 lines
13 KiB
YAML

name: release-desktop
on:
push:
tags: ["v*"]
workflow_dispatch:
permissions:
contents: read
concurrency:
# Stable releases must validate and publish serially. Per-tag concurrency would let an older
# release validate before a newer one publishes, then overwrite GitHub's "latest" pointer.
group: release-desktop-stable
cancel-in-progress: false
jobs:
validate:
name: Validate stable release
runs-on: ubuntu-24.04
outputs:
tag: ${{ steps.release.outputs.tag }}
version: ${{ steps.release.outputs.version }}
sha: ${{ steps.release.outputs.sha }}
steps:
- uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 # v5
with:
fetch-depth: 0
persist-credentials: false
- name: Validate tag, version, ancestry, and monotonicity
id: release
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
RELEASE_TAG: ${{ github.ref_name }}
RELEASE_REF_TYPE: ${{ github.ref_type }}
shell: bash
run: |
set -euo pipefail
if [[ "$RELEASE_REF_TYPE" != "tag" ]]; then
echo "Desktop releases must run from a tag, including manual dispatches." >&2
exit 1
fi
if [[ ! "$RELEASE_TAG" =~ ^v([0-9]+)\.([0-9]+)\.([0-9]+)$ ]]; then
echo "Desktop releases use the stable vMAJOR.MINOR.PATCH channel." >&2
exit 1
fi
version="$(node -p "require('./apps/desktop/package.json').version")"
if [[ "$RELEASE_TAG" != "v$version" ]]; then
echo "Tag $RELEASE_TAG does not match desktop version $version." >&2
exit 1
fi
tag_sha="$(git rev-parse "${RELEASE_TAG}^{commit}")"
if [[ "$tag_sha" != "$GITHUB_SHA" ]]; then
echo "The workflow revision does not match the tagged commit." >&2
exit 1
fi
git fetch --no-tags origin main
if ! git merge-base --is-ancestor "$tag_sha" origin/main; then
echo "Desktop releases must point to a commit already on main." >&2
exit 1
fi
if gh api "repos/${GITHUB_REPOSITORY}/releases/tags/${RELEASE_TAG}" >/dev/null 2>&1; then
echo "A release already exists for $RELEASE_TAG; refusing to replace it." >&2
exit 1
fi
latest_tag="$(
gh api --paginate "repos/${GITHUB_REPOSITORY}/releases?per_page=100" --jq '.[] | select(.draft == false and .prerelease == false) | .tag_name' |
sed -nE '/^v[0-9]+\.[0-9]+\.[0-9]+$/p' |
sort -V |
tail -n 1
)"
if [[ -n "$latest_tag" ]]; then
highest="$(printf '%s\n%s\n' "$latest_tag" "$RELEASE_TAG" | sort -V | tail -n 1)"
if [[ "$highest" != "$RELEASE_TAG" || "$latest_tag" == "$RELEASE_TAG" ]]; then
echo "$RELEASE_TAG must be newer than published release $latest_tag." >&2
exit 1
fi
fi
echo "tag=$RELEASE_TAG" >> "$GITHUB_OUTPUT"
echo "version=$version" >> "$GITHUB_OUTPUT"
echo "sha=$tag_sha" >> "$GITHUB_OUTPUT"
build:
name: Build signed ${{ matrix.artifact }} artifacts
needs: validate
runs-on: ${{ matrix.os }}
timeout-minutes: 60
strategy:
fail-fast: false
matrix:
include:
- os: macos-14
artifact: macos
- os: windows-2022
artifact: windows
- os: ubuntu-24.04
artifact: linux
steps:
- uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 # v5
with:
ref: ${{ needs.validate.outputs.sha }}
persist-credentials: false
- uses: pnpm/action-setup@b906affcce14559ad1aafd4ab0e942779e9f58b1 # v4
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
with:
node-version: 22
- run: pnpm install --frozen-lockfile
- name: Build renderer and Electron main process
run: pnpm --filter @rakazo/web build && pnpm --filter @rakazo/desktop build
env:
RAKAZO_ALLOW_DEV_SECRETS: "1"
- name: Require macOS signing and notarization credentials
if: runner.os == 'macOS'
shell: bash
env:
CSC_LINK: ${{ secrets.DESKTOP_MAC_CSC_LINK }}
CSC_KEY_PASSWORD: ${{ secrets.DESKTOP_MAC_CSC_KEY_PASSWORD }}
APPLE_ID: ${{ secrets.APPLE_ID }}
APPLE_APP_SPECIFIC_PASSWORD: ${{ secrets.APPLE_APP_SPECIFIC_PASSWORD }}
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
run: |
set -euo pipefail
test -n "$CSC_LINK"
test -n "$CSC_KEY_PASSWORD"
test -n "$APPLE_ID"
test -n "$APPLE_APP_SPECIFIC_PASSWORD"
test -n "$APPLE_TEAM_ID"
- name: Package signed and notarized universal macOS app
if: runner.os == 'macOS'
env:
CSC_LINK: ${{ secrets.DESKTOP_MAC_CSC_LINK }}
CSC_KEY_PASSWORD: ${{ secrets.DESKTOP_MAC_CSC_KEY_PASSWORD }}
APPLE_ID: ${{ secrets.APPLE_ID }}
APPLE_APP_SPECIFIC_PASSWORD: ${{ secrets.APPLE_APP_SPECIFIC_PASSWORD }}
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
run: >-
pnpm --filter @rakazo/desktop exec electron-builder
--mac --universal --publish never -c.forceCodeSigning=true
- name: Verify macOS signature, notarization ticket, and update feed
if: runner.os == 'macOS'
shell: bash
run: |
set -euo pipefail
app="apps/desktop/out/mac-universal/Rakazo.app"
codesign --verify --deep --strict --verbose=2 "$app"
xcrun stapler validate "$app"
spctl --assess --type execute --verbose=2 "$app"
grep -Fqx "provider: github" "$app/Contents/Resources/app-update.yml"
grep -Fqx "owner: elie222" "$app/Contents/Resources/app-update.yml"
grep -Fqx "repo: rakazo" "$app/Contents/Resources/app-update.yml"
- name: Require Windows signing credentials
if: runner.os == 'Windows'
shell: bash
env:
WIN_CSC_LINK: ${{ secrets.DESKTOP_WIN_CSC_LINK }}
WIN_CSC_KEY_PASSWORD: ${{ secrets.DESKTOP_WIN_CSC_KEY_PASSWORD }}
run: |
set -euo pipefail
test -n "$WIN_CSC_LINK"
test -n "$WIN_CSC_KEY_PASSWORD"
- name: Package signed x64 Windows app
if: runner.os == 'Windows'
env:
WIN_CSC_LINK: ${{ secrets.DESKTOP_WIN_CSC_LINK }}
WIN_CSC_KEY_PASSWORD: ${{ secrets.DESKTOP_WIN_CSC_KEY_PASSWORD }}
run: >-
pnpm --filter @rakazo/desktop exec electron-builder
--win --x64 --publish never -c.forceCodeSigning=true
- name: Verify Windows Authenticode signature and publisher-bound update feed
if: runner.os == 'Windows'
shell: pwsh
run: |
$ErrorActionPreference = "Stop"
$executables = @(
Get-ChildItem "apps/desktop/out/*.exe"
Get-ChildItem "apps/desktop/out/win-unpacked/*.exe"
)
if ($executables.Count -lt 2) { throw "Windows installer or application was not created." }
foreach ($executable in $executables) {
$signature = Get-AuthenticodeSignature $executable.FullName
if ($signature.Status -ne "Valid") {
throw "$($executable.Name) signature is $($signature.Status)."
}
}
$config = "apps/desktop/out/win-unpacked/resources/app-update.yml"
if (-not (Select-String -Path $config -Pattern '^publisherName:' -Quiet)) {
throw "Windows update config is not bound to the signing publisher."
}
$feed = Get-Content -Raw $config
foreach ($expected in @("provider: github", "owner: elie222", "repo: rakazo")) {
if ($feed -notmatch "(?m)^$([regex]::Escape($expected))\r?$") {
throw "Windows update config missing '$expected'."
}
}
- name: Package x64 Linux AppImage
if: runner.os == 'Linux'
run: >-
pnpm --filter @rakazo/desktop exec electron-builder
--linux --x64 --publish never
- name: Verify Linux update feed is pinned to the official GitHub channel
if: runner.os == 'Linux'
shell: bash
env:
RELEASE_VERSION: ${{ needs.validate.outputs.version }}
run: |
set -euo pipefail
config="apps/desktop/out/linux-unpacked/resources/app-update.yml"
if [[ ! -f "$config" ]]; then
config="$(find apps/desktop/out -name app-update.yml -print -quit)"
fi
test -n "$config"
test -f "$config"
grep -Fqx "provider: github" "$config"
grep -Fqx "owner: elie222" "$config"
grep -Fqx "repo: rakazo" "$config"
test -f apps/desktop/out/latest-linux.yml
grep -Fqx "version: $RELEASE_VERSION" apps/desktop/out/latest-linux.yml
- name: Retain installers and updater metadata
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
with:
name: desktop-${{ matrix.artifact }}-${{ needs.validate.outputs.sha }}
path: |
apps/desktop/out/*.AppImage
apps/desktop/out/*.blockmap
apps/desktop/out/*.dmg
apps/desktop/out/*.exe
apps/desktop/out/latest*.yml
apps/desktop/out/*.zip
if-no-files-found: error
compression-level: 0
retention-days: 7
publish:
name: Attest and publish complete release
needs: [validate, build]
runs-on: ubuntu-24.04
timeout-minutes: 15
permissions:
actions: read
attestations: write
contents: write
id-token: write
steps:
- uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 # v5
with:
fetch-depth: 0
persist-credentials: false
ref: ${{ needs.validate.outputs.sha }}
- uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4
with:
pattern: desktop-*-${{ needs.validate.outputs.sha }}
path: release-artifacts
merge-multiple: true
- name: Verify the complete stable update feed
env:
RELEASE_SHA: ${{ needs.validate.outputs.sha }}
RELEASE_TAG: ${{ needs.validate.outputs.tag }}
RELEASE_VERSION: ${{ needs.validate.outputs.version }}
shell: bash
run: |
set -euo pipefail
git fetch --force origin "refs/tags/${RELEASE_TAG}:refs/tags/${RELEASE_TAG}"
if [[ "$(git rev-parse "${RELEASE_TAG}^{commit}")" != "$RELEASE_SHA" ]]; then
echo "The release tag moved after the build started." >&2
exit 1
fi
test -f release-artifacts/latest.yml
test -f release-artifacts/latest-mac.yml
test -f release-artifacts/latest-linux.yml
compgen -G 'release-artifacts/*.dmg' >/dev/null
compgen -G 'release-artifacts/*.zip' >/dev/null
compgen -G 'release-artifacts/*.exe' >/dev/null
compgen -G 'release-artifacts/*.AppImage' >/dev/null
feeds=(
release-artifacts/latest.yml
release-artifacts/latest-mac.yml
release-artifacts/latest-linux.yml
)
for feed in "${feeds[@]}"; do
grep -Fqx "version: $RELEASE_VERSION" "$feed"
done
(
cd release-artifacts
find . -maxdepth 1 -type f ! -name SHA256SUMS -print0 |
sort -z |
xargs -0 sha256sum > SHA256SUMS
)
- name: Attest release provenance
uses: actions/attest-build-provenance@977bb373ede98d70efdf65b84cb5f73e068dcc2a # v3
with:
subject-path: release-artifacts/*
- name: Create draft and upload every platform
id: create_release
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
RELEASE_TAG: ${{ needs.validate.outputs.tag }}
run: >-
gh release create "$RELEASE_TAG" release-artifacts/*
--draft --generate-notes --title "Rakazo $RELEASE_TAG" --verify-tag
- name: Publish the completed release
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
RELEASE_TAG: ${{ needs.validate.outputs.tag }}
run: gh release edit "$RELEASE_TAG" --draft=false --latest
- name: Remove an incomplete draft
if: failure() && steps.create_release.outcome != 'skipped'
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
RELEASE_TAG: ${{ needs.validate.outputs.tag }}
shell: bash
run: |
if [[ "$(gh api "repos/${GITHUB_REPOSITORY}/releases/tags/${RELEASE_TAG}" --jq .draft 2>/dev/null)" == "true" ]]; then
gh release delete "$RELEASE_TAG" --yes
fi