diff --git a/.github/workflows/check-agent-versions.yml b/.github/workflows/check-agent-versions.yml new file mode 100644 index 0000000..b7508bc --- /dev/null +++ b/.github/workflows/check-agent-versions.yml @@ -0,0 +1,24 @@ +name: Check Agent Versions + +on: + schedule: + - cron: '37 6,18 * * *' + workflow_dispatch: + +jobs: + check-and-publish: + name: Check ${{ matrix.target }} and its dependencies + strategy: + fail-fast: false + matrix: + target: [claude, codex, t3] + uses: ./.github/workflows/publish-image.yml + with: + target: ${{ matrix.target }} + check_existing: true + secrets: inherit + permissions: + contents: write + packages: write + id-token: write + attestations: write diff --git a/.github/workflows/check-claude-version.yml b/.github/workflows/check-claude-version.yml deleted file mode 100644 index 2695f93..0000000 --- a/.github/workflows/check-claude-version.yml +++ /dev/null @@ -1,77 +0,0 @@ -name: Check Claude Code Version - -on: - schedule: - - cron: '37 6,18 * * *' # Twice daily at 6:37am and 6:37pm UTC - workflow_dispatch: # Manual trigger - -permissions: - contents: write - -jobs: - check-version: - name: Check for New Claude Code Version - runs-on: ubuntu-latest - outputs: - version: ${{ steps.claude-version.outputs.version }} - new-version: ${{ steps.check-tag.outputs.exists == 'false' }} - steps: - - name: Checkout repository - uses: actions/checkout@v4 - with: - fetch-depth: 0 # Fetch all tags - - - name: Get latest Claude Code version - id: claude-version - run: | - GCS_BUCKET="https://storage.googleapis.com/claude-code-dist-86c565f3-f756-42ad-8dfa-d59b1c096819/claude-code-releases" - VERSION=$(curl -fsSL "${GCS_BUCKET}/latest") - echo "version=${VERSION}" >> $GITHUB_OUTPUT - echo "Latest Claude Code version: ${VERSION}" - - - name: Check if version tag exists - id: check-tag - run: | - VERSION="${{ steps.claude-version.outputs.version }}" - TAG="v${VERSION}" - if git rev-parse "${TAG}" >/dev/null 2>&1; then - echo "exists=true" >> $GITHUB_OUTPUT - echo "Tag ${TAG} already exists" - else - echo "exists=false" >> $GITHUB_OUTPUT - echo "Tag ${TAG} does not exist - will create" - fi - - - name: Create and push version tag - if: steps.check-tag.outputs.exists == 'false' - run: | - VERSION="${{ steps.claude-version.outputs.version }}" - TAG="v${VERSION}" - - git config user.name "github-actions[bot]" - git config user.email "github-actions[bot]@users.noreply.github.com" - - git tag -a "${TAG}" -m "Claude Code version ${VERSION}" - git push origin "${TAG}" - - echo "Created and pushed tag: ${TAG}" - - - name: Version already up to date - if: steps.check-tag.outputs.exists == 'true' - run: | - VERSION="${{ steps.claude-version.outputs.version }}" - echo "Container is already at Claude Code version ${VERSION} (tag: v${VERSION})" - - publish: - name: Publish Container - needs: check-version - if: needs.check-version.outputs.new-version == 'true' - uses: ./.github/workflows/publish-container.yml - with: - version: ${{ needs.check-version.outputs.version }} - secrets: inherit - permissions: - contents: read - packages: write - id-token: write - attestations: write diff --git a/.github/workflows/publish-container.yml b/.github/workflows/publish-container.yml index 155c884..5bdf3e0 100644 --- a/.github/workflows/publish-container.yml +++ b/.github/workflows/publish-container.yml @@ -1,174 +1,75 @@ -name: Publish Container +name: Publish Containers on: push: - branches: - - main - tags: - - 'v*' # Match version tags like v2.1.17 + branches: [main] + tags: ['v*', 'codex-v*', 't3-v*'] pull_request: - branches: - - main + branches: [main] workflow_dispatch: inputs: + target: + description: Image to publish + type: choice + options: [all, claude, codex, t3] + default: all version: - description: 'Claude Code version to build (e.g., 2.1.23)' - required: false + description: Optional upstream version; requires one explicit target type: string update_aliases: - description: 'Also update alias tags (latest, vX, vX.Y). Disable to only push the exact version tag.' - required: false + description: Update latest, major, and minor aliases type: boolean default: true no_cache: - description: 'Rebuild every layer, ignoring the build cache (use after a transient download failure).' - required: false + description: Ignore build cache type: boolean default: false workflow_call: inputs: + target: + type: string + default: claude version: - description: 'Claude Code version to build (e.g., 2.1.23)' - required: true type: string + default: '' update_aliases: - description: 'Also update alias tags (latest, vX, vX.Y). Disable to only push the exact version tag.' - required: false type: boolean default: true - -env: - REGISTRY: ghcr.io - IMAGE_NAME: ${{ github.repository }} + no_cache: + type: boolean + default: false jobs: - verify-source: - name: Verify Claude Code Source URL - runs-on: ubuntu-latest - steps: - - name: Checkout repository - uses: actions/checkout@v4 - - - name: Verify GCS bucket URL - run: | - chmod +x .claude/skills/verifying-claude-source/scripts/verify-source.sh - .claude/skills/verifying-claude-source/scripts/verify-source.sh - - build-and-push: - name: Build and Push Container + select: runs-on: ubuntu-latest - needs: verify-source permissions: contents: read - packages: write - id-token: write - attestations: write + outputs: + matrix: ${{ steps.select.outputs.matrix }} + version: ${{ steps.select.outputs.version }} steps: - - name: Checkout repository - uses: actions/checkout@v4 - - - name: Set up QEMU - uses: docker/setup-qemu-action@v3 - - - name: Set up Docker Buildx - uses: docker/setup-buildx-action@v3 - - - name: Log in to Container Registry - uses: docker/login-action@v3 - with: - registry: ${{ env.REGISTRY }} - username: ${{ github.actor }} - password: ${{ secrets.GITHUB_TOKEN }} - - - name: Extract metadata - id: meta - uses: docker/metadata-action@v5 - with: - images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} - tags: | - type=ref,event=branch,enable=${{ inputs.version == '' }} - type=ref,event=pr,enable=${{ inputs.version == '' }} - type=semver,pattern={{version}},enable=${{ inputs.version == '' }} - type=semver,pattern={{major}}.{{minor}},enable=${{ inputs.version == '' }} - type=semver,pattern={{major}},enable=${{ inputs.version == '' }} - type=raw,value=latest,enable=${{ inputs.version == '' && github.ref == format('refs/heads/{0}', github.event.repository.default_branch) }} - # Tags for version input (workflow_dispatch / workflow_call) - type=semver,pattern={{version}},value=v${{ inputs.version }},enable=${{ inputs.version != '' }} - type=semver,pattern={{major}}.{{minor}},value=v${{ inputs.version }},enable=${{ inputs.version != '' && inputs.update_aliases }} - type=semver,pattern={{major}},value=v${{ inputs.version }},enable=${{ inputs.version != '' && inputs.update_aliases }} - type=raw,value=latest,enable=${{ inputs.version != '' && inputs.update_aliases }} - labels: | - org.opencontainers.image.title=Claude Code Container - org.opencontainers.image.description=Containerized Claude Code CLI with VSIX extension - - - name: Resolve Claude Code version - id: version + - uses: actions/checkout@v4 + - name: Select independently versioned images + id: select env: + IMAGE_TARGET: ${{ inputs.target || 'all' }} REQUESTED_VERSION: ${{ inputs.version }} - run: | - # Resolve "latest" here so the build arg changes with each release. - # The Dockerfile's RUN text never changes, so passing the literal - # "latest" would let Buildx reuse a stale cached layer. - if [ -n "$REQUESTED_VERSION" ]; then - VERSION="$REQUESTED_VERSION" - else - VERSION=$(curl -fsSL "https://downloads.claude.ai/claude-code-releases/latest") - fi - echo "Building Claude Code ${VERSION}" - echo "version=${VERSION}" >> "$GITHUB_OUTPUT" - - - name: Build and push Docker image - id: build - uses: docker/build-push-action@v5 - with: - context: . - platforms: linux/amd64,linux/arm64 - push: ${{ github.event_name != 'pull_request' }} - tags: ${{ steps.meta.outputs.tags }} - labels: ${{ steps.meta.outputs.labels }} - build-args: | - CLAUDE_VERSION=${{ steps.version.outputs.version }} - cache-from: type=gha - cache-to: type=gha,mode=max - no-cache: ${{ inputs.no_cache || false }} - - - name: Verify VSIX in built image - continue-on-error: true - if: startsWith(github.ref, 'refs/tags/v') || inputs.version != '' - run: | - # Extract first tag from metadata - IMAGE_TAG=$(echo "${{ steps.meta.outputs.tags }}" | head -1) - - echo "Verifying VSIX in image: ${IMAGE_TAG}" - - # Verify VSIX exists - docker run --rm --entrypoint ls "${IMAGE_TAG}" -lh /opt/claude-code/claude-code.vsix - - # Verify VSIX is valid - docker run --rm --entrypoint file "${IMAGE_TAG}" /opt/claude-code/claude-code.vsix - - echo "VSIX verification successful" - - - name: Verify Windows binary in built image - continue-on-error: true - if: startsWith(github.ref, 'refs/tags/v') || inputs.version != '' - run: | - # Extract first tag from metadata - IMAGE_TAG=$(echo "${{ steps.meta.outputs.tags }}" | head -1) - - echo "Verifying Windows binary in image: ${IMAGE_TAG}" - - if docker run --rm --entrypoint test "${IMAGE_TAG}" -f /opt/claude-code/win32-x64/claude.exe; then - docker run --rm --entrypoint ls "${IMAGE_TAG}" -lh /opt/claude-code/win32-x64/claude.exe - echo "Windows binary verification successful" - else - echo "::warning::Windows binary missing from image ${IMAGE_TAG} (not published for this version, or download failed)" - fi - - - name: Generate artifact attestation - if: github.event_name != 'pull_request' - uses: actions/attest-build-provenance@v1 - with: - subject-name: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} - subject-digest: ${{ steps.build.outputs.digest }} - push-to-registry: true + run: python3 scripts/image_release.py select --target "$IMAGE_TARGET" --version "$REQUESTED_VERSION" --ref "$GITHUB_REF" + + publish: + needs: select + strategy: + fail-fast: false + matrix: ${{ fromJSON(needs.select.outputs.matrix) }} + uses: ./.github/workflows/publish-image.yml + with: + target: ${{ matrix.target }} + version: ${{ needs.select.outputs.version }} + update_aliases: ${{ github.event_name == 'push' || github.event_name == 'pull_request' || inputs.update_aliases }} + no_cache: ${{ inputs.no_cache || false }} + secrets: inherit + permissions: + contents: write + packages: write + id-token: write + attestations: write diff --git a/.github/workflows/publish-image.yml b/.github/workflows/publish-image.yml new file mode 100644 index 0000000..5810627 --- /dev/null +++ b/.github/workflows/publish-image.yml @@ -0,0 +1,195 @@ +name: Publish One Image + +on: + workflow_call: + inputs: + target: + type: string + required: true + version: + type: string + default: '' + claude_version: + type: string + default: '' + codex_version: + type: string + default: '' + codex_vsix_version: + type: string + default: '' + t3_version: + type: string + default: '' + update_aliases: + type: boolean + default: true + no_cache: + type: boolean + default: false + check_existing: + type: boolean + default: false + +jobs: + publish: + name: Build and publish ${{ inputs.target }} + runs-on: ubuntu-latest + concurrency: + group: image-${{ github.repository }}-${{ inputs.target }} + cancel-in-progress: false + permissions: + contents: write + packages: write + id-token: write + attestations: write + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Test release selection and dependency tracking + run: python3 -m unittest discover -s scripts -p 'test_*.py' -v + + - name: Set up QEMU + uses: docker/setup-qemu-action@v3 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Log in to GHCR + if: github.event_name != 'pull_request' + uses: docker/login-action@v3 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Resolve this image and its dependencies + id: release + env: + GH_TOKEN: ${{ github.token }} + IMAGE_TARGET: ${{ inputs.target }} + REQUESTED_VERSION: ${{ inputs.version }} + CLAUDE_VERSION: ${{ inputs.claude_version }} + CODEX_VERSION: ${{ inputs.codex_version }} + CODEX_VSIX_VERSION: ${{ inputs.codex_vsix_version }} + T3_VERSION: ${{ inputs.t3_version }} + CHECK_EXISTING: ${{ inputs.check_existing }} + run: | + options=() + if [ "$CHECK_EXISTING" = true ]; then options+=(--check-existing); fi + python3 scripts/image_release.py plan --target "$IMAGE_TARGET" --version "$REQUESTED_VERSION" \ + --claude-version "$CLAUDE_VERSION" --codex-version "$CODEX_VERSION" \ + --codex-vsix-version "$CODEX_VSIX_VERSION" --t3-version "$T3_VERSION" "${options[@]}" + + - name: Verify Claude source for images that include Claude + if: steps.release.outputs.should_build == 'true' && inputs.target != 'codex' + run: .claude/skills/verifying-claude-source/scripts/verify-source.sh + + - name: Generate image labels + id: image_meta + if: steps.release.outputs.should_build == 'true' + uses: docker/metadata-action@v5 + with: + images: ${{ steps.release.outputs.image }} + flavor: latest=false + tags: type=raw,value=${{ steps.release.outputs.snapshot }} + labels: | + org.opencontainers.image.title=${{ inputs.target }} container + org.opencontainers.image.version=${{ steps.release.outputs.version }} + ${{ steps.release.outputs.labels }} + + - name: Build and publish multi-platform image + id: build + if: steps.release.outputs.should_build == 'true' + uses: docker/build-push-action@v5 + with: + context: . + target: ${{ inputs.target }} + platforms: linux/amd64,linux/arm64 + push: ${{ github.event_name != 'pull_request' }} + tags: ${{ steps.release.outputs.image }}:${{ steps.release.outputs.snapshot }} + labels: ${{ steps.image_meta.outputs.labels }} + build-args: | + CLAUDE_VERSION=${{ steps.release.outputs.claude_version }} + CODEX_VERSION=${{ steps.release.outputs.codex_version }} + CODEX_VSIX_VERSION=${{ steps.release.outputs.codex_vsix_version }} + T3CODE_VERSION=${{ steps.release.outputs.t3_version }} + cache-from: type=gha,scope=${{ inputs.target }} + cache-to: type=gha,mode=max,scope=${{ inputs.target }} + no-cache: ${{ inputs.no_cache }} + + - name: Verify Linux runtime, Windows payloads, and installed versions + if: steps.release.outputs.should_build == 'true' && github.event_name != 'pull_request' + env: + IMAGE_TARGET: ${{ inputs.target }} + IMAGE_REF: ${{ steps.release.outputs.image }}@${{ steps.build.outputs.digest }} + EXPECTED_VERSIONS: ${{ steps.release.outputs.versions }} + run: | + scripts/smoke-test-image.sh "$IMAGE_TARGET" "$IMAGE_REF" + docker run --rm --entrypoint cat "$IMAGE_REF" /opt/image-versions.json > /tmp/installed-versions.json + python3 - <<'PY' + import json, os + with open('/tmp/installed-versions.json') as source: + actual = json.load(source) + expected = json.loads(os.environ['EXPECTED_VERSIONS']) + assert actual == expected, (actual, expected) + PY + + - name: Generate artifact attestation + if: steps.release.outputs.should_build == 'true' && github.event_name != 'pull_request' + uses: actions/attest-build-provenance@v1 + with: + subject-name: ${{ steps.release.outputs.image }} + subject-digest: ${{ steps.build.outputs.digest }} + push-to-registry: true + + - name: Guard aliases against historical releases and stale builds + id: aliases + if: steps.release.outputs.should_build == 'true' && github.event_name != 'pull_request' && inputs.update_aliases + env: + IMAGE_TARGET: ${{ inputs.target }} + VERSIONS: ${{ steps.release.outputs.versions }} + REVISION: ${{ steps.release.outputs.revision }} + GH_TOKEN: ${{ github.token }} + run: python3 scripts/image_release.py aliases --target "$IMAGE_TARGET" --versions-json "$VERSIONS" --revision "$REVISION" + + - name: Generate product-specific tags + id: meta + if: steps.release.outputs.should_build == 'true' + uses: docker/metadata-action@v5 + with: + images: ${{ steps.release.outputs.image }} + flavor: latest=false + tags: | + type=semver,pattern={{version}},value=${{ steps.release.outputs.version }} + type=semver,pattern={{major}}.{{minor}},value=${{ steps.release.outputs.version }},enable=${{ steps.aliases.outputs.promote_aliases == 'true' }} + type=semver,pattern={{major}},value=${{ steps.release.outputs.version }},enable=${{ steps.aliases.outputs.promote_aliases == 'true' }} + type=raw,value=latest,enable=${{ steps.aliases.outputs.promote_aliases == 'true' }} + type=raw,value=main,enable=${{ github.ref == 'refs/heads/main' && github.event_name == 'push' && steps.aliases.outputs.promote_aliases == 'true' }} + type=raw,value=${{ steps.release.outputs.snapshot }} + + - name: Promote only verified images to version and alias tags + if: steps.release.outputs.should_build == 'true' && github.event_name != 'pull_request' + env: + IMAGE_TAGS: ${{ steps.meta.outputs.tags }} + IMAGE_REF: ${{ steps.release.outputs.image }}@${{ steps.build.outputs.digest }} + run: | + tag_options=() + while IFS= read -r tag; do + if [ -n "$tag" ]; then tag_options+=(--tag "$tag"); fi + done <<< "$IMAGE_TAGS" + docker buildx imagetools create "${tag_options[@]}" "$IMAGE_REF" + + - name: Record successful publication for scheduled update checks + if: steps.release.outputs.should_build == 'true' && github.event_name != 'pull_request' && steps.aliases.outputs.promote_aliases == 'true' + env: + RELEASE_MARKER: ${{ steps.release.outputs.marker }} + run: | + if ! git show-ref --verify --quiet "refs/tags/$RELEASE_MARKER"; then + git config user.name 'github-actions[bot]' + git config user.email 'github-actions[bot]@users.noreply.github.com' + git tag -a "$RELEASE_MARKER" -m "Published $RELEASE_MARKER" + git push origin "refs/tags/$RELEASE_MARKER" + fi diff --git a/CLAUDE.md b/CLAUDE.md index 3b7e602..77bbd65 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,295 +1,41 @@ -# CLAUDE.md +# Repository guidance -This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. +This repository builds separate Amazon Linux 2023 images for Claude, Codex, and T3 Code. For authentication, VSIX extraction, Windows payloads, desktop display setup, or user-facing commands, read [README.md](README.md). -## Project Overview +## Image changes -This project provides a containerized environment for running Claude Code CLI using Amazon Linux 2023. The container automatically downloads and installs the latest version of Claude Code, making it easy to run in isolated environments. +Use the named `claude`, `codex`, and `t3` targets in [Dockerfile](Dockerfile). An untargeted build defaults to Claude for compatibility. Shared development dependencies belong in `base`; desktop libraries belong in the T3 target. -## Architecture +Claude and Codex remain separate CLI images. T3 includes both providers, its web UI/server and native desktop, plus Windows x64 payloads. Keep the complete Codex Windows package and T3 Windows server bundle intact: their helpers, resources, and web assets are required alongside the executables. -### Container Design +For build-time version changes, keep `/opt/image-versions.json` consistent with the installed binaries and extensions. CI resolves current upstream versions and passes explicit build arguments; Dockerfile defaults support local builds. -- **Base Image**: Amazon Linux 2023 (official AWS Linux distribution) -- **Package Manager**: DNF (Dandified YUM) -- **Claude Code CLI**: Downloaded from Google Cloud Storage bucket at build time -- **Platform Detection**: Automatically detects x64 or ARM64 architecture +Amazon Linux includes `curl-minimal`; installing `curl` directly conflicts with it. The supported Linux architectures are x86_64 and aarch64. The image runs as root, so the desktop Compose service explicitly passes Electron's `--no-sandbox` flag. -### Build Process +## Publishing changes -1. Base image with Amazon Linux 2023 -2. Install dependencies (git, nodejs, npm) -3. Install GitHub CLI (gh) -4. Install pyright (Python static type checker via npm) -5. Download Claude Code binary for the detected platform -6. Download Claude Code VSIX extension (platform-independent) -7. Install CLI to `/usr/local/bin/claude` and VSIX to `/opt/claude-code/claude-code.vsix` -8. Set `/workspace` as working directory -9. Configure entrypoint to run `claude` command +For tag selection, upstream resolution, snapshot naming, or update detection, read [scripts/image_release.py](scripts/image_release.py) and its tests. For execution and promotion order, read [.github/workflows/publish-image.yml](.github/workflows/publish-image.yml). -### Runtime Configuration +Each image uses its own product version and registry namespace. T3 also tracks both provider versions and the Codex VSIX version. A provider update rebuilds T3 without changing T3's primary version. Unique snapshot tags record the full combination and workflow run. -The Docker Compose setup mounts: -- Current directory to `/workspace` (your code) -- `~/.claude` to `/root/.claude` (Claude Code settings) -- Passes through `ANTHROPIC_API_KEY` environment variable +Promote aliases only after runtime/artifact checks and provenance succeed, and only for the current upstream combination at the current `main` revision. Record Git success markers after promotion so failed publications remain eligible for retry. Scheduled checks verify registry aliases as well as the marker; stale aliases require repair even if their version was published before. Checks run independently for each target. Marker tags use the `images/` namespace and do not trigger release builds. -## Common Commands +## Verification -### Using Pre-built Images +For release-planning changes: ```bash -# Pull from GitHub Container Registry -docker pull ghcr.io/zeroae/claude-code:latest - -# Run with pre-built image -docker run --rm -v "$(pwd):/workspace" -e ANTHROPIC_API_KEY ghcr.io/zeroae/claude-code:latest --version - -# Interactive mode with pre-built image -docker run --rm -it -v "$(pwd):/workspace" -v ~/.claude:/root/.claude -e ANTHROPIC_API_KEY ghcr.io/zeroae/claude-code:latest -``` - -### Building and Running Locally - -```bash -# Build the container -docker compose build - -# Run Claude Code -docker compose run --rm claude [arguments] - -# Examples: -docker compose run --rm claude --version -docker compose run --rm claude --help -docker compose run --rm claude # Interactive mode -``` - -### Development - -```bash -# Get a shell inside the container -docker compose run --rm --entrypoint bash claude - -# Test Claude Code installation -docker run --rm claude-code:latest --version - -# Rebuild from scratch -docker compose build --no-cache -``` - -### Customization - -To modify the Dockerfile: -- Change base image in `FROM` line (currently `amazonlinux:2023`) -- Adjust dependencies in the `dnf install` command -- Platform detection is in the Claude Code download RUN step - -## Key Implementation Notes - -### Claude Code Binary Distribution - -Claude Code is distributed via Google Cloud Storage: -- Base URL: `https://storage.googleapis.com/claude-code-dist-86c565f3-f756-42ad-8dfa-d59b1c096819/claude-code-releases` -- Version endpoint: `${BASE_URL}/latest` (returns version string like "2.1.17") -- Binary endpoint: `${BASE_URL}/${VERSION}/${PLATFORM}/claude` - -**Source Discovery**: The GCS bucket URL is extracted from the official install script at `https://claude.ai/install.sh`. This is documented and automated in the Claude Code skill: -- `.claude/skills/verifying-claude-source/` - Self-contained skill with bundled verification script - -To verify the URL is still current: -```bash -# Run the bundled script -.claude/skills/verifying-claude-source/scripts/verify-source.sh - -# Or use the Claude Code skill -/verifying-claude-source -``` - -Supported platforms for Linux: -- `linux-x64` - Standard glibc x86_64 -- `linux-arm64` - Standard glibc ARM64 -- `linux-x64-musl` - Alpine/musl x86_64 -- `linux-arm64-musl` - Alpine/musl ARM64 - -This project uses the standard glibc builds since Amazon Linux uses glibc. - -### Claude Code VSIX Extension - -The container includes the Claude Code VS Code extension (VSIX) at `/opt/claude-code/claude-code.vsix`. - -**VSIX Distribution:** -- Source: Open VSX, `https://open-vsx.org/api/anthropic/claude-code/latest` (the `files.download` URL in the response) -- The VSIX is not on the release host (`downloads.claude.ai`), so it is no longer tied to the CLI version -- Platform: Universal (works on all architectures) -- The build warns and continues if the VSIX can't be fetched; `vsix=no` in the build log means it's missing - -**Extract VSIX from container:** -```bash -# Using docker run with pre-built image -docker run --rm -v "$(pwd):/output" --entrypoint cp \ - ghcr.io/zeroae/claude-code:latest \ - /opt/claude-code/claude-code.vsix /output/claude-code.vsix - -# Using docker compose -docker compose run --rm -v "$(pwd):/output" --entrypoint cp claude \ - /opt/claude-code/claude-code.vsix /output/claude-code.vsix +python3 -m unittest discover -s scripts -p 'test_*.py' -v ``` -**Install in VS Code:** -```bash -# Install the extracted VSIX -code --install-extension ./claude-code.vsix - -# Or install directly from VS Code -# Extensions: Install from VSIX... (Cmd+Shift+P) -``` +After rebuilding an affected target: -**Verify VSIX in container:** ```bash -# Check VSIX exists and size -docker compose run --rm --entrypoint ls claude -lh /opt/claude-code/claude-code.vsix - -# Verify it's a valid zip archive -docker compose run --rm --entrypoint file claude /opt/claude-code/claude-code.vsix +scripts/smoke-test-image.sh claude claude-code:latest +scripts/smoke-test-image.sh codex codex:latest +scripts/smoke-test-image.sh t3 t3-code:latest ``` -### Amazon Linux Package Management - -Amazon Linux 2023 includes these packages by default: -- `bash` -- `curl` (as curl-minimal) -- `ca-certificates` - -Installed packages: -- `git` - For repository operations -- `nodejs` - Runtime dependency for Claude Code -- `npm` - Package manager (comes with nodejs) -- `file` - File type identification utility -- `which` - Utility to locate commands -- `gh` - GitHub CLI for GitHub API operations -- `pyright` - Python static type checker (installed via npm) - -### API Key Configuration - -Claude Code requires an Anthropic API key. This can be provided via: -1. Environment variable: `ANTHROPIC_API_KEY` -2. Config file: `~/.claude/config.json` -3. Interactive login: `claude login` - -The Docker Compose setup handles option 1 automatically by passing through the environment variable. - -## Dependencies - -### Build Time -- Docker 20.10+ -- Internet connection (to download Claude Code binary) - -### Runtime -- Docker 20.10+ -- Docker Compose 2.0+ -- Anthropic API key - -## Troubleshooting - -### Build Issues - -**Problem**: DNF package conflicts -**Solution**: Amazon Linux pre-installs curl-minimal which conflicts with curl. Remove curl from the dependency list or use `dnf install -y --allowerasing curl`. - -**Problem**: Architecture detection fails -**Solution**: The Dockerfile only supports x86_64 and aarch64. Other architectures need manual platform specification. - -### Runtime Issues - -**Problem**: API key not found -**Solution**: Set `ANTHROPIC_API_KEY` environment variable or mount `~/.claude` with your configuration. - -**Problem**: Permission denied on workspace files -**Solution**: The container runs as root. Files created will be owned by root. Consider adding a user or using `--user` flag. - -### Version Updates - -To update to the latest Claude Code version: -```bash -docker compose build --no-cache -``` - -The build process fetches the latest version automatically from the `/latest` endpoint. - -## CI/CD - -### Automated Releases - -The container images are automatically built and published when new Claude Code versions are released. - -**Process:** -1. GitHub Actions checks daily for new Claude Code versions -2. When a new version is detected (e.g., `2.1.17`): - - Creates a git tag with the version number (e.g., `v2.1.17`) - - Triggers container build for both amd64 and arm64 - - Publishes images with multiple tags: - - `v2.1.17` (exact version) - - `v2.1` (minor version) - - `v2` (major version) - - `latest` (always points to latest release) - -**Image Tags:** -```bash -# Use specific version -docker pull ghcr.io/zeroae/claude-code:v2.1.17 - -# Use minor version (receives patch updates) -docker pull ghcr.io/zeroae/claude-code:v2.1 - -# Use major version (receives minor and patch updates) -docker pull ghcr.io/zeroae/claude-code:v2 - -# Use latest (always latest stable release) -docker pull ghcr.io/zeroae/claude-code:latest -``` - -**Manual Version Check:** -To manually trigger a version check without waiting for the daily schedule: -1. Go to Actions → Check Claude Code Version -2. Click "Run workflow" -3. If a new version exists, a git tag will be created and build will trigger - -### GitHub Actions Workflows - -The project includes two GitHub Actions workflows: - -#### Version Detection ([.github/workflows/check-claude-version.yml](.github/workflows/check-claude-version.yml)) - -Automatically detects new Claude Code versions and creates git tags: - -- **Schedule**: Runs daily at midnight UTC -- **Manual Trigger**: Can be manually triggered via workflow_dispatch -- **Process**: Fetches latest version from GCS bucket, compares against existing tags, creates new tag if needed - -#### Build and Publish ([.github/workflows/publish-container.yml](.github/workflows/publish-container.yml)) - -Builds and publishes container images: - -1. **Verifies Claude Code source URL** - Runs the bundled verification script to ensure the Dockerfile uses the current GCS bucket URL -2. **Builds multi-platform images** - Creates images for both `linux/amd64` and `linux/arm64` -3. **Publishes to GitHub Container Registry** - Pushes images to `ghcr.io/zeroae/claude-code` -4. **Generates attestations** - Creates build provenance attestations for security - -**Triggers:** -- Push to `main` branch → publishes as `main` tag -- Push version tags (e.g., `v2.1.17`) → publishes as `v2.1.17`, `v2.1`, `v2`, and `latest` tags -- Pull requests → builds but doesn't publish (for testing) -- Manual workflow dispatch → can be triggered manually - -**Published tags:** -- `vX.Y.Z` - Specific Claude Code version (e.g., `v2.1.17`) -- `vX.Y` - Minor version (e.g., `v2.1`) -- `vX` - Major version (e.g., `v2`) -- `latest` - Latest stable release -- `main` - Latest commit on main branch - -**Permissions required:** -- `contents: read` - Read repository contents -- `packages: write` - Publish to GitHub Container Registry +Smoke tests verify Linux startup, image separation, VSIX identity/platform, Windows executable formats, and T3 web assets. Windows binaries require a Windows host for execution tests. -The workflow uses `GITHUB_TOKEN` for authentication, which is automatically provided by GitHub Actions. +For Claude download-source changes, use `.claude/skills/verifying-claude-source/scripts/verify-source.sh`. It verifies the Dockerfile's `GCS_BUCKET` variable against the official installer; the variable name is retained even though the release host is now `downloads.claude.ai`. diff --git a/Dockerfile b/Dockerfile index 20c6871..8a47dbf 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,5 +1,27 @@ -FROM amazonlinux:2023 +ARG T3CODE_VERSION=0.1.9 +ARG CODEX_VERSION=0.169.0 +# Unpack the desktop package on the builder's architecture. Executing a target +# AppImage extractor fails under QEMU, even when ordinary target binaries work. +FROM --platform=$BUILDPLATFORM amazonlinux:2023 AS t3-desktop +ARG T3CODE_VERSION +ARG TARGETARCH +RUN dnf install -y binutils tar xz && dnf clean all +WORKDIR /tmp +RUN set -eu; \ + case "$TARGETARCH" in amd64|arm64) ;; *) echo "Unsupported architecture" >&2; exit 1 ;; esac; \ + curl -fsSL "https://github.com/pingdotgg/t3code/releases/download/v${T3CODE_VERSION}/T3-Code-${T3CODE_VERSION}-${TARGETARCH}.deb" -o /tmp/t3.deb; \ + ar p /tmp/t3.deb data.tar.xz > /tmp/data.tar.xz; \ + mkdir -p /tmp/desktop; \ + tar -xJf /tmp/data.tar.xz -C /tmp/desktop ./opt; \ + set -- /tmp/desktop/opt/*; \ + test "$#" -eq 1; \ + mv "$1" /tmp/t3-desktop; \ + test -x /tmp/t3-desktop/t3code; \ + test -s /tmp/t3-desktop/resources/app.asar; \ + rm /tmp/t3.deb /tmp/data.tar.xz + +FROM amazonlinux:2023 AS base # Install dependencies RUN dnf install -y \ git \ @@ -7,6 +29,9 @@ RUN dnf install -y \ npm \ file \ which \ + tar \ + gzip \ + unzip \ && dnf clean all # Install GitHub CLI @@ -17,6 +42,9 @@ RUN curl -fsSL https://cli.github.com/packages/rpm/gh-cli.repo | tee /etc/yum.re # Install pyright (Python static type checker) RUN npm install -g pyright +WORKDIR /workspace + +FROM base AS claude # Download and install Claude Code CLI, VSIX extension, and Windows binaries ARG CLAUDE_VERSION=latest RUN set -eu; \ @@ -55,9 +83,100 @@ RUN set -eu; \ "vsix=$( [ -f /opt/claude-code/claude-code.vsix ] && echo yes || echo no );" \ "win32=$( [ -f /opt/claude-code/win32-x64/claude.exe ] && echo yes || echo no ))" -# Set up working directory -WORKDIR /workspace - -# Run Claude Code +RUN /usr/local/bin/claude --version | node -e 'let s="";process.stdin.on("data",d=>s+=d).on("end",()=>require("fs").writeFileSync("/opt/image-versions.json",JSON.stringify({claude:s.trim().split(/\s+/)[0]})))' ENTRYPOINT ["/usr/local/bin/claude"] CMD ["--help"] + +# Keep the complete Windows package, including helper executables/resources. +FROM base AS codex-windows +ARG CODEX_VERSION +RUN set -eu; \ + mkdir -p /opt/codex/win32-x64 /tmp/codex-windows; \ + cd /tmp/codex-windows; \ + ARCHIVE=$(npm pack --quiet "@openai/codex@${CODEX_VERSION}-win32-x64"); \ + tar -xzf "$ARCHIVE"; \ + cp -a package/vendor/x86_64-pc-windows-msvc/. /opt/codex/win32-x64/; \ + test -s /opt/codex/win32-x64/bin/codex.exe + +FROM base AS t3-windows +ARG T3CODE_VERSION +RUN set -eu; \ + RELEASE_URL="https://github.com/pingdotgg/t3code/releases/download/v${T3CODE_VERSION}"; \ + ARCHIVE="t3-${T3CODE_VERSION}-win32-x64.zip"; \ + mkdir -p /opt/t3-code/win32-x64; \ + cd /tmp; \ + curl -fsSL "${RELEASE_URL}/SHA256SUMS" -o SHA256SUMS; \ + curl -fsSL "${RELEASE_URL}/${ARCHIVE}" -o "$ARCHIVE"; \ + grep " ${ARCHIVE}$" SHA256SUMS | sha256sum -c -; \ + unzip -tq "$ARCHIVE"; \ + unzip -q "$ARCHIVE" -d /opt/t3-code/win32-x64; \ + curl -fsSL "${RELEASE_URL}/latest.yml" -o desktop-release.yml; \ + curl -fsSL "${RELEASE_URL}/T3-Code-${T3CODE_VERSION}-x64.exe" -o /opt/t3-code/win32-x64/T3-Code-Setup.exe; \ + node -e 'const fs=require("fs"),crypto=require("crypto");const metadata=fs.readFileSync("desktop-release.yml","utf8");const expected=metadata.match(/url: T3-Code-[^\n]+-x64\.exe\s+sha512: (\S+)/)?.[1];const actual=crypto.createHash("sha512").update(fs.readFileSync("/opt/t3-code/win32-x64/T3-Code-Setup.exe")).digest("base64");if(!expected||actual!==expected)throw Error("T3 desktop checksum mismatch");console.log("Windows T3 desktop checksum verified")' + +FROM base AS codex-runtime +ARG CODEX_VERSION +RUN npm install -g "@openai/codex@${CODEX_VERSION}" && codex --version + +FROM codex-runtime AS codex +# The Codex extension ships native binaries, so select the image architecture. +ARG CODEX_VSIX_VERSION=26.51111.21308 +RUN set -eu; \ + case "$(uname -m)" in \ + x86_64) VSIX_PLATFORM=linux-x64 ;; \ + aarch64) VSIX_PLATFORM=linux-arm64 ;; \ + *) echo "Unsupported architecture" >&2; exit 1 ;; \ + esac; \ + mkdir -p /opt/codex; \ + VSIX_URL=$(curl -fsSL "https://open-vsx.org/api/openai/chatgpt/${VSIX_PLATFORM}/${CODEX_VSIX_VERSION}" \ + | node -p 'JSON.parse(require("fs").readFileSync(0, "utf8")).files.download'); \ + curl -fsSL "$VSIX_URL" -o /opt/codex/codex.vsix; \ + unzip -tq /opt/codex/codex.vsix; \ + unzip -p /opt/codex/codex.vsix extension/package.json \ + | node -e 'let s="";process.stdin.on("data",d=>s+=d).on("end",()=>{const p=JSON.parse(s);if(p.publisher!=="openai"||p.name!=="chatgpt")process.exit(1);console.log("Codex VSIX",p.version)})' +COPY --from=codex-windows /opt/codex/win32-x64 /opt/codex/win32-x64 +RUN set -eu; \ + VSIX_URL=$(curl -fsSL "https://open-vsx.org/api/openai/chatgpt/win32-x64/${CODEX_VSIX_VERSION}" \ + | node -p 'JSON.parse(require("fs").readFileSync(0, "utf8")).files.download'); \ + curl -fsSL "$VSIX_URL" -o /opt/codex/win32-x64/codex.vsix; \ + unzip -tq /opt/codex/win32-x64/codex.vsix +RUN codex --version > /tmp/codex-version && \ + unzip -p /opt/codex/codex.vsix extension/package.json \ + | node -e 'let s="";process.stdin.on("data",d=>s+=d).on("end",()=>{const fs=require("fs");fs.writeFileSync("/opt/image-versions.json",JSON.stringify({codex:fs.readFileSync("/tmp/codex-version","utf8").trim().split(/\s+/)[1],codex_vsix:JSON.parse(s).version}))})' && \ + rm /tmp/codex-version +ENTRYPOINT ["codex"] +CMD [] + +FROM codex-runtime AS t3 +COPY --from=claude /usr/local/bin/claude /usr/local/bin/claude +# Self-contained T3 Code server, including its browser UI. Keep the runtime +# outside /root/.t3 so mounting persistent user data does not hide the install. +ARG T3CODE_VERSION +RUN curl -fsSL https://t3.codes/install.sh -o /tmp/install-t3.sh && \ + T3CODE_HOME=/opt/t3-code T3CODE_INSTALL_BIN_DIR=/usr/local/bin \ + T3CODE_VERSION="${T3CODE_VERSION}" sh /tmp/install-t3.sh && \ + rm /tmp/install-t3.sh && \ + t3 --version + +# Electron desktop dependencies (GTK pulls in the X11 and accessibility libs). +RUN dnf install -y gtk3 nss alsa-lib cups-libs mesa-libgbm libxkbcommon \ + libXScrnSaver libXtst dejavu-sans-fonts xdg-utils desktop-file-utils \ + shared-mime-info && dnf clean all +COPY --from=t3-desktop /tmp/t3-desktop /opt/t3-desktop +RUN ln -s /opt/t3-desktop/t3code /usr/local/bin/t3-desktop + +# Windows payloads can be extracted for use on a Windows x64 host. +COPY --from=codex /opt/codex/win32-x64 /opt/codex/win32-x64 +COPY --from=claude /opt/claude-code/win32-x64 /opt/claude-code/win32-x64 +COPY --from=t3-windows /opt/t3-code/win32-x64 /opt/t3-code/win32-x64 +COPY --from=claude /opt/image-versions.json /tmp/claude-versions.json +COPY --from=codex /opt/image-versions.json /tmp/codex-versions.json +RUN node -e 'const fs=require("fs"),cp=require("child_process");const versions={...JSON.parse(fs.readFileSync("/tmp/claude-versions.json")),...JSON.parse(fs.readFileSync("/tmp/codex-versions.json")),t3:cp.execFileSync("t3",["--version"],{encoding:"utf8"}).trim().split(/\s+/).pop().replace(/^v/,"")};fs.writeFileSync("/opt/image-versions.json",JSON.stringify(versions))' && \ + rm /tmp/claude-versions.json /tmp/codex-versions.json + +EXPOSE 3773 +ENTRYPOINT ["t3"] +CMD ["serve", "--host", "0.0.0.0", "--port", "3773"] + +# Keep an untargeted docker build compatible with the original Claude image. +FROM claude AS default diff --git a/README.md b/README.md index 170b865..d50b2dd 100644 --- a/README.md +++ b/README.md @@ -1,23 +1,31 @@ -# Claude Code Container +# Coding Agent Containers -Run Claude Code in a containerized environment using Amazon Linux 2023. +Three separate Amazon Linux 2023 images share the same development tools: + +| Docker target | Local image | Published image | Contents | +| --- | --- | --- | --- | +| `claude` (default) | `claude-code:latest` | `ghcr.io/zeroae/claude-code` | Claude CLI, Claude VSIX, optional Windows CLI | +| `codex` | `codex:latest` | `ghcr.io/zeroae/codex` | Codex CLI, Linux VSIX, Windows x64 CLI bundle and VSIX | +| `t3` | `t3-code:latest` | `ghcr.io/zeroae/t3-code` | T3 server, browser UI, native desktop, Codex and Claude CLIs, plus Windows x64 payloads | + +The additional registry images become available when these changes are published. Both T3 interfaces use the same T3 image; the desktop service overrides its server entrypoint. ## Quick Start ### Using pre-built images from GitHub Container Registry -Pre-built multi-architecture images are available from GitHub Container Registry. Images are automatically built daily when new Claude Code versions are released. +Multi-architecture images are published to GitHub Container Registry. Each product has its own version checks and tags; T3 also rebuilds when its bundled providers change. ```bash # Pull specific version -docker pull ghcr.io/zeroae/claude-code:v2.1.17 +docker pull ghcr.io/zeroae/claude-code:2.1.17 # Pull latest stable release docker pull ghcr.io/zeroae/claude-code:latest # Run with specific version docker run --rm -v "$(pwd):/workspace" -e ANTHROPIC_API_KEY \ - ghcr.io/zeroae/claude-code:v2.1.17 --version + ghcr.io/zeroae/claude-code:2.1.17 --version # Start an interactive session with latest docker run --rm -it \ @@ -28,17 +36,17 @@ docker run --rm -it \ ``` **Available Tags:** -- `vX.Y.Z` - Specific version (e.g., `v2.1.17`) -- `vX.Y` - Minor version, receives patch updates (e.g., `v2.1`) -- `vX` - Major version, receives minor and patch updates (e.g., `v2`) +- `X.Y.Z` - Specific product version (e.g., `2.1.325`, `0.169.0`, or `0.1.9`) +- `X.Y` - Minor version alias (e.g., `2.1`, `0.169`, or `0.1`) +- `X` - Major version alias (e.g., `2` or `0`) - `latest` - Latest stable release - `main` - Latest commit on main branch (development) ### Building locally ```bash -# Build the container -docker compose build +# Build all three images +docker compose --profile codex --profile t3 build claude codex t3 # Run Claude Code (with your workspace mounted) docker compose run --rm claude --version @@ -51,7 +59,7 @@ docker compose run --rm claude - Docker 20.10+ - Docker Compose 2.0+ -- Anthropic API key (set as environment variable) +- Provider authentication: Claude or Codex login, or a supported API key ## Configuration @@ -95,16 +103,118 @@ claude --version ## Architecture - **Base Image**: Amazon Linux 2023 -- **Claude Code Version**: 2.1.17 (automatically fetches latest) +- **Claude Code Version**: latest by default; override `CLAUDE_VERSION` at build time - **Platform**: Linux x64/arm64 (auto-detected) -- **Source**: Google Cloud Storage (verified against official install script) -- **Included Tools**: git, nodejs, npm, file, which, gh (GitHub CLI), pyright +- **Claude Source**: `downloads.claude.ai` (verified against official install script) +- **Shared Tools**: git, nodejs, npm, file, which, tar, gzip, unzip, gh (GitHub CLI), pyright +- **Image Targets**: Claude and Codex each contain their own CLI; T3 contains both CLIs and both T3 UIs + +## Codex CLI + +Build the Codex image, then authenticate once using device login. Compose stores Codex settings and credentials in the `codex-state` volume, shared with T3 Code. + +```bash +docker compose build codex +docker compose run --rm codex login --device-auth +docker compose run --rm codex +``` + +After publishing, run the Codex image directly: + +```bash +docker run --rm -it -v "$(pwd):/workspace" -v codex-state:/root/.codex \ + ghcr.io/zeroae/codex:latest +``` + +### Codex VS Code extension + +The official Codex extension is `openai.chatgpt`. Its VSIX is bundled at `/opt/codex/codex.vsix`, fetched from Open VSX for `linux-x64` or `linux-arm64` to match the image. The build verifies its ZIP integrity and extension identity. The extension version is pinned separately from the CLI. + +```bash +docker compose run --rm --entrypoint cp codex /opt/codex/codex.vsix /workspace/codex.vsix +code --install-extension ./codex.vsix +``` + +Install this Linux VSIX in a compatible Linux VS Code environment, such as a remote container. For Windows x64, extract `/opt/codex/win32-x64/codex.vsix` instead. For macOS, install the matching extension from the editor's Marketplace. Bundling the VSIX does not install a VS Code editor in the image. + +## Bundled Windows x64 artifacts + +All images run on Linux; their Windows payloads are included for extraction and use on a Windows x64 machine, regardless of the Linux image's architecture. + +| Image | Path | Windows payload | +| --- | --- | --- | +| Claude | `/opt/claude-code/win32-x64/claude.exe` | Claude CLI (existing optional download) | +| Codex and T3 | `/opt/codex/win32-x64/` | Full Codex package: `bin/codex.exe`, helper executables, package metadata/resources, and `codex.vsix` | +| T3 | `/opt/claude-code/win32-x64/claude.exe` | Claude CLI | +| T3 | `/opt/t3-code/win32-x64/` | Complete T3 server bundle, its web UI, and `T3-Code-Setup.exe` desktop installer | + +```bash +# Extract the complete Codex Windows bundle, preserving its directory layout. +docker compose run --rm --entrypoint cp codex -a /opt/codex/win32-x64 /workspace/codex-windows-x64 + +# Extract T3's Windows server/desktop and its two providers. +docker compose run --rm --entrypoint cp t3 -a /opt/t3-code/win32-x64 /workspace/t3-windows-x64 +docker compose run --rm --entrypoint cp t3 -a /opt/codex/win32-x64 /workspace/codex-windows-x64 +docker compose run --rm --entrypoint cp t3 -a /opt/claude-code/win32-x64 /workspace/claude-windows-x64 +``` + +On Windows, keep each bundle intact and add the Codex `bin` directory and the Claude directory to `PATH`. Then run the extracted `t3.exe serve` for the server/web UI, or install the desktop app using `T3-Code-Setup.exe`. Install `codex.vsix` with VS Code's **Extensions: Install from VSIX** command. + +New Windows downloads are required: a missing artifact fails the build or smoke test. T3's server ZIP and desktop installer are verified against release checksums. Publishing checks verify Windows executable formats, VSIX identity/platform, and the Windows server's web assets. Windows execution itself requires a Windows host. + +## T3 Code server and browser UI + +The self-contained T3 Code distribution includes the server and web UI; no desktop display is needed in the container. + +```bash +docker compose --profile t3 up -d --build t3 +docker compose logs t3 +``` + +Open `http://localhost:3773` in your browser. If prompted, open `http://localhost:3773/pair#token=TOKEN` using the token printed in the server logs (the logged URL may use the container's internal IP). The port is published only on the Docker host's loopback interface. T3 settings and history persist in the `t3-state` volume. Codex authentication is shared with the `codex` service; Claude settings use the existing `~/.claude` mount. + +```bash +docker compose exec t3 t3 --version +docker compose --profile t3 down +``` + +### Native desktop app + +The T3 image also includes the Linux Electron desktop app as `t3-desktop`, unpacked from the official Debian package with native build tools, so cross-platform builds need neither a target-architecture extractor nor FUSE. The separate `t3` command runs the standalone server. + +On a Linux host running X11 or XWayland, pass the display and its Xauthority file: + +```bash +export XAUTHORITY="${XAUTHORITY:-$HOME/.Xauthority}" +docker compose run --build --rm t3-desktop +``` + +The `desktop` Compose profile mounts the host's X11 socket and authentication file, shares Codex credentials, and persists desktop settings in `t3-desktop-state`. It uses `--no-sandbox` because this image runs as root. A host display is required; Docker Desktop on macOS/Windows needs a separately configured X server. The browser UI above works without a host display. + +Claude and Codex are installed and available on `PATH` in the T3 image. Select either in **Settings → Providers**. Codex uses the shared login created above. For Claude, set `ANTHROPIC_API_KEY` or sign in using the mounted Claude configuration: + +```bash +docker compose run --rm --entrypoint claude t3 auth login +``` + +Provider login is required; credentials are supplied at runtime and are not baked into an image. + +Local builds use the Dockerfile defaults for Codex, its VSIX, and T3. CI resolves current stable upstream versions independently and passes explicit build arguments. Override local build arguments to pin a different combination: + +```bash +docker build --target codex --build-arg CODEX_VERSION=0.169.0 \ + --build-arg CODEX_VSIX_VERSION=26.51111.21308 -t codex:latest . +docker build --target t3 --build-arg CODEX_VERSION=0.169.0 \ + --build-arg T3CODE_VERSION=0.1.9 -t t3-code:latest . +``` + +Installation references: [Codex CLI](https://learn.chatgpt.com/docs/codex/cli), [T3 Code](https://github.com/pingdotgg/t3code/blob/main/docs/user/install.md). ## Maintenance ### Verify Claude Code Source URL -The Dockerfile downloads Claude Code from a Google Cloud Storage bucket. To verify the URL is still current: +The Dockerfile downloads Claude Code from its official release host. To verify the URL is still current: **Using the verification script:** ```bash @@ -121,28 +231,70 @@ The Dockerfile downloads Claude Code from a Google Cloud Storage bucket. To veri This compares the Dockerfile URL against the official install script at `https://claude.ai/install.sh`. -## Container Registry +## Publishing and independent version checks + +| Image | Product version example | Upstream version source | +| --- | --- | --- | +| `ghcr.io/zeroae/claude-code` | `2.1.325` | Claude release host | +| `ghcr.io/zeroae/codex` | `0.169.0` | `@openai/codex` on npm | +| `ghcr.io/zeroae/t3-code` | `0.1.9` | Stable `pingdotgg/t3code` GitHub release | + +Each image gets its own product-version tag, major/minor aliases, and `latest`. Pushes to `main` also update `main`. Docker tags have no leading `v`. -Published images are available at: -- **Registry**: `ghcr.io/zeroae/claude-code` -- **Automated Releases**: New images are automatically built daily when Claude Code releases new versions -- **Version Tags**: Container image tags match Claude Code versions (e.g., `v2.1.17`) -- **Flexible Versioning**: Pin to specific versions (`v2.1.17`), minor versions (`v2.1`), major versions (`v2`), or latest +**Check Agent Versions** runs twice daily at 06:37 and 18:37 UTC, with independent jobs for each image: -**Automated Process:** -1. GitHub Actions checks daily for new Claude Code versions -2. When detected, creates a git tag matching the version (e.g., `v2.1.17`) -3. Triggers multi-platform build (amd64 and arm64) -4. Publishes with multiple tags for version flexibility +- Claude updates rebuild Claude and T3. +- Codex CLI updates rebuild Codex and T3. +- Codex VSIX updates (tracked through Open VSX) rebuild Codex and T3. +- T3 releases rebuild T3. -**Workflows:** -- [check-claude-version.yml](.github/workflows/check-claude-version.yml) - Daily version detection -- [publish-container.yml](.github/workflows/publish-container.yml) - Build and publish +The plain version tags represent the current bundle for that product version. For example, `t3-code:0.1.9` can receive updated providers while T3 remains at 0.1.9. Every build also gets a unique snapshot tag containing the dependency versions, source commit, workflow run, and attempt. A T3 snapshot looks like: + +```text +0.1.9-claude2.1.325-codex0.169.0-vsix26.51111.21308-rd2a0eecc17d9-b123456789-1 +``` + +Installed versions are recorded in `/opt/image-versions.json` in every image and in `io.zeroae.version.*` OCI labels on published images: + +```bash +docker run --rm --entrypoint cat ghcr.io/zeroae/t3-code:latest /opt/image-versions.json +``` + +The publisher first pushes the unique snapshot, checks the Linux runtime and Windows artifacts, validates installed versions, and generates provenance. It then promotes the verified digest to the product version tag. Major/minor aliases, `latest`, and `main` move only when the bundle matches current upstream versions and the current `main` commit; historical releases cannot move those aliases backward. + +A successful alias publication is recorded with an `images//...` Git marker containing dependency versions and the source revision. Scheduled checks skip only when that marker exists and all product/major/minor/`latest` tags still have the expected versions and revision on both Linux platforms. Missing or stale tags trigger a rebuild and repair even when a success marker exists. Registry access errors fail the check rather than being treated as missing images. Changes to image source also produce new snapshots. Marker tags do not trigger builds. + +### Manual publishing + +Use **Publish Containers → Run workflow** and choose `claude`, `codex`, `t3`, or `all`. An explicit `version` applies only to the selected product and requires a single target. Use `update_aliases=false` for historical rebuilds that should leave `latest`, major, and minor aliases untouched. + +Source release tags select only their product: + +- `v2.1.325` → Claude +- `codex-v0.169.0` → Codex +- `t3-v0.1.9` → T3, with current provider versions + +The reusable publisher also accepts explicit provider and VSIX version inputs for a pinned T3 rebuild. Pull requests build all targets without publishing. + +### Workflows and checks + +- [check-agent-versions.yml](.github/workflows/check-agent-versions.yml): independent scheduled update checks. +- [publish-container.yml](.github/workflows/publish-container.yml): select targets for pushes, PRs, and manual builds. +- [publish-image.yml](.github/workflows/publish-image.yml): resolve dependencies, build, verify, attest, and promote one image. +- [image_release.py](scripts/image_release.py): shared version discovery and tag planning. + +```bash +python3 -m unittest discover -s scripts -p 'test_*.py' -v +scripts/smoke-test-image.sh claude claude-code:latest +scripts/smoke-test-image.sh codex codex:latest +scripts/smoke-test-image.sh t3 t3-code:latest +``` ## Files - [Dockerfile](Dockerfile) - Container definition - [docker-compose.yml](docker-compose.yml) - Docker Compose configuration +- [scripts/smoke-test-image.sh](scripts/smoke-test-image.sh) - CLI, extension, and server/UI checks - [.github/workflows/publish-container.yml](.github/workflows/publish-container.yml) - CI/CD workflow - [CLAUDE.md](CLAUDE.md) - Project documentation for Claude Code diff --git a/docker-compose.yml b/docker-compose.yml index 7a77410..5551a02 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -3,11 +3,64 @@ services: build: context: . dockerfile: Dockerfile + target: claude image: claude-code:latest volumes: - .:/workspace - ~/.claude:/root/.claude environment: - - ANTHROPIC_API_KEY=${ANTHROPIC_API_KEY} + - ANTHROPIC_API_KEY=${ANTHROPIC_API_KEY:-} stdin_open: true tty: true + codex: + profiles: [codex] + build: + context: . + target: codex + image: codex:latest + volumes: + - .:/workspace + - codex-state:/root/.codex + stdin_open: true + tty: true + t3: + profiles: [t3] + build: + context: . + target: t3 + image: t3-code:latest + ports: + - "127.0.0.1:3773:3773" + volumes: + - .:/workspace + - ~/.claude:/root/.claude + - codex-state:/root/.codex + - t3-state:/root/.t3 + environment: + - ANTHROPIC_API_KEY=${ANTHROPIC_API_KEY:-} + t3-desktop: + profiles: [desktop] + build: + context: . + target: t3 + image: t3-code:latest + entrypoint: [t3-desktop] + # Electron requires this when launched as the image's default root user. + command: [--no-sandbox] + shm_size: 1gb + volumes: + - .:/workspace + - ~/.claude:/root/.claude + - codex-state:/root/.codex + - t3-desktop-state:/root/.config + - /tmp/.X11-unix:/tmp/.X11-unix:ro + - ${XAUTHORITY:-/dev/null}:/tmp/.Xauthority:ro + environment: + - DISPLAY=${DISPLAY:-:0} + - XAUTHORITY=/tmp/.Xauthority + - ANTHROPIC_API_KEY=${ANTHROPIC_API_KEY:-} + +volumes: + codex-state: + t3-state: + t3-desktop-state: diff --git a/scripts/image_release.py b/scripts/image_release.py new file mode 100644 index 0000000..602a354 --- /dev/null +++ b/scripts/image_release.py @@ -0,0 +1,210 @@ +#!/usr/bin/env python3 +"""Resolve product releases and plan image tags without publishing anything.""" +import argparse +import json +import os +import re +import subprocess +import urllib.request + +PRODUCTS = { + "claude": ("claude-code", ("claude",)), + "codex": ("codex", ("codex", "codex_vsix")), + "t3": ("t3-code", ("t3", "claude", "codex", "codex_vsix")), +} +SOURCES = { + "claude": "https://downloads.claude.ai/claude-code-releases/latest", + "codex": "https://registry.npmjs.org/@openai%2fcodex/latest", + "codex_vsix": "https://open-vsx.org/api/openai/chatgpt/linux-x64/latest", + "t3": "https://api.github.com/repos/pingdotgg/t3code/releases/latest", +} +VERSION = re.compile(r"\d+\.\d+\.\d+(?:-[A-Za-z0-9.-]+)?\Z") + + +def validate_version(value): + value = value.strip().removeprefix("v") + if not VERSION.fullmatch(value): + raise ValueError(f"Invalid release version: {value!r}") + return value + + +def fetch_version(product): + headers = {"User-Agent": "zeroae-agent-images"} + if product == "t3" and os.environ.get("GH_TOKEN"): + headers["Authorization"] = f"Bearer {os.environ['GH_TOKEN']}" + request = urllib.request.Request(SOURCES[product], headers=headers) + with urllib.request.urlopen(request, timeout=60) as response: + body = response.read().decode() + if product == "claude": + return body + data = json.loads(body) + return data["tag_name" if product == "t3" else "version"] + + +def select_targets(target="all", version="", ref=""): + if target not in (*PRODUCTS, "all"): + raise ValueError(f"Unknown image target: {target}") + if ref.startswith("refs/tags/"): + tag = ref.removeprefix("refs/tags/") + for prefix, product in (("codex-v", "codex"), ("t3-v", "t3"), ("v", "claude")): + if tag.startswith(prefix): + return {"target": [product]}, validate_version(tag[len(prefix):]) + raise ValueError(f"Unsupported release tag: {tag}") + if version and target == "all": + raise ValueError("A version override requires one explicit image target") + return {"target": list(PRODUCTS) if target == "all" else [target]}, ( + validate_version(version) if version else "" + ) + + +def resolve_versions(target, overrides=None, fetch=None): + overrides = overrides or {} + fetch = fetch or fetch_version + return { + product: validate_version(overrides.get(product) or fetch(product)) + for product in PRODUCTS[target][1] + } + + +def release_plan(target, versions, owner, revision, build_id=""): + if not re.fullmatch(r"[A-Za-z0-9-]+", owner): + raise ValueError("Invalid registry owner") + if not re.fullmatch(r"[0-9a-f]{40}", revision): + raise ValueError("Expected a full Git commit SHA") + versions = {key: validate_version(versions[key]) for key in PRODUCTS[target][1]} + primary = versions[target] + snapshot = primary + if target == "t3": + snapshot += f"-claude{versions['claude']}-codex{versions['codex']}" + if "codex_vsix" in versions: + snapshot += f"-vsix{versions['codex_vsix']}" + snapshot += f"-r{revision[:12]}" + marker = f"images/{target}/{snapshot}" + if build_id: + if not re.fullmatch(r"\d+-\d+", build_id): + raise ValueError("Invalid workflow run identifier") + snapshot += f"-b{build_id}" + if len(snapshot) > 128: + raise ValueError("Dependency snapshot exceeds the Docker tag length limit") + return { + "target": target, + "image": f"ghcr.io/{owner.lower()}/{PRODUCTS[target][0]}", + "version": primary, + "revision": revision, + "snapshot": snapshot, + "marker": marker, + "versions": versions, + "labels": "\n".join( + f"io.zeroae.version.{key.replace('_', '-')}={value}" + for key, value in versions.items() + ), + **{f"{key}_version": versions.get(key, "") for key in SOURCES}, + } + + +def marker_exists(marker): + result = subprocess.run(["git", "show-ref", "--verify", "--quiet", f"refs/tags/{marker}"]) + if result.returncode not in (0, 1): + raise RuntimeError("Failed to check published release markers") + return result.returncode == 0 + + +def inspect_alias(reference): + result = subprocess.run( + ["docker", "buildx", "imagetools", "inspect", reference, "--format", "{{json .Image}}"], + capture_output=True, text=True, + ) + if result.returncode: + if any(error in result.stderr.lower() for error in ("not found", "manifest unknown", "404")): + return None + raise RuntimeError(f"Cannot inspect {reference}: {result.stderr.strip()}") + return json.loads(result.stdout) + + +def aliases_match(plan, inspect=None): + inspect = inspect or inspect_alias + major, minor, _ = plan["version"].split(".", 2) + aliases = ("latest", plan["version"], f"{major}.{minor}", major) + expected = {f"io.zeroae.version.{key.replace('_', '-')}": value for key, value in plan["versions"].items()} + expected["org.opencontainers.image.revision"] = plan["revision"] + for alias in dict.fromkeys(aliases): + data = inspect(f"{plan['image']}:{alias}") + if not data: + return False + configs = [data] if "config" in data else list(data.values()) + platforms = set() + for config in configs: + platforms.add((config.get("os"), config.get("architecture"))) + labels = (config.get("config") or {}).get("Labels") or {} + if any(labels.get(key) != value for key, value in expected.items()): + return False + if not {("linux", "amd64"), ("linux", "arm64")} <= platforms: + return False + return True + + +def aliases_allowed(versions, revision, main_revision, latest_versions): + return revision == main_revision and versions == latest_versions + + +def write_outputs(values): + if not os.environ.get("GITHUB_OUTPUT"): + return + with open(os.environ["GITHUB_OUTPUT"], "a") as output: + for key, value in values.items(): + if isinstance(value, (dict, list)): + value = json.dumps(value, separators=(",", ":")) + if "\n" in str(value): + output.write(f"{key}< +set -euo pipefail +image_target=${1:?Expected image target} +image_ref=${2:?Expected image reference} +case "$image_target" in claude|codex|t3) ;; *) echo "Unknown target: $image_target" >&2; exit 1 ;; esac + +docker run --rm -i --tmpfs /root/.codex --tmpfs /root/.t3 \ + --entrypoint sh "$image_ref" -s -- "$image_target" <<'CHECKS' +set -eu +node - "$1" <<'VERSIONS' +const fs=require('fs'),cp=require('child_process'); +const versions=JSON.parse(fs.readFileSync('/opt/image-versions.json','utf8')); +const target=process.argv[2]; +for(const tool of target==='t3'?['claude','codex','t3']:[target]){ + const output=cp.execFileSync(tool,['--version'],{encoding:'utf8'}).trim(); + const actual=tool==='claude'?output.split(/\s+/)[0]:output.split(/\s+/).pop().replace(/^v/,''); + if(versions[tool]!==actual)throw Error(`${tool} metadata ${versions[tool]} differs from installed ${actual}`); +} +console.log('Installed versions:',JSON.stringify(versions)); +VERSIONS +verify_windows_exe() { + test -s "$1" + file "$1" | tee /tmp/windows-file-type + grep -q "${2:-PE32+.*x86-64}" /tmp/windows-file-type +} +verify_codex_vsix() { + unzip -tq "$1" + node - "$1" "$2" <<'VSIX' +const fs=require('fs'),cp=require('child_process'); +const [archive,platform]=process.argv.slice(2); +const extension=JSON.parse(cp.execFileSync('unzip',['-p',archive,'extension/package.json'],{encoding:'utf8'})); +const manifest=cp.execFileSync('unzip',['-p',archive,'extension.vsixmanifest'],{encoding:'utf8'}); +const versions=JSON.parse(fs.readFileSync('/opt/image-versions.json','utf8')); +if(extension.publisher!=='openai'||extension.name!=='chatgpt'||extension.version!==versions.codex_vsix||!manifest.includes(`TargetPlatform="${platform}"`))throw Error(`Invalid Codex VSIX: ${archive}`); +console.log('Codex VSIX:',platform,extension.version); +VSIX +} +if [ "$1" = codex ] || [ "$1" = t3 ]; then + verify_windows_exe /opt/codex/win32-x64/bin/codex.exe + verify_codex_vsix /opt/codex/win32-x64/codex.vsix win32-x64 + node <<'CODEX_WINDOWS' +const fs=require('fs'); +const versions=JSON.parse(fs.readFileSync('/opt/image-versions.json','utf8')); +const windows=JSON.parse(fs.readFileSync('/opt/codex/win32-x64/codex-package.json','utf8')); +if(windows.version!==versions.codex||windows.target!=='x86_64-pc-windows-msvc')throw Error('Invalid Windows Codex package'); +CODEX_WINDOWS +fi +if [ "$1" = t3 ]; then + verify_windows_exe /opt/claude-code/win32-x64/claude.exe + # The x64 NSIS installer uses a 32-bit bootstrap executable. Its payload is + # selected and checksum-verified against the release's x64 metadata at build. + verify_windows_exe /opt/t3-code/win32-x64/T3-Code-Setup.exe 'PE32.*Nullsoft Installer' + node <<'WINDOWS' +const fs=require('fs'),path=require('path'),cp=require('child_process'); +const base='/opt/t3-code/win32-x64'; +const executables=fs.readdirSync(base,{recursive:true}).filter(p=>path.basename(p)==='t3.exe'); +if(executables.length!==1)throw Error('Expected one Windows T3 server executable'); +const binary=path.join(base,executables[0]); +const type=cp.execFileSync('file',[binary],{encoding:'utf8'}); +if(!/PE32\+.*x86-64/.test(type))throw Error(type); +if(!fs.existsSync(path.join(path.dirname(binary),'client','index.html')))throw Error('Missing Windows server web UI'); +console.log(type.trim()); +console.log('Windows T3 server web UI present'); +WINDOWS +fi +case "$1" in + claude) + claude --version + if command -v codex; then echo "Unexpected Codex CLI" >&2; exit 1; fi + if command -v t3; then echo "Unexpected T3 CLI" >&2; exit 1; fi + ;; + codex) + codex --version + if command -v claude; then echo "Unexpected Claude CLI" >&2; exit 1; fi + if command -v t3; then echo "Unexpected T3 CLI" >&2; exit 1; fi + case "$(uname -m)" in aarch64) platform=linux-arm64 ;; x86_64) platform=linux-x64 ;; esac + verify_codex_vsix /opt/codex/codex.vsix "$platform" + ;; + t3) + claude --version + codex --version + t3 --version + test -x /usr/local/bin/t3-desktop + test -s /opt/t3-desktop/resources/app.asar + ldd /opt/t3-desktop/t3code > /tmp/desktop-libs + if grep 'not found' /tmp/desktop-libs; then exit 1; fi + ELECTRON_RUN_AS_NODE=1 /opt/t3-desktop/t3code -e 'console.log("Desktop Electron:",process.versions.electron)' + t3 serve --host 0.0.0.0 --port 3773 > /tmp/t3.log 2>&1 & + server_pid=$! + trap 'kill "$server_pid" 2>/dev/null || true' EXIT + attempt=0 + until curl -fsS http://127.0.0.1:3773/ -o /tmp/index.html 2>/dev/null; do + attempt=$((attempt + 1)) + if [ "$attempt" -ge 20 ]; then cat /tmp/t3.log; exit 1; fi + sleep 1 + done + node <<'JS' +const fs=require('fs'),cp=require('child_process'); +const html=fs.readFileSync('/tmp/index.html','utf8'); +if(!html.toLowerCase().includes(''))throw Error('Missing UI HTML'); +const assets=[...html.matchAll(/(?:src|href)="([^" ]+\.(?:js|css))"/g)].map(m=>m[1]); +if(!assets.length)throw Error('Missing UI assets'); +for(const asset of assets){ + const data=cp.execFileSync('curl',['-fsS',`http://127.0.0.1:3773${asset}`]); + if(!data.length || data.subarray(0,100).toString().toLowerCase().includes(''))throw Error('Invalid UI asset'); + console.log('UI asset served:',asset,data.length,'bytes'); +} +JS + ;; +esac +CHECKS diff --git a/scripts/test_image_release.py b/scripts/test_image_release.py new file mode 100644 index 0000000..ab76c84 --- /dev/null +++ b/scripts/test_image_release.py @@ -0,0 +1,174 @@ +import json +import os +import subprocess +import sys +import tempfile +import unittest +from pathlib import Path +from unittest.mock import patch + +from image_release import aliases_allowed, aliases_match, inspect_alias, release_plan, resolve_versions, select_targets, validate_version + +VERSIONS = {"claude": "2.1.325", "codex": "0.169.0", "codex_vsix": "26.51111.21308", "t3": "0.1.9"} +REVISION = "a" * 40 + + +def published_configs(plan): + labels = {f"io.zeroae.version.{key.replace('_', '-')}": value for key, value in plan['versions'].items()} + labels['org.opencontainers.image.revision'] = plan['revision'] + return { + f"linux/{arch}": {"os": "linux", "architecture": arch, "config": {"Labels": dict(labels)}} + for arch in ('amd64', 'arm64') + } + + +class ImageReleaseTests(unittest.TestCase): + def test_main_builds_three_independent_targets(self): + self.assertEqual(select_targets(ref="refs/heads/main"), ({"target": ["claude", "codex", "t3"]}, "")) + + def test_product_source_tags_select_only_their_product(self): + for tag, target, version in (("v2.1.325", "claude", "2.1.325"), ("codex-v0.169.0", "codex", "0.169.0"), ("t3-v0.1.9", "t3", "0.1.9")): + with self.subTest(tag=tag): + self.assertEqual(select_targets(ref=f"refs/tags/{tag}"), ({"target": [target]}, version)) + + def test_version_override_requires_one_target(self): + with self.assertRaises(ValueError): + select_targets("all", "0.169.0") + self.assertEqual(select_targets("codex", "0.169.0"), ({"target": ["codex"]}, "0.169.0")) + + def test_versions_cannot_inject_paths_or_outputs(self): + for version in ("../latest", "1.2.3\nmarker=oops", "latest", "1.2.3;false"): + with self.subTest(version=version), self.assertRaises(ValueError): + validate_version(version) + + def test_codex_resolution_does_not_depend_on_claude_or_t3(self): + requested = [] + def fetch(product): + requested.append(product) + if product in ("claude", "t3"): + raise RuntimeError("Unrelated provider unavailable") + return VERSIONS[product] + versions = resolve_versions("codex", fetch=fetch) + self.assertEqual(versions, {"codex": "0.169.0", "codex_vsix": "26.51111.21308"}) + self.assertEqual(requested, ["codex", "codex_vsix"]) + + def test_claude_does_not_resolve_codex_or_t3(self): + requested = [] + resolve_versions("claude", fetch=lambda p: requested.append(p) or VERSIONS[p]) + self.assertEqual(requested, ["claude"]) + + def test_t3_version_override_keeps_resolving_its_dependencies(self): + versions = resolve_versions("t3", {"t3": "0.1.8"}, fetch=VERSIONS.__getitem__) + self.assertEqual(versions, {**VERSIONS, "t3": "0.1.8"}) + + def test_registry_names_and_tags_use_product_versions(self): + for target, name in (("claude", "claude-code"), ("codex", "codex"), ("t3", "t3-code")): + with self.subTest(target=target): + plan = release_plan(target, VERSIONS, "ZeroAE", REVISION) + self.assertEqual(plan["image"], f"ghcr.io/zeroae/{name}") + self.assertEqual(plan["version"], VERSIONS[target]) + self.assertTrue(plan["snapshot"].startswith(VERSIONS[target])) + self.assertTrue(plan["snapshot"].endswith("-r" + REVISION[:12])) + + def test_each_t3_dependency_update_changes_snapshot_but_not_primary_tag(self): + original = release_plan("t3", VERSIONS, "zeroae", REVISION) + for product in ("claude", "codex", "codex_vsix"): + with self.subTest(product=product): + updated = release_plan("t3", {**VERSIONS, product: "99.0.0"}, "zeroae", REVISION) + self.assertNotEqual(updated["snapshot"], original["snapshot"]) + self.assertNotEqual(updated["marker"], original["marker"]) + self.assertEqual(updated["version"], "0.1.9") + + def test_claude_updates_do_not_change_codex_tags(self): + before = release_plan("codex", VERSIONS, "zeroae", REVISION) + after = release_plan("codex", {**VERSIONS, "claude": "2.1.326"}, "zeroae", REVISION) + self.assertEqual(before, after) + + def test_extension_update_rebuilds_codex(self): + before = release_plan("codex", VERSIONS, "zeroae", REVISION) + after = release_plan("codex", {**VERSIONS, "codex_vsix": "26.51112.1"}, "zeroae", REVISION) + self.assertNotEqual(before["marker"], after["marker"]) + self.assertEqual(before["version"], after["version"]) + + def test_source_changes_create_new_snapshot(self): + before = release_plan("t3", VERSIONS, "zeroae", REVISION) + after = release_plan("t3", VERSIONS, "zeroae", "b" * 40) + self.assertNotEqual(before["snapshot"], after["snapshot"]) + + def test_rebuilds_have_distinct_snapshot_tags_but_the_same_success_marker(self): + before = release_plan("t3", VERSIONS, "zeroae", REVISION, "1234-1") + after = release_plan("t3", VERSIONS, "zeroae", REVISION, "1234-2") + self.assertNotEqual(before["snapshot"], after["snapshot"]) + self.assertEqual(before["marker"], after["marker"]) + + def test_historical_release_cannot_roll_back_latest(self): + current = {"codex": "0.169.0", "codex_vsix": VERSIONS['codex_vsix']} + historical = {**current, "codex": "0.168.0"} + self.assertFalse(aliases_allowed(historical, REVISION, REVISION, current)) + self.assertTrue(aliases_allowed(current, REVISION, REVISION, current)) + + def test_stale_code_or_provider_combination_cannot_promote_aliases(self): + self.assertFalse(aliases_allowed(VERSIONS, "b"*40, REVISION, VERSIONS)) + self.assertFalse(aliases_allowed({**VERSIONS, "codex": "0.168.0"}, REVISION, REVISION, VERSIONS)) + + def test_all_aliases_must_match_versions_and_revision_on_both_platforms(self): + plan = release_plan('codex', VERSIONS, 'zeroae', REVISION) + expected = published_configs(plan) + checked = [] + def inspect(reference): + checked.append(reference) + return expected + self.assertTrue(aliases_match(plan, inspect)) + self.assertEqual(checked, [plan['image']+':'+tag for tag in ('latest', '0.169.0', '0.169', '0')]) + for label, stale in (('io.zeroae.version.codex', '0.168.0'), ('org.opencontainers.image.revision', 'b'*40)): + with self.subTest(label=label): + drifted = published_configs(plan) + drifted['linux/arm64']['config']['Labels'][label] = stale + self.assertFalse(aliases_match(plan, lambda ref: drifted)) + self.assertFalse(aliases_match(plan, lambda ref: None)) + self.assertFalse(aliases_match(plan, lambda ref: {'linux/amd64': expected['linux/amd64']})) + self.assertFalse(aliases_match(plan, lambda ref: None if ref.endswith(':0') else expected)) + + def test_registry_errors_are_not_mistaken_for_missing_aliases(self): + with patch('image_release.subprocess.run', return_value=subprocess.CompletedProcess([], 1, '', 'manifest unknown')): + self.assertIsNone(inspect_alias('ghcr.io/zeroae/codex:latest')) + with patch('image_release.subprocess.run', return_value=subprocess.CompletedProcess([], 1, '', 'unauthorized')): + with self.assertRaises(RuntimeError): + inspect_alias('ghcr.io/zeroae/codex:latest') + + def test_success_markers_skip_only_the_exact_published_snapshot(self): + script = str(Path(__file__).with_name("image_release.py").resolve()) + with tempfile.TemporaryDirectory() as directory: + def git(*args): + return subprocess.check_output(["git", "-C", directory, *args], text=True).strip() + git("init", "--quiet") + git("-c", "user.name=Test", "-c", "user.email=test@example.com", "commit", "--quiet", "--allow-empty", "-m", "Initial") + command = [sys.executable, script, "plan", "--target", "t3", "--check-existing"] + for key, value in VERSIONS.items(): + command.extend([f"--{key.replace('_', '-')}-version", value]) + fixture = Path(directory, 'alias-configs.json') + docker = Path(directory, 'docker') + docker.write_text(f"#!{sys.executable}\nimport sys\nsys.stdout.write(open({str(fixture)!r}).read())\n") + docker.chmod(0o755) + environment = {**os.environ, 'PATH': directory + os.pathsep + os.environ['PATH']} + def plan(extra=()): + return json.loads(subprocess.check_output([*command, *extra], cwd=directory, env=environment, text=True)) + first = plan() + self.assertEqual(first["should_build"], "true") + self.assertEqual(git("tag", "--list"), "") # Planning never records a premature success. + git("tag", first["marker"]) + configs = published_configs(first) + fixture.write_text(json.dumps(configs)) + self.assertEqual(plan()["should_build"], "false") + drifted = published_configs(first) + drifted['linux/amd64']['config']['Labels']['io.zeroae.version.codex'] = '0.168.0' + fixture.write_text(json.dumps(drifted)) + self.assertEqual(plan()["should_build"], "true") # Existing marker cannot hide alias drift. + fixture.write_text(json.dumps(configs)) + self.assertEqual(plan()["should_build"], "false") + self.assertEqual(plan(["--codex-version", "0.170.0"])["should_build"], "true") + self.assertEqual(plan(["--revision", "b" * 40])["should_build"], "true") + + +if __name__ == "__main__": + unittest.main()