Distribution: artifacts, channels, and what actually ships
T3 Code distributes several deliberately different products: an npm CLI that contains a bundled web client, platform-specific Electron installers built from a staged closure, a release-controlled hosted web channel, store binaries plus fingerprint-gated mobile OTAs, and checksum-pinned AUR packages derived from the published Linux AppImage. The release matrix—not every target the builder knows—defines what is actually shipped.
What this chapter resolves
- Identify the concrete artifact and publication boundary for CLI, desktop, hosted web, mobile, and AUR delivery.
- Trace how the npm CLI embeds the web renderer and why its exact version publishes before the GitHub Release.
- Separate the desktop builder's supported platform targets from the four artifacts release CI currently produces.
- Explain release-controlled hosted channels and fingerprint-gated mobile updates without calling either an arbitrary-commit deployment.
- Follow a marketing download link and an AUR package back to a named GitHub Release asset.
“T3 Code release” does not name one file. It is a coordinated set of artifacts with different consumers, packaging closures, update mechanisms, and trust boundaries: an npm executable, desktop installers, a hosted web deployment, native mobile builds and JavaScript updates, and an Arch package that repackages a release asset. The important question is therefore not “what command builds T3 Code?” but which artifact is this consumer actually receiving, and what gate certified it?
1. One source revision fans out into several delivery contracts
The monorepo can produce a server/CLI bundle, a web build, desktop process output,
and mobile app output, but their distribution boundaries diverge quickly. The CLI is
an npm package named t3; desktop is an Electron-builder product with installer and
updater side files; the hosted web app is a channel alias; mobile has native-store
binaries and compatible OTA bundles; the AUR packages consume a published AppImage
rather than independently publishing a desktop build.
The release workflow first resolves stable versus nightly metadata, pins all later jobs to its chosen ref, and gives the channel a version, tag, npm dist-tag, and “latest” policy. Quality, relay configuration, desktop builds, and resource-monitor artifacts feed into CLI publication. Only a successful npm publication unlocks the GitHub Release. A successful GitHub Release then unlocks the AUR handoff and hosted web deploy. This is dependency ordering, not an assertion that all downstream consumer updates happen simultaneously.
Zoom with the controls, +/−, or Ctrl/⌘ + trackpad scroll. Enable Pan to drag, use two-finger scrolling, or use the arrow keys. 0 fits the diagram; Esc leaves Pan or expanded view.
Text equivalent
A release starts from a pinned source revision. Web output is copied into the CLI package, which publishes to npm. Desktop builds create macOS arm64 and x64 DMGs, a Linux x64 AppImage, and a Windows x64 NSIS installer, then those files go to GitHub Releases. AUR reads the Linux AppImage and digest from that release. Hosted web deploys one release-controlled latest or nightly channel. Mobile store binaries and OTA updates take their own EAS path; OTA requires a matching native fingerprint.
2. The npm artifact is a server executable plus an embedded renderer
apps/server/package.json declares a public executable: t3 resolves to
./dist/bin.mjs, and npm publishes only dist. The CLI build first runs the server
bundle, then looks for apps/web/dist. When that web output exists it is copied into
apps/server/dist/client; development icon replacements are applied there as a
post-copy product adjustment. That means the npm artifact can carry both the server
entrypoints and a renderer payload—rather than downloading the web product at
install time.
{
"name": "t3",
"version": "0.0.33",
"license": "MIT",
"repository": {
"type": "git",
"url": "https://github.com/pingdotgg/t3code",
"directory": "apps/server"
},
"bin": {
"t3": "./dist/bin.mjs"
},
"files": [
"dist"
],
"type": "module",
"scripts": {
"dev": "node --watch src/bin.ts",
"build:bundle": "vp pack && vp pack src/service-launcher.ts --out-dir dist --no-clean",
"start": "node dist/bin.mjs",
"typecheck": "tsgo --noEmit",
"test": "vp test run"const buildCmd = Command.make(
"build",
{
verbose: Flag.boolean("verbose").pipe(Flag.withDefault(false)),
},
(config) =>
Effect.gen(function* () {
const path = yield* Path.Path;
const fs = yield* FileSystem.FileSystem;
const repoRoot = yield* RepoRoot;
const serverDir = path.join(repoRoot, "apps/server");
yield* Effect.log("[cli] Running tsdown...");
yield* runCommand(
ChildProcess.make(process.execPath, ["--run", "build:bundle"], {
cwd: serverDir,
stdout: config.verbose ? "inherit" : "ignore",
stderr: "inherit",
shell: false,
}),
);
const webDist = path.join(repoRoot, "apps/web/dist");
const clientTarget = path.join(serverDir, "dist/client");
if (yield* fs.exists(webDist)) {
yield* fs.copy(webDist, clientTarget);
yield* applyDevelopmentIconOverrides(repoRoot, serverDir);
yield* Effect.log("[cli] Bundled web app into dist/client");
} else {
yield* Effect.logWarning("[cli] Web dist not found — skipping client bundle.");
}
}),
).pipe(Command.withDescription("Build the server package (tsdown + bundle web client)."));The build step can warn and continue if apps/web/dist is missing. Publication is
stricter: before it rewrites temporary publish metadata it verifies
dist/bin.mjs, dist/service-launcher.mjs, and dist/client/index.html. The
publish path then resolves workspace catalog/override dependencies into an npm-ready
manifest, applies publish icon overrides, invokes package publication with the
selected dist-tag/version, and restores the original local metadata and icons in
its release finalizer. That restore is a workspace-cleanliness guarantee; it does
not undo an already completed npm publication.
3. Desktop packaging constructs a closure, then CI selects four lanes
The desktop artifact builder does more than wrap apps/desktop. It requires emitted
desktop, resource, and server directories; verifies that the server bundle is
self-contained enough for its runtime needs; requires the embedded
server/dist/client/index.html; applies channel-appropriate web branding; and
stages Electron output, resources, server output, production dependencies, patches,
and platform-specific native materials into a temporary application tree.
The platform split matters. macOS and Linux package a merged application tree. The
Windows packaging path deliberately keeps desktop main-process dependencies in
app.asar and builds a separate server asar sidecar; it also carries the
Windows-to-WSL server support needed by that product. This is an implementation
choice about runtime resolution and payload shape, not a claim that every desktop
consumer launches a local WSL server.
const buildConfig: Record<string, unknown> = {
appId: DESKTOP_APP_ID,
productName: resolveDesktopProductName(version),
artifactName: "T3-Code-${version}-${arch}.${ext}",
electronLanguages: [...DESKTOP_ELECTRON_LANGUAGES],
files: [...DESKTOP_FILE_EXCLUSIONS],
directories: {
buildResources: "apps/desktop/resources",
},
// All platforms keep app.asar fully packed; electron-builder's default
// smart unpack extracts native libraries, which loaders find in
// app.asar.unpacked. Windows additionally ships the server tree as the
// hand-packed server.asar sidecar (see WINDOWS_SERVER_ASAR_RESOURCE).
extraResources: [
...DESKTOP_EXTRA_RESOURCES,
...(platform === "win" ? WINDOWS_SERVER_EXTRA_RESOURCES : []),
],
};
const updateChannel = resolveDesktopUpdateChannel(version);
const publishConfig = yield* resolveGitHubPublishConfig(updateChannel);
if (publishConfig) {
buildConfig.publish = [publishConfig];
} else if (mockUpdates) {
buildConfig.publish = [
{
provider: "generic",
url: resolveMockUpdateServerUrl(mockUpdateServerPort),
},
];
}
if (platform === "mac") {
buildConfig.mac = {
target: target === "dmg" ? [target, "zip"] : [target],
icon: "icon.icns",
category: "public.app-category.developer-tools",
protocols: [
{
name: "T3 Code",
schemes: ["t3code", "t3code-dev"],
},
],
...(macPasskeySigning
? {
entitlements: macPasskeySigning.entitlementsPath,
provisioningProfile: macPasskeySigning.provisioningProfilePath,
}
: {}),
}; if (platform === "linux") {
buildConfig.linux = {
target: [target],
executableName: "t3code",
icon: "icons",
category: "Development",
// electron-builder turns these into MimeType=x-scheme-handler/<scheme>;
// in the .desktop entry (Exec already gets %U), so browsers can hand
// t3code:// OAuth callbacks to the app.
protocols: [
{
name: "T3 Code",
schemes: ["t3code", "t3code-dev"],
},
],
desktop: {
entry: {
StartupWMClass: "t3code",
},
},
};
}
if (platform === "win") {
buildConfig.npmRebuild = false;
// Keep blockmap-based differential downloads enabled while changing the
// installed file topology. The optimization is in the payload shape, not
// in trading update bandwidth for install speed.
buildConfig.nsis = { differentialPackage: true };
const winConfig: Record<string, unknown> = {
target: [target],
icon: "icon.ico",
// Resource editing applies the product metadata and icon independently
// of code signing. Disabling it for local unsigned builds leaves the
// packaged executable with Electron's stock icon.
signAndEditExecutable: true,
};
if (signed) {
winConfig.azureSignOptions = yield* AzureTrustedSigningOptionsConfig;
}
buildConfig.win = winConfig;
}electron-builder receives an explicit platform flag, architecture, and
--publish never; packaging jobs upload their artifacts, then the release job merges
the relevant updater manifests and attaches DMGs, ZIPs, AppImages, EXEs, blockmaps,
and YAML manifests to GitHub Releases. The builder’s macOS dmg target includes a
ZIP updater artifact, which explains why a GitHub Release can have more files than
the user-facing installer matrix names.
Shipped matrix vs. possible matrix
| Release CI entry | Product installer target | Status in inspected workflow |
|---|---|---|
| macOS arm64 | DMG (plus updater ZIP) | active |
| macOS x64 | DMG (plus updater ZIP) | active |
| Linux x64 | AppImage | active |
| Windows x64 | NSIS installer | active |
| Windows arm64 | NSIS configuration is structurally possible | commented out; not shipped |
build:
name: Build ${{ matrix.label }}
# build_wsl_node_pty stays in `needs` so it runs first and its artifact is
# available to download, but only the Windows matrix entry consumes it. We
# therefore gate the job on preflight + relay (must succeed) WITHOUT requiring
# build_wsl_node_pty, so a failed Linux prebuild doesn't skip the macOS/Linux
# builds. `!cancelled()` (not `!failure()`) lets the job run even when
# build_wsl_node_pty failed; the Windows-only download step below then fails
# that single platform if the prebuild is missing.
needs: [preflight, relay_public_config, build_wsl_node_pty]
if: ${{ !cancelled() && needs.preflight.result == 'success' && needs.relay_public_config.result == 'success' }}
runs-on: ${{ matrix.runner }}
timeout-minutes: 30
env:
T3CODE_CLERK_PUBLISHABLE_KEY: ${{ needs.relay_public_config.outputs.clerk_publishable_key }}
T3CODE_CLERK_JWT_TEMPLATE: ${{ needs.relay_public_config.outputs.clerk_jwt_template }}
T3CODE_CLERK_CLI_OAUTH_CLIENT_ID: ${{ needs.relay_public_config.outputs.clerk_cli_oauth_client_id }}
T3CODE_RELAY_URL: ${{ needs.relay_public_config.outputs.relay_url }}
strategy:
fail-fast: false
matrix:
include:
- label: macOS arm64
runner: blacksmith-12vcpu-macos-26
platform: mac
target: dmg
arch: arm64
rust_target: aarch64-apple-darwin
resource_key: darwin-arm64
- label: macOS x64
runner: blacksmith-12vcpu-macos-26
platform: mac
target: dmg
arch: x64
rust_target: x86_64-apple-darwin
resource_key: darwin-x64
- label: Linux x64
runner: blacksmith-32vcpu-ubuntu-2404
platform: linux
target: AppImage
arch: x64
rust_target: x86_64-unknown-linux-gnu
resource_key: linux-x64
- label: Windows x64
runner: blacksmith-32vcpu-windows-2025
platform: win
target: nsis
arch: x64
rust_target: x86_64-pc-windows-msvc
resource_key: win32-x64
# - label: Windows arm64
# runner: windows-11-arm
# platform: win
# target: nsis
# arch: arm64
steps:4. Hosted web and mobile use channels, but their compatibility gates differ
The hosted web app deliberately disables Vercel’s normal Git deployment trigger.
Release CI performs the deployment, chooses latest for stable or nightly for a
nightly release, and aliases the deployment to that channel domain. The public router
uses a long-lived secure cookie to route app.t3.codes requests to the selected
channel origin. Thus “latest” is a release result, not necessarily the newest source
commit in the repository.
Mobile has a separate production workflow. It runs from Linux because its Expo fingerprint needs to match the build environment, reconciles store builds when the declared mobile version needs one, and publishes an OTA only for each platform with at least one completed production build matching the current native fingerprint. The mobile config explicitly uses the fingerprint runtime-version policy: native dependencies, config plugins, and patches participate in the compatibility identity.
- name: Publish fingerprint-gated OTA
if: steps.expo-token.outputs.present == 'true' && github.event_name == 'push'
working-directory: apps/mobile
env:
EXPO_TOKEN: ${{ secrets.EXPO_TOKEN }}
run: |
message="$(git log -1 --pretty=%s | head -c 120) ($(git rev-parse --short=9 HEAD))"
for platform in ios android; do
# eas-cli prints an environment-loaded notice to stdout before the
# JSON even with --json, so discard everything before the document.
hash="$(eas fingerprint:generate --platform "$platform" --environment production --json --non-interactive | sed -n '/^{/,$p' | jq -er '.hash | select(type == "string" and length > 0)')"
matching="$(eas build:list --platform "$platform" --build-profile production --status finished --fingerprint-hash "$hash" --limit 1 --json --non-interactive | jq 'length')"
if [ "$matching" -gt 0 ]; then
eas update \
--channel production \
--environment production \
--platform "$platform" \
--message "$message" \
--non-interactive
echo ":white_check_mark: $platform: OTA published to production (fingerprint \`$hash\`)" >> "$GITHUB_STEP_SUMMARY"
else
echo ":warning: $platform: no finished production build matches fingerprint \`$hash\` — OTA skipped; JS changes reach $platform only once a matching build ships" >> "$GITHUB_STEP_SUMMARY"
fi
done5. Download resolution and AUR both trust named release assets
The marketing download page fetches the latest GitHub Release and maps cards by
asset suffix: arm64/x64 DMGs, an x64 Windows .exe, and an x86_64 AppImage. If the
asset lookup or fetch fails, cards fall back to the releases page rather than
inventing a direct URL. Mobile cards are intentionally different: they point to the
App Store and Google Play, not a GitHub asset.
<script>
import { fetchLatestRelease, RELEASES_URL } from "../lib/releases";
async function init() {
const versionLabel = document.getElementById("version-label");
// Only release-asset links; mobile store cards have no data-asset and keep their href.
const cards = document.querySelectorAll<HTMLAnchorElement>("a[data-asset]");
try {
const release = await fetchLatestRelease();
if (versionLabel && release.tag_name) {
versionLabel.textContent = `Latest (${release.tag_name})`;
}
const changelogLink = document.getElementById("changelog-link") as HTMLAnchorElement | null;
if (changelogLink && release.html_url) {
changelogLink.href = release.html_url;
changelogLink.style.display = "";
}
cards.forEach((card) => {
const suffix = card.dataset.asset;
if (!suffix) return;
const match = (release.assets ?? []).find((a) => a.name.endsWith(`-${suffix}`));
if (match) {
card.href = match.browser_download_url;
} else {
card.href = RELEASES_URL;
}
});
} catch {
if (versionLabel) {
versionLabel.textContent = "Could not load release info.";
}
cards.forEach((card) => {
card.href = RELEASES_URL;
});
}
}
init();
</script>The AUR workflow begins only after GitHub Release publication. Its release script
accepts either a stable semver tag or the prescribed nightly tag pattern; selects
t3code-bin or t3code-nightly-bin accordingly; reads the exact AppImage digest
from that GitHub Release; reads the LICENSE at the same tag; edits the matching
PKGBUILD; validates it with namcap and makepkg; then pushes only if its AUR SSH
credential exists. A missing key is intentionally a “validated, not published” end
state.
if [[ "$tag" =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
pkgname='t3code-bin'
elif [[ "$tag" =~ ^v[0-9]+\.[0-9]+\.[0-9]+-nightly\.[0-9]{8}\.[0-9]+$ ]]; then
pkgname='t3code-nightly-bin'
else
echo "Release $tag does not publish an AUR package."
exit 0
fi
version="${tag#v}"
pkgver="${version//-/_}"
asset_name="T3-Code-${version}-x86_64.AppImage"
release_json="$(gh api "repos/$repo/releases/tags/$tag")"
asset_digest="$(jq -r --arg name "$asset_name" \
'.assets[] | select(.name == $name) | .digest' <<<"$release_json")"
appimage_sha256="${asset_digest#sha256:}"
if [[ ! "$appimage_sha256" =~ ^[0-9a-f]{64}$ ]]; then
echo "Release $tag is missing $asset_name or its SHA-256 digest." >&2
exit 1
fi
work_dir="$(mktemp -d)"
trap 'rm -rf -- "$work_dir"' EXIT
gh api -H 'Accept: application/vnd.github.raw' \
"repos/$repo/contents/LICENSE?ref=$tag" > "$work_dir/LICENSE"
license_sha256="$(sha256sum "$work_dir/LICENSE" | awk '{print $1}')"
package_dir="$repo_root/packaging/aur/$pkgname"
cd "$package_dir"
sed -Ei \
-e "s/^pkgver=.*/pkgver=$pkgver/" \
-e "s/^pkgrel=.*/pkgrel=$pkgrel/" \
-e "/# AppImage$/s/'[0-9a-f]{64}'/'$appimage_sha256'/" \
-e "/# upstream license$/s/'[0-9a-f]{64}'/'$license_sha256'/" \
PKGBUILDWork the artifact factory
Choose a delivery lane below and advance it one gate at a time. The desktop lane is useful for testing the crucial distinction: the builder can describe Windows arm64, but the final gate reports that the inspected release matrix does not activate it.
Choose a delivery lane, then inspect its gates
Each lane has a different output and failure boundary. Advance manually; no state progresses on its own.
npm CLI · Gate 1 of 4
- 1Build server
- 2Embed renderer
- 3Assert publish inputs
- 4Publish exact version
npm CLI
Build server
The server bundle emits dist/bin.mjs and the service launcher.
- Output
- t3 package
- Failure boundary
- Bundled code is not yet a complete product surface.
- Lane rule
- The published package contains dist only; publishing first proves the exact server version clients will invoke.
Static artifact map
| Lane | Output | Last gate |
|---|---|---|
| npm CLI | t3 package | Publish exact version — The release is held if npm publication fails. |
| Desktop release | DMG · AppImage · NSIS | Release four lanes — Windows arm64 is commented out in the release matrix: capability is not a published artifact. |
| Hosted + mobile | Channel deploy · store build · OTA | Gate OTA — Native drift skips OTA instead of shipping JavaScript into an incompatible binary. |
| AUR bridge | Arch package metadata | Validate then publish — Without the AUR SSH key, validation completes but publication is skipped. |
The durable reading rule is: source builds components; packaging assembles a consumer-specific closure; release policy selects and publishes a subset; channel and platform compatibility gates decide what a particular client may receive. Treating those as one step hides the real failure boundaries.