mirror of
https://github.com/openclaw/openclaw.git
synced 2026-07-21 02:06:43 +00:00
feat: show build identity in About screens (#103595)
* feat: show build identity in About screens * chore: leave root changelog to release automation * fix: translate Control UI About build details
This commit is contained in:
@@ -63,6 +63,7 @@ jobs:
|
||||
github-token: ${{ github.token }}
|
||||
|
||||
- name: Validate release approval and target
|
||||
id: release_approval
|
||||
env:
|
||||
APPROVAL_PATH: ${{ runner.temp }}/android-release-approval/approval.json
|
||||
DIRECT_RELEASE_RECOVERY: ${{ inputs.direct_release_recovery && 'true' || 'false' }}
|
||||
@@ -105,7 +106,7 @@ jobs:
|
||||
exit 1
|
||||
fi
|
||||
|
||||
release_json="$(gh release view "${RELEASE_TAG}" --repo "${GITHUB_REPOSITORY}" --json tagName,isDraft,isPrerelease,assets,url)"
|
||||
release_json="$(gh release view "${RELEASE_TAG}" --repo "${GITHUB_REPOSITORY}" --json tagName,isDraft,isPrerelease,createdAt,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
|
||||
@@ -118,6 +119,9 @@ jobs:
|
||||
echo "Normal Android promotion requires the target GitHub release to remain a draft." >&2
|
||||
exit 1
|
||||
fi
|
||||
release_created_at="$(printf '%s' "${release_json}" | jq -er '.createdAt')"
|
||||
build_timestamp="$(date -u -d "${release_created_at}" +%Y-%m-%dT%H:%M:%SZ)"
|
||||
echo "build_timestamp=${build_timestamp}" >> "${GITHUB_OUTPUT}"
|
||||
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
|
||||
@@ -247,7 +251,9 @@ jobs:
|
||||
|
||||
- name: Prepare and verify signed Android APK
|
||||
env:
|
||||
GIT_COMMIT: ${{ inputs.release_target_sha }}
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
OPENCLAW_BUILD_TIMESTAMP: ${{ steps.release_approval.outputs.build_timestamp }}
|
||||
RELEASE_TAG: ${{ inputs.tag }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
@@ -87,12 +87,36 @@ jobs:
|
||||
fi
|
||||
echo "Docker Hub publishing configured for ${DOCKERHUB_IMAGE}."
|
||||
|
||||
resolve_build_provenance:
|
||||
needs: [approve_manual_backfill, validate_publish_config]
|
||||
if: ${{ always() && needs.validate_publish_config.result == 'success' && (github.event_name != 'workflow_dispatch' || needs.approve_manual_backfill.result == 'success') }}
|
||||
runs-on: ubuntu-24.04
|
||||
permissions:
|
||||
contents: read
|
||||
outputs:
|
||||
built_at: ${{ steps.build_provenance.outputs.built_at }}
|
||||
source_sha: ${{ steps.build_provenance.outputs.source_sha }}
|
||||
steps:
|
||||
- name: Checkout selected source
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6
|
||||
with:
|
||||
ref: ${{ github.event_name == 'workflow_dispatch' && format('refs/tags/{0}', inputs.tag) || github.sha }}
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Resolve shared build provenance
|
||||
id: build_provenance
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
echo "source_sha=$(git rev-parse HEAD)" >> "$GITHUB_OUTPUT"
|
||||
echo "built_at=$(date -u +%Y-%m-%dT%H:%M:%SZ)" >> "$GITHUB_OUTPUT"
|
||||
|
||||
# KEEP THIS WORKFLOW ON GITHUB-HOSTED RUNNERS.
|
||||
# DO NOT MOVE IT BACK TO BLACKSMITH WITHOUT RE-VALIDATING TAG BUILDS AND BACKFILLS.
|
||||
# Build amd64 image. Default and slim tags point to the same slim runtime.
|
||||
build-amd64:
|
||||
needs: [approve_manual_backfill, validate_publish_config]
|
||||
if: ${{ always() && needs.validate_publish_config.result == 'success' && (github.event_name != 'workflow_dispatch' || needs.approve_manual_backfill.result == 'success') }}
|
||||
needs: [approve_manual_backfill, validate_publish_config, resolve_build_provenance]
|
||||
if: ${{ always() && needs.validate_publish_config.result == 'success' && needs.resolve_build_provenance.result == 'success' && (github.event_name != 'workflow_dispatch' || needs.approve_manual_backfill.result == 'success') }}
|
||||
# WARNING: DO NOT REVERT THIS TO A BLACKSMITH RUNNER WITHOUT RE-VALIDATING TAG BACKFILLS.
|
||||
runs-on: ubuntu-24.04
|
||||
permissions:
|
||||
@@ -105,7 +129,7 @@ jobs:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6
|
||||
with:
|
||||
ref: ${{ github.event_name == 'workflow_dispatch' && format('refs/tags/{0}', inputs.tag) || github.ref }}
|
||||
ref: ${{ needs.resolve_build_provenance.outputs.source_sha }}
|
||||
fetch-depth: 0
|
||||
|
||||
- &buildkit_prepull_step
|
||||
@@ -189,10 +213,12 @@ jobs:
|
||||
id: labels
|
||||
shell: bash
|
||||
env:
|
||||
BUILD_TIMESTAMP: ${{ needs.resolve_build_provenance.outputs.built_at }}
|
||||
SOURCE_SHA: ${{ needs.resolve_build_provenance.outputs.source_sha }}
|
||||
SOURCE_REF: ${{ github.event_name == 'workflow_dispatch' && format('refs/tags/{0}', inputs.tag) || github.ref }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
source_sha="$(git rev-parse HEAD)"
|
||||
source_sha="${SOURCE_SHA}"
|
||||
version="${source_sha}"
|
||||
if [[ "${SOURCE_REF}" == "refs/heads/main" ]]; then
|
||||
version="main"
|
||||
@@ -200,7 +226,7 @@ jobs:
|
||||
if [[ "${SOURCE_REF}" == refs/tags/v* ]]; then
|
||||
version="${SOURCE_REF#refs/tags/v}"
|
||||
fi
|
||||
created="$(date -u +%Y-%m-%dT%H:%M:%SZ)"
|
||||
created="${BUILD_TIMESTAMP}"
|
||||
{
|
||||
echo "value<<EOF"
|
||||
echo "org.opencontainers.image.revision=${source_sha}"
|
||||
@@ -219,6 +245,8 @@ jobs:
|
||||
cache-from: type=gha,scope=docker-release-amd64
|
||||
cache-to: type=gha,mode=max,scope=docker-release-amd64
|
||||
build-args: |
|
||||
GIT_COMMIT=${{ needs.resolve_build_provenance.outputs.source_sha }}
|
||||
OPENCLAW_BUILD_TIMESTAMP=${{ needs.resolve_build_provenance.outputs.built_at }}
|
||||
OPENCLAW_EXTENSIONS=diagnostics-otel,codex
|
||||
tags: ${{ steps.tags.outputs.value }}
|
||||
labels: ${{ steps.labels.outputs.value }}
|
||||
@@ -239,6 +267,8 @@ jobs:
|
||||
type=gha,scope=docker-release-browser-amd64
|
||||
cache-to: type=gha,mode=max,scope=docker-release-browser-amd64
|
||||
build-args: |
|
||||
GIT_COMMIT=${{ needs.resolve_build_provenance.outputs.source_sha }}
|
||||
OPENCLAW_BUILD_TIMESTAMP=${{ needs.resolve_build_provenance.outputs.built_at }}
|
||||
OPENCLAW_EXTENSIONS=diagnostics-otel,codex
|
||||
OPENCLAW_INSTALL_BROWSER=1
|
||||
tags: ${{ steps.tags.outputs.browser }}
|
||||
@@ -313,8 +343,8 @@ jobs:
|
||||
|
||||
# Build arm64 image. Default and slim tags point to the same slim runtime.
|
||||
build-arm64:
|
||||
needs: [approve_manual_backfill, validate_publish_config]
|
||||
if: ${{ always() && needs.validate_publish_config.result == 'success' && (github.event_name != 'workflow_dispatch' || needs.approve_manual_backfill.result == 'success') }}
|
||||
needs: [approve_manual_backfill, validate_publish_config, resolve_build_provenance]
|
||||
if: ${{ always() && needs.validate_publish_config.result == 'success' && needs.resolve_build_provenance.result == 'success' && (github.event_name != 'workflow_dispatch' || needs.approve_manual_backfill.result == 'success') }}
|
||||
# WARNING: DO NOT REVERT THIS TO A BLACKSMITH RUNNER WITHOUT RE-VALIDATING TAG BACKFILLS.
|
||||
runs-on: ubuntu-24.04-arm
|
||||
permissions:
|
||||
@@ -327,7 +357,7 @@ jobs:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6
|
||||
with:
|
||||
ref: ${{ github.event_name == 'workflow_dispatch' && format('refs/tags/{0}', inputs.tag) || github.ref }}
|
||||
ref: ${{ needs.resolve_build_provenance.outputs.source_sha }}
|
||||
fetch-depth: 0
|
||||
|
||||
- *buildkit_prepull_step
|
||||
@@ -392,10 +422,12 @@ jobs:
|
||||
id: labels
|
||||
shell: bash
|
||||
env:
|
||||
BUILD_TIMESTAMP: ${{ needs.resolve_build_provenance.outputs.built_at }}
|
||||
SOURCE_SHA: ${{ needs.resolve_build_provenance.outputs.source_sha }}
|
||||
SOURCE_REF: ${{ github.event_name == 'workflow_dispatch' && format('refs/tags/{0}', inputs.tag) || github.ref }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
source_sha="$(git rev-parse HEAD)"
|
||||
source_sha="${SOURCE_SHA}"
|
||||
version="${source_sha}"
|
||||
if [[ "${SOURCE_REF}" == "refs/heads/main" ]]; then
|
||||
version="main"
|
||||
@@ -403,7 +435,7 @@ jobs:
|
||||
if [[ "${SOURCE_REF}" == refs/tags/v* ]]; then
|
||||
version="${SOURCE_REF#refs/tags/v}"
|
||||
fi
|
||||
created="$(date -u +%Y-%m-%dT%H:%M:%SZ)"
|
||||
created="${BUILD_TIMESTAMP}"
|
||||
{
|
||||
echo "value<<EOF"
|
||||
echo "org.opencontainers.image.revision=${source_sha}"
|
||||
@@ -422,6 +454,8 @@ jobs:
|
||||
cache-from: type=gha,scope=docker-release-arm64
|
||||
cache-to: type=gha,mode=max,scope=docker-release-arm64
|
||||
build-args: |
|
||||
GIT_COMMIT=${{ needs.resolve_build_provenance.outputs.source_sha }}
|
||||
OPENCLAW_BUILD_TIMESTAMP=${{ needs.resolve_build_provenance.outputs.built_at }}
|
||||
OPENCLAW_EXTENSIONS=diagnostics-otel,codex
|
||||
tags: ${{ steps.tags.outputs.value }}
|
||||
labels: ${{ steps.labels.outputs.value }}
|
||||
@@ -442,6 +476,8 @@ jobs:
|
||||
type=gha,scope=docker-release-browser-arm64
|
||||
cache-to: type=gha,mode=max,scope=docker-release-browser-arm64
|
||||
build-args: |
|
||||
GIT_COMMIT=${{ needs.resolve_build_provenance.outputs.source_sha }}
|
||||
OPENCLAW_BUILD_TIMESTAMP=${{ needs.resolve_build_provenance.outputs.built_at }}
|
||||
OPENCLAW_EXTENSIONS=diagnostics-otel,codex
|
||||
OPENCLAW_INSTALL_BROWSER=1
|
||||
tags: ${{ steps.tags.outputs.browser }}
|
||||
@@ -516,7 +552,7 @@ jobs:
|
||||
|
||||
# Create multi-platform manifests
|
||||
create-manifest:
|
||||
needs: [approve_manual_backfill, validate_publish_config, build-amd64, build-arm64]
|
||||
needs: [approve_manual_backfill, validate_publish_config, resolve_build_provenance, build-amd64, build-arm64]
|
||||
if: ${{ always() && needs.validate_publish_config.result == 'success' && needs.build-amd64.result == 'success' && needs.build-arm64.result == 'success' && (github.event_name != 'workflow_dispatch' || needs.approve_manual_backfill.result == 'success') }}
|
||||
# WARNING: DO NOT REVERT THIS TO A BLACKSMITH RUNNER WITHOUT RE-VALIDATING TAG BACKFILLS.
|
||||
runs-on: ubuntu-24.04
|
||||
@@ -527,7 +563,7 @@ jobs:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6
|
||||
with:
|
||||
ref: ${{ github.event_name == 'workflow_dispatch' && format('refs/tags/{0}', inputs.tag) || github.ref }}
|
||||
ref: ${{ needs.resolve_build_provenance.outputs.source_sha }}
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Login to GitHub Container Registry
|
||||
@@ -654,7 +690,7 @@ jobs:
|
||||
fi
|
||||
|
||||
verify-attestations:
|
||||
needs: [create-manifest]
|
||||
needs: [resolve_build_provenance, create-manifest]
|
||||
if: ${{ always() && needs.create-manifest.result == 'success' }}
|
||||
runs-on: ubuntu-24.04
|
||||
permissions:
|
||||
@@ -664,6 +700,7 @@ jobs:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6
|
||||
with:
|
||||
ref: ${{ needs.resolve_build_provenance.outputs.source_sha }}
|
||||
fetch-depth: 1
|
||||
|
||||
- *buildkit_prepull_step
|
||||
@@ -704,9 +741,7 @@ jobs:
|
||||
dockerhub_arm64_refs=()
|
||||
browser_supported=0
|
||||
if [[ "${SOURCE_REF}" == refs/tags/v* ]]; then
|
||||
tag="${SOURCE_REF#refs/tags/}"
|
||||
git fetch --depth=1 origin "refs/tags/${tag}:refs/tags/${tag}"
|
||||
if git show "${SOURCE_REF}:Dockerfile" | grep -q '^ARG OPENCLAW_INSTALL_BROWSER'; then
|
||||
if grep -q '^ARG OPENCLAW_INSTALL_BROWSER' Dockerfile; then
|
||||
browser_supported=1
|
||||
fi
|
||||
elif grep -q '^ARG OPENCLAW_INSTALL_BROWSER' Dockerfile; then
|
||||
|
||||
+17
-5
@@ -102,6 +102,13 @@ RUN set -eux; \
|
||||
find /app/node_modules -name "matrix-sdk-crypto*.node" 2>/dev/null | grep -q . || \
|
||||
(echo "ERROR: matrix-sdk-crypto native addon missing after retries" >&2 && exit 1)
|
||||
|
||||
# Public source provenance supplied by release automation or local setup. Keep
|
||||
# these after the dependency layer so a new timestamp does not invalidate install.
|
||||
ARG GIT_COMMIT=""
|
||||
ARG OPENCLAW_BUILD_TIMESTAMP=""
|
||||
ENV GIT_COMMIT=${GIT_COMMIT} \
|
||||
OPENCLAW_BUILD_TIMESTAMP=${OPENCLAW_BUILD_TIMESTAMP}
|
||||
|
||||
COPY . .
|
||||
|
||||
# Normalize extension paths now so runtime COPY preserves safe modes
|
||||
@@ -122,13 +129,18 @@ RUN pnpm_config_verify_deps_before_run=false pnpm canvas:a2ui:bundle || \
|
||||
echo "/* A2UI bundle unavailable in this build */" > extensions/canvas/src/host/a2ui/a2ui.bundle.js && \
|
||||
echo "stub" > extensions/canvas/src/host/a2ui/.bundle.hash && \
|
||||
rm -rf vendor/a2ui apps/shared/OpenClawKit/Tools/CanvasA2UI)
|
||||
RUN if printf '%s\n' "$OPENCLAW_EXTENSIONS" | tr ',' ' ' | tr ' ' '\n' | grep -qx 'qa-lab'; then \
|
||||
export OPENCLAW_BUILD_PRIVATE_QA=1 OPENCLAW_ENABLE_PRIVATE_QA_CLI=1; \
|
||||
fi && \
|
||||
OPENCLAW_RUN_NODE_SKIP_DTS_BUILD="$OPENCLAW_DOCKER_BUILD_SKIP_DTS" OPENCLAW_TSDOWN_MAX_OLD_SPACE_MB="$OPENCLAW_DOCKER_BUILD_TSDOWN_MAX_OLD_SPACE_MB" NODE_OPTIONS="$OPENCLAW_DOCKER_BUILD_NODE_OPTIONS" pnpm_config_verify_deps_before_run=false pnpm build:docker
|
||||
# Force pnpm for UI build (Bun may fail on ARM/Synology architectures)
|
||||
ENV OPENCLAW_PREFER_PNPM=1
|
||||
RUN pnpm_config_verify_deps_before_run=false pnpm ui:build
|
||||
RUN set -eu; \
|
||||
if [ -z "$OPENCLAW_BUILD_TIMESTAMP" ]; then \
|
||||
OPENCLAW_BUILD_TIMESTAMP="$(date -u +%Y-%m-%dT%H:%M:%SZ)"; \
|
||||
export OPENCLAW_BUILD_TIMESTAMP; \
|
||||
fi; \
|
||||
if printf '%s\n' "$OPENCLAW_EXTENSIONS" | tr ',' ' ' | tr ' ' '\n' | grep -qx 'qa-lab'; then \
|
||||
export OPENCLAW_BUILD_PRIVATE_QA=1 OPENCLAW_ENABLE_PRIVATE_QA_CLI=1; \
|
||||
fi; \
|
||||
OPENCLAW_RUN_NODE_SKIP_DTS_BUILD="$OPENCLAW_DOCKER_BUILD_SKIP_DTS" OPENCLAW_TSDOWN_MAX_OLD_SPACE_MB="$OPENCLAW_DOCKER_BUILD_TSDOWN_MAX_OLD_SPACE_MB" NODE_OPTIONS="$OPENCLAW_DOCKER_BUILD_NODE_OPTIONS" pnpm_config_verify_deps_before_run=false pnpm build:docker; \
|
||||
pnpm_config_verify_deps_before_run=false pnpm ui:build
|
||||
RUN if printf '%s\n' "$OPENCLAW_EXTENSIONS" | tr ',' ' ' | tr ' ' '\n' | grep -qx 'qa-lab'; then \
|
||||
pnpm_config_verify_deps_before_run=false pnpm qa:lab:build && \
|
||||
mkdir -p dist/extensions/qa-lab/web && \
|
||||
|
||||
+524
-292
File diff suppressed because it is too large
Load Diff
@@ -2,6 +2,8 @@
|
||||
|
||||
## Unreleased
|
||||
|
||||
Shows the localized app version, Git commit, and build date together on the About screen, with real provenance in repository-backed debug builds.
|
||||
|
||||
## 2026.7.1 - 2026-07-08
|
||||
|
||||
Adds multi-gateway switching with isolated credentials, history, queues, and notification routing.
|
||||
|
||||
+11
-3
@@ -46,6 +46,12 @@ cd apps/android
|
||||
./gradlew :app:testThirdPartyDebugUnitTest
|
||||
```
|
||||
|
||||
Repository-backed debug Gradle invocations, including `pnpm android:run` and
|
||||
`pnpm android:screenshots`, stamp the full checkout commit and capture one UTC
|
||||
build timestamp shared by every debug variant in that invocation. Release
|
||||
tasks still require explicit `openclawBuildCommit` and
|
||||
`openclawBuildTimestamp` properties so signed artifacts remain reproducible.
|
||||
|
||||
Android release archives use the pinned version in `apps/android/version.json`. Update it with:
|
||||
|
||||
```bash
|
||||
@@ -102,12 +108,14 @@ Google Play API commands, or Play Console mutation commands.
|
||||
|
||||
See `apps/android/VERSIONING.md` and `apps/android/fastlane/SETUP.md` for the release workflow.
|
||||
|
||||
Flavor-specific direct Gradle tasks:
|
||||
Prefer `pnpm android:release:archive`, which stamps and validates the full Git commit and one UTC build timestamp before signing. Flavor-specific direct Gradle release tasks must pass the same metadata explicitly:
|
||||
|
||||
```bash
|
||||
cd apps/android
|
||||
./gradlew :app:bundlePlayRelease
|
||||
./gradlew :app:bundleThirdPartyRelease
|
||||
commit="$(git -C ../.. rev-parse HEAD)"
|
||||
built_at="$(date -u +%Y-%m-%dT%H:%M:%SZ)"
|
||||
./gradlew -PopenclawBuildCommit="$commit" -PopenclawBuildTimestamp="$built_at" :app:bundlePlayRelease
|
||||
./gradlew -PopenclawBuildCommit="$commit" -PopenclawBuildTimestamp="$built_at" :app:bundleThirdPartyRelease
|
||||
```
|
||||
|
||||
## Kotlin Lint + Format
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
import com.android.build.api.variant.impl.VariantOutputImpl
|
||||
import java.time.Instant
|
||||
import java.time.ZoneOffset
|
||||
import java.time.format.DateTimeFormatter
|
||||
import java.util.Properties
|
||||
|
||||
val dnsjavaInetAddressResolverService = "META-INF/services/java.net.spi.InetAddressResolverProvider"
|
||||
@@ -21,6 +24,61 @@ val openClawAndroidVersionCode =
|
||||
requireOpenClawAndroidVersionProperty("OPENCLAW_ANDROID_VERSION_CODE").toIntOrNull()
|
||||
?: error("OPENCLAW_ANDROID_VERSION_CODE must be an integer in Config/Version.properties.")
|
||||
|
||||
fun optionalOpenClawBuildProperty(name: String): String? =
|
||||
providers
|
||||
.gradleProperty(name)
|
||||
.orNull
|
||||
?.trim()
|
||||
?.takeIf { it.isNotEmpty() }
|
||||
|
||||
val fullGitCommitPattern = Regex("^[a-f0-9]{40}$")
|
||||
val buildTimestampFormatter =
|
||||
DateTimeFormatter.ofPattern("uuuu-MM-dd'T'HH:mm:ss.SSS'Z'").withZone(ZoneOffset.UTC)
|
||||
val explicitOpenClawBuildCommit =
|
||||
optionalOpenClawBuildProperty("openclawBuildCommit")
|
||||
?.lowercase()
|
||||
?.also { commit ->
|
||||
if (!fullGitCommitPattern.matches(commit)) {
|
||||
error("openclawBuildCommit must be a full 40-character hexadecimal Git commit.")
|
||||
}
|
||||
}
|
||||
|
||||
val explicitOpenClawBuildTimestamp =
|
||||
optionalOpenClawBuildProperty("openclawBuildTimestamp")
|
||||
?.let { timestamp ->
|
||||
if (!Regex("^\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:\\d{2}(?:\\.\\d{1,3})?Z$").matches(timestamp)) {
|
||||
error("openclawBuildTimestamp must be an ISO-8601 UTC timestamp.")
|
||||
}
|
||||
val instant =
|
||||
runCatching { Instant.parse(timestamp) }
|
||||
.getOrElse { error("openclawBuildTimestamp must be an ISO-8601 UTC timestamp.") }
|
||||
buildTimestampFormatter.format(instant)
|
||||
}
|
||||
|
||||
val repositoryBuildCommit =
|
||||
if (explicitOpenClawBuildCommit == null) {
|
||||
runCatching {
|
||||
providers
|
||||
.exec {
|
||||
workingDir(rootProject.projectDir)
|
||||
commandLine("git", "rev-parse", "HEAD")
|
||||
}.standardOutput
|
||||
.asText
|
||||
.get()
|
||||
.trim()
|
||||
.lowercase()
|
||||
.takeIf(fullGitCommitPattern::matches)
|
||||
}.getOrNull()
|
||||
} else {
|
||||
null
|
||||
}
|
||||
|
||||
val openClawBuildCommit = explicitOpenClawBuildCommit ?: repositoryBuildCommit ?: "unknown"
|
||||
// Keep every variant generated by one Gradle invocation on the same build instant.
|
||||
val invocationBuildTimestamp =
|
||||
providers.provider { buildTimestampFormatter.format(Instant.now()) }.get()
|
||||
val openClawBuildTimestamp = explicitOpenClawBuildTimestamp ?: invocationBuildTimestamp
|
||||
|
||||
val androidStoreFile = providers.gradleProperty("OPENCLAW_ANDROID_STORE_FILE").orNull?.takeIf { it.isNotBlank() }
|
||||
val androidStorePassword = providers.gradleProperty("OPENCLAW_ANDROID_STORE_PASSWORD").orNull?.takeIf { it.isNotBlank() }
|
||||
val androidKeyAlias = providers.gradleProperty("OPENCLAW_ANDROID_KEY_ALIAS").orNull?.takeIf { it.isNotBlank() }
|
||||
@@ -42,6 +100,8 @@ val wantsAndroidReleaseBuild =
|
||||
taskName.contains("Release", ignoreCase = true) ||
|
||||
Regex("""(^|:)(bundle|assemble)$""").containsMatchIn(taskName)
|
||||
}
|
||||
val missingAndroidBuildMetadata =
|
||||
explicitOpenClawBuildCommit == null || explicitOpenClawBuildTimestamp == null
|
||||
|
||||
if (wantsAndroidReleaseBuild && !hasAndroidReleaseSigning) {
|
||||
error(
|
||||
@@ -91,6 +151,8 @@ android {
|
||||
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
||||
versionCode = openClawAndroidVersionCode
|
||||
versionName = openClawAndroidVersionName
|
||||
buildConfigField("String", "GIT_COMMIT", "\"$openClawBuildCommit\"")
|
||||
buildConfigField("String", "BUILD_TIMESTAMP", "\"$openClawBuildTimestamp\"")
|
||||
ndk {
|
||||
// Support all major ABIs — native libs are tiny (~47 KB per ABI)
|
||||
abiFilters += listOf("armeabi-v7a", "arm64-v8a", "x86", "x86_64")
|
||||
@@ -300,6 +362,18 @@ tasks.withType<Test>().configureEach {
|
||||
useJUnitPlatform()
|
||||
}
|
||||
|
||||
val validateOpenClawReleaseBuildMetadata =
|
||||
tasks.register("validateOpenClawReleaseBuildMetadata") {
|
||||
doLast {
|
||||
if (missingAndroidBuildMetadata) {
|
||||
error(
|
||||
"Android release builds require -PopenclawBuildCommit and -PopenclawBuildTimestamp. " +
|
||||
"Use the repository Android release helper.",
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val validateThirdPartyLicenseAssets =
|
||||
tasks.register("validateThirdPartyLicenseAssets") {
|
||||
inputs.dir(thirdPartyLicensesDir)
|
||||
@@ -331,6 +405,7 @@ androidComponents {
|
||||
onVariants(selector().withBuildType("release")) { variant ->
|
||||
val variantName = variant.name
|
||||
val variantNameCapitalized = variantName.replaceFirstChar(Char::titlecase)
|
||||
val preBuildTaskName = "pre${variantNameCapitalized}Build"
|
||||
val stripTaskName = "strip${variantNameCapitalized}DnsjavaServiceDescriptor"
|
||||
val mergeTaskName = "merge${variantNameCapitalized}JavaResource"
|
||||
val minifyTaskName = "minify${variantNameCapitalized}WithR8"
|
||||
@@ -339,6 +414,10 @@ androidComponents {
|
||||
"intermediates/merged_java_res/$variantName/$mergeTaskName/base.jar",
|
||||
)
|
||||
|
||||
tasks.matching { task -> task.name == preBuildTaskName }.configureEach {
|
||||
dependsOn(validateOpenClawReleaseBuildMetadata)
|
||||
}
|
||||
|
||||
val stripTask =
|
||||
tasks.register(stripTaskName) {
|
||||
inputs.file(mergedJar)
|
||||
|
||||
@@ -0,0 +1,261 @@
|
||||
package ai.openclaw.app.ui
|
||||
|
||||
import ai.openclaw.app.R
|
||||
import ai.openclaw.app.ui.design.ClawPanel
|
||||
import ai.openclaw.app.ui.design.ClawTheme
|
||||
import android.content.ClipData
|
||||
import android.content.ClipboardManager
|
||||
import android.content.Context
|
||||
import android.widget.Toast
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.BoxWithConstraints
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.heightIn
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.semantics.clearAndSetSemantics
|
||||
import androidx.compose.ui.semantics.contentDescription
|
||||
import androidx.compose.ui.semantics.onClick
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.text.style.TextDirection
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import java.text.DateFormat
|
||||
import java.time.Instant
|
||||
import java.util.Date
|
||||
import java.util.Locale
|
||||
import java.util.TimeZone
|
||||
|
||||
internal data class AboutBuildIdentity(
|
||||
val version: String,
|
||||
val commit: String,
|
||||
val fullCommit: String?,
|
||||
val built: String,
|
||||
val buildTimestamp: String?,
|
||||
)
|
||||
|
||||
private data class AboutBuildCell(
|
||||
val title: String,
|
||||
val value: String,
|
||||
val accessibilityLabel: String,
|
||||
val forceLeftToRight: Boolean = false,
|
||||
val monospace: Boolean = false,
|
||||
val onClick: (() -> Unit)? = null,
|
||||
val onClickLabel: String? = null,
|
||||
)
|
||||
|
||||
private val fullGitCommitPattern = Regex("^[a-f0-9]{40}$")
|
||||
|
||||
internal fun aboutBuildIdentity(
|
||||
versionName: String,
|
||||
versionCode: Int,
|
||||
gitCommit: String,
|
||||
buildTimestamp: String,
|
||||
locale: Locale,
|
||||
unknownLabel: String,
|
||||
): AboutBuildIdentity {
|
||||
val normalizedCommit = gitCommit.trim().lowercase().takeIf(fullGitCommitPattern::matches)
|
||||
val normalizedBuildTimestamp =
|
||||
buildTimestamp.trim().takeIf { it.endsWith("Z") }?.takeIf { timestamp ->
|
||||
runCatching { Instant.parse(timestamp) }.isSuccess
|
||||
}
|
||||
val buildInstant = normalizedBuildTimestamp?.let(Instant::parse)
|
||||
val built =
|
||||
buildInstant?.let { instant ->
|
||||
DateFormat.getDateInstance(DateFormat.MEDIUM, locale).run {
|
||||
timeZone = TimeZone.getTimeZone("UTC")
|
||||
format(Date.from(instant))
|
||||
}
|
||||
} ?: unknownLabel
|
||||
|
||||
return AboutBuildIdentity(
|
||||
version = "${versionName.trim()} ($versionCode)",
|
||||
commit = normalizedCommit?.take(12) ?: unknownLabel,
|
||||
fullCommit = normalizedCommit,
|
||||
built = built,
|
||||
buildTimestamp = normalizedBuildTimestamp,
|
||||
)
|
||||
}
|
||||
|
||||
internal fun aboutCommitAccessibilityValue(
|
||||
fullCommit: String?,
|
||||
unknownLabel: String,
|
||||
): String =
|
||||
fullCommit?.let { commit ->
|
||||
commit.toCharArray().joinToString(" ")
|
||||
} ?: unknownLabel
|
||||
|
||||
@Composable
|
||||
internal fun AboutBuildIdentityPanel(
|
||||
versionName: String,
|
||||
versionCode: Int,
|
||||
gitCommit: String,
|
||||
buildTimestamp: String,
|
||||
locale: Locale,
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
val unknownLabel = stringResource(R.string.about_build_unknown)
|
||||
val identity =
|
||||
aboutBuildIdentity(
|
||||
versionName = versionName,
|
||||
versionCode = versionCode,
|
||||
gitCommit = gitCommit,
|
||||
buildTimestamp = buildTimestamp,
|
||||
locale = locale,
|
||||
unknownLabel = unknownLabel,
|
||||
)
|
||||
val commitClipboardLabel = stringResource(R.string.about_build_commit_clipboard_label)
|
||||
val commitCopiedConfirmation = stringResource(R.string.about_build_commit_copied)
|
||||
val timestampClipboardLabel = stringResource(R.string.about_build_timestamp_clipboard_label)
|
||||
val timestampCopiedConfirmation = stringResource(R.string.about_build_timestamp_copied)
|
||||
val copyCommitLabel = stringResource(R.string.about_build_copy_commit)
|
||||
val copyTimestampLabel = stringResource(R.string.about_build_copy_timestamp)
|
||||
val commitClick: (() -> Unit)? =
|
||||
identity.fullCommit?.let { commit ->
|
||||
{
|
||||
copyAboutBuildValue(
|
||||
context = context,
|
||||
label = commitClipboardLabel,
|
||||
value = commit,
|
||||
confirmation = commitCopiedConfirmation,
|
||||
)
|
||||
}
|
||||
}
|
||||
val timestampClick: (() -> Unit)? =
|
||||
identity.buildTimestamp?.let { timestamp ->
|
||||
{
|
||||
copyAboutBuildValue(
|
||||
context = context,
|
||||
label = timestampClipboardLabel,
|
||||
value = timestamp,
|
||||
confirmation = timestampCopiedConfirmation,
|
||||
)
|
||||
}
|
||||
}
|
||||
val builtAccessibilityLabel =
|
||||
identity.buildTimestamp?.let { timestamp ->
|
||||
stringResource(R.string.about_build_built_accessibility, identity.built, timestamp)
|
||||
} ?: stringResource(R.string.about_build_date_accessibility, identity.built)
|
||||
val cells =
|
||||
listOf(
|
||||
AboutBuildCell(
|
||||
title = stringResource(R.string.about_build_version_title),
|
||||
value = identity.version,
|
||||
accessibilityLabel = stringResource(R.string.about_build_version_accessibility, identity.version),
|
||||
forceLeftToRight = true,
|
||||
),
|
||||
AboutBuildCell(
|
||||
title = stringResource(R.string.about_build_commit_title),
|
||||
value = identity.commit,
|
||||
accessibilityLabel =
|
||||
stringResource(
|
||||
R.string.about_build_commit_accessibility,
|
||||
aboutCommitAccessibilityValue(identity.fullCommit, unknownLabel),
|
||||
),
|
||||
forceLeftToRight = true,
|
||||
monospace = true,
|
||||
onClick = commitClick,
|
||||
onClickLabel = identity.fullCommit?.let { copyCommitLabel },
|
||||
),
|
||||
AboutBuildCell(
|
||||
title = stringResource(R.string.about_build_built_title),
|
||||
value = identity.built,
|
||||
accessibilityLabel = builtAccessibilityLabel,
|
||||
onClick = timestampClick,
|
||||
onClickLabel = identity.buildTimestamp?.let { copyTimestampLabel },
|
||||
),
|
||||
)
|
||||
|
||||
ClawPanel(contentPadding = PaddingValues(horizontal = 12.dp, vertical = 8.dp)) {
|
||||
BoxWithConstraints(modifier = Modifier.fillMaxWidth()) {
|
||||
val wraps = maxWidth < 260.dp || LocalDensity.current.fontScale >= 1.3f
|
||||
if (wraps) {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(4.dp)) {
|
||||
cells.forEach { cell ->
|
||||
AboutBuildIdentityCell(cell = cell, modifier = Modifier.fillMaxWidth())
|
||||
}
|
||||
}
|
||||
} else {
|
||||
Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(6.dp)) {
|
||||
cells.forEach { cell ->
|
||||
AboutBuildIdentityCell(cell = cell, modifier = Modifier.weight(1f))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun AboutBuildIdentityCell(
|
||||
cell: AboutBuildCell,
|
||||
modifier: Modifier,
|
||||
) {
|
||||
val clickModifier =
|
||||
cell.onClick?.let { action ->
|
||||
Modifier.clickable(onClickLabel = cell.onClickLabel, onClick = action)
|
||||
} ?: Modifier
|
||||
val accessibilityModifier =
|
||||
Modifier.clearAndSetSemantics {
|
||||
contentDescription = cell.accessibilityLabel
|
||||
cell.onClick?.let { action ->
|
||||
onClick(label = cell.onClickLabel) {
|
||||
action()
|
||||
true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Column(
|
||||
modifier =
|
||||
modifier
|
||||
.then(clickModifier)
|
||||
.then(accessibilityModifier)
|
||||
.heightIn(min = 54.dp)
|
||||
.padding(horizontal = 5.dp, vertical = 6.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.Center,
|
||||
) {
|
||||
Text(
|
||||
text = cell.title,
|
||||
style = ClawTheme.type.caption.copy(fontSize = 11.sp, lineHeight = 14.sp),
|
||||
color = ClawTheme.colors.textSubtle,
|
||||
textAlign = TextAlign.Center,
|
||||
)
|
||||
Text(
|
||||
text = cell.value,
|
||||
style =
|
||||
ClawTheme.type.caption.copy(
|
||||
fontFamily = if (cell.monospace) FontFamily.Monospace else ClawTheme.type.caption.fontFamily,
|
||||
fontSize = 12.5.sp,
|
||||
lineHeight = 17.sp,
|
||||
textDirection = if (cell.forceLeftToRight) TextDirection.Ltr else ClawTheme.type.caption.textDirection,
|
||||
),
|
||||
color = if (cell.onClick == null) ClawTheme.colors.text else ClawTheme.colors.primary,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
textAlign = TextAlign.Center,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun copyAboutBuildValue(
|
||||
context: Context,
|
||||
label: String,
|
||||
value: String,
|
||||
confirmation: String,
|
||||
) {
|
||||
val clipboard = context.getSystemService(ClipboardManager::class.java) ?: return
|
||||
clipboard.setPrimaryClip(ClipData.newPlainText(label, value))
|
||||
Toast.makeText(context, confirmation, Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
@@ -138,6 +138,7 @@ import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.platform.LocalConfiguration
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.platform.LocalUriHandler
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
@@ -1636,14 +1637,20 @@ private fun AboutSettingsScreen(
|
||||
val updateAvailable by viewModel.gatewayUpdateAvailable.collectAsState()
|
||||
val latestVersion = updateAvailable?.latestVersion?.takeIf { it.isNotBlank() }
|
||||
val currentGatewayVersion = updateAvailable?.currentVersion?.takeIf { it.isNotBlank() } ?: gatewayVersion
|
||||
val appLocale = LocalConfiguration.current.locales[0]
|
||||
|
||||
SettingsDetailFrame(title = "About", subtitle = "OpenClaw for Android.", icon = Icons.Default.Info, onBack = onBack) {
|
||||
AboutHeroPanel()
|
||||
AboutBuildIdentityPanel(
|
||||
versionName = BuildConfig.VERSION_NAME,
|
||||
versionCode = BuildConfig.VERSION_CODE,
|
||||
gitCommit = BuildConfig.GIT_COMMIT,
|
||||
buildTimestamp = BuildConfig.BUILD_TIMESTAMP,
|
||||
locale = appLocale,
|
||||
)
|
||||
SettingsMetricPanel(
|
||||
rows =
|
||||
listOf(
|
||||
SettingsMetric("Android App", BuildConfig.VERSION_NAME),
|
||||
SettingsMetric("Build", BuildConfig.VERSION_CODE.toString()),
|
||||
SettingsMetric("Channel", androidDistributionChannel()),
|
||||
SettingsMetric("Gateway", currentGatewayVersion ?: "Not connected"),
|
||||
),
|
||||
|
||||
@@ -6,4 +6,18 @@
|
||||
<string name="new_chat_in_worktree">دردشة جديدة في worktree</string>
|
||||
<string name="gateway_trust_first_seen">تحقق من بصمة الشهادة قبل الوثوق بهذه البوابة.\n\n%1$s</string>
|
||||
<string name="gateway_trust_changed">تم تغيير شهادة البوابة. تابع فقط إذا كنت تتوقع هذا التغيير.\n\nSHA-256 القديم:\n%1$s\n\nSHA-256 الجديد:\n%2$s</string>
|
||||
<string name="about_build_unknown">غير معروف</string>
|
||||
<string name="about_build_version_title">الإصدار</string>
|
||||
<string name="about_build_commit_title">الالتزام</string>
|
||||
<string name="about_build_built_title">تاريخ البناء</string>
|
||||
<string name="about_build_version_accessibility">الإصدار %1$s</string>
|
||||
<string name="about_build_commit_accessibility">التزام Git %1$s</string>
|
||||
<string name="about_build_built_accessibility">تم البناء في %1$s بتوقيت UTC، والطابع الزمني %2$s</string>
|
||||
<string name="about_build_date_accessibility">تاريخ البناء %1$s</string>
|
||||
<string name="about_build_copy_commit">نسخ تجزئة التزام Git الكاملة</string>
|
||||
<string name="about_build_copy_timestamp">نسخ الطابع الزمني الكامل للبناء</string>
|
||||
<string name="about_build_commit_clipboard_label">التزام Git لـ OpenClaw</string>
|
||||
<string name="about_build_timestamp_clipboard_label">الطابع الزمني لبناء OpenClaw</string>
|
||||
<string name="about_build_commit_copied">تم نسخ التزام Git</string>
|
||||
<string name="about_build_timestamp_copied">تم نسخ الطابع الزمني للبناء</string>
|
||||
</resources>
|
||||
|
||||
@@ -6,4 +6,18 @@
|
||||
<string name="new_chat_in_worktree">Neuer Chat im Worktree</string>
|
||||
<string name="gateway_trust_first_seen">Überprüfen Sie den Zertifikatfingerabdruck, bevor Sie diesem Gateway vertrauen.\n\n%1$s</string>
|
||||
<string name="gateway_trust_changed">Das Gateway-Zertifikat wurde geändert. Fahren Sie nur fort, wenn Sie diese Änderung erwartet haben.\n\nAlter SHA-256-Wert:\n%1$s\n\nNeuer SHA-256-Wert:\n%2$s</string>
|
||||
<string name="about_build_unknown">Unbekannt</string>
|
||||
<string name="about_build_version_title">VERSION</string>
|
||||
<string name="about_build_commit_title">COMMIT</string>
|
||||
<string name="about_build_built_title">ERSTELLT</string>
|
||||
<string name="about_build_version_accessibility">Version %1$s</string>
|
||||
<string name="about_build_commit_accessibility">Git-Commit %1$s</string>
|
||||
<string name="about_build_built_accessibility">Erstellt am %1$s UTC, Zeitstempel %2$s</string>
|
||||
<string name="about_build_date_accessibility">Build-Datum %1$s</string>
|
||||
<string name="about_build_copy_commit">Vollständigen Git-Commit-Hash kopieren</string>
|
||||
<string name="about_build_copy_timestamp">Vollständigen Build-Zeitstempel kopieren</string>
|
||||
<string name="about_build_commit_clipboard_label">Git-Commit von OpenClaw</string>
|
||||
<string name="about_build_timestamp_clipboard_label">Build-Zeitstempel von OpenClaw</string>
|
||||
<string name="about_build_commit_copied">Git-Commit kopiert</string>
|
||||
<string name="about_build_timestamp_copied">Build-Zeitstempel kopiert</string>
|
||||
</resources>
|
||||
|
||||
@@ -6,4 +6,18 @@
|
||||
<string name="new_chat_in_worktree">Nuevo chat en worktree</string>
|
||||
<string name="gateway_trust_first_seen">Verifica la huella digital del certificado antes de confiar en este gateway.\n\n%1$s</string>
|
||||
<string name="gateway_trust_changed">El certificado del gateway cambió. Continúa solo si esperabas este cambio.\n\nSHA-256 anterior:\n%1$s\n\nSHA-256 nuevo:\n%2$s</string>
|
||||
<string name="about_build_unknown">Desconocido</string>
|
||||
<string name="about_build_version_title">VERSIÓN</string>
|
||||
<string name="about_build_commit_title">COMMIT</string>
|
||||
<string name="about_build_built_title">COMPILADO</string>
|
||||
<string name="about_build_version_accessibility">Versión %1$s</string>
|
||||
<string name="about_build_commit_accessibility">Commit de Git %1$s</string>
|
||||
<string name="about_build_built_accessibility">Compilado el %1$s UTC, marca de tiempo %2$s</string>
|
||||
<string name="about_build_date_accessibility">Fecha de compilación %1$s</string>
|
||||
<string name="about_build_copy_commit">Copiar el hash completo del commit de Git</string>
|
||||
<string name="about_build_copy_timestamp">Copiar la marca de tiempo completa de la compilación</string>
|
||||
<string name="about_build_commit_clipboard_label">Commit de Git de OpenClaw</string>
|
||||
<string name="about_build_timestamp_clipboard_label">Marca de tiempo de compilación de OpenClaw</string>
|
||||
<string name="about_build_commit_copied">Commit de Git copiado</string>
|
||||
<string name="about_build_timestamp_copied">Marca de tiempo de compilación copiada</string>
|
||||
</resources>
|
||||
|
||||
@@ -6,4 +6,18 @@
|
||||
<string name="new_chat_in_worktree">چت جدید در worktree</string>
|
||||
<string name="gateway_trust_first_seen">پیش از اعتماد به این دروازه، اثر انگشت گواهی را تأیید کنید.\n\n%1$s</string>
|
||||
<string name="gateway_trust_changed">گواهی دروازه تغییر کرده است. فقط در صورتی ادامه دهید که انتظار این تغییر را داشتید.\n\nSHA-256 قدیمی:\n%1$s\n\nSHA-256 جدید:\n%2$s</string>
|
||||
<string name="about_build_unknown">نامشخص</string>
|
||||
<string name="about_build_version_title">نسخه</string>
|
||||
<string name="about_build_commit_title">کامیت</string>
|
||||
<string name="about_build_built_title">ساختهشده</string>
|
||||
<string name="about_build_version_accessibility">نسخهٔ %1$s</string>
|
||||
<string name="about_build_commit_accessibility">کامیت Git %1$s</string>
|
||||
<string name="about_build_built_accessibility">ساختهشده در %1$s به وقت UTC، برچسب زمانی %2$s</string>
|
||||
<string name="about_build_date_accessibility">تاریخ ساخت %1$s</string>
|
||||
<string name="about_build_copy_commit">کپی هش کامل کامیت Git</string>
|
||||
<string name="about_build_copy_timestamp">کپی برچسب زمانی کامل ساخت</string>
|
||||
<string name="about_build_commit_clipboard_label">کامیت Git مربوط به OpenClaw</string>
|
||||
<string name="about_build_timestamp_clipboard_label">برچسب زمانی ساخت OpenClaw</string>
|
||||
<string name="about_build_commit_copied">کامیت Git کپی شد</string>
|
||||
<string name="about_build_timestamp_copied">برچسب زمانی ساخت کپی شد</string>
|
||||
</resources>
|
||||
|
||||
@@ -6,4 +6,18 @@
|
||||
<string name="new_chat_in_worktree">Nouveau chat dans le worktree</string>
|
||||
<string name="gateway_trust_first_seen">Vérifiez l’empreinte du certificat avant d’accorder votre confiance à cette passerelle.\n\n%1$s</string>
|
||||
<string name="gateway_trust_changed">Le certificat de la passerelle a changé. Continuez uniquement si vous vous attendiez à ce changement.\n\nAncien SHA-256 :\n%1$s\n\nNouveau SHA-256 :\n%2$s</string>
|
||||
<string name="about_build_unknown">Inconnu</string>
|
||||
<string name="about_build_version_title">VERSION</string>
|
||||
<string name="about_build_commit_title">COMMIT</string>
|
||||
<string name="about_build_built_title">COMPILÉ</string>
|
||||
<string name="about_build_version_accessibility">Version %1$s</string>
|
||||
<string name="about_build_commit_accessibility">Commit Git %1$s</string>
|
||||
<string name="about_build_built_accessibility">Compilé le %1$s UTC, horodatage %2$s</string>
|
||||
<string name="about_build_date_accessibility">Date de compilation %1$s</string>
|
||||
<string name="about_build_copy_commit">Copier le hash complet du commit Git</string>
|
||||
<string name="about_build_copy_timestamp">Copier l’horodatage complet de compilation</string>
|
||||
<string name="about_build_commit_clipboard_label">Commit Git d’OpenClaw</string>
|
||||
<string name="about_build_timestamp_clipboard_label">Horodatage de compilation d’OpenClaw</string>
|
||||
<string name="about_build_commit_copied">Commit Git copié</string>
|
||||
<string name="about_build_timestamp_copied">Horodatage de compilation copié</string>
|
||||
</resources>
|
||||
|
||||
@@ -6,4 +6,18 @@
|
||||
<string name="new_chat_in_worktree">worktree में नया चैट</string>
|
||||
<string name="gateway_trust_first_seen">इस गेटवे पर भरोसा करने से पहले प्रमाणपत्र फ़िंगरप्रिंट सत्यापित करें।\n\n%1$s</string>
|
||||
<string name="gateway_trust_changed">गेटवे प्रमाणपत्र बदल गया है। केवल तभी जारी रखें जब आपको इस बदलाव की अपेक्षा थी।\n\nपुराना SHA-256:\n%1$s\n\nनया SHA-256:\n%2$s</string>
|
||||
<string name="about_build_unknown">अज्ञात</string>
|
||||
<string name="about_build_version_title">संस्करण</string>
|
||||
<string name="about_build_commit_title">कमिट</string>
|
||||
<string name="about_build_built_title">बिल्ड</string>
|
||||
<string name="about_build_version_accessibility">संस्करण %1$s</string>
|
||||
<string name="about_build_commit_accessibility">Git कमिट %1$s</string>
|
||||
<string name="about_build_built_accessibility">%1$s UTC को बिल्ड किया गया, टाइमस्टैम्प %2$s</string>
|
||||
<string name="about_build_date_accessibility">बिल्ड की तारीख %1$s</string>
|
||||
<string name="about_build_copy_commit">पूरा Git कमिट हैश कॉपी करें</string>
|
||||
<string name="about_build_copy_timestamp">पूरा बिल्ड टाइमस्टैम्प कॉपी करें</string>
|
||||
<string name="about_build_commit_clipboard_label">OpenClaw Git कमिट</string>
|
||||
<string name="about_build_timestamp_clipboard_label">OpenClaw बिल्ड टाइमस्टैम्प</string>
|
||||
<string name="about_build_commit_copied">Git कमिट कॉपी किया गया</string>
|
||||
<string name="about_build_timestamp_copied">बिल्ड टाइमस्टैम्प कॉपी किया गया</string>
|
||||
</resources>
|
||||
|
||||
@@ -6,4 +6,18 @@
|
||||
<string name="new_chat_in_worktree">Chat baru di worktree</string>
|
||||
<string name="gateway_trust_first_seen">Verifikasi sidik jari sertifikat sebelum memercayai gateway ini.\n\n%1$s</string>
|
||||
<string name="gateway_trust_changed">Sertifikat gateway berubah. Lanjutkan hanya jika Anda mengharapkan perubahan ini.\n\nSHA-256 lama:\n%1$s\n\nSHA-256 baru:\n%2$s</string>
|
||||
<string name="about_build_unknown">Tidak diketahui</string>
|
||||
<string name="about_build_version_title">VERSI</string>
|
||||
<string name="about_build_commit_title">COMMIT</string>
|
||||
<string name="about_build_built_title">DIBUAT</string>
|
||||
<string name="about_build_version_accessibility">Versi %1$s</string>
|
||||
<string name="about_build_commit_accessibility">Commit Git %1$s</string>
|
||||
<string name="about_build_built_accessibility">Dibuat pada %1$s UTC, stempel waktu %2$s</string>
|
||||
<string name="about_build_date_accessibility">Tanggal build %1$s</string>
|
||||
<string name="about_build_copy_commit">Salin hash lengkap commit Git</string>
|
||||
<string name="about_build_copy_timestamp">Salin stempel waktu build lengkap</string>
|
||||
<string name="about_build_commit_clipboard_label">Commit Git OpenClaw</string>
|
||||
<string name="about_build_timestamp_clipboard_label">Stempel waktu build OpenClaw</string>
|
||||
<string name="about_build_commit_copied">Commit Git disalin</string>
|
||||
<string name="about_build_timestamp_copied">Stempel waktu build disalin</string>
|
||||
</resources>
|
||||
|
||||
@@ -6,4 +6,18 @@
|
||||
<string name="new_chat_in_worktree">Nuova chat nel worktree</string>
|
||||
<string name="gateway_trust_first_seen">Verifica l’impronta digitale del certificato prima di considerare attendibile questo gateway.\n\n%1$s</string>
|
||||
<string name="gateway_trust_changed">Il certificato del gateway è cambiato. Continua solo se ti aspettavi questa modifica.\n\nSHA-256 precedente:\n%1$s\n\nNuovo SHA-256:\n%2$s</string>
|
||||
<string name="about_build_unknown">Sconosciuto</string>
|
||||
<string name="about_build_version_title">VERSIONE</string>
|
||||
<string name="about_build_commit_title">COMMIT</string>
|
||||
<string name="about_build_built_title">COMPILATO</string>
|
||||
<string name="about_build_version_accessibility">Versione %1$s</string>
|
||||
<string name="about_build_commit_accessibility">Commit Git %1$s</string>
|
||||
<string name="about_build_built_accessibility">Compilato il %1$s UTC, timestamp %2$s</string>
|
||||
<string name="about_build_date_accessibility">Data di compilazione %1$s</string>
|
||||
<string name="about_build_copy_commit">Copia l’hash completo del commit Git</string>
|
||||
<string name="about_build_copy_timestamp">Copia il timestamp completo della compilazione</string>
|
||||
<string name="about_build_commit_clipboard_label">Commit Git di OpenClaw</string>
|
||||
<string name="about_build_timestamp_clipboard_label">Timestamp di compilazione di OpenClaw</string>
|
||||
<string name="about_build_commit_copied">Commit Git copiato</string>
|
||||
<string name="about_build_timestamp_copied">Timestamp di compilazione copiato</string>
|
||||
</resources>
|
||||
|
||||
@@ -6,4 +6,18 @@
|
||||
<string name="new_chat_in_worktree">worktree で新規チャット</string>
|
||||
<string name="gateway_trust_first_seen">このゲートウェイを信頼する前に、証明書のフィンガープリントを確認してください。\n\n%1$s</string>
|
||||
<string name="gateway_trust_changed">ゲートウェイの証明書が変更されました。想定した変更である場合のみ続行してください。\n\n以前の SHA-256:\n%1$s\n\n新しい SHA-256:\n%2$s</string>
|
||||
<string name="about_build_unknown">不明</string>
|
||||
<string name="about_build_version_title">バージョン</string>
|
||||
<string name="about_build_commit_title">コミット</string>
|
||||
<string name="about_build_built_title">ビルド日</string>
|
||||
<string name="about_build_version_accessibility">バージョン %1$s</string>
|
||||
<string name="about_build_commit_accessibility">Git コミット %1$s</string>
|
||||
<string name="about_build_built_accessibility">ビルド日 %1$s UTC、タイムスタンプ %2$s</string>
|
||||
<string name="about_build_date_accessibility">ビルド日 %1$s</string>
|
||||
<string name="about_build_copy_commit">Git コミットの完全なハッシュをコピー</string>
|
||||
<string name="about_build_copy_timestamp">完全なビルドタイムスタンプをコピー</string>
|
||||
<string name="about_build_commit_clipboard_label">OpenClaw の Git コミット</string>
|
||||
<string name="about_build_timestamp_clipboard_label">OpenClaw のビルドタイムスタンプ</string>
|
||||
<string name="about_build_commit_copied">Git コミットをコピーしました</string>
|
||||
<string name="about_build_timestamp_copied">ビルドタイムスタンプをコピーしました</string>
|
||||
</resources>
|
||||
|
||||
@@ -6,4 +6,18 @@
|
||||
<string name="new_chat_in_worktree">worktree에서 새 채팅</string>
|
||||
<string name="gateway_trust_first_seen">이 게이트웨이를 신뢰하기 전에 인증서 지문을 확인하세요.\n\n%1$s</string>
|
||||
<string name="gateway_trust_changed">게이트웨이 인증서가 변경되었습니다. 예상한 변경인 경우에만 계속하세요.\n\n이전 SHA-256:\n%1$s\n\n새 SHA-256:\n%2$s</string>
|
||||
<string name="about_build_unknown">알 수 없음</string>
|
||||
<string name="about_build_version_title">버전</string>
|
||||
<string name="about_build_commit_title">커밋</string>
|
||||
<string name="about_build_built_title">빌드 날짜</string>
|
||||
<string name="about_build_version_accessibility">버전 %1$s</string>
|
||||
<string name="about_build_commit_accessibility">Git 커밋 %1$s</string>
|
||||
<string name="about_build_built_accessibility">빌드 날짜 %1$s UTC, 타임스탬프 %2$s</string>
|
||||
<string name="about_build_date_accessibility">빌드 날짜 %1$s</string>
|
||||
<string name="about_build_copy_commit">Git 커밋 전체 해시 복사</string>
|
||||
<string name="about_build_copy_timestamp">전체 빌드 타임스탬프 복사</string>
|
||||
<string name="about_build_commit_clipboard_label">OpenClaw Git 커밋</string>
|
||||
<string name="about_build_timestamp_clipboard_label">OpenClaw 빌드 타임스탬프</string>
|
||||
<string name="about_build_commit_copied">Git 커밋을 복사했습니다</string>
|
||||
<string name="about_build_timestamp_copied">빌드 타임스탬프를 복사했습니다</string>
|
||||
</resources>
|
||||
|
||||
@@ -6,4 +6,18 @@
|
||||
<string name="new_chat_in_worktree">Nieuwe chat in worktree</string>
|
||||
<string name="gateway_trust_first_seen">Controleer de certificaatvingerafdruk voordat je deze gateway vertrouwt.\n\n%1$s</string>
|
||||
<string name="gateway_trust_changed">Het gatewaycertificaat is gewijzigd. Ga alleen door als je deze wijziging verwachtte.\n\nOude SHA-256:\n%1$s\n\nNieuwe SHA-256:\n%2$s</string>
|
||||
<string name="about_build_unknown">Onbekend</string>
|
||||
<string name="about_build_version_title">VERSIE</string>
|
||||
<string name="about_build_commit_title">COMMIT</string>
|
||||
<string name="about_build_built_title">GEBOUWD</string>
|
||||
<string name="about_build_version_accessibility">Versie %1$s</string>
|
||||
<string name="about_build_commit_accessibility">Git-commit %1$s</string>
|
||||
<string name="about_build_built_accessibility">Gebouwd op %1$s UTC, tijdstempel %2$s</string>
|
||||
<string name="about_build_date_accessibility">Builddatum %1$s</string>
|
||||
<string name="about_build_copy_commit">Volledige Git-commithash kopiëren</string>
|
||||
<string name="about_build_copy_timestamp">Volledige buildtijdstempel kopiëren</string>
|
||||
<string name="about_build_commit_clipboard_label">Git-commit van OpenClaw</string>
|
||||
<string name="about_build_timestamp_clipboard_label">Buildtijdstempel van OpenClaw</string>
|
||||
<string name="about_build_commit_copied">Git-commit gekopieerd</string>
|
||||
<string name="about_build_timestamp_copied">Buildtijdstempel gekopieerd</string>
|
||||
</resources>
|
||||
|
||||
@@ -6,4 +6,18 @@
|
||||
<string name="new_chat_in_worktree">Nowy czat w worktree</string>
|
||||
<string name="gateway_trust_first_seen">Sprawdź odcisk certyfikatu, zanim zaufasz tej bramie.\n\n%1$s</string>
|
||||
<string name="gateway_trust_changed">Certyfikat bramy uległ zmianie. Kontynuuj tylko wtedy, gdy oczekujesz tej zmiany.\n\nStary SHA-256:\n%1$s\n\nNowy SHA-256:\n%2$s</string>
|
||||
<string name="about_build_unknown">Nieznane</string>
|
||||
<string name="about_build_version_title">WERSJA</string>
|
||||
<string name="about_build_commit_title">COMMIT</string>
|
||||
<string name="about_build_built_title">ZBUDOWANO</string>
|
||||
<string name="about_build_version_accessibility">Wersja %1$s</string>
|
||||
<string name="about_build_commit_accessibility">Commit Git %1$s</string>
|
||||
<string name="about_build_built_accessibility">Zbudowano %1$s UTC, znacznik czasu %2$s</string>
|
||||
<string name="about_build_date_accessibility">Data kompilacji %1$s</string>
|
||||
<string name="about_build_copy_commit">Skopiuj pełny hash commita Git</string>
|
||||
<string name="about_build_copy_timestamp">Skopiuj pełny znacznik czasu kompilacji</string>
|
||||
<string name="about_build_commit_clipboard_label">Commit Git OpenClaw</string>
|
||||
<string name="about_build_timestamp_clipboard_label">Znacznik czasu kompilacji OpenClaw</string>
|
||||
<string name="about_build_commit_copied">Skopiowano commit Git</string>
|
||||
<string name="about_build_timestamp_copied">Skopiowano znacznik czasu kompilacji</string>
|
||||
</resources>
|
||||
|
||||
@@ -6,4 +6,18 @@
|
||||
<string name="new_chat_in_worktree">Novo chat no worktree</string>
|
||||
<string name="gateway_trust_first_seen">Verifique a impressão digital do certificado antes de confiar neste gateway.\n\n%1$s</string>
|
||||
<string name="gateway_trust_changed">O certificado do gateway foi alterado. Continue somente se você esperava essa alteração.\n\nSHA-256 antigo:\n%1$s\n\nSHA-256 novo:\n%2$s</string>
|
||||
<string name="about_build_unknown">Desconhecido</string>
|
||||
<string name="about_build_version_title">VERSÃO</string>
|
||||
<string name="about_build_commit_title">COMMIT</string>
|
||||
<string name="about_build_built_title">COMPILADO</string>
|
||||
<string name="about_build_version_accessibility">Versão %1$s</string>
|
||||
<string name="about_build_commit_accessibility">Commit do Git %1$s</string>
|
||||
<string name="about_build_built_accessibility">Compilado em %1$s UTC, carimbo de data/hora %2$s</string>
|
||||
<string name="about_build_date_accessibility">Data da compilação %1$s</string>
|
||||
<string name="about_build_copy_commit">Copiar o hash completo do commit do Git</string>
|
||||
<string name="about_build_copy_timestamp">Copiar o carimbo de data/hora completo da compilação</string>
|
||||
<string name="about_build_commit_clipboard_label">Commit do Git do OpenClaw</string>
|
||||
<string name="about_build_timestamp_clipboard_label">Carimbo de data/hora da compilação do OpenClaw</string>
|
||||
<string name="about_build_commit_copied">Commit do Git copiado</string>
|
||||
<string name="about_build_timestamp_copied">Carimbo de data/hora da compilação copiado</string>
|
||||
</resources>
|
||||
|
||||
@@ -6,4 +6,18 @@
|
||||
<string name="new_chat_in_worktree">Новый чат в worktree</string>
|
||||
<string name="gateway_trust_first_seen">Проверьте отпечаток сертификата, прежде чем доверять этому шлюзу.\n\n%1$s</string>
|
||||
<string name="gateway_trust_changed">Сертификат шлюза изменился. Продолжайте, только если вы ожидали это изменение.\n\nСтарый SHA-256:\n%1$s\n\nНовый SHA-256:\n%2$s</string>
|
||||
<string name="about_build_unknown">Неизвестно</string>
|
||||
<string name="about_build_version_title">ВЕРСИЯ</string>
|
||||
<string name="about_build_commit_title">КОММИТ</string>
|
||||
<string name="about_build_built_title">СОБРАНО</string>
|
||||
<string name="about_build_version_accessibility">Версия %1$s</string>
|
||||
<string name="about_build_commit_accessibility">Git-коммит %1$s</string>
|
||||
<string name="about_build_built_accessibility">Собрано %1$s UTC, метка времени %2$s</string>
|
||||
<string name="about_build_date_accessibility">Дата сборки %1$s</string>
|
||||
<string name="about_build_copy_commit">Скопировать полный хеш Git-коммита</string>
|
||||
<string name="about_build_copy_timestamp">Скопировать полную метку времени сборки</string>
|
||||
<string name="about_build_commit_clipboard_label">Git-коммит OpenClaw</string>
|
||||
<string name="about_build_timestamp_clipboard_label">Метка времени сборки OpenClaw</string>
|
||||
<string name="about_build_commit_copied">Git-коммит скопирован</string>
|
||||
<string name="about_build_timestamp_copied">Метка времени сборки скопирована</string>
|
||||
</resources>
|
||||
|
||||
@@ -6,4 +6,18 @@
|
||||
<string name="new_chat_in_worktree">Ny chatt i worktree</string>
|
||||
<string name="gateway_trust_first_seen">Verifiera certifikatets fingeravtryck innan du litar på denna gateway.\n\n%1$s</string>
|
||||
<string name="gateway_trust_changed">Gateway-certifikatet har ändrats. Fortsätt bara om du förväntade dig denna ändring.\n\nTidigare SHA-256:\n%1$s\n\nNy SHA-256:\n%2$s</string>
|
||||
<string name="about_build_unknown">Okänt</string>
|
||||
<string name="about_build_version_title">VERSION</string>
|
||||
<string name="about_build_commit_title">COMMIT</string>
|
||||
<string name="about_build_built_title">BYGGD</string>
|
||||
<string name="about_build_version_accessibility">Version %1$s</string>
|
||||
<string name="about_build_commit_accessibility">Git-commit %1$s</string>
|
||||
<string name="about_build_built_accessibility">Byggd den %1$s UTC, tidsstämpel %2$s</string>
|
||||
<string name="about_build_date_accessibility">Byggdatum %1$s</string>
|
||||
<string name="about_build_copy_commit">Kopiera fullständig Git-commit-hash</string>
|
||||
<string name="about_build_copy_timestamp">Kopiera hela tidsstämpeln för bygget</string>
|
||||
<string name="about_build_commit_clipboard_label">Git-commit för OpenClaw</string>
|
||||
<string name="about_build_timestamp_clipboard_label">Byggtidsstämpel för OpenClaw</string>
|
||||
<string name="about_build_commit_copied">Git-commit kopierad</string>
|
||||
<string name="about_build_timestamp_copied">Byggtidsstämpel kopierad</string>
|
||||
</resources>
|
||||
|
||||
@@ -6,4 +6,18 @@
|
||||
<string name="new_chat_in_worktree">แชทใหม่ใน worktree</string>
|
||||
<string name="gateway_trust_first_seen">ตรวจสอบลายนิ้วมือของใบรับรองก่อนเชื่อถือเกตเวย์นี้\n\n%1$s</string>
|
||||
<string name="gateway_trust_changed">ใบรับรองของเกตเวย์มีการเปลี่ยนแปลง ดำเนินการต่อเมื่อคุณคาดว่าจะมีการเปลี่ยนแปลงนี้เท่านั้น\n\nSHA-256 เดิม:\n%1$s\n\nSHA-256 ใหม่:\n%2$s</string>
|
||||
<string name="about_build_unknown">ไม่ทราบ</string>
|
||||
<string name="about_build_version_title">เวอร์ชัน</string>
|
||||
<string name="about_build_commit_title">คอมมิต</string>
|
||||
<string name="about_build_built_title">สร้างเมื่อ</string>
|
||||
<string name="about_build_version_accessibility">เวอร์ชัน %1$s</string>
|
||||
<string name="about_build_commit_accessibility">คอมมิต Git %1$s</string>
|
||||
<string name="about_build_built_accessibility">สร้างเมื่อ %1$s UTC, การประทับเวลา %2$s</string>
|
||||
<string name="about_build_date_accessibility">วันที่สร้าง %1$s</string>
|
||||
<string name="about_build_copy_commit">คัดลอกแฮชแบบเต็มของคอมมิต Git</string>
|
||||
<string name="about_build_copy_timestamp">คัดลอกการประทับเวลาแบบเต็มของบิลด์</string>
|
||||
<string name="about_build_commit_clipboard_label">คอมมิต Git ของ OpenClaw</string>
|
||||
<string name="about_build_timestamp_clipboard_label">การประทับเวลาของบิลด์ OpenClaw</string>
|
||||
<string name="about_build_commit_copied">คัดลอกคอมมิต Git แล้ว</string>
|
||||
<string name="about_build_timestamp_copied">คัดลอกการประทับเวลาของบิลด์แล้ว</string>
|
||||
</resources>
|
||||
|
||||
@@ -6,4 +6,18 @@
|
||||
<string name="new_chat_in_worktree">Worktree\'de yeni sohbet</string>
|
||||
<string name="gateway_trust_first_seen">Bu ağ geçidine güvenmeden önce sertifika parmak izini doğrulayın.\n\n%1$s</string>
|
||||
<string name="gateway_trust_changed">Ağ geçidi sertifikası değişti. Yalnızca bu değişikliği bekliyorsanız devam edin.\n\nEski SHA-256:\n%1$s\n\nYeni SHA-256:\n%2$s</string>
|
||||
<string name="about_build_unknown">Bilinmiyor</string>
|
||||
<string name="about_build_version_title">SÜRÜM</string>
|
||||
<string name="about_build_commit_title">COMMIT</string>
|
||||
<string name="about_build_built_title">DERLEME</string>
|
||||
<string name="about_build_version_accessibility">Sürüm %1$s</string>
|
||||
<string name="about_build_commit_accessibility">Git commit\'i %1$s</string>
|
||||
<string name="about_build_built_accessibility">%1$s UTC tarihinde derlendi, zaman damgası %2$s</string>
|
||||
<string name="about_build_date_accessibility">Derleme tarihi %1$s</string>
|
||||
<string name="about_build_copy_commit">Git commit\'inin tam hash değerini kopyala</string>
|
||||
<string name="about_build_copy_timestamp">Tam derleme zaman damgasını kopyala</string>
|
||||
<string name="about_build_commit_clipboard_label">OpenClaw Git commit\'i</string>
|
||||
<string name="about_build_timestamp_clipboard_label">OpenClaw derleme zaman damgası</string>
|
||||
<string name="about_build_commit_copied">Git commit\'i kopyalandı</string>
|
||||
<string name="about_build_timestamp_copied">Derleme zaman damgası kopyalandı</string>
|
||||
</resources>
|
||||
|
||||
@@ -6,4 +6,18 @@
|
||||
<string name="new_chat_in_worktree">Новий чат у worktree</string>
|
||||
<string name="gateway_trust_first_seen">Перевірте відбиток сертифіката, перш ніж довіряти цьому шлюзу.\n\n%1$s</string>
|
||||
<string name="gateway_trust_changed">Сертифікат шлюзу змінився. Продовжуйте, лише якщо ви очікували цю зміну.\n\nСтарий SHA-256:\n%1$s\n\nНовий SHA-256:\n%2$s</string>
|
||||
<string name="about_build_unknown">Невідомо</string>
|
||||
<string name="about_build_version_title">ВЕРСІЯ</string>
|
||||
<string name="about_build_commit_title">КОМІТ</string>
|
||||
<string name="about_build_built_title">ЗІБРАНО</string>
|
||||
<string name="about_build_version_accessibility">Версія %1$s</string>
|
||||
<string name="about_build_commit_accessibility">Git-коміт %1$s</string>
|
||||
<string name="about_build_built_accessibility">Зібрано %1$s UTC, позначка часу %2$s</string>
|
||||
<string name="about_build_date_accessibility">Дата збірки %1$s</string>
|
||||
<string name="about_build_copy_commit">Скопіювати повний хеш Git-коміту</string>
|
||||
<string name="about_build_copy_timestamp">Скопіювати повну позначку часу збірки</string>
|
||||
<string name="about_build_commit_clipboard_label">Git-коміт OpenClaw</string>
|
||||
<string name="about_build_timestamp_clipboard_label">Позначка часу збірки OpenClaw</string>
|
||||
<string name="about_build_commit_copied">Git-коміт скопійовано</string>
|
||||
<string name="about_build_timestamp_copied">Позначку часу збірки скопійовано</string>
|
||||
</resources>
|
||||
|
||||
@@ -6,4 +6,18 @@
|
||||
<string name="new_chat_in_worktree">Trò chuyện mới trong worktree</string>
|
||||
<string name="gateway_trust_first_seen">Xác minh dấu vân tay chứng chỉ trước khi tin cậy cổng này.\n\n%1$s</string>
|
||||
<string name="gateway_trust_changed">Chứng chỉ cổng đã thay đổi. Chỉ tiếp tục nếu bạn mong đợi thay đổi này.\n\nSHA-256 cũ:\n%1$s\n\nSHA-256 mới:\n%2$s</string>
|
||||
<string name="about_build_unknown">Không xác định</string>
|
||||
<string name="about_build_version_title">PHIÊN BẢN</string>
|
||||
<string name="about_build_commit_title">COMMIT</string>
|
||||
<string name="about_build_built_title">NGÀY TẠO</string>
|
||||
<string name="about_build_version_accessibility">Phiên bản %1$s</string>
|
||||
<string name="about_build_commit_accessibility">Commit Git %1$s</string>
|
||||
<string name="about_build_built_accessibility">Bản dựng được tạo lúc %1$s UTC, dấu thời gian %2$s</string>
|
||||
<string name="about_build_date_accessibility">Ngày tạo bản dựng %1$s</string>
|
||||
<string name="about_build_copy_commit">Sao chép toàn bộ mã băm commit Git</string>
|
||||
<string name="about_build_copy_timestamp">Sao chép toàn bộ dấu thời gian bản dựng</string>
|
||||
<string name="about_build_commit_clipboard_label">Commit Git của OpenClaw</string>
|
||||
<string name="about_build_timestamp_clipboard_label">Dấu thời gian bản dựng của OpenClaw</string>
|
||||
<string name="about_build_commit_copied">Đã sao chép commit Git</string>
|
||||
<string name="about_build_timestamp_copied">Đã sao chép dấu thời gian bản dựng</string>
|
||||
</resources>
|
||||
|
||||
@@ -6,4 +6,18 @@
|
||||
<string name="new_chat_in_worktree">在 worktree 中新建聊天</string>
|
||||
<string name="gateway_trust_first_seen">验证证书指纹后再信任此网关。\n\n%1$s</string>
|
||||
<string name="gateway_trust_changed">网关证书已更改。仅当这是您预期的更改时才继续。\n\n旧 SHA-256:\n%1$s\n\n新 SHA-256:\n%2$s</string>
|
||||
<string name="about_build_unknown">未知</string>
|
||||
<string name="about_build_version_title">版本</string>
|
||||
<string name="about_build_commit_title">提交</string>
|
||||
<string name="about_build_built_title">构建日期</string>
|
||||
<string name="about_build_version_accessibility">版本 %1$s</string>
|
||||
<string name="about_build_commit_accessibility">Git 提交 %1$s</string>
|
||||
<string name="about_build_built_accessibility">构建于 %1$s UTC,时间戳 %2$s</string>
|
||||
<string name="about_build_date_accessibility">构建日期 %1$s</string>
|
||||
<string name="about_build_copy_commit">复制完整 Git 提交哈希值</string>
|
||||
<string name="about_build_copy_timestamp">复制完整构建时间戳</string>
|
||||
<string name="about_build_commit_clipboard_label">OpenClaw Git 提交</string>
|
||||
<string name="about_build_timestamp_clipboard_label">OpenClaw 构建时间戳</string>
|
||||
<string name="about_build_commit_copied">已复制 Git 提交</string>
|
||||
<string name="about_build_timestamp_copied">已复制构建时间戳</string>
|
||||
</resources>
|
||||
|
||||
@@ -6,4 +6,18 @@
|
||||
<string name="new_chat_in_worktree">在 worktree 中新增聊天</string>
|
||||
<string name="gateway_trust_first_seen">請先驗證憑證指紋,再信任此閘道。\n\n%1$s</string>
|
||||
<string name="gateway_trust_changed">閘道憑證已變更。僅在這是您預期的變更時繼續。\n\n舊 SHA-256:\n%1$s\n\n新 SHA-256:\n%2$s</string>
|
||||
<string name="about_build_unknown">未知</string>
|
||||
<string name="about_build_version_title">版本</string>
|
||||
<string name="about_build_commit_title">提交</string>
|
||||
<string name="about_build_built_title">建置日期</string>
|
||||
<string name="about_build_version_accessibility">版本 %1$s</string>
|
||||
<string name="about_build_commit_accessibility">Git 提交 %1$s</string>
|
||||
<string name="about_build_built_accessibility">建置於 %1$s UTC,時間戳記 %2$s</string>
|
||||
<string name="about_build_date_accessibility">建置日期 %1$s</string>
|
||||
<string name="about_build_copy_commit">複製完整 Git 提交雜湊值</string>
|
||||
<string name="about_build_copy_timestamp">複製完整建置時間戳記</string>
|
||||
<string name="about_build_commit_clipboard_label">OpenClaw Git 提交</string>
|
||||
<string name="about_build_timestamp_clipboard_label">OpenClaw 建置時間戳記</string>
|
||||
<string name="about_build_commit_copied">已複製 Git 提交</string>
|
||||
<string name="about_build_timestamp_copied">已複製建置時間戳記</string>
|
||||
</resources>
|
||||
|
||||
@@ -6,4 +6,18 @@
|
||||
<string name="new_chat_in_worktree">New chat in worktree</string>
|
||||
<string name="gateway_trust_first_seen">Verify the certificate fingerprint before trusting this gateway.\n\n%1$s</string>
|
||||
<string name="gateway_trust_changed">The gateway certificate changed. Continue only if you expected this.\n\nOld SHA-256:\n%1$s\n\nNew SHA-256:\n%2$s</string>
|
||||
<string name="about_build_unknown">Unknown</string>
|
||||
<string name="about_build_version_title">VERSION</string>
|
||||
<string name="about_build_commit_title">COMMIT</string>
|
||||
<string name="about_build_built_title">BUILT</string>
|
||||
<string name="about_build_version_accessibility">Version %1$s</string>
|
||||
<string name="about_build_commit_accessibility">Git commit %1$s</string>
|
||||
<string name="about_build_built_accessibility">Built %1$s UTC, timestamp %2$s</string>
|
||||
<string name="about_build_date_accessibility">Build date %1$s</string>
|
||||
<string name="about_build_copy_commit">Copy full Git commit hash</string>
|
||||
<string name="about_build_copy_timestamp">Copy full build timestamp</string>
|
||||
<string name="about_build_commit_clipboard_label">OpenClaw Git commit</string>
|
||||
<string name="about_build_timestamp_clipboard_label">OpenClaw build timestamp</string>
|
||||
<string name="about_build_commit_copied">Git commit copied</string>
|
||||
<string name="about_build_timestamp_copied">Build timestamp copied</string>
|
||||
</resources>
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
package ai.openclaw.app
|
||||
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
import java.time.Instant
|
||||
|
||||
class BuildMetadataTest {
|
||||
@Test
|
||||
fun debugBuildConfigContainsRepositoryCommitAndUtcBuildTimestamp() {
|
||||
assertTrue(Regex("^[a-f0-9]{40}$").matches(BuildConfig.GIT_COMMIT))
|
||||
assertTrue(
|
||||
Regex("^\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:\\d{2}\\.\\d{3}Z$")
|
||||
.matches(BuildConfig.BUILD_TIMESTAMP),
|
||||
)
|
||||
Instant.parse(BuildConfig.BUILD_TIMESTAMP)
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import ai.openclaw.app.GatewayNodeCapabilityApproval
|
||||
import ai.openclaw.app.LocationMode
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Test
|
||||
import java.util.Locale
|
||||
|
||||
class SettingsScreensTest {
|
||||
@Test
|
||||
@@ -24,6 +25,55 @@ class SettingsScreensTest {
|
||||
assertEquals("Unknown", androidDistributionChannel(""))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun aboutBuildIdentityFormatsVersionShortCommitAndUtcDate() {
|
||||
val identity =
|
||||
aboutBuildIdentity(
|
||||
versionName = "2026.7.1",
|
||||
versionCode = 2026070102,
|
||||
gitCommit = "ABCDEF0123456789ABCDEF0123456789ABCDEF01",
|
||||
buildTimestamp = "2026-07-10T00:30:00.000Z",
|
||||
locale = Locale.US,
|
||||
unknownLabel = "Unknown",
|
||||
)
|
||||
|
||||
assertEquals("2026.7.1 (2026070102)", identity.version)
|
||||
assertEquals("abcdef012345", identity.commit)
|
||||
assertEquals("abcdef0123456789abcdef0123456789abcdef01", identity.fullCommit)
|
||||
assertEquals("Jul 10, 2026", identity.built)
|
||||
assertEquals("2026-07-10T00:30:00.000Z", identity.buildTimestamp)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun aboutBuildIdentityKeepsUnknownFallbacksVisible() {
|
||||
val identity =
|
||||
aboutBuildIdentity(
|
||||
versionName = "dev",
|
||||
versionCode = 1,
|
||||
gitCommit = "unknown",
|
||||
buildTimestamp = "unknown",
|
||||
locale = Locale.US,
|
||||
unknownLabel = "Unbekannt",
|
||||
)
|
||||
|
||||
assertEquals("dev (1)", identity.version)
|
||||
assertEquals("Unbekannt", identity.commit)
|
||||
assertEquals(null, identity.fullCommit)
|
||||
assertEquals("Unbekannt", identity.built)
|
||||
assertEquals(null, identity.buildTimestamp)
|
||||
assertEquals("Unbekannt", aboutCommitAccessibilityValue(identity.fullCommit, "Unbekannt"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun aboutCommitAccessibilityValueSpellsTheFullHash() {
|
||||
val commit = "abcdef0123456789abcdef0123456789abcdef01"
|
||||
|
||||
assertEquals(
|
||||
commit.toCharArray().joinToString(" "),
|
||||
aboutCommitAccessibilityValue(commit, "Unknown"),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun gatewayStatusLabelReportsWhichAuthRecoveryAppliesInsteadOfGenericLabel() {
|
||||
assertEquals(
|
||||
|
||||
@@ -16,7 +16,7 @@ import {
|
||||
readdirSync,
|
||||
writeFileSync,
|
||||
} from "node:fs";
|
||||
import { basename, delimiter, dirname, join } from "node:path";
|
||||
import { basename, delimiter, dirname, join, resolve } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { resolveAndroidVersion, syncAndroidVersioning } from "../../../scripts/lib/android-version.ts";
|
||||
|
||||
@@ -33,11 +33,127 @@ type CliOptions = {
|
||||
verifyApk?: string;
|
||||
};
|
||||
|
||||
export type AndroidBuildMetadata = {
|
||||
commit: string;
|
||||
timestamp: string;
|
||||
};
|
||||
|
||||
type ResolveAndroidBuildMetadataOptions = {
|
||||
env?: NodeJS.ProcessEnv;
|
||||
now?: () => Date;
|
||||
readGitCommit?: () => 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");
|
||||
const fullGitCommitPattern = /^[a-f0-9]{40}$/u;
|
||||
const isoUtcTimestampPattern = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d{1,3})?Z$/u;
|
||||
|
||||
function normalizeFullGitCommit(raw: string): string {
|
||||
const commit = raw.trim().toLowerCase();
|
||||
if (!fullGitCommitPattern.test(commit)) {
|
||||
throw new Error("Android build metadata requires a full 40-character hexadecimal Git commit");
|
||||
}
|
||||
return commit;
|
||||
}
|
||||
|
||||
function normalizeIsoUtcTimestamp(raw: string): string {
|
||||
const timestamp = raw.trim();
|
||||
if (!isoUtcTimestampPattern.test(timestamp)) {
|
||||
throw new Error("OPENCLAW_BUILD_TIMESTAMP must be an ISO-8601 UTC timestamp");
|
||||
}
|
||||
|
||||
const parsed = new Date(timestamp);
|
||||
if (Number.isNaN(parsed.getTime())) {
|
||||
throw new Error("OPENCLAW_BUILD_TIMESTAMP must be an ISO-8601 UTC timestamp");
|
||||
}
|
||||
const normalized = parsed.toISOString();
|
||||
if (normalized.slice(0, 19) !== timestamp.slice(0, 19)) {
|
||||
throw new Error("OPENCLAW_BUILD_TIMESTAMP must be a valid ISO-8601 UTC timestamp");
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
function readRepositoryCommit(): string {
|
||||
try {
|
||||
return execFileSync("git", ["rev-parse", "HEAD"], {
|
||||
cwd: rootDir,
|
||||
encoding: "utf8",
|
||||
stdio: ["ignore", "pipe", "ignore"],
|
||||
}).trim();
|
||||
} catch {
|
||||
throw new Error("Unable to resolve the Android release Git commit");
|
||||
}
|
||||
}
|
||||
|
||||
export function resolveAndroidBuildMetadata(
|
||||
options: ResolveAndroidBuildMetadataOptions = {},
|
||||
): AndroidBuildMetadata {
|
||||
const env = options.env ?? process.env;
|
||||
const explicitCommit = env.GIT_COMMIT?.trim() || env.GIT_SHA?.trim();
|
||||
let repositoryCommit: string | undefined;
|
||||
if (!explicitCommit) {
|
||||
try {
|
||||
repositoryCommit = (options.readGitCommit ?? readRepositoryCommit)().trim() || undefined;
|
||||
} catch {
|
||||
// GitHub's ambient SHA is safe only when there is no readable checkout.
|
||||
}
|
||||
}
|
||||
const commitSource = explicitCommit || repositoryCommit || env.GITHUB_SHA?.trim();
|
||||
if (!commitSource) {
|
||||
throw new Error("Unable to resolve the Android release Git commit");
|
||||
}
|
||||
const commit = normalizeFullGitCommit(commitSource);
|
||||
|
||||
const configuredTimestamp = env.OPENCLAW_BUILD_TIMESTAMP?.trim();
|
||||
const timestamp = configuredTimestamp
|
||||
? normalizeIsoUtcTimestamp(configuredTimestamp)
|
||||
: (options.now ?? (() => new Date()))().toISOString();
|
||||
|
||||
return { commit, timestamp };
|
||||
}
|
||||
|
||||
export function androidBuildMetadataGradleArgs(metadata: AndroidBuildMetadata): string[] {
|
||||
return [
|
||||
`-PopenclawBuildCommit=${metadata.commit}`,
|
||||
`-PopenclawBuildTimestamp=${metadata.timestamp}`,
|
||||
];
|
||||
}
|
||||
|
||||
export function verifyAndroidReleaseSource(
|
||||
expectedCommit: string,
|
||||
options: {
|
||||
rootDir?: string;
|
||||
runGit?: (args: string[], cwd: string) => string;
|
||||
} = {},
|
||||
): void {
|
||||
const cwd = options.rootDir ?? rootDir;
|
||||
const runGit =
|
||||
options.runGit ??
|
||||
((args: string[], gitCwd: string) =>
|
||||
execFileSync("git", args, {
|
||||
cwd: gitCwd,
|
||||
encoding: "utf8",
|
||||
stdio: ["ignore", "pipe", "ignore"],
|
||||
}));
|
||||
let head: string;
|
||||
let status: string;
|
||||
try {
|
||||
head = normalizeFullGitCommit(runGit(["rev-parse", "HEAD"], cwd));
|
||||
status = runGit(["status", "--porcelain", "--untracked-files=all"], cwd).trim();
|
||||
} catch {
|
||||
throw new Error("Android release builds require a readable Git checkout");
|
||||
}
|
||||
if (head !== expectedCommit) {
|
||||
throw new Error(`Android release commit mismatch: metadata ${expectedCommit}, checkout ${head}`);
|
||||
}
|
||||
if (status) {
|
||||
throw new Error("Android release builds require a clean Git checkout");
|
||||
}
|
||||
}
|
||||
|
||||
function parseArgs(argv: string[]): CliOptions {
|
||||
let artifact: CliOptions["artifact"] = "all";
|
||||
@@ -249,12 +365,15 @@ function main() {
|
||||
|
||||
syncAndroidVersioning({ mode: "check", rootDir });
|
||||
const version = resolveAndroidVersion(rootDir);
|
||||
const buildMetadata = resolveAndroidBuildMetadata();
|
||||
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}`);
|
||||
console.log(`Android build commit: ${buildMetadata.commit}`);
|
||||
console.log(`Android build timestamp: ${buildMetadata.timestamp}`);
|
||||
for (const artifact of artifacts) {
|
||||
console.log(`Release artifact: ${artifact.flavorName} ${artifact.kind}`);
|
||||
console.log(`Gradle task: ${artifact.gradleTask}`);
|
||||
@@ -265,11 +384,19 @@ function main() {
|
||||
return;
|
||||
}
|
||||
|
||||
verifyAndroidReleaseSource(buildMetadata.commit);
|
||||
mkdirSync(releaseOutputDir, { recursive: true });
|
||||
execFileSync("./gradlew", artifacts.map((artifact) => artifact.gradleTask), {
|
||||
cwd: androidDir,
|
||||
stdio: "inherit",
|
||||
});
|
||||
execFileSync(
|
||||
"./gradlew",
|
||||
[
|
||||
...androidBuildMetadataGradleArgs(buildMetadata),
|
||||
...artifacts.map((artifact) => artifact.gradleTask),
|
||||
],
|
||||
{
|
||||
cwd: androidDir,
|
||||
stdio: "inherit",
|
||||
},
|
||||
);
|
||||
|
||||
for (const artifact of artifacts) {
|
||||
const outputPath = join(
|
||||
@@ -286,4 +413,7 @@ function main() {
|
||||
}
|
||||
}
|
||||
|
||||
main();
|
||||
const isMain = process.argv[1] ? resolve(process.argv[1]) === fileURLToPath(import.meta.url) : false;
|
||||
if (isMain) {
|
||||
main();
|
||||
}
|
||||
|
||||
@@ -696,18 +696,19 @@ extension SettingsProTab {
|
||||
Text("Personal AI on your devices")
|
||||
.font(OpenClawType.footnote)
|
||||
.foregroundStyle(.secondary)
|
||||
SettingsBuildMetadataStrip(metadata: DeviceInfoHelper.buildMetadata())
|
||||
.padding(.top, 8)
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(.top, 4)
|
||||
.accessibilityElement(children: .combine)
|
||||
.accessibilityElement(children: .contain)
|
||||
.listRowBackground(Color.clear)
|
||||
.listRowSeparator(.hidden)
|
||||
}
|
||||
|
||||
// Concise public details only; deep hardware identifiers live in Diagnostics.
|
||||
detailListCard {
|
||||
SettingsDetailRow("OpenClaw app version", value: DeviceInfoHelper.openClawVersionString())
|
||||
SettingsDetailRow("Device", value: DeviceInfoHelper.deviceFamily())
|
||||
SettingsDetailRow("iOS", value: DeviceInfoHelper.iOSVersionStringForDisplay())
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import Darwin
|
||||
import OpenClawKit
|
||||
import SwiftUI
|
||||
import UIKit
|
||||
import UserNotifications
|
||||
|
||||
enum SettingsRoute: Hashable {
|
||||
@@ -47,6 +48,155 @@ struct SettingsDetailRow: View {
|
||||
}
|
||||
}
|
||||
|
||||
struct SettingsBuildMetadataStrip: View {
|
||||
let metadata: ArtifactBuildInfo
|
||||
@Environment(\.dynamicTypeSize) private var dynamicTypeSize
|
||||
@Environment(\.layoutDirection) private var layoutDirection
|
||||
|
||||
private struct Field: Identifiable {
|
||||
enum ID: String {
|
||||
case version
|
||||
case commit
|
||||
case built
|
||||
}
|
||||
|
||||
let id: ID
|
||||
let title: LocalizedStringKey
|
||||
let value: String?
|
||||
let forceLeftToRight: Bool
|
||||
}
|
||||
|
||||
private var fields: [Field] {
|
||||
[
|
||||
Field(id: .version, title: "Version", value: self.metadata.versionDisplay, forceLeftToRight: true),
|
||||
Field(id: .commit, title: "Commit", value: self.metadata.shortCommit, forceLeftToRight: true),
|
||||
Field(id: .built, title: "Built", value: self.metadata.localizedBuildDate(), forceLeftToRight: false),
|
||||
]
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
Group {
|
||||
if self.dynamicTypeSize.isAccessibilitySize {
|
||||
self.metadataColumn
|
||||
} else {
|
||||
ViewThatFits(in: .horizontal) {
|
||||
self.metadataRow
|
||||
self.metadataColumn
|
||||
}
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .center)
|
||||
.foregroundStyle(.secondary)
|
||||
.textSelection(.enabled)
|
||||
.accessibilityElement(children: .ignore)
|
||||
.accessibilityLabel(self.metadataAccessibilityLabel)
|
||||
.accessibilityActions {
|
||||
if self.metadata.gitCommit != nil {
|
||||
Button("Copy full commit hash") {
|
||||
self.copyCommit()
|
||||
}
|
||||
}
|
||||
Button("Copy build info") {
|
||||
self.copyBuildInfo()
|
||||
}
|
||||
}
|
||||
.contextMenu {
|
||||
if self.metadata.gitCommit != nil {
|
||||
Button {
|
||||
self.copyCommit()
|
||||
} label: {
|
||||
Label {
|
||||
Text("Copy Commit")
|
||||
.font(OpenClawType.subheadSemiBold)
|
||||
} icon: {
|
||||
Image(systemName: "number")
|
||||
}
|
||||
}
|
||||
}
|
||||
Button {
|
||||
self.copyBuildInfo()
|
||||
} label: {
|
||||
Label {
|
||||
Text("Copy Build Info")
|
||||
.font(OpenClawType.subheadSemiBold)
|
||||
} icon: {
|
||||
Image(systemName: "doc.on.doc")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var metadataRow: some View {
|
||||
HStack(alignment: .center, spacing: 0) {
|
||||
ForEach(Array(self.fields.enumerated()), id: \.element.id) { index, field in
|
||||
if index > 0 {
|
||||
Divider()
|
||||
.frame(height: 30)
|
||||
}
|
||||
self.metadataField(field, alignment: .center)
|
||||
.frame(minWidth: 72, maxWidth: .infinity)
|
||||
.padding(.horizontal, 4)
|
||||
}
|
||||
}
|
||||
.frame(minWidth: 240)
|
||||
}
|
||||
|
||||
private var metadataColumn: some View {
|
||||
VStack(alignment: .center, spacing: 8) {
|
||||
ForEach(self.fields) { field in
|
||||
self.metadataField(field, alignment: .center)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func metadataField(_ field: Field, alignment: HorizontalAlignment) -> some View {
|
||||
VStack(alignment: alignment, spacing: 1) {
|
||||
Text(field.title)
|
||||
.font(OpenClawType.caption2SemiBold)
|
||||
.textCase(.uppercase)
|
||||
Group {
|
||||
if let value = field.value {
|
||||
Text(verbatim: value)
|
||||
} else {
|
||||
Text("Unavailable")
|
||||
}
|
||||
}
|
||||
.font(OpenClawType.monoSmall)
|
||||
.lineLimit(1)
|
||||
.minimumScaleFactor(0.72)
|
||||
.environment(
|
||||
\.layoutDirection,
|
||||
field.forceLeftToRight ? .leftToRight : self.layoutDirection)
|
||||
}
|
||||
}
|
||||
|
||||
private var metadataAccessibilityLabel: Text {
|
||||
let version = self.metadata.versionDisplay
|
||||
let commit = self.metadata.spokenCommit
|
||||
let timestamp = self.metadata.buildTimestamp
|
||||
let built = self.metadata.localizedBuildDate() ?? timestamp
|
||||
if let commit, let timestamp, let built {
|
||||
return Text("Version \(version), commit \(commit), built \(built), timestamp \(timestamp)")
|
||||
}
|
||||
if let commit {
|
||||
return Text("Version \(version), commit \(commit), build date unavailable")
|
||||
}
|
||||
if let timestamp, let built {
|
||||
return Text("Version \(version), commit unavailable, built \(built), timestamp \(timestamp)")
|
||||
}
|
||||
return Text("Version \(version), commit unavailable, build date unavailable")
|
||||
}
|
||||
|
||||
private func copyCommit() {
|
||||
guard let gitCommit = self.metadata.gitCommit else { return }
|
||||
UIPasteboard.general.string = gitCommit
|
||||
}
|
||||
|
||||
private func copyBuildInfo() {
|
||||
UIPasteboard.general.string = self.metadata.copyText
|
||||
}
|
||||
}
|
||||
|
||||
struct SettingsApprovalItem: Identifiable {
|
||||
let id: String
|
||||
let icon: String
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import Darwin
|
||||
import Foundation
|
||||
import OpenClawKit
|
||||
import UIKit
|
||||
|
||||
/// Shared device and platform info for Settings, gateway node payloads, and device status.
|
||||
@@ -79,4 +80,14 @@ enum DeviceInfoHelper {
|
||||
}
|
||||
return "\(version) (\(build))"
|
||||
}
|
||||
|
||||
static func buildMetadata() -> ArtifactBuildInfo {
|
||||
self.buildMetadata(infoDictionary: Bundle.main.infoDictionary ?? [:])
|
||||
}
|
||||
|
||||
static func buildMetadata(infoDictionary: [String: Any]) -> ArtifactBuildInfo {
|
||||
ArtifactBuildInfo(
|
||||
infoDictionary: infoDictionary,
|
||||
versionKeys: ["OpenClawCanonicalVersion", "CFBundleShortVersionString"])
|
||||
}
|
||||
}
|
||||
|
||||
@@ -82,6 +82,10 @@
|
||||
<string>$(OPENCLAW_ACTIVE_APP_GROUP_ID)</string>
|
||||
<key>OpenClawCanonicalVersion</key>
|
||||
<string>$(OPENCLAW_IOS_VERSION)</string>
|
||||
<key>OpenClawGitCommit</key>
|
||||
<string>$(OPENCLAW_GIT_COMMIT)</string>
|
||||
<key>OpenClawBuildTimestamp</key>
|
||||
<string>$(OPENCLAW_BUILD_TIMESTAMP)</string>
|
||||
<key>OpenClawPushMode</key>
|
||||
<string>$(OPENCLAW_PUSH_MODE)</string>
|
||||
<key>OpenClawPushRelayBaseURL</key>
|
||||
|
||||
@@ -8,4 +8,14 @@ struct DeviceInfoHelperTests {
|
||||
|
||||
#expect(DeviceInfoHelper.iOSVersionStringForDisplay(version) == "26.5.0")
|
||||
}
|
||||
|
||||
@Test func `build metadata prefers canonical iOS version`() {
|
||||
let metadata = DeviceInfoHelper.buildMetadata(infoDictionary: [
|
||||
"OpenClawCanonicalVersion": "2026.7.10",
|
||||
"CFBundleShortVersionString": "2026.7.9",
|
||||
"CFBundleVersion": "42",
|
||||
])
|
||||
|
||||
#expect(metadata.versionDisplay == "2026.7.10 (42)")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -156,6 +156,9 @@ struct OpenClawTypographyTests {
|
||||
let settingsSections = try String(
|
||||
contentsOf: Self.sourceURL("Design/SettingsProTabSections.swift"),
|
||||
encoding: .utf8)
|
||||
let settingsSupport = try String(
|
||||
contentsOf: Self.sourceURL("Design/SettingsProTabSupport.swift"),
|
||||
encoding: .utf8)
|
||||
let privacyAccess = try String(
|
||||
contentsOf: Self.sourceURL("Settings/PrivacyAccessSectionView.swift"),
|
||||
encoding: .utf8)
|
||||
@@ -252,6 +255,10 @@ struct OpenClawTypographyTests {
|
||||
#expect(settingsSections.contains("func gatewayActionButton"))
|
||||
#expect(settingsSections.contains("func settingsToggle"))
|
||||
#expect(settingsSections.contains(".font(OpenClawType.subheadSemiBold)"))
|
||||
#expect(settingsSupport.contains("struct SettingsBuildMetadataStrip"))
|
||||
#expect(settingsSupport.contains(".font(OpenClawType.caption2SemiBold)"))
|
||||
#expect(settingsSupport.contains(".font(OpenClawType.monoSmall)"))
|
||||
#expect(settingsSupport.contains("Text(\"Copy Build Info\")"))
|
||||
#expect(settingsSections.contains("Text(\"Use Manual Gateway\")")
|
||||
|| settingsSections.contains("\"Use Manual Gateway\""))
|
||||
#expect(settingsSections.contains("func gatewaySecureField"))
|
||||
|
||||
@@ -280,6 +280,7 @@ struct RootTabsSourceGuardTests {
|
||||
|
||||
@Test func `settings about page shows concise public device details`() throws {
|
||||
let settingsSource = try String(contentsOf: Self.settingsProTabSectionsSourceURL(), encoding: .utf8)
|
||||
let supportSource = try String(contentsOf: Self.settingsProTabSupportSourceURL(), encoding: .utf8)
|
||||
let aboutDestination = try Self.extract(
|
||||
settingsSource,
|
||||
from: "var aboutDestination: some View",
|
||||
@@ -291,13 +292,20 @@ struct RootTabsSourceGuardTests {
|
||||
|
||||
#expect(!aboutDestination.contains("detailStatusCard("))
|
||||
#expect(aboutDestination.contains("detailListCard"))
|
||||
#expect(aboutDestination.contains("SettingsDetailRow(\"OpenClaw app version\""))
|
||||
#expect(aboutDestination.contains("SettingsBuildMetadataStrip(metadata: DeviceInfoHelper.buildMetadata())"))
|
||||
#expect(!aboutDestination.contains("SettingsDetailRow(\"OpenClaw app version\""))
|
||||
#expect(aboutDestination.contains("SettingsDetailRow(\"Device\", value: DeviceInfoHelper.deviceFamily())"))
|
||||
#expect(aboutDestination
|
||||
.contains("SettingsDetailRow(\"iOS\", value: DeviceInfoHelper.iOSVersionStringForDisplay())"))
|
||||
#expect(!aboutDestination.contains("SettingsDetailRow(\"Version\""))
|
||||
#expect(!aboutDestination.contains("SettingsDetailRow(\"Platform\""))
|
||||
#expect(!aboutDestination.contains("SettingsDetailRow(\"Model\""))
|
||||
#expect(supportSource.contains("title: \"Version\""))
|
||||
#expect(supportSource.contains("title: \"Commit\""))
|
||||
#expect(supportSource.contains("title: \"Built\""))
|
||||
#expect(supportSource.contains("ViewThatFits(in: .horizontal)"))
|
||||
#expect(supportSource.contains("Text(\"Unavailable\")"))
|
||||
#expect(supportSource.contains(".textCase(.uppercase)"))
|
||||
#expect(diagnosticsDestination
|
||||
.contains("SettingsDetailRow(\"Device\", value: DeviceInfoHelper.deviceFamily())"))
|
||||
#expect(diagnosticsDestination
|
||||
|
||||
@@ -47,17 +47,20 @@ struct SwiftUIRenderSmokeTests {
|
||||
|
||||
@Test @MainActor func `settings About destination builds in light and dark mode`() {
|
||||
for scheme in [ColorScheme.light, ColorScheme.dark] {
|
||||
let appModel = NodeAppModel()
|
||||
let gatewayController = GatewayConnectionController(appModel: appModel, startDiscovery: false)
|
||||
for typeSize in [DynamicTypeSize.large, .accessibility2] {
|
||||
let appModel = NodeAppModel()
|
||||
let gatewayController = GatewayConnectionController(appModel: appModel, startDiscovery: false)
|
||||
|
||||
let root = SettingsProTab(directRoute: .about)
|
||||
.environment(AppAppearanceModel())
|
||||
.environment(appModel)
|
||||
.environment(appModel.voiceWake)
|
||||
.environment(gatewayController)
|
||||
.preferredColorScheme(scheme)
|
||||
let root = SettingsProTab(directRoute: .about)
|
||||
.environment(AppAppearanceModel())
|
||||
.environment(appModel)
|
||||
.environment(appModel.voiceWake)
|
||||
.environment(gatewayController)
|
||||
.environment(\.dynamicTypeSize, typeSize)
|
||||
.preferredColorScheme(scheme)
|
||||
|
||||
_ = Self.host(root, size: CGSize(width: 393, height: 852))
|
||||
_ = Self.host(root, size: CGSize(width: 320, height: 852))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ require "tmpdir"
|
||||
require "tempfile"
|
||||
require "cgi"
|
||||
require "digest/md5"
|
||||
require "time"
|
||||
|
||||
default_platform(:ios)
|
||||
|
||||
@@ -1180,6 +1181,54 @@ def release_git_sha
|
||||
stdout.strip
|
||||
end
|
||||
|
||||
def normalize_release_git_sha(value)
|
||||
commit = value.to_s.strip.downcase
|
||||
UI.user_error!("Release build commit must be a full 40-character hexadecimal SHA.") unless commit.match?(/\A[0-9a-f]{40}\z/)
|
||||
commit
|
||||
end
|
||||
|
||||
def normalize_release_build_timestamp(value)
|
||||
timestamp = value.to_s.strip
|
||||
unless timestamp.match?(/\A\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d{1,3})?Z\z/)
|
||||
UI.user_error!("Release build timestamp must be an ISO-8601 UTC timestamp ending in Z.")
|
||||
end
|
||||
begin
|
||||
parsed = Time.iso8601(timestamp)
|
||||
rescue ArgumentError
|
||||
UI.user_error!("Release build timestamp must be a valid ISO-8601 UTC timestamp.")
|
||||
end
|
||||
fraction = timestamp[/\.(\d{1,3})Z\z/, 1].to_s.ljust(3, "0")
|
||||
canonical_input = timestamp.sub(/(?:\.\d{1,3})?Z\z/, ".#{fraction}Z")
|
||||
normalized = parsed.utc.iso8601(3)
|
||||
UI.user_error!("Release build timestamp must be a valid ISO-8601 UTC timestamp.") unless normalized == canonical_input
|
||||
normalized
|
||||
end
|
||||
|
||||
def verify_apple_release_source!(expected_commit)
|
||||
script_path = File.join(repo_root, "scripts", "apple-release-source-check.sh")
|
||||
sh(shell_join([
|
||||
"bash",
|
||||
script_path,
|
||||
"--root",
|
||||
repo_root,
|
||||
"--expected-commit",
|
||||
expected_commit
|
||||
]))
|
||||
end
|
||||
|
||||
def pin_release_build_provenance!
|
||||
commit = [ENV["GIT_COMMIT"], ENV["GIT_SHA"]].find { |value| env_present?(value) } || release_git_sha
|
||||
timestamp = env_present?(ENV["OPENCLAW_BUILD_TIMESTAMP"]) ? ENV["OPENCLAW_BUILD_TIMESTAMP"] : Time.now.utc.iso8601(3)
|
||||
normalized_commit = normalize_release_git_sha(commit)
|
||||
verify_apple_release_source!(normalized_commit)
|
||||
ENV["GIT_COMMIT"] = normalized_commit
|
||||
ENV["OPENCLAW_BUILD_TIMESTAMP"] = normalize_release_build_timestamp(timestamp)
|
||||
{
|
||||
git_commit: ENV.fetch("GIT_COMMIT"),
|
||||
build_timestamp: ENV.fetch("OPENCLAW_BUILD_TIMESTAMP")
|
||||
}
|
||||
end
|
||||
|
||||
def mobile_release_ref_command(command, platform:, version:, build: nil, version_code: nil, sha: nil)
|
||||
args = [
|
||||
"node",
|
||||
@@ -1222,9 +1271,18 @@ def record_mobile_release_ref!(platform:, version:, build: nil, version_code: ni
|
||||
)
|
||||
end
|
||||
|
||||
def validate_app_store_ipa!(ipa_path)
|
||||
def validate_app_store_ipa!(ipa_path, expected_commit:, expected_build_timestamp:)
|
||||
script_path = File.join(repo_root, "scripts", "ios-validate-app-store-ipa.sh")
|
||||
sh(shell_join(["bash", script_path, "--ipa", ipa_path]))
|
||||
sh(shell_join([
|
||||
"bash",
|
||||
script_path,
|
||||
"--ipa",
|
||||
ipa_path,
|
||||
"--expected-commit",
|
||||
expected_commit,
|
||||
"--expected-build-timestamp",
|
||||
expected_build_timestamp
|
||||
]))
|
||||
end
|
||||
|
||||
def build_app_store_release(context)
|
||||
@@ -1236,6 +1294,7 @@ def build_app_store_release(context)
|
||||
output_name = "OpenClaw-#{version}.ipa"
|
||||
expected_ipa_path = File.join(output_directory, output_name)
|
||||
|
||||
verify_apple_release_source!(context[:git_commit])
|
||||
FileUtils.mkdir_p(output_directory)
|
||||
FileUtils.rm_rf(archive_path)
|
||||
Dir[File.join(output_directory, "*.ipa")].each { |path| FileUtils.rm_f(path) }
|
||||
@@ -1277,7 +1336,11 @@ def build_app_store_release(context)
|
||||
UI.user_error!("xcodebuild export produced multiple IPAs in #{output_directory}: #{exported_ipas.join(", ")}") if exported_ipas.length > 1
|
||||
exported_ipa = exported_ipas.first
|
||||
FileUtils.mv(exported_ipa, expected_ipa_path) unless exported_ipa == expected_ipa_path
|
||||
validate_app_store_ipa!(expected_ipa_path)
|
||||
validate_app_store_ipa!(
|
||||
expected_ipa_path,
|
||||
expected_commit: context[:git_commit],
|
||||
expected_build_timestamp: context[:build_timestamp]
|
||||
)
|
||||
|
||||
{
|
||||
archive_path: archive_path,
|
||||
@@ -1349,6 +1412,7 @@ platform :ios do
|
||||
version_metadata = read_ios_version_metadata(release_version: release_version)
|
||||
version = version_metadata[:version]
|
||||
short_version = version_metadata[:short_version]
|
||||
provenance = pin_release_build_provenance!
|
||||
build_number = resolve_release_build_number(
|
||||
api_key: api_key,
|
||||
short_version: short_version,
|
||||
@@ -1358,7 +1422,9 @@ platform :ios do
|
||||
|
||||
{
|
||||
api_key: api_key,
|
||||
build_timestamp: provenance[:build_timestamp],
|
||||
build_number: build_number,
|
||||
git_commit: provenance[:git_commit],
|
||||
release_xcconfig: release_xcconfig,
|
||||
short_version: short_version,
|
||||
version: version
|
||||
@@ -1416,13 +1482,13 @@ platform :ios do
|
||||
UI.user_error!("Use `pnpm ios:release:upload`; direct Fastlane upload is disabled.")
|
||||
end
|
||||
|
||||
release_sha = release_git_sha
|
||||
release_signing_check!
|
||||
context = prepare_app_store_context(
|
||||
require_api_key: true,
|
||||
release_version: options[:release_version],
|
||||
build_number: options[:build_number]
|
||||
)
|
||||
release_sha = context[:git_commit]
|
||||
ensure_mobile_release_ref_available!(
|
||||
platform: "ios",
|
||||
version: context[:short_version],
|
||||
|
||||
@@ -158,6 +158,8 @@ targets:
|
||||
- "$(OPENCLAW_URL_SCHEME)"
|
||||
CFBundleShortVersionString: "$(OPENCLAW_MARKETING_VERSION)"
|
||||
OpenClawCanonicalVersion: "$(OPENCLAW_IOS_VERSION)"
|
||||
OpenClawGitCommit: "$(OPENCLAW_GIT_COMMIT)"
|
||||
OpenClawBuildTimestamp: "$(OPENCLAW_BUILD_TIMESTAMP)"
|
||||
OpenClawAppGroupIdentifier: "$(OPENCLAW_ACTIVE_APP_GROUP_ID)"
|
||||
CFBundleVersion: "$(OPENCLAW_BUILD_VERSION)"
|
||||
UILaunchScreen: {}
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import AppKit
|
||||
import OpenClawChatUI
|
||||
import OpenClawKit
|
||||
import SwiftUI
|
||||
|
||||
struct AboutSettings: View {
|
||||
@@ -37,13 +39,8 @@ struct AboutSettings: View {
|
||||
VStack(spacing: 3) {
|
||||
Text("OpenClaw")
|
||||
.font(.title3.bold())
|
||||
Text("Version \(self.versionString)")
|
||||
.foregroundStyle(.secondary)
|
||||
if let buildTimestamp {
|
||||
Text("Built \(buildTimestamp)\(self.buildSuffix)")
|
||||
.font(.footnote)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
AboutBuildMetadataStrip(metadata: self.buildMetadata)
|
||||
.padding(.top, 3)
|
||||
Text("Menu bar companion for notifications, screenshots, and privileged agent actions.")
|
||||
.font(.footnote)
|
||||
.foregroundStyle(.secondary)
|
||||
@@ -109,49 +106,129 @@ struct AboutSettings: View {
|
||||
}
|
||||
}
|
||||
|
||||
private var versionString: String {
|
||||
let version = Bundle.main.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String ?? "dev"
|
||||
let build = Bundle.main.object(forInfoDictionaryKey: "CFBundleVersion") as? String
|
||||
return build.map { "\(version) (\($0))" } ?? version
|
||||
private var buildMetadata: ArtifactBuildInfo {
|
||||
ArtifactBuildInfo(infoDictionary: Bundle.main.infoDictionary ?? [:])
|
||||
}
|
||||
}
|
||||
|
||||
private struct AboutBuildMetadataStrip: View {
|
||||
let metadata: ArtifactBuildInfo
|
||||
@Environment(\.layoutDirection) private var layoutDirection
|
||||
|
||||
private struct Field: Identifiable {
|
||||
enum ID: String {
|
||||
case version
|
||||
case commit
|
||||
case built
|
||||
}
|
||||
|
||||
let id: ID
|
||||
let title: LocalizedStringKey
|
||||
let value: String?
|
||||
let forceLeftToRight: Bool
|
||||
}
|
||||
|
||||
private var buildTimestamp: String? {
|
||||
guard
|
||||
let raw =
|
||||
(Bundle.main.object(forInfoDictionaryKey: "OpenClawBuildTimestamp") as? String) ??
|
||||
(Bundle.main.object(forInfoDictionaryKey: "OpenClawBuildTimestamp") as? String)
|
||||
else { return nil }
|
||||
let parser = ISO8601DateFormatter()
|
||||
parser.formatOptions = [.withInternetDateTime]
|
||||
guard let date = parser.date(from: raw) else { return raw }
|
||||
|
||||
let formatter = DateFormatter()
|
||||
formatter.dateStyle = .medium
|
||||
formatter.timeStyle = .short
|
||||
formatter.locale = .current
|
||||
return formatter.string(from: date)
|
||||
private var fields: [Field] {
|
||||
[
|
||||
Field(id: .version, title: "Version", value: self.metadata.versionDisplay, forceLeftToRight: true),
|
||||
Field(id: .commit, title: "Commit", value: self.metadata.shortCommit, forceLeftToRight: true),
|
||||
Field(id: .built, title: "Built", value: self.metadata.localizedBuildDate(), forceLeftToRight: false),
|
||||
]
|
||||
}
|
||||
|
||||
private var gitCommit: String {
|
||||
(Bundle.main.object(forInfoDictionaryKey: "OpenClawGitCommit") as? String) ??
|
||||
(Bundle.main.object(forInfoDictionaryKey: "OpenClawGitCommit") as? String) ??
|
||||
"unknown"
|
||||
var body: some View {
|
||||
ViewThatFits(in: .horizontal) {
|
||||
HStack(alignment: .center, spacing: 12) {
|
||||
ForEach(Array(self.fields.enumerated()), id: \.element.id) { index, field in
|
||||
if index > 0 {
|
||||
Divider()
|
||||
.frame(height: 28)
|
||||
}
|
||||
self.metadataField(field)
|
||||
.fixedSize(horizontal: true, vertical: false)
|
||||
}
|
||||
}
|
||||
.fixedSize(horizontal: true, vertical: false)
|
||||
|
||||
VStack(alignment: .center, spacing: 7) {
|
||||
ForEach(self.fields) { field in
|
||||
self.metadataField(field)
|
||||
}
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .center)
|
||||
.foregroundStyle(.secondary)
|
||||
.textSelection(.enabled)
|
||||
.accessibilityElement(children: .ignore)
|
||||
.accessibilityLabel(self.metadataAccessibilityLabel)
|
||||
.accessibilityActions {
|
||||
if self.metadata.gitCommit != nil {
|
||||
Button("Copy full commit hash") {
|
||||
self.copyCommit()
|
||||
}
|
||||
}
|
||||
Button("Copy build info") {
|
||||
self.copyBuildInfo()
|
||||
}
|
||||
}
|
||||
.contextMenu {
|
||||
if self.metadata.gitCommit != nil {
|
||||
Button("Copy Commit") {
|
||||
self.copyCommit()
|
||||
}
|
||||
}
|
||||
Button("Copy Build Info") {
|
||||
self.copyBuildInfo()
|
||||
}
|
||||
}
|
||||
.help(self.metadata.copyText)
|
||||
}
|
||||
|
||||
private var bundleID: String {
|
||||
Bundle.main.bundleIdentifier ?? "unknown"
|
||||
private func metadataField(_ field: Field) -> some View {
|
||||
VStack(alignment: .center, spacing: 1) {
|
||||
Text(field.title)
|
||||
.font(.caption2.weight(.semibold))
|
||||
.textCase(.uppercase)
|
||||
Group {
|
||||
if let value = field.value {
|
||||
Text(verbatim: value)
|
||||
} else {
|
||||
Text("Unavailable")
|
||||
}
|
||||
}
|
||||
.font(.caption.monospaced())
|
||||
.environment(
|
||||
\.layoutDirection,
|
||||
field.forceLeftToRight ? .leftToRight : self.layoutDirection)
|
||||
}
|
||||
}
|
||||
|
||||
private var buildSuffix: String {
|
||||
let git = self.gitCommit
|
||||
guard !git.isEmpty, git != "unknown" else { return "" }
|
||||
private var metadataAccessibilityLabel: Text {
|
||||
let version = self.metadata.versionDisplay
|
||||
let commit = self.metadata.spokenCommit
|
||||
let timestamp = self.metadata.buildTimestamp
|
||||
let built = self.metadata.localizedBuildDate() ?? timestamp
|
||||
if let commit, let timestamp, let built {
|
||||
return Text("Version \(version), commit \(commit), built \(built), timestamp \(timestamp)")
|
||||
}
|
||||
if let commit {
|
||||
return Text("Version \(version), commit \(commit), build date unavailable")
|
||||
}
|
||||
if let timestamp, let built {
|
||||
return Text("Version \(version), commit unavailable, built \(built), timestamp \(timestamp)")
|
||||
}
|
||||
return Text("Version \(version), commit unavailable, build date unavailable")
|
||||
}
|
||||
|
||||
var suffix = " (\(git)"
|
||||
#if DEBUG
|
||||
suffix += " DEBUG"
|
||||
#endif
|
||||
suffix += ")"
|
||||
return suffix
|
||||
private func copyCommit() {
|
||||
guard let gitCommit = self.metadata.gitCommit else { return }
|
||||
NSPasteboard.general.clearContents()
|
||||
NSPasteboard.general.setString(gitCommit, forType: .string)
|
||||
}
|
||||
|
||||
private func copyBuildInfo() {
|
||||
NSPasteboard.general.clearContents()
|
||||
NSPasteboard.general.setString(self.metadata.copyText, forType: .string)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
import Foundation
|
||||
|
||||
public struct ArtifactBuildInfo: Equatable, Sendable {
|
||||
public let version: String
|
||||
public let build: String
|
||||
public let gitCommit: String?
|
||||
public let buildTimestamp: String?
|
||||
public let builtAt: Date?
|
||||
|
||||
public init(
|
||||
infoDictionary: [String: Any],
|
||||
versionKeys: [String] = ["CFBundleShortVersionString"])
|
||||
{
|
||||
self.version = versionKeys.lazy.compactMap { Self.nonEmptyString(infoDictionary[$0]) }.first ?? "dev"
|
||||
self.build = Self.nonEmptyString(infoDictionary["CFBundleVersion"]) ?? ""
|
||||
self.gitCommit = Self.validGitCommit(Self.nonEmptyString(infoDictionary["OpenClawGitCommit"]))
|
||||
let buildTimestamp = Self.nonEmptyString(infoDictionary["OpenClawBuildTimestamp"])
|
||||
self.builtAt = buildTimestamp.flatMap(Self.parseBuildTimestamp)
|
||||
self.buildTimestamp = self.builtAt == nil ? nil : buildTimestamp
|
||||
}
|
||||
|
||||
public var versionDisplay: String {
|
||||
if self.build.isEmpty || self.build == self.version {
|
||||
return self.version
|
||||
}
|
||||
return "\(self.version) (\(self.build))"
|
||||
}
|
||||
|
||||
public var shortCommit: String? {
|
||||
self.gitCommit.map { String($0.prefix(12)) }
|
||||
}
|
||||
|
||||
public var spokenCommit: String? {
|
||||
self.gitCommit.map { $0.map(String.init).joined(separator: " ") }
|
||||
}
|
||||
|
||||
public func localizedBuildDate(
|
||||
locale: Locale = .current,
|
||||
timeZone: TimeZone = TimeZone(secondsFromGMT: 0)!) -> String?
|
||||
{
|
||||
guard let builtAt else { return nil }
|
||||
let formatter = DateFormatter()
|
||||
formatter.locale = locale
|
||||
formatter.timeZone = timeZone
|
||||
formatter.dateStyle = .medium
|
||||
formatter.timeStyle = .none
|
||||
return formatter.string(from: builtAt)
|
||||
}
|
||||
|
||||
public var copyText: String {
|
||||
[
|
||||
"Version \(self.versionDisplay)",
|
||||
"Commit \(self.gitCommit ?? "Unavailable")",
|
||||
"Built \(self.buildTimestamp ?? "Unavailable")",
|
||||
].joined(separator: "\n")
|
||||
}
|
||||
|
||||
private static func nonEmptyString(_ value: Any?) -> String? {
|
||||
guard let value = value as? String else { return nil }
|
||||
let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
return trimmed.isEmpty ? nil : trimmed
|
||||
}
|
||||
|
||||
private static func validGitCommit(_ value: String?) -> String? {
|
||||
guard let value, value.utf8.count == 40 else { return nil }
|
||||
let isAsciiHex = value.utf8.allSatisfy { byte in
|
||||
(48...57).contains(byte) || (65...70).contains(byte) || (97...102).contains(byte)
|
||||
}
|
||||
guard isAsciiHex else { return nil }
|
||||
return value.lowercased()
|
||||
}
|
||||
|
||||
private static func parseBuildTimestamp(_ value: String) -> Date? {
|
||||
let utcPattern = #"^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d{1,3})?Z$"#
|
||||
guard value.range(of: utcPattern, options: .regularExpression) != nil else { return nil }
|
||||
let withoutZulu = String(value.dropLast())
|
||||
let canonicalValue: String
|
||||
if let fractionSeparator = withoutZulu.lastIndex(of: ".") {
|
||||
let prefix = withoutZulu[...fractionSeparator]
|
||||
let fraction = withoutZulu[withoutZulu.index(after: fractionSeparator)...]
|
||||
let paddedFraction = String(fraction).padding(toLength: 3, withPad: "0", startingAt: 0)
|
||||
canonicalValue = "\(prefix)\(paddedFraction)Z"
|
||||
} else {
|
||||
canonicalValue = "\(withoutZulu).000Z"
|
||||
}
|
||||
let formatter = ISO8601DateFormatter()
|
||||
formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
|
||||
guard let date = formatter.date(from: canonicalValue) else { return nil }
|
||||
return formatter.string(from: date) == canonicalValue ? date : nil
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
import Foundation
|
||||
import Testing
|
||||
@testable import OpenClawKit
|
||||
|
||||
struct ArtifactBuildInfoTests {
|
||||
@Test func `preserves full provenance and formats compact UTC values`() {
|
||||
let commit = "ABCDEF0123456789ABCDEF0123456789ABCDEF01"
|
||||
let info = ArtifactBuildInfo(
|
||||
infoDictionary: [
|
||||
"OpenClawCanonicalVersion": "2026.7.10",
|
||||
"CFBundleShortVersionString": "2026.7.9",
|
||||
"CFBundleVersion": "42",
|
||||
"OpenClawGitCommit": commit,
|
||||
"OpenClawBuildTimestamp": "2026-01-01T00:30:00.123Z",
|
||||
],
|
||||
versionKeys: ["OpenClawCanonicalVersion", "CFBundleShortVersionString"])
|
||||
|
||||
#expect(info.versionDisplay == "2026.7.10 (42)")
|
||||
#expect(info.gitCommit == commit.lowercased())
|
||||
#expect(info.shortCommit == "abcdef012345")
|
||||
#expect(info.localizedBuildDate(locale: Locale(identifier: "en_US_POSIX")) == "Jan 1, 2026")
|
||||
#expect(info.copyText.contains(commit.lowercased()))
|
||||
#expect(info.copyText.contains("2026-01-01T00:30:00.123Z"))
|
||||
#expect(info.spokenCommit == commit.lowercased().map(String.init).joined(separator: " "))
|
||||
}
|
||||
|
||||
@Test func `reports missing or malformed optional provenance`() {
|
||||
let info = ArtifactBuildInfo(infoDictionary: [
|
||||
"CFBundleShortVersionString": "1.2.3",
|
||||
"CFBundleVersion": "1.2.3",
|
||||
"OpenClawGitCommit": "abc123",
|
||||
"OpenClawBuildTimestamp": "not-a-date",
|
||||
])
|
||||
|
||||
#expect(info.versionDisplay == "1.2.3")
|
||||
#expect(info.gitCommit == nil)
|
||||
#expect(info.shortCommit == nil)
|
||||
#expect(info.buildTimestamp == nil)
|
||||
#expect(info.localizedBuildDate() == nil)
|
||||
#expect(info.copyText.contains("Commit Unavailable"))
|
||||
#expect(info.copyText.contains("Built Unavailable"))
|
||||
#expect(info.spokenCommit == nil)
|
||||
}
|
||||
|
||||
@Test func `rejects non UTC timestamps and non ASCII commit digits`() {
|
||||
let info = ArtifactBuildInfo(infoDictionary: [
|
||||
"CFBundleShortVersionString": "1.2.3",
|
||||
"OpenClawGitCommit": String(repeating: "A", count: 40),
|
||||
"OpenClawBuildTimestamp": "2026-07-10T12:34:56+00:00",
|
||||
])
|
||||
|
||||
#expect(info.gitCommit == nil)
|
||||
#expect(info.buildTimestamp == nil)
|
||||
}
|
||||
|
||||
@Test func `rejects impossible calendar dates and accepts short fractions`() {
|
||||
let invalid = ArtifactBuildInfo(infoDictionary: [
|
||||
"CFBundleShortVersionString": "1.2.3",
|
||||
"OpenClawBuildTimestamp": "2026-02-30T12:34:56Z",
|
||||
])
|
||||
let valid = ArtifactBuildInfo(infoDictionary: [
|
||||
"CFBundleShortVersionString": "1.2.3",
|
||||
"OpenClawBuildTimestamp": "2026-07-10T12:34:56.7Z",
|
||||
])
|
||||
|
||||
#expect(invalid.buildTimestamp == nil)
|
||||
#expect(valid.buildTimestamp == "2026-07-10T12:34:56.7Z")
|
||||
#expect(valid.builtAt != nil)
|
||||
}
|
||||
|
||||
@Test func `retains commit independently when timestamp is absent`() {
|
||||
let commit = "abcdef0123456789abcdef0123456789abcdef01"
|
||||
let info = ArtifactBuildInfo(infoDictionary: [
|
||||
"CFBundleShortVersionString": "2026.7.10",
|
||||
"OpenClawGitCommit": commit,
|
||||
])
|
||||
|
||||
#expect(info.shortCommit == "abcdef012345")
|
||||
#expect(info.localizedBuildDate() == nil)
|
||||
#expect(info.copyText.contains(commit))
|
||||
#expect(info.copyText.contains("Built Unavailable"))
|
||||
}
|
||||
}
|
||||
+11
-1
@@ -103,7 +103,12 @@ The default sandbox backend uses Docker when `agents.defaults.sandbox` is enable
|
||||
### Manual flow
|
||||
|
||||
```bash
|
||||
docker build -t openclaw:local -f Dockerfile .
|
||||
BUILD_GIT_COMMIT="$(git rev-parse HEAD)"
|
||||
BUILD_TIMESTAMP="$(date -u +%Y-%m-%dT%H:%M:%SZ)"
|
||||
docker build \
|
||||
--build-arg "GIT_COMMIT=${BUILD_GIT_COMMIT}" \
|
||||
--build-arg "OPENCLAW_BUILD_TIMESTAMP=${BUILD_TIMESTAMP}" \
|
||||
-t openclaw:local -f Dockerfile .
|
||||
docker compose run --rm --no-deps --entrypoint node openclaw-gateway \
|
||||
dist/index.js onboard --mode local --no-install-daemon
|
||||
docker compose run --rm --no-deps --entrypoint node openclaw-gateway \
|
||||
@@ -111,6 +116,11 @@ docker compose run --rm --no-deps --entrypoint node openclaw-gateway \
|
||||
docker compose up -d openclaw-gateway
|
||||
```
|
||||
|
||||
The Docker context excludes `.git`. Pass the source identity as build arguments
|
||||
as shown above so the image's About screen reports the checked-out commit and
|
||||
one build timestamp. `scripts/docker/setup.sh` resolves and passes both values
|
||||
automatically.
|
||||
|
||||
<Note>
|
||||
Run `docker compose` from the repo root. If you enabled `OPENCLAW_EXTRA_MOUNTS` or `OPENCLAW_HOME_VOLUME`, the setup script writes `docker-compose.extra.yml`; include it after any `docker-compose.override.yml` you maintain yourself, e.g. `-f docker-compose.yml -f docker-compose.override.yml -f docker-compose.extra.yml`.
|
||||
</Note>
|
||||
|
||||
Executable
+95
@@ -0,0 +1,95 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
usage() {
|
||||
cat <<'EOF'
|
||||
Usage:
|
||||
scripts/apple-release-source-check.sh --root <repository> --expected-commit <full-sha>
|
||||
|
||||
Verifies an Apple release build uses the clean checkout at the selected commit.
|
||||
EOF
|
||||
}
|
||||
|
||||
EXPECTED_COMMIT=""
|
||||
ROOT_DIR=""
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
source "${SCRIPT_DIR}/lib/build-metadata.sh"
|
||||
|
||||
require_option_value() {
|
||||
local option="$1"
|
||||
local value="${2-}"
|
||||
|
||||
if [[ -z "${value}" || "${value}" == --* ]]; then
|
||||
echo "Missing value for ${option}." >&2
|
||||
usage >&2
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
--expected-commit)
|
||||
require_option_value "$1" "${2-}"
|
||||
EXPECTED_COMMIT="$2"
|
||||
shift 2
|
||||
;;
|
||||
--root)
|
||||
require_option_value "$1" "${2-}"
|
||||
ROOT_DIR="$2"
|
||||
shift 2
|
||||
;;
|
||||
-h | --help)
|
||||
usage
|
||||
exit 0
|
||||
;;
|
||||
*)
|
||||
echo "Unknown argument: $1" >&2
|
||||
usage >&2
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
if [[ -z "${ROOT_DIR}" ]]; then
|
||||
echo "Missing required --root." >&2
|
||||
usage >&2
|
||||
exit 1
|
||||
fi
|
||||
if [[ -z "${EXPECTED_COMMIT}" ]]; then
|
||||
echo "Missing required --expected-commit." >&2
|
||||
usage >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
EXPECTED_COMMIT="$(openclaw_trim_build_metadata_value "${EXPECTED_COMMIT}")"
|
||||
if ! openclaw_is_full_git_commit "${EXPECTED_COMMIT}"; then
|
||||
echo "Apple release commit must be a full 40-character hexadecimal SHA." >&2
|
||||
exit 1
|
||||
fi
|
||||
EXPECTED_COMMIT="$(printf '%s' "${EXPECTED_COMMIT}" | tr '[:upper:]' '[:lower:]')"
|
||||
|
||||
if ! CHECKOUT_COMMIT="$(git -C "${ROOT_DIR}" rev-parse --verify HEAD 2>/dev/null)"; then
|
||||
echo "Apple release builds require a readable Git checkout." >&2
|
||||
exit 1
|
||||
fi
|
||||
if ! openclaw_is_full_git_commit "${CHECKOUT_COMMIT}"; then
|
||||
echo "Apple release checkout HEAD must be a full Git commit." >&2
|
||||
exit 1
|
||||
fi
|
||||
CHECKOUT_COMMIT="$(printf '%s' "${CHECKOUT_COMMIT}" | tr '[:upper:]' '[:lower:]')"
|
||||
|
||||
if [[ "${EXPECTED_COMMIT}" != "${CHECKOUT_COMMIT}" ]]; then
|
||||
echo "Apple release commit mismatch: metadata ${EXPECTED_COMMIT}, checkout ${CHECKOUT_COMMIT}." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if ! CHECKOUT_STATUS="$(git -C "${ROOT_DIR}" status --porcelain=v1 --untracked-files=all 2>/dev/null)"; then
|
||||
echo "Apple release builds require a readable Git checkout." >&2
|
||||
exit 1
|
||||
fi
|
||||
if [[ -n "${CHECKOUT_STATUS}" ]]; then
|
||||
echo "Apple release builds require a clean Git checkout." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "Verified Apple release source: commit=${CHECKOUT_COMMIT} clean=true"
|
||||
+40
-3
@@ -11,6 +11,7 @@ import { pluginSdkEntrypoints } from "./lib/plugin-sdk-entries.mjs";
|
||||
import { resolvePnpmRunner } from "./pnpm-runner.mjs";
|
||||
|
||||
const nodeBin = process.execPath;
|
||||
const FULL_GIT_COMMIT_RE = /^[0-9a-f]{40}$/iu;
|
||||
const BUILD_CACHE_VERSION = 3;
|
||||
const PLUGIN_SDK_ENTRY_DTS_CACHE_ENV = [
|
||||
"OPENCLAW_BUILD_PRIVATE_QA",
|
||||
@@ -259,6 +260,38 @@ export function resolveBuildAllSteps(profile = "full") {
|
||||
});
|
||||
}
|
||||
|
||||
function readCurrentGitCommit() {
|
||||
const result = spawnSync("git", ["rev-parse", "HEAD"], {
|
||||
encoding: "utf8",
|
||||
stdio: ["ignore", "pipe", "ignore"],
|
||||
});
|
||||
return result.status === 0 ? result.stdout.trim() : null;
|
||||
}
|
||||
|
||||
/** Pin one source identity for every child process that contributes to this build. */
|
||||
export function resolveBuildAllEnvironment(
|
||||
env = process.env,
|
||||
now = () => new Date(),
|
||||
readGitCommit = readCurrentGitCommit,
|
||||
) {
|
||||
const explicitTimestamp = env.OPENCLAW_BUILD_TIMESTAMP?.trim();
|
||||
const explicitCommit = env.GIT_COMMIT?.trim() || env.GIT_SHA?.trim();
|
||||
const checkedOutCommit = explicitCommit ? null : readGitCommit()?.trim();
|
||||
// GITHUB_SHA names the workflow invocation and can differ from a checked-out tag.
|
||||
const commit = explicitCommit || checkedOutCommit || env.GITHUB_SHA?.trim();
|
||||
if (commit && !FULL_GIT_COMMIT_RE.test(commit)) {
|
||||
throw new Error("build commit must be a full 40-character hexadecimal SHA");
|
||||
}
|
||||
const buildEnv = {
|
||||
...env,
|
||||
OPENCLAW_BUILD_TIMESTAMP: explicitTimestamp || now().toISOString(),
|
||||
};
|
||||
if (commit) {
|
||||
buildEnv.GIT_COMMIT = commit.toLowerCase();
|
||||
}
|
||||
return buildEnv;
|
||||
}
|
||||
|
||||
function resolveStepEnv(step, env, platform) {
|
||||
const stepEnv = step.env ? Object.assign({}, env, step.env) : env;
|
||||
if (platform !== "win32" || !step.windowsNodeOptions) {
|
||||
@@ -586,11 +619,12 @@ if (isMainModule()) {
|
||||
if (args?.help) {
|
||||
console.log(buildAllUsage());
|
||||
} else {
|
||||
const buildEnv = resolveBuildAllEnvironment();
|
||||
const timings = [];
|
||||
let exitCode = 0;
|
||||
for (const step of resolveBuildAllSteps(args.profile)) {
|
||||
const startedAt = performance.now();
|
||||
const cacheState = resolveBuildAllStepCacheState(step);
|
||||
const cacheState = resolveBuildAllStepCacheState(step, { env: buildEnv });
|
||||
if (process.env.OPENCLAW_BUILD_CACHE !== "0" && cacheState.fresh) {
|
||||
restoreBuildAllStepCacheOutputs(cacheState);
|
||||
const durationMs = performance.now() - startedAt;
|
||||
@@ -599,7 +633,7 @@ if (isMainModule()) {
|
||||
continue;
|
||||
}
|
||||
console.error(`[build-all] ${step.label}`);
|
||||
const invocation = resolveBuildAllStep(step);
|
||||
const invocation = resolveBuildAllStep(step, { env: buildEnv });
|
||||
const result = spawnSync(invocation.command, invocation.args, invocation.options);
|
||||
const durationMs = performance.now() - startedAt;
|
||||
if (typeof result.status === "number") {
|
||||
@@ -611,7 +645,10 @@ if (isMainModule()) {
|
||||
exitCode = result.status;
|
||||
break;
|
||||
}
|
||||
writeBuildAllStepCacheStamp(step, resolveBuildAllStepCacheStampState(step, cacheState));
|
||||
writeBuildAllStepCacheStamp(
|
||||
step,
|
||||
resolveBuildAllStepCacheStampState(step, cacheState, { env: buildEnv }),
|
||||
);
|
||||
timings.push({ label: step.label, status: "ran", durationMs });
|
||||
console.error(`[build-all] ${step.label} done in ${formatBuildAllDuration(durationMs)}`);
|
||||
continue;
|
||||
|
||||
@@ -866,7 +866,12 @@ const server = await createServer({
|
||||
clearScreen: false,
|
||||
configFile: path.join(uiRoot, "vite.config.ts"),
|
||||
define: {
|
||||
OPENCLAW_CONTROL_UI_BUILD_ID: JSON.stringify("mock"),
|
||||
"globalThis.OPENCLAW_CONTROL_UI_BUILD_INFO": JSON.stringify({
|
||||
version: "2026.7.10",
|
||||
commit: "0123456789abcdef0123456789abcdef01234567",
|
||||
builtAt: "2026-07-10T12:34:56.000Z",
|
||||
buildId: "mock",
|
||||
}),
|
||||
},
|
||||
logLevel: "error",
|
||||
optimizeDeps: {
|
||||
|
||||
@@ -3,6 +3,7 @@ set -euo pipefail
|
||||
|
||||
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
|
||||
source "$ROOT_DIR/scripts/lib/docker-build.sh"
|
||||
source "$ROOT_DIR/scripts/lib/build-metadata.sh"
|
||||
source "$ROOT_DIR/scripts/lib/host-timeout.sh"
|
||||
COMPOSE_FILE="$ROOT_DIR/docker-compose.yml"
|
||||
EXTRA_COMPOSE_FILE="$ROOT_DIR/docker-compose.extra.yml"
|
||||
@@ -754,7 +755,14 @@ if [[ -n "$OFFLINE_MODE" ]]; then
|
||||
echo "==> Using preloaded Docker image: $IMAGE_NAME"
|
||||
elif [[ "$IMAGE_NAME" == "openclaw:local" ]]; then
|
||||
echo "==> Building Docker image: $IMAGE_NAME"
|
||||
BUILD_GIT_COMMIT="$(openclaw_resolve_git_commit "$ROOT_DIR")"
|
||||
BUILD_TIMESTAMP="$(openclaw_resolve_build_timestamp)"
|
||||
PROVENANCE_BUILD_ARGS=(--build-arg "OPENCLAW_BUILD_TIMESTAMP=${BUILD_TIMESTAMP}")
|
||||
if [[ "$BUILD_GIT_COMMIT" =~ ^[0-9a-fA-F]{40}$ ]]; then
|
||||
PROVENANCE_BUILD_ARGS+=(--build-arg "GIT_COMMIT=${BUILD_GIT_COMMIT}")
|
||||
fi
|
||||
run_docker_build \
|
||||
"${PROVENANCE_BUILD_ARGS[@]}" \
|
||||
--build-arg "OPENCLAW_IMAGE_APT_PACKAGES=${OPENCLAW_IMAGE_APT_PACKAGES}" \
|
||||
--build-arg "OPENCLAW_IMAGE_PIP_PACKAGES=${OPENCLAW_IMAGE_PIP_PACKAGES}" \
|
||||
--build-arg "OPENCLAW_EXTENSIONS=${OPENCLAW_EXTENSIONS}" \
|
||||
|
||||
@@ -24,6 +24,7 @@ VERSION_HELPER="${ROOT_DIR}/scripts/ios-write-version-xcconfig.sh"
|
||||
IOS_VERSION_HELPER="${ROOT_DIR}/scripts/ios-version.ts"
|
||||
VERSION_SYNC_HELPER="${ROOT_DIR}/scripts/ios-sync-versioning.ts"
|
||||
RELEASE_SIGNING_HELPER="${ROOT_DIR}/scripts/ios-release-signing.mjs"
|
||||
RELEASE_SOURCE_HELPER="${ROOT_DIR}/scripts/apple-release-source-check.sh"
|
||||
CANONICAL_TEAM_ID="FWJYW4S8P8"
|
||||
|
||||
BUILD_NUMBER=""
|
||||
@@ -31,6 +32,7 @@ RELEASE_VERSION=""
|
||||
TEAM_ID="${IOS_DEVELOPMENT_TEAM:-}"
|
||||
IOS_VERSION=""
|
||||
RELEASE_SIGNING_XCCONFIG=""
|
||||
RELEASE_GIT_COMMIT=""
|
||||
|
||||
prepare_build_dir() {
|
||||
if [[ -L "${BUILD_DIR}" ]]; then
|
||||
@@ -129,6 +131,11 @@ if [[ -n "${OPENCLAW_PUSH_RELAY_BASE_URL:-}" || -n "${IOS_PUSH_RELAY_BASE_URL:-}
|
||||
exit 1
|
||||
fi
|
||||
|
||||
source "${ROOT_DIR}/scripts/lib/build-metadata.sh"
|
||||
RELEASE_GIT_COMMIT="$(OPENCLAW_REQUIRE_BUILD_METADATA=1 openclaw_resolve_git_commit "${ROOT_DIR}")"
|
||||
bash "${RELEASE_SOURCE_HELPER}" --root "${ROOT_DIR}" --expected-commit "${RELEASE_GIT_COMMIT}"
|
||||
export GIT_COMMIT="${RELEASE_GIT_COMMIT}"
|
||||
|
||||
prepare_build_dir
|
||||
|
||||
(
|
||||
@@ -148,7 +155,8 @@ if [[ -z "${RELEASE_SIGNING_XCCONFIG}" ]]; then
|
||||
fi
|
||||
|
||||
(
|
||||
bash "${VERSION_HELPER}" --version "${IOS_VERSION}" --build-number "${BUILD_NUMBER}"
|
||||
OPENCLAW_REQUIRE_BUILD_METADATA=1 \
|
||||
bash "${VERSION_HELPER}" --version "${IOS_VERSION}" --build-number "${BUILD_NUMBER}"
|
||||
)
|
||||
node "${ROOT_DIR}/scripts/ios-write-swift-filelist.mjs"
|
||||
|
||||
|
||||
@@ -4,13 +4,16 @@ set -euo pipefail
|
||||
usage() {
|
||||
cat <<'EOF'
|
||||
Usage:
|
||||
scripts/ios-validate-app-store-ipa.sh --ipa apps/ios/build/app-store/OpenClaw-<version>.ipa
|
||||
scripts/ios-validate-app-store-ipa.sh --ipa apps/ios/build/app-store/OpenClaw-<version>.ipa \
|
||||
[--expected-commit <full-sha>] [--expected-build-timestamp <utc-iso>]
|
||||
|
||||
Validates the exported iOS App Store IPA before App Store Connect upload.
|
||||
EOF
|
||||
}
|
||||
|
||||
IPA_PATH=""
|
||||
EXPECTED_GIT_COMMIT=""
|
||||
EXPECTED_BUILD_TIMESTAMP=""
|
||||
EXPECTED_TEAM_ID="FWJYW4S8P8"
|
||||
EXPECTED_BUNDLE_ID="ai.openclawfoundation.app"
|
||||
EXPECTED_PROFILE_NAME="OpenClaw App Store ai.openclawfoundation.app"
|
||||
@@ -40,6 +43,16 @@ while [[ $# -gt 0 ]]; do
|
||||
IPA_PATH="$2"
|
||||
shift 2
|
||||
;;
|
||||
--expected-commit)
|
||||
require_option_value "$1" "${2-}"
|
||||
EXPECTED_GIT_COMMIT="$(printf '%s' "$2" | tr '[:upper:]' '[:lower:]')"
|
||||
shift 2
|
||||
;;
|
||||
--expected-build-timestamp)
|
||||
require_option_value "$1" "${2-}"
|
||||
EXPECTED_BUILD_TIMESTAMP="$2"
|
||||
shift 2
|
||||
;;
|
||||
-h|--help)
|
||||
usage
|
||||
exit 0
|
||||
@@ -158,8 +171,32 @@ assert_plist_empty_or_absent() {
|
||||
fi
|
||||
}
|
||||
|
||||
assert_build_provenance() {
|
||||
local commit
|
||||
local timestamp
|
||||
commit="$(plist_value "${info_plist}" "OpenClawGitCommit")"
|
||||
timestamp="$(plist_value "${info_plist}" "OpenClawBuildTimestamp")"
|
||||
if [[ ! "${commit}" =~ ^[0-9a-f]{40}$ ]]; then
|
||||
echo "Invalid IPA: OpenClawGitCommit must be a full lowercase commit SHA; got ${commit:-missing}." >&2
|
||||
exit 1
|
||||
fi
|
||||
if [[ ! "${timestamp}" =~ ^[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}(\.[0-9]{3})?Z$ ]]; then
|
||||
echo "Invalid IPA: OpenClawBuildTimestamp must be a canonical UTC timestamp; got ${timestamp:-missing}." >&2
|
||||
exit 1
|
||||
fi
|
||||
if [[ -n "${EXPECTED_GIT_COMMIT}" && "${commit}" != "${EXPECTED_GIT_COMMIT}" ]]; then
|
||||
echo "Invalid IPA: embedded Git commit mismatch; expected ${EXPECTED_GIT_COMMIT}, got ${commit}." >&2
|
||||
exit 1
|
||||
fi
|
||||
if [[ -n "${EXPECTED_BUILD_TIMESTAMP}" && "${timestamp}" != "${EXPECTED_BUILD_TIMESTAMP}" ]]; then
|
||||
echo "Invalid IPA: embedded build timestamp mismatch; expected ${EXPECTED_BUILD_TIMESTAMP}, got ${timestamp}." >&2
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
assert_plist_string "${info_plist}" "CFBundleIdentifier" "${EXPECTED_BUNDLE_ID}" "bundle identifier mismatch"
|
||||
assert_plist_string "${info_plist}" "OpenClawPushMode" "${EXPECTED_PUSH_MODE}" "push mode mismatch"
|
||||
assert_build_provenance
|
||||
assert_plist_empty_or_absent "${info_plist}" "OpenClawPushRelayBaseURL" "push relay URL override"
|
||||
assert_plist_key_absent "${info_plist}" "OpenClawPushTransport" "legacy push transport"
|
||||
assert_plist_key_absent "${info_plist}" "OpenClawPushDistribution" "legacy push distribution"
|
||||
|
||||
@@ -14,6 +14,7 @@ EOF
|
||||
}
|
||||
|
||||
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
source "${ROOT_DIR}/scripts/lib/build-metadata.sh"
|
||||
IOS_DIR="${ROOT_DIR}/apps/ios"
|
||||
BUILD_DIR="${IOS_DIR}/build"
|
||||
VERSION_XCCONFIG="${IOS_DIR}/build/Version.xcconfig"
|
||||
@@ -22,6 +23,8 @@ IOS_VERSION=""
|
||||
MARKETING_VERSION=""
|
||||
BUILD_NUMBER=""
|
||||
RELEASE_VERSION=""
|
||||
RESOLVED_GIT_COMMIT=""
|
||||
RESOLVED_BUILD_TIMESTAMP=""
|
||||
|
||||
require_option_value() {
|
||||
local option="$1"
|
||||
@@ -114,6 +117,9 @@ if [[ ! "${BUILD_NUMBER}" =~ ^[0-9]+$ ]]; then
|
||||
exit 1
|
||||
fi
|
||||
|
||||
RESOLVED_GIT_COMMIT="$(openclaw_resolve_git_commit "${ROOT_DIR}")"
|
||||
RESOLVED_BUILD_TIMESTAMP="$(openclaw_resolve_build_timestamp)"
|
||||
|
||||
prepare_build_dir
|
||||
|
||||
write_generated_file "${VERSION_XCCONFIG}" <<EOF
|
||||
@@ -122,6 +128,8 @@ write_generated_file "${VERSION_XCCONFIG}" <<EOF
|
||||
OPENCLAW_IOS_VERSION = ${IOS_VERSION}
|
||||
OPENCLAW_MARKETING_VERSION = ${MARKETING_VERSION}
|
||||
OPENCLAW_BUILD_VERSION = ${BUILD_NUMBER}
|
||||
OPENCLAW_GIT_COMMIT = ${RESOLVED_GIT_COMMIT}
|
||||
OPENCLAW_BUILD_TIMESTAMP = ${RESOLVED_BUILD_TIMESTAMP}
|
||||
EOF
|
||||
|
||||
echo "Prepared iOS version settings: ios=${IOS_VERSION} marketing=${MARKETING_VERSION} build=${BUILD_NUMBER}"
|
||||
echo "Prepared iOS version settings: ios=${IOS_VERSION} marketing=${MARKETING_VERSION} build=${BUILD_NUMBER} commit=${RESOLVED_GIT_COMMIT} built=${RESOLVED_BUILD_TIMESTAMP}"
|
||||
|
||||
@@ -0,0 +1,119 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
openclaw_trim_build_metadata_value() {
|
||||
local value="${1:-}"
|
||||
value="${value#"${value%%[![:space:]]*}"}"
|
||||
value="${value%"${value##*[![:space:]]}"}"
|
||||
printf '%s' "${value}"
|
||||
}
|
||||
|
||||
openclaw_is_full_git_commit() {
|
||||
[[ "${1:-}" =~ ^[0-9a-fA-F]{40}$ ]]
|
||||
}
|
||||
|
||||
openclaw_normalize_utc_build_timestamp() {
|
||||
local value="${1:-}"
|
||||
local LC_ALL=C
|
||||
[[ "${value}" =~ ^([0-9]{4})-([0-9]{2})-([0-9]{2})T([0-9]{2}):([0-9]{2}):([0-9]{2})([.][0-9]{1,3})?Z$ ]] || return 1
|
||||
|
||||
local year="${BASH_REMATCH[1]}"
|
||||
local month="${BASH_REMATCH[2]}"
|
||||
local day="${BASH_REMATCH[3]}"
|
||||
local hour="${BASH_REMATCH[4]}"
|
||||
local minute="${BASH_REMATCH[5]}"
|
||||
local second="${BASH_REMATCH[6]}"
|
||||
local fraction="${BASH_REMATCH[7]:-}"
|
||||
local year_number=$((10#${year}))
|
||||
local month_number=$((10#${month}))
|
||||
local day_number=$((10#${day}))
|
||||
local hour_number=$((10#${hour}))
|
||||
local minute_number=$((10#${minute}))
|
||||
local second_number=$((10#${second}))
|
||||
local max_day
|
||||
|
||||
((month_number >= 1 && month_number <= 12)) || return 1
|
||||
((hour_number <= 23 && minute_number <= 59 && second_number <= 59)) || return 1
|
||||
case "${month_number}" in
|
||||
1 | 3 | 5 | 7 | 8 | 10 | 12) max_day=31 ;;
|
||||
4 | 6 | 9 | 11) max_day=30 ;;
|
||||
2)
|
||||
max_day=28
|
||||
if ((year_number % 400 == 0 || (year_number % 4 == 0 && year_number % 100 != 0))); then
|
||||
max_day=29
|
||||
fi
|
||||
;;
|
||||
esac
|
||||
((day_number >= 1 && day_number <= max_day)) || return 1
|
||||
|
||||
fraction="${fraction#.}"
|
||||
case "${#fraction}" in
|
||||
0) fraction="000" ;;
|
||||
1) fraction="${fraction}00" ;;
|
||||
2) fraction="${fraction}0" ;;
|
||||
esac
|
||||
printf '%s-%s-%sT%s:%s:%s.%sZ' \
|
||||
"${year}" "${month}" "${day}" "${hour}" "${minute}" "${second}" "${fraction}"
|
||||
}
|
||||
|
||||
openclaw_is_utc_build_timestamp() {
|
||||
openclaw_normalize_utc_build_timestamp "${1:-}" >/dev/null
|
||||
}
|
||||
|
||||
openclaw_resolve_git_commit() {
|
||||
local root_dir="$1"
|
||||
local candidate
|
||||
local source_name
|
||||
for source_name in GIT_COMMIT GIT_SHA; do
|
||||
candidate="$(openclaw_trim_build_metadata_value "${!source_name:-}")"
|
||||
[[ -n "${candidate}" ]] && break
|
||||
done
|
||||
if [[ -n "${candidate}" ]]; then
|
||||
if ! openclaw_is_full_git_commit "${candidate}"; then
|
||||
echo "ERROR: ${source_name} must be a full 40-character hexadecimal commit." >&2
|
||||
return 1
|
||||
fi
|
||||
printf '%s' "${candidate}" | tr '[:upper:]' '[:lower:]'
|
||||
return 0
|
||||
fi
|
||||
|
||||
candidate="$( (cd "${root_dir}" && git rev-parse HEAD) 2>/dev/null || true)"
|
||||
if [[ -n "${candidate}" ]] && ! openclaw_is_full_git_commit "${candidate}"; then
|
||||
echo "ERROR: git rev-parse HEAD must return a full 40-character hexadecimal commit." >&2
|
||||
return 1
|
||||
fi
|
||||
# GITHUB_SHA names the workflow invocation and can differ from a checked-out tag.
|
||||
if [[ -z "${candidate}" ]]; then
|
||||
candidate="$(openclaw_trim_build_metadata_value "${GITHUB_SHA:-}")"
|
||||
if [[ -n "${candidate}" ]] && ! openclaw_is_full_git_commit "${candidate}"; then
|
||||
echo "ERROR: GITHUB_SHA must be a full 40-character hexadecimal commit." >&2
|
||||
return 1
|
||||
fi
|
||||
fi
|
||||
if [[ -z "${candidate}" ]]; then
|
||||
if [[ "${OPENCLAW_REQUIRE_BUILD_METADATA:-0}" == "1" ]]; then
|
||||
echo "ERROR: Unable to resolve a full Git commit for the release build." >&2
|
||||
return 1
|
||||
fi
|
||||
printf 'unknown'
|
||||
return 0
|
||||
fi
|
||||
printf '%s' "${candidate}" | tr '[:upper:]' '[:lower:]'
|
||||
}
|
||||
|
||||
openclaw_resolve_build_timestamp() {
|
||||
local candidate
|
||||
candidate="$(openclaw_trim_build_metadata_value "${OPENCLAW_BUILD_TIMESTAMP:-}")"
|
||||
if [[ -n "${candidate}" ]]; then
|
||||
if ! candidate="$(openclaw_normalize_utc_build_timestamp "${candidate}")"; then
|
||||
echo "ERROR: OPENCLAW_BUILD_TIMESTAMP must be an ISO-8601 UTC timestamp." >&2
|
||||
return 1
|
||||
fi
|
||||
else
|
||||
candidate="$(date -u +"%Y-%m-%dT%H:%M:%SZ")"
|
||||
if ! candidate="$(openclaw_normalize_utc_build_timestamp "${candidate}")"; then
|
||||
echo "ERROR: Unable to resolve an ISO-8601 UTC timestamp for the build." >&2
|
||||
return 1
|
||||
fi
|
||||
fi
|
||||
printf '%s' "${candidate}"
|
||||
}
|
||||
@@ -8,6 +8,7 @@ import { formatErrorMessage } from "../src/infra/errors.ts";
|
||||
import { writePackageDistInventory } from "../src/infra/package-dist-inventory.ts";
|
||||
import { preparePackageChangelog } from "./package-changelog.mjs";
|
||||
import { createPnpmRunnerSpawnSpec } from "./pnpm-runner.mjs";
|
||||
const FULL_GIT_COMMIT_RE = /^[0-9a-f]{40}$/iu;
|
||||
const requiredPreparedPathGroups = [
|
||||
["dist/index.js", "dist/index.mjs"],
|
||||
["dist/control-ui/index.html"],
|
||||
@@ -155,13 +156,42 @@ function run(command: string, args: string[], options: SpawnSyncOptions = {}): v
|
||||
process.exit(result.status ?? 1);
|
||||
}
|
||||
|
||||
function runPnpm(args: string[]): void {
|
||||
export function resolvePrepackBuildEnvironment(
|
||||
env: NodeJS.ProcessEnv = process.env,
|
||||
now: () => Date = () => new Date(),
|
||||
readGitCommit: () => string | null = () => {
|
||||
const result = spawnSync("git", ["rev-parse", "HEAD"], {
|
||||
encoding: "utf8",
|
||||
stdio: ["ignore", "pipe", "ignore"],
|
||||
});
|
||||
return result.status === 0 ? result.stdout.trim() : null;
|
||||
},
|
||||
): NodeJS.ProcessEnv {
|
||||
const explicitTimestamp = env.OPENCLAW_BUILD_TIMESTAMP?.trim();
|
||||
const explicitCommit = env.GIT_COMMIT?.trim() || env.GIT_SHA?.trim();
|
||||
const checkedOutCommit = explicitCommit ? null : readGitCommit()?.trim();
|
||||
// GITHUB_SHA names the workflow invocation and can differ from a checked-out tag.
|
||||
const commit = explicitCommit || checkedOutCommit || env.GITHUB_SHA?.trim();
|
||||
if (commit && !FULL_GIT_COMMIT_RE.test(commit)) {
|
||||
throw new Error("build commit must be a full 40-character hexadecimal SHA");
|
||||
}
|
||||
const buildEnv: NodeJS.ProcessEnv = {
|
||||
...env,
|
||||
OPENCLAW_BUILD_TIMESTAMP: explicitTimestamp || now().toISOString(),
|
||||
};
|
||||
if (commit) {
|
||||
buildEnv.GIT_COMMIT = commit.toLowerCase();
|
||||
}
|
||||
return buildEnv;
|
||||
}
|
||||
|
||||
function runPnpm(args: string[], env: NodeJS.ProcessEnv): void {
|
||||
const command = createPnpmRunnerSpawnSpec({
|
||||
env: process.env,
|
||||
env,
|
||||
pnpmArgs: args,
|
||||
stdio: "inherit",
|
||||
});
|
||||
run(command.command, command.args, command.options);
|
||||
run(command.command, command.args, { ...command.options, env });
|
||||
}
|
||||
|
||||
function runBuildSmoke(): void {
|
||||
@@ -173,8 +203,9 @@ async function writeDistInventory(): Promise<void> {
|
||||
}
|
||||
|
||||
async function main(): Promise<void> {
|
||||
runPnpm(["build"]);
|
||||
runPnpm(["ui:build"]);
|
||||
const buildEnv = resolvePrepackBuildEnvironment();
|
||||
runPnpm(["build"], buildEnv);
|
||||
runPnpm(["ui:build"], buildEnv);
|
||||
ensurePreparedArtifacts();
|
||||
await writeDistInventory();
|
||||
runBuildSmoke();
|
||||
|
||||
@@ -7,6 +7,7 @@ set -euo pipefail
|
||||
ROOT_DIR="$(cd "$(dirname "$0")/.." && pwd)"
|
||||
source "$ROOT_DIR/scripts/lib/plistbuddy.sh"
|
||||
source "$ROOT_DIR/scripts/lib/swift-toolchain.sh"
|
||||
source "$ROOT_DIR/scripts/lib/build-metadata.sh"
|
||||
APP_ROOT="$ROOT_DIR/dist/OpenClaw.app"
|
||||
BUILD_ROOT="$ROOT_DIR/apps/macos/.build"
|
||||
PRODUCT="OpenClaw"
|
||||
@@ -15,12 +16,26 @@ MLX_TTS_HELPER_ROOT="$ROOT_DIR/apps/macos-mlx-tts"
|
||||
MLX_TTS_HELPER_BUILD_ROOT="$MLX_TTS_HELPER_ROOT/.build"
|
||||
BUNDLE_ID="${BUNDLE_ID:-ai.openclaw.mac.debug}"
|
||||
PKG_VERSION="$(cd "$ROOT_DIR" && node -p "require('./package.json').version" 2>/dev/null || echo "0.0.0")"
|
||||
BUILD_TS=$(date -u +"%Y-%m-%dT%H:%M:%SZ")
|
||||
GIT_COMMIT=$(cd "$ROOT_DIR" && git rev-parse --short HEAD 2>/dev/null || echo "unknown")
|
||||
BUILD_CONFIG="${BUILD_CONFIG:-debug}"
|
||||
BUILD_TS="$(openclaw_resolve_build_timestamp)"
|
||||
if [[ "$BUILD_CONFIG" == "release" ]]; then
|
||||
OPENCLAW_REQUIRE_BUILD_METADATA=1
|
||||
fi
|
||||
BUILD_GIT_COMMIT="$(openclaw_resolve_git_commit "$ROOT_DIR")"
|
||||
if [[ "$BUILD_CONFIG" == "release" ]]; then
|
||||
bash "$ROOT_DIR/scripts/apple-release-source-check.sh" \
|
||||
--root "$ROOT_DIR" \
|
||||
--expected-commit "$BUILD_GIT_COMMIT"
|
||||
fi
|
||||
export OPENCLAW_BUILD_TIMESTAMP="$BUILD_TS"
|
||||
if openclaw_is_full_git_commit "$BUILD_GIT_COMMIT"; then
|
||||
export GIT_COMMIT="$BUILD_GIT_COMMIT"
|
||||
else
|
||||
unset GIT_COMMIT
|
||||
fi
|
||||
GIT_BUILD_NUMBER=$(cd "$ROOT_DIR" && git rev-list --count HEAD 2>/dev/null || echo "0")
|
||||
APP_VERSION="${APP_VERSION:-$PKG_VERSION}"
|
||||
APP_BUILD="${APP_BUILD:-}"
|
||||
BUILD_CONFIG="${BUILD_CONFIG:-debug}"
|
||||
if [[ -n "${BUILD_ARCHS:-}" ]]; then
|
||||
BUILD_ARCHS_VALUE="${BUILD_ARCHS}"
|
||||
elif [[ "$BUILD_CONFIG" == "release" ]]; then
|
||||
@@ -222,7 +237,14 @@ plist_set_string_required "$APP_ROOT/Contents/Info.plist" CFBundleIdentifier "$B
|
||||
plist_set_string_required "$APP_ROOT/Contents/Info.plist" CFBundleShortVersionString "$APP_VERSION"
|
||||
plist_set_string_required "$APP_ROOT/Contents/Info.plist" CFBundleVersion "$APP_BUILD"
|
||||
plist_set_string_required "$APP_ROOT/Contents/Info.plist" OpenClawBuildTimestamp "$BUILD_TS"
|
||||
plist_set_string_required "$APP_ROOT/Contents/Info.plist" OpenClawGitCommit "$GIT_COMMIT"
|
||||
plist_set_string_required "$APP_ROOT/Contents/Info.plist" OpenClawGitCommit "$BUILD_GIT_COMMIT"
|
||||
if [[ "$BUILD_CONFIG" == "release" ]]; then
|
||||
EMBEDDED_GIT_COMMIT="$(plist_print_required "$APP_ROOT/Contents/Info.plist" OpenClawGitCommit)"
|
||||
if [[ "$EMBEDDED_GIT_COMMIT" != "$BUILD_GIT_COMMIT" ]]; then
|
||||
echo "ERROR: Release app embedded Git commit '$EMBEDDED_GIT_COMMIT', expected '$BUILD_GIT_COMMIT'." >&2
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
plist_set_or_add_string "$APP_ROOT/Contents/Info.plist" SUFeedURL "$SPARKLE_FEED_URL"
|
||||
plist_set_or_add_string "$APP_ROOT/Contents/Info.plist" SUPublicEDKey "$SPARKLE_PUBLIC_ED_KEY"
|
||||
plist_set_or_add_bool "$APP_ROOT/Contents/Info.plist" SUEnableAutomaticChecks "$AUTO_CHECKS"
|
||||
|
||||
@@ -17,6 +17,7 @@
|
||||
set -euo pipefail
|
||||
|
||||
REPO_PATH="${OPENCLAW_REPO_PATH:-$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)}"
|
||||
source "$REPO_PATH/scripts/lib/build-metadata.sh"
|
||||
source "$REPO_PATH/scripts/lib/host-timeout.sh"
|
||||
RUN_SCRIPT_SRC="$REPO_PATH/scripts/run-openclaw-podman.sh"
|
||||
QUADLET_TEMPLATE="$REPO_PATH/scripts/podman/openclaw.container.in"
|
||||
@@ -373,6 +374,12 @@ ensure_private_existing_dir_owned_by_user "workspace directory" "$OPENCLAW_WORKS
|
||||
OPENCLAW_IMAGE_APT_PACKAGES="${OPENCLAW_IMAGE_APT_PACKAGES-${OPENCLAW_DOCKER_APT_PACKAGES:-}}"
|
||||
OPENCLAW_IMAGE_PIP_PACKAGES="${OPENCLAW_IMAGE_PIP_PACKAGES:-}"
|
||||
BUILD_ARGS=()
|
||||
BUILD_GIT_COMMIT="$(openclaw_resolve_git_commit "$REPO_PATH")"
|
||||
BUILD_TIMESTAMP="$(openclaw_resolve_build_timestamp)"
|
||||
BUILD_ARGS+=(--build-arg "OPENCLAW_BUILD_TIMESTAMP=${BUILD_TIMESTAMP}")
|
||||
if [[ "$BUILD_GIT_COMMIT" =~ ^[0-9a-fA-F]{40}$ ]]; then
|
||||
BUILD_ARGS+=(--build-arg "GIT_COMMIT=${BUILD_GIT_COMMIT}")
|
||||
fi
|
||||
if [[ -n "$OPENCLAW_IMAGE_APT_PACKAGES" ]]; then
|
||||
BUILD_ARGS+=(--build-arg "OPENCLAW_IMAGE_APT_PACKAGES=${OPENCLAW_IMAGE_APT_PACKAGES}")
|
||||
fi
|
||||
|
||||
@@ -1007,6 +1007,7 @@ const TOOLING_SOURCE_TEST_TARGETS = new Map([
|
||||
["test/scripts/openclaw-cross-os-release-workflow.test.ts"],
|
||||
],
|
||||
["scripts/mobile-release-ref.ts", ["test/scripts/mobile-release-ref.test.ts"]],
|
||||
["scripts/apple-release-source-check.sh", ["test/scripts/apple-release-source-check.test.ts"]],
|
||||
["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"]],
|
||||
@@ -1016,7 +1017,10 @@ const TOOLING_SOURCE_TEST_TARGETS = new Map([
|
||||
],
|
||||
["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"]],
|
||||
[
|
||||
"scripts/ios-release-prepare.sh",
|
||||
["test/scripts/ios-release-prepare.test.ts", "test/scripts/ios-release-wrapper-args.test.ts"],
|
||||
],
|
||||
["scripts/ios-release-signing.mjs", ["test/scripts/ios-release-signing.test.ts"]],
|
||||
["apps/ios/fastlane/Fastfile", ["test/scripts/ios-release-fastlane-gates.test.ts"]],
|
||||
[
|
||||
@@ -1241,6 +1245,16 @@ const TOOLING_SOURCE_TEST_TARGETS = new Map([
|
||||
"test/release-check.test.ts",
|
||||
],
|
||||
],
|
||||
[
|
||||
"scripts/lib/build-metadata.sh",
|
||||
[
|
||||
"src/docker-setup.e2e.test.ts",
|
||||
"test/scripts/apple-release-source-check.test.ts",
|
||||
"test/scripts/ios-version.test.ts",
|
||||
"test/scripts/package-mac-app.test.ts",
|
||||
"test/scripts/test-install-sh-docker.test.ts",
|
||||
],
|
||||
],
|
||||
[
|
||||
"scripts/lib/plistbuddy.sh",
|
||||
[
|
||||
@@ -1419,6 +1433,7 @@ const TOOLING_SOURCE_TEST_TARGETS = new Map([
|
||||
],
|
||||
["scripts/podman/openclaw.container.in", ["test/scripts/test-install-sh-docker.test.ts"]],
|
||||
["scripts/ios-run.sh", ["test/scripts/ios-run.test.ts"]],
|
||||
["scripts/ios-write-version-xcconfig.sh", ["test/scripts/ios-version.test.ts"]],
|
||||
["scripts/create-dmg.sh", ["test/scripts/create-dmg.test.ts"]],
|
||||
["scripts/kova-ci-summary.mjs", ["test/scripts/kova-ci-summary.test.ts"]],
|
||||
["scripts/make_appcast.sh", ["test/scripts/make-appcast.test.ts"]],
|
||||
|
||||
+121
-29
@@ -1,48 +1,140 @@
|
||||
// Write Build Info script supports OpenClaw repository automation.
|
||||
import { execSync } from "node:child_process";
|
||||
// Writes canonical package build provenance for runtime and release diagnostics.
|
||||
import { execFileSync } from "node:child_process";
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { fileURLToPath, pathToFileURL } from "node:url";
|
||||
|
||||
const rootDir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
|
||||
const distDir = path.join(rootDir, "dist");
|
||||
const pkgPath = path.join(rootDir, "package.json");
|
||||
const defaultRootDir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
|
||||
const FULL_GIT_COMMIT_RE = /^[0-9a-f]{40}$/iu;
|
||||
const UTC_ISO_TIMESTAMP_RE = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d{1,3})?Z$/u;
|
||||
|
||||
const readPackageVersion = () => {
|
||||
type ExecFileSync = (
|
||||
command: string,
|
||||
args: string[],
|
||||
options: {
|
||||
cwd: string;
|
||||
encoding: "utf8";
|
||||
stdio: ["ignore", "pipe", "ignore"];
|
||||
},
|
||||
) => string | Buffer;
|
||||
|
||||
export type BuildInfo = {
|
||||
version: string | null;
|
||||
commit: string | null;
|
||||
builtAt: string;
|
||||
};
|
||||
|
||||
type ResolveBuildInfoOptions = {
|
||||
rootDir?: string;
|
||||
env?: NodeJS.ProcessEnv;
|
||||
execFileSync?: ExecFileSync;
|
||||
now?: () => Date;
|
||||
};
|
||||
|
||||
function readPackageVersion(rootDir: string): string | null {
|
||||
try {
|
||||
const raw = fs.readFileSync(pkgPath, "utf8");
|
||||
const parsed = JSON.parse(raw) as { version?: string };
|
||||
return parsed.version ?? null;
|
||||
const raw = fs.readFileSync(path.join(rootDir, "package.json"), "utf8");
|
||||
const parsed = JSON.parse(raw) as { version?: unknown };
|
||||
return typeof parsed.version === "string" && parsed.version.trim()
|
||||
? parsed.version.trim()
|
||||
: null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
const resolveCommit = () => {
|
||||
const envCommit = process.env.GIT_COMMIT?.trim() || process.env.GIT_SHA?.trim();
|
||||
if (envCommit) {
|
||||
return envCommit;
|
||||
export function normalizeBuildCommit(raw: string, source = "GIT_COMMIT"): string {
|
||||
const commit = raw.trim().toLowerCase();
|
||||
if (!FULL_GIT_COMMIT_RE.test(commit)) {
|
||||
throw new Error(`${source} must be a full 40-character Git commit SHA.`);
|
||||
}
|
||||
return commit;
|
||||
}
|
||||
|
||||
export function normalizeBuildTimestamp(raw: string, source = "OPENCLAW_BUILD_TIMESTAMP"): string {
|
||||
const timestamp = raw.trim();
|
||||
if (!UTC_ISO_TIMESTAMP_RE.test(timestamp)) {
|
||||
throw new Error(`${source} must be an ISO-8601 UTC timestamp ending in Z.`);
|
||||
}
|
||||
|
||||
const parsed = new Date(timestamp);
|
||||
if (!Number.isFinite(parsed.getTime())) {
|
||||
throw new Error(`${source} must be a valid ISO-8601 UTC timestamp.`);
|
||||
}
|
||||
|
||||
const normalizedInput = timestamp.replace(/(?:\.(\d{1,3}))?Z$/u, (_match, fraction) => {
|
||||
return `.${String(fraction ?? "").padEnd(3, "0")}Z`;
|
||||
});
|
||||
const normalized = parsed.toISOString();
|
||||
if (normalized !== normalizedInput) {
|
||||
throw new Error(`${source} must be a valid ISO-8601 UTC timestamp.`);
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
function resolveGitCommit(rootDir: string, execFileSyncImpl: ExecFileSync): string | null {
|
||||
let raw: string;
|
||||
try {
|
||||
return execSync("git rev-parse HEAD", {
|
||||
raw = execFileSyncImpl("git", ["rev-parse", "HEAD"], {
|
||||
cwd: rootDir,
|
||||
encoding: "utf8",
|
||||
stdio: ["ignore", "pipe", "ignore"],
|
||||
})
|
||||
.toString()
|
||||
.trim();
|
||||
}).toString();
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
return normalizeBuildCommit(raw, "git rev-parse HEAD");
|
||||
}
|
||||
|
||||
const version = readPackageVersion();
|
||||
const commit = resolveCommit();
|
||||
export function resolveBuildInfo(options: ResolveBuildInfoOptions = {}): BuildInfo {
|
||||
const rootDir = options.rootDir ?? defaultRootDir;
|
||||
const env = options.env ?? process.env;
|
||||
const explicitCommit = env.GIT_COMMIT?.trim();
|
||||
const explicitSha = env.GIT_SHA?.trim();
|
||||
const githubSha = env.GITHUB_SHA?.trim();
|
||||
const explicitTimestamp = env.OPENCLAW_BUILD_TIMESTAMP?.trim();
|
||||
const checkedOutCommit =
|
||||
explicitCommit || explicitSha
|
||||
? null
|
||||
: resolveGitCommit(rootDir, options.execFileSync ?? execFileSync);
|
||||
// GITHUB_SHA names the workflow invocation and can differ from a checked-out tag.
|
||||
const commit = explicitCommit
|
||||
? normalizeBuildCommit(explicitCommit)
|
||||
: explicitSha
|
||||
? normalizeBuildCommit(explicitSha, "GIT_SHA")
|
||||
: (checkedOutCommit ?? (githubSha ? normalizeBuildCommit(githubSha, "GITHUB_SHA") : null));
|
||||
const builtAt = explicitTimestamp
|
||||
? normalizeBuildTimestamp(explicitTimestamp)
|
||||
: (options.now ?? (() => new Date()))().toISOString();
|
||||
|
||||
const buildInfo = {
|
||||
version,
|
||||
commit,
|
||||
builtAt: new Date().toISOString(),
|
||||
};
|
||||
return {
|
||||
version: readPackageVersion(rootDir),
|
||||
commit,
|
||||
builtAt,
|
||||
};
|
||||
}
|
||||
|
||||
fs.mkdirSync(distDir, { recursive: true });
|
||||
fs.writeFileSync(path.join(distDir, "build-info.json"), `${JSON.stringify(buildInfo, null, 2)}\n`);
|
||||
export function writeBuildInfo(options: ResolveBuildInfoOptions = {}): string {
|
||||
const rootDir = options.rootDir ?? defaultRootDir;
|
||||
const distDir = path.join(rootDir, "dist");
|
||||
const outputPath = path.join(distDir, "build-info.json");
|
||||
const buildInfo = resolveBuildInfo({ ...options, rootDir });
|
||||
|
||||
fs.mkdirSync(distDir, { recursive: true });
|
||||
fs.writeFileSync(outputPath, `${JSON.stringify(buildInfo, null, 2)}\n`);
|
||||
return outputPath;
|
||||
}
|
||||
|
||||
function isMainModule(): boolean {
|
||||
const argv1 = process.argv[1];
|
||||
return Boolean(argv1 && import.meta.url === pathToFileURL(argv1).href);
|
||||
}
|
||||
|
||||
if (isMainModule()) {
|
||||
try {
|
||||
writeBuildInfo();
|
||||
} catch (error) {
|
||||
process.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`);
|
||||
process.exitCode = 1;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -141,6 +141,10 @@ async function createDockerSetupSandbox(): Promise<DockerSetupSandbox> {
|
||||
join(repoRoot, "scripts", "lib", "docker-build.sh"),
|
||||
join(rootDir, "scripts", "lib", "docker-build.sh"),
|
||||
);
|
||||
await copyFile(
|
||||
join(repoRoot, "scripts", "lib", "build-metadata.sh"),
|
||||
join(rootDir, "scripts", "lib", "build-metadata.sh"),
|
||||
);
|
||||
await copyFile(
|
||||
join(repoRoot, "scripts", "lib", "docker-e2e-logs.sh"),
|
||||
join(rootDir, "scripts", "lib", "docker-e2e-logs.sh"),
|
||||
@@ -355,8 +359,10 @@ describe("scripts/docker/setup.sh", () => {
|
||||
|
||||
it("handles env defaults, home-volume mounts, and Docker build args", async () => {
|
||||
const activeSandbox = requireSandbox(sandbox);
|
||||
const buildCommit = "0123456789abcdef0123456789abcdef01234567";
|
||||
|
||||
const result = runDockerSetup(activeSandbox, {
|
||||
GIT_COMMIT: buildCommit,
|
||||
OPENCLAW_DOCKER_APT_PACKAGES: "curl wget",
|
||||
OPENCLAW_EXTRA_MOUNTS: undefined,
|
||||
OPENCLAW_HOME_VOLUME: "openclaw-home",
|
||||
@@ -390,6 +396,10 @@ describe("scripts/docker/setup.sh", () => {
|
||||
);
|
||||
expect(log).toContain("--build-arg OPENCLAW_DOCKER_BUILD_TSDOWN_MAX_OLD_SPACE_MB=");
|
||||
expect(log).toContain("--build-arg OPENCLAW_DOCKER_BUILD_SKIP_DTS=1");
|
||||
expect(log).toMatch(
|
||||
/--build-arg OPENCLAW_BUILD_TIMESTAMP=\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z/u,
|
||||
);
|
||||
expect(log).toContain(`--build-arg GIT_COMMIT=${buildCommit}`);
|
||||
expect(log).toContain(
|
||||
`run --rm --no-deps ${prestartContainerEnvFlags} --entrypoint node openclaw-gateway dist/index.js onboard --mode local --no-install-daemon --gateway-auth token --gateway-token-ref-env OPENCLAW_GATEWAY_TOKEN --skip-ui --suppress-gateway-token-output`,
|
||||
);
|
||||
|
||||
+55
-6
@@ -7,6 +7,7 @@ import { describe, expect, it } from "vitest";
|
||||
|
||||
const repoRoot = resolve(fileURLToPath(new URL(".", import.meta.url)), "..");
|
||||
const dockerfilePath = join(repoRoot, "Dockerfile");
|
||||
const dockerInstallDocsPath = join(repoRoot, "docs/install/docker.md");
|
||||
const dockerReleaseWorkflowPath = join(repoRoot, ".github/workflows/docker-release.yml");
|
||||
const fullReleaseValidationWorkflowPath = join(
|
||||
repoRoot,
|
||||
@@ -193,10 +194,7 @@ describe("Dockerfile", () => {
|
||||
it("does not let pnpm resync the full source workspace during Docker build scripts", async () => {
|
||||
const dockerfile = await readFile(dockerfilePath, "utf8");
|
||||
const collapsed = collapseDockerContinuations(dockerfile);
|
||||
const qaLabBuildBlock =
|
||||
/RUN if printf '%s\\n' "\$OPENCLAW_EXTENSIONS" \| tr ',' ' ' \| tr ' ' '\\n' \| grep -qx 'qa-lab'; then\s+pnpm_config_verify_deps_before_run=false pnpm qa:lab:build &&\s+mkdir -p dist\/extensions\/qa-lab\/web &&\s+rm -rf dist\/extensions\/qa-lab\/web\/dist &&\s+cp -R extensions\/qa-lab\/web\/dist dist\/extensions\/qa-lab\/web\/dist;\s+fi/u;
|
||||
const qaLabExtensionCheckIndex = collapsed.indexOf("grep -qx 'qa-lab'");
|
||||
const qaLabBuildBlockMatch = qaLabBuildBlock.exec(collapsed);
|
||||
const privateQaExportIndex = collapsed.indexOf(
|
||||
"export OPENCLAW_BUILD_PRIVATE_QA=1 OPENCLAW_ENABLE_PRIVATE_QA_CLI=1",
|
||||
);
|
||||
@@ -212,7 +210,6 @@ describe("Dockerfile", () => {
|
||||
const runtimeAssetsIndex = collapsed.indexOf("FROM build AS runtime-assets");
|
||||
|
||||
expect(qaLabExtensionCheckIndex).toBeGreaterThan(-1);
|
||||
expect(qaLabBuildBlockMatch?.index).toBeGreaterThan(-1);
|
||||
expect(buildDockerIndex).toBeGreaterThan(-1);
|
||||
expect(qaLabBuildIndex).toBeGreaterThan(-1);
|
||||
expect(qaLabDistCopyIndex).toBeGreaterThan(-1);
|
||||
@@ -220,7 +217,6 @@ describe("Dockerfile", () => {
|
||||
expect(privateQaExportIndex).toBeGreaterThan(qaLabExtensionCheckIndex);
|
||||
expect(privateQaExportIndex).toBeLessThan(buildDockerIndex);
|
||||
expect(qaLabBuildIndex).toBeGreaterThan(buildDockerIndex);
|
||||
expect(qaLabBuildBlockMatch?.index).toBeGreaterThan(buildDockerIndex);
|
||||
expect(qaLabDistCopyIndex).toBeGreaterThan(qaLabBuildIndex);
|
||||
expect(qaLabDistCopyIndex).toBeLessThan(runtimeAssetsIndex);
|
||||
expect(dockerfile).toContain(
|
||||
@@ -230,6 +226,34 @@ describe("Dockerfile", () => {
|
||||
expect(dockerfile).toContain("pnpm_config_verify_deps_before_run=false pnpm qa:lab:build");
|
||||
});
|
||||
|
||||
it("shares public source provenance across backend and Control UI builds", async () => {
|
||||
const dockerfile = await readFile(dockerfilePath, "utf8");
|
||||
const installIndex = dockerfile.indexOf("pnpm install --frozen-lockfile");
|
||||
const commitArgIndex = dockerfile.indexOf('ARG GIT_COMMIT=""');
|
||||
const timestampArgIndex = dockerfile.indexOf('ARG OPENCLAW_BUILD_TIMESTAMP=""');
|
||||
const provenanceEnvIndex = dockerfile.indexOf("ENV GIT_COMMIT=${GIT_COMMIT}");
|
||||
const backendBuildIndex = dockerfile.indexOf("pnpm build:docker");
|
||||
const uiBuildIndex = dockerfile.indexOf("pnpm ui:build");
|
||||
|
||||
expect(commitArgIndex).toBeGreaterThan(installIndex);
|
||||
expect(timestampArgIndex).toBeGreaterThan(commitArgIndex);
|
||||
expect(provenanceEnvIndex).toBeGreaterThan(timestampArgIndex);
|
||||
expect(dockerfile).toContain("OPENCLAW_BUILD_TIMESTAMP=${OPENCLAW_BUILD_TIMESTAMP}");
|
||||
expect(dockerfile).toContain('OPENCLAW_BUILD_TIMESTAMP="$(date -u +%Y-%m-%dT%H:%M:%SZ)"');
|
||||
expect(backendBuildIndex).toBeGreaterThan(provenanceEnvIndex);
|
||||
expect(uiBuildIndex).toBeGreaterThan(backendBuildIndex);
|
||||
});
|
||||
|
||||
it("documents provenance arguments for manual source builds", async () => {
|
||||
const docs = await readFile(dockerInstallDocsPath, "utf8");
|
||||
|
||||
expect(docs).toContain('BUILD_GIT_COMMIT="$(git rev-parse HEAD)"');
|
||||
expect(docs).toContain('BUILD_TIMESTAMP="$(date -u +%Y-%m-%dT%H:%M:%SZ)"');
|
||||
expect(docs).toContain('--build-arg "GIT_COMMIT=${BUILD_GIT_COMMIT}"');
|
||||
expect(docs).toContain('--build-arg "OPENCLAW_BUILD_TIMESTAMP=${BUILD_TIMESTAMP}"');
|
||||
expect(docs).toContain("The Docker context excludes `.git`.");
|
||||
});
|
||||
|
||||
it("prunes runtime dependencies and omitted plugin packages after the build stage", async () => {
|
||||
const dockerfile = await readFile(dockerfilePath, "utf8");
|
||||
expect(dockerfile).toContain("FROM build AS runtime-assets");
|
||||
@@ -327,6 +351,31 @@ describe("Dockerfile", () => {
|
||||
expect(workflow).not.toContain("OPENCLAW_EXTENSIONS=diagnostics-otel\n");
|
||||
});
|
||||
|
||||
it("uses one source commit and timestamp for every official Docker artifact", async () => {
|
||||
const workflow = await readFile(dockerReleaseWorkflowPath, "utf8");
|
||||
|
||||
expect(workflow).toContain("resolve_build_provenance:");
|
||||
expect(workflow).toContain("built_at: ${{ steps.build_provenance.outputs.built_at }}");
|
||||
expect(workflow).toContain("source_sha: ${{ steps.build_provenance.outputs.source_sha }}");
|
||||
expect(workflow.match(/date -u \+%Y-%m-%dT%H:%M:%SZ/gu)).toHaveLength(1);
|
||||
expect(
|
||||
workflow.split("BUILD_TIMESTAMP: ${{ needs.resolve_build_provenance.outputs.built_at }}")
|
||||
.length - 1,
|
||||
).toBe(2);
|
||||
expect(
|
||||
workflow.split("ref: ${{ needs.resolve_build_provenance.outputs.source_sha }}").length - 1,
|
||||
).toBe(4);
|
||||
expect(
|
||||
workflow.split("GIT_COMMIT=${{ needs.resolve_build_provenance.outputs.source_sha }}").length -
|
||||
1,
|
||||
).toBe(4);
|
||||
expect(
|
||||
workflow.split(
|
||||
"OPENCLAW_BUILD_TIMESTAMP=${{ needs.resolve_build_provenance.outputs.built_at }}",
|
||||
).length - 1,
|
||||
).toBe(4);
|
||||
});
|
||||
|
||||
it("publishes official Docker browser images with baked Chromium", async () => {
|
||||
const workflow = await readFile(dockerReleaseWorkflowPath, "utf8");
|
||||
|
||||
@@ -346,7 +395,7 @@ describe("Dockerfile", () => {
|
||||
expect(workflow).toContain("chrome-headless-shell");
|
||||
expect(workflow).toContain("grep -q '^ARG OPENCLAW_INSTALL_BROWSER' Dockerfile");
|
||||
expect(workflow).toContain("if: steps.tags.outputs.browser != ''");
|
||||
expect(workflow).toContain('git show "${SOURCE_REF}:Dockerfile"');
|
||||
expect(workflow).not.toContain('git show "${SOURCE_REF}:Dockerfile"');
|
||||
expect(workflow).toContain('if [[ -n "${BROWSER_TAGS}" ]]; then');
|
||||
});
|
||||
|
||||
|
||||
@@ -2,11 +2,75 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
collectPreparedPrepackErrors,
|
||||
resolvePrepackBuildEnvironment,
|
||||
resolvePrepackCommandStdio,
|
||||
resolvePrepackCommandTimeoutMs,
|
||||
runPrepackCommand,
|
||||
} from "../scripts/openclaw-prepack.ts";
|
||||
|
||||
describe("resolvePrepackBuildEnvironment", () => {
|
||||
it("pins one timestamp across package and Control UI builds", () => {
|
||||
const commit = "0123456789abcdef0123456789abcdef01234567";
|
||||
expect(
|
||||
resolvePrepackBuildEnvironment(
|
||||
{},
|
||||
() => new Date("2026-07-10T12:34:56.000Z"),
|
||||
() => commit,
|
||||
),
|
||||
).toMatchObject({
|
||||
GIT_COMMIT: commit,
|
||||
OPENCLAW_BUILD_TIMESTAMP: "2026-07-10T12:34:56.000Z",
|
||||
});
|
||||
expect(
|
||||
resolvePrepackBuildEnvironment(
|
||||
{ OPENCLAW_BUILD_TIMESTAMP: "2026-07-10T01:02:03.7Z" },
|
||||
() => new Date("2026-07-11T00:00:00.000Z"),
|
||||
() => commit,
|
||||
).OPENCLAW_BUILD_TIMESTAMP,
|
||||
).toBe("2026-07-10T01:02:03.7Z");
|
||||
});
|
||||
|
||||
it("normalizes explicit commit aliases and rejects malformed values", () => {
|
||||
expect(
|
||||
resolvePrepackBuildEnvironment(
|
||||
{ GIT_SHA: "A".repeat(40) },
|
||||
() => new Date("2026-07-10T12:34:56.000Z"),
|
||||
() => "b".repeat(40),
|
||||
).GIT_COMMIT,
|
||||
).toBe("a".repeat(40));
|
||||
expect(() =>
|
||||
resolvePrepackBuildEnvironment({ GIT_COMMIT: "deadbeef" }, undefined, () => null),
|
||||
).toThrow("full 40-character hexadecimal SHA");
|
||||
});
|
||||
|
||||
it("uses checked-out Git instead of unverified GitHub workflow context", () => {
|
||||
const checkedOutCommit = "b".repeat(40);
|
||||
const ambientCommit = "a".repeat(40);
|
||||
|
||||
expect(
|
||||
resolvePrepackBuildEnvironment(
|
||||
{ GITHUB_SHA: ambientCommit },
|
||||
() => new Date("2026-07-10T12:34:56.000Z"),
|
||||
() => checkedOutCommit,
|
||||
).GIT_COMMIT,
|
||||
).toBe(checkedOutCommit);
|
||||
expect(
|
||||
resolvePrepackBuildEnvironment(
|
||||
{ GITHUB_SHA: ambientCommit },
|
||||
() => new Date("2026-07-10T12:34:56.000Z"),
|
||||
() => null,
|
||||
).GIT_COMMIT,
|
||||
).toBe(ambientCommit);
|
||||
expect(() =>
|
||||
resolvePrepackBuildEnvironment(
|
||||
{ GITHUB_SHA: "bad" },
|
||||
() => new Date("2026-07-10T12:34:56.000Z"),
|
||||
() => null,
|
||||
),
|
||||
).toThrow("full 40-character hexadecimal SHA");
|
||||
});
|
||||
});
|
||||
|
||||
describe("collectPreparedPrepackErrors", () => {
|
||||
it("accepts prepared release artifacts", () => {
|
||||
expect(
|
||||
|
||||
@@ -3,6 +3,11 @@ import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { afterEach } from "vitest";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
androidBuildMetadataGradleArgs,
|
||||
resolveAndroidBuildMetadata,
|
||||
verifyAndroidReleaseSource,
|
||||
} from "../../apps/android/scripts/build-release-artifacts.ts";
|
||||
import { useAutoCleanupTempDirTracker } from "../helpers/temp-dir.js";
|
||||
|
||||
const SCRIPT = "apps/android/scripts/build-release-artifacts.ts";
|
||||
@@ -10,10 +15,15 @@ const APK_CERTIFICATE_SHA256 = "80dbc62315ea216dd6e8a7060735a866ddc464a48ed50fef
|
||||
const tempRoots = useAutoCleanupTempDirTracker(afterEach);
|
||||
|
||||
function run(args: string[], env: NodeJS.ProcessEnv = {}) {
|
||||
const processEnv = { ...process.env };
|
||||
delete processEnv.GIT_COMMIT;
|
||||
delete processEnv.GIT_SHA;
|
||||
delete processEnv.GITHUB_SHA;
|
||||
delete processEnv.OPENCLAW_BUILD_TIMESTAMP;
|
||||
return spawnSync(process.execPath, ["--import", "tsx", SCRIPT, ...args], {
|
||||
cwd: process.cwd(),
|
||||
encoding: "utf8",
|
||||
env: { ...process.env, ...env },
|
||||
env: { ...processEnv, ...env },
|
||||
});
|
||||
}
|
||||
|
||||
@@ -37,6 +47,105 @@ function fakeApkSigner(certificateSha256: string, signerCount = 1) {
|
||||
}
|
||||
|
||||
describe("Android release artifacts", () => {
|
||||
it("resolves release metadata from explicit environment values first", () => {
|
||||
const metadata = resolveAndroidBuildMetadata({
|
||||
env: {
|
||||
GIT_COMMIT: "A".repeat(40),
|
||||
GIT_SHA: "d".repeat(40),
|
||||
GITHUB_SHA: "b".repeat(40),
|
||||
OPENCLAW_BUILD_TIMESTAMP: "2026-07-10T01:02:03Z",
|
||||
},
|
||||
now: () => new Date("2026-07-11T00:00:00Z"),
|
||||
readGitCommit: () => "c".repeat(40),
|
||||
});
|
||||
|
||||
expect(metadata).toEqual({
|
||||
commit: "a".repeat(40),
|
||||
timestamp: "2026-07-10T01:02:03.000Z",
|
||||
});
|
||||
});
|
||||
|
||||
it("prefers explicit metadata, then the checkout, then a git-less GitHub fallback", () => {
|
||||
const gitShaMetadata = resolveAndroidBuildMetadata({
|
||||
env: { GIT_SHA: "a".repeat(40), GITHUB_SHA: "b".repeat(40) },
|
||||
now: () => new Date("2026-07-10T04:05:06Z"),
|
||||
readGitCommit: () => "c".repeat(40),
|
||||
});
|
||||
const repositoryMetadata = resolveAndroidBuildMetadata({
|
||||
env: { GITHUB_SHA: "b".repeat(40) },
|
||||
now: () => new Date("2026-07-10T04:05:06Z"),
|
||||
readGitCommit: () => "c".repeat(40),
|
||||
});
|
||||
const githubMetadata = resolveAndroidBuildMetadata({
|
||||
env: { GITHUB_SHA: "b".repeat(40) },
|
||||
now: () => new Date("2026-07-10T04:05:06Z"),
|
||||
readGitCommit: () => {
|
||||
throw new Error("git unavailable");
|
||||
},
|
||||
});
|
||||
|
||||
expect(gitShaMetadata.commit).toBe("a".repeat(40));
|
||||
expect(githubMetadata.commit).toBe("b".repeat(40));
|
||||
expect(repositoryMetadata).toEqual({
|
||||
commit: "c".repeat(40),
|
||||
timestamp: "2026-07-10T04:05:06.000Z",
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects missing commit metadata when neither Git nor GitHub is available", () => {
|
||||
expect(() =>
|
||||
resolveAndroidBuildMetadata({
|
||||
env: {},
|
||||
readGitCommit: () => {
|
||||
throw new Error("git unavailable");
|
||||
},
|
||||
}),
|
||||
).toThrow("Unable to resolve the Android release Git commit");
|
||||
});
|
||||
|
||||
it("rejects partial commits and non-UTC build timestamps", () => {
|
||||
expect(() =>
|
||||
resolveAndroidBuildMetadata({
|
||||
env: { GIT_COMMIT: "abc1234", GITHUB_SHA: "b".repeat(40) },
|
||||
}),
|
||||
).toThrow("full 40-character hexadecimal Git commit");
|
||||
expect(() =>
|
||||
resolveAndroidBuildMetadata({
|
||||
env: {
|
||||
GIT_COMMIT: "a".repeat(40),
|
||||
OPENCLAW_BUILD_TIMESTAMP: "2026-07-10T01:02:03+01:00",
|
||||
},
|
||||
}),
|
||||
).toThrow("ISO-8601 UTC timestamp");
|
||||
});
|
||||
|
||||
it("passes build metadata as named Gradle project properties", () => {
|
||||
expect(
|
||||
androidBuildMetadataGradleArgs({
|
||||
commit: "a".repeat(40),
|
||||
timestamp: "2026-07-10T01:02:03.000Z",
|
||||
}),
|
||||
).toEqual([
|
||||
`-PopenclawBuildCommit=${"a".repeat(40)}`,
|
||||
"-PopenclawBuildTimestamp=2026-07-10T01:02:03.000Z",
|
||||
]);
|
||||
});
|
||||
|
||||
it("requires release metadata to match a clean checkout", () => {
|
||||
const commit = "a".repeat(40);
|
||||
const cleanGit = (args: string[]) => (args[0] === "rev-parse" ? `${commit}\n` : "");
|
||||
|
||||
expect(() => verifyAndroidReleaseSource(commit, { runGit: cleanGit })).not.toThrow();
|
||||
expect(() => verifyAndroidReleaseSource("b".repeat(40), { runGit: cleanGit })).toThrow(
|
||||
"Android release commit mismatch",
|
||||
);
|
||||
expect(() =>
|
||||
verifyAndroidReleaseSource(commit, {
|
||||
runGit: (args) => (args[0] === "rev-parse" ? `${commit}\n` : " M app/src/main.kt\n"),
|
||||
}),
|
||||
).toThrow("Android release builds require a clean Git checkout");
|
||||
});
|
||||
|
||||
it("selects only the signed third-party APK for GitHub distribution", () => {
|
||||
const result = run(["--artifact", "third-party", "--dry-run"]);
|
||||
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
import { execFileSync, spawnSync } from "node:child_process";
|
||||
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
|
||||
const SCRIPT = path.join(process.cwd(), "scripts", "apple-release-source-check.sh");
|
||||
const BASH_BIN = process.platform === "win32" ? "bash" : "/bin/bash";
|
||||
const tempDirs: string[] = [];
|
||||
|
||||
function makeRepository(): { root: string; commit: string } {
|
||||
const root = mkdtempSync(path.join(tmpdir(), "openclaw-apple-release-source-"));
|
||||
tempDirs.push(root);
|
||||
execFileSync("git", ["init", "--quiet"], { cwd: root });
|
||||
execFileSync("git", ["config", "user.email", "release-test@openclaw.test"], { cwd: root });
|
||||
execFileSync("git", ["config", "user.name", "OpenClaw Release Test"], { cwd: root });
|
||||
writeFileSync(path.join(root, "tracked.txt"), "clean\n", "utf8");
|
||||
execFileSync("git", ["add", "tracked.txt"], { cwd: root });
|
||||
execFileSync("git", ["-c", "commit.gpgsign=false", "commit", "--quiet", "-m", "initial"], {
|
||||
cwd: root,
|
||||
});
|
||||
const commit = execFileSync("git", ["rev-parse", "HEAD"], {
|
||||
cwd: root,
|
||||
encoding: "utf8",
|
||||
}).trim();
|
||||
return { root, commit };
|
||||
}
|
||||
|
||||
function runCheck(root: string, commit: string) {
|
||||
const args = process.platform === "win32" ? [SCRIPT] : ["--noprofile", "--norc", SCRIPT];
|
||||
return spawnSync(BASH_BIN, [...args, "--root", root, "--expected-commit", commit], {
|
||||
cwd: process.cwd(),
|
||||
encoding: "utf8",
|
||||
});
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
for (const dir of tempDirs.splice(0)) {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
describe("Apple release source check", () => {
|
||||
it("accepts a matching full commit from a clean checkout", () => {
|
||||
const repository = makeRepository();
|
||||
const result = runCheck(repository.root, repository.commit.toUpperCase());
|
||||
|
||||
expect(result.status).toBe(0);
|
||||
expect(result.stdout).toContain(
|
||||
`Verified Apple release source: commit=${repository.commit} clean=true`,
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects release metadata from a different commit", () => {
|
||||
const repository = makeRepository();
|
||||
const mismatch = `${repository.commit[0] === "a" ? "b" : "a"}${repository.commit.slice(1)}`;
|
||||
const result = runCheck(repository.root, mismatch);
|
||||
|
||||
expect(result.status).toBe(1);
|
||||
expect(result.stderr).toContain(
|
||||
`Apple release commit mismatch: metadata ${mismatch}, checkout ${repository.commit}.`,
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects tracked, staged, and untracked release checkout changes", () => {
|
||||
const fixtures = [
|
||||
() => {
|
||||
const repository = makeRepository();
|
||||
writeFileSync(path.join(repository.root, "tracked.txt"), "dirty\n", "utf8");
|
||||
return repository;
|
||||
},
|
||||
() => {
|
||||
const repository = makeRepository();
|
||||
writeFileSync(path.join(repository.root, "staged.txt"), "staged\n", "utf8");
|
||||
execFileSync("git", ["add", "staged.txt"], { cwd: repository.root });
|
||||
return repository;
|
||||
},
|
||||
() => {
|
||||
const repository = makeRepository();
|
||||
writeFileSync(path.join(repository.root, "untracked.txt"), "untracked\n", "utf8");
|
||||
return repository;
|
||||
},
|
||||
];
|
||||
|
||||
for (const makeFixture of fixtures) {
|
||||
const repository = makeFixture();
|
||||
const result = runCheck(repository.root, repository.commit);
|
||||
expect(result.status).toBe(1);
|
||||
expect(result.stderr).toContain("Apple release builds require a clean Git checkout.");
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
formatBuildAllDuration,
|
||||
formatBuildAllTimingSummary,
|
||||
parseBuildAllArgs,
|
||||
resolveBuildAllEnvironment,
|
||||
resolveBuildAllStepCacheState,
|
||||
resolveBuildAllStepCacheStampState,
|
||||
resolveBuildAllStep,
|
||||
@@ -69,6 +70,79 @@ function withBuildCacheFixture(
|
||||
}
|
||||
|
||||
describe("resolveBuildAllStep", () => {
|
||||
it("pins one generated timestamp across every child build", () => {
|
||||
const commit = "0123456789abcdef0123456789abcdef01234567";
|
||||
const buildEnv = resolveBuildAllEnvironment(
|
||||
{ FOO: "bar" },
|
||||
() => new Date("2026-07-10T12:34:56.789Z"),
|
||||
() => commit,
|
||||
);
|
||||
const uiInvocation = resolveBuildAllStep(getBuildAllStep("ui:build"), {
|
||||
env: buildEnv,
|
||||
});
|
||||
const buildInfoInvocation = resolveBuildAllStep(getBuildAllStep("write-build-info"), {
|
||||
env: buildEnv,
|
||||
});
|
||||
|
||||
expect(uiInvocation.options.env).toMatchObject({
|
||||
FOO: "bar",
|
||||
GIT_COMMIT: commit,
|
||||
OPENCLAW_BUILD_TIMESTAMP: "2026-07-10T12:34:56.789Z",
|
||||
});
|
||||
expect(buildInfoInvocation.options.env.OPENCLAW_BUILD_TIMESTAMP).toBe(
|
||||
uiInvocation.options.env.OPENCLAW_BUILD_TIMESTAMP,
|
||||
);
|
||||
});
|
||||
|
||||
it("pins the first explicit full commit alias and rejects malformed values", () => {
|
||||
const gitSha = "A".repeat(40);
|
||||
expect(
|
||||
resolveBuildAllEnvironment(
|
||||
{ GIT_SHA: gitSha, GITHUB_SHA: "b".repeat(40) },
|
||||
() => new Date("2026-07-10T12:34:56.000Z"),
|
||||
() => "c".repeat(40),
|
||||
).GIT_COMMIT,
|
||||
).toBe(gitSha.toLowerCase());
|
||||
expect(() =>
|
||||
resolveBuildAllEnvironment({ GIT_COMMIT: "deadbeef" }, undefined, () => null),
|
||||
).toThrow("full 40-character hexadecimal SHA");
|
||||
});
|
||||
|
||||
it("uses checked-out Git instead of unverified GitHub workflow context", () => {
|
||||
const checkedOutCommit = "b".repeat(40);
|
||||
const ambientCommit = "a".repeat(40);
|
||||
|
||||
expect(
|
||||
resolveBuildAllEnvironment(
|
||||
{ GITHUB_SHA: ambientCommit },
|
||||
() => new Date("2026-07-10T12:34:56.000Z"),
|
||||
() => checkedOutCommit,
|
||||
).GIT_COMMIT,
|
||||
).toBe(checkedOutCommit);
|
||||
expect(
|
||||
resolveBuildAllEnvironment(
|
||||
{ GITHUB_SHA: ambientCommit },
|
||||
() => new Date("2026-07-10T12:34:56.000Z"),
|
||||
() => null,
|
||||
).GIT_COMMIT,
|
||||
).toBe(ambientCommit);
|
||||
expect(() =>
|
||||
resolveBuildAllEnvironment(
|
||||
{ GITHUB_SHA: "bad" },
|
||||
() => new Date("2026-07-10T12:34:56.000Z"),
|
||||
() => null,
|
||||
),
|
||||
).toThrow("full 40-character hexadecimal SHA");
|
||||
});
|
||||
|
||||
it("preserves an explicit build timestamp after trimming outer whitespace", () => {
|
||||
expect(
|
||||
resolveBuildAllEnvironment({
|
||||
OPENCLAW_BUILD_TIMESTAMP: " 2026-07-10T01:02:03.000Z ",
|
||||
}).OPENCLAW_BUILD_TIMESTAMP,
|
||||
).toBe("2026-07-10T01:02:03.000Z");
|
||||
});
|
||||
|
||||
it("routes pnpm steps through the npm_execpath pnpm runner on Windows", () => {
|
||||
const step = getBuildAllStep("plugins:assets:build");
|
||||
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-pnpm-runner-"));
|
||||
|
||||
@@ -24,6 +24,18 @@ function laneBody(source: string, name: string): string {
|
||||
return nextLane < 0 ? rest : rest.slice(0, nextLane);
|
||||
}
|
||||
|
||||
function functionBody(source: string, name: string): string {
|
||||
const startMarker = `def ${name}`;
|
||||
const start = source.indexOf(startMarker);
|
||||
if (start < 0) {
|
||||
throw new Error(`missing Fastfile function ${name}`);
|
||||
}
|
||||
|
||||
const rest = source.slice(start + startMarker.length);
|
||||
const nextFunction = rest.search(/\ndef /);
|
||||
return nextFunction < 0 ? rest : rest.slice(0, nextFunction);
|
||||
}
|
||||
|
||||
describe("iOS Fastlane release upload gates", () => {
|
||||
it("does not keep the old package release alias", () => {
|
||||
const packageJson = JSON.parse(readFileSync(packageJsonPath, "utf8")) as {
|
||||
@@ -77,13 +89,30 @@ describe("iOS Fastlane release upload gates", () => {
|
||||
|
||||
it("validates the exported IPA before the sole TestFlight upload call", () => {
|
||||
const fastfile = readFastfile();
|
||||
const validationCall = fastfile.indexOf("validate_app_store_ipa!(expected_ipa_path)");
|
||||
const validationCall = fastfile.indexOf("expected_commit: context[:git_commit]");
|
||||
const uploadCall = fastfile.indexOf("upload_to_testflight(");
|
||||
|
||||
expect(validationCall).toBeGreaterThanOrEqual(0);
|
||||
expect(uploadCall).toBeGreaterThan(validationCall);
|
||||
});
|
||||
|
||||
it("requires clean matching source before preparing and building release artifacts", () => {
|
||||
const fastfile = readFastfile();
|
||||
const verifier = functionBody(fastfile, "verify_apple_release_source!");
|
||||
const provenance = functionBody(fastfile, "pin_release_build_provenance!");
|
||||
const builder = functionBody(fastfile, "build_app_store_release");
|
||||
|
||||
expect(verifier).toContain('"apple-release-source-check.sh"');
|
||||
expect(verifier).toContain('"--root"');
|
||||
expect(verifier).toContain('"--expected-commit"');
|
||||
expect(provenance).toContain("verify_apple_release_source!(normalized_commit)");
|
||||
expect(provenance).not.toContain('ENV["GITHUB_SHA"]');
|
||||
expect(builder).toContain("verify_apple_release_source!(context[:git_commit])");
|
||||
expect(builder.indexOf("verify_apple_release_source!")).toBeLessThan(
|
||||
builder.indexOf("FileUtils.mkdir_p(output_directory)"),
|
||||
);
|
||||
});
|
||||
|
||||
it("preflights and records mobile release refs around TestFlight upload", () => {
|
||||
const fastfile = readFastfile();
|
||||
const releaseUpload = laneBody(fastfile, "release_upload");
|
||||
@@ -93,7 +122,11 @@ describe("iOS Fastlane release upload gates", () => {
|
||||
expect(fastfile).toContain('"--root"');
|
||||
expect(fastfile).toContain('"--sha"');
|
||||
expect(fastfile).toContain("repo_root");
|
||||
expect(releaseUpload).toContain("release_sha = release_git_sha");
|
||||
expect(fastfile).toContain("def pin_release_build_provenance!");
|
||||
expect(laneBody(fastfile, "prepare_app_store_context")).toContain(
|
||||
"provenance = pin_release_build_provenance!",
|
||||
);
|
||||
expect(releaseUpload).toContain("release_sha = context[:git_commit]");
|
||||
expect(releaseUpload).toContain("ensure_mobile_release_ref_available!");
|
||||
expect(releaseUpload).toContain("record_mobile_release_ref!");
|
||||
expect(releaseUpload).toContain(
|
||||
|
||||
@@ -105,4 +105,18 @@ describe("iOS release shell wrapper arguments", () => {
|
||||
expect(result.stderr).not.toContain("fastlane");
|
||||
expect(result.stdout).toBe("");
|
||||
});
|
||||
|
||||
it("requires stamped build metadata for App Store release preparation", () => {
|
||||
const script = readFileSync(path.join(process.cwd(), "scripts/ios-release-prepare.sh"), "utf8");
|
||||
|
||||
expect(script).toContain("OPENCLAW_REQUIRE_BUILD_METADATA=1");
|
||||
expect(script).toContain(
|
||||
'RELEASE_SOURCE_HELPER="${ROOT_DIR}/scripts/apple-release-source-check.sh"',
|
||||
);
|
||||
expect(script).toContain('--expected-commit "${RELEASE_GIT_COMMIT}"');
|
||||
expect(script.indexOf('bash "${RELEASE_SOURCE_HELPER}"')).toBeLessThan(
|
||||
script.lastIndexOf("prepare_build_dir"),
|
||||
);
|
||||
expect(script).toContain('export GIT_COMMIT="${RELEASE_GIT_COMMIT}"');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -16,6 +16,8 @@ import { afterEach, describe, expect, it } from "vitest";
|
||||
|
||||
const SCRIPT = path.join(process.cwd(), "scripts", "ios-validate-app-store-ipa.sh");
|
||||
const BASH_BIN = process.platform === "win32" ? "bash" : "/bin/bash";
|
||||
const BUILD_COMMIT = "0123456789abcdef0123456789abcdef01234567";
|
||||
const BUILD_TIMESTAMP = "2026-07-10T12:34:56.000Z";
|
||||
|
||||
const tempDirs: string[] = [];
|
||||
|
||||
@@ -176,7 +178,12 @@ async function writeIpaFixture(root: string): Promise<string> {
|
||||
|
||||
async function writeValidFixture(
|
||||
root: string,
|
||||
options: { pushMode?: string; legacyKey?: boolean } = {},
|
||||
options: {
|
||||
buildCommit?: string;
|
||||
buildTimestamp?: string;
|
||||
pushMode?: string;
|
||||
legacyKey?: boolean;
|
||||
} = {},
|
||||
): Promise<{
|
||||
ipaPath: string;
|
||||
plistBuddy: string;
|
||||
@@ -194,6 +201,8 @@ async function writeValidFixture(
|
||||
|
||||
const infoBody = [
|
||||
plistString("CFBundleIdentifier", "ai.openclawfoundation.app"),
|
||||
plistString("OpenClawGitCommit", options.buildCommit ?? BUILD_COMMIT),
|
||||
plistString("OpenClawBuildTimestamp", options.buildTimestamp ?? BUILD_TIMESTAMP),
|
||||
plistString("OpenClawPushMode", options.pushMode ?? "appStore"),
|
||||
plistString("OpenClawPushRelayBaseURL", ""),
|
||||
options.legacyKey ? plistString("OpenClawPushRelayProfile", "production") : "",
|
||||
@@ -266,26 +275,41 @@ cat "${profilePath}"
|
||||
return { ipaPath, plistBuddy, codesign, security, unzip };
|
||||
}
|
||||
|
||||
function runValidator(fixture: {
|
||||
ipaPath: string;
|
||||
plistBuddy: string;
|
||||
codesign: string;
|
||||
security: string;
|
||||
unzip: string;
|
||||
}): { ok: boolean; stdout: string; stderr: string } {
|
||||
function runValidator(
|
||||
fixture: {
|
||||
ipaPath: string;
|
||||
plistBuddy: string;
|
||||
codesign: string;
|
||||
security: string;
|
||||
unzip: string;
|
||||
},
|
||||
expected: { commit?: string; timestamp?: string } = {},
|
||||
): { ok: boolean; stdout: string; stderr: string } {
|
||||
try {
|
||||
const stdout = execFileSync(BASH_BIN, [...bashArgs(SCRIPT), "--ipa", fixture.ipaPath], {
|
||||
cwd: process.cwd(),
|
||||
env: {
|
||||
...process.env,
|
||||
IOS_VALIDATE_PLIST_BUDDY_BIN: fixture.plistBuddy,
|
||||
IOS_VALIDATE_CODESIGN_BIN: fixture.codesign,
|
||||
IOS_VALIDATE_SECURITY_BIN: fixture.security,
|
||||
IOS_VALIDATE_UNZIP_BIN: fixture.unzip,
|
||||
const stdout = execFileSync(
|
||||
BASH_BIN,
|
||||
[
|
||||
...bashArgs(SCRIPT),
|
||||
"--ipa",
|
||||
fixture.ipaPath,
|
||||
"--expected-commit",
|
||||
expected.commit ?? BUILD_COMMIT,
|
||||
"--expected-build-timestamp",
|
||||
expected.timestamp ?? BUILD_TIMESTAMP,
|
||||
],
|
||||
{
|
||||
cwd: process.cwd(),
|
||||
env: {
|
||||
...process.env,
|
||||
IOS_VALIDATE_PLIST_BUDDY_BIN: fixture.plistBuddy,
|
||||
IOS_VALIDATE_CODESIGN_BIN: fixture.codesign,
|
||||
IOS_VALIDATE_SECURITY_BIN: fixture.security,
|
||||
IOS_VALIDATE_UNZIP_BIN: fixture.unzip,
|
||||
},
|
||||
encoding: "utf8",
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
},
|
||||
encoding: "utf8",
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
});
|
||||
);
|
||||
return { ok: true, stdout, stderr: "" };
|
||||
} catch (error) {
|
||||
const e = error as { stdout?: unknown; stderr?: unknown };
|
||||
@@ -334,4 +358,20 @@ describe("scripts/ios-validate-app-store-ipa.sh", () => {
|
||||
expect(result.ok).toBe(false);
|
||||
expect(result.stderr).toContain("legacy relay profile");
|
||||
});
|
||||
|
||||
it("rejects malformed or mismatched embedded build provenance", async () => {
|
||||
const malformedRoot = mkdtempSync(path.join(os.tmpdir(), "openclaw-ios-ipa-"));
|
||||
const mismatchRoot = mkdtempSync(path.join(os.tmpdir(), "openclaw-ios-ipa-"));
|
||||
tempDirs.push(malformedRoot, mismatchRoot);
|
||||
const malformed = await writeValidFixture(malformedRoot, { buildCommit: "deadbeef" });
|
||||
const mismatch = await writeValidFixture(mismatchRoot);
|
||||
|
||||
const malformedResult = runValidator(malformed);
|
||||
const mismatchResult = runValidator(mismatch, { commit: "a".repeat(40) });
|
||||
|
||||
expect(malformedResult.ok).toBe(false);
|
||||
expect(malformedResult.stderr).toContain("full lowercase commit SHA");
|
||||
expect(mismatchResult.ok).toBe(false);
|
||||
expect(mismatchResult.stderr).toContain("embedded Git commit mismatch");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -16,6 +16,16 @@ import { installIosFixtureCleanup, writeIosFixture } from "./ios-version.test-su
|
||||
installIosFixtureCleanup();
|
||||
|
||||
describe("resolveIosVersion", () => {
|
||||
it("writes shared full commit and UTC timestamp settings for iOS builds", () => {
|
||||
const script = fs.readFileSync("scripts/ios-write-version-xcconfig.sh", "utf8");
|
||||
|
||||
expect(script).toContain('source "${ROOT_DIR}/scripts/lib/build-metadata.sh"');
|
||||
expect(script).toContain("OPENCLAW_GIT_COMMIT = ${RESOLVED_GIT_COMMIT}");
|
||||
expect(script).toContain("OPENCLAW_BUILD_TIMESTAMP = ${RESOLVED_BUILD_TIMESTAMP}");
|
||||
expect(script).toContain('openclaw_resolve_git_commit "${ROOT_DIR}"');
|
||||
expect(script).toContain("openclaw_resolve_build_timestamp");
|
||||
});
|
||||
|
||||
it("rejects missing CLI option values before reading version files", () => {
|
||||
const result = spawnSync(
|
||||
process.execPath,
|
||||
|
||||
@@ -2438,6 +2438,12 @@ describe("package artifact reuse", () => {
|
||||
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(
|
||||
"OPENCLAW_BUILD_TIMESTAMP: ${{ steps.release_approval.outputs.build_timestamp }}",
|
||||
);
|
||||
expect(androidWorkflow).toContain("GIT_COMMIT: ${{ inputs.release_target_sha }}");
|
||||
expect(androidWorkflow).toContain("--json tagName,isDraft,isPrerelease,createdAt,assets,url");
|
||||
expect(androidWorkflow).toContain("release_created_at=");
|
||||
expect(androidWorkflow).toContain(
|
||||
"Reusing verified Android APK from ${FALLBACK_ANDROID_BASE_TAG}",
|
||||
);
|
||||
|
||||
@@ -162,6 +162,163 @@ afterEach(() => {
|
||||
});
|
||||
|
||||
describe("package-mac-app plist stamping", () => {
|
||||
it("resolves canonical build provenance and rejects explicit invalid overrides", () => {
|
||||
const commit = "ABCDEF0123456789ABCDEF0123456789ABCDEF01";
|
||||
const valid = runHelper(`
|
||||
source scripts/lib/build-metadata.sh
|
||||
node() { echo "unexpected Node invocation" >&2; return 97; }
|
||||
GIT_COMMIT=${JSON.stringify(commit)}
|
||||
OPENCLAW_BUILD_TIMESTAMP=2026-07-10T12:34:56.7Z
|
||||
printf '%s\n%s\n' "$(openclaw_resolve_git_commit "$PWD")" "$(openclaw_resolve_build_timestamp)"
|
||||
`);
|
||||
const invalidCommit = runHelper(`
|
||||
source scripts/lib/build-metadata.sh
|
||||
GIT_COMMIT=abc123
|
||||
openclaw_resolve_git_commit "$PWD"
|
||||
`);
|
||||
const validAlias = runHelper(`
|
||||
source scripts/lib/build-metadata.sh
|
||||
unset GIT_COMMIT GITHUB_SHA
|
||||
GIT_SHA=${JSON.stringify(commit)}
|
||||
openclaw_resolve_git_commit "$PWD"
|
||||
`);
|
||||
const invalidTimestamp = runHelper(`
|
||||
source scripts/lib/build-metadata.sh
|
||||
OPENCLAW_BUILD_TIMESTAMP=2026-99-99T12:34:56Z
|
||||
openclaw_resolve_build_timestamp
|
||||
`);
|
||||
const missingLocalCommit = runHelper(`
|
||||
source scripts/lib/build-metadata.sh
|
||||
unset GIT_COMMIT GIT_SHA GITHUB_SHA
|
||||
empty_root="$(mktemp -d)"
|
||||
openclaw_resolve_git_commit "$empty_root"
|
||||
`);
|
||||
const missingReleaseCommit = runHelper(`
|
||||
source scripts/lib/build-metadata.sh
|
||||
unset GIT_COMMIT GIT_SHA GITHUB_SHA
|
||||
empty_root="$(mktemp -d)"
|
||||
OPENCLAW_REQUIRE_BUILD_METADATA=1 openclaw_resolve_git_commit "$empty_root"
|
||||
`);
|
||||
const ambientGithubCommit = runHelper(`
|
||||
source scripts/lib/build-metadata.sh
|
||||
unset GIT_COMMIT GIT_SHA
|
||||
GITHUB_SHA=${JSON.stringify("a".repeat(40))}
|
||||
openclaw_resolve_git_commit "$PWD"
|
||||
`);
|
||||
const invalidGithubFallback = runHelper(`
|
||||
source scripts/lib/build-metadata.sh
|
||||
unset GIT_COMMIT GIT_SHA
|
||||
GITHUB_SHA=bad
|
||||
empty_root="$(mktemp -d)"
|
||||
openclaw_resolve_git_commit "$empty_root"
|
||||
`);
|
||||
const checkedOutCommit = spawnSync("git", ["rev-parse", "HEAD"], {
|
||||
cwd: process.cwd(),
|
||||
encoding: "utf8",
|
||||
}).stdout.trim();
|
||||
|
||||
expect(valid.status).toBe(0);
|
||||
expect(valid.stdout).toBe(`${commit.toLowerCase()}\n2026-07-10T12:34:56.700Z\n`);
|
||||
expect(invalidCommit.status).toBe(1);
|
||||
expect(invalidCommit.stderr).toContain(
|
||||
"GIT_COMMIT must be a full 40-character hexadecimal commit",
|
||||
);
|
||||
expect(validAlias.status).toBe(0);
|
||||
expect(validAlias.stdout).toBe(commit.toLowerCase());
|
||||
expect(invalidTimestamp.status).toBe(1);
|
||||
expect(invalidTimestamp.stderr).toContain(
|
||||
"OPENCLAW_BUILD_TIMESTAMP must be an ISO-8601 UTC timestamp",
|
||||
);
|
||||
expect(missingLocalCommit.status).toBe(0);
|
||||
expect(missingLocalCommit.stdout).toBe("unknown");
|
||||
expect(missingReleaseCommit.status).toBe(1);
|
||||
expect(missingReleaseCommit.stderr).toContain("full Git commit for the release build");
|
||||
expect(ambientGithubCommit.status).toBe(0);
|
||||
expect(ambientGithubCommit.stdout).toBe(checkedOutCommit);
|
||||
expect(invalidGithubFallback.status).toBe(1);
|
||||
expect(invalidGithubFallback.stderr).toContain(
|
||||
"GITHUB_SHA must be a full 40-character hexadecimal commit",
|
||||
);
|
||||
});
|
||||
|
||||
it("normalizes valid timestamps without requiring host Node", () => {
|
||||
const result = runHelper(`
|
||||
source scripts/lib/build-metadata.sh
|
||||
node() { echo "unexpected Node invocation" >&2; return 97; }
|
||||
for value in \
|
||||
0000-01-01T00:00:00Z \
|
||||
2000-02-29T23:59:59.7Z \
|
||||
2024-02-29T12:34:56.78Z \
|
||||
2026-07-10T12:34:56.789Z; do
|
||||
OPENCLAW_BUILD_TIMESTAMP="$value" openclaw_resolve_build_timestamp
|
||||
printf '\n'
|
||||
done
|
||||
for value in \
|
||||
2026-00-01T00:00:00Z \
|
||||
2026-02-29T00:00:00Z \
|
||||
2100-02-29T00:00:00Z \
|
||||
2026-04-31T00:00:00Z \
|
||||
2026-01-01T24:00:00Z \
|
||||
2026-01-01T00:60:00Z \
|
||||
2026-01-01T00:00:60Z \
|
||||
2026-01-01T00:00:00+00:00; do
|
||||
if OPENCLAW_BUILD_TIMESTAMP="$value" openclaw_resolve_build_timestamp >/dev/null 2>&1; then
|
||||
exit 1
|
||||
fi
|
||||
done
|
||||
unset OPENCLAW_BUILD_TIMESTAMP
|
||||
generated="$(openclaw_resolve_build_timestamp)"
|
||||
[[ "$generated" =~ ^[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}[.]000Z$ ]]
|
||||
`);
|
||||
|
||||
expect(result.status).toBe(0);
|
||||
expect(result.stderr).toBe("");
|
||||
expect(result.stdout).toBe(
|
||||
[
|
||||
"0000-01-01T00:00:00.000Z",
|
||||
"2000-02-29T23:59:59.700Z",
|
||||
"2024-02-29T12:34:56.780Z",
|
||||
"2026-07-10T12:34:56.789Z",
|
||||
"",
|
||||
].join("\n"),
|
||||
);
|
||||
});
|
||||
|
||||
it("uses the shared build metadata policy for full commit and timestamp stamps", () => {
|
||||
const script = readFileSync(scriptPath, "utf8");
|
||||
|
||||
expect(script).toContain('source "$ROOT_DIR/scripts/lib/build-metadata.sh"');
|
||||
expect(script).toContain('BUILD_GIT_COMMIT="$(openclaw_resolve_git_commit "$ROOT_DIR")"');
|
||||
expect(script).toContain('BUILD_TS="$(openclaw_resolve_build_timestamp)"');
|
||||
expect(script).toContain('export OPENCLAW_BUILD_TIMESTAMP="$BUILD_TS"');
|
||||
expect(script).toContain('export GIT_COMMIT="$BUILD_GIT_COMMIT"');
|
||||
expect(script).not.toContain("git rev-parse --short HEAD");
|
||||
});
|
||||
|
||||
it("gates only release packaging on clean matching source and verifies the embedded commit", () => {
|
||||
const script = readFileSync(scriptPath, "utf8");
|
||||
const sourceCheck = script.indexOf('bash "$ROOT_DIR/scripts/apple-release-source-check.sh"');
|
||||
const build = script.indexOf('cd "$ROOT_DIR/apps/macos"');
|
||||
const embeddedRead = script.indexOf(
|
||||
'plist_print_required "$APP_ROOT/Contents/Info.plist" OpenClawGitCommit',
|
||||
);
|
||||
const signing = script.indexOf('"$ROOT_DIR/scripts/codesign-mac-app.sh"');
|
||||
const releaseBranch = script.lastIndexOf(
|
||||
'if [[ "$BUILD_CONFIG" == "release" ]]; then',
|
||||
sourceCheck,
|
||||
);
|
||||
const releaseBranchEnd = script.indexOf("\nfi", sourceCheck);
|
||||
|
||||
expect(script).toContain('BUILD_CONFIG="${BUILD_CONFIG:-debug}"');
|
||||
expect(sourceCheck).toBeGreaterThan(releaseBranch);
|
||||
expect(sourceCheck).toBeLessThan(releaseBranchEnd);
|
||||
expect(sourceCheck).toBeLessThan(build);
|
||||
expect(script).toContain('--expected-commit "$BUILD_GIT_COMMIT"');
|
||||
expect(embeddedRead).toBeGreaterThan(sourceCheck);
|
||||
expect(embeddedRead).toBeLessThan(signing);
|
||||
expect(script).toContain("Release app embedded Git commit");
|
||||
});
|
||||
|
||||
it("keeps dependency installation lockfile-safe", () => {
|
||||
const script = readFileSync(scriptPath, "utf8");
|
||||
const installBlock = script.slice(
|
||||
|
||||
@@ -673,6 +673,19 @@ printf 'status=%s\\n' "$status"
|
||||
expect(podmanSetup).not.toContain("OPENCLAW_DOCKER_PIP_PACKAGES");
|
||||
});
|
||||
|
||||
it("passes one source identity into local Docker and Podman builds", () => {
|
||||
const dockerSetup = readFileSync(DOCKER_SETUP_PATH, "utf8");
|
||||
const podmanSetup = readFileSync(PODMAN_SETUP_PATH, "utf8");
|
||||
|
||||
for (const setupScript of [dockerSetup, podmanSetup]) {
|
||||
expect(setupScript).toContain("scripts/lib/build-metadata.sh");
|
||||
expect(setupScript).toContain("openclaw_resolve_git_commit");
|
||||
expect(setupScript).toContain("openclaw_resolve_build_timestamp");
|
||||
expect(setupScript).toContain("OPENCLAW_BUILD_TIMESTAMP=${BUILD_TIMESTAMP}");
|
||||
expect(setupScript).toContain("GIT_COMMIT=${BUILD_GIT_COMMIT}");
|
||||
}
|
||||
});
|
||||
|
||||
it("keeps the Podman Quadlet template aligned with setup substitutions", () => {
|
||||
const setupScript = readFileSync(PODMAN_SETUP_PATH, "utf8");
|
||||
const template = readFileSync(PODMAN_QUADLET_TEMPLATE_PATH, "utf8");
|
||||
|
||||
@@ -263,6 +263,17 @@ describe("scripts/test-projects changed-target routing", () => {
|
||||
});
|
||||
|
||||
it("routes release wrapper changes through their owner tests", () => {
|
||||
expect(resolveChangedTestTargetPlan(["scripts/apple-release-source-check.sh"])).toEqual({
|
||||
mode: "targets",
|
||||
targets: ["test/scripts/apple-release-source-check.test.ts"],
|
||||
});
|
||||
expect(resolveChangedTestTargetPlan(["scripts/ios-release-prepare.sh"])).toEqual({
|
||||
mode: "targets",
|
||||
targets: [
|
||||
"test/scripts/ios-release-prepare.test.ts",
|
||||
"test/scripts/ios-release-wrapper-args.test.ts",
|
||||
],
|
||||
});
|
||||
expect(resolveChangedTestTargetPlan(["scripts/android-release.sh"])).toEqual({
|
||||
mode: "targets",
|
||||
targets: ["test/scripts/android-release-wrapper-args.test.ts"],
|
||||
@@ -1489,10 +1500,21 @@ describe("scripts/test-projects changed-target routing", () => {
|
||||
["test/e2e/qa-lab/runtime/package-openclaw-for-docker.e2e.test.ts"],
|
||||
],
|
||||
["scripts/ios-run.sh", ["test/scripts/ios-run.test.ts"]],
|
||||
["scripts/ios-write-version-xcconfig.sh", ["test/scripts/ios-version.test.ts"]],
|
||||
["scripts/create-dmg.sh", ["test/scripts/create-dmg.test.ts"]],
|
||||
["scripts/make_appcast.sh", ["test/scripts/make-appcast.test.ts"]],
|
||||
["scripts/package-mac-app.sh", ["test/scripts/package-mac-app.test.ts"]],
|
||||
["scripts/package-mac-dist.sh", ["test/scripts/package-mac-dist.test.ts"]],
|
||||
[
|
||||
"scripts/lib/build-metadata.sh",
|
||||
[
|
||||
"src/docker-setup.e2e.test.ts",
|
||||
"test/scripts/apple-release-source-check.test.ts",
|
||||
"test/scripts/ios-version.test.ts",
|
||||
"test/scripts/package-mac-app.test.ts",
|
||||
"test/scripts/test-install-sh-docker.test.ts",
|
||||
],
|
||||
],
|
||||
[
|
||||
"scripts/lib/swift-toolchain.sh",
|
||||
["test/scripts/package-mac-app.test.ts", "test/scripts/package-mac-dist.test.ts"],
|
||||
@@ -1983,6 +2005,16 @@ describe("scripts/test-projects changed-target routing", () => {
|
||||
"test/release-check.test.ts",
|
||||
],
|
||||
],
|
||||
[
|
||||
"scripts/lib/build-metadata.sh",
|
||||
[
|
||||
"src/docker-setup.e2e.test.ts",
|
||||
"test/scripts/apple-release-source-check.test.ts",
|
||||
"test/scripts/ios-version.test.ts",
|
||||
"test/scripts/package-mac-app.test.ts",
|
||||
"test/scripts/test-install-sh-docker.test.ts",
|
||||
],
|
||||
],
|
||||
[
|
||||
"scripts/lib/plistbuddy.sh",
|
||||
[
|
||||
|
||||
@@ -0,0 +1,156 @@
|
||||
// Build info tests cover canonical package provenance generation.
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
normalizeBuildCommit,
|
||||
normalizeBuildTimestamp,
|
||||
resolveBuildInfo,
|
||||
writeBuildInfo,
|
||||
} from "../../scripts/write-build-info.ts";
|
||||
import { useAutoCleanupTempDirTracker } from "../helpers/temp-dir.js";
|
||||
|
||||
describe("write-build-info", () => {
|
||||
const tempDirs = useAutoCleanupTempDirTracker(afterEach);
|
||||
|
||||
function createPackage(version = "2026.7.10"): string {
|
||||
const rootDir = tempDirs.make("openclaw-build-info-");
|
||||
fs.writeFileSync(path.join(rootDir, "package.json"), `${JSON.stringify({ version })}\n`);
|
||||
return rootDir;
|
||||
}
|
||||
|
||||
it("normalizes explicit release provenance and writes the shared manifest", () => {
|
||||
const rootDir = createPackage();
|
||||
const execFileSync = vi.fn(() => {
|
||||
throw new Error("Git fallback should not run");
|
||||
});
|
||||
|
||||
const outputPath = writeBuildInfo({
|
||||
rootDir,
|
||||
env: {
|
||||
GIT_COMMIT: "ABCDEF0123456789ABCDEF0123456789ABCDEF01",
|
||||
OPENCLAW_BUILD_TIMESTAMP: "2026-07-10T12:34:56Z",
|
||||
},
|
||||
execFileSync,
|
||||
});
|
||||
|
||||
expect(execFileSync).not.toHaveBeenCalled();
|
||||
expect(path.relative(rootDir, outputPath)).toBe("dist/build-info.json");
|
||||
expect(JSON.parse(fs.readFileSync(outputPath, "utf8"))).toEqual({
|
||||
version: "2026.7.10",
|
||||
commit: "abcdef0123456789abcdef0123456789abcdef01",
|
||||
builtAt: "2026-07-10T12:34:56.000Z",
|
||||
});
|
||||
});
|
||||
|
||||
it("falls back to build-time Git and one current UTC timestamp for local builds", () => {
|
||||
const rootDir = createPackage("2026.7.10-beta.1");
|
||||
const execFileSync = vi.fn(() => "1234567890ABCDEF1234567890ABCDEF12345678\n");
|
||||
|
||||
expect(
|
||||
resolveBuildInfo({
|
||||
rootDir,
|
||||
env: {},
|
||||
execFileSync,
|
||||
now: () => new Date("2026-07-10T01:02:03.456Z"),
|
||||
}),
|
||||
).toEqual({
|
||||
version: "2026.7.10-beta.1",
|
||||
commit: "1234567890abcdef1234567890abcdef12345678",
|
||||
builtAt: "2026-07-10T01:02:03.456Z",
|
||||
});
|
||||
expect(execFileSync).toHaveBeenCalledWith("git", ["rev-parse", "HEAD"], {
|
||||
cwd: rootDir,
|
||||
encoding: "utf8",
|
||||
stdio: ["ignore", "pipe", "ignore"],
|
||||
});
|
||||
});
|
||||
|
||||
it("uses null when Git metadata is unavailable", () => {
|
||||
const rootDir = createPackage();
|
||||
|
||||
expect(
|
||||
resolveBuildInfo({
|
||||
rootDir,
|
||||
env: {},
|
||||
execFileSync: () => {
|
||||
throw new Error("git unavailable");
|
||||
},
|
||||
now: () => new Date("2026-07-10T01:02:03.000Z"),
|
||||
}).commit,
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it("preserves GIT_COMMIT then GIT_SHA explicit input precedence", () => {
|
||||
const rootDir = createPackage();
|
||||
const fallbackSha = "1234567890abcdef1234567890abcdef12345678";
|
||||
|
||||
expect(
|
||||
resolveBuildInfo({
|
||||
rootDir,
|
||||
env: { GIT_SHA: fallbackSha },
|
||||
now: () => new Date("2026-07-10T01:02:03.000Z"),
|
||||
}).commit,
|
||||
).toBe(fallbackSha);
|
||||
expect(() =>
|
||||
resolveBuildInfo({
|
||||
rootDir,
|
||||
env: { GIT_COMMIT: "bad", GIT_SHA: fallbackSha },
|
||||
}),
|
||||
).toThrow("GIT_COMMIT must be a full 40-character Git commit SHA.");
|
||||
});
|
||||
|
||||
it("uses checked-out Git instead of unverified GitHub workflow context", () => {
|
||||
const rootDir = createPackage();
|
||||
const checkedOutCommit = "b".repeat(40);
|
||||
const execFileSync = vi.fn(() => checkedOutCommit);
|
||||
|
||||
expect(
|
||||
resolveBuildInfo({
|
||||
rootDir,
|
||||
env: { GITHUB_SHA: "a".repeat(40) },
|
||||
execFileSync,
|
||||
now: () => new Date("2026-07-10T01:02:03.000Z"),
|
||||
}).commit,
|
||||
).toBe(checkedOutCommit);
|
||||
expect(execFileSync).toHaveBeenCalledOnce();
|
||||
expect(
|
||||
resolveBuildInfo({
|
||||
rootDir,
|
||||
env: { GITHUB_SHA: "a".repeat(40) },
|
||||
execFileSync: () => {
|
||||
throw new Error("git unavailable");
|
||||
},
|
||||
now: () => new Date("2026-07-10T01:02:03.000Z"),
|
||||
}).commit,
|
||||
).toBe("a".repeat(40));
|
||||
expect(() =>
|
||||
resolveBuildInfo({
|
||||
rootDir,
|
||||
env: { GITHUB_SHA: "bad" },
|
||||
execFileSync: () => {
|
||||
throw new Error("git unavailable");
|
||||
},
|
||||
}),
|
||||
).toThrow("GITHUB_SHA must be a full 40-character Git commit SHA.");
|
||||
});
|
||||
|
||||
it("rejects abbreviated or malformed explicit commits", () => {
|
||||
expect(() => normalizeBuildCommit("abc1234")).toThrow(
|
||||
"GIT_COMMIT must be a full 40-character Git commit SHA.",
|
||||
);
|
||||
expect(() => normalizeBuildCommit("g".repeat(40))).toThrow(
|
||||
"GIT_COMMIT must be a full 40-character Git commit SHA.",
|
||||
);
|
||||
});
|
||||
|
||||
it("normalizes valid UTC timestamps and rejects offsets or impossible dates", () => {
|
||||
expect(normalizeBuildTimestamp("2026-07-10T12:34:56.7Z")).toBe("2026-07-10T12:34:56.700Z");
|
||||
expect(() => normalizeBuildTimestamp("2026-07-10T12:34:56+00:00")).toThrow(
|
||||
"OPENCLAW_BUILD_TIMESTAMP must be an ISO-8601 UTC timestamp ending in Z.",
|
||||
);
|
||||
expect(() => normalizeBuildTimestamp("2026-02-30T12:34:56Z")).toThrow(
|
||||
"OPENCLAW_BUILD_TIMESTAMP must be a valid ISO-8601 UTC timestamp.",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -78,6 +78,7 @@ describe("navigationIconForRoute", () => {
|
||||
automation: "terminal",
|
||||
mcp: "wrench",
|
||||
infrastructure: "globe",
|
||||
about: "fileText",
|
||||
"ai-agents": "brain",
|
||||
debug: "bug",
|
||||
logs: "scrollText",
|
||||
@@ -119,6 +120,7 @@ describe("titleForRoute", () => {
|
||||
automation: "Automation",
|
||||
mcp: "MCP",
|
||||
infrastructure: "Infrastructure",
|
||||
about: "About",
|
||||
"ai-agents": "AI & Agents",
|
||||
debug: "Debug",
|
||||
logs: "Logs",
|
||||
@@ -154,6 +156,7 @@ describe("subtitleForRoute", () => {
|
||||
automation: "Commands, hooks, cron, and plugins.",
|
||||
mcp: "MCP servers, auth, tools, and diagnostics.",
|
||||
infrastructure: "Gateway, web, browser, and media settings.",
|
||||
about: "Control UI and connected Gateway build identity.",
|
||||
"ai-agents": "Agents, models, skills, tools, memory, session.",
|
||||
debug: "Snapshots, events, RPC.",
|
||||
logs: "Live gateway logs.",
|
||||
@@ -223,6 +226,8 @@ describe("routeIdFromPath", () => {
|
||||
expect(routeIdFromPath("/logs")).toBe("logs");
|
||||
expect(routeIdFromPath("/dreaming")).toBe("dreams");
|
||||
expect(routeIdFromPath("/dreams")).toBe("dreams");
|
||||
expect(routeIdFromPath("/settings/about")).toBe("about");
|
||||
expect(routeIdFromPath("/about")).toBeNull();
|
||||
});
|
||||
|
||||
it("leaves root fallback to application startup", () => {
|
||||
@@ -302,6 +307,7 @@ describe("inferBasePathFromPathname", () => {
|
||||
it("preserves mount roots without a route suffix", () => {
|
||||
expect(inferBasePathFromPathname("/__openclaw__/")).toBe("/__openclaw__");
|
||||
expect(inferBasePathFromPathname("/apps/openclaw/")).toBe("/apps/openclaw");
|
||||
expect(inferBasePathFromPathname("/about/")).toBe("/about");
|
||||
expect(inferBasePathFromPathname("/typo")).toBe("");
|
||||
});
|
||||
|
||||
@@ -352,6 +358,7 @@ describe("SIDEBAR_NAV_ROUTES", () => {
|
||||
"worktrees",
|
||||
"debug",
|
||||
"logs",
|
||||
"about",
|
||||
]);
|
||||
});
|
||||
|
||||
|
||||
@@ -75,7 +75,7 @@ export const SETTINGS_NAVIGATION_GROUPS = [
|
||||
{ labelKey: "nav.settingsGroupAgents", routes: ["ai-agents", "automation", "mcp"] },
|
||||
{
|
||||
labelKey: "nav.settingsGroupSystem",
|
||||
routes: ["infrastructure", "worktrees", "debug", "logs"],
|
||||
routes: ["infrastructure", "worktrees", "debug", "logs", "about"],
|
||||
},
|
||||
] as const satisfies readonly SettingsNavigationGroup[];
|
||||
|
||||
@@ -105,6 +105,7 @@ const NAVIGATION_ICONS: NavigationItem = {
|
||||
automation: "terminal",
|
||||
mcp: "wrench",
|
||||
infrastructure: "globe",
|
||||
about: "fileText",
|
||||
"ai-agents": "brain",
|
||||
debug: "bug",
|
||||
logs: "scrollText",
|
||||
@@ -197,6 +198,7 @@ const NAVIGATION_COPY: Record<NavigationRouteId, { titleKey: string; subtitleKey
|
||||
automation: { titleKey: "tabs.automation", subtitleKey: "subtitles.automation" },
|
||||
mcp: { titleKey: "tabs.mcp", subtitleKey: "subtitles.mcp" },
|
||||
infrastructure: { titleKey: "tabs.infrastructure", subtitleKey: "subtitles.infrastructure" },
|
||||
about: { titleKey: "tabs.about", subtitleKey: "subtitles.about" },
|
||||
"ai-agents": { titleKey: "tabs.aiAgents", subtitleKey: "subtitles.aiAgents" },
|
||||
debug: { titleKey: "tabs.debug", subtitleKey: "subtitles.debug" },
|
||||
logs: { titleKey: "tabs.logs", subtitleKey: "subtitles.logs" },
|
||||
|
||||
@@ -14,6 +14,7 @@ const APP_ROUTE_DEFINITIONS = {
|
||||
automation: { path: "/settings/automation", aliases: ["/automation"] },
|
||||
mcp: { path: "/settings/mcp", aliases: ["/mcp"] },
|
||||
infrastructure: { path: "/settings/infrastructure", aliases: ["/infrastructure"] },
|
||||
about: { path: "/settings/about" },
|
||||
"ai-agents": { path: "/settings/ai-agents", aliases: ["/ai-agents"] },
|
||||
workboard: { path: "/workboard" },
|
||||
worktrees: { path: "/settings/worktrees", aliases: ["/worktrees"] },
|
||||
|
||||
@@ -2,6 +2,7 @@ import { createRouter } from "@openclaw/uirouter";
|
||||
import type { PageDefinition, Router, RouterHistory } from "@openclaw/uirouter";
|
||||
import { routeIdFromPath, type RouteId } from "./app-route-paths.ts";
|
||||
import type { ApplicationContext } from "./app/context.ts";
|
||||
import { page as aboutPage } from "./pages/about/route.ts";
|
||||
import { page as activityPage } from "./pages/activity/route.ts";
|
||||
import { page as agentsPage } from "./pages/agents/route.ts";
|
||||
import { page as channelsPage } from "./pages/channels/route.ts";
|
||||
@@ -42,6 +43,7 @@ const APP_ROUTE_TREE = [
|
||||
activityPage,
|
||||
agentsPage,
|
||||
channelsPage,
|
||||
aboutPage,
|
||||
...configPages,
|
||||
profilePage,
|
||||
workboardPage,
|
||||
|
||||
@@ -27,6 +27,10 @@ describe("inferControlUiPublicAssetPath", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps an about mount root distinct from the settings About route", () => {
|
||||
expect(inferControlUiPublicAssetPath("sw.js", { pathname: "/about/" })).toBe("/about/sw.js");
|
||||
});
|
||||
|
||||
it("prefers an explicit base path over pathname inference", () => {
|
||||
expect(
|
||||
inferControlUiPublicAssetPath("apple-touch-icon.png", {
|
||||
|
||||
@@ -15,6 +15,7 @@ describe("Control UI service worker cache versioning", () => {
|
||||
const viteConfigSource = fs.readFileSync(path.join(here, "../../vite.config.ts"), "utf8");
|
||||
|
||||
expect(mainSource).toContain('swUrl.searchParams.set("v"');
|
||||
expect(mainSource).toContain("CONTROL_UI_BUILD_INFO.buildId");
|
||||
expect(mainSource).toContain('updateViaCache: "none"');
|
||||
expect(mainSource).toContain('navigator.serviceWorker.addEventListener("message"');
|
||||
expect(mainSource).toContain("event.data.version !== currentControlUiBuildId");
|
||||
@@ -30,6 +31,12 @@ describe("Control UI service worker cache versioning", () => {
|
||||
'postMessage({ type: "sw-updated", version: CACHE_VERSION },',
|
||||
);
|
||||
expect(viteConfigSource).toContain("source.replace(placeholder, JSON.stringify(buildId))");
|
||||
expect(viteConfigSource).toContain(
|
||||
'"globalThis.OPENCLAW_CONTROL_UI_BUILD_INFO": JSON.stringify(buildInfo)',
|
||||
);
|
||||
expect(viteConfigSource).not.toContain(
|
||||
"OPENCLAW_CONTROL_UI_BUILD_ID: JSON.stringify(controlUiBuildId)",
|
||||
);
|
||||
expect(serviceWorkerSource).not.toContain('const CACHE_NAME = "openclaw-control-v1"');
|
||||
});
|
||||
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
controlUiBrowserOnlySharedModuleAliases,
|
||||
resolveControlUiBuildInfo,
|
||||
resolveExternalPackageAliasesForVite,
|
||||
resolveSourcePackageAliasesForVite,
|
||||
resolveTsconfigPathAliasesForVite,
|
||||
@@ -21,6 +22,116 @@ function findStringAlias(key: string) {
|
||||
}
|
||||
|
||||
describe("Control UI Vite config", () => {
|
||||
it("embeds one canonical artifact identity from explicit build inputs", () => {
|
||||
const readGitCommit = vi.fn(() => "f".repeat(40));
|
||||
expect(
|
||||
resolveControlUiBuildInfo({
|
||||
env: {
|
||||
GIT_COMMIT: "0123456789abcdef0123456789abcdef01234567",
|
||||
OPENCLAW_BUILD_TIMESTAMP: "2026-07-10T12:34:56Z",
|
||||
},
|
||||
readGitCommit,
|
||||
readPackageVersion: () => "2026.7.10",
|
||||
}),
|
||||
).toEqual({
|
||||
version: "2026.7.10",
|
||||
commit: "0123456789abcdef0123456789abcdef01234567",
|
||||
builtAt: "2026-07-10T12:34:56.000Z",
|
||||
buildId: "2026.7.10-0123456789ab-2026-07-10T12-34-56.000Z",
|
||||
});
|
||||
expect(readGitCommit).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("falls back to Git and the current UTC time only when inputs are absent", () => {
|
||||
expect(
|
||||
resolveControlUiBuildInfo({
|
||||
env: {},
|
||||
now: () => new Date("2026-07-10T13:14:15.000Z"),
|
||||
readGitCommit: () => "a".repeat(40),
|
||||
readPackageVersion: () => null,
|
||||
}),
|
||||
).toEqual({
|
||||
version: null,
|
||||
commit: "a".repeat(40),
|
||||
builtAt: "2026-07-10T13:14:15.000Z",
|
||||
buildId: "aaaaaaaaaaaa-2026-07-10T13-14-15.000Z",
|
||||
});
|
||||
});
|
||||
|
||||
it("uses checked-out Git instead of unverified GitHub workflow context", () => {
|
||||
const readGitCommit = vi.fn(() => "c".repeat(40));
|
||||
expect(
|
||||
resolveControlUiBuildInfo({
|
||||
env: { GITHUB_SHA: "b".repeat(40) },
|
||||
now: () => new Date("2026-07-10T13:14:15.000Z"),
|
||||
readGitCommit,
|
||||
readPackageVersion: () => null,
|
||||
}).commit,
|
||||
).toBe("c".repeat(40));
|
||||
expect(readGitCommit).toHaveBeenCalledOnce();
|
||||
expect(
|
||||
resolveControlUiBuildInfo({
|
||||
env: { GITHUB_SHA: "b".repeat(40) },
|
||||
now: () => new Date("2026-07-10T13:14:15.000Z"),
|
||||
readGitCommit: () => null,
|
||||
readPackageVersion: () => null,
|
||||
}).commit,
|
||||
).toBe("b".repeat(40));
|
||||
expect(() =>
|
||||
resolveControlUiBuildInfo({
|
||||
env: { GITHUB_SHA: "bad" },
|
||||
readGitCommit: () => null,
|
||||
readPackageVersion: () => null,
|
||||
}),
|
||||
).toThrow("GITHUB_SHA must be a full 40-character hexadecimal SHA");
|
||||
});
|
||||
|
||||
it("uses explicit commit aliases before reading Git", () => {
|
||||
const readGitCommit = vi.fn(() => "c".repeat(40));
|
||||
expect(
|
||||
resolveControlUiBuildInfo({
|
||||
env: { GIT_SHA: "A".repeat(40), GITHUB_SHA: "b".repeat(40) },
|
||||
now: () => new Date("2026-07-10T13:14:15.000Z"),
|
||||
readGitCommit,
|
||||
readPackageVersion: () => null,
|
||||
}).commit,
|
||||
).toBe("a".repeat(40));
|
||||
expect(readGitCommit).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does not let a generic release selector replace the artifact build identity", () => {
|
||||
expect(
|
||||
resolveControlUiBuildInfo({
|
||||
env: {
|
||||
OPENCLAW_VERSION: "latest",
|
||||
OPENCLAW_BUILD_TIMESTAMP: "2026-07-10T13:14:15.000Z",
|
||||
},
|
||||
readGitCommit: () => "a".repeat(40),
|
||||
readPackageVersion: () => "2026.7.10",
|
||||
}).buildId,
|
||||
).toBe("2026.7.10-aaaaaaaaaaaa-2026-07-10T13-14-15.000Z");
|
||||
});
|
||||
|
||||
it("fails closed for nonempty invalid explicit build inputs", () => {
|
||||
const readGitCommit = vi.fn(() => "a".repeat(40));
|
||||
expect(() =>
|
||||
resolveControlUiBuildInfo({
|
||||
env: { GIT_COMMIT: "deadbeef" },
|
||||
readGitCommit,
|
||||
readPackageVersion: () => "2026.7.10",
|
||||
}),
|
||||
).toThrow("GIT_COMMIT must be a full 40-character hexadecimal SHA");
|
||||
expect(readGitCommit).not.toHaveBeenCalled();
|
||||
|
||||
expect(() =>
|
||||
resolveControlUiBuildInfo({
|
||||
env: { OPENCLAW_BUILD_TIMESTAMP: "2026-07-10 12:34:56" },
|
||||
readGitCommit: () => "a".repeat(40),
|
||||
readPackageVersion: () => "2026.7.10",
|
||||
}),
|
||||
).toThrow("OPENCLAW_BUILD_TIMESTAMP must be a valid UTC ISO-8601 timestamp ending in Z");
|
||||
});
|
||||
|
||||
it("resolves root tsconfig package aliases for source imports", () => {
|
||||
expect(findStringAlias("@openclaw/net-policy/ip")?.replacement).toBe(
|
||||
path.join(repoRoot, "packages/net-policy/src/ip.ts"),
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
deriveControlUiBuildId,
|
||||
normalizeControlUiBuildInfo,
|
||||
normalizeControlUiBuildTimestamp,
|
||||
normalizeControlUiCommit,
|
||||
} from "./build-info.ts";
|
||||
|
||||
const COMMIT = "0123456789abcdef0123456789abcdef01234567";
|
||||
|
||||
describe("Control UI build info", () => {
|
||||
it("keeps only full Git SHAs", () => {
|
||||
expect(normalizeControlUiCommit(COMMIT.toUpperCase())).toBe(COMMIT);
|
||||
expect(normalizeControlUiCommit(COMMIT.slice(0, 12))).toBeNull();
|
||||
expect(normalizeControlUiCommit("not-a-sha")).toBeNull();
|
||||
});
|
||||
|
||||
it("canonicalizes only valid UTC build timestamps", () => {
|
||||
expect(normalizeControlUiBuildTimestamp("2026-07-10T12:34:56Z")).toBe(
|
||||
"2026-07-10T12:34:56.000Z",
|
||||
);
|
||||
expect(normalizeControlUiBuildTimestamp("2026-07-10T12:34:56.123Z")).toBe(
|
||||
"2026-07-10T12:34:56.123Z",
|
||||
);
|
||||
expect(normalizeControlUiBuildTimestamp("2026-07-10T12:34:56.7Z")).toBe(
|
||||
"2026-07-10T12:34:56.700Z",
|
||||
);
|
||||
expect(normalizeControlUiBuildTimestamp("2026-07-10T12:34:56.12Z")).toBe(
|
||||
"2026-07-10T12:34:56.120Z",
|
||||
);
|
||||
expect(normalizeControlUiBuildTimestamp("2026-02-30T12:34:56Z")).toBeNull();
|
||||
expect(normalizeControlUiBuildTimestamp("2026-07-10T12:34:56+00:00")).toBeNull();
|
||||
});
|
||||
|
||||
it("renders invalid injected metadata as unavailable instead of inventing identity", () => {
|
||||
expect(
|
||||
normalizeControlUiBuildInfo({
|
||||
version: " ",
|
||||
commit: "deadbeef",
|
||||
builtAt: "later",
|
||||
buildId: "",
|
||||
}),
|
||||
).toEqual({ version: null, commit: null, builtAt: null, buildId: "dev" });
|
||||
});
|
||||
|
||||
it("derives a stable service-worker id from the same artifact metadata", () => {
|
||||
expect(
|
||||
deriveControlUiBuildId({
|
||||
version: "2026.7.10",
|
||||
commit: COMMIT,
|
||||
builtAt: "2026-07-10T12:34:56.000Z",
|
||||
}),
|
||||
).toBe("2026.7.10-0123456789ab-2026-07-10T12-34-56.000Z");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,72 @@
|
||||
// Compile-time identity for the Control UI artifact.
|
||||
|
||||
export type ControlUiBuildInfo = Readonly<{
|
||||
version: string | null;
|
||||
commit: string | null;
|
||||
builtAt: string | null;
|
||||
buildId: string;
|
||||
}>;
|
||||
|
||||
type ControlUiBuildMetadata = Pick<ControlUiBuildInfo, "version" | "commit" | "builtAt">;
|
||||
|
||||
const FULL_GIT_SHA = /^[0-9a-f]{40}$/u;
|
||||
const UTC_BUILD_TIMESTAMP = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d{1,3})?Z$/u;
|
||||
const BUILD_ID_MAX_LENGTH = 96;
|
||||
|
||||
declare global {
|
||||
// Vite replaces this property with one object so the UI and service worker
|
||||
// share the exact artifact identity without separate compile-time constants.
|
||||
var OPENCLAW_CONTROL_UI_BUILD_INFO: ControlUiBuildInfo | undefined;
|
||||
}
|
||||
|
||||
function normalizeOptionalString(value: unknown): string | null {
|
||||
return typeof value === "string" && value.trim() ? value.trim() : null;
|
||||
}
|
||||
|
||||
export function normalizeControlUiCommit(value: unknown): string | null {
|
||||
const commit = normalizeOptionalString(value)?.toLowerCase() ?? null;
|
||||
return commit && FULL_GIT_SHA.test(commit) ? commit : null;
|
||||
}
|
||||
|
||||
export function normalizeControlUiBuildTimestamp(value: unknown): string | null {
|
||||
const timestamp = normalizeOptionalString(value);
|
||||
if (!timestamp || !UTC_BUILD_TIMESTAMP.test(timestamp)) {
|
||||
return null;
|
||||
}
|
||||
const date = new Date(timestamp);
|
||||
if (Number.isNaN(date.getTime())) {
|
||||
return null;
|
||||
}
|
||||
const canonicalInput = timestamp.replace(/(?:\.(\d{1,3}))?Z$/u, (_match, fraction) => {
|
||||
return `.${String(fraction ?? "").padEnd(3, "0")}Z`;
|
||||
});
|
||||
return date.toISOString() === canonicalInput ? date.toISOString() : null;
|
||||
}
|
||||
|
||||
export function normalizeControlUiBuildId(value: unknown): string {
|
||||
const normalized = normalizeOptionalString(value)?.replace(/[^a-zA-Z0-9._-]+/g, "-");
|
||||
return normalized?.slice(0, BUILD_ID_MAX_LENGTH) || "dev";
|
||||
}
|
||||
|
||||
export function deriveControlUiBuildId(info: ControlUiBuildMetadata): string {
|
||||
const identity = [info.version, info.commit?.slice(0, 12), info.builtAt]
|
||||
.filter((value): value is string => Boolean(value))
|
||||
.join("-");
|
||||
return normalizeControlUiBuildId(identity);
|
||||
}
|
||||
|
||||
export function normalizeControlUiBuildInfo(value: unknown): ControlUiBuildInfo {
|
||||
const record = value && typeof value === "object" ? (value as Record<string, unknown>) : {};
|
||||
const version = normalizeOptionalString(record.version);
|
||||
const commit = normalizeControlUiCommit(record.commit);
|
||||
const builtAt = normalizeControlUiBuildTimestamp(record.builtAt);
|
||||
const metadata = { version, commit, builtAt };
|
||||
return {
|
||||
...metadata,
|
||||
buildId: normalizeControlUiBuildId(record.buildId ?? deriveControlUiBuildId(metadata)),
|
||||
};
|
||||
}
|
||||
|
||||
const injectedBuildInfo = globalThis.OPENCLAW_CONTROL_UI_BUILD_INFO;
|
||||
|
||||
export const CONTROL_UI_BUILD_INFO = normalizeControlUiBuildInfo(injectedBuildInfo);
|
||||
@@ -0,0 +1,123 @@
|
||||
// Control UI tests cover About artifact identity against a mocked Gateway.
|
||||
import { chromium, type Browser } from "playwright";
|
||||
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
||||
import {
|
||||
canRunPlaywrightChromium,
|
||||
installMockGateway,
|
||||
resolvePlaywrightChromiumExecutablePath,
|
||||
startControlUiE2eServer,
|
||||
type ControlUiE2eServer,
|
||||
} from "../test-helpers/control-ui-e2e.ts";
|
||||
|
||||
const chromiumExecutablePath = resolvePlaywrightChromiumExecutablePath(chromium.executablePath());
|
||||
const chromiumAvailable = canRunPlaywrightChromium(chromiumExecutablePath);
|
||||
const allowMissingChromium = process.env.OPENCLAW_UI_E2E_ALLOW_MISSING_CHROMIUM === "1";
|
||||
const describeControlUiE2e = chromiumAvailable || !allowMissingChromium ? describe : describe.skip;
|
||||
const COMMIT = "0123456789abcdef0123456789abcdef01234567";
|
||||
const BUILT_AT = "2026-07-10T12:34:56.000Z";
|
||||
|
||||
let browser: Browser;
|
||||
let server: ControlUiE2eServer;
|
||||
|
||||
describeControlUiE2e("Control UI About mocked Gateway E2E", () => {
|
||||
beforeAll(async () => {
|
||||
if (!chromiumAvailable) {
|
||||
throw new Error(`Playwright Chromium is unavailable at ${chromiumExecutablePath}`);
|
||||
}
|
||||
server = await startControlUiE2eServer();
|
||||
browser = await chromium.launch({ executablePath: chromiumExecutablePath });
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await browser?.close();
|
||||
await server?.close();
|
||||
});
|
||||
|
||||
it("shows and copies browser artifact identity, separately from the Gateway version", async () => {
|
||||
const context = await browser.newContext({
|
||||
locale: "en-US",
|
||||
serviceWorkers: "block",
|
||||
viewport: { height: 900, width: 1280 },
|
||||
});
|
||||
await context.addInitScript(() => {
|
||||
Object.defineProperty(navigator, "clipboard", {
|
||||
configurable: true,
|
||||
value: {
|
||||
writeText: async (text: string) => {
|
||||
(globalThis as typeof globalThis & { __openclawCopiedCommit?: string })[
|
||||
"__openclawCopiedCommit"
|
||||
] = text;
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
const page = await context.newPage();
|
||||
await installMockGateway(page);
|
||||
|
||||
try {
|
||||
const response = await page.goto(`${server.baseUrl}settings/about`);
|
||||
expect(response?.status()).toBe(200);
|
||||
await page.getByRole("heading", { name: "Settings" }).waitFor();
|
||||
|
||||
const aboutLink = page.getByRole("link", { name: "About", exact: true });
|
||||
await expect.poll(() => aboutLink.getAttribute("aria-current")).toBe("page");
|
||||
|
||||
const strip = page.getByRole("group", { name: "Control UI build details" });
|
||||
const items = strip.locator(":scope > .about-build-strip__item");
|
||||
await expect.poll(() => items.count()).toBe(3);
|
||||
await expect.poll(() => items.nth(0).textContent()).toContain("2026.7.10");
|
||||
|
||||
const commit = items.nth(1).locator("code");
|
||||
await expect.poll(() => commit.textContent()).toBe(COMMIT.slice(0, 12));
|
||||
await expect.poll(() => commit.getAttribute("title")).toBe(COMMIT);
|
||||
|
||||
const built = items.nth(2).locator("time");
|
||||
await expect.poll(() => built.textContent()).toBe("Jul 10, 2026");
|
||||
await expect.poll(() => built.getAttribute("datetime")).toBe(BUILT_AT);
|
||||
await expect.poll(() => built.getAttribute("title")).toBe(BUILT_AT);
|
||||
|
||||
const gatewayRow = page.locator(".about-gateway-row");
|
||||
await expect.poll(() => gatewayRow.textContent()).toContain("e2e");
|
||||
await expect
|
||||
.poll(() => gatewayRow.textContent())
|
||||
.toContain("separate from this Control UI build");
|
||||
|
||||
const desktopTops = await items.evaluateAll((elements) =>
|
||||
elements.map((element) => Math.round(element.getBoundingClientRect().top)),
|
||||
);
|
||||
expect(new Set(desktopTops).size).toBe(1);
|
||||
|
||||
const copyButton = strip.locator(".about-build-strip__copy");
|
||||
await expect.poll(() => copyButton.getAttribute("aria-label")).toBe("Copy full commit hash");
|
||||
await copyButton.click();
|
||||
await expect.poll(() => copyButton.getAttribute("aria-label")).toBe("Commit hash copied");
|
||||
await expect
|
||||
.poll(() =>
|
||||
page.evaluate(
|
||||
() =>
|
||||
(globalThis as typeof globalThis & { __openclawCopiedCommit?: string })[
|
||||
"__openclawCopiedCommit"
|
||||
],
|
||||
),
|
||||
)
|
||||
.toBe(COMMIT);
|
||||
|
||||
await page.setViewportSize({ height: 812, width: 375 });
|
||||
const mobileTops = await items.evaluateAll((elements) =>
|
||||
elements.map((element) => Math.round(element.getBoundingClientRect().top)),
|
||||
);
|
||||
expect(new Set(mobileTops).size).toBe(3);
|
||||
await expect
|
||||
.poll(() =>
|
||||
page.evaluate(
|
||||
() => document.documentElement.scrollWidth - document.documentElement.clientWidth,
|
||||
),
|
||||
)
|
||||
.toBeLessThanOrEqual(1);
|
||||
const mobileScreenshot = await page.screenshot({ animations: "disabled", fullPage: true });
|
||||
expect(mobileScreenshot.byteLength).toBeGreaterThan(1_000);
|
||||
} finally {
|
||||
await context.close();
|
||||
}
|
||||
});
|
||||
});
|
||||
Generated
+4
-4
@@ -1,11 +1,11 @@
|
||||
{
|
||||
"fallbackKeys": [],
|
||||
"generatedAt": "2026-07-10T08:19:15.941Z",
|
||||
"generatedAt": "2026-07-10T08:37:26.526Z",
|
||||
"locale": "ar",
|
||||
"model": "gpt-5.5",
|
||||
"provider": "openai",
|
||||
"sourceHash": "3883d1abcb57aa52cf17aab0761a1f02be8a469c52ccc72dbe551f78cd5de297",
|
||||
"totalKeys": 1779,
|
||||
"translatedKeys": 1779,
|
||||
"sourceHash": "f6a3e03d983c828decd96c8d86d239a1ce4cb9ffd26f7989145a7614ebce30c3",
|
||||
"totalKeys": 1794,
|
||||
"translatedKeys": 1794,
|
||||
"workflow": 1
|
||||
}
|
||||
|
||||
Generated
+4
-4
@@ -1,11 +1,11 @@
|
||||
{
|
||||
"fallbackKeys": [],
|
||||
"generatedAt": "2026-07-10T08:19:11.851Z",
|
||||
"generatedAt": "2026-07-10T08:37:25.833Z",
|
||||
"locale": "de",
|
||||
"model": "gpt-5.5",
|
||||
"provider": "openai",
|
||||
"sourceHash": "3883d1abcb57aa52cf17aab0761a1f02be8a469c52ccc72dbe551f78cd5de297",
|
||||
"totalKeys": 1779,
|
||||
"translatedKeys": 1779,
|
||||
"sourceHash": "f6a3e03d983c828decd96c8d86d239a1ce4cb9ffd26f7989145a7614ebce30c3",
|
||||
"totalKeys": 1794,
|
||||
"translatedKeys": 1794,
|
||||
"workflow": 1
|
||||
}
|
||||
|
||||
Generated
+4
-4
@@ -1,11 +1,11 @@
|
||||
{
|
||||
"fallbackKeys": [],
|
||||
"generatedAt": "2026-07-10T08:19:12.401Z",
|
||||
"generatedAt": "2026-07-10T08:37:25.947Z",
|
||||
"locale": "es",
|
||||
"model": "gpt-5.5",
|
||||
"provider": "openai",
|
||||
"sourceHash": "3883d1abcb57aa52cf17aab0761a1f02be8a469c52ccc72dbe551f78cd5de297",
|
||||
"totalKeys": 1779,
|
||||
"translatedKeys": 1779,
|
||||
"sourceHash": "f6a3e03d983c828decd96c8d86d239a1ce4cb9ffd26f7989145a7614ebce30c3",
|
||||
"totalKeys": 1794,
|
||||
"translatedKeys": 1794,
|
||||
"workflow": 1
|
||||
}
|
||||
|
||||
Generated
+4
-4
@@ -1,11 +1,11 @@
|
||||
{
|
||||
"fallbackKeys": [],
|
||||
"generatedAt": "2026-07-10T08:19:22.383Z",
|
||||
"generatedAt": "2026-07-10T08:37:27.543Z",
|
||||
"locale": "fa",
|
||||
"model": "gpt-5.5",
|
||||
"provider": "openai",
|
||||
"sourceHash": "3883d1abcb57aa52cf17aab0761a1f02be8a469c52ccc72dbe551f78cd5de297",
|
||||
"totalKeys": 1779,
|
||||
"translatedKeys": 1779,
|
||||
"sourceHash": "f6a3e03d983c828decd96c8d86d239a1ce4cb9ffd26f7989145a7614ebce30c3",
|
||||
"totalKeys": 1794,
|
||||
"translatedKeys": 1794,
|
||||
"workflow": 1
|
||||
}
|
||||
|
||||
Generated
+4
-4
@@ -1,11 +1,11 @@
|
||||
{
|
||||
"fallbackKeys": [],
|
||||
"generatedAt": "2026-07-10T08:19:14.479Z",
|
||||
"generatedAt": "2026-07-10T08:37:26.288Z",
|
||||
"locale": "fr",
|
||||
"model": "gpt-5.5",
|
||||
"provider": "openai",
|
||||
"sourceHash": "3883d1abcb57aa52cf17aab0761a1f02be8a469c52ccc72dbe551f78cd5de297",
|
||||
"totalKeys": 1779,
|
||||
"translatedKeys": 1779,
|
||||
"sourceHash": "f6a3e03d983c828decd96c8d86d239a1ce4cb9ffd26f7989145a7614ebce30c3",
|
||||
"totalKeys": 1794,
|
||||
"translatedKeys": 1794,
|
||||
"workflow": 1
|
||||
}
|
||||
|
||||
Generated
+4
-4
@@ -1,11 +1,11 @@
|
||||
{
|
||||
"fallbackKeys": [],
|
||||
"generatedAt": "2026-07-10T08:19:15.230Z",
|
||||
"generatedAt": "2026-07-10T08:37:26.413Z",
|
||||
"locale": "hi",
|
||||
"model": "gpt-5.5",
|
||||
"provider": "openai",
|
||||
"sourceHash": "3883d1abcb57aa52cf17aab0761a1f02be8a469c52ccc72dbe551f78cd5de297",
|
||||
"totalKeys": 1779,
|
||||
"translatedKeys": 1779,
|
||||
"sourceHash": "f6a3e03d983c828decd96c8d86d239a1ce4cb9ffd26f7989145a7614ebce30c3",
|
||||
"totalKeys": 1794,
|
||||
"translatedKeys": 1794,
|
||||
"workflow": 1
|
||||
}
|
||||
|
||||
Generated
+2
@@ -93,6 +93,7 @@
|
||||
{"cache_key":"0f6b1af340bd55073fddb1be59060e51ef9d7165d0829491393a536180463bf5","model":"gpt-5.5","provider":"openai","segment_id":"overview.snapshot.lastChannelsRefresh","source_path":"ui/src/i18n/locales/hi.ts","src_lang":"en","text":"Last Channels Refresh","text_hash":"97a20d4f5b29914b8a08748cfc55d704a4d52ed948180cc90b7c1e06267c692f","tgt_lang":"hi","translated":"अंतिम चैनल रिफ्रेश","updated_at":"2026-06-26T21:33:15.607Z"}
|
||||
{"cache_key":"0fa51fb9e7d660ff75b9a91e4e0461a162f3b5b02106ba4c56c7e7c7295b7033","model":"gpt-5.5","provider":"openai","segment_id":"dreaming.phrases.filingLooseThoughts","source_path":"ui/src/i18n/locales/hi.ts","src_lang":"en","text":"filing away loose thoughts…","text_hash":"352e9ecf138c39219228e6e09c7d8fde37b02f1dd93fe411cdf781257e9be521","tgt_lang":"hi","translated":"बिखरे विचारों को फ़ाइल किया जा रहा है…","updated_at":"2026-06-26T21:34:24.815Z"}
|
||||
{"cache_key":"0fba2d5dead215b9bf8cf4dbde67a0a18686ec09a3e598efe086bd06b627f0e4","model":"gpt-5.5","provider":"openai","segment_id":"profilePage.insightModel","source_path":"ui/src/i18n/locales/hi.ts","src_lang":"en","text":"Most used model","text_hash":"a13c86cd4f835d2c2b5af940171ede2a27e0f805cef070d87628f1bd333bb706","tgt_lang":"hi","translated":"सबसे अधिक उपयोग किया गया model","updated_at":"2026-07-09T11:27:28.426Z"}
|
||||
{"cache_key":"0fbbc3e0f5044eb45299580ae63007f92c11b5b0b6dabbbf44e6971eda686ac6","model":"gpt-5.5","provider":"openai","segment_id":"tabs.about","source_path":"ui/src/i18n/locales/hi.ts","src_lang":"en","text":"About","text_hash":"4efca0d10c5feb8e9b35eb1d994f2905bb71714e6a271f511d713b539ea5faa1","tgt_lang":"hi","translated":"परिचय","updated_at":"2026-06-26T21:29:48.427Z"}
|
||||
{"cache_key":"0fe85a1bf4b86ecdc3a2d90e92bc495f67df949b23bf5e5c36aea457a49c7863","model":"gpt-5.5","provider":"openai","segment_id":"chat.refreshTitle","source_path":"ui/src/i18n/locales/hi.ts","src_lang":"en","text":"Refresh chat data","text_hash":"40b8edfd9a1326939cf9db6cca2b31d4af4e815606fe6d86f7982f4f9534e268","tgt_lang":"hi","translated":"चैट डेटा रीफ़्रेश करें","updated_at":"2026-06-26T21:36:16.800Z"}
|
||||
{"cache_key":"0ff9313f900028f3097a954f640efdb603a7a0b256f33332270ee6f08b6b6aed","model":"gpt-5.5","provider":"openai","segment_id":"execApproval.labels.cwd","source_path":"ui/src/i18n/locales/hi.ts","src_lang":"en","text":"CWD","text_hash":"0217f1cb7725737f15a6710df3bcfa3bc10a239f0f7801ec3d7168e675f5ebd6","tgt_lang":"hi","translated":"CWD","updated_at":"2026-06-26T21:31:18.653Z"}
|
||||
{"cache_key":"10419c56b65c6e46b45934a998bec02e871de17394057466ac7b7472ccead5a4","model":"gpt-5.5","provider":"openai","segment_id":"dreaming.stats.promoted","source_path":"ui/src/i18n/locales/hi.ts","src_lang":"en","text":"Promoted","text_hash":"0cf04463c4276a6276986c22155bd4a32ce81e8dd162a657dedfa9afb97a7371","tgt_lang":"hi","translated":"प्रमोट किया गया","updated_at":"2026-06-26T21:34:11.503Z"}
|
||||
@@ -1055,6 +1056,7 @@
|
||||
{"cache_key":"a499cbb14cc64a92c85c56f3fe21c0458798fcf5d41c72ddfa191d928971b46e","model":"gpt-5.5","provider":"openai","segment_id":"chat.composer.microphonePageInactive","source_path":"ui/src/i18n/locales/hi.ts","src_lang":"en","text":"Microphone inputs are unavailable while this page is inactive.","text_hash":"775110f07819e48dc96203ed710c4df3546892e5672d7c469dedeb1e0e163882","tgt_lang":"hi","translated":"इस पेज के निष्क्रिय होने पर माइक्रोफ़ोन इनपुट उपलब्ध नहीं होते।","updated_at":"2026-07-06T17:56:41.735Z"}
|
||||
{"cache_key":"a4cf89ac521759bd0db7ffb03ecb561b3661f011831f24fd96d9dd762b4c3f9b","model":"gpt-5.5","provider":"openai","segment_id":"usage.mosaic.title","source_path":"ui/src/i18n/locales/hi.ts","src_lang":"en","text":"Activity by Time","text_hash":"d4f5e691d1d415aabf25860ac10b620e6f798075db0ef42c7a59a41f340c80e6","tgt_lang":"hi","translated":"समय के अनुसार गतिविधि","updated_at":"2026-06-26T21:35:33.329Z"}
|
||||
{"cache_key":"a52e4b9740b8ea09f561f2e1e59c345edeb999400f9c641dc20fb5b3041bb999","model":"gpt-5.5","provider":"openai","segment_id":"overview.stats.instances","source_path":"ui/src/i18n/locales/hi.ts","src_lang":"en","text":"Instances","text_hash":"aa8c181ac3381dcd5890e42f64315a2540a9c7b35897570cf72f7ec1227e52e3","tgt_lang":"hi","translated":"इंस्टेंस","updated_at":"2026-06-26T21:33:15.607Z"}
|
||||
{"cache_key":"a5373bbbc97e2ae1b422320057b9be9a0c0e0b0e592690b70dd9e75060c9254b","model":"gpt-5.5","provider":"openai","segment_id":"aboutPage.version","source_path":"ui/src/i18n/locales/hi.ts","src_lang":"en","text":"Version","text_hash":"dd167905de0defcaf72de673ee44c07431770d129ccffab286bd2edfdaf62396","tgt_lang":"hi","translated":"संस्करण","updated_at":"2026-06-26T21:29:32.270Z"}
|
||||
{"cache_key":"a5bb15efc19a357825b921609b97670749ef138476b3a6cc1ef0e555acdc113f","model":"gpt-5.5","provider":"openai","segment_id":"cron.jobDetail.delivery","source_path":"ui/src/i18n/locales/hi.ts","src_lang":"en","text":"Delivery","text_hash":"52bfe584a5fc450539e2aa651b990fa2415060492a243816ab2994292089c6fd","tgt_lang":"hi","translated":"डिलीवरी","updated_at":"2026-06-26T21:38:12.145Z"}
|
||||
{"cache_key":"a5c6183b4802ffa0ef32cc9216b5017cd66eae2a894601a3aaf079e9f7818d0b","model":"gpt-5.5","provider":"openai","segment_id":"workboard.eventProtocolViolation","source_path":"ui/src/i18n/locales/hi.ts","src_lang":"en","text":"Protocol violation","text_hash":"367bb2052963f7d75beb672d3ca0430d7d49ac48a2759d578c7df933178fe564","tgt_lang":"hi","translated":"प्रोटोकॉल उल्लंघन","updated_at":"2026-06-26T21:33:07.229Z"}
|
||||
{"cache_key":"a5c838f448dad8a4a8fa5507358bb548fa288e9200e3f6128b2b06ef8221e3e9","model":"gpt-5.5","provider":"openai","segment_id":"chat.sidebar.sortUpdated","source_path":"ui/src/i18n/locales/hi.ts","src_lang":"en","text":"Last updated","text_hash":"382ac5f308f76c24b2c981e2041943bc2be2229cbd285ad362b9af1cfc386ef8","tgt_lang":"hi","translated":"अंतिम अपडेट","updated_at":"2026-07-06T15:07:02.499Z"}
|
||||
|
||||
Generated
+4
-4
@@ -1,11 +1,11 @@
|
||||
{
|
||||
"fallbackKeys": [],
|
||||
"generatedAt": "2026-07-10T08:19:18.615Z",
|
||||
"generatedAt": "2026-07-10T08:37:26.981Z",
|
||||
"locale": "id",
|
||||
"model": "gpt-5.5",
|
||||
"provider": "openai",
|
||||
"sourceHash": "3883d1abcb57aa52cf17aab0761a1f02be8a469c52ccc72dbe551f78cd5de297",
|
||||
"totalKeys": 1779,
|
||||
"translatedKeys": 1779,
|
||||
"sourceHash": "f6a3e03d983c828decd96c8d86d239a1ce4cb9ffd26f7989145a7614ebce30c3",
|
||||
"totalKeys": 1794,
|
||||
"translatedKeys": 1794,
|
||||
"workflow": 1
|
||||
}
|
||||
|
||||
Generated
+4
-4
@@ -1,11 +1,11 @@
|
||||
{
|
||||
"fallbackKeys": [],
|
||||
"generatedAt": "2026-07-10T08:19:16.594Z",
|
||||
"generatedAt": "2026-07-10T08:37:26.645Z",
|
||||
"locale": "it",
|
||||
"model": "gpt-5.5",
|
||||
"provider": "openai",
|
||||
"sourceHash": "3883d1abcb57aa52cf17aab0761a1f02be8a469c52ccc72dbe551f78cd5de297",
|
||||
"totalKeys": 1779,
|
||||
"translatedKeys": 1779,
|
||||
"sourceHash": "f6a3e03d983c828decd96c8d86d239a1ce4cb9ffd26f7989145a7614ebce30c3",
|
||||
"totalKeys": 1794,
|
||||
"translatedKeys": 1794,
|
||||
"workflow": 1
|
||||
}
|
||||
|
||||
Generated
+4
-4
@@ -1,11 +1,11 @@
|
||||
{
|
||||
"fallbackKeys": [],
|
||||
"generatedAt": "2026-07-10T08:19:13.014Z",
|
||||
"generatedAt": "2026-07-10T08:37:26.060Z",
|
||||
"locale": "ja-JP",
|
||||
"model": "gpt-5.5",
|
||||
"provider": "openai",
|
||||
"sourceHash": "3883d1abcb57aa52cf17aab0761a1f02be8a469c52ccc72dbe551f78cd5de297",
|
||||
"totalKeys": 1779,
|
||||
"translatedKeys": 1779,
|
||||
"sourceHash": "f6a3e03d983c828decd96c8d86d239a1ce4cb9ffd26f7989145a7614ebce30c3",
|
||||
"totalKeys": 1794,
|
||||
"translatedKeys": 1794,
|
||||
"workflow": 1
|
||||
}
|
||||
|
||||
Generated
+4
-4
@@ -1,11 +1,11 @@
|
||||
{
|
||||
"fallbackKeys": [],
|
||||
"generatedAt": "2026-07-10T08:19:13.740Z",
|
||||
"generatedAt": "2026-07-10T08:37:26.177Z",
|
||||
"locale": "ko",
|
||||
"model": "gpt-5.5",
|
||||
"provider": "openai",
|
||||
"sourceHash": "3883d1abcb57aa52cf17aab0761a1f02be8a469c52ccc72dbe551f78cd5de297",
|
||||
"totalKeys": 1779,
|
||||
"translatedKeys": 1779,
|
||||
"sourceHash": "f6a3e03d983c828decd96c8d86d239a1ce4cb9ffd26f7989145a7614ebce30c3",
|
||||
"totalKeys": 1794,
|
||||
"translatedKeys": 1794,
|
||||
"workflow": 1
|
||||
}
|
||||
|
||||
Generated
+4
-4
@@ -1,11 +1,11 @@
|
||||
{
|
||||
"fallbackKeys": [],
|
||||
"generatedAt": "2026-07-10T08:19:21.861Z",
|
||||
"generatedAt": "2026-07-10T08:37:27.430Z",
|
||||
"locale": "nl",
|
||||
"model": "gpt-5.5",
|
||||
"provider": "openai",
|
||||
"sourceHash": "3883d1abcb57aa52cf17aab0761a1f02be8a469c52ccc72dbe551f78cd5de297",
|
||||
"totalKeys": 1779,
|
||||
"translatedKeys": 1779,
|
||||
"sourceHash": "f6a3e03d983c828decd96c8d86d239a1ce4cb9ffd26f7989145a7614ebce30c3",
|
||||
"totalKeys": 1794,
|
||||
"translatedKeys": 1794,
|
||||
"workflow": 1
|
||||
}
|
||||
|
||||
Generated
+4
-4
@@ -1,11 +1,11 @@
|
||||
{
|
||||
"fallbackKeys": [],
|
||||
"generatedAt": "2026-07-10T08:19:19.496Z",
|
||||
"generatedAt": "2026-07-10T08:37:27.094Z",
|
||||
"locale": "pl",
|
||||
"model": "gpt-5.5",
|
||||
"provider": "openai",
|
||||
"sourceHash": "3883d1abcb57aa52cf17aab0761a1f02be8a469c52ccc72dbe551f78cd5de297",
|
||||
"totalKeys": 1779,
|
||||
"translatedKeys": 1779,
|
||||
"sourceHash": "f6a3e03d983c828decd96c8d86d239a1ce4cb9ffd26f7989145a7614ebce30c3",
|
||||
"totalKeys": 1794,
|
||||
"translatedKeys": 1794,
|
||||
"workflow": 1
|
||||
}
|
||||
|
||||
Generated
+4
-4
@@ -1,11 +1,11 @@
|
||||
{
|
||||
"fallbackKeys": [],
|
||||
"generatedAt": "2026-07-10T08:19:11.246Z",
|
||||
"generatedAt": "2026-07-10T08:37:25.717Z",
|
||||
"locale": "pt-BR",
|
||||
"model": "gpt-5.5",
|
||||
"provider": "openai",
|
||||
"sourceHash": "3883d1abcb57aa52cf17aab0761a1f02be8a469c52ccc72dbe551f78cd5de297",
|
||||
"totalKeys": 1779,
|
||||
"translatedKeys": 1779,
|
||||
"sourceHash": "f6a3e03d983c828decd96c8d86d239a1ce4cb9ffd26f7989145a7614ebce30c3",
|
||||
"totalKeys": 1794,
|
||||
"translatedKeys": 1794,
|
||||
"workflow": 1
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user