From c4779049bc667a4cc897fd901330817e9a108530 Mon Sep 17 00:00:00 2001 From: SarthakWade Date: Wed, 12 Aug 2026 23:25:52 +0530 Subject: [PATCH] ci: publish production image to GHCR --- .github/scripts/container-smoke.sh | 43 ++++++++++++++ .github/workflows/release.yml | 84 +++++++++++++++++++++++++++- CHANGELOG.md | 2 + CONTRIBUTING.md | 15 ++++- README.md | 19 ++++++- apps/headless/Dockerfile.linux | 3 + docs/roadmap/improvements-backlog.md | 8 ++- 7 files changed, 166 insertions(+), 8 deletions(-) create mode 100755 .github/scripts/container-smoke.sh diff --git a/.github/scripts/container-smoke.sh b/.github/scripts/container-smoke.sh new file mode 100755 index 0000000..dfa79a6 --- /dev/null +++ b/.github/scripts/container-smoke.sh @@ -0,0 +1,43 @@ +#!/bin/sh +set -eu + +if [ "$#" -ne 2 ]; then + echo "usage: container-smoke.sh IMAGE EXPECTED_VERSION" >&2 + exit 64 +fi + +IMAGE="$1" +EXPECTED_VERSION="$2" + +command -v docker >/dev/null 2>&1 || { echo "container smoke: Docker is required" >&2; exit 69; } + +ACTUAL_VERSION="$(docker run --rm --entrypoint /usr/local/bin/headless "$IMAGE" --version)" +[ "$ACTUAL_VERSION" = "headless $EXPECTED_VERSION" ] || { + echo "container smoke: expected headless $EXPECTED_VERSION, received $ACTUAL_VERSION" >&2 + exit 1 +} + +[ "$(docker run --rm --entrypoint /usr/bin/id "$IMAGE" -u)" = "10001" ] || { + echo "container smoke: image does not run as the unprivileged headless user" >&2 + exit 1 +} + +docker run --rm --entrypoint /usr/local/bin/headless "$IMAGE" capabilities \ + | grep -q '"protocolVersion"' +docker run --rm --entrypoint /usr/local/bin/headless "$IMAGE" runtime \ + | grep -q '"executable":"/usr/lib/chromium/chromium"' +docker run --rm --entrypoint /bin/sh "$IMAGE" -c 'test -x /usr/bin/ffmpeg' + +EXPOSED_PORTS="$(docker image inspect --format '{{json .Config.ExposedPorts}}' "$IMAGE")" +case "$EXPOSED_PORTS" in + null|'{}') ;; + *) echo "container smoke: image declares unexpected exposed ports: $EXPOSED_PORTS" >&2; exit 1 ;; +esac + +SOURCE_LABEL="$(docker image inspect --format '{{index .Config.Labels "org.opencontainers.image.source"}}' "$IMAGE")" +[ "$SOURCE_LABEL" = "https://github.com/LockInTime/headless" ] || { + echo "container smoke: source label does not link the image to the repository" >&2 + exit 1 +} + +echo "Production container smoke tests passed: $IMAGE" diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 0bcfc44..46ec031 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -29,6 +29,9 @@ on: permissions: contents: read +env: + CONTAINER_IMAGE: ghcr.io/lockintime/headless + jobs: version: runs-on: ubuntu-latest @@ -159,8 +162,85 @@ jobs: 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] + needs: [version, macos, linux-amd64, linux-arm64, container] if: >- github.event_name == 'push' && startsWith(github.ref, 'refs/tags/v') && @@ -193,11 +273,13 @@ jobs: | `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 diff --git a/CHANGELOG.md b/CHANGELOG.md index de45def..952c608 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -24,6 +24,8 @@ Cutting that release is tracked in ### Added +- Tagged releases now publish a smoke-tested, non-root amd64/arm64 production + image to GHCR with SemVer and commit-SHA tags, provenance, and an SBOM. - A checksum-verifying Linux bootstrap installer now selects the correct amd64/arm64 release package and delegates to the shared runtime preflight. - Tagged releases now include a verified `SHA256SUMS` manifest covering every diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index af5abb1..bd80e6f 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -5,7 +5,7 @@ drive real browsers through it, and the host — not the prompt — is what stop them doing something harmful. That shapes how we review changes, so please read this before your first PR. -Contributors are humans *and* coding agents. If you are an agent, your rules +Contributors are humans _and_ coding agents. If you are an agent, your rules live in [`AGENTS.md`](AGENTS.md); everything below applies to you too. ## Before you start @@ -58,7 +58,7 @@ Platform notes: runtime JS, or artifact code. Add the `macos-e2e` label to the PR if you touched `main.swift`, `Host/`, or capture code — that triggers the heavier WKWebView suite in CI. -- New behaviour ships with a test. New *safety* behaviour ships with a test +- New behaviour ships with a test. New _safety_ behaviour ships with a test that fails without the fix. - A protocol command still has to be added in both hosts, the validator, the CLI, the help text, and `capabilities` until the HostCore refactor @@ -72,6 +72,17 @@ suite on Linux and macOS, the web lint/build, and the Linux Docker E2E. All of it uses the same scripts you ran locally, so a green laptop should mean a green PR. +## Release operations + +Tagged releases publish `ghcr.io/lockintime/headless` with the repository's +`GITHUB_TOKEN`. GitHub creates a new organization container package as private +unless the organization is configured otherwise. After the first image is +created, a package administrator must change its visibility to **Public** in +the package settings and rerun the failed container job. The workflow logs out +of GHCR and requires an anonymous pull by digest before it creates the GitHub +Release, so a private image fails closed instead of producing a broken public +release. + ## Style - **Swift:** Foundation and system frameworks only. No new SwiftPM diff --git a/README.md b/README.md index cdb9bc4..f624c01 100644 --- a/README.md +++ b/README.md @@ -206,6 +206,18 @@ installer that checks Chromium and FFmpeg before copying the binaries. The Docker image is the supported self-contained Linux runtime and uses Debian's Chromium binary at `/usr/lib/chromium/chromium`. +Tagged releases publish a non-root amd64/arm64 image with Chromium and FFmpeg: + +```sh +docker pull ghcr.io/lockintime/headless:1.1.0 +docker run --rm ghcr.io/lockintime/headless:1.1.0 headless capabilities +``` + +Version and commit-SHA tags provide stable release references, while a digest +is the immutable deployment reference; `latest` tracks the newest release. The +image exposes no ports. Browser control remains on the private Unix socket +inside the container. + For a native Linux install, use a real Chromium binary supplied by the Linux distribution. Ubuntu's `/snap/bin/chromium` launcher resolves to `/usr/bin/snap`; it is rejected because repeated navigation is unreliable over @@ -241,9 +253,10 @@ with `headless --version`; wire protocol compatibility is versioned independently. See [CHANGELOG.md](CHANGELOG.md) for release history. Assets: macOS `Headless.app` zip, Linux amd64/arm64 tarballs, the Linux -`install.sh` bootstrap, and `SHA256SUMS`. The bootstrap verifies the selected -Linux package automatically. For manual installation, download the manifest -beside the selected package and verify it before installing: +`install.sh` bootstrap, a multi-platform GHCR image, and `SHA256SUMS`. The +bootstrap verifies the selected Linux package automatically. For manual +installation, download the manifest beside the selected package and verify it +before installing: ```sh sha256sum --ignore-missing -c SHA256SUMS # Linux diff --git a/apps/headless/Dockerfile.linux b/apps/headless/Dockerfile.linux index 507f950..c459bcd 100644 --- a/apps/headless/Dockerfile.linux +++ b/apps/headless/Dockerfile.linux @@ -27,6 +27,9 @@ RUN HEADLESS_VERSION="${HEADLESS_BUILD_VERSION:-$(cat VERSION)}" \ RUN strip --strip-unneeded .build/release/headless .build/release/headless-linux-host .build/release/headless-mcp FROM debian:bookworm-slim AS runtime-base +LABEL org.opencontainers.image.source="https://github.com/LockInTime/headless" \ + org.opencontainers.image.description="Persistent safety-enforced browser control for AI agents" \ + org.opencontainers.image.licenses="MIT" RUN apt-get update \ && apt-get install -y --no-install-recommends ca-certificates chromium chromium-sandbox ffmpeg \ && rm -rf /var/lib/apt/lists/* \ diff --git a/docs/roadmap/improvements-backlog.md b/docs/roadmap/improvements-backlog.md index 0527a7b..d6428a1 100644 --- a/docs/roadmap/improvements-backlog.md +++ b/docs/roadmap/improvements-backlog.md @@ -371,8 +371,12 @@ Owner-decided scope: package managers, no hosted service. product's Chromium runtime resolver and restricts FFmpeg to the recording allow-list or a validated absolute override. Offline tests cover latest and pinned versions, checksums, archive contents, architecture, and overrides. -- **E3.** ([#41](https://github.com/LockInTime/headless/issues/41)) Publish the Docker `production` image to GHCR on tag (release notes - currently say "not published"); this is also the interim Windows story. +- **E3.** [x] ([#41](https://github.com/LockInTime/headless/issues/41)) ~~Publish the Docker `production` image to GHCR on tag; this is also the + interim Windows story.~~ **Done:** packaging PRs build and smoke-test the + non-root production target with no exposed ports. Version tags publish an + amd64/arm64 GHCR manifest with SemVer, source-tag, commit-SHA, and `latest` + references plus OCI provenance and an SBOM, then pull by digest and rerun the + smoke suite before the GitHub Release is created. - **E4.** ([#42](https://github.com/LockInTime/headless/issues/42)) ~~`SHA256SUMS` for all release assets — the QA evidence bundle already ships sums; releases don't.~~ **Done:** the publish job requires all three named regular package files, generates `SHA256SUMS` atomically, verifies it, and