diff --git a/.github/actions/publish-generated-pr/action.yml b/.github/actions/publish-generated-pr/action.yml new file mode 100644 index 000000000000..09c065dc3140 --- /dev/null +++ b/.github/actions/publish-generated-pr/action.yml @@ -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 diff --git a/.github/workflows/control-ui-locale-refresh.yml b/.github/workflows/control-ui-locale-refresh.yml index c99d0cb66d67..05ff9776169f 100644 --- a/.github/workflows/control-ui-locale-refresh.yml +++ b/.github/workflows/control-ui-locale-refresh.yml @@ -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` diff --git a/.github/workflows/native-app-locale-refresh.yml b/.github/workflows/native-app-locale-refresh.yml index c261f2ebb006..724aed0fd9f3 100644 --- a/.github/workflows/native-app-locale-refresh.yml +++ b/.github/workflows/native-app-locale-refresh.yml @@ -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` diff --git a/scripts/test-projects.test-support.mjs b/scripts/test-projects.test-support.mjs index 80e6083d19bc..d42176e88000 100644 --- a/scripts/test-projects.test-support.mjs +++ b/scripts/test-projects.test-support.mjs @@ -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", diff --git a/test/scripts/ci-workflow-guards.test.ts b/test/scripts/ci-workflow-guards.test.ts index 78c68c4e9eb0..57a4d05109f9 100644 --- a/test/scripts/ci-workflow-guards.test.ts +++ b/test/scripts/ci-workflow-guards.test.ts @@ -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 }> }) => + (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 = [ { diff --git a/test/scripts/test-projects.test.ts b/test/scripts/test-projects.test.ts index 8cff128ca409..4ca478d6d764 100644 --- a/test/scripts/test-projects.test.ts +++ b/test/scripts/test-projects.test.ts @@ -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"]),