Skip to content
Open
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
92 changes: 72 additions & 20 deletions .github/workflows/images.yml
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,10 @@
#
# 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
# * push to main — build every image for linux/amd64 and linux/arm64 on a
# native runner each, push `sha-<commit>-<arch>` tags, and refresh each
# image's registry build cache. The merge job joins both architectures
# into the `sha-<commit>` manifest, and 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.
Expand Down Expand Up @@ -59,6 +61,7 @@ jobs:
runs-on: ubuntu-24.04
outputs:
images: ${{ steps.images.outputs.images }}
matrix: ${{ steps.images.outputs.matrix }}
publish: ${{ github.repository == 'LibreChat-AI/code-interpreter' && github.ref == 'refs/heads/main' }}
steps:
- name: List images
Expand Down Expand Up @@ -91,10 +94,19 @@ jobs:
)
echo "images=$images" >> "$GITHUB_OUTPUT"

# Every image is built for both architectures, each on a runner of
# that architecture: emulation would spend hours compiling the
# runner's Python and native wheels. `images` stays the per-image
# list the merge and promote jobs iterate.
matrix=$(jq -c '[ .[]
| (. + {platform: "amd64", runner: "ubuntu-24.04"}),
(. + {platform: "arm64", runner: "ubuntu-24.04-arm"}) ]' <<<"$images")
echo "matrix=$matrix" >> "$GITHUB_OUTPUT"

build:
name: ${{ matrix.image }}
name: ${{ matrix.image }} (${{ matrix.platform }})
needs: plan
runs-on: ubuntu-24.04
runs-on: ${{ matrix.runner }}
timeout-minutes: ${{ matrix.timeout }}
permissions:
contents: read
Expand All @@ -105,7 +117,7 @@ jobs:
strategy:
fail-fast: false
matrix:
include: ${{ fromJSON(needs.plan.outputs.images) }}
include: ${{ fromJSON(needs.plan.outputs.matrix) }}
steps:
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6
with:
Expand Down Expand Up @@ -148,12 +160,13 @@ jobs:

- 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.
# commit) builds only the image and architecture pairs 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 }}
REF: ${{ env.NAMESPACE }}/${{ matrix.image }}:sha-${{ github.sha }}-${{ matrix.platform }}
# A registry that cannot answer fails the job rather than risk
# rebuilding over a tag that may exist.
run: |
Expand Down Expand Up @@ -185,7 +198,7 @@ jobs:
DOCKER_METADATA_ANNOTATIONS_LEVELS: manifest,index
with:
images: ${{ env.NAMESPACE }}/${{ matrix.image }}
tags: type=sha,format=long
tags: type=sha,format=long,suffix=-${{ matrix.platform }}
labels: |
org.opencontainers.image.title=${{ matrix.image }}
org.opencontainers.image.description=${{ matrix.description }}
Expand All @@ -201,7 +214,7 @@ jobs:
context: .
file: ${{ matrix.dockerfile }}
target: ${{ matrix.target }}
platforms: linux/amd64
platforms: linux/${{ matrix.platform }}
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
Expand All @@ -210,30 +223,32 @@ jobs:
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) || '' }}
# A registry cache next to each image, one per architecture, 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-${{ matrix.platform }}
cache-to: ${{ needs.plan.outputs.publish == 'true' && format('type=registry,ref={0}/{1}:buildcache-{2},mode=max,ignore-error=true', env.NAMESPACE, matrix.image, matrix.platform) || '' }}

- name: Summarize
if: always()
env:
IMAGE_NAME: ${{ matrix.image }}
PLATFORM: ${{ matrix.platform }}
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"
echo "### $IMAGE_NAME ($PLATFORM)"
if [ "$EXISTED" = true ]; then
echo "\`$NAMESPACE/$IMAGE_NAME:sha-$GITHUB_SHA\` was already published; left unchanged."
echo "\`$NAMESPACE/$IMAGE_NAME:sha-$GITHUB_SHA-$PLATFORM\` 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 "Pushed \`$NAMESPACE/$IMAGE_NAME:sha-$GITHUB_SHA-$PLATFORM\`, pinnable as \`$NAMESPACE/$IMAGE_NAME@$DIGEST\`."
echo
fi
echo "Lowest free space on / while building: ${lowest:-unknown} MiB"
Expand All @@ -243,9 +258,46 @@ jobs:
echo '```'
} | tee -a "$GITHUB_STEP_SUMMARY"

merge:
name: Join architectures
needs: [plan, build]
if: needs.plan.outputs.publish == 'true'
runs-on: ubuntu-24.04
timeout-minutes: 15
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: Join the per-architecture tags into one manifest
# `sha-<commit>` is the tag hosts pull: a manifest listing both
# architectures, so Docker picks the one the host runs. It is written
# only after every leg of every image succeeded, and writing it again
# from the same per-architecture tags produces the same manifest, so a
# re-run is harmless.
env:
IMAGES: ${{ needs.plan.outputs.images }}
run: |
set -euo pipefail
while IFS=$'\t' read -r image description; do
ref="$NAMESPACE/$image"
docker buildx imagetools create --tag "$ref:sha-$GITHUB_SHA" \
--annotation "index:org.opencontainers.image.title=$image" \
--annotation "index:org.opencontainers.image.description=$description" \
--annotation "index:org.opencontainers.image.revision=$GITHUB_SHA" \
"$ref:sha-$GITHUB_SHA-amd64" \
"$ref:sha-$GITHUB_SHA-arm64"
echo "$ref:sha-$GITHUB_SHA <- amd64 + arm64" | tee -a "$GITHUB_STEP_SUMMARY"
done < <(jq -r '.[] | [.image, .description] | @tsv' <<<"$IMAGES")

promote:
name: Move main tags
needs: [plan, build]
needs: [plan, merge]
if: needs.plan.outputs.publish == 'true'
runs-on: ubuntu-24.04
timeout-minutes: 15
Expand Down
15 changes: 10 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -130,9 +130,9 @@ cut.

## Prebuilt images

The [Images workflow](.github/workflows/images.yml) builds one `linux/amd64`
image per Compose build and publishes it to GHCR whenever a push to `main`
changes an image input:
The [Images workflow](.github/workflows/images.yml) builds one image per
Compose build, for `linux/amd64` and `linux/arm64`, and publishes it to GHCR
whenever a push to `main` changes an image input:

| Image (`ghcr.io/librechat-ai/…`) | Dockerfile (target) | Compose service |
|---|---|---|
Expand All @@ -147,14 +147,19 @@ changes an image input:
Tags:

- `sha-<full commit SHA>` for every commit on `main` that changes an image
input. Commits that change none (docs, Helm, tests) get no tags, so take the
input: a manifest listing both architectures, so a host pulls the one it
runs. Commits that change none (docs, Helm, tests) get no tags, so take the
SHA from a successful Images run or the package page.
- `sha-<full commit SHA>-amd64` and `-arm64`, what each architecture's build
pushes before they are joined. Pull these only to force an architecture the
host does not run.
- `main`, pointed at the newest commit on `main` that has all seven images.
The seven tags move one after another, so pin `sha-` tags for deployments
and treat `main` as a convenience.

There is no `latest`. `sha-` tags are written once: re-running the workflow
for a commit builds only the images whose tag is missing. For exact bytes,
for a commit builds only the image and architecture pairs whose tag is
missing. For exact bytes,
replace a service's
`image:` below with `ghcr.io/librechat-ai/<image>@sha256:<digest>`; each image
has its own digest, listed in the summary of the run that pushed it.
Expand Down