Skip to content

Commit bad0cbb

Browse files
authored
refactor(box-images)!: adopt v0.1.0 line, relocate under apps/ (#1100)
Points every curated-image reference at the `v0.1.0` tag, gives the box images a home matching the rest of the repo, and splits their build from their release. ## Images All 53 references move from `20260605-p0-r3` to `v0.1.0` across 41 files. The two tags are different image lineages, not two versions of one: | | `20260605-p0-r3` | `v0.1.0` | | --- | --- | --- | | daemon | `COPY .../boxlite-daemon` | absent | | entrypoint | `/boxlite/bin/start-agent-runtime` | none (`node` keeps stock `docker-entrypoint.sh`) | | user | root | `boxlite` | | arch | linux/amd64 only | amd64 **+ arm64** | Because `v0.1.0` runs unprivileged, the six e2e files that wrote to `/root` now use `/workspace` — still on the container rootfs, so `copy_out` sees it, and owned by the box user. ## Layout `images/agent-runtime/` → `apps/box-images/`, joining `apps/{api,dex,proxy,runner}`, which already co-locate their Dockerfiles; top-level `images/` is gone. `scripts/images/build-agent-runtime.sh` → `apps/box-images/build.sh` so the Dockerfiles, `VERSION` and builder sit together. "agent-runtime" was ambiguous next to `src/guest` (the guest agent) and `apps/runner`. ## Workflows - **`build-box-images.yml`** (was `publish-boxlite-cloud-images.yml`) validates only — both architectures at `PUSH=0`, `contents: read`, no registry login, so it cannot write to GHCR. - **`release-box-images.yml`** is the sole publisher, driven by an `apps/box-images/vMAJOR.MINOR.PATCH` tag (same convention as the existing `sdks/go/v*` tags) and restricted to a release tag or `main`. Previously any edit under the image directory republished the same tag, silently moving the bytes a running box pulls. The release path now rejects a tag that disagrees with `VERSION` and refuses to republish an existing version unless dispatched with `allow-overwrite`. The existence check reads the registry API and branches on HTTP status — only 404 counts as free, so a 5xx or an expired token stops the release instead of reading as "not published". ## Verification - `actionlint` clean on both workflows. - `build.sh` builds all three flavors from its new location; the resulting arm64 image IDs match the pre-move builds, and the Dockerfiles are byte-identical renames. - `curated-images.constant.spec.ts` passes 9/9. - Release-workflow shell was exercised by extracting the `run:` bodies from the YAML and driving them directly: version resolution 10/10 (tag/VERSION disagreement, missing or wrong tag prefix, prerelease, malformed input) and the overwrite guard 8/8 across 404 / 200 / 500 / 429 / 401 / token-exchange failure / empty token / `allow-overwrite`. **The e2e suite has not been run against the new images.** The `/root` → `/workspace` move is derived from the image config — `images/config.rs:166` reads the OCI user and `portal/interfaces/container.rs:131` passes it through — not from an observed run. ## Notes for review - This branch is behind `main` and conflicts with `2933c30bd fix(infra): harden SST deployments (#1093)` in `apps/infra/.env.example`. Needs a rebase before merge; the resolution must keep `v0.1.0`. - Follow-up, not fixed here: `Record published digests` runs `imagetools inspect` under `set -e`, so a flaky inspect reddens the job after a successful publish. No silent-damage path — re-running the release hits the overwrite guard. https://claude.ai/code/session_01T3B2RsTupPrn2gPtHymU5T <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit - **New Features** - Introduced versioned Box images (`v0.1.0`) for Base, Python, and Node.js environments. - Added AMD64 and ARM64 image support. - Added controlled image publishing with version validation and registry safeguards. - **Bug Fixes** - Updated quickstarts, default configurations, and tests to use versioned images. - Standardized container file operations under `/workspace`. - **Documentation** - Documented image validation and release workflows, including non-publishing builds for main-branch changes. <!-- end of auto-generated comment: release notes by coderabbit.ai --> --------- Signed-off-by: dorianzheng <8065637+DorianZheng@users.noreply.github.com>
1 parent 2b1945f commit bad0cbb

64 files changed

Lines changed: 627 additions & 247 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.github/workflows/README.md

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -195,6 +195,26 @@ the labeled head commit — re-label after new pushes.
195195
See the **[E2E Local CI runbook](../../docs/ci/e2e-local.md)** for the jobs, the instance,
196196
one-time provisioning (`scripts/ci/setup-ci-runner.sh`), and troubleshooting.
197197

198+
### `build-box-images.yml` / `release-box-images.yml`
199+
200+
The box images in `apps/box-images/` (`base`, `python`, `node`) carry their own `VERSION`,
201+
independent of the product version, so build and release are split:
202+
203+
- **`build-box-images.yml`** validates. On PRs and `main` it builds all three flavors for
204+
`linux/amd64` and `linux/arm64` with `PUSH=0`, which exercises every layer without
205+
publishing. It holds `contents: read` only and has no registry login, so it cannot write
206+
to GHCR.
207+
- **`release-box-images.yml`** publishes, and is the only workflow that can. It runs on an
208+
`apps/box-images/vMAJOR.MINOR.PATCH` tag — the same path-prefixed convention as the
209+
`sdks/go/v*` tags — or on manual dispatch, and only from a release tag or `main`. The tag
210+
must agree with `apps/box-images/VERSION`, and publishing aborts if that version already
211+
exists on GHCR unless dispatched with `allow-overwrite`, so a rebuild cannot silently move
212+
the tag a running box pulls. The existence check reads the registry API and branches on the
213+
HTTP status: only a 404 counts as free, so a 5xx or an expired token stops the release
214+
instead of reading as "not published".
215+
216+
Releasing is therefore an explicit tag; merging to `main` does not publish.
217+
198218
## Trigger Behavior
199219

200220
| Change | warm-caches | build-runtime | build-wheels | build-node |
Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
name: Build Box Images
2+
3+
# Validation only — proves every flavor still builds for both architectures. Publishing lives in
4+
# release-box-images.yml, triggered by an apps/box-images/v* tag, so exactly one workflow can write
5+
# to GHCR and a Dockerfile edit can no longer move an already-published version tag.
6+
on:
7+
pull_request:
8+
paths:
9+
- 'apps/box-images/**' # Dockerfiles, VERSION and the build script define image contents.
10+
- '.dockerignore' # Docker context changes can change what lands in the image.
11+
- '.github/workflows/build-box-images.yml' # Workflow changes should validate themselves.
12+
push:
13+
branches: [main] # Catch anything that reached main without a PR run.
14+
paths:
15+
- 'apps/box-images/**'
16+
- '.dockerignore'
17+
- '.github/workflows/build-box-images.yml'
18+
workflow_dispatch:
19+
20+
permissions:
21+
contents: read # Checkout only needs repository read access; this job never pushes.
22+
23+
concurrency:
24+
group: build-box-images-${{ github.ref }} # Serialize per branch/ref.
25+
cancel-in-progress: true # A newer commit supersedes an in-flight validation build.
26+
27+
jobs:
28+
build:
29+
name: Build box images (no publish)
30+
runs-on: ubuntu-latest
31+
32+
steps:
33+
- name: Checkout code
34+
uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # Pin checkout for supply-chain stability.
35+
with:
36+
persist-credentials: false # Later steps do not need git credentials.
37+
38+
- name: Set up QEMU
39+
uses: docker/setup-qemu-action@c7c53464625b32c7a7e944ae62b3e17d2b600130 # Enable cross-arch build emulation.
40+
41+
- name: Set up Docker Buildx
42+
uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # Buildx is required for multi-arch images.
43+
44+
- name: Build all flavors for both architectures
45+
env:
46+
PUSH: '0' # PUSH=0 with two platforms validates every build step without publishing.
47+
PLATFORMS: linux/amd64,linux/arm64 # Both published architectures must keep building.
48+
run: bash apps/box-images/build.sh

.github/workflows/publish-boxlite-cloud-images.yml

Lines changed: 0 additions & 77 deletions
This file was deleted.
Lines changed: 172 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,172 @@
1+
name: Release Box Images
2+
3+
# The only workflow that writes to GHCR. Driven by an `apps/box-images/vMAJOR.MINOR.PATCH` tag so
4+
# the box images keep a release line independent of the product version (same convention as the
5+
# existing `sdks/go/v*` tags). Publishing refuses to move a version tag that already exists, so a
6+
# rebuild can never silently replace the bytes a running box pulls.
7+
on:
8+
push:
9+
tags:
10+
- 'apps/box-images/v*' # Path-prefixed tag keeps this release line separate from product v*.
11+
workflow_dispatch:
12+
inputs:
13+
version:
14+
description: 'Version to publish, with or without leading v. Defaults to apps/box-images/VERSION.'
15+
required: false
16+
type: string
17+
allow-overwrite:
18+
description: 'Republish even if the version tag already exists on GHCR. Moves a published tag.'
19+
required: false
20+
default: false
21+
type: boolean
22+
23+
permissions:
24+
contents: read # Checkout only needs repository read access.
25+
packages: write # GHCR push requires package write access.
26+
27+
concurrency:
28+
group: release-box-images # One release at a time, repository-wide.
29+
cancel-in-progress: false # Never cancel an in-flight publish.
30+
31+
jobs:
32+
release:
33+
name: Publish box images
34+
runs-on: ubuntu-latest
35+
# The workflow this replaced allowed publishing only from main; workflow_dispatch can target
36+
# any ref, so keep that restriction. A release tag is the other legitimate source.
37+
if: github.ref_type == 'tag' || github.ref == 'refs/heads/main'
38+
39+
steps:
40+
- name: Checkout code
41+
uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # Pin checkout for supply-chain stability.
42+
with:
43+
persist-credentials: false # Later steps do not need git credentials.
44+
45+
- name: Resolve version
46+
id: version
47+
env:
48+
INPUT_VERSION: ${{ inputs.version }} # Via env, never interpolated into the script body.
49+
TAG_NAME: ${{ github.ref_type == 'tag' && github.ref_name || '' }}
50+
run: |
51+
set -euo pipefail
52+
53+
file_version="$(tr -d '[:space:]' < apps/box-images/VERSION)"
54+
55+
if [ -n "${TAG_NAME:-}" ]; then
56+
# Tag push: the tag is the request. apps/box-images/v1.2.3 -> 1.2.3
57+
version="${TAG_NAME#apps/box-images/v}"
58+
if [ "$version" = "$TAG_NAME" ]; then
59+
echo "Tag '$TAG_NAME' is not of the form apps/box-images/vMAJOR.MINOR.PATCH" >&2
60+
exit 1
61+
fi
62+
# A tag that disagrees with the committed VERSION means the release is ambiguous:
63+
# the images would be built from a tree that does not describe itself as this version.
64+
if [ "$version" != "$file_version" ]; then
65+
echo "Tag version '$version' != apps/box-images/VERSION '$file_version'" >&2
66+
echo "Fix the tag or the VERSION file so they agree, then re-tag." >&2
67+
exit 1
68+
fi
69+
else
70+
version="${INPUT_VERSION:-$file_version}"
71+
version="${version#v}"
72+
fi
73+
74+
if ! echo "$version" | grep -Eq '^[0-9]+[.][0-9]+[.][0-9]+(-[0-9A-Za-z][0-9A-Za-z.-]*)?$'; then
75+
echo "Invalid version '$version'; expected MAJOR.MINOR.PATCH with optional -PRERELEASE" >&2
76+
exit 1
77+
fi
78+
79+
echo "tag=v$version" >> "$GITHUB_OUTPUT" # Docker tag shared by all three flavors.
80+
81+
# Asks the registry directly and branches on the HTTP status, because "the command failed"
82+
# and "the tag is absent" are different answers: a 5xx, a rate limit or an expired token
83+
# would otherwise read as absent and let the publish move a released tag. Only 404 is
84+
# treated as free; anything unrecognized stops the release.
85+
- name: Refuse to overwrite a published version
86+
env:
87+
TAG: ${{ steps.version.outputs.tag }} # Resolved above.
88+
ALLOW_OVERWRITE: ${{ inputs.allow-overwrite }} # Dispatch-only escape hatch; empty on tag push.
89+
GHCR_USER: ${{ github.actor }} # Basic-auth user for the GHCR token exchange.
90+
GHCR_TOKEN: ${{ secrets.GITHUB_TOKEN }} # Built-in token; read access is enough here.
91+
run: |
92+
set -euo pipefail
93+
94+
if [ "${ALLOW_OVERWRITE:-false}" = "true" ]; then
95+
echo "allow-overwrite=true — existing tags may be replaced."
96+
exit 0
97+
fi
98+
99+
published=""
100+
for image in base python node; do
101+
repo="boxlite-ai/boxlite-agent-${image}"
102+
103+
# -f rejects an error response, and `// empty` catches a 200 carrying no token, so an
104+
# unusable exchange stops here rather than sending an empty bearer and reading as 401.
105+
token="$(curl -fsSL -u "${GHCR_USER}:${GHCR_TOKEN}" \
106+
"https://ghcr.io/token?service=ghcr.io&scope=repository:${repo}:pull" \
107+
| jq -r '.token // empty')" || token=""
108+
if [ -z "$token" ]; then
109+
echo "Could not obtain a GHCR pull token for ${repo}; refusing to publish." >&2
110+
exit 1
111+
fi
112+
113+
status="$(curl -sS -o /dev/null -w '%{http_code}' \
114+
-H "Authorization: Bearer ${token}" \
115+
-H 'Accept: application/vnd.oci.image.index.v1+json' \
116+
-H 'Accept: application/vnd.docker.distribution.manifest.list.v2+json' \
117+
"https://ghcr.io/v2/${repo}/manifests/${TAG}")"
118+
119+
case "$status" in
120+
200) published="${published}${published:+, }ghcr.io/${repo}:${TAG}" ;;
121+
404) ;; # Not published — this flavor is free to take the tag.
122+
*)
123+
echo "Cannot tell whether ghcr.io/${repo}:${TAG} exists (HTTP ${status})." >&2
124+
echo "Refusing to publish on an inconclusive check." >&2
125+
exit 1
126+
;;
127+
esac
128+
done
129+
130+
if [ -n "$published" ]; then
131+
echo "Already published: $published" >&2
132+
echo "Bump apps/box-images/VERSION and tag again, or re-run with allow-overwrite=true." >&2
133+
exit 1
134+
fi
135+
136+
- name: Log in to GHCR
137+
uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # Authenticate Docker for GHCR reads and pushes.
138+
with:
139+
registry: ghcr.io # Target registry for BoxLite box images.
140+
username: ${{ github.actor }} # GitHub actor is accepted for GITHUB_TOKEN auth.
141+
password: ${{ secrets.GITHUB_TOKEN }} # Built-in token has packages:write from workflow permissions.
142+
143+
- name: Set up QEMU
144+
uses: docker/setup-qemu-action@c7c53464625b32c7a7e944ae62b3e17d2b600130 # Enable cross-arch build emulation.
145+
146+
- name: Set up Docker Buildx
147+
uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # Buildx is required for multi-arch images.
148+
149+
- name: Publish images
150+
env:
151+
TAG: ${{ steps.version.outputs.tag }} # Use the version resolved above.
152+
PUSH: '1' # Publish to GHCR instead of building locally.
153+
PLATFORMS: linux/amd64,linux/arm64 # Publish both supported CPU architectures.
154+
run: bash apps/box-images/build.sh
155+
156+
- name: Record published digests
157+
env:
158+
TAG: ${{ steps.version.outputs.tag }} # Same tag that was just published.
159+
run: |
160+
set -euo pipefail
161+
162+
{
163+
echo "### Published box images \`${TAG}\`"
164+
echo
165+
echo "| image | digest |"
166+
echo "| --- | --- |"
167+
for image in base python node; do
168+
ref="ghcr.io/boxlite-ai/boxlite-agent-${image}:${TAG}"
169+
digest="$(docker buildx imagetools inspect "$ref" --format '{{.Manifest.Digest}}')"
170+
echo "| \`${ref}\` | \`${digest}\` |"
171+
done
172+
} >> "$GITHUB_STEP_SUMMARY"

apps/api/src/box/constants/curated-images.constant.spec.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -7,9 +7,9 @@
77
import { BadRequestError } from '../../exceptions/bad-request.exception'
88
import { assertSupportedImage, supportedImages } from './curated-images.constant'
99

10-
const BASE_REF = 'ghcr.io/boxlite-ai/boxlite-agent-base:20260605-p0-r3'
11-
const PYTHON_REF = 'ghcr.io/boxlite-ai/boxlite-agent-python:20260605-p0-r3'
12-
const NODE_REF = 'ghcr.io/boxlite-ai/boxlite-agent-node:20260605-p0-r3'
10+
const BASE_REF = 'ghcr.io/boxlite-ai/boxlite-agent-base:v0.1.0'
11+
const PYTHON_REF = 'ghcr.io/boxlite-ai/boxlite-agent-python:v0.1.0'
12+
const NODE_REF = 'ghcr.io/boxlite-ai/boxlite-agent-node:v0.1.0'
1313

1414
describe('supported image allowlist', () => {
1515
const ENV_KEYS = [

apps/api/src/box/constants/curated-images.constant.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -36,17 +36,17 @@ const BUILTIN_IMAGE_SOURCES: BuiltinImageSource[] = [
3636
{
3737
name: 'base',
3838
envVar: 'BOXLITE_SYSTEM_BASE_IMAGE',
39-
fallbackRef: 'ghcr.io/boxlite-ai/boxlite-agent-base:20260605-p0-r3',
39+
fallbackRef: 'ghcr.io/boxlite-ai/boxlite-agent-base:v0.1.0',
4040
},
4141
{
4242
name: 'python',
4343
envVar: 'BOXLITE_SYSTEM_PYTHON_IMAGE',
44-
fallbackRef: 'ghcr.io/boxlite-ai/boxlite-agent-python:20260605-p0-r3',
44+
fallbackRef: 'ghcr.io/boxlite-ai/boxlite-agent-python:v0.1.0',
4545
},
4646
{
4747
name: 'node',
4848
envVar: 'BOXLITE_SYSTEM_NODE_IMAGE',
49-
fallbackRef: 'ghcr.io/boxlite-ai/boxlite-agent-node:20260605-p0-r3',
49+
fallbackRef: 'ghcr.io/boxlite-ai/boxlite-agent-node:v0.1.0',
5050
},
5151
]
5252

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,9 @@
11
#!/usr/bin/env bash
22
set -euo pipefail # Fail fast on command errors, unset variables, and broken pipes.
33

4-
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" # Repository root, also the Docker build context.
5-
VERSION_FILE="$ROOT_DIR/images/agent-runtime/VERSION" # Agent image release version source of truth.
4+
IMAGE_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" # Dockerfiles and VERSION live beside this script.
5+
ROOT_DIR="$(cd "$IMAGE_DIR/../.." && pwd)" # Repository root, also the Docker build context.
6+
VERSION_FILE="$IMAGE_DIR/VERSION" # Box image release version source of truth.
67

78
REGISTRY="${REGISTRY:-ghcr.io/boxlite-ai}" # Target registry namespace for the three image packages.
89
PLATFORMS="${PLATFORMS:-linux/amd64,linux/arm64}" # Default publish target covers Intel and ARM Linux hosts.
@@ -68,7 +69,7 @@ parse_platforms() { # Validate the comma-separated PLATFORMS input before any bu
6869
build_image() { # Build or publish one of base, python, or node with the shared version tag.
6970
local image="$1"
7071
local tag="$2"
71-
local dockerfile="$ROOT_DIR/images/agent-runtime/${image}.Dockerfile" # Dockerfile selected by image flavor.
72+
local dockerfile="$IMAGE_DIR/${image}.Dockerfile" # Dockerfile selected by image flavor.
7273
local target="$REGISTRY/boxlite-agent-${image}:$tag" # Existing GHCR package name plus version tag.
7374
local -a build_args=(buildx build --platform "$PLATFORMS" -f "$dockerfile" -t "$target") # Common Buildx arguments.
7475

0 commit comments

Comments
 (0)