Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
176 changes: 176 additions & 0 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
@@ -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"
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
@@ -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)
Expand Down
38 changes: 31 additions & 7 deletions docs/DEVELOPMENT.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
## Project Structure

```
ptc-cli-bash/
ptc-cli/
├── ptc-cli.sh # Main executable script
├── README.md # Main documentation
├── config/ # Configurations
Expand All @@ -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
Expand Down Expand Up @@ -50,8 +51,8 @@ ptc-cli-bash/

```bash
# Clone project
git clone <repository-url>
cd ptc-cli-bash
git clone https://github.com/OnTheGoSystems/ptc-cli.git
cd ptc-cli

# Set execution permissions
chmod +x ptc-cli.sh
Expand All @@ -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
Expand All @@ -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
Expand All @@ -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

Expand Down
117 changes: 117 additions & 0 deletions tests/test-git-context.sh
Original file line number Diff line number Diff line change
@@ -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