Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
29 commits
Select commit Hold shift + click to select a range
50b122e
feat(macapp): pin each Docker workroom to its Docker context (#309)
joelmoss Oct 3, 2026
3d1b3ee
feat(ci,macapp): publish the workroom-host image and pin it into the …
joelmoss Oct 3, 2026
12aeeec
fix(macapp): address the review of Docker context pinning (#309)
joelmoss Oct 3, 2026
27018c7
feat(macapp): start a stopped container when its workroom is opened (…
joelmoss Oct 3, 2026
9e272c1
feat(macapp): run container workrooms on Apple's container runtime (#…
joelmoss Oct 3, 2026
d733499
feat(macapp): New Workroom asks where: this Mac, Docker or Apple Cont…
joelmoss Oct 3, 2026
6eb5654
feat(macapp): keep a base per container runtime and Docker context (#…
joelmoss Oct 3, 2026
e0cb1cd
fix(macapp): address the review of the Apple container dialect (#309)
joelmoss Oct 3, 2026
502ed80
feat(wr-agent): relay git credentials from the Mac for a workroom tha…
joelmoss Oct 3, 2026
2896e95
feat(macapp): relay git credentials from the Mac's gh for local conta…
joelmoss Oct 3, 2026
6113cb9
docs(remote-workrooms): describe local container workrooms as built (…
joelmoss Oct 3, 2026
becb9f8
fix(macapp): harden the credential relay's listener (#309)
joelmoss Oct 3, 2026
afe42a6
test(macapp): XCUITest the New Workroom places submenu (#309)
joelmoss Oct 3, 2026
79e826d
feat(macapp): show how far a container workroom's image download has …
joelmoss Oct 3, 2026
d31d0eb
fix(macapp): keep New Workroom's tooltip off its submenu, and UI-test…
joelmoss Oct 3, 2026
a2ef9b1
feat(macapp): say why New Workroom's entries are off while a create r…
joelmoss Oct 3, 2026
2ef2432
fix(macapp): say once why New Workroom's entries are off (#309)
joelmoss Oct 3, 2026
04fe78d
feat(macapp): the row's + asks where, and a workroom's icon says what…
joelmoss Oct 3, 2026
f88d920
feat(macapp): mark a workroom on a host with the network glyph on its…
joelmoss Oct 3, 2026
02a6c9a
feat(macapp): a remote workroom's sidebar icon is its host glyph, not…
joelmoss Oct 3, 2026
dcad15a
feat(macapp): New Workroom's picker asks where too (#309)
joelmoss Oct 3, 2026
372be59
fix(macapp): address the pre-landing review of local container workro…
joelmoss Oct 3, 2026
8a41d23
ci: keep Release off the host image, and pin a public one into each N…
joelmoss Oct 3, 2026
b9798e1
test(wr-agent): pin the credential relay's error, cap and port paths …
joelmoss Oct 3, 2026
19a426d
ci: lint the GitHub workflows with actionlint
joelmoss Oct 3, 2026
1a094d5
fix(macapp): build a container's run arguments in steps CI's Swift ca…
joelmoss Oct 3, 2026
1f14e7f
test(macapp): compare a stub runtime's calls as Strings, which CI's S…
joelmoss Oct 3, 2026
6254eea
fix(macapp): address PR #320's review comments (#309)
joelmoss Oct 3, 2026
225949d
ci: check actionlint's release archive against a pinned sha256
joelmoss Oct 3, 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
21 changes: 21 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,27 @@ jobs:
DIFF_HEAD: ${{ github.sha }}
run: python3 scripts/ci/changes.py >> "$GITHUB_OUTPUT"

# Every workflow, shellchecked `run:` blocks included. Not behind `changes`: it takes seconds,
# and a workflow edit is exactly what the selector would skip. The pin moves with the Makefile's
# `actions-lint` (CONTRIBUTING). The release archive is checked against its pinned sha256 before
# it runs, so a replaced asset or tag fails here instead of running.
workflows:
runs-on: ubuntu-latest
timeout-minutes: 5
env:
ACTIONLINT_VERSION: 1.7.12
ACTIONLINT_SHA256: 8aca8db96f1b94770f1b0d72b6dddcb1ebb8123cb3712530b08cc387b349a3d8
steps:
- uses: actions/checkout@v5

- name: Lint the workflows
run: |
archive="actionlint_${ACTIONLINT_VERSION}_linux_amd64.tar.gz"
curl -fsSLO "https://github.com/rhysd/actionlint/releases/download/v${ACTIONLINT_VERSION}/$archive"
echo "$ACTIONLINT_SHA256 $archive" | sha256sum --check --strict
tar -xzf "$archive" actionlint
./actionlint -color

cli:
needs: changes
if: always() && !cancelled() && (needs.changes.result != 'success' || needs.changes.outputs.run != 'false')
Expand Down
120 changes: 120 additions & 0 deletions .github/workflows/host-image.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
name: Host image

# Publishes the `workroom-host` image a local container workroom runs (#309), for the Nightly
# workflow, which pins the app it builds to the digest this outputs. Release joins it with #260.
#
# Each architecture is built once, smoke-tested as that exact image (host-image-test.sh with
# WR_HOST_IMAGE), and only then pushed by digest: a second build for the push could resolve other
# Debian packages than the ones tested. The two digests are then joined into one multi-arch manifest
# under `tag`, and the manifest's digest is the output.
#
# The package must be public for an app to pull it without signing in to ghcr: set once, by hand,
# in the package's settings after its first push.

on:
workflow_call:
inputs:
tag:
description: Tag the manifest is published under (nightly, or the release version).
required: true
type: string
outputs:
image:
description: The published manifest, by digest (ghcr.io/<owner>/workroom-host@sha256:…).
value: ${{ jobs.manifest.outputs.image }}

jobs:
build:
strategy:
matrix:
include:
- arch: amd64
runner: ubuntu-latest
- arch: arm64
runner: ubuntu-24.04-arm
runs-on: ${{ matrix.runner }}
timeout-minutes: 20
permissions:
contents: read
packages: write
outputs:
# One output per leg; a matrix job's outputs are last-writer-wins otherwise.
digest-amd64: ${{ steps.push.outputs.digest-amd64 }}
digest-arm64: ${{ steps.push.outputs.digest-arm64 }}
steps:
- uses: actions/checkout@v5

- name: Build
run: docker build --tag workroom-host-candidate vcs/scripts/ssh-fixture

- name: Smoke-test that image
env:
WR_HOST_IMAGE: workroom-host-candidate
run: vcs/scripts/ssh-fixture/host-image-test.sh

- name: Log in to ghcr
env:
GH_TOKEN: ${{ github.token }}
ACTOR: ${{ github.actor }}
run: echo "$GH_TOKEN" | docker login ghcr.io -u "$ACTOR" --password-stdin

# Pushed under a per-run, per-arch tag only so `docker push` has something to push; the
# manifest below references it by digest, which is what the app pins.
- name: Push
id: push
run: |
repo="ghcr.io/${GITHUB_REPOSITORY_OWNER,,}/workroom-host"
tag="$repo:build-${{ github.run_id }}-${{ matrix.arch }}"
docker tag workroom-host-candidate "$tag"
digest="$(docker push "$tag" | sed -n 's/.*digest: \(sha256:[0-9a-f]\{64\}\).*/\1/p')"
test -n "$digest" || { echo "::error::no digest from docker push"; exit 1; }
echo "digest-${{ matrix.arch }}=$digest" >> "$GITHUB_OUTPUT"

manifest:
needs: build
runs-on: ubuntu-latest
timeout-minutes: 10
permissions:
contents: read
packages: write
outputs:
image: ${{ steps.create.outputs.image }}
steps:
- name: Log in to ghcr
env:
GH_TOKEN: ${{ github.token }}
ACTOR: ${{ github.actor }}
run: echo "$GH_TOKEN" | docker login ghcr.io -u "$ACTOR" --password-stdin

- name: Create the multi-arch manifest
id: create
env:
AMD64: ${{ needs.build.outputs.digest-amd64 }}
ARM64: ${{ needs.build.outputs.digest-arm64 }}
TAG: ${{ inputs.tag }}
run: |
repo="ghcr.io/${GITHUB_REPOSITORY_OWNER,,}/workroom-host"
docker buildx imagetools create --tag "$repo:$TAG" "$repo@$AMD64" "$repo@$ARM64"
digest="$(docker buildx imagetools inspect "$repo:$TAG" --format '{{json .Manifest.Digest}}' | tr -d '"')"
case "$digest" in sha256:*) ;; *) echo "::error::no manifest digest: $digest"; exit 1 ;; esac
echo "image=$repo@$digest" >> "$GITHUB_OUTPUT"
echo "Published $repo@$digest ($TAG)" >> "$GITHUB_STEP_SUMMARY"

# The app pulls without signing in to ghcr, so a package left private would ship a digest
# every create fails to pull. Asked as a stranger: an anonymous token, then the manifest.
- name: Check the image can be pulled anonymously
env:
IMAGE: ${{ steps.create.outputs.image }}
run: |
path="${IMAGE#ghcr.io/}"; name="${path%@*}"; digest="${IMAGE#*@}"
# A private package refuses the token itself, which the manifest's refusal below reports.
token="$(curl -sS "https://ghcr.io/token?scope=repository:$name:pull" | jq -r '.token // empty')"
status="$(curl -sS -o /dev/null -w '%{http_code}' \
-H "Authorization: Bearer $token" \
-H 'Accept: application/vnd.oci.image.index.v1+json' \
-H 'Accept: application/vnd.docker.distribution.manifest.list.v2+json' \
"https://ghcr.io/v2/$name/manifests/$digest")"
if [ "$status" != 200 ]; then
echo "::error::$IMAGE can't be pulled anonymously (HTTP $status): make the package public in its settings"
exit 1
fi
16 changes: 15 additions & 1 deletion .github/workflows/nightly.yml
Original file line number Diff line number Diff line change
Expand Up @@ -121,9 +121,21 @@ jobs:

# Build + sign + notarize + staple the DMG alongside the CLI. The publish job below
# uploads it, updates the appcast, then stamps the successful version as the skip marker.
macapp:
# The `workroom-host` image tonight's local container workrooms run (#309), built, tested and
# published per architecture; its digest is pinned into the app below.
host-image:
needs: check
if: needs.check.outputs.should_build == 'true'
uses: ./.github/workflows/host-image.yml
permissions:
contents: read
packages: write
with:
tag: nightly

macapp:
needs: [check, host-image]
if: needs.check.outputs.should_build == 'true'
runs-on: macos-15
env:
VERSION: ${{ needs.check.outputs.version }}
Expand Down Expand Up @@ -240,6 +252,8 @@ jobs:
SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}
SENTRY_ORG: ${{ secrets.SENTRY_ORG }}
SENTRY_PROJECT: ${{ secrets.SENTRY_PROJECT }}
# The host image this app runs, by digest (release.sh → WorkroomHostImage).
WORKROOM_HOST_IMAGE: ${{ needs.host-image.outputs.image }}
# release.sh derives MARKETING_VERSION = ${VERSION#v} and CURRENT_PROJECT_VERSION = commit
# count; the nightly version has no leading v, so it flows through unchanged.
run: |
Expand Down
3 changes: 3 additions & 0 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,9 @@ jobs:
# NOTARY_KEY / NOTARY_KEY_ID / NOTARY_ISSUER_ID.
# Optional: SENTRY_AUTH_TOKEN / SENTRY_ORG / SENTRY_PROJECT enable Sentry dSYM upload so release
# crashes symbolicate; release.sh skips that step when any of them (or sentry-cli) is absent.
# No `workroom-host` image (#309) yet: container workrooms are Nightly-only until #260, so a
# release has nothing to pin, and an image build that failed on tag day would hold the DMG back
# after the CLI has shipped. Add the `host-image` job (as nightly.yml has it) with #260.
macapp:
runs-on: macos-15
steps:
Expand Down
1 change: 1 addition & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ Run commands from the repository root; `make` lists available targets.

- `make cli-build`, `make cli-test`: build the CLI and run `go test ./...`.
- `make cli-lint`: run golangci-lint v2 and check gofmt/goimports formatting.
- `make actions-lint`: lint `.github/workflows` with actionlint, shellcheck included.
- `make app-build`: build Rust dependencies, generate the Xcode project, and build Debug.
- `make app-run`: rebuild and relaunch this checkout's Workroom Dev; stops its persisted session helpers.
- `make app-test`: run app unit/integration tests; safe alongside other workrooms' runs.
Expand Down
8 changes: 7 additions & 1 deletion CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,8 @@ Workroom *is* and how to use it, see [`README.md`](README.md).

Before opening a PR:

- Run the linters on everything you touched: `make cli-lint` (Go) and `make app-lint` (Swift).
- Run the linters on everything you touched: `make cli-lint` (Go), `make app-lint` (Swift) and
`make actions-lint` (GitHub workflows).
- Run the relevant tests: `make cli-test` and/or `make app-test`.
- Keep the `--json` contract stable — it's the boundary between the CLI and the app. Breaking changes
bump `schema_version` (`cmd/json.go`). See
Expand Down Expand Up @@ -68,6 +69,11 @@ older Go cannot read the current toolchain's export data. v1 is not an option he
`go/types` caps out at export-data version 2, so it cannot analyze a Go 1.27 stdlib, and it rejects
the v2-format `.golangci.yml`.

**To change a GitHub workflow:** **`actionlint` 1.7.12** for `make actions-lint`
(`go install github.com/rhysd/actionlint/cmd/actionlint@v1.7.12`), with `shellcheck` on `PATH` so
`run:` blocks are checked too. CI's `workflows` job runs the same version, checked by the
release archive's sha256: bump the version and the hash there together.

Runtime requirements for *using* Workroom are in [the README](README.md#requirements).

---
Expand Down
10 changes: 8 additions & 2 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -3,15 +3,16 @@
#
# App recipes run inside macapp/ and need its toolchain on PATH (xcodegen via Homebrew). The
# Xcode build also runs project.yml phases — a non-fatal swift-format lint and embedding the Go
# helper (macapp/Scripts/build-helper.sh). `cli-lint` needs golangci-lint installed (see AGENTS.md).
# helper (macapp/Scripts/build-helper.sh). `cli-lint` needs golangci-lint installed (see AGENTS.md),
# `actions-lint` actionlint (CONTRIBUTING).
export PATH := /opt/homebrew/bin:/usr/local/bin:$(PATH)
VERSION ?= $(shell git describe --tags --always --dirty 2>/dev/null || echo dev)

.DEFAULT_GOAL := help
.PHONY: help \
cli-build cli-test cli-install cli-lint cli-clean \
app-run app-build app-test app-uitest app-identity app-test-supervisor app-test-scripts app-generate app-format app-lint app-release app-icon app-tool-logos app-clean \
remote-host-image remote-host-image-test
remote-host-image remote-host-image-test actions-lint

help: ## List available targets
@grep -hE '^[a-z][a-zA-Z0-9_-]*:.*## ' $(MAKEFILE_LIST) \
Expand Down Expand Up @@ -39,6 +40,11 @@ cli-lint: ## Lint Go with golangci-lint (analyzers + formatters)
cli-clean: ## Remove the built binary
rm -f workroom

# --- GitHub Actions ---

actions-lint: ## Lint .github/workflows with actionlint (shellcheck included)
actionlint

# --- macOS app (macapp/) ---

# The Debug product is "Workroom Dev" (distinct bundle id + name) so it runs alongside the
Expand Down
70 changes: 70 additions & 0 deletions docs/designs/remote-workrooms.md
Original file line number Diff line number Diff line change
Expand Up @@ -1630,6 +1630,76 @@ these are the subsystems that actually gate "a remote workroom is a real workroo
instead of a fresh shell: its terminals as panes on its host, each attaching with
`--no-create`. A held session whose workroom is deleted is dropped at the next save.
- **Not done.** A held session is restored only in the window that claimed it.
- **As built (#309, local container workrooms on Docker and Apple's runtime).** A workroom can
be created in a container on the Mac, on Docker (Desktop, OrbStack, Colima) or on Apple's
`container`. These are not remote workrooms: they stop when the Mac sleeps. They ship with the
remote targets, behind the same preview and Nightly gate, until #260.
- **Choosing.** With the preview on, every way in asks where: a project's New Workroom is a
submenu, its row's "+" opens the same menu, and File ▸ New Workroom… (⌘N, ⌥⌘N, the tab bar's
"+") asks once its project is picked. The places are This Mac, then Docker and Apple Container
under "Local containers". It replaced New Remote Workroom. An entry that
can't be used says why in its title (`RemoteWorkrooms.unavailability`): Apple's runtime needs
Apple silicon and macOS 26, either may not be installed, and a create needs a Codaset or `gh` sign-in.
- **Drivers.** `RemoteHosts` keeps a `ContainerHostDriver` per `DriverKey`: runtime, and for
Docker the context. A descriptor's `driver` names the runtime; `container` stays Docker, so
every record before #309 is unchanged, and `apple-container` is Apple's. Each driver's sweep
keeps every recorded host and image, since two keys can reach one daemon.
- **Docker contexts.** A new Docker base is pinned to `docker context show` (never `default`,
which is whatever `DOCKER_HOST` says), recorded in its container record, and every command for
it names `--context`, which outranks `DOCKER_HOST` and `DOCKER_CONTEXT` (Docker 29, measured).
A record with no context runs with no `--context`, as before.
- **Bases.** A project keeps a base per runtime and context, in its descriptor's `bases` list
once it has more than one; a descriptor without the list is one base, as before. A workroom
derives from the base where it is asked for, made there first when there is none; a base made
before #309 (no context) still serves Docker. A delete builds an environment per key.
- **Apple's runtime (1.5.0, measured; its docs on `main` describe an unreleased CLI).**
`ContainerHostDriver.Dialect.apple` covers what differs. There is no `commit`: a derive stops
the base if running, exports its disk, starts it again in a task of its own, builds the disk
back into an image `FROM scratch` with the host image's own entrypoint and environment
(`AppleContainerCLI.dockerfile`, `$` escaped), runs it, and removes the image, since a
container's disk outlives it. `build --quiet` never returns. The builder is one VM for the whole
Mac and is left running. Apple removes an image a container still uses, so its sweep keeps any
image a container was run from. There is no `--filter` or template output, so it reads JSON;
no `--restart`, so a stopped container is started when its workroom is opened (below);
published ports survive a restart. Images are pulled `--arch arm64`. Each container gets
explicit CPUs and memory (half the cores, a quarter of memory, 2-8 GB; hidden
`containerCPUs`/`containerMemory` override), since Apple's default is a 1 GB VM.
- **Credentials.** A base decides how its workrooms reach GitHub, once, and records it
(`credentials: relay`): the broker when signed in to Codaset, else, or when the broker refuses
with `app_not_installed`, the Mac's own `gh`. A relayed base clones and a relayed workroom
fetches and branches with `gh auth token` as git's header (`cloneEnvironment`); the workroom
never enrols. Later git in it asks `wr-agent credential`, which, unenrolled, sends a per-launch
secret and the request to a port on its loopback (`relay.json`, set by `wr-agent credential
relay` on every connect); `ReverseForwardRegistry`, lifted out of the Debug broker route,
carries it to `CredentialRelay`'s listener on the Mac's loopback, which answers github.com over
https only, from `gh auth git-credential get`. Accepted (D9): anything in the workroom can ask,
so it has the user's `gh` access, as a workroom on the Mac does. It can ask only while the app
is connected (git is told to open the workroom when it isn't), but a token it was answered with
works until the user revokes it. An enrolled workroom never uses the relay.
- **Starting.** Selecting a container workroom marks its host opened (`RemoteHosts.activate`), and
its next connect starts a stopped container first, once: one the user stops afterwards stays
stopped until its workroom is selected again. Background status probes reach every reachable
remote workroom, so they never start one; an opened connect that finds one under way goes
after it rather than joining it.
- **The image.** Nightly (and Release, once #260 lifts the gate) builds the `FIXTURE=0` image
once per architecture, runs `host-image-test.sh` against that exact image (`WR_HOST_IMAGE`),
pushes it by digest and joins the two into one manifest on ghcr (`host-image.yml`). The
manifest's digest is pinned into the app (`WorkroomHostImage`); the hidden `remoteHostImage`
overrides it, and a local build runs `workroom-host`. A missing image is pulled before `run`, which never pulls (`--pull=never`).
The ghcr package must be made public once, by hand, after its first push: the workflow checks
the digest can be pulled anonymously, and `release.sh` won't build a Nightly without a pin.
- **Tests.** `RemoteHostsTests` drive both dialects through a stub CLI. The Docker fixture suites
(`run.sh`) cover the Docker path; `AppleContainerIntegrationTests` run the whole lifecycle on
the real Apple runtime, opt-in (`TEST_RUNNER_WR_APPLE_CONTAINER_TESTS=1`), since CI's macOS
runners can't run its VMs.
- **Pull progress.** The first create in a project pulls the host image, a couple of hundred MB.
The pull's output is read as it arrives (`HostStream.communicate`'s `onOutput`) and turned
into a fraction (`ImagePullProgress`): Docker prints only each layer's state without a
terminal, so it is the layers done of those seen; Apple's `[step/steps] … N%` lines give its
step and percent. It reaches the create through a task-local (`pullProgress`), and the
project row's spinner fills as a circle with the percentage as its tooltip.
- **Not done.** A development environment in the container (toolchains, agent CLIs) is #308;
Docker on another machine is #307.
- **Cross-machine session enumeration** (from Phase 1's S4 note): agent-side session naming, a
pane→tab mapping on the wire, and workroom UI state stored with the workroom.
- **OSC 7 and cmd-click (C7).** `SessionDaemon.swift:395-401` deliberately emits
Expand Down
12 changes: 12 additions & 0 deletions macapp/Scripts/release.sh
Original file line number Diff line number Diff line change
Expand Up @@ -95,13 +95,25 @@ BUILD_NUMBER="$(git -C "$MACAPP_DIR/.." rev-list --count HEAD 2>/dev/null || ech
# Go helper — with our Developer ID, hardened runtime, and a secure timestamp. A plain `build`
# leaves nested helpers without a timestamp (and injects get-task-allow), which notarization
# rejects. Archiving also produces a distribution build with get-task-allow omitted.
# The `workroom-host` image the app's local container workrooms run, by digest (#309): CI's
# host-image workflow publishes it and passes it here. Without it the app falls back to a locally
# built `workroom-host` (`make remote-host-image`), which a user won't have, so a Nightly, the only
# build that runs container workrooms until #260, refuses to build without one.
case "$CONFIGURATION:${WORKROOM_HOST_IMAGE:-}" in
Nightly:*@sha256:*) ;;
Nightly:*)
echo "error: a Nightly needs WORKROOM_HOST_IMAGE, the workroom-host image by digest" \
"(<repo>@sha256:...), got '${WORKROOM_HOST_IMAGE:-}'." >&2
exit 1 ;;
esac
echo "==> Archiving $CONFIGURATION ($APP_NAME) $SHORT_VERSION ($BUILD_NUMBER)"
rm -rf "$ARCHIVE" "$EXPORT_DIR"
xcodebuild -project "$PROJ" -scheme WorkroomApp -configuration "$CONFIGURATION" \
-derivedDataPath "$BUILD" \
-clonedSourcePackagesDirPath "$BUILD/SourcePackages" \
-archivePath "$ARCHIVE" \
MARKETING_VERSION="$SHORT_VERSION" CURRENT_PROJECT_VERSION="$BUILD_NUMBER" \
WORKROOM_HOST_IMAGE="${WORKROOM_HOST_IMAGE:-}" \
archive

# Upload dSYMs to Sentry so release crashes symbolicate. The archive bundles them in
Expand Down
Loading
Loading