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 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.
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 `auto`). Automatic mode reuses a preinstalled stable `com` version 1.17.92 or newer within major version 1, including the CLI baked into Gen2 runners; otherwise it installs the latest release privately and adds it to the job PATH. Set `install-cli: 'true'` to always install, or `'false'` to require a compatible installed CLI. 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
4 changes: 2 additions & 2 deletions action.yml
Original file line number Diff line number Diff line change
Expand Up @@ -24,8 +24,8 @@ inputs:
description: Composal API origin.
default: https://composal.ai
install-cli:
description: Install the latest released Composal CLI in the runner temporary directory.
default: 'true'
description: 'auto reuses a compatible installed CLI, otherwise installs privately; true always installs; false requires an installed CLI.'
default: 'auto'
outputs:
builder:
description: Embedded Buildx builder. Pass to the build action's builder input and set DOCKER_CONTEXT to the context output.
Expand Down
33 changes: 27 additions & 6 deletions builder.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,18 @@ import { promisify } from 'node:util';
import { setTimeout as delay } from 'node:timers/promises';

const exec = promisify(execFile);
const minimumCliVersion = [1, 17, 92];

function compatibleCli(version) {
const match = /^com(?:posal)? (\d+)\.(\d+)\.(\d+)$/.exec(version);
if (!match) return false;
const parts = match.slice(1).map(Number);
// A future major release needs an explicit compatibility review.
return parts[0] === minimumCliVersion[0] &&
(parts[1] > minimumCliVersion[1] ||
(parts[1] === minimumCliVersion[1] && parts[2] >= minimumCliVersion[2]));
}

const selectedKeys = ['DOCKER_CONTEXT', 'BUILDX_BUILDER', 'BUILDX_METADATA_PROVENANCE', 'DOCKER_BUILD_RECORD_UPLOAD', 'DOCKER_BUILD_SUMMARY'];
const escapeCommand = value => String(value).replaceAll('%', '%25').replaceAll('\r', '%0D').replaceAll('\n', '%0A');

Expand Down Expand Up @@ -61,15 +73,24 @@ export async function setup(env = process.env, run = command, wait = delay) {
console.log(`${name} (${((now - stageStartedAt) / 1000).toFixed(1)}s; total ${((now - startedAt) / 1000).toFixed(1)}s)`);
stageStartedAt = now;
};
const install = input(env, 'install-cli', 'true');
if (!['true', 'false'].includes(install)) throw new Error('install-cli must be true or false');
const install = input(env, 'install-cli', 'auto');
if (!['auto', 'true', 'false'].includes(install)) throw new Error('install-cli must be auto, true or false');
const wakeTimeout = Number(input(env, 'wake-timeout', '900'));
if (!Number.isInteger(wakeTimeout) || wakeTimeout < 30 || wakeTimeout > 3600) throw new Error('wake-timeout must be between 30 and 3600 seconds');
const use = input(env, 'use', 'true');
if (!['true', 'false'].includes(use)) throw new Error('use must be true or false');
const cleanEnv = withoutToken(env);
let cli = 'com';
if (install === 'true') {
let cliVersion;
if (install === 'auto') {
try {
cliVersion = (await run(cli, ['--version'], cleanEnv, 10_000)).trim();
} catch {
// Missing or unusable installations use a job-private released CLI.
}
}
const reuse = install === 'auto' && compatibleCli(cliVersion);
if (install === 'true' || (install === 'auto' && !reuse)) {
if (!env.RUNNER_TEMP) throw new Error('RUNNER_TEMP is required');
const directory = join(env.RUNNER_TEMP, `com-docker-builder-${randomUUID()}`);
await mkdir(directory, { recursive: true, mode: 0o700 });
Expand All @@ -80,9 +101,9 @@ export async function setup(env = process.env, run = command, wait = delay) {
if (!env.GITHUB_PATH) throw new Error('GITHUB_PATH is required');
await appendFile(env.GITHUB_PATH, `${dirname(cli)}\n`);
}
const cliVersion = (await run(cli, ['--version'], cleanEnv, 10_000)).trim();
if (!/^com(?:posal)? [0-9]+\.[0-9]+\.[0-9]+/.test(cliVersion)) throw new Error('Could not identify the Composal CLI version');
stage(`Composal CLI available: ${cliVersion}`);
if (!reuse) cliVersion = (await run(cli, ['--version'], cleanEnv, 10_000)).trim();
if (!compatibleCli(cliVersion)) throw new Error('Composal CLI 1.17.92 or newer in major version 1 is required');
stage(`Composal CLI available: ${cliVersion}${reuse ? ' (preinstalled)' : ''}`);
if (!env.RUNNER_TEMP) throw new Error('RUNNER_TEMP is required');
// The CLI keys supervisors by config home as well as builder identity.
// A job must not reuse or disconnect a pre-existing local connection.
Expand Down
41 changes: 40 additions & 1 deletion builder.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ async function fixture(t, inputs = {}, failure = '') {
const run = async (executable, args, childEnv) => {
calls.push({ executable, args, env: childEnv });
if (args.includes(failure)) throw new Error(`refused ${env.INPUT_TOKEN}`);
if (args[0] === '--version') return 'com 1.17.88\n';
if (args[0] === '--version') return 'com 1.17.92\n';
if (args[1] === 'status') return JSON.stringify({ docker_builder: { id: 'docker_builder_456', organization_id: 'org_123', status: 'ready' } });
if (args[1] === 'setup') {
const context = args[args.indexOf('--context') + 1];
Expand Down Expand Up @@ -429,3 +429,42 @@ test('v2 refuses a Classic builder before waking or connecting Docker', async t
await assert.rejects(setup(f.env, f.run), /requires an enabled Gen2 builder/);
assert.ok(!f.calls.some(c => c.args[1] === 'wake' || c.args[1] === 'setup'));
});

for (const version of ['com 1.17.92', 'com 1.18.0']) {
test(`automatic setup reuses compatible installed ${version} without downloading`, async t => {
const f = await fixture(t, { 'INPUT_INSTALL-CLI': '' });
const run = (exe, args, env) => args[0] === '--version' ? version : f.run(exe, args, env);
await setup(f.env, run);
assert.equal((await f.state()).STATE_cli, 'com');
assert.ok(!f.calls.some(c => c.executable === 'curl' || c.executable === 'sh'));
await cleanup({ ...f.env, ...await f.state() }, f.run);
assert.equal(f.calls.at(-2).executable, 'com');
});
}

for (const version of [null, 'com 1.17.91', 'com 2.0.0', 'com 1.17.92-dev', 'other 1.17.92']) {
test(`automatic setup installs a private CLI when installed version is ${version}`, async t => {
const f = await fixture(t, { 'INPUT_INSTALL-CLI': 'auto', COMPOSAL_API_TOKEN: 'ambient', VEX_API_TOKEN: 'ambient' });
const run = async (exe, args, env) => {
if (exe === 'com' && args[0] === '--version') {
assert.equal(env.INPUT_TOKEN, undefined);
assert.equal(env.COMPOSAL_API_TOKEN, undefined);
assert.equal(env.VEX_API_TOKEN, undefined);
if (version === null) throw new Error('ENOENT');
return version;
}
return f.run(exe, args, env);
};
await setup(f.env, run);
assert.match((await f.state()).STATE_cli, /com-docker-builder-.*\/com$/);
assert.equal(f.calls.filter(c => c.executable === 'curl').length, 1);
assert.equal(f.calls.filter(c => c.executable === 'sh').length, 1);
});
}

test('explicit preinstalled mode rejects an unsupported CLI without installing', async t => {
const f = await fixture(t);
const run = (exe, args, env) => args[0] === '--version' ? 'com 1.17.91' : f.run(exe, args, env);
await assert.rejects(setup(f.env, run), /1.17.92 or newer/);
assert.equal(f.calls.length, 0);
});