Skip to content

Fork Release

Fork Release #64

Workflow file for this run

# Fork-only build train: like upstream's nightly, main is checked on a
# schedule and becomes one nightly-versioned prerelease on this repository at
# most every six hours, so each release collects every commit since the last.
# A Linux and a macOS install of the same build share one version and can each
# fetch the other's t3 runtime over SSH.
#
# - Linux (GitHub-hosted) builds the AppImage with an update feed pointing at
# this repository, so the installed AppImage updates itself.
# - macOS runs on a self-hosted runner labelled `t3-mac`. Without an Apple
# Developer ID the app cannot update itself (Squirrel.Mac requires a real
# signature), so the app ships without an update feed and install-mac puts
# each published build onto that Mac directly.
# - Both desktop builds bake in this repository's release URL, so SSH remotes
# download the matching t3 archive from here instead of upstream.
#
# Manual dispatch builds main right away, skipping the six-hour gap. Only
# schedule and manual dispatch run here: a pull_request trigger would let
# untrusted code reach the self-hosted Mac.
name: Fork Release
on:
schedule:
# Same cadence as upstream's nightly; avoid minute zero, when GitHub
# scheduled jobs are busiest.
- cron: "8,38 * * * *"
workflow_dispatch:
permissions:
contents: read
# Builds run to completion and publish in order. A scheduled run that waited
# here rechecks the gap, so it skips once the build ahead of it has published.
concurrency:
group: fork-release
cancel-in-progress: false
queue: max
env:
T3CODE_RELEASE_BASE_URL: https://github.com/${{ github.repository }}/releases/download
# Keep in step with SEA_NODE_VERSION in apps/server/vite.config.ts.
SEA_NODE_VERSION: "26.8.2"
KEEP_RELEASES: "15"
jobs:
# Scheduled runs publish only when the last nightly is six hours old and main
# has moved past it; the rule is shared with upstream's release workflow.
due:
name: Check if a release is due
runs-on: ubuntu-24.04
timeout-minutes: 5
permissions:
contents: read
actions: read
outputs:
due: ${{ steps.check.outputs.due }}
date: ${{ steps.check.outputs.date }}
steps:
- uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6
with:
sparse-checkout: .github/scripts
- id: check
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8
with:
script: |
const { resolveForkRelease } = require('./.github/scripts/check-fork-release.cjs');
const { due, date } = await resolveForkRelease({ github, context, core });
core.setOutput('due', due);
if (date) core.setOutput('date', date);
version:
name: Resolve version
needs: due
if: needs.due.outputs.due == 'true'
runs-on: ubuntu-24.04
timeout-minutes: 10
outputs:
version: ${{ steps.resolve.outputs.version }}
tag: ${{ steps.resolve.outputs.tag }}
name: ${{ steps.resolve.outputs.name }}
steps:
- name: Checkout
shell: bash
env:
GIT_TERMINAL_PROMPT: "0"
run: |
set -euo pipefail
git init .
git remote add origin "${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}.git"
for attempt in 1 2 3; do
git fetch --no-tags --depth=1 origin "$GITHUB_SHA" && break
[[ "$attempt" -lt 3 ]] || exit 1
echo "::warning::git fetch attempt $attempt failed; retrying."
sleep $((attempt * 15))
done
git sparse-checkout set --no-cone '/*' '!/.repos/'
git checkout --detach FETCH_HEAD
- uses: voidzero-dev/setup-vp@250f29ce396baf5e8f24498e17c0dfdebabc26eb # v1
with:
node-version-file: package.json
cache: true
run-install: false
- run: vp install --filter=@t3tools/scripts...
- id: resolve
shell: bash
env:
RELEASE_DATE: ${{ needs.due.outputs.date }}
run: |
set -euo pipefail
node scripts/resolve-nightly-release.ts \
--date "$RELEASE_DATE" \
--run-number "${{ github.run_number }}" \
--sha "$GITHUB_SHA" \
--github-output
cat "$GITHUB_OUTPUT"
linux:
name: Linux x64
needs: version
runs-on: ubuntu-24.04
timeout-minutes: 45
env:
VERSION: ${{ needs.version.outputs.version }}
steps:
- name: Checkout
shell: bash
env:
GIT_TERMINAL_PROMPT: "0"
run: |
set -euo pipefail
git init .
git remote add origin "${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}.git"
for attempt in 1 2 3; do
git fetch --no-tags --depth=1 origin "$GITHUB_SHA" && break
[[ "$attempt" -lt 3 ]] || exit 1
echo "::warning::git fetch attempt $attempt failed; retrying."
sleep $((attempt * 15))
done
git sparse-checkout set --no-cone '/*' '!/.repos/'
git checkout --detach FETCH_HEAD
- uses: voidzero-dev/setup-vp@250f29ce396baf5e8f24498e17c0dfdebabc26eb # v1
with:
node-version-file: package.json
cache: true
run-install: false
- name: Install build libraries
run: |
sudo apt-get update
sudo apt-get install -y libsecret-1-dev pkg-config imagemagick
rustup update stable --no-self-update
rustup default stable
- run: vp install --filter=@t3tools/desktop... --filter=t3... --filter=@t3tools/web... --filter=@t3tools/scripts...
- run: node scripts/update-release-package-versions.ts "$VERSION"
- name: Build JS bundle
run: vp run build:desktop
# GITHUB_REPOSITORY gives the AppImage an update feed on this repo.
# Native module rebuilds download Electron headers, so a dropped
# connection gets one more attempt.
- name: Build AppImage
run: |
for attempt in 1 2; do
vp run dist:desktop:artifact --platform linux --target AppImage --arch x64 --build-version "$VERSION" --skip-build --verbose && exit 0
echo "::warning::AppImage build attempt $attempt failed."
done
exit 1
- name: Build CLI archive
shell: bash
run: |
set -euo pipefail
VP_NODE_VERSION="$SEA_NODE_VERSION" node apps/server/scripts/cli.ts build-exe --verbose
monitor="$(find native/resource-monitor/target -path '*/release/t3-resource-monitor' -type f | head -n 1)"
mkdir -p "$RUNNER_TEMP/cli-resource-monitor/linux-x64"
cp "$monitor" "$RUNNER_TEMP/cli-resource-monitor/linux-x64/"
node scripts/build-cli-archive.ts --platform linux --arch x64 --version "$VERSION" \
--resource-monitor-dir "$RUNNER_TEMP/cli-resource-monitor" --output-dir release-cli
node scripts/smoke-cli-archive.ts --archive release-cli/* --expect-version "$VERSION"
- name: Collect assets
shell: bash
run: |
set -euo pipefail
shopt -s nullglob
mkdir -p out
cp release/*.AppImage release/*.blockmap out/
for manifest in release/*.yml; do
[[ "$manifest" == */builder-debug.yml ]] || cp "$manifest" out/
done
cp release-cli/* out/
ls -la out
- uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
with:
name: build-linux-x64
path: out/*
if-no-files-found: error
mac:
name: macOS arm64 (self-hosted)
needs: version
runs-on: [self-hosted, macOS, ARM64, t3-mac]
timeout-minutes: 60
env:
VERSION: ${{ needs.version.outputs.version }}
steps:
# The workspace persists between runs on a self-hosted runner.
- name: Checkout
shell: bash
env:
GIT_TERMINAL_PROMPT: "0"
run: |
set -euo pipefail
find . -mindepth 1 -maxdepth 1 -exec rm -rf {} +
git init .
git remote add origin "${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}.git"
for attempt in 1 2 3; do
git fetch --no-tags --depth=1 origin "$GITHUB_SHA" && break
[[ "$attempt" -lt 3 ]] || exit 1
echo "::warning::git fetch attempt $attempt failed; retrying."
sleep $((attempt * 15))
done
git sparse-checkout set --no-cone '/*' '!/.repos/'
git checkout --detach FETCH_HEAD
- uses: voidzero-dev/setup-vp@250f29ce396baf5e8f24498e17c0dfdebabc26eb # v1
with:
node-version-file: package.json
cache: false
run-install: false
- name: Put cargo on PATH
run: |
echo "$HOME/.cargo/bin" >> "$GITHUB_PATH"
"$HOME/.cargo/bin/rustup" update stable --no-self-update
- run: vp install --filter=@t3tools/desktop... --filter=t3... --filter=@t3tools/web... --filter=@t3tools/scripts...
- run: node scripts/update-release-package-versions.ts "$VERSION"
- name: Build JS bundle
run: vp run build:desktop
# No GITHUB_REPOSITORY: an ad-hoc signed app cannot apply updates, so it
# gets no feed and this job installs each build instead. Retried like
# the AppImage build.
- name: Build app
run: |
for attempt in 1 2; do
env -u GITHUB_REPOSITORY vp run dist:desktop:artifact --platform mac --target dmg --arch arm64 --build-version "$VERSION" --skip-build --verbose && exit 0
echo "::warning::App build attempt $attempt failed."
done
exit 1
- name: Build CLI archive
shell: bash
run: |
set -euo pipefail
VP_NODE_VERSION="$SEA_NODE_VERSION" node apps/server/scripts/cli.ts build-exe --verbose
monitor="$(find native/resource-monitor/target -path '*/release/t3-resource-monitor' -type f | head -n 1)"
mkdir -p "$RUNNER_TEMP/cli-resource-monitor/darwin-arm64"
cp "$monitor" "$RUNNER_TEMP/cli-resource-monitor/darwin-arm64/"
node scripts/build-cli-archive.ts --platform mac --arch arm64 --version "$VERSION" \
--resource-monitor-dir "$RUNNER_TEMP/cli-resource-monitor" --output-dir release-cli
node scripts/smoke-cli-archive.ts --archive release-cli/* --expect-version "$VERSION"
- uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
with:
name: build-mac-arm64
path: |
release/*.dmg
release-cli/*
if-no-files-found: error
# The packaged .app only survives inside the zip electron-builder writes
# beside the dmg; install-mac unpacks it from there.
- uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
with:
name: install-mac-arm64
path: release/*.zip
if-no-files-found: error
release:
name: Publish release
needs: [version, linux, mac]
runs-on: ubuntu-24.04
timeout-minutes: 15
permissions:
contents: read
env:
# Tagging the built SHA after main changes workflows requires Workflows
# write permission, which GITHUB_TOKEN cannot grant. Scope this fine-grained
# PAT to this repository with Contents and Workflows read/write access.
GH_TOKEN: ${{ secrets.FORK_RELEASE_TOKEN }}
GH_REPO: ${{ github.repository }}
steps:
- name: Check release credential
run: |
if [[ -z "$GH_TOKEN" ]]; then
echo "::error::Set FORK_RELEASE_TOKEN to a repository-scoped token with Contents and Workflows read/write permissions."
exit 1
fi
- uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8
with:
pattern: build-*
merge-multiple: true
path: downloaded
# Multi-path uploads keep their directories (release/, release-cli/),
# and a release takes files only.
- name: Flatten assets
run: |
mkdir assets
find downloaded -type f -exec mv -n {} assets/ \;
ls -la assets
- name: Write checksums
working-directory: assets
run: |
sha256sum t3-*.tar.gz > SHA256SUMS
cat SHA256SUMS
# The updater shows each release's notes, so each covers only the changes
# since the build before it. Merged PRs come from GitHub's generator;
# a direct push to main has none, so list the commit subjects instead.
- name: Write release notes
env:
TAG: ${{ needs.version.outputs.tag }}
run: |
set -euo pipefail
previous="$(
{
gh release list --limit 200 --json tagName \
--jq '.[].tagName | select(test("-nightly\\."))' | grep -vxF "$TAG" || true
echo "$TAG"
} | sort -V | grep -B1 -xF "$TAG" | grep -vxF "$TAG" || true
)"
echo "Previous release: ${previous:-none}"
args=(-f tag_name="$TAG" -f target_commitish="$GITHUB_SHA")
[[ -n "$previous" ]] && args+=(-f previous_tag_name="$previous")
gh api "repos/$GH_REPO/releases/generate-notes" "${args[@]}" --jq .body > release-notes.md
if [[ -n "$previous" ]] && ! grep -q '^\* ' release-notes.md; then
{
echo "## What's Changed"
gh api "repos/$GH_REPO/compare/$previous...$GITHUB_SHA" \
--jq '.commits[] | select((.parents | length) == 1) | .commit.message | split("\n")[0] | "* " + .' \
| tail -n 50
echo
echo "**Full Changelog**: https://github.com/$GH_REPO/compare/$previous...$TAG"
} > release-notes.md
fi
cat release-notes.md
# A re-run replaces the release its failed attempt may have left behind.
- name: Create release
run: |
gh release delete "${{ needs.version.outputs.tag }}" --yes --cleanup-tag 2>/dev/null || true
gh release create "${{ needs.version.outputs.tag }}" assets/* \
--target "$GITHUB_SHA" \
--title "${{ needs.version.outputs.name }}" \
--notes-file release-notes.md \
--prerelease
- name: Prune old builds
run: |
gh release list --limit 200 --json tagName,isPrerelease \
--jq '.[] | select(.isPrerelease and (.tagName | test("-nightly\\."))) | .tagName' \
| tail -n +"$((KEEP_RELEASES + 1))" \
| while read -r tag; do
gh release delete "$tag" --yes --cleanup-tag
done
# Runs only after the release exists, so the Mac never runs a version whose
# runtime archive SSH remotes cannot download.
install-mac:
name: Install on the Mac
needs: [version, mac, release]
runs-on: [self-hosted, macOS, ARM64, t3-mac]
timeout-minutes: 10
env:
VERSION: ${{ needs.version.outputs.version }}
steps:
- name: Clean workspace
run: find . -mindepth 1 -maxdepth 1 -exec rm -rf {} +
- uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8
with:
pattern: "*-mac-arm64"
merge-multiple: true
path: artifacts
- name: Install app and runtime
shell: bash
run: |
set -euo pipefail
shopt -s nullglob
# Artifacts keep their upload directories, so locate files by name.
zips=()
while IFS= read -r zip; do zips+=("$zip"); done < <(find artifacts -type f -name '*.zip')
archive="$(find artifacts -type f -name "t3-$VERSION-darwin-arm64.tar.gz" | head -n 1)"
[[ ${#zips[@]} -eq 1 ]] || { echo "Expected one app zip, found ${#zips[@]}." >&2; exit 1; }
[[ -n "$archive" ]] || { echo "Missing t3-$VERSION-darwin-arm64.tar.gz." >&2; exit 1; }
# Unpack and check everything before touching the installed copy.
unpacked="$RUNNER_TEMP/app"
rm -rf "$unpacked"
ditto -x -k "${zips[0]}" "$unpacked"
apps=("$unpacked"/*.app)
[[ ${#apps[@]} -eq 1 ]] || { echo "Expected one .app in ${zips[0]}, found ${#apps[@]}." >&2; exit 1; }
app="${apps[0]}"
bundle_id="$(defaults read "$app/Contents/Info" CFBundleIdentifier)"
[[ -n "$bundle_id" ]] || { echo "$app has no bundle identifier." >&2; exit 1; }
runtime_root="$HOME/.t3/runtime/versions"
mkdir -p "$runtime_root"
staging="$(mktemp -d "$runtime_root/.staging-XXXXXX")"
trap 'rm -rf "$staging"' EXIT
tar -xzf "$archive" -C "$staging" --strip-components=1
"$staging/t3" --version
dest_dir=/Applications
[[ -w "$dest_dir" ]] || { dest_dir="$HOME/Applications"; mkdir -p "$dest_dir"; }
dest="$dest_dir/$(basename "$app")"
# Quit the running copy by the PID macOS registered for its bundle id.
pid="$(lsappinfo info -only pid "$(lsappinfo find bundleid="$bundle_id")" 2>/dev/null | sed -n 's/.*=//p')"
was_running=false
if [[ "$pid" =~ ^[0-9]+$ ]]; then
was_running=true
kill -TERM "$pid"
for _ in $(seq 1 30); do kill -0 "$pid" 2>/dev/null || break; sleep 1; done
fi
rm -rf "$dest.new"
ditto "$app" "$dest.new"
rm -rf "$dest"
mv "$dest.new" "$dest"
echo "Installed $VERSION to $dest"
# Pre-seed the runtime SSH clients would otherwise download.
printf '%s\n' "$VERSION" > "$staging/.install-complete"
rm -rf "$runtime_root/$VERSION"
mv "$staging" "$runtime_root/$VERSION"
trap - EXIT
# The runner kills every process carrying this job's RUNNER_TRACKING_ID
# when the job ends, which would quit the relaunched app again.
if [[ "$was_running" == true ]]; then env -u RUNNER_TRACKING_ID open "$dest"; fi