Eric Guo's blog.cloud-mes.com

Hoping writing JS, Ruby & Rails and Go article, but fallback to DevOps note

Running GitLab CI Locally on macOS With OrbStack and Docker

• Permalink

After rebasing my OpenCode eric_dev branch onto local dev, the macOS lint and type checks passed. I also wanted to run the actual .gitlab-ci.yml: Linux unit tests, browser tests, package verification, and the compiled CLI service smoke tests.

I asked Codex to run the pipeline through OrbStack and investigate anything that failed. In the latest verification on October 21, the first attempt exposed an outdated unit-test assertion. After a two-line test correction, a complete rerun passed all eight jobs in one pipeline, in 17 minutes. All 32 unit-test tasks ran without Turbo cache hits. All 730 browser/component cases passed, although two required one retry each. The CI configuration did not change.

An earlier run at cb92010c844 had passed without source changes or browser retries. The results below describe the later run at 272063ab9a4 plus its test correction. The 17 minutes covers the successful rerun only; it excludes the failed attempt and investigation.

These are my notes for repeating that verification later. The commands below use the runner options from the latest successful run and retain its evidence under $HOME/.cache/opencode-ci/.

What runs where

The setup has three parts:

  1. macOS runs gitlab-ci-local and the Docker client.
  2. OrbStack provides the Linux Docker engine.
  3. Each CI job runs in the image declared by .gitlab-ci.yml, with its own workspace and dependency installation.

I used gitlab-ci-local to interpret the existing pipeline. I did not translate its jobs into a second shell script or configure a GitLab Runner registration. This pipeline also needed neither Docker-in-Docker nor privileged containers.

The tested environment was:

Item Value
Source branch and commit eric_dev, 272063ab9a4, plus the two-line correction saved in tested.patch
Local rebase target dev, 4d5965762ec
Docker engine OrbStack, Linux ARM64
Available Docker resources 14 CPUs, about 15.7 GiB memory
Local runner gitlab-ci-local 4.76.1
CI image mcr.microsoft.com/playwright:v1.60.0-noble
Bun inside the jobs 1.4.2, from package.json
Simultaneous CI jobs 2
Unit-test package concurrency 2, already specified in .gitlab-ci.yml

This verifies the Linux ARM64 path. An AMD64 runner, Windows runner, different Docker security policy, or different set of GitLab variables still needs its own verification.

1. Check OrbStack and the host tools

Install and open OrbStack if necessary. Its quick-start guide includes the Homebrew installation route:

Install OrbStack and useful host tools if missing
brew install --cask orbstack
brew install bash node rsync ripgrep
open -a OrbStack

I already had Bun installed for OpenCode. The host needs it for bunx; each job installs the repository's declared Bun version independently.

The local runner's macOS instructions require Bash 4 or newer. Make sure Homebrew's binaries precede the older system Bash on PATH:

Check the Docker context and tool versions
export PATH="$(brew --prefix)/bin:$PATH"
command -v bash
bash --version
node --version
bun --version
docker context ls
docker context use orbstack
docker info

docker info should report a Linux engine. On my Apple Silicon Mac its architecture was aarch64. I also had an OrbStack Ubuntu machine listed as stopped; that did not matter because these jobs used Docker containers.

Pin the local runner so its behavior does not silently change between attempts:

Use the runner version from the successful run
gcl() {
bunx gitlab-ci-local@4.76.1 "$@"
}
gcl --version
gcl --help

Keep the following setup commands in the same terminal so the functions and variables remain available.

2. Configure the host and container proxies separately

My proxy listens on the Mac at HTTP port 6152 and SOCKS port 6153. For downloads made by host processes:

Proxy settings on macOS
export https_proxy=http://127.0.0.1:6152
export http_proxy=http://127.0.0.1:6152
export all_proxy=socks5://127.0.0.1:6153
export no_proxy=localhost,127.0.0.1,::1
export NO_PROXY="$no_proxy"

Inside an ordinary Docker container, 127.0.0.1 refers to that container. Use host.docker.internal to reach the Mac's proxy, as described in OrbStack's container networking documentation.

I supplied HTTP and HTTPS proxy variables in both lowercase and uppercase because the jobs invoke several different downloaders. I did not need a container-side SOCKS proxy. The localhost exclusions are essential here: the tests start local HTTP servers and connect to them directly.

Before starting the pipeline, test a real request from the CI image:

Check container access to npm through the Mac proxy
docker pull mcr.microsoft.com/playwright:v1.60.0-noble
docker run --rm \
-e http_proxy=http://host.docker.internal:6152 \
-e https_proxy=http://host.docker.internal:6152 \
-e no_proxy=localhost,127.0.0.1,::1 \
mcr.microsoft.com/playwright:v1.60.0-noble \
curl -fsS --max-time 30 -o /dev/null -w '%{http_code}\n' \
https://registry.npmjs.org/bun

Expect 200. A proxy's 200 Connection established line alone only confirms the tunnel handshake; checking the destination response is more useful. If pulling the image itself fails, inspect OrbStack's network/proxy configuration too: image downloads happen through the engine before these container environment variables exist.

Two more variables mattered for this repository:

  • ELECTRON_GET_USE_PROXY=1 enables proxy initialization in the Electron artifact downloader. See the Electron downloader's proxy documentation.
  • TURBO_ENV_MODE=loose lets the locally supplied proxy variables reach package subprocesses. The repository's turbo.json did not pass those variables through its normal strict environment configuration.

Loose mode changes environment filtering; it does not disable tests. It is nevertheless a local deviation worth recording. If a shared CI environment also requires a proxy, explicitly configuring the necessary globalPassThroughEnv or task passThroughEnv entries is more controlled than making loose mode permanent. Variables that affect outputs must also be accounted for in cache hashes. Turborepo documents both environment modes and passthrough variables.

3. Create an isolated checkout of the exact branch

I used a fresh clone so the jobs would not accidentally inherit macOS node_modules, old build outputs, or uncommitted fixes. The source working tree was clean before cloning.

Clone the local branch and retain the GitLab remote identity
source_repo=/Users/guochunzhong/git/oss/opencode
ci_root="$HOME/.cache/opencode-ci/$(date +%Y%m%d-%H%M%S)"
ci_base=$(git -C "$source_repo" rev-parse dev)
git -C "$source_repo" status --short --branch
mkdir -p "$(dirname "$ci_root")"
git clone --local --branch eric_dev "$source_repo" "$ci_root"
# In my source repository, deploy is the GitLab remote.
# Replace deploy with your actual GitLab remote name if different.
git -C "$ci_root" remote set-url origin \
"$(git -C "$source_repo" remote get-url deploy)"
cd "$ci_root"
git submodule update --init --recursive
git submodule status
git rev-parse HEAD
git status --short

A local clone includes commits, not uncommitted working-tree edits. The latest run started from a clean clone, then copied the corrected packages/core/test/variant.test.ts into it before the final pipeline. That correction was still uncommitted when testing finished. The tested source was therefore the commit plus the saved patch, not the commit alone.

When applying an intended correction to the isolated checkout, record it before starting the next run:

Record the exact source being tested
mkdir -p .cache
git rev-parse HEAD > .cache/tested-commit.txt
git submodule status > .cache/tested-submodules.txt
git diff HEAD --binary > .cache/tested.patch
git status --short

The patch includes tracked staged and unstaged changes; it does not capture untracked files or changes inside submodules. Inspect the status output and account for those separately when they are intended inputs.

The remote URL also matters: cloning from a filesystem path initially makes that path origin. I restored the GitLab URL so the local runner could derive the expected project identity.

OpenCode had two tracked submodules in this run, packages/7777 and packages/desktop-tab. Their pinned commits must be present. Private submodules need working Git authentication; a main-repository clone alone does not prove they were fetched. For this particular run I reused existing local submodule repositories at the same commits. The command above is the ordinary setup for a fresh checkout with access to their remotes.

As an alternative to fetching those remotes, the local reuse step was:

Reuse the pinned submodules from the source checkout
git -c protocol.file.allow=always \
-c "submodule.packages/7777.url=$source_repo/packages/7777" \
-c "submodule.packages/desktop-tab.url=$source_repo/packages/desktop-tab" \
submodule update --init --recursive
git submodule init
git submodule status

The URL overrides apply only to that command. The subsequent git submodule init records the normal submodule registrations locally; without it, this run's status output still showed a leading - despite the repositories being present. Verify the checked-out commits as well: 8d9047fe73aa695a893408cc3a2c31dfd1893157 for 7777 and c307a97483d88927e301e45e155f054a9297c4a9 for desktop-tab in this run.

4. Keep the CI image and package versions aligned

The pipeline already contained this configuration:

Relevant settings from .gitlab-ci.yml
variables:
GIT_SUBMODULE_STRATEGY: recursive
PLAYWRIGHT_VERSION: "1.60.0"
PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD: "1"
BUN_INSTALL_CACHE_DIR: "$CI_PROJECT_DIR/.cache/bun"
HUSKY: "0"
GITHUB_ACTIONS: "false"
TURBO_TELEMETRY_DISABLED: "1"
RAYON_NUM_THREADS: "2"
default:
image: "mcr.microsoft.com/playwright:v${PLAYWRIGHT_VERSION}-noble"

The Playwright image supplies the browser binaries and Linux system dependencies. The project's Playwright npm package still comes from bun install. Keep the image version aligned with @playwright/test; Playwright documents the executable lookup failures caused by mismatched versions.

Our before_script checked that alignment, installed build tools, installed the Bun version named in packageManager, and ran bun install --frozen-lockfile. The frozen installation passed. I did not regenerate the lockfile to make a local installation succeed.

5. Define the local runner options once

From the CI checkout, define a wrapper containing the exact local overrides:

Reusable local CI command
ci_state=.cache/gitlab-ci-final
ci_log="$ci_root/.cache/pipeline-final.log"
mkdir -p .cache
ci() {
gcl "$@" \
--state-dir "$ci_state" \
--pull-policy if-not-present \
--mount-cache \
--no-artifacts-to-source \
--no-color \
--timestamps \
--concurrency 2 \
--variable TURBO_ENV_MODE=loose \
--variable ELECTRON_GET_USE_PROXY=1 \
--variable http_proxy=http://host.docker.internal:6152 \
--variable https_proxy=http://host.docker.internal:6152 \
--variable HTTP_PROXY=http://host.docker.internal:6152 \
--variable HTTPS_PROXY=http://host.docker.internal:6152 \
--variable no_proxy=localhost,127.0.0.1,::1 \
--variable NO_PROXY=localhost,127.0.0.1,::1 \
--variable CI_PIPELINE_SOURCE=merge_request_event \
--variable "CI_MERGE_REQUEST_DIFF_BASE_SHA=$ci_base"
}

This wrapper simulates a merge-request pipeline so the conditional changed-file lint runs as part of the full pipeline. The runner warns about overriding CI_PIPELINE_SOURCE; that override is intentional here. For a branch-only run, omit the last two variables. The final state directory is separate from the initial failed attempt's .cache/gitlab-ci-local, preserving both sets of evidence.

These flags came from the installed runner's --help. In this run:

Option Purpose
--state-dir Keep expanded configuration, scripts, output, and artifacts together under an ignored directory
--pull-policy if-not-present Reuse the already downloaded, versioned Playwright image
--mount-cache Reuse the pipeline's declared Bun download cache through Docker volumes
--no-artifacts-to-source Retain reports in runner state without copying them back into source directories
--concurrency 2 Limit simultaneous jobs; each job can still start its own workers
--timestamps Make long installation steps and actual test time distinguishable

The Bun download cache is separate from Turbo task-result caching. Reusing package downloads does not mean tests were skipped. In the final unit job, Turbo reported 32 successful tasks and zero cached tasks.

During the earlier run, two simultaneous browser jobs used roughly 5 GiB combined in one sample, comfortably below the engine's memory limit. That is a single observation, not a universal memory requirement.

6. Validate the job list, then run the whole pipeline

Check the expanded configuration before paying for a full run:

Inspect the jobs and expanded pipeline
ci --list --validate-dependency-chain
ci --preview > .cache/gitlab-ci-expanded.yml

The six visible job definitions expanded to eight jobs:

Expected expanded jobs
check
unit
packages
app-e2e: [1/2]
app-e2e: [2/2]
components: [app]
components: [session-ui]
build

The E2E job has two shards, and the component job has a two-package matrix. Checking only the unexpanded job names would miss that distinction.

Run everything and preserve both the output and the exit status:

Run the complete pipeline
set -o pipefail
ci 2>&1 | tee "$ci_log"

pipefail matters: otherwise a successful tee can hide a failing runner exit code. Run this from the checkout root, where .gitlab-ci.yml lives.

The jobs installed their dependencies in fresh containers. The pipeline respected its check, test, and build stage order. Repeated apt-get and Bun setup were expected; they were part of the CI configuration being tested.

7. Watch useful signals while it runs

In another terminal, change to the same CI checkout and inspect the log and containers:

Monitor progress and resource use
tail -f .cache/pipeline-final.log
# Run these separately while the pipeline is active.
docker ps
docker stats --no-stream

The unit job spent several minutes exercising real service startup, shutdown, port conflicts, and child-process handling. A quiet interval was not enough evidence of a hang. When needed, I inspected the live job's processes:

Inspect a running job without restarting it
# Replace this with a container ID shown by docker ps.
job_container=YOUR_JOB_CONTAINER_ID
docker exec "$job_container" ps -eo pid,etime,pcpu,args

Some tests deliberately log messages such as disk full, ECONNRESET, or a failed migration. Treat those messages as clues, then read the surrounding test result. A grep for the word error is not a reliable pass/fail decision for a suite that tests error handling.

For browser jobs, look for retries as well as final failures. A job that passes after a flaky retry deserves different follow-up from one that passes on its first attempt. The latest run had two such cases in E2E shard 1; they are recorded below.

8. Exercise the merge-request-only path too

Our pipeline conditionally skips changed-file lint when CI_MERGE_REQUEST_DIFF_BASE_SHA is absent. A green branch run therefore does not establish that the merge-request path ran. The latest full run included both MR variables through the wrapper in section 5, so its check job exercised that path directly.

With that wrapper, job selection and dependency validation use the same MR context:

Validate merge-request job selection
ci --list --validate-dependency-chain

The runner warns when predefined variables are overridden. Here that override is intentional: it simulates an MR locally. Listing and dependency validation are still a preflight check, not a replacement for a real GitLab-created MR pipeline.

If only this job needs a later rerun, it inherits those same variables:

Run the check job with merge-request variables
set -o pipefail
ci check 2>&1 | tee .cache/check-mr.log

For an actual MR, use GitLab's real diff-base SHA. $ci_base above is the local dev commit captured before this run. The commit must exist in the checkout; the pipeline sets GIT_DEPTH: "0" for check so an older MR base is available.

The latest full pipeline reported:

MR lint result from the full pipeline
lint:changed: 0 problem(s) in 0 of 608 changed files since 4d5965762e

Those 608 files were the changed TypeScript files selected by the GUI-package lint script, not every file changed by the branch.

9. Read the reports and rerun only what needs investigation

With the wrapper above, the local runner stores browser artifacts below .cache/gitlab-ci-final/artifacts/. Its job-directory names are encoded, so discover them instead of hard-coding a name:

Locate browser reports and traces
rg --files --hidden --no-ignore "$ci_state/artifacts" \
| rg '(playwright\.xml|service-worker\.xml|reports/html/index\.html|trace\.zip)$'

The XML summaries can be checked without opening every HTML report:

Summarize the Playwright JUnit reports
python3 - "$ci_state/artifacts" <<'PY'
from pathlib import Path
import sys
import xml.etree.ElementTree as ET
root = Path(sys.argv[1])
reports = sorted(root.glob('*/reports/*.xml'))
assert reports, 'No JUnit reports found'
for report in reports:
data = ET.parse(report).getroot()
counts = {key: int(data.get(key, '0'))
for key in ('tests', 'failures', 'errors', 'skipped')}
print(report.relative_to(root), counts)
assert counts['failures'] == 0 and counts['errors'] == 0
PY

Check the Playwright output or HTML report for flaky retries too; zero terminal failures in JUnit is not by itself proof that every test passed on its first attempt.

To rerun a job, use its exact expanded name from --list. Quote names containing spaces or brackets:

Examples: rerun one job at a time
ci unit
ci 'app-e2e: [2/2]'
ci 'components: [session-ui]'
ci build

These are separate examples, not a required sequence after a green run. A selected-job invocation does not stand in for a complete pipeline. Our jobs declare dependencies: []; another pipeline may need predecessor artifacts or --needs.

When a failure appears, my order is:

  1. Read the failing command and its surrounding output, then inspect its report or trace.
  2. Reproduce it in the same Linux image and with the same user and environment.
  3. Fix the code or the test's actual synchronization/fixture problem where possible.
  4. Rerun the affected job. After relevant changes, finish with a complete pipeline run against the final source state.

I do not turn a red job green by increasing retries, disabling assertions, or enabling privileged mode without understanding the failure.

What the latest run uncovered

The first unit job failed in packages/core/test/variant.test.ts. Its coverage test assumed every bundled native provider package exposed reasoning variants. The newly bundled @opencode/ai/providers/openai/images adapter correctly exposed none, so the assertion expecting an empty list of packages without variants had become outdated.

The correction changed the test's name and asserted the exact exception:

Keep exhaustive provider coverage while accounting for the image adapter
-test("spells reasoning variants for every bundled native package", () => {
+test("omits reasoning variants only for the bundled image generation package", () => {
const supports: Variant.Support[] = [{ type: "toggle" }, { type: "effort" }, { type: "budget_tokens" }]
const missing = Array.from(Provider.builtinPackages).filter(
(packageName) => Variant.resolve(model(packageName, "test-model", 32_000), supports).length === 0,
)
- expect(missing).toEqual([])
+ expect(missing).toEqual(["@opencode/ai/providers/openai/images"])
})

The collection logic stayed unchanged. This still fails if another bundled package unexpectedly loses its variants, and it checks that the image adapter has none. The corrected file passed 170 test executions across ten repetitions in the Linux container. No runtime code changed.

The failed unit job also logged ot_session_acquire_buffer_lease failed: SessionClosed during a TUI diff-panel test. It appeared as Turbo interrupted sibling tasks after the core failure. That timing suggested an interruption effect rather than an independent regression, but the log alone did not prove the cause. The isolated TUI file then passed all 16 tests, the reported case passed ten repetitions, and the final full TUI suite passed all 1,549 tests. No TUI change was needed.

After diagnosis, I stopped the failed pipeline and started a fresh complete run with the correction. Exit 137 entries from interrupted browser jobs in the initial log belong to that deliberate stop; they are not results from the successful rerun.

The final pipeline still had two flaky browser cases, both passing on retry 1 under the existing Playwright configuration:

Case First-attempt failure Final result
composer.spec.ts:7, free usage limit and prompt restoration The editor retained Refactor the parser where the test expected an empty editor Passed on retry 1
session-queue.spec.ts:775, shell command queue rejection The expected This prompt cannot be queued notice did not appear Passed on retry 1

These symptoms do not establish a root cause. I retained the failure contexts, retry traces, and HTML report, and left the two cases as follow-up work. The other 728 browser/component cases passed on their first attempts. The JUnit reports showed zero final failures, errors, or skips, which is compatible with Playwright reporting two flaky cases.

Linux details that mattered

The Playwright image starts as root, but the unit job deliberately changes ownership and runs tests as pwuser. Permission-denial tests need an ordinary user:

The unit job preserves ordinary-user semantics
script:
- chown -R pwuser:pwuser "$CI_PROJECT_DIR"
- runuser -u pwuser -- git config --global user.email "bot@opencode.ai"
- runuser -u pwuser -- git config --global user.name "opencode"
- runuser -u pwuser -- bun turbo test --concurrency=2

This belongs in the isolated CI workspace. Manually running the package tests as root would exercise different permission behavior.

The repository also has Linux process-ownership and namespace tests. Some retain explicit capability-based skips under ordinary Docker security. The unit job passing does not mean every platform-specific case executed. The browser reports, separately, contained zero skipped tests.

For OpenCode specifically, tests must run from their owning package directories. The root bun test command is deliberately disabled; the root Turbo command dispatches each package's own test script correctly.

The final results

This was one full run of 272063ab9a4 plus the recorded two-line test correction. These are the results from that complete rerun:

Job Duration Result
check 1.58 min Lint including the MR path, 36 type-check tasks, rule tests, E2E types, generated client/OpenAPI checks passed
unit 6.18 min All 32 package tasks passed; zero cached tasks
packages 3.62 min CodeMode publish dry-run and packed SDK consumer verification passed
app-e2e: [1/2] 2.52 min 228 passed on first attempt; 2 passed on retry
app-e2e: [2/2] 3.50 min 226 tests plus 12 service-worker tests passed without retries
components: [app] 3.08 min 121 tests passed without retries
components: [session-ui] 2.33 min 141 tests passed without retries
build 3.42 min 14 dependency-build tasks, Bun CLI, Node SEA CLI, and both service smoke tests passed

The overlapping jobs made total wall time 17 minutes, rather than the sum of the durations. The 730 browser/component tests are separate from the much larger unit suite.

The build job did more than compile TypeScript: it built a Bun executable, started it through the service smoke test, built the Node SEA distribution, and repeated the smoke test for that distribution. Non-failing bundle-size, CSS, and build-timing advisories remained. The only source change for this rerun was the unit-test correction; .gitlab-ci.yml stayed unchanged. At verification completion, the correction remained uncommitted and nothing had been pushed.

Keep the evidence and check cleanup

Before the run, save the commit, submodule pins, and intended patch as shown in section 3. After the runner exits, keep those inputs with the log, JUnit/HTML reports, and local environment overrides. Check that the source still matches what ran:

Record the tested revision and check runner cleanup
test "$(git rev-parse HEAD)" = "$(cat .cache/tested-commit.txt)"
git diff HEAD --binary > .cache/after-run.patch
cmp .cache/tested.patch .cache/after-run.patch
git status --short
docker ps
docker volume ls

The successful run cleaned up its job containers. The declared Bun cache volume remained available for reuse, and the reports remained in the local state directory. There was no need for a global Docker prune.

The earlier run used /tmp/opencode-gitlab-rebase-20261021. The latest run's retained checkout is $HOME/.cache/opencode-ci/20261021-272063ab9a4-L2pVFZ. Inside it:

Evidence Path
Final pipeline log .cache/pipeline-final.log
Initial failed/interrupted log .cache/pipeline.log
Result summary .cache/results.txt
Tested commit, submodules, and correction .cache/tested-commit.txt, .cache/tested-submodules.txt, .cache/tested.patch
Exact runner invocation .cache/run-ci.sh
Final reports, failure contexts, and retry traces .cache/gitlab-ci-final/artifacts/
Targeted reproductions .cache/variant-reproduction-fixed.log, .cache/tui-panel-reproduction.log, .cache/tui-panel-repeated.log

These are local evidence paths, not public downloads. The reusable cache layout makes them easier to retain between sessions, but it is not a permanent archive.

Before the next release, I will repeat the same sequence: capture the intended source revision, prepare its submodules, check the job matrix, run the pipeline, exercise any MR-only checks, and read the reports. GitLab's own run remains the final check for its actual runner architecture, credentials, variables, and pipeline context.

Comments