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>
377 lines
16 KiB
YAML
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
|