325 lines
13 KiB
YAML
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
|