evOo-local/.github/workflows/publish-server-image.yml
cynarAI 634b3f823b
ci: stop image publish legs from cancelling each other (#191)
Every main push since the concurrency change cancelled two of the three
image builds: the build job's concurrency group named only the ref, so
the app, updater and computer legs of one run shared it and cancelled
each other, and the publish job never ran. Main has not published an
edge image since. The build and publish groups now name the matrix leg.

The Playwright report publisher also read the jobs of a run's latest
attempt, so a re-run already in progress made it fail on an empty
conclusion. It now reads the attempt that finished.


Claude-Session: https://claude.ai/code/session_01R8L4XgqjAemFR2remB5hVY

Co-authored-by: Claude <noreply@anthropic.com>
2026-10-10 06:50:07 +02:00

377 lines
16 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.
# Self-hosted evo runners on one host share a Docker daemon and often ~/.docker; parallel matrix
# jobs must use DOCKER_CONFIG under RUNNER_TEMP plus a per-job buildx builder name wired into
# build-push-action, or setup-buildx --use races and one job's post cleanup stops another's BuildKit.
# `ci-<run>-<image>-<arch>` handoff tags are extra aliases on the per-arch digest; GHCR does not
# expose a safe tag-only delete API (DELETE /v2/.../manifests/<tag> is unsupported or digest-scoped),
# so they are left in place and do not affect edge/sha-* manifests assembled in publish.
on:
push:
branches: [main]
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"
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.event_name == 'pull_request' && format('pr-{0}', github.event.pull_request.number) || github.ref }}
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
jobs:
changes:
name: Detect image publish inputs
runs-on: [self-hosted, evoo-ci]
outputs:
publish: ${{ steps.decide.outputs.publish }}
steps:
- uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 # v5.1.0
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
with:
persist-credentials: false
fetch-depth: 0
- uses: dorny/paths-filter@de90cc6fb38fc0963ad72b210f1f284cd68cea36 # v3.0.2
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
id: filter
with:
filters: |
publish:
- '.dockerignore'
- '.github/workflows/publish-server-image.yml'
- 'apps/**'
- 'infra/compose/**'
- 'infra/sandboxes/computer/**'
- 'infra/updater/**'
- 'packages/**'
- 'package.json'
- 'pnpm-lock.yaml'
- 'pnpm-workspace.yaml'
- name: Decide whether release jobs should run
id: decide
run: |
set -euo pipefail
if [[ "${{ github.event_name }}" == "workflow_dispatch" ]] || [[ "${{ github.ref }}" == refs/tags/v* ]]; then
echo "publish=true" >> "$GITHUB_OUTPUT"
elif [[ "${{ github.event_name }}" == "pull_request" ]]; then
echo "publish=true" >> "$GITHUB_OUTPUT"
elif [[ "${{ steps.filter.outputs.publish }}" == "true" ]]; then
echo "publish=true" >> "$GITHUB_OUTPUT"
else
echo "publish=false" >> "$GITHUB_OUTPUT"
fi
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
- name: Prepare isolated Docker config directory
run: |
set -euo pipefail
cfg="${RUNNER_TEMP}/docker-config"
mkdir -p "$cfg"
echo "DOCKER_CONFIG=${cfg}" >> "$GITHUB_ENV"
- id: buildx
uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3.12.0
with:
name: ci-pr-${{ github.event.pull_request.number }}-${{ matrix.name }}
- 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
with:
builder: ${{ steps.buildx.outputs.name }}
context: ${{ matrix.context }}
file: ${{ matrix.dockerfile }}
push: false
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
build:
name: Build ${{ matrix.name }} ${{ matrix.arch }}
needs: changes
if: github.event_name != 'pull_request' && needs.changes.outputs.publish == 'true'
# 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
concurrency:
group: publish-server-image-build-${{ github.ref }}-${{ matrix.name }}-${{ matrix.arch }}
cancel-in-progress: ${{ github.ref == 'refs/heads/main' }}
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
- name: Prepare isolated Docker config directory
run: |
set -euo pipefail
cfg="${RUNNER_TEMP}/docker-config"
mkdir -p "$cfg"
echo "DOCKER_CONFIG=${cfg}" >> "$GITHUB_ENV"
- id: buildx
uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3.12.0
with:
name: ci-${{ github.run_id }}-${{ github.run_attempt }}-${{ matrix.name }}-${{ matrix.arch }}
- 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:
builder: ${{ steps.buildx.outputs.name }}
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.changes.outputs.publish == 'true'
needs: [changes, build]
runs-on: [self-hosted, evoo-ci, Linux]
timeout-minutes: 15
concurrency:
group: publish-server-image-publish-${{ github.ref == 'refs/heads/main' && 'main' || github.ref }}-${{ matrix.name }}
cancel-in-progress: false
permissions:
contents: read
packages: write
id-token: write
attestations: write
strategy:
fail-fast: false
matrix:
include:
- name: app
- name: updater
- name: computer
steps:
- name: Prepare isolated Docker config directory
run: |
set -euo pipefail
cfg="${RUNNER_TEMP}/docker-config"
mkdir -p "$cfg"
echo "DOCKER_CONFIG=${cfg}" >> "$GITHUB_ENV"
- id: buildx
uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3.12.0
with:
name: ci-${{ github.run_id }}-${{ github.run_attempt }}-publish-${{ matrix.name }}
- 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