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
20 changes: 20 additions & 0 deletions .github/workflows/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -195,6 +195,26 @@ the labeled head commit — re-label after new pushes.
See the **[E2E Local CI runbook](../../docs/ci/e2e-local.md)** for the jobs, the instance,
one-time provisioning (`scripts/ci/setup-ci-runner.sh`), and troubleshooting.

### `build-box-images.yml` / `release-box-images.yml`

The box images in `apps/box-images/` (`base`, `python`, `node`) carry their own `VERSION`,
independent of the product version, so build and release are split:

- **`build-box-images.yml`** validates. On PRs and `main` it builds all three flavors for
`linux/amd64` and `linux/arm64` with `PUSH=0`, which exercises every layer without
publishing. It holds `contents: read` only and has no registry login, so it cannot write
to GHCR.
- **`release-box-images.yml`** publishes, and is the only workflow that can. It runs on an
`apps/box-images/vMAJOR.MINOR.PATCH` tag — the same path-prefixed convention as the
`sdks/go/v*` tags — or on manual dispatch, and only from a release tag or `main`. The tag
must agree with `apps/box-images/VERSION`, and publishing aborts if that version already
exists on GHCR unless dispatched with `allow-overwrite`, so a rebuild cannot silently move
the tag a running box pulls. The existence check reads the registry API and branches on the
HTTP status: only a 404 counts as free, so a 5xx or an expired token stops the release
instead of reading as "not published".

Releasing is therefore an explicit tag; merging to `main` does not publish.

## Trigger Behavior

| Change | warm-caches | build-runtime | build-wheels | build-node |
Expand Down
48 changes: 48 additions & 0 deletions .github/workflows/build-box-images.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
name: Build Box Images

# Validation only — proves every flavor still builds for both architectures. Publishing lives in
# release-box-images.yml, triggered by an apps/box-images/v* tag, so exactly one workflow can write
# to GHCR and a Dockerfile edit can no longer move an already-published version tag.
on:
pull_request:
paths:
- 'apps/box-images/**' # Dockerfiles, VERSION and the build script define image contents.
- '.dockerignore' # Docker context changes can change what lands in the image.
- '.github/workflows/build-box-images.yml' # Workflow changes should validate themselves.
push:
branches: [main] # Catch anything that reached main without a PR run.
paths:
- 'apps/box-images/**'
- '.dockerignore'
- '.github/workflows/build-box-images.yml'
workflow_dispatch:

permissions:
contents: read # Checkout only needs repository read access; this job never pushes.

concurrency:
group: build-box-images-${{ github.ref }} # Serialize per branch/ref.
cancel-in-progress: true # A newer commit supersedes an in-flight validation build.

jobs:
build:
name: Build box images (no publish)
runs-on: ubuntu-latest

steps:
- name: Checkout code
uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # Pin checkout for supply-chain stability.
with:
persist-credentials: false # Later steps do not need git credentials.

- name: Set up QEMU
uses: docker/setup-qemu-action@c7c53464625b32c7a7e944ae62b3e17d2b600130 # Enable cross-arch build emulation.

- name: Set up Docker Buildx
uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # Buildx is required for multi-arch images.

- name: Build all flavors for both architectures
env:
PUSH: '0' # PUSH=0 with two platforms validates every build step without publishing.
PLATFORMS: linux/amd64,linux/arm64 # Both published architectures must keep building.
run: bash apps/box-images/build.sh
77 changes: 0 additions & 77 deletions .github/workflows/publish-boxlite-cloud-images.yml

This file was deleted.

172 changes: 172 additions & 0 deletions .github/workflows/release-box-images.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,172 @@
name: Release Box Images

# The only workflow that writes to GHCR. Driven by an `apps/box-images/vMAJOR.MINOR.PATCH` tag so
# the box images keep a release line independent of the product version (same convention as the
# existing `sdks/go/v*` tags). Publishing refuses to move a version tag that already exists, so a
# rebuild can never silently replace the bytes a running box pulls.
on:
push:
tags:
- 'apps/box-images/v*' # Path-prefixed tag keeps this release line separate from product v*.
workflow_dispatch:
inputs:
version:
description: 'Version to publish, with or without leading v. Defaults to apps/box-images/VERSION.'
required: false
type: string
allow-overwrite:
description: 'Republish even if the version tag already exists on GHCR. Moves a published tag.'
required: false
default: false
type: boolean

permissions:
contents: read # Checkout only needs repository read access.
packages: write # GHCR push requires package write access.

concurrency:
group: release-box-images # One release at a time, repository-wide.
cancel-in-progress: false # Never cancel an in-flight publish.

jobs:
release:
name: Publish box images
runs-on: ubuntu-latest
# The workflow this replaced allowed publishing only from main; workflow_dispatch can target
# any ref, so keep that restriction. A release tag is the other legitimate source.
if: github.ref_type == 'tag' || github.ref == 'refs/heads/main'
Comment on lines +32 to +37

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

Add timeout-minutes to bound a stuck release.

This job has no explicit timeout, so it inherits GitHub's 360-minute default. Combined with concurrency: { group: release-box-images, cancel-in-progress: false } (repo-wide, serialized), a hung build/push step (e.g. a stalled registry push or retry loop) blocks every subsequent release for up to 6 hours with no automatic recovery.

🔧 Proposed fix
   release:
     name: Publish box images
     runs-on: ubuntu-latest
+    timeout-minutes: 30
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
release:
name: Publish box images
runs-on: ubuntu-latest
# The workflow this replaced allowed publishing only from main; workflow_dispatch can target
# any ref, so keep that restriction. A release tag is the other legitimate source.
if: github.ref_type == 'tag' || github.ref == 'refs/heads/main'
release:
name: Publish box images
runs-on: ubuntu-latest
timeout-minutes: 30
# The workflow this replaced allowed publishing only from main; workflow_dispatch can target
# any ref, so keep that restriction. A release tag is the other legitimate source.
if: github.ref_type == 'tag' || github.ref == 'refs/heads/main'
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In @.github/workflows/release-box-images.yml around lines 32 - 37, Add a
suitable timeout-minutes value to the release job definition identified by
release in the workflow, bounding stalled build or push operations while
preserving the existing ref restriction and concurrency behavior.


steps:
- name: Checkout code
uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # Pin checkout for supply-chain stability.
with:
persist-credentials: false # Later steps do not need git credentials.

- name: Resolve version
id: version
env:
INPUT_VERSION: ${{ inputs.version }} # Via env, never interpolated into the script body.
TAG_NAME: ${{ github.ref_type == 'tag' && github.ref_name || '' }}
run: |
set -euo pipefail

file_version="$(tr -d '[:space:]' < apps/box-images/VERSION)"

if [ -n "${TAG_NAME:-}" ]; then
# Tag push: the tag is the request. apps/box-images/v1.2.3 -> 1.2.3
version="${TAG_NAME#apps/box-images/v}"
if [ "$version" = "$TAG_NAME" ]; then
echo "Tag '$TAG_NAME' is not of the form apps/box-images/vMAJOR.MINOR.PATCH" >&2
exit 1
fi
# A tag that disagrees with the committed VERSION means the release is ambiguous:
# the images would be built from a tree that does not describe itself as this version.
if [ "$version" != "$file_version" ]; then
echo "Tag version '$version' != apps/box-images/VERSION '$file_version'" >&2
echo "Fix the tag or the VERSION file so they agree, then re-tag." >&2
exit 1
fi
else
version="${INPUT_VERSION:-$file_version}"
version="${version#v}"
fi

if ! echo "$version" | grep -Eq '^[0-9]+[.][0-9]+[.][0-9]+(-[0-9A-Za-z][0-9A-Za-z.-]*)?$'; then
echo "Invalid version '$version'; expected MAJOR.MINOR.PATCH with optional -PRERELEASE" >&2
exit 1
fi

echo "tag=v$version" >> "$GITHUB_OUTPUT" # Docker tag shared by all three flavors.

# Asks the registry directly and branches on the HTTP status, because "the command failed"
# and "the tag is absent" are different answers: a 5xx, a rate limit or an expired token
# would otherwise read as absent and let the publish move a released tag. Only 404 is
# treated as free; anything unrecognized stops the release.
- name: Refuse to overwrite a published version
env:
TAG: ${{ steps.version.outputs.tag }} # Resolved above.
ALLOW_OVERWRITE: ${{ inputs.allow-overwrite }} # Dispatch-only escape hatch; empty on tag push.
GHCR_USER: ${{ github.actor }} # Basic-auth user for the GHCR token exchange.
GHCR_TOKEN: ${{ secrets.GITHUB_TOKEN }} # Built-in token; read access is enough here.
run: |
set -euo pipefail

if [ "${ALLOW_OVERWRITE:-false}" = "true" ]; then
echo "allow-overwrite=true — existing tags may be replaced."
exit 0
fi

published=""
for image in base python node; do
repo="boxlite-ai/boxlite-agent-${image}"

# -f rejects an error response, and `// empty` catches a 200 carrying no token, so an
# unusable exchange stops here rather than sending an empty bearer and reading as 401.
token="$(curl -fsSL -u "${GHCR_USER}:${GHCR_TOKEN}" \
"https://ghcr.io/token?service=ghcr.io&scope=repository:${repo}:pull" \
| jq -r '.token // empty')" || token=""
if [ -z "$token" ]; then
echo "Could not obtain a GHCR pull token for ${repo}; refusing to publish." >&2
exit 1
fi

status="$(curl -sS -o /dev/null -w '%{http_code}' \
-H "Authorization: Bearer ${token}" \
-H 'Accept: application/vnd.oci.image.index.v1+json' \
-H 'Accept: application/vnd.docker.distribution.manifest.list.v2+json' \
"https://ghcr.io/v2/${repo}/manifests/${TAG}")"

case "$status" in
200) published="${published}${published:+, }ghcr.io/${repo}:${TAG}" ;;
404) ;; # Not published — this flavor is free to take the tag.
*)
echo "Cannot tell whether ghcr.io/${repo}:${TAG} exists (HTTP ${status})." >&2
echo "Refusing to publish on an inconclusive check." >&2
exit 1
;;
esac
done

if [ -n "$published" ]; then
echo "Already published: $published" >&2
echo "Bump apps/box-images/VERSION and tag again, or re-run with allow-overwrite=true." >&2
exit 1
fi

- name: Log in to GHCR
uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # Authenticate Docker for GHCR reads and pushes.
with:
registry: ghcr.io # Target registry for BoxLite box images.
username: ${{ github.actor }} # GitHub actor is accepted for GITHUB_TOKEN auth.
password: ${{ secrets.GITHUB_TOKEN }} # Built-in token has packages:write from workflow permissions.

- name: Set up QEMU
uses: docker/setup-qemu-action@c7c53464625b32c7a7e944ae62b3e17d2b600130 # Enable cross-arch build emulation.

- name: Set up Docker Buildx
uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # Buildx is required for multi-arch images.

- name: Publish images
env:
TAG: ${{ steps.version.outputs.tag }} # Use the version resolved above.
PUSH: '1' # Publish to GHCR instead of building locally.
PLATFORMS: linux/amd64,linux/arm64 # Publish both supported CPU architectures.
run: bash apps/box-images/build.sh

- name: Record published digests
env:
TAG: ${{ steps.version.outputs.tag }} # Same tag that was just published.
run: |
set -euo pipefail

{
echo "### Published box images \`${TAG}\`"
echo
echo "| image | digest |"
echo "| --- | --- |"
for image in base python node; do
ref="ghcr.io/boxlite-ai/boxlite-agent-${image}:${TAG}"
digest="$(docker buildx imagetools inspect "$ref" --format '{{.Manifest.Digest}}')"
echo "| \`${ref}\` | \`${digest}\` |"
done
} >> "$GITHUB_STEP_SUMMARY"
6 changes: 3 additions & 3 deletions apps/api/src/box/constants/curated-images.constant.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,9 +7,9 @@
import { BadRequestError } from '../../exceptions/bad-request.exception'
import { assertSupportedImage, supportedImages } from './curated-images.constant'

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

describe('supported image allowlist', () => {
const ENV_KEYS = [
Expand Down
6 changes: 3 additions & 3 deletions apps/api/src/box/constants/curated-images.constant.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,17 +36,17 @@ const BUILTIN_IMAGE_SOURCES: BuiltinImageSource[] = [
{
name: 'base',
envVar: 'BOXLITE_SYSTEM_BASE_IMAGE',
fallbackRef: 'ghcr.io/boxlite-ai/boxlite-agent-base:20260605-p0-r3',
fallbackRef: 'ghcr.io/boxlite-ai/boxlite-agent-base:v0.1.0',
},
{
name: 'python',
envVar: 'BOXLITE_SYSTEM_PYTHON_IMAGE',
fallbackRef: 'ghcr.io/boxlite-ai/boxlite-agent-python:20260605-p0-r3',
fallbackRef: 'ghcr.io/boxlite-ai/boxlite-agent-python:v0.1.0',
},
{
name: 'node',
envVar: 'BOXLITE_SYSTEM_NODE_IMAGE',
fallbackRef: 'ghcr.io/boxlite-ai/boxlite-agent-node:20260605-p0-r3',
fallbackRef: 'ghcr.io/boxlite-ai/boxlite-agent-node:v0.1.0',
},
]

Expand Down
File renamed without changes.
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
#!/usr/bin/env bash
set -euo pipefail # Fail fast on command errors, unset variables, and broken pipes.

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

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

Expand Down
6 changes: 3 additions & 3 deletions apps/dashboard/src/components/Box/CreateBoxDialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -22,9 +22,9 @@ import { toast } from 'sonner'
const NAME_REGEX = /^[a-zA-Z0-9][a-zA-Z0-9._-]*$/

const SUPPORTED_BOX_IMAGES = [
{ id: 'base', name: 'Base', ref: 'ghcr.io/boxlite-ai/boxlite-agent-base:20260605-p0-r3', isDefault: true },
{ id: 'python', name: 'Python', ref: 'ghcr.io/boxlite-ai/boxlite-agent-python:20260605-p0-r3', isDefault: false },
{ id: 'node', name: 'Node.js', ref: 'ghcr.io/boxlite-ai/boxlite-agent-node:20260605-p0-r3', isDefault: false },
{ id: 'base', name: 'Base', ref: 'ghcr.io/boxlite-ai/boxlite-agent-base:v0.1.0', isDefault: true },
{ id: 'python', name: 'Python', ref: 'ghcr.io/boxlite-ai/boxlite-agent-python:v0.1.0', isDefault: false },
{ id: 'node', name: 'Node.js', ref: 'ghcr.io/boxlite-ai/boxlite-agent-node:v0.1.0', isDefault: false },
] as const

const DEFAULTS = { cpu: 1, memory: 1, disk: 10, autoPauseIntervalSeconds: 900, autoDelete: 0 }
Expand Down
Loading
Loading