From 69157b963505d51b6652f99b8bd2ab65ae49fcc0 Mon Sep 17 00:00:00 2001 From: Talha Amjad Date: Mon, 14 Sep 2026 14:03:48 +0500 Subject: [PATCH] ci(release): include GitHub contributor attribution --- .github/release.yml | 4 +++ .github/workflows/release-candidate.yml | 15 ++++++++++- .github/workflows/release-to-pypi.yml | 14 ++++++++++ docs/releasing.md | 34 ++++++++++++++++++++++++- tests/test_release_notes.py | 28 +++++++++++++++++++- utils/render_release_notes.py | 30 ++++++++++++++++++++-- 6 files changed, 120 insertions(+), 5 deletions(-) create mode 100644 .github/release.yml diff --git a/.github/release.yml b/.github/release.yml new file mode 100644 index 00000000..f621731b --- /dev/null +++ b/.github/release.yml @@ -0,0 +1,4 @@ +changelog: + exclude: + authors: + - github-actions[bot] diff --git a/.github/workflows/release-candidate.yml b/.github/workflows/release-candidate.yml index 9ca543b1..d10ec384 100644 --- a/.github/workflows/release-candidate.yml +++ b/.github/workflows/release-candidate.yml @@ -56,6 +56,7 @@ jobs: source_tree: ${{ steps.source.outputs.tree }} tag: ${{ steps.contract.outputs.tag }} version: ${{ steps.contract.outputs.version }} + previous_tag: ${{ steps.source.outputs.previous_tag }} steps: - uses: actions/checkout@v7 with: @@ -85,6 +86,15 @@ jobs: [[ "$(jq -r .head.sha <<< "$pr")" == "$HEAD_SHA" ]] [[ "$(jq -r .head.ref <<< "$pr")" == release-please--branches--* ]] echo "tree=$(git rev-parse 'HEAD^{tree}')" >> "$GITHUB_OUTPUT" + manifest=.release-please-manifest.json + if [[ "$TARGET_BRANCH" == "release" ]]; then + manifest=.release-please-manifest.stable.json + fi + previous_version="$(git show "$BASE_SHA:$manifest" | jq -er '.["."]')" + previous_tag="v$previous_version" + git show-ref --verify --quiet "refs/tags/$previous_tag" + git merge-base --is-ancestor "refs/tags/$previous_tag" "$BASE_SHA" + echo "previous_tag=$previous_tag" >> "$GITHUB_OUTPUT" - name: Validate the combined version contract id: contract @@ -152,6 +162,7 @@ jobs: CONTROL_DIGEST: ${{ needs.containers.outputs.control_digest }} HEAD_SHA: ${{ inputs.head_sha }} PRODUCT_DIGEST: ${{ needs.containers.outputs.product_digest }} + PREVIOUS_TAG: ${{ needs.contract.outputs.previous_tag }} SOURCE_TREE: ${{ needs.contract.outputs.source_tree }} TAG: ${{ needs.contract.outputs.tag }} VERSION: ${{ needs.contract.outputs.version }} @@ -163,6 +174,7 @@ jobs: --arg channel "$CHANNEL" \ --arg control_digest "$CONTROL_DIGEST" \ --arg product_digest "$PRODUCT_DIGEST" \ + --arg previous_tag "$PREVIOUS_TAG" \ --arg source_sha "$HEAD_SHA" \ --arg source_tree "$SOURCE_TREE" \ --arg tag "$TAG" \ @@ -170,7 +182,8 @@ jobs: --arg worker_digest "$WORKER_DIGEST" \ '{candidate_run_id: $candidate_run_id, channel: $channel, source_sha: $source_sha, source_tree: $source_tree, tag: $tag, - version: $version, containers: {product: $product_digest, + version: $version, previous_tag: $previous_tag, + containers: {product: $product_digest, control: $control_digest, worker: $worker_digest}}' \ > release-candidate.json diff --git a/.github/workflows/release-to-pypi.yml b/.github/workflows/release-to-pypi.yml index 0a0feb0e..223d0135 100644 --- a/.github/workflows/release-to-pypi.yml +++ b/.github/workflows/release-to-pypi.yml @@ -33,6 +33,7 @@ jobs: product_digest: ${{ steps.candidate.outputs.product_digest }} source_sha: ${{ steps.source.outputs.sha }} version: ${{ steps.contract.outputs.version }} + previous_tag: ${{ steps.candidate.outputs.previous_tag }} worker_digest: ${{ steps.candidate.outputs.worker_digest }} steps: - uses: actions/checkout@v7 @@ -104,10 +105,16 @@ jobs: [[ "$candidate_tree" == "$release_tree" ]] [[ "$recorded_tree" == "$release_tree" ]] + previous_tag="$(jq -er '.previous_tag | select(type == "string" and startswith("v"))' "$candidate")" + [[ "$previous_tag" != "$TAG" ]] + git show-ref --verify --quiet "refs/tags/$previous_tag" + git merge-base --is-ancestor "refs/tags/$previous_tag" HEAD + { echo "product_digest=$(jq -r .containers.product "$candidate")" echo "control_digest=$(jq -r .containers.control "$candidate")" echo "worker_digest=$(jq -r .containers.worker "$candidate")" + echo "previous_tag=$previous_tag" } >> "$GITHUB_OUTPUT" - name: Prepare release assets and checksums @@ -221,8 +228,14 @@ jobs: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} TAG: ${{ inputs.tag }} VERSION: ${{ needs.resolve.outputs.version }} + PREVIOUS_TAG: ${{ needs.resolve.outputs.previous_tag }} shell: bash run: | + gh api --method POST "repos/$GITHUB_REPOSITORY/releases/generate-notes" \ + -f tag_name="$TAG" \ + -f previous_tag_name="$PREVIOUS_TAG" \ + -f configuration_file_path=.github/release.yml \ + --jq .body > github-contribution-notes.md gh release view "$TAG" \ --repo "$GITHUB_REPOSITORY" \ --json body \ @@ -231,6 +244,7 @@ jobs: --assets release-assets \ --channel "$CHANNEL" \ --existing release-please-notes.md \ + --github-notes github-contribution-notes.md \ --output release-notes.md \ --repository "$GITHUB_REPOSITORY" \ --tag "$TAG" \ diff --git a/docs/releasing.md b/docs/releasing.md index 28b350e1..a99215bb 100644 --- a/docs/releasing.md +++ b/docs/releasing.md @@ -101,12 +101,44 @@ candidate have the same version and Git tree. It then: 3. uploads the Python distributions, three Desktop packages, and `SHA256SUMS` to the GitHub release; 4. renders `.github/release-intro.md` with the exact asset names above the - generated changelog; and + generated changelog and adds GitHub's contributor notes; and 5. publishes the GitHub release as a prerelease or the latest stable release. Publication reuses the candidate artifacts. It does not rebuild them after the release pull request is merged. +### Contributor attribution + +Release Please continues to own version selection and the product changelog. +An expandable **Contributors and pull requests** section below that changelog +contains GitHub's native generated notes: PR author credits, any **New +Contributors** entries, and the full comparison link. The output is preserved +as Markdown rather than parsed into a separate author list. It credits PR +contributions, not every reviewer, issue reporter, or co-author. + +The candidate records the previous tag from its target branch's release +manifest at the verified base revision. Beta uses +`.release-please-manifest.json`; stable uses +`.release-please-manifest.stable.json`. Both candidate validation and publication +check that the previous tag exists and is an ancestor of the selected source. +Publication supplies this explicit range to GitHub, instead of asking it to +choose the latest release. `.github/release.yml` excludes release automation +by `github-actions[bot]`; human documentation and test contributions remain +eligible for credit. + +A contributor can appear as new in both a beta and its later stable release +when both comparison ranges include their first contribution. This follows +GitHub's attribution behavior; VidXP does not track a separate announcement +history. Nightly TestPyPI packages do not have a GitHub release or this section. + +GitHub generates the contribution notes before the final asset upload and +release-page edit. If generation fails, publication stops at that step; PyPI +and container publication may already have completed. Retry the same workflow +after resolving the error. Rendering replaces the previous attribution block +without duplicating it or changing the product changelog. Notes are regenerated +on retry, so later PR-title edits can be reflected even though the tag range +stays fixed. + The Desktop installers contain the candidate's tested VidXP wheel. This allows managed setup to be tested before that wheel is public. Selected dependencies still resolve from production PyPI; TestPyPI is used only for nightly package diff --git a/tests/test_release_notes.py b/tests/test_release_notes.py index a36f063c..0da84eab 100644 --- a/tests/test_release_notes.py +++ b/tests/test_release_notes.py @@ -24,7 +24,9 @@ def setUp(self): ROOT / ".github" / "release-intro.md" ).read_text(encoding="utf-8") - def render(self, notes: str, channel: str = "stable") -> str: + def render( + self, notes: str, channel: str = "stable", github_notes: str | None = None + ) -> str: return render( template=self.template, existing_notes=notes, @@ -33,6 +35,7 @@ def render(self, notes: str, channel: str = "stable") -> str: tag="v0.4.0-b", version="0.4.0-b", channel=channel, + github_notes=github_notes, ) def test_release_page_links_exact_assets_before_changelog(self): @@ -70,6 +73,29 @@ def test_rejects_an_ambiguous_platform_asset(self): with self.assertRaisesRegex(ValueError, "exactly one .*exe"): self.render("changes") + def test_contribution_notes_are_replaced_on_retry_without_changelog_loss(self): + notes = "## New Contributors\n* @first made their first contribution in #128" + first = self.render("* Improved indexing.", github_notes=notes) + self.assertEqual(first, self.render(first, github_notes=notes)) + self.assertEqual(first, self.render(first)) + + updated = self.render(first, github_notes="## What's Changed\n* Fix by @second") + self.assertIn("* Improved indexing.", updated) + self.assertNotIn("@first", updated) + self.assertEqual(updated.count("@second"), 1) + self.assertEqual(updated.count("
"), 1) + + def test_native_notes_without_new_contributors_are_preserved(self): + notes = "## What's Changed\n* Fix by @returning\n\n**Full Changelog**: link" + result = self.render("* Product fix.", channel="beta", github_notes=notes) + self.assertIn(notes, result) + self.assertIn("Beta release", result) + self.assertNotIn("## New Contributors", result) + + def test_empty_generated_notes_stop_rendering(self): + with self.assertRaisesRegex(ValueError, "must not be empty"): + self.render("* Product fix.", github_notes=" \n") + if __name__ == "__main__": unittest.main() diff --git a/utils/render_release_notes.py b/utils/render_release_notes.py index 2c667dbe..a83835e7 100644 --- a/utils/render_release_notes.py +++ b/utils/render_release_notes.py @@ -8,6 +8,8 @@ PAGE_START = "" CHANGELOG_START = "" PAGE_END = "" +ATTRIBUTION_START = "" +ATTRIBUTION_END = "" COMPOSED_PAGE = re.compile( rf"{re.escape(PAGE_START)}.*?{re.escape(CHANGELOG_START)}" rf"(?P.*?){re.escape(PAGE_END)}", @@ -36,7 +38,7 @@ def _download_url(repository: str, tag: str, asset: Path) -> str: def _original_changelog(notes: str) -> str: match = COMPOSED_PAGE.fullmatch(notes.strip()) if match: - return match.group("changelog").strip() + return match.group("changelog").split(ATTRIBUTION_START, 1)[0].strip() return notes.strip() @@ -49,6 +51,7 @@ def render( tag: str, version: str, channel: str, + github_notes: str | None = None, ) -> str: if channel not in {"beta", "stable"}: raise ValueError(f"unsupported release channel: {channel}") @@ -89,10 +92,27 @@ def render( if not changelog: changelog = "No user-facing changes were listed for this release." + attribution = "" + if github_notes is not None: + if not github_notes.strip(): + raise ValueError("GitHub contribution notes must not be empty") + attribution = ( + f"\n{ATTRIBUTION_START}\n" + "
\nContributors and pull requests\n\n" + f"{github_notes.strip()}\n\n
\n{ATTRIBUTION_END}\n" + ) + elif ATTRIBUTION_START in existing_notes and ATTRIBUTION_END in existing_notes: + # Preserve credits when an existing page is rendered without new notes. + attribution = ( + f"\n{ATTRIBUTION_START}" + + existing_notes.split(ATTRIBUTION_START, 1)[1].split(ATTRIBUTION_END, 1)[0] + + f"{ATTRIBUTION_END}\n" + ) + return ( f"{PAGE_START}\n{intro}\n\n" f"## What changed\n\n{CHANGELOG_START}\n" - f"{changelog}\n{PAGE_END}\n" + f"{changelog}\n{attribution}{PAGE_END}\n" ) @@ -103,6 +123,7 @@ def main() -> None: parser.add_argument("--assets", type=Path, required=True) parser.add_argument("--channel", choices=("beta", "stable"), required=True) parser.add_argument("--existing", type=Path, required=True) + parser.add_argument("--github-notes", type=Path) parser.add_argument("--output", type=Path, required=True) parser.add_argument("--repository", required=True) parser.add_argument("--tag", required=True) @@ -118,6 +139,11 @@ def main() -> None: tag=args.tag, version=args.version, channel=args.channel, + github_notes=( + args.github_notes.read_text(encoding="utf-8") + if args.github_notes is not None + else None + ), ) args.output.write_text(rendered, encoding="utf-8")