mirror of
https://github.com/openclaw/openclaw.git
synced 2026-07-09 15:59:30 +00:00
feat(android): publish signed APKs with stable releases (#101212)
* feat(android): publish signed release APKs * fix(android): preserve fallback corrections * test(android): isolate APK signer fixtures * docs: refresh generated docs map
This commit is contained in:
parent
dbba3d2a8e
commit
054e3675e1
19 changed files with 1099 additions and 62 deletions
469
.github/workflows/android-release.yml
vendored
Normal file
469
.github/workflows/android-release.yml
vendored
Normal file
|
|
@ -0,0 +1,469 @@
|
|||
name: Android Release
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
tag:
|
||||
description: Existing stable OpenClaw release tag to receive the signed Android APK
|
||||
required: true
|
||||
type: string
|
||||
release_publish_run_id:
|
||||
description: OpenClaw Release Publish run that approved this release
|
||||
required: true
|
||||
type: string
|
||||
release_publish_branch:
|
||||
description: Branch used by the approving OpenClaw Release Publish run
|
||||
required: true
|
||||
type: string
|
||||
release_target_sha:
|
||||
description: Exact release tag commit resolved by OpenClaw Release Publish
|
||||
required: true
|
||||
type: string
|
||||
direct_release_recovery:
|
||||
description: Allow a completed parent run and published release for explicit backfill or recovery
|
||||
required: true
|
||||
default: false
|
||||
type: boolean
|
||||
|
||||
permissions:
|
||||
actions: read
|
||||
attestations: write
|
||||
contents: write
|
||||
id-token: write
|
||||
|
||||
concurrency:
|
||||
group: android-release-${{ inputs.tag }}
|
||||
cancel-in-progress: false
|
||||
|
||||
env:
|
||||
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true"
|
||||
NODE_VERSION: "24.15.0"
|
||||
|
||||
jobs:
|
||||
publish_signed_android_apk:
|
||||
name: Publish signed Android APK
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 45
|
||||
environment: android-release
|
||||
steps:
|
||||
- name: Checkout release tag
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6
|
||||
with:
|
||||
ref: refs/tags/${{ inputs.tag }}
|
||||
fetch-depth: 0
|
||||
persist-credentials: false
|
||||
|
||||
- name: Download parent release approval
|
||||
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8
|
||||
with:
|
||||
name: android-release-approval-${{ inputs.release_publish_run_id }}
|
||||
path: ${{ runner.temp }}/android-release-approval
|
||||
repository: ${{ github.repository }}
|
||||
run-id: ${{ inputs.release_publish_run_id }}
|
||||
github-token: ${{ github.token }}
|
||||
|
||||
- name: Validate release approval and target
|
||||
env:
|
||||
APPROVAL_PATH: ${{ runner.temp }}/android-release-approval/approval.json
|
||||
DIRECT_RELEASE_RECOVERY: ${{ inputs.direct_release_recovery && 'true' || 'false' }}
|
||||
EXPECTED_WORKFLOW_BRANCH: ${{ inputs.release_publish_branch }}
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
RELEASE_PUBLISH_RUN_ID: ${{ inputs.release_publish_run_id }}
|
||||
RELEASE_TAG: ${{ inputs.tag }}
|
||||
RELEASE_TARGET_SHA: ${{ inputs.release_target_sha }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if [[ ! "${RELEASE_TAG}" =~ ^v[0-9]{4}\.[1-9][0-9]*\.[1-9][0-9]*(-[1-9][0-9]*)?$ ]]; then
|
||||
echo "Android APK publishing requires a final or correction OpenClaw release tag: ${RELEASE_TAG}" >&2
|
||||
exit 1
|
||||
fi
|
||||
if [[ "${EXPECTED_WORKFLOW_BRANCH}" != "main" && ! "${EXPECTED_WORKFLOW_BRANCH}" =~ ^release/[0-9]{4}\.[1-9][0-9]*\.[1-9][0-9]*$ ]]; then
|
||||
echo "release_publish_branch must be main or release/YYYY.M.PATCH." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
expected_source_ref="refs/tags/${RELEASE_TAG}"
|
||||
if [[ "${GITHUB_REF}" != "${expected_source_ref}" ]]; then
|
||||
echo "Android publication must run from ${expected_source_ref}, got ${GITHUB_REF}." >&2
|
||||
exit 1
|
||||
fi
|
||||
if [[ ! "${RELEASE_TARGET_SHA}" =~ ^[a-f0-9]{40}$ ]]; then
|
||||
echo "release_target_sha must be a full lowercase commit SHA." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
gh attestation verify "${APPROVAL_PATH}" \
|
||||
--repo "${GITHUB_REPOSITORY}" \
|
||||
--signer-workflow "${GITHUB_REPOSITORY}/.github/workflows/openclaw-release-publish.yml" \
|
||||
--source-ref "refs/heads/${EXPECTED_WORKFLOW_BRANCH}" \
|
||||
--deny-self-hosted-runners
|
||||
run_json="$(gh run view "${RELEASE_PUBLISH_RUN_ID}" --repo "${GITHUB_REPOSITORY}" --json workflowName,headBranch,event,status,conclusion,url)"
|
||||
printf '%s' "${run_json}" | node scripts/validate-release-publish-approval.mjs
|
||||
tag_sha="$(git rev-parse "${RELEASE_TAG}^{commit}")"
|
||||
if [[ "${RELEASE_TARGET_SHA}" != "${tag_sha}" ]]; then
|
||||
echo "Release target SHA ${RELEASE_TARGET_SHA} does not match ${RELEASE_TAG} (${tag_sha})." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
release_json="$(gh release view "${RELEASE_TAG}" --repo "${GITHUB_REPOSITORY}" --json tagName,isDraft,isPrerelease,assets,url)"
|
||||
if [[ "$(printf '%s' "${release_json}" | jq -r '.tagName')" != "${RELEASE_TAG}" ]]; then
|
||||
echo "GitHub release tag does not match ${RELEASE_TAG}." >&2
|
||||
exit 1
|
||||
fi
|
||||
if [[ "$(printf '%s' "${release_json}" | jq -r '.isPrerelease')" == "true" ]]; then
|
||||
echo "Android APK publishing requires a stable GitHub release." >&2
|
||||
exit 1
|
||||
fi
|
||||
if [[ "${DIRECT_RELEASE_RECOVERY}" != "true" && "$(printf '%s' "${release_json}" | jq -r '.isDraft')" != "true" ]]; then
|
||||
echo "Normal Android promotion requires the target GitHub release to remain a draft." >&2
|
||||
exit 1
|
||||
fi
|
||||
unexpected_assets="$(printf '%s' "${release_json}" | jq -r '[.assets[]? | select(.name | startswith("OpenClaw-Android")) | .name] | join(", ")')"
|
||||
if [[ -n "${unexpected_assets}" ]]; then
|
||||
echo "Target release already contains Android assets: ${unexpected_assets}. Immutable recovery accepts them only after rebuilding identical bytes." >&2
|
||||
fi
|
||||
|
||||
- name: Verify release source and Android version
|
||||
id: release_source
|
||||
env:
|
||||
RELEASE_TAG: ${{ inputs.tag }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
release_version="${RELEASE_TAG#v}"
|
||||
android_release_version="${release_version%%-*}"
|
||||
package_version="$(node -p 'require("./package.json").version')"
|
||||
android_version="$(node -p 'require("./apps/android/version.json").version')"
|
||||
fallback_base_tag=""
|
||||
fallback_base_sha=""
|
||||
if [[ "${package_version}" == "${release_version}" ]]; then
|
||||
:
|
||||
elif [[ "${RELEASE_TAG}" =~ -[1-9][0-9]*$ && "${package_version}" == "${android_release_version}" ]]; then
|
||||
fallback_base_tag="v${package_version}"
|
||||
fallback_base_sha="$(git rev-parse "${fallback_base_tag}^{commit}")"
|
||||
release_sha="$(git rev-parse HEAD)"
|
||||
if [[ "${fallback_base_sha}" != "${release_sha}" ]]; then
|
||||
echo "Fallback correction ${RELEASE_TAG} must resolve to the same source commit as ${fallback_base_tag}." >&2
|
||||
exit 1
|
||||
fi
|
||||
else
|
||||
echo "Release tag version ${release_version} does not match package.json ${package_version} or a same-commit fallback correction." >&2
|
||||
exit 1
|
||||
fi
|
||||
if [[ "${android_version}" != "${android_release_version}" ]]; then
|
||||
echo "Android version ${android_version} does not match release train ${android_release_version}." >&2
|
||||
exit 1
|
||||
fi
|
||||
echo "fallback_base_tag=${fallback_base_tag}" >> "${GITHUB_OUTPUT}"
|
||||
echo "FALLBACK_ANDROID_BASE_TAG=${fallback_base_tag}" >> "${GITHUB_ENV}"
|
||||
echo "FALLBACK_ANDROID_BASE_SHA=${fallback_base_sha}" >> "${GITHUB_ENV}"
|
||||
git diff --exit-code
|
||||
|
||||
- name: Setup Node environment
|
||||
uses: ./.github/actions/setup-node-env
|
||||
with:
|
||||
install-bun: "true"
|
||||
install-deps: "false"
|
||||
use-actions-cache: "false"
|
||||
|
||||
- name: Setup Java
|
||||
uses: actions/setup-java@ad2b38190b15e4d6bdf0c97fb4fca8412226d287 # v5
|
||||
with:
|
||||
distribution: temurin
|
||||
java-version: 17
|
||||
cache: gradle
|
||||
cache-dependency-path: |
|
||||
apps/android/**/*.gradle*
|
||||
apps/android/**/gradle-wrapper.properties
|
||||
apps/android/gradle/libs.versions.toml
|
||||
|
||||
- name: Cache Android SDK
|
||||
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5
|
||||
with:
|
||||
path: ~/.android-sdk
|
||||
key: ${{ runner.os }}-android-sdk-v1-cmdline-14742923-platform-36-build-tools-36.0.0
|
||||
restore-keys: |
|
||||
${{ runner.os }}-android-sdk-v1-
|
||||
|
||||
- name: Setup Android SDK
|
||||
run: |
|
||||
set -euo pipefail
|
||||
ANDROID_SDK_ROOT="$HOME/.android-sdk"
|
||||
CMDLINE_TOOLS_VERSION="14742923"
|
||||
ARCHIVE="commandlinetools-linux-${CMDLINE_TOOLS_VERSION}_latest.zip"
|
||||
URL="https://dl.google.com/android/repository/${ARCHIVE}"
|
||||
if [[ ! -x "${ANDROID_SDK_ROOT}/cmdline-tools/latest/bin/sdkmanager" ]]; then
|
||||
mkdir -p "${ANDROID_SDK_ROOT}/cmdline-tools"
|
||||
curl -fsSL "${URL}" -o "${RUNNER_TEMP}/${ARCHIVE}"
|
||||
rm -rf "${ANDROID_SDK_ROOT}/cmdline-tools/latest"
|
||||
unzip -q "${RUNNER_TEMP}/${ARCHIVE}" -d "${ANDROID_SDK_ROOT}/cmdline-tools"
|
||||
mv "${ANDROID_SDK_ROOT}/cmdline-tools/cmdline-tools" "${ANDROID_SDK_ROOT}/cmdline-tools/latest"
|
||||
fi
|
||||
echo "ANDROID_SDK_ROOT=${ANDROID_SDK_ROOT}" >> "${GITHUB_ENV}"
|
||||
echo "ANDROID_HOME=${ANDROID_SDK_ROOT}" >> "${GITHUB_ENV}"
|
||||
echo "${ANDROID_SDK_ROOT}/cmdline-tools/latest/bin" >> "${GITHUB_PATH}"
|
||||
echo "${ANDROID_SDK_ROOT}/platform-tools" >> "${GITHUB_PATH}"
|
||||
|
||||
- name: Install Android SDK packages
|
||||
run: |
|
||||
set -eu
|
||||
yes | sdkmanager --sdk_root="${ANDROID_SDK_ROOT}" --licenses >/dev/null
|
||||
sdkmanager --sdk_root="${ANDROID_SDK_ROOT}" --install \
|
||||
"platform-tools" \
|
||||
"platforms;android-36" \
|
||||
"build-tools;36.0.0"
|
||||
|
||||
- name: Create apps-signing read token
|
||||
if: ${{ steps.release_source.outputs.fallback_base_tag == '' }}
|
||||
id: apps-signing-token
|
||||
uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3
|
||||
with:
|
||||
app-id: "2729701"
|
||||
private-key: ${{ secrets.GH_APP_PRIVATE_KEY }}
|
||||
owner: openclaw
|
||||
repositories: apps-signing
|
||||
permission-contents: read
|
||||
|
||||
- name: Checkout encrypted Android signing assets
|
||||
if: ${{ steps.release_source.outputs.fallback_base_tag == '' }}
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6
|
||||
with:
|
||||
repository: openclaw/apps-signing
|
||||
ref: main
|
||||
path: apps/android/build/release-signing/apps-signing
|
||||
token: ${{ steps.apps-signing-token.outputs.token }}
|
||||
persist-credentials: false
|
||||
|
||||
- name: Materialize Android release signing
|
||||
if: ${{ steps.release_source.outputs.fallback_base_tag == '' }}
|
||||
env:
|
||||
MATCH_PASSWORD: ${{ secrets.MATCH_PASSWORD }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if [[ -z "${MATCH_PASSWORD}" ]]; then
|
||||
echo "Android release signing secrets are unavailable." >&2
|
||||
exit 1
|
||||
fi
|
||||
node scripts/android-release-signing.mjs --mode materialize
|
||||
|
||||
- name: Prepare and verify signed Android APK
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
RELEASE_TAG: ${{ inputs.tag }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
mkdir -p dist
|
||||
if [[ -n "${FALLBACK_ANDROID_BASE_TAG}" ]]; then
|
||||
base_dir="${RUNNER_TEMP}/fallback-android-release"
|
||||
mkdir -p "${base_dir}"
|
||||
gh release download "${FALLBACK_ANDROID_BASE_TAG}" --repo "${GITHUB_REPOSITORY}" \
|
||||
--pattern OpenClaw-Android.apk \
|
||||
--pattern OpenClaw-Android-SHA256SUMS.txt \
|
||||
--dir "${base_dir}"
|
||||
(
|
||||
cd "${base_dir}"
|
||||
sha256sum --strict --check OpenClaw-Android-SHA256SUMS.txt
|
||||
)
|
||||
gh attestation verify "${base_dir}/OpenClaw-Android.apk" \
|
||||
--repo "${GITHUB_REPOSITORY}" \
|
||||
--signer-workflow "${GITHUB_REPOSITORY}/.github/workflows/android-release.yml" \
|
||||
--source-ref "refs/tags/${FALLBACK_ANDROID_BASE_TAG}" \
|
||||
--source-digest "${FALLBACK_ANDROID_BASE_SHA}" \
|
||||
--deny-self-hosted-runners
|
||||
bun apps/android/scripts/build-release-artifacts.ts \
|
||||
--verify-apk "${base_dir}/OpenClaw-Android.apk"
|
||||
cp "${base_dir}/OpenClaw-Android.apk" dist/OpenClaw-Android.apk
|
||||
cp "${base_dir}/OpenClaw-Android-SHA256SUMS.txt" dist/OpenClaw-Android-SHA256SUMS.txt
|
||||
else
|
||||
node --input-type=module <<'NODE'
|
||||
import { readFileSync } from "node:fs";
|
||||
import { spawnSync } from "node:child_process";
|
||||
|
||||
const path = "apps/android/build/release-signing/gradle.properties";
|
||||
const expected = new Set([
|
||||
"OPENCLAW_ANDROID_STORE_FILE",
|
||||
"OPENCLAW_ANDROID_STORE_PASSWORD",
|
||||
"OPENCLAW_ANDROID_KEY_ALIAS",
|
||||
"OPENCLAW_ANDROID_KEY_PASSWORD",
|
||||
]);
|
||||
const properties = new Map();
|
||||
for (const rawLine of readFileSync(path, "utf8").split(/\r?\n/u)) {
|
||||
const line = rawLine.trim();
|
||||
if (!line || line.startsWith("#")) continue;
|
||||
const separator = line.indexOf("=");
|
||||
if (separator <= 0) throw new Error("Malformed Android signing property.");
|
||||
properties.set(line.slice(0, separator).trim(), line.slice(separator + 1).trim());
|
||||
}
|
||||
for (const name of expected) {
|
||||
const value = properties.get(name);
|
||||
if (!value) throw new Error(`Missing Android signing property: ${name}`);
|
||||
process.env[`ORG_GRADLE_PROJECT_${name}`] = value;
|
||||
}
|
||||
const result = spawnSync(
|
||||
"bun",
|
||||
["apps/android/scripts/build-release-artifacts.ts", "--artifact", "third-party"],
|
||||
{ env: process.env, stdio: "inherit" },
|
||||
);
|
||||
process.exit(result.status ?? 1);
|
||||
NODE
|
||||
|
||||
version="$(node -p 'require("./apps/android/version.json").version')"
|
||||
source_apk="apps/android/build/release-artifacts/openclaw-${version}-third-party-release.apk"
|
||||
source_checksum="${source_apk}.sha256"
|
||||
test -s "${source_apk}"
|
||||
test -s "${source_checksum}"
|
||||
cp "${source_apk}" dist/OpenClaw-Android.apk
|
||||
(
|
||||
cd dist
|
||||
sha256sum OpenClaw-Android.apk > OpenClaw-Android-SHA256SUMS.txt
|
||||
)
|
||||
fi
|
||||
|
||||
expected_version_name="${RELEASE_TAG#v}"
|
||||
expected_version_name="${expected_version_name%%-*}"
|
||||
expected_version_code="$(node -p 'require("./apps/android/version.json").versionCode')"
|
||||
actual_app_id="$(apkanalyzer manifest application-id dist/OpenClaw-Android.apk)"
|
||||
actual_version_name="$(apkanalyzer manifest version-name dist/OpenClaw-Android.apk)"
|
||||
actual_version_code="$(apkanalyzer manifest version-code dist/OpenClaw-Android.apk)"
|
||||
if [[ "${actual_app_id}" != "ai.openclaw.app" ]]; then
|
||||
echo "Standalone APK has unexpected application ID ${actual_app_id}." >&2
|
||||
exit 1
|
||||
fi
|
||||
if [[ "${actual_version_name}" != "${expected_version_name}" || "${actual_version_code}" != "${expected_version_code}" ]]; then
|
||||
echo "Standalone APK version metadata does not match apps/android/version.json." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [[ -n "${FALLBACK_ANDROID_BASE_TAG}" ]]; then
|
||||
echo "Reusing verified Android APK from ${FALLBACK_ANDROID_BASE_TAG} for same-commit fallback correction ${RELEASE_TAG}."
|
||||
elif [[ "${RELEASE_TAG}" =~ -([1-9][0-9]*)$ ]]; then
|
||||
correction_number="${BASH_REMATCH[1]}"
|
||||
releases_json="$(gh api --paginate --slurp "repos/${GITHUB_REPOSITORY}/releases?per_page=100")"
|
||||
previous_tag="$(printf '%s' "${releases_json}" | jq -r \
|
||||
--arg base_tag "v${expected_version_name}" \
|
||||
--argjson correction_number "${correction_number}" '
|
||||
[
|
||||
.[][]
|
||||
| select(.draft == false and .prerelease == false)
|
||||
| select(any(.assets[]?; .name == "OpenClaw-Android.apk"))
|
||||
| select(any(.assets[]?; .name == "OpenClaw-Android-SHA256SUMS.txt"))
|
||||
| if .tag_name == $base_tag then
|
||||
{ tag: .tag_name, number: 0 }
|
||||
elif (.tag_name | startswith($base_tag + "-")) then
|
||||
(.tag_name | ltrimstr($base_tag + "-")) as $suffix
|
||||
| select($suffix | test("^[1-9][0-9]*$"))
|
||||
| { tag: .tag_name, number: ($suffix | tonumber) }
|
||||
else empty end
|
||||
| select(.number < $correction_number)
|
||||
]
|
||||
| sort_by(.number)
|
||||
| last
|
||||
| .tag // empty
|
||||
')"
|
||||
if [[ -n "${previous_tag}" ]]; then
|
||||
previous_dir="${RUNNER_TEMP}/previous-android-release"
|
||||
mkdir -p "${previous_dir}"
|
||||
gh release download "${previous_tag}" --repo "${GITHUB_REPOSITORY}" \
|
||||
--pattern OpenClaw-Android.apk \
|
||||
--pattern OpenClaw-Android-SHA256SUMS.txt \
|
||||
--dir "${previous_dir}"
|
||||
(
|
||||
cd "${previous_dir}"
|
||||
sha256sum --strict --check OpenClaw-Android-SHA256SUMS.txt
|
||||
)
|
||||
gh attestation verify "${previous_dir}/OpenClaw-Android.apk" \
|
||||
--repo "${GITHUB_REPOSITORY}" \
|
||||
--signer-workflow "${GITHUB_REPOSITORY}/.github/workflows/android-release.yml" \
|
||||
--source-ref "refs/tags/${previous_tag}" \
|
||||
--deny-self-hosted-runners
|
||||
bun apps/android/scripts/build-release-artifacts.ts \
|
||||
--verify-apk "${previous_dir}/OpenClaw-Android.apk"
|
||||
previous_version_code="$(apkanalyzer manifest version-code "${previous_dir}/OpenClaw-Android.apk")"
|
||||
if (( actual_version_code <= previous_version_code )); then
|
||||
echo "Android correction versionCode ${actual_version_code} must exceed ${previous_tag} versionCode ${previous_version_code}." >&2
|
||||
exit 1
|
||||
fi
|
||||
else
|
||||
echo "No earlier verified standalone APK exists for ${expected_version_name}; accepting this correction as the standalone channel bootstrap."
|
||||
fi
|
||||
fi
|
||||
|
||||
- name: Attest Android APK provenance
|
||||
uses: actions/attest@a1948c3f048ba23858d222213b7c278aabede763 # v4.1.1
|
||||
with:
|
||||
subject-path: dist/OpenClaw-Android.apk
|
||||
|
||||
- name: Upload immutable Android release assets
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
RELEASE_TAG: ${{ inputs.tag }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
attach_or_verify_asset() {
|
||||
local source_path="$1"
|
||||
local asset_name="$2"
|
||||
local existing_dir="${RUNNER_TEMP}/existing-${asset_name}"
|
||||
if gh release view "${RELEASE_TAG}" --repo "${GITHUB_REPOSITORY}" --json assets |
|
||||
jq -e --arg name "${asset_name}" 'any(.assets[]?; .name == $name)' >/dev/null; then
|
||||
rm -rf "${existing_dir}"
|
||||
mkdir -p "${existing_dir}"
|
||||
gh release download "${RELEASE_TAG}" --repo "${GITHUB_REPOSITORY}" \
|
||||
--pattern "${asset_name}" --dir "${existing_dir}"
|
||||
cmp --silent "${source_path}" "${existing_dir}/${asset_name}" || {
|
||||
echo "Existing Android release asset ${asset_name} differs from this exact-tag build." >&2
|
||||
exit 1
|
||||
}
|
||||
return
|
||||
fi
|
||||
gh release upload "${RELEASE_TAG}" "${source_path}#${asset_name}" --repo "${GITHUB_REPOSITORY}"
|
||||
}
|
||||
|
||||
attach_or_verify_asset dist/OpenClaw-Android.apk OpenClaw-Android.apk
|
||||
attach_or_verify_asset dist/OpenClaw-Android-SHA256SUMS.txt OpenClaw-Android-SHA256SUMS.txt
|
||||
|
||||
- name: Verify published Android release assets
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
RELEASE_TAG: ${{ inputs.tag }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
verify_dir="${RUNNER_TEMP}/verify-android-release"
|
||||
mkdir -p "${verify_dir}"
|
||||
gh release download "${RELEASE_TAG}" --repo "${GITHUB_REPOSITORY}" \
|
||||
--pattern OpenClaw-Android.apk \
|
||||
--pattern OpenClaw-Android-SHA256SUMS.txt \
|
||||
--dir "${verify_dir}"
|
||||
(
|
||||
cd "${verify_dir}"
|
||||
sha256sum --strict --check OpenClaw-Android-SHA256SUMS.txt
|
||||
)
|
||||
gh attestation verify "${verify_dir}/OpenClaw-Android.apk" \
|
||||
--repo "${GITHUB_REPOSITORY}" \
|
||||
--signer-workflow "${GITHUB_REPOSITORY}/.github/workflows/android-release.yml" \
|
||||
--source-ref "refs/tags/${RELEASE_TAG}" \
|
||||
--deny-self-hosted-runners
|
||||
bun apps/android/scripts/build-release-artifacts.ts \
|
||||
--verify-apk "${verify_dir}/OpenClaw-Android.apk"
|
||||
|
||||
release_json="$(gh release view "${RELEASE_TAG}" --repo "${GITHUB_REPOSITORY}" --json assets)"
|
||||
expected_names='["OpenClaw-Android-SHA256SUMS.txt","OpenClaw-Android.apk"]'
|
||||
actual_names="$(printf '%s' "${release_json}" | jq -c '[.assets[]? | select(.name | startswith("OpenClaw-Android")) | .name] | sort')"
|
||||
if [[ "${actual_names}" != "${expected_names}" ]]; then
|
||||
echo "Android release asset names do not match the canonical contract." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Remove materialized signing files
|
||||
if: ${{ always() }}
|
||||
run: rm -rf apps/android/build/release-signing
|
||||
|
||||
- name: Summary
|
||||
env:
|
||||
RELEASE_TAG: ${{ inputs.tag }}
|
||||
run: |
|
||||
{
|
||||
echo "## Signed Android APK published"
|
||||
echo
|
||||
echo "- APK: https://github.com/${GITHUB_REPOSITORY}/releases/download/${RELEASE_TAG}/OpenClaw-Android.apk"
|
||||
echo "- Checksums: https://github.com/${GITHUB_REPOSITORY}/releases/download/${RELEASE_TAG}/OpenClaw-Android-SHA256SUMS.txt"
|
||||
echo "- Provenance: \`gh attestation verify OpenClaw-Android.apk --repo ${GITHUB_REPOSITORY} --signer-workflow ${GITHUB_REPOSITORY}/.github/workflows/android-release.yml\`"
|
||||
} >> "${GITHUB_STEP_SUMMARY}"
|
||||
136
.github/workflows/openclaw-release-publish.yml
vendored
136
.github/workflows/openclaw-release-publish.yml
vendored
|
|
@ -71,6 +71,7 @@ on:
|
|||
|
||||
permissions:
|
||||
actions: write
|
||||
attestations: read
|
||||
contents: write
|
||||
|
||||
concurrency:
|
||||
|
|
@ -461,6 +462,11 @@ jobs:
|
|||
publish:
|
||||
name: Publish plugins, then OpenClaw
|
||||
needs: [resolve_release_target]
|
||||
permissions:
|
||||
actions: write
|
||||
attestations: write
|
||||
contents: write
|
||||
id-token: write
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 120
|
||||
environment: npm-release
|
||||
|
|
@ -487,6 +493,51 @@ jobs:
|
|||
with:
|
||||
install-bun: "false"
|
||||
|
||||
- name: Write Android release approval
|
||||
if: ${{ !contains(inputs.tag, '-alpha.') && !contains(inputs.tag, '-beta.') }}
|
||||
env:
|
||||
RELEASE_PUBLISH_BRANCH: ${{ github.ref_name }}
|
||||
RELEASE_PUBLISH_RUN_ID: ${{ github.run_id }}
|
||||
RELEASE_TAG: ${{ inputs.tag }}
|
||||
TARGET_SHA: ${{ needs.resolve_release_target.outputs.sha }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
approval_dir="${RUNNER_TEMP}/android-release-approval"
|
||||
mkdir -p "${approval_dir}"
|
||||
node --input-type=module <<'NODE'
|
||||
import { writeFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
|
||||
const approval = {
|
||||
version: 1,
|
||||
repository: process.env.GITHUB_REPOSITORY,
|
||||
workflow: "OpenClaw Release Publish",
|
||||
parentRunId: process.env.RELEASE_PUBLISH_RUN_ID,
|
||||
workflowBranch: process.env.RELEASE_PUBLISH_BRANCH,
|
||||
releaseTag: process.env.RELEASE_TAG,
|
||||
targetSha: process.env.TARGET_SHA,
|
||||
};
|
||||
writeFileSync(
|
||||
join(process.env.RUNNER_TEMP, "android-release-approval", "approval.json"),
|
||||
`${JSON.stringify(approval, null, 2)}\n`,
|
||||
);
|
||||
NODE
|
||||
|
||||
- name: Attest Android release approval
|
||||
if: ${{ !contains(inputs.tag, '-alpha.') && !contains(inputs.tag, '-beta.') }}
|
||||
uses: actions/attest@a1948c3f048ba23858d222213b7c278aabede763 # v4.1.1
|
||||
with:
|
||||
subject-path: ${{ runner.temp }}/android-release-approval/approval.json
|
||||
|
||||
- name: Upload Android release approval
|
||||
if: ${{ !contains(inputs.tag, '-alpha.') && !contains(inputs.tag, '-beta.') }}
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
|
||||
with:
|
||||
name: android-release-approval-${{ github.run_id }}
|
||||
path: ${{ runner.temp }}/android-release-approval/approval.json
|
||||
if-no-files-found: error
|
||||
retention-days: 30
|
||||
|
||||
- name: Dispatch publish workflows
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
|
|
@ -513,6 +564,10 @@ jobs:
|
|||
[[ "${RELEASE_TAG}" != *"-alpha."* && "${RELEASE_TAG}" != *"-beta."* ]]
|
||||
}
|
||||
|
||||
is_android_release() {
|
||||
[[ "${RELEASE_TAG}" =~ ^v[0-9]{4}\.[1-9][0-9]*\.[1-9][0-9]*(-[1-9][0-9]*)?$ ]]
|
||||
}
|
||||
|
||||
dispatch_workflow_at_ref() {
|
||||
local workflow_ref="$1"
|
||||
shift
|
||||
|
|
@ -942,6 +997,9 @@ jobs:
|
|||
}
|
||||
|
||||
publish_github_release() {
|
||||
if is_android_release; then
|
||||
verify_android_release_asset_contract
|
||||
fi
|
||||
if is_stable_release; then
|
||||
verify_windows_release_asset_contract
|
||||
fi
|
||||
|
|
@ -949,6 +1007,54 @@ jobs:
|
|||
echo "- GitHub release: https://github.com/${GITHUB_REPOSITORY}/releases/tag/${RELEASE_TAG}" >> "$GITHUB_STEP_SUMMARY"
|
||||
}
|
||||
|
||||
verify_android_release_asset_contract() {
|
||||
local actual_android_assets actual_digest expected_android_assets expected_digest expected_hash release_json verify_dir
|
||||
local -a required_assets=(
|
||||
"OpenClaw-Android.apk"
|
||||
"OpenClaw-Android-SHA256SUMS.txt"
|
||||
)
|
||||
|
||||
release_json="$(gh release view "${RELEASE_TAG}" --repo "${GITHUB_REPOSITORY}" --json assets,url)"
|
||||
expected_android_assets="$(printf '%s\n' "${required_assets[@]}" | jq -R . | jq -sc 'sort')"
|
||||
actual_android_assets="$(printf '%s' "${release_json}" | jq -c '
|
||||
[.assets[]? | select(.name | startswith("OpenClaw-Android")) | .name] | sort
|
||||
')"
|
||||
if [[ "${actual_android_assets}" != "${expected_android_assets}" ]]; then
|
||||
echo "Stable release Android asset names do not match the canonical contract." >&2
|
||||
return 1
|
||||
fi
|
||||
|
||||
verify_dir="${RUNNER_TEMP}/openclaw-android-release-contract"
|
||||
rm -rf "${verify_dir}"
|
||||
mkdir -p "${verify_dir}"
|
||||
gh release download "${RELEASE_TAG}" \
|
||||
--repo "${GITHUB_REPOSITORY}" \
|
||||
--pattern "OpenClaw-Android.apk" \
|
||||
--pattern "OpenClaw-Android-SHA256SUMS.txt" \
|
||||
--dir "${verify_dir}"
|
||||
(
|
||||
cd "${verify_dir}"
|
||||
sha256sum --strict --check OpenClaw-Android-SHA256SUMS.txt
|
||||
)
|
||||
expected_hash="$(awk '$2 == "OpenClaw-Android.apk" { print $1 }' "${verify_dir}/OpenClaw-Android-SHA256SUMS.txt")"
|
||||
if [[ ! "${expected_hash}" =~ ^[a-f0-9]{64}$ ]]; then
|
||||
echo "Android checksum manifest does not contain the canonical APK entry." >&2
|
||||
return 1
|
||||
fi
|
||||
expected_digest="sha256:${expected_hash}"
|
||||
actual_digest="$(printf '%s' "${release_json}" | jq -r '.assets[]? | select(.name == "OpenClaw-Android.apk") | .digest // empty')"
|
||||
if [[ "${actual_digest}" != "${expected_digest}" ]]; then
|
||||
echo "Android release APK digest does not match its checksum manifest." >&2
|
||||
return 1
|
||||
fi
|
||||
gh attestation verify "${verify_dir}/OpenClaw-Android.apk" \
|
||||
--repo "${GITHUB_REPOSITORY}" \
|
||||
--signer-workflow "${GITHUB_REPOSITORY}/.github/workflows/android-release.yml" \
|
||||
--source-ref "refs/tags/${RELEASE_TAG}" \
|
||||
--deny-self-hosted-runners
|
||||
echo "- Android APK asset contract: verified" >> "${GITHUB_STEP_SUMMARY}"
|
||||
}
|
||||
|
||||
verify_windows_release_asset_contract() {
|
||||
local actual_companion_assets actual_digest asset_name expected_companion_assets expected_digest expected_hash expected_installer_names manifest_dir manifest_json manifest_path release_json
|
||||
# Add future promoted installer names, such as MSIX x64/ARM64, here.
|
||||
|
|
@ -1041,6 +1147,21 @@ jobs:
|
|||
wait_for_run windows-node-release.yml "${windows_node_run_id}"
|
||||
}
|
||||
|
||||
promote_android_release_asset() {
|
||||
if ! is_android_release; then
|
||||
return 0
|
||||
fi
|
||||
|
||||
android_release_run_id="$(dispatch_workflow_at_ref "${RELEASE_TAG}" android-release.yml \
|
||||
-f tag="${RELEASE_TAG}" \
|
||||
-f release_publish_run_id="${GITHUB_RUN_ID}" \
|
||||
-f release_publish_branch="${CHILD_WORKFLOW_REF}" \
|
||||
-f release_target_sha="${TARGET_SHA}" \
|
||||
-f direct_release_recovery=false)"
|
||||
echo "- Android release run ID: \`${android_release_run_id}\`" >> "${GITHUB_STEP_SUMMARY}"
|
||||
wait_for_run android-release.yml "${android_release_run_id}"
|
||||
}
|
||||
|
||||
upload_dependency_evidence_release_asset() {
|
||||
local release_version download_dir asset_path asset_name artifact_name
|
||||
release_version="${RELEASE_TAG#v}"
|
||||
|
|
@ -1180,7 +1301,7 @@ jobs:
|
|||
}
|
||||
|
||||
append_release_proof_to_github_release() {
|
||||
local release_version body_file notes_file evidence_path tarball integrity telegram_line clawhub_line clawhub_bootstrap_line clawhub_runtime_state_path windows_line
|
||||
local release_version body_file notes_file evidence_path tarball integrity telegram_line clawhub_line clawhub_bootstrap_line clawhub_runtime_state_path android_line windows_line
|
||||
|
||||
release_version="${RELEASE_TAG#v}"
|
||||
body_file="${RUNNER_TEMP}/release-body.md"
|
||||
|
|
@ -1203,6 +1324,10 @@ jobs:
|
|||
if [[ -n "${windows_node_run_id// }" ]]; then
|
||||
windows_line="- Windows Hub promotion: https://github.com/${GITHUB_REPOSITORY}/actions/runs/${windows_node_run_id} from openclaw/openclaw-windows-node@${WINDOWS_NODE_TAG}"
|
||||
fi
|
||||
android_line=""
|
||||
if [[ -n "${android_release_run_id// }" ]]; then
|
||||
android_line="- Android APK: https://github.com/${GITHUB_REPOSITORY}/releases/download/${RELEASE_TAG}/OpenClaw-Android.apk (https://github.com/${GITHUB_REPOSITORY}/actions/runs/${android_release_run_id})"
|
||||
fi
|
||||
|
||||
RELEASE_BODY_FILE="${body_file}" \
|
||||
RELEASE_NOTES_FILE="${notes_file}" \
|
||||
|
|
@ -1220,6 +1345,7 @@ jobs:
|
|||
CLAWHUB_LINE="${clawhub_line}" \
|
||||
CLAWHUB_BOOTSTRAP_LINE="${clawhub_bootstrap_line}" \
|
||||
TELEGRAM_LINE="${telegram_line}" \
|
||||
ANDROID_LINE="${android_line}" \
|
||||
WINDOWS_LINE="${windows_line}" \
|
||||
node --input-type=module <<'NODE'
|
||||
import { readFileSync, writeFileSync } from "node:fs";
|
||||
|
|
@ -1247,6 +1373,7 @@ jobs:
|
|||
process.env.CLAWHUB_BOOTSTRAP_LINE,
|
||||
`- OpenClaw npm publish: https://github.com/${process.env.RELEASE_REPO}/actions/runs/${process.env.OPENCLAW_NPM_RUN_ID}`,
|
||||
process.env.TELEGRAM_LINE,
|
||||
...(process.env.ANDROID_LINE ? [process.env.ANDROID_LINE] : []),
|
||||
...(process.env.WINDOWS_LINE ? [process.env.WINDOWS_LINE] : []),
|
||||
].join("\n");
|
||||
|
||||
|
|
@ -1272,6 +1399,9 @@ jobs:
|
|||
else
|
||||
echo "- OpenClaw npm publish: skipped by input"
|
||||
fi
|
||||
if is_android_release && [[ "${PUBLISH_OPENCLAW_NPM}" == "true" ]]; then
|
||||
echo "- Android APK: required before the GitHub release can be published"
|
||||
fi
|
||||
if is_stable_release && [[ "${PUBLISH_OPENCLAW_NPM}" == "true" ]]; then
|
||||
echo "- Windows Hub promotion: required before the GitHub release can be published"
|
||||
fi
|
||||
|
|
@ -1420,6 +1550,7 @@ jobs:
|
|||
failed=0
|
||||
openclaw_failed=0
|
||||
windows_node_run_id=""
|
||||
android_release_run_id=""
|
||||
if [[ -n "${openclaw_pid}" ]] && ! wait "${openclaw_pid}"; then
|
||||
failed=1
|
||||
openclaw_failed=1
|
||||
|
|
@ -1449,6 +1580,9 @@ jobs:
|
|||
verify_published_release true
|
||||
fi
|
||||
create_or_update_github_release
|
||||
if ! promote_android_release_asset; then
|
||||
failed=1
|
||||
fi
|
||||
upload_dependency_evidence_release_asset
|
||||
upload_release_evidence_assets
|
||||
if ! promote_windows_release_assets; then
|
||||
|
|
|
|||
|
|
@ -4,6 +4,8 @@
|
|||
|
||||
Adds multi-gateway support: the app remembers every paired gateway, lists them in Settings with a quick switcher on the Connect tab, and switches between them without pairing again. Credentials, device tokens, TLS trust, notification routing, chat history, and queued offline messages stay scoped to their gateway, and forgetting a gateway removes all of its stored state.
|
||||
|
||||
Stable GitHub Releases now include a signed standalone Android APK with checksums and verifiable GitHub Actions provenance. (#9443)
|
||||
|
||||
Android notification forwarding now excludes native WhatsApp, Telegram, Telegram X, Discord, and Signal channel apps to prevent duplicate cross-session replies. (#48516)
|
||||
|
||||
Assistant messages now offer a long-press Listen action with gateway TTS playback, on-device fallback, and tap-to-stop status.
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@
|
|||
"assetPath": "android/openclaw",
|
||||
"uploadKeystoreEncryptedFile": "upload-keystore.jks.enc",
|
||||
"gradlePropertiesEncryptedFile": "gradle.properties.enc",
|
||||
"apkCertificateSha256": "80dbc62315ea216dd6e8a7060735a866ddc464a48ed50fef29ff0550468b9a63",
|
||||
"materializedRoot": "apps/android/build/release-signing",
|
||||
"gradlePropertyNames": [
|
||||
"OPENCLAW_ANDROID_STORE_FILE",
|
||||
|
|
|
|||
|
|
@ -61,6 +61,7 @@ MATCH_PASSWORD=<signing repo password> pnpm android:release:signing:check
|
|||
```
|
||||
|
||||
The signing sync pulls encrypted Android upload-key assets from the shared `apps-signing` repo and materializes decrypted files under `apps/android/build/release-signing/`.
|
||||
Standalone release APK verification also requires that key's public certificate SHA-256 fingerprint to match `Config/ReleaseSigning.json`.
|
||||
|
||||
Generate raw Google Play screenshots:
|
||||
|
||||
|
|
@ -86,6 +87,10 @@ the screenshots, then shuts down the emulator it started.
|
|||
|
||||
`pnpm android:bundle:release` is an alias for the same Fastlane archive lane.
|
||||
|
||||
Regular final and correction OpenClaw releases publish the signed third-party APK as `OpenClaw-Android.apk` with a checksum manifest and GitHub Actions provenance. `.github/workflows/android-release.yml` is the only automated GitHub Release upload path; `OpenClaw Release Publish` dispatches it while the canonical release is still a draft and blocks publication until the uploaded asset contract verifies.
|
||||
|
||||
The protected `android-release` environment supplies `MATCH_PASSWORD`; the repository's read-only GitHub App token checks out encrypted material from `openclaw/apps-signing`. The workflow builds the exact release tag, refuses to replace different existing bytes, and re-downloads the APK for checksum, certificate, and provenance verification.
|
||||
|
||||
`pnpm android:release:archive` is for local archive validation only. It is not a
|
||||
fallback upload path after `pnpm android:release:upload` fails.
|
||||
|
||||
|
|
|
|||
|
|
@ -52,7 +52,8 @@ Recommended workflow:
|
|||
6. Run `ANDROID_SCREENSHOT_AVD=<avd-name> pnpm android:screenshots` to refresh raw Google Play screenshots with a script-managed emulator, or run `pnpm android:screenshots` when exactly one ADB device is already connected.
|
||||
7. Run `pnpm android:release:archive` to produce the signed Play AAB and third-party APK.
|
||||
8. Run `pnpm android:release:upload` to upload metadata, screenshots, and the Play AAB to the configured Google Play track.
|
||||
9. Complete production rollout manually in Google Play Console when needed.
|
||||
9. For a regular final or correction OpenClaw release, let `OpenClaw Release Publish` dispatch the protected `Android Release` workflow. It builds the signed third-party APK from the exact tag and attaches the verified APK, checksum manifest, and GitHub provenance before the release draft can publish. Before tagging a correction with its own package version, increment the pinned `versionCode`; the workflow verifies it is higher than the preceding final or correction APK. A same-commit fallback correction reuses the base release's verified APK and adds provenance for the correction tag.
|
||||
10. Complete production rollout manually in Google Play Console when needed.
|
||||
|
||||
If `pnpm android:release:upload` fails, stop at that failure. Do not continue by
|
||||
uploading archived artifacts through `pnpm android:release:archive`,
|
||||
|
|
@ -60,7 +61,7 @@ uploading archived artifacts through `pnpm android:release:archive`,
|
|||
Google Play API mutation commands, or Play Console mutation commands. Fix the
|
||||
failing release-lane step, then rerun `pnpm android:release:upload`.
|
||||
|
||||
The third-party flavor is archived as a signed APK for non-Play distribution. It is not uploaded by the Play release lane.
|
||||
The third-party flavor is archived as a signed APK for non-Play distribution. The Play release lane never uploads it. Official GitHub distribution is owned only by `.github/workflows/android-release.yml`, which publishes regular final and correction tags through the protected `android-release` environment as `OpenClaw-Android.apk`.
|
||||
|
||||
## Release SHA tracking
|
||||
|
||||
|
|
|
|||
|
|
@ -100,6 +100,7 @@ Release rules:
|
|||
- `apps/android/CHANGELOG.md` is the Android-only changelog and release-note source.
|
||||
- `apps/android/fastlane/metadata/android/en-US/release_notes.txt` is generated from that changelog by `pnpm android:version:sync`.
|
||||
- `apps/android/Config/ReleaseSigning.json` pins the encrypted Android signing assets in the shared signing repo.
|
||||
- `apkCertificateSha256` in that manifest pins the upload certificate accepted for standalone release APKs; rotate it only with the encrypted keystore.
|
||||
- `MATCH_PASSWORD` enables Fastlane to pull encrypted Android signing assets into `apps/android/build/release-signing/` before release validation or archive builds.
|
||||
- Supported pinned Android versions use CalVer: `YYYY.M.D`.
|
||||
- `versionCode` uses `YYYYMMDDNN`, where `NN` is a two-digit build number for the pinned version.
|
||||
|
|
@ -113,6 +114,7 @@ Release rules:
|
|||
- `pnpm android:screenshots` builds and installs the Play debug app, launches deterministic screenshot scenes, and captures raw PNGs.
|
||||
- `pnpm android:release:archive` builds the signed Play AAB and third-party APK into `apps/android/build/release-artifacts/`.
|
||||
- `pnpm android:release:upload` uploads the Play AAB to the configured Google Play track. The default track is `internal`.
|
||||
- Stable GitHub Release APK publication is separate from Google Play: `OpenClaw Release Publish` dispatches `.github/workflows/android-release.yml`, whose protected `android-release` environment provides `MATCH_PASSWORD`; the repository GitHub App reads the encrypted signing repo.
|
||||
- Production promotion remains manual in Google Play Console.
|
||||
- If `pnpm android:release:upload` fails, agent-driven releases must stop and report the failing step. Do not fall back to `pnpm android:release:archive`, `pnpm android:release:metadata`, direct Fastlane lanes, Gradle release artifacts plus Google Play upload commands, or mobile release ref recording.
|
||||
|
||||
|
|
|
|||
|
|
@ -4,9 +4,19 @@
|
|||
* version metadata, verifies signatures, and writes SHA-256 checksum files.
|
||||
*/
|
||||
|
||||
import { $ } from "bun";
|
||||
import { existsSync, readdirSync } from "node:fs";
|
||||
import { basename, dirname, join } from "node:path";
|
||||
import { execFileSync } from "node:child_process";
|
||||
import { createHash } from "node:crypto";
|
||||
import {
|
||||
accessSync,
|
||||
constants,
|
||||
copyFileSync,
|
||||
existsSync,
|
||||
mkdirSync,
|
||||
readFileSync,
|
||||
readdirSync,
|
||||
writeFileSync,
|
||||
} from "node:fs";
|
||||
import { basename, delimiter, dirname, join } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { resolveAndroidVersion, syncAndroidVersioning } from "../../../scripts/lib/android-version.ts";
|
||||
|
||||
|
|
@ -18,28 +28,52 @@ type ReleaseArtifact = {
|
|||
};
|
||||
|
||||
type CliOptions = {
|
||||
artifact: "all" | ReleaseArtifact["flavorName"];
|
||||
dryRun: boolean;
|
||||
verifyApk?: string;
|
||||
};
|
||||
|
||||
const scriptDir = dirname(fileURLToPath(import.meta.url));
|
||||
const androidDir = join(scriptDir, "..");
|
||||
const rootDir = join(androidDir, "..", "..");
|
||||
const releaseOutputDir = join(androidDir, "build", "release-artifacts");
|
||||
const releaseSigningManifestPath = join(androidDir, "Config", "ReleaseSigning.json");
|
||||
|
||||
function parseArgs(argv: string[]): CliOptions {
|
||||
let artifact: CliOptions["artifact"] = "all";
|
||||
let dryRun = false;
|
||||
let verifyApk: string | undefined;
|
||||
|
||||
for (const arg of argv) {
|
||||
for (let index = 0; index < argv.length; index += 1) {
|
||||
const arg = argv[index];
|
||||
switch (arg) {
|
||||
case "--artifact": {
|
||||
const value = argv[index + 1];
|
||||
if (value !== "all" && value !== "play" && value !== "third-party") {
|
||||
throw new Error("--artifact must be one of: all, play, third-party");
|
||||
}
|
||||
artifact = value;
|
||||
index += 1;
|
||||
break;
|
||||
}
|
||||
case "--dry-run": {
|
||||
dryRun = true;
|
||||
break;
|
||||
}
|
||||
case "--verify-apk": {
|
||||
const value = argv[index + 1];
|
||||
if (!value || value.startsWith("-")) {
|
||||
throw new Error("Missing value for --verify-apk");
|
||||
}
|
||||
verifyApk = value;
|
||||
index += 1;
|
||||
break;
|
||||
}
|
||||
case "-h":
|
||||
case "--help": {
|
||||
console.log(
|
||||
[
|
||||
"Usage: bun apps/android/scripts/build-release-artifacts.ts [--dry-run]",
|
||||
"Usage: bun apps/android/scripts/build-release-artifacts.ts [--artifact all|play|third-party] [--dry-run] [--verify-apk PATH]",
|
||||
"",
|
||||
"Builds the signed Play AAB and third-party APK from apps/android/version.json.",
|
||||
].join("\n"),
|
||||
|
|
@ -52,7 +86,22 @@ function parseArgs(argv: string[]): CliOptions {
|
|||
}
|
||||
}
|
||||
|
||||
return { dryRun };
|
||||
if (verifyApk && (artifact !== "all" || dryRun)) {
|
||||
throw new Error("--verify-apk cannot be combined with --artifact or --dry-run");
|
||||
}
|
||||
|
||||
return { artifact, dryRun, verifyApk };
|
||||
}
|
||||
|
||||
function pinnedApkCertificateSha256(): string {
|
||||
const manifest = JSON.parse(readFileSync(releaseSigningManifestPath, "utf8")) as {
|
||||
apkCertificateSha256?: unknown;
|
||||
};
|
||||
const fingerprint = manifest.apkCertificateSha256;
|
||||
if (typeof fingerprint !== "string" || !/^[a-f0-9]{64}$/u.test(fingerprint)) {
|
||||
throw new Error("ReleaseSigning.json must pin apkCertificateSha256 as 64 lowercase hex digits");
|
||||
}
|
||||
return fingerprint;
|
||||
}
|
||||
|
||||
function releaseArtifacts(versionName: string): ReleaseArtifact[] {
|
||||
|
|
@ -89,21 +138,19 @@ function releaseArtifacts(versionName: string): ReleaseArtifact[] {
|
|||
];
|
||||
}
|
||||
|
||||
async function sha256Hex(path: string): Promise<string> {
|
||||
const buffer = await Bun.file(path).arrayBuffer();
|
||||
const digest = await crypto.subtle.digest("SHA-256", buffer);
|
||||
return Array.from(new Uint8Array(digest), (byte) => byte.toString(16).padStart(2, "0")).join("");
|
||||
function sha256Hex(path: string): string {
|
||||
return createHash("sha256").update(readFileSync(path)).digest("hex");
|
||||
}
|
||||
|
||||
async function writeSha256File(path: string): Promise<string> {
|
||||
const hash = await sha256Hex(path);
|
||||
function writeSha256File(path: string): string {
|
||||
const hash = sha256Hex(path);
|
||||
const checksumPath = `${path}.sha256`;
|
||||
await Bun.write(checksumPath, `${hash} ${basename(path)}\n`);
|
||||
writeFileSync(checksumPath, `${hash} ${basename(path)}\n`);
|
||||
return hash;
|
||||
}
|
||||
|
||||
async function verifyAabSignature(path: string): Promise<void> {
|
||||
await $`jarsigner -verify ${path}`.quiet();
|
||||
function verifyAabSignature(path: string): void {
|
||||
execFileSync("jarsigner", ["-verify", path], { stdio: "ignore" });
|
||||
}
|
||||
|
||||
function resolveApkSignerFromSdk(sdkRoot: string | undefined): string | null {
|
||||
|
|
@ -124,57 +171,87 @@ function resolveApkSignerFromSdk(sdkRoot: string | undefined): string | null {
|
|||
return candidates[0] ?? null;
|
||||
}
|
||||
|
||||
async function resolveApkSigner(): Promise<string> {
|
||||
function resolveApkSigner(): string {
|
||||
const sdkApkSigner =
|
||||
resolveApkSignerFromSdk(Bun.env.ANDROID_HOME) ??
|
||||
resolveApkSignerFromSdk(Bun.env.ANDROID_SDK_ROOT);
|
||||
resolveApkSignerFromSdk(process.env.ANDROID_HOME) ??
|
||||
resolveApkSignerFromSdk(process.env.ANDROID_SDK_ROOT);
|
||||
if (sdkApkSigner) {
|
||||
return sdkApkSigner;
|
||||
}
|
||||
|
||||
for (const pathDir of (process.env.PATH ?? "").split(delimiter)) {
|
||||
const candidate = join(pathDir, "apksigner");
|
||||
try {
|
||||
accessSync(candidate, constants.X_OK);
|
||||
return candidate;
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
throw new Error("Missing apksigner. Install Android SDK build-tools or put apksigner on PATH.");
|
||||
}
|
||||
|
||||
function verifyApkSignature(path: string, expectedCertificateSha256: string): void {
|
||||
const apkSigner = resolveApkSigner();
|
||||
let output: string;
|
||||
try {
|
||||
return (await $`command -v apksigner`.text()).trim();
|
||||
output = execFileSync(apkSigner, ["verify", "--print-certs", path], {
|
||||
encoding: "utf8",
|
||||
stdio: ["ignore", "pipe", "inherit"],
|
||||
});
|
||||
} catch {
|
||||
throw new Error(`apksigner verification failed for ${path}`);
|
||||
}
|
||||
|
||||
const fingerprints = Array.from(
|
||||
output.matchAll(/^Signer #[0-9]+ certificate SHA-256 digest: ([a-fA-F0-9:]+)$/gmu),
|
||||
(match) => match[1].replaceAll(":", "").toLowerCase(),
|
||||
);
|
||||
if (fingerprints.length !== 1 || !/^[a-f0-9]{64}$/u.test(fingerprints[0] ?? "")) {
|
||||
throw new Error(`Expected exactly one SHA-256 signing certificate for ${path}`);
|
||||
}
|
||||
if (fingerprints[0] !== expectedCertificateSha256) {
|
||||
throw new Error(
|
||||
"Missing apksigner. Install Android SDK build-tools or put apksigner on PATH.",
|
||||
`APK signing certificate mismatch for ${path}: expected ${expectedCertificateSha256}, got ${fingerprints[0]}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async function verifyApkSignature(path: string): Promise<void> {
|
||||
const apkSigner = await resolveApkSigner();
|
||||
const apkSignerProcess = Bun.spawn([apkSigner, "verify", path], {
|
||||
stdout: "ignore",
|
||||
stderr: "inherit",
|
||||
});
|
||||
const exitCode = await apkSignerProcess.exited;
|
||||
if (exitCode !== 0) {
|
||||
throw new Error(`apksigner verification failed for ${path}`);
|
||||
}
|
||||
}
|
||||
|
||||
async function copyArtifact(sourcePath: string, destinationPath: string): Promise<void> {
|
||||
const sourceFile = Bun.file(sourcePath);
|
||||
if (!(await sourceFile.exists())) {
|
||||
function copyArtifact(sourcePath: string, destinationPath: string): void {
|
||||
if (!existsSync(sourcePath)) {
|
||||
throw new Error(`Signed release artifact missing at ${sourcePath}`);
|
||||
}
|
||||
|
||||
await Bun.write(destinationPath, sourceFile);
|
||||
copyFileSync(sourcePath, destinationPath);
|
||||
}
|
||||
|
||||
async function verifyArtifactSignature(artifact: ReleaseArtifact, outputPath: string): Promise<void> {
|
||||
function verifyArtifactSignature(
|
||||
artifact: ReleaseArtifact,
|
||||
outputPath: string,
|
||||
expectedCertificateSha256: string,
|
||||
): void {
|
||||
if (artifact.kind === "aab") {
|
||||
await verifyAabSignature(outputPath);
|
||||
verifyAabSignature(outputPath);
|
||||
} else {
|
||||
await verifyApkSignature(outputPath);
|
||||
verifyApkSignature(outputPath, expectedCertificateSha256);
|
||||
}
|
||||
}
|
||||
|
||||
async function main() {
|
||||
function main() {
|
||||
const options = parseArgs(process.argv.slice(2));
|
||||
const expectedCertificateSha256 = pinnedApkCertificateSha256();
|
||||
if (options.verifyApk) {
|
||||
verifyApkSignature(options.verifyApk, expectedCertificateSha256);
|
||||
console.log(`Verified pinned APK signing certificate: ${options.verifyApk}`);
|
||||
return;
|
||||
}
|
||||
|
||||
syncAndroidVersioning({ mode: "check", rootDir });
|
||||
const version = resolveAndroidVersion(rootDir);
|
||||
const artifacts = releaseArtifacts(version.canonicalVersion);
|
||||
const artifacts = releaseArtifacts(version.canonicalVersion).filter(
|
||||
(artifact) => options.artifact === "all" || artifact.flavorName === options.artifact,
|
||||
);
|
||||
|
||||
console.log(`Android versionName: ${version.canonicalVersion}`);
|
||||
console.log(`Android versionCode: ${version.versionCode}`);
|
||||
|
|
@ -188,8 +265,11 @@ async function main() {
|
|||
return;
|
||||
}
|
||||
|
||||
await $`mkdir -p ${releaseOutputDir}`;
|
||||
await $`./gradlew ${artifacts.map((artifact) => artifact.gradleTask)}`.cwd(androidDir);
|
||||
mkdirSync(releaseOutputDir, { recursive: true });
|
||||
execFileSync("./gradlew", artifacts.map((artifact) => artifact.gradleTask), {
|
||||
cwd: androidDir,
|
||||
stdio: "inherit",
|
||||
});
|
||||
|
||||
for (const artifact of artifacts) {
|
||||
const outputPath = join(
|
||||
|
|
@ -197,13 +277,13 @@ async function main() {
|
|||
`openclaw-${version.canonicalVersion}-${artifact.flavorName}-release.${artifact.kind}`,
|
||||
);
|
||||
|
||||
await copyArtifact(artifact.sourcePath, outputPath);
|
||||
await verifyArtifactSignature(artifact, outputPath);
|
||||
const hash = await writeSha256File(outputPath);
|
||||
copyArtifact(artifact.sourcePath, outputPath);
|
||||
verifyArtifactSignature(artifact, outputPath, expectedCertificateSha256);
|
||||
const hash = writeSha256File(outputPath);
|
||||
|
||||
console.log(`Signed ${artifact.kind.toUpperCase()} (${artifact.flavorName}): ${outputPath}`);
|
||||
console.log(`SHA-256 (${artifact.flavorName}): ${hash}`);
|
||||
}
|
||||
}
|
||||
|
||||
await main();
|
||||
main();
|
||||
|
|
|
|||
|
|
@ -4849,6 +4849,7 @@ Do not edit it by hand; run `pnpm docs:map:gen`.
|
|||
- Route: /platforms/android
|
||||
- Headings:
|
||||
- H2: Support snapshot
|
||||
- H2: Install outside Google Play
|
||||
- H2: Mirror and control Android from a remote Mac
|
||||
- H3: Before you begin
|
||||
- H3: Enable ADB over TCP
|
||||
|
|
|
|||
|
|
@ -9,19 +9,43 @@ title: "Android app"
|
|||
---
|
||||
|
||||
<Note>
|
||||
The official Android app is available on [Google Play](https://play.google.com/store/apps/details?id=ai.openclaw.app&hl=en_IN). It is a companion node and requires a running OpenClaw Gateway. Source: [apps/android](https://github.com/openclaw/openclaw/tree/main/apps/android) ([build instructions](https://github.com/openclaw/openclaw/blob/main/apps/android/README.md)).
|
||||
The official Android app is available on [Google Play](https://play.google.com/store/apps/details?id=ai.openclaw.app&hl=en_IN) and as a signed standalone APK on supported [GitHub Releases](https://github.com/openclaw/openclaw/releases). It is a companion node and requires a running OpenClaw Gateway. Source: [apps/android](https://github.com/openclaw/openclaw/tree/main/apps/android) ([build instructions](https://github.com/openclaw/openclaw/blob/main/apps/android/README.md)).
|
||||
</Note>
|
||||
|
||||
## Support snapshot
|
||||
|
||||
- Role: companion node app (Android does not host the Gateway).
|
||||
- Gateway required: yes (run it on macOS, Linux, or Windows via WSL2).
|
||||
- Install: [Google Play](https://play.google.com/store/apps/details?id=ai.openclaw.app&hl=en_IN) for the app, [Getting Started](/start/getting-started) for the Gateway, then [Pairing](/channels/pairing).
|
||||
- Install: [Google Play](https://play.google.com/store/apps/details?id=ai.openclaw.app&hl=en_IN) or `OpenClaw-Android.apk` from a supported [GitHub Release](https://github.com/openclaw/openclaw/releases), [Getting Started](/start/getting-started) for the Gateway, then [Pairing](/channels/pairing).
|
||||
- Gateway: [Runbook](/gateway) + [Configuration](/gateway/configuration).
|
||||
- Protocols: [Gateway protocol](/gateway/protocol) (nodes + control plane).
|
||||
|
||||
System control (launchd/systemd) lives on the Gateway host — see [Gateway](/gateway).
|
||||
|
||||
## Install outside Google Play
|
||||
|
||||
Regular final and correction GitHub Releases include a universal `OpenClaw-Android.apk` and `OpenClaw-Android-SHA256SUMS.txt`. The APK is built from the release tag, signed with the OpenClaw Android release key, and carries GitHub Actions provenance.
|
||||
|
||||
Choose a [release](https://github.com/openclaw/openclaw/releases) that lists both assets, then download and verify that exact tag before sideloading:
|
||||
|
||||
```bash
|
||||
release_tag=vYYYY.M.PATCH
|
||||
gh release download "$release_tag" \
|
||||
--repo openclaw/openclaw \
|
||||
--pattern OpenClaw-Android.apk \
|
||||
--pattern OpenClaw-Android-SHA256SUMS.txt
|
||||
sha256sum --check OpenClaw-Android-SHA256SUMS.txt
|
||||
gh attestation verify OpenClaw-Android.apk \
|
||||
--repo openclaw/openclaw \
|
||||
--signer-workflow openclaw/openclaw/.github/workflows/android-release.yml \
|
||||
--source-ref "refs/tags/${release_tag}" \
|
||||
--deny-self-hosted-runners
|
||||
```
|
||||
|
||||
<Warning>
|
||||
Google Play and standalone APK installs use different update channels and may have different signing identities. Android may require uninstalling the existing app before switching channels, which removes its local app data. Stay on one channel for normal updates.
|
||||
</Warning>
|
||||
|
||||
## Mirror and control Android from a remote Mac
|
||||
|
||||
[scrcpy](https://github.com/Genymobile/scrcpy) mirrors an Android screen in a macOS window and
|
||||
|
|
|
|||
|
|
@ -35,7 +35,7 @@ Tideclaw alpha builds are a separate internal prerelease track (npm dist-tag `al
|
|||
- `extended-stable` means the supported trailing-month npm package, beginning at patch `33`; patch `34` and later are maintenance releases on that monthly line
|
||||
- Regular final and regular correction releases publish to npm `beta` by default; release operators can target `latest` explicitly, or promote a vetted beta build later
|
||||
- The dedicated monthly extended-stable path publishes the core npm package and every npm-publishable official plugin at the same exact version. It does not publish plugins to ClawHub or publish macOS or Windows artifacts, a GitHub Release, private-repository dist-tags, Docker images, mobile artifacts, or website downloads.
|
||||
- Every regular final release ships the npm package, macOS app, and signed Windows Hub installers together. Beta releases normally validate and publish the npm/package path first, with native app build/sign/notarize/promote reserved for regular final unless explicitly requested.
|
||||
- Every regular final release ships the npm package, macOS app, signed standalone Android APK, and signed Windows Hub installers together. Beta releases normally validate and publish the npm/package path first, with native app build/sign/notarize/promote reserved for regular final unless explicitly requested.
|
||||
|
||||
## Release cadence
|
||||
|
||||
|
|
@ -190,7 +190,7 @@ Stable publication is not complete until `main` carries the actual shipped relea
|
|||
|
||||
A complete closeout requires both assets and a matching checksum. A partial manifest replays its recorded `main` SHA and rollback drill to regenerate identical bytes, then attaches the missing checksum; an invalid pair, or a checksum without a manifest, stays blocking. A push-triggered run without rollback drill repository variables skips without completing closeout; a missing or more-than-90-day-old drill record still blocks manual evidence-backed closeout. Private recovery commands remain in the maintainer-only runbook. Use manual dispatch only to repair or replay an evidence-backed stable closeout.
|
||||
|
||||
A legacy fallback correction tag may reuse base-package evidence only when the correction tag resolves to the same source commit as the base stable tag. A correction with different source must publish and verify its own package evidence.
|
||||
A legacy fallback correction tag may reuse base-package evidence only when the correction tag resolves to the same source commit as the base stable tag. Its Android release reuses the base tag's verified APK and adds provenance for the correction tag. A correction with different source must publish and verify its own package evidence and use a higher Android `versionCode`.
|
||||
|
||||
## Release preflight
|
||||
|
||||
|
|
@ -447,7 +447,7 @@ release needs:
|
|||
4. Dispatch `Plugin NPM Release` with `publish_scope=all-publishable` and `ref=<release-sha>`.
|
||||
5. Dispatch `Plugin ClawHub Release` with the same scope and SHA.
|
||||
6. Dispatch `OpenClaw NPM Release` with the release tag, npm dist-tag, and saved `preflight_run_id` after verifying the saved `full_release_validation_run_id`.
|
||||
7. For stable releases, create or update the GitHub release as a draft, dispatch `Windows Node Release` with the explicit `windows_node_tag` and candidate-approved `windows_node_installer_digests`, and verify the canonical installer/checksum assets before publishing the draft.
|
||||
7. For stable releases, create or update the GitHub release as a draft, dispatch `Windows Node Release` with the explicit `windows_node_tag` and candidate-approved `windows_node_installer_digests`, and verify the canonical Windows installer/checksum assets. Also dispatch `Android Release` to build the exact-tag signed APK plus checksum and provenance. Verify both native asset contracts before publishing the draft.
|
||||
|
||||
Beta publish example:
|
||||
|
||||
|
|
|
|||
|
|
@ -21,6 +21,7 @@ function usage() {
|
|||
process.stdout.write(`Usage:
|
||||
scripts/android-release-signing.mjs --mode plan
|
||||
scripts/android-release-signing.mjs --mode check
|
||||
scripts/android-release-signing.mjs --mode materialize
|
||||
scripts/android-release-signing.mjs --mode sync-pull
|
||||
scripts/android-release-signing.mjs --mode sync-push --keystore PATH --properties PATH
|
||||
|
||||
|
|
@ -110,6 +111,7 @@ function readManifest(manifestPath) {
|
|||
parsed.gradlePropertiesEncryptedFile,
|
||||
"gradlePropertiesEncryptedFile",
|
||||
),
|
||||
apkCertificateSha256: requireString(parsed.apkCertificateSha256, "apkCertificateSha256"),
|
||||
materializedRoot: requireString(parsed.materializedRoot, "materializedRoot"),
|
||||
gradlePropertyNames: parsed.gradlePropertyNames,
|
||||
};
|
||||
|
|
@ -123,6 +125,11 @@ function readManifest(manifestPath) {
|
|||
`Android release signing manifest must list Gradle properties: ${requiredPropertyNames.join(", ")}.`,
|
||||
);
|
||||
}
|
||||
if (!/^[a-f0-9]{64}$/u.test(manifest.apkCertificateSha256)) {
|
||||
throw new Error(
|
||||
"Android release signing manifest apkCertificateSha256 must be 64 lowercase hex digits.",
|
||||
);
|
||||
}
|
||||
|
||||
return manifest;
|
||||
}
|
||||
|
|
@ -320,6 +327,7 @@ Signing branch: ${manifest.signingBranch}
|
|||
Signing assets: ${manifest.assetPath}
|
||||
Encrypted upload keystore: ${manifest.uploadKeystoreEncryptedFile}
|
||||
Encrypted Gradle properties: ${manifest.gradlePropertiesEncryptedFile}
|
||||
Pinned APK certificate SHA-256: ${manifest.apkCertificateSha256}
|
||||
Materialized output: ${relativePath(materializedDir)}
|
||||
Gradle bridge: Fastlane exports ORG_GRADLE_PROJECT_* values from the materialized properties file.
|
||||
`);
|
||||
|
|
@ -332,17 +340,18 @@ function writeSigningRepoManifest(workspace, manifest) {
|
|||
assetPath: manifest.assetPath,
|
||||
uploadKeystoreEncryptedFile: manifest.uploadKeystoreEncryptedFile,
|
||||
gradlePropertiesEncryptedFile: manifest.gradlePropertiesEncryptedFile,
|
||||
apkCertificateSha256: manifest.apkCertificateSha256,
|
||||
gradlePropertyNames: requiredPropertyNames,
|
||||
};
|
||||
fs.writeFileSync(signingManifestPath, `${JSON.stringify(signingManifest, null, 2)}\n`);
|
||||
}
|
||||
|
||||
function syncPull(manifest, options) {
|
||||
function materialize(manifest, options) {
|
||||
const workspace = resolveWorkspace(manifest, options);
|
||||
const materializedDir = resolveMaterializedDir(manifest, options);
|
||||
const tempPropertiesPath = path.join(materializedDir, ".gradle.properties.decrypted.tmp");
|
||||
|
||||
cloneSigningRepo(manifest, workspace, materializedDir);
|
||||
assertWorkspaceInsideMaterialized(workspace, materializedDir);
|
||||
if (!fs.existsSync(encryptedKeystorePath(workspace, manifest))) {
|
||||
throw new Error(
|
||||
`Missing encrypted Android upload keystore in signing repo at ${manifest.assetPath}/${manifest.uploadKeystoreEncryptedFile}.`,
|
||||
|
|
@ -379,6 +388,13 @@ function syncPull(manifest, options) {
|
|||
);
|
||||
}
|
||||
|
||||
function syncPull(manifest, options) {
|
||||
const workspace = resolveWorkspace(manifest, options);
|
||||
const materializedDir = resolveMaterializedDir(manifest, options);
|
||||
cloneSigningRepo(manifest, workspace, materializedDir);
|
||||
materialize(manifest, options);
|
||||
}
|
||||
|
||||
function requirePushSources(options) {
|
||||
if (!options.keystorePath) {
|
||||
throw new Error(
|
||||
|
|
@ -444,6 +460,8 @@ try {
|
|||
} else if (options.mode === "check") {
|
||||
validateMaterializedSigning(resolveMaterializedDir(manifest, options));
|
||||
process.stdout.write("Android release signing materialization is valid.\n");
|
||||
} else if (options.mode === "materialize") {
|
||||
materialize(manifest, options);
|
||||
} else if (options.mode === "sync-pull") {
|
||||
syncPull(manifest, options);
|
||||
} else if (options.mode === "sync-push") {
|
||||
|
|
|
|||
|
|
@ -527,6 +527,7 @@ const GITHUB_WORKFLOW_OWNER_TEST_TARGETS = new Map([
|
|||
".github/workflows/npm-telegram-beta-e2e.yml",
|
||||
["test/scripts/package-acceptance-workflow.test.ts"],
|
||||
],
|
||||
[".github/workflows/android-release.yml", ["test/scripts/package-acceptance-workflow.test.ts"]],
|
||||
[
|
||||
".github/workflows/openclaw-cross-os-release-checks-reusable.yml",
|
||||
[
|
||||
|
|
@ -998,6 +999,10 @@ const TOOLING_SOURCE_TEST_TARGETS = new Map([
|
|||
["scripts/android-release.sh", ["test/scripts/android-release-wrapper-args.test.ts"]],
|
||||
["scripts/android-release-signing.mjs", ["test/scripts/android-release-signing.test.ts"]],
|
||||
["scripts/android-release-upload.sh", ["test/scripts/android-release-wrapper-args.test.ts"]],
|
||||
[
|
||||
"apps/android/scripts/build-release-artifacts.ts",
|
||||
["test/scripts/android-release-artifacts.test.ts"],
|
||||
],
|
||||
["apps/android/fastlane/Fastfile", ["test/scripts/android-release-fastlane-gates.test.ts"]],
|
||||
["scripts/ios-release-archive.sh", ["test/scripts/ios-release-wrapper-args.test.ts"]],
|
||||
["scripts/ios-release-prepare.sh", ["test/scripts/ios-release-wrapper-args.test.ts"]],
|
||||
|
|
|
|||
|
|
@ -7,6 +7,24 @@ const run = JSON.parse(fs.readFileSync(0, "utf8"));
|
|||
const releasePublishRunId = process.env.RELEASE_PUBLISH_RUN_ID ?? "";
|
||||
const expectedBranch = process.env.EXPECTED_WORKFLOW_BRANCH ?? "";
|
||||
const directRecovery = process.env.DIRECT_RELEASE_RECOVERY === "true";
|
||||
const approvalPath = process.env.APPROVAL_PATH ?? "";
|
||||
|
||||
if (approvalPath) {
|
||||
const approval = JSON.parse(fs.readFileSync(approvalPath, "utf8"));
|
||||
const expectedApproval = {
|
||||
version: 1,
|
||||
repository: process.env.GITHUB_REPOSITORY,
|
||||
workflow: "OpenClaw Release Publish",
|
||||
parentRunId: releasePublishRunId,
|
||||
workflowBranch: expectedBranch,
|
||||
releaseTag: process.env.RELEASE_TAG,
|
||||
targetSha: process.env.RELEASE_TARGET_SHA,
|
||||
};
|
||||
if (JSON.stringify(approval) !== JSON.stringify(expectedApproval)) {
|
||||
console.error("Attested Android release approval does not match this run request.");
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
const checks = [
|
||||
["workflowName", "OpenClaw Release Publish"],
|
||||
|
|
|
|||
91
test/scripts/android-release-artifacts.test.ts
Normal file
91
test/scripts/android-release-artifacts.test.ts
Normal file
|
|
@ -0,0 +1,91 @@
|
|||
import { spawnSync } from "node:child_process";
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { afterEach } from "vitest";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { useAutoCleanupTempDirTracker } from "../helpers/temp-dir.js";
|
||||
|
||||
const SCRIPT = "apps/android/scripts/build-release-artifacts.ts";
|
||||
const APK_CERTIFICATE_SHA256 = "80dbc62315ea216dd6e8a7060735a866ddc464a48ed50fef29ff0550468b9a63";
|
||||
const tempRoots = useAutoCleanupTempDirTracker(afterEach);
|
||||
|
||||
function run(args: string[], env: NodeJS.ProcessEnv = {}) {
|
||||
return spawnSync(process.execPath, ["--import", "tsx", SCRIPT, ...args], {
|
||||
cwd: process.cwd(),
|
||||
encoding: "utf8",
|
||||
env: { ...process.env, ...env },
|
||||
});
|
||||
}
|
||||
|
||||
function fakeApkSigner(certificateSha256: string, signerCount = 1) {
|
||||
const tempRoot = tempRoots.make("openclaw-apksigner-");
|
||||
const buildToolsDir = path.join(tempRoot, "build-tools", "36.0.0");
|
||||
fs.mkdirSync(buildToolsDir, { recursive: true });
|
||||
const apkSignerPath = path.join(buildToolsDir, "apksigner");
|
||||
const signerLines = Array.from(
|
||||
{ length: signerCount },
|
||||
(_, index) => `Signer #${index + 1} certificate SHA-256 digest: ${certificateSha256}`,
|
||||
);
|
||||
fs.writeFileSync(
|
||||
apkSignerPath,
|
||||
`#!/bin/sh\nprintf '%s\\n' ${signerLines.map((line) => `'${line}'`).join(" ")}\n`,
|
||||
);
|
||||
fs.chmodSync(apkSignerPath, 0o755);
|
||||
const apkPath = path.join(tempRoot, "OpenClaw-Android.apk");
|
||||
fs.writeFileSync(apkPath, "fake apk bytes");
|
||||
return { apkPath, sdkRoot: tempRoot };
|
||||
}
|
||||
|
||||
describe("Android release artifacts", () => {
|
||||
it("selects only the signed third-party APK for GitHub distribution", () => {
|
||||
const result = run(["--artifact", "third-party", "--dry-run"]);
|
||||
|
||||
expect(result.status).toBe(0);
|
||||
expect(result.stdout).toContain("Release artifact: third-party apk");
|
||||
expect(result.stdout).toContain("Gradle task: :app:assembleThirdPartyRelease");
|
||||
expect(result.stdout).not.toContain("Release artifact: play aab");
|
||||
});
|
||||
|
||||
it("rejects unknown artifact selectors", () => {
|
||||
const result = run(["--artifact", "debug", "--dry-run"]);
|
||||
|
||||
expect(result.status).toBe(1);
|
||||
expect(result.stderr).toContain("--artifact must be one of: all, play, third-party");
|
||||
});
|
||||
|
||||
it("accepts the pinned standalone APK signing certificate", () => {
|
||||
const { apkPath, sdkRoot } = fakeApkSigner(APK_CERTIFICATE_SHA256);
|
||||
|
||||
const result = run(["--verify-apk", apkPath], {
|
||||
ANDROID_HOME: sdkRoot,
|
||||
ANDROID_SDK_ROOT: sdkRoot,
|
||||
});
|
||||
|
||||
expect(result.status).toBe(0);
|
||||
expect(result.stdout).toContain("Verified pinned APK signing certificate");
|
||||
});
|
||||
|
||||
it("rejects an APK signed by another certificate", () => {
|
||||
const { apkPath, sdkRoot } = fakeApkSigner("a".repeat(64));
|
||||
|
||||
const result = run(["--verify-apk", apkPath], {
|
||||
ANDROID_HOME: sdkRoot,
|
||||
ANDROID_SDK_ROOT: sdkRoot,
|
||||
});
|
||||
|
||||
expect(result.status).toBe(1);
|
||||
expect(result.stderr).toContain("APK signing certificate mismatch");
|
||||
});
|
||||
|
||||
it("rejects APKs with multiple signers", () => {
|
||||
const { apkPath, sdkRoot } = fakeApkSigner(APK_CERTIFICATE_SHA256, 2);
|
||||
|
||||
const result = run(["--verify-apk", apkPath], {
|
||||
ANDROID_HOME: sdkRoot,
|
||||
ANDROID_SDK_ROOT: sdkRoot,
|
||||
});
|
||||
|
||||
expect(result.status).toBe(1);
|
||||
expect(result.stderr).toContain("Expected exactly one SHA-256 signing certificate");
|
||||
});
|
||||
});
|
||||
|
|
@ -9,6 +9,7 @@ const SCRIPT = path.join(process.cwd(), "scripts", "android-release-signing.mjs"
|
|||
const MATCH_PASSWORD = "test-match-password";
|
||||
const STORE_PASSWORD = "store_secret_value";
|
||||
const KEY_PASSWORD = "key_secret_value";
|
||||
const APK_CERTIFICATE_SHA256 = "80dbc62315ea216dd6e8a7060735a866ddc464a48ed50fef29ff0550468b9a63";
|
||||
|
||||
const tempRoots: string[] = [];
|
||||
|
||||
|
|
@ -93,6 +94,7 @@ function writeManifest(tempRoot: string, signingRepo: string): string {
|
|||
assetPath: "android/openclaw",
|
||||
uploadKeystoreEncryptedFile: "upload-keystore.jks.enc",
|
||||
gradlePropertiesEncryptedFile: "gradle.properties.enc",
|
||||
apkCertificateSha256: APK_CERTIFICATE_SHA256,
|
||||
materializedRoot: "unused-by-test",
|
||||
gradlePropertyNames: [
|
||||
"OPENCLAW_ANDROID_STORE_FILE",
|
||||
|
|
@ -160,6 +162,7 @@ describe("scripts/android-release-signing.mjs", () => {
|
|||
expect(result.ok).toBe(true);
|
||||
expect(result.stdout).toContain("Signing repo: git@github.com:openclaw/apps-signing.git");
|
||||
expect(result.stdout).toContain("Signing assets: android/openclaw");
|
||||
expect(result.stdout).toContain(`Pinned APK certificate SHA-256: ${APK_CERTIFICATE_SHA256}`);
|
||||
expect(result.stdout).toContain("Materialized output: apps/android/build/release-signing");
|
||||
expect(result.stdout).toContain("ORG_GRADLE_PROJECT_*");
|
||||
});
|
||||
|
|
@ -270,6 +273,26 @@ describe("scripts/android-release-signing.mjs", () => {
|
|||
]);
|
||||
|
||||
expect(check.ok).toBe(true);
|
||||
|
||||
fs.rmSync(path.join(materializedDir, "upload-keystore.jks"));
|
||||
fs.rmSync(path.join(materializedDir, "gradle.properties"));
|
||||
const materialize = runNode(
|
||||
[
|
||||
"--mode",
|
||||
"materialize",
|
||||
"--manifest",
|
||||
manifestPath,
|
||||
"--workspace",
|
||||
path.join(materializedDir, "pull-workspace"),
|
||||
"--materialized-dir",
|
||||
materializedDir,
|
||||
],
|
||||
env,
|
||||
);
|
||||
expect(materialize.ok).toBe(true);
|
||||
expect(fs.readFileSync(path.join(materializedDir, "upload-keystore.jks"), "utf8")).toBe(
|
||||
"fake keystore bytes\n",
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
|
|
|
|||
|
|
@ -23,6 +23,7 @@ const SETUP_PNPM_STORE_CACHE_ACTION = ".github/actions/setup-pnpm-store-cache/ac
|
|||
const DOCKER_E2E_PLAN_ACTION = ".github/actions/docker-e2e-plan/action.yml";
|
||||
const RELEASE_CHECKS_WORKFLOW = ".github/workflows/openclaw-release-checks.yml";
|
||||
const RELEASE_PUBLISH_WORKFLOW = ".github/workflows/openclaw-release-publish.yml";
|
||||
const ANDROID_RELEASE_WORKFLOW = ".github/workflows/android-release.yml";
|
||||
const STABLE_MAIN_CLOSEOUT_WORKFLOW = ".github/workflows/openclaw-stable-main-closeout.yml";
|
||||
const WINDOWS_NODE_RELEASE_WORKFLOW = ".github/workflows/windows-node-release.yml";
|
||||
const FULL_RELEASE_VALIDATION_WORKFLOW = ".github/workflows/full-release-validation.yml";
|
||||
|
|
@ -2093,6 +2094,99 @@ describe("package artifact reuse", () => {
|
|||
);
|
||||
});
|
||||
|
||||
it("gates stable GitHub publication on the signed Android APK contract", () => {
|
||||
const releaseWorkflow = readFileSync(RELEASE_PUBLISH_WORKFLOW, "utf8");
|
||||
const androidWorkflow = readFileSync(ANDROID_RELEASE_WORKFLOW, "utf8");
|
||||
const androidDocs = readFileSync("docs/platforms/android.md", "utf8");
|
||||
const releaseDocs = readFileSync("docs/reference/RELEASING.md", "utf8");
|
||||
const approvalScript = readFileSync("scripts/validate-release-publish-approval.mjs", "utf8");
|
||||
|
||||
expect(androidWorkflow).toContain("environment: android-release");
|
||||
expect(androidWorkflow).toContain(
|
||||
"actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1",
|
||||
);
|
||||
expect(androidWorkflow).toContain("repositories: apps-signing");
|
||||
expect(androidWorkflow).toContain("permission-contents: read");
|
||||
expect(androidWorkflow).toContain("--mode materialize");
|
||||
expect(androidWorkflow).not.toContain("APPS_SIGNING_DEPLOY_KEY");
|
||||
expect(androidWorkflow).toContain("MATCH_PASSWORD");
|
||||
expect(androidWorkflow).toContain("scripts/validate-release-publish-approval.mjs");
|
||||
expect(releaseWorkflow).toContain("Write Android release approval");
|
||||
expect(releaseWorkflow).toContain("Attest Android release approval");
|
||||
expect(releaseWorkflow).toContain("Upload Android release approval");
|
||||
expect(releaseWorkflow).toContain("android-release-approval-${{ github.run_id }}");
|
||||
expect(releaseWorkflow).toContain("parentRunId: process.env.RELEASE_PUBLISH_RUN_ID");
|
||||
expect(releaseWorkflow).toContain("releaseTag: process.env.RELEASE_TAG");
|
||||
expect(releaseWorkflow).toContain("targetSha: process.env.TARGET_SHA");
|
||||
expect(androidWorkflow).toContain("Download parent release approval");
|
||||
expect(androidWorkflow).toContain(
|
||||
"android-release-approval-${{ inputs.release_publish_run_id }}",
|
||||
);
|
||||
expect(androidWorkflow).toContain(
|
||||
'--signer-workflow "${GITHUB_REPOSITORY}/.github/workflows/openclaw-release-publish.yml"',
|
||||
);
|
||||
expect(androidWorkflow).toContain('--source-ref "refs/heads/${EXPECTED_WORKFLOW_BRANCH}"');
|
||||
expect(approvalScript).toContain(
|
||||
"Attested Android release approval does not match this run request.",
|
||||
);
|
||||
expect(androidWorkflow).toContain('--artifact", "third-party');
|
||||
expect(androidWorkflow).toContain("OpenClaw-Android.apk");
|
||||
expect(androidWorkflow).toContain("OpenClaw-Android-SHA256SUMS.txt");
|
||||
expect(androidWorkflow).toContain("actions/attest@a1948c3f048ba23858d222213b7c278aabede763");
|
||||
expect(androidWorkflow).toContain("--signer-workflow");
|
||||
expect(androidWorkflow).toContain('--source-ref "refs/tags/${RELEASE_TAG}"');
|
||||
expect(androidWorkflow).toContain("--deny-self-hosted-runners");
|
||||
expect(androidWorkflow).toContain("--verify-apk");
|
||||
expect(androidWorkflow).toContain('expected_source_ref="refs/tags/${RELEASE_TAG}"');
|
||||
expect(androidWorkflow).toContain("release_target_sha must be a full lowercase commit SHA");
|
||||
expect(androidWorkflow).toContain("does not match ${RELEASE_TAG} (${tag_sha})");
|
||||
expect(androidWorkflow).toContain(
|
||||
"must resolve to the same source commit as ${fallback_base_tag}",
|
||||
);
|
||||
expect(androidWorkflow).toContain("FALLBACK_ANDROID_BASE_TAG");
|
||||
expect(androidWorkflow).toContain("FALLBACK_ANDROID_BASE_SHA");
|
||||
expect(androidWorkflow).toContain('--source-digest "${FALLBACK_ANDROID_BASE_SHA}"');
|
||||
expect(androidWorkflow).toContain("steps.release_source.outputs.fallback_base_tag == ''");
|
||||
expect(androidWorkflow).toContain(
|
||||
"Reusing verified Android APK from ${FALLBACK_ANDROID_BASE_TAG}",
|
||||
);
|
||||
expect(androidWorkflow).toContain("Existing Android release asset ${asset_name} differs");
|
||||
expect(androidWorkflow).not.toContain("--clobber");
|
||||
|
||||
expect(releaseWorkflow).toContain("promote_android_release_asset()");
|
||||
expect(releaseWorkflow).toContain("is_android_release()");
|
||||
expect(androidWorkflow).toContain("requires a final or correction OpenClaw release tag");
|
||||
expect(androidWorkflow).toContain("previous_version_code");
|
||||
expect(androidWorkflow).toContain("must exceed ${previous_tag} versionCode");
|
||||
expect(androidWorkflow).toContain("standalone channel bootstrap");
|
||||
expect(releaseWorkflow).toContain(
|
||||
'dispatch_workflow_at_ref "${RELEASE_TAG}" android-release.yml',
|
||||
);
|
||||
expect(releaseWorkflow).toContain('-f release_target_sha="${TARGET_SHA}"');
|
||||
expect(releaseWorkflow).toContain("verify_android_release_asset_contract");
|
||||
expect(releaseWorkflow).toContain("Android release APK digest does not match");
|
||||
expect(releaseWorkflow).toContain("Android APK asset contract: verified");
|
||||
|
||||
const createDraftCall = releaseWorkflow.lastIndexOf(
|
||||
"\n create_or_update_github_release\n",
|
||||
);
|
||||
const promoteAndroidCall = releaseWorkflow.lastIndexOf(
|
||||
"\n if ! promote_android_release_asset; then\n",
|
||||
);
|
||||
const publishReleaseCall = releaseWorkflow.lastIndexOf(
|
||||
"\n publish_github_release\n",
|
||||
);
|
||||
expect(createDraftCall).toBeGreaterThan(-1);
|
||||
expect(promoteAndroidCall).toBeGreaterThan(createDraftCall);
|
||||
expect(publishReleaseCall).toBeGreaterThan(promoteAndroidCall);
|
||||
|
||||
expect(androidDocs).toContain("github.com/openclaw/openclaw/releases");
|
||||
expect(androidDocs).not.toContain("releases/latest/download/OpenClaw-Android.apk");
|
||||
expect(androidDocs).toContain("gh attestation verify OpenClaw-Android.apk");
|
||||
expect(androidDocs).toContain('--source-ref "refs/tags/${release_tag}"');
|
||||
expect(releaseDocs).toContain("signed standalone Android APK");
|
||||
});
|
||||
|
||||
it("rejects malformed Windows checksum manifest lines before parsing entries", () => {
|
||||
const releaseWorkflow = readFileSync(RELEASE_PUBLISH_WORKFLOW, "utf8");
|
||||
const validateManifestLinesIndex = releaseWorkflow.indexOf("all(.[]; test(");
|
||||
|
|
@ -2439,6 +2533,7 @@ describe("package artifact reuse", () => {
|
|||
LIVE_E2E_WORKFLOW,
|
||||
NPM_TELEGRAM_WORKFLOW,
|
||||
".github/workflows/openclaw-release-publish.yml",
|
||||
".github/workflows/android-release.yml",
|
||||
".github/workflows/openclaw-npm-release.yml",
|
||||
".github/workflows/macos-release.yml",
|
||||
".github/workflows/plugin-clawhub-release.yml",
|
||||
|
|
|
|||
|
|
@ -264,6 +264,19 @@ describe("scripts/test-projects changed-target routing", () => {
|
|||
mode: "targets",
|
||||
targets: ["test/scripts/android-release-wrapper-args.test.ts"],
|
||||
});
|
||||
expect(
|
||||
resolveChangedTestTargetPlan(["apps/android/scripts/build-release-artifacts.ts"]),
|
||||
).toEqual({
|
||||
mode: "targets",
|
||||
targets: ["test/scripts/android-release-artifacts.test.ts"],
|
||||
});
|
||||
expect(resolveChangedTestTargetPlan([".github/workflows/android-release.yml"])).toEqual({
|
||||
mode: "targets",
|
||||
targets: [
|
||||
"test/scripts/package-acceptance-workflow.test.ts",
|
||||
"test/scripts/ci-workflow-guards.test.ts",
|
||||
],
|
||||
});
|
||||
expect(resolveChangedTestTargetPlan(["scripts/release-fast-pretag-check.sh"])).toEqual({
|
||||
mode: "targets",
|
||||
targets: ["test/scripts/package-acceptance-workflow.test.ts"],
|
||||
|
|
|
|||
|
|
@ -1,15 +1,23 @@
|
|||
// Validate release publish approval tests cover the stdin/env CLI contract.
|
||||
import { spawnSync } from "node:child_process";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { useAutoCleanupTempDirTracker } from "../helpers/temp-dir.js";
|
||||
|
||||
const SCRIPT_PATH = "scripts/validate-release-publish-approval.mjs";
|
||||
const tempRoots = useAutoCleanupTempDirTracker(afterEach);
|
||||
|
||||
function runApprovalScript(
|
||||
run: Record<string, unknown>,
|
||||
env: {
|
||||
DIRECT_RELEASE_RECOVERY?: string;
|
||||
EXPECTED_WORKFLOW_BRANCH?: string;
|
||||
APPROVAL_PATH?: string;
|
||||
GITHUB_REPOSITORY?: string;
|
||||
RELEASE_TAG?: string;
|
||||
RELEASE_PUBLISH_RUN_ID?: string;
|
||||
RELEASE_TARGET_SHA?: string;
|
||||
} = {},
|
||||
) {
|
||||
return spawnSync(process.execPath, [SCRIPT_PATH], {
|
||||
|
|
@ -19,12 +27,35 @@ function runApprovalScript(
|
|||
...process.env,
|
||||
DIRECT_RELEASE_RECOVERY: env.DIRECT_RELEASE_RECOVERY ?? "false",
|
||||
EXPECTED_WORKFLOW_BRANCH: env.EXPECTED_WORKFLOW_BRANCH ?? "release/2026.6.21",
|
||||
APPROVAL_PATH: env.APPROVAL_PATH ?? "",
|
||||
GITHUB_REPOSITORY: env.GITHUB_REPOSITORY ?? "openclaw/openclaw",
|
||||
RELEASE_TAG: env.RELEASE_TAG ?? "v2026.6.21",
|
||||
RELEASE_PUBLISH_RUN_ID: env.RELEASE_PUBLISH_RUN_ID ?? "123",
|
||||
RELEASE_TARGET_SHA: env.RELEASE_TARGET_SHA ?? "a".repeat(40),
|
||||
},
|
||||
input: JSON.stringify(run),
|
||||
});
|
||||
}
|
||||
|
||||
function writeApproval(overrides: Record<string, unknown> = {}) {
|
||||
const tempRoot = tempRoots.make("openclaw-release-approval-");
|
||||
const approvalPath = path.join(tempRoot, "approval.json");
|
||||
fs.writeFileSync(
|
||||
approvalPath,
|
||||
`${JSON.stringify({
|
||||
version: 1,
|
||||
repository: "openclaw/openclaw",
|
||||
workflow: "OpenClaw Release Publish",
|
||||
parentRunId: "123",
|
||||
workflowBranch: "release/2026.6.21",
|
||||
releaseTag: "v2026.6.21",
|
||||
targetSha: "a".repeat(40),
|
||||
...overrides,
|
||||
})}\n`,
|
||||
);
|
||||
return approvalPath;
|
||||
}
|
||||
|
||||
function approvalRun(overrides: Record<string, unknown> = {}) {
|
||||
return {
|
||||
conclusion: null,
|
||||
|
|
@ -68,12 +99,36 @@ describe("scripts/validate-release-publish-approval.mjs", () => {
|
|||
expect(result.stdout).toBe("");
|
||||
});
|
||||
|
||||
it("accepts an exact attested Android release approval", () => {
|
||||
const approvalPath = writeApproval();
|
||||
|
||||
const result = runApprovalScript(approvalRun(), { APPROVAL_PATH: approvalPath });
|
||||
|
||||
expect(result.status).toBe(0);
|
||||
expect(result.stderr).toBe("");
|
||||
});
|
||||
|
||||
it.each([
|
||||
["parent run", { parentRunId: "999" }],
|
||||
["release tag", { releaseTag: "v2026.6.22" }],
|
||||
["target SHA", { targetSha: "b".repeat(40) }],
|
||||
["extra field", { unexpected: true }],
|
||||
])("rejects an attested Android approval for another %s", (_name, overrides) => {
|
||||
const approvalPath = writeApproval(overrides);
|
||||
|
||||
const result = runApprovalScript(approvalRun(), { APPROVAL_PATH: approvalPath });
|
||||
|
||||
expect(result.status).toBe(1);
|
||||
expect(result.stderr).toContain(
|
||||
"Attested Android release approval does not match this run request.",
|
||||
);
|
||||
});
|
||||
|
||||
it("accepts completed success or failure runs for direct recovery", () => {
|
||||
for (const conclusion of ["success", "failure"]) {
|
||||
const result = runApprovalScript(
|
||||
approvalRun({ conclusion, status: "completed" }),
|
||||
{ DIRECT_RELEASE_RECOVERY: "true" },
|
||||
);
|
||||
const result = runApprovalScript(approvalRun({ conclusion, status: "completed" }), {
|
||||
DIRECT_RELEASE_RECOVERY: "true",
|
||||
});
|
||||
|
||||
expect(result.status).toBe(0);
|
||||
expect(result.stdout).toContain(
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue