Skip to content

🐳 ci: Publish Code API Images to GHCR #3

🐳 ci: Publish Code API Images to GHCR

🐳 ci: Publish Code API Images to GHCR #3

Workflow file for this run

# Builds the Code API container images and publishes them to GHCR, so a host
# can pull a tested image instead of compiling one in place. Like ci.yml this
# file is inert inside the monorepo — GitHub only runs workflows from the repo
# root — and becomes a root workflow in the published repo.
#
# Entry points:
#
# * push to main — build every image, push `sha-<commit>` tags, and refresh
# each image's registry build cache. The promote job then points the
# `main` tags at the newest main commit that has all images.
# * pull_request — build every image without pushing, reading the cache that
# main wrote, so Dockerfile breakage surfaces before merge.
# * workflow_dispatch — on main, the same as a push; on any other branch, a
# build-only run.
#
# The image list lives in the plan job and feeds both the build matrix and the
# promotion, so the two cannot drift apart. README.md ("Prebuilt images")
# documents the tags and how a Compose host switches to them.
name: Images
on:
push:
branches: [main]
# Everything the Dockerfiles below COPY from the build context, plus this
# workflow. Every run builds every image, so a commit that has tags has
# them for all images; unchanged images are cache hits.
paths: &image-inputs
- .dockerignore
- .github/workflows/images.yml
- api/**
- docker/**
- javascript-packages.txt
- launcher/**
- packages/code/src/**
- service/**
- shared/**
pull_request:
paths: *image-inputs
workflow_dispatch:
permissions:
contents: read
concurrency:
# Each main commit gets its own group and is never cancelled, so no commit's
# build can be dropped by a later queued run (GitHub keeps only one pending
# run per group). Other refs share one group per ref and cancel superseded
# runs.
group: images-${{ github.ref == 'refs/heads/main' && github.sha || github.ref }}
cancel-in-progress: ${{ github.ref != 'refs/heads/main' }}
env:
NAMESPACE: ghcr.io/librechat-ai
jobs:
plan:
name: Plan
runs-on: ubuntu-24.04
outputs:
images: ${{ steps.images.outputs.images }}
publish: ${{ github.repository == 'LibreChat-AI/code-interpreter' && github.ref == 'refs/heads/main' }}
steps:
- name: List images
id: images
# One image per distinct Compose build in docker-compose.yaml. The
# sandbox runner's two Compose targets (KVM_ENABLED=true/false) are
# published as two images. The Lambda MicroVM targets are not built
# here; they are not used by Compose hosts.
run: |
images=$(jq -c . <<'JSON'
[
{"image": "code-interpreter-api", "dockerfile": "service/Dockerfile", "target": "api",
"description": "Code API HTTP server (Compose service api)", "timeout": 30},
{"image": "code-interpreter-worker", "dockerfile": "service/Dockerfile", "target": "worker",
"description": "Code API job worker (Compose service service-worker)", "timeout": 30},
{"image": "code-interpreter-file-server", "dockerfile": "service/Dockerfile", "target": "production",
"description": "Code API file server (Compose service file_server)", "timeout": 30},
{"image": "code-interpreter-egress-gateway", "dockerfile": "service/Dockerfile.egress-gateway", "target": "production",
"description": "Sandbox egress gateway (Compose service egress_gateway)", "timeout": 30},
{"image": "code-interpreter-tool-call-server", "dockerfile": "service/Dockerfile.tool-call-server", "target": "production",
"description": "Tool call server (Compose service tool_call_server)", "timeout": 30},
{"image": "code-interpreter-sandbox-runner", "dockerfile": "api/Dockerfile", "target": "sandbox-runner-true",
"description": "libkrun microVM sandbox runner with the guest rootfs and runtime packages baked in (Compose service sandbox-runner, KVM_ENABLED=true)",
"timeout": 60},
{"image": "code-interpreter-sandbox-runner-direct", "dockerfile": "api/Dockerfile", "target": "sandbox-runner-false",
"description": "Directory-root sandbox runner; runtime packages are mounted from the host (Compose service sandbox-runner, KVM_ENABLED=false)",
"timeout": 30}
]
JSON
)
echo "images=$images" >> "$GITHUB_OUTPUT"
build:
name: ${{ matrix.image }}
needs: plan
runs-on: ubuntu-24.04
timeout-minutes: ${{ matrix.timeout }}
permissions:
contents: read
# Pushes happen only when needs.plan.outputs.publish is true (main).
# Pull request runs read the build cache and never push; GitHub caps a
# fork's token at read access regardless.
packages: write
strategy:
fail-fast: false
matrix:
include: ${{ fromJSON(needs.plan.outputs.images) }}
steps:
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6
with:
persist-credentials: false
- name: Sample free disk
# A cold KVM runner build peaks at roughly 17 GB of builder state, well
# inside the hosted runner's free space, so no preinstalled toolchains
# are removed. The sampler keeps that margin visible in the summary.
run: |
nohup bash -c 'while :; do df -BM --output=avail / | tail -n1 | tr -dc 0-9; echo; sleep 10; done' \
>"$RUNNER_TEMP/disk-avail-mib.log" 2>/dev/null &
- uses: docker/setup-buildx-action@f87e5991a6d7451dcb8d9637bfbc97413f497069 # v4.4.1
- name: Log in to GHCR
# Pull request runs log in only to read the cache, and only from this
# repository; fork runs read public packages anonymously.
if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository
uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ github.token }}
- name: Check for an existing tag
# `sha-` tags are written once. A re-run (or a dispatch on the same
# commit) builds only the images whose tag is missing, so a failed
# re-run cannot leave the commit's set mixed across two builds.
id: existing
if: needs.plan.outputs.publish == 'true'
env:
REF: ${{ env.NAMESPACE }}/${{ matrix.image }}:sha-${{ github.sha }}
run: |
for attempt in 1 2 3; do
if revision=$(docker buildx imagetools inspect "$REF" --format '{{ json .Image }}' |
jq -er 'if has("config") then . else .["linux/amd64"] end
| .config.Labels["org.opencontainers.image.revision"]'); then
if [ "$revision" != "$GITHUB_SHA" ]; then
echo "::error::$REF records revision $revision"
exit 1
fi
echo "$REF already exists; not rebuilding it."
echo "exists=true" >> "$GITHUB_OUTPUT"
exit 0
fi
sleep 2
done
echo "exists=false" >> "$GITHUB_OUTPUT"
- name: Image metadata
id: meta
if: steps.existing.outputs.exists != 'true'
uses: docker/metadata-action@dc802804100637a589fabce1cb79ff13a1411302 # v6.2.0
env:
DOCKER_METADATA_ANNOTATIONS_LEVELS: manifest,index
with:
images: ${{ env.NAMESPACE }}/${{ matrix.image }}
tags: type=sha,format=long
labels: |
org.opencontainers.image.title=${{ matrix.image }}
org.opencontainers.image.description=${{ matrix.description }}
annotations: |
org.opencontainers.image.title=${{ matrix.image }}
org.opencontainers.image.description=${{ matrix.description }}
- name: Build${{ needs.plan.outputs.publish == 'true' && ' and push' || '' }}
id: build
if: steps.existing.outputs.exists != 'true'
uses: docker/build-push-action@c3c9e263c25d99ce0380d002d59b67737d91b0dc # v7.4.0
with:
context: .
file: ${{ matrix.dockerfile }}
target: ${{ matrix.target }}
platforms: linux/amd64
push: ${{ needs.plan.outputs.publish == 'true' }}
# Without a push, keep the result in the builder only: nothing is
# loaded into the runner's Docker engine, which would need a second
# copy of the multi-gigabyte sandbox image on disk.
outputs: ${{ needs.plan.outputs.publish != 'true' && 'type=cacheonly' || '' }}
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
annotations: ${{ steps.meta.outputs.annotations }}
# A registry cache next to each image, written only from main.
# mode=max keeps the intermediate stages (the KVM runner's package
# stage above all), which the GitHub Actions cache could not hold:
# its 10 GB repository limit would evict them between pushes.
cache-from: type=registry,ref=${{ env.NAMESPACE }}/${{ matrix.image }}:buildcache
cache-to: ${{ needs.plan.outputs.publish == 'true' && format('type=registry,ref={0}/{1}:buildcache,mode=max,ignore-error=true', env.NAMESPACE, matrix.image) || '' }}
- name: Summarize
if: always()
env:
IMAGE_NAME: ${{ matrix.image }}
PUSHED: ${{ needs.plan.outputs.publish == 'true' && steps.build.outcome == 'success' }}
EXISTED: ${{ steps.existing.outputs.exists == 'true' }}
DIGEST: ${{ steps.build.outputs.digest }}
run: |
lowest=$(sort -n "$RUNNER_TEMP/disk-avail-mib.log" 2>/dev/null | head -n1)
{
echo "### $IMAGE_NAME"
if [ "$EXISTED" = true ]; then
echo "\`$NAMESPACE/$IMAGE_NAME:sha-$GITHUB_SHA\` was already published; left unchanged."
echo
fi
if [ "$PUSHED" = true ]; then
echo "Pushed \`$NAMESPACE/$IMAGE_NAME:sha-$GITHUB_SHA\`, pinnable as \`$NAMESPACE/$IMAGE_NAME@$DIGEST\`."
echo
fi
echo "Lowest free space on / while building: ${lowest:-unknown} MiB"
echo '```'
df -h /
docker buildx du 2>/dev/null | tail -n1 || true
echo '```'
} | tee -a "$GITHUB_STEP_SUMMARY"
promote:
name: Move main tags
needs: [plan, build]
if: needs.plan.outputs.publish == 'true'
runs-on: ubuntu-24.04
timeout-minutes: 15
# Promotions run one at a time. Each resolves its target when it runs
# instead of trusting its own commit, so a stale re-run, or a queued
# promotion that GitHub replaces, cannot leave `main` behind or move it
# backwards.
concurrency:
group: images-promote
cancel-in-progress: false
permissions:
contents: read
packages: write
steps:
- uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ github.token }}
- name: Point main at the newest complete commit
# Target: the newest of the last 100 commits on main for which every
# image has a `sha-` tag. No image's `main` moves to an older commit
# than the one it names, so an inspection error can delay a promotion
# but never reverse one. A registry cannot move several repositories'
# tags in one transaction, so a failure part-way leaves some `main`
# tags behind; the next promotion, or a re-run of this job, finishes
# the move. Deployments should pin `sha-` tags rather than `main`.
env:
GH_TOKEN: ${{ github.token }}
IMAGES: ${{ needs.plan.outputs.images }}
run: |
set -euo pipefail
# Prints the revision a tag records; fails if the tag cannot be read
# after one retry (missing, or the registry is unreachable).
revision_of() {
local attempt
for attempt in 1 2; do
docker buildx imagetools inspect "$1" --format '{{ json .Image }}' 2>/dev/null |
jq -er 'if has("config") then . else .["linux/amd64"] end
| .config.Labels["org.opencontainers.image.revision"]' && return 0
sleep 2
done
return 1
}
complete() {
local image
for image in "${images[@]}"; do
[ "$(revision_of "$NAMESPACE/$image:sha-$1" || true)" = "$1" ] || return 1
done
}
retag() {
local attempt
for attempt in 1 2 3; do
docker buildx imagetools create --tag "$1:main" "$1:sha-$2" && return 0
sleep $(( attempt * 10 ))
done
return 1
}
mapfile -t images < <(jq -r '.[].image' <<<"$IMAGES")
mapfile -t commits < <(gh api "repos/$GITHUB_REPOSITORY/commits?sha=main&per_page=100" --jq '.[].sha')
declare -A position
for i in "${!commits[@]}"; do position[${commits[$i]}]=$i; done
target=''
for sha in "${commits[@]}"; do
if complete "$sha"; then
target=$sha
break
fi
done
if [ -z "$target" ]; then
echo "::error::None of the last ${#commits[@]} commits on main has all images."
exit 1
fi
for image in "${images[@]}"; do
ref="$NAMESPACE/$image"
current=$(revision_of "$ref:main" || true)
if [ "$current" = "$target" ]; then
echo "$ref:main already at sha-$target" | tee -a "$GITHUB_STEP_SUMMARY"
continue
fi
if [ -n "$current" ] && [ -n "${position[$current]:-}" ] &&
[ "${position[$current]}" -lt "${position[$target]}" ]; then
echo "::warning::$ref:main names $current, newer than $target; leaving it."
continue
fi
retag "$ref" "$target"
echo "$ref:main -> sha-$target" | tee -a "$GITHUB_STEP_SUMMARY"
done