From ff47712d04762fdb3a17c271d9398932da687220 Mon Sep 17 00:00:00 2001 From: AuraMindNest Date: Mon, 15 Jun 2026 17:25:27 -0600 Subject: [PATCH 1/8] Test harness: mock git/mirror infrastructure for destructive ops. --- .github/workflows/assets/lib.sh | 26 ++++ .github/workflows/assets/translation.sh | 114 +++++++++++++++ .github/workflows/lint.yml | 19 ++- .github/workflows/start-translation.yml | 145 +------------------ Makefile | 3 + README.md | 10 ++ tests/helpers/common.bash | 53 +++++++ tests/helpers/fixtures/libraries-empty.json | 10 ++ tests/helpers/fixtures/libraries-multi.json | 10 ++ tests/helpers/fixtures/libraries-single.json | 6 + tests/helpers/git_fixtures.bash | 55 +++++++ tests/helpers/mock_gh.bash | 70 +++++++++ tests/test_lib.bats | 141 ++++++++++++++++++ tests/test_merge_guard.bats | 58 ++++++++ tests/test_process_submodule.bats | 88 +++++++++++ 15 files changed, 664 insertions(+), 144 deletions(-) create mode 100644 .github/workflows/assets/translation.sh create mode 100644 Makefile create mode 100644 tests/helpers/common.bash create mode 100644 tests/helpers/fixtures/libraries-empty.json create mode 100644 tests/helpers/fixtures/libraries-multi.json create mode 100644 tests/helpers/fixtures/libraries-single.json create mode 100644 tests/helpers/git_fixtures.bash create mode 100644 tests/helpers/mock_gh.bash create mode 100644 tests/test_lib.bats create mode 100644 tests/test_merge_guard.bats create mode 100644 tests/test_process_submodule.bats diff --git a/.github/workflows/assets/lib.sh b/.github/workflows/assets/lib.sh index 7462c9a..377c26a 100644 --- a/.github/workflows/assets/lib.sh +++ b/.github/workflows/assets/lib.sh @@ -14,6 +14,16 @@ set_git_bot_config() { repo_exists() { gh repo view "$1/$2" &>/dev/null; } +# Returns 0 if org/repo has an open PR into local-{lang_code} with head matching +# "translation-{lang_code}-*". +has_open_translation_pr() { + local org="$1" repo="$2" lang_code="$3" + local base_br="local-${lang_code}" + gh pr list --repo "$org/$repo" --state open --base "$base_br" --json headRefName \ + --jq ".[] | select(.headRefName | startswith(\"translation-${lang_code}-\")) | .headRefName" \ + 2>/dev/null | grep -q . +} + # ── Git clone helpers ──────────────────────────────────────────────── # Clone repo at branch/tag into $3. Pass "keep" as $4 to preserve .git. @@ -197,6 +207,22 @@ parse_list() { done } +# Parse "[.adoc, .md]" or '[".adoc",".md"]' into one extension per line. +parse_extensions() { + local s="$1" + s="${s//[[:space:]]/}"; [[ -z "$s" ]] && return + s="${s#[}"; s="${s%]}" + local result=() + IFS=',' read -ra parts <<< "$s" + for part in "${parts[@]}"; do + part="${part//\"/}"; part="${part//\'/}" + [[ -z "$part" ]] && continue + [[ "$part" == .* ]] || part=".${part}" + result+=("$part") + done + printf '%s\n' "${result[@]}" +} + # Return 0 when $1 is a well-formed Weblate language code. is_valid_lang_code() { local code="$1" diff --git a/.github/workflows/assets/translation.sh b/.github/workflows/assets/translation.sh new file mode 100644 index 0000000..428b94f --- /dev/null +++ b/.github/workflows/assets/translation.sh @@ -0,0 +1,114 @@ +# shellcheck shell=bash +# start-translation orchestration helpers. +# Source after env.sh and lib.sh. Requires globals: +# MODULE_ORG, MASTER_BRANCH, BOOST_ORG, BOOST_WORK, ORG_WORK, libs_ref, +# lang_codes_arr, add_or_update (associative), ORG_REPO_MISSING, META_MISSING, +# NO_DOC_PATHS, GITHUB_WORKSPACE. + +# Wipe dest_repo (except .git), copy pruned source, commit, push master only. +sync_repo_master() { + local dest_repo="$1" sub_clone="$2" libs_ref="$3" + find "$dest_repo" -mindepth 1 -maxdepth 1 ! -name '.git' -exec rm -rf {} + || return 2 + cp -r "$sub_clone/." "$dest_repo/" || return 2 + set_git_bot_config "$dest_repo" + git -C "$dest_repo" add -A || return 2 + if ! git -C "$dest_repo" diff --cached --quiet; then + git -C "$dest_repo" commit -m "Update the original documentation of $libs_ref" || return 2 + fi + git -C "$dest_repo" push origin "$MASTER_BRANCH" || return 2 +} + +# Merge master into local-{lang_code} and push. +update_local_merge_from_master() { + local repo_dir="$1" lang_code="$2" + local local_br="local-${lang_code}" + git -C "$repo_dir" fetch origin "$MASTER_BRANCH" || return 2 + git -C "$repo_dir" fetch origin "$local_br" || return 2 + git -C "$repo_dir" checkout -B "$local_br" "origin/$local_br" || return 2 + git -C "$repo_dir" merge "origin/$MASTER_BRANCH" || return 2 + git -C "$repo_dir" push origin "${local_br}:${local_br}" || return 2 +} + +# Create local-{lang_code} in a library mirror repo from master, with create-tag.yml. +ensure_local_branch_in_repo() { + local dest_repo="$1" sub_name="$2" lang_code="$3" + local local_br="local-${lang_code}" + if git -C "$dest_repo" ls-remote --exit-code --heads origin "$local_br" &>/dev/null; then + echo " Branch $local_br already exists in $sub_name." >&2 + return 0 + fi + echo " Creating branch $local_br in $sub_name from $MASTER_BRANCH..." >&2 + git -C "$dest_repo" fetch origin "$MASTER_BRANCH" || return 2 + git -C "$dest_repo" checkout -B "$local_br" "origin/$MASTER_BRANCH" || return 2 + add_create_tag_workflow "$dest_repo" || return 2 + git -C "$dest_repo" push -u origin "$local_br" || return 2 + echo " Created branch $local_br." >&2 +} + +# Handle local-{lang_code} branch in a library mirror repo after master is synced. +# Returns 0 if submodule should be added to add_or_update[lang_code]; 1 if skipped (open PR); 2 on git failure. +process_local_branch() { + local dest_repo="$1" sub_name="$2" lang_code="$3" + local local_br="local-${lang_code}" + if git -C "$dest_repo" ls-remote --exit-code --heads origin "$local_br" &>/dev/null; then + if has_open_translation_pr "$MODULE_ORG" "$sub_name" "$lang_code"; then + echo " Open translation PR found for $sub_name ($local_br), skipping." >&2 + return 1 + fi + update_local_merge_from_master "$dest_repo" "$lang_code" || return 2 + else + ensure_local_branch_in_repo "$dest_repo" "$sub_name" "$lang_code" || return 2 + fi + return 0 +} + +process_one_submodule() { + local sub_name="$1" doc_paths + + if ! repo_exists "$MODULE_ORG" "$sub_name"; then + ORG_REPO_MISSING+=("$sub_name") + echo " Error: $MODULE_ORG/$sub_name does not exist. Run add-submodules first." >&2 + return 2 + fi + + doc_paths=$(get_doc_paths "$sub_name" "$libs_ref") || { + META_MISSING+=("$sub_name") + echo " No libraries.json." >&2; return 2 + } + [[ -z "$doc_paths" ]] && { + NO_DOC_PATHS+=("$sub_name") + echo " No doc paths in metadata, skipping." >&2; return 1 + } + + local sub_clone="$BOOST_WORK/$sub_name" + clone_repo "https://github.com/${BOOST_ORG}/${sub_name}.git" \ + "$libs_ref" "$sub_clone" || { echo " Clone failed." >&2; return 2; } + + local -a paths_arr + mapfile -t paths_arr <<< "$doc_paths" + prune_to_doc_only "$sub_clone" "${paths_arr[@]}" + + local org_repo_url="https://github.com/${MODULE_ORG}/${sub_name}.git" + local dest_repo="$ORG_WORK/$sub_name" + clone_repo "$org_repo_url" "$MASTER_BRANCH" "$dest_repo" keep || { + echo " clone_repo failed." >&2; return 2 + } + + sync_repo_master "$dest_repo" "$sub_clone" "$libs_ref" || return 2 + + local any_added=0 rc + for lang_code in "${lang_codes_arr[@]}"; do + if process_local_branch "$dest_repo" "$sub_name" "$lang_code"; then + if [[ -n "${add_or_update[$lang_code]:-}" ]]; then + add_or_update["$lang_code"]+=" $sub_name" + else + add_or_update["$lang_code"]="$sub_name" + fi + any_added=1 + else + rc=$? + [[ $rc -eq 2 ]] && return 2 + fi + done + [[ $any_added -eq 1 ]] +} diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index bf2013c..bf112d5 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -20,7 +20,9 @@ jobs: shellcheck -x \ .github/workflows/assets/env.sh \ .github/workflows/assets/lib.sh \ - scripts/*.sh + .github/workflows/assets/translation.sh \ + scripts/*.sh \ + tests/helpers/*.bash - name: actionlint run: | @@ -33,3 +35,18 @@ jobs: echo "${expected_sha256} ${tarball}" | sha256sum -c - tar -xzf "$tarball" ./actionlint -color + + test: + runs-on: ubuntu-latest + permissions: + contents: read + steps: + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + with: + persist-credentials: false + + - name: Install bats + run: sudo apt-get update && sudo apt-get install -y bats + + - name: Run tests + run: make test diff --git a/.github/workflows/start-translation.yml b/.github/workflows/start-translation.yml index 8ed3558..c053650 100644 --- a/.github/workflows/start-translation.yml +++ b/.github/workflows/start-translation.yml @@ -113,159 +113,18 @@ jobs: source "$GITHUB_WORKSPACE/.github/workflows/assets/env.sh" # shellcheck source=assets/lib.sh source "$GITHUB_WORKSPACE/.github/workflows/assets/lib.sh" + # shellcheck source=assets/translation.sh + source "$GITHUB_WORKSPACE/.github/workflows/assets/translation.sh" # LIBS_REF: workflow env # shellcheck disable=SC2153 libs_ref="${LIBS_REF:?}" - # BOOST_ORG: env.sh - # shellcheck disable=SC2153 - boost_org="${BOOST_ORG:?}" ORG_WORK="$WORK_DIR/$MODULE_ORG" mkdir -p "$ORG_WORK" - # ── GitHub API helpers (via gh CLI) ────────────────────────────────── - - # Returns 0 if org/repo has an open PR into local-{lang_code} with head matching - # "translation-{lang_code}-*". - has_open_translation_pr() { - local org="$1" repo="$2" lang_code="$3" - local base_br="local-${lang_code}" - gh pr list --repo "$org/$repo" --state open --base "$base_br" --json headRefName \ - --jq ".[] | select(.headRefName | startswith(\"translation-${lang_code}-\")) | .headRefName" \ - 2>/dev/null | grep -q . - } - - # ── Organization repo sync helpers ────────────────────────────────────── - - # Wipe dest_repo (except .git), copy pruned source, commit, push master only. - sync_repo_master() { - local dest_repo="$1" sub_clone="$2" libs_ref="$3" - find "$dest_repo" -mindepth 1 -maxdepth 1 ! -name '.git' -exec rm -rf {} + || return 2 - cp -r "$sub_clone/." "$dest_repo/" || return 2 - set_git_bot_config "$dest_repo" - git -C "$dest_repo" add -A || return 2 - if ! git -C "$dest_repo" diff --cached --quiet; then - git -C "$dest_repo" commit -m "Update the original documentation of $libs_ref" || return 2 - fi - git -C "$dest_repo" push origin "$MASTER_BRANCH" || return 2 - } - - # Merge master into local-{lang_code} and push. - update_local_merge_from_master() { - local repo_dir="$1" lang_code="$2" - local local_br="local-${lang_code}" - git -C "$repo_dir" fetch origin "$MASTER_BRANCH" || return 2 - git -C "$repo_dir" fetch origin "$local_br" || return 2 - git -C "$repo_dir" checkout -B "$local_br" "origin/$local_br" || return 2 - git -C "$repo_dir" merge "origin/$MASTER_BRANCH" || return 2 - git -C "$repo_dir" push origin "${local_br}:${local_br}" || return 2 - } - - # Create local-{lang_code} in a library mirror repo from master, with create-tag.yml. - ensure_local_branch_in_repo() { - local dest_repo="$1" sub_name="$2" lang_code="$3" - local local_br="local-${lang_code}" - if git -C "$dest_repo" ls-remote --exit-code --heads origin "$local_br" &>/dev/null; then - echo " Branch $local_br already exists in $sub_name." >&2 - return 0 - fi - echo " Creating branch $local_br in $sub_name from $MASTER_BRANCH..." >&2 - git -C "$dest_repo" fetch origin "$MASTER_BRANCH" || return 2 - git -C "$dest_repo" checkout -B "$local_br" "origin/$MASTER_BRANCH" || return 2 - add_create_tag_workflow "$dest_repo" || return 2 - git -C "$dest_repo" push -u origin "$local_br" || return 2 - echo " Created branch $local_br." >&2 - } - - # Handle local-{lang_code} branch in a library mirror repo after master is synced. - # Returns 0 if submodule should be added to add_or_update[lang_code]; 1 if skipped (open PR); 2 on git failure. - process_local_branch() { - local dest_repo="$1" sub_name="$2" lang_code="$3" - local local_br="local-${lang_code}" - if git -C "$dest_repo" ls-remote --exit-code --heads origin "$local_br" &>/dev/null; then - if has_open_translation_pr "$MODULE_ORG" "$sub_name" "$lang_code"; then - echo " Open translation PR found for $sub_name ($local_br), skipping." >&2 - return 1 - fi - update_local_merge_from_master "$dest_repo" "$lang_code" || return 2 - else - ensure_local_branch_in_repo "$dest_repo" "$sub_name" "$lang_code" || return 2 - fi - return 0 - } - - # ── Per-submodule processing ────────────────────────────────────────── - - process_one_submodule() { - local sub_name="$1" doc_paths - - if ! repo_exists "$MODULE_ORG" "$sub_name"; then - ORG_REPO_MISSING+=("$sub_name") - echo " Error: $MODULE_ORG/$sub_name does not exist. Run add-submodules first." >&2 - return 2 - fi - - doc_paths=$(get_doc_paths "$sub_name" "$libs_ref") || { - META_MISSING+=("$sub_name") - echo " No libraries.json." >&2; return 2 - } - [[ -z "$doc_paths" ]] && { - NO_DOC_PATHS+=("$sub_name") - echo " No doc paths in metadata, skipping." >&2; return 1 - } - - local sub_clone="$BOOST_WORK/$sub_name" - clone_repo "https://github.com/${boost_org}/${sub_name}.git" \ - "$libs_ref" "$sub_clone" || { echo " Clone failed." >&2; return 2; } - - local -a paths_arr - mapfile -t paths_arr <<< "$doc_paths" - prune_to_doc_only "$sub_clone" "${paths_arr[@]}" - - local org_repo_url="https://github.com/${MODULE_ORG}/${sub_name}.git" - local dest_repo="$ORG_WORK/$sub_name" - clone_repo "$org_repo_url" "$MASTER_BRANCH" "$dest_repo" keep || { - echo " clone_repo failed." >&2; return 2 - } - - sync_repo_master "$dest_repo" "$sub_clone" "$libs_ref" || return 2 - - local any_added=0 - for lang_code in "${lang_codes_arr[@]}"; do - if process_local_branch "$dest_repo" "$sub_name" "$lang_code"; then - if [[ -n "${add_or_update[$lang_code]:-}" ]]; then - add_or_update["$lang_code"]+=" $sub_name" - else - add_or_update["$lang_code"]="$sub_name" - fi - any_added=1 - else - rc=$? - [[ $rc -eq 2 ]] && return 2 - fi - done - [[ $any_added -eq 1 ]] - } - # ── Weblate helpers ─────────────────────────────────────────────────── - # Parse "[.adoc, .md]" or '[".adoc",".md"]' into one extension per line. - parse_extensions() { - local s="$1" - s="${s//[[:space:]]/}"; [[ -z "$s" ]] && return - s="${s#[}"; s="${s%]}" - local result=() - IFS=',' read -ra parts <<< "$s" - for part in "${parts[@]}"; do - part="${part//\"/}"; part="${part//\'/}" - [[ -z "$part" ]] && continue - [[ "$part" == .* ]] || part=".${part}" - result+=("$part") - done - printf '%s\n' "${result[@]}" - } - # POST to Weblate add-or-update (async: server returns 202 + task_id quickly). # Payload: {organization, add_or_update: {lang_code: [subs...]}, version, extensions} trigger_weblate() { diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..1e9217f --- /dev/null +++ b/Makefile @@ -0,0 +1,3 @@ +.PHONY: test +test: + bats tests/ diff --git a/README.md b/README.md index e0c4014..b9cf716 100644 --- a/README.md +++ b/README.md @@ -160,6 +160,16 @@ documented below. | `LANG_CODES` | `add-submodules`, `start-translation` | Default language codes when **`client_payload.lang_codes`** is omitted (comma- or bracket-list, e.g. `zh_Hans,ja`). Must be set here or passed in the dispatch payload. | | `SUBMODULES_ORG` | `add-submodules`, `start-translation` | Optional. GitHub org for **`boostorg`** mirror repos (e.g. `CppDigest`). If unset, the org is the same as this repository’s owner. **`sync-translation`** relies on **`.gitmodules`** URLs already pointing at the correct hosts. | +## Development + +Run the shell test suite locally (requires [bats](https://github.com/bats-core/bats-core); on Ubuntu: `apt install bats`): + +```bash +make test +``` + +CI runs **`make test`** on every push and pull request alongside ShellCheck and actionlint. + ## License This repository is distributed under the diff --git a/tests/helpers/common.bash b/tests/helpers/common.bash new file mode 100644 index 0000000..f10cc3b --- /dev/null +++ b/tests/helpers/common.bash @@ -0,0 +1,53 @@ +# shellcheck shell=bash +# Shared helpers for bats tests. + +REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +ASSETS_DIR="$REPO_ROOT/.github/workflows/assets" +FIXTURES_DIR="$REPO_ROOT/tests/helpers/fixtures" + +load_env() { + export GITHUB_REPOSITORY="${GITHUB_REPOSITORY:-testorg/boost-docs-translation}" + # shellcheck source=/dev/null + source "$ASSETS_DIR/env.sh" +} + +load_lib() { + load_env + # shellcheck source=/dev/null + source "$ASSETS_DIR/lib.sh" +} + +load_translation() { + load_lib + export GITHUB_WORKSPACE="$REPO_ROOT" + # shellcheck source=/dev/null + source "$ASSETS_DIR/translation.sh" +} + +# Run a function and capture its exit code (works under set -e in callers). +run_fn() { + set +e + "$@" + local rc=$? + set -e + return "$rc" +} + +reset_process_globals() { + ORG_REPO_MISSING=() + META_MISSING=() + NO_DOC_PATHS=() + declare -gA add_or_update=() + lang_codes_arr=() + UPDATES=() +} + +init_process_globals() { + reset_process_globals + MODULE_ORG="${MODULE_ORG:-testorg}" + MASTER_BRANCH="${MASTER_BRANCH:-master}" + BOOST_ORG="${BOOST_ORG:-boostorg}" + libs_ref="${libs_ref:-develop}" + lang_codes_arr=("en") + add_or_update["en"]="" +} diff --git a/tests/helpers/fixtures/libraries-empty.json b/tests/helpers/fixtures/libraries-empty.json new file mode 100644 index 0000000..96c9c88 --- /dev/null +++ b/tests/helpers/fixtures/libraries-empty.json @@ -0,0 +1,10 @@ +[ + { + "name": "", + "key": "algorithm" + }, + { + "name": "NoKey", + "key": "" + } +] diff --git a/tests/helpers/fixtures/libraries-multi.json b/tests/helpers/fixtures/libraries-multi.json new file mode 100644 index 0000000..b2f6735 --- /dev/null +++ b/tests/helpers/fixtures/libraries-multi.json @@ -0,0 +1,10 @@ +[ + { + "name": "Minmax", + "key": "minmax" + }, + { + "name": "String", + "key": "string" + } +] diff --git a/tests/helpers/fixtures/libraries-single.json b/tests/helpers/fixtures/libraries-single.json new file mode 100644 index 0000000..01144dd --- /dev/null +++ b/tests/helpers/fixtures/libraries-single.json @@ -0,0 +1,6 @@ +[ + { + "name": "Algorithm", + "key": "algorithm" + } +] diff --git a/tests/helpers/git_fixtures.bash b/tests/helpers/git_fixtures.bash new file mode 100644 index 0000000..62c1cfa --- /dev/null +++ b/tests/helpers/git_fixtures.bash @@ -0,0 +1,55 @@ +# shellcheck shell=bash +# Temp git repos for integration-style tests. + +init_git_fixture_root() { + GIT_FIXTURE_ROOT="$(mktemp -d)" +} + +cleanup_git_fixture_root() { + if [[ -n "${GIT_FIXTURE_ROOT:-}" && -d "$GIT_FIXTURE_ROOT" ]]; then + rm -rf "$GIT_FIXTURE_ROOT" + fi + GIT_FIXTURE_ROOT="" +} + +# Create a bare remote and a working clone with an initial commit on master. +# Sets BARE_REMOTE and WORK_REPO globals. +create_bare_remote_with_clone() { + local name="$1" + local bare="$GIT_FIXTURE_ROOT/${name}.git" + local work="$GIT_FIXTURE_ROOT/${name}" + + git init --bare "$bare" + git init "$work" + git -C "$work" config user.email "test@test.local" + git -C "$work" config user.name "Test" + git -C "$work" remote add origin "$bare" + echo "init" >"$work/README" + git -C "$work" add README + git -C "$work" commit -m "init" + git -C "$work" push -u origin master + + BARE_REMOTE="$bare" + WORK_REPO="$work" +} + +# Create a local branch on the bare remote (e.g. local-en). +create_remote_branch() { + local bare="$1" branch="$2" from_branch="${3:-master}" + local tmp + tmp="$(mktemp -d)" + git clone "$bare" "$tmp" + git -C "$tmp" checkout -b "$branch" "origin/$from_branch" + git -C "$tmp" push -u origin "$branch" + rm -rf "$tmp" +} + +# Populate a clone directory with doc-like content for prune tests. +create_prune_fixture_dir() { + local dir="$1" + mkdir -p "$dir/doc" "$dir/src" "$dir/.github/workflows" "$dir/minmax/other" + echo "doc" >"$dir/doc/readme.txt" + echo "src" >"$dir/src/main.cpp" + echo "wf" >"$dir/.github/workflows/ci.yml" + echo "other" >"$dir/minmax/other/data.txt" +} diff --git a/tests/helpers/mock_gh.bash b/tests/helpers/mock_gh.bash new file mode 100644 index 0000000..85ca83a --- /dev/null +++ b/tests/helpers/mock_gh.bash @@ -0,0 +1,70 @@ +# shellcheck shell=bash +# Install a stub gh on PATH for tests. + +MOCK_GH_DIR="" +_ORIG_PATH="" + +install_mock_gh() { + _ORIG_PATH="$PATH" + MOCK_GH_DIR="$(mktemp -d)" + cat >"$MOCK_GH_DIR/gh" <<'MOCK_EOF' +#!/usr/bin/env bash +set -uo pipefail + +cmd="${1:-}" +shift || true + +case "$cmd" in + repo) + if [[ "${1:-}" == "view" ]]; then + exit "${MOCK_REPO_VIEW_EXIT:-0}" + fi + ;; + api) + api_url="${*}" + if [[ "$api_url" == *libraries.json* ]]; then + if [[ -n "${MOCK_LIBRARIES_FIXTURE:-}" && -f "$MOCK_LIBRARIES_FIXTURE" ]]; then + cat "$MOCK_LIBRARIES_FIXTURE" + fi + exit "${MOCK_GH_API_EXIT:-0}" + fi + exit "${MOCK_GH_API_EXIT:-1}" + ;; + pr) + if [[ "${1:-}" == "list" ]]; then + if [[ -n "${MOCK_PR_LIST_STDERR:-}" ]]; then + printf '%s\n' "$MOCK_PR_LIST_STDERR" >&2 + fi + if [[ -n "${MOCK_PR_LIST_STDOUT:-}" ]]; then + printf '%s\n' "$MOCK_PR_LIST_STDOUT" + fi + exit "${MOCK_PR_LIST_EXIT:-0}" + fi + ;; +esac + +echo "mock gh: unhandled invocation: gh $*" >&2 +exit 127 +MOCK_EOF + chmod +x "$MOCK_GH_DIR/gh" + export PATH="$MOCK_GH_DIR:$PATH" +} + +restore_mock_gh() { + if [[ -n "$_ORIG_PATH" ]]; then + export PATH="$_ORIG_PATH" + fi + if [[ -n "$MOCK_GH_DIR" && -d "$MOCK_GH_DIR" ]]; then + rm -rf "$MOCK_GH_DIR" + fi + unset MOCK_GH_DIR _ORIG_PATH + unset MOCK_REPO_VIEW_EXIT MOCK_LIBRARIES_FIXTURE MOCK_GH_API_EXIT + unset MOCK_PR_LIST_STDOUT MOCK_PR_LIST_STDERR MOCK_PR_LIST_EXIT +} + +reset_mock_gh() { + export MOCK_REPO_VIEW_EXIT=0 + unset MOCK_LIBRARIES_FIXTURE MOCK_GH_API_EXIT + unset MOCK_PR_LIST_STDOUT MOCK_PR_LIST_STDERR MOCK_PR_LIST_EXIT + export MOCK_PR_LIST_EXIT=0 +} diff --git a/tests/test_lib.bats b/tests/test_lib.bats new file mode 100644 index 0000000..f0d7de4 --- /dev/null +++ b/tests/test_lib.bats @@ -0,0 +1,141 @@ +#!/usr/bin/env bats + +setup() { + # shellcheck source=tests/helpers/common.bash + source "$BATS_TEST_DIRNAME/helpers/common.bash" + load_lib +} + +@test "parse_list: empty input produces no output" { + run parse_list "" + [ "$status" -eq 0 ] + [ -z "$output" ] +} + +@test "parse_list: comma-separated codes" { + run parse_list "zh_Hans,en" + [ "$status" -eq 0 ] + [ "$output" = $'zh_Hans\nen' ] +} + +@test "parse_list: bracketed list with spaces" { + run parse_list "[zh_Hans, en]" + [ "$status" -eq 0 ] + [ "$output" = $'zh_Hans\nen' ] +} + +@test "parse_list: trailing comma ignored" { + run parse_list "en," + [ "$status" -eq 0 ] + [ "$output" = "en" ] +} + +@test "parse_extensions: bracketed extensions" { + run parse_extensions "[.adoc, .md]" + [ "$status" -eq 0 ] + [ "$output" = $'.adoc\n.md' ] +} + +@test "parse_extensions: JSON-style quoted extensions" { + run parse_extensions '[".adoc",".md"]' + [ "$status" -eq 0 ] + [ "$output" = $'.adoc\n.md' ] +} + +@test "parse_extensions: bare extension gets dot prefix" { + run parse_extensions "adoc" + [ "$status" -eq 0 ] + [ "$output" = ".adoc" ] +} + +@test "parse_extensions: empty input produces no output" { + run parse_extensions "" + [ "$status" -eq 0 ] + [ -z "$output" ] +} + +@test "is_valid_lang_code: accepts common BCP 47 codes" { + is_valid_lang_code "en" + is_valid_lang_code "zh_Hans" + is_valid_lang_code "pt_BR" +} + +@test "is_valid_lang_code: rejects invalid codes" { + ! is_valid_lang_code "" + ! is_valid_lang_code "en US" + ! is_valid_lang_code "zh/Hans" + ! is_valid_lang_code "a" +} + +@test "get_doc_paths: single-library repo emits doc" { + # shellcheck source=tests/helpers/mock_gh.bash + source "$BATS_TEST_DIRNAME/helpers/mock_gh.bash" + install_mock_gh + export MOCK_LIBRARIES_FIXTURE="$FIXTURES_DIR/libraries-single.json" + + run get_doc_paths "algorithm" "develop" + [ "$status" -eq 0 ] + [ "$output" = "doc" ] + + restore_mock_gh +} + +@test "get_doc_paths: multi-library repo emits per-key doc paths" { + # shellcheck source=tests/helpers/mock_gh.bash + source "$BATS_TEST_DIRNAME/helpers/mock_gh.bash" + install_mock_gh + export MOCK_LIBRARIES_FIXTURE="$FIXTURES_DIR/libraries-multi.json" + + run get_doc_paths "container" "develop" + [ "$status" -eq 0 ] + [ "$output" = $'minmax/doc\nstring/doc' ] + + restore_mock_gh +} + +@test "get_doc_paths: API failure returns non-zero" { + # shellcheck source=tests/helpers/mock_gh.bash + source "$BATS_TEST_DIRNAME/helpers/mock_gh.bash" + install_mock_gh + export MOCK_GH_API_EXIT=1 + + run get_doc_paths "algorithm" "develop" + [ "$status" -eq 1 ] + + restore_mock_gh +} + +@test "prune_to_doc_only: keeps doc and root files, removes other dirs" { + # shellcheck source=tests/helpers/git_fixtures.bash + source "$BATS_TEST_DIRNAME/helpers/git_fixtures.bash" + init_git_fixture_root + local_dir="$GIT_FIXTURE_ROOT/prune-single" + create_prune_fixture_dir "$local_dir" + echo "root" >"$local_dir/LICENSE" + + prune_to_doc_only "$local_dir" "doc" + + [ -d "$local_dir/doc" ] + [ -f "$local_dir/LICENSE" ] + [ ! -d "$local_dir/src" ] + [ ! -d "$local_dir/.github" ] + + cleanup_git_fixture_root +} + +@test "prune_to_doc_only: multi-level path prunes intermediate dirs" { + # shellcheck source=tests/helpers/git_fixtures.bash + source "$BATS_TEST_DIRNAME/helpers/git_fixtures.bash" + init_git_fixture_root + local_dir="$GIT_FIXTURE_ROOT/prune-multi" + create_prune_fixture_dir "$local_dir" + + prune_to_doc_only "$local_dir" "minmax/doc" + + [ -d "$local_dir/minmax" ] + [ ! -d "$local_dir/minmax/other" ] + [ ! -d "$local_dir/doc" ] + [ ! -d "$local_dir/src" ] + + cleanup_git_fixture_root +} diff --git a/tests/test_merge_guard.bats b/tests/test_merge_guard.bats new file mode 100644 index 0000000..a398c28 --- /dev/null +++ b/tests/test_merge_guard.bats @@ -0,0 +1,58 @@ +#!/usr/bin/env bats + +setup() { + # shellcheck source=tests/helpers/common.bash + source "$BATS_TEST_DIRNAME/helpers/common.bash" + # shellcheck source=tests/helpers/mock_gh.bash + source "$BATS_TEST_DIRNAME/helpers/mock_gh.bash" + load_lib + install_mock_gh + reset_mock_gh +} + +teardown() { + restore_mock_gh +} + +@test "has_open_translation_pr: returns true when open PR exists" { + export MOCK_PR_LIST_STDOUT="translation-zh_Hans-foo" + + run has_open_translation_pr "testorg" "algorithm" "zh_Hans" + [ "$status" -eq 0 ] +} + +@test "has_open_translation_pr: returns false when no open PR" { + unset MOCK_PR_LIST_STDOUT + + run has_open_translation_pr "testorg" "algorithm" "zh_Hans" + [ "$status" -eq 1 ] +} + +@test "has_open_translation_pr: API failure returns false (fail-open)" { + export MOCK_PR_LIST_EXIT=1 + export MOCK_PR_LIST_STDERR="API rate limit exceeded" + unset MOCK_PR_LIST_STDOUT + + run has_open_translation_pr "testorg" "algorithm" "zh_Hans" + [ "$status" -eq 1 ] +} + +@test "process_local_branch: returns 1 when open translation PR exists" { + # shellcheck source=tests/helpers/git_fixtures.bash + source "$BATS_TEST_DIRNAME/helpers/git_fixtures.bash" + load_translation + init_git_fixture_root + init_process_globals + + create_bare_remote_with_clone "mirror" + create_remote_branch "$BARE_REMOTE" "local-en" "master" + dest_repo="$GIT_FIXTURE_ROOT/mirror-clone" + git clone "$BARE_REMOTE" "$dest_repo" + + export MOCK_PR_LIST_STDOUT="translation-en-abc123" + + run process_local_branch "$dest_repo" "algorithm" "en" + [ "$status" -eq 1 ] + + cleanup_git_fixture_root +} diff --git a/tests/test_process_submodule.bats b/tests/test_process_submodule.bats new file mode 100644 index 0000000..97f8eb6 --- /dev/null +++ b/tests/test_process_submodule.bats @@ -0,0 +1,88 @@ +#!/usr/bin/env bats + +setup() { + # shellcheck source=tests/helpers/common.bash + source "$BATS_TEST_DIRNAME/helpers/common.bash" + # shellcheck source=tests/helpers/mock_gh.bash + source "$BATS_TEST_DIRNAME/helpers/mock_gh.bash" + # shellcheck source=tests/helpers/git_fixtures.bash + source "$BATS_TEST_DIRNAME/helpers/git_fixtures.bash" + load_translation + install_mock_gh + reset_mock_gh + init_git_fixture_root + init_process_globals + + WORK_DIR="$(mktemp -d)" + BOOST_WORK="$WORK_DIR/boost" + ORG_WORK="$WORK_DIR/$MODULE_ORG" + mkdir -p "$BOOST_WORK" "$ORG_WORK" +} + +teardown() { + restore_mock_gh + cleanup_git_fixture_root + rm -rf "${WORK_DIR:-}" +} + +@test "process_one_submodule: returns 2 when mirror repo missing" { + export MOCK_REPO_VIEW_EXIT=1 + + set +e + process_one_submodule "missing-lib" + status=$? + set -e + + [ "$status" -eq 2 ] + [[ " ${ORG_REPO_MISSING[*]} " == *" missing-lib "* ]] +} + +@test "process_one_submodule: returns 1 when metadata has no doc paths" { + export MOCK_LIBRARIES_FIXTURE="$FIXTURES_DIR/libraries-empty.json" + + set +e + process_one_submodule "algorithm" + status=$? + set -e + + [ "$status" -eq 1 ] + [[ " ${NO_DOC_PATHS[*]} " == *" algorithm "* ]] +} + +@test "process_one_submodule: returns 0 on success path" { + libs_ref="master" + export MOCK_LIBRARIES_FIXTURE="$FIXTURES_DIR/libraries-single.json" + + create_bare_remote_with_clone "boost-algorithm" + boost_bare="$BARE_REMOTE" + boost_work="$WORK_REPO" + mkdir -p "$boost_work/doc" + echo "doc content" >"$boost_work/doc/page.adoc" + git -C "$boost_work" add doc/ + git -C "$boost_work" commit -m "add doc" + git -C "$boost_work" push origin master + + create_bare_remote_with_clone "mirror-algorithm" + mirror_bare="$BARE_REMOTE" + + clone_repo() { + local url="$1" branch="$2" dest="$3" keep="${4:-}" + local bare="" + case "$url" in + *boostorg/algorithm*) bare="$boost_bare" ;; + *testorg/algorithm*) bare="$mirror_bare" ;; + *) echo "unexpected clone url: $url" >&2; return 1 ;; + esac + mkdir -p "$dest" + git clone --branch "$branch" "$bare" "$dest" + [[ "$keep" == "keep" ]] || rm -rf "$dest/.git" + } + + set +e + process_one_submodule "algorithm" + status=$? + set -e + + [ "$status" -eq 0 ] + [[ "${add_or_update[en]}" == *algorithm* ]] +} From 1ffa22cd7d0ffca84f2be492faef6d455a7fad10 Mon Sep 17 00:00:00 2001 From: AuraMindNest Date: Tue, 16 Jun 2026 15:42:24 -0600 Subject: [PATCH 2/8] Add pre-commit logic. --- .githooks/pre-commit | 3 + .github/workflows/assets/translation.sh | 1 + .github/workflows/lint.yml | 23 +----- .gitignore | 5 +- Makefile | 9 +- README.md | 18 +++- scripts/install-git-hooks.sh | 12 +++ scripts/lint.sh | 105 ++++++++++++++++++++++++ scripts/pre-commit.sh | 14 ++++ scripts/test.sh | 36 ++++++++ tests/helpers/common.bash | 1 + tests/helpers/git_fixtures.bash | 1 + 12 files changed, 201 insertions(+), 27 deletions(-) create mode 100755 .githooks/pre-commit create mode 100755 scripts/install-git-hooks.sh create mode 100755 scripts/lint.sh create mode 100755 scripts/pre-commit.sh create mode 100755 scripts/test.sh diff --git a/.githooks/pre-commit b/.githooks/pre-commit new file mode 100755 index 0000000..f4b4092 --- /dev/null +++ b/.githooks/pre-commit @@ -0,0 +1,3 @@ +#!/usr/bin/env bash +set -euo pipefail +exec "$(git rev-parse --show-toplevel)/scripts/pre-commit.sh" diff --git a/.github/workflows/assets/translation.sh b/.github/workflows/assets/translation.sh index 428b94f..59fe1fa 100644 --- a/.github/workflows/assets/translation.sh +++ b/.github/workflows/assets/translation.sh @@ -4,6 +4,7 @@ # MODULE_ORG, MASTER_BRANCH, BOOST_ORG, BOOST_WORK, ORG_WORK, libs_ref, # lang_codes_arr, add_or_update (associative), ORG_REPO_MISSING, META_MISSING, # NO_DOC_PATHS, GITHUB_WORKSPACE. +# shellcheck disable=SC2034,SC2154 # Wipe dest_repo (except .git), copy pruned source, commit, push master only. sync_repo_master() { diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index bf112d5..8328259 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -14,27 +14,8 @@ jobs: with: persist-credentials: false - - name: ShellCheck - run: | - set -euo pipefail - shellcheck -x \ - .github/workflows/assets/env.sh \ - .github/workflows/assets/lib.sh \ - .github/workflows/assets/translation.sh \ - scripts/*.sh \ - tests/helpers/*.bash - - - name: actionlint - run: | - set -euo pipefail - version="1.7.7" - tarball="actionlint_${version}_linux_amd64.tar.gz" - expected_sha256="023070a287cd8cccd71515fedc843f1985bf96c436b7effaecce67290e7e0757" - curl -fsSL -o "$tarball" \ - "https://github.com/rhysd/actionlint/releases/download/v${version}/${tarball}" - echo "${expected_sha256} ${tarball}" | sha256sum -c - - tar -xzf "$tarball" - ./actionlint -color + - name: ShellCheck and actionlint + run: scripts/lint.sh test: runs-on: ubuntu-latest diff --git a/.gitignore b/.gitignore index 59f888c..6d4d57d 100644 --- a/.gitignore +++ b/.gitignore @@ -8,4 +8,7 @@ data/ # Local test scripts -test/ \ No newline at end of file +test/ + +# Tool caches (actionlint binary downloaded by scripts/lint.sh) +.cache/ \ No newline at end of file diff --git a/Makefile b/Makefile index 1e9217f..6c692c2 100644 --- a/Makefile +++ b/Makefile @@ -1,3 +1,8 @@ -.PHONY: test +.PHONY: lint test check +lint: + scripts/lint.sh + test: - bats tests/ + scripts/test.sh + +check: lint test diff --git a/README.md b/README.md index b9cf716..4975e47 100644 --- a/README.md +++ b/README.md @@ -162,13 +162,25 @@ documented below. ## Development -Run the shell test suite locally (requires [bats](https://github.com/bats-core/bats-core); on Ubuntu: `apt install bats`): +Install git hooks (runs lint + tests before each commit): ```bash -make test +scripts/install-git-hooks.sh ``` -CI runs **`make test`** on every push and pull request alongside ShellCheck and actionlint. +Requires **git** and **curl** for first-time setup. ShellCheck, actionlint, and bats are +downloaded automatically into `.cache/` when not already installed (`apt install bats` +is optional). + +Run checks manually: + +```bash +make lint # ShellCheck + actionlint +make test # bats test suite +make check # lint + test (same as pre-commit) +``` + +CI runs **`make test`** and **`scripts/lint.sh`** on every push and pull request. ## License diff --git a/scripts/install-git-hooks.sh b/scripts/install-git-hooks.sh new file mode 100755 index 0000000..910fca0 --- /dev/null +++ b/scripts/install-git-hooks.sh @@ -0,0 +1,12 @@ +#!/usr/bin/env bash +# Point this repository at the version-controlled hooks in .githooks/. +set -euo pipefail + +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +cd "$ROOT" + +chmod +x .githooks/pre-commit scripts/lint.sh scripts/test.sh scripts/pre-commit.sh + +git config core.hooksPath .githooks +echo "Installed git hooks from .githooks/ (core.hooksPath=.githooks)." >&2 +echo "Pre-commit runs: scripts/pre-commit.sh (lint + make test)." >&2 diff --git a/scripts/lint.sh b/scripts/lint.sh new file mode 100755 index 0000000..92b253e --- /dev/null +++ b/scripts/lint.sh @@ -0,0 +1,105 @@ +#!/usr/bin/env bash +# Run ShellCheck and actionlint (same checks as CI lint job). +set -euo pipefail + +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +cd "$ROOT" + +ensure_shellcheck() { + if command -v shellcheck >/dev/null 2>&1; then + SHELLCHECK_BIN="$(command -v shellcheck)" + return + fi + + local version="v0.11.0" + local cache_dir="$ROOT/.cache/shellcheck" + local bin="$cache_dir/shellcheck" + mkdir -p "$cache_dir" + + if [[ -x "$bin" ]]; then + SHELLCHECK_BIN="$bin" + return + fi + + local os arch tarball url + os="$(uname -s)" + arch="$(uname -m)" + case "$os" in + Linux) + case "$arch" in + x86_64) tarball="shellcheck-${version}.linux.x86_64.tar.xz" ;; + aarch64|arm64) tarball="shellcheck-${version}.linux.aarch64.tar.xz" ;; + *) + echo "lint: unsupported Linux architecture for shellcheck download: $arch" >&2 + echo "lint: install shellcheck manually (e.g. apt install shellcheck)." >&2 + exit 1 + ;; + esac + ;; + Darwin) + case "$arch" in + x86_64) tarball="shellcheck-${version}.darwin.x86_64.tar.xz" ;; + arm64) tarball="shellcheck-${version}.darwin.aarch64.tar.xz" ;; + *) + echo "lint: unsupported macOS architecture for shellcheck download: $arch" >&2 + exit 1 + ;; + esac + ;; + *) + echo "lint: shellcheck not found and auto-download unsupported on $os." >&2 + echo "lint: install shellcheck manually (e.g. apt install shellcheck)." >&2 + exit 1 + ;; + esac + + url="https://github.com/koalaman/shellcheck/releases/download/${version}/${tarball}" + if [[ ! -f "$cache_dir/$tarball" ]]; then + echo "lint: downloading shellcheck ${version}..." >&2 + curl -fsSL -o "$cache_dir/$tarball" "$url" + fi + if [[ ! -d "$cache_dir/shellcheck-${version}" ]]; then + if ! tar -xJf "$cache_dir/$tarball" -C "$cache_dir"; then + echo "lint: failed to extract shellcheck (is xz installed? apt install xz-utils)." >&2 + exit 1 + fi + fi + cp "$cache_dir/shellcheck-${version}/shellcheck" "$bin" + chmod +x "$bin" + SHELLCHECK_BIN="$bin" +} + +ensure_shellcheck + +"$SHELLCHECK_BIN" -x \ + .github/workflows/assets/env.sh \ + .github/workflows/assets/lib.sh \ + .github/workflows/assets/translation.sh \ + scripts/*.sh \ + tests/helpers/*.bash + +ACTIONLINT_VERSION="1.7.7" +ACTIONLINT_SHA256="023070a287cd8cccd71515fedc843f1985bf96c436b7effaecce67290e7e0757" +CACHE_DIR="$ROOT/.cache/actionlint" +ACTIONLINT_BIN="$CACHE_DIR/actionlint" +mkdir -p "$CACHE_DIR" + +if [[ ! -x "$ACTIONLINT_BIN" ]]; then + os="$(uname -s)" + case "$os" in + Darwin) tarball="actionlint_${ACTIONLINT_VERSION}_darwin_amd64.tar.gz" ;; + Linux) tarball="actionlint_${ACTIONLINT_VERSION}_linux_amd64.tar.gz" ;; + *) + echo "lint: unsupported OS for actionlint download: $os" >&2 + exit 1 + ;; + esac + curl -fsSL -o "$CACHE_DIR/$tarball" \ + "https://github.com/rhysd/actionlint/releases/download/v${ACTIONLINT_VERSION}/${tarball}" + if [[ "$os" == "Linux" ]]; then + echo "${ACTIONLINT_SHA256} $CACHE_DIR/$tarball" | sha256sum -c - + fi + tar -xzf "$CACHE_DIR/$tarball" -C "$CACHE_DIR" +fi + +"$ACTIONLINT_BIN" -color diff --git a/scripts/pre-commit.sh b/scripts/pre-commit.sh new file mode 100755 index 0000000..6ac355d --- /dev/null +++ b/scripts/pre-commit.sh @@ -0,0 +1,14 @@ +#!/usr/bin/env bash +# Pre-commit checks: lint (ShellCheck + actionlint) and bats tests. +set -euo pipefail + +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +cd "$ROOT" + +echo "pre-commit: running lint..." >&2 +"$ROOT/scripts/lint.sh" + +echo "pre-commit: running tests..." >&2 +"$ROOT/scripts/test.sh" + +echo "pre-commit: all checks passed." >&2 diff --git a/scripts/test.sh b/scripts/test.sh new file mode 100755 index 0000000..cc9a92c --- /dev/null +++ b/scripts/test.sh @@ -0,0 +1,36 @@ +#!/usr/bin/env bash +# Run bats tests (downloads bats-core to .cache/ if bats is not installed). +set -euo pipefail + +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +cd "$ROOT" + +ensure_bats() { + if command -v bats >/dev/null 2>&1; then + BATS_BIN="$(command -v bats)" + return + fi + + local version="v1.11.1" + local cache_dir="$ROOT/.cache/bats-core" + local bin="$cache_dir/bin/bats" + + if [[ -x "$bin" ]]; then + BATS_BIN="$bin" + return + fi + + if ! command -v git >/dev/null 2>&1; then + echo "test: bats is required (e.g. apt install bats) or install git to auto-download." >&2 + exit 1 + fi + + echo "test: downloading bats-core ${version}..." >&2 + rm -rf "$cache_dir" + git clone --depth 1 --branch "$version" \ + https://github.com/bats-core/bats-core.git "$cache_dir" + BATS_BIN="$bin" +} + +ensure_bats +"$BATS_BIN" tests/ diff --git a/tests/helpers/common.bash b/tests/helpers/common.bash index f10cc3b..609e9f7 100644 --- a/tests/helpers/common.bash +++ b/tests/helpers/common.bash @@ -1,5 +1,6 @@ # shellcheck shell=bash # Shared helpers for bats tests. +# shellcheck disable=SC2034 REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" ASSETS_DIR="$REPO_ROOT/.github/workflows/assets" diff --git a/tests/helpers/git_fixtures.bash b/tests/helpers/git_fixtures.bash index 62c1cfa..70aa949 100644 --- a/tests/helpers/git_fixtures.bash +++ b/tests/helpers/git_fixtures.bash @@ -1,5 +1,6 @@ # shellcheck shell=bash # Temp git repos for integration-style tests. +# shellcheck disable=SC2034 init_git_fixture_root() { GIT_FIXTURE_ROOT="$(mktemp -d)" From 4b6d4b4d61eab61d9ed1c2cc52ff8ec30f748c87 Mon Sep 17 00:00:00 2001 From: AuraMindNest Date: Tue, 16 Jun 2026 15:46:07 -0600 Subject: [PATCH 3/8] Fix CI fail. --- tests/helpers/common.bash | 9 +++++---- tests/test_process_submodule.bats | 6 +++--- 2 files changed, 8 insertions(+), 7 deletions(-) diff --git a/tests/helpers/common.bash b/tests/helpers/common.bash index 609e9f7..39fe508 100644 --- a/tests/helpers/common.bash +++ b/tests/helpers/common.bash @@ -45,10 +45,11 @@ reset_process_globals() { init_process_globals() { reset_process_globals - MODULE_ORG="${MODULE_ORG:-testorg}" - MASTER_BRANCH="${MASTER_BRANCH:-master}" - BOOST_ORG="${BOOST_ORG:-boostorg}" - libs_ref="${libs_ref:-develop}" + # Fixed values so tests behave the same locally and in CI (GITHUB_REPOSITORY varies). + MODULE_ORG="testorg" + MASTER_BRANCH="master" + BOOST_ORG="boostorg" + libs_ref="develop" lang_codes_arr=("en") add_or_update["en"]="" } diff --git a/tests/test_process_submodule.bats b/tests/test_process_submodule.bats index 97f8eb6..fc9fb95 100644 --- a/tests/test_process_submodule.bats +++ b/tests/test_process_submodule.bats @@ -69,9 +69,9 @@ teardown() { local url="$1" branch="$2" dest="$3" keep="${4:-}" local bare="" case "$url" in - *boostorg/algorithm*) bare="$boost_bare" ;; - *testorg/algorithm*) bare="$mirror_bare" ;; - *) echo "unexpected clone url: $url" >&2; return 1 ;; + *"${BOOST_ORG}/algorithm"*) bare="$boost_bare" ;; + *"${MODULE_ORG}/algorithm"*) bare="$mirror_bare" ;; + *) echo "unexpected clone url: $url (MODULE_ORG=$MODULE_ORG)" >&2; return 1 ;; esac mkdir -p "$dest" git clone --branch "$branch" "$bare" "$dest" From e2572cce8bb87af067349c121dd110cd0672c073 Mon Sep 17 00:00:00 2001 From: AuraMindNest Date: Wed, 17 Jun 2026 08:18:59 -0600 Subject: [PATCH 4/8] Rerun the coderabbitai review. From 2ccf92fdf5fdbc326392fb88c5c695b159eeceef Mon Sep 17 00:00:00 2001 From: AuraMindNest Date: Wed, 17 Jun 2026 08:29:33 -0600 Subject: [PATCH 5/8] Fix due to the coderabbitai review. --- .gitignore | 2 +- scripts/lint.sh | 67 ++++++++++++++++++++++++++++++++++++++++++------- 2 files changed, 59 insertions(+), 10 deletions(-) diff --git a/.gitignore b/.gitignore index 6d4d57d..215caff 100644 --- a/.gitignore +++ b/.gitignore @@ -11,4 +11,4 @@ data/ test/ # Tool caches (actionlint binary downloaded by scripts/lint.sh) -.cache/ \ No newline at end of file +.cache/ diff --git a/scripts/lint.sh b/scripts/lint.sh index 92b253e..c6307ce 100755 --- a/scripts/lint.sh +++ b/scripts/lint.sh @@ -21,14 +21,20 @@ ensure_shellcheck() { return fi - local os arch tarball url + local os arch tarball url expected_sha256 os="$(uname -s)" arch="$(uname -m)" case "$os" in Linux) case "$arch" in - x86_64) tarball="shellcheck-${version}.linux.x86_64.tar.xz" ;; - aarch64|arm64) tarball="shellcheck-${version}.linux.aarch64.tar.xz" ;; + x86_64) + tarball="shellcheck-${version}.linux.x86_64.tar.xz" + expected_sha256="8c3be12b05d5c177a04c29e3c78ce89ac86f1595681cab149b65b97c4e227198" + ;; + aarch64|arm64) + tarball="shellcheck-${version}.linux.aarch64.tar.xz" + expected_sha256="12b331c1d2db6b9eb13cfca64306b1b157a86eb69db83023e261eaa7e7c14588" + ;; *) echo "lint: unsupported Linux architecture for shellcheck download: $arch" >&2 echo "lint: install shellcheck manually (e.g. apt install shellcheck)." >&2 @@ -38,8 +44,14 @@ ensure_shellcheck() { ;; Darwin) case "$arch" in - x86_64) tarball="shellcheck-${version}.darwin.x86_64.tar.xz" ;; - arm64) tarball="shellcheck-${version}.darwin.aarch64.tar.xz" ;; + x86_64) + tarball="shellcheck-${version}.darwin.x86_64.tar.xz" + expected_sha256="3c89db4edcab7cf1c27bff178882e0f6f27f7afdf54e859fa041fca10febe4c6" + ;; + arm64) + tarball="shellcheck-${version}.darwin.aarch64.tar.xz" + expected_sha256="56affdd8de5527894dca6dc3d7e0a99a873b0f004d7aabc30ae407d3f48b0a79" + ;; *) echo "lint: unsupported macOS architecture for shellcheck download: $arch" >&2 exit 1 @@ -59,6 +71,11 @@ ensure_shellcheck() { curl -fsSL -o "$cache_dir/$tarball" "$url" fi if [[ ! -d "$cache_dir/shellcheck-${version}" ]]; then + if [[ "$os" == "Linux" ]]; then + echo "${expected_sha256} $cache_dir/$tarball" | sha256sum -c - + else + echo "${expected_sha256} $cache_dir/$tarball" | shasum -a 256 -c - + fi if ! tar -xJf "$cache_dir/$tarball" -C "$cache_dir"; then echo "lint: failed to extract shellcheck (is xz installed? apt install xz-utils)." >&2 exit 1 @@ -79,16 +96,46 @@ ensure_shellcheck tests/helpers/*.bash ACTIONLINT_VERSION="1.7.7" -ACTIONLINT_SHA256="023070a287cd8cccd71515fedc843f1985bf96c436b7effaecce67290e7e0757" CACHE_DIR="$ROOT/.cache/actionlint" ACTIONLINT_BIN="$CACHE_DIR/actionlint" mkdir -p "$CACHE_DIR" if [[ ! -x "$ACTIONLINT_BIN" ]]; then os="$(uname -s)" + arch="$(uname -m)" case "$os" in - Darwin) tarball="actionlint_${ACTIONLINT_VERSION}_darwin_amd64.tar.gz" ;; - Linux) tarball="actionlint_${ACTIONLINT_VERSION}_linux_amd64.tar.gz" ;; + Linux) + case "$arch" in + x86_64) + tarball="actionlint_${ACTIONLINT_VERSION}_linux_amd64.tar.gz" + expected_sha256="023070a287cd8cccd71515fedc843f1985bf96c436b7effaecce67290e7e0757" + ;; + aarch64|arm64) + tarball="actionlint_${ACTIONLINT_VERSION}_linux_arm64.tar.gz" + expected_sha256="401942f9c24ed71e4fe71b76c7d638f66d8633575c4016efd2977ce7c28317d0" + ;; + *) + echo "lint: unsupported Linux architecture for actionlint download: $arch" >&2 + exit 1 + ;; + esac + ;; + Darwin) + case "$arch" in + x86_64) + tarball="actionlint_${ACTIONLINT_VERSION}_darwin_amd64.tar.gz" + expected_sha256="28e5de5a05fc558474f638323d736d822fff183d2d492f0aecb2b73cc44584f5" + ;; + arm64) + tarball="actionlint_${ACTIONLINT_VERSION}_darwin_arm64.tar.gz" + expected_sha256="2693315b9093aeacb4ebd91a993fea54fc215057bf0da2659056b4bc033873db" + ;; + *) + echo "lint: unsupported macOS architecture for actionlint download: $arch" >&2 + exit 1 + ;; + esac + ;; *) echo "lint: unsupported OS for actionlint download: $os" >&2 exit 1 @@ -97,7 +144,9 @@ if [[ ! -x "$ACTIONLINT_BIN" ]]; then curl -fsSL -o "$CACHE_DIR/$tarball" \ "https://github.com/rhysd/actionlint/releases/download/v${ACTIONLINT_VERSION}/${tarball}" if [[ "$os" == "Linux" ]]; then - echo "${ACTIONLINT_SHA256} $CACHE_DIR/$tarball" | sha256sum -c - + echo "${expected_sha256} $CACHE_DIR/$tarball" | sha256sum -c - + else + echo "${expected_sha256} $CACHE_DIR/$tarball" | shasum -a 256 -c - fi tar -xzf "$CACHE_DIR/$tarball" -C "$CACHE_DIR" fi From 1bfb25de918590c0caa87a3aeb870ae62f0de982 Mon Sep 17 00:00:00 2001 From: AuraMindNest Date: Wed, 17 Jun 2026 13:25:56 -0600 Subject: [PATCH 6/8] Fix due to the first reviewer. --- .github/workflows/assets/lib.sh | 11 +++++++--- .github/workflows/assets/translation.sh | 9 +++++---- tests/test_merge_guard.bats | 27 +++++++++++++++++++++++-- 3 files changed, 38 insertions(+), 9 deletions(-) diff --git a/.github/workflows/assets/lib.sh b/.github/workflows/assets/lib.sh index 377c26a..10e9f23 100644 --- a/.github/workflows/assets/lib.sh +++ b/.github/workflows/assets/lib.sh @@ -15,13 +15,18 @@ set_git_bot_config() { repo_exists() { gh repo view "$1/$2" &>/dev/null; } # Returns 0 if org/repo has an open PR into local-{lang_code} with head matching -# "translation-{lang_code}-*". +# "translation-{lang_code}-*"; 1 if none; 2 if the GitHub API check failed. has_open_translation_pr() { local org="$1" repo="$2" lang_code="$3" local base_br="local-${lang_code}" - gh pr list --repo "$org/$repo" --state open --base "$base_br" --json headRefName \ + local output + if ! output=$(gh pr list --repo "$org/$repo" --state open --base "$base_br" --json headRefName \ --jq ".[] | select(.headRefName | startswith(\"translation-${lang_code}-\")) | .headRefName" \ - 2>/dev/null | grep -q . + 2>&1); then + echo " Error: could not list open PRs for $org/$repo (base=$base_br): $output" >&2 + return 2 + fi + [[ -n "$output" ]] } # ── Git clone helpers ──────────────────────────────────────────────── diff --git a/.github/workflows/assets/translation.sh b/.github/workflows/assets/translation.sh index 59fe1fa..86a2c6c 100644 --- a/.github/workflows/assets/translation.sh +++ b/.github/workflows/assets/translation.sh @@ -52,10 +52,11 @@ process_local_branch() { local dest_repo="$1" sub_name="$2" lang_code="$3" local local_br="local-${lang_code}" if git -C "$dest_repo" ls-remote --exit-code --heads origin "$local_br" &>/dev/null; then - if has_open_translation_pr "$MODULE_ORG" "$sub_name" "$lang_code"; then - echo " Open translation PR found for $sub_name ($local_br), skipping." >&2 - return 1 - fi + has_open_translation_pr "$MODULE_ORG" "$sub_name" "$lang_code" + case $? in + 0) echo " Open translation PR found for $sub_name ($local_br), skipping." >&2; return 1 ;; + 2) return 2 ;; + esac update_local_merge_from_master "$dest_repo" "$lang_code" || return 2 else ensure_local_branch_in_repo "$dest_repo" "$sub_name" "$lang_code" || return 2 diff --git a/tests/test_merge_guard.bats b/tests/test_merge_guard.bats index a398c28..ada2cb0 100644 --- a/tests/test_merge_guard.bats +++ b/tests/test_merge_guard.bats @@ -28,13 +28,14 @@ teardown() { [ "$status" -eq 1 ] } -@test "has_open_translation_pr: API failure returns false (fail-open)" { +@test "has_open_translation_pr: API failure returns 2 (fail-closed)" { export MOCK_PR_LIST_EXIT=1 export MOCK_PR_LIST_STDERR="API rate limit exceeded" unset MOCK_PR_LIST_STDOUT run has_open_translation_pr "testorg" "algorithm" "zh_Hans" - [ "$status" -eq 1 ] + [ "$status" -eq 2 ] + [[ "$output" == *"API rate limit exceeded"* ]] } @test "process_local_branch: returns 1 when open translation PR exists" { @@ -56,3 +57,25 @@ teardown() { cleanup_git_fixture_root } + +@test "process_local_branch: returns 2 when PR check fails" { + # shellcheck source=tests/helpers/git_fixtures.bash + source "$BATS_TEST_DIRNAME/helpers/git_fixtures.bash" + load_translation + init_git_fixture_root + init_process_globals + + create_bare_remote_with_clone "mirror" + create_remote_branch "$BARE_REMOTE" "local-en" "master" + dest_repo="$GIT_FIXTURE_ROOT/mirror-clone" + git clone "$BARE_REMOTE" "$dest_repo" + + export MOCK_PR_LIST_EXIT=1 + export MOCK_PR_LIST_STDERR="API rate limit exceeded" + unset MOCK_PR_LIST_STDOUT + + run process_local_branch "$dest_repo" "algorithm" "en" + [ "$status" -eq 2 ] + + cleanup_git_fixture_root +} From 59556f0f67266b405ae3d378d32bce66487ea244 Mon Sep 17 00:00:00 2001 From: AuraMindNest Date: Wed, 17 Jun 2026 15:00:14 -0600 Subject: [PATCH 7/8] Fix due to the second coderabbitai review. --- scripts/test.sh | 10 ++++++---- tests/helpers/common.bash | 4 +++- tests/helpers/git_fixtures.bash | 1 + 3 files changed, 10 insertions(+), 5 deletions(-) diff --git a/scripts/test.sh b/scripts/test.sh index cc9a92c..acdcd68 100755 --- a/scripts/test.sh +++ b/scripts/test.sh @@ -11,7 +11,7 @@ ensure_bats() { return fi - local version="v1.11.1" + local bats_commit="b640ec3cf2c7c9cfc9e6351479261186f76eeec8" local cache_dir="$ROOT/.cache/bats-core" local bin="$cache_dir/bin/bats" @@ -25,10 +25,12 @@ ensure_bats() { exit 1 fi - echo "test: downloading bats-core ${version}..." >&2 + echo "test: downloading bats-core ${bats_commit}..." >&2 rm -rf "$cache_dir" - git clone --depth 1 --branch "$version" \ - https://github.com/bats-core/bats-core.git "$cache_dir" + git init -q "$cache_dir" + git -C "$cache_dir" remote add origin https://github.com/bats-core/bats-core.git + git -C "$cache_dir" fetch --depth 1 origin "$bats_commit" + git -C "$cache_dir" checkout -q FETCH_HEAD BATS_BIN="$bin" } diff --git a/tests/helpers/common.bash b/tests/helpers/common.bash index 39fe508..1bc8b14 100644 --- a/tests/helpers/common.bash +++ b/tests/helpers/common.bash @@ -27,10 +27,12 @@ load_translation() { # Run a function and capture its exit code (works under set -e in callers). run_fn() { + local errexit_was_on=0 + [[ $- == *e* ]] && errexit_was_on=1 set +e "$@" local rc=$? - set -e + (( errexit_was_on )) && set -e || set +e return "$rc" } diff --git a/tests/helpers/git_fixtures.bash b/tests/helpers/git_fixtures.bash index 70aa949..eb93e49 100644 --- a/tests/helpers/git_fixtures.bash +++ b/tests/helpers/git_fixtures.bash @@ -22,6 +22,7 @@ create_bare_remote_with_clone() { git init --bare "$bare" git init "$work" + git -C "$work" checkout -b master git -C "$work" config user.email "test@test.local" git -C "$work" config user.name "Test" git -C "$work" remote add origin "$bare" From 3b1aca06c3bb7794e22a5de29245435669ade5bb Mon Sep 17 00:00:00 2001 From: AuraMindNest Date: Wed, 17 Jun 2026 15:01:54 -0600 Subject: [PATCH 8/8] Fix CI fail. --- tests/helpers/common.bash | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/tests/helpers/common.bash b/tests/helpers/common.bash index 1bc8b14..8d32cf1 100644 --- a/tests/helpers/common.bash +++ b/tests/helpers/common.bash @@ -32,7 +32,11 @@ run_fn() { set +e "$@" local rc=$? - (( errexit_was_on )) && set -e || set +e + if (( errexit_was_on )); then + set -e + else + set +e + fi return "$rc" }