Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
87 changes: 16 additions & 71 deletions .github/workflows/fork-release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,10 @@ name: Fork release
# fork's required artifacts and, only when explicitly told to, promotes an
# already-qualified candidate to a GitHub Release on `nullStack65/t3code`.
#
# Runner capacity is an explicit, authorized input, never a silent hosted
# default: every runner label must be declared in the repository variable
# `T3CODE_AUTHORIZED_RUNNERS` or preflight fails closed. When no authorized
# runner exists, assemble and verify the same candidate on an authorized
# Builds use fixed GitHub-hosted labels in this workflow. They are deliberately
# not workflow-dispatch inputs or repository variables, so a caller cannot
# redirect source execution to arbitrary runner capacity. When hosted capacity
# is unavailable, assemble and verify the same candidate on an authorized
# Windows/WSL or Intel macOS machine with `scripts/build-fork-candidate.ts`.
#
# Required initial targets:
Expand Down Expand Up @@ -66,18 +66,6 @@ on:
default: false
type: boolean

# Trusted runner selection. Labels come from repository variables set by the
# owner, never from a caller-supplied input, so a dispatch cannot schedule or
# execute source on an arbitrary runner. If a variable is unset the job runs on
# a deliberately unmatched label, causing a safe queue rather than an
# unauthorized run; the `authorize` job reports the exact missing variable.
# `T3CODE_AUTHORIZED_RUNNERS` must list every label below or `authorize` fails.
env:
T3CODE_LINUX_RUNNER: ${{ vars.T3CODE_LINUX_RUNNER }}
T3CODE_WINDOWS_RUNNER: ${{ vars.T3CODE_WINDOWS_RUNNER }}
T3CODE_MACOS_X64_RUNNER: ${{ vars.T3CODE_MACOS_X64_RUNNER }}
T3CODE_MACOS_ARM64_RUNNER: ${{ vars.T3CODE_MACOS_ARM64_RUNNER }}

permissions:
contents: read

Expand All @@ -86,51 +74,9 @@ concurrency:
cancel-in-progress: false

jobs:
# Authorization runs FIRST, on a fixed owner-configured Linux label, before
# any build job is scheduled. Every later job `needs: [authorize]`, so a
# dispatch cannot execute source on an arbitrary caller-supplied runner. The
# labels themselves come from repository variables, not workflow inputs.
authorize:
name: Authorize runner capacity
runs-on: ${{ vars.T3CODE_LINUX_RUNNER }}
timeout-minutes: 5
steps:
- name: Assert authorized runner capacity
shell: bash
env:
AUTHORIZED: ${{ vars.T3CODE_AUTHORIZED_RUNNERS }}
LINUX_RUNNER: ${{ vars.T3CODE_LINUX_RUNNER }}
WINDOWS_RUNNER: ${{ vars.T3CODE_WINDOWS_RUNNER }}
MACOS_X64_RUNNER: ${{ vars.T3CODE_MACOS_X64_RUNNER }}
MACOS_ARM64_RUNNER: ${{ vars.T3CODE_MACOS_ARM64_RUNNER }}
INCLUDE_ARM64: ${{ inputs.include_macos_arm64 }}
run: |
set -euo pipefail
if [ -z "${AUTHORIZED:-}" ]; then
echo "::error::No authorized runner capacity is declared. Set the repository variable T3CODE_AUTHORIZED_RUNNERS to the comma-separated labels this fork may use, or build a candidate with scripts/build-fork-candidate.ts on an authorized machine."
exit 1
fi
IFS=',' read -ra allowed <<< "$AUTHORIZED"
check() {
local label="$1" role="$2" candidate
[ -n "$label" ] || { echo "::error::$role runner variable (vars.T3CODE_*_RUNNER) is unset"; exit 1; }
for candidate in "${allowed[@]}"; do
candidate="$(echo "$candidate" | xargs)"
[ "$candidate" = "$label" ] && return 0
done
echo "::error::$role runner '$label' is not declared in T3CODE_AUTHORIZED_RUNNERS"
exit 1
}
check "$LINUX_RUNNER" Linux
check "$WINDOWS_RUNNER" Windows
check "$MACOS_X64_RUNNER" "Intel macOS"
if [ "$INCLUDE_ARM64" = "true" ]; then check "$MACOS_ARM64_RUNNER" "Apple Silicon macOS"; fi
echo "Authorized runner capacity confirmed."

preflight:
name: Preflight
needs: [authorize]
runs-on: ${{ vars.T3CODE_LINUX_RUNNER }}
runs-on: ubuntu-24.04
timeout-minutes: 15
outputs:
version: ${{ steps.meta.outputs.version }}
Expand Down Expand Up @@ -232,7 +178,7 @@ jobs:
bundle:
name: Build JS bundle
needs: [preflight]
runs-on: ${{ vars.T3CODE_LINUX_RUNNER }}
runs-on: ubuntu-24.04
timeout-minutes: 30
env:
T3CODE_RELEASE_BUILD: "1"
Expand Down Expand Up @@ -299,7 +245,7 @@ jobs:
cli_linux_x64:
name: Linux x64 runtime archive
needs: [preflight, bundle]
runs-on: ${{ vars.T3CODE_LINUX_RUNNER }}
runs-on: ubuntu-24.04
timeout-minutes: 30
env:
T3CODE_RELEASE_BUILD: "1"
Expand Down Expand Up @@ -457,7 +403,7 @@ jobs:
clerk_cli_oauth_client_id: ${{ needs.preflight.outputs.clerk_cli_oauth_client_id }}
relay_url: ${{ needs.preflight.outputs.relay_url }}
label: Windows x64
runner: ${{ vars.T3CODE_WINDOWS_RUNNER }}
runner: windows-2025
platform: win
target: nsis
arch: x64
Expand All @@ -483,7 +429,7 @@ jobs:
clerk_cli_oauth_client_id: ${{ needs.preflight.outputs.clerk_cli_oauth_client_id }}
relay_url: ${{ needs.preflight.outputs.relay_url }}
label: macOS x64
runner: ${{ vars.T3CODE_MACOS_X64_RUNNER }}
runner: macos-15-intel
platform: mac
target: dmg
arch: x64
Expand All @@ -507,7 +453,7 @@ jobs:
clerk_cli_oauth_client_id: ${{ needs.preflight.outputs.clerk_cli_oauth_client_id }}
relay_url: ${{ needs.preflight.outputs.relay_url }}
label: macOS arm64
runner: ${{ vars.T3CODE_MACOS_ARM64_RUNNER }}
runner: macos-15
platform: mac
target: dmg
arch: arm64
Expand All @@ -518,10 +464,9 @@ jobs:

qualify:
name: Qualify candidate
needs:
[authorize, preflight, desktop_win_x64, desktop_mac_x64, desktop_mac_arm64, cli_linux_x64]
needs: [preflight, desktop_win_x64, desktop_mac_x64, desktop_mac_arm64, cli_linux_x64]
if: ${{ !cancelled() && needs.preflight.result == 'success' && needs.desktop_win_x64.result == 'success' && needs.desktop_mac_x64.result == 'success' && needs.cli_linux_x64.result == 'success' && (inputs.include_macos_arm64 == false || needs.desktop_mac_arm64.result == 'success') }}
runs-on: ${{ vars.T3CODE_LINUX_RUNNER }}
runs-on: ubuntu-24.04
timeout-minutes: 20
steps:
- name: Bootstrap the release commit
Expand Down Expand Up @@ -736,9 +681,9 @@ jobs:
# run that has no mechanism to receive one.
receipts:
name: Import native acceptance receipts
needs: [authorize, preflight, qualify]
needs: [preflight, qualify]
if: ${{ !cancelled() && needs.qualify.result == 'success' && inputs.upload_receipts && inputs.receipts_source_run_id != '' }}
runs-on: ${{ vars.T3CODE_LINUX_RUNNER }}
runs-on: ubuntu-24.04
timeout-minutes: 15
permissions:
contents: read
Expand Down Expand Up @@ -810,9 +755,9 @@ jobs:

publish:
name: Promote qualified candidate
needs: [authorize, preflight, qualify]
needs: [preflight, qualify]
if: ${{ !cancelled() && needs.preflight.result == 'success' && needs.qualify.result == 'success' && inputs.publish && inputs.candidate_run_id != '' }}
runs-on: ${{ vars.T3CODE_LINUX_RUNNER }}
runs-on: ubuntu-24.04
timeout-minutes: 20
# Publication is the only job with write access and it runs in a named
# environment so it can require manual approval. The job-level concurrency
Expand Down
35 changes: 16 additions & 19 deletions docs/operations/fork-release.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,19 +12,18 @@ and `release-desktop.yml`.

## Runners

Runner capacity is owner-configured, not caller-supplied. Runner labels come
from repository variables (`vars.T3CODE_LINUX_RUNNER`,
`vars.T3CODE_WINDOWS_RUNNER`, `vars.T3CODE_MACOS_X64_RUNNER`,
`vars.T3CODE_MACOS_ARM64_RUNNER`) and every label must also appear in
`vars.T3CODE_AUTHORIZED_RUNNERS`. A dispatch cannot name an arbitrary runner, so
source is never scheduled on unauthorized capacity. The `authorize` job runs
first, on the owner-configured Linux label, and every build job `needs`
transitively through `preflight`, so authorization happens before any source
executes.

- No self-hosted label is guessed.
The workflow uses fixed GitHub-hosted labels in source: `ubuntu-24.04` for
Linux x64, `windows-2025` for Windows x64, and `macos-15-intel` for Intel
macOS x64. Apple Silicon remains opt-in on `macos-15`. Runner labels are not
workflow-dispatch inputs or repository variables, so a caller cannot redirect
source execution to arbitrary runner capacity. GitHub-hosted runners are free
and unlimited for public repositories; if that capacity is unavailable, use
the local candidate route below.

- No self-hosted label is accepted or guessed.
- No personal machine is registered to run public-PR jobs.
- No hosted/paid fallback is added silently.
- The fixed labels are explicit in `.github/workflows/fork-release.yml` and
are validated by the focused workflow contract tests.

### Local candidate route (when CI capacity is unavailable)

Expand Down Expand Up @@ -232,9 +231,8 @@ by the same version and SHA:
archive's real `t3code-build-info.json`. Completed outputs from a working
platform are preserved even when another platform is unavailable.

Apple Silicon macOS is built only when `include_macos_arm64` is set and an
authorized `vars.T3CODE_MACOS_ARM64_RUNNER` is configured; it is reported
untested.
Apple Silicon macOS is built only when `include_macos_arm64` is set; it is
reported untested.

## Versioning

Expand Down Expand Up @@ -264,10 +262,9 @@ Rules:

1. Pick the immutable source SHA on `main` and the upstream base version.
2. Run **Fork release** (`workflow_dispatch`) with `sha`, `version`, and
`upstream_base`. Runner labels are not inputs; they come from repository
variables. Leave `publish` off to build a candidate.
3. `authorize` checks the owner-configured runner labels first. Preflight then
checks out that explicit SHA (never `FETCH_HEAD`), asserts `HEAD == sha`, and
`upstream_base`. Runner labels are fixed by the workflow source. Leave
`publish` off to build a candidate.
3. Preflight checks out that explicit SHA (never `FETCH_HEAD`), asserts `HEAD == sha`, and
applies the public ancestry policy (the SHA must be an ancestor of
`origin/main`) with `scripts/select-release-source.ts` — this runs before
dependencies are installed and imports no workspace packages.
Expand Down
39 changes: 23 additions & 16 deletions scripts/lib/fork-release-workflow.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -70,29 +70,36 @@ it.effect("builds the Windows CLI archive so the Windows install path has an ass
}),
);

it.effect("no job silently defaults to a GitHub-hosted runner label", () =>
it.effect("uses fixed supported hosted labels and no caller-controlled runner variables", () =>
Effect.gen(function* () {
const text = yield* Effect.promise(() => readWorkflow("fork-release.yml"));
assert.notInclude(text, "runs-on: ubuntu-");
assert.notInclude(text, "runs-on: windows-");
assert.notInclude(text, "runs-on: macos-");
assert.include(text, "T3CODE_AUTHORIZED_RUNNERS");
assert.include(text, "runs-on: ubuntu-24.04");
assert.include(text, "runner: windows-2025");
assert.include(text, "runner: macos-15-intel");
assert.include(text, "runner: macos-15");
assert.notInclude(text, "T3CODE_AUTHORIZED_RUNNERS");
assert.notInclude(text, "vars.T3CODE_LINUX_RUNNER");
assert.notInclude(text, "vars.T3CODE_WINDOWS_RUNNER");
assert.notInclude(text, "vars.T3CODE_MACOS_X64_RUNNER");
assert.notInclude(text, "vars.T3CODE_MACOS_ARM64_RUNNER");
assert.notInclude(text, "inputs.linux_runner");
assert.notInclude(text, "inputs.windows_runner");
assert.notInclude(text, "inputs.macos_x64_runner");
assert.notInclude(text, "inputs.macos_arm64_runner");
}),
);

it.effect("authorization runs before any build job and uses owner variables, not inputs", () =>
it.effect("all build and promotion jobs stay on the fixed hosted Linux label", () =>
Effect.gen(function* () {
const text = yield* Effect.promise(() => readWorkflow("fork-release.yml"));
const authorize = jobBlock(text, "authorize");
assert.include(authorize, "T3CODE_AUTHORIZED_RUNNERS");
// Runner labels come from repository variables, never caller inputs.
assert.notInclude(text, "inputs.linux_runner");
assert.notInclude(text, "inputs.windows_runner");
assert.notInclude(text, "inputs.macos_x64_runner");
assert.notInclude(text, "inputs.macos_arm64_runner");
// Every build job transitively depends on authorization.
assert.include(jobBlock(text, "preflight"), "needs: [authorize]");
assert.include(jobBlock(text, "bundle"), "needs: [preflight]");
for (const job of ["preflight", "bundle", "cli_linux_x64", "qualify", "receipts", "publish"]) {
assert.include(
jobBlock(text, job),
"runs-on: ubuntu-24.04",
`${job} must use hosted Linux x64`,
);
}
assert.notInclude(text, "authorize:");
}),
);

Expand Down
Loading