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..fe892b7 --- /dev/null +++ b/changelog/unreleased/publish-a-release-without-creating-it-on-github.md @@ -0,0 +1,18 @@ +--- +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` 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 d606e0d..499a533 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,13 @@ 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` 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 + 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..2f1057a 100644 --- a/src/tenzir_ship/api.py +++ b/src/tenzir_ship/api.py @@ -176,10 +176,13 @@ 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. + """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 @@ -200,6 +203,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..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: @@ -192,6 +194,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 +236,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,20 +1282,28 @@ 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.""" + """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." ) + # 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 +1368,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 +1377,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 +1458,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 +1628,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 with --tag to push " + "the release tag without creating the GitHub release." + ), +) @click.option( "--tag", "create_tag", @@ -1617,12 +1667,13 @@ 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, 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. """ @@ -1647,4 +1698,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..b7ab463 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -182,6 +182,32 @@ 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_tag=True, + create_github_release=False, + ) + assert captured["create_tag"] is True + 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..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 @@ -9145,3 +9147,241 @@ 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_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 pushes the tag without invoking or requiring gh.""" + runner = CliRunner() + project_dir = tmp_path / "project" + _setup_publishable_release(project_dir, runner) + + subprocess_commands: list[list[str]] = [] + git_calls: list[str] = [] + + def fake_run( + args: list[str], *, check: bool, stdout: object = None, stderr: object = None + ) -> None: + 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( + cli, + [ + "--root", + str(project_dir), + "release", + "publish", + "v1.0.0", + "--tag", + "--no-github-release", + "--yes", + ], + ) + + assert result.exit_code == 0, result.output + 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_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) + + result = runner.invoke( + cli, + [ + "--root", + str(project_dir), + "release", + "publish", + "v1.0.0", + "--no-github-release", + "--yes", + ], + ) + + assert result.exit_code != 0 + assert "--no-github-release requires --tag" 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 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")