Skip to content

ToDesktop Build & Release #123

ToDesktop Build & Release

ToDesktop Build & Release #123

Workflow file for this run

name: ToDesktop Build & Release
on:
workflow_dispatch:
inputs:
pr_number:
description: Pull-request number whose body to use as release notes (optional; falls back to auto-generated notes if empty or absent)
required: false
type: string
default: ''
push:
tags:
- "v*"
permissions:
contents: write
# Required for Workload Identity Federation (keyless GCP auth) used to fetch
# the proprietary PP Formula font from gs://comfy-org-fonts before building.
id-token: write
concurrency:
group: build-release-${{ github.ref_name }}
cancel-in-progress: false
jobs:
build:
if: >
startsWith(github.ref, 'refs/tags/v') &&
(github.event_name == 'workflow_dispatch' || github.actor != 'github-actions[bot]')
runs-on: ubuntu-latest
name: ToDesktop Build
env:
TODESKTOP_ACCESS_TOKEN: ${{ secrets.TODESKTOP_ACCESS_TOKEN }}
TODESKTOP_EMAIL: ${{ secrets.TODESKTOP_EMAIL }}
outputs:
build_id: ${{ steps.extract.outputs.build_id }}
mac_url: ${{ steps.extract.outputs.mac_url }}
windows_url: ${{ steps.extract.outputs.windows_url }}
linux_url: ${{ steps.extract.outputs.linux_url }}
build_status: ${{ steps.extract.outputs.build_status }}
steps:
- name: Checkout
uses: actions/checkout@v6
- name: Install pnpm
uses: pnpm/action-setup@v4
with:
run_install: false
- name: Setup Node.js
uses: actions/setup-node@v6
with:
node-version: 22
cache: pnpm
- name: Validate ToDesktop credentials
run: |
if [ -z "$TODESKTOP_EMAIL" ] || [ -z "$TODESKTOP_ACCESS_TOKEN" ]; then
echo "TODESKTOP_EMAIL and TODESKTOP_ACCESS_TOKEN must be set in repository secrets."
exit 1
fi
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Resolve Datadog release version
id: datadog
shell: bash
run: |
echo "release_version=$(node ./scripts/datadog-release-version.cjs)" >> "$GITHUB_OUTPUT"
# Fetch the proprietary PP Formula font into the renderer public dir
# before the build. It is gitignored (never committed to this public
# repo) and pulled from a private bucket via keyless Workload Identity
# Federation. Release builds must ship the licensed display face, so the
# fetch fails loudly (no --soft-fail); a missing font would otherwise
# silently fall back to Inter.
- name: Authenticate to Google Cloud
uses: google-github-actions/auth@v2
with:
workload_identity_provider: 'projects/525069664901/locations/global/workloadIdentityPools/github-actions-pool/providers/github-oidc'
- name: Set up Cloud SDK
uses: google-github-actions/setup-gcloud@v2
- name: Fetch proprietary fonts
run: pnpm run font:fetch
- name: Build app
env:
VITE_DATADOG_RUM_VERSION: ${{ steps.datadog.outputs.release_version }}
run: pnpm run build
- name: Ensure tag matches package.json version
if: startsWith(github.ref, 'refs/tags/v')
shell: bash
run: |
TAG_VERSION="${GITHUB_REF_NAME#v}"
PKG_VERSION="$(node -p "require('./package.json').version")"
if [ "$TAG_VERSION" != "$PKG_VERSION" ]; then
echo "Tag version ($TAG_VERSION) does not match package.json version ($PKG_VERSION)."
exit 1
fi
- name: Upload Datadog sourcemaps
env:
DATADOG_API_KEY: ${{ secrets.DATADOG_API_KEY }}
DATADOG_SITE: us5.datadoghq.com
VITE_DATADOG_RUM_VERSION: ${{ steps.datadog.outputs.release_version }}
run: pnpm run datadog:sourcemaps:upload
- name: Install ToDesktop CLI
run: npm install --location=global @todesktop/cli@1.22.0
- name: Pre-fetch bootstrap-python for all platforms
# @todesktop/cli now eagerly validates the `extraResources` paths in
# todesktop.json *before* invoking the `beforeBuild` hook, so the
# platform-specific bootstrap-python directories must exist locally
# at the time `todesktop build` is invoked.
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: pnpm run bootstrap:fetch
- name: Verify bootstrap-python is populated for every platform
# Belt-and-braces check: even though fetch-bootstrap-python.mjs now
# exits non-zero on failure, surface the directory state in the build
# log and explicitly fail if any platform's Python binary is missing.
# The 0.6.4 installer shipped without bootstrap-python because a fetch
# error was swallowed and the directory was empty going into the
# packaging step. That class of failure must never be quiet again.
shell: bash
run: |
set -euo pipefail
echo "bootstrap-python tree:"
ls -la bootstrap-python/ || true
missing=0
for f in \
bootstrap-python/win-x64/python.exe \
bootstrap-python/mac-arm64/bin/python3 \
bootstrap-python/linux-x64/bin/python3
do
if [ ! -f "$f" ]; then
echo "::error::bootstrap-python verification failed: $f is missing"
missing=$((missing + 1))
else
echo " OK: $f"
fi
done
if [ "$missing" -gt 0 ]; then
echo "::error::$missing bootstrap-python binar(ies) missing — refusing to build a broken installer"
exit 1
fi
- name: Build with ToDesktop
shell: bash
run: |
set -euo pipefail
todesktop build --config=todesktop.json --ephemeral | tee todesktop-build.log
- name: Extract ToDesktop build ID
id: build_id
shell: bash
run: |
node <<'NODE'
const fs = require('node:fs')
const log = fs.readFileSync('todesktop-build.log', 'utf8')
const matches = [
...log.matchAll(
/https?:\/\/(?:app\.todesktop\.com\/apps|dl\.todesktop\.com)\/[^/\s]+\/builds\/([A-Za-z0-9_-]+)/g
)
]
const buildIds = [...new Set(matches.map((match) => match[1]).filter(Boolean))]
if (buildIds.length === 0) {
throw new Error('Could not extract a ToDesktop build ID from CLI output.')
}
if (buildIds.length > 1) {
throw new Error(`Multiple build IDs found in CLI output (${buildIds.join(', ')}).`)
}
const output = process.env.GITHUB_OUTPUT
if (!output) throw new Error('GITHUB_OUTPUT not set')
fs.appendFileSync(output, `build_id=${buildIds[0]}\n`)
NODE
- name: Fetch ToDesktop build metadata by ID
env:
BUILD_ID: ${{ steps.build_id.outputs.build_id }}
run: todesktop builds "${BUILD_ID}" --config=todesktop.json --exit > todesktop-build.raw 2>&1
- name: Extract build details
id: extract
shell: bash
run: |
node <<'NODE'
const fs = require('node:fs')
const parseBuildOutput = (raw, expectedBuildId) => {
const buildIdPattern =
/https?:\/\/(?:app\.todesktop\.com\/apps|dl\.todesktop\.com)\/[^/\s]+\/builds\/([A-Za-z0-9_-]+)/g
const buildIds = [...new Set([...raw.matchAll(buildIdPattern)].map((match) => match[1]))].filter(
Boolean
)
if (expectedBuildId && buildIds.length > 0 && !buildIds.includes(expectedBuildId)) {
throw new Error(
`Build ID mismatch in raw CLI output. Expected ${expectedBuildId}, found ${buildIds.join(', ')}`
)
}
const buildId = expectedBuildId || buildIds[0] || ''
if (!buildId) {
throw new Error('Could not extract a ToDesktop build ID from metadata output.')
}
const appUrl = [...raw.matchAll(/https?:\/\/app\.todesktop\.com\/apps\/[^/\s]+\/builds\/[A-Za-z0-9_-]+/g)]
.map((match) => match[0])
.at(-1)
const downloadUrl = [...raw.matchAll(/https?:\/\/dl\.todesktop\.com\/[^/\s]+\/builds\/[A-Za-z0-9_-]+/g)]
.map((match) => match[0])
.at(-1)
return {
id: buildId,
status: /Build complete!/i.test(raw) ? 'succeeded' : 'unknown',
standardUniversalDownloadUrl: downloadUrl || '',
__appUrl: appUrl || ''
}
}
const expectedBuildId = process.env.EXPECTED_BUILD_ID || ''
const rawBuildOutput = fs.readFileSync('todesktop-build.raw', 'utf8')
const build = parseBuildOutput(rawBuildOutput, expectedBuildId)
const safeUrl = (value) => (typeof value === 'string' ? value : '')
const macUrl = safeUrl(build?.mac?.standardDownloadUrl)
const windowsUrl = safeUrl(build?.windows?.standardDownloadUrl)
const linuxUrl = safeUrl(build?.linux?.standardDownloadUrl)
const universalUrl = safeUrl(build?.standardUniversalDownloadUrl)
const status = build?.status || 'unknown'
const buildId = build?.id || ''
if (!buildId) {
throw new Error('Build ID missing from ToDesktop build output')
}
if (expectedBuildId && String(buildId) !== expectedBuildId) {
throw new Error(`Build ID mismatch. Expected ${expectedBuildId}, received ${buildId}`)
}
const titleCase = (value) =>
String(value)
.replace(/[-_]/g, ' ')
.replace(/\b\w/g, (m) => m.toUpperCase())
const normalizeFormatName = (value) => {
if (value === 'appImage') return 'AppImage'
return titleCase(value)
}
const normalizeArchName = (value) => {
const lowered = String(value).toLowerCase()
if (lowered === 'x64') return 'x64'
if (lowered === 'arm64') return 'arm64'
if (lowered === 'ia32') return 'ia32'
if (lowered === 'universal') return 'universal'
return titleCase(value)
}
const collectArtifacts = (platformName, platformData) => {
const rows = []
const artifactDownloads = platformData?.artifactDownloads
if (!artifactDownloads || typeof artifactDownloads !== 'object') return rows
for (const [formatKey, formatValue] of Object.entries(artifactDownloads)) {
if (!formatValue || typeof formatValue !== 'object') continue
const directUrl = safeUrl(formatValue.standardUrl || formatValue.url)
if (directUrl) {
rows.push({
platform: platformName,
format: normalizeFormatName(formatKey),
arch: 'n/a',
size: Number.isFinite(formatValue.size) ? `${formatValue.size} bytes` : 'n/a',
url: directUrl
})
continue
}
for (const [archKey, archValue] of Object.entries(formatValue)) {
if (!archValue || typeof archValue !== 'object') continue
const url = safeUrl(archValue.standardUrl || archValue.url)
if (!url) continue
rows.push({
platform: platformName,
format: normalizeFormatName(formatKey),
arch: normalizeArchName(archKey),
size: Number.isFinite(archValue.size) ? `${archValue.size} bytes` : 'n/a',
url
})
}
}
return rows.sort((a, b) => {
if (a.platform !== b.platform) return a.platform.localeCompare(b.platform)
if (a.format !== b.format) return a.format.localeCompare(b.format)
return a.arch.localeCompare(b.arch)
})
}
const assetRows = [
...collectArtifacts('macOS', build?.mac),
...collectArtifacts('Windows', build?.windows),
...collectArtifacts('Linux', build?.linux)
]
const todesktopConfig = JSON.parse(fs.readFileSync('todesktop.json', 'utf8'))
const appId = todesktopConfig.id
const configBuildPageUrl =
appId && buildId ? `https://app.todesktop.com/apps/${appId}/builds/${buildId}` : ''
const buildPageUrl = safeUrl(build?.__appUrl) || configBuildPageUrl
fs.writeFileSync(
'release-assets.json',
JSON.stringify(
{
buildId,
status,
buildPageUrl,
universalUrl,
platformPages: {
macOS: macUrl || null,
windows: windowsUrl || null,
linux: linuxUrl || null
},
assets: assetRows
},
null,
2
)
)
const output = process.env.GITHUB_OUTPUT
if (!output) throw new Error('GITHUB_OUTPUT not set')
fs.appendFileSync(output, `build_id=${buildId}\n`)
fs.appendFileSync(output, `build_status=${status}\n`)
fs.appendFileSync(output, `mac_url=${macUrl}\n`)
fs.appendFileSync(output, `windows_url=${windowsUrl}\n`)
fs.appendFileSync(output, `linux_url=${linuxUrl}\n`)
const successStatuses = new Set(['succeeded', 'success', 'finished', 'complete', 'completed'])
if (!successStatuses.has(String(status).toLowerCase())) {
throw new Error(`ToDesktop build did not succeed (status=${status})`)
}
NODE
env:
EXPECTED_BUILD_ID: ${{ steps.build_id.outputs.build_id }}
- name: Upload release metadata artifact
uses: actions/upload-artifact@v7
with:
name: todesktop-release-assets
path: release-assets.json
release:
needs: build
if: >
startsWith(github.ref, 'refs/tags/v') &&
(github.event_name == 'workflow_dispatch' || github.actor != 'github-actions[bot]')
runs-on: ubuntu-latest
name: Publish Release
steps:
- name: Create GitHub Release (gh CLI)
shell: bash
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
PR_NUMBER: ${{ inputs.pr_number }}
run: |
set -euo pipefail
tag="${GITHUB_REF_NAME}"
if gh release view "${tag}" --repo "${GITHUB_REPOSITORY}" >/dev/null 2>&1; then
echo "Release for ${tag} already exists."
exit 0
fi
# Classify the tag. Anything with a pre-release suffix
# (vX.Y.Z-rc.N, -beta.N, -alpha.N, etc.) ships as a GitHub
# "Pre-release" — visible at /releases but not surfaced as
# "Latest" and not picked up by auto-update unless the user
# explicitly opts in. Plain vX.Y.Z is the stable channel.
if [[ "${tag}" =~ ^v[0-9]+\.[0-9]+\.[0-9]+-.+$ ]]; then
channel_flag="--prerelease"
channel_label="pre-release"
else
channel_flag="--latest"
channel_label="latest"
fi
# Prefer the release PR body as release notes when the
# workflow was dispatched with a pr_number. Falls back to
# GitHub's auto-generated PR-titles list when no PR is
# known (manual tag push, etc.) so we never ship a release
# with empty notes.
notes_file=""
if [[ -n "${PR_NUMBER}" ]]; then
notes_file="$(mktemp)"
if gh pr view "${PR_NUMBER}" \
--repo "${GITHUB_REPOSITORY}" \
--json body \
--jq '.body' > "${notes_file}" && [[ -s "${notes_file}" ]]; then
echo "Using PR #${PR_NUMBER} body as release notes."
else
echo "PR #${PR_NUMBER} body unavailable; falling back to auto-generated notes."
notes_file=""
fi
fi
if [[ -n "${notes_file}" ]]; then
gh release create "${tag}" \
--repo "${GITHUB_REPOSITORY}" \
${channel_flag} \
--notes-file "${notes_file}"
else
gh release create "${tag}" \
--repo "${GITHUB_REPOSITORY}" \
${channel_flag} \
--generate-notes
fi
echo "Published ${channel_label} release for ${tag}."