Skip to content

ci: publish multi-platform production image to GHCR #12

ci: publish multi-platform production image to GHCR

ci: publish multi-platform production image to GHCR #12

Workflow file for this run

name: Release
on:
push:
tags:
- "v*"
pull_request:
paths:
- ".github/workflows/release.yml"
- ".github/scripts/**"
- "apps/headless/Dockerfile.linux"
- "apps/headless/build.sh"
- "apps/headless/build-linux.sh"
- "apps/headless/install-linux.sh"
- "apps/headless/install.sh"
- "apps/headless/Tests/linux-installer.sh"
- "apps/headless/Package.swift"
- "apps/headless/Sources/HeadlessProtocol/ProductVersion.swift"
- "apps/headless/VERSION"
- "apps/headless/VersionSupport/**"
workflow_dispatch:
inputs:
dry_run:
description: Build and verify release packages without publishing
required: true
default: true
type: boolean
permissions:
contents: read
env:
CONTAINER_IMAGE: ghcr.io/lockintime/headless
jobs:
version:
runs-on: ubuntu-latest
outputs:
version: ${{ steps.version.outputs.version }}
publish: ${{ steps.version.outputs.publish }}
steps:
- uses: actions/checkout@v7
- id: version
shell: bash
env:
DRY_RUN: ${{ inputs.dry_run }}
run: |
set -euo pipefail
if [[ "$GITHUB_EVENT_NAME" == "push" ]]; then
version="${GITHUB_REF_NAME#v}"
if ! printf '%s\n' "$version" | grep -Eq -f apps/headless/VersionSupport/semver-pattern.txt; then
echo "Release tag must be a semantic version prefixed with v: $GITHUB_REF_NAME" >&2
exit 64
fi
expected="$(tr -d '[:space:]' < apps/headless/VERSION)"
if [[ "$version" != "$expected" ]]; then
echo "Release tag $version does not match apps/headless/VERSION $expected" >&2
exit 64
fi
echo "publish=true" >> "$GITHUB_OUTPUT"
else
if [[ "$GITHUB_EVENT_NAME" == "workflow_dispatch" && "$DRY_RUN" != "true" ]]; then
echo "manual release runs must use dry_run=true; publish by pushing a v* tag" >&2
exit 64
fi
version="0.0.${GITHUB_RUN_NUMBER}"
echo "publish=false" >> "$GITHUB_OUTPUT"
fi
echo "version=$version" >> "$GITHUB_OUTPUT"
macos:
needs: version
runs-on: macos-latest
steps:
- uses: actions/checkout@v7
- name: Install FFmpeg
run: brew install ffmpeg
- name: Build
env:
HEADLESS_VERSION: ${{ needs.version.outputs.version }}
run: ./apps/headless/build.sh
- name: Unit tests
env:
HEADLESS_VERSION: ${{ needs.version.outputs.version }}
run: ./apps/headless/test.sh
- name: E2E
run: zsh ./apps/headless/Tests/macos-e2e.sh
- name: Package
env:
VERSION: ${{ needs.version.outputs.version }}
run: |
set -eu
cd apps/headless
ditto -c -k --keepParent Headless.app "Headless-${VERSION}-macos.zip"
test -s "Headless-${VERSION}-macos.zip"
unzip -t "Headless-${VERSION}-macos.zip"
unzip -Z1 "Headless-${VERSION}-macos.zip" | grep -qx 'Headless.app/Contents/MacOS/Headless'
unzip -Z1 "Headless-${VERSION}-macos.zip" | grep -qx 'Headless.app/Contents/Resources/bin/headless'
unzip -Z1 "Headless-${VERSION}-macos.zip" | grep -qx 'Headless.app/Contents/Resources/bin/headless-mcp'
- uses: actions/upload-artifact@v7
with:
name: macos
path: apps/headless/Headless-${{ needs.version.outputs.version }}-macos.zip
linux-amd64:
needs: version
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- name: Build
env:
HEADLESS_LINUX_PLATFORM: linux/amd64
HEADLESS_VERSION: ${{ needs.version.outputs.version }}
run: ./apps/headless/build-linux.sh
- name: E2E
run: ./apps/headless/Tests/linux-docker.sh
- name: Package
env:
VERSION: ${{ needs.version.outputs.version }}
run: |
set -eu
cp apps/headless/build/headless-linux-amd64.tar.gz \
"apps/headless/build/headless-${VERSION}-linux-amd64.tar.gz"
archive="apps/headless/build/headless-${VERSION}-linux-amd64.tar.gz"
test -s "$archive"
tar -tzf "$archive" > archive-contents.txt
for expected in headless headless-host headless-mcp install-linux.sh Headless_HeadlessProtocol.resources/AgentRuntime.js; do
grep -qx "$expected" archive-contents.txt
done
- uses: actions/upload-artifact@v7
with:
name: linux-amd64
path: apps/headless/build/headless-${{ needs.version.outputs.version }}-linux-amd64.tar.gz
linux-arm64:
needs: version
runs-on: ubuntu-24.04-arm
steps:
- uses: actions/checkout@v7
- name: Build
env:
HEADLESS_LINUX_PLATFORM: linux/arm64
HEADLESS_VERSION: ${{ needs.version.outputs.version }}
run: ./apps/headless/build-linux.sh
- name: E2E
run: ./apps/headless/Tests/linux-docker.sh
- name: Package
env:
VERSION: ${{ needs.version.outputs.version }}
run: |
set -eu
cp apps/headless/build/headless-linux-arm64.tar.gz \
"apps/headless/build/headless-${VERSION}-linux-arm64.tar.gz"
archive="apps/headless/build/headless-${VERSION}-linux-arm64.tar.gz"
test -s "$archive"
tar -tzf "$archive" > archive-contents.txt
for expected in headless headless-host headless-mcp install-linux.sh Headless_HeadlessProtocol.resources/AgentRuntime.js; do
grep -qx "$expected" archive-contents.txt
done
- uses: actions/upload-artifact@v7
with:
name: linux-arm64
path: apps/headless/build/headless-${{ needs.version.outputs.version }}-linux-arm64.tar.gz
container:
needs: version
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
steps:
- uses: actions/checkout@v7
- uses: docker/setup-buildx-action@v4
- name: Build native production image for verification
run: >-
docker build
--build-arg HEADLESS_VERSION=${{ needs.version.outputs.version }}
--target production
--tag headless-release-smoke:${{ github.run_id }}
--file apps/headless/Dockerfile.linux
apps/headless
- name: Smoke test native production image
run: >-
./.github/scripts/container-smoke.sh
headless-release-smoke:${{ github.run_id }}
${{ needs.version.outputs.version }}
- name: Generate container metadata
id: metadata
uses: docker/metadata-action@v6
with:
images: ${{ env.CONTAINER_IMAGE }}
tags: |
type=ref,event=tag
type=semver,pattern={{version}}
type=semver,pattern={{major}}.{{minor}}
type=sha,format=long
type=raw,value=latest
labels: |
org.opencontainers.image.title=Headless
org.opencontainers.image.description=Persistent safety-enforced browser control for AI agents
org.opencontainers.image.source=https://github.com/LockInTime/headless
org.opencontainers.image.licenses=MIT
- name: Log in to GHCR
if: needs.version.outputs.publish == 'true'
uses: docker/login-action@v4
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Build multi-platform production image
id: publish
uses: docker/build-push-action@v7
with:
context: apps/headless
file: apps/headless/Dockerfile.linux
target: production
platforms: linux/amd64,linux/arm64
build-args: HEADLESS_VERSION=${{ needs.version.outputs.version }}
labels: ${{ steps.metadata.outputs.labels }}
tags: ${{ steps.metadata.outputs.tags }}
push: ${{ needs.version.outputs.publish == 'true' }}
provenance: mode=max
sbom: true
- name: Verify published manifest and native image
if: needs.version.outputs.publish == 'true'
env:
DIGEST: ${{ steps.publish.outputs.digest }}
run: |
set -euo pipefail
docker buildx imagetools inspect "$CONTAINER_IMAGE@$DIGEST"
docker logout ghcr.io
for attempt in {1..12}; do
if docker pull "$CONTAINER_IMAGE@$DIGEST"; then break; fi
if [[ "$attempt" == 12 ]]; then
echo "Published container is not anonymously readable" >&2
exit 1
fi
sleep 5
done
./.github/scripts/container-smoke.sh "$CONTAINER_IMAGE@$DIGEST" "${{ needs.version.outputs.version }}"
publish:
needs: [version, macos, linux-amd64, linux-arm64, container]
if: >-
github.event_name == 'push' &&
startsWith(github.ref, 'refs/tags/v') &&
needs.version.outputs.publish == 'true'
runs-on: ubuntu-latest
permissions:
contents: write
steps:
- uses: actions/checkout@v7
- uses: actions/download-artifact@v8
with:
path: dist
merge-multiple: true
- name: Add Linux bootstrap installer
run: cp apps/headless/install.sh dist/install.sh
- name: Generate and verify checksums
run: ./.github/scripts/release-checksums.sh "${{ needs.version.outputs.version }}" dist
- name: Create GitHub Release
uses: softprops/action-gh-release@v3
with:
tag_name: ${{ github.ref_name }}
name: Headless ${{ needs.version.outputs.version }}
generate_release_notes: true
body: |
## Downloads
| File | Platform |
| --- | --- |
| `Headless-${{ needs.version.outputs.version }}-macos.zip` | macOS (ad-hoc signed `.app`) |
| `headless-${{ needs.version.outputs.version }}-linux-amd64.tar.gz` | Linux x86_64 |
| `headless-${{ needs.version.outputs.version }}-linux-arm64.tar.gz` | Linux arm64 |
| `install.sh` | Verified Linux bootstrap installer |
| `${{ env.CONTAINER_IMAGE }}:${{ needs.version.outputs.version }}` | Linux amd64/arm64 container |
### Notes
- **macOS:** Unzip and run `Headless.app`. Gatekeeper may warn (ad-hoc signature; notarization not included yet). CLI: `Headless.app/Contents/Resources/bin/headless`.
- **Linux:** `curl -fsSL https://github.com/LockInTime/headless/releases/latest/download/install.sh | sh`. The installer verifies the selected tarball against `SHA256SUMS`. Chromium and FFmpeg remain system dependencies.
- **Container:** `docker pull ${{ env.CONTAINER_IMAGE }}:${{ needs.version.outputs.version }}`. The image includes Chromium and FFmpeg and runs as uid 10001.
files: |
dist/Headless-${{ needs.version.outputs.version }}-macos.zip
dist/headless-${{ needs.version.outputs.version }}-linux-amd64.tar.gz
dist/headless-${{ needs.version.outputs.version }}-linux-arm64.tar.gz
dist/SHA256SUMS
dist/install.sh
fail_on_unmatched_files: true