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: 3 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -361,10 +361,6 @@ path, fragment directory, and Markdown or RST format from committed Towncrier co
`read-towncrier-config`. It does not commit or push. Version selection, fragment linking, the Towncrier build, and
formatting must all succeed.

After the Towncrier build, the Action runs release-creator's step that adds a "New Contributors" subsection to the new
changelog section with `release-creator contributors`, which release-creator 3.1.0 adds. The caller's `release-prepare`
group needs release-creator 3.1.0 or later.

```yaml
- id: prepare
uses: tox-dev/github-workflows/prepare-towncrier@SHA
Expand Down Expand Up @@ -531,6 +527,9 @@ the asset recheck.

- `tag`, `commit`, `title`, and `notes-file` are required. The Action uses the caller repository; `commit` is the tag's
commit, used as the target when the Action creates the Release.
- Before it writes a draft, the Action asks GitHub to generate notes for the range from the previous tag in the notes'
`Full Changelog` link to `tag`, and adds GitHub's first-time contributors, bots excluded, as a `New Contributors`
section above the trailing links. The changelog file never carries this list. A published Release is left as it is.
- `assets-directory`: Optional directory of the assets to attach. The Action compares each filename and SHA256 digest
with GitHub's Release asset metadata, uploads missing draft assets, and rejects extra or changed assets. After
publication it verifies GitHub's release attestation for each local asset.
Expand Down
3 changes: 3 additions & 0 deletions changelog.d/+release-new-contributors.change.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
`publish-github-release` credits first-time contributors on the GitHub Release, taking the list from GitHub's generated
notes for the range since the previous tag and placing it above the package and comparison links. `prepare-towncrier` no
longer adds a New Contributors subsection to the changelog.
13 changes: 1 addition & 12 deletions prepare-towncrier/action.yml
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@ runs:
set -euo pipefail
if [[ "$PHASE" == finalize ]]; then
echo "Release version: $VERSION (from the version input in the finalize phase)"
echo 'Fragment linking, changelog build and contributors skipped: the build phase did them'
echo 'Fragment linking and changelog build skipped: the build phase did them'
printf 'version=%s\n' "$VERSION" >>"$GITHUB_OUTPUT"
exit 0
fi
Expand Down Expand Up @@ -77,17 +77,6 @@ runs:
set -euo pipefail
echo "Changelog version: $CHANGELOG_PREFIX${VERSION#v} (tag $VERSION${CHANGELOG_PREFIX:+, changelog-prefix $CHANGELOG_PREFIX})"
towncrier build --yes --version "$CHANGELOG_PREFIX${VERSION#v}"
- name: 📝 Add new contributors to the changelog
if: inputs.phase != 'finalize'
shell: bash
env:
VERSION: ${{ steps.version.outputs.version }}
CHANGELOG_PREFIX: ${{ inputs.changelog-prefix }}
CHANGELOG: ${{ steps.config.outputs.changelog }}
REPOSITORY: ${{ github.repository }}
GH_TOKEN: ${{ github.token }}
run: >-
release-creator contributors --changelog "$CHANGELOG" --version "$CHANGELOG_PREFIX${VERSION#v}" --repository "$REPOSITORY"
- name: 🧹 Format changelog
if: inputs.phase != 'build'
shell: bash
Expand Down
31 changes: 27 additions & 4 deletions publish-github-release/action.yml
Original file line number Diff line number Diff line change
Expand Up @@ -75,30 +75,53 @@ runs:
echo "GitHub Release $TAG is published and immutable with the expected assets; nothing to upload or publish"
fi
printf 'immutable=%s\n' "$immutable" >>"$GITHUB_OUTPUT"
- name: 👋 Credit new contributors
shell: bash
env:
GH_REPO: ${{ github.repository }}
TAG: ${{ inputs.tag }}
COMMIT: ${{ inputs.commit }}
NOTES_FILE: ${{ inputs.notes-file }}
run: |
set -euo pipefail
if jq -e '.[0].draft == false' "$RUNNER_TEMP/release-existing.json" >/dev/null; then
echo "New Contributors skipped: GitHub Release $TAG is already published"
cp "$NOTES_FILE" "$RUNNER_TEMP/release-body.md"
exit 0
fi
# The comparison link that release-creator renders names the previous tag GitHub should count from.
previous=$(grep -oP "/compare/\K[^)]+(?=\.\.\.\Q$TAG\E\))" "$NOTES_FILE" | head -n 1 || true)
request=(-f "tag_name=$TAG" -f "target_commitish=$COMMIT")
if [[ -n "$previous" ]]; then
request+=(-f "previous_tag_name=$previous")
fi
echo "Generated notes range: ${previous:-first release}...$TAG"
gh api -X POST "repos/$GH_REPO/releases/generate-notes" "${request[@]}" --jq .body >"$RUNNER_TEMP/generated-notes.md"
python3 "$GITHUB_ACTION_PATH/../tools/new_contributors.py" \
"$NOTES_FILE" "$RUNNER_TEMP/generated-notes.md" "$RUNNER_TEMP/release-body.md"
- name: 🚀 Create or update the draft
shell: bash
env:
GH_REPO: ${{ github.repository }}
TAG: ${{ inputs.tag }}
COMMIT: ${{ inputs.commit }}
TITLE: ${{ inputs.title }}
NOTES_FILE: ${{ inputs.notes-file }}
PRERELEASE: ${{ inputs.prerelease }}
run: |
set -euo pipefail
if jq -e 'length == 0' "$RUNNER_TEMP/release-existing.json" >/dev/null; then
jq -n --arg tag "$TAG" --arg commit "$COMMIT" --arg title "$TITLE" \
--rawfile body "$NOTES_FILE" --argjson prerelease "$PRERELEASE" \
--rawfile body "$RUNNER_TEMP/release-body.md" --argjson prerelease "$PRERELEASE" \
'{tag_name: $tag, target_commitish: $commit, name: $title, body: $body,
draft: true, prerelease: $prerelease}' >"$RUNNER_TEMP/release-request.json"
gh api -X POST "repos/$GH_REPO/releases" --input "$RUNNER_TEMP/release-request.json" \
>"$RUNNER_TEMP/release-created.json"
jq -er '.id | select(type == "number")' "$RUNNER_TEMP/release-created.json" >"$RUNNER_TEMP/release-id"
echo "GitHub Release $TAG: created draft $(<"$RUNNER_TEMP/release-id") for $COMMIT (prerelease $PRERELEASE)"
elif jq -e --arg title "$TITLE" --rawfile body "$NOTES_FILE" --argjson prerelease "$PRERELEASE" \
elif jq -e --arg title "$TITLE" --rawfile body "$RUNNER_TEMP/release-body.md" --argjson prerelease "$PRERELEASE" \
'.[0].draft and (.[0].name != $title or .[0].body != $body or .[0].prerelease != $prerelease)' \
"$RUNNER_TEMP/release-existing.json" >/dev/null; then
jq -n --arg title "$TITLE" --rawfile body "$NOTES_FILE" --argjson prerelease "$PRERELEASE" \
jq -n --arg title "$TITLE" --rawfile body "$RUNNER_TEMP/release-body.md" --argjson prerelease "$PRERELEASE" \
'{name: $title, body: $body, prerelease: $prerelease}' >"$RUNNER_TEMP/release-update.json"
gh api -X PATCH "repos/$GH_REPO/releases/$(<"$RUNNER_TEMP/release-id")" \
--input "$RUNNER_TEMP/release-update.json" \
Expand Down
55 changes: 19 additions & 36 deletions tests/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,6 @@

import json
import os
import re
import shutil
import ssl
import subprocess
Expand Down Expand Up @@ -139,45 +138,29 @@ def repository(tmp_path: Path, github_api: tuple[HTTPServer, dict[str, str]], mo

def graphql(request: Request) -> Response:
query = request.get_json()["query"]
if 'object(oid: "' in query:
commits = (part.split('"', 1)[0] for part in query.split('object(oid: "')[1:])
data: dict[str, object] = {
"repository": {
f"c{index}": {
"associatedPullRequests": {
"nodes": [
{
"number": int(environment.get("TEST_PULL_NUMBER", "2")),
"mergedAt": "2026-10-22T00:00:00Z",
"mergeCommit": {
"oid": commit,
"authors": {
"nodes": [
{"name": "Release test", "email": "a@example.org", "user": author}
]
},
commits = (part.split('"', 1)[0] for part in query.split('object(oid: "')[1:])
data: dict[str, object] = {
"repository": {
f"c{index}": {
"associatedPullRequests": {
"nodes": [
{
"number": int(environment.get("TEST_PULL_NUMBER", "2")),
"mergedAt": "2026-10-22T00:00:00Z",
"mergeCommit": {
"oid": commit,
"authors": {
"nodes": [{"name": "Release test", "email": "a@example.org", "user": author}]
},
"author": author,
}
]
}
},
"author": author,
}
]
}
for index, commit in enumerate(commits)
}
for index, commit in enumerate(commits)
}
elif "pullRequest(number: " in query:
numbers = re.findall(r"p(\d+): pullRequest", query)
data = {
"repository": {
f"p{number}": {"number": int(number), "mergedAt": "2026-10-22T00:00:00Z", "author": author}
for number in numbers
}
}
else:
data = {
alias: {"issueCount": int(environment.get("TEST_PRIOR_PULLS", "0"))}
for alias in re.findall(r"(s\d+): search", query)
}
}
return Response(json.dumps({"data": data}), mimetype="application/json")

server.expect_request("/api/graphql", method="POST").respond_with_handler(graphql)
Expand Down
30 changes: 30 additions & 0 deletions tests/new_contributors/test_new_contributors.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
from __future__ import annotations

import runpy
import sys
from pathlib import Path

import pytest

_TOOL = Path(__file__).resolve().parents[2] / "tools/new_contributors.py"


def test_new_contributors_lead_a_body_of_links_only(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
) -> None:
notes, generated, output = tmp_path / "notes.md", tmp_path / "generated.md", tmp_path / "body.md"
notes.write_text("[📜 Full Changelog](https://github.com/tox-dev/fixture/commits/0.1.0)\n")
generated.write_text("## New Contributors\n* @alice made their first contribution in https://example.org/pull/1\n")
monkeypatch.setattr(sys, "argv", ["new_contributors.py", str(notes), str(generated), str(output)])

runpy.run_path(str(_TOOL), run_name="__main__")

assert output.read_text() == (
"### New Contributors\n\n- @alice made their first contribution in https://example.org/pull/1\n\n"
"[📜 Full Changelog](https://github.com/tox-dev/fixture/commits/0.1.0)\n"
)
assert capsys.readouterr().out == "New Contributors: @alice\n"


def test_new_contributors_module_imports_without_running() -> None:
assert runpy.run_path(str(_TOOL))["__all__"] == ["main"]
22 changes: 6 additions & 16 deletions tests/prepare_towncrier/test_prepare.py
Original file line number Diff line number Diff line change
Expand Up @@ -98,7 +98,6 @@ def test_prepare_custom_fragment_directory(
def test_prepare_renders_github_notes(
repository: Path, tmp_path: Path, action_scripts: Path, github_api: tuple[HTTPServer, dict[str, str]]
) -> None:
github_api[1]["TEST_PRIOR_PULLS"] = "1"
result = action(
repository,
tmp_path,
Expand Down Expand Up @@ -171,7 +170,6 @@ def test_prepare_preserves_prefixed_changelog_heading(repository: Path, tmp_path
def test_prepare_links_named_fragments(
repository: Path, tmp_path: Path, action_scripts: Path, github_api: tuple[HTTPServer, dict[str, str]]
) -> None:
github_api[1]["TEST_PRIOR_PULLS"] = "1"
result = action(
repository,
tmp_path,
Expand Down Expand Up @@ -322,20 +320,12 @@ def _author_lookup(github_api: tuple[HTTPServer, dict[str, str]], number: int) -
return environment


@pytest.mark.parametrize(
("prior_pulls", "credited"), [pytest.param("0", True, id="first-pull"), pytest.param("1", False, id="returning")]
)
def test_prepare_adds_new_contributors_after_building(
repository: Path,
tmp_path: Path,
action_scripts: Path,
github_api: tuple[HTTPServer, dict[str, str]],
prior_pulls: str,
credited: bool,
def test_prepare_leaves_new_contributors_out_of_the_changelog(
repository: Path, tmp_path: Path, action_scripts: Path, github_api: tuple[HTTPServer, dict[str, str]]
) -> None:
github_api[1]["TEST_PRIOR_PULLS"] = prior_pulls

result = action(repository, tmp_path, action_scripts, "prepare-towncrier", version="0.2.0", PHASE="build")
result = action(
repository, tmp_path, action_scripts, "prepare-towncrier", version="0.2.0", PHASE="build", **github_api[1]
)

assert result.returncode == 0, result.stderr
assert ("### New Contributors" in (repository / "CHANGELOG.md").read_text()) is credited
assert "New Contributors" not in (repository / "CHANGELOG.md").read_text()
Loading