From f297925ec93b54ab425a6cb56275fd591f3cf0d6 Mon Sep 17 00:00:00 2001 From: Tobias Mayer Date: Tue, 4 Aug 2026 10:20:31 +0200 Subject: [PATCH 1/2] Allow publishing a release without creating it on GitHub `release publish` commits, tags, pushes, and creates the GitHub release in one command, so there is no way to sequence anything between the push and the release. Two situations need that seam: - The release belongs in a different repository than the one being pushed to. A monorepo that mirrors its sources to a public repository wants the tag in both and the release only in the public one. - The release should only appear once downstream builds have proven the tag is releasable. Creating it first means a failing build leaves a published release with no artifacts behind it. Add `--no-github-release` to stop after the tag is pushed, and surface it as the `create-github-release` input on the reusable workflow. The default is unchanged, so existing callers keep creating releases exactly as before. Since only that step shells out to `gh`, the CLI is no longer required when it is skipped. Assisted-By: Claude Opus 5 --- .github/workflows/release.yaml | 12 ++ ...ase-publishing-before-mutating-anything.md | 11 + ...a-release-without-creating-it-on-github.md | 11 + .../references/create-local-release.md | 10 + .../references/create-remote-release.md | 4 + src/tenzir_ship/api.py | 2 + src/tenzir_ship/cli/_release.py | 173 +++++++++------ tests/test_api.py | 21 ++ tests/test_cli.py | 197 ++++++++++++++++++ 9 files changed, 377 insertions(+), 64 deletions(-) create mode 100644 changelog/unreleased/confirm-release-publishing-before-mutating-anything.md create mode 100644 changelog/unreleased/publish-a-release-without-creating-it-on-github.md diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml index d204fc8..1ab5b97 100644 --- a/.github/workflows/release.yaml +++ b/.github/workflows/release.yaml @@ -62,6 +62,14 @@ on: required: false default: false type: boolean + create-github-release: + description: >- + Create the GitHub release. Set to false to commit, tag and push only, + leaving the release to be created separately - for example in another + repository, or only once downstream builds have proven the tag good. + required: false + default: true + type: boolean publish-no-latest-on-non-main: description: "Pass --no-latest on non-main branch releases" required: false @@ -387,6 +395,9 @@ jobs: if [ "$IS_LATEST" != "true" ] && { [ "$BRANCH" = "main" ] || [ "$NO_LATEST_NON_MAIN" = "true" ]; }; then PUBLISH_ARGS+=(--no-latest) fi + if [ "$CREATE_GITHUB_RELEASE" != "true" ]; then + PUBLISH_ARGS+=(--no-github-release) + fi tenzir-ship --root "$CHANGELOG_ROOT" release publish "$RELEASE_VERSION" "${PUBLISH_ARGS[@]}" env: BRANCH: ${{ steps.release-type.outputs.branch }} @@ -395,6 +406,7 @@ jobs: RELEASE_VERSION: ${{ steps.create-release.outputs.version }} IS_LATEST: ${{ steps.determine-latest.outputs.is_latest }} NO_LATEST_NON_MAIN: ${{ inputs.publish-no-latest-on-non-main }} + CREATE_GITHUB_RELEASE: ${{ inputs.create-github-release }} GH_TOKEN: ${{ steps.auth-token.outputs.token }} - name: Copy release to main branch (non-main release) diff --git a/changelog/unreleased/confirm-release-publishing-before-mutating-anything.md b/changelog/unreleased/confirm-release-publishing-before-mutating-anything.md new file mode 100644 index 0000000..bc6a3f6 --- /dev/null +++ b/changelog/unreleased/confirm-release-publishing-before-mutating-anything.md @@ -0,0 +1,11 @@ +--- +title: Confirm release publishing before mutating anything +type: bugfix +authors: + - claude +prs: + - 41 +created: 2026-08-04T08:47:24.381906Z +--- + +`release publish` asked for confirmation only after the commit, tag, and both pushes had already reached the remote, so declining could not undo them. The prompt now appears before the first mutation and lists every step it is about to run. diff --git a/changelog/unreleased/publish-a-release-without-creating-it-on-github.md b/changelog/unreleased/publish-a-release-without-creating-it-on-github.md new file mode 100644 index 0000000..e2182d8 --- /dev/null +++ b/changelog/unreleased/publish-a-release-without-creating-it-on-github.md @@ -0,0 +1,11 @@ +--- +title: Publish a release without creating it on GitHub +type: feature +authors: + - claude +prs: + - 41 +created: 2026-08-04T08:30:19.785303Z +--- + +The new `--no-github-release` flag on `release publish` stops after the tag is pushed, and the reusable workflow exposes it as `create-github-release`. Use it when the release belongs in a different repository than the push target, or when it should only appear once downstream builds have proven the tag releasable. The `gh` CLI is no longer required when the step is skipped. diff --git a/skills/tenzir-ship/references/create-local-release.md b/skills/tenzir-ship/references/create-local-release.md index d606e0d..ed3fbf6 100644 --- a/skills/tenzir-ship/references/create-local-release.md +++ b/skills/tenzir-ship/references/create-local-release.md @@ -142,6 +142,8 @@ Publishing a release via `tenzir-ship` performs the following steps: 3. Push to git remote 4. Create a release via the GitHub API +Step 4 is optional; see the note on `--no-github-release` below. + ### Procedure Inspect the current git changes and stage the exact set you want in the release @@ -167,3 +169,11 @@ Notes: version as prerelease. - Add `--no-latest` if the user requested that a stable release must not be marked as latest. +- Add `--no-github-release` to stop after step 3, leaving the GitHub release to + be created separately. Use it only when the user asks for it, or when the + project's release procedure defers release creation - for example when the + release belongs in a different repository than the one being pushed to, or + when it must appear only after downstream builds have proven the tag + releasable. Never add it on your own initiative: a release the user expected + is then missing. Conversely, do not omit it when the procedure calls for it, + or you publish a release the user intended to defer. diff --git a/skills/tenzir-ship/references/create-remote-release.md b/skills/tenzir-ship/references/create-remote-release.md index 9c59755..88d7102 100644 --- a/skills/tenzir-ship/references/create-remote-release.md +++ b/skills/tenzir-ship/references/create-remote-release.md @@ -103,4 +103,8 @@ Wait briefly for the run to register, find its ID, then watch it. Verify: - If the run succeeds, report the GitHub release URL. +- A project whose workflow passes `create-github-release: false` to the + reusable workflow stops after pushing the tag, so no release exists to link. + Report the tag and the run URL instead of treating the missing release as a + failure. - If it fails, report the run URL so the user can inspect the logs. diff --git a/src/tenzir_ship/api.py b/src/tenzir_ship/api.py index 869493d..3bf354b 100644 --- a/src/tenzir_ship/api.py +++ b/src/tenzir_ship/api.py @@ -176,6 +176,7 @@ def release_publish( commit_message: str | None = None, assume_yes: bool = False, title: str | None = None, + create_github_release: bool = True, ) -> None: """Publish a release to GitHub using the same workflow as the CLI. @@ -200,6 +201,7 @@ def release_publish( commit_message=commit_message, assume_yes=assume_yes, github_title_format=title, + create_github_release=create_github_release, ) def validate(self, *, lenient: bool = False) -> None: diff --git a/src/tenzir_ship/cli/_release.py b/src/tenzir_ship/cli/_release.py index 69ff48f..72fec50 100644 --- a/src/tenzir_ship/cli/_release.py +++ b/src/tenzir_ship/cli/_release.py @@ -192,6 +192,7 @@ def _render_release_progress(tracker: StepTracker) -> None: # Helper functions "_find_release_manifest", "_github_release_exists", + "_build_github_release_command", "_latest_semver", "_bump_version_value", "_validate_semver_label", @@ -233,6 +234,45 @@ def _github_release_exists(repository: str, tag_name: str, gh_path: str) -> bool return False +def _build_github_release_command( + gh_path: str, + *, + tag_name: str, + repository: str, + notes_path: Path, + title: str | None, + draft: bool, + prerelease: bool, + no_latest: bool, + release_exists: bool, +) -> list[str]: + """Build the `gh release create`/`edit` command for a release. + + Built before the confirmation prompt so the prompt can display the command + that will actually run rather than a placeholder. + """ + command = [ + gh_path, + "release", + "edit" if release_exists else "create", + tag_name, + "--repo", + repository, + "--notes-file", + str(notes_path), + ] + if title: + command.extend(["--title", title]) + if draft and not release_exists: + # An existing release is not turned back into a draft. + command.append("--draft") + if prerelease: + command.append("--prerelease") + if no_latest: + command.append("--latest=false") + return command + + def _latest_semver(project_root: Path, *, stable_only: bool = True) -> Version | None: versions: list[Version] = [] for manifest in iter_release_manifests(project_root): @@ -1240,6 +1280,7 @@ def publish_release( commit_message: str | None, assume_yes: bool, github_title_format: str | None = None, + create_github_release: bool = True, ) -> None: """Python wrapper around the ``release publish`` command.""" @@ -1252,8 +1293,10 @@ def publish_release( "Set the 'repository' field in config.yaml or package.yaml before publishing releases." ) + # Only the GitHub release step shells out to `gh`, so a run that skips it + # has no reason to require the CLI. gh_path = shutil.which("gh") - if gh_path is None: + if gh_path is None and create_github_release: raise click.ClickException("The 'gh' CLI is required but was not found in PATH.") manifest = _find_release_manifest(project_root, version) @@ -1318,7 +1361,8 @@ def publish_release( tracker.add("tag", f'git tag -a {tag_name} -m "Release {tag_name}"') tracker.add("push_branch", f"git push {push_remote} {push_branch}:{push_remote_ref}") tracker.add("push_tag", f"git push {push_remote} {tag_name}") - tracker.add("publish", f"gh release create {tag_name} --repo {config.repository} ...") + if create_github_release: + tracker.add("publish", f"gh release create {tag_name} --repo {config.repository} ...") def _fail_step_and_raise(step_name: str, exc: Exception) -> NoReturn: """Mark step as failed, render progress, and re-raise the exception.""" @@ -1326,6 +1370,50 @@ def _fail_step_and_raise(step_name: str, exc: Exception) -> NoReturn: _render_release_progress(tracker) raise click.ClickException(str(exc)) from exc + # Reading whether the release already exists is side-effect free, so both it + # and the resulting command can be resolved before anything is mutated. That + # keeps the prompt below honest: an existing release is edited, not created. + command: list[str] = [] + if create_github_release: + release_exists = _github_release_exists(config.repository, tag_name, cast(str, gh_path)) + command = _build_github_release_command( + cast(str, gh_path), + tag_name=tag_name, + repository=config.repository, + notes_path=notes_path, + title=github_release_title, + draft=draft, + prerelease=resolved_prerelease, + no_latest=resolved_no_latest, + release_exists=release_exists, + ) + tracker.update_command("publish", shlex.join(command)) + + # Confirm before the first mutation rather than after the pushes: a commit + # and tag that are already on the remote cannot be taken back by declining. + if not assume_yes: + if create_github_release: + target = f"to GitHub repository {config.repository}" + else: + target = f"to remote {push_remote or 'origin'} without a GitHub release" + log_info(f"publish release {release_version} with tag {tag_name} {target}?") + planned = [step.command for step in tracker.steps] + log_info(f"this will run {format_bold(str(len(planned)))} step(s):") + for command_line in planned: + log_info(f" {command_line}") + try: + confirmed = click.confirm( + "", + default=True, + prompt_suffix="[Y/n]: ", + show_default=False, + ) + except (click.exceptions.Abort, KeyboardInterrupt) as exc: + abort_on_user_interrupt(exc) + if not confirmed: + log_info("aborted release publish.") + return + # Execute commit step if create_commit: assert final_commit_message is not None @@ -1363,68 +1451,14 @@ def _fail_step_and_raise(step_name: str, exc: Exception) -> NoReturn: tracker.complete("push_tag") log_success(f"pushed git tag {tag_name} to remote {remote_name}.") - release_exists = _github_release_exists(config.repository, tag_name, gh_path) - if release_exists: - command: list[str] = [ - gh_path, - "release", - "edit", - tag_name, - "--repo", - config.repository, - "--notes-file", - str(notes_path), - ] - if github_release_title: - command.extend(["--title", github_release_title]) - if resolved_prerelease: - command.append("--prerelease") - if resolved_no_latest: - command.append("--latest=false") - confirmation_action = "gh release edit" - else: - command = [ - gh_path, - "release", - "create", - tag_name, - "--repo", - config.repository, - "--notes-file", - str(notes_path), - ] - if github_release_title: - command.extend(["--title", github_release_title]) - if draft: - command.append("--draft") - if resolved_prerelease: - command.append("--prerelease") - if resolved_no_latest: - command.append("--latest=false") - confirmation_action = "gh release create" - - tracker.update_command("publish", shlex.join(command)) - - if not assume_yes: - prompt_question = ( - f"Publish release {release_version} with tag {tag_name} " - f"to GitHub repository {config.repository}?" - ) - log_info(prompt_question.lower()) - prompt_action = f"This will run {format_bold(confirmation_action)}." - log_info(prompt_action.lower()) - try: - confirmed = click.confirm( - "", - default=True, - prompt_suffix="[Y/n]: ", - show_default=False, - ) - except (click.exceptions.Abort, KeyboardInterrupt) as exc: - abort_on_user_interrupt(exc) - if not confirmed: - log_info("aborted release publish.") - return + if not create_github_release: + # The commit and tag are pushed; whoever owns the GitHub release creates + # it separately. Useful when the release must be created in a different + # repository than the one being pushed to, or only after downstream + # builds have proven the tag is releasable. + _render_release_progress(tracker) + log_info(f"skipped creating a GitHub release for {tag_name}.") + return try: subprocess.run(command, check=True) @@ -1587,6 +1621,15 @@ def release_version_cmd(ctx: CLIContext, bare: bool) -> None: is_flag=True, help="Prevent GitHub from marking this as the latest release.", ) +@click.option( + "--github-release/--no-github-release", + "create_github_release", + default=True, + help=( + "Create the GitHub release. Use --no-github-release to commit, tag and " + "push only, leaving the release to be created separately." + ), +) @click.option( "--tag", "create_tag", @@ -1617,6 +1660,7 @@ def release_publish_cmd( draft: bool, prerelease: bool, no_latest: bool, + create_github_release: bool, create_tag: bool, create_commit: bool, commit_message: str | None, @@ -1647,4 +1691,5 @@ def release_publish_cmd( commit_message=commit_message, assume_yes=assume_yes, github_title_format=github_title_format, + create_github_release=create_github_release, ) diff --git a/tests/test_api.py b/tests/test_api.py index 56695e2..cf50a7c 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -182,6 +182,27 @@ def fake_publish_release(ctx: cli_module.CLIContext, **kwargs: object) -> None: assert captured["github_title_format"] == "$PROJECT $VERSION - $TITLE" +def test_python_api_release_publish_forwards_create_github_release( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> None: + """The facade must be able to skip release creation like the CLI can.""" + project_dir = _bootstrap_project(tmp_path) + client = Changelog(root=project_dir) + captured: dict[str, object] = {} + + def fake_publish_release(ctx: cli_module.CLIContext, **kwargs: object) -> None: + captured.update(kwargs) + + monkeypatch.setattr("tenzir_ship.api.publish_release", fake_publish_release) + + client.release_publish(version="v1.2.3", create_github_release=False) + assert captured["create_github_release"] is False + + captured.clear() + client.release_publish(version="v1.2.3") + assert captured["create_github_release"] is True + + def test_python_api_release_version_ignores_release_candidates(tmp_path: Path) -> None: project_dir = _bootstrap_project(tmp_path) client = Changelog(root=project_dir) diff --git a/tests/test_cli.py b/tests/test_cli.py index 2e589cc..9f6aeb8 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -9145,3 +9145,200 @@ def test_validate_rejects_duplicate_ids_within_one_manifest(tmp_path: Path) -> N assert result.exit_code != 0 assert "non-unique elements" in result.output + + +def test_release_publish_no_github_release_skips_gh( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> None: + """--no-github-release must not invoke gh at all.""" + runner = CliRunner() + project_dir = tmp_path / "project" + _setup_publishable_release(project_dir, runner) + + commands: list[list[str]] = [] + + def fake_run( + args: list[str], *, check: bool, stdout: object = None, stderr: object = None + ) -> None: + commands.append(args) + + monkeypatch.setattr("tenzir_ship.cli._release.subprocess.run", fake_run) + + result = runner.invoke( + cli, + [ + "--root", + str(project_dir), + "release", + "publish", + "v1.0.0", + "--no-github-release", + "--yes", + ], + ) + + assert result.exit_code == 0, result.output + assert not any(arg and arg[0].endswith("gh") for arg in commands), commands + assert not any("release" in arg and "create" in arg for arg in commands), commands + assert "skipped creating a GitHub release" in result.output + + +def test_release_publish_no_github_release_does_not_require_gh( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> None: + """The gh CLI is only needed by the step that was skipped.""" + runner = CliRunner() + project_dir = tmp_path / "project" + _setup_publishable_release(project_dir, runner) + + monkeypatch.setattr("tenzir_ship.cli._release.shutil.which", lambda command: None) + + result = runner.invoke( + cli, + [ + "--root", + str(project_dir), + "release", + "publish", + "v1.0.0", + "--no-github-release", + "--yes", + ], + ) + + assert result.exit_code == 0, result.output + assert "gh' CLI is required" not in result.output + + +def test_release_publish_still_creates_release_by_default( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> None: + """Omitting the flag keeps the previous behaviour.""" + runner = CliRunner() + project_dir = tmp_path / "project" + _setup_publishable_release(project_dir, runner) + + commands: list[list[str]] = [] + + def fake_run( + args: list[str], *, check: bool, stdout: object = None, stderr: object = None + ) -> None: + commands.append(args) + if len(args) >= 3 and args[1:3] == ["release", "view"]: + raise subprocess.CalledProcessError(returncode=1, cmd=args) + + monkeypatch.setattr("tenzir_ship.cli._release.shutil.which", lambda command: "/usr/bin/gh") + monkeypatch.setattr("tenzir_ship.cli._release.subprocess.run", fake_run) + + result = runner.invoke( + cli, + ["--root", str(project_dir), "release", "publish", "v1.0.0", "--yes"], + ) + + assert result.exit_code == 0, result.output + assert commands[-1][1:3] == ["release", "create"], commands[-1] + + +def test_release_publish_declining_aborts_before_any_git_mutation( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> None: + """Declining must happen before the commit and tag reach the remote.""" + runner = CliRunner() + project_dir = tmp_path / "project" + _setup_publishable_release(project_dir, runner) + + calls: list[str] = [] + + monkeypatch.setattr("tenzir_ship.cli._release.shutil.which", lambda command: "/usr/bin/gh") + monkeypatch.setattr( + "tenzir_ship.cli._release.get_push_branch_info", + lambda *a, **k: ("origin", "main", "main"), + ) + monkeypatch.setattr( + "tenzir_ship.cli._release.subprocess.run", + lambda args, **kwargs: (_ for _ in ()).throw( + subprocess.CalledProcessError(returncode=1, cmd=args) + ), + ) + for name in ("create_git_commit", "create_annotated_git_tag", "push_current_branch"): + monkeypatch.setattr( + f"tenzir_ship.cli._release.{name}", + lambda *a, _name=name, **k: calls.append(_name), + ) + + result = runner.invoke( + cli, + ["--root", str(project_dir), "release", "publish", "v1.0.0", "--tag"], + input="n\n", + ) + + assert result.exit_code == 0, result.output + assert "aborted release publish" in result.output + assert calls == [], calls + + +def test_release_publish_no_github_release_still_confirms( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> None: + """--yes must stay meaningful in tag-only mode.""" + runner = CliRunner() + project_dir = tmp_path / "project" + _setup_publishable_release(project_dir, runner) + + calls: list[str] = [] + monkeypatch.setattr("tenzir_ship.cli._release.shutil.which", lambda command: None) + monkeypatch.setattr( + "tenzir_ship.cli._release.get_push_branch_info", + lambda *a, **k: ("origin", "main", "main"), + ) + for name in ("create_annotated_git_tag", "push_current_branch"): + monkeypatch.setattr( + f"tenzir_ship.cli._release.{name}", + lambda *a, _name=name, **k: calls.append(_name), + ) + + result = runner.invoke( + cli, + [ + "--root", + str(project_dir), + "release", + "publish", + "v1.0.0", + "--tag", + "--no-github-release", + ], + input="n\n", + ) + + assert result.exit_code == 0, result.output + assert "aborted release publish" in result.output + assert calls == [], calls + + +def test_release_publish_prompt_shows_edit_for_existing_release( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> None: + """The prompt must name the operation that will actually run.""" + runner = CliRunner() + project_dir = tmp_path / "project" + _setup_publishable_release(project_dir, runner) + + def fake_run( + args: list[str], *, check: bool, stdout: object = None, stderr: object = None + ) -> None: + # `release view` succeeding means the release already exists. + return None + + monkeypatch.setattr("tenzir_ship.cli._release.shutil.which", lambda command: "/usr/bin/gh") + monkeypatch.setattr("tenzir_ship.cli._release.subprocess.run", fake_run) + + result = runner.invoke( + cli, + ["--root", str(project_dir), "release", "publish", "v1.0.0"], + input="n\n", + ) + + assert result.exit_code == 0, result.output + assert "release edit" in result.output, result.output + assert "release create" not in result.output, result.output From b51a1548d81915fb3bbbed687201658522179ff6 Mon Sep 17 00:00:00 2001 From: Matthias Vallentin Date: Tue, 4 Aug 2026 11:58:55 +0200 Subject: [PATCH 2/2] Prevent empty deferred publishes Require tag creation when GitHub release creation is deferred so a publish cannot succeed without doing work. Render successful progress in green and cover the CLI, Python API, and reusable workflow contracts. Assisted-by: GPT-5.6-sol (pi) --- ...a-release-without-creating-it-on-github.md | 9 ++- .../references/create-local-release.md | 8 ++- src/tenzir_ship/api.py | 6 +- src/tenzir_ship/cli/_release.py | 19 +++-- tests/test_api.py | 7 +- tests/test_cli.py | 71 +++++++++++++++---- tests/test_workflows.py | 18 +++++ 7 files changed, 111 insertions(+), 27 deletions(-) diff --git a/changelog/unreleased/publish-a-release-without-creating-it-on-github.md b/changelog/unreleased/publish-a-release-without-creating-it-on-github.md index e2182d8..fe892b7 100644 --- a/changelog/unreleased/publish-a-release-without-creating-it-on-github.md +++ b/changelog/unreleased/publish-a-release-without-creating-it-on-github.md @@ -3,9 +3,16 @@ title: Publish a release without creating it on GitHub type: feature authors: - claude + - codex prs: - 41 created: 2026-08-04T08:30:19.785303Z --- -The new `--no-github-release` flag on `release publish` stops after the tag is pushed, and the reusable workflow exposes it as `create-github-release`. Use it when the release belongs in a different repository than the push target, or when it should only appear once downstream builds have proven the tag releasable. The `gh` CLI is no longer required when the step is skipped. +The new `--no-github-release` flag on `release publish` pushes the release tag without creating a GitHub release: + +```sh +tenzir-ship release publish --commit --tag --no-github-release --yes +``` + +The flag requires `--tag`, so the command cannot report success without publishing either a tag or a GitHub release. The reusable workflow exposes the same choice as `create-github-release: false`. Use it when the release belongs in a different repository than the push target, or when it should appear only after downstream builds have proven the tag releasable. The `gh` CLI is not required when release creation is skipped. diff --git a/skills/tenzir-ship/references/create-local-release.md b/skills/tenzir-ship/references/create-local-release.md index ed3fbf6..499a533 100644 --- a/skills/tenzir-ship/references/create-local-release.md +++ b/skills/tenzir-ship/references/create-local-release.md @@ -169,9 +169,11 @@ Notes: version as prerelease. - Add `--no-latest` if the user requested that a stable release must not be marked as latest. -- Add `--no-github-release` to stop after step 3, leaving the GitHub release to - be created separately. Use it only when the user asks for it, or when the - project's release procedure defers release creation - for example when the +- Add `--no-github-release` together with `--tag` to stop after step 3, leaving + the GitHub release to be created separately. The command rejects + `--no-github-release` without `--tag` because no publish step would remain. + Use it only when the user asks for it, or when the project's release procedure + defers release creation - for example when the release belongs in a different repository than the one being pushed to, or when it must appear only after downstream builds have proven the tag releasable. Never add it on your own initiative: a release the user expected diff --git a/src/tenzir_ship/api.py b/src/tenzir_ship/api.py index 3bf354b..2f1057a 100644 --- a/src/tenzir_ship/api.py +++ b/src/tenzir_ship/api.py @@ -178,9 +178,11 @@ def release_publish( title: str | None = None, create_github_release: bool = True, ) -> None: - """Publish a release to GitHub using the same workflow as the CLI. + """Publish a release using the same workflow as the CLI. - If no version is provided, defaults to the latest release. + If no version is provided, defaults to the latest release. Set + ``create_github_release=False`` together with ``create_tag=True`` to + push the release tag without creating a GitHub release. """ resolved_version = version diff --git a/src/tenzir_ship/cli/_release.py b/src/tenzir_ship/cli/_release.py index 72fec50..b43cc49 100644 --- a/src/tenzir_ship/cli/_release.py +++ b/src/tenzir_ship/cli/_release.py @@ -150,9 +150,10 @@ def update_command(self, name: str, command: str) -> None: def _render_release_progress(tracker: StepTracker) -> None: - """Render release progress summary to stderr on failure.""" + """Render a release progress summary.""" total = len(tracker.steps) done = len([s for s in tracker.steps if s.status == StepStatus.COMPLETED]) + failed = any(s.status == StepStatus.FAILED for s in tracker.steps) progress = f"{done}/{total}" lines: list[str] = [] @@ -173,7 +174,8 @@ def _render_release_progress(tracker: StepTracker) -> None: if lines: content = Text.from_markup("\n".join(lines)) title = f"Release Progress ({progress})" - _print_renderable(Panel(content, title=title, border_style="red")) + border_style = "red" if failed else "green" + _print_renderable(Panel(content, title=title, border_style=border_style)) for step in tracker.steps: if step.status == StepStatus.FAILED: @@ -1282,12 +1284,17 @@ def publish_release( github_title_format: str | None = None, create_github_release: bool = True, ) -> None: - """Python wrapper around the ``release publish`` command.""" + """Execute the release publishing workflow.""" config = ctx.ensure_config() _enforce_structure_is_valid(ctx, action="publish a release") project_root = ctx.project_root + if not create_github_release and not create_tag: + raise click.ClickException( + "--no-github-release requires --tag; set create_tag=True when using the Python API." + ) + if not config.repository: raise click.ClickException( "Set the 'repository' field in config.yaml or package.yaml before publishing releases." @@ -1626,8 +1633,8 @@ def release_version_cmd(ctx: CLIContext, bare: bool) -> None: "create_github_release", default=True, help=( - "Create the GitHub release. Use --no-github-release to commit, tag and " - "push only, leaving the release to be created separately." + "Create the GitHub release. Use --no-github-release with --tag to push " + "the release tag without creating the GitHub release." ), ) @click.option( @@ -1666,7 +1673,7 @@ def release_publish_cmd( commit_message: str | None, assume_yes: bool, ) -> None: - """Publish a release to GitHub using the gh CLI. + """Publish a release with optional git and GitHub steps. If no version is provided, defaults to the latest release. """ diff --git a/tests/test_api.py b/tests/test_api.py index cf50a7c..b7ab463 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -195,7 +195,12 @@ def fake_publish_release(ctx: cli_module.CLIContext, **kwargs: object) -> None: monkeypatch.setattr("tenzir_ship.api.publish_release", fake_publish_release) - client.release_publish(version="v1.2.3", create_github_release=False) + client.release_publish( + version="v1.2.3", + create_tag=True, + create_github_release=False, + ) + assert captured["create_tag"] is True assert captured["create_github_release"] is False captured.clear() diff --git a/tests/test_cli.py b/tests/test_cli.py index 9f6aeb8..726d492 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -12,7 +12,9 @@ import pytest import yaml from click.testing import CliRunner +from rich.panel import Panel +import tenzir_ship.cli._release as release_module from tenzir_ship import __version__ from tenzir_ship.cli import INFO_PREFIX, cli, main from tenzir_ship.cli._core import create_cli_context @@ -9147,21 +9149,65 @@ def test_validate_rejects_duplicate_ids_within_one_manifest(tmp_path: Path) -> N assert "non-unique elements" in result.output -def test_release_publish_no_github_release_skips_gh( +def test_release_progress_style_matches_outcome(monkeypatch: pytest.MonkeyPatch) -> None: + """Successful progress is green while failed progress remains red.""" + rendered: list[Panel] = [] + + def capture_panel(renderable: object) -> None: + assert isinstance(renderable, Panel) + rendered.append(renderable) + + monkeypatch.setattr(release_module, "_print_renderable", capture_panel) + + successful = release_module.StepTracker() + successful.add("tag", "git tag v1.0.0") + successful.complete("tag") + release_module._render_release_progress(successful) + + failed = release_module.StepTracker() + failed.add("push", "git push origin v1.0.0") + failed.fail("push") + release_module._render_release_progress(failed) + + assert [panel.border_style for panel in rendered] == ["green", "red"] + + +def test_release_publish_no_github_release_pushes_tag_without_gh( monkeypatch: pytest.MonkeyPatch, tmp_path: Path ) -> None: - """--no-github-release must not invoke gh at all.""" + """--no-github-release pushes the tag without invoking or requiring gh.""" runner = CliRunner() project_dir = tmp_path / "project" _setup_publishable_release(project_dir, runner) - commands: list[list[str]] = [] + subprocess_commands: list[list[str]] = [] + git_calls: list[str] = [] def fake_run( args: list[str], *, check: bool, stdout: object = None, stderr: object = None ) -> None: - commands.append(args) + subprocess_commands.append(args) + + def fake_create_tag(*args: object, **kwargs: object) -> bool: + git_calls.append("tag") + return True + + def fake_push_branch(*args: object, **kwargs: object) -> tuple[str, str, str]: + git_calls.append("push_branch") + return "origin", "main", "main" + + def fake_push_tag(*args: object, **kwargs: object) -> str: + git_calls.append("push_tag") + return "origin" + monkeypatch.setattr("tenzir_ship.cli._release.shutil.which", lambda command: None) + monkeypatch.setattr( + "tenzir_ship.cli._release.get_push_branch_info", + lambda *args, **kwargs: ("origin", "main", "main"), + ) + monkeypatch.setattr("tenzir_ship.cli._release.create_annotated_git_tag", fake_create_tag) + monkeypatch.setattr("tenzir_ship.cli._release.push_current_branch", fake_push_branch) + monkeypatch.setattr("tenzir_ship.cli._release.push_git_tag", fake_push_tag) monkeypatch.setattr("tenzir_ship.cli._release.subprocess.run", fake_run) result = runner.invoke( @@ -9172,27 +9218,24 @@ def fake_run( "release", "publish", "v1.0.0", + "--tag", "--no-github-release", "--yes", ], ) assert result.exit_code == 0, result.output - assert not any(arg and arg[0].endswith("gh") for arg in commands), commands - assert not any("release" in arg and "create" in arg for arg in commands), commands + assert subprocess_commands == [] + assert git_calls == ["tag", "push_branch", "push_tag"] assert "skipped creating a GitHub release" in result.output -def test_release_publish_no_github_release_does_not_require_gh( - monkeypatch: pytest.MonkeyPatch, tmp_path: Path -) -> None: - """The gh CLI is only needed by the step that was skipped.""" +def test_release_publish_no_github_release_requires_tag(tmp_path: Path) -> None: + """The skipped-release mode must not succeed without a publish step.""" runner = CliRunner() project_dir = tmp_path / "project" _setup_publishable_release(project_dir, runner) - monkeypatch.setattr("tenzir_ship.cli._release.shutil.which", lambda command: None) - result = runner.invoke( cli, [ @@ -9206,8 +9249,8 @@ def test_release_publish_no_github_release_does_not_require_gh( ], ) - assert result.exit_code == 0, result.output - assert "gh' CLI is required" not in result.output + assert result.exit_code != 0 + assert "--no-github-release requires --tag" in result.output def test_release_publish_still_creates_release_by_default( diff --git a/tests/test_workflows.py b/tests/test_workflows.py index df14956..ed576c6 100644 --- a/tests/test_workflows.py +++ b/tests/test_workflows.py @@ -83,6 +83,7 @@ def test_reusable_release_is_the_only_reusable_release_workflow() -> None: "pre-publish", "post-publish", "skip-publish", + "create-github-release", "publish-no-latest-on-non-main", "copy-release-to-main-on-non-main", "update-latest-branch-on-main", @@ -105,6 +106,23 @@ def test_reusable_release_auth_and_signing_defaults_are_opt_in() -> None: assert _as_mapping(inputs["sign_tags"])["default"] is False +def test_reusable_release_can_defer_github_release_creation() -> None: + workflow = _load_workflow("release.yaml") + workflow_call = _as_mapping(_as_mapping(workflow["on"])["workflow_call"]) + inputs = _as_mapping(workflow_call["inputs"]) + assert _as_mapping(inputs["create-github-release"])["default"] is True + + release_job = _job(workflow, "release") + steps = _as_sequence(release_job["steps"]) + stage_and_publish = _step_by_name(steps, "Stage and publish") + env = _as_mapping(stage_and_publish["env"]) + assert env["CREATE_GITHUB_RELEASE"] == "${{ inputs.create-github-release }}" + + run = cast(str, stage_and_publish["run"]) + assert 'if [ "$CREATE_GITHUB_RELEASE" != "true" ]; then' in run + assert "PUBLISH_ARGS+=(--no-github-release)" in run + + def test_reusable_release_validates_optional_auth_token_and_signing_inputs() -> None: workflow = _load_workflow("release.yaml") release_job = _job(workflow, "release")