Repository navigation
Fork Release #64
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
| # 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 |