fix(ci): publish locale refreshes through exact merge (#103625)

This commit is contained in:
Peter Steinberger
2026-07-10 11:25:25 +01:00
committed by GitHub
parent bacc75a358
commit bcf2798920
6 changed files with 896 additions and 151 deletions
@@ -0,0 +1,359 @@
name: Publish generated pull request
description: Commit generated files to an automation branch and open or update its pull request.
inputs:
primary-private-key:
description: Primary OpenClaw GitHub App private key.
required: true
fallback-private-key:
description: Fallback OpenClaw GitHub App private key.
required: true
base-branch:
description: Target branch for the generated pull request.
required: true
head-branch:
description: Automation-owned branch for the generated commit.
required: true
commit-message:
description: Generated commit message.
required: true
pr-title:
description: Generated pull request title.
required: true
pr-body:
description: Generated pull request body.
required: true
generated-paths:
description: Newline-delimited generated paths to commit.
required: true
runs:
using: composite
steps:
- name: Create generated PR app token
id: app-token
continue-on-error: true
uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3
with:
app-id: "2729701"
private-key: ${{ inputs.primary-private-key }}
permission-contents: write
permission-pull-requests: write
- name: Create generated PR fallback app token
id: app-token-fallback
if: steps.app-token.outcome == 'failure'
uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3
with:
app-id: "2971289"
private-key: ${{ inputs.fallback-private-key }}
permission-contents: write
permission-pull-requests: write
- name: Publish generated pull request
shell: bash
env:
GH_TOKEN: ${{ steps.app-token.outputs.token || steps.app-token-fallback.outputs.token }}
BASE_BRANCH: ${{ inputs.base-branch }}
HEAD_BRANCH: ${{ inputs.head-branch }}
COMMIT_MESSAGE: ${{ inputs.commit-message }}
PR_TITLE: ${{ inputs.pr-title }}
PR_BODY: ${{ inputs.pr-body }}
GENERATED_PATHS: ${{ inputs.generated-paths }}
run: |
set -euo pipefail
export GH_PROMPT_DISABLED=1
export GIT_ASKPASS=/bin/false
export GIT_EDITOR=true
export GIT_SEQUENCE_EDITOR=true
export GIT_TERMINAL_PROMPT=0
if [[ -z "${GH_TOKEN:-}" ]]; then
echo "Generated PR publication requires an OpenClaw GitHub App token." >&2
exit 1
fi
generated_paths=()
while IFS= read -r generated_path; do
if [[ -n "${generated_path}" ]]; then
generated_paths+=("${generated_path}")
fi
done <<< "${GENERATED_PATHS}"
if [[ "${#generated_paths[@]}" -eq 0 ]]; then
echo "Generated PR publication requires at least one generated path." >&2
exit 1
fi
find_open_pr() {
timeout --signal=TERM --kill-after=10s 60s \
gh api --method GET "repos/${GITHUB_REPOSITORY}/pulls" \
-f state=open -f base="${BASE_BRANCH}" \
-f "head=${GITHUB_REPOSITORY_OWNER}:${HEAD_BRANCH}" \
--jq '[.[] | select(.base.repo.full_name == env.GITHUB_REPOSITORY and .head.repo.full_name == env.GITHUB_REPOSITORY and .base.ref == env.BASE_BRANCH and .head.ref == env.HEAD_BRANCH)][0] | if . == null then "" else [.html_url, .head.sha] | @tsv end'
}
neutralize_stale_pr() {
local base_head current_head stale_pr_head stale_pr_record stale_pr_url
stale_pr_record="$(find_open_pr)"
stale_pr_url="${stale_pr_record%%$'\t'*}"
if [[ -z "${stale_pr_url}" ]]; then
return 0
fi
stale_pr_head="${stale_pr_record#*$'\t'}"
fetch_base
if ! git merge-base --is-ancestor "${source_commit}" "${base_ref}"; then
echo "::error::Resolved workflow source is not an ancestor of latest ${BASE_BRANCH}."
return 1
fi
if ! git diff --quiet "${source_commit}" "${base_ref}" -- "${generated_paths[@]}"; then
echo "A newer main-triggered full locale refresh will retire the stale pull request." \
>> "${GITHUB_STEP_SUMMARY}"
return 0
fi
current_head="$(read_remote_head)"
if [[ -z "${current_head}" ]]; then
echo "Stale generated pull request is already unmergeable because its branch is absent." \
>> "${GITHUB_STEP_SUMMARY}"
return 0
fi
if [[ "${current_head}" != "${stale_pr_head}" ]]; then
echo "::error::Generated branch moved before stale pull request retirement."
return 1
fi
# Move the exact stale branch to base under a lease. This makes the PR unmergeable without
# an unsafe close mutation that could race a newer publisher using the deterministic branch.
git switch -C "${HEAD_BRANCH}" "${base_ref}"
push_log="${RUNNER_TEMP}/generated-pr-push.log"
if ! push_generated_branch "${stale_pr_head}"; then
report_push_failure
return "${push_status}"
fi
base_head="$(git rev-parse "${base_ref}")"
current_head="$(read_remote_head)"
if [[ "${current_head}" != "${base_head}" ]]; then
echo "::error::Generated branch moved during stale pull request retirement."
return 1
fi
echo "Neutralized stale generated pull request: ${stale_pr_url}" \
>> "${GITHUB_STEP_SUMMARY}"
}
read_remote_head() {
timeout --signal=TERM --kill-after=10s 60s \
git ls-remote --heads origin "refs/heads/${HEAD_BRANCH}" |
awk 'NR == 1 { print $1 }'
}
fetch_base() {
timeout --signal=TERM --kill-after=10s 120s \
git fetch --no-tags origin \
"+refs/heads/${BASE_BRANCH}:refs/remotes/origin/${BASE_BRANCH}"
base_ref="refs/remotes/origin/${BASE_BRANCH}"
}
entry_at() {
local commit="$1"
local entry
local path="$2"
entry="$(git ls-tree "${commit}" -- "${path}" | awk -F '\t' 'NR == 1 { print $1 }')"
printf '%s' "${entry:-__missing__}"
}
desired_matches_tree() {
local actual_entry desired_entry path treeish="$1"
while IFS= read -r -d '' path; do
desired_entry="$(entry_at "${desired_commit}" "${path}")"
actual_entry="$(entry_at "${treeish}" "${path}")"
if [[ "${desired_entry}" != "${actual_entry}" ]]; then
return 1
fi
done < "${changed_paths_file}"
return 0
}
prepare_branch() {
local base_entry desired_entry path source_entry
local overlap=false
prepare_outcome=ready
fetch_base
if ! git merge-base --is-ancestor "${source_commit}" "${base_ref}"; then
echo "::error::Resolved workflow source is not an ancestor of latest ${BASE_BRANCH}."
return 1
fi
# Never overwrite a generated path that changed on main after this workflow's source SHA.
# Its main-triggered full refresh owns the newer reconciliation run.
while IFS= read -r -d '' path; do
source_entry="$(entry_at "${source_commit}" "${path}")"
desired_entry="$(entry_at "${desired_commit}" "${path}")"
base_entry="$(entry_at "${base_ref}" "${path}")"
if [[ "${source_entry}" != "${base_entry}" && "${desired_entry}" != "${base_entry}" ]]; then
echo "::notice::Deferring stale generated output because ${path} changed on ${BASE_BRANCH}."
overlap=true
fi
done < "${changed_paths_file}"
if [[ "${overlap}" = "true" ]]; then
prepare_outcome=deferred
return 0
fi
git switch -C "${HEAD_BRANCH}" "${base_ref}"
while IFS= read -r -d '' path; do
desired_entry="$(entry_at "${desired_commit}" "${path}")"
if [[ "${desired_entry}" = "__missing__" ]]; then
git rm -f --ignore-unmatch -- "${path}"
else
git restore --source="${desired_commit}" --staged --worktree -- "${path}"
fi
done < "${changed_paths_file}"
git add -A -- "${generated_paths[@]}"
if git diff --cached --quiet -- "${generated_paths[@]}"; then
prepare_outcome=merged
return 0
fi
git commit --no-gpg-sign --no-verify -m "${COMMIT_MESSAGE}"
}
push_generated_branch() {
local expected_head="$1"
set +e
push_output="$(
timeout --signal=TERM --kill-after=10s 60s \
git push \
"--force-with-lease=refs/heads/${HEAD_BRANCH}:${expected_head}" \
origin "HEAD:refs/heads/${HEAD_BRANCH}" 2>&1
)"
push_status="$?"
set -e
printf '%s\n' "${push_output}" | tee "${push_log}"
return "${push_status}"
}
report_push_failure() {
if grep -Eiq 'GH013|repository rule violations|required status check' "${push_log}"; then
echo "::error::Generated branch push was rejected by repository rules; refusing a doomed retry."
elif grep -Eiq 'stale info|non-fast-forward|fetch first' "${push_log}"; then
echo "::error::Generated branch moved concurrently; refusing to overwrite the newer head."
fi
}
verify_publication() {
local final_pr_head final_pr_record final_pr_url
for attempt in 1 2 3; do
final_pr_record="$(find_open_pr)"
final_pr_url="${final_pr_record%%$'\t'*}"
if [[ -n "${final_pr_url}" ]]; then
final_pr_head="${final_pr_record#*$'\t'}"
if [[ "${final_pr_head}" = "${published_commit}" ]]; then
echo "Generated pull request: ${final_pr_url}" >> "${GITHUB_STEP_SUMMARY}"
return 0
fi
echo "::notice::Generated pull request head has not converged yet; rechecking."
fi
sleep "${attempt}"
done
fetch_base
if desired_matches_tree "${base_ref}"; then
echo "Generated output was merged while publication was being reconciled." \
>> "${GITHUB_STEP_SUMMARY}"
return 0
fi
final_pr_head="$(read_remote_head)"
if [[ "${final_pr_head}" != "${published_commit}" ]]; then
echo "::error::Generated automation branch moved during pull request reconciliation."
return 1
fi
echo "::error::Generated branch has no open same-repository pull request."
return 1
}
source_commit="$(git rev-parse HEAD)"
git config user.name "github-actions[bot]"
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
timeout --signal=TERM --kill-after=10s 30s gh auth setup-git
git add -A -- "${generated_paths[@]}"
if git diff --cached --quiet -- "${generated_paths[@]}"; then
echo "No generated changes."
neutralize_stale_pr
exit 0
fi
# Snapshot the generator's desired blobs before moving to the latest base.
git commit --no-gpg-sign --no-verify -m "${COMMIT_MESSAGE}"
desired_commit="$(git rev-parse HEAD)"
changed_paths_file="${RUNNER_TEMP}/generated-pr-changed-paths"
git diff --name-only -z --no-renames "${source_commit}" "${desired_commit}" -- "${generated_paths[@]}" \
> "${changed_paths_file}"
prepare_branch
if [[ "${prepare_outcome}" = "deferred" ]]; then
echo "A newer main-triggered full locale refresh will reconcile the generated output." \
>> "${GITHUB_STEP_SUMMARY}"
exit 0
fi
if [[ "${prepare_outcome}" = "merged" ]]; then
neutralize_stale_pr
exit 0
fi
# Lease the exact observed automation head so a cancelled run cannot overwrite newer output.
remote_head="$(read_remote_head)"
push_log="${RUNNER_TEMP}/generated-pr-push.log"
if ! push_generated_branch "${remote_head}"; then
current_remote_head="$(read_remote_head)"
branch_was_deleted="$([[ -n "${remote_head}" && -z "${current_remote_head}" ]] && echo true || echo false)"
if [[ "${branch_was_deleted}" != "true" ]] ||
! grep -Eiq 'stale info|non-fast-forward|fetch first' "${push_log}"; then
report_push_failure
exit "${push_status}"
fi
# A merge can consume and delete the branch after observation. Rebuild from latest base;
# overlap detection defers to the guaranteed main-triggered full refresh.
prepare_branch
if [[ "${prepare_outcome}" = "deferred" ]]; then
echo "A newer main-triggered full locale refresh will reconcile the generated output." \
>> "${GITHUB_STEP_SUMMARY}"
exit 0
fi
if [[ "${prepare_outcome}" = "merged" ]]; then
neutralize_stale_pr
exit 0
fi
if ! push_generated_branch ""; then
report_push_failure
exit "${push_status}"
fi
fi
published_commit="$(git rev-parse HEAD)"
current_remote_head="$(read_remote_head)"
if [[ "${current_remote_head}" != "${published_commit}" ]]; then
fetch_base
if desired_matches_tree "${base_ref}"; then
echo "Generated output was merged before pull request reconciliation." \
>> "${GITHUB_STEP_SUMMARY}"
exit 0
fi
echo "::error::Generated automation branch moved after publication."
exit 1
fi
body_file="${RUNNER_TEMP}/generated-pr-body.md"
printf '%s\n' "${PR_BODY}" > "${body_file}"
pr_record="$(find_open_pr)"
pr_url="${pr_record%%$'\t'*}"
set +e
if [[ -n "${pr_url}" ]]; then
timeout --signal=TERM --kill-after=10s 60s \
gh pr edit "${pr_url}" --title "${PR_TITLE}" --body-file "${body_file}"
pr_mutation_status="$?"
else
timeout --signal=TERM --kill-after=10s 60s \
gh pr create --repo "${GITHUB_REPOSITORY}" \
--base "${BASE_BRANCH}" --head "${HEAD_BRANCH}" \
--title "${PR_TITLE}" --body-file "${body_file}"
pr_mutation_status="$?"
fi
set -e
if ! verify_publication; then
if [[ "${pr_mutation_status:-0}" -ne 0 ]]; then
exit "${pr_mutation_status}"
fi
exit 1
fi
+75 -99
View File
@@ -20,101 +20,77 @@ on:
workflow_dispatch:
permissions:
contents: write
contents: read
concurrency:
group: control-ui-locale-refresh-${{ github.event_name == 'push' && github.ref || github.event_name == 'workflow_dispatch' && format('manual-{0}', github.run_id) || github.event_name == 'release' && format('release-{0}', github.event.release.tag_name) || format('{0}-{1}', github.event_name, github.run_id) }}
cancel-in-progress: ${{ github.event_name == 'push' && github.ref == 'refs/heads/main' && github.actor != 'github-actions[bot]' }}
group: control-ui-locale-refresh
cancel-in-progress: "${{ github.event_name == 'push' && github.ref == 'refs/heads/main' && !startsWith(github.event.head_commit.message, 'chore(ui): refresh control ui locales') }}"
jobs:
plan:
if: github.repository == 'openclaw/openclaw' && (github.event_name != 'push' || github.actor != 'github-actions[bot]')
resolve-base:
if: >-
github.repository == 'openclaw/openclaw' &&
(github.event_name != 'workflow_dispatch' || github.ref == 'refs/heads/main')
runs-on: ubuntu-latest
outputs:
has_locales: ${{ steps.plan.outputs.has_locales }}
locales_json: ${{ steps.plan.outputs.locales_json }}
sha: ${{ steps.base.outputs.sha }}
steps:
- name: Checkout
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6
with:
fetch-depth: 0
persist-credentials: false
submodules: false
- name: Plan locale matrix
id: plan
- name: Resolve default branch head
id: base
env:
BEFORE_SHA: ${{ github.event.before }}
EVENT_NAME: ${{ github.event_name }}
DEFAULT_BRANCH: ${{ github.event.repository.default_branch }}
GH_TOKEN: ${{ github.token }}
REPOSITORY: ${{ github.repository }}
run: |
set -euo pipefail
all_locales_json='["zh-CN","zh-TW","pt-BR","de","es","ja-JP","ko","fr","ar","it","tr","uk","id","pl","th","vi","nl","fa"]'
if [ "$EVENT_NAME" != "push" ]; then
echo "has_locales=true" >> "$GITHUB_OUTPUT"
echo "locales_json=$all_locales_json" >> "$GITHUB_OUTPUT"
exit 0
fi
before_ref="$BEFORE_SHA"
if [ -z "$before_ref" ] || [ "$before_ref" = "0000000000000000000000000000000000000000" ]; then
before_ref="$(git rev-parse HEAD^)"
fi
changed_files="$(git diff --name-only "$before_ref" HEAD)"
echo "changed files:"
printf '%s\n' "$changed_files"
if printf '%s\n' "$changed_files" | grep -Eq '^(ui/src/i18n/locales/en\.ts|ui/src/i18n/lib/types\.ts|ui/src/i18n/lib/registry\.ts|scripts/control-ui-i18n\.ts|\.github/workflows/control-ui-locale-refresh\.yml)$'; then
echo "has_locales=true" >> "$GITHUB_OUTPUT"
echo "locales_json=$all_locales_json" >> "$GITHUB_OUTPUT"
exit 0
fi
locales_json="$(printf '%s\n' "$changed_files" | node <<'EOF'
const fs = require("node:fs");
const changed = fs.readFileSync(0, "utf8").split(/\r?\n/).filter(Boolean);
const locales = new Set();
for (const file of changed) {
let match = file.match(/^ui\/src\/i18n\/locales\/(.+)\.ts$/);
if (match && match[1] !== "en") {
locales.add(match[1]);
continue;
}
match = file.match(/^ui\/src\/i18n\/\.i18n\/(.+)\.(?:meta\.json|tm\.jsonl)$/);
if (match) {
locales.add(match[1]);
}
}
process.stdout.write(JSON.stringify([...locales]));
EOF
sha="$(
timeout --signal=TERM --kill-after=10s 60s \
gh api --method GET "repos/${REPOSITORY}/commits/${DEFAULT_BRANCH}" --jq .sha
)"
if [ "$locales_json" = "[]" ]; then
echo "has_locales=false" >> "$GITHUB_OUTPUT"
echo "locales_json=[]" >> "$GITHUB_OUTPUT"
exit 0
if [[ ! "${sha}" =~ ^[0-9a-f]{40}$ ]]; then
echo "Unable to resolve ${DEFAULT_BRANCH} to an exact commit." >&2
exit 1
fi
echo "has_locales=true" >> "$GITHUB_OUTPUT"
echo "locales_json=$locales_json" >> "$GITHUB_OUTPUT"
echo "sha=${sha}" >> "${GITHUB_OUTPUT}"
refresh:
needs: plan
if: github.repository == 'openclaw/openclaw' && needs.plan.outputs.has_locales == 'true'
needs: resolve-base
if: needs.resolve-base.result == 'success'
strategy:
fail-fast: false
max-parallel: 4
matrix:
locale: ${{ fromJson(needs.plan.outputs.locales_json) }}
locale:
[
zh-CN,
zh-TW,
pt-BR,
de,
es,
ja-JP,
ko,
fr,
hi,
ar,
it,
tr,
uk,
id,
pl,
th,
vi,
nl,
fa,
ru,
]
runs-on: ubuntu-latest
name: Refresh ${{ matrix.locale }}
steps:
- name: Checkout
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6
with:
persist-credentials: true
ref: ${{ needs.resolve-base.outputs.sha }}
persist-credentials: false
submodules: false
- name: Setup Node environment
@@ -220,15 +196,16 @@ jobs:
finalize:
name: Commit control UI locale refresh
needs: refresh
if: needs.refresh.result == 'success'
needs: [resolve-base, refresh]
if: needs.resolve-base.result == 'success' && needs.refresh.result == 'success'
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6
with:
ref: ${{ needs.resolve-base.outputs.sha }}
fetch-depth: 0
persist-credentials: true
persist-credentials: false
submodules: false
- name: Download locale artifacts
@@ -255,31 +232,30 @@ jobs:
- name: Validate control UI locale refresh
run: node --import tsx scripts/control-ui-i18n.ts check
- name: Commit and push aggregate locale refresh
env:
TARGET_BRANCH: ${{ github.event.repository.default_branch }}
run: |
set -euo pipefail
if ! git status --porcelain -- ui/src/i18n | grep -q .; then
echo "No control UI locale changes."
exit 0
fi
- name: Open or update generated locale PR
uses: ./.github/actions/publish-generated-pr
with:
primary-private-key: ${{ secrets.GH_APP_PRIVATE_KEY }}
fallback-private-key: ${{ secrets.GH_APP_PRIVATE_KEY_FALLBACK }}
base-branch: ${{ github.event.repository.default_branch }}
head-branch: automation/control-ui-locale-refresh
commit-message: "chore(ui): refresh control ui locales"
pr-title: "chore(ui): refresh control ui locales"
generated-paths: ui/src/i18n
pr-body: |
## What Problem This Solves
git config user.name "github-actions[bot]"
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
git add -A ui/src/i18n
git commit --no-verify -m "chore(ui): refresh control ui locales"
Keeps generated Control UI locales synchronized without bypassing protected-branch checks.
for attempt in 1 2 3 4 5; do
git fetch origin "${TARGET_BRANCH}"
git rebase "origin/${TARGET_BRANCH}"
if git push origin HEAD:"${TARGET_BRANCH}"; then
exit 0
fi
git rebase --abort >/dev/null 2>&1 || true
echo "Aggregate push attempt ${attempt} failed; retrying."
sleep $((attempt * 2))
done
## Why This Change Was Made
echo "Failed to push aggregate control UI locale update after retries."
exit 1
The Control UI Locale Refresh workflow generated this update from `${{ needs.resolve-base.outputs.sha }}` and published it through a reviewable automation branch.
## User Impact
No direct user-facing change beyond refreshed translations.
## Evidence
- [Locale refresh run](${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }})
- `node --import tsx scripts/control-ui-i18n.ts check`
+64 -33
View File
@@ -10,6 +10,7 @@ on:
- apps/macos/Sources/**
- apps/macos/Package.swift
- apps/shared/OpenClawKit/Sources/**
- apps/.i18n/native/**
- apps/.i18n/native-source.json
- scripts/control-ui-i18n.ts
- scripts/native-app-i18n.ts
@@ -18,15 +19,42 @@ on:
workflow_dispatch:
permissions:
contents: write
contents: read
concurrency:
group: native-app-locale-refresh-${{ github.event_name == 'push' && github.ref || format('manual-{0}', github.run_id) }}
cancel-in-progress: ${{ github.event_name == 'push' && github.ref == 'refs/heads/main' && github.actor != 'github-actions[bot]' }}
group: native-app-locale-refresh
cancel-in-progress: "${{ github.event_name == 'push' && github.ref == 'refs/heads/main' && !startsWith(github.event.head_commit.message, 'chore(i18n): refresh native locales') }}"
jobs:
resolve-base:
if: >-
github.repository == 'openclaw/openclaw' &&
(github.event_name != 'workflow_dispatch' || github.ref == 'refs/heads/main')
runs-on: ubuntu-latest
outputs:
sha: ${{ steps.base.outputs.sha }}
steps:
- name: Resolve default branch head
id: base
env:
DEFAULT_BRANCH: ${{ github.event.repository.default_branch }}
GH_TOKEN: ${{ github.token }}
REPOSITORY: ${{ github.repository }}
run: |
set -euo pipefail
sha="$(
timeout --signal=TERM --kill-after=10s 60s \
gh api --method GET "repos/${REPOSITORY}/commits/${DEFAULT_BRANCH}" --jq .sha
)"
if [[ ! "${sha}" =~ ^[0-9a-f]{40}$ ]]; then
echo "Unable to resolve ${DEFAULT_BRANCH} to an exact commit." >&2
exit 1
fi
echo "sha=${sha}" >> "${GITHUB_OUTPUT}"
refresh:
if: github.repository == 'openclaw/openclaw' && (github.event_name != 'workflow_dispatch' || github.ref == 'refs/heads/main') && (github.event_name != 'push' || github.actor != 'github-actions[bot]')
needs: resolve-base
if: needs.resolve-base.result == 'success'
strategy:
fail-fast: false
max-parallel: 2
@@ -61,7 +89,8 @@ jobs:
- name: Checkout
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6
with:
persist-credentials: true
ref: ${{ needs.resolve-base.outputs.sha }}
persist-credentials: false
submodules: false
- name: Setup Node environment
@@ -167,15 +196,16 @@ jobs:
finalize:
name: Commit native locale refresh
needs: refresh
if: needs.refresh.result == 'success'
needs: [resolve-base, refresh]
if: needs.resolve-base.result == 'success' && needs.refresh.result == 'success'
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6
with:
ref: ${{ needs.resolve-base.outputs.sha }}
fetch-depth: 0
persist-credentials: true
persist-credentials: false
submodules: false
- name: Download locale artifacts
@@ -207,31 +237,32 @@ jobs:
- name: Validate native locale refresh
run: node --import tsx scripts/native-app-i18n.ts check
- name: Commit and push aggregate locale refresh
env:
TARGET_BRANCH: ${{ github.event.repository.default_branch }}
run: |
set -euo pipefail
if ! git status --porcelain -- apps/.i18n/native apps/.i18n/native-source.json | grep -q .; then
echo "No native locale changes."
exit 0
fi
- name: Open or update generated locale PR
uses: ./.github/actions/publish-generated-pr
with:
primary-private-key: ${{ secrets.GH_APP_PRIVATE_KEY }}
fallback-private-key: ${{ secrets.GH_APP_PRIVATE_KEY_FALLBACK }}
base-branch: ${{ github.event.repository.default_branch }}
head-branch: automation/native-app-locale-refresh
commit-message: "chore(i18n): refresh native locales"
pr-title: "chore(i18n): refresh native locales"
generated-paths: |
apps/.i18n/native
apps/.i18n/native-source.json
pr-body: |
## What Problem This Solves
git config user.name "github-actions[bot]"
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
git add -A apps/.i18n/native apps/.i18n/native-source.json
git commit --no-verify -m "chore(i18n): refresh native locales"
Keeps generated native app locales synchronized without bypassing protected-branch checks.
for attempt in 1 2 3 4 5; do
git fetch origin "${TARGET_BRANCH}"
git rebase "origin/${TARGET_BRANCH}"
if git push origin HEAD:"${TARGET_BRANCH}"; then
exit 0
fi
git rebase --abort >/dev/null 2>&1 || true
echo "Aggregate push attempt ${attempt} failed; retrying."
sleep $((attempt * 2))
done
## Why This Change Was Made
echo "Failed to push aggregate native locale update after retries."
exit 1
The Native App Locale Refresh workflow generated this update from `${{ needs.resolve-base.outputs.sha }}` and published it through a reviewable automation branch.
## User Impact
No direct user-facing change beyond refreshed translations.
## Evidence
- [Locale refresh run](${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }})
- `node --import tsx scripts/native-app-i18n.ts check`
+5 -1
View File
@@ -632,6 +632,7 @@ const TOOLING_SOURCE_TEST_TARGETS = new Map([
],
[".crabbox.yaml", ["test/scripts/package-acceptance-workflow.test.ts"]],
[".github/actions/detect-docs-changes/action.yml", ["test/scripts/ci-workflow-guards.test.ts"]],
[".github/actions/publish-generated-pr/action.yml", ["test/scripts/ci-workflow-guards.test.ts"]],
[
".github/actions/docker-e2e-plan/action.yml",
["test/scripts/package-acceptance-workflow.test.ts", "test/scripts/ci-workflow-guards.test.ts"],
@@ -759,7 +760,10 @@ const TOOLING_SOURCE_TEST_TARGETS = new Map([
["scripts/ci-docker-pull-retry.sh", ["test/scripts/ci-docker-pull-retry.test.ts"]],
["scripts/control-ui-i18n.ts", ["test/scripts/control-ui-i18n.test.ts"]],
["scripts/apple-app-i18n.ts", ["test/scripts/apple-app-i18n.test.ts"]],
["scripts/native-app-i18n.ts", ["test/scripts/native-app-i18n.test.ts"]],
[
"scripts/native-app-i18n.ts",
["test/scripts/native-app-i18n.test.ts", "test/scripts/ci-workflow-guards.test.ts"],
],
["scripts/android-app-i18n.ts", ["test/scripts/android-app-i18n.test.ts"]],
[
"scripts/copy-bundled-plugin-metadata.mjs",
+377 -18
View File
@@ -1,18 +1,34 @@
// Ci Workflow Guards tests cover ci workflow guards script behavior.
import { execFileSync } from "node:child_process";
import { existsSync, readdirSync, readFileSync } from "node:fs";
import { execFileSync, spawnSync } from "node:child_process";
import {
chmodSync,
existsSync,
mkdirSync,
mkdtempSync,
readdirSync,
readFileSync,
rmSync,
writeFileSync,
} from "node:fs";
import { tmpdir } from "node:os";
import path from "node:path";
import { describe, expect, it } from "vitest";
import { parse } from "yaml";
import { NATIVE_I18N_LOCALES } from "../../scripts/native-app-i18n.ts";
import { SUPPORTED_LOCALES } from "../../ui/src/i18n/lib/registry.ts";
const CHECKOUT_V6 = "actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10";
const CACHE_V5 = "actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae";
const SETUP_GO_V6 = "actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c";
const UPLOAD_ARTIFACT_V7 = "actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a";
const DOWNLOAD_ARTIFACT_V8 = "actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c";
const CREATE_GITHUB_APP_TOKEN_V3 =
"actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1";
const OPENGREP_PR_DIFF_WORKFLOW = ".github/workflows/opengrep-precise.yml";
const OPENGREP_FULL_WORKFLOW = ".github/workflows/opengrep-precise-full.yml";
const CONTROL_UI_LOCALE_REFRESH_WORKFLOW = ".github/workflows/control-ui-locale-refresh.yml";
const NATIVE_APP_LOCALE_REFRESH_WORKFLOW = ".github/workflows/native-app-locale-refresh.yml";
const PUBLISH_GENERATED_PR_ACTION = ".github/actions/publish-generated-pr/action.yml";
function readCiWorkflow() {
return parse(readFileSync(".github/workflows/ci.yml", "utf8"));
@@ -99,6 +115,144 @@ function findUnpinnedExternalActions(): string[] {
return violations;
}
function runGit(cwd: string, args: string[]): string {
return execFileSync("git", args, { cwd, encoding: "utf8" }).trim();
}
function writeExecutable(filePath: string, lines: string[]): void {
writeFileSync(filePath, `${lines.join("\n")}\n`, "utf8");
chmodSync(filePath, 0o755);
}
function runGeneratedPublisherScenario(
baseChangePath: "a" | "b",
options: { stalePrHeadOnce?: boolean } = {},
) {
const root = mkdtempSync(path.join(tmpdir(), "openclaw-generated-pr-"));
try {
const origin = path.join(root, "origin.git");
const updater = path.join(root, "updater");
const worktree = path.join(root, "worktree");
const generatedDir = path.join(worktree, "generated");
const fakeBin = path.join(root, "bin");
const runnerTemp = path.join(root, "runner-temp");
const prState = path.join(root, "pr-open");
const stalePrHeadOnce = path.join(root, "stale-pr-head-once");
const summary = path.join(root, "summary.md");
mkdirSync(generatedDir, { recursive: true });
mkdirSync(fakeBin);
mkdirSync(runnerTemp);
writeFileSync(summary, "", "utf8");
if (options.stalePrHeadOnce) {
writeFileSync(stalePrHeadOnce, "", "utf8");
}
runGit(root, ["init", "--bare", origin]);
runGit(root, ["init", "--initial-branch=main", worktree]);
runGit(worktree, ["config", "user.name", "Test Publisher"]);
runGit(worktree, ["config", "user.email", "publisher@example.com"]);
writeFileSync(path.join(generatedDir, "a.txt"), "old-a\n", "utf8");
writeFileSync(path.join(generatedDir, "b.txt"), "old-b\n", "utf8");
runGit(worktree, ["add", "generated"]);
runGit(worktree, ["commit", "-m", "base"]);
runGit(worktree, ["remote", "add", "origin", origin]);
runGit(worktree, ["push", "-u", "origin", "main"]);
runGit(root, ["--git-dir", origin, "symbolic-ref", "HEAD", "refs/heads/main"]);
runGit(root, ["clone", "--branch", "main", origin, updater]);
runGit(updater, ["config", "user.name", "Base Updater"]);
runGit(updater, ["config", "user.email", "updater@example.com"]);
writeFileSync(
path.join(updater, "generated", `${baseChangePath}.txt`),
`newer-${baseChangePath}\n`,
"utf8",
);
runGit(updater, ["add", "generated"]);
runGit(updater, ["commit", "-m", "update base"]);
runGit(updater, ["push", "origin", "main"]);
writeFileSync(path.join(generatedDir, "a.txt"), "desired-a\n", "utf8");
writeExecutable(path.join(fakeBin, "timeout"), [
"#!/usr/bin/env bash",
"set -euo pipefail",
'while [[ "$#" -gt 0 ]]; do',
' case "$1" in',
" --signal=*|--kill-after=*) shift ;;",
' [0-9]*s) shift; break ;;',
" *) break ;;",
" esac",
"done",
'exec "$@"',
]);
writeExecutable(path.join(fakeBin, "gh"), [
"#!/usr/bin/env bash",
"set -euo pipefail",
'case "${1-}:${2-}" in',
" auth:setup-git) exit 0 ;;",
" api:*)",
' if [[ -f "$FAKE_PR_STATE" ]]; then',
' if [[ -f "$FAKE_STALE_HEAD_ONCE" ]]; then',
' head="0000000000000000000000000000000000000000"',
' rm -f "$FAKE_STALE_HEAD_ONCE"',
" else",
' head="$(git --git-dir="$FAKE_ORIGIN" rev-parse refs/heads/automation/locale)"',
" fi",
' printf "https://github.com/openclaw/openclaw/pull/1\\t%s\\n" "$head"',
" fi",
" ;;",
" pr:create)",
' : > "$FAKE_PR_STATE"',
' printf "%s\\n" "https://github.com/openclaw/openclaw/pull/1"',
" ;;",
" pr:edit) exit 0 ;;",
' *) printf "unexpected gh call: %s\\n" "$*" >&2; exit 2 ;;',
"esac",
]);
const action = parse(readFileSync(PUBLISH_GENERATED_PR_ACTION, "utf8"));
const publishRun = action.runs.steps.find(
(step: { name?: string }) => step.name === "Publish generated pull request",
).run;
execFileSync("bash", ["-c", publishRun], {
cwd: worktree,
encoding: "utf8",
env: {
...process.env,
BASE_BRANCH: "main",
COMMIT_MESSAGE: "chore(test): refresh generated output",
FAKE_ORIGIN: origin,
FAKE_PR_STATE: prState,
FAKE_STALE_HEAD_ONCE: stalePrHeadOnce,
GENERATED_PATHS: "generated",
GH_TOKEN: "test-token",
GITHUB_REPOSITORY: "openclaw/openclaw",
GITHUB_REPOSITORY_OWNER: "openclaw",
GITHUB_STEP_SUMMARY: summary,
HEAD_BRANCH: "automation/locale",
PATH: `${fakeBin}:${process.env.PATH ?? ""}`,
PR_BODY: "Generated test body",
PR_TITLE: "chore(test): refresh generated output",
RUNNER_TEMP: runnerTemp,
},
});
const branchRef = "refs/heads/automation/locale";
const branchExists =
spawnSync("git", ["--git-dir", origin, "show-ref", "--verify", branchRef]).status === 0;
return {
branchExists,
generatedA: branchExists
? runGit(root, ["--git-dir", origin, "show", `${branchRef}:generated/a.txt`])
: "",
generatedB: branchExists
? runGit(root, ["--git-dir", origin, "show", `${branchRef}:generated/b.txt`])
: "",
summary: readFileSync(summary, "utf8"),
};
} finally {
rmSync(root, { force: true, recursive: true });
}
}
describe("ci workflow guards", () => {
it("makes the hosted release-gate fallback explicit and exact-SHA only", () => {
const workflow = readCiWorkflow();
@@ -171,9 +325,11 @@ describe("ci workflow guards", () => {
expect(findUnpinnedExternalActions()).toEqual([]);
});
it("keeps locale refresh matrices alive and commits each aggregate once", () => {
it("keeps locale refresh matrices alive and publishes each aggregate through a PR", () => {
const controlUiWorkflow = parse(readFileSync(CONTROL_UI_LOCALE_REFRESH_WORKFLOW, "utf8"));
const workflow = parse(readFileSync(NATIVE_APP_LOCALE_REFRESH_WORKFLOW, "utf8"));
const controlUiResolveBase = controlUiWorkflow.jobs["resolve-base"];
const nativeResolveBase = workflow.jobs["resolve-base"];
const refresh = workflow.jobs.refresh;
const nativeFinalize = workflow.jobs.finalize;
const controlUiFinalize = controlUiWorkflow.jobs.finalize;
@@ -190,15 +346,26 @@ describe("ci workflow guards", () => {
(step: { name?: string }) => step.name === "Refresh control UI locale files",
);
expect(refresh.if).toContain("github.ref == 'refs/heads/main'");
expect(refresh.strategy.matrix.locale).toContain("sv");
expect(refresh.if).toBe("needs.resolve-base.result == 'success'");
expect(refresh.strategy.matrix.locale).toEqual(NATIVE_I18N_LOCALES);
expect(controlUiWorkflow.concurrency["cancel-in-progress"]).toContain(
"github.actor != 'github-actions[bot]'",
"!startsWith(github.event.head_commit.message, 'chore(ui): refresh control ui locales')",
);
expect(controlUiWorkflow.concurrency.group).toBe("control-ui-locale-refresh");
expect(controlUiWorkflow.jobs.plan).toBeUndefined();
expect(controlUiWorkflow.jobs.refresh.if).toBe("needs.resolve-base.result == 'success'");
expect(controlUiWorkflow.jobs.refresh.strategy.matrix.locale).toEqual(
SUPPORTED_LOCALES.filter((locale) => locale !== "en"),
);
expect(workflow.concurrency["cancel-in-progress"]).toContain(
"github.actor != 'github-actions[bot]'",
"!startsWith(github.event.head_commit.message, 'chore(i18n): refresh native locales')",
);
expect(workflow.concurrency.group).toBe("native-app-locale-refresh");
expect(controlUiResolveBase.if).not.toContain("chore(ui): refresh control ui locales");
expect(nativeResolveBase.if).not.toContain("chore(i18n): refresh native locales");
expect(workflow.on.push.paths).toContain("ui/src/i18n/.i18n/glossary.*.json");
expect(workflow.on.push.paths).toContain("apps/.i18n/native/**");
expect(workflow.on.push.paths).toContain("apps/.i18n/native-source.json");
expect(refreshStep.run).toContain("run_refresh anthropic");
expect(refreshStep.run).toContain("retrying with OpenAI");
expect(refreshStep.run).toContain("run_openai_refresh");
@@ -222,13 +389,150 @@ describe("ci workflow guards", () => {
expect(controlUiRefreshStep.env.OPENAI_API_KEY).toBe("${{ secrets.OPENAI_API_KEY }}");
expect(controlUiRefreshStep.env.OPENCLAW_CONTROL_UI_I18N_AUTH_OPTIONAL).toBe("0");
for (const [refreshJob, finalizeJob, artifactPattern, commitMessage] of [
[refresh, nativeFinalize, "native-locale-*", "chore(i18n): refresh native locales"],
for (const ownerWorkflow of [controlUiWorkflow, workflow]) {
const resolveBase = ownerWorkflow.jobs["resolve-base"];
const resolveStep = resolveBase.steps.find(
(step: { name?: string }) => step.name === "Resolve default branch head",
);
expect(resolveBase.outputs.sha).toBe("${{ steps.base.outputs.sha }}");
expect(resolveStep.env.GH_TOKEN).toBe("${{ github.token }}");
expect(resolveStep.run).toContain(
'gh api --method GET "repos/${REPOSITORY}/commits/${DEFAULT_BRANCH}" --jq .sha',
);
expect(resolveStep.run).toContain('[[ ! "${sha}" =~ ^[0-9a-f]{40}$ ]]');
const checkoutSteps = Object.values(ownerWorkflow.jobs).flatMap(
(job: { steps?: Array<{ uses?: string; with?: Record<string, unknown> }> }) =>
(job.steps ?? []).filter((step) => step.uses === CHECKOUT_V6),
);
expect(checkoutSteps.length).toBeGreaterThan(0);
for (const checkoutStep of checkoutSteps) {
expect(checkoutStep.with?.ref).toBe("${{ needs.resolve-base.outputs.sha }}");
expect(checkoutStep.with?.["persist-credentials"]).toBe(false);
}
}
const publishAction = parse(readFileSync(PUBLISH_GENERATED_PR_ACTION, "utf8"));
const primaryTokenStep = publishAction.runs.steps.find(
(step: { name?: string }) => step.name === "Create generated PR app token",
);
const fallbackTokenStep = publishAction.runs.steps.find(
(step: { name?: string }) => step.name === "Create generated PR fallback app token",
);
const actionPublishStep = publishAction.runs.steps.find(
(step: { name?: string }) => step.name === "Publish generated pull request",
);
expect(primaryTokenStep).toMatchObject({
id: "app-token",
"continue-on-error": true,
uses: CREATE_GITHUB_APP_TOKEN_V3,
with: {
"app-id": "2729701",
"private-key": "${{ inputs.primary-private-key }}",
"permission-contents": "write",
"permission-pull-requests": "write",
},
});
expect(fallbackTokenStep).toMatchObject({
id: "app-token-fallback",
if: "steps.app-token.outcome == 'failure'",
uses: CREATE_GITHUB_APP_TOKEN_V3,
with: {
"app-id": "2971289",
"private-key": "${{ inputs.fallback-private-key }}",
"permission-contents": "write",
"permission-pull-requests": "write",
},
});
expect(actionPublishStep.env.GH_TOKEN).toBe(
"${{ steps.app-token.outputs.token || steps.app-token-fallback.outputs.token }}",
);
expect(actionPublishStep.run).toContain("GIT_TERMINAL_PROMPT=0");
expect(actionPublishStep.run).toContain("gh auth setup-git");
expect(actionPublishStep.run).toContain("timeout --signal=TERM --kill-after=10s 120s");
expect(actionPublishStep.run).toContain("--force-with-lease=refs/heads/");
expect(actionPublishStep.run).toContain(
"GH013|repository rule violations|required status check",
);
expect(actionPublishStep.run).toContain("refusing a doomed retry");
expect(actionPublishStep.run).toContain("branch_was_deleted");
expect(actionPublishStep.run).toContain(
'[[ -n "${remote_head}" && -z "${current_remote_head}" ]]',
);
expect(actionPublishStep.run).toContain('push_generated_branch ""');
expect(actionPublishStep.run).toContain(
"overlap detection defers to the guaranteed main-triggered full refresh",
);
expect(actionPublishStep.run).toContain(
'gh api --method GET "repos/${GITHUB_REPOSITORY}/pulls"',
);
expect(actionPublishStep.run).toContain(
'-f "head=${GITHUB_REPOSITORY_OWNER}:${HEAD_BRANCH}"',
);
expect(actionPublishStep.run).toContain(
".head.repo.full_name == env.GITHUB_REPOSITORY",
);
expect(actionPublishStep.run).toContain(".head.ref == env.HEAD_BRANCH");
expect(actionPublishStep.run).toContain(".head.sha");
expect(actionPublishStep.run).not.toContain("gh pr list");
expect(actionPublishStep.run).toContain("neutralize_stale_pr");
expect(actionPublishStep.run).toContain("unsafe close mutation");
expect(actionPublishStep.run).not.toContain("gh pr close");
expect(actionPublishStep.run).toContain('source_commit="$(git rev-parse HEAD)"');
expect(actionPublishStep.run).toContain(
'git merge-base --is-ancestor "${source_commit}" "${base_ref}"',
);
expect(actionPublishStep.run).toContain("Snapshot the generator's desired blobs");
expect(actionPublishStep.run).toContain(
'git diff --name-only -z --no-renames "${source_commit}" "${desired_commit}"',
);
expect(actionPublishStep.run).toContain(
'[[ "${source_entry}" != "${base_entry}" && "${desired_entry}" != "${base_entry}" ]]',
);
expect(actionPublishStep.run).toContain('git switch -C "${HEAD_BRANCH}" "${base_ref}"');
expect(actionPublishStep.run).toContain(
'git restore --source="${desired_commit}" --staged --worktree -- "${path}"',
);
expect(actionPublishStep.run).not.toContain("git rebase");
expect(actionPublishStep.run).toContain("verify_publication");
expect(actionPublishStep.run).toContain("desired_matches_tree");
expect(actionPublishStep.run).toContain(
'[[ "${current_remote_head}" != "${published_commit}" ]]',
);
expect(actionPublishStep.run).toContain(
'[[ "${final_pr_head}" != "${published_commit}" ]]',
);
expect(actionPublishStep.run).toContain("gh pr edit");
expect(actionPublishStep.run).toContain("gh pr create");
expect(actionPublishStep.run).toContain('--base "${BASE_BRANCH}"');
expect(actionPublishStep.run).toContain('--head "${HEAD_BRANCH}"');
expect(actionPublishStep.run).toContain('--body-file "${body_file}"');
expect(actionPublishStep.run).not.toContain('HEAD:"${BASE_BRANCH}"');
for (const [
ownerWorkflow,
refreshJob,
finalizeJob,
artifactPattern,
commitMessage,
automationBranch,
] of [
[
workflow,
refresh,
nativeFinalize,
"native-locale-*",
"chore(i18n): refresh native locales",
"automation/native-app-locale-refresh",
],
[
controlUiWorkflow,
controlUiWorkflow.jobs.refresh,
controlUiFinalize,
"control-ui-locale-*",
"chore(ui): refresh control ui locales",
"automation/control-ui-locale-refresh",
],
] as const) {
const uploadStep = refreshJob.steps.find(
@@ -237,24 +541,79 @@ describe("ci workflow guards", () => {
const downloadStep = finalizeJob.steps.find(
(step: { name?: string }) => step.name === "Download locale artifacts",
);
const commitStep = finalizeJob.steps.find(
(step: { name?: string }) => step.name === "Commit and push aggregate locale refresh",
const checkoutStep = finalizeJob.steps.find(
(step: { uses?: string }) => step.uses === CHECKOUT_V6,
);
const publishStep = finalizeJob.steps.find(
(step: { name?: string }) => step.name === "Open or update generated locale PR",
);
expect(finalizeJob.needs).toBe("refresh");
expect(finalizeJob.if).toBe("needs.refresh.result == 'success'");
expect(ownerWorkflow.permissions.contents).toBe("read");
expect(refreshJob.needs).toBe("resolve-base");
expect(finalizeJob.needs).toEqual(["resolve-base", "refresh"]);
expect(finalizeJob.if).toBe(
"needs.resolve-base.result == 'success' && needs.refresh.result == 'success'",
);
expect(uploadStep.uses).toBe(UPLOAD_ARTIFACT_V7);
expect(downloadStep.uses).toBe(DOWNLOAD_ARTIFACT_V8);
expect(downloadStep.with.pattern).toBe(artifactPattern);
expect(downloadStep.with["merge-multiple"]).toBe(true);
expect(commitStep.run).toContain(`git commit --no-verify -m "${commitMessage}"`);
expect(commitStep.run).toContain("for attempt in 1 2 3 4 5");
expect(commitStep.run).toContain('git fetch origin "${TARGET_BRANCH}"');
expect(commitStep.run).toContain('git rebase "origin/${TARGET_BRANCH}"');
expect(commitStep.run).toContain('git push origin HEAD:"${TARGET_BRANCH}"');
expect(checkoutStep.with["persist-credentials"]).toBe(false);
expect(checkoutStep.with["fetch-depth"]).toBe(0);
expect(publishStep.uses).toBe("./.github/actions/publish-generated-pr");
expect(publishStep.with).toMatchObject({
"primary-private-key": "${{ secrets.GH_APP_PRIVATE_KEY }}",
"fallback-private-key": "${{ secrets.GH_APP_PRIVATE_KEY_FALLBACK }}",
"base-branch": "${{ github.event.repository.default_branch }}",
"head-branch": automationBranch,
"commit-message": commitMessage,
"pr-title": commitMessage,
});
expect(publishStep.with["generated-paths"]).toContain(
automationBranch.includes("native") ? "apps/.i18n/native" : "ui/src/i18n",
);
expect(publishStep.with["pr-body"]).toContain("## What Problem This Solves");
expect(publishStep.with["pr-body"]).toContain("## Evidence");
expect(publishStep.with["pr-body"]).toContain("${{ needs.resolve-base.outputs.sha }}");
expect(publishStep.with["pr-body"]).not.toContain("${{ github.sha }}");
}
});
it.skipIf(process.platform === "win32")(
"replays generated blobs without overwriting a newer non-overlapping base change",
() => {
const result = runGeneratedPublisherScenario("b");
expect(result.branchExists).toBe(true);
expect(result.generatedA).toBe("desired-a");
expect(result.generatedB).toBe("newer-b");
expect(result.summary).toContain("https://github.com/openclaw/openclaw/pull/1");
},
);
it.skipIf(process.platform === "win32")(
"defers instead of overwriting a newer overlapping generated path",
() => {
const result = runGeneratedPublisherScenario("a");
expect(result.branchExists).toBe(false);
expect(result.summary).toContain(
"A newer main-triggered full locale refresh will reconcile the generated output.",
);
},
);
it.skipIf(process.platform === "win32")(
"retries a stale pull request head read after the branch push",
() => {
const result = runGeneratedPublisherScenario("b", { stalePrHeadOnce: true });
expect(result.branchExists).toBe(true);
expect(result.generatedA).toBe("desired-a");
expect(result.summary).toContain("https://github.com/openclaw/openclaw/pull/1");
},
);
it("fails OpenGrep SARIF artifact uploads when reports are missing", () => {
const cases = [
{
+16
View File
@@ -1266,6 +1266,22 @@ describe("scripts/test-projects changed-target routing", () => {
});
});
it("keeps generated locale publisher and inventory edits on workflow guards", () => {
expect(
resolveChangedTestTargetPlan([".github/actions/publish-generated-pr/action.yml"]),
).toEqual({
mode: "targets",
targets: ["test/scripts/ci-workflow-guards.test.ts"],
});
expect(resolveChangedTestTargetPlan(["scripts/native-app-i18n.ts"])).toEqual({
mode: "targets",
targets: [
"test/scripts/native-app-i18n.test.ts",
"test/scripts/ci-workflow-guards.test.ts",
],
});
});
it("keeps security-sensitive guard workflow edits on guard workflow tests", () => {
expect(
resolveChangedTestTargetPlan([".github/workflows/security-sensitive-guard.yml"]),