Desktop Release nightly / auto #242
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: 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 |