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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
48 changes: 48 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -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
22 changes: 18 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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`.
18 changes: 18 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
@@ -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"]
4 changes: 2 additions & 2 deletions scripts/make_new_release.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
17 changes: 11 additions & 6 deletions scripts/set-workflows.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down
8 changes: 4 additions & 4 deletions templates/cliff.toml
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
# Copy to <repo>/cliff.toml and replace REPO with the repository name.
# Copy to <repo>/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.
#
Expand All @@ -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 = [
Expand Down Expand Up @@ -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 %}
Expand Down
32 changes: 32 additions & 0 deletions tests/conftest.py
Original file line number Diff line number Diff line change
@@ -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
12 changes: 12 additions & 0 deletions tests/test_make_new_release.py
Original file line number Diff line number Diff line change
@@ -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"
20 changes: 20 additions & 0 deletions tests/test_pins.py
Original file line number Diff line number Diff line change
@@ -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 = "(.+)"')
)
38 changes: 38 additions & 0 deletions tests/test_set_workflows.py
Original file line number Diff line number Diff line change
@@ -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()