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
14 changes: 8 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,9 @@

Connect a Linux GitHub Actions runner to an existing Composal organization builder. Docker layers and `RUN --mount=type=cache` data stay on its persistent remote disk. Images push from the builder to your registry; deployment hosts pull the published digest.

Use `ComposalAI/docker-builder@1` (also available as `@v1`), or pin a release commit for reproducibility. Remote builders are in preview.
Version 2 requires an AWS/EBS Gen2 builder and checks the backend before waking it. It refuses Classic builders without fallback; existing `@1` / `@v1` releases remain available for Classic.

Use `ComposalAI/docker-builder@2` (also available as `@v2`), or pin a release commit for reproducibility. Remote builders are in preview.

```yaml
concurrency:
Expand All @@ -16,12 +18,12 @@ steps:
registry: registry.example.com
username: ${{ secrets.REGISTRY_USERNAME }}
password: ${{ secrets.REGISTRY_PASSWORD }}
- uses: ComposalAI/docker-builder@1
- uses: ComposalAI/docker-builder@2
id: remote
with:
token: ${{ secrets.COMPOSAL_API_TOKEN }}
org: acme
builder: ci
builder: ci-gen2
- uses: docker/build-push-action@v6
with:
context: .
Expand All @@ -32,9 +34,9 @@ steps:

Authenticate to your registry before selecting the remote context: the gateway does not expose the daemon registry-login endpoint. For the Composal registry, `com registry login --write-config` can instead write client credentials after setup without using that endpoint. BuildKit receives registry authentication through the normal Docker client session.

An organization admin must provision the builder first with `com docker-builder create ci --org acme`. 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.
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`), `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 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.

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 All @@ -44,7 +46,7 @@ The normal setup works with `docker/build-push-action`, `docker buildx build`, a

Use one connection per job. Post-job cleanup disconnects the local proxy and removes its context, including after build failure. It keeps the remote builder and cache; organization idle policy sleeps compute. Runner termination can prevent cleanup. Disconnecting does not prove a remote operation has stopped: uncertain operations retain their slot until the service reconciles them or an administrator recovers the builder.

One builder can serve multiple native builds on the same daemon and retained cache. Organization concurrency defaults to one across all its builders; an admin can raise it with `com docker-builder settings --org acme --max-concurrency 4`. New VMs receive 2 CPUs and 4 GiB RAM per configured concurrent operation, with a 256 GiB cache disk. Changing the policy does not resize an already running VM: drain its builds, sleep it to checkpoint the cache, then wake it. Qualify image size, cold build memory and warm performance before switching production builds.
One builder can serve multiple native builds on the same daemon and retained cache. Organization concurrency defaults to one across all its builders; an admin can raise it with `com docker-builder settings --org acme --max-concurrency 4`. Organization admission limits support up to 32 concurrent builds. Gen2 builders run on AWS Boxmad nodes with fair CPU shares and 4 GiB RAM reserved per build slot, capped at eight resource slots (32 GiB RAM). Higher admission limits share those resources. The six-hour compute lifetime limit is validated before activation and policy changes. Each builder mounts its own encrypted 512 GiB gp3 EBS cache disk (16,000 provisioned IOPS / 1,000 MiB/s) directly as ext4 for Docker overlay2. The volume persists when compute sleeps. Changing the policy does not resize an already running builder: drain its builds, sleep it to checkpoint the cache, then wake it. Qualify image size, cold build memory and warm performance before switching production builds.

Coordinate CI jobs to fit the organization's configured capacity. The single concurrency group in the example serializes jobs; multiple groups can admit more jobs when capacity has been increased. GitHub concurrency only coordinates workflows in the same repository, and concurrent work outside those groups can still be refused. Use `queue: max` to retain pending builds (up to 100); without it GitHub replaces the previous pending build. Use separate builders when workloads need independent caches. Remote builders are in preview.

Expand Down
4 changes: 2 additions & 2 deletions action.yml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
name: Composal Docker builder
author: ComposalAI
description: Connect Buildx to an existing Composal builder with a persistent remote Docker cache.
description: Connect Buildx to an existing Composal builder with a persistent EBS cache on the AWS Gen2 fleet.
branding:
icon: package
color: purple
Expand All @@ -13,7 +13,7 @@ inputs:
required: true
builder:
description: Existing organization builder slug or public ID.
default: main
default: main-gen2
wake-timeout:
description: Maximum seconds to wait for the shared builder to become ready, before connecting Docker.
default: '900'
Expand Down
18 changes: 17 additions & 1 deletion builder.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,7 @@ export async function setup(env = process.env, run = command, wait = delay) {
if (!token) throw new Error('token is required');
if (env.GITHUB_ACTIONS === 'true') console.log(`::add-mask::${escapeCommand(token)}`);
const org = input(env, 'org');
const builder = input(env, 'builder', 'main');
const builder = input(env, 'builder', 'main-gen2');
if (!/^[a-zA-Z0-9][a-zA-Z0-9_-]*$/.test(org)) throw new Error('org is required and must be a slug or public ID');
if (!/^[a-zA-Z0-9][a-zA-Z0-9_-]*$/.test(builder)) throw new Error('builder must be a slug or public ID');
const api = new URL(input(env, 'api-url', 'https://composal.ai'));
Expand Down Expand Up @@ -107,6 +107,22 @@ export async function setup(env = process.env, run = command, wait = delay) {
if (!/^org_[a-zA-Z0-9]+$/.test(resource?.organization_id) || !/^docker_builder_[a-zA-Z0-9]+$/.test(resource?.id)) {
throw new Error('Composal CLI returned an unexpected builder identity');
}
// Read the new backend field directly until all released CLIs retain it.
// Fail closed: a v2 action must never quietly build on Classic or locally.
let backend;
try {
const response = await fetch(`${api.origin}/api/v1/organizations/${resource.organization_id}/docker_builders/${resource.id}`, {
headers: { Authorization: `Bearer ${token}`, Accept: 'application/json' },
redirect: 'error', signal: AbortSignal.timeout(15_000),
});
if (!response.ok) throw new Error('backend lookup refused');
backend = (await response.json()).docker_builder;
} catch {
throw new Error('Could not verify Gen2 builder placement');
}
if (backend?.id !== resource.id || backend.organization_id !== resource.organization_id || backend.backend !== 'gen2') {
throw new Error('docker-builder@2 requires an enabled Gen2 builder; select its builder name');
}
stage(`Builder resolved: ${resource.status || 'unknown state'}`);
if (resource.status !== 'ready') {
// Complete the control-plane wake before Docker opens its first HTTP
Expand Down
13 changes: 13 additions & 0 deletions builder.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,9 @@ import { join } from 'node:path';
import { command, cleanup, reportFailure, setup } from './builder.mjs';

async function fixture(t, inputs = {}, failure = '') {
t.mock.method(globalThis, 'fetch', async () => ({ ok: true, json: async () => ({ docker_builder: {
id: 'docker_builder_456', organization_id: 'org_123', backend: 'gen2',
} }) }));
const directory = await mkdtemp(join(tmpdir(), 'com-builder-test-'));
t.after(() => rm(directory, { recursive: true, force: true }));
const env = {
Expand Down Expand Up @@ -410,3 +413,13 @@ test('command failures preserve timeout and signal details without stdout or env
return true;
});
});


test('v2 refuses a Classic builder before waking or connecting Docker', async t => {
const f = await fixture(t);
t.mock.method(globalThis, 'fetch', async () => ({ok: true, json: async () => ({docker_builder: {
id: 'docker_builder_456', organization_id: 'org_123', backend: 'classic',
}})}));
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'));
});