Skip to content
Open
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
4 changes: 4 additions & 0 deletions .github/release.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
changelog:
exclude:
authors:
- github-actions[bot]
15 changes: 14 additions & 1 deletion .github/workflows/release-candidate.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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 }}
Expand All @@ -163,14 +174,16 @@ 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" \
--arg version "$VERSION" \
--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

Expand Down
14 changes: 14 additions & 0 deletions .github/workflows/release-to-pypi.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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 \
Expand All @@ -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" \
Expand Down
34 changes: 33 additions & 1 deletion docs/releasing.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
28 changes: 27 additions & 1 deletion tests/test_release_notes.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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):
Expand Down Expand Up @@ -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("<details>"), 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()
30 changes: 28 additions & 2 deletions utils/render_release_notes.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@
PAGE_START = "<!-- vidxp-release-page:start -->"
CHANGELOG_START = "<!-- vidxp-release-changelog:start -->"
PAGE_END = "<!-- vidxp-release-page:end -->"
ATTRIBUTION_START = "<!-- vidxp-release-attribution:start -->"
ATTRIBUTION_END = "<!-- vidxp-release-attribution:end -->"
COMPOSED_PAGE = re.compile(
rf"{re.escape(PAGE_START)}.*?{re.escape(CHANGELOG_START)}"
rf"(?P<changelog>.*?){re.escape(PAGE_END)}",
Expand Down Expand Up @@ -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()


Expand All @@ -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}")
Expand Down Expand Up @@ -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"
"<details>\n<summary>Contributors and pull requests</summary>\n\n"
f"{github_notes.strip()}\n\n</details>\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"
)


Expand All @@ -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)
Expand All @@ -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")

Expand Down
Loading