diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index cc1939c6b16..36807ad2865 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -365,11 +365,18 @@ jobs: fi echo "pypi environment protection rules: $rules (prevent_self_review: $self_review)" + # This job deliberately never checks the repository out, so setup-uv + # runs against an empty directory: its cache key would hash nothing and + # its empty-workdir check would warn, once each per released package. It + # installs no dependencies either — `uv publish` only uploads the files + # the approval covered — so there is nothing to cache. - name: Install uv uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0 with: version: "0.12.5" python-version: "3.14.7" + enable-cache: "false" + ignore-empty-workdir: "true" - name: Download built artifacts uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 diff --git a/packages/reflex-release/news/+quiet-publish-annotations.misc.md b/packages/reflex-release/news/+quiet-publish-annotations.misc.md new file mode 100644 index 00000000000..841ded5630e --- /dev/null +++ b/packages/reflex-release/news/+quiet-publish-annotations.misc.md @@ -0,0 +1 @@ +Stop the gated publish job from warning about its (deliberately) empty workspace, and log the no-op lockstep pin without annotating the run. diff --git a/packages/reflex-release/src/reflex_release/commands.py b/packages/reflex-release/src/reflex_release/commands.py index 4412d052e05..9889accfd8f 100644 --- a/packages/reflex-release/src/reflex_release/commands.py +++ b/packages/reflex-release/src/reflex_release/commands.py @@ -615,7 +615,7 @@ def cmd_pin_lockstep(config: Config, package: str, version: str) -> None: config.require_known(package) targets = config.exact_pin_targets(package) if not targets: - notice(f"{package} has no exact-pin lockstep siblings; nothing to do.") + echo(f"{package} has no exact-pin lockstep siblings; nothing to do.") return pyproject = config.package_path(package) / "pyproject.toml" for target in targets: @@ -1174,7 +1174,7 @@ def cmd_post_release(config: Config, tag: str, package: str, version: str) -> No """ workflow = config.post_release_workflow if workflow is None: - notice(f"no {POST_RELEASE_WORKFLOW_KEY} is configured; nothing to dispatch.") + echo(f"no {POST_RELEASE_WORKFLOW_KEY} is configured; nothing to dispatch.") return # An unset environment variable reaches here as an empty string, and GitHub # accepts a dispatch carrying empty inputs — the run would go green having diff --git a/packages/reflex-release/src/reflex_release/scaffold.py b/packages/reflex-release/src/reflex_release/scaffold.py index 5a7484af430..178ccb74088 100644 --- a/packages/reflex-release/src/reflex_release/scaffold.py +++ b/packages/reflex-release/src/reflex_release/scaffold.py @@ -321,7 +321,7 @@ def _selects_package(packages: tuple[str, ...]) -> str: return f"contains(fromJson('{listing}'), inputs.package)" -def _uv_setup_block(config: Config) -> str: +def _uv_setup_block(config: Config, *, checkout: bool = True) -> str: """Render the ``with:`` block pinning the uv and Python the workflows use. Every generated workflow installs uv the same way, so the pins live in one @@ -331,19 +331,22 @@ def _uv_setup_block(config: Config) -> str: Args: config: The repository configuration. + checkout: Whether the job installing uv checked the repository out. A + job that did not runs in an empty working directory, where setup-uv + has nothing to key a cache on and warns twice per run about it — + noise on a job that installs no dependencies anyway. Returns: The block, indented under a ``uses: astral-sh/setup-uv`` step, or an - empty string when neither version is pinned. + empty string when there is nothing to pass. """ - pins = [ - f' {key}: "{value}"' - for key, value in ( - ("version", config.uv_version), - ("python-version", config.python_version), - ) - if value + settings = [ + ("version", config.uv_version), + ("python-version", config.python_version), ] + if not checkout: + settings += [("enable-cache", "false"), ("ignore-empty-workdir", "true")] + pins = [f' {key}: "{value}"' for key, value in settings if value] return "\n".join([" with:", *pins]) if pins else "" @@ -504,6 +507,7 @@ def render(name: str, config: Config) -> str: "@@HEADER@@": header, "@@CLI@@": cli, "@@UV_SETUP_WITH@@": _uv_setup_block(config), + "@@UV_SETUP_NO_CHECKOUT@@": _uv_setup_block(config, checkout=False), "@@MAIN_BRANCH@@": config.main_branch, "@@PRERELEASE_PREFIX@@": config.prerelease_branch_prefix, "@@HOTFIX_PREFIX@@": config.hotfix_branch_prefix, diff --git a/packages/reflex-release/src/reflex_release/templates/workflows/publish.yml b/packages/reflex-release/src/reflex_release/templates/workflows/publish.yml index 7da2e9e5a6e..8ce3bd17349 100644 --- a/packages/reflex-release/src/reflex_release/templates/workflows/publish.yml +++ b/packages/reflex-release/src/reflex_release/templates/workflows/publish.yml @@ -360,9 +360,14 @@ jobs: fi echo "pypi environment protection rules: $rules (prevent_self_review: $self_review)" + # This job deliberately never checks the repository out, so setup-uv + # runs against an empty directory: its cache key would hash nothing and + # its empty-workdir check would warn, once each per released package. It + # installs no dependencies either — `uv publish` only uploads the files + # the approval covered — so there is nothing to cache. - name: Install uv uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0 -@@UV_SETUP_WITH@@ +@@UV_SETUP_NO_CHECKOUT@@ - name: Download built artifacts uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 diff --git a/tests/units/reflex_release/test_commands.py b/tests/units/reflex_release/test_commands.py index bbcdd7f5aed..afd72570a52 100644 --- a/tests/units/reflex_release/test_commands.py +++ b/tests/units/reflex_release/test_commands.py @@ -991,7 +991,37 @@ def test_post_release_without_a_configured_workflow_does_nothing( ) commands.cmd_post_release(config, "v1.2.3", "mypkg", "1.2.3") assert captured == [] - assert "no post-release-workflow is configured" in capsys.readouterr().out + out = capsys.readouterr().out + assert "no post-release-workflow is configured" in out + assert "::notice::" not in out + + +def test_pin_lockstep_pins_the_sibling_to_the_exact_version( + config: Config, repo: Path +) -> None: + write_lockstep(repo) + commands.cmd_pin_lockstep(load_config(repo), "mypkg", "1.2.3") + assert '"widget-core == 1.2.3"' in (repo / "pyproject.toml").read_text( + encoding="utf-8" + ) + + +def test_pin_lockstep_without_siblings_does_not_annotate_the_run( + config: Config, repo: Path, capsys: pytest.CaptureFixture +) -> None: + """A step that no-ops has nothing an approver needs to be shown. + + Every package outside an exact-pin lockstep group runs this step, so an + annotation here is one "nothing to do" line per package on the summary of + every release batch. + """ + commands.cmd_pin_lockstep(config, "mypkg", "1.2.3") + out = capsys.readouterr().out + assert "mypkg has no exact-pin lockstep siblings" in out + assert "::notice::" not in out + assert "widget-core >= 0.1.0" in (repo / "pyproject.toml").read_text( + encoding="utf-8" + ) def test_post_release_dispatches_the_workflow_on_the_tag( diff --git a/tests/units/reflex_release/test_scaffold.py b/tests/units/reflex_release/test_scaffold.py index 803f913cc63..4f7b9db83fd 100644 --- a/tests/units/reflex_release/test_scaffold.py +++ b/tests/units/reflex_release/test_scaffold.py @@ -52,14 +52,24 @@ def test_render_substitutes_every_placeholder(config: Config) -> None: assert "Generated by reflex-release" in rendered -#: The step every generated workflow installs uv with, and the placeholder that -#: has to follow it so the configured pins reach it. -_UV_SETUP = "uses: astral-sh/setup-uv@" -_UV_SETUP_PLACEHOLDER = "@@UV_SETUP_WITH@@" +#: The actions the generated workflows install uv and the repository with. +_UV_ACTION = "astral-sh/setup-uv@" +_CHECKOUT_ACTION = "actions/checkout@" + +#: The setup-uv step, and the placeholders one of which has to follow it so the +#: configured pins reach it. Which of the two belongs on a given step depends on +#: whether its job checked out, which test_render_quiets_setup_uv_only_where_ +#: there_is_no_checkout enforces against the rendered YAML. +_UV_SETUP = f"uses: {_UV_ACTION}" +_UV_SETUP_PLACEHOLDERS = ("@@UV_SETUP_WITH@@", "@@UV_SETUP_NO_CHECKOUT@@") + +#: What the checkout-less block passes on top of the pins, to keep setup-uv from +#: warning about a working directory that is empty on purpose. +_QUIET_SETUP = {"enable-cache": "false", "ignore-empty-workdir": "true"} def test_every_template_pins_the_uv_it_installs() -> None: - """Each setup-uv step must be followed by the pin placeholder. + """Each setup-uv step must be followed by a pin placeholder. render() only fails on a placeholder it could not substitute, so a step added to a template without one would silently install an unpinned uv. @@ -71,13 +81,58 @@ def test_every_template_pins_the_uv_it_installs() -> None: if _UV_SETUP not in line: continue found += 1 - assert lines[index + 1] == _UV_SETUP_PLACEHOLDER, ( - f"{template.name} line {index + 1} installs uv without the " - f"{_UV_SETUP_PLACEHOLDER} line that pins it" + assert lines[index + 1] in _UV_SETUP_PLACEHOLDERS, ( + f"{template.name} line {index + 1} installs uv without one of the " + f"{', '.join(_UV_SETUP_PLACEHOLDERS)} lines that pin it" ) assert found, "no template installs uv; the placeholder guard is checking nothing" +def _uv_steps_by_checkout(rendered: str) -> tuple[list[dict], list[dict]]: + """Split a rendered workflow's setup-uv steps by what their job checked out. + + Args: + rendered: A rendered workflow. + + Returns: + The ``uses: astral-sh/setup-uv`` step mappings of the jobs that check the + repository out, then those of the jobs that do not. + """ + checked_out: list[dict] = [] + bare: list[dict] = [] + for job in yaml.safe_load(rendered)["jobs"].values(): + steps = job.get("steps") or [] + group = ( + checked_out + if any(_CHECKOUT_ACTION in step.get("uses", "") for step in steps) + else bare + ) + group += [step for step in steps if _UV_ACTION in step.get("uses", "")] + return checked_out, bare + + +def test_render_quiets_setup_uv_only_where_there_is_no_checkout( + config: Config, +) -> None: + """The quieting block belongs on the jobs with an empty workspace, and only there. + + setup-uv hashes the working directory for its cache key and checks that the + directory is not empty; a job that installs uv without a checkout (the gated + publish job) trips both, for a cache it has no dependencies to fill. A job + that did check out has files to hash and dependencies to install, so the same + block there would quietly turn a cache it wants off. + """ + found = 0 + for name in GENERATED_WORKFLOWS: + checked_out, bare = _uv_steps_by_checkout(render(name, config)) + found += len(bare) + for step in bare: + assert _QUIET_SETUP.items() <= step["with"].items(), name + for step in checked_out: + assert not _QUIET_SETUP.keys() & (step.get("with") or {}).keys(), name + assert found, "no job installs uv without a checkout; this guard checks nothing" + + def test_render_pins_uv_and_python(config: Config) -> None: for name in CORE_WORKFLOWS: rendered = render(name, config) @@ -128,9 +183,12 @@ def test_render_omits_the_block_when_nothing_is_pinned(repo: Path) -> None: assert steps # The placeholder line goes away entirely rather than leaving a `with:` with # nothing under it, and takes its own newline with it rather than opening a - # gap in the step list. - for index in steps: - assert lines[index + 1].strip() != "with:" + # gap in the step list. The checkout-less job keeps its block: what that one + # passes silences setup-uv rather than pinning it. + _, bare = _uv_steps_by_checkout(rendered) + assert sum(1 for index in steps if lines[index + 1].strip() == "with:") == len(bare) + for step in bare: + assert set(step["with"]) == set(_QUIET_SETUP) assert "\n\n\n" not in rendered yaml.safe_load(rendered)