evOo-local/.github/workflows/publish-server-image.yml
Cursor Agent f96a707cca
ci: tag loaded computer image before tooling smoke
Co-authored-by: cynarAI <cynarAI@users.noreply.github.com>
2026-10-07 21:41:56 +00:00

309 lines
13 KiB
YAML

name: publish-server-image
# Release tags publish the source-addressed image used by in-app updates plus the human-readable
# semver tags. Main publishes `edge`. Pull requests build with a read-only token and an isolated
# cache, so untrusted code can validate the images without receiving package-publish authority.
on:
push:
branches: [main]
tags: ["v*"]
pull_request:
paths:
- ".dockerignore"
- ".github/workflows/publish-server-image.yml"
- "apps/**"
- "infra/compose/**"
- "infra/sandboxes/computer/**"
- "infra/updater/**"
- "packages/**"
- "package.json"
- "pnpm-lock.yaml"
- "pnpm-workspace.yaml"
workflow_dispatch:
permissions:
contents: read
env:
# docker/metadata-action otherwise truncates commit tags to seven collision-prone characters.
DOCKER_METADATA_SHORT_SHA_LENGTH: 40
# build-push-action v6 uploads large *.dockerbuild records by default; publish reads digests from GHCR handoff tags.
DOCKER_BUILD_RECORD_UPLOAD: false
concurrency:
group: publish-server-image-${{ github.ref }}
cancel-in-progress: false
jobs:
installer-smoke:
name: Compose installer smokes
if: github.event_name == 'pull_request'
runs-on: [self-hosted, evoo-ci, Linux]
timeout-minutes: 5
permissions:
contents: read
steps:
- uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 # v5.1.0
with:
persist-credentials: false
- run: bash infra/compose/run-smokes.sh
validate:
name: Validate ${{ matrix.name }} image
if: github.event_name == 'pull_request'
runs-on: [self-hosted, evoo-ci, Linux]
timeout-minutes: 90
permissions:
contents: read
strategy:
fail-fast: false
matrix:
include:
- name: app
dockerfile: infra/compose/Dockerfile
context: .
- name: updater
dockerfile: infra/updater/Dockerfile
context: .
- name: computer
dockerfile: infra/sandboxes/computer/Dockerfile
context: infra/sandboxes/computer
steps:
- uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 # v5.1.0
with:
persist-credentials: false
- uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3.12.0
- name: Resolve the lowercase image namespace
id: ns
# GHCR rejects repository names with capitals; the GitHub repository slug has them.
run: echo "repo=${GITHUB_REPOSITORY,,}" >> "$GITHUB_OUTPUT"
- id: meta
uses: docker/metadata-action@c299e40c65443455700f0fdfc63efafe5b349051 # v5.10.0
with:
images: ghcr.io/${{ steps.ns.outputs.repo }}/${{ matrix.name }}
tags: type=sha,prefix=sha-
- uses: docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8 # v6.19.2
id: build
with:
context: ${{ matrix.context }}
file: ${{ matrix.dockerfile }}
push: false
load: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
build-args: ${{ matrix.name == 'app' && format('GIT_SHA={0}', github.sha) || '' }}
cache-from: type=gha,scope=${{ matrix.name }}
cache-to: type=gha,mode=max,scope=pr-${{ github.event.pull_request.number }}-${{ matrix.name }}
provenance: false
sbom: false
- name: Tag built computer image for tooling test
if: matrix.name == 'computer'
env:
TAGS: ${{ steps.meta.outputs.tags }}
IMAGE: ghcr.io/${{ steps.ns.outputs.repo }}/computer:sha-${{ github.sha }}
run: |
set -euo pipefail
src_tag="$(printf '%s\n' "$TAGS" | sed '/^[[:space:]]*$/d' | head -1)"
docker tag "$src_tag" "$IMAGE"
- name: Verify computer image tooling
if: matrix.name == 'computer'
env:
RAKAZO_COMPUTER_IMAGE: ghcr.io/${{ steps.ns.outputs.repo }}/computer:sha-${{ github.sha }}
run: bash infra/sandboxes/computer/computer-image-tools.test.sh
build:
name: Build ${{ matrix.name }} ${{ matrix.arch }}
if: github.event_name != 'pull_request'
# Each architecture builds natively on its own runner and pushes by digest only.
# amd64 on evo Linux runners. arm64 is paused: the macOS runners have no Docker buildx,
# and QEMU emulation took hours for the desktop-sized computer image.
# See docs/self-host.md (Published images and tags).
runs-on: ${{ matrix.runner }}
timeout-minutes: 90
permissions:
contents: read
packages: write
strategy:
fail-fast: false
matrix:
name: [app, updater, computer]
arch: [amd64]
include:
# Only the updater image contains the Docker CLI; the application image stays unprivileged.
# The supervisor is not a separate published image: it runs from `app` on the internal network.
- name: app
dockerfile: infra/compose/Dockerfile
context: .
- name: updater
dockerfile: infra/updater/Dockerfile
context: .
- name: computer
dockerfile: infra/sandboxes/computer/Dockerfile
context: infra/sandboxes/computer
- arch: amd64
runner: [self-hosted, evoo-ci, Linux]
steps:
- uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 # v5.1.0
with:
persist-credentials: false
- uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3.12.0
- name: Log in to GHCR
uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3.7.0
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Resolve the lowercase image namespace
id: ns
# GHCR rejects repository names with capitals; the GitHub repository slug has them.
run: echo "repo=${GITHUB_REPOSITORY,,}" >> "$GITHUB_OUTPUT"
- id: meta
uses: docker/metadata-action@c299e40c65443455700f0fdfc63efafe5b349051 # v5.10.0
with:
# Labels only; the tagged manifest is assembled in the publish job.
images: ghcr.io/${{ steps.ns.outputs.repo }}/${{ matrix.name }}
tags: type=sha,prefix=sha-
- id: build
uses: docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8 # v6.19.2
with:
context: ${{ matrix.context }}
file: ${{ matrix.dockerfile }}
platforms: linux/${{ matrix.arch }}
labels: ${{ steps.meta.outputs.labels }}
# A fork can publish only to its own namespace; no credential can redirect this value.
outputs: type=image,name=ghcr.io/${{ steps.ns.outputs.repo }}/${{ matrix.name }},push-by-digest=true,name-canonical=true,push=true
# GET /health can report the exact source commit without a deployment-supplied override.
build-args: ${{ matrix.name == 'app' && format('GIT_SHA={0}', github.sha) || '' }}
cache-from: type=gha,scope=${{ matrix.name }}-${{ matrix.arch }}
cache-to: type=gha,mode=max,scope=${{ matrix.name }}-${{ matrix.arch }}
provenance: mode=max
sbom: true
- name: Tag digest for publish job handoff
env:
DIGEST: ${{ steps.build.outputs.digest }}
IMAGE: ghcr.io/${{ steps.ns.outputs.repo }}/${{ matrix.name }}
run: |
set -euo pipefail
docker buildx imagetools create \
-t "${IMAGE}:ci-${GITHUB_RUN_ID}-${{ matrix.name }}-${{ matrix.arch }}" \
"${IMAGE}@${DIGEST}"
publish:
name: Publish ${{ matrix.name }} image
if: github.event_name != 'pull_request'
needs: build
runs-on: [self-hosted, evoo-ci, Linux]
timeout-minutes: 15
permissions:
contents: read
packages: write
id-token: write
attestations: write
strategy:
fail-fast: false
matrix:
include:
- name: app
- name: updater
- name: computer
steps:
- uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3.12.0
- name: Log in to GHCR
uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3.7.0
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Resolve the lowercase image namespace
id: ns
# GHCR rejects repository names with capitals; the GitHub repository slug has them.
run: echo "repo=${GITHUB_REPOSITORY,,}" >> "$GITHUB_OUTPUT"
- name: Collect architecture digests from registry handoff tags
id: digest_dir
env:
IMAGE: ghcr.io/${{ steps.ns.outputs.repo }}/${{ matrix.name }}
IMAGE_NAME: ${{ matrix.name }}
run: |
set -euo pipefail
digest_dir="${RUNNER_TEMP}/digests"
mkdir -p "$digest_dir"
for arch in amd64; do
handoff="${IMAGE}:ci-${GITHUB_RUN_ID}-${IMAGE_NAME}-${arch}"
digest="$(docker buildx imagetools inspect "$handoff" --format '{{.Manifest.Digest}}')"
touch "${digest_dir}/${digest#sha256:}"
done
echo "path=${digest_dir}" >> "$GITHUB_OUTPUT"
- id: meta
uses: docker/metadata-action@c299e40c65443455700f0fdfc63efafe5b349051 # v5.10.0
with:
# A fork can publish only to its own namespace; no credential can redirect this value.
images: ghcr.io/${{ steps.ns.outputs.repo }}/${{ matrix.name }}
tags: |
type=semver,pattern=v{{version}}
type=semver,pattern=v{{major}}.{{minor}}
type=sha,prefix=sha-
type=raw,value=edge,enable=${{ github.ref == 'refs/heads/main' }}
# Stable releases only: a hyphen marks a prerelease (v1.0.0-rc.1) that must not move latest.
type=raw,value=latest,enable=${{ startsWith(github.ref, 'refs/tags/v') && !contains(github.ref, '-') }}
- name: Assemble and verify the manifest
id: manifest
working-directory: ${{ steps.digest_dir.outputs.path }}
env:
IMAGE: ghcr.io/${{ steps.ns.outputs.repo }}/${{ matrix.name }}
TAGS: ${{ steps.meta.outputs.tags }}
run: |
set -euo pipefail
tag_args=()
while IFS= read -r tag; do
[[ -n "$tag" ]] && tag_args+=(-t "$tag")
done <<< "$TAGS"
sources=()
for digest in *; do
sources+=("${IMAGE}@sha256:${digest}")
done
if [[ "${#sources[@]}" -ne 1 ]]; then
echo "Expected one digest per architecture, found ${#sources[@]}." >&2
exit 1
fi
docker buildx imagetools create "${tag_args[@]}" "${sources[@]}"
first_tag="$(head -n 1 <<< "$TAGS")"
platforms="$(docker buildx imagetools inspect "$first_tag" \
--format '{{range .Manifest.Manifests}}{{.Platform.OS}}/{{.Platform.Architecture}}{{"\n"}}{{end}}')"
for want in linux/amd64; do
grep -Fqx "$want" <<< "$platforms" || { echo "$first_tag is missing $want" >&2; exit 1; }
done
echo "digest=$(docker buildx imagetools inspect "$first_tag" --format '{{.Manifest.Digest}}')" >> "$GITHUB_OUTPUT"
# Owner type is not exposed on the github context; resolve it from the repository metadata API.
- name: Resolve registry attestation eligibility
id: attestation
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
set -euo pipefail
owner_type="$(gh api "repos/${GITHUB_REPOSITORY}" --jq '.owner.type')"
private="$(gh api "repos/${GITHUB_REPOSITORY}" --jq '.private')"
if [[ "$owner_type" == "User" && "$private" == "true" ]]; then
echo "enabled=false" >> "$GITHUB_OUTPUT"
echo "Skipping registry attestation: unavailable for private repositories on personal accounts."
else
echo "enabled=true" >> "$GITHUB_OUTPUT"
fi
- name: Attest the published image
if: steps.attestation.outputs.enabled == 'true'
uses: actions/attest-build-provenance@4d101475d8b20a2381f78447822ac1eab6504dd8 # v4.2.2
with:
subject-name: ghcr.io/${{ steps.ns.outputs.repo }}/${{ matrix.name }}
subject-digest: ${{ steps.manifest.outputs.digest }}
push-to-registry: true
- name: Remove CI handoff tags
continue-on-error: true
env:
IMAGE: ghcr.io/${{ steps.ns.outputs.repo }}/${{ matrix.name }}
IMAGE_NAME: ${{ matrix.name }}
run: |
set -euo pipefail
for arch in amd64; do
docker buildx imagetools rm "${IMAGE}:ci-${GITHUB_RUN_ID}-${IMAGE_NAME}-${arch}"
done