From d6548be070f776a2ed66f52fac7a9633848a9abf Mon Sep 17 00:00:00 2001 From: Pavel Tkachenko Date: Thu, 6 Aug 2026 10:51:35 +0300 Subject: [PATCH] Run the test suites on every push MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit There was no CI here — no workflow file, nothing ran the suites automatically. They take four seconds and need no network (four stub curl, the rest only use --dry-run, which skips preflight), so there was nothing standing in the way. That matters more than usual in this repository: the script is downloaded by tag from customer pipelines and vendored into the action, so a broken main becomes a release and a release becomes what those pipelines fetch. The suites run 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 deliberately and current bash quietly accepts syntax 3.2 rejects, so a contributor on Linux cannot see that break. Each suite gets its own log group and one failure does not hide the others. Four repository checks beyond the suites, each one something that had to be noticed by hand before: - --version agrees with the VERSION constant. - No config/examples/*.config comes back; those were KEY=VALUE files the parser rejected, and the extension is what suggested the format. - Every fenced block in README.md and docs/*.md is what it claims to be: yaml parses, bash survives bash -n. Verified against the previous revision of the README, where it catches the two blocks that showed CLI output while tagged bash. - Only the double-brace placeholder appears; a single-brace one is taken literally and yields a path nobody has. Link checking is a separate advisory job, because a dead link is a real defect but an upstream hiccup must not turn main red. Adding the workflow surfaced something immediately: a CI checkout leaves the tree on a detached HEAD, and there the file-tag auto-detection produces nothing, so six of the twelve invocation tests stopped before doing any work. The workflow puts the checkout on a branch, and the git-context behaviour is now asserted on purpose in tests/test-git-context.sh instead of depending on how a runner happens to check out: detected on a branch, refused with an explicit message on a detached HEAD, and satisfied by passing the tag explicitly. That file also records the asymmetry it exposed — no repository at all falls back to a default tag, while a repository on a detached HEAD falls back to nothing, because the fallback chain assumes the first command fails where it actually succeeds and prints an empty string. Left as it stands; changing it changes CLI behaviour. Suites: 234 assertions across eight files, no failures. Fixing what the new checks found in docs/DEVELOPMENT.md: `git clone ` is not valid shell, and the directory it then entered was never the repository name; three examples used the single-brace placeholder; the --timeout illustration is a fragment rather than a script. Also documented the CI and the offline property of the suites, and added the status badge. --- .github/workflows/test.yml | 176 +++++++++++++++++++++++++++++++++++++ README.md | 2 + docs/DEVELOPMENT.md | 38 ++++++-- tests/test-git-context.sh | 117 ++++++++++++++++++++++++ 4 files changed, 326 insertions(+), 7 deletions(-) create mode 100644 .github/workflows/test.yml create mode 100755 tests/test-git-context.sh 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