From 945a87bd498fd97e7400274f9e67888a9f1a7f30 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 14 Aug 2026 21:27:37 +0000 Subject: [PATCH 1/3] feat(reflex-release): delegate builds to a repository-supplied workflow MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Packages whose artifacts cannot come from a single `uv build` — a matrix of platform-specific wheels, say — can now hand their build to a workflow the consuming repository owns: [[tool.reflex-release.custom-build]] packages = ["mypkg"] workflow = "build_wheels.yml" expect-artifacts = ["*.tar.gz", "*-macosx_*_arm64.whl"] The generated publish.yml calls that workflow in place of its own build job for those packages, passing the package, version, tag, build directory and the artifact-name prefix to upload under. publish.yml is restructured from build/publish/tag-and-release into prepare/build/collect/publish/tag-and-release so that the artifact verification, the post-build hook, the release notes and the checksum manifest run in one place whichever job produced the files. The custom build jobs sit between prepare and collect, so the whole matrix runs before the approval gate, and collect tolerates a skipped build path but never a failed one — a lost matrix leg stops the release instead of uploading a partial set. The trust boundary is unchanged: the calling job grants only contents: read and no secrets, and a called workflow cannot hold more privilege than its caller grants, so a custom build is inside the same unprivileged boundary as the built-in one. verify-dist checks every collected file is that package at that version, and expect-artifacts additionally requires the set to be complete, since a version can only be uploaded to PyPI once. Also: - reject custom-build on a pin-exact lockstep member, whose pyproject.toml rewrite happens in a checkout the custom workflow never sees; - run the dev-pin gate in prepare for custom-built packages, which never reach the build job where it normally runs; - fail `sync` (so `sync --check` on every PR) when a configured build workflow is missing or declares no workflow_call trigger; - pass DIST_DIR to post_build.sh and fix the README example, which used a path the build never wrote to. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01E1ujR3svpGxMwm5pBMrsXh --- packages/reflex-release/README.md | 199 ++++++++++++++++-- packages/reflex-release/news/6883.feature.md | 1 + .../src/reflex_release/commands.py | 7 +- .../src/reflex_release/config.py | 158 +++++++++++++- .../reflex-release/src/reflex_release/dist.py | 21 +- .../src/reflex_release/scaffold.py | 197 ++++++++++++++++- .../templates/workflows/publish.yml | 150 ++++++++++--- tests/units/reflex_release/conftest.py | 27 +++ tests/units/reflex_release/test_config.py | 149 +++++++++++++ tests/units/reflex_release/test_dist.py | 22 ++ tests/units/reflex_release/test_scaffold.py | 195 ++++++++++++++++- 11 files changed, 1063 insertions(+), 63 deletions(-) create mode 100644 packages/reflex-release/news/6883.feature.md diff --git a/packages/reflex-release/README.md b/packages/reflex-release/README.md index 10e53e50c9c..336b0285404 100644 --- a/packages/reflex-release/README.md +++ b/packages/reflex-release/README.md @@ -42,7 +42,7 @@ give every package a [tag-derived version](#tag-derived-versions), and commit. | --- | --- | --- | | `.github/workflows/dispatch_release.yml` | manual | Materializes news fragments into `CHANGELOG.md` at the next version. Final releases land through a pull request; prereleases go straight to an `r/pre-*` branch. | | `.github/workflows/release_from_changelog.yml` | push to `main`, `r/pre-**`, `r/hotfix/**` | Publishes any changelog version that has no git tag. | -| `.github/workflows/publish.yml` | called by the two above, or manual | Builds one package, waits for `pypi` environment approval, uploads, then tags and creates the GitHub release. | +| `.github/workflows/publish.yml` | called by the two above, or manual | Builds one package — in-repo, or through a [workflow you supply](#custom-builds) — waits for `pypi` environment approval, uploads, then tags and creates the GitHub release. | | `.github/workflows/changelog.yml` | pull request | Requires a news fragment for every package the PR touches, rejects hand-written version headings, and fails if the generated workflows have drifted. | | `.github/workflows/auto_release_internal.yml` | push to `main` | Only for repos with `internal-packages`: patch-releases them whenever they change. | @@ -139,6 +139,22 @@ changelog-exempt-packages = [] dispatch-package-inputs = "auto" ``` +### Custom build workflows + +A package whose artifacts cannot come from a plain `uv build` — a matrix of +platform-specific wheels, say — delegates its build to a workflow the +repository owns: + +```toml +[[tool.reflex-release.custom-build]] +packages = ["mypkg"] +workflow = "build_wheels.yml" +# Optional: filename globs that must each match at least one built file. +expect-artifacts = ["*.tar.gz", "*-manylinux*_x86_64.whl", "*-macosx_*_arm64.whl"] +``` + +See [Custom builds](#custom-builds) for the workflow's contract. + Package names are **directory names**: `mypkg` for the root package (whatever you called it) and the directory name under `packages/` for the rest. @@ -324,16 +340,20 @@ can undermine it. | Stage | Privileges | Runs | | --- | --- | --- | -| `build` | `contents: read`. No OIDC, no secrets. | Your repository's code: the build backend, its hooks, `post_build.sh`. | +| `prepare` | `contents: read`. No OIDC, no secrets. | Validation only: no build, no repository hooks. | +| `build` / `custom-build-*` | `contents: read`. No OIDC, no secrets. | Your repository's code: the build backend, its hooks, and any [custom build workflow](#custom-builds). | +| `collect` | `contents: read`. No OIDC, no secrets. | Artifact verification and `post_build.sh`. | | `publish` | `id-token: write` — the only job that can mint a PyPI token. | Nothing from your repository. Downloads the artifact, checks it, runs `uv publish`. | | `tag-and-release` | `contents: write`. | `git` and `gh`, after a successful upload. | | `materialize` (dispatch) | `contents`/`pull-requests`/`actions: write`. | towncrier and `git`/`gh`; writes changelogs, opens the PR. | -The important split is the first two rows: **arbitrary repository code executes -only where there is nothing to steal**, and the job holding the credential runs -no repository code at all. A malicious build backend or `post_build.sh` can -corrupt the artifact — a reviewer approving it is the control — but it cannot -reach the token. +The important split is between the build rows and `publish`: **arbitrary +repository code executes only where there is nothing to steal**, and the job +holding the credential runs no repository code at all. A malicious build +backend, custom build workflow or `post_build.sh` can corrupt the artifact — a +reviewer approving it is the control — but it cannot reach the token. Delegating +a build to your own workflow does not move that line: a called workflow inherits +the calling job's `contents: read` and cannot ask for more. Every checkout uses `persist-credentials: false`, no `${{ }}` expression is interpolated into a shell script (inputs travel through `env:`), and no workflow @@ -372,7 +392,7 @@ Either way, the reviewer list is the control worth auditing. The reviewer approves the `publish` job of a specific run. At that point the version, the changelog section and the built artifact already exist and are visible in the run. **Check the version and the package in the run name**, and -that the run was triggered by a merge you recognize. The build job's summary +that the run was triggered by a merge you recognize. The `collect` job's summary lists the SHA-256 of every file that will be uploaded, so what you are approving is named down to the byte before you approve it. @@ -381,12 +401,15 @@ metadata against the release: not just the version — which lockstep siblings share — but the **distribution name**, since every package in a repository publishes through the same trusted-publishing identity and nothing else would stop a misconfigured build from uploading one package under another's approval. +For a build spread over a matrix, `expect-artifacts` additionally requires the +set to be complete, so a leg that silently produced nothing stops the release +rather than shipping a version that can never be completed. The `SHA256SUMS` manifest travels *inside* the same artifact, so it proves the upload matches the build — it is an integrity check against truncation and -partial downloads, **not** a defense against a compromised build job, which -could write both the files and the manifest. The defense there is that the -artifact can only be written by the build job of the same run, and that the run +partial downloads, **not** a defense against a compromised build, which could +write both the files and the manifest. The defense there is that the artifact +can only be written by the unprivileged jobs of the same run, and that the run is triggered by a branch your ruleset controls. The manifest is attached to the GitHub release as well, so the record of what a @@ -500,22 +523,168 @@ weigh it against [Ways to weaken it](#ways-to-weaken-it). They need no `news/` d from the fragment check. Adding or removing one changes `auto_release_internal.yml`, so re-run `reflex-release sync`. +## Custom builds + +Some packages cannot be built by `uv build` on one runner: a project shipping +compiled extensions needs one job per platform, each on its own operating +system. Those packages hand the build to a workflow **your repository owns**, +and the pipeline keeps everything either side of it: + +```toml +[[tool.reflex-release.custom-build]] +packages = ["mypkg"] +workflow = "build_wheels.yml" +``` + +`publish.yml` then calls `.github/workflows/build_wheels.yml` in place of its +own build job for `mypkg`, and nothing else about the release changes — same +changelog detection, same version, same approval gate, same tag-after-upload. +The whole matrix runs **before** the gate: the reviewer approves a set of files +that already exists and has already been checked. + +### The contract + +Your workflow declares these inputs, and uploads what it builds: + +```yaml +name: Build wheels + +on: + workflow_call: + inputs: + package: + description: "Package being built" + required: true + type: string + version: + description: "Version being released (no v prefix)" + required: true + type: string + tag: + description: "Tag the checkout with this so the build derives that version" + required: true + type: string + build-dir: + description: "Repo-relative directory of the package" + required: true + type: string + artifact-prefix: + description: "Name every uploaded artifact " + required: true + type: string + +jobs: + wheels: + strategy: + fail-fast: false + matrix: + include: + - { os: ubuntu-latest, target: linux-x86_64 } + - { os: macos-latest, target: macos-arm64 } + - { os: windows-latest, target: windows-x64 } + runs-on: ${{ matrix.os }} + steps: + - uses: actions/checkout@v6 + with: + fetch-tags: true + fetch-depth: 0 + persist-credentials: false + + # The dynamic-versioning backend reads the version off the newest tag, so + # tagging the local checkout is what makes the wheels carry `version`. + - run: git tag "$TAG" + env: + TAG: ${{ inputs.tag }} + + - uses: pypa/cibuildwheel@v3 + with: + package-dir: ${{ inputs.build-dir }} + + - uses: actions/upload-artifact@v7 + with: + name: ${{ inputs.artifact-prefix }}${{ matrix.target }} + path: wheelhouse/*.whl + if-no-files-found: error + overwrite: true +``` + +Four rules, all of them load-bearing: + +1. **Name every artifact `${{ inputs.artifact-prefix }}`.** The prefix + already ends in the separator the publish workflow globs on, so appending + anything unique per matrix leg is enough. Anything not matching that prefix + is not collected, and therefore not published. +2. **Upload the distribution files themselves** — wheels and sdists, nothing + else. Every file that lands in `dist/` is checked and uploaded to PyPI, so a + build log or a `.zip` of debug symbols fails the release. +3. **Tag the checkout with `tag`** (or otherwise make the build produce + `version`). If the artifacts carry a different version, `verify-dist` fails + the release before the gate rather than shipping `0.0.0dev0`. +4. **Let failures fail.** Every job your workflow starts must succeed; a lost + matrix leg fails the called workflow, which skips the gate and turns the run + red. Do not paper over a leg with `continue-on-error`. + +`fail-fast: false` is a good idea but not required — it only decides whether +the sibling legs are cancelled when one fails, not whether the release stops. + +### Requiring the full set + +A matrix leg that runs, succeeds and uploads nothing is indistinguishable from +one you never configured — and an incomplete upload cannot be taken back, +because PyPI accepts a version once. Name what the release must contain: + +```toml +[[tool.reflex-release.custom-build]] +packages = ["mypkg"] +workflow = "build_wheels.yml" +expect-artifacts = ["*.tar.gz", "*-manylinux*_x86_64.whl", "*-macosx_*_arm64.whl"] +``` + +Each pattern is matched against the built filenames, and each one must match at +least one file or the release stops before the approval. + +### What stays the pipeline's job + +Only the build is delegated. Before your workflow runs, `prepare` has validated +the request against the changelog, the branch rules, the lockstep invariant and +the existing tags, and computed the version. After it, `collect` verifies every +file is that package at that version, runs your `post_build.sh` hook, extracts +the release notes and writes the SHA-256 manifest the approver sees. + +Two constraints follow from where the build sits: + +- **No secrets, no OIDC.** The calling job grants `contents: read`, and a called + workflow cannot hold more privilege than its caller grants — so a custom build + is inside the same trust boundary as the built-in one. A build needing a + credential does not belong here. +- **Not compatible with `pin-exact`.** That rewrites the package's + `pyproject.toml` in the build checkout, and your workflow builds from a + checkout this pipeline never touches. Configuring both is rejected at load + time. + +`reflex-release sync` (and so `sync --check` on every pull request) fails if a +configured build workflow is missing or has no `workflow_call` trigger, so a +renamed file is a red PR rather than a failed release. + ## Post-build hook Create `.github/scripts/publish/post_build.sh` for repository-specific artifact -checks. It runs in the unprivileged build job — after a successful build, with -`PACKAGE`, `VERSION` and `BUILD_DIR` in the environment, no secrets and no OIDC -— and a non-zero exit fails the release before anything is uploaded: +checks. It runs in the unprivileged `collect` job — after a successful build, +with `PACKAGE`, `VERSION`, `BUILD_DIR` (the package's directory in the checkout) +and `DIST_DIR` (where the built files are) in the environment, no secrets and no +OIDC — and a non-zero exit fails the release before anything is uploaded: ```bash #!/usr/bin/env bash set -euo pipefail [[ "$PACKAGE" == "mypkg" ]] || exit 0 -unzip -l "$BUILD_DIR"/dist/*.whl | grep -q '\.pyi$' || { +unzip -l "$DIST_DIR"/*.whl | grep -q '\.pyi$' || { echo "Error: no .pyi files in the wheel"; exit 1 } ``` +It runs for custom builds too, on exactly the files that were collected. + ## Keeping the workflows current Bump `cli-command` in `pyproject.toml`, run `reflex-release sync`, commit the diff --git a/packages/reflex-release/news/6883.feature.md b/packages/reflex-release/news/6883.feature.md new file mode 100644 index 00000000000..efb728788ed --- /dev/null +++ b/packages/reflex-release/news/6883.feature.md @@ -0,0 +1 @@ +`reflex-release` can now delegate a package's build to a workflow the consuming repository owns, for packages whose artifacts cannot come from a single `uv build` — a matrix of platform-specific wheels, say. A `[[tool.reflex-release.custom-build]]` entry names the packages and the workflow file, and the generated `publish.yml` calls it in place of its own build job, passing the package, version, tag, build directory and the artifact-name prefix to upload under. Everything either side of the build is unchanged: the whole matrix runs before the approval gate, in the same unprivileged trust boundary (`contents: read`, no secrets, no OIDC — a called workflow cannot hold more than its caller grants), and every file it produces is verified against the release, hashed into the manifest the approver sees, and published only after that approval. A failed leg fails the release rather than uploading a partial set, and `expect-artifacts` can additionally require the built set to match a list of filename patterns so a leg that silently produced nothing is caught too. `reflex-release sync` fails when a configured build workflow is missing or declares no `workflow_call` trigger, so a renamed file is a red pull request instead of a failed release. diff --git a/packages/reflex-release/src/reflex_release/commands.py b/packages/reflex-release/src/reflex_release/commands.py index 2d92550c7e7..01389bd5341 100644 --- a/packages/reflex-release/src/reflex_release/commands.py +++ b/packages/reflex-release/src/reflex_release/commands.py @@ -512,7 +512,12 @@ def cmd_verify_dist(config: Config, package: str, version: str, dist_dir: str) - """ config.require_known(package) distribution = config.distribution_name(package) - count = verify_dist(config.root / dist_dir, distribution, Version(version)) + count = verify_dist( + config.root / dist_dir, + distribution, + Version(version), + config.expect_artifacts(package), + ) echo(f"{count} artifact(s) of {distribution} at version {version}") diff --git a/packages/reflex-release/src/reflex_release/config.py b/packages/reflex-release/src/reflex_release/config.py index cf87fef18e9..1fb34466d7d 100644 --- a/packages/reflex-release/src/reflex_release/config.py +++ b/packages/reflex-release/src/reflex_release/config.py @@ -9,6 +9,7 @@ from __future__ import annotations import dataclasses +import re import sys from pathlib import Path @@ -26,6 +27,7 @@ _KNOWN_KEYS = frozenset({ "allow-self-review", "cli-command", + "custom-build", "dispatch-package-inputs", "root-package", "root-source-dirs", @@ -45,6 +47,42 @@ _KNOWN_LOCKSTEP_KEYS = frozenset({"members", "publish-last", "pin-exact"}) +_KNOWN_CUSTOM_BUILD_KEYS = frozenset({"packages", "workflow", "expect-artifacts"}) + + +@dataclasses.dataclass(frozen=True) +class CustomBuild: + """A repository-supplied workflow that builds some packages' artifacts. + + Packages whose artifacts cannot come from a plain ``uv build`` — a matrix of + platform-specific wheels, say — delegate the build to a workflow the + repository owns. It runs unprivileged, before the approval gate, like the + built-in build job; everything it uploads is verified against the release + before a reviewer ever sees it. + + Attributes: + packages: The packages built by this workflow. + workflow: The workflow's filename under ``.github/workflows``. + expect_artifacts: Filename glob patterns that must each match at least + one built file, so a matrix leg that quietly produced nothing fails + the release instead of publishing an incomplete set. + """ + + packages: tuple[str, ...] + workflow: str + expect_artifacts: tuple[str, ...] = () + + @property + def job_id(self) -> str: + """Return the publish-workflow job id that calls this workflow. + + Returns: + The workflow filename reduced to the characters GitHub allows in a + job id, behind a fixed prefix. + """ + stem = re.sub(r"[^A-Za-z0-9_-]+", "-", self.workflow.rpartition(".")[0]) + return f"custom-build-{stem}" + @dataclasses.dataclass(frozen=True) class LockstepGroup: @@ -107,6 +145,7 @@ class Config: changelog_exempt_packages: Packages excluded from the pull-request news fragment requirement. lockstep: The lockstep groups. + custom_build: The repository-supplied build workflows. """ root: Path @@ -129,6 +168,7 @@ class Config: internal_packages: tuple[str, ...] = () changelog_exempt_packages: tuple[str, ...] = () lockstep: tuple[LockstepGroup, ...] = () + custom_build: tuple[CustomBuild, ...] = () def package_dir(self, package: str) -> str: """Return the repo-relative directory of a package. @@ -400,6 +440,42 @@ def exact_pin_targets(self, package: str) -> tuple[str, ...]: return () return tuple(member for member in group.members if member != package) + def custom_build_for(self, package: str) -> CustomBuild | None: + """Return the repository-supplied workflow that builds a package. + + Args: + package: The package name. + + Returns: + The entry, or None when the package builds with ``uv build``. + """ + return next( + (entry for entry in self.custom_build if package in entry.packages), None + ) + + def custom_build_packages(self) -> tuple[str, ...]: + """List every package built by a repository-supplied workflow. + + Returns: + The package names, in configuration order. + """ + return tuple( + package for entry in self.custom_build for package in entry.packages + ) + + def expect_artifacts(self, package: str) -> tuple[str, ...]: + """Return the filename patterns a package's build must produce. + + Args: + package: The package being released. + + Returns: + The configured glob patterns, or an empty tuple when the build is + only required to produce artifacts of the right name and version. + """ + entry = self.custom_build_for(package) + return entry.expect_artifacts if entry is not None else () + def branch_allows_publish(self, version: Version, ref_name: str) -> bool: """Return whether a version may be published from a branch. @@ -572,6 +648,83 @@ def _load_lockstep(table: dict, packages: list[str]) -> tuple[LockstepGroup, ... return tuple(groups) +def _load_custom_build(table: dict, config: Config) -> tuple[CustomBuild, ...]: + """Parse and validate the ``[[tool.reflex-release.custom-build]]`` entries. + + Args: + table: The ``[tool.reflex-release]`` table. + config: The configuration loaded so far, with its lockstep groups. + + Returns: + The validated custom build entries. + """ + entries = table.get("custom-build", []) + if not isinstance(entries, list): + fail(f"[[tool.{TOOL_TABLE}.custom-build]] must be an array of tables") + + packages = config.all_packages() + builds: list[CustomBuild] = [] + seen: set[str] = set() + workflows: dict[str, str] = {} + for entry in entries: + if not isinstance(entry, dict): + fail(f"[[tool.{TOOL_TABLE}.custom-build]] must be an array of tables") + if unknown := sorted(set(entry) - _KNOWN_CUSTOM_BUILD_KEYS): + fail( + f"unknown key(s) in [[tool.{TOOL_TABLE}.custom-build]]: " + f"{', '.join(unknown)}" + ) + members = _string_list(entry, "packages") + if not members: + fail( + f"a [[tool.{TOOL_TABLE}.custom-build]] entry needs at least one " + "package in `packages`" + ) + workflow = _string(entry, "workflow", "") + if not workflow.endswith((".yml", ".yaml")) or "/" in workflow: + fail( + f"[[tool.{TOOL_TABLE}.custom-build]] workflow must be a bare " + 'filename under .github/workflows, e.g. "build_wheels.yml" ' + f"(got {workflow!r})" + ) + build = CustomBuild( + packages=members, + workflow=workflow, + expect_artifacts=_string_list(entry, "expect-artifacts"), + ) + # One job per entry in the generated publish workflow, so two entries + # whose workflows share a job id would silently collapse into one. + if previous := workflows.get(build.job_id): + fail( + f"[[tool.{TOOL_TABLE}.custom-build]] entries for {previous} and " + f"{workflow} produce the same publish job ({build.job_id}); list " + "every package one workflow builds in a single entry" + ) + workflows[build.job_id] = workflow + for member in members: + if member not in packages: + fail( + f"custom-build package {member!r} is not a package in this " + "repository" + ) + if member in seen: + fail(f"package {member!r} appears in more than one custom-build entry") + seen.add(member) + # pin-exact rewrites the package's pyproject.toml in the build + # checkout; a custom build workflow builds from a checkout this + # pipeline never touches, so the pin would silently not be applied. + if config.exact_pin_targets(member): + fail( + f"{member} pins its lockstep siblings exactly (pin-exact), " + "which rewrites its pyproject.toml in the build checkout — a " + "custom build workflow builds from its own checkout, so the " + "pin would never be applied. Drop pin-exact or build " + f"{member} in-repo." + ) + builds.append(build) + return tuple(builds) + + def _default_root_source_dirs(root: Path, root_package: str | None) -> tuple[str, ...]: """Guess the root package's source directories. @@ -715,4 +868,7 @@ def load_config(root: Path) -> Config: if latest is not None and latest not in packages: fail(f"[tool.{TOOL_TABLE}] latest-release-package is unknown: {latest!r}") - return dataclasses.replace(config, lockstep=_load_lockstep(table, packages)) + # Custom builds are validated against the lockstep groups, so they load + # onto a configuration that already carries them. + config = dataclasses.replace(config, lockstep=_load_lockstep(table, packages)) + return dataclasses.replace(config, custom_build=_load_custom_build(table, config)) diff --git a/packages/reflex-release/src/reflex_release/dist.py b/packages/reflex-release/src/reflex_release/dist.py index 01d161f2f96..7a7f691aabb 100644 --- a/packages/reflex-release/src/reflex_release/dist.py +++ b/packages/reflex-release/src/reflex_release/dist.py @@ -2,10 +2,12 @@ from __future__ import annotations +import fnmatch import re import sys import tarfile import zipfile +from collections.abc import Sequence from pathlib import Path from packaging.version import InvalidVersion, Version @@ -77,7 +79,12 @@ def dist_metadata(path: Path) -> tuple[str, str]: return fields["Name"], fields["Version"] -def verify_dist(dist_dir: Path, distribution: str, target: Version) -> int: +def verify_dist( + dist_dir: Path, + distribution: str, + target: Version, + expect: Sequence[str] = (), +) -> int: """Verify every built artifact is the expected distribution at the target version. Catches a misconfigured dynamic-versioning tag prefix building e.g. @@ -91,6 +98,10 @@ def verify_dist(dist_dir: Path, distribution: str, target: Version) -> int: dist_dir: The directory holding the built artifacts. distribution: The distribution name every artifact must declare. target: The version every artifact must declare. + expect: Filename glob patterns that must each match at least one + artifact. A build spread over a matrix of platforms is only correct + if every leg contributed, and a leg that produced nothing is + otherwise indistinguishable from one that was never configured. Returns: The number of artifacts verified. @@ -119,6 +130,14 @@ def verify_dist(dist_dir: Path, distribution: str, target: Version) -> int: fail(f"artifact {path.name} has unparsable version {raw!r}") if found != target: fail(f"artifact {path.name} has version {found}, expected {target}") + for pattern in expect: + if not any(fnmatch.fnmatchcase(path.name, pattern) for path in files): + fail( + f"no built artifact matches the expected pattern {pattern!r}; the " + f"build produced: {', '.join(path.name for path in files)}. " + "Publishing an incomplete set of artifacts is not recoverable — a " + "version can only be uploaded once — so this stops the release." + ) return len(files) diff --git a/packages/reflex-release/src/reflex_release/scaffold.py b/packages/reflex-release/src/reflex_release/scaffold.py index 4224c91ad8d..188dcd175dc 100644 --- a/packages/reflex-release/src/reflex_release/scaffold.py +++ b/packages/reflex-release/src/reflex_release/scaffold.py @@ -25,6 +25,7 @@ import dataclasses import difflib +import json import re import subprocess from importlib import metadata @@ -34,7 +35,7 @@ from .actions import echo, fail from .changelog import parse_sections, render_heading -from .config import Config, load_config, load_pyproject +from .config import TOOL_TABLE, Config, load_config, load_pyproject from .discovery import releasable_packages, title_format from .versions import ACTIONS @@ -59,6 +60,33 @@ TEMPLATE_DIR = Path(__file__).parent / "templates" / "workflows" +#: The ``workflow_call`` interface a custom build workflow has to declare. +CUSTOM_BUILD_CONTRACT = """\ +on: + workflow_call: + inputs: + package: + description: "Package being built" + required: true + type: string + version: + description: "Version being released (no v prefix)" + required: true + type: string + tag: + description: "Tag the checkout with this so the build derives that version" + required: true + type: string + build-dir: + description: "Repo-relative directory of the package" + required: true + type: string + artifact-prefix: + description: "Name every uploaded artifact " + required: true + type: string\ +""" + _GITHUB_REMOTE_RE = re.compile( r"github\.com[:/](?P[^/]+)/(?P[^/]+?)(?:\.git)?$" ) @@ -261,6 +289,130 @@ def _indented_list(items: list[str], indent: int) -> str: return "\n".join(f"{' ' * indent}- {item}" for item in items) +def _selects_package(packages: tuple[str, ...]) -> str: + """Render the workflow expression matching a set of packages. + + Args: + packages: The package names to match. + + Returns: + A ``contains(fromJson(...), inputs.package)`` expression. + """ + listing = json.dumps(list(packages), separators=(",", ":")) + return f"contains(fromJson('{listing}'), inputs.package)" + + +def _custom_build_note(config: Config) -> str: + """Render the header comment describing the custom build stage. + + Args: + config: The repository configuration. + + Returns: + The comment lines, or an empty string for a repository that builds + every package with ``uv build``. + """ + if not config.custom_build: + return "" + return "".join( + f"{line}\n" + for line in ( + "# custom-build-* unprivileged: packages configured with a custom", + "# build workflow build there instead of in `build`.", + "# The workflow is this repository's own file, and", + "# uploads its distribution files as artifacts named", + "# after the `artifact-prefix` input it is given.", + ) + ) + + +def _default_build_guard(config: Config) -> str: + """Render the clause keeping custom-built packages out of the build job. + + Args: + config: The repository configuration. + + Returns: + The extra ``if`` condition, or an empty string. + """ + packages = config.custom_build_packages() + if not packages: + return "" + return f" &&\n !{_selects_package(packages)}" + + +def _custom_dev_pin_step(config: Config) -> str: + """Render the dev-pin gate for packages that skip the built-in build job. + + Args: + config: The repository configuration. + + Returns: + The step, indented under the prepare job's ``steps``, or an empty + string. + """ + packages = config.custom_build_packages() + if not packages: + return "" + return ( + "\n" + + "\n".join([ + " # Custom-built packages never reach the `build` job, where this gate", + " # normally runs after the lockstep pin rewrites their metadata. They", + " # cannot be exact-pin lockstep members, so nothing rewrites theirs", + " # and the gate belongs here — still before anything is built.", + " - name: Reject development-release dependency pins", + " if: >-", + " steps.prepare.outputs.skipped != 'true' &&", + f" {_selects_package(packages)}", + " env:", + " PACKAGE: ${{ inputs.package }}", + f' run: {config.cli_command} check-dev-pins "$PACKAGE"', + ]) + + "\n" + ) + + +def _custom_build_jobs(config: Config) -> str: + """Render one publish-workflow job per custom build workflow. + + Args: + config: The repository configuration. + + Returns: + The job blocks, or an empty string. + """ + if not config.custom_build: + return "" + blocks = [ + "\n".join([ + " # Repository-supplied build, replacing the `build` job for:", + f" # {', '.join(entry.packages)}", + " # A called workflow can hold no more privilege than the calling job", + " # grants it, so this runs inside the same unprivileged boundary as", + " # `build`: no secrets, no OIDC, and everything it produces is verified", + " # by `collect` before the approval gate. Every job it starts has to", + " # succeed, so a lost matrix leg stops the release.", + f" {entry.job_id}:", + " needs: prepare", + " if: >-", + " needs.prepare.outputs.skipped != 'true' &&", + f" {_selects_package(entry.packages)}", + " permissions:", + " contents: read", + f" uses: ./{WORKFLOW_DIR}/{entry.workflow}", + " with:", + " package: ${{ inputs.package }}", + " version: ${{ needs.prepare.outputs.version }}", + " tag: ${{ needs.prepare.outputs.tag }}", + " build-dir: ${{ needs.prepare.outputs.build_dir }}", + " artifact-prefix: dist-${{ inputs.package }}--", + ]) + for entry in config.custom_build + ] + return "\n" + "\n\n".join(blocks) + "\n" + + def render(name: str, config: Config) -> str: """Render one workflow template for a repository. @@ -301,6 +453,13 @@ def render(name: str, config: Config) -> str: "@@PACKAGE_INPUTS@@": _package_input_block(config), "@@PACKAGE_SELECTION@@": _package_selection_block(config), "@@INTERNAL_PATHS@@": _indented_list(internal_paths, 6), + "@@CUSTOM_BUILD_NOTE@@": _custom_build_note(config), + "@@CUSTOM_DEV_PIN_STEP@@": _custom_dev_pin_step(config), + "@@DEFAULT_BUILD_GUARD@@": _default_build_guard(config), + "@@CUSTOM_BUILD_JOBS@@": _custom_build_jobs(config), + "@@CUSTOM_BUILD_NEEDS@@": "".join( + f", {entry.job_id}" for entry in config.custom_build + ), } text = (TEMPLATE_DIR / name).read_text(encoding="utf-8") for placeholder, value in substitutions.items(): @@ -343,6 +502,41 @@ def check_title_format(config: Config) -> None: ) +def check_custom_build_workflows(config: Config) -> None: + """Fail unless every configured custom build workflow exists and is callable. + + The generated publish workflow calls these files by path, so a missing file + or a missing ``workflow_call`` trigger is a run that fails at release time. + Checking here means the pull-request drift check catches it instead. + + Args: + config: The repository configuration. + """ + generated = {*managed_workflows(config), *OPTIONAL_WORKFLOWS} + for entry in config.custom_build: + listing = ", ".join(entry.packages) + if entry.workflow in generated: + fail( + f"[[tool.{TOOL_TABLE}.custom-build]] names {entry.workflow}, which " + "reflex-release generates; a custom build workflow has to be a " + "separate file this repository owns" + ) + target = config.root / WORKFLOW_DIR / entry.workflow + if not target.is_file(): + fail( + f"{listing} builds through {WORKFLOW_DIR}/{entry.workflow}, which " + f"does not exist. Create it with:\n\n{CUSTOM_BUILD_CONTRACT}" + ) + if not re.search( + r"^\s*workflow_call:", target.read_text(encoding="utf-8"), re.MULTILINE + ): + fail( + f"{WORKFLOW_DIR}/{entry.workflow} declares no `workflow_call` " + f"trigger, so publish.yml cannot call it to build {listing}. It " + f"needs:\n\n{CUSTOM_BUILD_CONTRACT}" + ) + + def sync(config: Config, check: bool = False, force: bool = False) -> None: """Write the scaffolded workflows, or verify they are up to date. @@ -352,6 +546,7 @@ def sync(config: Config, check: bool = False, force: bool = False) -> None: force: Overwrite files that were not generated by this tool. """ check_title_format(config) + check_custom_build_workflows(config) workflow_dir = config.root / WORKFLOW_DIR stale: list[str] = [] for name in managed_workflows(config): 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 32abf688965..67599bcb8fe 100644 --- a/packages/reflex-release/src/reflex_release/templates/workflows/publish.yml +++ b/packages/reflex-release/src/reflex_release/templates/workflows/publish.yml @@ -3,14 +3,19 @@ name: Publish to PyPI run-name: Publish ${{ inputs.package }} ${{ inputs.version }} -# Publishes one package at one version, in three stages: +# Publishes one package at one version, in five stages: # -# build unprivileged: validates the request against the changelog +# prepare unprivileged: validates the request against the changelog # (source of truth), branch rules, lockstep invariant and -# existing tags; tags the local checkout (the dynamic -# versioning backend derives the version from it); builds; -# verifies the built metadata; extracts release notes; -# uploads everything as a workflow artifact. +# existing tags, then emits the version, tag and build +# directory every later job works from. +# build unprivileged: tags the local checkout (the dynamic +# versioning backend derives the version from it), builds, +# and uploads what it produced as a workflow artifact. +@@CUSTOM_BUILD_NOTE@@# collect unprivileged: gathers every file the build produced, +# verifies each one is this package at this version, extracts +# the release notes and writes the checksum manifest the +# approver sees. # publish gated by the `pypi` environment — a human reviewer must # approve every upload, alphas included. Holds the only # OIDC (id-token) privilege and runs nothing but @@ -21,6 +26,10 @@ run-name: Publish ${{ inputs.package }} ${{ inputs.version }} # bump and the release-from-changelog workflow retries on # the next push. # +# Every build job runs before the approval gate and none of them holds a +# credential, so a build that fails — any leg of a matrix included — stops the +# release instead of publishing an incomplete set. +# # REQUIRED CONFIGURATION: # - The `pypi` environment MUST have required reviewers configured # (Settings -> Environments -> pypi). The publish job asserts this via the @@ -31,8 +40,8 @@ run-name: Publish ${{ inputs.package }} ${{ inputs.version }} # - If the `pypi` environment restricts deployment branches, allow # @@MAIN_BRANCH@@, @@PRERELEASE_PREFIX@@* and @@HOTFIX_PREFIX@@*. # - Optional: a `.github/scripts/publish/post_build.sh` hook runs after the -# build with PACKAGE, VERSION and BUILD_DIR in the environment — use it for -# repository-specific artifact checks. +# build with PACKAGE, VERSION, BUILD_DIR and DIST_DIR in the environment — +# use it for repository-specific artifact checks. on: workflow_call: @@ -69,7 +78,7 @@ concurrency: cancel-in-progress: false jobs: - build: + prepare: runs-on: ubuntu-latest permissions: contents: read @@ -77,6 +86,7 @@ jobs: skipped: ${{ steps.prepare.outputs.skipped }} version: ${{ steps.prepare.outputs.version }} tag: ${{ steps.prepare.outputs.tag }} + build_dir: ${{ steps.prepare.outputs.build_dir }} prerelease: ${{ steps.prepare.outputs.prerelease }} mark_latest: ${{ steps.prepare.outputs.mark_latest }} steps: @@ -97,20 +107,37 @@ jobs: VERSION: ${{ inputs.version }} REF_NAME: ${{ github.ref_name }} run: @@CLI@@ prepare-publish +@@CUSTOM_DEV_PIN_STEP@@ + build: + needs: prepare + if: >- + needs.prepare.outputs.skipped != 'true'@@DEFAULT_BUILD_GUARD@@ + runs-on: ubuntu-latest + permissions: + contents: read + steps: + - name: Checkout + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + fetch-tags: true + fetch-depth: 0 + persist-credentials: false + + - name: Install uv + uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0 - name: Pin lockstep siblings to exact versions - if: steps.prepare.outputs.skipped != 'true' env: PACKAGE: ${{ inputs.package }} - VERSION: ${{ steps.prepare.outputs.version }} + VERSION: ${{ needs.prepare.outputs.version }} run: @@CLI@@ pin-lockstep # A *.dev dependency pin references an unpublished version, so it must # never reach released package metadata. Scoped to the package being # published so a dependency can still be released while dependents - # temporarily dev-pin it. + # temporarily dev-pin it. It runs after the lockstep pin, which is what + # turns a sibling's dev pin into the exact version being released. - name: Reject development-release dependency pins - if: steps.prepare.outputs.skipped != 'true' env: PACKAGE: ${{ inputs.package }} run: @@CLI@@ check-dev-pins "$PACKAGE" @@ -120,9 +147,8 @@ jobs: # is what selects the version being built. The tag is only pushed after a # successful upload (tag-and-release job). - name: Tag local checkout - if: steps.prepare.outputs.skipped != 'true' env: - TAG: ${{ steps.prepare.outputs.tag }} + TAG: ${{ needs.prepare.outputs.tag }} run: git tag "$TAG" # --out-dir is absolute so the artifacts land in the repository root @@ -130,31 +156,87 @@ jobs: # build into the workspace root and a standalone package into its own # directory. - name: Build - if: steps.prepare.outputs.skipped != 'true' env: - BUILD_DIR: ${{ steps.prepare.outputs.build_dir }} + BUILD_DIR: ${{ needs.prepare.outputs.build_dir }} run: uv build --directory "$BUILD_DIR" --out-dir "$GITHUB_WORKSPACE/dist" + - name: Upload the built distribution files + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: dist-${{ inputs.package }}--build + path: dist/* + if-no-files-found: error + overwrite: true +@@CUSTOM_BUILD_JOBS@@ + # Everything that turns "some files exist" into "these exact files are what + # the reviewer is approving": the metadata check, the repository's own hook, + # the release notes and the checksum manifest. One code path, whichever job + # produced the files. + collect: + needs: [prepare, build@@CUSTOM_BUILD_NEEDS@@] + # Exactly one build path runs for a given package, so the others report + # 'skipped' — which this tolerates while still failing closed on a build + # that errored, was cancelled, or (in a matrix) lost a single leg. + if: >- + !cancelled() && !failure() && + needs.prepare.outputs.skipped != 'true' + runs-on: ubuntu-latest + permissions: + contents: read + steps: + - name: Checkout + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + persist-credentials: false + + - name: Install uv + uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0 + + # One artifact per build job — a platform matrix uploads one per leg — + # merged into a single dist/. The `--` in the pattern is what keeps a + # package from collecting a sibling's artifacts when its name is a prefix + # of theirs (mypkg vs mypkg-base). + - name: Download the built distribution files + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + pattern: dist-${{ inputs.package }}--* + path: dist + merge-multiple: true + + - name: Flatten the downloaded artifacts + run: | + set -euo pipefail + # mkdir, because no matching artifact leaves nothing behind at all — + # verify-dist reports that far better than `find` reports a missing + # directory. + mkdir -p dist + # `uv publish dist/*` uploads files, not directories, so a build + # workflow that kept its files in a subdirectory is normalized here + # rather than failing the release over a layout detail. A name + # collision leaves the directory non-empty and fails the step. + find dist -mindepth 2 -type f -exec mv -n -t dist -- {} + + find dist -mindepth 1 -type d -delete + ls -l dist + - name: Verify built artifact names and versions - if: steps.prepare.outputs.skipped != 'true' env: PACKAGE: ${{ inputs.package }} - VERSION: ${{ steps.prepare.outputs.version }} + VERSION: ${{ needs.prepare.outputs.version }} run: @@CLI@@ verify-dist - name: Repository-specific post-build checks - if: steps.prepare.outputs.skipped != 'true' && hashFiles('.github/scripts/publish/post_build.sh') != '' + if: hashFiles('.github/scripts/publish/post_build.sh') != '' env: PACKAGE: ${{ inputs.package }} - VERSION: ${{ steps.prepare.outputs.version }} - BUILD_DIR: ${{ steps.prepare.outputs.build_dir }} + VERSION: ${{ needs.prepare.outputs.version }} + BUILD_DIR: ${{ needs.prepare.outputs.build_dir }} + DIST_DIR: dist run: bash .github/scripts/publish/post_build.sh - name: Extract release notes from changelog - if: steps.prepare.outputs.skipped != 'true' env: PACKAGE: ${{ inputs.package }} - VERSION: ${{ steps.prepare.outputs.version }} + VERSION: ${{ needs.prepare.outputs.version }} NOTES_PATH: release_notes.md run: @@CLI@@ extract-notes @@ -164,7 +246,6 @@ jobs: # front of the approver (job summary), and it is attached to the GitHub # release so the record of what a version contains outlives this run. - name: Write checksum manifest - if: steps.prepare.outputs.skipped != 'true' run: | set -euo pipefail # Bare filenames, covering exactly the files that go to PyPI: the @@ -180,7 +261,6 @@ jobs: } >> "$GITHUB_STEP_SUMMARY" - name: Upload artifacts for the gated publish - if: steps.prepare.outputs.skipped != 'true' uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: name: publish-${{ inputs.package }} @@ -194,10 +274,10 @@ jobs: # The human approval gate: this job targets the `pypi` environment, whose # required reviewers must approve before it starts. It holds the only OIDC # privilege and deliberately runs no repository code and resolves no script - # dependencies — it only uploads the artifact built above. + # dependencies — it only uploads the artifact collected above. publish: - needs: build - if: needs.build.outputs.skipped != 'true' + needs: [prepare, collect] + if: needs.prepare.outputs.skipped != 'true' runs-on: ubuntu-latest environment: name: pypi @@ -268,7 +348,7 @@ jobs: run: uv publish --check-url https://pypi.org/simple/ dist/* tag-and-release: - needs: [build, publish] + needs: [prepare, publish] runs-on: ubuntu-latest permissions: contents: write @@ -290,17 +370,17 @@ jobs: - name: Push tag env: - TAG: ${{ needs.build.outputs.tag }} + TAG: ${{ needs.prepare.outputs.tag }} GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: @@CLI@@ push-tag - name: Create GitHub release env: - TAG: ${{ needs.build.outputs.tag }} + TAG: ${{ needs.prepare.outputs.tag }} PACKAGE: ${{ inputs.package }} - VERSION: ${{ needs.build.outputs.version }} - PRERELEASE: ${{ needs.build.outputs.prerelease }} - MARK_LATEST: ${{ needs.build.outputs.mark_latest }} + VERSION: ${{ needs.prepare.outputs.version }} + PRERELEASE: ${{ needs.prepare.outputs.prerelease }} + MARK_LATEST: ${{ needs.prepare.outputs.mark_latest }} NOTES_PATH: release_notes.md CHECKSUMS_PATH: SHA256SUMS GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/tests/units/reflex_release/conftest.py b/tests/units/reflex_release/conftest.py index 3997319ffc8..40a8426e1e0 100644 --- a/tests/units/reflex_release/conftest.py +++ b/tests/units/reflex_release/conftest.py @@ -75,6 +75,33 @@ def write_lockstep(repo: Path) -> None: ) +def write_custom_build(repo: Path, extra: str = "", workflow: bool = True) -> None: + """Configure a custom build workflow for the root package. + + Args: + repo: The repository root. + extra: Additional keys for the ``custom-build`` entry. + workflow: Whether to also create the referenced workflow file. + """ + pyproject = repo / "pyproject.toml" + pyproject.write_text( + pyproject.read_text(encoding="utf-8").replace( + "\n[tool.towncrier]", + "\n[[tool.reflex-release.custom-build]]\n" + 'packages = ["mypkg"]\n' + 'workflow = "build_wheels.yml"\n' + extra + "\n[tool.towncrier]", + ), + encoding="utf-8", + ) + if workflow: + workflows = repo / ".github" / "workflows" + workflows.mkdir(parents=True, exist_ok=True) + (workflows / "build_wheels.yml").write_text( + "name: Build wheels\non:\n workflow_call:\n inputs: {}\njobs: {}\n", + encoding="utf-8", + ) + + @pytest.fixture def repo(tmp_path: Path) -> Path: """Create a git repository with a root package and one sub-package. diff --git a/tests/units/reflex_release/test_config.py b/tests/units/reflex_release/test_config.py index 7be66da1b09..5187811b129 100644 --- a/tests/units/reflex_release/test_config.py +++ b/tests/units/reflex_release/test_config.py @@ -322,3 +322,152 @@ def test_requires_fragments(config: Config, repo: Path) -> None: ) def test_is_final(version: str, final: bool) -> None: assert is_final(Version(version)) is final + + +CUSTOM_BUILD = """\ +root-package = "mypkg" +packages-dir = "packages" + +[[tool.reflex-release.custom-build]] +packages = ["mypkg"] +workflow = "build_wheels.yml" +expect-artifacts = ["*.tar.gz"] +""" + + +def test_custom_build_is_resolved_per_package(config: Config, repo: Path) -> None: + write_config(repo, CUSTOM_BUILD) + reloaded = load_config(repo) + entry = reloaded.custom_build_for("mypkg") + assert entry is not None + assert entry.workflow == "build_wheels.yml" + assert reloaded.custom_build_packages() == ("mypkg",) + assert reloaded.expect_artifacts("mypkg") == ("*.tar.gz",) + # A package with no entry builds in-repo and carries no expectations. + assert reloaded.custom_build_for("widget-core") is None + assert reloaded.expect_artifacts("widget-core") == () + + +def test_custom_build_rejects_an_unknown_package(config: Config, repo: Path) -> None: + write_config( + repo, + 'root-package = "mypkg"\n\n[[tool.reflex-release.custom-build]]\n' + 'packages = ["nope"]\nworkflow = "build.yml"\n', + ) + with pytest.raises(ReleaseError, match="not a package in this repository"): + load_config(repo) + + +def test_custom_build_rejects_an_empty_package_list(config: Config, repo: Path) -> None: + write_config( + repo, + 'root-package = "mypkg"\n\n[[tool.reflex-release.custom-build]]\n' + 'packages = []\nworkflow = "build.yml"\n', + ) + with pytest.raises(ReleaseError, match="at least one package"): + load_config(repo) + + +@pytest.mark.parametrize("workflow", ["", "build", "ci/build.yml"]) +def test_custom_build_rejects_a_workflow_that_is_not_a_bare_filename( + config: Config, repo: Path, workflow: str +) -> None: + write_config( + repo, + 'root-package = "mypkg"\n\n[[tool.reflex-release.custom-build]]\n' + f'packages = ["mypkg"]\nworkflow = "{workflow}"\n', + ) + with pytest.raises(ReleaseError, match="must be a bare filename"): + load_config(repo) + + +def test_custom_build_rejects_a_package_listed_twice( + config: Config, repo: Path +) -> None: + write_config( + repo, + 'root-package = "mypkg"\npackages-dir = "packages"\n\n' + "[[tool.reflex-release.custom-build]]\n" + 'packages = ["mypkg"]\nworkflow = "one.yml"\n\n' + "[[tool.reflex-release.custom-build]]\n" + 'packages = ["mypkg", "widget-core"]\nworkflow = "two.yml"\n', + ) + with pytest.raises(ReleaseError, match="more than one custom-build entry"): + load_config(repo) + + +def test_custom_build_rejects_two_entries_sharing_a_workflow( + config: Config, repo: Path +) -> None: + """One job per entry, so a shared file would collide as a job id.""" + write_config( + repo, + 'root-package = "mypkg"\npackages-dir = "packages"\n\n' + "[[tool.reflex-release.custom-build]]\n" + 'packages = ["mypkg"]\nworkflow = "build.yml"\n\n' + "[[tool.reflex-release.custom-build]]\n" + 'packages = ["widget-core"]\nworkflow = "build.yml"\n', + ) + with pytest.raises(ReleaseError, match="same publish job"): + load_config(repo) + + +def test_custom_build_rejects_an_unknown_key(config: Config, repo: Path) -> None: + write_config( + repo, + 'root-package = "mypkg"\n\n[[tool.reflex-release.custom-build]]\n' + 'packages = ["mypkg"]\nworkflow = "build.yml"\nartifacts = ["x"]\n', + ) + with pytest.raises(ReleaseError, match="unknown key"): + load_config(repo) + + +def test_custom_build_is_incompatible_with_an_exact_lockstep_pin( + config: Config, repo: Path +) -> None: + """pin-exact rewrites a checkout the custom build workflow never sees.""" + write_config( + repo, + 'root-package = "mypkg"\npackages-dir = "packages"\n\n' + "[[tool.reflex-release.lockstep]]\n" + 'members = ["mypkg", "widget-core"]\n' + 'publish-last = ["mypkg"]\n' + "pin-exact = true\n\n" + "[[tool.reflex-release.custom-build]]\n" + 'packages = ["mypkg"]\nworkflow = "build.yml"\n', + ) + with pytest.raises(ReleaseError, match="pin-exact"): + load_config(repo) + + +def test_a_lockstep_member_that_does_not_pin_may_build_custom( + config: Config, repo: Path +) -> None: + """Only the pinning member rewrites metadata; its siblings are unaffected.""" + write_config( + repo, + 'root-package = "mypkg"\npackages-dir = "packages"\n\n' + "[[tool.reflex-release.lockstep]]\n" + 'members = ["mypkg", "widget-core"]\n' + 'publish-last = ["mypkg"]\n' + "pin-exact = true\n\n" + "[[tool.reflex-release.custom-build]]\n" + 'packages = ["widget-core"]\nworkflow = "build.yml"\n', + ) + assert load_config(repo).custom_build_packages() == ("widget-core",) + + +def test_custom_build_rejects_workflows_that_share_a_job_id( + config: Config, repo: Path +) -> None: + """Distinct filenames can still collapse into one generated job.""" + write_config( + repo, + 'root-package = "mypkg"\npackages-dir = "packages"\n\n' + "[[tool.reflex-release.custom-build]]\n" + 'packages = ["mypkg"]\nworkflow = "build.yml"\n\n' + "[[tool.reflex-release.custom-build]]\n" + 'packages = ["widget-core"]\nworkflow = "build.yaml"\n', + ) + with pytest.raises(ReleaseError, match="same publish job"): + load_config(repo) diff --git a/tests/units/reflex_release/test_dist.py b/tests/units/reflex_release/test_dist.py index 97e39e242d5..57f09ac25b0 100644 --- a/tests/units/reflex_release/test_dist.py +++ b/tests/units/reflex_release/test_dist.py @@ -151,3 +151,25 @@ def test_pin_exact_requires_exactly_one_match( ) with pytest.raises(ReleaseError, match="expected exactly one"): pin_exact(pyproject, "widget-core", Version("1.2.3")) + + +def test_expected_artifact_patterns_must_all_match(tmp_path: Path) -> None: + """A platform matrix is only complete if every leg contributed a file.""" + make_wheel(tmp_path, "1.2.3") + make_sdist(tmp_path, "1.2.3") + assert ( + verify_dist( + tmp_path, + "widget-core", + Version("1.2.3"), + ["*.tar.gz", "*-py3-none-any.whl"], + ) + == 2 + ) + + +def test_a_missing_expected_artifact_stops_the_release(tmp_path: Path) -> None: + """A matrix leg that produced nothing must not publish a partial set.""" + make_wheel(tmp_path, "1.2.3") + with pytest.raises(ReleaseError, match=r"\*macosx\*\.whl"): + verify_dist(tmp_path, "widget-core", Version("1.2.3"), ["*macosx*.whl"]) diff --git a/tests/units/reflex_release/test_scaffold.py b/tests/units/reflex_release/test_scaffold.py index a43f25d1680..34ab2db268b 100644 --- a/tests/units/reflex_release/test_scaffold.py +++ b/tests/units/reflex_release/test_scaffold.py @@ -26,7 +26,7 @@ ) from reflex_release.versions import ACTIONS -from .conftest import write_lockstep +from .conftest import write_custom_build, write_lockstep def test_render_substitutes_every_placeholder(config: Config) -> None: @@ -359,8 +359,7 @@ def test_release_branch_exemption_requires_the_bot_author(config: Config) -> Non def test_dev_pin_gate_runs_after_the_lockstep_pin(config: Config) -> None: """The gate has to judge the metadata the build actually emits.""" - steps = yaml.safe_load(render("publish.yml", config))["jobs"]["build"]["steps"] - names = [step.get("name", "") for step in steps] + names = [step.get("name", "") for step in _job_steps(config, "build")] assert names.index("Pin lockstep siblings to exact versions") < names.index( "Reject development-release dependency pins" ) @@ -468,20 +467,21 @@ def _approval_step(config: Config) -> dict: return yaml.safe_load(render("publish.yml", config))["jobs"]["publish"]["steps"][0] -def _build_steps(config: Config) -> list[dict]: - """Return the steps of the publish workflow's unprivileged build job. +def _job_steps(config: Config, job: str) -> list[dict]: + """Return the steps of one job of the publish workflow. Args: config: The repository configuration. + job: The job id. Returns: The parsed steps. """ - return yaml.safe_load(render("publish.yml", config))["jobs"]["build"]["steps"] + return yaml.safe_load(render("publish.yml", config))["jobs"][job]["steps"] def _manifest_step(config: Config) -> dict: - """Return the build step that writes the checksum manifest. + """Return the step that writes the checksum manifest. Args: config: The repository configuration. @@ -490,7 +490,9 @@ def _manifest_step(config: Config) -> dict: The parsed step. """ return next( - step for step in _build_steps(config) if "> SHA256SUMS" in step.get("run", "") + step + for step in _job_steps(config, "collect") + if "> SHA256SUMS" in step.get("run", "") ) @@ -518,7 +520,7 @@ def test_verify_dist_is_told_which_package_it_is_checking(config: Config) -> Non """Lockstep siblings share a version, so the name is what tells them apart.""" step = next( step - for step in _build_steps(config) + for step in _job_steps(config, "collect") if step.get("run", "").endswith("verify-dist") ) assert "PACKAGE" in step["env"] @@ -588,3 +590,178 @@ def test_init_keeps_existing_towncrier_configuration(tmp_path: Path) -> None: text = (tmp_path / "pyproject.toml").read_text(encoding="utf-8") assert text.count("[tool.towncrier]") == 1 assert 'directory = "changes"' in text + + +def test_publish_workflow_has_no_custom_build_job_by_default(config: Config) -> None: + document = yaml.safe_load(render("publish.yml", config)) + assert list(document["jobs"]) == [ + "prepare", + "build", + "collect", + "publish", + "tag-and-release", + ] + assert document["jobs"]["collect"]["needs"] == ["prepare", "build"] + assert document["jobs"]["build"]["if"].strip() == ( + "needs.prepare.outputs.skipped != 'true'" + ) + + +def test_a_custom_build_replaces_the_build_job_for_its_packages( + config: Config, repo: Path +) -> None: + write_custom_build(repo) + jobs = yaml.safe_load(render("publish.yml", load_config(repo)))["jobs"] + custom = jobs["custom-build-build_wheels"] + assert custom["uses"] == "./.github/workflows/build_wheels.yml" + # Selected for exactly the configured packages, and excluded from the + # built-in build job for the same ones. + assert "contains(fromJson('[\"mypkg\"]'), inputs.package)" in custom["if"] + assert "!contains(fromJson('[\"mypkg\"]'), inputs.package)" in jobs["build"]["if"] + + +def test_a_custom_build_is_unprivileged_and_gets_no_secrets( + config: Config, repo: Path +) -> None: + """It runs repository code, so it must stay on the build side of the gate.""" + write_custom_build(repo) + custom = yaml.safe_load(render("publish.yml", load_config(repo)))["jobs"][ + "custom-build-build_wheels" + ] + assert custom["permissions"] == {"contents": "read"} + assert "secrets" not in custom + + +def test_a_custom_build_is_told_the_version_tag_and_artifact_prefix( + config: Config, repo: Path +) -> None: + write_custom_build(repo) + inputs = yaml.safe_load(render("publish.yml", load_config(repo)))["jobs"][ + "custom-build-build_wheels" + ]["with"] + assert inputs["version"] == "${{ needs.prepare.outputs.version }}" + assert inputs["tag"] == "${{ needs.prepare.outputs.tag }}" + assert inputs["build-dir"] == "${{ needs.prepare.outputs.build_dir }}" + # The prefix ends in the separator the collect pattern matches on, so a + # package cannot pick up the artifacts of a sibling it is a prefix of. + assert inputs["artifact-prefix"] == "dist-${{ inputs.package }}--" + + +def test_collect_waits_for_every_build_path(config: Config, repo: Path) -> None: + """A failed matrix leg fails its workflow, which must stop the release.""" + write_custom_build(repo) + jobs = yaml.safe_load(render("publish.yml", load_config(repo)))["jobs"] + assert jobs["collect"]["needs"] == [ + "prepare", + "build", + "custom-build-build_wheels", + ] + # Tolerates the build path that was skipped, never one that failed. + condition = jobs["collect"]["if"] + assert "!cancelled()" in condition + assert "!failure()" in condition + + +def test_collect_gathers_every_artifact_of_the_package( + config: Config, repo: Path +) -> None: + write_custom_build(repo) + step = next( + step + for step in _job_steps(load_config(repo), "collect") + if str(step.get("uses", "")).startswith("actions/download-artifact") + ) + assert step["with"]["pattern"] == "dist-${{ inputs.package }}--*" + assert step["with"]["merge-multiple"] is True + + +def test_the_gate_covers_what_the_custom_build_produced( + config: Config, repo: Path +) -> None: + """The approval is over verified artifacts however they were built.""" + write_custom_build(repo) + jobs = yaml.safe_load(render("publish.yml", load_config(repo)))["jobs"] + assert jobs["publish"]["needs"] == ["prepare", "collect"] + assert jobs["publish"]["environment"]["name"] == "pypi" + names = [step.get("name", "") for step in jobs["collect"]["steps"]] + assert "Verify built artifact names and versions" in names + + +def test_custom_built_packages_still_pass_the_dev_pin_gate( + config: Config, repo: Path +) -> None: + """They skip the build job, where the gate normally runs.""" + write_custom_build(repo) + reloaded = load_config(repo) + step = next( + step + for step in _job_steps(reloaded, "prepare") + if step.get("name") == "Reject development-release dependency pins" + ) + assert "contains(fromJson('[\"mypkg\"]'), inputs.package)" in step["if"] + # Unconfigured repositories keep the gate in the build job alone. + assert not [ + step + for step in _job_steps(config, "prepare") + if step.get("name") == "Reject development-release dependency pins" + ] + + +def test_sync_rejects_a_missing_custom_build_workflow( + config: Config, repo: Path +) -> None: + write_custom_build(repo, workflow=False) + with pytest.raises(ReleaseError, match="does not exist"): + sync(load_config(repo)) + + +def test_sync_rejects_a_custom_build_workflow_that_cannot_be_called( + config: Config, repo: Path +) -> None: + write_custom_build(repo) + target = repo / WORKFLOW_DIR / "build_wheels.yml" + target.write_text("name: Build wheels\non:\n push:\n", encoding="utf-8") + with pytest.raises(ReleaseError, match="no `workflow_call` trigger"): + sync(load_config(repo)) + + +def test_sync_rejects_a_custom_build_naming_a_generated_workflow( + config: Config, repo: Path +) -> None: + """Pointing it at publish.yml would make the workflow call itself.""" + pyproject = repo / "pyproject.toml" + pyproject.write_text( + pyproject.read_text(encoding="utf-8").replace( + "\n[tool.towncrier]", + "\n[[tool.reflex-release.custom-build]]\n" + 'packages = ["mypkg"]\nworkflow = "publish.yml"\n\n[tool.towncrier]', + ), + encoding="utf-8", + ) + with pytest.raises(ReleaseError, match="reflex-release generates"): + sync(load_config(repo)) + + +def test_sync_round_trips_with_a_custom_build(config: Config, repo: Path) -> None: + write_custom_build(repo) + reloaded = load_config(repo) + sync(reloaded) + sync(reloaded, check=True) + + +@pytest.mark.parametrize("mode", ["checkboxes", "text"]) +def test_custom_build_workflows_are_valid_yaml( + config: Config, repo: Path, mode: str +) -> None: + write_custom_build(repo, extra='expect-artifacts = ["*.whl"]\n') + pyproject = repo / "pyproject.toml" + pyproject.write_text( + pyproject.read_text(encoding="utf-8").replace( + "[tool.reflex-release]", + f'[tool.reflex-release]\ndispatch-package-inputs = "{mode}"', + ), + encoding="utf-8", + ) + reloaded = load_config(repo) + for name in managed_workflows(reloaded): + assert yaml.safe_load(render(name, reloaded))["jobs"], name From 035d4fac7fab2861ffd2bd0f3e6d6caa3c5f17ff Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 14 Aug 2026 21:52:23 +0000 Subject: [PATCH 2/3] chore(reflex-release): name the news fragment for its pull request Towncrier fragments are named ..md, and the issue_format turns that number into the changelog's link. Renamed from a placeholder to the actual pull request. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01E1ujR3svpGxMwm5pBMrsXh --- packages/reflex-release/news/{6883.feature.md => 6891.feature.md} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename packages/reflex-release/news/{6883.feature.md => 6891.feature.md} (100%) diff --git a/packages/reflex-release/news/6883.feature.md b/packages/reflex-release/news/6891.feature.md similarity index 100% rename from packages/reflex-release/news/6883.feature.md rename to packages/reflex-release/news/6891.feature.md From 9c3ab1800720bf761b46d6d2f554a967fe6270db Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 14 Aug 2026 22:24:36 +0000 Subject: [PATCH 3/3] fix(reflex-release): address review feedback on the custom build path MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Validate that a custom build workflow declares all five contract inputs, not just a workflow_call trigger. GitHub rejects a call naming an undeclared input, so a renamed or forgotten input used to surface as a failed release; `sync --check` now makes it a red pull request. Read by indentation rather than with a YAML parser, which this tool deliberately does not carry on the release path — it is lenient, and what it misses GitHub still rejects before any job runs. - Restrict the workflow filename to a YAML-safe bare filename. It is interpolated into the generated `uses:` as a bare scalar, so a name carrying YAML structure produced altered workflow YAML instead of an error. - Give collect's checkout full history and tags. post_build.sh moved there from the build job, whose checkout has both, and a hook that inspects them has to keep working. Documented the one remaining difference: collect builds nothing, so the release tag is not applied locally. - Name the sub-table in type errors from [[lockstep]] and [[custom-build]] entries, which pointed at [tool.reflex-release] instead of the table the key actually lives in. Fixed in the shared helper, so the pre-existing lockstep case is covered too. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01E1ujR3svpGxMwm5pBMrsXh --- packages/reflex-release/README.md | 11 +- .../src/reflex_release/config.py | 46 +++++--- .../src/reflex_release/scaffold.py | 110 +++++++++++++++++- .../templates/workflows/publish.yml | 4 + tests/units/reflex_release/conftest.py | 4 +- tests/units/reflex_release/test_config.py | 63 ++++++++++ tests/units/reflex_release/test_scaffold.py | 78 +++++++++++++ 7 files changed, 295 insertions(+), 21 deletions(-) diff --git a/packages/reflex-release/README.md b/packages/reflex-release/README.md index 336b0285404..d28284ee75e 100644 --- a/packages/reflex-release/README.md +++ b/packages/reflex-release/README.md @@ -663,8 +663,10 @@ Two constraints follow from where the build sits: time. `reflex-release sync` (and so `sync --check` on every pull request) fails if a -configured build workflow is missing or has no `workflow_call` trigger, so a -renamed file is a red PR rather than a failed release. +configured build workflow is missing, has no `workflow_call` trigger, or does +not declare all five inputs — GitHub rejects a call naming an undeclared input, +so without that check a renamed input would surface as a failed release instead +of a red PR. ## Post-build hook @@ -683,7 +685,10 @@ unzip -l "$DIST_DIR"/*.whl | grep -q '\.pyi$' || { } ``` -It runs for custom builds too, on exactly the files that were collected. +It runs for custom builds too, on exactly the files that were collected. The +checkout it runs in has full history and tags, but — unlike the build job — the +release tag is not applied locally, since `collect` builds nothing. Use +`$VERSION` rather than `git describe` to identify the release. ## Keeping the workflows current diff --git a/packages/reflex-release/src/reflex_release/config.py b/packages/reflex-release/src/reflex_release/config.py index 1fb34466d7d..26295fbc508 100644 --- a/packages/reflex-release/src/reflex_release/config.py +++ b/packages/reflex-release/src/reflex_release/config.py @@ -49,6 +49,13 @@ _KNOWN_CUSTOM_BUILD_KEYS = frozenset({"packages", "workflow", "expect-artifacts"}) +_LOCKSTEP_LABEL = f"[[tool.{TOOL_TABLE}.lockstep]]" +_CUSTOM_BUILD_LABEL = f"[[tool.{TOOL_TABLE}.custom-build]]" + +#: A custom build workflow's filename, restricted to what is safe to write as a +#: bare YAML scalar in the generated ``uses:``. +_WORKFLOW_FILENAME_RE = re.compile(r"[A-Za-z0-9_.-]+\.ya?ml") + @dataclasses.dataclass(frozen=True) class CustomBuild: @@ -540,36 +547,44 @@ def load_pyproject(path: Path) -> dict: return tomllib.load(f) -def _string_list(table: dict, key: str) -> tuple[str, ...]: +def _string_list( + table: dict, key: str, label: str = f"[tool.{TOOL_TABLE}]" +) -> tuple[str, ...]: """Read a list-of-strings setting. Args: table: The table to read from. key: The setting name. + label: How to name the table in an error, so a key in a sub-table does + not send the reader looking for it in the top-level one. Returns: The values as a tuple (empty when the key is absent). """ value = table.get(key, []) if not isinstance(value, list) or any(not isinstance(item, str) for item in value): - fail(f"[tool.{TOOL_TABLE}] {key} must be a list of strings") + fail(f"{label} {key} must be a list of strings") return tuple(value) -def _string(table: dict, key: str, default: str) -> str: +def _string( + table: dict, key: str, default: str, label: str = f"[tool.{TOOL_TABLE}]" +) -> str: """Read a string setting. Args: table: The table to read from. key: The setting name. default: The value to use when the key is absent. + label: How to name the table in an error, so a key in a sub-table does + not send the reader looking for it in the top-level one. Returns: The configured string. """ value = table.get(key, default) if not isinstance(value, str): - fail(f"[tool.{TOOL_TABLE}] {key} must be a string") + fail(f"{label} {key} must be a string") return value @@ -614,8 +629,8 @@ def _load_lockstep(table: dict, packages: list[str]) -> tuple[LockstepGroup, ... f"unknown key(s) in [[tool.{TOOL_TABLE}.lockstep]]: " f"{', '.join(unknown)}" ) - members = _string_list(entry, "members") - publish_last = _string_list(entry, "publish-last") + members = _string_list(entry, "members", _LOCKSTEP_LABEL) + publish_last = _string_list(entry, "publish-last", _LOCKSTEP_LABEL) if len(members) < 2: fail(f"a [[tool.{TOOL_TABLE}.lockstep]] group needs at least two members") if len(set(members)) != len(members): @@ -674,23 +689,28 @@ def _load_custom_build(table: dict, config: Config) -> tuple[CustomBuild, ...]: f"unknown key(s) in [[tool.{TOOL_TABLE}.custom-build]]: " f"{', '.join(unknown)}" ) - members = _string_list(entry, "packages") + members = _string_list(entry, "packages", _CUSTOM_BUILD_LABEL) if not members: fail( f"a [[tool.{TOOL_TABLE}.custom-build]] entry needs at least one " "package in `packages`" ) - workflow = _string(entry, "workflow", "") - if not workflow.endswith((".yml", ".yaml")) or "/" in workflow: + workflow = _string(entry, "workflow", "", _CUSTOM_BUILD_LABEL) + # The name is interpolated into the generated workflow's `uses:` as a + # bare scalar, so it has to be a plain filename and nothing that YAML + # would read as structure. + if not _WORKFLOW_FILENAME_RE.fullmatch(workflow): fail( - f"[[tool.{TOOL_TABLE}.custom-build]] workflow must be a bare " - 'filename under .github/workflows, e.g. "build_wheels.yml" ' - f"(got {workflow!r})" + f"{_CUSTOM_BUILD_LABEL} workflow must be a bare filename under " + '.github/workflows made of letters, digits, ".", "_" and "-", ' + f'e.g. "build_wheels.yml" (got {workflow!r})' ) build = CustomBuild( packages=members, workflow=workflow, - expect_artifacts=_string_list(entry, "expect-artifacts"), + expect_artifacts=_string_list( + entry, "expect-artifacts", _CUSTOM_BUILD_LABEL + ), ) # One job per entry in the generated publish workflow, so two entries # whose workflows share a job id would silently collapse into one. diff --git a/packages/reflex-release/src/reflex_release/scaffold.py b/packages/reflex-release/src/reflex_release/scaffold.py index 188dcd175dc..4f56db57840 100644 --- a/packages/reflex-release/src/reflex_release/scaffold.py +++ b/packages/reflex-release/src/reflex_release/scaffold.py @@ -60,6 +60,9 @@ TEMPLATE_DIR = Path(__file__).parent / "templates" / "workflows" +#: The inputs the generated publish workflow passes to a custom build workflow. +CUSTOM_BUILD_INPUTS = ("package", "version", "tag", "build-dir", "artifact-prefix") + #: The ``workflow_call`` interface a custom build workflow has to declare. CUSTOM_BUILD_CONTRACT = """\ on: @@ -502,6 +505,95 @@ def check_title_format(config: Config) -> None: ) +def _indent_of(line: str) -> int: + """Return a line's leading-whitespace width. + + Args: + line: The line to measure. + + Returns: + The number of leading whitespace characters. + """ + return len(line) - len(line.lstrip()) + + +def _nested_lines(lines: list[str], index: int) -> list[str]: + """Return the lines nested under the mapping key at an index. + + Args: + lines: Significant lines of a YAML document (no blanks or comments). + index: The index of the key whose block to return. + + Returns: + The following lines indented deeper than that key. + """ + outer = _indent_of(lines[index]) + end = next( + ( + offset + for offset, line in enumerate(lines[index + 1 :]) + if _indent_of(line) <= outer + ), + len(lines) - index - 1, + ) + return lines[index + 1 : index + 1 + end] + + +def _key_index(lines: list[str], key: str) -> int | None: + """Return the index of the line declaring a block mapping key. + + Args: + lines: Significant lines of a YAML document. + key: The key to find. + + Returns: + The index, or None when no line declares that key with a nested block. + """ + pattern = re.compile(rf"\s*{re.escape(key)}:$") + return next( + (index for index, line in enumerate(lines) if pattern.fullmatch(line.rstrip())), + None, + ) + + +def workflow_call_inputs(text: str) -> set[str]: + """List the input names a workflow declares under ``on: workflow_call``. + + This reads the block by indentation rather than parsing YAML: the tool runs + in the jobs holding write access on the release path, so it deliberately + carries no YAML parser. It is lenient by design — an input it fails to see + is still caught by GitHub, which rejects a call naming an undeclared input + before any job in the run starts. + + Args: + text: The workflow file's contents. + + Returns: + The declared input names, empty when the block cannot be found. + """ + lines = [ + line + for line in text.splitlines() + if line.strip() and not line.lstrip().startswith("#") + ] + call = _key_index(lines, "workflow_call") + if call is None: + return set() + block = _nested_lines(lines, call) + inputs = _key_index(block, "inputs") + if inputs is None: + return set() + declared = _nested_lines(block, inputs) + if not declared: + return set() + # Only the keys at the shallowest depth are the input names; anything + # deeper describes one of them. + depth = min(_indent_of(line) for line in declared) + return { + line.strip().partition(":")[0] for line in declared if _indent_of(line) == depth + } + + def check_custom_build_workflows(config: Config) -> None: """Fail unless every configured custom build workflow exists and is callable. @@ -527,14 +619,26 @@ def check_custom_build_workflows(config: Config) -> None: f"{listing} builds through {WORKFLOW_DIR}/{entry.workflow}, which " f"does not exist. Create it with:\n\n{CUSTOM_BUILD_CONTRACT}" ) - if not re.search( - r"^\s*workflow_call:", target.read_text(encoding="utf-8"), re.MULTILINE - ): + text = target.read_text(encoding="utf-8") + if not re.search(r"\bworkflow_call\b", text): fail( f"{WORKFLOW_DIR}/{entry.workflow} declares no `workflow_call` " f"trigger, so publish.yml cannot call it to build {listing}. It " f"needs:\n\n{CUSTOM_BUILD_CONTRACT}" ) + # GitHub rejects a call naming an undeclared input, which would fail the + # release itself; catching it here makes it a red pull request instead. + if missing := [ + name + for name in CUSTOM_BUILD_INPUTS + if name not in workflow_call_inputs(text) + ]: + fail( + f"{WORKFLOW_DIR}/{entry.workflow} does not declare the input(s) " + f"publish.yml passes it: {', '.join(missing)}. Building {listing} " + f"would fail the release when GitHub validates the call. It " + f"needs:\n\n{CUSTOM_BUILD_CONTRACT}" + ) def sync(config: Config, check: bool = False, force: bool = False) -> None: 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 67599bcb8fe..0ce30f70c4e 100644 --- a/packages/reflex-release/src/reflex_release/templates/workflows/publish.yml +++ b/packages/reflex-release/src/reflex_release/templates/workflows/publish.yml @@ -184,9 +184,13 @@ jobs: permissions: contents: read steps: + # Full history and tags: post_build.sh used to run in the build job, whose + # checkout has both, and a hook that inspects them must keep working. - name: Checkout uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: + fetch-tags: true + fetch-depth: 0 persist-credentials: false - name: Install uv diff --git a/tests/units/reflex_release/conftest.py b/tests/units/reflex_release/conftest.py index 40a8426e1e0..5386c63c2f3 100644 --- a/tests/units/reflex_release/conftest.py +++ b/tests/units/reflex_release/conftest.py @@ -8,7 +8,7 @@ import pytest from reflex_release.config import Config, load_config -from reflex_release.scaffold import towncrier_config_toml +from reflex_release.scaffold import CUSTOM_BUILD_CONTRACT, towncrier_config_toml ROOT_PYPROJECT = """\ [project] @@ -97,7 +97,7 @@ def write_custom_build(repo: Path, extra: str = "", workflow: bool = True) -> No workflows = repo / ".github" / "workflows" workflows.mkdir(parents=True, exist_ok=True) (workflows / "build_wheels.yml").write_text( - "name: Build wheels\non:\n workflow_call:\n inputs: {}\njobs: {}\n", + f"name: Build wheels\n{CUSTOM_BUILD_CONTRACT}\njobs: {{}}\n", encoding="utf-8", ) diff --git a/tests/units/reflex_release/test_config.py b/tests/units/reflex_release/test_config.py index 5187811b129..1bcf97afc4f 100644 --- a/tests/units/reflex_release/test_config.py +++ b/tests/units/reflex_release/test_config.py @@ -2,6 +2,7 @@ from __future__ import annotations +import re from pathlib import Path import pytest @@ -471,3 +472,65 @@ def test_custom_build_rejects_workflows_that_share_a_job_id( ) with pytest.raises(ReleaseError, match="same publish job"): load_config(repo) + + +@pytest.mark.parametrize( + "workflow", ["build.yml\njobs: bad", "build .yml: x", "~build.yml", "*.yml"] +) +def test_custom_build_rejects_a_yaml_unsafe_workflow_name( + config: Config, repo: Path, workflow: str +) -> None: + """The name is written into `uses:` as a bare scalar.""" + pyproject = repo / "pyproject.toml" + pyproject.write_text( + pyproject.read_text(encoding="utf-8").replace( + "\n[tool.towncrier]", + "\n[[tool.reflex-release.custom-build]]\n" + 'packages = ["mypkg"]\n' + f"workflow = {workflow!r}\n\n[tool.towncrier]", + ), + encoding="utf-8", + ) + with pytest.raises(ReleaseError, match="must be a bare filename"): + load_config(repo) + + +@pytest.mark.parametrize( + ("body", "expected"), + [ + ( + 'packages = ["mypkg"]\nworkflow = "b.yml"\nexpect-artifacts = "*.whl"\n', + "[[tool.reflex-release.custom-build]] expect-artifacts", + ), + ( + 'packages = [1]\nworkflow = "b.yml"\n', + "[[tool.reflex-release.custom-build]] packages", + ), + ( + 'packages = ["mypkg"]\nworkflow = 1\n', + "[[tool.reflex-release.custom-build]] workflow", + ), + ], +) +def test_custom_build_type_errors_name_their_own_table( + config: Config, repo: Path, body: str, expected: str +) -> None: + """A key in a sub-table must not send the reader to the top-level one.""" + write_config( + repo, + 'root-package = "mypkg"\n\n[[tool.reflex-release.custom-build]]\n' + body, + ) + with pytest.raises(ReleaseError, match=re.escape(expected)): + load_config(repo) + + +def test_lockstep_type_errors_name_their_own_table(config: Config, repo: Path) -> None: + write_config( + repo, + 'root-package = "mypkg"\npackages-dir = "packages"\n\n' + "[[tool.reflex-release.lockstep]]\nmembers = 1\n", + ) + with pytest.raises( + ReleaseError, match=re.escape("[[tool.reflex-release.lockstep]] members") + ): + load_config(repo) diff --git a/tests/units/reflex_release/test_scaffold.py b/tests/units/reflex_release/test_scaffold.py index 34ab2db268b..bb59c6230af 100644 --- a/tests/units/reflex_release/test_scaffold.py +++ b/tests/units/reflex_release/test_scaffold.py @@ -12,6 +12,8 @@ from reflex_release.config import Config, load_config from reflex_release.scaffold import ( CORE_WORKFLOWS, + CUSTOM_BUILD_CONTRACT, + CUSTOM_BUILD_INPUTS, INTERNAL_WORKFLOW, MAX_DISPATCH_CHECKBOXES, WORKFLOW_DIR, @@ -23,6 +25,7 @@ sync, towncrier_config_toml, use_checkboxes, + workflow_call_inputs, ) from reflex_release.versions import ACTIONS @@ -765,3 +768,78 @@ def test_custom_build_workflows_are_valid_yaml( reloaded = load_config(repo) for name in managed_workflows(reloaded): assert yaml.safe_load(render(name, reloaded))["jobs"], name + + +WORKFLOW_WITH_DISPATCH = """\ +on: + # a manual trigger with inputs of its own + workflow_dispatch: + inputs: + debug: + type: boolean + workflow_call: + inputs: + package: { required: true, type: string } + version: + required: true + type: string + tag: + required: true + type: string + build-dir: + required: true + type: string + artifact-prefix: + required: true + type: string + secrets: + token: + required: false +jobs: {} +""" + + +def test_workflow_call_inputs_reads_the_right_block() -> None: + """A workflow_dispatch trigger has an `inputs:` block too.""" + assert workflow_call_inputs(WORKFLOW_WITH_DISPATCH) == set(CUSTOM_BUILD_INPUTS) + assert workflow_call_inputs("on:\n push:\njobs: {}\n") == set() + + +def test_the_documented_contract_declares_every_passed_input() -> None: + """The error message tells people what to write, so it has to be right.""" + assert workflow_call_inputs(CUSTOM_BUILD_CONTRACT) == set(CUSTOM_BUILD_INPUTS) + + +def test_every_passed_input_is_in_the_contract(config: Config, repo: Path) -> None: + """The generated call and the contract it is validated against must agree.""" + write_custom_build(repo) + passed = yaml.safe_load(render("publish.yml", load_config(repo)))["jobs"][ + "custom-build-build_wheels" + ]["with"] + assert set(passed) == set(CUSTOM_BUILD_INPUTS) + + +def test_sync_rejects_a_custom_build_workflow_missing_an_input( + config: Config, repo: Path +) -> None: + """GitHub would reject the generated call; catch it in the pull request.""" + write_custom_build(repo) + target = repo / WORKFLOW_DIR / "build_wheels.yml" + target.write_text( + target.read_text(encoding="utf-8").replace( + ' tag:\n description: "Tag the checkout with this so the' + ' build derives that version"\n required: true\n type:' + " string\n", + "", + ), + encoding="utf-8", + ) + with pytest.raises(ReleaseError, match="does not declare the input"): + sync(load_config(repo)) + + +def test_collect_keeps_the_git_context_the_hook_used_to_get(config: Config) -> None: + """post_build.sh moved here from build, whose checkout has history and tags.""" + checkout = _job_steps(config, "collect")[0] + assert checkout["with"]["fetch-tags"] is True + assert checkout["with"]["fetch-depth"] == 0