Skip to content

Merge pull request #19 from TomCohenDev/fork-sync/2026-11-05-2031 #24

Merge pull request #19 from TomCohenDev/fork-sync/2026-11-05-2031

Merge pull request #19 from TomCohenDev/fork-sync/2026-11-05-2031 #24

Workflow file for this run

name: Fork Release
# Builds the desktop apps and the headless server archives from fork main and
# publishes a nightly-channel release. The desktop apps update from it with
# electron-updater. Servers update from it with the upstream boot-service
# updater (`t3 update`, "Update everything"), which reads the server archives
# t3-<version>-linux-<arch>.tar.gz and SHA256SUMS. SHA256SUMS is uploaded
# last, so a release without it is not ready for servers. It does not touch
# main: .github/workflows/fork-sync.yml merges upstream. See docs/fork/operations.md "Fork Release".
on:
# Every push to main, so that servers get each change from a release. A push
# that changes only docs, markdown or the portal builds no release. The next
# release lists its commits, because the notes start at the previous tag.
push:
branches: [main]
paths-ignore:
- docs/**
- "**/*.md"
workflow_dispatch:
inputs:
notify_agent:
description: Call the agent webhook if this run fails. The agent sets false on its own retries.
type: boolean
default: true
# One run at a time, and a run is never cancelled, so a release is never half
# published. GitHub keeps one waiting run: a newer push replaces it, so a
# burst of pushes builds only the running commit and the newest one.
concurrency:
group: fork-release
cancel-in-progress: false
permissions:
contents: read
jobs:
# Also builds the JS bundle: a second job spent a minute on its own checkout and setup.
prepare:
name: Prepare release and build JS bundle
runs-on: ubuntu-24.04
timeout-minutes: 30
outputs:
sha: ${{ steps.commit.outputs.sha }}
released: ${{ steps.released.outputs.released }}
version: ${{ steps.version.outputs.version }}
tag: ${{ steps.version.outputs.tag }}
name: ${{ steps.version.outputs.name }}
notes: ${{ steps.notes.outputs.notes }}
steps:
# The full history and the release tags, without file contents, for the release notes.
- name: Checkout
uses: actions/checkout@v6
with:
fetch-depth: 0
filter: blob:none
sparse-checkout: |
/*
!/.repos/
sparse-checkout-cone-mode: false
- id: commit
run: echo "sha=$(git rev-parse HEAD)" >> "$GITHUB_OUTPUT"
- id: released
name: Check for an existing release
env:
GH_TOKEN: ${{ github.token }}
run: |
set -euo pipefail
sha="${{ steps.commit.outputs.sha }}"
existing="$(gh release list --repo "$GITHUB_REPOSITORY" --exclude-drafts --limit 30 --json tagName,name \
--jq "[.[] | select(.name | contains(\"(${sha:0:12})\"))][0].tagName // empty")"
if [[ -n "$existing" ]]; then
echo "Commit ${sha} is already released as ${existing}."
echo "released=true" >> "$GITHUB_OUTPUT"
else
echo "released=false" >> "$GITHUB_OUTPUT"
fi
- uses: voidzero-dev/setup-vp@v1
with:
node-version-file: package.json
cache: true
run-install: false
- run: vp install --filter=t3... --filter=@t3tools/web... --filter=@t3tools/desktop... --filter=@t3tools/scripts...
# <base>-nightly.<UTC date>.<run number x 10000>. The date and the run number
# only grow. The gap of 10000 leaves room for local builds between two CI
# releases (scripts/fork-deploy-local.sh). The app needs one integer here.
- id: version
name: Resolve release version
run: |
node scripts/resolve-nightly-release.ts \
--date "$(date -u +%Y%m%d)" \
--run-number "$(( ${{ github.run_number }} * 10000 ))" \
--sha "${{ steps.commit.outputs.sha }}" \
--github-output
# The commits after the previous release tag, from git. See scripts/fork-release-notes.ts.
# A failed builder never stops the release: it warns and falls back to a one-line note.
- id: notes
name: Write release notes
run: |
set -euo pipefail
sha="${{ steps.commit.outputs.sha }}"
if ! node scripts/fork-release-notes.ts "$sha" > "$RUNNER_TEMP/notes.md" 2> "$RUNNER_TEMP/notes-error.log"; then
cat "$RUNNER_TEMP/notes-error.log" >&2
echo "::warning title=Release notes failed::$(tail -n 1 "$RUNNER_TEMP/notes-error.log")"
echo "Fork build of pingdotgg/t3code main plus fork commits, from ${sha}. (Release notes failed; see the workflow log.)" > "$RUNNER_TEMP/notes.md"
fi
cat "$RUNNER_TEMP/notes.md"
{
echo "notes<<FORK_RELEASE_NOTES_EOF"
cat "$RUNNER_TEMP/notes.md"
echo "FORK_RELEASE_NOTES_EOF"
} >> "$GITHUB_OUTPUT"
- if: steps.released.outputs.released != 'true'
run: node scripts/update-release-package-versions.ts "${{ steps.version.outputs.version }}"
- if: steps.released.outputs.released != 'true'
run: sudo apt-get update && sudo apt-get install -y libsecret-1-dev pkg-config
- if: steps.released.outputs.released != 'true'
env:
# Optional T3 Connect config. Empty values build the app without T3 Connect.
T3CODE_CLERK_PUBLISHABLE_KEY: ${{ vars.CLERK_PUBLISHABLE_KEY }}
T3CODE_CLERK_JWT_TEMPLATE: ${{ vars.CLERK_JWT_TEMPLATE }}
T3CODE_CLERK_CLI_OAUTH_CLIENT_ID: ${{ vars.CLERK_CLI_OAUTH_CLIENT_ID }}
T3CODE_RELAY_URL: ${{ vars.RELAY_URL }}
run: vp run build:desktop
- if: steps.released.outputs.released != 'true'
uses: actions/upload-artifact@v7
with:
name: js-bundle
path: |
apps/server/dist
apps/desktop/dist-electron
if-no-files-found: error
retention-days: 1
desktop:
name: Desktop ${{ matrix.label }}
needs: [prepare]
if: needs.prepare.outputs.released != 'true'
runs-on: ${{ matrix.runner }}
timeout-minutes: 60
permissions:
contents: write
# Each platform publishes its own files, so the Mac release does not wait
# for the slower Windows build, and a Windows failure does not stop it.
strategy:
fail-fast: false
matrix:
include:
- label: macOS arm64
runner: macos-15
platform: mac
target: dmg
arch: arm64
rust_target: aarch64-apple-darwin
- label: macOS x64
runner: macos-15-intel
platform: mac
target: dmg
arch: x64
rust_target: x86_64-apple-darwin
- label: Windows x64
runner: windows-2025
platform: win
target: nsis
arch: x64
rust_target: x86_64-pc-windows-msvc
# node-pty compiles from source on Linux, so each arch builds on its own hardware.
- label: Linux x64
runner: ubuntu-24.04
platform: linux
target: AppImage
arch: x64
rust_target: x86_64-unknown-linux-gnu
resource_key: linux-x64
- label: Linux arm64
runner: ubuntu-24.04-arm
platform: linux
target: AppImage
arch: arm64
rust_target: aarch64-unknown-linux-gnu
resource_key: linux-arm64
steps:
- uses: actions/checkout@v6
with:
ref: ${{ needs.prepare.outputs.sha }}
sparse-checkout: |
/*
!/.repos/
sparse-checkout-cone-mode: false
- uses: voidzero-dev/setup-vp@v1
with:
node-version-file: package.json
cache: ${{ matrix.platform != 'win' }}
run-install: false
- run: vp install --filter=@t3tools/desktop... --filter=t3... --filter=@t3tools/scripts...
- run: node scripts/update-release-package-versions.ts "${{ needs.prepare.outputs.version }}"
- uses: actions/download-artifact@v8
with:
name: js-bundle
path: apps
# Same packages and toolchain as upstream's release-desktop.yml.
- name: Install Linux desktop build libraries
if: matrix.platform == 'linux'
run: sudo apt-get update && sudo apt-get install -y libsecret-1-dev pkg-config imagemagick
# Same caches as upstream's release-desktop.yml. An uncached build took up to 105 seconds.
# Limit: the keys hold the crate sources, not the Rust version, so a new stable Rust
# alone rebuilds nothing. Add the Rust version to the keys if a toolchain change matters.
- name: Cache resource monitor
id: resource_monitor_cache
uses: actions/cache@v6
with:
path: native/resource-monitor/target/${{ matrix.rust_target }}/release/t3-resource-monitor${{ matrix.platform == 'win' && '.exe' || '' }}
key: resource-monitor-${{ matrix.rust_target }}-${{ hashFiles('native/resource-monitor/Cargo.lock', 'native/resource-monitor/Cargo.toml', 'native/resource-monitor/src/**') }}
- name: Cache Linux capture helpers
id: capture_helper_cache
if: matrix.platform == 'linux'
uses: actions/cache@v6
with:
path: |
native/kde-snap-shot/target/${{ matrix.rust_target }}/release/t3-kde-snap-shot
native/hyprland-snap-shot/target/${{ matrix.rust_target }}/release/t3-hyprland-snap-shot
key: linux-capture-helpers-${{ matrix.rust_target }}-${{ hashFiles('native/kde-snap-shot/Cargo.lock', 'native/kde-snap-shot/Cargo.toml', 'native/kde-snap-shot/src/**', 'native/hyprland-snap-shot/Cargo.lock', 'native/hyprland-snap-shot/Cargo.toml', 'native/hyprland-snap-shot/src/**', 'native/hyprland-snap-shot/protocols/**') }}
- if: matrix.platform == 'linux' && (steps.resource_monitor_cache.outputs.cache-hit != 'true' || steps.capture_helper_cache.outputs.cache-hit != 'true')
uses: dtolnay/rust-toolchain@stable
with:
targets: ${{ matrix.rust_target }}
# The resource monitor build needs the Spectre-mitigated MSVC libraries.
# The Visual Studio Installer takes minutes, so cache the library folder.
- id: spectre
name: Find Spectre-mitigated MSVC lib folder
if: matrix.platform == 'win' && steps.resource_monitor_cache.outputs.cache-hit != 'true'
shell: pwsh
run: |
$vswhere = "${env:ProgramFiles(x86)}\Microsoft Visual Studio\Installer\vswhere.exe"
$installPath = & $vswhere -products * -latest -property installationPath
$msvc = Get-ChildItem "$installPath\VC\Tools\MSVC" | Sort-Object Name | Select-Object -Last 1
"path=$($msvc.FullName)\lib\spectre" >> $env:GITHUB_OUTPUT
"key=msvc-spectre-${env:ImageOS}-${env:ImageVersion}-$($msvc.Name)" >> $env:GITHUB_OUTPUT
- id: spectre_cache
if: matrix.platform == 'win' && steps.resource_monitor_cache.outputs.cache-hit != 'true'
uses: actions/cache/restore@v6
with:
path: ${{ steps.spectre.outputs.path }}
key: ${{ steps.spectre.outputs.key }}
- name: Install Spectre-mitigated MSVC libs
if: matrix.platform == 'win' && steps.resource_monitor_cache.outputs.cache-hit != 'true' && steps.spectre_cache.outputs.cache-hit != 'true'
shell: pwsh
run: |
$vswhere = "${env:ProgramFiles(x86)}\Microsoft Visual Studio\Installer\vswhere.exe"
$installPath = & $vswhere -products * -latest -property installationPath
$setupExe = "${env:ProgramFiles(x86)}\Microsoft Visual Studio\Installer\setup.exe"
$proc = Start-Process -FilePath $setupExe `
-ArgumentList "modify", "--installPath", "`"$installPath`"", "--add", `
"Microsoft.VisualStudio.Component.VC.Runtimes.x86.x64.Spectre", "--quiet", "--norestart" `
-Wait -PassThru -NoNewWindow
if ($null -eq $proc -or $proc.ExitCode -ne 0) {
Write-Error "Visual Studio Installer failed"
exit 1
}
- if: matrix.platform == 'win' && steps.resource_monitor_cache.outputs.cache-hit != 'true' && steps.spectre_cache.outputs.cache-hit != 'true'
uses: actions/cache/save@v6
with:
path: ${{ steps.spectre.outputs.path }}
key: ${{ steps.spectre.outputs.key }}
# Built here, so the desktop build below always reuses it.
- name: Build resource monitor
if: steps.resource_monitor_cache.outputs.cache-hit != 'true'
shell: bash
run: cargo build --locked --release --manifest-path native/resource-monitor/Cargo.toml --target "${{ matrix.rust_target }}"
# electron-builder only signs with a trusted identity. The self-made
# certificate is its own root, so trust it on the runner for code signing.
- name: Trust fork signing certificate
if: matrix.platform == 'mac'
env:
CSC_LINK: ${{ secrets.CSC_LINK }}
CSC_KEY_PASSWORD: ${{ secrets.CSC_KEY_PASSWORD }}
run: |
set -euo pipefail
if [[ -z "$CSC_LINK" || -z "$CSC_KEY_PASSWORD" ]]; then
echo "CSC_LINK and CSC_KEY_PASSWORD are required. Squirrel.Mac rejects updates with another signature." >&2
exit 1
fi
printf '%s' "$CSC_LINK" | base64 --decode > "$RUNNER_TEMP/fork.p12"
/usr/bin/openssl pkcs12 -in "$RUNNER_TEMP/fork.p12" -nokeys -clcerts -passin env:CSC_KEY_PASSWORD -out "$RUNNER_TEMP/fork.pem"
sudo security add-trusted-cert -d -r trustRoot -p codeSign -k /Library/Keychains/System.keychain "$RUNNER_TEMP/fork.pem"
rm -f "$RUNNER_TEMP/fork.p12"
- name: Build desktop artifact
shell: bash
env:
T3CODE_DESKTOP_UPDATE_REPOSITORY: ${{ github.repository }}
T3CODE_DESKTOP_MAC_SELF_SIGNED: "true"
T3CODE_DESKTOP_REUSE_RESOURCE_MONITOR: "true"
T3CODE_DESKTOP_REUSE_LINUX_CAPTURE_HELPERS: ${{ steps.capture_helper_cache.outputs.cache-hit == 'true' }}
CSC_LINK: ${{ matrix.platform == 'mac' && secrets.CSC_LINK || '' }}
CSC_KEY_PASSWORD: ${{ matrix.platform == 'mac' && secrets.CSC_KEY_PASSWORD || '' }}
run: |
set -euo pipefail
args=(--platform "${{ matrix.platform }}" --target "${{ matrix.target }}" --arch "${{ matrix.arch }}"
--build-version "${{ needs.prepare.outputs.version }}" --skip-build --verbose)
# Windows stays unsigned. No Apple API key is set, so macOS is not notarized.
if [[ "${{ matrix.platform }}" == "mac" ]]; then
args+=(--signed)
fi
vp run dist:desktop:artifact "${args[@]}"
- name: Verify macOS signature
if: matrix.platform == 'mac'
run: |
set -euo pipefail
# The build script keeps only the dmg and zip, so check the app inside the zip.
ditto -x -k release/*-${{ matrix.arch }}.zip "$RUNNER_TEMP/verify"
app="$(ls -d "$RUNNER_TEMP"/verify/*.app)"
codesign -dvv "$app" 2>&1 | tee "$RUNNER_TEMP/codesign.txt"
codesign --verify --deep --strict --verbose=2 "$app"
grep -q "Tom Cohen T3 Fork" "$RUNNER_TEMP/codesign.txt"
# The headless server: upstream's self-contained CLI archive with the `t3`
# single-executable, the web client and the native packages. It needs no
# Node on the machine. The steps copy upstream's release-desktop.yml.
# Setup Vite+ selects the exact engines.node version from package.json,
# which a native build must run on.
- name: Build server single-executable
if: matrix.platform == 'linux'
run: node apps/server/scripts/cli.ts build-exe --verbose
- name: Build server archive
if: matrix.platform == 'linux'
run: |
set -euo pipefail
monitor_dir="$RUNNER_TEMP/cli-resource-monitor"
mkdir -p "$monitor_dir/${{ matrix.resource_key }}"
cp "native/resource-monitor/target/${{ matrix.rust_target }}/release/t3-resource-monitor" \
"$monitor_dir/${{ matrix.resource_key }}/"
node scripts/build-cli-archive.ts \
--platform linux \
--arch "${{ matrix.arch }}" \
--version "${{ needs.prepare.outputs.version }}" \
--resource-monitor-dir "$monitor_dir" \
--output-dir release-cli
node scripts/smoke-cli-archive.ts --archive release-cli/* --expect-version "${{ needs.prepare.outputs.version }}"
- name: Collect release assets
shell: bash
run: |
set -euo pipefail
shopt -s nullglob
mkdir -p release-publish
for file in release/*.dmg release/*.zip release/*.exe release/*.AppImage release/*.blockmap release/*.yml release-cli/*; do
[[ "$file" == */builder-debug.yml ]] && continue
cp "$file" release-publish/
done
# Both Mac builds write nightly-mac.yml. The mac_manifest job merges the x64 one into it.
if [[ "${{ matrix.platform }}" == mac && "${{ matrix.arch }}" == x64 ]]; then
mv release-publish/nightly-mac.yml release-publish/nightly-mac-x64.yml
fi
ls -la release-publish
# The first platform to finish creates the release, the others add their files.
# Each platform has its own updater manifest: nightly-mac.yml (nightly-mac-x64.yml
# until mac_manifest merges it), nightly.yml (Windows), nightly-linux.yml (x64) and
# nightly-linux-arm64.yml.
# The release creates a tag. GITHUB_TOKEN cannot create a tag on a commit
# whose workflow files differ from main, which happens when main moved
# during the run. FORK_SYNC_TOKEN has the workflow scope.
- name: Publish to the release
if: github.ref == 'refs/heads/main'
shell: bash
env:
GH_TOKEN: ${{ secrets.FORK_SYNC_TOKEN || github.token }}
NOTES: ${{ needs.prepare.outputs.notes }}
MANIFEST: ${{ matrix.platform == 'mac' && matrix.arch == 'x64' && 'nightly-mac-x64.yml' || matrix.platform == 'mac' && 'nightly-mac.yml' || matrix.platform == 'win' && 'nightly.yml' || matrix.arch == 'arm64' && 'nightly-linux-arm64.yml' || 'nightly-linux.yml' }}
run: |
set -euo pipefail
test -s "release-publish/$MANIFEST"
tag="${{ needs.prepare.outputs.tag }}"
if ! gh release view "$tag" --repo "$GITHUB_REPOSITORY" >/dev/null 2>&1 &&
gh release create "$tag" release-publish/* \
--repo "$GITHUB_REPOSITORY" \
--target "${{ needs.prepare.outputs.sha }}" \
--title "${{ needs.prepare.outputs.name }}" \
--notes "$NOTES" \
--prerelease; then
exit 0
fi
# Another platform may still be creating the release: it stays a draft, which its
# tag cannot find, until that platform's files are up. Wait for it, 20 minutes at most.
for attempt in $(seq 1 40); do
gh release view "$tag" --repo "$GITHUB_REPOSITORY" >/dev/null 2>&1 && break
if [ "$attempt" -eq 40 ]; then
echo "Release $tag did not appear." >&2
exit 1
fi
sleep 30
done
gh release upload "$tag" release-publish/* --repo "$GITHUB_REPOSITORY" --clobber
# A manual run from another branch tests the build without a release.
- name: Upload assets of a branch run
if: github.ref != 'refs/heads/main'
uses: actions/upload-artifact@v7
with:
name: release-${{ matrix.platform }}-${{ matrix.arch }}
path: release-publish/*
if-no-files-found: error
retention-days: 3
# The checksums job writes SHA256SUMS from these exact bytes. This step
# runs after the publish step, so the archive is on the release.
- name: Upload server archive for the checksums
if: matrix.platform == 'linux'
uses: actions/upload-artifact@v7
with:
name: cli-linux-${{ matrix.arch }}
path: release-cli/t3-*.tar.gz
if-no-files-found: error
retention-days: 1
# electron-updater reads one nightly-mac.yml and picks the file entry of the running arch,
# so the x64 manifest merges into the arm64 one. Without both, the release keeps what it has:
# Intel Macs then skip this release, and Apple silicon Macs never get an x64 entry.
mac_manifest:
name: Merge macOS updater manifests
needs: [prepare, desktop]
if: always() && needs.prepare.result == 'success' && needs.prepare.outputs.released != 'true' && github.ref == 'refs/heads/main'
runs-on: ubuntu-24.04
timeout-minutes: 10
permissions:
contents: write
steps:
- uses: actions/checkout@v6
with:
ref: ${{ needs.prepare.outputs.sha }}
sparse-checkout: |
/*
!/.repos/
sparse-checkout-cone-mode: false
- uses: voidzero-dev/setup-vp@v1
with:
node-version-file: package.json
cache: true
run-install: false
- run: vp install --filter=@t3tools/scripts...
- name: Merge and publish nightly-mac.yml
env:
GH_TOKEN: ${{ secrets.FORK_SYNC_TOKEN || github.token }}
TAG: ${{ needs.prepare.outputs.tag }}
run: |
set -euo pipefail
mkdir -p manifests
for name in nightly-mac.yml nightly-mac-x64.yml; do
gh release download "$TAG" --repo "$GITHUB_REPOSITORY" --pattern "$name" --dir manifests || true
done
if [[ ! -s manifests/nightly-mac.yml || ! -s manifests/nightly-mac-x64.yml ]]; then
echo "Both macOS manifests are needed to merge. Found: $(ls manifests)"
exit 0
fi
node scripts/merge-update-manifests.ts --platform mac manifests/nightly-mac.yml manifests/nightly-mac-x64.yml
cat manifests/nightly-mac.yml
gh release upload "$TAG" manifests/nightly-mac.yml --repo "$GITHUB_REPOSITORY" --clobber
gh release delete-asset "$TAG" nightly-mac-x64.yml --repo "$GITHUB_REPOSITORY" --yes
# Writes SHA256SUMS for the server archives and uploads it last. The server
# updaters need it, so a release without it is not installable on servers.
# It runs only when every server archive exists, so it never marks a release
# with a missing archive as ready. A desktop failure does not stop it.
checksums:
name: Server checksums
needs: [prepare, desktop]
if: always() && needs.prepare.result == 'success' && needs.prepare.outputs.released != 'true'
runs-on: ubuntu-24.04
timeout-minutes: 10
permissions:
contents: write
steps:
- uses: actions/download-artifact@v8
with:
pattern: cli-*
merge-multiple: true
path: release-cli
# Keep in step with the Linux rows of the desktop matrix.
- name: Write SHA256SUMS
run: |
set -euo pipefail
cd release-cli
version="${{ needs.prepare.outputs.version }}"
archives=("t3-${version}-linux-arm64.tar.gz" "t3-${version}-linux-x64.tar.gz")
for archive in "${archives[@]}"; do
test -s "$archive" || { echo "Missing server archive $archive." >&2; exit 1; }
done
sha256sum "${archives[@]}" > SHA256SUMS
cat SHA256SUMS
- name: Publish SHA256SUMS
if: github.ref == 'refs/heads/main'
env:
GH_TOKEN: ${{ secrets.FORK_SYNC_TOKEN || github.token }}
run: gh release upload "${{ needs.prepare.outputs.tag }}" release-cli/SHA256SUMS --repo "$GITHUB_REPOSITORY" --clobber
- name: Upload SHA256SUMS of a branch run
if: github.ref != 'refs/heads/main'
uses: actions/upload-artifact@v7
with:
name: release-checksums
path: release-cli/SHA256SUMS
if-no-files-found: error
retention-days: 3
# Starts a repair agent on a T3 server through a scheduled-task webhook.
# Off until the T3_AGENT_WEBHOOK_URL secret is set.
notify_agent:
name: Notify repair agent
needs: [prepare, desktop, mac_manifest, checksums]
if: failure() && github.event.inputs.notify_agent != 'false'
runs-on: ubuntu-24.04
timeout-minutes: 20
permissions:
actions: read
steps:
- uses: actions/checkout@v6
with:
sparse-checkout: scripts/fork-notify-agent.sh
sparse-checkout-cone-mode: false
- name: Call agent webhook
env:
GH_TOKEN: ${{ github.token }}
WEBHOOK_URL: ${{ secrets.T3_AGENT_WEBHOOK_URL }}
CF_ACCESS_CLIENT_ID: ${{ secrets.CF_ACCESS_CLIENT_ID }}
CF_ACCESS_CLIENT_SECRET: ${{ secrets.CF_ACCESS_CLIENT_SECRET }}
NEEDS_JSON: ${{ toJSON(needs) }}
run: scripts/fork-notify-agent.sh