Skip to content

Desktop Release nightly / auto #242

Desktop Release nightly / auto

Desktop Release nightly / auto #242

Workflow file for this run

name: Desktop Release
run-name: Desktop Release ${{ inputs.channel || github.ref_name }} / ${{ inputs.build_scope || 'auto' }}
on:
push:
tags:
- "v*.*.*"
workflow_dispatch:
inputs:
channel:
description: "Release channel. Nightly builds from main and publishes a GitHub prerelease."
required: true
default: nightly
type: choice
options:
- nightly
- stable
version:
description: "Optional. Stable target version. Blank uses the next patch after the latest stable tag; nightly appends -nightly.YYYYMMDD.RUN."
required: false
type: string
build_scope:
description: "Artifacts to build. Auto uses mac-arm64-windows-linux for nightly and all for stable."
required: false
default: auto
type: choice
options:
- auto
- mac-arm64
- mac-arm64-windows
- mac-arm64-windows-linux
- mac-all
- linux
- all
deploy_web:
description: "Deploy the channel's hosted web app after the release publishes. Set to false to skip."
required: false
default: "true"
type: string
permissions:
actions: read
checks: read
contents: write
concurrency:
group: desktop-release-${{ github.ref }}
cancel-in-progress: true
jobs:
preflight:
name: Verify desktop release
runs-on: ubuntu-latest
timeout-minutes: 20
outputs:
version: ${{ steps.release_meta.outputs.version }}
tag: ${{ steps.release_meta.outputs.tag }}
name: ${{ steps.release_meta.outputs.name }}
channel: ${{ steps.release_meta.outputs.channel }}
is_prerelease: ${{ steps.release_meta.outputs.is_prerelease }}
make_latest: ${{ steps.release_meta.outputs.make_latest }}
build_scope: ${{ steps.release_meta.outputs.build_scope }}
build_windows: ${{ steps.release_meta.outputs.build_windows }}
build_macos: ${{ steps.release_meta.outputs.build_macos }}
build_linux: ${{ steps.release_meta.outputs.build_linux }}
macos_matrix: ${{ steps.release_meta.outputs.macos_matrix }}
ref: ${{ github.sha }}
steps:
- name: Checkout
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
fetch-depth: 0
- name: Setup Vite+
uses: voidzero-dev/setup-vp@313600b80b104eadebb9111787d37a2e83e014ca # v1.17.0
with:
node-version-file: package.json
cache: true
version: "0.2.4"
run-install: false
- name: Verify required CI checks passed
env:
GITHUB_TOKEN: ${{ github.token }}
run: node scripts/verify-release-ci.ts
- name: Install dependencies
run: vp install --frozen-lockfile
- name: Ensure Electron binary is installed
run: node scripts/ensure-electron-binary.ts
- id: release_meta
name: Resolve release metadata
shell: bash
env:
DISPATCH_CHANNEL: ${{ inputs.channel }}
DISPATCH_VERSION: ${{ inputs.version }}
DISPATCH_BUILD_SCOPE: ${{ inputs.build_scope }}
run: |
plain_stable_pattern='^[0-9]+\.[0-9]+\.[0-9]+$'
nightly_pattern='^[0-9]+\.[0-9]+\.[0-9]+-nightly\.[0-9]{8}\.[0-9]+$'
build_scope="${DISPATCH_BUILD_SCOPE:-auto}"
resolve_next_stable_version() {
git fetch --force --tags origin "refs/tags/*:refs/tags/*"
latest_stable_tag="$(
git tag --list 'v[0-9]*.[0-9]*.[0-9]*' --sort=-v:refname |
grep -E '^v[0-9]+\.[0-9]+\.[0-9]+$' |
head -n 1 || true
)"
if [[ -z "$latest_stable_tag" ]]; then
echo "0.0.1"
return
fi
latest_version="${latest_stable_tag#v}"
IFS='.' read -r major minor patch <<< "$latest_version"
echo "$major.$minor.$((patch + 1))"
}
resolve_nightly_metadata() {
target_version="${1:-}"
args=(
scripts/resolve-nightly-release.ts
--date "$(date -u +%Y%m%d)"
--run-number "$GITHUB_RUN_NUMBER"
--sha "$GITHUB_SHA"
)
if [[ -n "$target_version" ]]; then
args+=(--target-version "$target_version")
else
git fetch --force --tags origin "refs/tags/*:refs/tags/*"
fi
node "${args[@]}"
}
if [[ "${GITHUB_REF_TYPE}" == "tag" ]]; then
version="${GITHUB_REF_NAME#v}"
if [[ "$version" =~ $nightly_pattern ]]; then
channel="nightly"
name="Threadlines Nightly $version (${GITHUB_SHA:0:12})"
is_prerelease=true
make_latest=false
elif [[ "$version" =~ $plain_stable_pattern ]]; then
channel="stable"
name="Threadlines v$version"
is_prerelease=false
make_latest=true
else
echo "Invalid release tag: ${GITHUB_REF_NAME}" >&2
exit 1
fi
else
channel="${DISPATCH_CHANNEL:-nightly}"
raw="${DISPATCH_VERSION:-}"
if [[ "$channel" != "stable" && "$channel" != "nightly" ]]; then
echo "Invalid release channel: $channel" >&2
exit 1
fi
if [[ "$channel" == "nightly" && "${GITHUB_REF_NAME}" != "main" ]]; then
echo "Nightly releases must be dispatched from main. Current ref is ${GITHUB_REF_NAME}." >&2
exit 1
fi
raw="${raw#v}"
if [[ "$channel" == "stable" ]]; then
if [[ -z "${raw//[[:space:]]/}" ]]; then
version="$(resolve_next_stable_version)"
elif [[ "$raw" =~ $plain_stable_pattern ]]; then
version="$raw"
else
echo "Stable releases require a plain semver version, got: ${raw:-<blank>}" >&2
exit 1
fi
name="Threadlines v$version"
is_prerelease=false
make_latest=true
else
if [[ -z "${raw//[[:space:]]/}" ]]; then
nightly_metadata="$(resolve_nightly_metadata)"
elif [[ "$raw" =~ $plain_stable_pattern ]]; then
nightly_metadata="$(resolve_nightly_metadata "$raw")"
elif [[ "$raw" =~ $nightly_pattern ]]; then
version="$raw"
name="Threadlines Nightly $version (${GITHUB_SHA:0:12})"
else
echo "Nightly releases require a blank version, a plain target version, or a full nightly version. Got: ${raw:-<blank>}" >&2
exit 1
fi
if [[ -n "${nightly_metadata:-}" ]]; then
while IFS='=' read -r key value; do
case "$key" in
version) version="$value" ;;
name) name="$value" ;;
esac
done <<< "$nightly_metadata"
fi
is_prerelease=true
make_latest=false
fi
fi
if [[ ! "$version" =~ ^[0-9]+\.[0-9]+\.[0-9]+([.-][0-9A-Za-z.-]+)?$ ]]; then
echo "Invalid release version: ${version:-<blank>}" >&2
exit 1
fi
if [[ "$build_scope" == "auto" ]]; then
if [[ "$channel" == "stable" ]]; then
build_scope="all"
else
build_scope="mac-arm64-windows-linux"
fi
fi
echo "Resolved build scope: $build_scope"
build_macos=true
build_linux=false
case "$build_scope" in
mac-arm64)
build_windows=false
macos_matrix='{"include":[{"arch":"arm64","runner":"macos-15"}]}'
;;
mac-arm64-windows)
build_windows=true
macos_matrix='{"include":[{"arch":"arm64","runner":"macos-15"}]}'
;;
mac-arm64-windows-linux)
build_windows=true
build_linux=true
macos_matrix='{"include":[{"arch":"arm64","runner":"macos-15"}]}'
;;
mac-all)
build_windows=false
macos_matrix='{"include":[{"arch":"arm64","runner":"macos-15"},{"arch":"x64","runner":"macos-15-intel"}]}'
;;
linux)
build_windows=false
build_macos=false
build_linux=true
macos_matrix='{"include":[{"arch":"arm64","runner":"macos-15"}]}'
;;
all)
build_windows=true
build_linux=true
macos_matrix='{"include":[{"arch":"arm64","runner":"macos-15"},{"arch":"x64","runner":"macos-15-intel"}]}'
;;
*)
echo "Invalid build scope: ${build_scope:-<blank>}" >&2
exit 1
;;
esac
if [[ "${GITHUB_REF_TYPE}" != "tag" ]] && git rev-parse --verify --quiet "refs/tags/v$version" >/dev/null; then
echo "Release tag already exists: v$version" >&2
exit 1
fi
echo "version=$version" >> "$GITHUB_OUTPUT"
echo "tag=v$version" >> "$GITHUB_OUTPUT"
echo "name=$name" >> "$GITHUB_OUTPUT"
echo "channel=$channel" >> "$GITHUB_OUTPUT"
echo "is_prerelease=$is_prerelease" >> "$GITHUB_OUTPUT"
echo "make_latest=$make_latest" >> "$GITHUB_OUTPUT"
echo "build_scope=$build_scope" >> "$GITHUB_OUTPUT"
echo "build_windows=$build_windows" >> "$GITHUB_OUTPUT"
echo "build_macos=$build_macos" >> "$GITHUB_OUTPUT"
echo "build_linux=$build_linux" >> "$GITHUB_OUTPUT"
echo "macos_matrix=$macos_matrix" >> "$GITHUB_OUTPUT"
- name: Release smoke
run: node scripts/release-smoke.ts
prepare_release:
name: Prepare draft GitHub Release
needs: preflight
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- name: Checkout
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
ref: ${{ needs.preflight.outputs.ref }}
fetch-depth: 0
- name: Setup Vite+
uses: voidzero-dev/setup-vp@313600b80b104eadebb9111787d37a2e83e014ca # v1.17.0
with:
node-version-file: package.json
cache: true
version: "0.2.4"
run-install: false
- name: Install dependencies
run: vp install --frozen-lockfile
- name: Generate release notes
env:
GH_TOKEN: ${{ github.token }}
CHANNEL: ${{ needs.preflight.outputs.channel }}
TAG: ${{ needs.preflight.outputs.tag }}
CURRENT_REF: ${{ needs.preflight.outputs.ref }}
REPOSITORY: ${{ github.repository }}
run: |
set -euo pipefail
previous_tag="$(
node scripts/generate-release-notes.ts \
--channel "$CHANNEL" \
--current-tag "$TAG" \
--print-baseline-tag
)"
api_args=(
--method POST
"repos/$REPOSITORY/releases/generate-notes"
--field tag_name="$TAG"
--field target_commitish="$CURRENT_REF"
)
if [[ -n "$previous_tag" ]]; then
api_args+=(--field previous_tag_name="$previous_tag")
fi
github_notes_file=""
if gh api "${api_args[@]}" --jq '.body' > github-generated-notes.md; then
if [[ -s github-generated-notes.md ]]; then
github_notes_file="github-generated-notes.md"
else
echo "::warning::GitHub generated an empty release-notes body; using local release-note metadata instead."
fi
else
echo "::warning::GitHub's generated release-notes API failed; using local release-note metadata instead."
fi
args=(
--channel "$CHANNEL"
--current-tag "$TAG"
--current-ref "$CURRENT_REF"
--repository "$REPOSITORY"
--output release-notes.md
)
if [[ -n "$github_notes_file" ]]; then
args+=(--github-notes-file "$github_notes_file")
fi
if [[ "$CHANNEL" == "stable" ]]; then
highlights_file="apps/marketing/src/content/changelog/${TAG}.md"
if [[ ! -f "$highlights_file" ]]; then
echo "::error::Stable release content is missing at $highlights_file. Run the Prepare Stable Release Content workflow and merge its Draft PR before publishing $TAG."
exit 1
fi
args+=(--highlights-file "$highlights_file")
fi
node scripts/generate-release-notes.ts "${args[@]}"
- name: Create draft release
env:
GH_TOKEN: ${{ github.token }}
TAG: ${{ needs.preflight.outputs.tag }}
TARGET_REF: ${{ needs.preflight.outputs.ref }}
RELEASE_NAME: ${{ needs.preflight.outputs.name }}
IS_PRERELEASE: ${{ needs.preflight.outputs.is_prerelease }}
run: |
set -euo pipefail
args=(
release create "$TAG"
--target "$TARGET_REF"
--title "$RELEASE_NAME"
--notes-file release-notes.md
--draft
)
if [[ "$IS_PRERELEASE" == "true" ]]; then
args+=(--prerelease --latest=false)
else
args+=(--latest)
fi
gh "${args[@]}"
windows:
name: Build Windows x64 installer
needs:
- preflight
- prepare_release
if: ${{ needs.preflight.outputs.build_windows == 'true' }}
runs-on: windows-latest
timeout-minutes: 30
steps:
- name: Checkout
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
ref: ${{ needs.preflight.outputs.ref }}
fetch-depth: 0
- name: Setup Vite+
uses: voidzero-dev/setup-vp@313600b80b104eadebb9111787d37a2e83e014ca # v1.17.0
with:
node-version-file: package.json
cache: true
version: "0.2.4"
run-install: false
- name: Install dependencies
run: vp install --frozen-lockfile
- name: Ensure Electron binary is installed
run: node scripts/ensure-electron-binary.ts
- name: Align release package versions
run: node scripts/update-release-package-versions.ts "${{ needs.preflight.outputs.version }}"
- name: Require Windows signing for installer releases
shell: pwsh
env:
THREADLINES_DESKTOP_SIGNED: ${{ vars.THREADLINES_DESKTOP_SIGNED == 'true' && 'true' || 'false' }}
AZURE_TRUSTED_SIGNING_PUBLISHER_NAME: ${{ vars.AZURE_TRUSTED_SIGNING_PUBLISHER_NAME }}
AZURE_TRUSTED_SIGNING_ENDPOINT: ${{ vars.AZURE_TRUSTED_SIGNING_ENDPOINT }}
AZURE_TRUSTED_SIGNING_CERTIFICATE_PROFILE_NAME: ${{ vars.AZURE_TRUSTED_SIGNING_CERTIFICATE_PROFILE_NAME }}
AZURE_TRUSTED_SIGNING_ACCOUNT_NAME: ${{ vars.AZURE_TRUSTED_SIGNING_ACCOUNT_NAME }}
AZURE_CLIENT_ID: ${{ secrets.AZURE_CLIENT_ID }}
AZURE_CLIENT_SECRET: ${{ secrets.AZURE_CLIENT_SECRET }}
AZURE_TENANT_ID: ${{ secrets.AZURE_TENANT_ID }}
run: |
if ($env:THREADLINES_DESKTOP_SIGNED -ne "true") {
throw "Windows desktop releases must be signed. Configure THREADLINES_DESKTOP_SIGNED=true and the Azure Trusted Signing variables/secrets before publishing."
}
$required = @(
"AZURE_TRUSTED_SIGNING_PUBLISHER_NAME",
"AZURE_TRUSTED_SIGNING_ENDPOINT",
"AZURE_TRUSTED_SIGNING_CERTIFICATE_PROFILE_NAME",
"AZURE_TRUSTED_SIGNING_ACCOUNT_NAME",
"AZURE_CLIENT_ID",
"AZURE_CLIENT_SECRET",
"AZURE_TENANT_ID"
)
$missing = $required | Where-Object { [string]::IsNullOrWhiteSpace([Environment]::GetEnvironmentVariable($_)) }
if ($missing.Count -gt 0) {
throw "Windows Azure Trusted Signing is missing required environment values: $($missing -join ', ')"
}
- name: Prepare Azure Trusted Signing module
shell: pwsh
run: |
$moduleVersion = "0.5.8"
$moduleRoot = Join-Path $env:RUNNER_TEMP "trusted-signing-modules"
New-Item -ItemType Directory -Path $moduleRoot -Force | Out-Null
Save-Module `
-Name TrustedSigning `
-RequiredVersion $moduleVersion `
-Repository PSGallery `
-Path $moduleRoot `
-Force
$env:PSModulePath = "$moduleRoot$([IO.Path]::PathSeparator)$env:PSModulePath"
"PSModulePath=$env:PSModulePath" |
Out-File -FilePath $env:GITHUB_ENV -Encoding utf8 -Append
Import-Module TrustedSigning -RequiredVersion $moduleVersion -Force
Get-Command Invoke-TrustedSigning -ErrorAction Stop | Out-Null
- name: Build Windows installer
env:
THREADLINES_DESKTOP_SIGNED: ${{ vars.THREADLINES_DESKTOP_SIGNED == 'true' && 'true' || 'false' }}
AZURE_TRUSTED_SIGNING_PUBLISHER_NAME: ${{ vars.AZURE_TRUSTED_SIGNING_PUBLISHER_NAME }}
AZURE_TRUSTED_SIGNING_ENDPOINT: ${{ vars.AZURE_TRUSTED_SIGNING_ENDPOINT }}
AZURE_TRUSTED_SIGNING_CERTIFICATE_PROFILE_NAME: ${{ vars.AZURE_TRUSTED_SIGNING_CERTIFICATE_PROFILE_NAME }}
AZURE_TRUSTED_SIGNING_ACCOUNT_NAME: ${{ vars.AZURE_TRUSTED_SIGNING_ACCOUNT_NAME }}
AZURE_CLIENT_ID: ${{ secrets.AZURE_CLIENT_ID }}
AZURE_CLIENT_SECRET: ${{ secrets.AZURE_CLIENT_SECRET }}
AZURE_TENANT_ID: ${{ secrets.AZURE_TENANT_ID }}
THREADLINES_POSTHOG_KEY: ${{ secrets.THREADLINES_POSTHOG_KEY }}
THREADLINES_POSTHOG_HOST: ${{ vars.THREADLINES_POSTHOG_HOST }}
THREADLINES_TELEMETRY_ENABLED: ${{ vars.THREADLINES_TELEMETRY_ENABLED }}
run: >
vp exec vp run dist:desktop:artifact --
--platform win
--target nsis
--arch x64
--build-version "${{ needs.preflight.outputs.version }}"
--verbose
- name: Normalize Windows updater manifests
shell: pwsh
env:
CHANNEL: ${{ needs.preflight.outputs.channel }}
run: |
if ($env:CHANNEL -eq "nightly") {
if (-not (Test-Path release\nightly.yml)) {
if (Test-Path release\latest.yml) {
Copy-Item release\latest.yml release\nightly.yml -Force
} else {
throw "Nightly build did not produce nightly.yml or latest.yml."
}
}
if (-not (Test-Path release\latest.yml)) {
Copy-Item release\nightly.yml release\latest.yml -Force
}
} else {
if (-not (Test-Path release\latest.yml)) {
if (Test-Path release\nightly.yml) {
Move-Item release\nightly.yml release\latest.yml -Force
} else {
throw "Stable build did not produce latest.yml or nightly.yml."
}
}
Remove-Item release\nightly.yml -Force -ErrorAction SilentlyContinue
}
- name: Remove non-release build metadata
shell: pwsh
run: Remove-Item release\builder-debug*.yml -Force -ErrorAction SilentlyContinue
- name: List release assets
shell: pwsh
run: Get-ChildItem release | Select-Object Name, Length
- name: Verify Windows installer signature
shell: pwsh
run: |
$installers = Get-ChildItem release -Filter *.exe
if ($installers.Count -eq 0) {
throw "No Windows installer executable was produced."
}
$signatures = $installers | ForEach-Object {
$signature = Get-AuthenticodeSignature -FilePath $_.FullName
[pscustomobject]@{
Name = $_.Name
Status = $signature.Status
Publisher = $signature.SignerCertificate.Subject
Issuer = $signature.SignerCertificate.Issuer
}
}
$signatures | Format-Table -AutoSize
$invalid = $signatures | Where-Object { $_.Status -ne "Valid" }
if ($invalid.Count -gt 0) {
throw "Windows release assets must have a valid Authenticode signature before upload."
}
- name: Upload Windows assets to draft release
shell: bash
env:
GH_TOKEN: ${{ github.token }}
TAG: ${{ needs.preflight.outputs.tag }}
run: |
set -euo pipefail
shopt -s nullglob
assets=(release/*.exe release/*.zip release/*.blockmap release/*.yml)
if (( ${#assets[@]} == 0 )); then
echo "No Windows release assets found." >&2
exit 1
fi
gh release upload "$TAG" "${assets[@]}" --clobber
macos:
name: Build macOS ${{ matrix.arch }} artifact
needs:
- preflight
- prepare_release
if: ${{ needs.preflight.outputs.build_macos == 'true' }}
runs-on: ${{ matrix.runner }}
timeout-minutes: 50
strategy:
fail-fast: false
max-parallel: 1
matrix: ${{ fromJSON(needs.preflight.outputs.macos_matrix) }}
steps:
- name: Checkout
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
ref: ${{ needs.preflight.outputs.ref }}
fetch-depth: 0
- name: Setup Vite+
uses: voidzero-dev/setup-vp@313600b80b104eadebb9111787d37a2e83e014ca # v1.17.0
with:
node-version-file: package.json
cache: true
version: "0.2.4"
run-install: false
- name: Cache desktop build downloads
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
with:
path: |
~/Library/Caches/electron
~/Library/Caches/electron-builder
key: macos-${{ matrix.arch }}-desktop-${{ hashFiles('pnpm-lock.yaml', 'package.json', 'apps/desktop/package.json') }}
restore-keys: |
macos-${{ matrix.arch }}-desktop-
macos-desktop-
- name: Install dependencies
run: vp install --frozen-lockfile
- name: Ensure Electron binary is installed
run: node scripts/ensure-electron-binary.ts
- name: Align release package versions
run: node scripts/update-release-package-versions.ts "${{ needs.preflight.outputs.version }}"
- name: Require macOS signing for updater releases
env:
THREADLINES_DESKTOP_SIGNED: ${{ vars.THREADLINES_MACOS_SIGNED == 'true' && 'true' || 'false' }}
run: |
if [[ "$THREADLINES_DESKTOP_SIGNED" != "true" ]]; then
echo "::error::macOS desktop releases must be Developer ID signed for Squirrel.Mac updates. Configure THREADLINES_MACOS_SIGNED=true and the macOS signing/notarization secrets before publishing."
exit 1
fi
# actool from Xcode 26+ compiles the adaptive app icon (Assets.car) that
# follows the macOS 26 system icon appearance. The build degrades to the
# static icon when no Xcode 26 toolchain is present.
- name: Select Xcode 26 toolchain for adaptive app icon
run: |
latest_xcode="$(ls -d /Applications/Xcode_26*.app 2>/dev/null | sort -V | tail -n 1 || true)"
if [[ -n "$latest_xcode" ]]; then
sudo xcode-select -s "$latest_xcode/Contents/Developer"
xcodebuild -version
else
echo "::warning::No Xcode 26 toolchain on this runner; the packaged macOS icon will not adapt to the system icon appearance."
fi
- name: Build macOS artifact
timeout-minutes: 35
env:
THREADLINES_DESKTOP_SIGNED: ${{ vars.THREADLINES_MACOS_SIGNED == 'true' && 'true' || 'false' }}
CSC_LINK: ${{ secrets.MACOS_CSC_LINK }}
CSC_KEY_PASSWORD: ${{ secrets.MACOS_CSC_KEY_PASSWORD }}
APPLE_API_KEY_CONTENT: ${{ secrets.APPLE_API_KEY }}
APPLE_API_KEY_ID: ${{ secrets.APPLE_API_KEY_ID }}
APPLE_API_ISSUER: ${{ secrets.APPLE_API_ISSUER }}
THREADLINES_NOTARY_TIMEOUT_SECONDS: "900"
THREADLINES_NOTARY_POLL_SECONDS: "30"
THREADLINES_NOTARY_SUBMIT_ATTEMPTS: "3"
THREADLINES_POSTHOG_KEY: ${{ secrets.THREADLINES_POSTHOG_KEY }}
THREADLINES_POSTHOG_HOST: ${{ vars.THREADLINES_POSTHOG_HOST }}
THREADLINES_TELEMETRY_ENABLED: ${{ vars.THREADLINES_TELEMETRY_ENABLED }}
run: |
set -euo pipefail
if [[ -z "$APPLE_API_KEY_CONTENT" || -z "$APPLE_API_KEY_ID" || -z "$APPLE_API_ISSUER" ]]; then
echo "::error::macOS notarization requires APPLE_API_KEY, APPLE_API_KEY_ID, and APPLE_API_ISSUER secrets."
exit 1
fi
apple_api_key_path="$RUNNER_TEMP/AuthKey_${APPLE_API_KEY_ID}.p8"
printf "%s" "$APPLE_API_KEY_CONTENT" > "$apple_api_key_path"
chmod 600 "$apple_api_key_path"
unset APPLE_API_KEY_CONTENT
APPLE_API_KEY="$apple_api_key_path" vp exec vp run dist:desktop:artifact -- \
--platform mac \
--target dmg \
--arch "${{ matrix.arch }}" \
--build-version "${{ needs.preflight.outputs.version }}" \
--verbose
- name: Normalize macOS updater manifests
shell: bash
env:
ARCH: ${{ matrix.arch }}
CHANNEL: ${{ needs.preflight.outputs.channel }}
run: |
set -euo pipefail
normalize_manifest() {
local source="$1"
local target="$2"
if [[ -f "release/$source" ]]; then
if [[ "$source" != "$target" ]]; then
mv -f "release/$source" "release/$target"
fi
fi
}
if [[ "$CHANNEL" == "nightly" ]]; then
target_manifest="nightly-mac.yml"
fallback_manifest="latest-mac.yml"
alias_manifest="latest-mac.yml"
else
target_manifest="latest-mac.yml"
fallback_manifest="nightly-mac.yml"
alias_manifest=""
fi
if [[ "$ARCH" == "x64" ]]; then
arch_target_manifest="${target_manifest%.yml}-x64.yml"
if [[ -n "$alias_manifest" ]]; then
arch_alias_manifest="${alias_manifest%.yml}-x64.yml"
else
arch_alias_manifest=""
fi
else
arch_target_manifest="$target_manifest"
arch_alias_manifest="$alias_manifest"
fi
if [[ -f "release/$target_manifest" ]]; then
normalize_manifest "$target_manifest" "$arch_target_manifest"
elif [[ -f "release/$fallback_manifest" ]]; then
normalize_manifest "$fallback_manifest" "$arch_target_manifest"
else
echo "Build did not produce $target_manifest or $fallback_manifest." >&2
exit 1
fi
if [[ -n "$arch_alias_manifest" ]]; then
cp -f "release/$arch_target_manifest" "release/$arch_alias_manifest"
fi
for manifest in latest-mac.yml latest-mac-x64.yml nightly-mac.yml nightly-mac-x64.yml; do
if [[ "$manifest" != "$arch_target_manifest" && "$manifest" != "${arch_alias_manifest:-}" ]]; then
rm -f "release/$manifest"
fi
done
if [[ ! -f "release/$arch_target_manifest" ]]; then
echo "Normalized macOS manifest is missing: $arch_target_manifest" >&2
exit 1
fi
if [[ -n "$arch_alias_manifest" && ! -f "release/$arch_alias_manifest" ]]; then
echo "Normalized macOS alias manifest is missing: $arch_alias_manifest" >&2
exit 1
fi
- name: Remove non-release build metadata
run: rm -f release/builder-debug*.yml
- name: List macOS release assets
run: find release -maxdepth 1 -type f -print
- name: Upload macOS assets to draft release
env:
GH_TOKEN: ${{ github.token }}
TAG: ${{ needs.preflight.outputs.tag }}
run: |
set -euo pipefail
shopt -s nullglob
assets=(release/*.dmg release/*.zip release/*.zip.blockmap release/*mac*.yml)
if (( ${#assets[@]} == 0 )); then
echo "No macOS release assets found." >&2
exit 1
fi
gh release upload "$TAG" "${assets[@]}" --clobber
linux:
name: Build Linux x64 AppImage
needs:
- preflight
- prepare_release
if: ${{ needs.preflight.outputs.build_linux == 'true' }}
runs-on: ubuntu-latest
timeout-minutes: 30
steps:
- name: Checkout
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
ref: ${{ needs.preflight.outputs.ref }}
fetch-depth: 0
- name: Setup Vite+
uses: voidzero-dev/setup-vp@313600b80b104eadebb9111787d37a2e83e014ca # v1.17.0
with:
node-version-file: package.json
cache: true
version: "0.2.4"
run-install: false
- name: Cache desktop build downloads
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
with:
path: |
~/.cache/electron
~/.cache/electron-builder
key: linux-x64-desktop-${{ hashFiles('pnpm-lock.yaml', 'package.json', 'apps/desktop/package.json') }}
restore-keys: |
linux-x64-desktop-
- name: Install dependencies
run: vp install --frozen-lockfile
- name: Ensure Electron binary is installed
run: node scripts/ensure-electron-binary.ts
- name: Align release package versions
run: node scripts/update-release-package-versions.ts "${{ needs.preflight.outputs.version }}"
- name: Build Linux AppImage
env:
THREADLINES_POSTHOG_KEY: ${{ secrets.THREADLINES_POSTHOG_KEY }}
THREADLINES_POSTHOG_HOST: ${{ vars.THREADLINES_POSTHOG_HOST }}
THREADLINES_TELEMETRY_ENABLED: ${{ vars.THREADLINES_TELEMETRY_ENABLED }}
run: >
vp exec vp run dist:desktop:artifact --
--platform linux
--target AppImage
--arch x64
--build-version "${{ needs.preflight.outputs.version }}"
--verbose
- name: Normalize Linux updater manifests
env:
CHANNEL: ${{ needs.preflight.outputs.channel }}
run: |
set -euo pipefail
if [[ "$CHANNEL" == "nightly" ]]; then
if [[ ! -f release/nightly-linux.yml ]]; then
if [[ -f release/latest-linux.yml ]]; then
cp release/latest-linux.yml release/nightly-linux.yml
else
echo "Nightly build did not produce nightly-linux.yml or latest-linux.yml." >&2
exit 1
fi
fi
if [[ ! -f release/latest-linux.yml ]]; then
cp release/nightly-linux.yml release/latest-linux.yml
fi
else
if [[ ! -f release/latest-linux.yml ]]; then
if [[ -f release/nightly-linux.yml ]]; then
mv release/nightly-linux.yml release/latest-linux.yml
else
echo "Stable build did not produce latest-linux.yml or nightly-linux.yml." >&2
exit 1
fi
fi
rm -f release/nightly-linux.yml
fi
- name: Remove non-release build metadata
run: rm -f release/builder-debug*.yml
- name: List release assets
run: ls -la release
- name: Boot AppImage and verify backend serves
timeout-minutes: 3
run: |
set -euo pipefail
command -v xvfb-run >/dev/null || (sudo apt-get update && sudo apt-get install -y xvfb)
appimage="$(ls release/*.AppImage | head -1)"
chmod +x "$appimage"
export THREADLINES_HOME="$RUNNER_TEMP/threadlines-home"
xvfb-run -a "$appimage" --appimage-extract-and-run --no-sandbox > "$RUNNER_TEMP/app.log" 2>&1 &
app_pid=$!
cleanup() {
kill "$app_pid" 2>/dev/null || true
for _ in $(seq 1 5); do
if ! kill -0 "$app_pid" 2>/dev/null; then
break
fi
sleep 1
done
kill -KILL "$app_pid" 2>/dev/null || true
wait "$app_pid" 2>/dev/null || true
}
trap cleanup EXIT
probe_url() {
curl --connect-timeout 1 --max-time 1 -sf -o /dev/null "$1"
}
url=""
for _ in $(seq 1 45); do
if probe_url "http://127.0.0.1:3773/"; then
url="http://127.0.0.1:3773/"
break
fi
if ! kill -0 "$app_pid" 2>/dev/null; then
echo "App process exited early." >&2
break
fi
sleep 2
done
if [ -z "$url" ]; then
for port in $(ss -tlnp 2>/dev/null | awk '/threadlines|node|electron/ {split($4, a, ":"); print a[length(a)]}' | sort -u); do
if probe_url "http://127.0.0.1:${port}/"; then
url="http://127.0.0.1:${port}/"
break
fi
done
fi
if [ -z "$url" ]; then
echo "AppImage backend never became reachable." >&2
cat "$RUNNER_TEMP/app.log" || true
exit 1
fi
echo "Backend serving at $url"
- name: Dump Linux app log on failure
if: failure()
run: cat "$RUNNER_TEMP/app.log" 2>/dev/null || true
- name: Upload Linux assets to draft release
env:
GH_TOKEN: ${{ github.token }}
TAG: ${{ needs.preflight.outputs.tag }}
run: |
set -euo pipefail
shopt -s nullglob
assets=(release/*.AppImage release/*-linux.yml)
if (( ${#assets[@]} == 0 )); then
echo "No Linux release assets found." >&2
exit 1
fi
gh release upload "$TAG" "${assets[@]}" --clobber
publish:
name: Publish GitHub Release
needs:
- preflight
- prepare_release
- windows
- macos
- linux
if: ${{ always() && needs.prepare_release.result == 'success' && (needs.macos.result == 'success' || needs.macos.result == 'skipped') && (needs.windows.result == 'success' || needs.windows.result == 'skipped') && (needs.linux.result == 'success' || needs.linux.result == 'skipped') && (needs.macos.result == 'success' || needs.windows.result == 'success' || needs.linux.result == 'success') }}
runs-on: ubuntu-latest
timeout-minutes: 30
permissions:
contents: write
actions: write
steps:
- name: Checkout
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
ref: ${{ needs.preflight.outputs.ref }}
fetch-depth: 0
- name: Setup Vite+
uses: voidzero-dev/setup-vp@313600b80b104eadebb9111787d37a2e83e014ca # v1.17.0
with:
node-version-file: package.json
cache: true
version: "0.2.4"
run-install: false
- name: Install dependencies
run: vp install --frozen-lockfile --ignore-scripts
- name: Download updater manifests
if: ${{ needs.macos.result == 'success' }}
shell: bash
env:
GH_TOKEN: ${{ github.token }}
TAG: ${{ needs.preflight.outputs.tag }}
CHANNEL: ${{ needs.preflight.outputs.channel }}
run: |
set -euo pipefail
rm -rf release-manifests
mkdir -p release-manifests
args=(
release download "$TAG"
--dir release-manifests
--clobber
)
args+=(--pattern "*mac*.yml")
gh "${args[@]}"
ls -la release-manifests
- name: Merge macOS updater manifests
if: ${{ needs.macos.result == 'success' }}
shell: bash
env:
GH_TOKEN: ${{ github.token }}
TAG: ${{ needs.preflight.outputs.tag }}
CHANNEL: ${{ needs.preflight.outputs.channel }}
run: |
set -euo pipefail
merge_mac_manifest() {
local primary="$1"
local secondary="$2"
local primary_path="release-manifests/$primary"
local secondary_path="release-manifests/$secondary"
if [[ -f "$primary_path" && -f "$secondary_path" ]]; then
node scripts/merge-update-manifests.ts \
--platform mac \
"$primary_path" \
"$secondary_path" \
"$primary_path"
gh release upload "$TAG" "$primary_path" --clobber
gh release delete-asset "$TAG" "$secondary" --yes
elif [[ -f "$primary_path" ]]; then
gh release upload "$TAG" "$primary_path" --clobber
elif [[ -f "$secondary_path" ]]; then
mv "$secondary_path" "$primary_path"
gh release upload "$TAG" "$primary_path" --clobber
gh release delete-asset "$TAG" "$secondary" --yes
else
echo "Missing macOS updater manifest pair: $primary / $secondary" >&2
exit 1
fi
}
if [[ "$CHANNEL" == "nightly" ]]; then
merge_mac_manifest "nightly-mac.yml" "nightly-mac-x64.yml"
cp -f release-manifests/nightly-mac.yml release-manifests/latest-mac.yml
gh release upload "$TAG" release-manifests/latest-mac.yml --clobber
gh release delete-asset "$TAG" "latest-mac-x64.yml" --yes || true
else
merge_mac_manifest "latest-mac.yml" "latest-mac-x64.yml"
gh release delete-asset "$TAG" "nightly-mac.yml" --yes || true
gh release delete-asset "$TAG" "nightly-mac-x64.yml" --yes || true
fi
- name: Publish draft release
env:
GH_TOKEN: ${{ github.token }}
TAG: ${{ needs.preflight.outputs.tag }}
IS_PRERELEASE: ${{ needs.preflight.outputs.is_prerelease }}
run: |
set -euo pipefail
args=(release edit "$TAG" --draft=false)
if [[ "$IS_PRERELEASE" == "true" ]]; then
args+=(--prerelease)
else
args+=(--latest)
fi
gh "${args[@]}"
- name: Publish npm package for stable release
if: ${{ needs.preflight.outputs.channel == 'stable' }}
env:
GH_TOKEN: ${{ github.token }}
TAG: ${{ needs.preflight.outputs.tag }}
VERSION: ${{ needs.preflight.outputs.version }}
run: |
set -euo pipefail
gh workflow run npm-package.yml \
--ref "$TAG" \
-f version="$VERSION" \
-f dist_tag=latest \
-f publish=true
echo "Dispatched npm publish of @threadlines/server@$VERSION from $TAG."
deploy_web_app:
name: Deploy app.threadlines.dev
needs:
- preflight
- publish
# Real reason this gate never fired: an `if` without a status-check
# function gets an implicit `success()`, which evaluates over the
# TRANSITIVE needs chain — and `linux` (skipped on most releases) is an
# ancestor via `publish`. `publish` shields itself with always(); this job
# must do the same and check its direct needs' results explicitly.
if: ${{ !cancelled() && needs.preflight.result == 'success' && needs.publish.result == 'success' && (github.event_name != 'workflow_dispatch' || inputs.deploy_web != 'false') }}
uses: ./.github/workflows/deploy-web.yml
with:
ref: ${{ needs.preflight.outputs.ref }}
version: ${{ needs.preflight.outputs.version }}
channel: ${{ needs.preflight.outputs.channel == 'nightly' && 'nightly' || 'latest' }}
secrets: inherit
cleanup_failed_release:
name: Cleanup failed draft release
needs:
- preflight
- prepare_release
- windows
- macos
- linux
- publish
if: ${{ always() && needs.prepare_release.result == 'success' && ((needs.windows.result != 'success' && needs.windows.result != 'skipped') || (needs.macos.result != 'success' && needs.macos.result != 'skipped') || (needs.linux.result != 'success' && needs.linux.result != 'skipped') || needs.publish.result != 'success') }}
runs-on: ubuntu-latest
timeout-minutes: 5
steps:
- name: Delete failed draft release
env:
GH_TOKEN: ${{ github.token }}
TAG: ${{ needs.preflight.outputs.tag }}
EVENT_NAME: ${{ github.event_name }}
REPOSITORY: ${{ github.repository }}
run: |
set -euo pipefail
if [[ "$EVENT_NAME" == "workflow_dispatch" ]]; then
echo "Deleting failed workflow-dispatch release $TAG and its generated tag."
gh release delete "$TAG" --repo "$REPOSITORY" --yes
gh api --method DELETE "repos/$REPOSITORY/git/refs/tags/$TAG" || true
exit 0
fi
is_draft="$(gh release view "$TAG" --repo "$REPOSITORY" --json isDraft -q .isDraft 2>/dev/null || echo false)"
if [[ "$is_draft" != "true" ]]; then
echo "Release $TAG is not a draft; leaving it in place."
exit 0
fi
gh release delete "$TAG" --repo "$REPOSITORY" --yes