diff --git a/.github/scripts/auto_release_internal/compute_version.sh b/.github/scripts/auto_release_internal/compute_version.sh deleted file mode 100755 index bd1e29197e7..00000000000 --- a/.github/scripts/auto_release_internal/compute_version.sh +++ /dev/null @@ -1,15 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail - -: "${PKG:?}" - -LATEST=$(git tag -l "${PKG}-v*" | sed "s/^${PKG}-v//" | sort -V | tail -1) -if [ -z "$LATEST" ]; then - NEXT="0.0.1" -else - IFS='.' read -r MAJOR MINOR PATCH <<< "$LATEST" - NEXT="${MAJOR}.${MINOR}.$((PATCH + 1))" -fi - -echo "version=$NEXT" >> "$GITHUB_OUTPUT" -echo "tag=${PKG}-v${NEXT}" >> "$GITHUB_OUTPUT" diff --git a/.github/scripts/auto_release_internal/create_release.sh b/.github/scripts/auto_release_internal/create_release.sh deleted file mode 100755 index 1bb6a95ee1c..00000000000 --- a/.github/scripts/auto_release_internal/create_release.sh +++ /dev/null @@ -1,14 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail - -: "${TAG:?}" -: "${PKG:?}" -: "${VERSION:?}" -: "${GH_TOKEN:?}" -: "${GITHUB_SHA:?}" - -gh release create "$TAG" \ - --title "$PKG@$VERSION" \ - --notes "Automated release for $PKG v$VERSION" \ - --target "$GITHUB_SHA" \ - --latest=false diff --git a/.github/scripts/auto_release_internal/trigger_publish.sh b/.github/scripts/auto_release_internal/trigger_publish.sh deleted file mode 100755 index f5b6103d32b..00000000000 --- a/.github/scripts/auto_release_internal/trigger_publish.sh +++ /dev/null @@ -1,7 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail - -: "${TAG:?}" -: "${GH_TOKEN:?}" - -gh workflow run publish.yml -f tag="$TAG" diff --git a/.github/scripts/dispatch_release/create_release.sh b/.github/scripts/dispatch_release/create_release.sh deleted file mode 100755 index 8f66b3c9c31..00000000000 --- a/.github/scripts/dispatch_release/create_release.sh +++ /dev/null @@ -1,20 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail - -: "${TAG:?}" -: "${PKG:?}" -: "${VERSION:?}" -: "${ACTION:?}" -: "${GH_TOKEN:?}" -: "${GITHUB_SHA:?}" - -ARGS=(--title "$PKG@$VERSION" --notes "Dispatch release for $PKG v$VERSION (action=$ACTION)" --target "$GITHUB_SHA") -if [[ "$ACTION" == release-* ]]; then - if [[ "$PKG" != "reflex" ]]; then - ARGS+=(--latest=false) - fi -else - ARGS+=(--prerelease --latest=false) -fi - -gh release create "$TAG" "${ARGS[@]}" diff --git a/.github/scripts/dispatch_release/detect.sh b/.github/scripts/dispatch_release/detect.sh index 71d3b525f85..2f472ca7434 100755 --- a/.github/scripts/dispatch_release/detect.sh +++ b/.github/scripts/dispatch_release/detect.sh @@ -29,9 +29,11 @@ for key in "${ORDER[@]}"; do done if [[ ${#PACKAGES[@]} -eq 0 ]]; then - echo "Error: select at least one package" - exit 1 + # No explicit selection: the plan step auto-detects packages with pending + # news fragments (or, for release-from-prerelease, packages whose changelog + # is topped by an alpha). + echo "No packages checked; deferring to auto-detection in the plan step." fi -JOINED=$(IFS=,; echo "${PACKAGES[*]}") +JOINED=$(IFS=,; echo "${PACKAGES[*]:-}") echo "packages=[$JOINED]" >> "$GITHUB_OUTPUT" diff --git a/.github/scripts/dispatch_release/open_release_pr.sh b/.github/scripts/dispatch_release/open_release_pr.sh new file mode 100755 index 00000000000..b9b4ba74488 --- /dev/null +++ b/.github/scripts/dispatch_release/open_release_pr.sh @@ -0,0 +1,66 @@ +#!/usr/bin/env bash +set -euo pipefail + +: "${GH_TOKEN:?}" +: "${ACTION:?}" +: "${REF_NAME:?}" +: "${RELEASES:?}" +: "${GITHUB_RUN_ID:?}" + +# Final versions publish from main — except hotfix trains, which publish +# directly from their r/hotfix/** branch, so the PR targets it instead. +BASE="main" +if [[ "$REF_NAME" == r/hotfix/* ]]; then + BASE="$REF_NAME" +fi + +BRANCH="release/${ACTION}-${GITHUB_RUN_ID}" +SUMMARY=$(echo "$RELEASES" | jq -r '[.[] | "\(.package)@\(.next)"] | join(", ")') + +BODY_FILE="${RUNNER_TEMP}/release_pr_body.md" +{ + echo "Materialized changelogs for release action \`${ACTION}\` (dispatched on \`${REF_NAME}\`)." + echo "" + echo "| Package | Current | Next | Tag |" + echo "|---------|---------|------|-----|" + echo "$RELEASES" | jq -r '.[] | "| `\(.package)` | `\(if .current == "" then "" else .current end)` | `\(.next)` | `\(.tag)` |"' + echo "" + echo "Merging this PR lands the versions above for release: the push to \`${BASE}\`" + echo "triggers the \`release_from_changelog\` workflow, which builds each package and" + echo "then waits for \`pypi\` environment approval before uploading; the tag and" + echo "GitHub release are created only after a successful upload. If a publish" + echo "fails, fix the problem on top of the changelog bump — the next push retries" + echo "automatically." +} > "$BODY_FILE" + +git config user.name "github-actions[bot]" +git config user.email "41898282+github-actions[bot]@users.noreply.github.com" +# Stage the changelog rewrites (including first-release creations); a +# directory pathspec with a wildcard never matches, so name the files. The +# consumed news fragments are already staged by towncrier's git rm, and +# `commit -a` below picks up any tracked change it left unstaged — while +# never committing untracked stray files. +git add -- CHANGELOG.md 'packages/*/CHANGELOG.md' +if git diff --cached --quiet && git diff --quiet; then + echo "Error: materialization produced no changes; nothing to release." + exit 1 +fi +git commit -a -m "Materialize changelogs for ${SUMMARY} (${ACTION})" +# The token is supplied via gh's credential helper so it never appears in a +# remote URL or process argv. +git -c credential.helper= -c 'credential.helper=!gh auth git-credential' \ + push origin "HEAD:refs/heads/${BRANCH}" + +PR_URL=$(gh pr create --base "$BASE" --head "$BRANCH" --title "Release ${SUMMARY}" --body-file "$BODY_FILE") + +# The PR only rewrites changelogs and deletes consumed news fragments, so the +# changelog fragment check does not apply. Label failure is non-fatal. +gh pr edit "$PR_URL" --add-label skip-changelog || echo "::notice::could not add skip-changelog label to $PR_URL" + +{ + echo "## Release PR opened" + echo "" + echo "$PR_URL" + echo "" + echo "Releases: ${SUMMARY}" +} >> "$GITHUB_STEP_SUMMARY" diff --git a/.github/scripts/dispatch_release/plan.py b/.github/scripts/dispatch_release/plan.py deleted file mode 100644 index 2788d44c05e..00000000000 --- a/.github/scripts/dispatch_release/plan.py +++ /dev/null @@ -1,209 +0,0 @@ -# /// script -# requires-python = ">=3.10" -# dependencies = ["packaging"] -# /// -"""Plan the next version for each selected package. - -Reads PACKAGES_JSON and ACTION from env, computes the next version per package -using packaging.Version, writes a markdown summary to GITHUB_STEP_SUMMARY and a -JSON array to GITHUB_OUTPUT for the release matrix. -""" - -from __future__ import annotations - -import json -import operator -import os -import pathlib -import subprocess -import sys -from typing import NoReturn - -from packaging.version import InvalidVersion, Version - -ACTIONS: dict[str, tuple[str, str | None]] = { - "new-prerelease-patch": ("new-prerelease", "patch"), - "new-prerelease-minor": ("new-prerelease", "minor"), - "new-prerelease-major": ("new-prerelease", "major"), - "continued-prerelease": ("continued-prerelease", None), - "release-from-prerelease": ("release", "from-prerelease"), - "release-post": ("release", "post"), - "release-patch": ("release", "patch"), - "release-minor": ("release", "minor"), - "release-major": ("release", "major"), -} - - -def fail(message: str) -> NoReturn: - """Print to stderr and exit 1.""" - sys.stderr.write(f"Error: {message}\n") - sys.exit(1) - - -def pick_latest(prefix: str) -> str | None: - """Return the largest PEP 440 tag under prefix (prefix stripped), or None. - - Args: - prefix: Tag prefix, e.g. ``v`` or ``reflex-base-v``. - """ - raw = subprocess.check_output(["git", "tag", "-l", f"{prefix}*"], text=True) - pairs: list[tuple[Version, str]] = [] - for line in raw.splitlines(): - tag = line.removeprefix(prefix).strip() - if not tag: - continue - try: - pairs.append((Version(tag), tag)) - except InvalidVersion: - continue - if not pairs: - return None - _, original = max(pairs, key=operator.itemgetter(0)) - return original - - -def next_version(latest: str | None, action: str, pkg: str) -> str: - """Compute the next version for pkg given action and latest. - - Args: - latest: Current latest version string, or None if the package has no prior tags. - action: One of the keys in ACTIONS. - pkg: Package name, used for error messages. - - Returns: - The next version string. - """ - mode, sub = ACTIONS[action] - - if latest is None: - major = minor = patch = alpha_n = post_n = 0 - is_alpha = False - else: - ver = Version(latest) - major, minor, patch = ver.major, ver.minor, ver.micro - is_alpha = ver.pre is not None and ver.pre[0] == "a" - alpha_n = ver.pre[1] if is_alpha and ver.pre is not None else 0 - post_n = ver.post or 0 - - display = latest if latest is not None else "" - - if mode == "new-prerelease": - if sub == "patch": - return f"{major}.{minor}.{patch + 1}a1" - if sub == "minor": - return f"{major}.{minor + 1}.0a1" - return f"{major + 1}.0.0a1" - - if mode == "continued-prerelease": - if not is_alpha: - fail( - f"continued-prerelease requires latest to be an alpha; " - f"latest for {pkg} is {display!r}" - ) - return f"{major}.{minor}.{patch}a{alpha_n + 1}" - - if sub == "from-prerelease": - if not is_alpha: - fail( - f"release-from-prerelease requires latest to be an alpha; " - f"latest for {pkg} is {display!r}" - ) - return f"{major}.{minor}.{patch}" - if sub == "post": - if latest is None: - fail(f"release-post requires an existing release; no tags for {pkg}") - if is_alpha: - fail( - f"release-post cannot follow an alpha; latest for {pkg} is {display!r}" - ) - return f"{major}.{minor}.{patch}.post{post_n + 1}" - if sub == "patch": - return f"{major}.{minor}.{patch + 1}" - if sub == "minor": - return f"{major}.{minor + 1}.0" - return f"{major + 1}.0.0" - - -def tag_exists(tag: str) -> bool: - """Return whether a local git tag exists.""" - return ( - subprocess.run( - ["git", "rev-parse", "-q", "--verify", f"refs/tags/{tag}"], - capture_output=True, - check=False, - ).returncode - == 0 - ) - - -def main() -> None: - """Entry point.""" - action = os.environ["ACTION"] - packages_json = os.environ["PACKAGES_JSON"] - github_output = os.environ["GITHUB_OUTPUT"] - github_step_summary = os.environ["GITHUB_STEP_SUMMARY"] - - if action not in ACTIONS: - fail(f"unknown action '{action}'") - - packages: list[str] = json.loads(packages_json) - - summary_rows = [ - "## Release plan", - "", - f"Action: `{action}`", - "", - "| Package | Current | Next | Tag |", - "|---------|---------|------|-----|", - ] - releases: list[dict[str, str]] = [] - - for pkg in packages: - tag_prefix = "v" if pkg == "reflex" else f"{pkg}-v" - current = pick_latest(tag_prefix) - next_ver = next_version(current, action, pkg) - tag = f"{tag_prefix}{next_ver}" - - if tag_exists(tag): - fail(f"tag {tag} already exists") - - display = current if current is not None else "" - summary_rows.append(f"| `{pkg}` | `{display}` | `{next_ver}` | `{tag}` |") - releases.append({ - "package": pkg, - "current": current or "", - "next": next_ver, - "tag": tag, - }) - - # reflex is not independently releasable via dispatch: when reflex-base is - # released, reflex is released alongside it with a matching version. The - # publish workflow pins reflex-base exactly when building reflex. - reflex_base_release = next( - (r for r in releases if r["package"] == "reflex-base"), None - ) - if reflex_base_release is not None: - reflex_version = reflex_base_release["next"] - reflex_tag = f"v{reflex_version}" - if tag_exists(reflex_tag): - fail(f"tag {reflex_tag} already exists") - current_reflex = pick_latest("v") - display = current_reflex if current_reflex is not None else "" - summary_rows.append( - f"| `reflex` | `{display}` | `{reflex_version}` | `{reflex_tag}` |" - ) - releases.append({ - "package": "reflex", - "current": current_reflex or "", - "next": reflex_version, - "tag": reflex_tag, - }) - - with pathlib.Path(github_step_summary).open("a") as f: - f.write("\n".join(summary_rows) + "\n") - with pathlib.Path(github_output).open("a") as f: - f.write(f"releases={json.dumps(releases)}\n") - - -if __name__ == "__main__": - main() diff --git a/.github/scripts/dispatch_release/push_prerelease.sh b/.github/scripts/dispatch_release/push_prerelease.sh new file mode 100755 index 00000000000..3d92e358e5b --- /dev/null +++ b/.github/scripts/dispatch_release/push_prerelease.sh @@ -0,0 +1,59 @@ +#!/usr/bin/env bash +set -euo pipefail + +: "${GH_TOKEN:?}" +: "${ACTION:?}" +: "${REF_NAME:?}" +: "${RELEASES:?}" +: "${GITHUB_RUN_ID:?}" + +SUMMARY=$(echo "$RELEASES" | jq -r '[.[] | "\(.package)@\(.next)"] | join(", ")') + +if [[ "$ACTION" == "continued-prerelease" ]]; then + if [[ "$REF_NAME" != r/pre-* ]]; then + echo "Error: continued-prerelease must be dispatched on the r/pre-* branch of an existing prerelease train (got '$REF_NAME')" + exit 1 + fi + BRANCH="$REF_NAME" +else + # Same release timezone as the changelog heading dates (RELEASE_TIMEZONE in + # scripts/release.py) so the branch name and headings never disagree. + BRANCH="r/pre-$(TZ=America/Los_Angeles date +%Y.%m.%d)" + if git ls-remote --exit-code --heads origin "$BRANCH" >/dev/null 2>&1; then + BRANCH="${BRANCH}-${GITHUB_RUN_ID}" + fi +fi + +git config user.name "github-actions[bot]" +git config user.email "41898282+github-actions[bot]@users.noreply.github.com" +# Stage the changelog rewrites (including first-release creations); a +# directory pathspec with a wildcard never matches, so name the files. The +# consumed news fragments are already staged by towncrier's git rm, and +# `commit -a` below picks up any tracked change it left unstaged — while +# never committing untracked stray files. +git add -- CHANGELOG.md 'packages/*/CHANGELOG.md' +if git diff --cached --quiet && git diff --quiet; then + echo "Error: materialization produced no changes; nothing to push." + exit 1 +fi +git commit -a -m "Materialize changelogs for ${SUMMARY} (${ACTION})" +# The token is supplied via gh's credential helper so it never appears in a +# remote URL or process argv. +git -c credential.helper= -c 'credential.helper=!gh auth git-credential' \ + push origin "HEAD:refs/heads/${BRANCH}" + +# Pushes made with GITHUB_TOKEN do not fire on-push workflows, so dispatch the +# changelog check explicitly. Building is automatic; the upload itself still +# waits for pypi environment approval. +gh workflow run release_from_changelog.yml --ref "$BRANCH" + +{ + echo "## Prerelease pushed" + echo "" + echo "Branch: \`${BRANCH}\`" + echo "" + echo "Releases: ${SUMMARY}" + echo "" + echo "Dispatched the \`release_from_changelog\` workflow on the branch; approve the" + echo "\`pypi\` environment deployments to upload the alphas." +} >> "$GITHUB_STEP_SUMMARY" diff --git a/.github/scripts/dispatch_release/show_plan.sh b/.github/scripts/dispatch_release/show_plan.sh deleted file mode 100755 index 1f2467b40a5..00000000000 --- a/.github/scripts/dispatch_release/show_plan.sh +++ /dev/null @@ -1,17 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail - -: "${RELEASES:?}" -: "${ACTION:?}" - -{ - echo "## Approved release plan" - echo "" - echo "Action: \`${ACTION}\`" - echo "" - echo "| Package | Current | Next | Tag |" - echo "|---------|---------|------|-----|" - echo "$RELEASES" | jq -r '.[] | "| `\(.package)` | `\(if .current == "" then "" else .current end)` | `\(.next)` | `\(.tag)` |"' -} >> "$GITHUB_STEP_SUMMARY" - -echo "$RELEASES" | jq . diff --git a/.github/scripts/dispatch_release/trigger_publish.sh b/.github/scripts/dispatch_release/trigger_publish.sh deleted file mode 100755 index 73733376db2..00000000000 --- a/.github/scripts/dispatch_release/trigger_publish.sh +++ /dev/null @@ -1,8 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail - -: "${TAG:?}" -: "${REF:?}" -: "${GH_TOKEN:?}" - -gh workflow run publish.yml --ref "$REF" -f tag="$TAG" diff --git a/.github/scripts/publish/create_release.sh b/.github/scripts/publish/create_release.sh new file mode 100755 index 00000000000..55c90b11290 --- /dev/null +++ b/.github/scripts/publish/create_release.sh @@ -0,0 +1,27 @@ +#!/usr/bin/env bash +set -euo pipefail + +: "${TAG:?}" +: "${PKG:?}" +: "${VERSION:?}" +: "${PRERELEASE:?}" +: "${MARK_LATEST:?}" +: "${NOTES_PATH:?}" +: "${GH_TOKEN:?}" +: "${GITHUB_SHA:?}" + +if gh release view "$TAG" --json name >/dev/null 2>&1; then + echo "Release $TAG already exists; skipping (safe re-run)." + exit 0 +fi + +ARGS=(--title "$PKG@$VERSION" --notes-file "$NOTES_PATH" --target "$GITHUB_SHA") +if [[ "$PRERELEASE" == "true" ]]; then + ARGS+=(--prerelease --latest=false) +elif [[ "$MARK_LATEST" == "true" ]]; then + ARGS+=(--latest) +else + ARGS+=(--latest=false) +fi + +gh release create "$TAG" "${ARGS[@]}" diff --git a/.github/scripts/publish/parse_tag.sh b/.github/scripts/publish/parse_tag.sh deleted file mode 100755 index 290cc49fa51..00000000000 --- a/.github/scripts/publish/parse_tag.sh +++ /dev/null @@ -1,35 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail - -: "${TAG:?}" - -# Tag format: v1.2.3 for reflex, reflex-lucide-v0.1.0 for sub-packages. -# Package and version are restricted to a safe character set so the captured -# groups can be interpolated into shell and written to $GITHUB_OUTPUT without -# escaping concerns (a tag can in principle come from any actor able to -# publish a release). -PKG_RE='[A-Za-z0-9_-]+' -VER_RE='[0-9][A-Za-z0-9.+-]*' -if [[ "$TAG" =~ ^v(${VER_RE})$ ]]; then - PACKAGE="reflex" - BUILD_DIR="." - VERSION="${BASH_REMATCH[1]}" -elif [[ "$TAG" =~ ^(${PKG_RE})-v(${VER_RE})$ ]]; then - PACKAGE="${BASH_REMATCH[1]}" - VERSION="${BASH_REMATCH[2]}" - if [ -d "packages/$PACKAGE" ]; then - BUILD_DIR="packages/$PACKAGE" - else - echo "Error: no build directory known for package '$PACKAGE'" - exit 1 - fi -else - echo "Error: Tag '$TAG' does not match expected format (v or -v)" - exit 1 -fi - -{ - echo "package=$PACKAGE" - echo "build_dir=$BUILD_DIR" - echo "version=$VERSION" -} >> "$GITHUB_OUTPUT" diff --git a/.github/scripts/publish/pin_reflex_base.sh b/.github/scripts/publish/pin_reflex_base.sh deleted file mode 100755 index 6b8de90fb60..00000000000 --- a/.github/scripts/publish/pin_reflex_base.sh +++ /dev/null @@ -1,11 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail - -: "${VERSION:?}" - -if ! grep -q '"reflex-base >= ' pyproject.toml; then - echo "Error: expected 'reflex-base >= ...' dependency in pyproject.toml" - exit 1 -fi -sed -i 's|"reflex-base >= [^"]*"|"reflex-base == '"$VERSION"'"|' pyproject.toml -grep '"reflex-base' pyproject.toml diff --git a/.github/scripts/publish/push_tag.sh b/.github/scripts/publish/push_tag.sh new file mode 100755 index 00000000000..50a186646ae --- /dev/null +++ b/.github/scripts/publish/push_tag.sh @@ -0,0 +1,17 @@ +#!/usr/bin/env bash +set -euo pipefail + +: "${TAG:?}" +: "${GH_TOKEN:?}" + +# Tag the published commit and push it. Runs only after a successful PyPI +# upload — the tag's existence is what marks a version as published. Pushing +# an identical existing tag is a no-op (safe re-runs); a same-name tag on a +# different commit is rejected by git, which means another run already +# published this version from a different commit — investigate, don't force. +# +# The token is supplied via gh's credential helper so it never appears in a +# remote URL or process argv. +git tag --force "$TAG" +git -c credential.helper= -c 'credential.helper=!gh auth git-credential' \ + push origin "refs/tags/${TAG}" diff --git a/.github/workflows/auto_release_internal.yml b/.github/workflows/auto_release_internal.yml index 8236ba73d09..35b85901e31 100644 --- a/.github/workflows/auto_release_internal.yml +++ b/.github/workflows/auto_release_internal.yml @@ -1,5 +1,12 @@ name: Auto-release internal packages +# Internal packages (no changelog, patch-bump versioning) release on every +# push to main that touches them. Publishing goes through publish.yml with no +# explicit version: the next patch version is computed from the newest tag +# inside the per-package publish concurrency group, and the tag is only +# pushed after a successful upload. Like every publish, the upload itself +# waits for approval by the pypi environment's required reviewers. + on: push: branches: [main] @@ -38,40 +45,22 @@ jobs: DISPATCH_PACKAGE: ${{ inputs.package }} run: bash .github/scripts/auto_release_internal/detect.sh - release: + publish: needs: detect if: needs.detect.outputs.packages != '[]' - runs-on: ubuntu-latest - permissions: - contents: write - actions: write strategy: matrix: package: ${{ fromJson(needs.detect.outputs.packages) }} fail-fast: false + # Holds the per-package publish lock for the whole called run, so + # back-to-back pushes compute and publish strictly one version at a time. concurrency: - group: release-${{ matrix.package }} + group: publish-${{ matrix.package }} cancel-in-progress: false - steps: - - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - with: - fetch-tags: true - fetch-depth: 0 - persist-credentials: false - - name: Compute next version - id: version - env: - PKG: ${{ matrix.package }} - run: bash .github/scripts/auto_release_internal/compute_version.sh - - name: Create GitHub release (not marked latest) - env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - TAG: ${{ steps.version.outputs.tag }} - PKG: ${{ matrix.package }} - VERSION: ${{ steps.version.outputs.version }} - run: bash .github/scripts/auto_release_internal/create_release.sh - - name: Trigger publish workflow - env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - TAG: ${{ steps.version.outputs.tag }} - run: bash .github/scripts/auto_release_internal/trigger_publish.sh + permissions: + contents: write + id-token: write + actions: read + uses: ./.github/workflows/publish.yml + with: + package: ${{ matrix.package }} diff --git a/.github/workflows/changelog.yml b/.github/workflows/changelog.yml index 250d13f34c6..dde678d9645 100644 --- a/.github/workflows/changelog.yml +++ b/.github/workflows/changelog.yml @@ -28,10 +28,24 @@ jobs: else echo "skip=false" >> "$GITHUB_OUTPUT" fi + # The heading guard runs even for skip-changelog PRs: that label only + # waives the news-fragment requirement, not the publish-trigger guard. - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - if: steps.skip.outputs.skip != 'true' with: fetch-depth: 0 + # A merged version heading without a git tag triggers a real publish + # (see release_from_changelog.yml), so new headings must come from the + # Dispatch release workflow's release/* branches — not hand edits. + # check-headings uses the same parser the release pipeline publishes + # from, so the guard and the publisher cannot disagree. Escape hatch + # for deliberate restructuring: the 'changelog-version-edit' label. + - uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0 + if: ${{ !contains(github.event.pull_request.labels.*.name, 'changelog-version-edit') && !startsWith(github.head_ref, 'release/') }} + - name: Reject manual changelog version headings + if: ${{ !contains(github.event.pull_request.labels.*.name, 'changelog-version-edit') && !startsWith(github.head_ref, 'release/') }} + env: + BASE_REF: origin/${{ github.base_ref }} + run: uv run --no-config --locked --script scripts/release.py check-headings - uses: ./.github/actions/setup_build_env if: steps.skip.outputs.skip != 'true' with: diff --git a/.github/workflows/dispatch_release.yml b/.github/workflows/dispatch_release.yml index a329004d142..e7b6d426136 100644 --- a/.github/workflows/dispatch_release.yml +++ b/.github/workflows/dispatch_release.yml @@ -1,11 +1,50 @@ name: Dispatch release -# REQUIRED CONFIGURATION: any action starting with `release-` is gated on the -# `dispatch-release-approval` environment. The gate only enforces a second -# approval if that environment has required reviewers configured in repo -# Settings → Environments. Without reviewers configured, the gate job -# auto-succeeds and provides NO protection — verify the environment is set up -# before relying on this workflow for production releases. +# Kicks off a release by materializing news fragments into the selected +# packages' CHANGELOG.md files at the next version — the changelogs are the +# source of truth for publishing (see release_from_changelog.yml, which +# builds any changelog version that has no git tag yet and uploads it once +# the pypi environment deployment is approved). +# +# Leaving every package unchecked auto-selects the packages to release: +# those with pending news fragments — or, for release-from-prerelease, those +# whose changelog is topped by an alpha (the train's fragments are already +# consumed). Fragments in the repo-root news/ select reflex-base, which +# releases reflex alongside it. +# +# Prerelease actions (new-prerelease-*, continued-prerelease) write alpha +# versions and push them straight to an r/pre- branch (continued +# prereleases push back to the r/pre-* branch the workflow was dispatched +# on); alphas build immediately and upload after pypi environment approval — +# no PR review involved. To pull new work into a prerelease train, merge +# main into the r/pre-* branch, then dispatch continued-prerelease on it. +# +# Release actions (release-*) open a pull request with the changelog changes +# instead — reviewing and merging that PR is how final versions land for +# publishing. The PR targets main, or the r/hotfix/** branch the workflow +# was dispatched on (hotfix branches may publish final versions directly). +# release-from-prerelease collapses the accumulated alpha sections into the +# single final-version section, so alpha headings never appear in a +# published final changelog. +# +# Materializing changelogs never publishes by itself: every PyPI upload — +# alphas included — additionally requires approval by the `pypi` +# environment's required reviewers (see publish.yml). +# +# REQUIRED CONFIGURATION: +# - The `pypi` environment must have required reviewers (asserted, fail +# closed, by publish.yml). +# - "Allow GitHub Actions to create and approve pull requests" must be +# enabled in the repo's Actions settings for release-* actions to open PRs. +# - Branch protection on main should require review; merging a release PR is +# what lands final versions for publishing. +# - Recommended: a ruleset restricting who can create/push r/** and +# release/** branches to maintainers plus the github-actions[bot] app +# (this workflow pushes as github-actions[bot] via GITHUB_TOKEN). +# - If the pypi environment restricts deployment branches, allow main, +# r/pre-*, and r/hotfix/*. +# - The old dispatch-release-approval environment is no longer used; the +# pypi environment reviewers and release-PR review replace it. on: workflow_dispatch: @@ -94,6 +133,10 @@ on: permissions: contents: read +concurrency: + group: dispatch-release-${{ github.ref }} + cancel-in-progress: false + jobs: detect: runs-on: ubuntu-latest @@ -125,13 +168,13 @@ jobs: reflex_hosting_cli: ${{ inputs.reflex_hosting_cli }} run: bash .github/scripts/dispatch_release/detect.sh - plan: + materialize: needs: detect runs-on: ubuntu-latest permissions: - contents: read - outputs: - releases: ${{ steps.plan.outputs.releases }} + contents: write + pull-requests: write + actions: write steps: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: @@ -139,58 +182,30 @@ jobs: fetch-depth: 0 persist-credentials: false - uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0 - - id: plan + - name: Plan versions + id: plan env: PACKAGES_JSON: ${{ needs.detect.outputs.packages }} ACTION: ${{ inputs.action }} - run: uv run --script .github/scripts/dispatch_release/plan.py - - gate: - needs: plan - if: ${{ startsWith(inputs.action, 'release-') }} - runs-on: ubuntu-latest - permissions: - contents: read - environment: dispatch-release-approval - steps: - - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - with: - persist-credentials: false - - name: Show release plan + run: uv run --no-config --locked --script scripts/release.py plan + - name: Materialize changelogs env: - RELEASES: ${{ needs.plan.outputs.releases }} + RELEASES_JSON: ${{ steps.plan.outputs.releases }} ACTION: ${{ inputs.action }} - run: bash .github/scripts/dispatch_release/show_plan.sh - - release: - needs: [plan, gate] - if: ${{ always() && needs.plan.result == 'success' && (needs.gate.result == 'success' || needs.gate.result == 'skipped') }} - runs-on: ubuntu-latest - permissions: - contents: write - actions: write - strategy: - matrix: - include: ${{ fromJson(needs.plan.outputs.releases) }} - fail-fast: false - concurrency: - group: dispatch-release-${{ matrix.package }} - cancel-in-progress: false - steps: - - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - with: - persist-credentials: false - - name: Create GitHub release + run: uv run --no-config --locked --script scripts/release.py materialize + - name: Push prerelease branch + if: ${{ !startsWith(inputs.action, 'release-') }} env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - TAG: ${{ matrix.tag }} - PKG: ${{ matrix.package }} - VERSION: ${{ matrix.next }} ACTION: ${{ inputs.action }} - run: bash .github/scripts/dispatch_release/create_release.sh - - name: Trigger publish workflow + REF_NAME: ${{ github.ref_name }} + RELEASES: ${{ steps.plan.outputs.releases }} + run: bash .github/scripts/dispatch_release/push_prerelease.sh + - name: Open release pull request + if: ${{ startsWith(inputs.action, 'release-') }} env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - TAG: ${{ matrix.tag }} - REF: ${{ github.ref_name }} - run: bash .github/scripts/dispatch_release/trigger_publish.sh + ACTION: ${{ inputs.action }} + REF_NAME: ${{ github.ref_name }} + RELEASES: ${{ steps.plan.outputs.releases }} + run: bash .github/scripts/dispatch_release/open_release_pr.sh diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 5a695060f53..ecf5bd99081 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -1,32 +1,105 @@ name: Publish to PyPI -run-name: Publish ${{ github.event.release.tag_name || inputs.tag }} +run-name: Publish ${{ inputs.package }} ${{ inputs.version }} + +# Publishes one package at one version, in three stages: +# +# build unprivileged: validates the request against the changelog +# (source of truth), branch rules, lockstep invariant and +# existing tags; tags the local checkout (uv-dynamic- +# versioning derives the version from it); builds; verifies +# the built metadata; extracts release notes; uploads +# everything as a workflow artifact. +# publish gated by the `pypi` environment — a human reviewer must +# approve every upload, alphas included. Holds the only +# OIDC (id-token) privilege and runs nothing but +# `uv publish` on the pre-built artifact. +# tag-and-release only after a successful upload: pushes the tag and +# creates the GitHub release. A failed run therefore leaves +# no tag behind — fix the problem on top of the changelog +# bump and the release_from_changelog workflow retries on +# the next push. +# +# REQUIRED CONFIGURATION: +# - The `pypi` environment MUST have required reviewers configured +# (Settings → Environments → pypi). The publish job asserts this via the +# API and fails closed when it cannot prove reviewers are configured. +# - PyPI trusted publishing must trust this workflow file (publish.yml) with +# the `pypi` environment — unchanged by the changelog-driven flow, +# including when called from another workflow (the OIDC job_workflow_ref +# claim still points at publish.yml). +# - If the `pypi` environment restricts deployment branches, allow main, +# r/pre-*, and r/hotfix/*. on: - release: - types: [published] + workflow_call: + inputs: + package: + description: "Package to publish (reflex for the repo-root package)" + required: true + type: string + version: + description: "Target version (e.g. 1.2.3 or 1.2.3a1, no v prefix). Empty auto patch-bumps the newest tag — only for packages without a CHANGELOG.md." + required: false + type: string + default: "" workflow_dispatch: inputs: - tag: - description: "Release tag (e.g. v1.2.3 or reflex-lucide-v0.1.0)" + package: + description: "Package to publish" required: true + type: choice + options: + - reflex + - hatch-reflex-pyi + - reflex-base + - reflex-components-code + - reflex-components-core + - reflex-components-dataeditor + - reflex-components-gridjs + - reflex-components-internal + - reflex-components-lucide + - reflex-components-markdown + - reflex-components-moment + - reflex-components-plotly + - reflex-components-radix + - reflex-components-react-player + - reflex-components-recharts + - reflex-components-sonner + - reflex-docgen + - reflex-hosting-cli + - reflex-site-shared + version: + description: "Target version (e.g. 1.2.3 or 1.2.3a1, no v prefix). Empty auto patch-bumps the newest tag — only for packages without a CHANGELOG.md." + required: false + default: "" permissions: contents: read +# The same per-package group every caller uses on its calling job, so direct +# dispatches and workflow_call publishes of one package are mutually +# exclusive end to end. (A called workflow's top-level concurrency is ignored +# for workflow_call, so this cannot deadlock against the callers' groups.) +concurrency: + group: publish-${{ inputs.package }} + cancel-in-progress: false + jobs: - publish: + build: runs-on: ubuntu-latest - environment: - name: pypi permissions: - id-token: write contents: read + outputs: + skipped: ${{ steps.prepare.outputs.skipped }} + version: ${{ steps.prepare.outputs.version }} + tag: ${{ steps.prepare.outputs.tag }} + prerelease: ${{ steps.prepare.outputs.prerelease }} + mark_latest: ${{ steps.prepare.outputs.mark_latest }} steps: - name: Checkout uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: - ref: ${{ github.event.release.tag_name || inputs.tag }} fetch-tags: true fetch-depth: 0 persist-credentials: false @@ -41,34 +114,170 @@ jobs: save-cache: false cache-python: false - - name: Parse release tag - id: parse + - name: Validate against changelog, branch rules and existing tags + id: prepare env: - TAG: ${{ github.event.release.tag_name || inputs.tag }} - run: bash .github/scripts/publish/parse_tag.sh + PACKAGE: ${{ inputs.package }} + VERSION: ${{ inputs.version }} + REF_NAME: ${{ github.ref_name }} + run: uv run --no-config --locked --script scripts/release.py prepare-publish # A *.dev dependency pin references an unpublished version, so it must never reach # released package metadata. Scoped to the package being published so a dependency # can still be released while dependents temporarily dev-pin it. - name: Reject development-release dependency pins + if: steps.prepare.outputs.skipped != 'true' env: - PACKAGE: ${{ steps.parse.outputs.package }} + PACKAGE: ${{ inputs.package }} run: uv run --with packaging --no-project python scripts/check_min_deps.py --check-dev-pins "$PACKAGE" - name: Pin reflex-base to exact version - if: steps.parse.outputs.package == 'reflex' + if: steps.prepare.outputs.skipped != 'true' && inputs.package == 'reflex' + env: + VERSION: ${{ steps.prepare.outputs.version }} + run: uv run --no-config --locked --script scripts/release.py pin-reflex-base + + # uv-dynamic-versioning derives each package's version from the newest + # reachable tag with the package's prefix, so tagging HEAD locally is + # what selects the version being built. The tag is only pushed after a + # successful upload (tag-and-release job). + - name: Tag local checkout + if: steps.prepare.outputs.skipped != 'true' env: - VERSION: ${{ steps.parse.outputs.version }} - run: bash .github/scripts/publish/pin_reflex_base.sh + TAG: ${{ steps.prepare.outputs.tag }} + run: git tag "$TAG" - name: Build - run: uv build --directory "${{ steps.parse.outputs.build_dir }}" + if: steps.prepare.outputs.skipped != 'true' + run: uv build --directory "${{ steps.prepare.outputs.build_dir }}" + + - name: Verify built artifact versions + if: steps.prepare.outputs.skipped != 'true' + env: + VERSION: ${{ steps.prepare.outputs.version }} + run: uv run --no-config --locked --script scripts/release.py verify-dist - name: Verify .pyi files in wheel - if: steps.parse.outputs.package == 'reflex' + if: steps.prepare.outputs.skipped != 'true' && inputs.package == 'reflex' env: - BUILD_DIR: ${{ steps.parse.outputs.build_dir }} + BUILD_DIR: ${{ steps.prepare.outputs.build_dir }} run: bash .github/scripts/publish/verify_pyi.sh + - name: Extract release notes from changelog + if: steps.prepare.outputs.skipped != 'true' + env: + PACKAGE: ${{ inputs.package }} + VERSION: ${{ steps.prepare.outputs.version }} + NOTES_PATH: release_notes.md + run: uv run --no-config --locked --script scripts/release.py extract-notes + + # The manifest lets the gated publish job re-verify (with coreutils + # only) that what it uploads is byte-identical to what was built and + # verified here — everything the human approves is covered by it. + - name: Write checksum manifest + if: steps.prepare.outputs.skipped != 'true' + run: sha256sum dist/* release_notes.md > SHA256SUMS + + - name: Upload artifacts for the gated publish + if: steps.prepare.outputs.skipped != 'true' + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: publish-${{ inputs.package }} + path: | + dist/* + release_notes.md + SHA256SUMS + if-no-files-found: error + overwrite: true + + # The human approval gate: this job targets the `pypi` environment, whose + # required reviewers must approve before it starts. It holds the only OIDC + # privilege and deliberately runs no repository code and resolves no script + # dependencies — it only uploads the artifact built above. + publish: + needs: build + if: needs.build.outputs.skipped != 'true' + runs-on: ubuntu-latest + environment: + name: pypi + permissions: + id-token: write + # Reading the environment's protection rules needs repository read + # access on GITHUB_TOKEN (contents) plus actions: read. + actions: read + contents: read + steps: + - name: Require a human-approval gate on the pypi environment + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + set -euo pipefail + # Fail closed: uploads must never run unattended. If this job + # started without reviewers configured on the pypi environment, + # nothing paused it — refuse to publish and tell the admins how to + # fix it. The two failure modes get distinct messages so a token + # scope problem is not mistaken for missing reviewers. + if ! rules=$(gh api "repos/${GITHUB_REPOSITORY}/environments/pypi" --jq '[.protection_rules[]?.type]' 2>"$RUNNER_TEMP/gh_api_error.txt"); then + echo "::error::Could not read the pypi environment's protection rules ($(tail -n1 "$RUNNER_TEMP/gh_api_error.txt")); refusing to publish unattended. This is a token or configuration problem, not a missing-reviewers problem: the job needs contents: read and actions: read on GITHUB_TOKEN, and the pypi environment must exist." + exit 1 + fi + if [[ "$rules" != *'"required_reviewers"'* ]]; then + echo "::error::The pypi environment has no required reviewers. Every PyPI upload must be approved by a human: add required reviewers to the pypi environment (Settings -> Environments) and re-run." + exit 1 + fi + echo "pypi environment protection rules: $rules" + + - name: Install uv + uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0 + with: + version: "0.11.14" + enable-cache: false + restore-cache: false + save-cache: false + + - name: Download built artifacts + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: publish-${{ inputs.package }} + + - name: Verify artifact integrity + run: sha256sum -c SHA256SUMS + + # --check-url makes a retried run skip files that already made it to + # PyPI (e.g. when the upload succeeded but tagging failed), instead of + # erroring on the duplicate upload. - name: Publish - run: uv publish dist/* + run: uv publish --check-url https://pypi.org/simple/ dist/* + + tag-and-release: + needs: [build, publish] + runs-on: ubuntu-latest + permissions: + contents: write + steps: + - name: Checkout + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + persist-credentials: false + + - name: Download release notes + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: publish-${{ inputs.package }} + + - name: Push tag + env: + TAG: ${{ needs.build.outputs.tag }} + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: bash .github/scripts/publish/push_tag.sh + + - name: Create GitHub release + env: + TAG: ${{ needs.build.outputs.tag }} + PKG: ${{ inputs.package }} + VERSION: ${{ needs.build.outputs.version }} + PRERELEASE: ${{ needs.build.outputs.prerelease }} + MARK_LATEST: ${{ needs.build.outputs.mark_latest }} + NOTES_PATH: release_notes.md + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: bash .github/scripts/publish/create_release.sh diff --git a/.github/workflows/release_from_changelog.yml b/.github/workflows/release_from_changelog.yml new file mode 100644 index 00000000000..54305838062 --- /dev/null +++ b/.github/workflows/release_from_changelog.yml @@ -0,0 +1,143 @@ +name: Release from changelog + +# The CHANGELOG.md files are the source of truth for publishing. On every push +# to main or a release branch, this workflow compares the newest version +# heading in each package's CHANGELOG.md (repo root for reflex, packages/*/ +# for sub-packages) against the repo's git tags and runs the publish workflow +# for any version that has no tag yet. Publishing itself is gated by the +# `pypi` environment's required reviewers — detection and builds are +# automatic, uploads are not. +# +# Tags are only pushed after a successful publish (see publish.yml), so a +# failed build/publish is retried by pushing a fix on top of the changelog +# bump — no tag or release cleanup required. +# +# Branch policy (enforced again at publish time): final versions only from +# main or r/hotfix/**; prereleases only from r/pre-* or r/hotfix/** (the +# Dispatch release workflow materializes alphas on r/pre-* branches). +# +# reflex publishes strictly after every other package in the batch: its +# metadata pins reflex-base exactly, so the sibling must be uploaded first. +# detect fails closed if the reflex/reflex-base lockstep invariant is broken. +# +# workflow_dispatch exists so the Dispatch release workflow can trigger a +# check on a branch it just pushed (pushes made with GITHUB_TOKEN do not fire +# on-push workflows) and for manual re-checks; it runs against the branch it +# is dispatched on. + +on: + push: + branches: ["main", "r/pre-**", "r/hotfix/**"] + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: release-from-changelog-${{ github.ref }} + cancel-in-progress: false + +jobs: + detect: + runs-on: ubuntu-latest + permissions: + contents: read + outputs: + packages: ${{ steps.detect.outputs.packages }} + any: ${{ steps.detect.outputs.any }} + reflex_version: ${{ steps.detect.outputs.reflex_version }} + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + fetch-tags: true + fetch-depth: 0 + persist-credentials: false + - uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0 + - id: detect + env: + REF_NAME: ${{ github.ref_name }} + run: uv run --no-config --locked --script scripts/release.py detect + + publish: + needs: detect + if: needs.detect.outputs.any == 'true' + strategy: + matrix: + include: ${{ fromJson(needs.detect.outputs.packages) }} + fail-fast: false + # Serializes the entire publish (build, approval, upload, tag) per + # package, so a second push while a publish is in flight waits and then + # no-ops on the already-created tag. + concurrency: + group: publish-${{ matrix.package }} + cancel-in-progress: false + permissions: + contents: write + id-token: write + actions: read + uses: ./.github/workflows/publish.yml + with: + package: ${{ matrix.package }} + version: ${{ matrix.version }} + + # reflex pins reflex-base exactly, so it publishes only after every other + # package in the batch (reflex-base included) has fully published and + # tagged. If any sibling failed, reflex is held back — fail closed rather + # than ship an uninstallable pin. + publish-reflex: + needs: [detect, publish] + if: >- + always() && needs.detect.result == 'success' && + needs.detect.outputs.reflex_version != '' && + (needs.publish.result == 'success' || needs.publish.result == 'skipped') + concurrency: + group: publish-reflex + cancel-in-progress: false + permissions: + contents: write + id-token: write + actions: read + uses: ./.github/workflows/publish.yml + with: + package: reflex + version: ${{ needs.detect.outputs.reflex_version }} + + # One loud, canonical failure signal for partial releases: red whenever any + # leg of the batch failed or reflex was held back. + report: + needs: [detect, publish, publish-reflex] + if: always() + runs-on: ubuntu-latest + permissions: {} + steps: + - name: Report release batch outcome + env: + DETECT: ${{ needs.detect.result }} + PUBLISH: ${{ needs.publish.result }} + PUBLISH_REFLEX: ${{ needs.publish-reflex.result }} + REFLEX_VERSION: ${{ needs.detect.outputs.reflex_version }} + run: | + set -euo pipefail + echo "detect: $DETECT, publish: $PUBLISH, publish-reflex: $PUBLISH_REFLEX" + failed=0 + # Anything that is not success/skipped (failure, cancelled, + # timed_out, a rejected environment approval, ...) is a failed leg. + for leg in "detect:$DETECT" "publish:$PUBLISH" "publish-reflex:$PUBLISH_REFLEX"; do + case "${leg#*:}" in + success | skipped) ;; + *) + echo "::error::release leg '${leg%%:*}' ended '${leg#*:}'." + failed=1 + ;; + esac + done + if [[ "$failed" -eq 1 ]]; then + if [[ "$DETECT" != "success" ]]; then + echo "::error::Changelog detection did not complete (check for a reflex/reflex-base lockstep violation) — no packages were published." + fi + if [[ -n "$REFLEX_VERSION" && "$PUBLISH_REFLEX" != "success" ]]; then + echo "::error::reflex v$REFLEX_VERSION did not publish (held back or failed)." + fi + echo "::error::Fix the problem on top of the changelog bump; the next push retries the unpublished versions." + fi + exit "$failed" diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 552fd7f0945..b6997b99975 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -88,13 +88,48 @@ If you don't yet know the PR number, use an [orphan fragment](https://towncrier. **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. -**Publishing CHANGELOG.md**: This step should be completed by maintainers during -the release process. If you have access to publish a release, you can run the -following command to generate the `CHANGELOG.md` file in each subpackage. - -```bash -uv run towncrier build --config pyproject.toml --version v0.9.4 -``` +**Changelog version headings:** PRs to `main` must not add new version headings to any `CHANGELOG.md` — a merged heading without a git tag is a publish trigger, so new headings only come from the *Dispatch release* workflow. CI enforces this with the same parser the release pipeline uses; for deliberate restructuring of already-published sections, apply the `changelog-version-edit` label. + +**Releasing (maintainers):** the `CHANGELOG.md` files are the source of truth +for publishing. A release is cut by materializing the news fragments into a +package's `CHANGELOG.md` under a new version heading and landing that change on +a release branch — never by tagging manually. The pieces: + +1. **Dispatch release** (`dispatch_release.yml`, run from the Actions tab) + selects packages and a release action, computes the next version(s), runs + towncrier, and delivers the changelog bump. Leaving every package + unchecked auto-selects the packages with pending news fragments (for + `release-from-prerelease`: the packages whose changelog is topped by an + alpha). Details: + - *Prerelease actions* (`new-prerelease-*`, `continued-prerelease`) push + alpha versions straight to an `r/pre-` branch (continued + prereleases push back to the `r/pre-*` branch they are dispatched on); + alphas build immediately and upload once the `pypi` environment + deployment is approved. To pull new work into a prerelease train, merge + `main` into its branch and dispatch `continued-prerelease` on it. + - *Release actions* (`release-*`) open a PR with the changelog changes + instead; reviewing and merging that PR is how final versions land (the + upload still waits for the `pypi` environment approval below). The PR + targets `main`, or the `r/hotfix/...` branch the workflow was dispatched + on (hotfix branches may publish final versions directly). + `release-from-prerelease` collapses the accumulated alpha sections into + one final-version section — alpha headings never ship in a final + changelog. + - Selecting `reflex-base` automatically releases the root `reflex` package + at the same version. +2. **Release from changelog** (`release_from_changelog.yml`) runs on every push + to `main`, `r/pre-*`, and `r/hotfix/**`: any package whose newest changelog + version has no git tag gets built and queued for publishing. Final + (non-alpha) versions only publish from `main` or `r/hotfix/**`; alphas only + from `r/pre-*`/`r/hotfix/**`. `reflex` and `reflex-base` are checked as a + lockstep pair and `reflex` publishes only after the rest of the batch. +3. **Publish to PyPI** (`publish.yml`, also manually dispatchable with a + package + version) validates and builds without privileges, then **waits + for a human to approve the `pypi` environment deployment** — every upload, + alphas and internal packages included, requires that approval. Only after a + successful upload does it push the tag and create the GitHub release, so a + failed or rejected publish leaves no tag behind — fix the problem on top of + the changelog bump and the next push retries automatically. **Where changelogs are published:** the docs site renders every `CHANGELOG.md` in the repo (repo root and `packages/*/`) under diff --git a/scripts/release.py b/scripts/release.py new file mode 100644 index 00000000000..16a76a8eaf9 --- /dev/null +++ b/scripts/release.py @@ -0,0 +1,1329 @@ +# /// script +# requires-python = ">=3.10" +# dependencies = [ +# "packaging==26.2", +# "towncrier==25.8.0", +# "tomli==2.4.1; python_version < '3.11'", +# "tzdata==2025.2; sys_platform == 'win32'", +# ] +# /// +# Exact pins, resolved with hashes in release.py.lock (regenerate with +# `uv lock --script scripts/release.py` after editing the list above). +# Every workflow call site passes --locked so header/lock drift fails loudly. +"""Changelog-driven release helpers for the CI release workflows. + +The ``CHANGELOG.md`` files (repo root for ``reflex``, ``packages//`` for +sub-packages) are the source of truth for publishing: a package must be +published exactly when the newest version heading in its changelog has no +corresponding git tag. Tags are only created *after* a successful publish, so a +failed build or upload is retried by pushing a fix on top of the changelog bump +— no tags or releases ever need to be deleted. + +Subcommands (all read their inputs from environment variables, GitHub Actions +style, and append to ``$GITHUB_OUTPUT`` / ``$GITHUB_STEP_SUMMARY``): + +- ``detect``: list packages whose newest changelog version is untagged. + Used by the ``release_from_changelog`` workflow on every push to ``main``, + ``r/pre-*`` and ``r/hotfix/**`` branches. Final (non-prerelease) versions are + only released from ``main`` or ``r/hotfix/**``; prereleases only from + ``r/pre-*`` or ``r/hotfix/**``. +- ``plan``: compute the next version for each selected package for a release + action (``new-prerelease-patch``, ``continued-prerelease``, + ``release-from-prerelease``, ``release-major``, ...). An empty selection + auto-detects packages with pending news fragments (or alpha-topped + changelogs for ``release-from-prerelease``). +- ``materialize``: write the planned versions into the changelogs with + towncrier; for ``release-from-prerelease`` also collapse the accumulated + alpha sections into the single final-version section (alpha headings never + appear in a published final changelog). +- ``prepare-publish``: validate a (package, version) pair against the + changelog, the branch rules, the reflex/reflex-base lockstep invariant and + existing tags, and emit build metadata for the publish workflow. +- ``pin-reflex-base``: rewrite reflex's ``reflex-base >= ...`` requirement to + the exact release version before building. +- ``verify-dist``: check the core-metadata Version of every built artifact + against the target version. +- ``extract-notes``: write the changelog section for a version to a file, for + use as GitHub release notes. +- ``check-headings``: fail when the working tree adds changelog version + headings that BASE_REF does not have — used by PR CI so hand-edited + headings (which would otherwise be publish triggers) are rejected by the + exact parser the release pipeline uses. +""" + +from __future__ import annotations + +import dataclasses +import datetime +import json +import os +import re +import subprocess +import sys +import tarfile +import zipfile +import zoneinfo +from pathlib import Path +from typing import NoReturn + +from packaging.version import InvalidVersion, Version + +try: + import tomllib +except ImportError: + import tomli as tomllib # pyright: ignore[reportMissingImports] + +REPO_ROOT = Path(__file__).resolve().parent.parent + +ROOT_PACKAGE = "reflex" + +# Maps a dispatch action to (mode, submode) driving next_version(). +ACTIONS: dict[str, tuple[str, str | None]] = { + "new-prerelease-patch": ("new-prerelease", "patch"), + "new-prerelease-minor": ("new-prerelease", "minor"), + "new-prerelease-major": ("new-prerelease", "major"), + "continued-prerelease": ("continued-prerelease", None), + "release-from-prerelease": ("release", "from-prerelease"), + "release-post": ("release", "post"), + "release-patch": ("release", "patch"), + "release-minor": ("release", "minor"), + "release-major": ("release", "major"), +} + +# Changelog heading dates (and the r/pre- branch names, see +# push_prerelease.sh) are stamped in the project's home timezone: CI runners +# run on UTC, which would date an evening release with tomorrow's date. +RELEASE_TIMEZONE = "America/Los_Angeles" + +NO_SIGNIFICANT_CHANGES = "No significant changes" + +_HEADING_RE = re.compile(r"^(?P