Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
43 changes: 43 additions & 0 deletions .github/scripts/container-smoke.sh
Original file line number Diff line number Diff line change
@@ -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"
84 changes: 83 additions & 1 deletion .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,9 @@ on:
permissions:
contents: read

env:
CONTAINER_IMAGE: ghcr.io/lockintime/headless

jobs:
version:
runs-on: ubuntu-latest
Expand Down Expand Up @@ -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') &&
Expand Down Expand Up @@ -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
Expand Down
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
15 changes: 13 additions & 2 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand Down
19 changes: 16 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
3 changes: 3 additions & 0 deletions apps/headless/Dockerfile.linux
Original file line number Diff line number Diff line change
Expand Up @@ -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/* \
Expand Down
8 changes: 6 additions & 2 deletions docs/roadmap/improvements-backlog.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading