From a0a1a9eb767227a4edc5a27a559f4e6b4fabd802 Mon Sep 17 00:00:00 2001 From: Masen Furer Date: Mon, 14 Sep 2026 16:17:37 -0700 Subject: [PATCH 1/3] Add changelog-driven application deployment workflows --- packages/reflex-release/README.md | 102 ++++++ .../news/+app-deployments.feature.md | 1 + packages/reflex-release/pyproject.toml | 4 +- .../reflex-release/src/reflex_release/app.py | 314 ++++++++++++++++++ .../reflex-release/src/reflex_release/cli.py | 28 +- .../src/reflex_release/commands.py | 4 +- .../src/reflex_release/config.py | 112 +++++++ .../src/reflex_release/gitutil.py | 13 + .../src/reflex_release/scaffold.py | 70 +++- .../templates/workflows/app/changelog.yml | 20 ++ .../templates/workflows/app/deploy_dev.yml | 60 ++++ .../workflows/app/dispatch_release.yml | 54 +++ .../templates/workflows/app/publish.yml | 107 ++++++ .../workflows/app/release_from_changelog.yml | 27 ++ tests/units/reflex_release/test_app.py | 271 +++++++++++++++ tests/units/reflex_release/test_scaffold.py | 4 +- 16 files changed, 1176 insertions(+), 15 deletions(-) create mode 100644 packages/reflex-release/news/+app-deployments.feature.md create mode 100644 packages/reflex-release/src/reflex_release/app.py create mode 100644 packages/reflex-release/src/reflex_release/templates/workflows/app/changelog.yml create mode 100644 packages/reflex-release/src/reflex_release/templates/workflows/app/deploy_dev.yml create mode 100644 packages/reflex-release/src/reflex_release/templates/workflows/app/dispatch_release.yml create mode 100644 packages/reflex-release/src/reflex_release/templates/workflows/app/publish.yml create mode 100644 packages/reflex-release/src/reflex_release/templates/workflows/app/release_from_changelog.yml create mode 100644 tests/units/reflex_release/test_app.py diff --git a/packages/reflex-release/README.md b/packages/reflex-release/README.md index 8905c0a6b9b..b860921f8ac 100644 --- a/packages/reflex-release/README.md +++ b/packages/reflex-release/README.md @@ -1029,3 +1029,105 @@ The same shape recovers a final version: seed `## 1.2.3 ()` and dispatch ## License Apache-2.0. + +## Application deployments + +An application repository can use the same dispatch, changelog and post-publish +release model without Python packages or PyPI. Configure an app table instead of +package/towncrier settings; the repository needs only a `pyproject.toml` containing +these tool settings (no `[project]` table is required): + +```toml +[tool.reflex-release] +cli-command = "uvx reflex-release@" +main-branch = "main" +release-timezone = "America/Los_Angeles" + +[tool.reflex-release.app] +build-workflow = "build_app.yml" +deploy-workflow = "deploy_app.yml" +# Optional: source from a tracked Git submodule instead of only this repository. +source-submodule = "reflex" +source-ref = "origin/main" +production-environment = "production" +staging-environment = "staging" +dev-environment = "development" +# Optional: omit to disable the generated speculative dev workflow. +dev-schedule = "*/30 * * * *" +``` + +Create the two repository-owned workflows, then run `reflex-release sync`. +`sync --check` checks their input contracts and generated workflow drift. +The app mode generates `dispatch_release.yml`, `release_from_changelog.yml`, +`publish.yml`, `changelog.yml`, and optionally `deploy_dev.yml`. Package releases +continue using their existing templates. Switching modes requires reviewing the +changed workflows. No build, registry, hosting provider, or deployment CLI is +hardcoded into the app templates. + +Dispatch release accepts optional `revision` and `reason` inputs. It always +starts from the configured main branch, resolves the submodule branch, tag or SHA +(default `source-ref`), commits the submodule pointer and a new `CHANGELOG.md` +section, and opens a PR against main. Omitting `source-submodule` supports apps +whose source lives entirely in the deployment repository; in that case leave +`revision` empty. The source repository URL comes from `.gitmodules`. The default +checkout/token must be able to read it; private cross-repository submodules need +an appropriately scoped checkout credential configured by the consumer. + +Versions use `ISO-year.week.sequence`, for example `2026.37.0`, `2026.37.1`. +Weeks and week years follow ISO 8601 in `release-timezone`, including December/ +January boundaries. Counters start at zero and reserve versions across tags, +existing changelog entries and release PR branches, including PRs that have not +merged yet. Deploy reasons are preserved as changelog text, and submodule commits +are recorded alongside them. Level-two headings are reserved for release +versions; put any subsections under level-three headings. + +On a push to main, detection reads the top changelog version at that push's exact +commit. An absent heading or an existing matching tag is a no-op. Otherwise: + +1. Build the captured repository commit and its committed source submodule. +2. Deploy that build to staging. +3. Wait for production environment approval. +4. Deploy the same build to production. +5. Push the version tag on the deployed repository commit and create a GitHub + release from its changelog notes, using the existing release helpers. + +The whole sequence owns one concurrency group, including the approval wait. +`queue: max` lets up to 100 pending runs queue instead of replacing one another; +GitHub orders runs by when they enter the queue. Detection happens after acquiring +the queue, with fresh tags, and rejects an untagged version older than a published +one. A failed build/deploy creates no release tag: fix and push, or rerun. If tag +creation succeeds but GitHub release creation fails, rerun just the failed release +job; a fresh detection deliberately treats the existing tag as already published. +For a rollback, dispatch a new version targeting the older known-good source SHA. + +The optional dev workflow resolves `source-ref` afresh, uses a separate concurrency +group and environment, and never edits changelogs, commits source pins, or creates +tags/releases. Its version is `dev--` (12-character SHA +prefixes; no source suffix without a submodule). Each scheduled run invokes the +hooks; a consumer may reuse existing immutable images or skip redundant deployments +inside its own hooks. + +### Repository-owned hook contract + +Both hooks are local reusable workflows under `.github/workflows`, with the +following `workflow_call` string inputs: + +| Hook | Required inputs | Contract | +| --- | --- | --- | +| Build | `ref`, `version`, `source-revision` | Check out exact `ref`. Build and retain artifacts/images under `version`. Empty `source-revision` means use committed source; a nonempty value is a resolved speculative dev SHA. | +| Deploy | `ref`, `version`, `environment` | Deploy the existing build; succeed only when the requested deployment has completed. | + +The names of the files and environments are configurable. Hooks receive inherited +repository secrets and `contents: read` / `id-token: write` permissions for provider +authentication. They must not rebuild during promotion; use immutable image tags, +digests or workflow artifacts keyed by `version`. The deploy hook owns routing, +health checks and any GitOps reconciliation wait. Opening or merging a GitOps PR +alone is not deployment completion. Environment-scoped provider secrets must be +bound in the repository-owned hook; inherited secrets do not select an environment. + +The generated approval job binds `production-environment` and verifies it has +required reviewers via the GitHub API. `allow-self-review = false` additionally +requires GitHub's “Prevent self-review” setting. Protect main and release branches, +enable Actions to create PRs, and configure the hook credentials before dispatching. +The PR workflow can also be dispatched explicitly: dispatch uses this path because +PRs created using `GITHUB_TOKEN` do not start `pull_request` runs automatically. diff --git a/packages/reflex-release/news/+app-deployments.feature.md b/packages/reflex-release/news/+app-deployments.feature.md new file mode 100644 index 00000000000..6b11d53745e --- /dev/null +++ b/packages/reflex-release/news/+app-deployments.feature.md @@ -0,0 +1 @@ +Add changelog-driven application deployments with configurable build and deploy workflows, optional pinned source submodules, queued staging and production promotion, and speculative development builds. Release tags and GitHub releases are created only after production deployment succeeds. diff --git a/packages/reflex-release/pyproject.toml b/packages/reflex-release/pyproject.toml index 13174a63ab8..fae83650870 100644 --- a/packages/reflex-release/pyproject.toml +++ b/packages/reflex-release/pyproject.toml @@ -1,12 +1,12 @@ [project] name = "reflex-release" dynamic = ["version"] -description = "Changelog-driven release automation: towncrier news fragments, GitHub Actions workflows and human-gated PyPI publishing for single-package repos and monorepos." +description = "Changelog-driven releases: human-gated package publishing and application deployments with configurable GitHub Actions workflows." license.text = "Apache-2.0" readme = "README.md" authors = [{ name = "Reflex", email = "maintainers@reflex.dev" }] maintainers = [{ name = "Reflex", email = "maintainers@reflex.dev" }] -keywords = ["release", "changelog", "towncrier", "github-actions", "pypi"] +keywords = ["release", "changelog", "towncrier", "github-actions", "pypi", "gitops"] requires-python = ">=3.10" dependencies = [ "packaging >=24.2,<27", diff --git a/packages/reflex-release/src/reflex_release/app.py b/packages/reflex-release/src/reflex_release/app.py new file mode 100644 index 00000000000..0e45c0d64b1 --- /dev/null +++ b/packages/reflex-release/src/reflex_release/app.py @@ -0,0 +1,314 @@ +"""Changelog-driven application deployments, independent of build infrastructure.""" + +from __future__ import annotations + +import datetime as dt +import re +import tempfile +from pathlib import Path +from zoneinfo import ZoneInfo + +from .actions import ReleaseError, fail, write_outputs, write_summary +from .commands import cmd_create_release, cmd_push_tag +from .config import AppConfig, Config +from .gitutil import ( + authenticated_git, + commit_exists, + configure_bot_identity, + gh_run, + git, + git_push, + git_run, + tag_exists, +) + +VERSION_RE = re.compile( + r"(?P[0-9]{4})\.(?P[1-9]|[1-4][0-9]|5[0-3])\.(?P0|[1-9][0-9]*)" +) +HEADING_RE = re.compile(r"^## (?P\S+)\s*$", re.MULTILINE) + + +def settings(config: Config) -> AppConfig: + """Require application mode. + + Args: + config: Repository configuration. + + Returns: + Application settings. + """ + if config.app is None: + fail("this command requires [tool.reflex-release.app]") + return config.app + + +def version_key(version: str) -> tuple[dt.date, int]: + """Validate and order ISO-year.week.sequence release identifiers. + + Args: + version: ISO week year and number with a release counter. + + Returns: + The ISO week's Monday and release counter. + """ + match = VERSION_RE.fullmatch(version) + if match: + try: + return dt.date.fromisocalendar( + int(match["year"]), int(match["week"]), 1 + ), int(match["serial"]) + except ValueError: + pass + message = f"invalid app version {version!r}; expected ISO-year.week.sequence, e.g. 2026.37.0" + raise ReleaseError(message) + + +def sections(config: Config) -> list[tuple[str, str]]: + """Read validated application release sections in file order. + + Args: + config: Repository configuration. + + Returns: + Version and notes pairs, newest first. + """ + path = config.root / config.changelog_filename + if not path.is_file(): + return [] + text = path.read_text(encoding="utf-8") + headings = list(HEADING_RE.finditer(text)) + result = [] + previous = None + for index, heading in enumerate(headings): + version = heading["version"] + key = version_key(version) + if previous is not None and key >= previous: + fail("app changelog versions must be unique and ordered newest first") + previous = key + end = headings[index + 1].start() if index + 1 < len(headings) else len(text) + result.append((version, text[heading.end() : end].strip())) + return result + + +def resolve_source(config: Config, revision: str = "") -> str: + """Resolve and check out the requested upstream revision in the source submodule. + + Args: + config: Repository configuration. + revision: Branch, tag or SHA; empty uses the configured default. + + Returns: + The resolved commit SHA, or empty for an app without a submodule. + """ + app = settings(config) + if not app.source_submodule: + if revision: + fail("a target revision requires app source-submodule") + return "" + entry = git(["ls-files", "--stage", "--", app.source_submodule], config.root) + if not entry.startswith("160000 "): + fail(f"{app.source_submodule} is not a tracked git submodule") + revision = revision or app.source_ref + if revision.startswith("-") or any(c.isspace() for c in revision): + fail("target revision must be a git branch, tag or SHA") + authenticated_git( + ["submodule", "update", "--init", "--", app.source_submodule], config.root + ) + source = config.root / app.source_submodule + fetch = ["fetch", "--tags", "--prune"] + if git(["rev-parse", "--is-shallow-repository"], source).strip() == "true": + fetch.append("--unshallow") + authenticated_git([*fetch, "origin", "+refs/heads/*:refs/remotes/origin/*"], source) + branch = revision.removeprefix("origin/").removeprefix("refs/heads/") + candidates = [f"refs/remotes/origin/{branch}", f"refs/tags/{revision}", revision] + resolved = next( + (candidate for candidate in candidates if commit_exists(source, candidate)), + None, + ) + if resolved is None: + # A full SHA need not be reachable from any advertised branch or tag. + authenticated_git(["fetch", "origin", revision], source) + resolved = "FETCH_HEAD" + sha = git(["rev-parse", "--verify", f"{resolved}^{{commit}}"], source).strip() + git_run(["checkout", "--detach", sha], source) + authenticated_git(["submodule", "update", "--init", "--recursive"], source) + return sha + + +def materialize(config: Config, revision: str, reason: str) -> str: + """Pin source and prepend the next deployment's changelog section. + + Args: + config: Repository configuration. + revision: Requested source revision, or empty for the configured default. + reason: Operator's deployment reason. + + Returns: + The allocated version. + """ + app = settings(config) + existing = sections(config) + today = dt.datetime.now(ZoneInfo(config.release_timezone)).date() + # Read remote reservations as well as tags: two PRs dispatched before either + # merges must not allocate the same version. The dispatch workflow queues. + branches = authenticated_git( + [ + "ls-remote", + "--heads", + "origin", + f"refs/heads/{config.release_branch_prefix}*", + ], + config.root, + ) + reserved = { + line.split()[1].removeprefix(f"refs/heads/{config.release_branch_prefix}") + for line in branches.splitlines() + } + reserved.update(version for version, _ in existing) + reserved.update(git(["tag", "--list"], config.root).splitlines()) + keys = [version_key(value) for value in reserved if VERSION_RE.fullmatch(value)] + monday = today - dt.timedelta(days=today.weekday()) + date = max([monday, *(key[0] for key in keys)]) + count = max((key[1] for key in keys if key[0] == date), default=-1) + 1 + year, week, _ = date.isocalendar() + version = f"{year}.{week}.{count}" + # Indent continuation lines so a reason cannot create another version heading. + reason = reason.strip() or "Deploy the current application source." + notes = "- " + reason.replace("\n", "\n ") + source_sha = resolve_source(config, revision) + if source_sha: + notes += f"\n- Source `{app.source_submodule}`: `{source_sha}`." + path = config.root / config.changelog_filename + text = path.read_text(encoding="utf-8") if path.exists() else "# Changelog\n" + first = HEADING_RE.search(text) + split = first.start() if first else len(text) + path.write_text( + text[:split].rstrip() + f"\n\n## {version}\n\n{notes}\n\n" + text[split:], + encoding="utf-8", + ) + write_outputs(version=version, source_revision=source_sha) + return version + + +def open_pr(config: Config, version: str) -> None: + """Commit the materialized source pin and open a release PR against main. + + Args: + config: Repository configuration. + version: The newly materialized release identifier. + """ + app = settings(config) + releases = sections(config) + if not releases or releases[0][0] != version: + fail("the requested version must be the top changelog section") + branch = f"{config.release_branch_prefix}{version}" + configure_bot_identity(config.root) + git_run(["checkout", "-b", branch], config.root) + paths = [config.changelog_filename] + if app.source_submodule: + paths.append(app.source_submodule) + git_run(["add", "--", *paths], config.root) + git_run(["commit", "-m", f"Release {version}", "--", *paths], config.root) + git_push(f"HEAD:refs/heads/{branch}", config.root) + with tempfile.TemporaryDirectory() as directory: + body = Path(directory) / "release.md" + body.write_text( + f"Release {version}\n\n{releases[0][1]}\n\n" + "Merging builds this commit and deploys to staging. Production requires " + "environment approval; the tag and GitHub release follow a successful production deploy.\n", + encoding="utf-8", + ) + gh_run( + [ + "pr", + "create", + "--base", + config.main_branch, + "--head", + branch, + "--title", + f"Release {version}", + "--body-file", + str(body), + ], + config.root, + ) + # GITHUB_TOKEN-created PRs do not trigger pull_request workflows. + gh_run(["workflow", "run", "changelog.yml", "--ref", branch], config.root) + write_summary([ + f"Opened release PR for **{version}** against `{config.main_branch}`." + ]) + + +def detect(config: Config, ref_name: str) -> None: + """Emit the top untagged version, refusing stale or unauthorized deployments. + + Args: + config: Repository configuration. + ref_name: Branch on which the workflow is running. + """ + settings(config) + if ref_name != config.main_branch: + fail(f"app releases may only deploy from {config.main_branch}") + releases = sections(config) + version = releases[0][0] if releases else "" + skip = not version or tag_exists(config.root, version) + if not skip: + published = [ + value + for value in git(["tag", "--list"], config.root).splitlines() + if VERSION_RE.fullmatch(value) + ] + if any(version_key(value) > version_key(version) for value in published): + fail(f"refusing to deploy {version} over a newer published app version") + write_outputs( + version=version, + any="false" if skip else "true", + ref=git(["rev-parse", "HEAD"], config.root).strip(), + ) + + +def dev(config: Config) -> None: + """Resolve speculative source without committing or allocating a release. + + Args: + config: Repository configuration. + """ + sha = resolve_source(config) + ref = git(["rev-parse", "HEAD"], config.root).strip() + write_outputs( + ref=ref, + source_revision=sha, + version=f"dev-{ref[:12]}" + (f"-{sha[:12]}" if sha else ""), + ) + + +def finalize(config: Config, version: str) -> None: + """Tag a successfully deployed checkout and reuse GitHub release creation. + + Args: + config: Repository configuration. + version: The version that the production hook successfully deployed. + """ + settings(config) + releases = sections(config) + if not releases or releases[0][0] != version: + fail("deployed version must match the checkout's top changelog section") + if tag_exists(config.root, version) and git( + ["rev-parse", f"refs/tags/{version}^{{commit}}"], config.root + ) != git(["rev-parse", "HEAD"], config.root): + fail(f"{version} already tags a different commit") + cmd_push_tag(config, version) + with tempfile.TemporaryDirectory() as directory: + notes = Path(directory) / "notes.md" + notes.write_text(releases[0][1] + "\n", encoding="utf-8") + cmd_create_release( + config, + version, + "", + version, + False, + True, + notes, + Path(directory) / "checksums", + ) diff --git a/packages/reflex-release/src/reflex_release/cli.py b/packages/reflex-release/src/reflex_release/cli.py index cc345823962..2e4b42d40ac 100644 --- a/packages/reflex-release/src/reflex_release/cli.py +++ b/packages/reflex-release/src/reflex_release/cli.py @@ -12,7 +12,7 @@ import sys from pathlib import Path -from . import commands +from . import app, commands from .actions import ReleaseError from .config import Config, load_config from .devpins import check_dev_pins @@ -77,6 +77,22 @@ def build_parser() -> argparse.ArgumentParser: ) sub = parser.add_subparsers(dest="command", required=True) + app_materialize = sub.add_parser( + "app-materialize", help="Pin app source and write a deployment changelog entry." + ) + app_materialize.add_argument("--revision", default=_env("TARGET_REVISION")) + app_materialize.add_argument("--reason", default=_env("DEPLOY_REASON")) + for name in ("app-open-pr", "app-finalize"): + command = sub.add_parser(name) + command.add_argument("--version", default=_env("VERSION")) + app_detect = sub.add_parser( + "app-detect", help="Detect an unpublished app changelog version." + ) + app_detect.add_argument( + "--ref-name", default=_env("REF_NAME") or _env("GITHUB_REF_NAME") + ) + sub.add_parser("app-dev", help="Resolve the speculative development source.") + detect = sub.add_parser( "detect", help="List packages whose newest changelog version has no git tag." ) @@ -319,6 +335,16 @@ def dispatch(args: argparse.Namespace, config: Config) -> None: config: The repository configuration. """ match args.command: + case "app-materialize": + app.materialize(config, args.revision, args.reason) + case "app-open-pr": + app.open_pr(config, args.version) + case "app-detect": + app.detect(config, args.ref_name) + case "app-dev": + app.dev(config) + case "app-finalize": + app.finalize(config, args.version) case "detect": commands.cmd_detect(config, args.ref_name) case "plan": diff --git a/packages/reflex-release/src/reflex_release/commands.py b/packages/reflex-release/src/reflex_release/commands.py index dc434dc5aed..8e5549262c9 100644 --- a/packages/reflex-release/src/reflex_release/commands.py +++ b/packages/reflex-release/src/reflex_release/commands.py @@ -1348,7 +1348,9 @@ def cmd_create_release( """ # The root package is the repository, so its tag already names the release # unambiguously; only a sub-package needs to say which package it is. - title = tag if package == config.root_package else f"{package}@{version}" + title = ( + tag if config.app or package == config.root_package else f"{package}@{version}" + ) existing = _release_view(config, tag) if existing is not None: _accept_existing_release( diff --git a/packages/reflex-release/src/reflex_release/config.py b/packages/reflex-release/src/reflex_release/config.py index 7498ac7ba01..dc87580c374 100644 --- a/packages/reflex-release/src/reflex_release/config.py +++ b/packages/reflex-release/src/reflex_release/config.py @@ -52,6 +52,7 @@ POST_RELEASE_INPUTS = ("tag", "package", "version") _KNOWN_KEYS = frozenset({ + "app", "allow-self-review", "cli-command", "custom-build", @@ -140,6 +141,31 @@ class LockstepGroup: pin_exact: bool = False +@dataclasses.dataclass(frozen=True) +class AppConfig: + """Repository-owned workflows and source selection for an application. + + Attributes: + build_workflow: Reusable build workflow filename. + deploy_workflow: Reusable deploy workflow filename. + source_submodule: Optional repo-relative source submodule path. + source_ref: Default upstream revision for dispatch and speculative dev. + production_environment: GitHub environment requiring human approval. + staging_environment: Environment passed to the staging deploy hook. + dev_environment: Environment passed to speculative dev deployments. + dev_schedule: Optional five-field cron expression for dev builds. + """ + + build_workflow: str + deploy_workflow: str + source_submodule: str = "" + source_ref: str = "origin/main" + production_environment: str = "production" + staging_environment: str = "staging" + dev_environment: str = "development" + dev_schedule: str = "" + + @dataclasses.dataclass(frozen=True) class Config: """Resolved release configuration for one repository. @@ -194,6 +220,7 @@ class Config: never_publish_packages: Packages this repository builds but never releases. They are excluded from every release path rather than merely exempt from the changelog. + app: Application deployment settings, replacing package publishing. """ root: Path @@ -224,6 +251,7 @@ class Config: uv_version: str = DEFAULT_UV_VERSION python_version: str = DEFAULT_PYTHON_VERSION never_publish_packages: tuple[str, ...] = () + app: AppConfig | None = None def package_dir(self, package: str) -> str: """Return the repo-relative directory of a package. @@ -849,6 +877,85 @@ def _default_root_source_dirs(root: Path, root_package: str | None) -> tuple[str return (module,) if (root / module).is_dir() else () +def _load_app(table: dict, towncrier: dict) -> AppConfig: + """Parse application workflows and source settings. + + Args: + table: The reflex-release configuration table. + towncrier: Towncrier settings, which cannot accompany app mode. + + Returns: + Validated application configuration. + """ + app_table = table["app"] + fields = {field.name.replace("_", "-") for field in dataclasses.fields(AppConfig)} + if not isinstance(app_table, dict) or set(app_table) - fields: + fail(f"[tool.{TOOL_TABLE}.app] must contain only: {', '.join(sorted(fields))}") + values = { + key.replace("-", "_"): _string(app_table, key, "", f"[tool.{TOOL_TABLE}.app]") + for key in app_table + } + for key in ("build_workflow", "deploy_workflow"): + if not _WORKFLOW_FILENAME_RE.fullmatch(values.get(key, "")): + fail( + f"app {key.replace('_', '-')} must name a workflow under .github/workflows" + ) + app = AppConfig(**values) + for key in ("production_environment", "staging_environment", "dev_environment"): + if not re.fullmatch(r"[A-Za-z0-9][A-Za-z0-9_.-]*", getattr(app, key)): + fail(f"app {key} must be a nonempty environment name") + if ( + len({ + app.production_environment, + app.staging_environment, + app.dev_environment, + }) + != 3 + ): + fail("app development, staging and production environments must be distinct") + path = Path(app.source_submodule) + if app.source_submodule and ( + path.is_absolute() + or ".." in path.parts + or not path.parts + or any(part.startswith(".") for part in path.parts) + or not re.fullmatch(r"[A-Za-z0-9_/-]+", app.source_submodule) + ): + fail("app source-submodule must be a relative directory inside the repository") + if ( + not app.source_ref + or app.source_ref.startswith("-") + or any(c.isspace() for c in app.source_ref) + ): + fail("app source-ref must be a git revision") + if app.dev_schedule and ( + len(app.dev_schedule.split()) != 5 + or not re.fullmatch(r"[0-9*/,-]+(?: +[0-9*/,-]+){4}", app.dev_schedule) + ): + fail("app dev-schedule must be a five-field numeric cron expression") + if ( + any( + key in table + for key in ( + "root-package", + "packages-dir", + "custom-build", + "lockstep", + "internal-packages", + "never-publish-packages", + "latest-release-package", + "post-release-workflow", + "tag-prefix", + ) + ) + or towncrier + ): + fail( + "app deployments cannot be combined with package or towncrier release settings" + ) + return app + + def load_config(root: Path) -> Config: """Load the release configuration from a repository's ``pyproject.toml``. @@ -966,6 +1073,11 @@ def load_config(root: Path) -> Config: f"auto, checkboxes, text (got {config.dispatch_package_inputs!r})" ) + if "app" in table: + return dataclasses.replace( + config, app=_load_app(table, towncrier), packages_dir=None + ) + packages = config.all_packages() if not packages: fail( diff --git a/packages/reflex-release/src/reflex_release/gitutil.py b/packages/reflex-release/src/reflex_release/gitutil.py index 0922a8b4df3..87330ece116 100644 --- a/packages/reflex-release/src/reflex_release/gitutil.py +++ b/packages/reflex-release/src/reflex_release/gitutil.py @@ -58,6 +58,19 @@ def git_push(refspec: str, cwd: Path) -> None: git_run([*_CREDENTIAL_HELPER, "push", "origin", refspec], cwd) +def authenticated_git(args: list[str], cwd: Path) -> str: + """Run git with the GitHub CLI credential helper, returning stdout. + + Args: + args: Git arguments, such as fetch or ls-remote. + cwd: The repository directory. + + Returns: + The command's stdout. + """ + return git([*_CREDENTIAL_HELPER, *args], cwd) + + def configure_bot_identity(cwd: Path) -> None: """Set the committer identity to github-actions[bot] for this repository. diff --git a/packages/reflex-release/src/reflex_release/scaffold.py b/packages/reflex-release/src/reflex_release/scaffold.py index 178ccb74088..47edc4797f7 100644 --- a/packages/reflex-release/src/reflex_release/scaffold.py +++ b/packages/reflex-release/src/reflex_release/scaffold.py @@ -63,9 +63,10 @@ #: Workflow scaffolded only for repositories that declare internal packages. INTERNAL_WORKFLOW = "auto_release_internal.yml" +APP_DEV_WORKFLOW = "deploy_dev.yml" #: Generated workflows that a repository may stop needing. -OPTIONAL_WORKFLOWS = (INTERNAL_WORKFLOW,) +OPTIONAL_WORKFLOWS = (INTERNAL_WORKFLOW, APP_DEV_WORKFLOW) #: Every workflow this tool can generate, whether or not a given repository #: currently gets it. @@ -159,6 +160,11 @@ def managed_workflows(config: Config) -> tuple[str, ...]: Returns: The workflow filenames. """ + if config.app: + return ( + *CORE_WORKFLOWS, + *((APP_DEV_WORKFLOW,) if config.app.dev_schedule else ()), + ) if config.internal_packages: return (*CORE_WORKFLOWS, INTERNAL_WORKFLOW) return CORE_WORKFLOWS @@ -491,6 +497,26 @@ def render(name: str, config: Config) -> str: f"# Re-run `{cli} sync` after changing [tool.reflex-release] in", f"# pyproject.toml. `{cli} sync --check` fails when this file drifts.", ]) + if config.app: + substitutions = { + "@@HEADER@@": header, + "@@CLI@@": cli, + "@@UV_SETUP_WITH@@": _uv_setup_block(config), + "@@MAIN_BRANCH@@": config.main_branch, + "@@BUILD_WORKFLOW@@": config.app.build_workflow, + "@@DEPLOY_WORKFLOW@@": config.app.deploy_workflow, + "@@PRODUCTION_ENVIRONMENT@@": config.app.production_environment, + "@@STAGING_ENVIRONMENT@@": config.app.staging_environment, + "@@DEV_ENVIRONMENT@@": config.app.dev_environment, + "@@DEV_SCHEDULE@@": config.app.dev_schedule, + "@@ALLOW_SELF_REVIEW@@": "true" if config.allow_self_review else "false", + } + text = (TEMPLATE_DIR / "app" / name).read_text(encoding="utf-8") + for placeholder, value in substitutions.items(): + text = text.replace(placeholder, value) + if re.search(r"@@[A-Z_]+@@", text): + fail(f"unsubstituted app workflow placeholder in {name}") + return text # The trigger fires on exactly the paths detect-internal counts as a # package's source, so the workflow cannot run and then find nothing. internal_paths: list[str] = [] @@ -731,11 +757,11 @@ def generated_workflow_names() -> set[str]: The file names and the ``name:`` of each generated workflow. """ names: set[str] = set(GENERATED_WORKFLOWS) - for filename in GENERATED_WORKFLOWS: - text = (TEMPLATE_DIR / filename).read_text(encoding="utf-8") + for target in TEMPLATE_DIR.rglob("*.yml"): + text = target.read_text(encoding="utf-8") match = _WORKFLOW_NAME_RE.search(text) if match is None: - fail(f"the {filename} template declares no top-level name") + fail(f"the {target.name} template declares no top-level name") names.add(match["name"]) return names @@ -768,8 +794,24 @@ def sync(config: Config, check: bool = False, force: bool = False) -> None: check: Report drift and fail instead of writing. force: Overwrite files that were not generated by this tool. """ - check_title_format(config) - check_custom_build_workflows(config) + if config.app: + for workflow, inputs in ( + (config.app.build_workflow, {"ref", "version", "source-revision"}), + (config.app.deploy_workflow, {"ref", "version", "environment"}), + ): + target = config.root / WORKFLOW_DIR / workflow + if workflow in GENERATED_WORKFLOWS or not target.is_file(): + fail( + f"app workflow {workflow} must be an existing, repository-owned workflow" + ) + missing = inputs - workflow_call_inputs(target.read_text(encoding="utf-8")) + if missing: + fail( + f"app workflow {workflow} must declare workflow_call inputs: {', '.join(sorted(missing))}" + ) + else: + check_title_format(config) + check_custom_build_workflows(config) check_post_release_workflow(config) workflow_dir = config.root / WORKFLOW_DIR stale: list[str] = [] @@ -817,10 +859,10 @@ def sync(config: Config, check: bool = False, force: bool = False) -> None: continue if check: stale.append(name) - echo(f"{WORKFLOW_DIR}/{name} is obsolete: no package is internal any more") + echo(f"{WORKFLOW_DIR}/{name} is obsolete for the current configuration") continue target.unlink() - echo(f"removed {WORKFLOW_DIR}/{name} (no internal packages)") + echo(f"removed {WORKFLOW_DIR}/{name} (no longer configured)") if stale: fail( @@ -959,7 +1001,8 @@ def init(root: Path, pin: str | None, force: bool) -> None: _append_table( pyproject, "tool.reflex-release", release_config_toml(root, cli_command) ) - _append_table(pyproject, "tool.towncrier", towncrier_config_toml(root)) + if "app" not in load_pyproject(pyproject).get("tool", {}).get(TOOL_TABLE, {}): + _append_table(pyproject, "tool.towncrier", towncrier_config_toml(root)) config = load_config(root) for package in config.all_packages(): @@ -982,6 +1025,15 @@ def print_checklist(config: Config) -> None: Args: config: The repository configuration. """ + if config.app: + echo( + f"Configure required reviewers on {config.app.production_environment!r}, " + f"protect {config.main_branch!r}, and enable Actions to create PRs. " + "Build/deploy hooks own their credentials and must report deployment " + "completion before returning success. Pin cli-command to a published " + "reflex-release version with app support." + ) + return if "@" not in config.cli_command: echo( "\nWarning: the workflows invoke this tool unpinned " diff --git a/packages/reflex-release/src/reflex_release/templates/workflows/app/changelog.yml b/packages/reflex-release/src/reflex_release/templates/workflows/app/changelog.yml new file mode 100644 index 00000000000..620d0c18776 --- /dev/null +++ b/packages/reflex-release/src/reflex_release/templates/workflows/app/changelog.yml @@ -0,0 +1,20 @@ +@@HEADER@@ +name: Check release workflows + +on: + pull_request: + workflow_dispatch: + +permissions: + contents: read + +jobs: + check: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + persist-credentials: false + - uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0 +@@UV_SETUP_WITH@@ + - run: @@CLI@@ sync --check diff --git a/packages/reflex-release/src/reflex_release/templates/workflows/app/deploy_dev.yml b/packages/reflex-release/src/reflex_release/templates/workflows/app/deploy_dev.yml new file mode 100644 index 00000000000..0dedf1733f4 --- /dev/null +++ b/packages/reflex-release/src/reflex_release/templates/workflows/app/deploy_dev.yml @@ -0,0 +1,60 @@ +@@HEADER@@ +name: Deploy development application + +on: + schedule: + - cron: "@@DEV_SCHEDULE@@" + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: app-development + cancel-in-progress: false + queue: max + +jobs: + prepare: + if: github.ref == 'refs/heads/@@MAIN_BRANCH@@' + runs-on: ubuntu-latest + outputs: + version: ${{ steps.source.outputs.version }} + ref: ${{ steps.source.outputs.ref }} + source-revision: ${{ steps.source.outputs.source_revision }} + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + ref: ${{ github.sha }} + persist-credentials: false + submodules: recursive + - uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0 +@@UV_SETUP_WITH@@ + - id: source + env: + GH_TOKEN: ${{ github.token }} + run: @@CLI@@ app-dev + + build: + needs: prepare + permissions: + contents: read + id-token: write + uses: ./.github/workflows/@@BUILD_WORKFLOW@@ + with: + ref: ${{ needs.prepare.outputs.ref }} + version: ${{ needs.prepare.outputs.version }} + source-revision: ${{ needs.prepare.outputs.source-revision }} + secrets: inherit + + deploy: + needs: [prepare, build] + permissions: + contents: read + id-token: write + uses: ./.github/workflows/@@DEPLOY_WORKFLOW@@ + with: + ref: ${{ needs.prepare.outputs.ref }} + version: ${{ needs.prepare.outputs.version }} + environment: "@@DEV_ENVIRONMENT@@" + secrets: inherit diff --git a/packages/reflex-release/src/reflex_release/templates/workflows/app/dispatch_release.yml b/packages/reflex-release/src/reflex_release/templates/workflows/app/dispatch_release.yml new file mode 100644 index 00000000000..202595e9b84 --- /dev/null +++ b/packages/reflex-release/src/reflex_release/templates/workflows/app/dispatch_release.yml @@ -0,0 +1,54 @@ +@@HEADER@@ +name: Dispatch release + +on: + workflow_dispatch: + inputs: + revision: + description: "Optional source submodule SHA, branch or tag (empty uses configured source-ref)" + required: false + type: string + reason: + description: "Why this deployment is being released" + required: false + type: string + +permissions: + contents: read + +concurrency: + group: dispatch-app-release + cancel-in-progress: false + queue: max + +jobs: + materialize: + if: github.ref == 'refs/heads/@@MAIN_BRANCH@@' + runs-on: ubuntu-latest + permissions: + contents: write + pull-requests: write + actions: write + steps: + # Resolve main after entering the dispatch queue, so version allocation + # includes every release PR that has merged while this run was waiting. + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + ref: "@@MAIN_BRANCH@@" + fetch-depth: 0 + persist-credentials: false + submodules: recursive + - uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0 +@@UV_SETUP_WITH@@ + - name: Pin source and materialize changelog + id: materialize + env: + TARGET_REVISION: ${{ inputs.revision }} + DEPLOY_REASON: ${{ inputs.reason }} + GH_TOKEN: ${{ github.token }} + run: @@CLI@@ app-materialize + - name: Open release PR + env: + VERSION: ${{ steps.materialize.outputs.version }} + GH_TOKEN: ${{ github.token }} + run: @@CLI@@ app-open-pr diff --git a/packages/reflex-release/src/reflex_release/templates/workflows/app/publish.yml b/packages/reflex-release/src/reflex_release/templates/workflows/app/publish.yml new file mode 100644 index 00000000000..e494755ea6c --- /dev/null +++ b/packages/reflex-release/src/reflex_release/templates/workflows/app/publish.yml @@ -0,0 +1,107 @@ +@@HEADER@@ +name: Publish application + +# This workflow is only called through the queued release_from_changelog +# workflow. Repository hooks must succeed only once deployment is complete. +on: + workflow_call: + +permissions: + contents: read + +jobs: + detect: + runs-on: ubuntu-latest + outputs: + any: ${{ steps.detect.outputs.any }} + version: ${{ steps.detect.outputs.version }} + ref: ${{ steps.detect.outputs.ref }} + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + ref: ${{ github.sha }} + fetch-depth: 0 + persist-credentials: false + - uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0 +@@UV_SETUP_WITH@@ + - id: detect + env: + REF_NAME: ${{ github.ref_name }} + run: @@CLI@@ app-detect + + build: + needs: detect + if: needs.detect.outputs.any == 'true' + permissions: + contents: read + id-token: write + uses: ./.github/workflows/@@BUILD_WORKFLOW@@ + with: + ref: ${{ needs.detect.outputs.ref }} + version: ${{ needs.detect.outputs.version }} + source-revision: "" + secrets: inherit + + staging: + needs: [detect, build] + permissions: + contents: read + id-token: write + uses: ./.github/workflows/@@DEPLOY_WORKFLOW@@ + with: + ref: ${{ needs.detect.outputs.ref }} + version: ${{ needs.detect.outputs.version }} + environment: "@@STAGING_ENVIRONMENT@@" + secrets: inherit + + approval: + needs: staging + runs-on: ubuntu-latest + environment: "@@PRODUCTION_ENVIRONMENT@@" + permissions: + contents: read + actions: read + steps: + - name: Require configured production reviewers + env: + GH_TOKEN: ${{ github.token }} + ENVIRONMENT: "@@PRODUCTION_ENVIRONMENT@@" + ALLOW_SELF_REVIEW: "@@ALLOW_SELF_REVIEW@@" + run: | + set -euo pipefail + rules=$(gh api "repos/${GITHUB_REPOSITORY}/environments/${ENVIRONMENT}") + jq -e '.protection_rules | any(.type == "required_reviewers" and (.reviewers | length > 0))' <<< "$rules" + if [[ "$ALLOW_SELF_REVIEW" != "true" ]]; then + jq -e '.protection_rules | any(.type == "required_reviewers" and .prevent_self_review == true)' <<< "$rules" + fi + + production: + needs: [detect, approval] + permissions: + contents: read + id-token: write + uses: ./.github/workflows/@@DEPLOY_WORKFLOW@@ + with: + ref: ${{ needs.detect.outputs.ref }} + version: ${{ needs.detect.outputs.version }} + environment: "@@PRODUCTION_ENVIRONMENT@@" + secrets: inherit + + release: + needs: [detect, production] + runs-on: ubuntu-latest + permissions: + contents: write + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + ref: ${{ needs.detect.outputs.ref }} + fetch-depth: 0 + persist-credentials: false + - uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0 +@@UV_SETUP_WITH@@ + - name: Tag deployed commit and create GitHub release + env: + GH_TOKEN: ${{ github.token }} + VERSION: ${{ needs.detect.outputs.version }} + run: @@CLI@@ app-finalize diff --git a/packages/reflex-release/src/reflex_release/templates/workflows/app/release_from_changelog.yml b/packages/reflex-release/src/reflex_release/templates/workflows/app/release_from_changelog.yml new file mode 100644 index 00000000000..51b3bad51d3 --- /dev/null +++ b/packages/reflex-release/src/reflex_release/templates/workflows/app/release_from_changelog.yml @@ -0,0 +1,27 @@ +@@HEADER@@ +name: Release from changelog + +on: + push: + branches: ["@@MAIN_BRANCH@@"] + workflow_dispatch: + +permissions: + contents: read + +# Own the staging environment until production and tagging finish. Later runs +# queue instead of replacing pending runs (GitHub allows up to 100 pending). +concurrency: + group: app-release + cancel-in-progress: false + queue: max + +jobs: + publish: + if: github.ref == 'refs/heads/@@MAIN_BRANCH@@' + permissions: + contents: write + actions: read + id-token: write + uses: ./.github/workflows/publish.yml + secrets: inherit diff --git a/tests/units/reflex_release/test_app.py b/tests/units/reflex_release/test_app.py new file mode 100644 index 00000000000..6dc6bec62ca --- /dev/null +++ b/tests/units/reflex_release/test_app.py @@ -0,0 +1,271 @@ +"""Application release tests using real repositories and pinned submodule commits.""" + +from __future__ import annotations + +import datetime as dt +import re +from pathlib import Path + +import pytest +import yaml +from reflex_release import app +from reflex_release.actions import ReleaseError +from reflex_release.cli import main +from reflex_release.config import Config, load_config +from reflex_release.scaffold import managed_workflows, render, sync + +from .conftest import commit_all, git + + +@pytest.fixture +def app_repo(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Config: + root = tmp_path / "app" + root.mkdir() + (root / "pyproject.toml").write_text( + "[tool.reflex-release]\n[tool.reflex-release.app]\n" + 'build-workflow = "build.yml"\ndeploy-workflow = "deploy.yml"\n' + 'dev-schedule = "*/30 * * * *"\n' + ) + workflows = root / ".github/workflows" + workflows.mkdir(parents=True) + for name, inputs in ( + ("build", ("ref", "version", "source-revision")), + ("deploy", ("ref", "version", "environment")), + ): + (workflows / f"{name}.yml").write_text( + "on:\n workflow_call:\n inputs:\n" + + "".join( + f" {field}:\n type: string\n required: true\n" + for field in inputs + ) + + "jobs: {}\n" + ) + git(root, "init", "-q", "-b", "main") + commit_all(root) + remote = tmp_path / "remote.git" + git(root, "clone", "--bare", str(root), str(remote)) + git(root, "remote", "add", "origin", str(remote)) + + class FrozenDateTime(dt.datetime): + @classmethod + def now(cls, tz=None): + return cls(2026, 9, 11, 12, tzinfo=tz) + + monkeypatch.setattr(app.dt, "datetime", FrozenDateTime) + return load_config(root) + + +def test_materialize_reserves_versions_from_tags_and_open_pr_branches(app_repo: Config): + root = app_repo.root + git(root, "tag", "2026.37.2") + git(root, "push", "origin", "HEAD:refs/heads/release/2026.37.3") + version = app.materialize(app_repo, "", "Fix docs\n\n## 9999.1.0\nMore detail") + assert version == "2026.37.4" + assert [version for version, _ in app.sections(app_repo)] == [version] + assert " ## 9999.1.0" in (root / "CHANGELOG.md").read_text() + assert not app.tag_exists(root, version) + assert app.materialize(app_repo, "", "Again") == "2026.37.5" + + +@pytest.mark.parametrize( + ("version", "date", "counter"), + [ + ("2020.53.0", dt.date(2020, 12, 28), 0), + ("2026.1.10", dt.date(2025, 12, 29), 10), + ], +) +def test_iso_week_versions(version, date, counter): + assert app.version_key(version) == (date, counter) + + +@pytest.mark.parametrize( + "version", + ["2021.53.0", "2026.0.0", "2026.37.01", "2026-W37-5", "-x", "2026.37.0\nany=true"], +) +def test_bad_versions(version): + with pytest.raises(ReleaseError, match="invalid app version"): + app.version_key(version) + + +def test_detect_untagged_published_and_wrong_branch(app_repo: Config, capsys): + version = app.materialize(app_repo, "", "Release") + capsys.readouterr() + app.detect(app_repo, "main") + assert "any=true" in capsys.readouterr().out + git(app_repo.root, "tag", version) + app.detect(app_repo, "main") + assert "any=false" in capsys.readouterr().out + with pytest.raises(ReleaseError, match="only deploy from main"): + app.detect(app_repo, "feature") + + +def test_detect_empty_and_stale(app_repo: Config, capsys): + app.detect(app_repo, "main") + assert "any=false" in capsys.readouterr().out + app.materialize(app_repo, "", "Old version") + git(app_repo.root, "tag", "2026.37.99") + with pytest.raises(ReleaseError, match="newer published"): + app.detect(app_repo, "main") + + +@pytest.mark.parametrize( + "text", ["## 2026.37.0\n\n## 2026.37.1\n", "## 2026.37.0\n\n## 2026.37.0\n"] +) +def test_changelog_must_be_ordered_and_unique(app_repo: Config, text): + (app_repo.root / "CHANGELOG.md").write_text(text) + with pytest.raises(ReleaseError, match="unique and ordered"): + app.sections(app_repo) + + +def test_source_resolves_branch_tag_sha_and_commits_only_the_pin( + app_repo: Config, tmp_path: Path, monkeypatch +): + source = tmp_path / "source" + source.mkdir() + git(source, "init", "-q", "-b", "main") + (source / "app.txt").write_text("old") + commit_all(source) + old = git(source, "rev-parse", "HEAD").strip() + git(source, "tag", "v1") + git( + app_repo.root, + "-c", + "protocol.file.allow=always", + "submodule", + "add", + str(source), + "source", + ) + pyproject = app_repo.root / "pyproject.toml" + pyproject.write_text(pyproject.read_text() + 'source-submodule = "source"\n') + commit_all(app_repo.root) + config = load_config(app_repo.root) + (source / "app.txt").write_text("new") + commit_all(source) + new = git(source, "rev-parse", "HEAD").strip() + assert app.resolve_source(config) == new + assert app.resolve_source(config, "v1") == old + assert app.resolve_source(config, new) == new + version = app.materialize(config, "v1", "Pin the known good revision") + assert old in app.sections(config)[0][1] + assert git(app_repo.root / "source", "rev-parse", "HEAD").strip() == old + # An unrelated staged change must not sneak into the release commit. + (app_repo.root / "unrelated.txt").write_text("private work") + git(app_repo.root, "add", "unrelated.txt") + calls = [] + monkeypatch.setattr(app, "gh_run", lambda args, cwd: calls.append(args)) + app.open_pr(config, version) + assert calls[0][:2] == ["pr", "create"] + assert calls[0][calls[0].index("--base") + 1] == "main" + assert "unrelated.txt" not in git( + app_repo.root, "show", "--format=", "--name-only", "HEAD" + ) + assert "160000" in git(app_repo.root, "ls-tree", "HEAD", "source") + assert not app.tag_exists(app_repo.root, version) + + +def test_finalize_reuses_release_tools_and_rejects_wrong_commit( + app_repo: Config, monkeypatch +): + version = app.materialize(app_repo, "", "Deploy now") + commit_all(app_repo.root) + called = [] + monkeypatch.setattr(app, "cmd_push_tag", lambda *args: called.append("tag")) + monkeypatch.setattr( + app, "cmd_create_release", lambda *args: called.append("release") + ) + app.finalize(app_repo, version) + assert called == ["tag", "release"] + git(app_repo.root, "tag", version, "HEAD~1") + with pytest.raises(ReleaseError, match="different commit"): + app.finalize(app_repo, version) + assert called == ["tag", "release"] + + +def test_dev_never_changes_changelog_or_tags(app_repo: Config, capsys): + app.dev(app_repo) + assert "version=dev-" in capsys.readouterr().out + assert not (app_repo.root / "CHANGELOG.md").exists() + assert not git(app_repo.root, "tag", "--list").strip() + with pytest.raises(ReleaseError, match="requires app source-submodule"): + app.resolve_source(app_repo, "main") + + +@pytest.mark.parametrize( + "extra", + [ + 'source-submodule = "../outside"', + 'source-submodule = ".git"', + 'source-ref = "--upload-pack=bad"', + 'production-environment = "${{ inputs.bad }}"', + 'staging-environment = "production"', + 'dev-schedule = "* * * * *\\nfoo"', + 'typo = "value"', + ], +) +def test_reject_unsafe_app_configuration(app_repo: Config, extra): + path = app_repo.root / "pyproject.toml" + # Replace the default cron when testing that setting. + text = path.read_text() + if extra.startswith("dev-schedule"): + text = re.sub(r"dev-schedule = .*\n", "", text) + path.write_text(text + extra + "\n") + with pytest.raises(ReleaseError): + load_config(app_repo.root) + + +def test_app_workflow_contracts_and_dependency_graph(app_repo: Config): + sync(app_repo) + sync(app_repo, check=True) + workflows = { + name: yaml.safe_load(render(name, app_repo)) + for name in managed_workflows(app_repo) + } + release = workflows["release_from_changelog.yml"] + assert release["concurrency"]["queue"] == "max" + assert release["concurrency"]["cancel-in-progress"] is False + assert "tags" not in release[True]["push"] + jobs = workflows["publish.yml"]["jobs"] + assert jobs["production"]["needs"] == ["detect", "approval"] + assert jobs["release"]["needs"] == ["detect", "production"] + assert jobs["approval"]["environment"] == "production" + assert jobs["build"]["with"]["source-revision"] == "" + assert "workflow_dispatch" not in workflows["publish.yml"][True] + assert "submodules" not in jobs["release"]["steps"][0]["with"] + (app_repo.root / ".github/workflows/build.yml").write_text( + "on:\n workflow_call:\n" + ) + with pytest.raises(ReleaseError, match="workflow_call inputs"): + sync(app_repo, check=True) + + +def test_app_cli_materialize_and_detect(app_repo: Config, capsys): + assert ( + main([ + "--root", + str(app_repo.root), + "app-materialize", + "--reason", + "CLI deploy", + ]) + == 0 + ) + assert main(["--root", str(app_repo.root), "app-detect", "--ref-name", "main"]) == 0 + assert "any=true" in capsys.readouterr().out + + +def test_app_init_does_not_add_towncrier(app_repo: Config): + assert main(["--root", str(app_repo.root), "init"]) == 0 + assert "towncrier" not in (app_repo.root / "pyproject.toml").read_text() + assert not (app_repo.root / "news").exists() + + +def test_removing_dev_schedule_removes_only_generated_dev_workflow(app_repo: Config): + sync(app_repo) + path = app_repo.root / "pyproject.toml" + path.write_text(re.sub(r"dev-schedule = .*\n", "", path.read_text())) + config = load_config(app_repo.root) + sync(config) + assert not (app_repo.root / ".github/workflows/deploy_dev.yml").exists() + assert (app_repo.root / ".github/workflows/build.yml").exists() + assert (app_repo.root / ".github/workflows/deploy.yml").exists() diff --git a/tests/units/reflex_release/test_scaffold.py b/tests/units/reflex_release/test_scaffold.py index 4f7b9db83fd..a5a0ae0c759 100644 --- a/tests/units/reflex_release/test_scaffold.py +++ b/tests/units/reflex_release/test_scaffold.py @@ -75,7 +75,7 @@ def test_every_template_pins_the_uv_it_installs() -> None: added to a template without one would silently install an unpinned uv. """ found = 0 - for template in sorted(TEMPLATE_DIR.glob("*.yml")): + for template in sorted(TEMPLATE_DIR.rglob("*.yml")): lines = template.read_text(encoding="utf-8").splitlines() for index, line in enumerate(lines): if _UV_SETUP not in line: @@ -123,7 +123,7 @@ def test_render_quiets_setup_uv_only_where_there_is_no_checkout( block there would quietly turn a cache it wants off. """ found = 0 - for name in GENERATED_WORKFLOWS: + for name in (*CORE_WORKFLOWS, INTERNAL_WORKFLOW): checked_out, bare = _uv_steps_by_checkout(render(name, config)) found += len(bare) for step in bare: From 467fec18edb7b0c0f5f64cb46ce17e01f0bdcecb Mon Sep 17 00:00:00 2001 From: Masen Furer Date: Wed, 16 Sep 2026 15:49:02 -0700 Subject: [PATCH 2/3] Address app release workflow validation and review findings --- packages/reflex-release/README.md | 6 +- .../reflex-release/src/reflex_release/app.py | 18 ++++ .../reflex-release/src/reflex_release/cli.py | 5 +- .../src/reflex_release/config.py | 11 ++ .../src/reflex_release/scaffold.py | 50 ++++++++- .../templates/workflows/app/changelog.yml | 27 +++++ tests/units/reflex_release/test_app.py | 100 ++++++++++++++++-- 7 files changed, 199 insertions(+), 18 deletions(-) diff --git a/packages/reflex-release/README.md b/packages/reflex-release/README.md index b860921f8ac..b94bff4b0b6 100644 --- a/packages/reflex-release/README.md +++ b/packages/reflex-release/README.md @@ -1070,8 +1070,10 @@ starts from the configured main branch, resolves the submodule branch, tag or SH section, and opens a PR against main. Omitting `source-submodule` supports apps whose source lives entirely in the deployment repository; in that case leave `revision` empty. The source repository URL comes from `.gitmodules`. The default -checkout/token must be able to read it; private cross-repository submodules need -an appropriately scoped checkout credential configured by the consumer. +checkout/token must be able to read it. Private cross-repository submodules are +not supported by the generated app workflows: their checkout uses the current +repository's token and there is no configurable cross-repository credential. +Use a public source submodule or keep the source in the deployment repository. Versions use `ISO-year.week.sequence`, for example `2026.37.0`, `2026.37.1`. Weeks and week years follow ISO 8601 in `release-timezone`, including December/ diff --git a/packages/reflex-release/src/reflex_release/app.py b/packages/reflex-release/src/reflex_release/app.py index 0e45c0d64b1..c8639fc2cf9 100644 --- a/packages/reflex-release/src/reflex_release/app.py +++ b/packages/reflex-release/src/reflex_release/app.py @@ -19,6 +19,7 @@ git, git_push, git_run, + git_show, tag_exists, ) @@ -90,6 +91,23 @@ def sections(config: Config) -> list[tuple[str, str]]: return result +def check_headings(config: Config, base_ref: str) -> None: + """Reject newly added application release headings in ordinary pull requests. + + Args: + config: Repository configuration. + base_ref: The revision to compare the changelog against. + """ + base_text = git_show(config.root, base_ref, config.changelog_filename) or "" + known = {heading["version"] for heading in HEADING_RE.finditer(base_text)} + added = [version for version, _ in sections(config) if version not in known] + if added: + fail( + "new CHANGELOG.md version headings are deployment triggers and must be " + "materialized by the Dispatch release workflow: " + ", ".join(added) + ) + + def resolve_source(config: Config, revision: str = "") -> str: """Resolve and check out the requested upstream revision in the source submodule. diff --git a/packages/reflex-release/src/reflex_release/cli.py b/packages/reflex-release/src/reflex_release/cli.py index 2e4b42d40ac..107f568988e 100644 --- a/packages/reflex-release/src/reflex_release/cli.py +++ b/packages/reflex-release/src/reflex_release/cli.py @@ -362,7 +362,10 @@ def dispatch(args: argparse.Namespace, config: Config) -> None: case "extract-notes": commands.cmd_extract_notes(config, args.package, args.version, args.output) case "check-headings": - commands.cmd_check_headings(config, args.base_ref) + if config.app: + app.check_headings(config, args.base_ref) + else: + commands.cmd_check_headings(config, args.base_ref) case "changelog-check": commands.cmd_changelog_check(config, args.base_ref) case "check-dev-pins": diff --git a/packages/reflex-release/src/reflex_release/config.py b/packages/reflex-release/src/reflex_release/config.py index dc87580c374..550f256bccc 100644 --- a/packages/reflex-release/src/reflex_release/config.py +++ b/packages/reflex-release/src/reflex_release/config.py @@ -12,6 +12,7 @@ import re import sys from pathlib import Path +from zoneinfo import ZoneInfo, ZoneInfoNotFoundError from packaging.version import Version @@ -938,11 +939,17 @@ def _load_app(table: dict, towncrier: dict) -> AppConfig: key in table for key in ( "root-package", + "root-source-dirs", "packages-dir", + "package-source-subdirs", "custom-build", + "dispatch-package-inputs", "lockstep", "internal-packages", + "changelog-exempt-packages", "never-publish-packages", + "prerelease-branch-prefix", + "hotfix-branch-prefix", "latest-release-package", "post-release-workflow", "tag-prefix", @@ -1074,6 +1081,10 @@ def load_config(root: Path) -> Config: ) if "app" in table: + try: + ZoneInfo(config.release_timezone) + except (ValueError, ZoneInfoNotFoundError): + fail(f"invalid release-timezone: {config.release_timezone!r}") return dataclasses.replace( config, app=_load_app(table, towncrier), packages_dir=None ) diff --git a/packages/reflex-release/src/reflex_release/scaffold.py b/packages/reflex-release/src/reflex_release/scaffold.py index 47edc4797f7..7912f51347d 100644 --- a/packages/reflex-release/src/reflex_release/scaffold.py +++ b/packages/reflex-release/src/reflex_release/scaffold.py @@ -503,6 +503,7 @@ def render(name: str, config: Config) -> str: "@@CLI@@": cli, "@@UV_SETUP_WITH@@": _uv_setup_block(config), "@@MAIN_BRANCH@@": config.main_branch, + "@@RELEASE_PREFIX@@": config.release_branch_prefix, "@@BUILD_WORKFLOW@@": config.app.build_workflow, "@@DEPLOY_WORKFLOW@@": config.app.deploy_workflow, "@@PRODUCTION_ENVIRONMENT@@": config.app.production_environment, @@ -672,6 +673,18 @@ def workflow_call_inputs(text: str) -> set[str]: Returns: The declared input names, empty when the block cannot be found. """ + return set(_workflow_call_input_blocks(text)) + + +def _workflow_call_input_blocks(text: str) -> dict[str, list[str]]: + """Read block-style workflow inputs and their declaration lines. + + Args: + text: The workflow file's contents. + + Returns: + Input names mapped to their nested declaration lines. + """ lines = [ line for line in text.splitlines() @@ -679,19 +692,21 @@ def workflow_call_inputs(text: str) -> set[str]: ] call = _key_index(lines, "workflow_call") if call is None: - return set() + return {} block = _nested_lines(lines, call) inputs = _key_index(block, "inputs") if inputs is None: - return set() + return {} declared = _nested_lines(block, inputs) if not declared: - return set() + return {} # 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 + line.strip().partition(":")[0]: _nested_lines(declared, index) + for index, line in enumerate(declared) + if _indent_of(line) == depth } @@ -804,11 +819,36 @@ def sync(config: Config, check: bool = False, force: bool = False) -> None: fail( f"app workflow {workflow} must be an existing, repository-owned workflow" ) - missing = inputs - workflow_call_inputs(target.read_text(encoding="utf-8")) + declarations = _workflow_call_input_blocks( + target.read_text(encoding="utf-8") + ) + missing = inputs - declarations.keys() if missing: fail( f"app workflow {workflow} must declare workflow_call inputs: {', '.join(sorted(missing))}" ) + for name in sorted(inputs): + lines = declarations[name] + depth = min((_indent_of(line) for line in lines), default=0) + lines = [line for line in lines if _indent_of(line) == depth] + if not ( + any( + re.fullmatch( + r"\s+type:\s*(?:string|'string'|\"string\")\s*(?:#.*)?", + line, + ) + for line in lines + ) + and any( + re.fullmatch( + r"\s+required:\s*(?:true|True|TRUE)\s*(?:#.*)?", line + ) + for line in lines + ) + ): + fail( + f"app workflow {workflow} input {name!r} must be a required string" + ) else: check_title_format(config) check_custom_build_workflows(config) diff --git a/packages/reflex-release/src/reflex_release/templates/workflows/app/changelog.yml b/packages/reflex-release/src/reflex_release/templates/workflows/app/changelog.yml index 620d0c18776..651decca488 100644 --- a/packages/reflex-release/src/reflex_release/templates/workflows/app/changelog.yml +++ b/packages/reflex-release/src/reflex_release/templates/workflows/app/changelog.yml @@ -3,18 +3,45 @@ name: Check release workflows on: pull_request: + branches: ["@@MAIN_BRANCH@@"] + types: [opened, synchronize, reopened, labeled, unlabeled] workflow_dispatch: permissions: contents: read + pull-requests: read jobs: check: runs-on: ubuntu-latest steps: + - name: Read the pull request's labels + if: github.event_name == 'pull_request' + id: labels + env: + GH_TOKEN: ${{ github.token }} + PR_URL: ${{ github.event.pull_request.url }} + shell: bash + run: | + set -euo pipefail + gh api "$PR_URL" --jq ' + [.labels[].name] | + "version_edit=\(index("changelog-version-edit") != null)" + ' >> "$GITHUB_OUTPUT" - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: + fetch-depth: 0 persist-credentials: false - uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0 @@UV_SETUP_WITH@@ + - name: Reject manual changelog version headings + if: >- + ${{ github.event_name == 'pull_request' + && steps.labels.outputs.version_edit != 'true' + && !(startsWith(github.head_ref, '@@RELEASE_PREFIX@@') + && github.event.pull_request.user.login == 'github-actions[bot]') }} + env: + BASE_REF: origin/${{ github.base_ref }} + shell: bash + run: @@CLI@@ check-headings - run: @@CLI@@ sync --check diff --git a/tests/units/reflex_release/test_app.py b/tests/units/reflex_release/test_app.py index 6dc6bec62ca..590cb04b191 100644 --- a/tests/units/reflex_release/test_app.py +++ b/tests/units/reflex_release/test_app.py @@ -87,21 +87,20 @@ def test_bad_versions(version): app.version_key(version) -def test_detect_untagged_published_and_wrong_branch(app_repo: Config, capsys): +def test_detect_untagged_published_and_wrong_branch(app_repo: Config, outputs): version = app.materialize(app_repo, "", "Release") - capsys.readouterr() app.detect(app_repo, "main") - assert "any=true" in capsys.readouterr().out + assert outputs()["any"] == "true" git(app_repo.root, "tag", version) app.detect(app_repo, "main") - assert "any=false" in capsys.readouterr().out + assert outputs()["any"] == "false" with pytest.raises(ReleaseError, match="only deploy from main"): app.detect(app_repo, "feature") -def test_detect_empty_and_stale(app_repo: Config, capsys): +def test_detect_empty_and_stale(app_repo: Config, outputs): app.detect(app_repo, "main") - assert "any=false" in capsys.readouterr().out + assert outputs()["any"] == "false" app.materialize(app_repo, "", "Old version") git(app_repo.root, "tag", "2026.37.99") with pytest.raises(ReleaseError, match="newer published"): @@ -182,9 +181,9 @@ def test_finalize_reuses_release_tools_and_rejects_wrong_commit( assert called == ["tag", "release"] -def test_dev_never_changes_changelog_or_tags(app_repo: Config, capsys): +def test_dev_never_changes_changelog_or_tags(app_repo: Config, outputs): app.dev(app_repo) - assert "version=dev-" in capsys.readouterr().out + assert outputs()["version"].startswith("dev-") assert not (app_repo.root / "CHANGELOG.md").exists() assert not git(app_repo.root, "tag", "--list").strip() with pytest.raises(ReleaseError, match="requires app source-submodule"): @@ -239,7 +238,88 @@ def test_app_workflow_contracts_and_dependency_graph(app_repo: Config): sync(app_repo, check=True) -def test_app_cli_materialize_and_detect(app_repo: Config, capsys): +@pytest.mark.parametrize( + "setting", + [ + "changelog-exempt-packages = []", + 'root-source-dirs = ["src"]', + 'package-source-subdirs = ["src"]', + 'dispatch-package-inputs = "text"', + 'prerelease-branch-prefix = "pre/"', + 'hotfix-branch-prefix = "hotfix/"', + ], +) +def test_app_rejects_package_only_settings(app_repo: Config, setting): + """App mode rejects settings that only affect package publishing.""" + path = app_repo.root / "pyproject.toml" + path.write_text( + path.read_text().replace( + "[tool.reflex-release.app]", setting + "\n[tool.reflex-release.app]" + ) + ) + with pytest.raises(ReleaseError, match="package"): + load_config(app_repo.root) + + +@pytest.mark.parametrize("timezone", ["Mars/Olympus", "", "/etc/passwd"]) +def test_app_rejects_invalid_timezone_at_load(app_repo: Config, timezone): + """Invalid timezones produce a configuration error before release allocation.""" + path = app_repo.root / "pyproject.toml" + path.write_text( + path.read_text().replace( + "[tool.reflex-release.app]", + f'release-timezone = "{timezone}"\n[tool.reflex-release.app]', + ) + ) + with pytest.raises(ReleaseError, match="release-timezone"): + load_config(app_repo.root) + + +@pytest.mark.parametrize( + "field", + [ + "type: boolean", + "type: number", + "description: missing type", + "type: string\n required: false", + "type: boolean\n required: true\n description: |\n type: string", + ], +) +def test_app_rejects_invalid_hook_contract(app_repo: Config, field): + """Hooks must declare the required string inputs the generated caller sends.""" + path = app_repo.root / ".github/workflows/build.yml" + text = path.read_text().replace("type: string\n required: true", field, 1) + path.write_text(text) + with pytest.raises(ReleaseError, match="required string"): + sync(app_repo) + + +def test_app_checks_manual_changelog_headings(app_repo: Config): + """Ordinary PRs cannot introduce a heading that triggers a deployment.""" + args = ["--root", str(app_repo.root), "check-headings", "--base-ref", "HEAD"] + assert main(args) == 0 + path = app_repo.root / "CHANGELOG.md" + path.write_text("## 2026.37.0\n\nRelease notes.\n") + assert main(args) == 1 + commit_all(app_repo.root) + path.write_text(path.read_text() + "Additional notes.\n") + assert main(args) == 0 + + +def test_app_changelog_workflow_guards_release_headings(app_repo: Config): + """Generated PR checks enforce heading provenance with the bot exemption.""" + workflow = yaml.safe_load(render("changelog.yml", app_repo)) + steps = workflow["jobs"]["check"]["steps"] + guard = next( + step for step in steps if step.get("run", "").endswith(" check-headings") + ) + assert "github-actions[bot]" in guard["if"] + assert "startsWith(github.head_ref, 'release/')" in guard["if"] + assert "version_edit" in guard["if"] + assert workflow["permissions"]["pull-requests"] == "read" + + +def test_app_cli_materialize_and_detect(app_repo: Config, outputs): assert ( main([ "--root", @@ -251,7 +331,7 @@ def test_app_cli_materialize_and_detect(app_repo: Config, capsys): == 0 ) assert main(["--root", str(app_repo.root), "app-detect", "--ref-name", "main"]) == 0 - assert "any=true" in capsys.readouterr().out + assert outputs()["any"] == "true" def test_app_init_does_not_add_towncrier(app_repo: Config): From 23ba96ea37a423b060936eac6387b5d12d5ad6ce Mon Sep 17 00:00:00 2001 From: Masen Furer Date: Wed, 16 Sep 2026 17:01:24 -0700 Subject: [PATCH 3/3] Cancel stale app changelog checks and clarify hook syntax --- packages/reflex-release/README.md | 5 +++++ packages/reflex-release/src/reflex_release/scaffold.py | 3 ++- .../src/reflex_release/templates/workflows/app/changelog.yml | 4 ++++ tests/units/reflex_release/test_app.py | 4 ++++ 4 files changed, 15 insertions(+), 1 deletion(-) diff --git a/packages/reflex-release/README.md b/packages/reflex-release/README.md index b94bff4b0b6..b5e32c6725c 100644 --- a/packages/reflex-release/README.md +++ b/packages/reflex-release/README.md @@ -1111,6 +1111,11 @@ inside its own hooks. ### Repository-owned hook contract +Declare input metadata in block style (`type: string` and `required: true` on +separate indented lines). The release tool checks this deliberately limited +format without loading a YAML parser on the privileged release path; flow-style +mappings such as `version: {type: string, required: true}` are not supported. + Both hooks are local reusable workflows under `.github/workflows`, with the following `workflow_call` string inputs: diff --git a/packages/reflex-release/src/reflex_release/scaffold.py b/packages/reflex-release/src/reflex_release/scaffold.py index 7912f51347d..8a19cfe605a 100644 --- a/packages/reflex-release/src/reflex_release/scaffold.py +++ b/packages/reflex-release/src/reflex_release/scaffold.py @@ -847,7 +847,8 @@ def sync(config: Config, check: bool = False, force: bool = False) -> None: ) ): fail( - f"app workflow {workflow} input {name!r} must be a required string" + f"app workflow {workflow} input {name!r} must be a required string " + "using block-style type and required keys" ) else: check_title_format(config) diff --git a/packages/reflex-release/src/reflex_release/templates/workflows/app/changelog.yml b/packages/reflex-release/src/reflex_release/templates/workflows/app/changelog.yml index 651decca488..8835821dd62 100644 --- a/packages/reflex-release/src/reflex_release/templates/workflows/app/changelog.yml +++ b/packages/reflex-release/src/reflex_release/templates/workflows/app/changelog.yml @@ -7,6 +7,10 @@ on: types: [opened, synchronize, reopened, labeled, unlabeled] workflow_dispatch: +concurrency: + group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + permissions: contents: read pull-requests: read diff --git a/tests/units/reflex_release/test_app.py b/tests/units/reflex_release/test_app.py index 590cb04b191..d617afc054f 100644 --- a/tests/units/reflex_release/test_app.py +++ b/tests/units/reflex_release/test_app.py @@ -317,6 +317,10 @@ def test_app_changelog_workflow_guards_release_headings(app_repo: Config): assert "startsWith(github.head_ref, 'release/')" in guard["if"] assert "version_edit" in guard["if"] assert workflow["permissions"]["pull-requests"] == "read" + assert workflow["concurrency"] == { + "group": "${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}", + "cancel-in-progress": True, + } def test_app_cli_materialize_and_detect(app_repo: Config, outputs):