GRM-51: refactor: convert shell scripts and inline workflow scripts to Python
This commit is contained in:
+14
-65
@@ -13,12 +13,6 @@ jobs:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Set up environment
|
||||
run: make setup
|
||||
- name: Install actionlint
|
||||
run: |
|
||||
set -euo pipefail
|
||||
mkdir -p "$HOME/.local/bin"
|
||||
bash <(curl -sL https://raw.githubusercontent.com/rhysd/actionlint/main/scripts/download-actionlint.bash) latest "$HOME/.local/bin"
|
||||
echo "$HOME/.local/bin" >> "$GITHUB_PATH"
|
||||
- name: Lint all
|
||||
run: |
|
||||
. .venv/bin/activate
|
||||
@@ -48,21 +42,10 @@ jobs:
|
||||
fetch-depth: 0
|
||||
- name: Set up environment
|
||||
run: make setup
|
||||
- name: Install git-cliff
|
||||
run: |
|
||||
set -euo pipefail
|
||||
GIT_CLIFF_VERSION="2.13.0"
|
||||
URL="https://github.com/orhun/git-cliff/releases/download/v${GIT_CLIFF_VERSION}/git-cliff-${GIT_CLIFF_VERSION}-x86_64-unknown-linux-gnu.tar.gz"
|
||||
TMPDIR="$(mktemp -d)"
|
||||
curl -sL "$URL" | tar xz -C "$TMPDIR"
|
||||
mkdir -p "$HOME/.local/bin"
|
||||
mv "$TMPDIR/git-cliff-${GIT_CLIFF_VERSION}/git-cliff" "$HOME/.local/bin/git-cliff"
|
||||
chmod +x "$HOME/.local/bin/git-cliff"
|
||||
echo "$HOME/.local/bin" >> "$GITHUB_PATH"
|
||||
- name: Release dry-run validation
|
||||
run: |
|
||||
set -euo pipefail
|
||||
. .venv/bin/activate
|
||||
export PATH="$HOME/.local/bin:$PATH"
|
||||
PYTHONPATH=. python3 scripts/ci/release.py --dry-run || true
|
||||
|
||||
detect-changes:
|
||||
@@ -82,27 +65,11 @@ jobs:
|
||||
env:
|
||||
PYTHONPATH: src
|
||||
run: |
|
||||
set -euo pipefail
|
||||
. .venv/bin/activate
|
||||
BASE="origin/master"
|
||||
HEAD="${{ github.event.pull_request.head.sha || github.sha }}"
|
||||
# Use classify_changes.py for consistent file classification
|
||||
ANSIBLE_RESULT=$(python3 scripts/ci/classify_changes.py --base "$BASE" --head "$HEAD" --check ansible --quiet)
|
||||
if [ "$ANSIBLE_RESULT" = "true" ]; then
|
||||
echo "ansible-changed=true" >> "$GITHUB_OUTPUT"
|
||||
echo "Ansible files changed — molecule tests will run."
|
||||
else
|
||||
echo "ansible-changed=false" >> "$GITHUB_OUTPUT"
|
||||
echo "No Ansible files changed — skipping molecule tests."
|
||||
fi
|
||||
USER_FACING_RESULT=$(python3 scripts/ci/classify_changes.py --base "$BASE" --head "$HEAD" --check user-facing --quiet)
|
||||
if [ "$USER_FACING_RESULT" = "true" ]; then
|
||||
echo "user-facing-changed=true" >> "$GITHUB_OUTPUT"
|
||||
echo "User-facing files changed — release dry-run will run."
|
||||
else
|
||||
echo "user-facing-changed=false" >> "$GITHUB_OUTPUT"
|
||||
echo "No user-facing files changed — skipping release dry-run."
|
||||
fi
|
||||
python3 scripts/ci/classify_changes.py \
|
||||
--base "origin/master" \
|
||||
--head "${{ github.event.pull_request.head.sha || github.sha }}" \
|
||||
--github-output
|
||||
|
||||
discover-runners:
|
||||
needs: [detect-changes]
|
||||
@@ -123,15 +90,11 @@ jobs:
|
||||
MOLECULE_RUNNERS: ${{ vars.MOLECULE_RUNNERS }}
|
||||
PYTHONPATH: src
|
||||
run: |
|
||||
set -euo pipefail
|
||||
. .venv/bin/activate
|
||||
OUTPUT=$(python3 scripts/ci/discover_runners.py --owner "${{ github.repository_owner }}" --repo "${{ github.event.repository.name }}")
|
||||
echo "$OUTPUT"
|
||||
# Parse outputs
|
||||
RUNNER_COUNT=$(echo "$OUTPUT" | grep '^count=' | cut -d= -f2)
|
||||
RUNNER_INDICES=$(echo "$OUTPUT" | grep '^indices=' | cut -d= -f2)
|
||||
echo "runner-count=$RUNNER_COUNT" >> "$GITHUB_OUTPUT"
|
||||
echo "runner-indices=$RUNNER_INDICES" >> "$GITHUB_OUTPUT"
|
||||
python3 scripts/ci/discover_runners.py \
|
||||
--owner "${{ github.repository_owner }}" \
|
||||
--repo "${{ github.event.repository.name }}" \
|
||||
--github-output
|
||||
|
||||
molecule-tests:
|
||||
needs: [quality, detect-changes, discover-runners]
|
||||
@@ -150,30 +113,16 @@ jobs:
|
||||
RUNNER_INDEX: ${{ matrix.runner-index }}
|
||||
MAX_RUNNERS: ${{ needs.discover-runners.outputs.runner-count }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
. .venv/bin/activate
|
||||
echo "Runner index: $RUNNER_INDEX"
|
||||
echo "Max runners: $MAX_RUNNERS"
|
||||
# Skip if runner index exceeds available runners
|
||||
if [ "$RUNNER_INDEX" -gt "$MAX_RUNNERS" ]; then
|
||||
echo "Skipping — runner index $RUNNER_INDEX > max runners $MAX_RUNNERS"
|
||||
echo "TEST_PAIRS=" >> "$GITHUB_ENV"
|
||||
echo "SKIP=true" >> "$GITHUB_ENV"
|
||||
exit 0
|
||||
fi
|
||||
PAIRS=$(python3 scripts/ci/distribute_molecule.py --runner-index "$RUNNER_INDEX" --max-runners "$MAX_RUNNERS")
|
||||
echo "Assigned pairs: $PAIRS"
|
||||
echo "TEST_PAIRS=$PAIRS" >> "$GITHUB_ENV"
|
||||
echo "SKIP=false" >> "$GITHUB_ENV"
|
||||
python3 scripts/ci/distribute_molecule.py \
|
||||
--runner-index "$RUNNER_INDEX" \
|
||||
--max-runners "$MAX_RUNNERS" \
|
||||
--github-env --skip-if-excess
|
||||
- name: Run molecule tests
|
||||
if: env.SKIP != 'true'
|
||||
run: |
|
||||
set -euo pipefail
|
||||
. .venv/bin/activate
|
||||
if [ -z "$TEST_PAIRS" ]; then
|
||||
echo "No test pairs assigned — skipping"
|
||||
exit 0
|
||||
fi
|
||||
if [ -z "$TEST_PAIRS" ]; then exit 0; fi
|
||||
# shellcheck disable=SC2086 # intentional word splitting for argument expansion
|
||||
python3 scripts/ci/molecule_ci_guard.py $TEST_PAIRS
|
||||
env:
|
||||
|
||||
@@ -30,17 +30,7 @@ jobs:
|
||||
fetch-depth: 1
|
||||
- name: Check if this is a release commit
|
||||
id: check
|
||||
run: |
|
||||
set -euo pipefail
|
||||
MSG=$(git log -1 --pretty=%s)
|
||||
echo "Commit message: $MSG"
|
||||
if echo "$MSG" | grep -qE '^release: v[0-9]+\.[0-9]+\.[0-9]+'; then
|
||||
echo "is-release=true" >> "$GITHUB_OUTPUT"
|
||||
echo "Release commit — skipping all post-merge jobs."
|
||||
else
|
||||
echo "is-release=false" >> "$GITHUB_OUTPUT"
|
||||
echo "Regular merge commit — running all post-merge jobs."
|
||||
fi
|
||||
run: python3 scripts/ci/detect_release_commit.py
|
||||
|
||||
release:
|
||||
needs: [detect-type]
|
||||
@@ -54,29 +44,16 @@ jobs:
|
||||
token: ${{ secrets.REPO_TOKEN }}
|
||||
- name: Set up environment
|
||||
run: make setup
|
||||
- name: Install git-cliff
|
||||
run: |
|
||||
set -euo pipefail
|
||||
GIT_CLIFF_VERSION="2.13.0"
|
||||
URL="https://github.com/orhun/git-cliff/releases/download/v${GIT_CLIFF_VERSION}/git-cliff-${GIT_CLIFF_VERSION}-x86_64-unknown-linux-gnu.tar.gz"
|
||||
TMPDIR="$(mktemp -d)"
|
||||
curl -sL "$URL" | tar xz -C "$TMPDIR"
|
||||
mkdir -p "$HOME/.local/bin"
|
||||
mv "$TMPDIR/git-cliff-${GIT_CLIFF_VERSION}/git-cliff" "$HOME/.local/bin/git-cliff"
|
||||
chmod +x "$HOME/.local/bin/git-cliff"
|
||||
echo "$HOME/.local/bin" >> "$GITHUB_PATH"
|
||||
"$HOME/.local/bin/git-cliff" --version
|
||||
- name: Configure git
|
||||
run: |
|
||||
set -euo pipefail
|
||||
git config user.name "grm-ci-bot"
|
||||
git config user.email "grm-ci-bot@oblachno.fyi"
|
||||
- name: Run release
|
||||
env:
|
||||
PYTHONPATH: .
|
||||
run: |
|
||||
set -euo pipefail
|
||||
. .venv/bin/activate
|
||||
export PATH="$HOME/.local/bin:$PATH"
|
||||
python3 scripts/ci/release.py
|
||||
- name: Notify on failure
|
||||
if: failure()
|
||||
@@ -84,7 +61,6 @@ jobs:
|
||||
REPO_TOKEN: ${{ secrets.REPO_TOKEN }}
|
||||
PYTHONPATH: src
|
||||
run: |
|
||||
set -euo pipefail
|
||||
python3 scripts/ci/notify_failure.py \
|
||||
--repo "${{ github.repository }}" \
|
||||
--run-id "${{ github.run_id }}" \
|
||||
@@ -107,7 +83,6 @@ jobs:
|
||||
REPO_TOKEN: ${{ secrets.REPO_TOKEN }}
|
||||
PYTHONPATH: src
|
||||
run: |
|
||||
set -euo pipefail
|
||||
. .venv/bin/activate
|
||||
python3 scripts/ci/sync_wiki.py --repo "${{ github.repository }}" --strict
|
||||
- name: Notify on failure
|
||||
@@ -116,7 +91,6 @@ jobs:
|
||||
REPO_TOKEN: ${{ secrets.REPO_TOKEN }}
|
||||
PYTHONPATH: src
|
||||
run: |
|
||||
set -euo pipefail
|
||||
python3 scripts/ci/notify_failure.py \
|
||||
--repo "${{ github.repository }}" \
|
||||
--run-id "${{ github.run_id }}" \
|
||||
@@ -135,36 +109,18 @@ jobs:
|
||||
token: ${{ secrets.REPO_TOKEN }}
|
||||
- name: Set up environment
|
||||
run: make setup
|
||||
- name: Generate badge SVG files
|
||||
run: |
|
||||
set -euo pipefail
|
||||
. .venv/bin/activate
|
||||
python3 scripts/generate_badges.py --output-dir .badges/
|
||||
# Verify badges were generated
|
||||
if [ -z "$(ls -A .badges/ 2>/dev/null)" ]; then
|
||||
echo "::error::No badge SVG files generated"
|
||||
exit 1
|
||||
fi
|
||||
- name: Push badges to badges branch
|
||||
- name: Generate and push badges
|
||||
env:
|
||||
PRE_COMMIT_ALLOW_NO_CONFIG: "1"
|
||||
run: |
|
||||
set -euo pipefail
|
||||
git config user.name "gitea-actions-bot"
|
||||
git config user.email "actions@oblachno.fyi"
|
||||
git checkout --orphan badges
|
||||
git rm -rf .
|
||||
cp -r .badges/* .
|
||||
git add ./*.svg
|
||||
git commit --no-verify -m "Update badges [skip ci]"
|
||||
git push origin badges --force
|
||||
. .venv/bin/activate
|
||||
python3 scripts/ci/push_badges.py
|
||||
- name: Notify on failure
|
||||
if: failure()
|
||||
env:
|
||||
REPO_TOKEN: ${{ secrets.REPO_TOKEN }}
|
||||
PYTHONPATH: src
|
||||
run: |
|
||||
set -euo pipefail
|
||||
python3 scripts/ci/notify_failure.py \
|
||||
--repo "${{ github.repository }}" \
|
||||
--run-id "${{ github.run_id }}" \
|
||||
@@ -186,18 +142,13 @@ jobs:
|
||||
env:
|
||||
VIKUNJA_TOKEN: ${{ secrets.VIKUNJA_TOKEN }}
|
||||
PYTHONPATH: src
|
||||
run: |
|
||||
set -euo pipefail
|
||||
python3 scripts/ci/post_merge.py \
|
||||
"$(git log -1 --pretty=%B)" \
|
||||
--commit-sha "$(git rev-parse HEAD)"
|
||||
run: python3 scripts/ci/post_merge.py --from-git
|
||||
- name: Notify on failure
|
||||
if: failure()
|
||||
env:
|
||||
REPO_TOKEN: ${{ secrets.REPO_TOKEN }}
|
||||
PYTHONPATH: src
|
||||
run: |
|
||||
set -euo pipefail
|
||||
python3 scripts/ci/notify_failure.py \
|
||||
--repo "${{ github.repository }}" \
|
||||
--run-id "${{ github.run_id }}" \
|
||||
|
||||
@@ -13,35 +13,17 @@ jobs:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- name: Install git-cliff
|
||||
run: |
|
||||
set -euo pipefail
|
||||
GIT_CLIFF_VERSION="2.13.0"
|
||||
URL="https://github.com/orhun/git-cliff/releases/download/v${GIT_CLIFF_VERSION}/git-cliff-${GIT_CLIFF_VERSION}-x86_64-unknown-linux-gnu.tar.gz"
|
||||
TMPDIR="$(mktemp -d)"
|
||||
curl -sL "$URL" | tar xz -C "$TMPDIR"
|
||||
mkdir -p "$HOME/.local/bin"
|
||||
mv "$TMPDIR/git-cliff-${GIT_CLIFF_VERSION}/git-cliff" "$HOME/.local/bin/git-cliff"
|
||||
chmod +x "$HOME/.local/bin/git-cliff"
|
||||
echo "$HOME/.local/bin" >> "$GITHUB_PATH"
|
||||
"$HOME/.local/bin/git-cliff" --version
|
||||
- name: Install CI tools
|
||||
run: python3 scripts/install_tools.py --tool git-cliff
|
||||
- name: Install build tools
|
||||
run: |
|
||||
set -euo pipefail
|
||||
python3 -m pip install --break-system-packages build twine requests python-dotenv click
|
||||
- name: Validate PYPI_TOKEN
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if [ -z "${{ secrets.PYPI_TOKEN }}" ]; then
|
||||
echo "::warning::PYPI_TOKEN is not set — package will be built but not published to PyPI."
|
||||
fi
|
||||
run: python3 -m pip install --break-system-packages build twine requests python-dotenv click
|
||||
- name: Build and publish release
|
||||
env:
|
||||
REPO_TOKEN: ${{ secrets.REPO_TOKEN }}
|
||||
PYPI_TOKEN: ${{ secrets.PYPI_TOKEN }}
|
||||
PYTHONPATH: src
|
||||
run: |
|
||||
set -euo pipefail
|
||||
export PATH="$HOME/.local/bin:$PATH"
|
||||
python3 scripts/ci/publish.py \
|
||||
"${{ github.ref_name }}" \
|
||||
"${{ github.repository }}"
|
||||
@@ -51,7 +33,6 @@ jobs:
|
||||
REPO_TOKEN: ${{ secrets.REPO_TOKEN }}
|
||||
PYTHONPATH: src
|
||||
run: |
|
||||
set -euo pipefail
|
||||
python3 scripts/ci/notify_failure.py \
|
||||
--repo "${{ github.repository }}" \
|
||||
--run-id "${{ github.run_id }}" \
|
||||
|
||||
@@ -3,7 +3,8 @@
|
||||
## Build & Test Commands
|
||||
|
||||
```bash
|
||||
make setup # Create venv, install deps, set up hooks
|
||||
make setup # Create venv, install deps, set up hooks, install CI tools
|
||||
make install-tools # Install actionlint, git-cliff, act_runner to ~/.local/bin
|
||||
make lint-all # ruff + pyright + bandit + ansible-lint + checkmake + actionlint
|
||||
make pytest-cov # Unit tests with 100% coverage enforcement
|
||||
make test-unit # Unit tests without coverage
|
||||
@@ -15,6 +16,11 @@ make workflow-dryrun # Dry-run all workflows in Docker (act_runner exec --dryrun
|
||||
make workflow-check # workflow-lint + workflow-dryrun
|
||||
```
|
||||
|
||||
`make setup` automatically installs all development tools:
|
||||
- **Python deps** via `scripts/setup.py` (pip install -e .[dev], ansible-galaxy, pre-commit hooks)
|
||||
- **checkmake** via `scripts/install_checkmake.py` (Makefile linter)
|
||||
- **actionlint, git-cliff, act_runner** via `scripts/install_tools.py` (CI/CD tools to ~/.local/bin)
|
||||
|
||||
## Workflow Verification (Before Push)
|
||||
|
||||
Workflow YAML files (`.gitea/workflows/*.yml`) are verified with two tools:
|
||||
@@ -22,16 +28,15 @@ Workflow YAML files (`.gitea/workflows/*.yml`) are verified with two tools:
|
||||
1. **actionlint** — Static linter that catches syntax errors, invalid
|
||||
expressions, unknown keys, type mismatches, and shellcheck issues.
|
||||
Config: `.gitea/actionlint.yaml` (registers custom `docker` runner label).
|
||||
Install: `bash <(curl -sL https://raw.githubusercontent.com/rhysd/actionlint/main/scripts/download-actionlint.bash)`
|
||||
Installed automatically by `make setup` via `scripts/install_tools.py`.
|
||||
|
||||
2. **act_runner exec --dryrun** — Gitea's own runner in dry-run mode.
|
||||
Validates job dependencies, step ordering, and Docker image selection
|
||||
without starting containers. Install from
|
||||
[gitea/act_runner releases](https://gitea.com/gitea/act_runner/releases).
|
||||
without starting containers. Installed automatically by `make setup`.
|
||||
|
||||
Both run via `make workflow-check` and are part of `make lint-all`.
|
||||
The pre-commit hook runs actionlint automatically when workflow files change.
|
||||
The CI `quality` job installs actionlint and runs `make lint-all`.
|
||||
The CI `quality` job runs `make setup` (which installs all tools) then `make lint-all`.
|
||||
|
||||
## Architecture
|
||||
|
||||
@@ -220,7 +225,7 @@ types from accidentally skipping releases.
|
||||
**Workflow-only paths** (infrastructure → no release needed):
|
||||
- `.gitea/**` — Gitea Actions workflows
|
||||
- `scripts/ci/**` — CI/CD automation scripts
|
||||
- `scripts/setup.sh`, `scripts/molecule_all.sh`, `scripts/__init__.py` — Shell scripts and package init
|
||||
- `scripts/setup.py`, `scripts/molecule_all.py`, `scripts/install_tools.py`, `scripts/__init__.py` — Dev tooling and package init
|
||||
- `docs/**` — Documentation
|
||||
- `tests/**` — Test files
|
||||
- `AGENTS.md`, `README.md`, `CHANGELOG.md`, `TROUBLESHOOTING.md` — Project docs
|
||||
@@ -236,8 +241,8 @@ types from accidentally skipping releases.
|
||||
- Any new file type not in the allowlist
|
||||
|
||||
**Script directory structure:**
|
||||
- `scripts/` — Dev tools (run locally by developers): `check_test_speed.py`, `configure_repo.py`, `install_checkmake.py`, `setup.sh`, `molecule_all.sh`
|
||||
- `scripts/ci/` — CI/CD automation (run by workflows): `release.py`, `publish.py`, `auto_merge.py`, `classify_changes.py`, `doc_coverage.py`, `sync_wiki.py`, etc.
|
||||
- `scripts/` — Dev tools (run locally by developers): `check_test_speed.py`, `configure_repo.py`, `install_checkmake.py`, `install_tools.py`, `setup.py`, `molecule_all.py`
|
||||
- `scripts/ci/` — CI/CD automation (run by workflows): `release.py`, `publish.py`, `auto_merge.py`, `classify_changes.py`, `detect_release_commit.py`, `push_badges.py`, `doc_coverage.py`, `sync_wiki.py`, etc.
|
||||
|
||||
**CI behavior based on classification:**
|
||||
- **Molecule tests**: Only run when `ansible/` or `.ansible-lint` files change
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
.PHONY: all setup install update lint ansible-lint makefile-lint lint-all test test-unit pytest-cov molecule molecule-all test-all clean workflow-lint workflow-dryrun workflow-check
|
||||
.PHONY: all setup install update lint ansible-lint makefile-lint lint-all test test-unit pytest-cov molecule molecule-all test-all clean workflow-lint workflow-dryrun workflow-check install-tools
|
||||
|
||||
PYTHON := python3
|
||||
VENV := .venv
|
||||
@@ -7,8 +7,8 @@ CHECKMAKE := $(shell command -v checkmake 2>/dev/null || echo $(HOME)/go/bin/che
|
||||
|
||||
all: setup
|
||||
|
||||
setup: $(VENV)/bin/activate .env activate-scripts checkmake
|
||||
@bash scripts/setup.sh "$(BIN)"
|
||||
setup: $(VENV)/bin/activate .env activate-scripts checkmake install-tools
|
||||
@$(PYTHON) scripts/setup.py --bin "$(BIN)"
|
||||
|
||||
.env:
|
||||
@if [ ! -f .env ]; then \
|
||||
@@ -34,6 +34,9 @@ install-hooks:
|
||||
checkmake:
|
||||
@python3 scripts/install_checkmake.py
|
||||
|
||||
install-tools:
|
||||
@$(PYTHON) scripts/install_tools.py
|
||||
|
||||
install:
|
||||
@if [ -z "$(HOST)" ]; then echo "HOST is required. Example: make install HOST=192.168.1.10"; exit 1; fi
|
||||
$(BIN)/grm install $(HOST) $(if $(USER),--user $(USER),) $(if $(KEY),--key $(KEY),) $(if $(NAME),--name $(NAME),) $(if $(TOKEN),--token $(TOKEN),) $(if $(ASK_BECOME_PASS),--ask-become-pass,)
|
||||
@@ -121,7 +124,7 @@ molecule:
|
||||
|
||||
# All scenarios on all supported platforms (sequential; use CI matrix for parallel execution)
|
||||
molecule-all:
|
||||
@bash scripts/molecule_all.sh
|
||||
@$(PYTHON) scripts/molecule_all.py --bin "$(BIN)"
|
||||
|
||||
test: test-all
|
||||
|
||||
|
||||
@@ -221,6 +221,26 @@ When adding or removing Gitea runners:
|
||||
2. If runners are at the instance level, update the `MOLECULE_RUNNERS` repo variable
|
||||
3. The workflow automatically scales the matrix to match available runners
|
||||
|
||||
### Release Commit Detection
|
||||
|
||||
The `detect-type` job in the post-merge workflow runs
|
||||
`scripts/ci/detect_release_commit.py` to check whether the latest commit
|
||||
is a release commit (format: `release: vX.Y.Z`). When a release commit
|
||||
is detected, all post-merge jobs (release, sync-wiki, badges, vikunja)
|
||||
are skipped — the tag push triggers the publish workflow instead.
|
||||
|
||||
### Badge Generation and Push
|
||||
|
||||
The `badges` job in the post-merge workflow runs
|
||||
`scripts/ci/push_badges.py` which:
|
||||
1. Generates quality badge SVG files via `scripts/generate_badges.py`
|
||||
2. Creates an orphan `badges` branch
|
||||
3. Copies SVG files to the branch root
|
||||
4. Force-pushes the branch to the remote
|
||||
|
||||
This replaces the previous inline shell script with a tested Python
|
||||
equivalent that handles all git operations in a single script.
|
||||
|
||||
## git-cliff Commit Preprocessing
|
||||
|
||||
Merge commits on master have the format `GRM-N <conventional commit>`. The `GRM-N ` prefix is not a valid conventional commit prefix, so `cliff.toml` includes a `commit_preprocessors` entry that strips it before parsing:
|
||||
|
||||
@@ -172,6 +172,17 @@ def get_latest_tag() -> str:
|
||||
return result.stdout.strip()
|
||||
|
||||
|
||||
def _write_github_output(key: str, value: str) -> None:
|
||||
"""Append a key=value line to the $GITHUB_OUTPUT file."""
|
||||
import os
|
||||
|
||||
gh_output = os.environ.get("GITHUB_OUTPUT")
|
||||
if not gh_output:
|
||||
raise click.ClickException("GITHUB_OUTPUT environment variable is not set")
|
||||
with open(gh_output, "a") as f: # noqa: PTH123
|
||||
f.write(f"{key}={value}\n")
|
||||
|
||||
|
||||
@click.command()
|
||||
@click.option("--base", default=None, help="Base ref (default: latest tag).")
|
||||
@click.option("--head", default="HEAD", help="Head ref (default: HEAD).")
|
||||
@@ -182,10 +193,22 @@ def get_latest_tag() -> str:
|
||||
default="all",
|
||||
help="Check specific category: all (default), ansible, or user-facing.",
|
||||
)
|
||||
def main(base: str | None, head: str, quiet: bool, check: str) -> None:
|
||||
@click.option(
|
||||
"--github-output",
|
||||
"github_output",
|
||||
is_flag=True,
|
||||
default=False,
|
||||
help="Write results to $GITHUB_OUTPUT file (for CI workflow steps).",
|
||||
)
|
||||
def main(base: str | None, head: str, quiet: bool, check: str, github_output: bool) -> None:
|
||||
if base is None:
|
||||
base = get_latest_tag()
|
||||
if not base:
|
||||
if github_output:
|
||||
_write_github_output("ansible-changed", "true")
|
||||
_write_github_output("user-facing-changed", "true")
|
||||
click.echo("No tags found — treating all changes as user-facing.")
|
||||
return
|
||||
if quiet:
|
||||
click.echo("true")
|
||||
else:
|
||||
@@ -194,12 +217,26 @@ def main(base: str | None, head: str, quiet: bool, check: str) -> None:
|
||||
|
||||
files = get_changed_files(base, head)
|
||||
if not files:
|
||||
if github_output:
|
||||
_write_github_output("ansible-changed", "false")
|
||||
_write_github_output("user-facing-changed", "false")
|
||||
click.echo(f"No changes between {base} and {head}.")
|
||||
return
|
||||
if quiet:
|
||||
click.echo("false")
|
||||
else:
|
||||
click.echo(_("No changes between {base} and {head}.", base=base, head=head))
|
||||
return
|
||||
|
||||
if github_output:
|
||||
ansible_files = [f for f in files if f.startswith("ansible/") or f == ".ansible-lint"]
|
||||
user_files = [f for f in files if is_user_facing(f)]
|
||||
_write_github_output("ansible-changed", "true" if ansible_files else "false")
|
||||
_write_github_output("user-facing-changed", "true" if user_files else "false")
|
||||
click.echo(f"Ansible files changed: {bool(ansible_files)}")
|
||||
click.echo(f"User-facing files changed: {bool(user_files)}")
|
||||
return
|
||||
|
||||
if check == "ansible":
|
||||
# Check only for Ansible-related file changes
|
||||
ansible_files = [f for f in files if f.startswith("ansible/") or f == ".ansible-lint"]
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Detect whether the latest git commit is a release commit.
|
||||
|
||||
Release commits have the format ``release: vX.Y.Z [skip ci]``.
|
||||
This script writes ``is-release=true`` or ``is-release=false`` to
|
||||
``$GITHUB_OUTPUT`` for use in CI workflow conditionals.
|
||||
|
||||
Usage::
|
||||
|
||||
python3 scripts/ci/detect_release_commit.py
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import re
|
||||
import subprocess # nosec B404
|
||||
|
||||
import click
|
||||
|
||||
RELEASE_RE = re.compile(r"^release: v\d+\.\d+\.\d+")
|
||||
|
||||
|
||||
def get_commit_message() -> str:
|
||||
"""Get the subject of the latest git commit."""
|
||||
result = subprocess.run( # nosec B603 B607
|
||||
["git", "log", "-1", "--pretty=%s"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False,
|
||||
)
|
||||
if result.returncode != 0:
|
||||
raise click.ClickException(f"git log failed: {result.stderr.strip()}")
|
||||
return result.stdout.strip()
|
||||
|
||||
|
||||
def is_release_commit(message: str) -> bool:
|
||||
"""Check if a commit message matches the release commit format."""
|
||||
return bool(RELEASE_RE.match(message))
|
||||
|
||||
|
||||
def write_github_output(key: str, value: str) -> None:
|
||||
"""Append a key=value line to the $GITHUB_OUTPUT file."""
|
||||
gh_output = os.environ.get("GITHUB_OUTPUT")
|
||||
if not gh_output:
|
||||
raise click.ClickException("GITHUB_OUTPUT environment variable is not set")
|
||||
with open(gh_output, "a") as f: # noqa: PTH123
|
||||
f.write(f"{key}={value}\n")
|
||||
|
||||
|
||||
@click.command()
|
||||
def main() -> None:
|
||||
"""Detect if the latest commit is a release commit and set GITHUB_OUTPUT."""
|
||||
msg = get_commit_message()
|
||||
click.echo(f"Commit message: {msg}")
|
||||
is_release = is_release_commit(msg)
|
||||
write_github_output("is-release", "true" if is_release else "false")
|
||||
if is_release:
|
||||
click.echo("Release commit — skipping all post-merge jobs.")
|
||||
else:
|
||||
click.echo("Regular merge commit — running all post-merge jobs.")
|
||||
|
||||
|
||||
if __name__ == "__main__": # pragma: no cover
|
||||
main() # pragma: no cover
|
||||
@@ -128,7 +128,20 @@ def generate_indices(count: int) -> list[str]:
|
||||
@click.option("--repo", default=None, help="Repository name (for API query).")
|
||||
@click.option("--count", "output_count", is_flag=True, help="Output only the count.")
|
||||
@click.option("--indices", "output_indices", is_flag=True, help="Output only the JSON indices array.")
|
||||
def main(owner: str | None, repo: str | None, output_count: bool, output_indices: bool) -> None:
|
||||
@click.option(
|
||||
"--github-output",
|
||||
"github_output",
|
||||
is_flag=True,
|
||||
default=False,
|
||||
help="Write results to $GITHUB_OUTPUT file (for CI workflow steps).",
|
||||
)
|
||||
def main(
|
||||
owner: str | None,
|
||||
repo: str | None,
|
||||
output_count: bool,
|
||||
output_indices: bool,
|
||||
github_output: bool,
|
||||
) -> None:
|
||||
token = os.environ.get("REPO_TOKEN", "")
|
||||
|
||||
if owner is None:
|
||||
@@ -139,6 +152,17 @@ def main(owner: str | None, repo: str | None, output_count: bool, output_indices
|
||||
count = get_runner_count(GITEA_API_URL, token, owner, repo)
|
||||
indices = generate_indices(count)
|
||||
|
||||
if github_output:
|
||||
gh_output = os.environ.get("GITHUB_OUTPUT")
|
||||
if not gh_output:
|
||||
raise click.ClickException("GITHUB_OUTPUT environment variable is not set")
|
||||
with open(gh_output, "a") as f: # noqa: PTH123
|
||||
f.write(f"runner-count={count}\n")
|
||||
f.write(f"runner-indices={json.dumps(indices)}\n")
|
||||
click.echo(f"Runner count: {count}")
|
||||
click.echo(f"Runner indices: {indices}")
|
||||
return
|
||||
|
||||
if output_count:
|
||||
click.echo(str(count))
|
||||
return
|
||||
|
||||
@@ -100,6 +100,17 @@ def pairs_for_runner(pairs: list[TestPair], runner_index: int, max_runners: int)
|
||||
return groups[runner_index]
|
||||
|
||||
|
||||
def _write_github_env(key: str, value: str) -> None:
|
||||
"""Append a key=value line to the $GITHUB_ENV file."""
|
||||
import os
|
||||
|
||||
gh_env = os.environ.get("GITHUB_ENV")
|
||||
if not gh_env:
|
||||
raise click.ClickException("GITHUB_ENV environment variable is not set")
|
||||
with open(gh_env, "a") as f: # noqa: PTH123
|
||||
f.write(f"{key}={value}\n")
|
||||
|
||||
|
||||
@click.command()
|
||||
@click.option(
|
||||
"--runner-index",
|
||||
@@ -127,7 +138,27 @@ def pairs_for_runner(pairs: list[TestPair], runner_index: int, max_runners: int)
|
||||
is_flag=True,
|
||||
help="List all supported platforms, one per line.",
|
||||
)
|
||||
def cli(runner_index: int | None, max_runners: int, list_all: bool, list_platforms: bool) -> None:
|
||||
@click.option(
|
||||
"--github-env",
|
||||
"github_env",
|
||||
is_flag=True,
|
||||
default=False,
|
||||
help="Write TEST_PAIRS and SKIP to $GITHUB_ENV (for CI workflow steps).",
|
||||
)
|
||||
@click.option(
|
||||
"--skip-if-excess",
|
||||
is_flag=True,
|
||||
default=False,
|
||||
help="With --github-env: write SKIP=true when runner-index exceeds max-runners.",
|
||||
)
|
||||
def cli(
|
||||
runner_index: int | None,
|
||||
max_runners: int,
|
||||
list_all: bool,
|
||||
list_platforms: bool,
|
||||
github_env: bool,
|
||||
skip_if_excess: bool,
|
||||
) -> None:
|
||||
scenarios = discover_scenarios()
|
||||
if list_all:
|
||||
for s in scenarios:
|
||||
@@ -144,10 +175,26 @@ def cli(runner_index: int | None, max_runners: int, list_all: bool, list_platfor
|
||||
labels = " ".join(p.encode() for p in group) if group else "(none)"
|
||||
click.echo(f"Runner {i}: {labels}")
|
||||
return
|
||||
|
||||
# Skip if runner index exceeds available runners (CI static matrix has 3 slots)
|
||||
if skip_if_excess and github_env and runner_index > max_runners:
|
||||
click.echo(f"Skipping — runner index {runner_index} > max runners {max_runners}")
|
||||
_write_github_env("TEST_PAIRS", "")
|
||||
_write_github_env("SKIP", "true")
|
||||
return
|
||||
|
||||
# Convert 1-based CLI index to 0-based internal index
|
||||
zero_based = runner_index - 1
|
||||
assigned = pairs_for_runner(pairs, zero_based, max_runners)
|
||||
click.echo(" ".join(p.encode() for p in assigned))
|
||||
encoded = " ".join(p.encode() for p in assigned)
|
||||
|
||||
if github_env:
|
||||
_write_github_env("TEST_PAIRS", encoded)
|
||||
_write_github_env("SKIP", "false")
|
||||
click.echo(f"Assigned pairs: {encoded}")
|
||||
return
|
||||
|
||||
click.echo(encoded)
|
||||
|
||||
|
||||
if __name__ == "__main__": # pragma: no cover
|
||||
|
||||
@@ -45,6 +45,8 @@ REQUIRED_SCRIPTS = [
|
||||
"post_merge.py",
|
||||
"classify_changes.py",
|
||||
"discover_runners.py",
|
||||
"detect_release_commit.py",
|
||||
"push_badges.py",
|
||||
]
|
||||
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@ Usage:
|
||||
|
||||
import os
|
||||
import re
|
||||
import subprocess # nosec B404
|
||||
|
||||
import click
|
||||
from dotenv import load_dotenv # pyright: ignore[reportMissingImports,reportUnknownVariableType]
|
||||
@@ -19,6 +20,32 @@ from gitea_runner_manager.i18n import _
|
||||
load_dotenv(override=True)
|
||||
|
||||
|
||||
def _get_git_commit_message() -> str:
|
||||
"""Get the full commit message of the latest commit."""
|
||||
result = subprocess.run( # nosec B603 B607
|
||||
["git", "log", "-1", "--pretty=%B"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False,
|
||||
)
|
||||
if result.returncode != 0:
|
||||
raise click.ClickException(f"git log failed: {result.stderr.strip()}")
|
||||
return result.stdout.strip()
|
||||
|
||||
|
||||
def _get_git_commit_sha() -> str:
|
||||
"""Get the SHA of the latest commit."""
|
||||
result = subprocess.run( # nosec B603 B607
|
||||
["git", "rev-parse", "HEAD"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False,
|
||||
)
|
||||
if result.returncode != 0:
|
||||
raise click.ClickException(f"git rev-parse failed: {result.stderr.strip()}")
|
||||
return result.stdout.strip()
|
||||
|
||||
|
||||
def extract_task_id(commit_msg: str) -> str:
|
||||
"""Extract GRM-N task identifier from the first line of commit message."""
|
||||
first_line = commit_msg.split("\n")[0]
|
||||
@@ -69,9 +96,16 @@ def build_comment(task_id: str, conv_msg: str, commit_sha: str) -> str:
|
||||
|
||||
|
||||
@click.command()
|
||||
@click.argument("commit_msg")
|
||||
@click.argument("commit_msg", required=False)
|
||||
@click.option("--commit-sha", default="", help="Commit SHA")
|
||||
def main(commit_msg: str, commit_sha: str) -> None:
|
||||
@click.option("--from-git", is_flag=True, default=False, help="Read commit message and SHA from git.")
|
||||
def main(commit_msg: str | None, commit_sha: str, from_git: bool) -> None:
|
||||
if from_git:
|
||||
commit_msg = _get_git_commit_message()
|
||||
if not commit_sha:
|
||||
commit_sha = _get_git_commit_sha()
|
||||
if not commit_msg:
|
||||
raise click.ClickException("commit_msg argument is required (or use --from-git)")
|
||||
token = os.environ.get("VIKUNJA_TOKEN", "")
|
||||
if not token:
|
||||
raise click.ClickException(_("ERROR: VIKUNJA_TOKEN is not set."))
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Generate badge SVG files and push them to the ``badges`` branch.
|
||||
|
||||
Replaces the inline shell script in the post-merge workflow with a
|
||||
tested Python equivalent.
|
||||
|
||||
Usage::
|
||||
|
||||
python3 scripts/ci/push_badges.py
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import subprocess # nosec B404
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import click
|
||||
|
||||
|
||||
def _run(cmd: list[str], **kwargs: Any) -> subprocess.CompletedProcess[str]:
|
||||
"""Run a command and return the result."""
|
||||
return subprocess.run(cmd, check=True, text=True, **kwargs) # nosec B603
|
||||
|
||||
|
||||
def generate_badges(output_dir: str) -> None:
|
||||
"""Generate badge SVG files using generate_badges.py."""
|
||||
_run([sys.executable, "scripts/generate_badges.py", "--output-dir", output_dir])
|
||||
badges = list(Path(output_dir).glob("*.svg"))
|
||||
if not badges:
|
||||
raise click.ClickException("No badge SVG files generated")
|
||||
click.echo(f"Generated {len(badges)} badge files")
|
||||
|
||||
|
||||
def push_to_badges_branch(badges_dir: str) -> None:
|
||||
"""Push generated badges to the orphan ``badges`` branch."""
|
||||
_run(["git", "config", "user.name", "gitea-actions-bot"])
|
||||
_run(["git", "config", "user.email", "actions@oblachno.fyi"])
|
||||
_run(["git", "checkout", "--orphan", "badges"])
|
||||
_run(["git", "rm", "-rf", "."])
|
||||
|
||||
# Copy badge files to root
|
||||
import shutil
|
||||
|
||||
for svg in Path(badges_dir).glob("*.svg"):
|
||||
shutil.copy2(svg, Path.cwd() / svg.name)
|
||||
|
||||
_run(["git", "add", "./*.svg"])
|
||||
_run(["git", "commit", "--no-verify", "-m", "Update badges [skip ci]"])
|
||||
_run(["git", "push", "origin", "badges", "--force"])
|
||||
click.echo("Badges pushed to badges branch")
|
||||
|
||||
|
||||
@click.command()
|
||||
@click.option("--output-dir", default=".badges/", help="Temporary directory for badge files.")
|
||||
def main(output_dir: str) -> None:
|
||||
"""Generate badges and push them to the badges branch."""
|
||||
generate_badges(output_dir)
|
||||
push_to_badges_branch(output_dir)
|
||||
|
||||
|
||||
if __name__ == "__main__": # pragma: no cover
|
||||
main() # pragma: no cover
|
||||
@@ -0,0 +1,196 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Install CI/CD development tools that are not Python packages.
|
||||
|
||||
Handles installation of:
|
||||
- actionlint (workflow YAML linter)
|
||||
- git-cliff (changelog generator)
|
||||
- act_runner (Gitea Actions local runner, optional)
|
||||
|
||||
Each tool is installed to ``~/.local/bin`` if not already on PATH.
|
||||
Idempotent: skips tools that are already available.
|
||||
|
||||
Usage::
|
||||
|
||||
python3 scripts/install_tools.py # install all
|
||||
python3 scripts/install_tools.py --tool actionlint # install one
|
||||
python3 scripts/install_tools.py --list # list status
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import platform
|
||||
import shutil
|
||||
import tarfile
|
||||
import tempfile
|
||||
import urllib.request
|
||||
from pathlib import Path
|
||||
|
||||
import click
|
||||
|
||||
TARGET_DIR = Path.home() / ".local" / "bin"
|
||||
|
||||
ACTIONLINT_VERSION = "1.7.12"
|
||||
|
||||
GIT_CLIFF_VERSION = "2.13.0"
|
||||
|
||||
ACT_RUNNER_VERSION = "0.2.11"
|
||||
|
||||
|
||||
def _arch() -> str:
|
||||
"""Return the architecture string used by release assets."""
|
||||
machine = platform.machine().lower()
|
||||
if machine in {"x86_64", "amd64"}:
|
||||
return "amd64"
|
||||
if machine in {"aarch64", "arm64"}:
|
||||
return "arm64"
|
||||
raise click.ClickException(f"Unsupported architecture: {machine}")
|
||||
|
||||
|
||||
def _ensure_target_dir() -> Path:
|
||||
"""Ensure the target directory exists and return it."""
|
||||
TARGET_DIR.mkdir(parents=True, exist_ok=True)
|
||||
return TARGET_DIR
|
||||
|
||||
|
||||
def _download(url: str, dest: Path) -> None:
|
||||
"""Download a file from ``url`` to ``dest``."""
|
||||
urllib.request.urlretrieve(url, dest) # nosec B310
|
||||
|
||||
|
||||
def _download_and_extract_tarball(url: str, binary_name: str) -> Path:
|
||||
"""Download a tarball, extract the binary, and install it to TARGET_DIR.
|
||||
|
||||
Returns the path to the installed binary.
|
||||
"""
|
||||
target_dir = _ensure_target_dir()
|
||||
dest = target_dir / binary_name
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
tarball = Path(tmpdir) / "archive.tar.gz"
|
||||
_download(url, tarball)
|
||||
with tarfile.open(tarball, "r:gz") as tar:
|
||||
tar.extractall(tmpdir) # nosec B202
|
||||
# Find the binary in the extracted tree
|
||||
extracted = Path(tmpdir).rglob(binary_name)
|
||||
found = next(extracted, None)
|
||||
if found is None:
|
||||
raise click.ClickException(f"Binary {binary_name} not found in archive from {url}")
|
||||
shutil.copy2(found, dest)
|
||||
dest.chmod(0o755)
|
||||
return dest
|
||||
|
||||
|
||||
def _download_binary(url: str, binary_name: str) -> Path:
|
||||
"""Download a standalone binary and install it to TARGET_DIR.
|
||||
|
||||
Returns the path to the installed binary.
|
||||
"""
|
||||
target_dir = _ensure_target_dir()
|
||||
dest = target_dir / binary_name
|
||||
_download(url, dest)
|
||||
dest.chmod(0o755)
|
||||
return dest
|
||||
|
||||
|
||||
def _is_installed(name: str) -> bool:
|
||||
"""Check if a tool is already on PATH or in TARGET_DIR."""
|
||||
if shutil.which(name) is not None:
|
||||
return True
|
||||
return (TARGET_DIR / name).exists()
|
||||
|
||||
|
||||
def install_actionlint() -> bool:
|
||||
"""Install actionlint if not already present. Returns True if installed/skipped."""
|
||||
if _is_installed("actionlint"):
|
||||
click.echo("actionlint: already installed")
|
||||
return True
|
||||
arch = _arch()
|
||||
url = (
|
||||
f"https://github.com/rhysd/actionlint/releases/download/"
|
||||
f"v{ACTIONLINT_VERSION}/actionlint_{ACTIONLINT_VERSION}_linux_{arch}.tar.gz"
|
||||
)
|
||||
dest = _download_and_extract_tarball(url, "actionlint")
|
||||
click.echo(f"actionlint: installed to {dest}")
|
||||
return True
|
||||
|
||||
|
||||
def install_git_cliff() -> bool:
|
||||
"""Install git-cliff if not already present. Returns True if installed/skipped."""
|
||||
if _is_installed("git-cliff"):
|
||||
click.echo("git-cliff: already installed")
|
||||
return True
|
||||
arch = _arch()
|
||||
url = (
|
||||
f"https://github.com/orhun/git-cliff/releases/download/"
|
||||
f"v{GIT_CLIFF_VERSION}/git-cliff-{GIT_CLIFF_VERSION}-{arch}-unknown-linux-gnu.tar.gz"
|
||||
)
|
||||
dest = _download_and_extract_tarball(url, "git-cliff")
|
||||
click.echo(f"git-cliff: installed to {dest}")
|
||||
return True
|
||||
|
||||
|
||||
def install_act_runner() -> bool:
|
||||
"""Install act_runner if not already present. Returns True if installed/skipped."""
|
||||
if _is_installed("act_runner"):
|
||||
click.echo("act_runner: already installed")
|
||||
return True
|
||||
arch = _arch()
|
||||
url = (
|
||||
f"https://gitea.com/gitea/act_runner/releases/download/"
|
||||
f"v{ACT_RUNNER_VERSION}/act_runner-{ACT_RUNNER_VERSION}-linux-{arch}"
|
||||
)
|
||||
dest = _download_binary(url, "act_runner")
|
||||
click.echo(f"act_runner: installed to {dest}")
|
||||
return True
|
||||
|
||||
|
||||
TOOL_NAMES = ["actionlint", "git-cliff", "act_runner"]
|
||||
|
||||
|
||||
def _install_tool(name: str) -> bool:
|
||||
"""Install a single tool by name."""
|
||||
if name == "actionlint":
|
||||
return install_actionlint()
|
||||
if name == "git-cliff":
|
||||
return install_git_cliff()
|
||||
if name == "act_runner":
|
||||
return install_act_runner()
|
||||
raise click.ClickException(f"Unknown tool: {name}")
|
||||
|
||||
|
||||
def list_tools() -> None:
|
||||
"""Print the installation status of all tools."""
|
||||
for name in TOOL_NAMES:
|
||||
status = "installed" if _is_installed(name) else "not installed"
|
||||
click.echo(f" {name}: {status}")
|
||||
|
||||
|
||||
@click.command()
|
||||
@click.option("--tool", type=click.Choice(TOOL_NAMES), help="Install a specific tool.")
|
||||
@click.option("--list", "list_status", is_flag=True, help="List tool installation status.")
|
||||
def main(tool: str | None, list_status: bool) -> None:
|
||||
"""Install CI/CD development tools to ~/.local/bin."""
|
||||
if list_status:
|
||||
list_tools()
|
||||
return
|
||||
|
||||
tools_to_install = [tool] if tool else TOOL_NAMES
|
||||
failed: list[str] = []
|
||||
for name in tools_to_install:
|
||||
try:
|
||||
_install_tool(name)
|
||||
except Exception as exc:
|
||||
click.echo(f" {name}: FAILED — {exc}", err=True)
|
||||
failed.append(name)
|
||||
|
||||
if failed:
|
||||
raise click.ClickException(f"Failed to install: {', '.join(failed)}")
|
||||
|
||||
# Remind user to add ~/.local/bin to PATH if not already there
|
||||
path_env = os.environ.get("PATH", "")
|
||||
if str(TARGET_DIR) not in path_env:
|
||||
click.echo(f"\nAdd {TARGET_DIR} to your PATH to use these tools.")
|
||||
|
||||
|
||||
if __name__ == "__main__": # pragma: no cover
|
||||
main() # pragma: no cover
|
||||
@@ -0,0 +1,92 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Run all molecule scenarios on all supported OS platforms.
|
||||
|
||||
Replaces the previous ``scripts/molecule_all.sh`` with a tested Python equivalent.
|
||||
Sequential execution — CI uses the parallel matrix instead.
|
||||
|
||||
Usage::
|
||||
|
||||
python3 scripts/molecule_all.py
|
||||
python3 scripts/molecule_all.py --bin .venv/bin
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import subprocess # nosec B404
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import click
|
||||
|
||||
from scripts.ci.distribute_molecule import PLATFORMS
|
||||
|
||||
ROLE_DIR = Path("ansible/roles/gitea-runner")
|
||||
SCENARIOS = ["default", "multi-instance", "lifecycle", "template-content", "deregister", "update"]
|
||||
|
||||
|
||||
def _run_molecule(molecule_bin: str, scenario: str, role_dir: Path, env: dict[str, str]) -> int:
|
||||
"""Run a single molecule scenario. Returns the exit code."""
|
||||
cmd = [molecule_bin, "test"]
|
||||
if scenario != "default":
|
||||
cmd.extend(["-s", scenario])
|
||||
|
||||
click.echo(f"--- Scenario: {scenario} ---")
|
||||
result = subprocess.run( # nosec B603
|
||||
cmd,
|
||||
cwd=str(role_dir),
|
||||
env=env,
|
||||
)
|
||||
return result.returncode
|
||||
|
||||
|
||||
def _run_platform(
|
||||
molecule_bin: str,
|
||||
platform: dict[str, str],
|
||||
role_dir: Path,
|
||||
scenarios: list[str],
|
||||
base_env: dict[str, str],
|
||||
) -> int:
|
||||
"""Run all scenarios for a single platform. Returns the first non-zero exit code."""
|
||||
env = dict(base_env)
|
||||
env["MOLECULE_PLATFORM_NAME"] = platform["name"]
|
||||
env["MOLECULE_PLATFORM_IMAGE"] = platform["image"]
|
||||
if platform.get("command"):
|
||||
env["MOLECULE_PLATFORM_COMMAND"] = platform["command"]
|
||||
else:
|
||||
env.pop("MOLECULE_PLATFORM_COMMAND", None)
|
||||
|
||||
click.echo(f"=== Platform: {platform['name']} ===")
|
||||
for scenario in scenarios:
|
||||
rc = _run_molecule(molecule_bin, scenario, role_dir, env)
|
||||
if rc != 0:
|
||||
return rc
|
||||
return 0
|
||||
|
||||
|
||||
@click.command()
|
||||
@click.option("--bin", "bin_dir", default=".venv/bin", help="Path to the virtualenv bin directory.")
|
||||
def main(bin_dir: str) -> None:
|
||||
"""Run all molecule scenarios on all supported OS platforms sequentially."""
|
||||
molecule_bin = str(Path(bin_dir) / "molecule")
|
||||
if not Path(molecule_bin).exists():
|
||||
raise click.ClickException(f"molecule not found at {molecule_bin}. Run 'make setup' first.")
|
||||
|
||||
if not ROLE_DIR.exists():
|
||||
raise click.ClickException(f"Role directory not found: {ROLE_DIR}")
|
||||
|
||||
base_env = dict(os.environ)
|
||||
base_env["ANSIBLE_ALLOW_BROKEN_CONDITIONALS"] = "true"
|
||||
base_env["ANSIBLE_INJECT_INVOCATION"] = "1"
|
||||
|
||||
for platform in PLATFORMS:
|
||||
rc = _run_platform(molecule_bin, platform, ROLE_DIR, SCENARIOS, base_env)
|
||||
if rc != 0:
|
||||
click.echo(f"FAILED on platform {platform['name']}", err=True)
|
||||
sys.exit(rc)
|
||||
|
||||
click.echo("All molecule scenarios passed on all platforms.")
|
||||
|
||||
|
||||
if __name__ == "__main__": # pragma: no cover
|
||||
main() # pragma: no cover
|
||||
@@ -1,35 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
# Run all molecule scenarios on all supported OS platforms.
|
||||
# Used by `make molecule-all`. Sequential — CI uses parallel matrix instead.
|
||||
# Platform list is sourced from scripts/distribute_molecule.py to avoid duplication.
|
||||
set -euo pipefail
|
||||
|
||||
MOLECULE_BIN="$(realpath "${BIN:-.venv/bin}/molecule")"
|
||||
ROLE_DIR="$(cd "$(dirname "$0")/.." && pwd)/ansible/roles/gitea-runner"
|
||||
SCRIPTS_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||
|
||||
# Read platforms from distribute_molecule.py (single source of truth)
|
||||
PLATFORMS_OUTPUT="$("$MOLECULE_BIN" python "${SCRIPTS_DIR}/distribute_molecule.py" --list-platforms 2>/dev/null || \
|
||||
python3 "${SCRIPTS_DIR}/distribute_molecule.py" --list-platforms)"
|
||||
|
||||
for p in $PLATFORMS_OUTPUT; do
|
||||
IFS="|" read -r name image command <<< "$p"
|
||||
export MOLECULE_PLATFORM_NAME="$name" MOLECULE_PLATFORM_IMAGE="$image"
|
||||
if [ -n "$command" ]; then
|
||||
export MOLECULE_PLATFORM_COMMAND="$command"
|
||||
else
|
||||
unset MOLECULE_PLATFORM_COMMAND
|
||||
fi
|
||||
echo "=== Platform: $name ==="
|
||||
for s in default multi-instance lifecycle template-content deregister update; do
|
||||
echo "--- Scenario: $s on $name ---"
|
||||
(
|
||||
cd "$ROLE_DIR"
|
||||
if [ "$s" = "default" ]; then
|
||||
ANSIBLE_ALLOW_BROKEN_CONDITIONALS=true ANSIBLE_INJECT_INVOCATION=1 "$MOLECULE_BIN" test
|
||||
else
|
||||
ANSIBLE_ALLOW_BROKEN_CONDITIONALS=true ANSIBLE_INJECT_INVOCATION=1 "$MOLECULE_BIN" test -s "$s"
|
||||
fi
|
||||
)
|
||||
done
|
||||
done
|
||||
@@ -0,0 +1,90 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Project setup: install Python deps, Ansible collections, and pre-commit hooks.
|
||||
|
||||
Replaces the previous ``scripts/setup.sh`` with a tested Python equivalent.
|
||||
|
||||
Usage::
|
||||
|
||||
python3 scripts/setup.py --bin .venv/bin
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import subprocess # nosec B404
|
||||
from pathlib import Path
|
||||
|
||||
import click
|
||||
|
||||
|
||||
def _run(cmd: list[str], bin_dir: str) -> None:
|
||||
"""Run a command, streaming output to stdout/stderr."""
|
||||
click.echo(f" $ {' '.join(cmd)}")
|
||||
subprocess.run(cmd, check=True) # nosec B603
|
||||
|
||||
|
||||
def _install_python_deps(bin_dir: str) -> None:
|
||||
"""Install the project with dev extras in editable mode."""
|
||||
pip = str(Path(bin_dir) / "pip")
|
||||
_run([pip, "install", "-e", ".[dev]"], bin_dir)
|
||||
|
||||
|
||||
def _install_ansible_collections(bin_dir: str) -> None:
|
||||
"""Install required Ansible Galaxy collections."""
|
||||
galaxy = str(Path(bin_dir) / "ansible-galaxy")
|
||||
requirements = Path("ansible/requirements.yml")
|
||||
if not requirements.exists():
|
||||
click.echo(" ansible/requirements.yml not found — skipping collections.")
|
||||
return
|
||||
_run([galaxy, "collection", "install", "-r", str(requirements)], bin_dir)
|
||||
|
||||
|
||||
def _install_pre_commit_hooks(bin_dir: str) -> None:
|
||||
"""Install pre-commit hooks for commit-msg, pre-commit, and pre-push."""
|
||||
pre_commit = str(Path(bin_dir) / "pre-commit")
|
||||
for hook_type in ["pre-commit", "commit-msg", "pre-push"]:
|
||||
_run([pre_commit, "install", "--hook-type", hook_type], bin_dir)
|
||||
|
||||
|
||||
def _verify(bin_dir: str) -> None:
|
||||
"""Print versions of installed tools for verification."""
|
||||
grm = str(Path(bin_dir) / "grm")
|
||||
pre_commit = str(Path(bin_dir) / "pre-commit")
|
||||
for tool in [grm, pre_commit]:
|
||||
try:
|
||||
result = subprocess.run([tool, "--version"], capture_output=True, text=True, timeout=10) # nosec B603
|
||||
if result.returncode == 0:
|
||||
click.echo(f" {result.stdout.strip()}")
|
||||
except (FileNotFoundError, subprocess.TimeoutExpired):
|
||||
pass
|
||||
|
||||
|
||||
@click.command()
|
||||
@click.option("--bin", "bin_dir", default=".venv/bin", help="Path to the virtualenv bin directory.")
|
||||
def main(bin_dir: str) -> None:
|
||||
"""Install Python deps, Ansible collections, and pre-commit hooks."""
|
||||
if not Path(bin_dir).exists():
|
||||
raise click.ClickException(f"Bin directory not found: {bin_dir}. Run 'python3 -m venv .venv' first.")
|
||||
|
||||
click.echo("Installing Python dependencies...")
|
||||
_install_python_deps(bin_dir)
|
||||
|
||||
click.echo("Installing Ansible collections...")
|
||||
_install_ansible_collections(bin_dir)
|
||||
|
||||
click.echo("Installing pre-commit hooks...")
|
||||
_install_pre_commit_hooks(bin_dir)
|
||||
|
||||
click.echo("")
|
||||
click.echo("Setup complete.")
|
||||
click.echo("Activate the virtual environment with one of:")
|
||||
click.echo(" source .venv/bin/activate (generic)")
|
||||
click.echo(" source activate.sh (bash)")
|
||||
click.echo(" source activate.fish (fish)")
|
||||
click.echo(" source activate.zsh (zsh)")
|
||||
click.echo("")
|
||||
|
||||
_verify(bin_dir)
|
||||
|
||||
|
||||
if __name__ == "__main__": # pragma: no cover
|
||||
main() # pragma: no cover
|
||||
@@ -1,22 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
set -e
|
||||
|
||||
BIN="${1:-.venv/bin}"
|
||||
|
||||
"$BIN/pip" install -e ".[dev]"
|
||||
"$BIN/ansible-galaxy" collection install -r ansible/requirements.yml
|
||||
"$BIN/pre-commit" install
|
||||
"$BIN/pre-commit" install --hook-type commit-msg
|
||||
"$BIN/pre-commit" install --hook-type pre-push
|
||||
|
||||
echo ""
|
||||
echo "Setup complete."
|
||||
echo "Activate the virtual environment with one of:"
|
||||
echo " source .venv/bin/activate (generic)"
|
||||
echo " source activate.sh (bash)"
|
||||
echo " source activate.fish (fish)"
|
||||
echo " source activate.zsh (zsh)"
|
||||
|
||||
# Verification
|
||||
"$BIN/grm" --version 2>/dev/null || true
|
||||
"$BIN/pre-commit" --version 2>/dev/null || true
|
||||
@@ -1,11 +1,13 @@
|
||||
"""Unit tests for scripts/ci/classify_changes.py."""
|
||||
|
||||
from pathlib import Path
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import click
|
||||
import pytest
|
||||
from click.testing import CliRunner
|
||||
|
||||
import scripts.ci.classify_changes as classify_changes_mod
|
||||
from scripts.ci.classify_changes import (
|
||||
classify_changes,
|
||||
get_changed_files,
|
||||
@@ -312,3 +314,60 @@ class TestMain:
|
||||
result = runner.invoke(main, ["--check", "user-facing"])
|
||||
assert result.exit_code == 0
|
||||
assert "User-facing changes detected" in result.output
|
||||
|
||||
|
||||
class TestGithubOutput:
|
||||
def test_writes_outputs(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
gh_file = tmp_path / "output.txt"
|
||||
monkeypatch.setenv("GITHUB_OUTPUT", str(gh_file))
|
||||
with patch.object(
|
||||
classify_changes_mod, "get_changed_files", return_value=["src/cli.py", "ansible/tasks/main.yml"]
|
||||
):
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(main, ["--base", "v1.0", "--head", "HEAD", "--github-output"])
|
||||
assert result.exit_code == 0
|
||||
content = gh_file.read_text()
|
||||
assert "ansible-changed=true" in content
|
||||
assert "user-facing-changed=true" in content
|
||||
|
||||
def test_no_changes(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
gh_file = tmp_path / "output.txt"
|
||||
monkeypatch.setenv("GITHUB_OUTPUT", str(gh_file))
|
||||
with patch.object(classify_changes_mod, "get_changed_files", return_value=[]):
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(main, ["--base", "v1.0", "--head", "HEAD", "--github-output"])
|
||||
assert result.exit_code == 0
|
||||
content = gh_file.read_text()
|
||||
assert "ansible-changed=false" in content
|
||||
assert "user-facing-changed=false" in content
|
||||
|
||||
def test_no_tags(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
gh_file = tmp_path / "output.txt"
|
||||
monkeypatch.setenv("GITHUB_OUTPUT", str(gh_file))
|
||||
with patch.object(classify_changes_mod, "get_latest_tag", return_value=""):
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(main, ["--github-output"])
|
||||
assert result.exit_code == 0
|
||||
content = gh_file.read_text()
|
||||
assert "ansible-changed=true" in content
|
||||
assert "user-facing-changed=true" in content
|
||||
|
||||
def test_no_env_var(self, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.delenv("GITHUB_OUTPUT", raising=False)
|
||||
with patch.object(classify_changes_mod, "get_changed_files", return_value=["src/cli.py"]):
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(main, ["--base", "v1.0", "--head", "HEAD", "--github-output"])
|
||||
assert result.exit_code != 0
|
||||
|
||||
def test_workflow_only(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
gh_file = tmp_path / "output.txt"
|
||||
monkeypatch.setenv("GITHUB_OUTPUT", str(gh_file))
|
||||
with patch.object(
|
||||
classify_changes_mod, "get_changed_files", return_value=[".gitea/workflows/ci.yml", "AGENTS.md"]
|
||||
):
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(main, ["--base", "v1.0", "--head", "HEAD", "--github-output"])
|
||||
assert result.exit_code == 0
|
||||
content = gh_file.read_text()
|
||||
assert "ansible-changed=false" in content
|
||||
assert "user-facing-changed=false" in content
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
from click import ClickException
|
||||
from click.testing import CliRunner
|
||||
|
||||
import scripts.ci.detect_release_commit as detect_release_commit
|
||||
|
||||
|
||||
class TestGetCommitMessage:
|
||||
def test_success(self) -> None:
|
||||
mock_result = subprocess.CompletedProcess(args=[], returncode=0, stdout="feat: add feature\n", stderr="")
|
||||
with patch("subprocess.run", return_value=mock_result):
|
||||
assert detect_release_commit.get_commit_message() == "feat: add feature"
|
||||
|
||||
def test_failure(self) -> None:
|
||||
mock_result = subprocess.CompletedProcess(args=[], returncode=1, stdout="", stderr="git error")
|
||||
with patch("subprocess.run", return_value=mock_result):
|
||||
with pytest.raises(ClickException, match="git log failed"):
|
||||
detect_release_commit.get_commit_message()
|
||||
|
||||
|
||||
class TestIsReleaseCommit:
|
||||
def test_release_commit(self) -> None:
|
||||
assert detect_release_commit.is_release_commit("release: v1.0.0 [skip ci]") is True
|
||||
|
||||
def test_release_commit_no_skip(self) -> None:
|
||||
assert detect_release_commit.is_release_commit("release: v0.1.0") is True
|
||||
|
||||
def test_regular_commit(self) -> None:
|
||||
assert detect_release_commit.is_release_commit("feat: add feature") is False
|
||||
|
||||
def test_empty(self) -> None:
|
||||
assert detect_release_commit.is_release_commit("") is False
|
||||
|
||||
|
||||
class TestWriteGithubOutput:
|
||||
def test_write(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
gh_file = tmp_path / "output.txt"
|
||||
monkeypatch.setenv("GITHUB_OUTPUT", str(gh_file))
|
||||
detect_release_commit.write_github_output("is-release", "true")
|
||||
with open(gh_file) as f:
|
||||
assert f.read() == "is-release=true\n"
|
||||
|
||||
def test_no_env(self, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.delenv("GITHUB_OUTPUT", raising=False)
|
||||
with pytest.raises(ClickException, match="GITHUB_OUTPUT"):
|
||||
detect_release_commit.write_github_output("is-release", "true")
|
||||
|
||||
|
||||
class TestMain:
|
||||
def test_release_commit(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
gh_file = tmp_path / "output.txt"
|
||||
monkeypatch.setenv("GITHUB_OUTPUT", str(gh_file))
|
||||
with patch.object(detect_release_commit, "get_commit_message", return_value="release: v1.0.0 [skip ci]"):
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(detect_release_commit.main, [])
|
||||
assert result.exit_code == 0
|
||||
assert "Release commit" in result.output
|
||||
with open(gh_file) as f:
|
||||
assert "is-release=true" in f.read()
|
||||
|
||||
def test_regular_commit(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
gh_file = tmp_path / "output.txt"
|
||||
monkeypatch.setenv("GITHUB_OUTPUT", str(gh_file))
|
||||
with patch.object(detect_release_commit, "get_commit_message", return_value="feat: add feature"):
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(detect_release_commit.main, [])
|
||||
assert result.exit_code == 0
|
||||
assert "Regular merge commit" in result.output
|
||||
with open(gh_file) as f:
|
||||
assert "is-release=false" in f.read()
|
||||
@@ -1,8 +1,10 @@
|
||||
"""Unit tests for scripts/ci/discover_runners.py."""
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
from click.testing import CliRunner
|
||||
|
||||
from scripts.ci.discover_runners import (
|
||||
@@ -188,3 +190,21 @@ class TestMain:
|
||||
result = runner.invoke(main, ["--indices"])
|
||||
assert result.exit_code == 0
|
||||
assert json.loads(result.output.strip()) == ["1"]
|
||||
|
||||
@patch("scripts.ci.discover_runners.get_runner_count", return_value=3)
|
||||
def test_github_output(self, mock_count: MagicMock, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
gh_file = tmp_path / "output.txt"
|
||||
monkeypatch.setenv("GITHUB_OUTPUT", str(gh_file))
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(main, ["--github-output"])
|
||||
assert result.exit_code == 0
|
||||
content = gh_file.read_text()
|
||||
assert "runner-count=3" in content
|
||||
assert "runner-indices=" in content
|
||||
|
||||
@patch("scripts.ci.discover_runners.get_runner_count", return_value=3)
|
||||
def test_github_output_no_env(self, mock_count: MagicMock, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.delenv("GITHUB_OUTPUT", raising=False)
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(main, ["--github-output"])
|
||||
assert result.exit_code != 0
|
||||
|
||||
@@ -5,12 +5,14 @@ from unittest.mock import patch
|
||||
|
||||
import click
|
||||
import pytest
|
||||
from click.testing import CliRunner
|
||||
|
||||
from scripts.ci.distribute_molecule import (
|
||||
MOLECULE_ROOT,
|
||||
PLATFORMS,
|
||||
TestPair,
|
||||
build_pairs,
|
||||
cli,
|
||||
discover_scenarios,
|
||||
distribute,
|
||||
pairs_for_runner,
|
||||
@@ -192,6 +194,51 @@ class TestCli:
|
||||
assert "ubuntu-2204" in result.output
|
||||
|
||||
|
||||
class TestGithubEnv:
|
||||
def test_writes_env(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
gh_file = tmp_path / "env.txt"
|
||||
monkeypatch.setenv("GITHUB_ENV", str(gh_file))
|
||||
root = tmp_path / "molecule"
|
||||
scenario = root / "alpha"
|
||||
scenario.mkdir(parents=True)
|
||||
(scenario / "molecule.yml").write_text("name: alpha\n")
|
||||
with patch("scripts.ci.distribute_molecule.MOLECULE_ROOT", root):
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(cli, ["--runner-index", "1", "--max-runners", "3", "--github-env"])
|
||||
assert result.exit_code == 0
|
||||
content = gh_file.read_text()
|
||||
assert "TEST_PAIRS=" in content
|
||||
assert "SKIP=false" in content
|
||||
|
||||
def test_skip_if_excess(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
gh_file = tmp_path / "env.txt"
|
||||
monkeypatch.setenv("GITHUB_ENV", str(gh_file))
|
||||
root = tmp_path / "molecule"
|
||||
scenario = root / "alpha"
|
||||
scenario.mkdir(parents=True)
|
||||
(scenario / "molecule.yml").write_text("name: alpha\n")
|
||||
with patch("scripts.ci.distribute_molecule.MOLECULE_ROOT", root):
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(
|
||||
cli, ["--runner-index", "5", "--max-runners", "3", "--github-env", "--skip-if-excess"]
|
||||
)
|
||||
assert result.exit_code == 0
|
||||
content = gh_file.read_text()
|
||||
assert "TEST_PAIRS=\n" in content
|
||||
assert "SKIP=true" in content
|
||||
|
||||
def test_no_env_var(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.delenv("GITHUB_ENV", raising=False)
|
||||
root = tmp_path / "molecule"
|
||||
scenario = root / "alpha"
|
||||
scenario.mkdir(parents=True)
|
||||
(scenario / "molecule.yml").write_text("name: alpha\n")
|
||||
with patch("scripts.ci.distribute_molecule.MOLECULE_ROOT", root):
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(cli, ["--runner-index", "1", "--max-runners", "3", "--github-env"])
|
||||
assert result.exit_code != 0
|
||||
|
||||
|
||||
def test_main_module_block() -> None:
|
||||
import scripts.ci.distribute_molecule as dm
|
||||
|
||||
|
||||
@@ -77,7 +77,8 @@ class TestMain:
|
||||
# Write ci-cd-workflow.md with all scripts
|
||||
(docs / "tech" / "ci-cd-workflow.md").write_text(
|
||||
"auto_merge.py release.py publish.py review_pr.py "
|
||||
"notify_failure.py post_merge.py classify_changes.py discover_runners.py"
|
||||
"notify_failure.py post_merge.py classify_changes.py discover_runners.py "
|
||||
"detect_release_commit.py push_badges.py"
|
||||
)
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(main, ["--docs-dir", str(docs)])
|
||||
|
||||
@@ -0,0 +1,282 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import platform
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
from click import ClickException
|
||||
from click.testing import CliRunner
|
||||
|
||||
import scripts.install_tools as install_tools
|
||||
|
||||
|
||||
class TestArch:
|
||||
def test_amd64(self) -> None:
|
||||
with patch.object(platform, "machine", return_value="x86_64"):
|
||||
assert install_tools._arch() == "amd64"
|
||||
|
||||
def test_arm64(self) -> None:
|
||||
with patch.object(platform, "machine", return_value="aarch64"):
|
||||
assert install_tools._arch() == "arm64"
|
||||
|
||||
def test_unsupported(self) -> None:
|
||||
with patch.object(platform, "machine", return_value="riscv64"):
|
||||
with pytest.raises(ClickException):
|
||||
install_tools._arch()
|
||||
|
||||
|
||||
class TestIsInstalled:
|
||||
def test_on_path(self) -> None:
|
||||
with patch("shutil.which", return_value="/usr/bin/actionlint"):
|
||||
assert install_tools._is_installed("actionlint") is True
|
||||
|
||||
def test_in_target_dir(self, tmp_path: Path) -> None:
|
||||
with patch.object(install_tools, "TARGET_DIR", tmp_path):
|
||||
(tmp_path / "actionlint").touch()
|
||||
with patch("shutil.which", return_value=None):
|
||||
assert install_tools._is_installed("actionlint") is True
|
||||
|
||||
def test_not_installed(self, tmp_path: Path) -> None:
|
||||
with patch.object(install_tools, "TARGET_DIR", tmp_path):
|
||||
with patch("shutil.which", return_value=None):
|
||||
assert install_tools._is_installed("actionlint") is False
|
||||
|
||||
|
||||
class TestDownload:
|
||||
def test_download(self, tmp_path: Path) -> None:
|
||||
dest = tmp_path / "file.bin"
|
||||
|
||||
def _write_file(url: str, path: Path) -> tuple[str, None]:
|
||||
Path(path).write_bytes(b"data")
|
||||
return str(path), None
|
||||
|
||||
with patch("urllib.request.urlretrieve", side_effect=_write_file) as mock_retrieve:
|
||||
install_tools._download("https://example.com/file", dest)
|
||||
mock_retrieve.assert_called_once()
|
||||
assert dest.read_bytes() == b"data"
|
||||
|
||||
|
||||
class TestDownloadBinary:
|
||||
def test_download(self, tmp_path: Path) -> None:
|
||||
dest = tmp_path / "act_runner"
|
||||
|
||||
def _write_file(url: str, path: Path) -> tuple[str, None]:
|
||||
Path(path).write_bytes(b"binary")
|
||||
return str(path), None
|
||||
|
||||
with patch.object(install_tools, "TARGET_DIR", tmp_path):
|
||||
with patch.object(install_tools, "_download", side_effect=_write_file):
|
||||
result = install_tools._download_binary("https://example.com/act_runner", "act_runner")
|
||||
assert result == dest
|
||||
assert dest.exists()
|
||||
assert dest.stat().st_mode & 0o111
|
||||
|
||||
|
||||
class TestDownloadAndExtractTarball:
|
||||
def test_extract(self, tmp_path: Path) -> None:
|
||||
import tarfile
|
||||
|
||||
# Create a fake tarball with a binary
|
||||
tarball_path = tmp_path / "archive.tar.gz"
|
||||
binary_content = b"fake binary"
|
||||
with tarfile.open(tarball_path, "w:gz") as tar:
|
||||
import io
|
||||
|
||||
info = tarfile.TarInfo(name="actionlint")
|
||||
info.size = len(binary_content)
|
||||
tar.addfile(info, io.BytesIO(binary_content))
|
||||
|
||||
target_dir = tmp_path / "bin"
|
||||
target_dir.mkdir()
|
||||
|
||||
with patch.object(install_tools, "TARGET_DIR", target_dir):
|
||||
with patch.object(
|
||||
install_tools,
|
||||
"_download",
|
||||
side_effect=lambda url, dest: Path(dest).write_bytes(tarball_path.read_bytes()),
|
||||
):
|
||||
result = install_tools._download_and_extract_tarball(
|
||||
"https://example.com/actionlint.tar.gz", "actionlint"
|
||||
)
|
||||
|
||||
assert result == target_dir / "actionlint"
|
||||
assert result.exists()
|
||||
assert result.read_bytes() == binary_content
|
||||
|
||||
def test_binary_not_found(self, tmp_path: Path) -> None:
|
||||
import tarfile
|
||||
|
||||
tarball_path = tmp_path / "archive.tar.gz"
|
||||
with tarfile.open(tarball_path, "w:gz") as tar:
|
||||
import io
|
||||
|
||||
info = tarfile.TarInfo(name="other_file")
|
||||
info.size = 0
|
||||
tar.addfile(info, io.BytesIO(b""))
|
||||
|
||||
target_dir = tmp_path / "bin"
|
||||
target_dir.mkdir()
|
||||
|
||||
with patch.object(install_tools, "TARGET_DIR", target_dir):
|
||||
with patch.object(
|
||||
install_tools,
|
||||
"_download",
|
||||
side_effect=lambda url, dest: Path(dest).write_bytes(tarball_path.read_bytes()),
|
||||
):
|
||||
with pytest.raises(ClickException, match="not found in archive"):
|
||||
install_tools._download_and_extract_tarball("https://example.com/actionlint.tar.gz", "actionlint")
|
||||
|
||||
|
||||
class TestInstallActionlint:
|
||||
def test_already_installed(self) -> None:
|
||||
with patch.object(install_tools, "_is_installed", return_value=True):
|
||||
assert install_tools.install_actionlint() is True
|
||||
|
||||
def test_install(self, tmp_path: Path) -> None:
|
||||
import tarfile
|
||||
|
||||
tarball_path = tmp_path / "archive.tar.gz"
|
||||
binary_content = b"fake actionlint"
|
||||
with tarfile.open(tarball_path, "w:gz") as tar:
|
||||
import io
|
||||
|
||||
info = tarfile.TarInfo(name="actionlint")
|
||||
info.size = len(binary_content)
|
||||
tar.addfile(info, io.BytesIO(binary_content))
|
||||
|
||||
with patch.object(install_tools, "_is_installed", return_value=False):
|
||||
with patch.object(install_tools, "TARGET_DIR", tmp_path):
|
||||
with patch.object(platform, "machine", return_value="x86_64"):
|
||||
with patch.object(
|
||||
install_tools,
|
||||
"_download",
|
||||
side_effect=lambda url, dest: Path(dest).write_bytes(tarball_path.read_bytes()),
|
||||
):
|
||||
assert install_tools.install_actionlint() is True
|
||||
assert (tmp_path / "actionlint").exists()
|
||||
|
||||
|
||||
class TestInstallGitCliff:
|
||||
def test_already_installed(self) -> None:
|
||||
with patch.object(install_tools, "_is_installed", return_value=True):
|
||||
assert install_tools.install_git_cliff() is True
|
||||
|
||||
def test_install(self, tmp_path: Path) -> None:
|
||||
import tarfile
|
||||
|
||||
tarball_path = tmp_path / "archive.tar.gz"
|
||||
binary_content = b"fake git-cliff"
|
||||
with tarfile.open(tarball_path, "w:gz") as tar:
|
||||
import io
|
||||
|
||||
info = tarfile.TarInfo(name="git-cliff")
|
||||
info.size = len(binary_content)
|
||||
tar.addfile(info, io.BytesIO(binary_content))
|
||||
|
||||
with patch.object(install_tools, "_is_installed", return_value=False):
|
||||
with patch.object(install_tools, "TARGET_DIR", tmp_path):
|
||||
with patch.object(platform, "machine", return_value="x86_64"):
|
||||
with patch.object(
|
||||
install_tools,
|
||||
"_download",
|
||||
side_effect=lambda url, dest: Path(dest).write_bytes(tarball_path.read_bytes()),
|
||||
):
|
||||
assert install_tools.install_git_cliff() is True
|
||||
assert (tmp_path / "git-cliff").exists()
|
||||
|
||||
|
||||
class TestInstallActRunner:
|
||||
def test_already_installed(self) -> None:
|
||||
with patch.object(install_tools, "_is_installed", return_value=True):
|
||||
assert install_tools.install_act_runner() is True
|
||||
|
||||
def test_install(self, tmp_path: Path) -> None:
|
||||
def _write_file(url: str, path: Path) -> tuple[str, None]:
|
||||
Path(path).write_bytes(b"binary")
|
||||
return str(path), None
|
||||
|
||||
with patch.object(install_tools, "_is_installed", return_value=False):
|
||||
with patch.object(install_tools, "TARGET_DIR", tmp_path):
|
||||
with patch.object(platform, "machine", return_value="x86_64"):
|
||||
with patch.object(install_tools, "_download", side_effect=_write_file):
|
||||
assert install_tools.install_act_runner() is True
|
||||
assert (tmp_path / "act_runner").exists()
|
||||
|
||||
|
||||
class TestListTools:
|
||||
def test_list(self, tmp_path: Path) -> None:
|
||||
with patch.object(install_tools, "TARGET_DIR", tmp_path):
|
||||
with patch("shutil.which", return_value=None):
|
||||
with patch.object(install_tools, "TOOL_NAMES", ["actionlint", "git-cliff", "act_runner"]):
|
||||
install_tools.list_tools()
|
||||
|
||||
|
||||
class TestInstallTool:
|
||||
def test_actionlint(self) -> None:
|
||||
with patch.object(install_tools, "install_actionlint", return_value=True) as mock:
|
||||
assert install_tools._install_tool("actionlint") is True
|
||||
mock.assert_called_once()
|
||||
|
||||
def test_git_cliff(self) -> None:
|
||||
with patch.object(install_tools, "install_git_cliff", return_value=True) as mock:
|
||||
assert install_tools._install_tool("git-cliff") is True
|
||||
mock.assert_called_once()
|
||||
|
||||
def test_act_runner(self) -> None:
|
||||
with patch.object(install_tools, "install_act_runner", return_value=True) as mock:
|
||||
assert install_tools._install_tool("act_runner") is True
|
||||
mock.assert_called_once()
|
||||
|
||||
def test_unknown_tool(self) -> None:
|
||||
with pytest.raises(ClickException, match="Unknown tool"):
|
||||
install_tools._install_tool("unknown")
|
||||
|
||||
|
||||
class TestMain:
|
||||
def test_list_status(self) -> None:
|
||||
runner = CliRunner()
|
||||
with patch.object(install_tools, "_is_installed", return_value=True):
|
||||
result = runner.invoke(install_tools.main, ["--list"])
|
||||
assert result.exit_code == 0
|
||||
assert "actionlint" in result.output
|
||||
|
||||
def test_install_all(self) -> None:
|
||||
runner = CliRunner()
|
||||
with patch.object(install_tools, "_install_tool", return_value=True) as mock_install:
|
||||
result = runner.invoke(install_tools.main, [])
|
||||
assert result.exit_code == 0
|
||||
assert mock_install.call_count == 3
|
||||
|
||||
def test_install_specific_tool(self) -> None:
|
||||
runner = CliRunner()
|
||||
with patch.object(install_tools, "_install_tool", return_value=True) as mock_install:
|
||||
result = runner.invoke(install_tools.main, ["--tool", "actionlint"])
|
||||
assert result.exit_code == 0
|
||||
mock_install.assert_called_once_with("actionlint")
|
||||
|
||||
def test_install_failure(self) -> None:
|
||||
runner = CliRunner()
|
||||
with patch.object(install_tools, "_install_tool", side_effect=Exception("network error")):
|
||||
result = runner.invoke(install_tools.main, ["--tool", "actionlint"])
|
||||
assert result.exit_code != 0
|
||||
|
||||
def test_path_reminder(self, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""When TARGET_DIR is not in PATH, a reminder is printed."""
|
||||
monkeypatch.setenv("PATH", "/usr/bin:/bin")
|
||||
runner = CliRunner()
|
||||
with patch.object(install_tools, "_install_tool", return_value=True):
|
||||
result = runner.invoke(install_tools.main, [])
|
||||
assert result.exit_code == 0
|
||||
assert "Add" in result.output
|
||||
assert "PATH" in result.output
|
||||
|
||||
def test_no_path_reminder_when_in_path(self, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""When TARGET_DIR is in PATH, no reminder is printed."""
|
||||
target_dir = str(install_tools.TARGET_DIR)
|
||||
monkeypatch.setenv("PATH", f"/usr/bin:{target_dir}:/bin")
|
||||
runner = CliRunner()
|
||||
with patch.object(install_tools, "_install_tool", return_value=True):
|
||||
result = runner.invoke(install_tools.main, [])
|
||||
assert result.exit_code == 0
|
||||
assert "Add" not in result.output
|
||||
@@ -0,0 +1,130 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
from click.testing import CliRunner
|
||||
|
||||
import scripts.molecule_all as molecule_all
|
||||
|
||||
|
||||
class TestRunMolecule:
|
||||
def test_success(self) -> None:
|
||||
import subprocess
|
||||
|
||||
mock_result = subprocess.CompletedProcess(args=[], returncode=0, stdout="", stderr="")
|
||||
with patch("subprocess.run", return_value=mock_result) as mock_run:
|
||||
rc = molecule_all._run_molecule("/bin/molecule", "default", Path("/tmp/role"), {})
|
||||
assert rc == 0
|
||||
mock_run.assert_called_once()
|
||||
|
||||
def test_failure(self) -> None:
|
||||
import subprocess
|
||||
|
||||
mock_result = subprocess.CompletedProcess(args=[], returncode=1, stdout="", stderr="")
|
||||
with patch("subprocess.run", return_value=mock_result):
|
||||
rc = molecule_all._run_molecule("/bin/molecule", "default", Path("/tmp/role"), {})
|
||||
assert rc == 1
|
||||
|
||||
def test_non_default_scenario_adds_flag(self) -> None:
|
||||
import subprocess
|
||||
|
||||
mock_result = subprocess.CompletedProcess(args=[], returncode=0, stdout="", stderr="")
|
||||
with patch("subprocess.run", return_value=mock_result) as mock_run:
|
||||
molecule_all._run_molecule("/bin/molecule", "lifecycle", Path("/tmp/role"), {})
|
||||
cmd = mock_run.call_args[0][0]
|
||||
assert "-s" in cmd
|
||||
assert "lifecycle" in cmd
|
||||
|
||||
|
||||
class TestRunPlatform:
|
||||
def test_all_scenarios_pass(self) -> None:
|
||||
platform = {"name": "ubuntu-2204", "image": "ubuntu:22.04", "command": "/lib/systemd/systemd"}
|
||||
with patch("scripts.molecule_all._run_molecule", return_value=0) as mock_run:
|
||||
rc = molecule_all._run_platform("/bin/molecule", platform, Path("/tmp/role"), ["default", "lifecycle"], {})
|
||||
assert rc == 0
|
||||
assert mock_run.call_count == 2
|
||||
|
||||
def test_stops_on_failure(self) -> None:
|
||||
platform = {"name": "ubuntu-2204", "image": "ubuntu:22.04", "command": "/lib/systemd/systemd"}
|
||||
with patch("scripts.molecule_all._run_molecule", side_effect=[1, 0]) as mock_run:
|
||||
rc = molecule_all._run_platform("/bin/molecule", platform, Path("/tmp/role"), ["default", "lifecycle"], {})
|
||||
assert rc == 1
|
||||
assert mock_run.call_count == 1
|
||||
|
||||
def test_sets_env_vars(self) -> None:
|
||||
platform = {"name": "ubuntu-2204", "image": "ubuntu:22.04", "command": "/lib/systemd/systemd"}
|
||||
captured_env: dict[str, str] = {}
|
||||
|
||||
def _capture_env(cmd, cwd, env):
|
||||
captured_env.update(env)
|
||||
import subprocess
|
||||
|
||||
return subprocess.CompletedProcess(args=cmd, returncode=0, stdout="", stderr="")
|
||||
|
||||
with patch("subprocess.run", side_effect=_capture_env):
|
||||
molecule_all._run_platform("/bin/molecule", platform, Path("/tmp/role"), ["default"], {"PATH": "/usr/bin"})
|
||||
assert captured_env["MOLECULE_PLATFORM_NAME"] == "ubuntu-2204"
|
||||
assert captured_env["MOLECULE_PLATFORM_IMAGE"] == "ubuntu:22.04"
|
||||
assert captured_env["MOLECULE_PLATFORM_COMMAND"] == "/lib/systemd/systemd"
|
||||
|
||||
def test_empty_command_removes_env(self) -> None:
|
||||
platform = {"name": "custom", "image": "custom:latest", "command": ""}
|
||||
captured_env: dict[str, str] = {}
|
||||
|
||||
def _capture_env(cmd, cwd, env):
|
||||
captured_env.update(env)
|
||||
import subprocess
|
||||
|
||||
return subprocess.CompletedProcess(args=cmd, returncode=0, stdout="", stderr="")
|
||||
|
||||
with patch("subprocess.run", side_effect=_capture_env):
|
||||
molecule_all._run_platform(
|
||||
"/bin/molecule", platform, Path("/tmp/role"), ["default"], {"MOLECULE_PLATFORM_COMMAND": "old"}
|
||||
)
|
||||
assert "MOLECULE_PLATFORM_COMMAND" not in captured_env
|
||||
|
||||
|
||||
class TestMain:
|
||||
def test_molecule_not_found(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.chdir(tmp_path)
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(molecule_all.main, ["--bin", "nonexistent/bin"])
|
||||
assert result.exit_code != 0
|
||||
assert "molecule not found" in result.output
|
||||
|
||||
def test_role_dir_not_found(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.chdir(tmp_path)
|
||||
bin_dir = tmp_path / ".venv" / "bin"
|
||||
bin_dir.mkdir(parents=True)
|
||||
(bin_dir / "molecule").touch()
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(molecule_all.main, ["--bin", str(bin_dir)])
|
||||
assert result.exit_code != 0
|
||||
assert "Role directory not found" in result.output
|
||||
|
||||
def test_all_pass(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.chdir(tmp_path)
|
||||
bin_dir = tmp_path / ".venv" / "bin"
|
||||
bin_dir.mkdir(parents=True)
|
||||
(bin_dir / "molecule").touch()
|
||||
(tmp_path / "ansible" / "roles" / "gitea-runner").mkdir(parents=True)
|
||||
|
||||
runner = CliRunner()
|
||||
with patch("scripts.molecule_all._run_platform", return_value=0):
|
||||
result = runner.invoke(molecule_all.main, ["--bin", str(bin_dir)])
|
||||
assert result.exit_code == 0
|
||||
assert "All molecule scenarios passed" in result.output
|
||||
|
||||
def test_platform_failure(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.chdir(tmp_path)
|
||||
bin_dir = tmp_path / ".venv" / "bin"
|
||||
bin_dir.mkdir(parents=True)
|
||||
(bin_dir / "molecule").touch()
|
||||
(tmp_path / "ansible" / "roles" / "gitea-runner").mkdir(parents=True)
|
||||
|
||||
runner = CliRunner()
|
||||
with patch("scripts.molecule_all._run_platform", return_value=1):
|
||||
result = runner.invoke(molecule_all.main, ["--bin", str(bin_dir)])
|
||||
assert result.exit_code != 0
|
||||
@@ -1,6 +1,7 @@
|
||||
"""Unit tests for scripts/ci/post_merge.py."""
|
||||
|
||||
import http
|
||||
import subprocess
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import click
|
||||
@@ -9,6 +10,8 @@ from click.testing import CliRunner
|
||||
|
||||
from gitea_runner_manager.exceptions import APIError
|
||||
from scripts.ci.post_merge import (
|
||||
_get_git_commit_message,
|
||||
_get_git_commit_sha,
|
||||
build_comment,
|
||||
extract_conventional_msg,
|
||||
extract_task_id,
|
||||
@@ -206,3 +209,69 @@ class TestMain:
|
||||
assert result.exit_code == 0
|
||||
assert "Warning" in result.output
|
||||
assert "not updated" in result.output.lower()
|
||||
|
||||
|
||||
class TestGetGitCommitMessage:
|
||||
def test_success(self) -> None:
|
||||
mock_result = subprocess.CompletedProcess(args=[], returncode=0, stdout="GRM-20: fix: bug\n", stderr="")
|
||||
with patch("subprocess.run", return_value=mock_result):
|
||||
assert _get_git_commit_message() == "GRM-20: fix: bug"
|
||||
|
||||
def test_failure(self) -> None:
|
||||
mock_result = subprocess.CompletedProcess(args=[], returncode=1, stdout="", stderr="git error")
|
||||
with patch("subprocess.run", return_value=mock_result):
|
||||
with pytest.raises(click.ClickException, match="git log failed"):
|
||||
_get_git_commit_message()
|
||||
|
||||
|
||||
class TestGetGitCommitSha:
|
||||
def test_success(self) -> None:
|
||||
mock_result = subprocess.CompletedProcess(args=[], returncode=0, stdout="abc123\n", stderr="")
|
||||
with patch("subprocess.run", return_value=mock_result):
|
||||
assert _get_git_commit_sha() == "abc123"
|
||||
|
||||
def test_failure(self) -> None:
|
||||
mock_result = subprocess.CompletedProcess(args=[], returncode=1, stdout="", stderr="git error")
|
||||
with patch("subprocess.run", return_value=mock_result):
|
||||
with pytest.raises(click.ClickException, match="git rev-parse failed"):
|
||||
_get_git_commit_sha()
|
||||
|
||||
|
||||
class TestFromGit:
|
||||
@patch.dict("os.environ", {"VIKUNJA_TOKEN": "tok"})
|
||||
@patch("scripts.ci.post_merge.VikunjaClient")
|
||||
@patch("scripts.ci.post_merge._get_git_commit_sha", return_value="abc123")
|
||||
@patch("scripts.ci.post_merge._get_git_commit_message", return_value="GRM-20: fix: bug")
|
||||
def test_from_git(self, mock_msg: MagicMock, mock_sha: MagicMock, mock_client_cls: MagicMock) -> None:
|
||||
mock_client = MagicMock()
|
||||
mock_client.list_project_tasks.return_value = [
|
||||
{"id": 267, "identifier": "GRM-20"},
|
||||
]
|
||||
mock_client_cls.return_value = mock_client
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(main, ["--from-git"])
|
||||
assert result.exit_code == 0
|
||||
assert "updated and marked done" in result.output
|
||||
|
||||
@patch.dict("os.environ", {"VIKUNJA_TOKEN": "tok"})
|
||||
@patch("scripts.ci.post_merge.VikunjaClient")
|
||||
@patch("scripts.ci.post_merge._get_git_commit_sha", return_value="abc123")
|
||||
@patch("scripts.ci.post_merge._get_git_commit_message", return_value="GRM-20: fix: bug")
|
||||
def test_from_git_with_explicit_sha(
|
||||
self, mock_msg: MagicMock, mock_sha: MagicMock, mock_client_cls: MagicMock
|
||||
) -> None:
|
||||
mock_client = MagicMock()
|
||||
mock_client.list_project_tasks.return_value = [
|
||||
{"id": 267, "identifier": "GRM-20"},
|
||||
]
|
||||
mock_client_cls.return_value = mock_client
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(main, ["--from-git", "--commit-sha", "explicit_sha"])
|
||||
assert result.exit_code == 0
|
||||
|
||||
@patch.dict("os.environ", {"VIKUNJA_TOKEN": "tok"})
|
||||
def test_no_msg_and_no_from_git(self) -> None:
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(main, [])
|
||||
assert result.exit_code != 0
|
||||
assert "commit_msg" in result.output
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
from click import ClickException
|
||||
from click.testing import CliRunner
|
||||
|
||||
import scripts.ci.push_badges as push_badges
|
||||
|
||||
|
||||
class TestGenerateBadges:
|
||||
def test_success(self, tmp_path: Path) -> None:
|
||||
output_dir = tmp_path / ".badges"
|
||||
output_dir.mkdir()
|
||||
(output_dir / "badge1.svg").touch()
|
||||
|
||||
with patch("subprocess.run") as mock_run:
|
||||
push_badges.generate_badges(str(output_dir))
|
||||
mock_run.assert_called_once()
|
||||
|
||||
def test_no_badges_generated(self, tmp_path: Path) -> None:
|
||||
output_dir = tmp_path / ".badges"
|
||||
output_dir.mkdir()
|
||||
|
||||
with patch("subprocess.run"):
|
||||
with pytest.raises(ClickException, match="No badge SVG files generated"):
|
||||
push_badges.generate_badges(str(output_dir))
|
||||
|
||||
|
||||
class TestPushToBadgesBranch:
|
||||
def test_success(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.chdir(tmp_path)
|
||||
badges_dir = tmp_path / ".badges"
|
||||
badges_dir.mkdir()
|
||||
svg = badges_dir / "badge1.svg"
|
||||
svg.write_text("<svg></svg>")
|
||||
|
||||
with patch("subprocess.run") as mock_run:
|
||||
push_badges.push_to_badges_branch(str(badges_dir))
|
||||
|
||||
# Should have called git config, checkout, rm, add, commit, push
|
||||
assert mock_run.call_count >= 6
|
||||
# Verify the SVG was copied to cwd
|
||||
assert (tmp_path / "badge1.svg").exists()
|
||||
|
||||
|
||||
class TestMain:
|
||||
def test_success(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.chdir(tmp_path)
|
||||
badges_dir = tmp_path / ".badges"
|
||||
badges_dir.mkdir()
|
||||
(badges_dir / "badge1.svg").touch()
|
||||
|
||||
runner = CliRunner()
|
||||
with patch("subprocess.run"):
|
||||
result = runner.invoke(push_badges.main, ["--output-dir", str(badges_dir)])
|
||||
assert result.exit_code == 0
|
||||
|
||||
def test_no_badges(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.chdir(tmp_path)
|
||||
badges_dir = tmp_path / ".badges"
|
||||
badges_dir.mkdir()
|
||||
|
||||
runner = CliRunner()
|
||||
with patch("subprocess.run"):
|
||||
result = runner.invoke(push_badges.main, ["--output-dir", str(badges_dir)])
|
||||
assert result.exit_code != 0
|
||||
@@ -0,0 +1,107 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
from click.testing import CliRunner
|
||||
|
||||
import scripts.setup as setup
|
||||
|
||||
|
||||
class TestRun:
|
||||
def test_run_success(self) -> None:
|
||||
with patch("subprocess.run") as mock_run:
|
||||
setup._run(["echo", "hello"], ".venv/bin")
|
||||
mock_run.assert_called_once_with(["echo", "hello"], check=True)
|
||||
|
||||
def test_run_failure(self) -> None:
|
||||
import subprocess
|
||||
|
||||
with patch("subprocess.run", side_effect=subprocess.CalledProcessError(1, ["echo"])):
|
||||
with pytest.raises(subprocess.CalledProcessError):
|
||||
setup._run(["echo", "hello"], ".venv/bin")
|
||||
|
||||
|
||||
class TestInstallPythonDeps:
|
||||
def test_install(self) -> None:
|
||||
with patch("scripts.setup._run") as mock_run:
|
||||
setup._install_python_deps(".venv/bin")
|
||||
mock_run.assert_called_once_with([".venv/bin/pip", "install", "-e", ".[dev]"], ".venv/bin")
|
||||
|
||||
|
||||
class TestInstallAnsibleCollections:
|
||||
def test_install(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.chdir(tmp_path)
|
||||
(tmp_path / "ansible").mkdir()
|
||||
(tmp_path / "ansible/requirements.yml").write_text("collections: []")
|
||||
with patch("scripts.setup._run") as mock_run:
|
||||
setup._install_ansible_collections(".venv/bin")
|
||||
mock_run.assert_called_once()
|
||||
|
||||
def test_no_requirements(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.chdir(tmp_path)
|
||||
with patch("scripts.setup._run") as mock_run:
|
||||
setup._install_ansible_collections(".venv/bin")
|
||||
mock_run.assert_not_called()
|
||||
|
||||
|
||||
class TestInstallPreCommitHooks:
|
||||
def test_install(self) -> None:
|
||||
with patch("scripts.setup._run") as mock_run:
|
||||
setup._install_pre_commit_hooks(".venv/bin")
|
||||
assert mock_run.call_count == 3
|
||||
calls = [c.args[0] for c in mock_run.call_args_list]
|
||||
# Each call should have the pre-commit binary and --hook-type flag
|
||||
for call in calls:
|
||||
assert ".venv/bin/pre-commit" in call[0]
|
||||
assert "--hook-type" in call
|
||||
|
||||
|
||||
class TestVerify:
|
||||
def test_verify_success(self) -> None:
|
||||
import subprocess
|
||||
|
||||
mock_result = subprocess.CompletedProcess(
|
||||
args=["grm", "--version"], returncode=0, stdout="grm 1.0.0", stderr=""
|
||||
)
|
||||
with patch("subprocess.run", return_value=mock_result):
|
||||
setup._verify(".venv/bin")
|
||||
|
||||
def test_verify_not_found(self) -> None:
|
||||
|
||||
with patch("subprocess.run", side_effect=FileNotFoundError()):
|
||||
setup._verify(".venv/bin")
|
||||
|
||||
def test_verify_timeout(self) -> None:
|
||||
import subprocess
|
||||
|
||||
with patch("subprocess.run", side_effect=subprocess.TimeoutExpired(cmd=["grm", "--version"], timeout=10)):
|
||||
setup._verify(".venv/bin")
|
||||
|
||||
|
||||
class TestMain:
|
||||
def test_bin_not_found(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.chdir(tmp_path)
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(setup.main, ["--bin", "nonexistent/bin"])
|
||||
assert result.exit_code != 0
|
||||
assert "Bin directory not found" in result.output
|
||||
|
||||
def test_success(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.chdir(tmp_path)
|
||||
bin_dir = tmp_path / ".venv" / "bin"
|
||||
bin_dir.mkdir(parents=True)
|
||||
(bin_dir / "grm").touch()
|
||||
(bin_dir / "pre-commit").touch()
|
||||
(tmp_path / "ansible").mkdir()
|
||||
(tmp_path / "ansible/requirements.yml").write_text("collections: []")
|
||||
|
||||
runner = CliRunner()
|
||||
with patch("scripts.setup._install_python_deps"):
|
||||
with patch("scripts.setup._install_ansible_collections"):
|
||||
with patch("scripts.setup._install_pre_commit_hooks"):
|
||||
with patch("scripts.setup._verify"):
|
||||
result = runner.invoke(setup.main, ["--bin", str(bin_dir)])
|
||||
assert result.exit_code == 0
|
||||
assert "Setup complete" in result.output
|
||||
Reference in New Issue
Block a user