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:
- macOS runs
gitlab-ci-localand the Docker client. - OrbStack provides the Linux Docker engine.
- 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:
|
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:
|
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:
|
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:
|
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:
|
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=1enables proxy initialization in the Electron artifact downloader. See the Electron downloader's proxy documentation.TURBO_ENV_MODE=looselets the locally supplied proxy variables reach package subprocesses. The repository'sturbo.jsondid 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.
|
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:
|
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:
|
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:
|
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:
|
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:
|
The six visible job definitions expanded to eight jobs:
|
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:
|
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:
|
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:
|
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:
|
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:
|
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:
|
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:
|
The XML summaries can be checked without opening every HTML report:
|
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:
|
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:
- Read the failing command and its surrounding output, then inspect its report or trace.
- Reproduce it in the same Linux image and with the same user and environment.
- Fix the code or the test's actual synchronization/fixture problem where possible.
- 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:
|
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:
|
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:
|
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.