Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
28 commits
Select commit Hold shift + click to select a range
756e6a4
Hash through swift-crypto on both platforms
bguidolim Sep 29, 2026
5e589fa
Replace OSAllocatedUnfairLock with a portable Locked value
bguidolim Sep 29, 2026
3e39dbc
Build the terminal and process layer on Glibc as well as Darwin
bguidolim Sep 29, 2026
8dedbe2
Resolve Homebrew prefixes and installation guidance per platform
bguidolim Sep 29, 2026
3cf95bf
Resolve the home directory from $HOME on both platforms
bguidolim Sep 29, 2026
0bdc461
Offer an upgrade command that matches how mcs was installed
bguidolim Sep 29, 2026
33f3dbf
Build, test and release on Linux for both architectures
bguidolim Sep 29, 2026
af65af2
Document Linux support and the platform differences
bguidolim Sep 29, 2026
edcf93e
Install the Swift toolchain dependencies swiftly asks for
bguidolim Sep 29, 2026
7a0c3d5
Lint on Linux and fix two shellcheck findings in the release job
bguidolim Sep 29, 2026
58ef140
Install the linters into their own bin directory
bguidolim Sep 29, 2026
a4e3093
Use the fileExists result when deciding a source is a directory
bguidolim Sep 29, 2026
23a142c
Cache the Swift toolchain on Linux runners
bguidolim Sep 29, 2026
6db5ee3
Share one dependency-source cache across every build job
bguidolim Sep 29, 2026
58dfcde
Pin the linter versions and assert what ran
bguidolim Sep 29, 2026
029ee50
Run the Linux smoke job on this PR to fill the compatibility matrix
bguidolim Sep 29, 2026
c8b0f16
Fill the compatibility matrix from the Linux smoke run
bguidolim Sep 29, 2026
59c3c14
Restore the smoke workflow to manual dispatch only
bguidolim Sep 29, 2026
4c137dc
Require the smoke workflow to follow a changed command contract
bguidolim Sep 29, 2026
47a0240
Run the smoke flows in PR checks on Linux and macOS
bguidolim Sep 29, 2026
1f6258b
Run the Linux test and release build in parallel
bguidolim Sep 29, 2026
7d9bde7
Stop hangs and unconfirmed actions when input is closed
bguidolim Sep 29, 2026
5edc7c7
Lint on macOS again and fold CI jobs into matrices
bguidolim Sep 29, 2026
e3f37eb
Point to the native Claude Code installer and trim test-only seams
bguidolim Sep 29, 2026
ba03f39
Bring comments and docs in line with the Linux port
bguidolim Sep 29, 2026
552a303
Cancel cleanly on Ctrl-C, and stop offering a re-sync that cannot work
bguidolim Oct 9, 2026
fcbfcde
Compare merged settings values without the ObjC runtime
bguidolim Oct 9, 2026
e64aec7
Name the ObjC-bridging trap in the platform rule
bguidolim Oct 9, 2026
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
1 change: 1 addition & 0 deletions .github/PULL_REQUEST_TEMPLATE.md
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@ Only check items that apply to this PR. Delete irrelevant ones.
- [ ] Any `fix()` implementation does cleanup/migration only — never installs or registers resources
- [ ] State migrations are guarded by `isNeeded()` to stay idempotent with `mcs sync`
- [ ] Integration tests updated for new features (`LifecycleIntegrationTests` or `DoctorRunnerIntegrationTests`)
- [ ] `.github/actions/mcs-smoke` has a step for any new command, flag contract, or manifest key, and the matrix row in `docs/linux-support.md` reflects it
- [ ] New file write/copy/delete paths use `PathContainment.safePath()` and handle the `nil` (escape) case
- [ ] Docs updated if behavior changed (`CLAUDE.md`, `docs/`, `techpack.yaml` schema in `ExternalPackManifest.swift`)

Expand Down
143 changes: 143 additions & 0 deletions .github/actions/mcs-smoke/action.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,143 @@
name: mcs smoke flows
description: Run whole mcs command flows against a fixture pack, using an already-built binary.

inputs:
mcs:
description: Path to the mcs binary under test.
required: true

runs:
using: composite
steps:
# Every later step runs with HOME inside the job's temp dir, so the flows can never touch the
# runner's real ~/.mcs or ~/.claude — and mcs honouring $HOME is itself under test.
- name: Sandbox HOME and stub the claude CLI
shell: bash
run: |
SANDBOX="$RUNNER_TEMP/home"
mkdir -p "$SANDBOX/.local/bin"
# A stand-in for the Claude Code CLI: every command calls ensureClaudeCLI, which refuses to
# run without it, and nothing here asserts on what Claude itself does.
printf '#!/bin/sh\nexit 0\n' > "$SANDBOX/.local/bin/claude"
chmod +x "$SANDBOX/.local/bin/claude"
echo "$SANDBOX/.local/bin" >> "$GITHUB_PATH"
echo "HOME=$SANDBOX" >> "$GITHUB_ENV"
echo "MCS=${{ inputs.mcs }}" >> "$GITHUB_ENV"

# A local pack exercises the same manifest, trust and artifact paths as a git pack without
# making the smoke run depend on a network fetch. It lives under ~ so both `pack add` and
# mcs.yaml reach it through mcs's own tilde expansion.
- name: Create a fixture pack
shell: bash
run: |
mkdir -p "$HOME/fixture-pack/skills/greet" "$RUNNER_TEMP/project"
cat > "$HOME/fixture-pack/techpack.yaml" <<'PACK'
schemaVersion: 1
identifier: smoke
displayName: Smoke Pack
description: Fixture pack for the smoke flows
prompts:
- key: GREETING
type: input
prompt: Greeting to use
default: hello
components:
- id: greet-skill
displayName: Greet skill
description: Skill whose body is substituted from a prompt value
skill:
source: skills/greet
destination: greet
- id: git-present
displayName: git
description: A formula whose name is also its command, so the PATH probe satisfies it
brew: git
- id: asks-for-input
displayName: Asks for input
description: Off a terminal its prompt must see end of input rather than hang
type: configuration
shell: read answer || touch "$HOME/interactive-saw-eof"
shellInteractive: true
PACK
cat > "$HOME/fixture-pack/skills/greet/SKILL.md" <<'SKILL'
---
name: greet
description: Says __GREETING__
---
Says __GREETING__.
SKILL
cat > "$RUNNER_TEMP/project/mcs.yaml" <<'BOOT'
schemaVersion: 1
packs:
- source: ~/fixture-pack
values:
GREETING: hei
BOOT

- name: mcs pack add (tilde path), pack list --json, pack validate
shell: bash
working-directory: ${{ runner.temp }}/project
run: |
# Quoted so the shell leaves the ~ for mcs to expand against $HOME. The shell component
# makes the pack ask for trust, and `pack add` has no --trust-all, so the answer is piped.
printf 'y\n' | "$MCS" pack add '~/fixture-pack'
"$MCS" pack list --json
"$MCS" pack validate smoke
test -f "$HOME/.mcs/registry.yaml"

# Bounded, so a regression to the PTY hang fails the job instead of running out its clock.
# perl rather than timeout(1), which macOS does not ship.
- name: mcs bootstrap --trust-all
shell: bash
working-directory: ${{ runner.temp }}/project
run: |
git init -q .
perl -e 'alarm shift; exec @ARGV' 300 "$MCS" bootstrap --trust-all
# The seeded value has to reach the installed artifact, not just the prompt store.
grep -q hei .claude/skills/greet/SKILL.md
test -f "$HOME/interactive-saw-eof"

- name: mcs sync --pack --prune --yes
shell: bash
working-directory: ${{ runner.temp }}/project
run: |
"$MCS" sync --pack smoke --prune --yes
test -f .claude/skills/greet/SKILL.md

- name: mcs doctor and doctor --fix
shell: bash
working-directory: ${{ runner.temp }}/project
run: |
"$MCS" doctor
"$MCS" doctor --fix --yes

- name: mcs update --trust-all and check-updates
shell: bash
working-directory: ${{ runner.temp }}/project
run: |
"$MCS" update --project --trust-all
"$MCS" check-updates --json

# The one step that pins stdin to /dev/null explicitly: every prompt has to resolve from the
# stored prior or a declared default rather than blocking on stdin.
- name: Non-interactive prompt resolution
shell: bash
working-directory: ${{ runner.temp }}/project
run: |
"$MCS" sync --all < /dev/null
grep -q hei .claude/skills/greet/SKILL.md

- name: mcs export and cleanup
shell: bash
working-directory: ${{ runner.temp }}/project
run: |
"$MCS" export "$RUNNER_TEMP/exported" --identifier exported --non-interactive
test -f "$RUNNER_TEMP/exported/techpack.yaml"
"$MCS" cleanup --force

- name: mcs pack remove
shell: bash
working-directory: ${{ runner.temp }}/project
run: |
"$MCS" pack remove smoke --force
test ! -e .claude/skills/greet/SKILL.md
99 changes: 99 additions & 0 deletions .github/actions/setup-swift-linux/action.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
name: Set up Swift on Linux
description: Install a pinned Swift toolchain on an Ubuntu runner via swiftly and put it on PATH.

inputs:
swift-version:
description: The toolchain version swiftly installs and selects.
default: '6.2.0'

runs:
using: composite
steps:
# swiftly installs a toolchain built for this Ubuntu release, so the cache key carries it: one
# restored onto another image may not run there.
- name: Identify the image
id: image
shell: bash
run: |
set -euo pipefail
. /etc/os-release
echo "release=${ID}-${VERSION_ID}" >> "$GITHUB_OUTPUT"

- name: Restore the Swift toolchain
id: toolchain
uses: actions/cache@v6
with:
# swiftly keeps the toolchains and env.sh under the data dir and its selected-version
# state under the config dir; a hit needs both or `swift` resolves to nothing.
path: |
~/.local/share/swiftly
~/.config/swiftly
key: swiftly-${{ steps.image.outputs.release }}-${{ runner.arch }}-${{ inputs.swift-version }}

- name: Install the toolchain's system dependencies
# Not cacheable — these are system packages, and the toolchain needs them on a cache hit too.
# The names are Ubuntu-release-specific, so a miss is tolerated here: on a cache miss the
# install step below asks swiftly for the authoritative list and retries.
shell: bash
run: |
set -euo pipefail
# No `apt-get update` first: it costs more than everything else here, and the runner images
# ship a usable index. A package that cannot be resolved falls back to updating once.
for pkg in libcurl4-openssl-dev pkg-config python3-lldb-13; do
sudo apt-get -y install "$pkg" && continue
sudo apt-get update
sudo apt-get -y install "$pkg" || echo "::warning::apt could not install $pkg"
done

- name: Install Swift via swiftly
if: steps.toolchain.outputs.cache-hit != 'true'
# swiftly refuses to install a toolchain until Swift's own system dependencies are present,
# and names the missing ones in an `apt-get -y install …` line. Those names differ by Ubuntu
# release and architecture, so they are read back from swiftly rather than trusted to the
# list above. Unpacked outside the checkout so the tree the caller builds in stays clean.
shell: bash
env:
SWIFT_VERSION: ${{ inputs.swift-version }}
# Pinned and checksummed: this binary runs before anything else in the job, so an
# unannounced upstream release must not be able to change or break it.
SWIFTLY_VERSION: '1.2.0'
SWIFTLY_SHA256_X86_64: 0b26b568811404374c7d7f07276b6aad4145db5d1893a45f307de6a94ca8d9c3
SWIFTLY_SHA256_AARCH64: 590d855e03807791f1b84aab77471bf7e31f9e3b22151972526077da296ecf96
run: |
set -euo pipefail
cd "$RUNNER_TEMP"
ARCH=$(uname -m)
case "$ARCH" in
x86_64) SHA="$SWIFTLY_SHA256_X86_64" ;;
aarch64) SHA="$SWIFTLY_SHA256_AARCH64" ;;
*) echo "::error::No pinned swiftly checksum for $ARCH"; exit 1 ;;
esac
TARBALL="swiftly-${SWIFTLY_VERSION}-${ARCH}.tar.gz"
curl -fsSL --retry 3 -o "$TARBALL" "https://download.swift.org/swiftly/linux/$TARBALL"
echo "$SHA $TARBALL" | sha256sum -c -
tar zxf "$TARBALL"
./swiftly init --quiet-shell-followup --assume-yes --skip-install
. "${XDG_DATA_HOME:-$HOME/.local/share}/swiftly/env.sh"

LOG="$RUNNER_TEMP/swiftly-install.log"
if ! swiftly install --use "$SWIFT_VERSION" --assume-yes 2>&1 | tee "$LOG"; then
PKGS=$(sed -n 's/^apt-get -y install //p' "$LOG" | tr '\n' ' ')
test -n "$PKGS" || { echo "::error::swiftly failed without naming missing packages"; exit 1; }
echo "Installing the dependencies swiftly asked for: $PKGS"
for pkg in $PKGS; do
sudo apt-get -y install "$pkg" || echo "::warning::apt could not install $pkg"
done
swiftly install --use "$SWIFT_VERSION" --assume-yes
fi

- name: Put Swift on PATH
shell: bash
run: |
set -euo pipefail
# env.sh lives under $XDG_DATA_HOME when that is set; fail loudly rather than silently
# continuing without swiftly on PATH if the layout ever moves or a cache hit is partial.
ENV_SH="${XDG_DATA_HOME:-$HOME/.local/share}/swiftly/env.sh"
test -f "$ENV_SH" || { echo "::error::swiftly env.sh not found at $ENV_SH"; exit 1; }
. "$ENV_SH"
echo "$SWIFTLY_BIN_DIR" >> "$GITHUB_PATH"
"$SWIFTLY_BIN_DIR/swift" --version
38 changes: 38 additions & 0 deletions .github/actions/spm-deps/action.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
name: Swift package dependencies
description: Restore (and optionally save) the cached package sources, then resolve them.

inputs:
restore-only:
description: Restore the cache without saving it — for jobs that run after the one that owns it.
default: 'false'

runs:
using: composite
steps:
# Dependency *sources* only, and the key is deliberately platform-independent: these are git
# clones with no absolute paths, so every macOS and Linux job shares one entry. Compiled output
# is not cached — it is not portable across runner or toolchain, and a cold build of the whole
# graph costs seconds next to running the suite.
- if: inputs.restore-only != 'true'
uses: actions/cache@v6
with:
path: |
.build/checkouts
.build/repositories
.build/workspace-state.json
key: spm-${{ hashFiles('Package.swift') }}
restore-keys: spm-

- if: inputs.restore-only == 'true'
uses: actions/cache/restore@v6
with:
path: |
.build/checkouts
.build/repositories
.build/workspace-state.json
key: spm-${{ hashFiles('Package.swift') }}
restore-keys: spm-

- name: Resolve packages
shell: bash
run: swift package resolve
Loading
Loading