Repository navigation
🐳 ci: Publish Code API Images to GHCR #3
Workflow file for this run
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| # 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 |