Files
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

506 lines
21 KiB
YAML

name: Mobile Internal Build
on:
workflow_dispatch:
inputs:
platform:
description: Mobile platform to build
required: true
type: choice
default: android
options:
- android
- ios
- all
publish_android:
description: Publish the Android APK and latest.json to the public release feed
required: true
type: boolean
default: false
android_version_name:
description: Stable Android version name when publishing, such as 0.1.1
required: true
type: string
default: 0.1.0
permissions:
contents: read
id-token: write
concurrency:
group: ${{ inputs.publish_android && 'mobile-android-release-publish' || format('mobile-internal-build-{0}-{1}', inputs.platform, github.ref) }}
cancel-in-progress: ${{ !inputs.publish_android }}
jobs:
android:
name: Build internal Android APK
if: inputs.platform == 'android' || inputs.platform == 'all'
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Set up pnpm
uses: pnpm/action-setup@v4
- name: Set up Node.js
uses: actions/setup-node@v4
with:
node-version-file: .node-version
cache: pnpm
cache-dependency-path: pnpm-lock.yaml
- name: Set up Go
uses: actions/setup-go@v5
with:
go-version: "1.26.0"
cache-dependency-path: |
go.work
go.work.sum
packages/agent/daemon/go.mod
packages/agent/daemon/go.sum
packages/device-link/go.mod
packages/device-link/go.sum
- name: Set up JDK
uses: actions/setup-java@v4
with:
distribution: temurin
java-version: "17"
- name: Set up Android SDK
uses: android-actions/setup-android@v3
- name: Install Android build dependencies
shell: bash
run: |
sdkmanager --install \
"platform-tools" \
"platforms;android-36" \
"build-tools;36.0.0" \
"ndk;27.3.13750724"
- name: Install workspace dependencies
run: pnpm install --frozen-lockfile
- name: Prepare stable Android signing key
shell: bash
env:
ANDROID_RELEASE_KEYSTORE_BASE64: ${{ secrets.ANDROID_RELEASE_KEYSTORE_BASE64 }}
TUTTI_ANDROID_RELEASE_KEYSTORE_PASSWORD: ${{ secrets.ANDROID_RELEASE_KEYSTORE_PASSWORD }}
TUTTI_ANDROID_RELEASE_KEY_ALIAS: ${{ secrets.ANDROID_RELEASE_KEY_ALIAS }}
TUTTI_ANDROID_RELEASE_KEY_PASSWORD: ${{ secrets.ANDROID_RELEASE_KEY_PASSWORD }}
run: |
if [[ -z "${ANDROID_RELEASE_KEYSTORE_BASE64}" ||
-z "${TUTTI_ANDROID_RELEASE_KEYSTORE_PASSWORD}" ||
-z "${TUTTI_ANDROID_RELEASE_KEY_ALIAS}" ||
-z "${TUTTI_ANDROID_RELEASE_KEY_PASSWORD}" ]]; then
echo "Stable Android release signing secrets are required." >&2
exit 1
fi
signing_dir="${RUNNER_TEMP}/tutti-android-signing"
keystore_path="${signing_dir}/release.keystore"
mkdir -p "${signing_dir}"
printf '%s' "${ANDROID_RELEASE_KEYSTORE_BASE64}" | base64 --decode > "${keystore_path}"
chmod 600 "${keystore_path}"
test -s "${keystore_path}"
keytool -list \
-keystore "${keystore_path}" \
-storepass "${TUTTI_ANDROID_RELEASE_KEYSTORE_PASSWORD}" \
-alias "${TUTTI_ANDROID_RELEASE_KEY_ALIAS}" \
>/dev/null
echo "TUTTI_ANDROID_RELEASE_KEYSTORE_PATH=${keystore_path}" >> "${GITHUB_ENV}"
- name: Build Android release APK
env:
TUTTI_ANDROID_RELEASE_KEYSTORE_PASSWORD: ${{ secrets.ANDROID_RELEASE_KEYSTORE_PASSWORD }}
TUTTI_ANDROID_RELEASE_KEY_ALIAS: ${{ secrets.ANDROID_RELEASE_KEY_ALIAS }}
TUTTI_ANDROID_RELEASE_KEY_PASSWORD: ${{ secrets.ANDROID_RELEASE_KEY_PASSWORD }}
TUTTI_ANDROID_VERSION_CODE: ${{ github.run_number }}
TUTTI_ANDROID_VERSION_NAME: ${{ inputs.android_version_name }}
run: |
pnpm --filter @tutti-os/mobile android:aar
cd apps/mobile/android
./gradlew app:assembleRelease
- name: Verify signed Android APK
shell: bash
run: |
signed_apk="apps/mobile/android/app/build/outputs/apk/release/app-release.apk"
expected_certificate="apps/mobile/android/release-certificate.pem"
build_tools="${ANDROID_HOME}/build-tools/36.0.0"
test -f "${signed_apk}"
"${build_tools}/zipalign" -c -p 4 "${signed_apk}"
verification_output="$(
"${build_tools}/apksigner" verify --verbose --print-certs "${signed_apk}"
)"
printf '%s\n' "${verification_output}"
expected_digest="$(
keytool -printcert -file "${expected_certificate}" |
sed -n 's/^[[:space:]]*SHA256: //p' |
tr -d ':' |
tr '[:upper:]' '[:lower:]'
)"
actual_digest="$(
printf '%s\n' "${verification_output}" |
sed -n 's/^Signer #1 certificate SHA-256 digest: //p'
)"
if [[ -z "${expected_digest}" || "${actual_digest}" != "${expected_digest}" ]]; then
echo "Android APK signing certificate does not match the pinned release certificate." >&2
exit 1
fi
- name: Generate APK checksum
working-directory: apps/mobile/android/app/build/outputs/apk/release
run: sha256sum app-release.apk > SHA256SUMS
- name: Validate Android release publishing configuration
if: inputs.publish_android && (inputs.platform == 'android' || inputs.platform == 'all')
env:
AWS_REGION: ${{ vars.AWS_REGION }}
TUTTI_ARTIFACTS_AWS_ROLE_ARN: ${{ vars.TUTTI_ARTIFACTS_AWS_ROLE_ARN }}
TUTTI_MOBILE_RELEASE_ASSETS_BASE_URL: ${{ vars.TUTTI_MOBILE_RELEASE_ASSETS_BASE_URL }}
TUTTI_MOBILE_RELEASE_ASSETS_S3_BUCKET: ${{ vars.TUTTI_MOBILE_RELEASE_ASSETS_S3_BUCKET }}
TUTTI_MOBILE_RELEASE_ASSETS_S3_PREFIX: ${{ vars.TUTTI_MOBILE_RELEASE_ASSETS_S3_PREFIX }}
TUTTI_ANDROID_VERSION_NAME: ${{ inputs.android_version_name }}
run: |
for name in AWS_REGION TUTTI_ARTIFACTS_AWS_ROLE_ARN TUTTI_MOBILE_RELEASE_ASSETS_BASE_URL TUTTI_MOBILE_RELEASE_ASSETS_S3_BUCKET TUTTI_MOBILE_RELEASE_ASSETS_S3_PREFIX; do
if [[ -z "${!name:-}" ]]; then
echo "Missing required Android release publishing variable: ${name}" >&2
exit 1
fi
done
if [[ ! "${TUTTI_ANDROID_VERSION_NAME}" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
echo "android_version_name must be stable semver x.y.z." >&2
exit 1
fi
- name: Build Android release pointer
if: inputs.publish_android && (inputs.platform == 'android' || inputs.platform == 'all')
env:
TUTTI_ANDROID_VERSION_CODE: ${{ github.run_number }}
TUTTI_ANDROID_VERSION_NAME: ${{ inputs.android_version_name }}
TUTTI_MOBILE_RELEASE_ASSETS_BASE_URL: ${{ vars.TUTTI_MOBILE_RELEASE_ASSETS_BASE_URL }}
run: |
node tools/scripts/build-mobile-release-latest.mjs \
--apk apps/mobile/android/app/build/outputs/apk/release/app-release.apk \
--base-url "${TUTTI_MOBILE_RELEASE_ASSETS_BASE_URL}" \
--output mobile-release-latest.json \
--tag "tutti-mobile-v${TUTTI_ANDROID_VERSION_NAME}" \
--version-code "${TUTTI_ANDROID_VERSION_CODE}" \
--version-name "${TUTTI_ANDROID_VERSION_NAME}"
- name: Configure AWS credentials for Android release
if: inputs.publish_android && (inputs.platform == 'android' || inputs.platform == 'all')
uses: aws-actions/configure-aws-credentials@v4
with:
aws-region: ${{ vars.AWS_REGION }}
role-to-assume: ${{ vars.TUTTI_ARTIFACTS_AWS_ROLE_ARN }}
- name: Prevent Android release rollback
if: inputs.publish_android && (inputs.platform == 'android' || inputs.platform == 'all')
env:
TUTTI_MOBILE_RELEASE_ASSETS_S3_BUCKET: ${{ vars.TUTTI_MOBILE_RELEASE_ASSETS_S3_BUCKET }}
TUTTI_MOBILE_RELEASE_ASSETS_S3_PREFIX: ${{ vars.TUTTI_MOBILE_RELEASE_ASSETS_S3_PREFIX }}
TUTTI_ANDROID_VERSION_CODE: ${{ github.run_number }}
TUTTI_ANDROID_VERSION_NAME: ${{ inputs.android_version_name }}
run: |
prefix="${TUTTI_MOBILE_RELEASE_ASSETS_S3_PREFIX%/}"
pointer_path="s3://${TUTTI_MOBILE_RELEASE_ASSETS_S3_BUCKET}/${prefix}/latest.json"
candidate_tag="tutti-mobile-v${TUTTI_ANDROID_VERSION_NAME}"
if aws s3 cp "${pointer_path}" current-mobile-release-pointer.json 2> current-mobile-pointer-download.err; then
export RELEASE_VERSION_CODE="${TUTTI_ANDROID_VERSION_CODE}"
export RELEASE_TAG="${candidate_tag}"
node <<'NODE'
const fs = require("node:fs");
const current = JSON.parse(fs.readFileSync("current-mobile-release-pointer.json", "utf8"));
const currentCode = Number(current.versionCode);
const candidateCode = Number(process.env.RELEASE_VERSION_CODE);
if (!Number.isSafeInteger(currentCode) || currentCode <= 0) {
throw new Error("Existing Android release pointer has an invalid versionCode.");
}
if (!Number.isSafeInteger(candidateCode) || candidateCode <= 0) {
throw new Error("Candidate Android release has an invalid versionCode.");
}
if (candidateCode < currentCode) {
throw new Error(`Refusing to move Android release from versionCode ${currentCode} back to ${candidateCode}.`);
}
if (current.tag === process.env.RELEASE_TAG && candidateCode > currentCode) {
throw new Error(`Android version ${process.env.RELEASE_TAG} already has a published release.`);
}
if (candidateCode === currentCode && current.tag !== process.env.RELEASE_TAG) {
throw new Error(`Android versionCode ${candidateCode} is already published by ${current.tag}.`);
}
NODE
elif ! grep -Eq "(404|NoSuchKey|Not Found)" current-mobile-pointer-download.err; then
cat current-mobile-pointer-download.err >&2
exit 1
fi
- name: Publish Android release assets and pointer
if: inputs.publish_android && (inputs.platform == 'android' || inputs.platform == 'all')
env:
TUTTI_MOBILE_RELEASE_ASSETS_S3_BUCKET: ${{ vars.TUTTI_MOBILE_RELEASE_ASSETS_S3_BUCKET }}
TUTTI_MOBILE_RELEASE_ASSETS_S3_PREFIX: ${{ vars.TUTTI_MOBILE_RELEASE_ASSETS_S3_PREFIX }}
TUTTI_ANDROID_VERSION_NAME: ${{ inputs.android_version_name }}
run: |
prefix="${TUTTI_MOBILE_RELEASE_ASSETS_S3_PREFIX%/}"
checksum_path="apps/mobile/android/app/build/outputs/apk/release/SHA256SUMS"
apk_sha256="$(awk 'NR == 1 { print $1 }' "${checksum_path}")"
if [[ ! "${apk_sha256}" =~ ^[a-f0-9]{64}$ ]]; then
echo "Generated Android APK checksum is invalid." >&2
exit 1
fi
remote_dir="s3://${TUTTI_MOBILE_RELEASE_ASSETS_S3_BUCKET}/${prefix}/tutti-mobile-v${TUTTI_ANDROID_VERSION_NAME}/${apk_sha256}"
pointer_path="s3://${TUTTI_MOBILE_RELEASE_ASSETS_S3_BUCKET}/${prefix}/latest.json"
preflight_immutable() {
local source_path="$1"
local destination_path="$2"
local comparison_path="${RUNNER_TEMP}/$(basename "${destination_path}").published"
local error_path="${comparison_path}.err"
if aws s3 cp "${destination_path}" "${comparison_path}" 2> "${error_path}"; then
if ! cmp --silent "${source_path}" "${comparison_path}"; then
echo "Refusing to overwrite immutable Android release asset: ${destination_path}" >&2
exit 1
fi
echo "Immutable Android release asset already exists with identical content: ${destination_path}"
return 0
fi
if grep -Eq "(404|NoSuchKey|Not Found)" "${error_path}"; then
return 10
else
cat "${error_path}" >&2
return 1
fi
}
upload_immutable() {
local source_path="$1"
local destination_path="$2"
local content_type="$3"
aws s3 cp "${source_path}" "${destination_path}" \
--cache-control "public, max-age=31536000, immutable" \
--content-type "${content_type}"
}
apk_needs_upload=false
if preflight_immutable \
apps/mobile/android/app/build/outputs/apk/release/app-release.apk \
"${remote_dir}/app-release.apk"; then
:
else
status="$?"
if [[ "${status}" -eq 10 ]]; then apk_needs_upload=true; else exit "${status}"; fi
fi
checksum_needs_upload=false
if preflight_immutable "${checksum_path}" "${remote_dir}/SHA256SUMS"; then
:
else
status="$?"
if [[ "${status}" -eq 10 ]]; then checksum_needs_upload=true; else exit "${status}"; fi
fi
if [[ "${apk_needs_upload}" == true ]]; then
upload_immutable \
apps/mobile/android/app/build/outputs/apk/release/app-release.apk \
"${remote_dir}/app-release.apk" \
"application/vnd.android.package-archive"
fi
if [[ "${checksum_needs_upload}" == true ]]; then
upload_immutable "${checksum_path}" "${remote_dir}/SHA256SUMS" "text/plain"
fi
aws s3 cp mobile-release-latest.json "${pointer_path}" \
--cache-control "public, max-age=60" \
--content-type "application/json"
- name: Upload internal Android artifact
uses: actions/upload-artifact@v4
with:
name: tutti-mobile-internal-${{ github.sha }}
path: |
apps/mobile/android/app/build/outputs/apk/release/app-release.apk
apps/mobile/android/app/build/outputs/apk/release/SHA256SUMS
if-no-files-found: error
retention-days: 14
ios:
name: Build and upload iOS TestFlight
if: inputs.platform == 'ios' || inputs.platform == 'all'
runs-on: macos-26
timeout-minutes: 60
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Set up pnpm
uses: pnpm/action-setup@v4
- name: Set up Node.js
uses: actions/setup-node@v4
with:
node-version-file: .node-version
cache: pnpm
cache-dependency-path: pnpm-lock.yaml
- name: Set up Go
uses: actions/setup-go@v5
with:
go-version: "1.26.0"
cache-dependency-path: |
go.work
go.work.sum
packages/agent/daemon/go.mod
packages/agent/daemon/go.sum
packages/device-link/go.mod
packages/device-link/go.sum
- name: Install workspace dependencies
run: pnpm install --frozen-lockfile
- name: Prepare App Store Connect authentication
shell: bash
env:
APPLE_API_ISSUER: ${{ secrets.APPLE_API_ISSUER }}
APPLE_API_KEY_ID: ${{ secrets.APPLE_API_KEY_ID }}
APPLE_API_KEY_P8_BASE64: ${{ secrets.APPLE_API_KEY_P8_BASE64 }}
IOS_DEVELOPMENT_TEAM: ${{ vars.IOS_DEVELOPMENT_TEAM }}
run: |
if [[ -z "${APPLE_API_ISSUER}" ||
-z "${APPLE_API_KEY_ID}" ||
-z "${APPLE_API_KEY_P8_BASE64}" ||
-z "${IOS_DEVELOPMENT_TEAM}" ]]; then
echo "Apple API key secrets and IOS_DEVELOPMENT_TEAM are required." >&2
exit 1
fi
private_key_dir="${RUNNER_TEMP}/tutti-ios-private-keys"
private_key_path="${private_key_dir}/AuthKey_${APPLE_API_KEY_ID}.p8"
mkdir -p "${private_key_dir}"
node -e \
"require('node:fs').writeFileSync(process.argv[1], Buffer.from(process.env.APPLE_API_KEY_P8_BASE64, 'base64'))" \
"${private_key_path}"
chmod 600 "${private_key_path}"
echo "APPLE_API_KEY_PATH=${private_key_path}" >> "${GITHUB_ENV}"
echo "IOS_DEVELOPMENT_TEAM=${IOS_DEVELOPMENT_TEAM}" >> "${GITHUB_ENV}"
- name: Build iOS framework and install pods
run: pnpm --filter @tutti-os/mobile ios:pods
- name: Archive iOS app
shell: bash
env:
APPLE_API_ISSUER: ${{ secrets.APPLE_API_ISSUER }}
APPLE_API_KEY_ID: ${{ secrets.APPLE_API_KEY_ID }}
run: |
archive_path="${RUNNER_TEMP}/TuttiMobile.xcarchive"
xcodebuild \
-workspace apps/mobile/ios/TuttiMobile.xcworkspace \
-scheme TuttiMobile \
-configuration Release \
-destination "generic/platform=iOS" \
-archivePath "${archive_path}" \
-allowProvisioningUpdates \
-authenticationKeyPath "${APPLE_API_KEY_PATH}" \
-authenticationKeyID "${APPLE_API_KEY_ID}" \
-authenticationKeyIssuerID "${APPLE_API_ISSUER}" \
DEVELOPMENT_TEAM="${IOS_DEVELOPMENT_TEAM}" \
CODE_SIGN_STYLE=Automatic \
CURRENT_PROJECT_VERSION="${GITHUB_RUN_NUMBER}.${GITHUB_RUN_ATTEMPT}" \
clean archive
echo "IOS_ARCHIVE_PATH=${archive_path}" >> "${GITHUB_ENV}"
- name: Export App Store Connect IPA
shell: bash
env:
APPLE_API_ISSUER: ${{ secrets.APPLE_API_ISSUER }}
APPLE_API_KEY_ID: ${{ secrets.APPLE_API_KEY_ID }}
run: |
export_dir="${RUNNER_TEMP}/tutti-ios-export"
export_options="${RUNNER_TEMP}/tutti-ios-export-options.plist"
artifact_dir="${RUNNER_TEMP}/tutti-ios-artifact"
plutil -create xml1 "${export_options}"
plutil -insert destination -string export "${export_options}"
plutil -insert method -string app-store-connect "${export_options}"
plutil -insert signingStyle -string automatic "${export_options}"
plutil -insert teamID -string "${IOS_DEVELOPMENT_TEAM}" "${export_options}"
plutil -insert manageAppVersionAndBuildNumber -bool false "${export_options}"
plutil -insert uploadSymbols -bool true "${export_options}"
xcodebuild \
-exportArchive \
-archivePath "${IOS_ARCHIVE_PATH}" \
-exportPath "${export_dir}" \
-exportOptionsPlist "${export_options}" \
-allowProvisioningUpdates \
-authenticationKeyPath "${APPLE_API_KEY_PATH}" \
-authenticationKeyID "${APPLE_API_KEY_ID}" \
-authenticationKeyIssuerID "${APPLE_API_ISSUER}"
ipa_path="$(find "${export_dir}" -maxdepth 1 -name '*.ipa' -print -quit)"
test -n "${ipa_path}"
mkdir -p "${artifact_dir}"
cp "${ipa_path}" "${artifact_dir}/tutti-mobile-testflight.ipa"
- name: Verify TestFlight IPA
shell: bash
run: |
artifact_dir="${RUNNER_TEMP}/tutti-ios-artifact"
verification_dir="${RUNNER_TEMP}/tutti-ios-verification"
mkdir -p "${verification_dir}"
unzip -q "${artifact_dir}/tutti-mobile-testflight.ipa" -d "${verification_dir}"
app_path="$(find "${verification_dir}/Payload" -maxdepth 1 -name '*.app' -print -quit)"
test -n "${app_path}"
test -f "${app_path}/embedded.mobileprovision"
test -f "${app_path}/main.jsbundle"
codesign --verify --deep --strict "${app_path}"
(
cd "${artifact_dir}"
shasum -a 256 tutti-mobile-testflight.ipa > SHA256SUMS
)
- name: Upload to TestFlight
shell: bash
env:
APPLE_API_ISSUER: ${{ secrets.APPLE_API_ISSUER }}
APPLE_API_KEY_ID: ${{ secrets.APPLE_API_KEY_ID }}
run: |
upload_dir="${RUNNER_TEMP}/tutti-ios-upload"
upload_options="${RUNNER_TEMP}/tutti-ios-upload-options.plist"
plutil -create xml1 "${upload_options}"
plutil -insert destination -string upload "${upload_options}"
plutil -insert method -string app-store-connect "${upload_options}"
plutil -insert signingStyle -string automatic "${upload_options}"
plutil -insert teamID -string "${IOS_DEVELOPMENT_TEAM}" "${upload_options}"
plutil -insert manageAppVersionAndBuildNumber -bool false "${upload_options}"
plutil -insert uploadSymbols -bool true "${upload_options}"
xcodebuild \
-exportArchive \
-archivePath "${IOS_ARCHIVE_PATH}" \
-exportPath "${upload_dir}" \
-exportOptionsPlist "${upload_options}" \
-allowProvisioningUpdates \
-authenticationKeyPath "${APPLE_API_KEY_PATH}" \
-authenticationKeyID "${APPLE_API_KEY_ID}" \
-authenticationKeyIssuerID "${APPLE_API_ISSUER}"
- name: Upload TestFlight IPA artifact
uses: actions/upload-artifact@v4
with:
name: tutti-mobile-ios-testflight-${{ github.sha }}
path: |
${{ runner.temp }}/tutti-ios-artifact/tutti-mobile-testflight.ipa
${{ runner.temp }}/tutti-ios-artifact/SHA256SUMS
if-no-files-found: error
retention-days: 14