Files
tutti/.github/workflows/publish-claude-code-binaries.yml
agent-radar 2ed3415fb7
Publish Claude Code Binaries / Stage and publish binaries (push) Canceled after 0s
Publish Tutti App Runtime / Build darwin-arm64 (push) Canceled after 0s
Publish Tutti App Runtime / Build darwin-amd64 (push) Canceled after 0s
Publish Tutti App Runtime / Build linux-amd64 (push) Canceled after 0s
Publish Tutti App Runtime / Build windows-amd64 (push) Canceled after 0s
Sync Tutti Agent Skills / sync (push) Canceled after 0s
Windows Agent Adapters / Test Windows agent adapters (push) Canceled after 0s
Windows Daemon Adapters / Test Windows daemon adapters (push) Canceled after 0s
Publish Tutti App Runtime / Publish runtime catalog (push) Canceled after 0s
源码快照 @ 2026-09-01 (source-only, 剔除非源码素材)
2026-09-01 20:17:27 +08:00

182 lines
8.0 KiB
YAML

name: Publish Claude Code Binaries
# Publishes zstd-compressed Claude Code native binaries to the app-runtime
# CDN bucket. The desktop bundle no longer vendors the ~230MB-per-platform
# @anthropic-ai/claude-agent-sdk-<platform> packages; tuttid downloads them at
# runtime from <cdn>/claude-code/<claudeVersion>/claude-<platform>.zst (see
# services/tuttid/service/agentstatus/claude_binary.go). Runs automatically
# whenever the sidecar bumps its pinned SDK version; uploads are immutable and
# skipped when the version is already published, so re-runs are safe.
on:
push:
branches:
- main
paths:
- packages/agent/claude-sdk-sidecar/package.json
workflow_dispatch:
inputs:
aws_region:
description: AWS region. Defaults to runtime vars, then release vars.
required: false
type: string
aws_role_arn:
description: AWS role ARN. Defaults to runtime vars, then release vars.
required: false
type: string
s3_bucket:
description: S3 bucket. Defaults to runtime vars, then release vars.
required: false
type: string
s3_prefix:
description: S3 prefix. Defaults to claude-code.
required: false
type: string
permissions:
contents: read
id-token: write
env:
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
jobs:
publish:
name: Stage and publish binaries
runs-on: ubuntu-latest
env:
AWS_REGION_VALUE: ${{ inputs.aws_region || vars.TUTTI_APP_RUNTIME_AWS_REGION || vars.TUTTI_APP_RELEASES_AWS_REGION }}
AWS_ROLE_ARN_VALUE: ${{ inputs.aws_role_arn || vars.TUTTI_APP_RUNTIME_AWS_ROLE_ARN || vars.TUTTI_APP_RELEASES_AWS_ROLE_ARN }}
S3_BUCKET_VALUE: ${{ inputs.s3_bucket || vars.TUTTI_APP_RUNTIME_S3_BUCKET || vars.TUTTI_APP_RELEASES_S3_BUCKET }}
S3_PREFIX_VALUE: ${{ inputs.s3_prefix || 'claude-code' }}
steps:
- name: Checkout
uses: actions/checkout@v6
- name: Setup Node.js
uses: actions/setup-node@v6
with:
node-version-file: .node-version
- name: Validate AWS configuration
shell: bash
run: |
missing=()
for name in AWS_REGION_VALUE AWS_ROLE_ARN_VALUE S3_BUCKET_VALUE; do
if [ -z "${!name:-}" ]; then
missing+=("${name}")
fi
done
if [ "${#missing[@]}" -gt 0 ]; then
printf 'Missing required AWS configuration: %s\n' "${missing[*]}" >&2
exit 1
fi
- name: Stage claude binaries
id: stage
shell: bash
run: |
node tools/scripts/stage-claude-code-binaries.mjs \
--out "${GITHUB_WORKSPACE}/dist/claude-code" | tee stage-summary.json
claude_version="$(node -e "process.stdout.write(JSON.parse(require('fs').readFileSync('stage-summary.json','utf8')).claudeVersion)")"
# The version becomes an S3 key segment; reject anything that is not
# a plain semver so a malformed manifest cannot steer paths.
if ! [[ "${claude_version}" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
echo "staged claude version is not a plain semver: ${claude_version}" >&2
exit 1
fi
echo "claude_version=${claude_version}" >> "$GITHUB_OUTPUT"
- name: Configure AWS credentials
uses: aws-actions/configure-aws-credentials@v4
with:
aws-region: ${{ env.AWS_REGION_VALUE }}
role-to-assume: ${{ env.AWS_ROLE_ARN_VALUE }}
- name: Upload binaries to S3
shell: bash
env:
CLAUDE_VERSION: ${{ steps.stage.outputs.claude_version }}
run: |
prefix="${S3_PREFIX_VALUE%/}"
version_dir="dist/claude-code/${CLAUDE_VERSION}"
uploaded=0
skipped=0
while IFS= read -r artifact; do
relative_path="${artifact#dist/claude-code/}"
key="${prefix}/${relative_path}"
local_size="$(wc -c < "${artifact}" | tr -d ' ')"
local_sha256="$(sha256sum "${artifact}" | awk '{print $1}')"
content_type="application/zstd"
case "${artifact}" in
*.json) content_type="application/json" ;;
esac
# Keys are immutable. A conditional create (If-None-Match: *) has
# S3 itself enforce that: the object is written only when the key
# does not exist yet, atomically even across racing publishers. It
# also avoids probing missing keys with head-object, which reports
# 403 instead of 404 when the role lacks s3:ListBucket and would
# abort the job on a key that simply is not published yet.
outcome=""
for attempt in 1 2 3; do
if aws s3api put-object \
--bucket "${S3_BUCKET_VALUE}" \
--key "${key}" \
--body "${artifact}" \
--if-none-match '*' \
--content-type "${content_type}" \
--cache-control "public, max-age=31536000, immutable" \
--metadata "claude-sha256=${local_sha256}" \
>/dev/null 2>put-error.txt; then
outcome="uploaded"
break
fi
if grep -qiE 'PreconditionFailed' put-error.txt; then
outcome="exists"
break
fi
# 409 ConditionalRequestConflict: a concurrent publisher is
# writing this key right now. Wait for its create to land, then
# re-probe — the retry turns into a plain 412 once it exists.
if grep -qiE 'ConditionalRequestConflict' put-error.txt && [ "${attempt}" -lt 3 ]; then
sleep 15
continue
fi
echo "put-object failed for s3://${S3_BUCKET_VALUE}/${key}:" >&2
cat put-error.txt >&2
exit 1
done
if [ "${outcome}" = "uploaded" ]; then
echo "uploaded: s3://${S3_BUCKET_VALUE}/${key}"
uploaded=$((uploaded + 1))
continue
fi
# The key already exists: only treat it as published when it holds
# the staged bytes — a mismatch means a corrupted or foreign
# publish and must stop the job. head-object is reliable here
# regardless of s3:ListBucket because the object exists.
if ! head_json="$(aws s3api head-object --bucket "${S3_BUCKET_VALUE}" --key "${key}" \
--output json 2>head-error.txt)"; then
echo "existing object could not be verified for s3://${S3_BUCKET_VALUE}/${key} (does the role have s3:GetObject on this prefix?):" >&2
cat head-error.txt >&2
exit 1
fi
remote_size="$(node -e "process.stdout.write(String(JSON.parse(process.argv[1]).ContentLength))" "${head_json}")"
remote_sha256="$(node -e "process.stdout.write(JSON.parse(process.argv[1]).Metadata?.['claude-sha256'] ?? '')" "${head_json}")"
# The size check is unconditional: the sha256 metadata is
# caller-supplied at upload time, not derived from the stored
# body, so matching metadata alone cannot vouch for the bytes.
if [ "${remote_size}" != "${local_size}" ]; then
echo "immutable key already exists with different content: s3://${S3_BUCKET_VALUE}/${key} (remote ${remote_size} bytes, staged ${local_size} bytes)" >&2
exit 1
fi
if [ -n "${remote_sha256}" ] && [ "${remote_sha256}" != "${local_sha256}" ]; then
echo "immutable key already exists with different content: s3://${S3_BUCKET_VALUE}/${key} (remote sha256 ${remote_sha256}, staged ${local_sha256})" >&2
exit 1
fi
echo "exists with matching content, skipping: s3://${S3_BUCKET_VALUE}/${key}"
skipped=$((skipped + 1))
done < <(find "${version_dir}" -type f | sort)
echo "uploaded=${uploaded} skipped=${skipped} version=${CLAUDE_VERSION}"