From 92ab99908af8afe819e58ed25cfd827f8cce1a0c Mon Sep 17 00:00:00 2001 From: holger krekel Date: Tue, 1 Sep 2026 21:28:01 +0200 Subject: [PATCH 1/2] feat: support arbitrary owners, not just chatmail org repos --- README.md | 8 ++++---- scripts/make_new_release.py | 4 ++-- scripts/set-workflows.sh | 17 +++++++++++------ templates/cliff.toml | 8 ++++---- 4 files changed, 21 insertions(+), 16 deletions(-) diff --git a/README.md b/README.md index c566011..7ab0295 100644 --- a/README.md +++ b/README.md @@ -71,10 +71,10 @@ tox is not used. ../workflows/scripts/set-workflows.sh - It installs `.github/workflows/ci.yml`, `.github/workflows/release.yml` - and `cliff.toml` with the repository name substituted, and stages - them. It asks nothing, so review with `git status` and - `git diff --cached`. + It installs `.github/workflows/ci.yml`, `.github/workflows/release.yml` and `cliff.toml`, + putting the owner and repository name that `origin` names into the changelog links, + and stages the files. A repository outside the chatmail organization is set up the same way. + The script asks nothing, so review with `git status` and `git diff --cached`. ci.yml is boilerplate and is overwritten on every run, so re-run the script whenever the templates change here. release.yml and cliff.toml diff --git a/scripts/make_new_release.py b/scripts/make_new_release.py index 6d6898b..4e2e182 100755 --- a/scripts/make_new_release.py +++ b/scripts/make_new_release.py @@ -181,8 +181,8 @@ def check_ready_to_release(): """Refuses to start a release the repository cannot carry through.""" if not Path("cliff.toml").exists(): print("Error: no cliff.toml, which git-cliff needs to write the") - print("changelog. Copy one from a sibling repository and adapt the") - print("repository URL in its commit_preprocessors.") + print("changelog. Install one by running scripts/set-workflows.sh") + print("from the workflows repository here.") sys.exit(1) if ( run(["git", "diff", "--quiet"]) != 0 diff --git a/scripts/set-workflows.sh b/scripts/set-workflows.sh index 212fe8c..696b200 100755 --- a/scripts/set-workflows.sh +++ b/scripts/set-workflows.sh @@ -23,10 +23,13 @@ if [ ! -f pyproject.toml ]; then fi remote=$(git remote get-url origin 2>/dev/null || echo "") -case "$remote" in - *github.com*) name=$(basename "${remote%.git}") ;; - *) name=$(basename "$repo") ;; -esac +slug=$(printf '%s\n' "${remote%.git}" | sed -n 's#.*github\.com[:/]##p') + +if [ -z "$slug" ]; then + echo "origin is not a github.com repository: ${remote:-none}" >&2 + echo "cliff.toml needs owner/name for the links in its changelog" >&2 + exit 1 +fi tmp=$(mktemp -d) trap 'rm -rf "$tmp"' EXIT @@ -51,12 +54,14 @@ install_once() { install "$src" "$dst" } -echo "$name:" +echo "$slug:" install "$shared/templates/ci.yml" .github/workflows/ci.yml install_once "$shared/templates/release.yml" .github/workflows/release.yml -sed "s/REPO/$name/g" "$shared/templates/cliff.toml" >"$tmp/cliff.toml" +# The first line tells a human to do by hand what the script does. +sed -e "/^# Copy to /d" -e "s|github.com/OWNER/REPO|github.com/$slug|g" \ + "$shared/templates/cliff.toml" >"$tmp/cliff.toml" install_once "$tmp/cliff.toml" cliff.toml echo diff --git a/templates/cliff.toml b/templates/cliff.toml index a016868..c65927c 100644 --- a/templates/cliff.toml +++ b/templates/cliff.toml @@ -1,4 +1,4 @@ -# Copy to /cliff.toml and replace REPO with the repository name. +# Copy to /cliff.toml, replacing OWNER/REPO with owner and name. # Used by chatmail/workflow's scripts/make_new_release.py to generate the # CHANGELOG.md entry from Conventional Commits. # @@ -16,7 +16,7 @@ filter_unconventional = false split_commits = false # regex for preprocessing the commit messages commit_preprocessors = [ - { pattern = '\((\w+\s)?#([0-9]+)\)', replace = "([#${2}](https://github.com/chatmail/REPO/pull/${2}))"}, # replace pull request / issue numbers + { pattern = '\((\w+\s)?#([0-9]+)\)', replace = "([#${2}](https://github.com/OWNER/REPO/pull/${2}))"}, # replace pull request / issue numbers ] # regex for parsing and grouping commits commit_parsers = [ @@ -78,11 +78,11 @@ footer = """ {% if release.version -%} {% if release.previous.version -%} [{{ release.version | trim_start_matches(pat="v") }}]: \ - https://github.com/chatmail/REPO\ + https://github.com/OWNER/REPO\ /compare/{{ release.previous.version }}..{{ release.version }} {% endif -%} {% else -%} - [unreleased]: https://github.com/chatmail/REPO\ + [unreleased]: https://github.com/OWNER/REPO\ /compare/{{ release.previous.version }}..HEAD {% endif -%} {% endfor %} From ddbde8db0d974b21454e03889d516aa2224668ea Mon Sep 17 00:00:00 2001 From: holger krekel Date: Tue, 1 Sep 2026 22:51:19 +0200 Subject: [PATCH 2/2] test: add CI and some basic tests --- .github/workflows/ci.yml | 48 ++++++++++++++++++++++++++++++++++ README.md | 14 ++++++++++ pyproject.toml | 18 +++++++++++++ tests/conftest.py | 32 +++++++++++++++++++++++ tests/test_make_new_release.py | 12 +++++++++ tests/test_pins.py | 20 ++++++++++++++ tests/test_set_workflows.py | 38 +++++++++++++++++++++++++++ 7 files changed, 182 insertions(+) create mode 100644 .github/workflows/ci.yml create mode 100644 pyproject.toml create mode 100644 tests/conftest.py create mode 100644 tests/test_make_new_release.py create mode 100644 tests/test_pins.py create mode 100644 tests/test_set_workflows.py diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..e9d459a --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,48 @@ +# CI for this repository itself. py-checks cannot serve here: it builds +# and publishes a Python package, and this repository has none. + +name: CI + +on: + push: + branches: [ main ] + tags-ignore: [ '**' ] + pull_request: + branches: [ main ] + +permissions: + contents: read + +concurrency: + group: ci-${{ github.ref }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} + +# tests/test_pins.py keeps this equal to the other two pins. +env: + RUFF_VERSION: "0.16.0" + +jobs: + checks: + name: ruff, shellcheck and tests + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v7 + with: + persist-credentials: false + + - uses: astral-sh/setup-uv@v9.0.0 + with: + python-version: "3.11" + + - name: Ruff lint + run: uvx ruff@$RUFF_VERSION check . + + - name: Ruff format check + run: uvx ruff@$RUFF_VERSION format --check . + + # shellcheck comes with the runner image. + - name: Shellcheck + run: shellcheck scripts/set-workflows.sh + + - name: Run tests + run: uvx pytest diff --git a/README.md b/README.md index 7ab0295..115244a 100644 --- a/README.md +++ b/README.md @@ -150,3 +150,17 @@ For now, consumers reference `py-checks.yml@main` so refinements propagate immed The ruff version is pinned in py-checks.yml and bumped centrally there. Actions are pinned to their major tags, except `astral-sh/setup-uv`, which publishes no moving major tag and is pinned to an exact version. +The release script and the CI of this repository carry the same pin, +and `tests/test_pins.py` fails when the three drift apart. + + +## Developing this repository + +py-checks does not apply here: this repository ships templates and +scripts, not a package. Its own `ci.yml` runs ruff, shellcheck on +`set-workflows.sh`, and a pytest suite that installs the templates +into fixture repositories and checks what comes out of them. + +Run the same checks locally with `uvx ruff check .`, +`uvx ruff format --check .`, `shellcheck scripts/set-workflows.sh` +and `uvx pytest`. diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..5b73fc9 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,18 @@ +# Not a package: only tool configuration lives here. + +[tool.ruff] +lint.select = [ + "F", # Pyflakes + "I", # isort + + "PLC", # Pylint Convention + "PLE", # Pylint Error + "PLW", # Pylint Warning +] +lint.ignore = [ + "PLC0415" # import-outside-top-level +] + +[tool.pytest.ini_options] +addopts = "-v -ra --strict-markers" +testpaths = ["tests"] diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..518636d --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,32 @@ +import subprocess +import sys +from pathlib import Path + +import pytest + +REPO = Path(__file__).resolve().parent.parent +sys.path.insert(0, str(REPO / "scripts")) + + +@pytest.fixture +def make_repo(tmp_path): + """Returns a factory for a git repository with one commit.""" + + def make(origin=None): + path = tmp_path / "project" + path.mkdir() + (path / "pyproject.toml").write_text("[project]\n") + for args in [ + ["init", "-q", "."], + ["config", "user.email", "t@example.org"], + ["config", "user.name", "t"], + ["commit", "-q", "--allow-empty", "-m", "first"], + ]: + subprocess.run(["git", *args], cwd=path, check=True) + if origin: + subprocess.run( + ["git", "remote", "add", "origin", origin], cwd=path, check=True + ) + return path + + return make diff --git a/tests/test_make_new_release.py b/tests/test_make_new_release.py new file mode 100644 index 0000000..85446e3 --- /dev/null +++ b/tests/test_make_new_release.py @@ -0,0 +1,12 @@ +import subprocess + +import make_new_release as release + + +def test_latest_release_tag_is_the_highest_version(make_repo, monkeypatch): + """Numeric order, and tags from before the v prefix still count.""" + path = make_repo() + for name in ["0.9.0", "v0.10.0", "nightly"]: + subprocess.run(["git", "tag", name], cwd=path, check=True) + monkeypatch.chdir(path) + assert release.get_latest_release_tag() == "v0.10.0" diff --git a/tests/test_pins.py b/tests/test_pins.py new file mode 100644 index 0000000..f1009a6 --- /dev/null +++ b/tests/test_pins.py @@ -0,0 +1,20 @@ +import re +from pathlib import Path + +REPO = Path(__file__).resolve().parent.parent + + +def pinned(path, pattern): + """Returns the ruff version the file pins.""" + match = re.search(pattern, (REPO / path).read_text()) + assert match, f"no pin matching {pattern!r} in {path}" + return match.group(1) + + +def test_ruff_is_pinned_to_one_version(): + """py-checks, the CI of this repository and the release script agree.""" + assert ( + pinned(".github/workflows/py-checks.yml", r'RUFF_VERSION: "(.+)"') + == pinned(".github/workflows/ci.yml", r'RUFF_VERSION: "(.+)"') + == pinned("scripts/make_new_release.py", r'RUFF_VERSION = "(.+)"') + ) diff --git a/tests/test_set_workflows.py b/tests/test_set_workflows.py new file mode 100644 index 0000000..1a57186 --- /dev/null +++ b/tests/test_set_workflows.py @@ -0,0 +1,38 @@ +import subprocess +from pathlib import Path + +import pytest + +SCRIPT = Path(__file__).resolve().parent.parent / "scripts" / "set-workflows.sh" + + +def install(path): + """Runs set-workflows.sh in the repository and returns the result.""" + return subprocess.run( + [str(SCRIPT)], cwd=path, capture_output=True, text=True, check=False + ) + + +@pytest.mark.parametrize( + "origin,slug", + [ + ("git@github.com:hpk42/cmtop.git", "hpk42/cmtop"), + ("https://github.com/chatmail/cmping.git", "chatmail/cmping"), + ("https://github.com/acme/tool", "acme/tool"), + ], +) +def test_changelog_links_point_at_origin(make_repo, origin, slug): + path = make_repo(origin) + assert install(path).returncode == 0 + cliff = (path / "cliff.toml").read_text() + assert f"https://github.com/{slug}/pull/" in cliff + assert f"https://github.com/{slug}\\" in cliff + assert "OWNER/REPO" not in cliff + assert "# Copy to" not in cliff + + +@pytest.mark.parametrize("origin", [None, "git@codeberg.org:someone/thing.git"]) +def test_origin_without_a_github_path_is_refused(make_repo, origin): + path = make_repo(origin) + assert install(path).returncode == 1 + assert not (path / "cliff.toml").exists()