name: Docker Release on: push: tags: - "v*" paths-ignore: - "docs/**" - "**/*.md" - "**/*.mdx" - ".agents/**" - "skills/**" workflow_dispatch: inputs: tag: description: Existing release tag to backfill (for example v2026.3.22) required: true type: string concurrency: group: ${{ github.event_name == 'workflow_dispatch' && format('docker-release-manual-{0}', inputs.tag) || format('docker-release-push-{0}', github.run_id) }} cancel-in-progress: false env: FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true" REGISTRY: ghcr.io IMAGE_NAME: ${{ github.repository }} jobs: validate_manual_backfill: if: github.event_name == 'workflow_dispatch' runs-on: ubuntu-24.04 permissions: contents: read steps: - name: Validate tag input format env: RELEASE_TAG: ${{ inputs.tag }} run: | set -euo pipefail if [[ ! "${RELEASE_TAG}" =~ ^v[0-9]{4}\.[1-9][0-9]*\.[1-9][0-9]*(-(alpha|beta)\.[1-9][0-9]*)?$ ]]; then echo "Invalid release tag: ${RELEASE_TAG}" exit 1 fi - name: Checkout selected tag uses: actions/checkout@v6 with: ref: refs/tags/${{ inputs.tag }} fetch-depth: 0 approve_manual_backfill: if: github.event_name == 'workflow_dispatch' needs: validate_manual_backfill # WARNING: KEEP MANUAL BACKFILLS GATED BY THE docker-release ENVIRONMENT. runs-on: ubuntu-24.04 environment: docker-release permissions: {} steps: - name: Approve Docker backfill env: RELEASE_TAG: ${{ inputs.tag }} run: echo "Approved Docker backfill for $RELEASE_TAG" # KEEP THIS WORKFLOW ON GITHUB-HOSTED RUNNERS. # DO NOT MOVE IT BACK TO BLACKSMITH WITHOUT RE-VALIDATING TAG BUILDS AND BACKFILLS. # Build amd64 image. Default and slim tags point to the same slim runtime. build-amd64: needs: [approve_manual_backfill] if: ${{ always() && (github.event_name != 'workflow_dispatch' || needs.approve_manual_backfill.result == 'success') }} # WARNING: DO NOT REVERT THIS TO A BLACKSMITH RUNNER WITHOUT RE-VALIDATING TAG BACKFILLS. runs-on: ubuntu-24.04 permissions: packages: write contents: read outputs: digest: ${{ steps.build.outputs.digest }} browser_digest: ${{ steps.build-browser.outputs.digest }} steps: - name: Checkout uses: actions/checkout@v6 with: ref: ${{ github.event_name == 'workflow_dispatch' && format('refs/tags/{0}', inputs.tag) || github.ref }} fetch-depth: 0 - name: Set up Docker Builder uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4 - name: Login to GitHub Container Registry uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4 with: registry: ${{ env.REGISTRY }} username: ${{ github.repository_owner }} password: ${{ secrets.GITHUB_TOKEN }} - name: Resolve image tags (amd64) id: tags shell: bash env: IMAGE: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} SOURCE_REF: ${{ github.event_name == 'workflow_dispatch' && format('refs/tags/{0}', inputs.tag) || github.ref }} run: | set -euo pipefail tags=() slim_tags=() browser_tags=() browser_supported=0 if grep -q '^ARG OPENCLAW_INSTALL_BROWSER' Dockerfile; then browser_supported=1 fi if [[ "${SOURCE_REF}" == refs/tags/v* ]]; then version="${SOURCE_REF#refs/tags/v}" tags+=("${IMAGE}:${version}-amd64") slim_tags+=("${IMAGE}:${version}-slim-amd64") if [[ "${browser_supported}" == "1" ]]; then browser_tags+=("${IMAGE}:${version}-browser-amd64") fi fi if [[ ${#tags[@]} -eq 0 ]]; then echo "::error::No amd64 tags resolved for ref ${SOURCE_REF}" exit 1 fi { echo "value<> "$GITHUB_OUTPUT" - name: Resolve OCI labels (amd64) id: labels shell: bash env: SOURCE_REF: ${{ github.event_name == 'workflow_dispatch' && format('refs/tags/{0}', inputs.tag) || github.ref }} run: | set -euo pipefail source_sha="$(git rev-parse HEAD)" version="${source_sha}" if [[ "${SOURCE_REF}" == "refs/heads/main" ]]; then version="main" fi if [[ "${SOURCE_REF}" == refs/tags/v* ]]; then version="${SOURCE_REF#refs/tags/v}" fi created="$(date -u +%Y-%m-%dT%H:%M:%SZ)" { echo "value<> "$GITHUB_OUTPUT" - name: Build and push amd64 image id: build # WARNING: KEEP THE OFFICIAL DOCKER ACTION HERE; DO NOT SWITCH THIS BACK TO BLACKSMITH BLINDLY. uses: docker/build-push-action@bcafcacb16a39f128d818304e6c9c0c18556b85f # v7.1.0 with: context: . platforms: linux/amd64 cache-from: type=gha,scope=docker-release-amd64 cache-to: type=gha,mode=max,scope=docker-release-amd64 build-args: | OPENCLAW_EXTENSIONS=diagnostics-otel,codex tags: ${{ steps.tags.outputs.value }} labels: ${{ steps.labels.outputs.value }} sbom: true provenance: mode=max push: true - name: Build and push amd64 browser image id: build-browser if: steps.tags.outputs.browser != '' # WARNING: KEEP THE OFFICIAL DOCKER ACTION HERE; DO NOT SWITCH THIS BACK TO BLACKSMITH BLINDLY. uses: docker/build-push-action@bcafcacb16a39f128d818304e6c9c0c18556b85f # v7.1.0 with: context: . platforms: linux/amd64 cache-from: | type=gha,scope=docker-release-amd64 type=gha,scope=docker-release-browser-amd64 cache-to: type=gha,mode=max,scope=docker-release-browser-amd64 build-args: | OPENCLAW_EXTENSIONS=diagnostics-otel,codex OPENCLAW_INSTALL_BROWSER=1 tags: ${{ steps.tags.outputs.browser }} labels: ${{ steps.labels.outputs.value }} sbom: true provenance: mode=max push: true - name: Smoke test amd64 runtime workspace templates shell: bash env: IMAGE_REFS: ${{ steps.tags.outputs.value }} run: | set -euo pipefail mapfile -t image_refs <<< "${IMAGE_REFS}" image_ref="${image_refs[0]}" if [[ -z "${image_ref}" ]]; then echo "::error::No amd64 image ref resolved for runtime template smoke" exit 1 fi docker run --rm --entrypoint /bin/sh "${image_ref}" -lc ' set -eu test -f /app/src/agents/templates/HEARTBEAT.md temp_root="$(mktemp -d)" trap "rm -rf \"${temp_root}\"" EXIT mkdir -p "${temp_root}/home" "${temp_root}/cwd" cd "${temp_root}/cwd" set +e HOME="${temp_root}/home" \ USERPROFILE="${temp_root}/home" \ OPENCLAW_HOME="${temp_root}/home" \ OPENCLAW_NO_ONBOARD=1 \ OPENCLAW_SUPPRESS_NOTES=1 \ OPENCLAW_DISABLE_BUNDLED_PLUGINS=1 \ OPENCLAW_DISABLE_BUNDLED_ENTRY_SOURCE_FALLBACK=1 \ AWS_EC2_METADATA_DISABLED=true \ AWS_SHARED_CREDENTIALS_FILE="${temp_root}/home/.aws/credentials" \ AWS_CONFIG_FILE="${temp_root}/home/.aws/config" \ node /app/openclaw.mjs agent --message "workspace bootstrap smoke" --session-id "workspace-bootstrap-smoke" --local --timeout 1 --json \ >"${temp_root}/out.log" 2>&1 status="$?" set -e if grep -F "Missing workspace template:" "${temp_root}/out.log"; then cat "${temp_root}/out.log" exit 1 fi test -f "${temp_root}/home/.openclaw/workspace/HEARTBEAT.md" if [ "${status}" -ne 0 ]; then cat "${temp_root}/out.log" fi ' - name: Smoke test amd64 browser image if: steps.tags.outputs.browser != '' shell: bash env: IMAGE_REFS: ${{ steps.tags.outputs.browser }} run: | set -euo pipefail mapfile -t image_refs <<< "${IMAGE_REFS}" image_ref="${image_refs[0]}" if [[ -z "${image_ref}" ]]; then echo "::error::No amd64 browser image ref resolved" exit 1 fi docker run --rm --entrypoint /bin/sh "${image_ref}" -lc ' set -eu browser="$(find /home/node/.cache/ms-playwright -maxdepth 5 -type f \( -name chrome -o -name chromium -o -name chrome-headless-shell \) -print | head -1)" test -n "${browser}" "${browser}" --version ' # Build arm64 image. Default and slim tags point to the same slim runtime. build-arm64: needs: [approve_manual_backfill] if: ${{ always() && (github.event_name != 'workflow_dispatch' || needs.approve_manual_backfill.result == 'success') }} # WARNING: DO NOT REVERT THIS TO A BLACKSMITH RUNNER WITHOUT RE-VALIDATING TAG BACKFILLS. runs-on: ubuntu-24.04-arm permissions: packages: write contents: read outputs: digest: ${{ steps.build.outputs.digest }} browser_digest: ${{ steps.build-browser.outputs.digest }} steps: - name: Checkout uses: actions/checkout@v6 with: ref: ${{ github.event_name == 'workflow_dispatch' && format('refs/tags/{0}', inputs.tag) || github.ref }} fetch-depth: 0 - name: Set up Docker Builder uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4 - name: Login to GitHub Container Registry uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4 with: registry: ${{ env.REGISTRY }} username: ${{ github.repository_owner }} password: ${{ secrets.GITHUB_TOKEN }} - name: Resolve image tags (arm64) id: tags shell: bash env: IMAGE: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} SOURCE_REF: ${{ github.event_name == 'workflow_dispatch' && format('refs/tags/{0}', inputs.tag) || github.ref }} run: | set -euo pipefail tags=() slim_tags=() browser_tags=() browser_supported=0 if grep -q '^ARG OPENCLAW_INSTALL_BROWSER' Dockerfile; then browser_supported=1 fi if [[ "${SOURCE_REF}" == refs/tags/v* ]]; then version="${SOURCE_REF#refs/tags/v}" tags+=("${IMAGE}:${version}-arm64") slim_tags+=("${IMAGE}:${version}-slim-arm64") if [[ "${browser_supported}" == "1" ]]; then browser_tags+=("${IMAGE}:${version}-browser-arm64") fi fi if [[ ${#tags[@]} -eq 0 ]]; then echo "::error::No arm64 tags resolved for ref ${SOURCE_REF}" exit 1 fi { echo "value<> "$GITHUB_OUTPUT" - name: Resolve OCI labels (arm64) id: labels shell: bash env: SOURCE_REF: ${{ github.event_name == 'workflow_dispatch' && format('refs/tags/{0}', inputs.tag) || github.ref }} run: | set -euo pipefail source_sha="$(git rev-parse HEAD)" version="${source_sha}" if [[ "${SOURCE_REF}" == "refs/heads/main" ]]; then version="main" fi if [[ "${SOURCE_REF}" == refs/tags/v* ]]; then version="${SOURCE_REF#refs/tags/v}" fi created="$(date -u +%Y-%m-%dT%H:%M:%SZ)" { echo "value<> "$GITHUB_OUTPUT" - name: Build and push arm64 image id: build # WARNING: KEEP THE OFFICIAL DOCKER ACTION HERE; DO NOT SWITCH THIS BACK TO BLACKSMITH BLINDLY. uses: docker/build-push-action@bcafcacb16a39f128d818304e6c9c0c18556b85f # v7.1.0 with: context: . platforms: linux/arm64 cache-from: type=gha,scope=docker-release-arm64 cache-to: type=gha,mode=max,scope=docker-release-arm64 build-args: | OPENCLAW_EXTENSIONS=diagnostics-otel,codex tags: ${{ steps.tags.outputs.value }} labels: ${{ steps.labels.outputs.value }} sbom: true provenance: mode=max push: true - name: Build and push arm64 browser image id: build-browser if: steps.tags.outputs.browser != '' # WARNING: KEEP THE OFFICIAL DOCKER ACTION HERE; DO NOT SWITCH THIS BACK TO BLACKSMITH BLINDLY. uses: docker/build-push-action@bcafcacb16a39f128d818304e6c9c0c18556b85f # v7.1.0 with: context: . platforms: linux/arm64 cache-from: | type=gha,scope=docker-release-arm64 type=gha,scope=docker-release-browser-arm64 cache-to: type=gha,mode=max,scope=docker-release-browser-arm64 build-args: | OPENCLAW_EXTENSIONS=diagnostics-otel,codex OPENCLAW_INSTALL_BROWSER=1 tags: ${{ steps.tags.outputs.browser }} labels: ${{ steps.labels.outputs.value }} sbom: true provenance: mode=max push: true - name: Smoke test arm64 runtime workspace templates shell: bash env: IMAGE_REFS: ${{ steps.tags.outputs.value }} run: | set -euo pipefail mapfile -t image_refs <<< "${IMAGE_REFS}" image_ref="${image_refs[0]}" if [[ -z "${image_ref}" ]]; then echo "::error::No arm64 image ref resolved for runtime template smoke" exit 1 fi docker run --rm --entrypoint /bin/sh "${image_ref}" -lc ' set -eu test -f /app/src/agents/templates/HEARTBEAT.md temp_root="$(mktemp -d)" trap "rm -rf \"${temp_root}\"" EXIT mkdir -p "${temp_root}/home" "${temp_root}/cwd" cd "${temp_root}/cwd" set +e HOME="${temp_root}/home" \ USERPROFILE="${temp_root}/home" \ OPENCLAW_HOME="${temp_root}/home" \ OPENCLAW_NO_ONBOARD=1 \ OPENCLAW_SUPPRESS_NOTES=1 \ OPENCLAW_DISABLE_BUNDLED_PLUGINS=1 \ OPENCLAW_DISABLE_BUNDLED_ENTRY_SOURCE_FALLBACK=1 \ AWS_EC2_METADATA_DISABLED=true \ AWS_SHARED_CREDENTIALS_FILE="${temp_root}/home/.aws/credentials" \ AWS_CONFIG_FILE="${temp_root}/home/.aws/config" \ node /app/openclaw.mjs agent --message "workspace bootstrap smoke" --session-id "workspace-bootstrap-smoke" --local --timeout 1 --json \ >"${temp_root}/out.log" 2>&1 status="$?" set -e if grep -F "Missing workspace template:" "${temp_root}/out.log"; then cat "${temp_root}/out.log" exit 1 fi test -f "${temp_root}/home/.openclaw/workspace/HEARTBEAT.md" if [ "${status}" -ne 0 ]; then cat "${temp_root}/out.log" fi ' - name: Smoke test arm64 browser image if: steps.tags.outputs.browser != '' shell: bash env: IMAGE_REFS: ${{ steps.tags.outputs.browser }} run: | set -euo pipefail mapfile -t image_refs <<< "${IMAGE_REFS}" image_ref="${image_refs[0]}" if [[ -z "${image_ref}" ]]; then echo "::error::No arm64 browser image ref resolved" exit 1 fi docker run --rm --entrypoint /bin/sh "${image_ref}" -lc ' set -eu browser="$(find /home/node/.cache/ms-playwright -maxdepth 5 -type f \( -name chrome -o -name chromium -o -name chrome-headless-shell \) -print | head -1)" test -n "${browser}" "${browser}" --version ' # Create multi-platform manifests create-manifest: needs: [approve_manual_backfill, build-amd64, build-arm64] if: ${{ always() && needs.build-amd64.result == 'success' && needs.build-arm64.result == 'success' && (github.event_name != 'workflow_dispatch' || needs.approve_manual_backfill.result == 'success') }} # WARNING: DO NOT REVERT THIS TO A BLACKSMITH RUNNER WITHOUT RE-VALIDATING TAG BACKFILLS. runs-on: ubuntu-24.04 permissions: packages: write contents: read steps: - name: Checkout uses: actions/checkout@v6 with: ref: ${{ github.event_name == 'workflow_dispatch' && format('refs/tags/{0}', inputs.tag) || github.ref }} fetch-depth: 0 - name: Login to GitHub Container Registry uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4 with: registry: ${{ env.REGISTRY }} username: ${{ github.repository_owner }} password: ${{ secrets.GITHUB_TOKEN }} - name: Resolve manifest tags id: tags shell: bash env: IMAGE: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} SOURCE_REF: ${{ github.event_name == 'workflow_dispatch' && format('refs/tags/{0}', inputs.tag) || github.ref }} IS_MANUAL_BACKFILL: ${{ github.event_name == 'workflow_dispatch' && '1' || '0' }} run: | set -euo pipefail tags=() slim_tags=() browser_tags=() browser_supported=0 if grep -q '^ARG OPENCLAW_INSTALL_BROWSER' Dockerfile; then browser_supported=1 fi if [[ "${SOURCE_REF}" == refs/tags/v* ]]; then version="${SOURCE_REF#refs/tags/v}" tags+=("${IMAGE}:${version}") slim_tags+=("${IMAGE}:${version}-slim") if [[ "${browser_supported}" == "1" ]]; then browser_tags+=("${IMAGE}:${version}-browser") fi # Manual backfills should only republish the requested version tags. if [[ "${IS_MANUAL_BACKFILL}" != "1" && "$version" =~ ^[0-9]+\.[0-9]+\.[0-9]+(-[0-9]+)?$ ]]; then tags+=("${IMAGE}:latest" "${IMAGE}:main") slim_tags+=("${IMAGE}:slim" "${IMAGE}:main-slim") if [[ "${browser_supported}" == "1" ]]; then browser_tags+=("${IMAGE}:latest-browser" "${IMAGE}:main-browser") fi fi fi if [[ ${#tags[@]} -eq 0 ]]; then echo "::error::No manifest tags resolved for ref ${SOURCE_REF}" exit 1 fi { echo "value<> "$GITHUB_OUTPUT" - name: Create and push manifest shell: bash env: TAGS: ${{ steps.tags.outputs.value }} BROWSER_TAGS: ${{ steps.tags.outputs.browser }} AMD64_DIGEST: ${{ needs.build-amd64.outputs.digest }} ARM64_DIGEST: ${{ needs.build-arm64.outputs.digest }} AMD64_BROWSER_DIGEST: ${{ needs.build-amd64.outputs.browser_digest }} ARM64_BROWSER_DIGEST: ${{ needs.build-arm64.outputs.browser_digest }} run: | set -euo pipefail mapfile -t tags <<< "${TAGS}" mapfile -t browser_tags <<< "${BROWSER_TAGS}" create_manifest() { local amd64_digest="$1" local arm64_digest="$2" shift 2 local args=() for tag in "$@"; do [ -z "$tag" ] && continue args+=("-t" "$tag") done docker buildx imagetools create "${args[@]}" "$amd64_digest" "$arm64_digest" } create_manifest "${AMD64_DIGEST}" "${ARM64_DIGEST}" "${tags[@]}" if [[ -n "${BROWSER_TAGS}" ]]; then create_manifest "${AMD64_BROWSER_DIGEST}" "${ARM64_BROWSER_DIGEST}" "${browser_tags[@]}" fi verify-attestations: needs: [create-manifest] if: ${{ always() && needs.create-manifest.result == 'success' }} runs-on: ubuntu-24.04 permissions: contents: read packages: read steps: - name: Checkout uses: actions/checkout@v6 with: fetch-depth: 1 - name: Set up Docker Builder uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4 - name: Login to GitHub Container Registry uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4 with: registry: ${{ env.REGISTRY }} username: ${{ github.repository_owner }} password: ${{ secrets.GITHUB_TOKEN }} - name: Resolve image refs id: refs shell: bash env: IMAGE: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} SOURCE_REF: ${{ github.event_name == 'workflow_dispatch' && format('refs/tags/{0}', inputs.tag) || github.ref }} IS_MANUAL_BACKFILL: ${{ github.event_name == 'workflow_dispatch' && '1' || '0' }} run: | set -euo pipefail multi_refs=() slim_multi_refs=() amd64_refs=() arm64_refs=() browser_supported=0 if [[ "${SOURCE_REF}" == refs/tags/v* ]]; then tag="${SOURCE_REF#refs/tags/}" git fetch --depth=1 origin "refs/tags/${tag}:refs/tags/${tag}" if git show "${SOURCE_REF}:Dockerfile" | grep -q '^ARG OPENCLAW_INSTALL_BROWSER'; then browser_supported=1 fi elif grep -q '^ARG OPENCLAW_INSTALL_BROWSER' Dockerfile; then browser_supported=1 fi if [[ "${SOURCE_REF}" == refs/tags/v* ]]; then version="${SOURCE_REF#refs/tags/v}" multi_refs+=("${IMAGE}:${version}") slim_multi_refs+=("${IMAGE}:${version}-slim") amd64_refs+=( "${IMAGE}:${version}-amd64" "${IMAGE}:${version}-slim-amd64" ) arm64_refs+=( "${IMAGE}:${version}-arm64" "${IMAGE}:${version}-slim-arm64" ) if [[ "${browser_supported}" == "1" ]]; then multi_refs+=("${IMAGE}:${version}-browser") amd64_refs+=("${IMAGE}:${version}-browser-amd64") arm64_refs+=("${IMAGE}:${version}-browser-arm64") fi if [[ "${IS_MANUAL_BACKFILL}" != "1" && "$version" =~ ^[0-9]+\.[0-9]+\.[0-9]+(-[0-9]+)?$ ]]; then multi_refs+=("${IMAGE}:latest" "${IMAGE}:main") slim_multi_refs+=("${IMAGE}:slim" "${IMAGE}:main-slim") if [[ "${browser_supported}" == "1" ]]; then multi_refs+=("${IMAGE}:latest-browser" "${IMAGE}:main-browser") fi fi fi if [[ ${#multi_refs[@]} -eq 0 || ${#amd64_refs[@]} -eq 0 || ${#arm64_refs[@]} -eq 0 ]]; then echo "::error::No Docker image refs resolved for ref ${SOURCE_REF}" exit 1 fi { echo "multi<> "$GITHUB_OUTPUT" - name: Verify Docker attestations shell: bash env: MULTI_REFS: ${{ steps.refs.outputs.multi }} AMD64_REFS: ${{ steps.refs.outputs.amd64 }} ARM64_REFS: ${{ steps.refs.outputs.arm64 }} run: | set -euo pipefail mapfile -t multi_refs <<< "${MULTI_REFS}" mapfile -t amd64_refs <<< "${AMD64_REFS}" mapfile -t arm64_refs <<< "${ARM64_REFS}" node scripts/verify-docker-attestations.mjs \ --platform linux/amd64 \ --platform linux/arm64 \ "${multi_refs[@]}" node scripts/verify-docker-attestations.mjs \ --platform linux/amd64 \ "${amd64_refs[@]}" node scripts/verify-docker-attestations.mjs \ --platform linux/arm64 \ "${arm64_refs[@]}"