diff --git a/.github/actions/prep-release/action.yml b/.github/actions/prep-release/action.yml index 42e83d29..390d1a34 100644 --- a/.github/actions/prep-release/action.yml +++ b/.github/actions/prep-release/action.yml @@ -21,6 +21,10 @@ inputs: description: "If set, do not make a PR" default: "false" required: false + silent: + description: "Set a placeholder in the changelog and don't publish the release." + required: false + type: boolean since: description: "Use PRs with activity since this date or git reference" required: false @@ -55,6 +59,7 @@ runs: export RH_VERSION_SPEC=${{ inputs.version_spec }} export RH_POST_VERSION_SPEC=${{ inputs.post_version_spec }} export RH_DRY_RUN=${{ inputs.dry_run }} + export RH_SILENT=${{ inputs.silent }} export RH_SINCE=${{ inputs.since }} export RH_SINCE_LAST_STABLE=${{ inputs.since_last_stable }} diff --git a/.github/actions/publish-changelog/action.yml b/.github/actions/publish-changelog/action.yml new file mode 100644 index 00000000..70e03e31 --- /dev/null +++ b/.github/actions/publish-changelog/action.yml @@ -0,0 +1,49 @@ +name: "Publish Changelog" +description: "Remove silent placeholder entries in the changelog file." +inputs: + token: + description: "GitHub access token" + required: true + target: + description: "The owner/repo GitHub target" + required: true + branch: + description: "The branch to target" + required: false + dry_run: + description: "If set, do not make a PR" + default: "false" + required: false +outputs: + pr_url: + description: "The html URL of the draft GitHub release" + value: ${{ steps.publish-changelog.outputs.pr_url }} +runs: + using: "composite" + steps: + - name: install-releaser + shell: bash -eux {0} + run: | + # Install Jupyter Releaser from git unless we are testing Releaser itself + if ! command -v jupyter-releaser &> /dev/null + then + pip install -q git+https://github.com/jupyter-server/jupyter_releaser.git@v2 + fi + + - id: publish-changelog + shell: bash -eux {0} + run: | + export GITHUB_ACCESS_TOKEN=${{ inputs.token }} + export GITHUB_ACTOR=${{ github.triggering_actor }} + export RH_REPOSITORY=${{ inputs.target }} + if [ ! -z ${{ inputs.branch }} ]; then + export RH_BRANCH=${{ inputs.branch }} + fi + export RH_DRY_RUN=${{ inputs.dry_run }} + + python -m jupyter_releaser.actions.publish_changelog + + - shell: bash -eux {0} + run: | + echo "## Next Step" >> $GITHUB_STEP_SUMMARY + echo "Merge the changelog update PR: ${{ steps.publish-changelog.outputs.pr_url }}" >> $GITHUB_STEP_SUMMARY diff --git a/.github/workflows/prep-release.yml b/.github/workflows/prep-release.yml index 52dd6361..6508003d 100644 --- a/.github/workflows/prep-release.yml +++ b/.github/workflows/prep-release.yml @@ -15,6 +15,10 @@ on: post_version_spec: description: "Post Version Specifier" required: false + silent: + description: "Set a placeholder in the changelog and don't publish the release." + required: false + type: boolean since: description: "Use PRs with activity since this date or git reference" required: false @@ -41,6 +45,7 @@ jobs: post_version_spec: ${{ github.event.inputs.post_version_spec }} target: ${{ github.event.inputs.target }} branch: ${{ github.event.inputs.branch }} + silent: ${{ github.event.inputs.silent }} since: ${{ github.event.inputs.since }} since_last_stable: ${{ github.event.inputs.since_last_stable }} diff --git a/.github/workflows/publish-changelog.yml b/.github/workflows/publish-changelog.yml new file mode 100644 index 00000000..a9eda4a1 --- /dev/null +++ b/.github/workflows/publish-changelog.yml @@ -0,0 +1,35 @@ +name: "Publish Changelog" +on: + workflow_dispatch: + inputs: + token: + description: "GitHub access token" + required: true + target: + description: "The owner/repo GitHub target" + required: true + branch: + description: "The branch to target" + required: false + +jobs: + publish_changelog: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: jupyterlab/maintainer-tools/.github/actions/base-setup@v1 + - name: Install Dependencies + shell: bash + run: | + pip install -e . + - name: Publish changelog + id: publish-changelog + uses: jupyter-server/jupyter_releaser/.github/actions/publish-changelog@v2 + with: + token: ${{ secrets.ADMIN_GITHUB_TOKEN }} + target: ${{ github.event.inputs.target }} + branch: ${{ github.event.inputs.branch }} + + - name: "** Next Step **" + run: | + echo "Merge the changelog update PR: ${{ steps.publish-changelog.outputs.pr_url }}" diff --git a/docs/source/get_started/making_release_from_releaser.md b/docs/source/get_started/making_release_from_releaser.md index 8cd70f0f..3f395529 100644 --- a/docs/source/get_started/making_release_from_releaser.md +++ b/docs/source/get_started/making_release_from_releaser.md @@ -70,10 +70,22 @@ already uses Jupyter Releaser. to the next minor version directly. Note: The "next" and "patch" options are not available when using dev versions, you must use explicit versions instead. + - Use the "since" field to select PRs prior to the latest tag to include in the release - - Type "true" in the "since the last stable git tag" if you would like to include PRs since the last non-prerelease version tagged on the target repository and branch. + + - Check "Use PRs with activity since the last stable git tag" if you would like to include PRs since the last non-prerelease version tagged on the target repository and branch. + + - Check "Set a placeholder in the changelog and don't publish the release" if + you want to carry a silent release (e.g. in case of a security release). + That option will change the default behavior by keeping the version + changelog only in the GitHub release and keeping it private (aka in _Draft_ + state). The changelog file will only contains a placeholder to be replaced + by the release body once the maintainers have chosen to publish the release. + - The additional "Post Version Spec" field should be used if your repo uses a dev version (e.g. 0.7.0.dev0) + - The workflow will use the GitHub API to find the relevant pull requests and make an appropriate changelog entry. + - The workflow will create a draft GitHub release to the target repository and branch, with the draft changelog contents. diff --git a/docs/source/get_started/making_release_from_repo.md b/docs/source/get_started/making_release_from_repo.md index 1d72e450..0cc8a613 100644 --- a/docs/source/get_started/making_release_from_repo.md +++ b/docs/source/get_started/making_release_from_repo.md @@ -30,10 +30,17 @@ already uses Jupyter Releaser using workflows on its own repository. - Use the "since" field to select PRs prior to the latest tag to include in the release -- Type "true" in the "since the last stable git tag" if you would like to include PRs since the last non-prerelease version tagged on the target repository and branch. +- Check "Use PRs with activity since the last stable git tag" if you would like to include PRs since the last non-prerelease version tagged on the target repository and branch. - The additional "Post Version Spec" field should be used if your repo uses a dev version (e.g. 0.7.0.dev0) +- Check "Set a placeholder in the changelog and don't publish the release" if + you want to carry a silent release (e.g. in case of a security release). + That option will change the default behavior by keeping the version + changelog only in the GitHub release and keeping it private (aka in _Draft_ + state). The changelog file will only contains a placeholder to be replaced + by the release body once the maintainers have chosen to publish the release. + - The workflow will use the GitHub API to find the relevant pull requests and make an appropriate changelog entry. - The workflow will create a draft GitHub release to the target diff --git a/docs/source/how_to_guides/convert_repo_from_releaser.md b/docs/source/how_to_guides/convert_repo_from_releaser.md index d988de09..0c1cf2f6 100644 --- a/docs/source/how_to_guides/convert_repo_from_releaser.md +++ b/docs/source/how_to_guides/convert_repo_from_releaser.md @@ -69,7 +69,7 @@ B. Prep target repository: build system and for version handling. - If previously providing `version_info` like `version_info = (1, 7, 0, '.dev', '0')`, use a pattern like the one below in your version file: -```toml +```python import re from typing import List diff --git a/docs/source/how_to_guides/convert_repo_from_repo.md b/docs/source/how_to_guides/convert_repo_from_repo.md index 32c9498c..af0fb0e9 100644 --- a/docs/source/how_to_guides/convert_repo_from_repo.md +++ b/docs/source/how_to_guides/convert_repo_from_repo.md @@ -58,7 +58,7 @@ See checklist below for details: build system and for version handling. - If previously providing `version_info` like `version_info = (1, 7, 0, '.dev', '0')`, use a pattern like the one below in your version file: -```toml +```python import re from typing import List diff --git a/docs/source/images/prep_release.png b/docs/source/images/prep_release.png index 3d753116..8de9dfb3 100644 Binary files a/docs/source/images/prep_release.png and b/docs/source/images/prep_release.png differ diff --git a/docs/source/images/prep_release_repo.png b/docs/source/images/prep_release_repo.png index 31257349..9aebb803 100644 Binary files a/docs/source/images/prep_release_repo.png and b/docs/source/images/prep_release_repo.png differ diff --git a/example-workflows/full-release.yml b/example-workflows/full-release.yml index 52037246..186631b8 100644 --- a/example-workflows/full-release.yml +++ b/example-workflows/full-release.yml @@ -12,6 +12,10 @@ on: post_version_spec: description: "Post Version Specifier" required: false + # silent: + # description: "Set a placeholder in the changelog and don't publish the release." + # required: false + # type: boolean since: description: "Use PRs with activity since this date or git reference" required: false @@ -40,6 +44,7 @@ jobs: version_spec: ${{ github.event.inputs.version_spec }} post_version_spec: ${{ github.event.inputs.post_version_spec }} branch: ${{ github.event.inputs.branch }} + # silent: ${{ github.event.inputs.silent }} since: ${{ github.event.inputs.since }} since_last_stable: ${{ github.event.inputs.since_last_stable }} diff --git a/example-workflows/prep-release.yml b/example-workflows/prep-release.yml index 6f092810..83f876f8 100644 --- a/example-workflows/prep-release.yml +++ b/example-workflows/prep-release.yml @@ -12,6 +12,10 @@ on: post_version_spec: description: "Post Version Specifier" required: false + # silent: + # description: "Set a placeholder in the changelog and don't publish the release." + # required: false + # type: boolean since: description: "Use PRs with activity since this date or git reference" required: false @@ -33,6 +37,7 @@ jobs: version_spec: ${{ github.event.inputs.version_spec }} post_version_spec: ${{ github.event.inputs.post_version_spec }} branch: ${{ github.event.inputs.branch }} + # silent: ${{ github.event.inputs.silent }} since: ${{ github.event.inputs.since }} since_last_stable: ${{ github.event.inputs.since_last_stable }} diff --git a/example-workflows/publish-changelog.yml b/example-workflows/publish-changelog.yml new file mode 100644 index 00000000..5d5de7f2 --- /dev/null +++ b/example-workflows/publish-changelog.yml @@ -0,0 +1,29 @@ +name: "Publish Changelog" +on: + release: + types: [published] + + workflow_dispatch: + inputs: + token: + description: "GitHub access token" + required: true + branch: + description: "The branch to target" + required: false + +jobs: + publish_changelog: + runs-on: ubuntu-latest + steps: + - uses: jupyterlab/maintainer-tools/.github/actions/base-setup@v1 + - name: Publish changelog + id: publish-changelog + uses: jupyter-server/jupyter_releaser/.github/actions/publish-changelog@v2 + with: + token: ${{ secrets.ADMIN_GITHUB_TOKEN }} + branch: ${{ github.event.inputs.branch }} + + - name: "** Next Step **" + run: | + echo "Merge the changelog update PR: ${{ steps.publish-changelog.outputs.pr_url }}" diff --git a/jupyter_releaser/actions/publish_changelog.py b/jupyter_releaser/actions/publish_changelog.py new file mode 100644 index 00000000..c463e65a --- /dev/null +++ b/jupyter_releaser/actions/publish_changelog.py @@ -0,0 +1,20 @@ +"""Remove silent placeholder entries in the changelog.""" +# Copyright (c) Jupyter Development Team. +# Distributed under the terms of the Modified BSD License. +import os + +from jupyter_releaser.actions.common import run_action, setup +from jupyter_releaser.util import CHECKOUT_NAME, get_default_branch + +setup(False) + +run_action("jupyter-releaser prep-git") + +# Handle the branch. +if not os.environ.get("RH_BRANCH"): + cur_dir = os.getcwd() + os.chdir(CHECKOUT_NAME) + os.environ["RH_BRANCH"] = get_default_branch() or "" + os.chdir(cur_dir) + +run_action("jupyter-releaser publish-changelog") diff --git a/jupyter_releaser/changelog.py b/jupyter_releaser/changelog.py index c9d43a23..cf5ea015 100644 --- a/jupyter_releaser/changelog.py +++ b/jupyter_releaser/changelog.py @@ -3,13 +3,18 @@ # Distributed under the terms of the Modified BSD License. import re from pathlib import Path +from typing import Optional +import mdformat +from fastcore.net import HTTP404NotFoundError # type:ignore[import-untyped] from github_activity import generate_activity_md # type:ignore[import-untyped] from jupyter_releaser import util START_MARKER = "" END_MARKER = "" +START_SILENT_MARKER = "" +END_SILENT_MARKER = "" PR_PREFIX = "Automated Changelog Entry" PRECOMMIT_PREFIX = "[pre-commit.ci] pre-commit autoupdate" @@ -171,7 +176,7 @@ def build_entry( update_changelog(changelog_path, entry) -def update_changelog(changelog_path, entry): +def update_changelog(changelog_path, entry, silent=False): """Update a changelog with a new entry.""" # Get the new version version = util.get_version() @@ -187,14 +192,86 @@ def update_changelog(changelog_path, entry): msg = "Insert marker appears more than once in changelog" raise ValueError(msg) - changelog = insert_entry(changelog, entry, version=version) + changelog = insert_entry(changelog, entry, version=version, silent=silent) Path(changelog_path).write_text(changelog, encoding="utf-8") -def insert_entry(changelog, entry, version=None): +def remove_placeholder_entries( + repo: str, + auth: Optional[str], + changelog_path: str, + dry_run: bool, +) -> int: + """Replace any silent marker with the GitHub release body + if the release has been published. + + Parameters + ---------- + repo : str + The GitHub owner/repo + auth : str + The GitHub authorization token + changelog_path : str + The changelog file path + dry_run: bool + + Returns + ------- + int + Number of placeholders removed + """ + + changelog = Path(changelog_path).read_text(encoding="utf-8") + start_count = changelog.count(START_SILENT_MARKER) + end_count = changelog.count(END_SILENT_MARKER) + if start_count != end_count: + msg = "" + raise ValueError(msg) + + repo = repo or util.get_repo() + owner, repo_name = repo.split("/") + gh = util.get_gh_object(dry_run=dry_run, owner=owner, repo=repo_name, token=auth) + + # Replace silent placeholder by release body if it has been published + previous_index = None + changes_count = 0 + for _ in range(start_count): + start = changelog.index(START_SILENT_MARKER, previous_index) + end = changelog.index(END_SILENT_MARKER, start) + + version = _extract_version(changelog[start + len(START_SILENT_MARKER) : end]) + try: + util.log(f"Getting release for tag '{version}'...") + release = gh.repos.get_release_by_tag(owner=owner, repo=repo_name, tag=f"v{version}") + except HTTP404NotFoundError: + # Skip this version + pass + else: + if not release.draft: + changelog_text = mdformat.text(release.body) + changelog = ( + changelog[:start] + + f"\n\n{changelog_text}\n\n" + + changelog[end + len(END_SILENT_MARKER) :] + ) + changes_count += 1 + + previous_index = end + + # Write back the new changelog + Path(changelog_path).write_text(format(changelog), encoding="utf-8") + return changes_count + + +def insert_entry( + changelog: str, entry: str, version: Optional[str] = None, silent: bool = False +) -> str: """Insert the entry into the existing changelog.""" # Test if we are augmenting an existing changelog entry (for new PRs) # Preserve existing PR entries since we may have formatted them + if silent: + entry = f"{START_SILENT_MARKER}\n\n## {version}\n\n{END_SILENT_MARKER}" + new_entry = f"{START_MARKER}\n\n{entry}\n\n{END_MARKER}" prev_entry = changelog[ changelog.index(START_MARKER) : changelog.index(END_MARKER) + len(END_MARKER) @@ -218,7 +295,7 @@ def insert_entry(changelog, entry, version=None): return format(changelog) -def format(changelog): # noqa +def format(changelog: str) -> str: # noqa A001 """Clean up changelog formatting""" changelog = re.sub(r"\n\n+", r"\n\n", changelog) return re.sub(r"\n\n+$", r"\n", changelog) @@ -349,7 +426,12 @@ def extract_current(changelog_path): def extract_current_version(changelog_path): """Extract the current released version from the changelog""" body = extract_current(changelog_path) - match = re.match(r"#+ (\d\S+)", body.strip()) + return _extract_version(body) + + +def _extract_version(entry: str) -> str: + """Extract version from entry""" + match = re.match(r"#+ (\d\S+)", entry.strip()) if not match: msg = "Could not find previous version" raise ValueError(msg) diff --git a/jupyter_releaser/cli.py b/jupyter_releaser/cli.py index 12825a55..9d98ef13 100644 --- a/jupyter_releaser/cli.py +++ b/jupyter_releaser/cli.py @@ -249,6 +249,12 @@ def main(force): ), ] +silent_option: t.Any = [ + click.option( + "--silent", envvar="RH_SILENT", default=False, help="Set a placeholder in the changelog." + ) +] + since_options: t.Any = [ click.option( "--since", @@ -383,10 +389,11 @@ def bump_version(version_spec, version_cmd, changelog_path, python_packages, tag @add_options(auth_options) @add_options(changelog_path_options) @add_options(release_url_options) +@add_options(silent_option) @use_checkout_dir() -def extract_changelog(dry_run, auth, changelog_path, release_url): +def extract_changelog(dry_run, auth, changelog_path, release_url, silent): """Extract the changelog entry.""" - lib.extract_changelog(dry_run, auth, changelog_path, release_url) + lib.extract_changelog(dry_run, auth, changelog_path, release_url, silent) @main.command() @@ -396,15 +403,10 @@ def build_changelog( ref, branch, repo, auth, changelog_path, since, since_last_stable, resolve_backports ): """Build changelog entry""" + # We don't silence building the entry as it will be extracted to + # populate the release body changelog.build_entry( - ref, - branch, - repo, - auth, - changelog_path, - since, - since_last_stable, - resolve_backports, + ref, branch, repo, auth, changelog_path, since, since_last_stable, resolve_backports ) @@ -416,6 +418,7 @@ def build_changelog( @add_options(changelog_path_options) @add_options(dry_run_options) @add_options(post_version_spec_options) +@add_options(silent_option) @add_options(tag_format_options) @use_checkout_dir() def draft_changelog( @@ -430,6 +433,7 @@ def draft_changelog( dry_run, post_version_spec, post_version_message, + silent, tag_format, ): """Create a changelog entry PR""" @@ -445,6 +449,7 @@ def draft_changelog( dry_run, post_version_spec, post_version_message, + silent, tag_format, ) @@ -551,8 +556,9 @@ def tag_release(dist_dir, release_message, tag_format, tag_message, no_git_tag_w @add_options(dry_run_options) @add_options(release_url_options) @add_options(post_version_spec_options) -@click.argument("assets", nargs=-1) +@add_options(silent_option) @add_options(tag_format_options) +@click.argument("assets", nargs=-1) @use_checkout_dir() def populate_release( ref, @@ -566,8 +572,9 @@ def populate_release( release_url, post_version_spec, post_version_message, - assets, + silent, tag_format, + assets, ): """Populate a release.""" lib.populate_release( @@ -584,6 +591,7 @@ def populate_release( post_version_message, assets, tag_format, + silent, ) @@ -684,10 +692,11 @@ def publish_assets( @add_options(auth_options) @add_options(dry_run_options) @add_options(release_url_options) +@add_options(silent_option) @use_checkout_dir() -def publish_release(auth, dry_run, release_url): +def publish_release(auth, dry_run, release_url, silent): """Publish GitHub release""" - lib.publish_release(auth, dry_run, release_url) + lib.publish_release(auth, dry_run, release_url, silent) @main.command() @@ -717,5 +726,16 @@ def forwardport_changelog(auth, ref, branch, repo, username, changelog_path, dry ) +@main.command() +@add_options(auth_options) +@add_options(branch_options) +@add_options(changelog_path_options) +@add_options(dry_run_options) +@use_checkout_dir() +def publish_changelog(auth, ref, branch, repo, changelog_path, dry_run): + """Remove changelog placeholder entries.""" + lib.publish_changelog(branch, repo, auth, changelog_path, dry_run) + + if __name__ == "__main__": # pragma: no cover main() diff --git a/jupyter_releaser/lib.py b/jupyter_releaser/lib.py index 365619df..725df123 100644 --- a/jupyter_releaser/lib.py +++ b/jupyter_releaser/lib.py @@ -57,6 +57,7 @@ def draft_changelog( dry_run, post_version_spec, post_version_message, + silent, tag_format, ): """Create a changelog entry PR""" @@ -80,7 +81,8 @@ def draft_changelog( raise ValueError(msg) current = changelog.extract_current(changelog_path) - util.log(f"\n\nCurrent Changelog Entry:\n{current}") + if not silent: + util.log(f"\n\nCurrent Changelog Entry:\n{current}") # Check out all changed files. try: @@ -105,6 +107,7 @@ def draft_changelog( "post_version_spec": post_version_spec, "post_version_message": post_version_message, "expected_sha": current_sha, + "silent": silent, } with tempfile.TemporaryDirectory() as d: metadata_path = Path(d) / util.METADATA_JSON @@ -115,7 +118,7 @@ def draft_changelog( tag_name, branch, tag_name, current, True, prerelease, files=[metadata_path] ) - # Remove draft releases over a day old + # Remove non-silent draft releases over a day old if bool(os.environ.get("GITHUB_ACTIONS")): for rel in gh.repos.list_releases(): if str(rel.draft).lower() == "false": @@ -126,6 +129,10 @@ def draft_changelog( ) delta = datetime.now(tz=timezone.utc) - d_created if delta.days > 0: + # Check for silent status here to avoid request to often the GitHub API + metadata = util.extract_metadata_from_release_url(gh, rel.html_url, auth) + if metadata.get("silent"): + continue gh.repos.delete_release(rel.id) # Set the GitHub action output for the release url. @@ -168,7 +175,15 @@ def make_changelog_pr(auth, branch, repo, title, commit_message, body, dry_run=F util.run(f"git push origin {pr_branch}", echo=True) # title, head, base, body, maintainer_can_modify, draft, issue - pull = gh.pulls.create(title, head, base, body, maintainer_can_modify, False, None) + pull = gh.pulls.create( + title=title, + head=head, + base=base, + body=body, + maintainer_can_modify=maintainer_can_modify, + draf=False, + issue=None, + ) # Try to add the documentation label to the PR. number = pull.number @@ -180,6 +195,22 @@ def make_changelog_pr(auth, branch, repo, title, commit_message, body, dry_run=F util.actions_output("pr_url", pull.html_url) +def publish_changelog(branch, repo, auth, changelog_path, dry_run): + """Remove changelog placeholder entries.""" + count = changelog.remove_placeholder_entries(repo, auth, changelog_path, dry_run) + + if count == 0: + util.log("Changelog did not changed.") + return + + # Create a forward port PR + title = f"{changelog.PR_PREFIX} - Remove {count} placeholder entries." + commit_message = f'git commit -a -m "{title}"' + body = title + + make_changelog_pr(auth, branch, repo, title, commit_message, body, dry_run) + + def tag_release(dist_dir, release_message, tag_format, tag_message, no_git_tag_workspace): """Create release commit and tag""" # Get the new version @@ -212,11 +243,11 @@ def populate_release( post_version_message, assets, tag_format, + silent=False, ): """Populate release assets and push tags and commits""" branch = branch or util.get_branch() assets = assets or glob(f"{dist_dir}/*") - body = changelog.extract_current(changelog_path) match = util.parse_release_url(release_url) owner, repo_name = match["owner"], match["repo"] @@ -235,6 +266,9 @@ def populate_release( gh = util.get_gh_object(dry_run=dry_run, owner=owner, repo=repo_name, token=auth) release = util.release_for_url(gh, release_url) + # if the release is silent, the changelog source of truth is the GitHub release + body = release.body if silent else changelog.extract_current(changelog_path) + remote_name = util.get_remote_name(dry_run) remote_url = util.run(f"git config --get remote.{remote_name}.url") if not os.path.exists(remote_url): @@ -416,7 +450,7 @@ def publish_assets( # noqa util.log("No files to upload") -def publish_release(auth, dry_run, release_url): +def publish_release(auth, dry_run, release_url, silent): """Publish GitHub release""" util.log(f"Publishing {release_url}") @@ -432,7 +466,7 @@ def publish_release(auth, dry_run, release_url): release.target_commitish, release.name, release.body, - False, + silent, # In silent mode the release will stay in draft mode release.prerelease, ) @@ -522,13 +556,18 @@ def prep_git(ref, branch, repo, auth, username, url): # noqa return branch -def extract_changelog(dry_run, auth, changelog_path, release_url): - """Extract the changelog from the draft GH release body and update it.""" +def extract_changelog(dry_run, auth, changelog_path, release_url, silent=False): + """Extract the changelog from the draft GH release body and update it. + + > If the release must is silent, the changelog entry will be replaced by + > a placeholder + """ match = util.parse_release_url(release_url) gh = util.get_gh_object(dry_run=dry_run, owner=match["owner"], repo=match["repo"], token=auth) release = util.release_for_url(gh, release_url) + changelog_text = mdformat.text(release.body) - changelog.update_changelog(changelog_path, changelog_text) + changelog.update_changelog(changelog_path, changelog_text, silent=silent) def forwardport_changelog(auth, ref, branch, repo, username, changelog_path, dry_run, release_url): diff --git a/jupyter_releaser/mock_github.py b/jupyter_releaser/mock_github.py index 3fff4296..38e7be2e 100644 --- a/jupyter_releaser/mock_github.py +++ b/jupyter_releaser/mock_github.py @@ -145,6 +145,12 @@ def list_releases(owner: str, repo: str) -> List[Release]: return list(releases.values()) +@app.get("/repos/{owner}/{repo}/releases/tags/{tag}") +def get_release_by_tag(owner: str, repo: str, tag: str) -> Release: + """https://docs.github.com/en/rest/releases/releases#get-a-release-by-tag-name""" + return next(filter(lambda r: r.tag_name == tag, releases.values())) + + @app.post("/repos/{owner}/{repo}/releases") async def create_a_release(owner: str, repo: str, request: Request) -> Release: """https://docs.github.com/en/rest/releases/releases#create-a-release""" diff --git a/jupyter_releaser/tests/conftest.py b/jupyter_releaser/tests/conftest.py index 0674b4f4..b42ca60a 100644 --- a/jupyter_releaser/tests/conftest.py +++ b/jupyter_releaser/tests/conftest.py @@ -204,20 +204,19 @@ def mock_github(): @fixture -def draft_release(mock_github): - gh = GhApi(owner="foo", repo="bar") - tag = uuid.uuid4().hex - data = dict( - version_spec="foo", - branch="bar", - repo="fizz", - since="buzz", - since_last_stable=False, - version=tag, - post_version_spec="dev", - post_version_message="hi", +def release_metadata(): + return dict( + [(k, v) for k, v in testutil.BASE_RELEASE_METADATA.items() if k != "version"] + + [("version", uuid.uuid4().hex)] ) + +@fixture +def draft_release(mock_github, release_metadata): + gh = GhApi(owner="foo", repo="bar") + data = release_metadata + tag = "v" + data["version"] + with tempfile.TemporaryDirectory() as d: metadata_path = Path(d) / "metadata.json" with open(metadata_path, "w") as fid: diff --git a/jupyter_releaser/tests/test_changelog.py b/jupyter_releaser/tests/test_changelog.py new file mode 100644 index 00000000..84a66d98 --- /dev/null +++ b/jupyter_releaser/tests/test_changelog.py @@ -0,0 +1,170 @@ +# Copyright (c) Jupyter Development Team. +# Distributed under the terms of the Modified BSD License. + +import os +import subprocess + +import pytest +from ghapi.core import GhApi +from pytest import fixture + +from jupyter_releaser.changelog import ( + END_MARKER, + END_SILENT_MARKER, + START_MARKER, + START_SILENT_MARKER, + remove_placeholder_entries, + update_changelog, +) +from jupyter_releaser.tests import util as testutil +from jupyter_releaser.util import release_for_url + + +@fixture +def module_template(): + return testutil.PY_MODULE_TEMPLATE + + +@fixture +def mock_py_package(tmp_path, module_template): + pyproject = tmp_path / "pyproject.toml" + pyproject.write_text(testutil.pyproject_template(), encoding="utf-8") + + foopy = tmp_path / "foo.py" + foopy.write_text(module_template, encoding="utf-8") + + +def test_update_changelog(tmp_path, mock_py_package): + changelog = tmp_path / "CHANGELOG.md" + changelog.write_text(testutil.CHANGELOG_TEMPLATE, encoding="utf-8") + + os.chdir(tmp_path) + update_changelog(str(changelog), testutil.CHANGELOG_ENTRY) + + new_changelog = changelog.read_text(encoding="utf-8") + + assert f"{START_MARKER}\n{testutil.CHANGELOG_ENTRY}\n{END_MARKER}" in new_changelog + + +def test_silent_update_changelog(tmp_path, mock_py_package): + changelog = tmp_path / "CHANGELOG.md" + changelog.write_text(testutil.CHANGELOG_TEMPLATE, encoding="utf-8") + os.chdir(tmp_path) + update_changelog(str(changelog), testutil.CHANGELOG_ENTRY, True) + + new_changelog = changelog.read_text(encoding="utf-8") + + assert ( + f"{START_MARKER}\n\n{START_SILENT_MARKER}\n\n## 0.0.1\n\n{END_SILENT_MARKER}\n\n{END_MARKER}" + in new_changelog + ) + + +def test_update_changelog_with_old_silent_entry(tmp_path, mock_py_package): + changelog = tmp_path / "CHANGELOG.md" + changelog.write_text(testutil.CHANGELOG_TEMPLATE, encoding="utf-8") + os.chdir(tmp_path) + + # Update changelog for current version + update_changelog(str(changelog), testutil.CHANGELOG_ENTRY, True) + # Bump version + subprocess.check_call(["pipx", "run", "hatch", "version", "patch"], cwd=tmp_path) # noqa S603 + # Update changelog for the new version + update_changelog(str(changelog), testutil.CHANGELOG_ENTRY) + + new_changelog = changelog.read_text(encoding="utf-8") + + assert f"{START_SILENT_MARKER}\n\n## 0.0.1\n\n{END_SILENT_MARKER}" in new_changelog + assert ( + f"{START_MARKER}\n\n{START_SILENT_MARKER}\n\n## 0.0.1\n\n{END_SILENT_MARKER}\n\n{END_MARKER}" + not in new_changelog + ) + + +@pytest.mark.parametrize("module_template", ['__version__ = "0.0.3"\n']) +def test_silence_existing_changelog_entry(tmp_path, mock_py_package): + changelog = tmp_path / "CHANGELOG.md" + changelog.write_text(testutil.CHANGELOG_TEMPLATE, encoding="utf-8") + os.chdir(tmp_path) + update_changelog(str(changelog), testutil.CHANGELOG_ENTRY) + + new_changelog = changelog.read_text(encoding="utf-8") + assert f"{START_MARKER}\n{testutil.CHANGELOG_ENTRY}\n{END_MARKER}" in new_changelog + + # Should silent the current entry + update_changelog(str(changelog), testutil.CHANGELOG_ENTRY, True) + + new_changelog = changelog.read_text(encoding="utf-8") + assert ( + f"{START_MARKER}\n\n{START_SILENT_MARKER}\n\n## 0.0.3\n\n{END_SILENT_MARKER}\n\n{END_MARKER}" + in new_changelog + ) + assert f"{START_MARKER}\n{testutil.CHANGELOG_ENTRY}\n{END_MARKER}" not in new_changelog + + +@pytest.mark.parametrize( + "release_metadata", + [ + dict( + [(k, v) for k, v in testutil.BASE_RELEASE_METADATA.items() if k != "version"] + + [("version", "0.0.0")] + ) + ], +) +def test_remove_placeholder_entries(tmp_path, release_metadata, draft_release): + # Create changelog with silent placeholder + changelog = tmp_path / "CHANGELOG.md" + placeholder = ( + f"\n{START_SILENT_MARKER}\n\n## {release_metadata['version']}\n\n{END_SILENT_MARKER}\n" + ) + changelog.write_text(testutil.CHANGELOG_TEMPLATE + placeholder, encoding="utf-8") + os.chdir(tmp_path) + + # Publish the release (as it is a draft from `draft_release`) + gh = GhApi(owner="foo", repo="bar") + release = release_for_url(gh, draft_release) + published_changelog = "Published body" + gh.repos.update_release( + release["id"], + release["tag_name"], + release["target_commitish"], + release["name"], + published_changelog, + False, + release["prerelease"], + ) + + remove_placeholder_entries("foo/bar", None, changelog, False) + + new_changelog = changelog.read_text(encoding="utf-8") + assert placeholder not in new_changelog + assert published_changelog in new_changelog + + +@pytest.mark.parametrize( + "release_metadata", + [ + dict( + [ + (k, v) + for k, v in testutil.BASE_RELEASE_METADATA.items() + if k not in ("version", "silent") + ] + + [("version", "0.0.0"), ("silent", True)] + ) + ], +) +def test_dont_remove_placeholder_entries(tmp_path, release_metadata, draft_release): + changelog = tmp_path / "CHANGELOG.md" + placeholder = ( + f"\n{START_SILENT_MARKER}\n\n## {release_metadata['version']}\n\n{END_SILENT_MARKER}\n" + ) + changelog.write_text(testutil.CHANGELOG_TEMPLATE + placeholder, encoding="utf-8") + os.chdir(tmp_path) + + # Release is not published, so this is a no-op + remove_placeholder_entries("foo/bar", None, changelog, False) + + new_changelog = changelog.read_text(encoding="utf-8") + assert placeholder in new_changelog + assert "hi" not in new_changelog diff --git a/jupyter_releaser/tests/test_cli.py b/jupyter_releaser/tests/test_cli.py index e8f4d6b7..30e06fb8 100644 --- a/jupyter_releaser/tests/test_cli.py +++ b/jupyter_releaser/tests/test_cli.py @@ -175,6 +175,7 @@ def test_list_envvars(runner): release-url: RH_RELEASE_URL repo: RH_REPOSITORY resolve-backports: RH_RESOLVE_BACKPORTS +silent: RH_SILENT since: RH_SINCE since-last-stable: RH_SINCE_LAST_STABLE tag-format: RH_TAG_FORMAT diff --git a/jupyter_releaser/tests/util.py b/jupyter_releaser/tests/util.py index 8b665495..263afacd 100644 --- a/jupyter_releaser/tests/util.py +++ b/jupyter_releaser/tests/util.py @@ -68,6 +68,18 @@ """ +BASE_RELEASE_METADATA = dict( + version_spec="foo", + branch="bar", + repo="fizz", + since="buzz", + since_last_stable=False, + post_version_spec="dev", + post_version_message="hi", + silent=False, +) + + def setup_cfg_template(package_name="foo", module_name=None): return f""" [metadata]