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:
Peter Steinberger
2026-07-10 10:42:36 +01:00
committed by GitHub
parent 1f3ea6faaa
commit 7fe004d852
139 changed files with 4971 additions and 563 deletions
+7 -1
View File
@@ -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
+51 -16
View File
@@ -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
View File
@@ -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 && \
File diff suppressed because it is too large Load Diff
+2
View File
@@ -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
View File
@@ -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
+79
View File
@@ -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 lempreinte du certificat avant daccorder 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 lhorodatage complet de compilation</string>
<string name="about_build_commit_clipboard_label">Commit Git dOpenClaw</string>
<string name="about_build_timestamp_clipboard_label">Horodatage de compilation dOpenClaw</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 limpronta 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 lhash 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(
+136 -6
View File
@@ -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"])
}
}
+4
View File
@@ -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
+12 -9
View File
@@ -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))
}
}
}
+70 -4
View File
@@ -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],
+2
View File
@@ -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: {}
+118 -41
View File
@@ -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: "", 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
View File
@@ -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>
+95
View File
@@ -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
View File
@@ -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;
+6 -1
View File
@@ -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: {
+8
View File
@@ -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}" \
+9 -1
View File
@@ -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"
+38 -1
View File
@@ -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"
+9 -1
View File
@@ -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}"
+119
View File
@@ -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}"
}
+36 -5
View File
@@ -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();
+26 -4
View File
@@ -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"
+7
View File
@@ -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
+16 -1
View File
@@ -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
View File
@@ -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;
}
}
+10
View File
@@ -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
View File
@@ -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');
});
+64
View File
@@ -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(
+110 -1
View File
@@ -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.");
}
});
});
+74
View File
@@ -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}"');
});
});
+59 -19
View File
@@ -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");
});
});
+10
View File
@@ -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}",
);
+157
View File
@@ -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");
+32
View File
@@ -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",
[
+156
View File
@@ -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.",
);
});
});
+7
View File
@@ -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",
]);
});
+3 -1
View File
@@ -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" },
+1
View File
@@ -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
View File
@@ -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,
+4
View File
@@ -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", {
+7
View File
@@ -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"');
});
+112 -1
View File
@@ -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"),
+55
View File
@@ -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");
});
});
+72
View File
@@ -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);
+123
View File
@@ -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();
}
});
});
+4 -4
View File
@@ -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
}
+4 -4
View File
@@ -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
}
+4 -4
View File
@@ -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
}
+4 -4
View File
@@ -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
}
+4 -4
View File
@@ -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
}
+4 -4
View File
@@ -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
}
+2
View File
@@ -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"}
+4 -4
View File
@@ -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
}
+4 -4
View File
@@ -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
}
+4 -4
View File
@@ -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
}
+4 -4
View File
@@ -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
}
+4 -4
View File
@@ -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
}
+4 -4
View File
@@ -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
}
+4 -4
View File
@@ -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