Skip to content
Merged
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
198 changes: 198 additions & 0 deletions .github/workflows/reload-build.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,198 @@
name: reload-build

# Dispatchable tagged dev-build for the cloud reload scripts' Blacksmith builder.
#
# scripts/reload-cloud.sh --builder blacksmith and scripts/reload-cloud-ios.sh
# --builder blacksmith (in the cmuxterm-hq control repo) dispatch this workflow
# against an ephemeral branch holding the caller's working tree, then download the
# produced artifact and install it locally. This is the Blacksmith alternative to
# SSH-leasing a fleet Mac. It is workflow_dispatch ONLY: nothing here runs on push
# or pull_request, so it never adds to the heavy CI fan-out.

on:
workflow_dispatch:
inputs:
tag:
description: Dev build tag (becomes cmux DEV <tag> / dev.cmux.ios.<tag>)
required: true
type: string
ref:
description: Source ref to build (branch or SHA). Defaults to the dispatch ref.
required: false
default: ""
type: string
platform:
description: Which artifact to build
required: false
default: macos
type: choice
options:
- macos
- ios
runner:
description: macOS runner label to build on
required: false
default: blacksmith-6vcpu-macos-26
type: choice
options:
- blacksmith-6vcpu-macos-26
- blacksmith-6vcpu-macos-15
- blacksmith-6vcpu-macos-latest
- warp-macos-26-arm64-6x
- warp-macos-15-arm64-6x
- depot-macos-latest
nonce:
description: Opaque marker echoed into run-name so the dispatcher can find this run.
required: false
default: ""
type: string

# Surface tag/platform/nonce in the run title so the dispatcher can match its run
# by the nonce it passed (gh workflow run does not return a run id).
run-name: "reload-build ${{ inputs.tag }} ${{ inputs.platform }} ${{ inputs.nonce }}"

permissions:
contents: read

concurrency:
# One in-flight build per tag+platform; a newer dispatch supersedes an older one.
group: reload-build-${{ inputs.tag }}-${{ inputs.platform }}
cancel-in-progress: true

jobs:
build:
runs-on: ${{ inputs.runner }}
timeout-minutes: 60
env:
# The ghostty CLI helper zig build is skipped; GhosttyKit comes prebuilt.
CMUX_SKIP_ZIG_BUILD: "1"
SWIFT_BACKTRACE: "interactive=no,timeout=0s,symbolicate=off,color=no"
steps:
- name: Checkout source ref
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
ref: ${{ inputs.ref || github.ref }}
submodules: recursive

- name: Start timer
id: t0
run: echo "epoch=$(date +%s)" >> "$GITHUB_OUTPUT"

- name: Select Xcode
run: |
set -euo pipefail
if [ -d "/Applications/Xcode.app/Contents/Developer" ]; then
XCODE_DIR="/Applications/Xcode.app/Contents/Developer"
else
XCODE_APP="$(find /Applications -maxdepth 1 -name 'Xcode*.app' -print 2>/dev/null | sort | tail -n 1 || true)"
[ -n "$XCODE_APP" ] || { echo "No Xcode.app under /Applications" >&2; exit 1; }
XCODE_DIR="$XCODE_APP/Contents/Developer"
fi
echo "DEVELOPER_DIR=$XCODE_DIR" >> "$GITHUB_ENV"
export DEVELOPER_DIR="$XCODE_DIR"
xcodebuild -version

- name: Install zig
run: ./scripts/install-zig-ci.sh

- name: Provision GhosttyKit (macOS)
if: ${{ inputs.platform == 'macos' }}
run: ./scripts/download-prebuilt-ghosttykit.sh || ./scripts/ensure-ghosttykit.sh

- name: Mark deps-ready
id: t1
run: echo "epoch=$(date +%s)" >> "$GITHUB_OUTPUT"

# --- macOS: build the tagged dev app via the same reload.sh the fleet uses ---
- name: Build tagged macOS app
if: ${{ inputs.platform == 'macos' }}
id: build_macos
run: |
set -euo pipefail
./scripts/reload.sh --tag "${{ inputs.tag }}" --swift-frontend-workaround 2>&1 | tee /tmp/reload.log
Comment on lines +107 to +112

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 security The macOS build step inlines ${{ inputs.tag }} directly into the shell command. GitHub Actions evaluates ${{ }} expressions before passing the string to the shell, so a tag like foo" --extra-flag or $(evil) is injected verbatim, turning the reload.sh invocation into arbitrary shell execution. The iOS step already does this correctly by binding the value to BUILD_TAG via env: and referencing $BUILD_TAG in the script — apply the same pattern here.

Suggested change
- name: Build tagged macOS app
if: ${{ inputs.platform == 'macos' }}
id: build_macos
run: |
set -euo pipefail
./scripts/reload.sh --tag "${{ inputs.tag }}" --swift-frontend-workaround 2>&1 | tee /tmp/reload.log
- name: Build tagged macOS app
if: ${{ inputs.platform == 'macos' }}
id: build_macos
env:
BUILD_TAG: ${{ inputs.tag }}
run: |
set -euo pipefail
./scripts/reload.sh --tag "$BUILD_TAG" --swift-frontend-workaround 2>&1 | tee /tmp/reload.log

app_path="$(awk '/^App path:/{getline; sub(/^ /,""); print; exit}' /tmp/reload.log)"
[ -n "$app_path" ] && [ -d "$app_path" ] || { echo "could not locate built app" >&2; exit 1; }
echo "app_path=$app_path" >> "$GITHUB_OUTPUT"
mkdir -p artifact
( cd "$(dirname "$app_path")" && ditto -c -k --sequesterRsrc --keepParent "$(basename "$app_path")" "$GITHUB_WORKSPACE/artifact/app.zip" )

# --- iOS: build an UNSIGNED debug archive (signed locally by the caller) ---
- name: Build unsigned iOS archive
if: ${{ inputs.platform == 'ios' }}
id: build_ios
env:
BUILD_TAG: ${{ inputs.tag }}
run: |
set -euo pipefail
slug="$(printf '%s' "$BUILD_TAG" | tr '[:upper:]' '[:lower:]' | tr -c 'a-z0-9-' '-' | sed 's/-\{2,\}/-/g; s/^-//; s/-$//')"
bundle_id="dev.cmux.ios.$slug"

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Empty iOS slug breaks bundle

Medium Severity

The iOS slug sanitizer can produce an empty string when the dev tag has no alphanumeric characters, yielding bundle id dev.cmux.ios. with no suffix. ios/scripts/reload.sh’s sanitize_tag falls back to dev and rejects unusable tags; this workflow does neither before xcodebuild archive.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 4b9de05. Configure here.

display_name="cmux DEV $BUILD_TAG"

# Register the iOS platform if the runner only has macOS provisioned.
ios_ready() { xcrun simctl runtime list 2>/dev/null | grep -qiE "iOS [0-9].*\(Ready\)"; }
if ! ios_ready; then
echo "iOS platform not registered; installing via downloadPlatform iOS"
xcodebuild -downloadPlatform iOS 2>&1 | tr '\r' '\n' | grep -ivE 'Preparing to download|registering download' | tail -8 || true
ios_ready || { echo "iOS platform still not registered; archive would fail" >&2; exit 1; }

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ios_ready grep never matches

High Severity

The ios_ready helper looks for iOS … (Ready) in xcrun simctl runtime list, but typical output lists runtimes like iOS 18.0 (22A3351) with a build number in parentheses, not (Ready). After xcodebuild -downloadPlatform iOS, the same check still fails and the step exits before archiving.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 4b9de05. Configure here.

fi

./scripts/ensure-ghosttykit.sh
out="$GITHUB_WORKSPACE/build"
mkdir -p "$out"
archive="$out/cmux-ios-$slug.xcarchive"
rm -rf "$archive"
xcodebuild archive \
-workspace ios/cmux.xcworkspace \
-scheme cmux-ios \
-configuration Debug \
-destination 'generic/platform=iOS' \
-archivePath "$archive" \
-derivedDataPath "$RUNNER_TEMP/cmux-ios-dd" \
PRODUCT_BUNDLE_IDENTIFIER="$bundle_id" \
PRODUCT_DISPLAY_NAME="$display_name" \
CMUX_GIT_SHA="$(git rev-parse --short HEAD)" \
CMUX_DEV_TAG="$BUILD_TAG" \
EXCLUDED_SOURCE_FILE_NAMES=Info.plist \
CODE_SIGNING_ALLOWED=NO \
CODE_SIGNING_REQUIRED=NO \
CODE_SIGN_IDENTITY=""
[ -d "$archive" ] || { echo "archive not produced: $archive" >&2; exit 1; }
mkdir -p artifact
( cd "$out" && ditto -c -k --keepParent "$(basename "$archive")" "$GITHUB_WORKSPACE/artifact/archive.zip" )

- name: Write timings.json
if: ${{ always() }}
run: |
set -euo pipefail
mkdir -p artifact
now=$(date +%s)
t0=${{ steps.t0.outputs.epoch }}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 t0 missing fallback — if the "Start timer" step is skipped (e.g. on a mid-run cancellation that still lets always() steps execute), steps.t0.outputs.epoch is empty and t0=${{ steps.t0.outputs.epoch }} expands to t0=, making every $(( … - t0 )) arithmetic expression fail. t1 already has || 0 for exactly this reason; t0 should too.

t1=${{ steps.t1.outputs.epoch || 0 }}
cat > artifact/timings.json <<JSON
{
"tag": "${{ inputs.tag }}",
"platform": "${{ inputs.platform }}",
"runner": "${{ inputs.runner }}",
"ref": "${{ inputs.ref }}",
Comment on lines +171 to +176

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 The tag and ref inputs are expanded by the GitHub Actions template engine directly inside the heredoc body, before the shell runs. A tag containing a " character (e.g. v1.0"injected) will produce syntactically invalid JSON in timings.json. Since t0 has no || 0 fallback (unlike t1), an empty steps.t0.outputs.epoch — which can happen if the timer step was skipped on a cancellation path — would also cause an arithmetic error in the $(( now - t0 )) expressions.

Suggested change
cat > artifact/timings.json <<JSON
{
"tag": "${{ inputs.tag }}",
"platform": "${{ inputs.platform }}",
"runner": "${{ inputs.runner }}",
"ref": "${{ inputs.ref }}",
cat > artifact/timings.json <<JSON
{
"tag": "$TAG",
"platform": "${{ inputs.platform }}",
"runner": "${{ inputs.runner }}",
"ref": "$REF",

"deps_seconds": $(( t1 > t0 ? t1 - t0 : 0 )),
"build_seconds": $(( t1 > 0 ? now - t1 : 0 )),
"post_checkout_total_seconds": $(( now - t0 ))
}
JSON
{
echo "### reload-build timings"
echo ""
echo "- runner: \`${{ inputs.runner }}\`"
echo "- platform: \`${{ inputs.platform }}\`"
echo "- deps: $(( t1 > t0 ? t1 - t0 : 0 ))s"
echo "- build: $(( t1 > 0 ? now - t1 : 0 ))s"
echo "- post-checkout total: $(( now - t0 ))s"
} >> "$GITHUB_STEP_SUMMARY"

- name: Upload artifact
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: reload-${{ inputs.tag }}-${{ inputs.platform }}
path: artifact/
retention-days: 3
if-no-files-found: error