Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ Authenticate to your registry before selecting the remote context: the gateway d

An organization admin must request Gen2 builder activation first. Composal provisions a separate `ci-gen2` builder through the organization API with `backend: gen2`; an existing Classic builder cannot be converted in place. The token needs membership in that organization. Setup fails if the builder is missing, unavailable, or refused; it never falls back to a local build or creates a cache accidentally.

Inputs are `token` and `org` (required), `builder` (default `main-gen2`), `wake-timeout` (default `900` seconds, range `30`–`3600`), `use` (default `true`), `api-url` (default `https://composal.ai`), and `install-cli` (default `true`). The installed CLI is added to the job PATH. Set `install-cli: 'false'` to use an already installed released `com` supporting background Docker builder setup. Docker and Buildx must be installed on the runner. Outputs are `builder`, `context`, and `driver` (`docker`). Each job isolates CLI supervisor state in its temporary directory. Setup logs the CLI version, checks gateway health and waits for the shared builder to become ready before opening native Docker connections. Cold starts can take several minutes; `wake-timeout` controls this wait. Progress includes the builder state and generation. Setup requests a retry when it finds a builder already starting, so a failed earlier wake can resume. Canceling a job disconnects only that job; it does not cancel the shared wake. It retries transient status-read server errors, then checks the remote daemon with bounded, retryable probes and verifies Buildx readiness before selecting the context and builder for subsequent job steps using `DOCKER_CONTEXT` and `BUILDX_BUILDER`. It leaves Docker's saved defaults unchanged and restores the previous job environment during cleanup.
Inputs are `token` and `org` (required), `builder` (default `main-gen2`), `wake-timeout` (default `900` seconds, range `30`–`3600`), `use` (default `true`), `api-url` (default `https://composal.ai`), and `install-cli` (default `true`). The installed CLI is added to the job PATH. Set `install-cli: 'false'` to use an already installed released `com` supporting background Docker builder setup. Docker and Buildx must be installed on the runner. Outputs are `builder`, `context`, and `driver` (`docker`). Each job isolates CLI supervisor state in its temporary directory. Setup logs the CLI version, checks gateway health and waits for the shared builder to become ready before opening native Docker connections. Cold starts can take several minutes; `wake-timeout` controls this wait. Progress includes the builder state and generation. Setup requests a retry when it finds a builder already starting, so a failed earlier wake can resume. Canceling a job disconnects only that job; it does not cancel the shared wake. It retries transient status-read server errors, then checks the remote daemon with bounded, retryable probes before selecting the context and builder for subsequent job steps using `DOCKER_CONTEXT` and `BUILDX_BUILDER`. CLI setup verifies that Buildx is installed; the build performs BuildKit discovery once, without a redundant setup inspection. It leaves Docker's saved defaults unchanged and restores the previous job environment during cleanup.

If a startup attempt fails, setup reports its generation, stage and failure reason immediately. A fresh job can retry a failed startup against the same builder and retained cache. Simultaneous jobs share that startup; canceling one job keeps the shared startup running.

Expand Down
5 changes: 3 additions & 2 deletions builder.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -195,8 +195,9 @@ export async function setup(env = process.env, run = command, wait = delay) {
}
}
stage('Remote Docker ready');
// Inspect alone can exit zero with a node error; version must succeed first.
await run('docker', ['buildx', 'inspect', context, '--bootstrap'], dockerEnv, 300_000);
// CLI setup already verifies the local Buildx plugin and implicit Docker
// driver. The build discovers BuildKit itself; repeating discovery here adds
// remote round trips without making the first build faster.
if (use === 'true') {
await record(env.GITHUB_STATE, 'selected', 'true');
// The scoped gateway does not expose BuildKit content-record downloads.
Expand Down
28 changes: 17 additions & 11 deletions builder.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -43,14 +43,14 @@ async function fixture(t, inputs = {}, failure = '') {
test('setup returns a ready explicit builder and selects it for subsequent steps without persisting its token', async t => {
const f = await fixture(t);
await setup(f.env, f.run);
assert.deepEqual(f.calls.map(c => c.args.slice(0, 2)), [['--version'], ['docker-builder', 'status'], ['docker-builder', 'setup'], ['--context', f.calls[3].args[1]], ['buildx', 'inspect']]);
assert.deepEqual(f.calls.map(c => c.args.slice(0, 2)), [['--version'], ['docker-builder', 'status'], ['docker-builder', 'setup'], ['--context', f.calls[3].args[1]]]);
for (const call of f.calls.slice(1, 3)) {
assert.equal(call.env.COMPOSAL_API_TOKEN, f.env.INPUT_TOKEN);
assert.equal(call.env.VEX_API_TOKEN, f.env.INPUT_TOKEN);
assert.ok(!call.args.join(' ').includes(f.env.INPUT_TOKEN));
}
assert.equal(f.calls[3].env.COMPOSAL_API_TOKEN, undefined);
assert.equal(f.calls[4].env.DOCKER_CONTEXT, f.calls[3].args[1]);
assert.equal(f.calls[3].env.DOCKER_CONTEXT, f.calls[3].args[1]);
const environment = await readFile(f.env.GITHUB_ENV, 'utf8');
assert.ok(environment.includes('DOCKER_CONTEXT<<'));
assert.ok(environment.includes('BUILDX_METADATA_PROVENANCE<<'));
Expand Down Expand Up @@ -187,14 +187,6 @@ test('a stalled control-plane wake has one bounded deadline and publishes no con
await assert.rejects(readFile(f.env.GITHUB_OUTPUT), { code: 'ENOENT' });
});

test('bootstrap failure retains cleanup state and emits no usable builder', async t => {
const f = await fixture(t, {}, '--bootstrap');
await assert.rejects(setup(f.env, f.run), /refused/);
await assert.rejects(readFile(f.env.GITHUB_OUTPUT), { code: 'ENOENT' });
await cleanup({ ...f.env, ...await f.state() }, f.run);
assert.equal(f.calls.at(-2).args[1], 'disconnect');
});

test('missing builder fails before setup; CI cannot silently create a misspelled cache', async t => {
const f = await fixture(t, {}, 'status');
await assert.rejects(setup(f.env, f.run), /refused/);
Expand Down Expand Up @@ -253,6 +245,20 @@ test('malformed setup response still disconnects the resolved builder without em
});


test('a non-Docker driver is refused before publishing outputs and remains cleanable', async t => {
const f = await fixture(t);
const run = async (executable, args, env) => {
const result = await f.run(executable, args, env);
return args[1] === 'setup' ? JSON.stringify({ ...JSON.parse(result), driver: 'docker-container' }) : result;
};
await assert.rejects(setup(f.env, run), /unexpected builder connection/);
await assert.rejects(readFile(f.env.GITHUB_OUTPUT), { code: 'ENOENT' });
await cleanup({ ...f.env, ...await f.state() }, f.run);
assert.equal(f.calls.at(-2).args[1], 'disconnect');
assert.equal(f.calls.at(-1).args[1], 'rm');
});


test('explicit-output mode leaves Docker environment untouched', async t => {
const f = await fixture(t, { INPUT_USE: 'false' });
await setup(f.env, f.run);
Expand Down Expand Up @@ -284,7 +290,7 @@ test('local invocation never prints a token-bearing GitHub masking command', asy
});


test('daemon failure refuses setup even when Buildx inspect would return success', async t => {
test('daemon failure retains cleanup state and refuses to publish a usable builder', async t => {
const f = await fixture(t, {}, 'version');
await assert.rejects(setup(f.env, f.run), /refused/);
await assert.rejects(readFile(f.env.GITHUB_OUTPUT), { code: 'ENOENT' });
Expand Down