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
7 changes: 7 additions & 0 deletions .github/labels.yml
Original file line number Diff line number Diff line change
Expand Up @@ -138,3 +138,10 @@
- name: "state/wont-fix"
description: "Issue has been closed and won't be fix/implemented."
color: "eeeeee"

# ----------------------------------
# CI
# ----------------------------------
- name: "ci/skip-changelog"
description: "No changelog fragment required"
color: "ededed"
106 changes: 106 additions & 0 deletions .github/workflows/changelog-check.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
---
# yamllint disable rule:truthy
name: Changelog

# A release is assembled from news fragments, so every pull request that
# changes behaviour must carry one. Failing here — rather than at release
# time, the way the shared platform does — puts the cost on the author while
# they still have the context, instead of on whoever cuts the release.
#
# Scoped to `main`: that is the only long-lived branch here, and the one
# releases are tagged from.
on:
pull_request:
types: [opened, synchronize, reopened, labeled, unlabeled]
branches: [main]

concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true

permissions:
contents: read
pull-requests: read

jobs:
fragment:
name: News fragment present
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4

- name: Require a news fragment
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
PR: ${{ github.event.pull_request.number }}
LABELS: ${{ join(github.event.pull_request.labels.*.name, ',') }}
AUTHOR: ${{ github.event.pull_request.user.login }}
run: |
set -euo pipefail

# Trivial changes (dependency bumps, typo fixes) opt out. The
# platform's rule is at least one fragment per *release*, not per
# PR, so skipping here still satisfies it.
case ",${LABELS}," in
*,ci/skip-changelog,*)
echo "ci/skip-changelog is set — no fragment required."
exit 0
;;
esac

# This repository has no .github/dependabot.yml yet, so nothing
# applies ci/skip-changelog to a bump automatically. Dependabot's
# PRs are dependency bumps by construction; exempt the author so
# enabling it later does not immediately block on this check.
# Compared as a string, not as a `case` pattern: `[bot]` in a glob
# is a character class and would not match the literal login. Only
# this one bot is exempt — humans still need a fragment.
if [ "${AUTHOR}" = "dependabot[bot]" ]; then
echo "Opened by ${AUTHOR} — dependency update, no fragment required."
exit 0
fi

# towncrier only assembles direct children of changelog/ whose name
# is <id>.<type>.md, where <type> is one of the types configured in
# pyproject.toml. "+" marks an orphan fragment (no issue number) and
# the optional numeric segment is the counter towncrier appends when
# a name collides. Anything else — a nested path, a missing or
# unknown type — is ignored at release time, so it must fail here
# rather than silently vanish from the changelog.
#
# `[^/]+` is deliberately permissive. towncrier scans the
# dot-separated parts of the name from the right and takes the last
# one that is a configured type as the category, folding everything
# before it into the issue id. `123.unknown.fixed.md` is therefore a
# real `fixed` fragment whose id is `123.unknown`, so narrowing this
# would reject a file that does reach the changelog.
TYPES="security|removed|deprecated|added|changed|fixed|housekeeping"
FRAGMENT="^changelog/[^/]+\.(${TYPES})(\.[0-9]+)?\.md$"

# Ask the API which files the PR adds rather than diffing locally:
# no dependence on checkout depth or on which commit is HEAD. The
# REST endpoint is the paginated one: `gh pr view --json files`
# returns only the first page, so a fragment in a large pull
# request would go unseen and the check would fail a PR that has
# one. Note the REST field is `.filename`, not `.path`.
ADDED=$(gh api --paginate \
"repos/${GITHUB_REPOSITORY}/pulls/${PR}/files?per_page=100" \
--jq '.[] | select(.additions > 0) | .filename' \
| { grep -E "${FRAGMENT}" || true; } \
| { grep -v '^changelog/towncrier\.md\.template$' || true; })

if [ -z "${ADDED}" ]; then
MSG="No news fragment found. Add one directly under changelog/,"
MSG="${MSG} named <id>.<type>.md, e.g. uv run --group dev"
MSG="${MSG} towncrier create -c"
MSG="${MSG} \"What changed\" ${PR}.fixed.md — types: security,"
MSG="${MSG} removed, deprecated, added, changed, fixed,"
MSG="${MSG} housekeeping. Nested paths and unknown types are not"
MSG="${MSG} read by towncrier. Label the PR ci/skip-changelog if"
MSG="${MSG} it genuinely needs no entry."
echo "::error::${MSG}"
exit 1
fi

echo "Found news fragment(s):"
echo "${ADDED}"
7 changes: 6 additions & 1 deletion .markdownlintignore
Original file line number Diff line number Diff line change
@@ -1,3 +1,8 @@
docs/node_modules/
.venv/
docs/docs/reference
docs/docs/reference

# towncrier news fragments are prose snippets rather than standalone
# documents: they carry no top-level heading (MD041) because towncrier
# splices them under generated section headings at release time.
changelog/
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
# Changelog

This project uses [*towncrier*](https://towncrier.readthedocs.io/) and the changes for the upcoming release can be found in <https://github.com/opsmill/schema-library/tree/main/changelog>.

<!-- towncrier release notes start -->
5 changes: 5 additions & 0 deletions changelog/+towncrier.housekeeping.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
The changelog is now assembled from news fragments with [towncrier](https://towncrier.readthedocs.io/).
Add a file under `changelog/` named `<id>.<type>.md` describing your change, where `<type>` is one of
`security`, `removed`, `deprecated`, `added`, `changed`, `fixed` or `housekeeping`. A CI check fails a
pull request that carries none, unless it is labelled `ci/skip-changelog`. Assembling the fragments
into `CHANGELOG.md` at release time is a follow-up; existing releases are not back-filled.
1 change: 1 addition & 0 deletions changelog/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
!.gitignore
53 changes: 53 additions & 0 deletions changelog/towncrier.md.template
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
{% if render_title %}
{% if versiondata.name %}
# {{ versiondata.name }} {{ versiondata.version }} ({{ versiondata.date }})
{% else %}
# {{ versiondata.version }} ({{ versiondata.date }})
{% endif %}
{% endif %}
{% for section, _ in sections.items() %}
{% if section %}

## {{section}}
{% endif %}
{% if sections[section] %}
{% for category, val in definitions.items() if category in sections[section] %}
### {{ definitions[category]['name'] }}

{% for text, values in sections[section][category].items() %}
- {{ text }}
{%- if values %}
{% if "\n - " in text or '\n * ' in text %}


(
{%- else %}
{% if text %} ({% endif %}
{%- endif -%}
{%- for issue in values %}
{{ issue.split(": ", 1)[0] }}{% if not loop.last %}, {% endif %}
{%- endfor %}
{% if text %}){% endif %}

{% else %}

{% endif %}
{% endfor %}

{% if issues_by_category[section][category] and "]: " in issues_by_category[section][category][0] %}
{% for issue in issues_by_category[section][category] %}
{{ issue }}
{% endfor %}

{% endif %}
{% if sections[section][category]|length == 0 %}
No significant changes.

{% else %}
{% endif %}
{% endfor %}
{% else %}
No significant changes.

{% endif %}
{% endfor +%}
52 changes: 52 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ dev = [
"pylint>=4.0.4",
"vale>=3.13.0.0",
"types-pyyaml>=6.0.12.20250915",
"towncrier>=24.8,<27",
# Covers the docs-generation helpers in tasks/, whose escaping rules are
# subtle enough to regress without being noticed until a docs build fails.
"pytest>=8.3.0",
Expand Down Expand Up @@ -62,3 +63,54 @@ quote-style = "double"
indent-style = "space"
skip-magic-trailing-comma = false
line-ending = "auto"

[tool.towncrier]
# `schema-library` is a repository of schema files, not an importable package
# exposing `__version__`, and `[project] version` is unrelated to the published
# tags. `package` is therefore intentionally omitted; pass `--version X.Y.Z` to
# `towncrier build` at release time.
directory = "changelog"
filename = "CHANGELOG.md"
start_string = "<!-- towncrier release notes start -->\n"
underlines = ["", "", ""]
# Releases are tagged `vX.Y.Z` here, so the tree link carries the `v` while
# `--version` is passed without it.
title_format = "## [Schema Library - v{version}](https://github.com/opsmill/schema-library/tree/v{version}) - {project_date}"
issue_format = "[#{issue}](https://github.com/opsmill/schema-library/issues/{issue})"
orphan_prefix = "+"
template = "changelog/towncrier.md.template"

[[tool.towncrier.type]]
directory = "security"
name = "Security"
showcontent = true

[[tool.towncrier.type]]
directory = "removed"
name = "Removed"
showcontent = true

[[tool.towncrier.type]]
directory = "deprecated"
name = "Deprecated"
showcontent = true

[[tool.towncrier.type]]
directory = "added"
name = "Added"
showcontent = true

[[tool.towncrier.type]]
directory = "changed"
name = "Changed"
showcontent = true

[[tool.towncrier.type]]
directory = "fixed"
name = "Fixed"
showcontent = true

[[tool.towncrier.type]]
directory = "housekeeping"
name = "Housekeeping"
showcontent = true
16 changes: 16 additions & 0 deletions uv.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading