I use three machines in my local development setup.

Codex stays on my development machine. I tell Codex what I want, and it manages the other two machines for me.

The first machine is a 2018 ThinkPad X1 Carbon 6th Gen. It has a four-core Intel Core i5-8350U and 16 GB of RAM. It runs Arch Linux with Omarchy and Hyprland. It is the always-on Gitea host for my Git repositories and CI job history.

The second machine is an M1 Max MacBook Pro with 64 GB of memory. It is registered as the Gitea Actions runner. The Mac runs the tests and builds the apps. I need a Mac runner because iOS apps must be built with Xcode.

All three machines use Tailscale. Codex reaches the ThinkPad and Mac through the private tailnet and SSH. I do not need to open Gitea, the runner, or my deployed apps to the public internet.

Codex stays on the development machine and delegates work over Tailscale and SSH to an always-on Gitea host and a separate M1 CI runner

How the machines are set up

Codex has two machine skills: one for the ThinkPad and one for the Mac. The skills describe how to connect, what checks to run, and where the services live.

The setup is:

  1. Tailscale connects both machines to my private tailnet.
  2. The ThinkPad runs Gitea with Gitea Actions enabled.
  3. The Mac has Xcode, Docker, Node.js, and the Gitea runner.
  4. A macOS LaunchAgent starts the runner automatically and keeps it running.
  5. Credentials stay outside Git. Release secrets live in Gitea Actions.
  6. Each repository that needs CI has a workflow under .gitea/workflows/.

Trusted projects use their own runner labels. A workflow uses the label to send its job to the Mac:

jobs:
  release:
    runs-on: my-app-ios-release

The label routes the job, but it does not isolate a repository by itself. Sensitive projects need a repository-scoped or dedicated runner.

How repositories are added

I created a small CLI called gtea because I use this flow often. It is only a convenience wrapper. The same behaviour could live in a function in ~/.zshrc, or in an alias that calls a script.

When I want to copy a GitHub repository into my private Gitea, Codex uses:

gtea clone https://github.com/example/project.git

The command clones the GitHub repository, remembers its URL as the upstream, creates a private Gitea repository, changes origin to Gitea, and pushes the current branch. The Gitea token is never stored in the Git remote.

This is not a continuous mirror. When GitHub changes, Codex fetches from the saved upstream and pushes the changes to Gitea.

How TestFlight releases are set up

The TestFlight flow is one workflow I set up on purpose. My girlfriend recently got into vibe coding and is working on Wui Gam, a privacy-first iPhone tea app. She can ask her agent to push to main; Gitea then sends the build to the Mac runner, and the result appears in TestFlight. She does not need to manage Xcode, signing, or CI herself.

The repository contains .gitea/workflows/testflight.yml and a release script. The workflow starts after a push to main, or from the manual run button in Gitea. Its dedicated runner label sends the job to the Mac.

The one-time Apple setup includes an App Store Connect API key, an Apple Distribution certificate exported as a password-protected .p12, and an App Store provisioning profile for each app target. The files are encoded and stored as encrypted Gitea Actions secrets. Their values never go into the repository.

During a release, the script creates a temporary Keychain, imports the certificate, installs the provisioning profiles, and checks that they match the app. It removes the temporary Keychain and decoded files when the job finishes.

Gitea sends the build to an M1 Mac, where it is tested, signed, and uploaded to TestFlight

The final workflow step calls the project's release command:

make app-store

The workflow runs this command automatically; I do not type it myself. The runner checks Xcode and the iOS SDK, gives the build a unique number, tests the project, creates the signed archive, and uploads the IPA to App Store Connect. After Apple processes it, the build appears in TestFlight.

What else the runner does

The Mac runner also tests pull requests, builds Docker images, deploys web apps, builds Android APKs, packages macOS apps, and publishes release files to Gitea.

This gives me private Git hosting and repeatable releases without operating the machines by hand. Each private release records its Git commit SHA. Software using a private build can compare its current SHA with the latest release in Gitea, then download and install the update when they differ. For my iOS builds, TestFlight handles distribution instead.

The remaining task is automatic cleanup for old Docker images, package versions, and runner work folders.