mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-12 21:53:00 -06:00
fix(ci): publish locale refreshes through exact merge (#103625)
This commit is contained in:
committed by
GitHub
parent
bacc75a358
commit
bcf2798920
@@ -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
|
||||
@@ -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`
|
||||
|
||||
@@ -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`
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 = [
|
||||
{
|
||||
|
||||
@@ -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"]),
|
||||
|
||||
Reference in New Issue
Block a user