Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
53 changes: 53 additions & 0 deletions packages/reflex-release/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -132,6 +132,10 @@ internal-packages = []
# Packages excluded from the pull-request news-fragment requirement.
changelog-exempt-packages = []

# A workflow of your own to run after every published tag. Omit it (or leave it
# empty) to dispatch nothing. See "Post-release workflow".
post-release-workflow = "docs_publish.yml"

# How the Dispatch release form asks which packages to release: one checkbox
# per package ("checkboxes"), a comma-separated field ("text"), or "auto" —
# checkboxes while they fit under GitHub's ten-input workflow_dispatch limit,
Expand Down Expand Up @@ -516,6 +520,54 @@ unzip -l "$BUILD_DIR"/dist/*.whl | grep -q '\.pyi$' || {
}
```

## Post-release workflow

Set `post-release-workflow` to a workflow of your own and `publish.yml`
dispatches it once per published tag, after the upload, the tag and the GitHub
release all exist — the hook for whatever has to follow a release: publishing
docs, refreshing a container image, notifying a downstream repository.

It runs **on the tag**, so it sees exactly the tree that was published, and it
is handed the three facts about the release:

```yaml
# .github/workflows/docs_publish.yml
on:
workflow_dispatch:
inputs:
tag:
description: "The published tag"
required: true
type: string
package:
description: "The published package"
required: true
type: string
version:
description: "The published version"
required: true
type: string
```

All three inputs are required: GitHub rejects a dispatch that passes inputs the
workflow does not declare, and a dispatch with *empty* ones is accepted, so
`post-release` refuses to run without all three rather than telling your
workflow nothing.

The workflow must exist on the default branch (GitHub's rule for
`workflow_dispatch`) and be one of yours. `sync` rejects any name a generated
workflow answers to — its file name *or* its display name, since `gh workflow
run` resolves either, and including the ones your repository does not currently
get, like `auto_release_internal.yml`.

Adding or removing the setting changes `publish.yml` (the dispatch step) and the
`actions: write` grant it needs in `publish.yml`, `release_from_changelog.yml`
and `auto_release_internal.yml`, so re-run `reflex-release sync`.

The dispatch is the last thing a release does, so a failure there never leaves a
half-published version — but it does fail the run, loudly, naming the tag whose
follow-up did not start.

## Keeping the workflows current

Bump `cli-command` in `pyproject.toml`, run `reflex-release sync`, commit the
Expand Down Expand Up @@ -547,6 +599,7 @@ a flag for running the same command by hand.
| `check-dev-pins` | Reject `*.dev` dependency pins in published metadata. |
| `extract-notes` | Write a version's changelog section for the release body. |
| `push-tag` / `create-release` | Tag and publish the GitHub release. |
| `post-release` | Dispatch the configured post-release workflow for a tag. |
| `check-headings` | Reject hand-written changelog version headings (PR CI). |
| `changelog-check` | Require news fragments for changed packages (PR CI). |
| `detect-internal` | List internal packages touched by a push. |
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Add `post-release-workflow` to `[tool.reflex-release]`: the named workflow is dispatched once per published tag, on the tag itself, after the upload, the tag and the GitHub release exist — with `tag`, `package` and `version` as `workflow_dispatch` inputs. The dispatch step and the `actions: write` grant it needs are only scaffolded into `publish.yml` (and the workflows that call it) when the setting is present, so a repository that runs nothing after a release keeps the narrower permissions.
13 changes: 13 additions & 0 deletions packages/reflex-release/src/reflex_release/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -257,6 +257,17 @@ def build_parser() -> argparse.ArgumentParser:
help="Checksum manifest to attach to the release.",
)

post_release = sub.add_parser(
"post-release", help="Dispatch the configured post-release workflow."
)
post_release.add_argument("--tag", default=_env("TAG"), help="The published tag.")
post_release.add_argument(
"--package", default=_env("PACKAGE"), help="Published package."
)
post_release.add_argument(
"--version", default=_env("VERSION"), help="Published version."
)

create = sub.add_parser("create", help="Create a news fragment.")
create.add_argument("name", help="Fragment filename, e.g. 1234.feature.md.")
create.add_argument(
Expand Down Expand Up @@ -343,6 +354,8 @@ def dispatch(args: argparse.Namespace, config: Config) -> None:
args.notes,
args.checksums,
)
case "post-release":
commands.cmd_post_release(config, args.tag, args.package, args.version)
case "create":
commands.cmd_create(
config, args.package or (config.root_package or ""), args.name
Expand Down
49 changes: 48 additions & 1 deletion packages/reflex-release/src/reflex_release/commands.py
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@
latest_version,
parse_sections,
)
from .config import Config, is_final
from .config import POST_RELEASE_INPUTS, POST_RELEASE_WORKFLOW_KEY, Config, is_final
from .discovery import (
alpha_train_packages,
build_changelog,
Expand Down Expand Up @@ -982,6 +982,53 @@ def cmd_create_release(
gh_run(args, config.root)


def cmd_post_release(config: Config, tag: str, package: str, version: str) -> None:
"""Dispatch the configured post-release workflow for a published tag.

Runs once per published tag, after the upload, the tag and the GitHub
release: the workflow is dispatched on the tag itself, so it sees exactly
the tree that was published. A failure here is loud but harmless — the
version is already released — so it names the tag it could not hand on.

Args:
config: The repository configuration.
tag: The tag that was published.
package: The published package.
version: The published version.
"""
workflow = config.post_release_workflow
if workflow is None:
notice(f"no {POST_RELEASE_WORKFLOW_KEY} is configured; nothing to dispatch.")
return
# An unset environment variable reaches here as an empty string, and GitHub
# accepts a dispatch carrying empty inputs — the run would go green having
# told the workflow nothing.
values = {"tag": tag, "package": package, "version": version}
if missing := [name for name in POST_RELEASE_INPUTS if not values[name]]:
fail(
f"the post-release dispatch has no {', '.join(missing)}; it is run "
"from the publish workflow with TAG, PACKAGE and VERSION in the "
"environment"
)
config.require_known(package)

fields: list[str] = []
for name in POST_RELEASE_INPUTS:
fields += ["--field", f"{name}={values[name]}"]
failed = gh_run(
Comment thread
cubic-dev-ai[bot] marked this conversation as resolved.
["workflow", "run", workflow, "--ref", tag, *fields], config.root, check=False
)
if failed:
fail(
f"{package} {version} was published and tagged {tag}, but the "
f"post-release workflow {workflow!r} could not be dispatched on it. "
"Check that the workflow exists on the default branch and declares "
f"workflow_dispatch inputs named {', '.join(POST_RELEASE_INPUTS)}, "
"then dispatch it by hand."
)
notice(f"dispatched {workflow} for {tag}")


def cmd_packages(config: Config) -> None:
"""Print the repository's releasable packages, one per line.

Expand Down
19 changes: 19 additions & 0 deletions packages/reflex-release/src/reflex_release/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,16 @@

TOOL_TABLE = "reflex-release"

#: The setting naming the workflow to dispatch after each published tag.
POST_RELEASE_WORKFLOW_KEY = "post-release-workflow"

#: The ``workflow_dispatch`` inputs that workflow is dispatched with, in the
#: order they are passed. This is the contract a consumer repository writes its
#: post-release workflow against, so the payload built by ``post-release``, the
#: step scaffolded into ``publish.yml`` and the documentation all name it from
#: here rather than repeating the list.
POST_RELEASE_INPUTS = ("tag", "package", "version")

_KNOWN_KEYS = frozenset({
"allow-self-review",
"cli-command",
Expand All @@ -40,6 +50,7 @@
"latest-release-package",
"internal-packages",
"changelog-exempt-packages",
POST_RELEASE_WORKFLOW_KEY,
"lockstep",
})

Expand Down Expand Up @@ -106,6 +117,9 @@ class Config:
instead of from a changelog.
changelog_exempt_packages: Packages excluded from the pull-request news
fragment requirement.
post_release_workflow: A workflow dispatched once per published tag,
after the tag and the GitHub release exist, or None to dispatch
nothing.
lockstep: The lockstep groups.
"""

Expand All @@ -128,6 +142,7 @@ class Config:
latest_release_package: str | None = None
internal_packages: tuple[str, ...] = ()
changelog_exempt_packages: tuple[str, ...] = ()
post_release_workflow: str | None = None
lockstep: tuple[LockstepGroup, ...] = ()

def package_dir(self, package: str) -> str:
Expand Down Expand Up @@ -660,6 +675,10 @@ def load_config(root: Path) -> Config:
latest_release_package=latest_release_package or None,
internal_packages=_string_list(table, "internal-packages"),
changelog_exempt_packages=_string_list(table, "changelog-exempt-packages"),
# The documented opt-out is leaving the key out; an empty string is the
# same thing rather than a workflow named "".
post_release_workflow=_string(table, POST_RELEASE_WORKFLOW_KEY, "").strip()
or None,
)

if not config.main_branch:
Expand Down
89 changes: 88 additions & 1 deletion packages/reflex-release/src/reflex_release/scaffold.py
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,14 @@

from .actions import echo, fail
from .changelog import parse_sections, render_heading
from .config import Config, load_config, load_pyproject
from .config import (
POST_RELEASE_INPUTS,
POST_RELEASE_WORKFLOW_KEY,
TOOL_TABLE,
Config,
load_config,
load_pyproject,
)
from .discovery import releasable_packages, title_format
from .versions import ACTIONS

Expand All @@ -57,12 +64,19 @@
#: Generated workflows that a repository may stop needing.
OPTIONAL_WORKFLOWS = (INTERNAL_WORKFLOW,)

#: Every workflow this tool can generate, whether or not a given repository
#: currently gets it.
GENERATED_WORKFLOWS = (*CORE_WORKFLOWS, *OPTIONAL_WORKFLOWS)

TEMPLATE_DIR = Path(__file__).parent / "templates" / "workflows"

_GITHUB_REMOTE_RE = re.compile(
r"github\.com[:/](?P<owner>[^/]+)/(?P<repo>[^/]+?)(?:\.git)?$"
)

# A workflow's display name: the only top-level `name:` key, at column 0.
_WORKFLOW_NAME_RE = re.compile(r"^name:[ \t]*(?P<name>\S.*?)[ \t]*$", re.MULTILINE)

TOWNCRIER_TYPES = (
("breaking", "Breaking Changes"),
("deprecation", "Deprecations"),
Expand Down Expand Up @@ -261,6 +275,22 @@ def _indented_list(items: list[str], indent: int) -> str:
return "\n".join(f"{' ' * indent}- {item}" for item in items)


#: The step that hands each published tag to the repository's own workflow.
#: ``@@INPUTS@@`` is the dispatch contract, named from one place.
POST_RELEASE_STEP = """
# One dispatch per published tag, on the tag itself, after the upload,
# the tag and the GitHub release: the workflow sees exactly the tree that
# was published. It must declare workflow_dispatch inputs named
# @@INPUTS@@.
- name: Trigger the post-release workflow
env:
TAG: ${{ needs.build.outputs.tag }}
PACKAGE: ${{ inputs.package }}
VERSION: ${{ needs.build.outputs.version }}
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: @@CLI@@ post-release"""


def render(name: str, config: Config) -> str:
"""Render one workflow template for a repository.

Expand Down Expand Up @@ -301,9 +331,23 @@ 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),
# Dispatching a workflow is a write on the actions scope, which the
# publish job never needs and so is not granted by default.
"@@ACTIONS_PERMISSION@@": ("write" if config.post_release_workflow else "read"),
"@@POST_RELEASE_STEP@@": (
POST_RELEASE_STEP.replace("@@CLI@@", cli).replace(
"@@INPUTS@@", ", ".join(POST_RELEASE_INPUTS)
)
if config.post_release_workflow
else ""
),
}
text = (TEMPLATE_DIR / name).read_text(encoding="utf-8")
for placeholder, value in substitutions.items():
# A placeholder alone on a line stands for an optional block: an empty
# value removes the line rather than leaving a blank one behind.
if not value:
text = text.replace(f"{placeholder}\n", "")
text = text.replace(placeholder, value)
if remaining := re.findall(r"@@[A-Z_]+@@", text):
fail(
Expand Down Expand Up @@ -343,6 +387,48 @@ def check_title_format(config: Config) -> None:
)


def generated_workflow_names() -> set[str]:
"""Return every name a workflow this tool generates answers to.

``gh workflow run`` resolves a workflow by file name *or* by display name,
so both are the tool's own — and every workflow it can generate counts, not
just the ones a repository currently gets: a repository that drops its last
internal package would otherwise be left dispatching a workflow ``sync``
deletes.

Returns:
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")
match = _WORKFLOW_NAME_RE.search(text)
if match is None:
fail(f"the {filename} template declares no top-level name")
names.add(match["name"])
return names


def check_post_release_workflow(config: Config) -> None:
"""Fail when the post-release workflow is one this tool generates.

Handing a published tag back to the release pipeline itself would either
re-enter it or fail on inputs it does not declare, so a repository that
means to run something of its own after a release has to name that.

Args:
config: The repository configuration.
"""
workflow = config.post_release_workflow
if workflow is not None and workflow in generated_workflow_names():
fail(
f"[tool.{TOOL_TABLE}] {POST_RELEASE_WORKFLOW_KEY} is {workflow!r}, which "
"names a workflow this tool generates (GitHub resolves a workflow by "
"file name or by display name); name a workflow of your own to run "
"after each published tag"
)


def sync(config: Config, check: bool = False, force: bool = False) -> None:
"""Write the scaffolded workflows, or verify they are up to date.

Expand All @@ -352,6 +438,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_post_release_workflow(config)
workflow_dir = config.root / WORKFLOW_DIR
stale: list[str] = []
for name in managed_workflows(config):
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,7 @@ jobs:
permissions:
contents: write
id-token: write
actions: read
actions: @@ACTIONS_PERMISSION@@
uses: ./.github/workflows/publish.yml
with:
package: ${{ matrix.package }}
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,8 @@ run-name: Publish ${{ inputs.package }} ${{ inputs.version }}
# - 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.
# - Optional: post-release-workflow names a workflow this one dispatches on
# every published tag, once the release exists.

on:
workflow_call:
Expand Down Expand Up @@ -272,6 +274,7 @@ jobs:
runs-on: ubuntu-latest
permissions:
contents: write
actions: @@ACTIONS_PERMISSION@@
steps:
- name: Checkout
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
Expand Down Expand Up @@ -305,3 +308,4 @@ jobs:
CHECKSUMS_PATH: SHA256SUMS
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: @@CLI@@ create-release
@@POST_RELEASE_STEP@@
Loading
Loading