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
2 changes: 1 addition & 1 deletion CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -84,7 +84,7 @@ uv run towncrier create --config pyproject.toml --dir packages/reflex-components

Drop `--dir` for a fragment against the main `reflex` package.

If you don't yet know the PR number, use an [orphan fragment](https://towncrier.readthedocs.io/en/stable/cli.html#towncrier-create) (`+.feature.md`) and rename it after opening the PR.
If you don't yet know the PR number, use an [orphan fragment](https://towncrier.readthedocs.io/en/stable/cli.html#towncrier-create) (`+.feature.md`). Renaming it after opening the PR is nice, but not required: the release workflow renames any orphan fragment that made it to `main` after the PR that merged it, so the changelog entry still links to it.

**Skipping the fragment check:** for PRs that are genuinely not user-facing (CI-only tweaks, script fixes, test-only changes), apply the `skip-changelog` label on the PR to bypass the changelog CI check.

Expand Down
16 changes: 11 additions & 5 deletions packages/reflex-release/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -241,10 +241,16 @@ uvx reflex-release create 1234.feature.md # root package
uvx reflex-release create --package widget-core 1234.bugfix.md # sub-package
```

Before you know the PR number, use an orphan fragment (`+something.feature.md`)
and rename it later. CI requires a fragment for every package whose source the
PR touches; the `skip-changelog` label waives that for changes that genuinely
are not user-facing.
Before you know the PR number, use an orphan fragment (`+something.feature.md`).
Renaming it once the PR exists is nice but optional: when the release
materializes the changelog, every orphan fragment left over is renamed after the
pull request whose commit added it — read out of that commit's subject, which
GitHub writes as `Merge pull request #N ...` or `... (#N)` — so its entry gets
the usual link. A fragment whose commit
landed outside a pull request keeps its orphan name and its entry gets no link,
with a warning in the job log. CI requires a fragment for every package whose
source the PR touches; the `skip-changelog` label waives that for changes that
genuinely are not user-facing.

## Adding sub-packages

Expand Down Expand Up @@ -764,7 +770,7 @@ a flag for running the same command by hand.
| `create [--package P] NAME` | Create a news fragment. |
| `packages` | List releasable packages. |
| `plan` | Compute the next version of each selected package. |
| `materialize` | Run towncrier and (for `release-from-prerelease`) collapse alphas. |
| `materialize` | Name orphan fragments after their PR, run towncrier and (for `release-from-prerelease`) collapse alphas. |
| `open-release-pr` / `push-prerelease` | Commit the changelogs and deliver them. |
| `detect` | List packages whose newest changelog version has no tag. |
| `prepare-publish` | Validate a package/version and emit build metadata. |
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Materializing a changelog now names leftover orphan news fragments (`+something.feature.md`) after the pull request whose commit added them, so their entries get the usual `#`-link instead of shipping unlinked.
9 changes: 9 additions & 0 deletions packages/reflex-release/src/reflex_release/actions.py
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,15 @@ def notice(message: str) -> None:
sys.stdout.write(f"::notice::{message}\n")


def warning(message: str) -> None:
"""Emit a GitHub Actions warning annotation (plain line outside Actions).

Args:
message: The warning text.
"""
sys.stdout.write(f"::warning::{message}\n")


def error(message: str) -> None:
"""Emit a GitHub Actions error annotation (plain line outside Actions).

Expand Down
9 changes: 7 additions & 2 deletions packages/reflex-release/src/reflex_release/commands.py
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@
from .config import POST_RELEASE_INPUTS, POST_RELEASE_WORKFLOW_KEY, Config, is_final
from .discovery import (
alpha_train_packages,
associate_orphan_fragments,
build_changelog,
category_order,
changelog_packages,
Expand Down Expand Up @@ -328,8 +329,10 @@ def cmd_plan(config: Config, action: str, selection: str) -> None:
def cmd_materialize(config: Config, action: str, releases_json: str) -> None:
"""Write the planned versions into the changelogs via towncrier.

For ``release-from-prerelease``, collapses the alpha sections of each
changelog into the single final-version section after building it.
Orphan fragments are first named after the pull request that added them, so
the entries towncrier writes carry a link. For ``release-from-prerelease``,
collapses the alpha sections of each changelog into the single final-version
section after building it.

Args:
config: The repository configuration.
Expand All @@ -346,6 +349,8 @@ def cmd_materialize(config: Config, action: str, releases_json: str) -> None:

for release in releases:
package, version = release["package"], release["next"]
for old_name, new_name in associate_orphan_fragments(config, package):
echo(f"{package}: renamed news fragment {old_name} -> {new_name}")
build_changelog(config, package, version, today)
if collapse:
path = config.changelog_path(package)
Expand Down
174 changes: 172 additions & 2 deletions packages/reflex-release/src/reflex_release/discovery.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,16 +2,27 @@

from __future__ import annotations

import re
import subprocess
import sys
from pathlib import Path

from packaging.version import Version

from .actions import fail
from .actions import fail, warning
from .changelog import DEFAULT_TITLE_FORMAT, latest_version
from .config import Config, load_pyproject
from .gitutil import latest_tag_version
from .gitutil import adding_commit_messages, git_run, latest_tag_version

#: towncrier's default prefix for fragments written before the number is known.
DEFAULT_ORPHAN_PREFIX = "+"

#: The two shapes GitHub itself writes a pull request number in: the subject of
#: a merge commit, and the suffix a squash merge appends to the subject.
_PR_SUBJECT_PATTERNS = (
re.compile(r"^Merge pull request #(\d+)\b"),
re.compile(r"\(#(\d+)\)$"),
)


def towncrier_table(config: Config) -> dict:
Expand Down Expand Up @@ -60,6 +71,22 @@ def title_format(config: Config) -> str:
return configured or DEFAULT_TITLE_FORMAT


def orphan_prefix(config: Config) -> str:
"""Return the prefix marking a fragment as having no issue number yet.

Args:
config: The repository configuration.

Returns:
The configured ``orphan_prefix``, or towncrier's default. Empty when the
repository disables orphan fragments.
"""
configured = towncrier_table(config).get("orphan_prefix", DEFAULT_ORPHAN_PREFIX)
if not isinstance(configured, str):
fail(f"[tool.towncrier] orphan_prefix must be a string: {configured!r}")
return configured


def category_order(config: Config) -> list[str]:
"""Read the towncrier category display names, in configured order.

Expand Down Expand Up @@ -108,6 +135,149 @@ def has_pending_fragments(news_dir: Path, types: set[str]) -> bool:
)


def split_fragment_name(basename: str, types: set[str]) -> tuple[str, str] | None:
"""Split a fragment filename into its issue part and the rest.

Mirrors towncrier's own parsing: the fragment type is the last
dot-separated part naming a configured type, everything before it is the
issue and everything from it onwards is the suffix (type, optional counter,
extension).

Args:
basename: The fragment filename.
types: The configured fragment type keys.

Returns:
An ``(issue, suffix)`` tuple, or None when no part names a type.
"""
parts = basename.split(".")
for index in reversed(range(1, len(parts))):
if parts[index] in types:
return ".".join(parts[:index]), ".".join(parts[index:])
return None


def pull_request_number(message: str) -> str | None:
"""Extract the pull request number a commit message refers to.

Only the two shapes GitHub writes itself are recognized, and only in the
subject: a number elsewhere in the message is a reference (``Fixes #12``),
not the pull request the commit landed through.

Args:
message: The full commit message.

Returns:
The pull request number, or None when the subject names none.
"""
subject = message.strip().partition("\n")[0].strip()
for pattern in _PR_SUBJECT_PATTERNS:
match = pattern.search(subject)
if match:
return match[1]
return None


def _unused_fragment_name(taken: set[str], issue: str, suffix: str) -> str:
"""Return a fragment filename for an issue that no file in the directory uses.

towncrier keys fragments by ``(issue, type, counter)``, so a second fragment
for the same pull request and type takes the ``<issue>.<type>.<n>.<ext>``
counter form instead of colliding with the first.

Args:
taken: The filenames already present in the news directory.
issue: The issue (pull request) number to name the fragment after.
suffix: The fragment suffix (type, optional counter, extension).

Returns:
A filename that is not in ``taken``.
"""
candidate = f"{issue}.{suffix}"
fragment_type, _, rest = suffix.partition(".")
counter = 0
while candidate in taken:
counter += 1
candidate = ".".join(filter(None, (issue, fragment_type, str(counter), rest)))
return candidate


def _fragment_pull_request(root: Path, rel_path: str) -> str | None:
"""Return the pull request number a committed fragment landed through.

Args:
root: The repository root.
rel_path: Repo-relative path of the fragment.

Returns:
The number named by the first candidate commit that has one, or None
when none of them does.
"""
for message in adding_commit_messages(root, rel_path):
number = pull_request_number(message)
if number is not None:
return number
return None


def associate_orphan_fragments(config: Config, package: str) -> list[tuple[str, str]]:
"""Rename a package's orphan fragments after the pull request that added them.

A contributor who does not know the number yet writes ``+something.feature.md``
and is supposed to rename it once the pull request exists; when they don't,
the changelog entry ships with no link. The commit that added the fragment
knows the number, so it is recovered here instead — right before towncrier
consumes the fragments, so the entry gets its link.

Fragments whose number cannot be recovered (added by a commit that did not
land through a pull request, or not committed at all) are left alone, with a
warning: towncrier renders them as entries without a link.

Args:
config: The repository configuration.
package: The package name.

Returns:
The ``(old name, new name)`` pairs that were renamed.

Raises:
ReleaseError: When a fragment cannot be renamed. Only a tracked fragment
is ever renamed, so ``git mv`` failing means the worktree is not in
the state the release assumes, and a bare rename in its place would
leave the orphan behind in the release commit.
"""
news_dir = config.news_dir(package)
prefix = orphan_prefix(config)
if not prefix or not news_dir.is_dir():
return []
types = fragment_types(config)
fragments = sorted(news_dir.iterdir())
taken = {path.name for path in fragments}
renamed: list[tuple[str, str]] = []
for path in fragments:
if not path.name.startswith(prefix) or not path.is_file():
continue
parsed = split_fragment_name(path.name, types)
if parsed is None:
continue
rel_path = path.relative_to(config.root).as_posix()
number = _fragment_pull_request(config.root, rel_path)
if number is None:
warning(
f"{rel_path}: no pull request found for the commit that added "
"this orphan fragment; its changelog entry will have no link."
)
continue
new_name = _unused_fragment_name(taken, number, parsed[1])
# git mv, not a bare rename: the release commit stages only the
# changelogs, so an unstaged deletion would leave the orphan behind for
# the next release to materialize a second time.
git_run(["mv", "--", str(path), str(news_dir / new_name)], config.root)
taken.add(new_name)
renamed.append((path.name, new_name))
return renamed


def changelog_packages(config: Config) -> list[str]:
"""List the packages that maintain a ``CHANGELOG.md``.

Expand Down
79 changes: 79 additions & 0 deletions packages/reflex-release/src/reflex_release/gitutil.py
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,85 @@ def git_show(root: Path, ref: str, rel_path: str) -> str | None:
return result.stdout if result.returncode == 0 else None


def _log_message(root: Path, args: list[str], rel_path: str) -> str | None:
"""Return the message of the first commit a path-limited ``git log`` selects.

The path is passed as a ``:(literal)`` pathspec: a filename holding ``*``,
``?`` or brackets is a glob to git, which would read some other file's
history.

Args:
root: The repository root.
args: Extra ``git log`` arguments placed before the pathspec.
rel_path: Repo-relative file path.

Returns:
The commit message, or None when the log selected no commit.
"""
result = subprocess.run(
[
"git",
"log",
*args,
"--max-count=1",
"--format=%B",
"--",
f":(literal){rel_path}",
],
cwd=root,
capture_output=True,
text=True,
check=False,
)
if result.returncode != 0:
return None
return result.stdout.strip() or None


def adding_commit_messages(root: Path, rel_path: str) -> list[str]:
"""Return the messages of the commits that brought a file into ``HEAD``.

Two candidates, most specific first, because which one carries the pull
request number depends on the repository's merge strategy:

1. the commit that added the path along ``HEAD``'s first-parent line, which
is the merge commit of a non-fast-forward merge (``Merge pull request
#N``) and the squash commit of a squash merge;
2. the commit that created the file anywhere in history, which is that same
squash commit, or the branch commit of a non-fast-forward merge.

The second is what recovers the number when a fragment reaches the branch
being released through a merge that is not a pull request — merging ``main``
into a prerelease branch to pull new work into the train, say, where the
first-parent commit is that plain branch merge.

Args:
root: The repository root.
rel_path: Repo-relative file path.

Returns:
The distinct candidate messages, most specific first. Empty when the
path is not committed in ``HEAD``: an uncommitted file is not the file
an old commit added at the same path (a fragment an earlier release
already consumed), so its history says nothing about it.
"""
if (
subprocess.run(
["git", "rev-parse", "-q", "--verify", f"HEAD:{rel_path}"],
cwd=root,
capture_output=True,
check=False,
).returncode
!= 0
):
return []
messages = [
_log_message(root, ["--first-parent", "--diff-filter=A"], rel_path),
_log_message(root, ["--diff-filter=A"], rel_path),
]
return list(dict.fromkeys(message for message in messages if message))


def changed_files(root: Path, base_ref: str) -> list[str]:
"""List the repo-relative paths changed since the merge base with a ref.

Expand Down
Loading
Loading