Skip to content

Commit 3f3cd37

Browse files
committed
✨ feat(release): credit new contributors on releases only
1 parent a135eae commit 3f3cd37

9 files changed

Lines changed: 213 additions & 87 deletions

File tree

‎README.md‎

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -361,10 +361,6 @@ path, fragment directory, and Markdown or RST format from committed Towncrier co
361361
`read-towncrier-config`. It does not commit or push. Version selection, fragment linking, the Towncrier build, and
362362
formatting must all succeed.
363363

364-
After the Towncrier build, the Action runs release-creator's step that adds a "New Contributors" subsection to the new
365-
changelog section with `release-creator contributors`, which release-creator 3.1.0 adds. The caller's `release-prepare`
366-
group needs release-creator 3.1.0 or later.
367-
368364
```yaml
369365
- id: prepare
370366
uses: tox-dev/github-workflows/prepare-towncrier@SHA
@@ -531,6 +527,9 @@ the asset recheck.
531527

532528
- `tag`, `commit`, `title`, and `notes-file` are required. The Action uses the caller repository; `commit` is the tag's
533529
commit, used as the target when the Action creates the Release.
530+
- Before it writes a draft, the Action asks GitHub to generate notes for the range from the previous tag in the notes'
531+
`Full Changelog` link to `tag`, and adds GitHub's first-time contributors, bots excluded, as a `New Contributors`
532+
section above the trailing links. The changelog file never carries this list. A published Release is left as it is.
534533
- `assets-directory`: Optional directory of the assets to attach. The Action compares each filename and SHA256 digest
535534
with GitHub's Release asset metadata, uploads missing draft assets, and rejects extra or changed assets. After
536535
publication it verifies GitHub's release attestation for each local asset.
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
`publish-github-release` credits first-time contributors on the GitHub Release, taking the list from GitHub's generated
2+
notes for the range since the previous tag and placing it above the package and comparison links. `prepare-towncrier` no
3+
longer adds a New Contributors subsection to the changelog.

‎prepare-towncrier/action.yml‎

Lines changed: 1 addition & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,7 @@ runs:
3939
set -euo pipefail
4040
if [[ "$PHASE" == finalize ]]; then
4141
echo "Release version: $VERSION (from the version input in the finalize phase)"
42-
echo 'Fragment linking, changelog build and contributors skipped: the build phase did them'
42+
echo 'Fragment linking and changelog build skipped: the build phase did them'
4343
printf 'version=%s\n' "$VERSION" >>"$GITHUB_OUTPUT"
4444
exit 0
4545
fi
@@ -77,17 +77,6 @@ runs:
7777
set -euo pipefail
7878
echo "Changelog version: $CHANGELOG_PREFIX${VERSION#v} (tag $VERSION${CHANGELOG_PREFIX:+, changelog-prefix $CHANGELOG_PREFIX})"
7979
towncrier build --yes --version "$CHANGELOG_PREFIX${VERSION#v}"
80-
- name: 📝 Add new contributors to the changelog
81-
if: inputs.phase != 'finalize'
82-
shell: bash
83-
env:
84-
VERSION: ${{ steps.version.outputs.version }}
85-
CHANGELOG_PREFIX: ${{ inputs.changelog-prefix }}
86-
CHANGELOG: ${{ steps.config.outputs.changelog }}
87-
REPOSITORY: ${{ github.repository }}
88-
GH_TOKEN: ${{ github.token }}
89-
run: >-
90-
release-creator contributors --changelog "$CHANGELOG" --version "$CHANGELOG_PREFIX${VERSION#v}" --repository "$REPOSITORY"
9180
- name: 🧹 Format changelog
9281
if: inputs.phase != 'build'
9382
shell: bash

‎publish-github-release/action.yml‎

Lines changed: 27 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -75,30 +75,53 @@ runs:
7575
echo "GitHub Release $TAG is published and immutable with the expected assets; nothing to upload or publish"
7676
fi
7777
printf 'immutable=%s\n' "$immutable" >>"$GITHUB_OUTPUT"
78+
- name: 👋 Credit new contributors
79+
shell: bash
80+
env:
81+
GH_REPO: ${{ github.repository }}
82+
TAG: ${{ inputs.tag }}
83+
COMMIT: ${{ inputs.commit }}
84+
NOTES_FILE: ${{ inputs.notes-file }}
85+
run: |
86+
set -euo pipefail
87+
if jq -e '.[0].draft == false' "$RUNNER_TEMP/release-existing.json" >/dev/null; then
88+
echo "New Contributors skipped: GitHub Release $TAG is already published"
89+
cp "$NOTES_FILE" "$RUNNER_TEMP/release-body.md"
90+
exit 0
91+
fi
92+
# The comparison link that release-creator renders names the previous tag GitHub should count from.
93+
previous=$(grep -oP "/compare/\K[^)]+(?=\.\.\.\Q$TAG\E\))" "$NOTES_FILE" | head -n 1 || true)
94+
request=(-f "tag_name=$TAG" -f "target_commitish=$COMMIT")
95+
if [[ -n "$previous" ]]; then
96+
request+=(-f "previous_tag_name=$previous")
97+
fi
98+
echo "Generated notes range: ${previous:-first release}...$TAG"
99+
gh api -X POST "repos/$GH_REPO/releases/generate-notes" "${request[@]}" --jq .body >"$RUNNER_TEMP/generated-notes.md"
100+
python3 "$GITHUB_ACTION_PATH/../tools/new_contributors.py" \
101+
"$NOTES_FILE" "$RUNNER_TEMP/generated-notes.md" "$RUNNER_TEMP/release-body.md"
78102
- name: 🚀 Create or update the draft
79103
shell: bash
80104
env:
81105
GH_REPO: ${{ github.repository }}
82106
TAG: ${{ inputs.tag }}
83107
COMMIT: ${{ inputs.commit }}
84108
TITLE: ${{ inputs.title }}
85-
NOTES_FILE: ${{ inputs.notes-file }}
86109
PRERELEASE: ${{ inputs.prerelease }}
87110
run: |
88111
set -euo pipefail
89112
if jq -e 'length == 0' "$RUNNER_TEMP/release-existing.json" >/dev/null; then
90113
jq -n --arg tag "$TAG" --arg commit "$COMMIT" --arg title "$TITLE" \
91-
--rawfile body "$NOTES_FILE" --argjson prerelease "$PRERELEASE" \
114+
--rawfile body "$RUNNER_TEMP/release-body.md" --argjson prerelease "$PRERELEASE" \
92115
'{tag_name: $tag, target_commitish: $commit, name: $title, body: $body,
93116
draft: true, prerelease: $prerelease}' >"$RUNNER_TEMP/release-request.json"
94117
gh api -X POST "repos/$GH_REPO/releases" --input "$RUNNER_TEMP/release-request.json" \
95118
>"$RUNNER_TEMP/release-created.json"
96119
jq -er '.id | select(type == "number")' "$RUNNER_TEMP/release-created.json" >"$RUNNER_TEMP/release-id"
97120
echo "GitHub Release $TAG: created draft $(<"$RUNNER_TEMP/release-id") for $COMMIT (prerelease $PRERELEASE)"
98-
elif jq -e --arg title "$TITLE" --rawfile body "$NOTES_FILE" --argjson prerelease "$PRERELEASE" \
121+
elif jq -e --arg title "$TITLE" --rawfile body "$RUNNER_TEMP/release-body.md" --argjson prerelease "$PRERELEASE" \
99122
'.[0].draft and (.[0].name != $title or .[0].body != $body or .[0].prerelease != $prerelease)' \
100123
"$RUNNER_TEMP/release-existing.json" >/dev/null; then
101-
jq -n --arg title "$TITLE" --rawfile body "$NOTES_FILE" --argjson prerelease "$PRERELEASE" \
124+
jq -n --arg title "$TITLE" --rawfile body "$RUNNER_TEMP/release-body.md" --argjson prerelease "$PRERELEASE" \
102125
'{name: $title, body: $body, prerelease: $prerelease}' >"$RUNNER_TEMP/release-update.json"
103126
gh api -X PATCH "repos/$GH_REPO/releases/$(<"$RUNNER_TEMP/release-id")" \
104127
--input "$RUNNER_TEMP/release-update.json" \

‎tests/conftest.py‎

Lines changed: 19 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,6 @@
22

33
import json
44
import os
5-
import re
65
import shutil
76
import ssl
87
import subprocess
@@ -139,45 +138,29 @@ def repository(tmp_path: Path, github_api: tuple[HTTPServer, dict[str, str]], mo
139138

140139
def graphql(request: Request) -> Response:
141140
query = request.get_json()["query"]
142-
if 'object(oid: "' in query:
143-
commits = (part.split('"', 1)[0] for part in query.split('object(oid: "')[1:])
144-
data: dict[str, object] = {
145-
"repository": {
146-
f"c{index}": {
147-
"associatedPullRequests": {
148-
"nodes": [
149-
{
150-
"number": int(environment.get("TEST_PULL_NUMBER", "2")),
151-
"mergedAt": "2026-10-22T00:00:00Z",
152-
"mergeCommit": {
153-
"oid": commit,
154-
"authors": {
155-
"nodes": [
156-
{"name": "Release test", "email": "a@example.org", "user": author}
157-
]
158-
},
141+
commits = (part.split('"', 1)[0] for part in query.split('object(oid: "')[1:])
142+
data: dict[str, object] = {
143+
"repository": {
144+
f"c{index}": {
145+
"associatedPullRequests": {
146+
"nodes": [
147+
{
148+
"number": int(environment.get("TEST_PULL_NUMBER", "2")),
149+
"mergedAt": "2026-10-22T00:00:00Z",
150+
"mergeCommit": {
151+
"oid": commit,
152+
"authors": {
153+
"nodes": [{"name": "Release test", "email": "a@example.org", "user": author}]
159154
},
160-
"author": author,
161-
}
162-
]
163-
}
155+
},
156+
"author": author,
157+
}
158+
]
164159
}
165-
for index, commit in enumerate(commits)
166160
}
161+
for index, commit in enumerate(commits)
167162
}
168-
elif "pullRequest(number: " in query:
169-
numbers = re.findall(r"p(\d+): pullRequest", query)
170-
data = {
171-
"repository": {
172-
f"p{number}": {"number": int(number), "mergedAt": "2026-10-22T00:00:00Z", "author": author}
173-
for number in numbers
174-
}
175-
}
176-
else:
177-
data = {
178-
alias: {"issueCount": int(environment.get("TEST_PRIOR_PULLS", "0"))}
179-
for alias in re.findall(r"(s\d+): search", query)
180-
}
163+
}
181164
return Response(json.dumps({"data": data}), mimetype="application/json")
182165

183166
server.expect_request("/api/graphql", method="POST").respond_with_handler(graphql)
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
from __future__ import annotations
2+
3+
import runpy
4+
import sys
5+
from pathlib import Path
6+
7+
import pytest
8+
9+
_TOOL = Path(__file__).resolve().parents[2] / "tools/new_contributors.py"
10+
11+
12+
def test_new_contributors_lead_a_body_of_links_only(
13+
tmp_path: Path, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
14+
) -> None:
15+
notes, generated, output = tmp_path / "notes.md", tmp_path / "generated.md", tmp_path / "body.md"
16+
notes.write_text("[📜 Full Changelog](https://github.com/tox-dev/fixture/commits/0.1.0)\n")
17+
generated.write_text("## New Contributors\n* @alice made their first contribution in https://example.org/pull/1\n")
18+
monkeypatch.setattr(sys, "argv", ["new_contributors.py", str(notes), str(generated), str(output)])
19+
20+
runpy.run_path(str(_TOOL), run_name="__main__")
21+
22+
assert output.read_text() == (
23+
"### New Contributors\n\n- @alice made their first contribution in https://example.org/pull/1\n\n"
24+
"[📜 Full Changelog](https://github.com/tox-dev/fixture/commits/0.1.0)\n"
25+
)
26+
assert capsys.readouterr().out == "New Contributors: @alice\n"
27+
28+
29+
def test_new_contributors_module_imports_without_running() -> None:
30+
assert runpy.run_path(str(_TOOL))["__all__"] == ["main"]

‎tests/prepare_towncrier/test_prepare.py‎

Lines changed: 6 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -98,7 +98,6 @@ def test_prepare_custom_fragment_directory(
9898
def test_prepare_renders_github_notes(
9999
repository: Path, tmp_path: Path, action_scripts: Path, github_api: tuple[HTTPServer, dict[str, str]]
100100
) -> None:
101-
github_api[1]["TEST_PRIOR_PULLS"] = "1"
102101
result = action(
103102
repository,
104103
tmp_path,
@@ -171,7 +170,6 @@ def test_prepare_preserves_prefixed_changelog_heading(repository: Path, tmp_path
171170
def test_prepare_links_named_fragments(
172171
repository: Path, tmp_path: Path, action_scripts: Path, github_api: tuple[HTTPServer, dict[str, str]]
173172
) -> None:
174-
github_api[1]["TEST_PRIOR_PULLS"] = "1"
175173
result = action(
176174
repository,
177175
tmp_path,
@@ -322,20 +320,12 @@ def _author_lookup(github_api: tuple[HTTPServer, dict[str, str]], number: int) -
322320
return environment
323321

324322

325-
@pytest.mark.parametrize(
326-
("prior_pulls", "credited"), [pytest.param("0", True, id="first-pull"), pytest.param("1", False, id="returning")]
327-
)
328-
def test_prepare_adds_new_contributors_after_building(
329-
repository: Path,
330-
tmp_path: Path,
331-
action_scripts: Path,
332-
github_api: tuple[HTTPServer, dict[str, str]],
333-
prior_pulls: str,
334-
credited: bool,
323+
def test_prepare_leaves_new_contributors_out_of_the_changelog(
324+
repository: Path, tmp_path: Path, action_scripts: Path, github_api: tuple[HTTPServer, dict[str, str]]
335325
) -> None:
336-
github_api[1]["TEST_PRIOR_PULLS"] = prior_pulls
337-
338-
result = action(repository, tmp_path, action_scripts, "prepare-towncrier", version="0.2.0", PHASE="build")
326+
result = action(
327+
repository, tmp_path, action_scripts, "prepare-towncrier", version="0.2.0", PHASE="build", **github_api[1]
328+
)
339329

340330
assert result.returncode == 0, result.stderr
341-
assert ("### New Contributors" in (repository / "CHANGELOG.md").read_text()) is credited
331+
assert "New Contributors" not in (repository / "CHANGELOG.md").read_text()

0 commit comments

Comments
 (0)