Skip to content

feat(auth): add origin-bound saved-credential challenges #67

feat(auth): add origin-bound saved-credential challenges

feat(auth): add origin-bound saved-credential challenges #67

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/Tests/macos-distribution.sh"
- "apps/headless/headless.entitlements"
- "apps/headless/Package.swift"
- "apps/headless/Sources/HeadlessProtocol/ProductVersion.swift"
- "apps/headless/VERSION"
- "apps/headless/VersionSupport/**"
- "packages/headless-npm/**"
- "pnpm-lock.yaml"
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:
APPLE_DEVELOPER_ID_CERTIFICATE_BASE64: ${{ secrets.APPLE_DEVELOPER_ID_CERTIFICATE_BASE64 }}
APPLE_DEVELOPER_ID_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_DEVELOPER_ID_CERTIFICATE_PASSWORD }}
APPLE_NOTARY_ISSUER_ID: ${{ secrets.APPLE_NOTARY_ISSUER_ID }}
APPLE_NOTARY_KEY_BASE64: ${{ secrets.APPLE_NOTARY_KEY_BASE64 }}
APPLE_NOTARY_KEY_ID: ${{ secrets.APPLE_NOTARY_KEY_ID }}
DRY_RUN: ${{ inputs.dry_run }}
NPM_TOKEN: ${{ secrets.NPM_TOKEN }}
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
npm_version="$(node -p "require('./packages/headless-npm/package.json').version")"
if [[ "$version" != "$npm_version" ]]; then
echo "Release tag $version does not match npm launcher $npm_version" >&2
exit 64
fi
for required in \
APPLE_DEVELOPER_ID_CERTIFICATE_BASE64 \
APPLE_DEVELOPER_ID_CERTIFICATE_PASSWORD \
APPLE_NOTARY_KEY_BASE64 \
APPLE_NOTARY_KEY_ID \
APPLE_NOTARY_ISSUER_ID \
NPM_TOKEN; do
if [[ -z "${!required:-}" ]]; then
echo "Required release secret is missing: $required" >&2
exit 64
fi
done
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"
npm-launcher:
needs: version
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- uses: pnpm/action-setup@v6
- uses: actions/setup-node@v7
with:
node-version: 24
cache: pnpm
- run: pnpm install --frozen-lockfile --filter @lockintime/headless
- run: pnpm --filter @lockintime/headless test
- name: Verify published package contents
working-directory: packages/headless-npm
run: npm pack --dry-run
macos:
needs: version
runs-on: macos-latest
steps:
- uses: actions/checkout@v7
- name: Install FFmpeg
run: brew install ffmpeg
- name: Import Developer ID certificate
if: needs.version.outputs.publish == 'true'
id: signing
shell: bash
env:
CERTIFICATE_BASE64: ${{ secrets.APPLE_DEVELOPER_ID_CERTIFICATE_BASE64 }}
CERTIFICATE_PASSWORD: ${{ secrets.APPLE_DEVELOPER_ID_CERTIFICATE_PASSWORD }}
run: |
set -euo pipefail
[[ -n "$CERTIFICATE_BASE64" && -n "$CERTIFICATE_PASSWORD" ]] || {
echo "Apple Developer ID certificate secrets are required for tagged releases" >&2
exit 64
}
keychain="$RUNNER_TEMP/headless-signing.keychain-db"
certificate="$RUNNER_TEMP/headless-developer-id.p12"
keychain_password="$(uuidgen)$(uuidgen)"
printf '%s' "$CERTIFICATE_BASE64" | base64 -D > "$certificate"
chmod 0600 "$certificate"
security create-keychain -p "$keychain_password" "$keychain"
security set-keychain-settings -lut 21600 "$keychain"
security unlock-keychain -p "$keychain_password" "$keychain"
security import "$certificate" -k "$keychain" -P "$CERTIFICATE_PASSWORD" -T /usr/bin/codesign
security set-key-partition-list -S apple-tool:,apple: -s -k "$keychain_password" "$keychain"
security list-keychains -d user -s "$keychain"
identities="$(security find-identity -v -p codesigning "$keychain" | sed -n 's/.*"\(Developer ID Application:.*\)"/\1/p')"
[[ "$(printf '%s\n' "$identities" | sed '/^$/d' | wc -l | tr -d ' ')" == 1 ]] || {
echo "The certificate archive must contain exactly one Developer ID Application identity" >&2
exit 64
}
echo "identity=$identities" >> "$GITHUB_OUTPUT"
- name: Build
env:
CODESIGN_IDENTITY: ${{ steps.signing.outputs.identity }}
HEADLESS_ARCHS: arm64 x86_64
HEADLESS_RELEASE_BUILD: ${{ needs.version.outputs.publish == 'true' && '1' || '0' }}
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: Validate distribution bundle
shell: bash
env:
PUBLISH: ${{ needs.version.outputs.publish }}
VERSION: ${{ needs.version.outputs.version }}
run: |
set -euo pipefail
mode=adhoc
if [[ "$PUBLISH" == true ]]; then mode=developer-id; fi
./apps/headless/Tests/macos-distribution.sh \
apps/headless/Headless.app "$VERSION" "$mode" "arm64 x86_64"
- name: Prepare notarization key
if: needs.version.outputs.publish == 'true'
shell: bash
env:
NOTARY_KEY_BASE64: ${{ secrets.APPLE_NOTARY_KEY_BASE64 }}
NOTARY_KEY_ID: ${{ secrets.APPLE_NOTARY_KEY_ID }}
NOTARY_ISSUER_ID: ${{ secrets.APPLE_NOTARY_ISSUER_ID }}
run: |
set -euo pipefail
[[ -n "$NOTARY_KEY_BASE64" && -n "$NOTARY_KEY_ID" && -n "$NOTARY_ISSUER_ID" ]] || {
echo "Apple notary API secrets are required for tagged releases" >&2
exit 64
}
printf '%s' "$NOTARY_KEY_BASE64" | base64 -D > "$RUNNER_TEMP/AuthKey.p8"
chmod 0600 "$RUNNER_TEMP/AuthKey.p8"
- name: Package
env:
APPLE_NOTARY_ISSUER_ID: ${{ secrets.APPLE_NOTARY_ISSUER_ID }}
APPLE_NOTARY_KEY_ID: ${{ secrets.APPLE_NOTARY_KEY_ID }}
APPLE_NOTARY_KEY_PATH: ${{ runner.temp }}/AuthKey.p8
PUBLISH: ${{ needs.version.outputs.publish }}
VERSION: ${{ needs.version.outputs.version }}
run: |
set -euo pipefail
if [[ "$PUBLISH" == true ]]; then
./.github/scripts/macos-package.sh \
apps/headless/Headless.app "$VERSION" apps/headless --notarize
else
./.github/scripts/macos-package.sh \
apps/headless/Headless.app "$VERSION" apps/headless
fi
- name: Clean signing material
if: always() && needs.version.outputs.publish == 'true'
shell: bash
env:
KEYCHAIN: ${{ runner.temp }}/headless-signing.keychain-db
run: |
set -euo pipefail
if [[ -f "$KEYCHAIN" ]]; then security delete-keychain "$KEYCHAIN" || true; fi
rm -f "$RUNNER_TEMP/headless-developer-id.p12" "$RUNNER_TEMP/AuthKey.p8"
- 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 headless-credential-broker 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 headless-credential-broker 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, npm-launcher, 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 universal, Developer ID signed and notarized |
| `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`, or install with `brew install --cask LockInTime/headless/headless` after the tap sync completes. The app is universal, Developer ID signed, notarized, and stapled. 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.
- **npm:** `npx @lockintime/headless help`. The launcher verifies the matching GitHub release asset and provides both `headless` and `headless-mcp` shims.
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
publish-npm:
needs: [version, publish]
if: >-
github.event_name == 'push' &&
startsWith(github.ref, 'refs/tags/v') &&
needs.version.outputs.publish == 'true'
runs-on: ubuntu-latest
permissions:
contents: read
id-token: write
steps:
- uses: actions/checkout@v7
- uses: actions/setup-node@v7
with:
node-version: 24
registry-url: https://registry.npmjs.org
- name: Publish verified launcher
working-directory: packages/headless-npm
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
run: |
set -euo pipefail
[[ -n "$NODE_AUTH_TOKEN" ]] || {
echo "NPM_TOKEN is required to publish @lockintime/headless" >&2
exit 64
}
npm publish --access public --provenance