diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 0000000..f208d9e --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,176 @@ +name: Self-test + +# This repository ships a script downloaded by tag from customer pipelines, the +# recipes the product prints, and the copy the action vendors and republishes. +# A broken main becomes a release, and a release becomes what those pipelines +# fetch — so the suites that already existed now run on every push. +on: + push: + branches: [main] + pull_request: {} + workflow_dispatch: {} + +jobs: + suites: + name: suites on ${{ matrix.label }} + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + include: + # macOS still ships bash 3.2.57 and the CLI targets it on purpose, so + # this leg is not decoration: current bash accepts syntax 3.2 rejects, + # and a contributor on Linux cannot see that break. + - os: macos-latest + label: bash 3.2 (macOS) + bash_bin: /bin/bash + - os: ubuntu-latest + label: current bash (Linux) + bash_bin: bash + steps: + - uses: actions/checkout@v7 + + # actions/checkout leaves the tree on a detached HEAD, where the CLI's + # branch auto-detection finds nothing. Most suites only care about file + # discovery, so they get an ordinary branch to run on; the behaviour on a + # detached HEAD is asserted deliberately in tests/test-git-context.sh + # rather than left to the runner's checkout style. + - name: put the checkout on a branch + run: git checkout -b self-test-run + + - name: bash under test + env: + BASH_BIN: ${{ matrix.bash_bin }} + run: | + "$BASH_BIN" --version | head -1 + + - name: ptc-cli.sh parses + env: + BASH_BIN: ${{ matrix.bash_bin }} + run: | + "$BASH_BIN" -n ptc-cli.sh + + - name: every suite + env: + BASH_BIN: ${{ matrix.bash_bin }} + run: | + set -uo pipefail + shopt -s nullglob + suites=(tests/test-*.sh) + if [ ${#suites[@]} -eq 0 ]; then + echo "::error::no suites found — tests/test-*.sh matched nothing" + exit 1 + fi + failed=0 + for suite in "${suites[@]}"; do + echo "::group::$suite" + if "$BASH_BIN" "$suite"; then + echo "$suite OK" + else + failed=1 + echo "::error file=$suite::$suite failed" + fi + echo "::endgroup::" + done + exit "$failed" + + checks: + name: repository checks + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v7 + + - name: --version agrees with VERSION + run: | + set -euo pipefail + declared=$(grep -m1 -oE 'readonly VERSION="[^"]+"' ptc-cli.sh | cut -d'"' -f2) + printed=$(./ptc-cli.sh --version | grep -oE '[0-9]+\.[0-9]+\.[0-9]+') + echo "declared=$declared printed=$printed" + test "$declared" = "$printed" + + - name: config examples stay YAML + run: | + set -euo pipefail + # The examples were once KEY=VALUE files the parser rejected, and the + # .config extension is what suggested that format. tests/ proves they + # parse; this only stops the extension coming back. + if compgen -G 'config/examples/*.config' > /dev/null; then + echo "::error::config/examples/*.config is back — these are YAML, not KEY=VALUE" + exit 1 + fi + echo "no .config files — OK" + + - name: every fenced block is what it claims to be + run: | + set -euo pipefail + python3 -m pip install --quiet pyyaml + python3 - <<'PY' + import glob, os, re, subprocess, sys, tempfile, yaml + + # A block tagged `yaml` must parse as YAML and one tagged `bash` must + # at least survive bash -n. Output samples and fragments belong in + # `text` — a reader copies what looks like a command. + bad = [] + total = 0 + for path in ['README.md'] + sorted(glob.glob('docs/*.md')): + blocks = re.findall(r'```(\w+)\n(.*?)```', open(path).read(), re.S) + total += len(blocks) + for i, (lang, body) in enumerate(blocks, 1): + first = body.splitlines()[0][:60] if body.strip() else '(empty)' + if lang == 'yaml': + try: + docs = list(yaml.safe_load_all(body)) + assert docs and docs[0], 'parsed to nothing' + except Exception as e: + bad.append(f'{path} block {i} (yaml, {first!r}): {e}') + elif lang == 'bash': + with tempfile.NamedTemporaryFile('w', suffix='.sh', delete=False) as f: + f.write(body) + tmp = f.name + r = subprocess.run(['bash', '-n', tmp], capture_output=True, text=True) + os.unlink(tmp) + if r.returncode: + bad.append(f'{path} block {i} (bash, {first!r}): {r.stderr.strip()}') + + if not total: + sys.exit('no fenced blocks found at all — check the parser') + if bad: + sys.exit('blocks that do not run as written:\n ' + '\n '.join(bad)) + print(f'{total} fenced blocks OK') + PY + + - name: only the double-brace placeholder is used + run: | + set -euo pipefail + # substitute_pattern expands {{lang}} only; a single-brace one is + # taken literally and produces a path nobody has. + if grep -rnE '(^|[^{])\{lang\}([^}]|$)' README.md docs/ config/examples/ 2>/dev/null; then + echo "::error::single-brace placeholder found — only the double-brace form is substituted" + exit 1 + fi + echo "no single-brace placeholders — OK" + + links: + name: documentation links (advisory) + runs-on: ubuntu-latest + # Advisory: a dead link is a real defect, but an upstream hiccup must not + # turn main red. Read the log when this is yellow. + continue-on-error: true + steps: + - uses: actions/checkout@v7 + - name: every link resolves + run: | + set -uo pipefail + bad=0 + # Skip anything holding a shell variable — those are templates, not URLs. + for url in $(grep -ohE 'https?://[^)"`< ]+' README.md docs/*.md | sed 's/[.,]$//' | grep -v '\$' | sort -u); do + code=$(curl -s -o /dev/null -w '%{http_code}' -L --max-time 20 "$url" || echo 000) + printf '%-76s %s\n' "$url" "$code" + case "$code" in + 2*|3*) ;; + # The API base answers 404 by design; it is a base URL, not a page. + 404) case "$url" in */api/v1/) ;; *) bad=1 ;; esac ;; + *) bad=1 ;; + esac + done + exit "$bad" diff --git a/README.md b/README.md index 76a7035..f454c19 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,7 @@ # PTC CLI - Private Translation Cloud CLI +[![Self-test](https://github.com/OnTheGoSystems/ptc-cli/actions/workflows/test.yml/badge.svg)](https://github.com/OnTheGoSystems/ptc-cli/actions/workflows/test.yml) + Bash script for processing translation files through PTC (Private Translation Cloud) API with support for various project configurations. [Sample repositories](https://github.com/OnTheGoSystems/ptc-cli/wiki/Sample-repositories) diff --git a/docs/DEVELOPMENT.md b/docs/DEVELOPMENT.md index e064cce..5a9385b 100644 --- a/docs/DEVELOPMENT.md +++ b/docs/DEVELOPMENT.md @@ -3,7 +3,7 @@ ## Project Structure ``` -ptc-cli-bash/ +ptc-cli/ ├── ptc-cli.sh # Main executable script ├── README.md # Main documentation ├── config/ # Configurations @@ -21,6 +21,7 @@ ptc-cli-bash/ │ ├── test-error-shapes.sh # Both shapes of a rejected API response │ ├── test-rate-limit.sh # 429 backoff and failure descriptions │ ├── test-config-examples.sh # Every config/examples/* fed to the parser +│ ├── test-git-context.sh # Where the file tag comes from (branch, detached HEAD) │ └── fixtures/ # Test data (created automatically) └── docs/ # Documentation └── DEVELOPMENT.md # This guide @@ -50,8 +51,8 @@ ptc-cli-bash/ ```bash # Clone project -git clone -cd ptc-cli-bash +git clone https://github.com/OnTheGoSystems/ptc-cli.git +cd ptc-cli # Set execution permissions chmod +x ptc-cli.sh @@ -72,11 +73,34 @@ for suite in tests/test-*.sh; do echo "== $suite"; bash "$suite" || break; done ./tests/test-exit-codes.sh ./tests/test-error-shapes.sh ./tests/test-rate-limit.sh +./tests/test-config-examples.sh +./tests/test-git-context.sh # Test specific functionality -./ptc-cli.sh -s en -p '{lang}-copy.json' --dry-run --verbose +./ptc-cli.sh -s en -p '{{lang}}-copy.json' --dry-run --verbose ``` +The whole set is offline — four suites stub `curl`, the rest only use +`--dry-run`, which skips preflight — and finishes in about four seconds. + +### CI + +`.github/workflows/test.yml` runs every suite on push and on pull request. Two +things worth knowing about it: + +- It runs the suites **twice**: on `ubuntu-latest` with current bash, and on + `macos-latest` against `/bin/bash`, which is still 3.2.57. The CLI targets 3.2 + on purpose, and current bash quietly accepts syntax 3.2 rejects — so a + contributor on Linux cannot see that break without this leg. +- A CI checkout lands on a detached HEAD, where branch auto-detection finds + nothing, so the workflow puts the tree on a branch before running the suites. + The detached-HEAD behaviour itself is asserted in `tests/test-git-context.sh` + rather than left to the runner's checkout style. + +It also checks that every fenced block in `README.md` and `docs/*.md` is what it +claims to be: `yaml` parses, `bash` survives `bash -n`. Blocks showing CLI +output belong in `text`. + `test-status-handling.sh` sources `ptc-cli.sh` and stubs `curl`, so it covers status parsing, polling and preflight without network access. It asserts on the number of requests made, not just the return code: the failure mode it guards @@ -88,13 +112,13 @@ like a timeout rather than an error. Enable verbose mode for debugging: ```bash -./ptc-cli.sh -s en -p '{lang}/**/*.json' --verbose --dry-run +./ptc-cli.sh -s en -p '{{lang}}/**/*.json' --verbose --dry-run ``` For additional bash debugging you can use: ```bash -bash -x ./ptc-cli.sh -s en -p '{lang}-copy.json' --dry-run +bash -x ./ptc-cli.sh -s en -p '{{lang}}-copy.json' --dry-run ``` ## Adding New Features @@ -109,7 +133,7 @@ bash -x ./ptc-cli.sh -s en -p '{lang}-copy.json' --dry-run Example of adding `--timeout` option: -```bash +```text # In variables section TIMEOUT=300 diff --git a/tests/test-git-context.sh b/tests/test-git-context.sh new file mode 100755 index 0000000..0859448 --- /dev/null +++ b/tests/test-git-context.sh @@ -0,0 +1,117 @@ +#!/bin/bash + +# What the CLI makes of the git context it is run in. +# +# The file tag defaults to the current branch, and CI is exactly where that +# assumption is weakest: a CI checkout normally leaves the tree on a detached +# HEAD, where `git branch --show-current` prints an empty string and exits 0. +# These assert the behaviour as it stands today rather than leaving it to +# whichever checkout style a runner happens to use — including the case that +# stops the run, so a change in that area has to be deliberate. + +set -uo pipefail + +readonly TEST_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +readonly CLI="$(dirname "$TEST_DIR")/ptc-cli.sh" + +readonly RED='\033[0;31m' +readonly GREEN='\033[0;32m' +readonly NC='\033[0m' + +test_count=0 +passed_count=0 +failed_count=0 + +pass() { echo -e "${GREEN}[PASS]${NC} $*"; passed_count=$((passed_count + 1)); test_count=$((test_count + 1)); } +fail() { echo -e "${RED}[FAIL]${NC} $*"; failed_count=$((failed_count + 1)); test_count=$((test_count + 1)); } + +# A throwaway repository with one translatable file, left on `main`. +make_repo() { + local dir + dir="$(mktemp -d)" + mkdir -p "$dir/locales" + printf '{"hello":"Hello"}\n' > "$dir/locales/en.json" + ( + cd "$dir" || exit 1 + git init -q -b main . 2>/dev/null || { git init -q .; git checkout -q -b main 2>/dev/null; } + git add -A + git -c user.email=test@example.com -c user.name=test commit -qm init + ) + echo "$dir" +} + +run_cli() { + local dir="$1" + shift + ( cd "$dir" && PTC_API_TOKEN='' "$CLI" -s en -p 'locales/{{lang}}.json' --dry-run "$@" 2>&1 ) +} + +main() { + echo "Git context — where the file tag comes from" + echo "===========================================" + + local repo output + repo="$(make_repo)" + + # 1. On a branch, the tag is detected and the run proceeds. + output="$(run_cli "$repo")" + if echo "$output" | grep -q 'Processing completed successfully'; then + pass "on a branch: the file tag is auto-detected and the run completes" + else + fail "on a branch: the run did not complete" + echo "$output" | tail -3 | sed 's/^/ /' + fi + + # 2. Detached HEAD — what every CI checkout looks like by default. + ( cd "$repo" && git checkout -q --detach HEAD ) + output="$(run_cli "$repo")" + if echo "$output" | grep -q 'could not auto-detect git branch'; then + pass "detached HEAD: auto-detection fails with an explicit message" + else + fail "detached HEAD: expected the auto-detect failure message" + echo "$output" | tail -3 | sed 's/^/ /' + fi + + # 3. ...and an explicit tag is the way through it. This is why the CI + # recipes the CLI prints pass one. + output="$(run_cli "$repo" --file-tag-name my-branch)" + if echo "$output" | grep -q 'Processing completed successfully'; then + pass "detached HEAD: an explicit --file-tag-name completes the run" + else + fail "detached HEAD: an explicit --file-tag-name should complete the run" + echo "$output" | tail -3 | sed 's/^/ /' + fi + + # 4. Outside a repository there IS a default, and the run proceeds. + # + # Note the asymmetry with case 2, which is what it looks like: no repo + # falls back to "main", while a repo on a detached HEAD falls back to + # nothing. get_current_branch chains + # git branch --show-current || git rev-parse --abbrev-ref HEAD || echo main + # and that chain assumes the first command fails on a detached HEAD. It does + # not — it prints an empty string and exits 0, so neither fallback is + # reached. Asserted here as it stands; changing it changes CLI behaviour and + # belongs in its own change, not in a test. + local bare + bare="$(mktemp -d)" + mkdir -p "$bare/locales" + printf '{"hello":"Hello"}\n' > "$bare/locales/en.json" + output="$(run_cli "$bare")" + if echo "$output" | grep -q 'Processing completed successfully'; then + pass "outside a repository: falls back to a default tag and completes" + else + fail "outside a repository: expected the default-tag fallback to complete the run" + echo "$output" | tail -3 | sed 's/^/ /' + fi + + rm -rf "$repo" "$bare" + + echo + echo "Total: $test_count Passed: $passed_count Failed: $failed_count" + [ "$failed_count" -eq 0 ] || return 1 + return 0 +} + +if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then + main "$@" +fi