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
44 changes: 33 additions & 11 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -185,34 +185,56 @@ jobs:
with:
fetch-depth: 2

- name: Build inference router image
- name: Log in to GHCR (for cached image pulls)
uses: docker/login-action@9780b0c442fbb1117ed29e0efdff1e18412f7567 # v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}

- name: Check if inference router image needs rebuild
id: router-check
run: |
if git diff --name-only HEAD~1 HEAD -- inference-router/ Cargo.toml Cargo.lock | grep -q .; then
echo "changed=true" >> "$GITHUB_OUTPUT"
else
echo "changed=false" >> "$GITHUB_OUTPUT"
fi

- name: Build inference router image (only when source changed)
if: steps.router-check.outputs.changed == 'true'
run: docker build -t azureclaw-inference-router:test -f inference-router/Dockerfile .

- name: Pull cached inference router image (when source unchanged)
if: steps.router-check.outputs.changed != 'true'
run: |
REPO_LOWER="${GITHUB_REPOSITORY,,}"
GHCR_IMG="ghcr.io/${REPO_LOWER}-inference-router:latest"
if docker pull "$GHCR_IMG" 2>/dev/null; then
echo "Pulled cached inference router from GHCR: $GHCR_IMG"
docker tag "$GHCR_IMG" azureclaw-inference-router:test
else
echo "::warning::No cached inference router image in GHCR — building locally"
docker build -t azureclaw-inference-router:test -f inference-router/Dockerfile .
fi

- name: Check if base image needs rebuild
id: base-check
run: |
if git diff --name-only HEAD~1 HEAD -- sandbox-images/openclaw/Dockerfile.base | grep -q .; then
if git diff --name-only HEAD~1 HEAD -- sandbox-images/openclaw/Dockerfile.base vendor/sandbox-wheels/ | grep -q .; then
echo "changed=true" >> "$GITHUB_OUTPUT"
else
echo "changed=false" >> "$GITHUB_OUTPUT"
fi

- name: Log in to GHCR (for cached base image pull)
if: steps.base-check.outputs.changed != 'true'
uses: docker/login-action@9780b0c442fbb1117ed29e0efdff1e18412f7567 # v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}

- name: Build sandbox base image (only when Dockerfile.base changed)
if: steps.base-check.outputs.changed == 'true'
run: docker build -t azureclaw-sandbox-base:test -f sandbox-images/openclaw/Dockerfile.base .

- name: Pull cached base image (when Dockerfile.base unchanged)
if: steps.base-check.outputs.changed != 'true'
run: |
# Pull from GHCR (published by sandbox-base-publish.yml on dev/main).
# Pull from GHCR (published by image-cache-publish.yml on dev/main).
# Falls back to local build only if the image isn't available yet
# (e.g. before the first publish run, or for forks without access).
REPO_LOWER="${GITHUB_REPOSITORY,,}"
Expand Down
103 changes: 103 additions & 0 deletions .github/workflows/image-cache-publish.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
name: Image Cache Publish

# Publishes the long-lived build artefacts (sandbox base image, inference router
# image, controller image) to GHCR so the Container Image Scan job in ci.yml
# can pull known-good prebuilt images instead of rebuilding every Rust/Docker
# layer from scratch on every PR. Public-CI rebuilds are slow and brittle —
# they transitively depend on upstream npm / crates.io / network availability.
#
# Visibility: each package should be marked PRIVATE in the GitHub UI under
# Packages → <name> → Package settings → Change visibility → Private. CI in
# this repo can still pull private GHCR images using the auto-provided
# GITHUB_TOKEN (no extra secrets needed).

on:
push:
branches: [dev, main]
paths:
- 'sandbox-images/openclaw/Dockerfile.base'
- 'vendor/sandbox-wheels/**'
- 'inference-router/**'
- 'controller/**'
- 'Cargo.toml'
- 'Cargo.lock'
- '.github/workflows/image-cache-publish.yml'
workflow_dispatch:

permissions:
contents: read
packages: write

jobs:
publish:
name: Build and publish ${{ matrix.image.name }}
runs-on: ubuntu-latest
timeout-minutes: 60
strategy:
fail-fast: false
matrix:
image:
- name: sandbox-base
suffix: -sandbox-base
dockerfile: sandbox-images/openclaw/Dockerfile.base
paths: 'sandbox-images/openclaw/Dockerfile.base vendor/sandbox-wheels/'
- name: inference-router
suffix: -inference-router
dockerfile: inference-router/Dockerfile
paths: 'inference-router/ Cargo.toml Cargo.lock'
- name: controller
suffix: -controller
dockerfile: controller/Dockerfile
paths: 'controller/ Cargo.toml Cargo.lock'
steps:
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
with:
fetch-depth: 2

- name: Skip when no relevant paths changed
id: changed
run: |
# On workflow_dispatch always run; on push only run if relevant paths changed.
if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then
echo "run=true" >> "$GITHUB_OUTPUT"
exit 0
fi
if git diff --name-only HEAD~1 HEAD -- ${{ matrix.image.paths }} | grep -q .; then
echo "run=true" >> "$GITHUB_OUTPUT"
else
echo "run=false" >> "$GITHUB_OUTPUT"
echo "::notice::Skipping ${{ matrix.image.name }} — no changes in ${{ matrix.image.paths }}"
fi

- name: Log in to GHCR
if: steps.changed.outputs.run == 'true'
uses: docker/login-action@9780b0c442fbb1117ed29e0efdff1e18412f7567 # v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}

- name: Compute image tags
if: steps.changed.outputs.run == 'true'
id: tags
run: |
REPO_LOWER="${GITHUB_REPOSITORY,,}"
IMG="ghcr.io/${REPO_LOWER}${{ matrix.image.suffix }}"
echo "image=$IMG" >> "$GITHUB_OUTPUT"
echo "sha_tag=$IMG:sha-${GITHUB_SHA::7}" >> "$GITHUB_OUTPUT"
echo "branch_tag=$IMG:${GITHUB_REF_NAME}" >> "$GITHUB_OUTPUT"
if [ "$GITHUB_REF_NAME" = "main" ] || [ "$GITHUB_REF_NAME" = "dev" ]; then
echo "latest_tag=$IMG:latest" >> "$GITHUB_OUTPUT"
fi

- name: Build and push ${{ matrix.image.name }}
if: steps.changed.outputs.run == 'true'
run: |
TAGS=( -t "${{ steps.tags.outputs.sha_tag }}" -t "${{ steps.tags.outputs.branch_tag }}" )
if [ -n "${{ steps.tags.outputs.latest_tag }}" ]; then
TAGS+=( -t "${{ steps.tags.outputs.latest_tag }}" )
fi
docker build "${TAGS[@]}" -f "${{ matrix.image.dockerfile }}" .
for tag in "${{ steps.tags.outputs.sha_tag }}" "${{ steps.tags.outputs.branch_tag }}" "${{ steps.tags.outputs.latest_tag }}"; do
[ -n "$tag" ] && docker push "$tag"
done
63 changes: 0 additions & 63 deletions .github/workflows/sandbox-base-publish.yml

This file was deleted.

32 changes: 32 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,38 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased] — Phase 2

### S19.b `phase2-ci-image-cache-router-controller` — extend GHCR cache to router + controller

#### Refactored

- `.github/workflows/sandbox-base-publish.yml` → `.github/workflows/image-cache-publish.yml`.
Generalised from a single-image publish to a 3-image matrix covering
`sandbox-base`, `inference-router`, and `controller`. Each matrix branch
conditionally runs only when its own paths changed (sandbox-base on
`Dockerfile.base` + `vendor/sandbox-wheels/`; inference-router on
`inference-router/` + `Cargo.{toml,lock}`; controller on `controller/` +
`Cargo.{toml,lock}`). Workflow-dispatch ignores the path filter.

#### Fixed

- `container-scan` job in `.github/workflows/ci.yml` now also pulls the
inference router image from GHCR when `inference-router/` and
`Cargo.{toml,lock}` are unchanged, falling back to local build only as
last resort. Matches the pattern already in place for the sandbox base
image. Cuts PR-time Rust rebuild waste significantly (router compile is
the longest individual step in the job).
- `sandbox-images/openclaw/Dockerfile.base` channel-dep sanity check
removed. The previous attempt to assert that
`/usr/local/lib/node_modules/openclaw/node_modules/{grammy,@discordjs/opus,
@slack/bolt,@larksuiteoapi/node-sdk}` exists was incorrect: in OpenClaw
2026.4.26 channel deps are *not* hoisted into the global tree at install
time — they live under per-extension `dist/extensions/<channel>/node_modules/`
and are surfaced via the `link_pkg` symlink block earlier in the same
Dockerfile. The build-time assertion produced false negatives. Replaced
with a simpler "trust openclaw doctor's exit code" approach: run
`set -o pipefail`, run `openclaw doctor --fix` without `|| true` mask,
log staging stats. Doctor's own success is the source of truth.

### S15.g.3 `phase2-cli-rename` — `@azure/azureclaw` → `@azureclaw/cli`

#### Refactored
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
# Phase 2 — S19.b CI Image Cache (Router + Controller)

**Date:** 2026-04-29
**Branch:** `phase2-ci-image-cache-router-controller`
**Slice:** S19.b (follow-up to S19)

## Scope

1. Generalise `.github/workflows/sandbox-base-publish.yml` →
`.github/workflows/image-cache-publish.yml`. Three-image matrix:
`sandbox-base`, `inference-router`, `controller`. Each branch is gated on
its own path filter so we don't rebuild all three on every commit.
2. Update `container-scan` in `.github/workflows/ci.yml` to also pull the
inference router image from GHCR (with local-build fallback), matching
the pattern already in place for the sandbox base image.
3. Fix the channel-dep sanity check in `sandbox-images/openclaw/Dockerfile.base`
that S19 introduced. The check was incorrect — channel deps in OpenClaw
2026.4.26 don't live where the check was looking. Replace with "trust
openclaw doctor's exit code".

## Rationale

S19 (PR #108) cut PR-time CI cost for the sandbox base image. Inference
router and controller have similar issues:

- Inference router is the second-biggest single-step cost in `container-scan`
(Rust + cargo build of an Axum app + cross-compile target).
- Controller is similar.

Both rebuild from scratch on every PR even when they haven't changed. Same
GHCR push/pull pattern fixes both.

The Dockerfile.base sanity check fix is needed because the S19 check was
returning false negatives — the `/usr/local/lib/node_modules/openclaw/node_modules/`
location I asserted on doesn't actually contain channel deps in OpenClaw
2026.4.26 (channel deps live under `dist/extensions/<channel>/node_modules/`
and are surfaced via the `link_pkg` symlink block at line 79-95). Doctor's
own exit code is the right source of truth.

## Security considerations

### Image cache publish workflow (matrix extension)

- Same security model as the original S19 workflow:
- Authentication via `GITHUB_TOKEN` (`packages: write`).
- Inference router image contains only the compiled Rust binary +
distroless base. No secrets, no Azure-specific configuration.
- Controller image is the same shape as inference router.
- Each package should be marked **private** in GHCR settings to preserve
current exposure surface.

### Container-scan job

- Adds `packages: read` permission (already present from S19).
- Logs into GHCR before the per-image conditional build/pull steps.
- Falls back to local build if no cached image exists.

### Dockerfile.base check change

- Removes the channel-dep filesystem assertion. This is a strict reduction
in build-time validation but the previous assertion was producing false
negatives, so it offered no real protection.
- Adds `set -o pipefail` so the `openclaw doctor … | tail -40` pipe
surfaces a non-zero exit code from doctor instead of hiding it behind
tail's success.
- Drops `|| true` mask. Real doctor failures will now fail the build.

## Verification

- This **is** the verification PR: container-scan must go green.
- The first push will trigger image-cache-publish.yml on the matrix to
prepare the GHCR cache for subsequent PRs.

## Files touched

- `.github/workflows/image-cache-publish.yml` (renamed from `sandbox-base-publish.yml`, generalised)
- `.github/workflows/ci.yml` (container-scan job: add router pull, add login, restructure)
- `sandbox-images/openclaw/Dockerfile.base` (drop false-negative sanity check)
- `CHANGELOG.md`
- `docs/security-audits/2026-04-29-phase2-ci-image-cache-router-controller-s19b.md` (this file)
Loading
Loading