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
42 changes: 31 additions & 11 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,9 @@ on:
- "apps/headless/build-linux.sh"
- "apps/headless/install-linux.sh"
- "apps/headless/Package.swift"
- "apps/headless/Sources/HeadlessProtocol/ProductVersion.swift"
- "apps/headless/VERSION"
- "apps/headless/VersionSupport/**"
workflow_dispatch:
inputs:
dry_run:
Expand All @@ -31,26 +34,34 @@ jobs:
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 -eu
if [ "$GITHUB_EVENT_NAME" = "push" ]; then
case "$GITHUB_REF" in
refs/tags/v*) ;;
*) echo "release publishing requires a v* tag" >&2; exit 64 ;;
esac
echo "version=${GITHUB_REF_NAME#v}" >> "$GITHUB_OUTPUT"
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
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
echo "version=0.0.${GITHUB_RUN_NUMBER}" >> "$GITHUB_OUTPUT"
version="0.0.${GITHUB_RUN_NUMBER}"
echo "publish=false" >> "$GITHUB_OUTPUT"
fi
echo "version=$version" >> "$GITHUB_OUTPUT"

macos:
needs: version
Expand All @@ -64,6 +75,8 @@ jobs:
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
Expand All @@ -90,7 +103,10 @@ jobs:
steps:
- uses: actions/checkout@v7
- name: Build
run: HEADLESS_LINUX_PLATFORM=linux/amd64 ./apps/headless/build-linux.sh
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
Expand All @@ -117,7 +133,10 @@ jobs:
steps:
- uses: actions/checkout@v7
- name: Build
run: HEADLESS_LINUX_PLATFORM=linux/arm64 ./apps/headless/build-linux.sh
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
Expand Down Expand Up @@ -160,6 +179,7 @@ jobs:
with:
tag_name: ${{ github.ref_name }}
name: Headless ${{ needs.version.outputs.version }}
generate_release_notes: true
body: |
## Downloads

Expand Down
11 changes: 8 additions & 3 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,9 +8,10 @@ Releases are tagged `vX.Y.Z` and published by

Two versions travel independently, on purpose:

- **Product version** — the git tag, flowing into the macOS `Info.plist` via
`HEADLESS_VERSION` and into release assets.
- **Protocol version** — `headlessProtocolVersion` in `Protocol.swift`,
- **Product version**: the git tag, embedded in every binary via
`HEADLESS_VERSION`, reported by the CLI, host, and MCP adapter, and used for
release assets.
- **Protocol version**: `headlessProtocolVersion` in `Protocol.swift`,
currently `0.5`. It changes only when the wire contract changes, and always
with an entry in
[`docs/roadmap/architecture-decisions.md`](docs/roadmap/architecture-decisions.md).
Expand Down Expand Up @@ -77,6 +78,10 @@ Cutting that release is tracked in

### Changed

- Product versions now come from the release tag at build time and are
reported consistently by `headless --version`, host `ping`, MCP
`serverInfo`, package metadata, and the website. Release notes are generated
automatically for each tag while protocol versioning stays independent.
- The release workflow can now exercise and verify every package without
publishing, manually or on pull requests that change packaging inputs.
- macOS WebKit and Linux Chromium now share one `HostCore` dispatcher and
Expand Down
22 changes: 13 additions & 9 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -41,15 +41,15 @@ P2 uses the same CLI everywhere Headless runs:
Three common agent browser paths, scored **1–5** as qualitative capability
judgments (not lab benchmarks):

| Dimension | Coordinate CU (regular browser) | Scripted (PW / Puppeteer / Selenium) | Headless |
| --- | :---: | :---: | :---: |
| Targeting precision | 2 | 4 | 5 |
| Safety / blast radius | 2 | 3 | 5 |
| Evidence (shots, video, QA) | 3 | 3 | 5 |
| Agent surface (tokens / glue) | 2 | 3 | 5 |
| Setup friction | 3 | 3 | 4 |
| Platform coverage (host OS) | 5 | 5 | 4 |
| Desktop / OS reach | 5 | 1 | 1 |
| Dimension | Coordinate CU (regular browser) | Scripted (PW / Puppeteer / Selenium) | Headless |
| ----------------------------- | :-----------------------------: | :----------------------------------: | :------: |
| Targeting precision | 2 | 4 | 5 |
| Safety / blast radius | 2 | 3 | 5 |
| Evidence (shots, video, QA) | 3 | 3 | 5 |
| Agent surface (tokens / glue) | 2 | 3 | 5 |
| Setup friction | 3 | 3 | 4 |
| Platform coverage (host OS) | 5 | 5 | 4 |
| Desktop / OS reach | 5 | 1 | 1 |

- **Coordinate CU** — strong when the agent needs the whole desktop; weaker on
precise web targeting (pixels drift), larger screenshot/prompt cost, and a
Expand Down Expand Up @@ -210,6 +210,10 @@ git tag v1.0.0
git push origin v1.0.0
```

The tag is embedded as the product version in every binary. Verify an install
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, and
`SHA256SUMS`. Download the manifest beside the selected package and verify it
before installing:
Expand Down
16 changes: 12 additions & 4 deletions apps/headless/Dockerfile.linux
Original file line number Diff line number Diff line change
@@ -1,6 +1,10 @@
FROM swift:6.3-bookworm AS builder
WORKDIR /src
ARG HEADLESS_VERSION
ENV HEADLESS_BUILD_VERSION=${HEADLESS_VERSION}
COPY Package.swift ./
COPY VERSION ./
COPY VersionSupport ./VersionSupport
COPY main.swift ./
COPY Host ./Host
COPY Sources ./Sources
Expand All @@ -11,11 +15,15 @@ COPY docs ./docs
COPY tools ./tools
COPY build.sh build-linux.sh install-linux.sh benchmark.sh test.sh package.json headless.entitlements Dockerfile.linux .dockerignore ./
RUN mkdir -p build Headless.app
RUN swift build -c release --static-swift-stdlib --product headless-protocol-tests \
RUN HEADLESS_VERSION="${HEADLESS_BUILD_VERSION:-$(cat VERSION)}" \
swift build -c release --static-swift-stdlib --product headless-protocol-tests \
&& ./.build/release/headless-protocol-tests
RUN swift build -c release --static-swift-stdlib --product headless
RUN swift build -c release --static-swift-stdlib --product headless-linux-host
RUN swift build -c release --static-swift-stdlib --product headless-mcp
RUN HEADLESS_VERSION="${HEADLESS_BUILD_VERSION:-$(cat VERSION)}" \
swift build -c release --static-swift-stdlib --product headless
RUN HEADLESS_VERSION="${HEADLESS_BUILD_VERSION:-$(cat VERSION)}" \
swift build -c release --static-swift-stdlib --product headless-linux-host
RUN HEADLESS_VERSION="${HEADLESS_BUILD_VERSION:-$(cat VERSION)}" \
swift build -c release --static-swift-stdlib --product headless-mcp
RUN strip --strip-unneeded .build/release/headless .build/release/headless-linux-host .build/release/headless-mcp

FROM debian:bookworm-slim AS runtime-base
Expand Down
2 changes: 1 addition & 1 deletion apps/headless/MCP/main.swift
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,7 @@ while let line = readLine() {
write(["jsonrpc": "2.0", "id": id ?? NSNull(), "result": [
"protocolVersion": "2025-06-18",
"capabilities": ["tools": ["listChanged": false]],
"serverInfo": ["name": "headless", "version": headlessProtocolVersion],
"serverInfo": ["name": "headless", "version": headlessProductVersion],
]])
case "notifications/initialized":
continue
Expand Down
23 changes: 22 additions & 1 deletion apps/headless/Package.swift
Original file line number Diff line number Diff line change
@@ -1,6 +1,19 @@
// swift-tools-version: 5.10

import PackageDescription
import Foundation

let packageDirectory = URL(fileURLWithPath: #filePath).deletingLastPathComponent()
let fallbackProductVersion = try String(
contentsOf: packageDirectory.appendingPathComponent("VERSION"), encoding: .utf8
).trimmingCharacters(in: .whitespacesAndNewlines)
let productVersion = ProcessInfo.processInfo.environment["HEADLESS_VERSION"] ?? fallbackProductVersion
let semanticVersionPattern = try String(
contentsOf: packageDirectory.appendingPathComponent("VersionSupport/semver-pattern.txt"), encoding: .utf8
).trimmingCharacters(in: .whitespacesAndNewlines)
guard productVersion.range(of: semanticVersionPattern, options: .regularExpression) != nil else {
fatalError("HEADLESS_VERSION must be a semantic version, received: \(productVersion)")
}

let package = Package(
name: "Headless",
Expand All @@ -15,8 +28,16 @@ let package = Package(
.library(name: "HeadlessProtocol", targets: ["HeadlessProtocol"]),
],
targets: [
.target(
name: "CHeadlessVersion",
path: "VersionSupport",
exclude: ["semver-pattern.txt"],
publicHeadersPath: "include",
cSettings: [.define("HEADLESS_PRODUCT_VERSION", to: "\"\(productVersion)\"")]
),
.target(
name: "HeadlessProtocol",
dependencies: ["CHeadlessVersion"],
resources: [.process("Resources")]
),
.executableTarget(
Expand All @@ -38,7 +59,7 @@ let package = Package(
dependencies: ["HeadlessProtocol"],
path: ".",
exclude: [
"Package.swift", "Sources", "Tests", "tools", "build.sh",
"Package.swift", "Sources", "Tests", "tools", "VersionSupport", "VERSION", "build.sh",
"package.json", "headless.entitlements", "build", "docs", "test.sh",
"LinuxHost", "Dockerfile.linux", "Headless.app", "build-linux.sh", "install-linux.sh", "benchmark.sh", ".dockerignore",
"MCP", "node_modules",
Expand Down
2 changes: 2 additions & 0 deletions apps/headless/Sources/HeadlessCLI/main.swift
Original file line number Diff line number Diff line change
Expand Up @@ -107,6 +107,8 @@ do {
switch local {
case .help:
print(agentHelp)
case .version:
print("headless \(headlessProductVersion)")
case .capabilities:
printJSON(capabilitiesDocument)
case .runtime:
Expand Down
5 changes: 5 additions & 0 deletions apps/headless/Sources/HeadlessProtocol/CLI.swift
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import Foundation

public enum LocalCommand: Equatable, Sendable {
case help
case version
case capabilities
case runtime
case start
Expand Down Expand Up @@ -74,6 +75,9 @@ public struct CLIParser {
case "help", "--help", "-h":
try requireEmpty(arguments)
return CLIInvocation(local: .help, jsonOutput: jsonOutput)
case "version", "--version", "-V":
try requireEmpty(arguments)
return CLIInvocation(local: .version, jsonOutput: jsonOutput)
case "capabilities":
try requireEmpty(arguments)
return CLIInvocation(local: .capabilities, jsonOutput: true)
Expand Down Expand Up @@ -638,6 +642,7 @@ Core workflow:
headless --session qa capture-info

Commands:
version | --version
start | status | stop | runtime
session create [NAME] | session list | session close NAME
visit URL
Expand Down
1 change: 1 addition & 0 deletions apps/headless/Sources/HeadlessProtocol/HostCore.swift
Original file line number Diff line number Diff line change
Expand Up @@ -234,6 +234,7 @@ public final class HostCore<Engine: BrowserEngine>: @unchecked Sendable {
"pid": .number(Double(ProcessInfo.processInfo.processIdentifier)),
"engine": .string(engine.name),
"platform": .string(engine.platform),
"productVersion": .string(headlessProductVersion),
"protocolVersion": .string(headlessProtocolVersion),
"capabilities": engine.capabilities.document,
"recordingAvailable": .bool(BrowserRecording.isAvailable()),
Expand Down
5 changes: 5 additions & 0 deletions apps/headless/Sources/HeadlessProtocol/ProductVersion.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
import CHeadlessVersion

/// The product release version embedded at compile time. This is independent
/// from the wire protocol version used for compatibility checks.
public let headlessProductVersion = String(cString: headless_product_version())
5 changes: 3 additions & 2 deletions apps/headless/Tests/HeadlessMCPTests/main.swift
Original file line number Diff line number Diff line change
Expand Up @@ -24,8 +24,8 @@ func integer(_ value: Any?, _ message: String) throws -> Int {
}

func run() throws {
guard CommandLine.arguments.count == 2 else {
throw TestFailure(description: "usage: headless-mcp-tests /path/to/headless-mcp")
guard CommandLine.arguments.count == 3 else {
throw TestFailure(description: "usage: headless-mcp-tests /path/to/headless-mcp EXPECTED_VERSION")
}

try LocalRuntime.preparePrivateDirectory()
Expand Down Expand Up @@ -84,6 +84,7 @@ func run() throws {
try expect(initialize["protocolVersion"] as? String == "2025-06-18", "initialize protocol version changed")
let serverInfo = try object(initialize["serverInfo"], "initialize server info was absent")
try expect(serverInfo["name"] as? String == "headless", "initialize server name changed")
try expect(serverInfo["version"] as? String == CommandLine.arguments[2], "MCP product version changed")

let list = try object(responses[1]["result"], "tools/list result was absent")
guard let tools = list["tools"] as? [[String: Any]], tools.count == 1 else {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -725,6 +725,9 @@ struct ProtocolTests {
(["start"], .start),
(["help"], .help),
(["--help"], .help),
(["version"], .version),
(["--version"], .version),
(["-V"], .version),
]
for (arguments, command) in localCommands {
let invocation = try CLIParser().parse(arguments)
Expand Down Expand Up @@ -1819,6 +1822,7 @@ struct ProtocolTests {
}
try expect(pingResult["engine"] == .string("fake"), "ping should identify the engine")
try expect(pingResult["platform"] == .string("test"), "ping should identify the platform")
try expect(pingResult["productVersion"] == .string(headlessProductVersion), "ping should identify the product version")
try expect(pingResult["adapter"] == .string("test-adapter"), "engine ping details should be merged")
try expect(pingResult["capabilities"] != nil, "ping should publish the active engine profile")

Expand Down
1 change: 1 addition & 0 deletions apps/headless/VERSION
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
1.0.2
9 changes: 9 additions & 0 deletions apps/headless/VersionSupport/headless_version.c
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
#include "headless_version.h"

#ifndef HEADLESS_PRODUCT_VERSION
#error "HEADLESS_PRODUCT_VERSION must be defined by Package.swift"
#endif

const char *headless_product_version(void) {
return HEADLESS_PRODUCT_VERSION;
}
6 changes: 6 additions & 0 deletions apps/headless/VersionSupport/include/headless_version.h
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
#ifndef HEADLESS_VERSION_H
#define HEADLESS_VERSION_H

const char *headless_product_version(void);

#endif
1 change: 1 addition & 0 deletions apps/headless/VersionSupport/semver-pattern.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)(-((0|[1-9][0-9]*|[0-9A-Za-z-]*[A-Za-z-][0-9A-Za-z-]*)(\.(0|[1-9][0-9]*|[0-9A-Za-z-]*[A-Za-z-][0-9A-Za-z-]*))*))?(\+([0-9A-Za-z-]+)(\.[0-9A-Za-z-]+)*)?$
5 changes: 3 additions & 2 deletions apps/headless/build-linux.sh
Original file line number Diff line number Diff line change
Expand Up @@ -9,10 +9,11 @@ command -v docker >/dev/null 2>&1 || {
}

IMAGE="headless-linux-build"
VERSION="${HEADLESS_VERSION:-$(tr -d '[:space:]' < VERSION)}"
if [ -n "${HEADLESS_LINUX_PLATFORM:-}" ]; then
docker build --platform "$HEADLESS_LINUX_PLATFORM" --target production -f Dockerfile.linux -t "$IMAGE" .
docker build --build-arg HEADLESS_VERSION="$VERSION" --platform "$HEADLESS_LINUX_PLATFORM" --target production -f Dockerfile.linux -t "$IMAGE" .
else
docker build --target production -f Dockerfile.linux -t "$IMAGE" .
docker build --build-arg HEADLESS_VERSION="$VERSION" --target production -f Dockerfile.linux -t "$IMAGE" .
fi
mkdir -p build/linux
CONTAINER="$(docker create "$IMAGE")"
Expand Down
2 changes: 1 addition & 1 deletion apps/headless/build.sh
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ done
APP="Headless.app"
ARCH="$(uname -m)"
ICON="build/Headless.icns"
VERSION="${HEADLESS_VERSION:-1.0.0}"
VERSION="${HEADLESS_VERSION:-$(tr -d '[:space:]' < VERSION)}"
mkdir -p build/module-cache build/swiftpm-module-cache build/bin

# Select an SDK the installed Swift compiler can read. Apple occasionally ships
Expand Down
2 changes: 1 addition & 1 deletion apps/headless/package.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"name": "@headless/app",
"private": true,
"version": "0.0.0",
"version": "1.0.2",
"scripts": {
"build": "./build.sh",
"build:linux": "./build-linux.sh",
Expand Down
Loading
Loading