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
118 changes: 114 additions & 4 deletions .github/workflows/mobile-bk-release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@
#
# SECURITY. The android job holds the keystore secret AND runs every
# dependency's install scripts, so it gets `contents: read` and nothing else;
# only the publish job, which runs no build scripts, gets `contents: write`.
# only the publish jobs, which run no build scripts, get `contents: write`.
# Triggers stay push/dispatch-only: DO NOT add `pull_request`,
# `pull_request_target` or `issue_comment`, which would run fork-authored code
# in the same job as the signing key.
Expand Down Expand Up @@ -215,13 +215,40 @@ jobs:
- name: Build unsigned IPA
env:
BK_GIT_SHA: ${{ github.sha }}
# SideStore needs a new CFBundleVersion even when app-version.ts has
# not changed. Workflow run numbers are monotonic across both release
# branches, so an expbkmain build can never look older than the last.
BK_IOS_BUILD_NUMBER: ${{ github.run_number }}
run: node scripts/build-bk-mobile.ts --platform ios

- name: Upload IPA
- name: Generate SideStore source
env:
BK_MOBILE_VERSION: ${{ needs.guard.outputs.version }}
run: |
set -euo pipefail
shopt -s nullglob
ipas=(release/mobile/*.ipa)
if [[ ${#ipas[@]} -ne 1 ]]; then
echo "::error::Expected exactly one IPA, found ${#ipas[@]}." >&2
exit 1
fi
released_at=$(date -u +%Y-%m-%dT%H:%M:%SZ)
node scripts/generate-bk-mobile-source.ts \
--branch "$GITHUB_REF_NAME" \
--repository "$GITHUB_REPOSITORY" \
--version "$BK_MOBILE_VERSION" \
--git-sha "$GITHUB_SHA" \
--build-number "$GITHUB_RUN_NUMBER" \
--date "$released_at" \
--ipa "${ipas[0]}"

- name: Upload IPA and SideStore source
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
with:
name: bk-mobile-ios-${{ github.sha }}
path: release/mobile/*.ipa
path: |
release/mobile/*.ipa
release/mobile/bk-mobile-${{ github.ref_name }}.json
retention-days: 7
if-no-files-found: error

Expand Down Expand Up @@ -264,7 +291,7 @@ jobs:
run: |
set -euo pipefail
shopt -s nullglob
assets=(release/mobile/*.apk release/mobile/*.ipa)
assets=(release/mobile/*.apk release/mobile/*.ipa release/mobile/*.json)
if [[ ${#assets[@]} -eq 0 ]]; then
echo "::error::No artifacts to publish." >&2
exit 1
Expand All @@ -280,6 +307,7 @@ jobs:
echo " It upgrades a previous BK build in place."
echo "- **iOS**: download the \`.ipa\` on your Mac and open it with SideStore,"
echo " which re-signs it with your free Apple ID."
echo " Add the branch's SideStore source once to receive later builds as updates."
echo
echo "Pair from **Settings → Connections** on the server this was built for,"
echo "then scan the QR code in the app."
Expand All @@ -295,3 +323,85 @@ jobs:
--notes-file release-notes.md \
--prerelease \
--target "${{ github.sha }}"

source:
name: Publish SideStore source
needs: [guard, ios, publish]
# A rerun repairs the rolling source without rebuilding when the immutable
# per-SHA release already exists. This makes a partial publish recoverable.
if: >-
always()
&& (needs.guard.outputs.already-published == 'true'
|| (needs.ios.result == 'success'
&& needs.publish.result == 'success'))
runs-on: ubuntu-latest
timeout-minutes: 10
permissions:
# No checkout and no branch code execution: this job only copies the
# source JSON generated by the secretless iOS job between releases.
contents: write
env:
GH_TOKEN: ${{ github.token }}
steps:
- name: Download source from the immutable build release
run: |
set -euo pipefail
source_asset="bk-mobile-${GITHUB_REF_NAME}.json"
mkdir -p release/mobile
gh release download "${{ needs.guard.outputs.tag }}" \
--repo "$GITHUB_REPOSITORY" \
--pattern "$source_asset" \
--dir release/mobile

- name: Publish rolling SideStore source
run: |
set -euo pipefail
source_tag="bk-mobile-source-${GITHUB_REF_NAME}"
source_asset="bk-mobile-${GITHUB_REF_NAME}.json"
source_path="release/mobile/${source_asset}"
previous_dir=$(mktemp -d)

# Retain recent immutable versions for inspection and downgrade. The
# freshly built version remains first because SideStore treats order,
# not semantic sorting, as the definition of "latest".
if gh release download "$source_tag" \
--repo "$GITHUB_REPOSITORY" \
--pattern "$source_asset" \
--dir "$previous_dir" 2>/dev/null; then
merged_source=$(mktemp)
jq --slurpfile previous "$previous_dir/$source_asset" '
.apps[0].versions = (
reduce (
.apps[0].versions + ($previous[0].apps[0].versions // [])
)[] as $version (
[];
if any(
.[];
.version == $version.version
and (.buildVersion // "") == ($version.buildVersion // "")
) then . else . + [$version] end
) | .[:20]
)
' "$source_path" > "$merged_source"
mv "$merged_source" "$source_path"
fi

if ! gh release view "$source_tag" --repo "$GITHUB_REPOSITORY" >/dev/null 2>&1; then
gh release create "$source_tag" \
--repo "$GITHUB_REPOSITORY" \
--title "BK T3 Code ${GITHUB_REF_NAME} SideStore source" \
--notes "Rolling AltSource feed for BK T3 Code builds from ${GITHUB_REF_NAME}." \
--prerelease \
--target "$GITHUB_SHA"
fi

gh release upload "$source_tag" "$source_path" \
--repo "$GITHUB_REPOSITORY" \
--clobber

verify_dir=$(mktemp -d)
gh release download "$source_tag" \
--repo "$GITHUB_REPOSITORY" \
--pattern "$source_asset" \
--dir "$verify_dir"
cmp "$source_path" "$verify_dir/$source_asset"
15 changes: 15 additions & 0 deletions apps/mobile/app.config.bk.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -124,4 +124,19 @@ describe("applyBkMobileConfig", () => {
applyBkMobileConfig(upstreamConfig, { ...BK_ENV, BK_ANDROID_VERSION_CODE: "nope" }),
).toThrow(/positive integer/);
});

it("uses a unique iOS build number for SideStore updates", () => {
const config = applyBkMobileConfig(upstreamConfig, {
...BK_ENV,
BK_IOS_BUILD_NUMBER: "42",
});
expect(config.ios?.buildNumber).toBe("42");

expect(() =>
applyBkMobileConfig(upstreamConfig, { ...BK_ENV, BK_IOS_BUILD_NUMBER: "4.2" }),
).toThrow(/positive integer/);
expect(() =>
applyBkMobileConfig(upstreamConfig, { ...BK_ENV, BK_IOS_BUILD_NUMBER: "0" }),
).toThrow(/positive integer/);
});
});
3 changes: 3 additions & 0 deletions apps/mobile/app.config.bk.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ import {
BK_MOBILE_ICON_PATH,
BK_MOBILE_SCHEME,
parseBkAndroidVersionCode,
parseBkIosBuildNumber,
type BkRepoEnv,
} from "../../scripts/lib/bk-mobile.ts";

Expand All @@ -45,6 +46,7 @@ export function applyBkMobileConfig(config: ExpoConfig, repoEnv: BkRepoEnv): Exp

const gitSha = repoEnv.BK_GIT_SHA?.trim() || null;
const androidVersionCode = parseBkAndroidVersionCode(repoEnv.BK_ANDROID_VERSION_CODE);
const iosBuildNumber = parseBkIosBuildNumber(repoEnv.BK_IOS_BUILD_NUMBER);

const {
owner: _owner,
Expand Down Expand Up @@ -72,6 +74,7 @@ export function applyBkMobileConfig(config: ExpoConfig, repoEnv: BkRepoEnv): Exp
updates: { enabled: false },
ios: {
...ios,
...(iosBuildNumber === null ? {} : { buildNumber: iosBuildNumber }),
// `bundleIdentifier` is already the BK id: app.config.ts resolves it from
// T3CODE_IOS_PERSONAL_TEAM_BUNDLE_ID, which assertBkBuildEnvironment
// pins. Rewriting it here would desynchronise it from the extension
Expand Down
45 changes: 35 additions & 10 deletions docs/operations/bk-mobile-build.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,15 +5,16 @@ upstream's, built with a different identity and distributed by sideload rather
than through the App Store or Play Store — this fork has no Expo/EAS account,
no Apple Developer team and no store listings.

| | |
| --------------------------- | ------------------------------------------------------ |
| App name | **BK T3 Code** |
| Bundle id / package | `work.beknown.bkt3code.mobile` |
| URL scheme | `t3code-bk://` |
| Version reported to servers | `<MOBILE_APP_VERSION>+bk.<sha7>` |
| Android | release APK, signed with the fork keystore, sideloaded |
| iOS | **unsigned** `.ipa`, re-signed on device by SideStore |
| OTA updates | **disabled** — every release is a fresh sideload |
| | |
| --------------------------- | --------------------------------------------------------- |
| App name | **BK T3 Code** |
| Bundle id / package | `work.beknown.bkt3code.mobile` |
| URL scheme | `t3code-bk://` |
| Version reported to servers | `<MOBILE_APP_VERSION>+bk.<sha7>` |
| Android | release APK, signed with the fork keystore, sideloaded |
| iOS | **unsigned** `.ipa`, re-signed on device by SideStore |
| Expo OTA updates | **disabled** |
| iOS binary updates | SideStore AltSource; every update is re-signed/sideloaded |

Identity lives in `apps/mobile/app.config.bk.ts`, which rewrites the finished
upstream Expo config when `T3CODE_BK_MOBILE=1`. `apps/mobile/app.config.ts`
Expand Down Expand Up @@ -64,6 +65,29 @@ dev server, and nothing needs to be.
2. Open it with SideStore, which re-signs it using your free Apple ID.
3. Refresh it within 7 days, which is how long a free-team signature lasts.

For later builds, add the branch's update source to SideStore once:

| Channel | SideStore source URL |
| ----------- | ---------------------------------------------------------------------------------------------------------------- |
| `expbkmain` | `https://github.com/beknown-work/bkt3code/releases/download/bk-mobile-source-expbkmain/bk-mobile-expbkmain.json` |
| `bkmain` | `https://github.com/beknown-work/bkt3code/releases/download/bk-mobile-source-bkmain/bk-mobile-bkmain.json` |

In SideStore, open **Browse → Sources → +**, paste the URL, and add the source.
The current BK T3 Code build then appears in that source, and future builds show
an **Update** button. Keep LocalDevVPN enabled while installing, updating, or
refreshing.

This is a binary update, not an Expo OTA: SideStore downloads the new IPA,
re-signs it with the same Apple Account and installs it over the existing app.
iOS does not allow BK T3 Code to replace its own executable, so the final update
action must remain in SideStore. App data is preserved because the re-signed
bundle identity stays stable.

The workflow publishes the JSON at a stable rolling-release URL only after the
matching immutable IPA release exists. Each CI IPA receives `github.run_number`
as its `CFBundleVersion`, allowing SideStore to detect a newer build even while
`MOBILE_APP_VERSION` remains unchanged.

Free Apple IDs cannot sign App Groups, extension targets, push notifications,
Associated Domains or Sign in with Apple. BK iOS builds therefore reuse
upstream's `T3CODE_IOS_PERSONAL_TEAM=1` path, which strips exactly those, and
Expand Down Expand Up @@ -164,4 +188,5 @@ T3CODE_BK_MOBILE=1 T3CODE_IOS_PERSONAL_TEAM=1 \
native manifest version, the release tag and the base of the `client_version`
the app reports. Bump it there and nowhere else. The Android `versionCode` comes
from the workflow run number, so it stays monotonic without being tracked by
hand.
hand. The iOS `CFBundleVersion` uses that same run number so SideStore can order
updates that share one `MOBILE_APP_VERSION`.
2 changes: 1 addition & 1 deletion docs/operations/expbkt3-customizations.md
Original file line number Diff line number Diff line change
Expand Up @@ -111,7 +111,7 @@ turn-settlement rewrite in `state/threadReducer.ts`, the restart predicate in
| Session identity | `apps/server/src/identity/SessionIdentityEnvironment.ts`, `apps/server/src/provider/claudeSessionIdentity.expbkt3.ts` | `ProviderCommandReactor.ts` execution-options seam, `ProviderService.ts` adapter-spawn seam, `identityEnvironment` on `ProviderSessionExecutionOptions`, conditional scrub in `SourceControlExecutionEnvironment.ts`, `server.ts` layer, `ClaudeAdapter.ts` system-prompt append + `UserPromptSubmit` hook seams |
| Agent views in chat | `apps/server/src/agentui/`, `persistence/AgentUiRenders.ts`, migration 1022, `packages/contracts/src/agentUi.ts`, `packages/client-runtime/src/state/agentUi.ts`, `apps/web/src/fork/agentUiSurface.tsx`, `apps/web/src/state/agentUi.ts` | `t3_show_ui` in the MCP control toolkit, fork RPC group + scopes + handlers, one `ws.ts` dep, one `server.ts` layer, marked handle passthrough in `ActivityPayloadProjection.ts`, `agentUi` field + read in `session-logic.ts`, one import and early return in `MessagesTimeline.tsx`, Experiments toggle + `settingsSearch.ts` entry |
| Experimental deployment | `.github/workflows/deploy-expbkt3.yml`, `deploy/expbkt3/` | none |
| BK mobile distribution | `apps/mobile/app.config.bk.ts`, `apps/mobile/plugins/withBkAndroidReleaseSigning.cjs`, `apps/mobile/src/lib/bkBuildIdentity.ts`, `scripts/build-bk-mobile.ts`, `.github/workflows/mobile-bk-release.yml` | two lines in `app.config.ts` (import + export), one linking prefix in `App.tsx`, one version call in `authClientMetadata.ts` |
| BK mobile distribution | `apps/mobile/app.config.bk.ts`, `apps/mobile/plugins/withBkAndroidReleaseSigning.cjs`, `apps/mobile/src/lib/bkBuildIdentity.ts`, `scripts/build-bk-mobile.ts`, `scripts/generate-bk-mobile-source.ts`, `.github/workflows/mobile-bk-release.yml` | two lines in `app.config.ts` (import + export), one linking prefix in `App.tsx`, one version call in `authClientMetadata.ts` |

Agent views currently have an enforced runtime-off gate in
`apps/web/src/fork/agentUiRuntime.ts`. Framed collaboration apps can decline to
Expand Down
8 changes: 8 additions & 0 deletions scripts/build-bk-mobile.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import {
bkArtifactFileName,
bkBuildEnv,
bkReleaseTag,
parseBkIosBuildNumber,
parseMobileAppVersion,
} from "./lib/bk-mobile.ts";

Expand Down Expand Up @@ -61,6 +62,13 @@ describe("build identity", () => {
it("derives the Xcode scheme the way Expo sanitises the app name", () => {
expect(xcodeSchemeName("BK T3 Code")).toBe("BKT3Code");
});

it("accepts only positive integer iOS build numbers", () => {
expect(parseBkIosBuildNumber(undefined)).toBeNull();
expect(parseBkIosBuildNumber(" 17 ")).toBe("17");
expect(() => parseBkIosBuildNumber("0")).toThrow(/positive integer/);
expect(() => parseBkIosBuildNumber("17beta")).toThrow(/positive integer/);
});
});

describe("parseMobileAppVersion", () => {
Expand Down
78 changes: 78 additions & 0 deletions scripts/generate-bk-mobile-source.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
import { describe, expect, it } from "vite-plus/test";

import { createBkMobileSource, parseArgs } from "./generate-bk-mobile-source.ts";

const input = {
branch: "expbkmain" as const,
repository: "beknown-work/bkt3code",
version: "1.0.4",
gitSha: "a1b2c3d4e5f6789012345678901234567890abcd",
buildNumber: "17",
date: "2026-09-01T12:34:56Z",
ipaSize: 35_221_878,
ipaSha256: "f".repeat(64),
};

describe("createBkMobileSource", () => {
it("publishes a stable source URL and an immutable IPA URL", () => {
const source = createBkMobileSource(input);
expect(source.sourceURL).toBe(
"https://github.com/beknown-work/bkt3code/releases/download/" +
"bk-mobile-source-expbkmain/bk-mobile-expbkmain.json",
);
expect(source.apps[0]?.versions[0]).toMatchObject({
version: "1.0.4",
buildVersion: "17",
marketingVersion: "1.0.4+bk.a1b2c3d",
downloadURL:
"https://github.com/beknown-work/bkt3code/releases/download/" +
"bk-mobile-v1.0.4-a1b2c3d/bk-t3code-1.0.4-a1b2c3d.ipa",
size: 35_221_878,
sha256: "f".repeat(64),
minOSVersion: "18.0",
});
});

it("uses the unsigned app's original bundle identifier", () => {
const source = createBkMobileSource(input);
expect(source.apps[0]?.bundleIdentifier).toBe("work.beknown.bkt3code.mobile");
expect(source.apps[0]?.appPermissions).toEqual({
entitlements: [],
privacy: {
NSCameraUsageDescription:
"Allow T3 Code to access your camera so you can scan pairing QR codes.",
NSFaceIDUsageDescription: "Allow BKT3Code to access your Face ID biometric data.",
NSLocalNetworkUsageDescription:
"Allow T3 Code to connect to T3 Code servers on your local network or tailnet.",
},
});
});
});

describe("parseArgs", () => {
const argv = [
"--branch",
"expbkmain",
"--repository",
"beknown-work/bkt3code",
"--version",
"1.0.4",
"--git-sha",
"a1b2c3d4",
"--build-number",
"17",
"--date",
"2026-09-01T12:34:56Z",
"--ipa",
"release/mobile/bk-t3code-1.0.4-a1b2c3d.ipa",
];

it("derives the channel-specific output name", () => {
expect(parseArgs(argv).outputPath).toMatch(/release\/mobile\/bk-mobile-expbkmain\.json$/);
});

it("rejects channels and build numbers SideStore cannot order", () => {
expect(() => parseArgs(argv.with(1, "feature"))).toThrow(/--branch/);
expect(() => parseArgs(argv.with(9, "0"))).toThrow(/positive integer/);
});
});
Loading
Loading