diff --git a/.github/scripts/auto_release_internal/detect.sh b/.github/scripts/auto_release_internal/detect.sh deleted file mode 100755 index 92c954bf564..00000000000 --- a/.github/scripts/auto_release_internal/detect.sh +++ /dev/null @@ -1,20 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail - -: "${EVENT_NAME:?}" - -if [ "$EVENT_NAME" = "workflow_dispatch" ]; then - : "${DISPATCH_PACKAGE:?}" - printf 'packages=["%s"]\n' "$DISPATCH_PACKAGE" >> "$GITHUB_OUTPUT" - exit 0 -fi - -PACKAGES=() -for pkg in reflex-components-internal reflex-site-shared; do - if git diff --name-only HEAD~1 HEAD -- "packages/$pkg/" | grep -q .; then - PACKAGES+=("\"$pkg\"") - fi -done - -JOINED=$(IFS=,; echo "${PACKAGES[*]:-}") -echo "packages=[$JOINED]" >> "$GITHUB_OUTPUT" diff --git a/.github/scripts/dispatch_release/detect.sh b/.github/scripts/dispatch_release/detect.sh deleted file mode 100755 index 9a57a7d48dc..00000000000 --- a/.github/scripts/dispatch_release/detect.sh +++ /dev/null @@ -1,40 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail - -declare -A MAP=( - [hatch_reflex_pyi]=hatch-reflex-pyi - [reflex_base]=reflex-base - [reflex_components_code]=reflex-components-code - [reflex_components_core]=reflex-components-core - [reflex_components_dataeditor]=reflex-components-dataeditor - [reflex_components_gridjs]=reflex-components-gridjs - [reflex_components_lucide]=reflex-components-lucide - [reflex_components_markdown]=reflex-components-markdown - [reflex_components_moment]=reflex-components-moment - [reflex_components_plotly]=reflex-components-plotly - [reflex_components_radix]=reflex-components-radix - [reflex_components_react_player]=reflex-components-react-player - [reflex_components_recharts]=reflex-components-recharts - [reflex_components_sonner]=reflex-components-sonner - [reflex_docgen]=reflex-docgen - [reflex_hosting_cli]=reflex-hosting-cli - [reflex_release]=reflex-release -) -ORDER=(hatch_reflex_pyi reflex_base reflex_components_code reflex_components_core reflex_components_dataeditor reflex_components_gridjs 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_release) - -PACKAGES=() -for key in "${ORDER[@]}"; do - if [[ "${!key:-false}" == "true" ]]; then - PACKAGES+=("\"${MAP[$key]}\"") - fi -done - -if [[ ${#PACKAGES[@]} -eq 0 ]]; then - # 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[*]:-}") -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 deleted file mode 100755 index b9b4ba74488..00000000000 --- a/.github/scripts/dispatch_release/open_release_pr.sh +++ /dev/null @@ -1,66 +0,0 @@ -#!/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/push_prerelease.sh b/.github/scripts/dispatch_release/push_prerelease.sh deleted file mode 100755 index 3d92e358e5b..00000000000 --- a/.github/scripts/dispatch_release/push_prerelease.sh +++ /dev/null @@ -1,59 +0,0 @@ -#!/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/publish/create_release.sh b/.github/scripts/publish/create_release.sh deleted file mode 100755 index 55c90b11290..00000000000 --- a/.github/scripts/publish/create_release.sh +++ /dev/null @@ -1,27 +0,0 @@ -#!/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/post_build.sh b/.github/scripts/publish/post_build.sh new file mode 100755 index 00000000000..989a63da816 --- /dev/null +++ b/.github/scripts/publish/post_build.sh @@ -0,0 +1,20 @@ +#!/usr/bin/env bash +# Repository-specific artifact checks, run by publish.yml's collect job after +# every build and before the approval gate. PACKAGE, VERSION, BUILD_DIR and +# DIST_DIR are in the environment. +set -euo pipefail + +: "${PACKAGE:?}" +: "${DIST_DIR:?}" + +# The reflex wheel carries the generated .pyi stubs (scripts/hatch_build.py). +# A build that silently produced none would ship a release with no type +# information, so it must not reach the approver. +if [ "$PACKAGE" = "reflex" ]; then + if unzip -l "$DIST_DIR"/*.whl | grep '\.pyi$'; then + echo "✓ .pyi files found in distribution" + else + echo "Error: No .pyi files found in wheel" + exit 1 + fi +fi diff --git a/.github/scripts/publish/push_tag.sh b/.github/scripts/publish/push_tag.sh deleted file mode 100755 index 50a186646ae..00000000000 --- a/.github/scripts/publish/push_tag.sh +++ /dev/null @@ -1,17 +0,0 @@ -#!/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/scripts/publish/verify_pyi.sh b/.github/scripts/publish/verify_pyi.sh deleted file mode 100755 index c6a466e0a7b..00000000000 --- a/.github/scripts/publish/verify_pyi.sh +++ /dev/null @@ -1,11 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail - -: "${BUILD_DIR:?}" - -if unzip -l "$BUILD_DIR"/dist/*.whl | grep '\.pyi$'; then - echo "✓ .pyi files found in distribution" -else - echo "Error: No .pyi files found in wheel" - exit 1 -fi diff --git a/.github/workflows/auto_release_internal.yml b/.github/workflows/auto_release_internal.yml index 35b85901e31..1735f86d510 100644 --- a/.github/workflows/auto_release_internal.yml +++ b/.github/workflows/auto_release_internal.yml @@ -1,28 +1,27 @@ +# Generated by reflex-release; do not edit by hand. +# Re-run `uv run --frozen --package reflex-release reflex-release sync` after changing [tool.reflex-release] in +# pyproject.toml. `uv run --frozen --package reflex-release reflex-release sync --check` fails when this file drifts. 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. +# 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] + branches: ["main"] paths: - - "packages/reflex-components-internal/**" - - "packages/reflex-site-shared/**" - - ".github/workflows/auto_release_internal.yml" + - "packages/reflex-components-internal/src/**" + - "packages/reflex-site-shared/src/**" workflow_dispatch: inputs: package: - description: "Package to release" + description: "Internal package to release" required: true - type: choice - options: - - reflex-components-internal - - reflex-site-shared + type: string permissions: contents: read @@ -35,15 +34,24 @@ jobs: outputs: packages: ${{ steps.detect.outputs.packages }} steps: + # The whole pushed range has to be reachable: the paths filter above + # matches every commit in the push, so detection must diff the same range + # rather than only the last commit. - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: - fetch-depth: 2 + fetch-depth: 0 persist-credentials: false + - uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0 + with: + version: "0.12.5" + python-version: "3.14.7" - id: detect env: - EVENT_NAME: ${{ github.event_name }} DISPATCH_PACKAGE: ${{ inputs.package }} - run: bash .github/scripts/auto_release_internal/detect.sh + BASE_SHA: ${{ github.event.before }} + HEAD_SHA: ${{ github.event.after }} + shell: bash + run: uv run --frozen --package reflex-release reflex-release detect-internal publish: needs: detect diff --git a/.github/workflows/changelog.yml b/.github/workflows/changelog.yml index dde678d9645..59a90a872f6 100644 --- a/.github/workflows/changelog.yml +++ b/.github/workflows/changelog.yml @@ -1,5 +1,27 @@ +# Generated by reflex-release; do not edit by hand. +# Re-run `uv run --frozen --package reflex-release reflex-release sync` after changing [tool.reflex-release] in +# pyproject.toml. `uv run --frozen --package reflex-release reflex-release sync --check` fails when this file drifts. name: changelog +# Two guards on every pull request: +# +# 1. New CHANGELOG.md version headings are rejected. A merged version heading +# without a git tag is a real publish trigger (see +# release_from_changelog.yml), so headings must come from the Dispatch +# release workflow's release/* branches — not from hand edits. +# The guard uses the same parser the release pipeline publishes from, so the +# two cannot disagree. Escape hatch for deliberate restructuring of +# already-published sections: the 'changelog-version-edit' label. +# +# 2. Every package whose source the PR touches must add a news fragment under +# its news/ directory. Waive with the 'skip-changelog' label for changes +# that are genuinely not user-facing. +# +# 3. The generated release workflows must match what the pinned reflex-release +# version produces from [tool.reflex-release] — adding a package or editing +# the configuration without re-running `uv run --frozen --package reflex-release reflex-release sync` fails here rather than +# at release time. + permissions: contents: read @@ -7,96 +29,52 @@ concurrency: group: ${{ github.workflow }}-${{ github.event.pull_request.id }} cancel-in-progress: true +# The branches are the ones release_from_changelog publishes from: a merged +# version heading is a publish trigger wherever it lands, and r/hotfix/** +# publishes final versions without going through main at all. on: pull_request: - branches: ["main"] + branches: + ["main", "r/pre-**", "r/hotfix/**"] jobs: changelog: runs-on: ubuntu-latest timeout-minutes: 10 steps: - - name: Check for skip-changelog label - id: skip - shell: bash - env: - SKIP_CHANGELOG: ${{ contains(github.event.pull_request.labels.*.name, 'skip-changelog') }} - run: | - if [ "$SKIP_CHANGELOG" = "true" ]; then - echo "skip=true" >> "$GITHUB_OUTPUT" - echo "PR has 'skip-changelog' label; bypassing changelog check." - 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 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. + persist-credentials: false - 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/') }} + with: + version: "0.12.5" + python-version: "3.14.7" + # The heading guard runs even for skip-changelog PRs: that label only + # waives the news-fragment requirement, not the publish-trigger guard. + # + # The exemption for release branches also requires the pull request to be + # authored by github-actions[bot], which is how the Dispatch release + # workflow opens it. A branch name is attacker-selectable; the author is + # not, so a contributor cannot skip the guard by naming their branch + # release/something and hand-writing a version heading. The + # 'changelog-version-edit' label needs triage rights to apply, so it is a + # sound escape hatch on its own. - name: Reject manual changelog version headings - if: ${{ !contains(github.event.pull_request.labels.*.name, 'changelog-version-edit') && !startsWith(github.head_ref, 'release/') }} + if: >- + ${{ !contains(github.event.pull_request.labels.*.name, 'changelog-version-edit') + && !(startsWith(github.head_ref, 'release/') + && github.event.pull_request.user.login == 'github-actions[bot]') }} 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: - python-version: "3.14" - run-uv-sync: true - - name: Determine affected packages - if: steps.skip.outputs.skip != 'true' - id: affected shell: bash - run: | - set -euo pipefail - changed=$(git diff --name-only origin/main...HEAD) - affected=() - if printf '%s\n' "$changed" | grep -qE '^reflex/'; then - affected+=(".") - fi - for pkg_dir in packages/*/; do - pkg=$(basename "$pkg_dir") - case "$pkg" in - integrations-docs|reflex-components-internal|reflex-site-shared) continue ;; - esac - if printf '%s\n' "$changed" | grep -qE "^packages/$pkg/src/"; then - affected+=("${pkg_dir%/}") - fi - done - printf '%s\n' "${affected[@]}" > affected.txt - echo "Affected packages:" - cat affected.txt + run: uv run --frozen --package reflex-release reflex-release check-headings - name: Check for news fragments - if: steps.skip.outputs.skip != 'true' + if: ${{ !contains(github.event.pull_request.labels.*.name, 'skip-changelog') }} + env: + BASE_REF: origin/${{ github.base_ref }} + shell: bash + run: uv run --frozen --package reflex-release reflex-release changelog-check + - name: Check the release workflows are up to date shell: bash - run: | - set -euo pipefail - if [ ! -s affected.txt ]; then - echo "No packaged source changes in this PR; no changelog fragments required." - exit 0 - fi - failed=0 - while IFS= read -r pkg_dir; do - [ -z "$pkg_dir" ] && continue - echo "::group::towncrier check ($pkg_dir)" - if ! uv run towncrier check --config pyproject.toml --dir "$pkg_dir" --compare-with origin/main; then - failed=1 - fi - echo "::endgroup::" - done < affected.txt - if [ "$failed" -ne 0 ]; then - echo "" - echo "One or more affected packages is missing a news fragment under /news/." - echo "Add a fragment named ..md where is one of:" - echo " breaking, deprecation, feature, bugfix, performance, docs, misc" - echo "Or apply the 'skip-changelog' label if the change is genuinely not user-facing." - exit 1 - fi + run: uv run --frozen --package reflex-release reflex-release sync --check diff --git a/.github/workflows/dispatch_release.yml b/.github/workflows/dispatch_release.yml index f73008151d8..f92ab955cc3 100644 --- a/.github/workflows/dispatch_release.yml +++ b/.github/workflows/dispatch_release.yml @@ -1,50 +1,53 @@ +# Generated by reflex-release; do not edit by hand. +# Re-run `uv run --frozen --package reflex-release reflex-release sync` after changing [tool.reflex-release] in +# pyproject.toml. `uv run --frozen --package reflex-release reflex-release sync --check` fails when this file drifts. name: Dispatch release # 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). +# 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. +# Selecting no package auto-selects the ones 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). Lockstep +# groups share one checkbox: their members only ever release together. +# +# The package list is generated from [tool.reflex-release] — after adding or +# removing a package, re-run `uv run --frozen --package reflex-release reflex-release sync`. # # 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. +# 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 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. +# 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). +# Materializing changelogs never publishes by itself: every 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. +# - "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/pre-**, r/hotfix/** and +# release/** branches to maintainers plus the github-actions[bot] +# app (this workflow pushes as github-actions[bot] via GITHUB_TOKEN). on: workflow_dispatch: @@ -65,12 +68,16 @@ on: - release-patch - release-minor - release-major + reflex: + description: "reflex (with reflex-base)" + type: boolean + default: false hatch_reflex_pyi: description: "hatch-reflex-pyi" type: boolean default: false - reflex_base: - description: "reflex-base" + integrations_docs: + description: "integrations-docs" type: boolean default: false reflex_components_code: @@ -142,39 +149,7 @@ concurrency: cancel-in-progress: false jobs: - detect: - runs-on: ubuntu-latest - permissions: - contents: read - outputs: - packages: ${{ steps.detect.outputs.packages }} - steps: - - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - with: - persist-credentials: false - - id: detect - env: - hatch_reflex_pyi: ${{ inputs.hatch_reflex_pyi }} - reflex_base: ${{ inputs.reflex_base }} - reflex_components_code: ${{ inputs.reflex_components_code }} - reflex_components_core: ${{ inputs.reflex_components_core }} - reflex_components_dataeditor: ${{ inputs.reflex_components_dataeditor }} - reflex_components_gridjs: ${{ inputs.reflex_components_gridjs }} - reflex_components_lucide: ${{ inputs.reflex_components_lucide }} - reflex_components_markdown: ${{ inputs.reflex_components_markdown }} - reflex_components_moment: ${{ inputs.reflex_components_moment }} - reflex_components_plotly: ${{ inputs.reflex_components_plotly }} - reflex_components_radix: ${{ inputs.reflex_components_radix }} - reflex_components_react_player: ${{ inputs.reflex_components_react_player }} - reflex_components_recharts: ${{ inputs.reflex_components_recharts }} - reflex_components_sonner: ${{ inputs.reflex_components_sonner }} - reflex_docgen: ${{ inputs.reflex_docgen }} - reflex_hosting_cli: ${{ inputs.reflex_hosting_cli }} - reflex_release: ${{ inputs.reflex_release }} - run: bash .github/scripts/dispatch_release/detect.sh - materialize: - needs: detect runs-on: ubuntu-latest permissions: contents: write @@ -187,30 +162,57 @@ jobs: fetch-depth: 0 persist-credentials: false - uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0 + with: + version: "0.12.5" + python-version: "3.14.7" - name: Plan versions id: plan env: - PACKAGES_JSON: ${{ needs.detect.outputs.packages }} ACTION: ${{ inputs.action }} - run: uv run --no-config --locked --script scripts/release.py plan + # Each unselected package contributes an empty string, so selecting + # nothing leaves PACKAGES blank and the planner auto-selects. + PACKAGES: >- + ${{ inputs.reflex && 'reflex,reflex-base' || '' }} + ${{ inputs.hatch_reflex_pyi && 'hatch-reflex-pyi' || '' }} + ${{ inputs.integrations_docs && 'integrations-docs' || '' }} + ${{ inputs.reflex_components_code && 'reflex-components-code' || '' }} + ${{ inputs.reflex_components_core && 'reflex-components-core' || '' }} + ${{ inputs.reflex_components_dataeditor && 'reflex-components-dataeditor' || '' }} + ${{ inputs.reflex_components_gridjs && 'reflex-components-gridjs' || '' }} + ${{ inputs.reflex_components_lucide && 'reflex-components-lucide' || '' }} + ${{ inputs.reflex_components_markdown && 'reflex-components-markdown' || '' }} + ${{ inputs.reflex_components_moment && 'reflex-components-moment' || '' }} + ${{ inputs.reflex_components_plotly && 'reflex-components-plotly' || '' }} + ${{ inputs.reflex_components_radix && 'reflex-components-radix' || '' }} + ${{ inputs.reflex_components_react_player && 'reflex-components-react-player' || '' }} + ${{ inputs.reflex_components_recharts && 'reflex-components-recharts' || '' }} + ${{ inputs.reflex_components_sonner && 'reflex-components-sonner' || '' }} + ${{ inputs.reflex_docgen && 'reflex-docgen' || '' }} + ${{ inputs.reflex_hosting_cli && 'reflex-hosting-cli' || '' }} + ${{ inputs.reflex_release && 'reflex-release' || '' }} + shell: bash + run: uv run --frozen --package reflex-release reflex-release plan - name: Materialize changelogs env: - RELEASES_JSON: ${{ steps.plan.outputs.releases }} ACTION: ${{ inputs.action }} - run: uv run --no-config --locked --script scripts/release.py materialize + RELEASES_JSON: ${{ steps.plan.outputs.releases }} + shell: bash + run: uv run --frozen --package reflex-release reflex-release materialize - name: Push prerelease branch if: ${{ !startsWith(inputs.action, 'release-') }} env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} ACTION: ${{ inputs.action }} REF_NAME: ${{ github.ref_name }} - RELEASES: ${{ steps.plan.outputs.releases }} - run: bash .github/scripts/dispatch_release/push_prerelease.sh + RELEASES_JSON: ${{ steps.plan.outputs.releases }} + shell: bash + run: uv run --frozen --package reflex-release reflex-release push-prerelease - name: Open release pull request if: ${{ startsWith(inputs.action, 'release-') }} env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} ACTION: ${{ inputs.action }} REF_NAME: ${{ github.ref_name }} - RELEASES: ${{ steps.plan.outputs.releases }} - run: bash .github/scripts/dispatch_release/open_release_pr.sh + RELEASES_JSON: ${{ steps.plan.outputs.releases }} + shell: bash + run: uv run --frozen --package reflex-release reflex-release open-release-pr diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 6c9528bd7b5..854d7c7c9fc 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -1,15 +1,23 @@ +# Generated by reflex-release; do not edit by hand. +# Re-run `uv run --frozen --package reflex-release reflex-release sync` after changing [tool.reflex-release] in +# pyproject.toml. `uv run --frozen --package reflex-release reflex-release sync --check` fails when this file drifts. name: Publish to PyPI run-name: Publish ${{ inputs.package }} ${{ inputs.version }} -# Publishes one package at one version, in three stages: +# Publishes one package at one version, in five stages: # -# build unprivileged: validates the request against the changelog +# prepare unprivileged: validates the request against the changelog # (source of truth), branch rules, lockstep invariant and -# existing tags; tags the local checkout (uv-dynamic- -# versioning derives the version from it); builds; verifies -# the built metadata; extracts release notes; uploads -# everything as a workflow artifact. +# existing tags, then emits the version, tag and build +# directory every later job works from. +# build unprivileged: tags the local checkout (the dynamic +# versioning backend derives the version from it), builds, +# and uploads what it produced as a workflow artifact. +# collect unprivileged: gathers every file the build produced, +# verifies each one is this package at this version, extracts +# the release notes and writes the checksum manifest the +# approver sees. # publish gated by the `pypi` environment — a human reviewer must # approve every upload, alphas included. Holds the only # OIDC (id-token) privilege and runs nothing but @@ -17,25 +25,33 @@ run-name: Publish ${{ inputs.package }} ${{ inputs.version }} # 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 +# bump and the release-from-changelog workflow retries on # the next push. # +# Every build job runs before the approval gate and none of them holds a +# credential, so a build that fails — any leg of a matrix included — stops the +# release instead of publishing an incomplete set. +# # REQUIRED CONFIGURATION: # - The `pypi` environment MUST have required reviewers configured -# (Settings → Environments → pypi). The publish job asserts this via the +# (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/*. +# the `pypi` environment — including when it is 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/*. +# - Optional: a `.github/scripts/publish/post_build.sh` hook runs after the +# build with PACKAGE, VERSION, BUILD_DIR and DIST_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: inputs: package: - description: "Package to publish (reflex for the repo-root package)" + description: "Package to publish" required: true type: string version: @@ -48,28 +64,7 @@ on: 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-release - - reflex-site-shared + 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 @@ -79,15 +74,15 @@ 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.) +# 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: - build: + prepare: runs-on: ubuntu-latest permissions: contents: read @@ -95,6 +90,7 @@ jobs: skipped: ${{ steps.prepare.outputs.skipped }} version: ${{ steps.prepare.outputs.version }} tag: ${{ steps.prepare.outputs.tag }} + build_dir: ${{ steps.prepare.outputs.build_dir }} prerelease: ${{ steps.prepare.outputs.prerelease }} mark_latest: ${{ steps.prepare.outputs.mark_latest }} steps: @@ -108,12 +104,8 @@ jobs: - name: Install uv uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0 with: - version: "0.11.14" - python-version: "3.14.5" - enable-cache: false - restore-cache: false - save-cache: false - cache-python: false + version: "0.12.5" + python-version: "3.14.7" - name: Validate against changelog, branch rules and existing tags id: prepare @@ -121,66 +113,181 @@ jobs: PACKAGE: ${{ inputs.package }} VERSION: ${{ inputs.version }} REF_NAME: ${{ github.ref_name }} - run: uv run --no-config --locked --script scripts/release.py prepare-publish + shell: bash + run: uv run --frozen --package reflex-release reflex-release 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' + build: + needs: prepare + if: >- + needs.prepare.outputs.skipped != 'true' + runs-on: ubuntu-latest + permissions: + contents: read + steps: + - name: Checkout + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + fetch-tags: true + fetch-depth: 0 + persist-credentials: false + + - name: Install uv + uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0 + with: + version: "0.12.5" + python-version: "3.14.7" + + - name: Pin lockstep siblings to exact versions env: PACKAGE: ${{ inputs.package }} - run: uv run --with packaging --no-project python scripts/check_min_deps.py --check-dev-pins "$PACKAGE" + VERSION: ${{ needs.prepare.outputs.version }} + shell: bash + run: uv run --frozen --package reflex-release reflex-release pin-lockstep - - name: Pin reflex-base to exact version - if: steps.prepare.outputs.skipped != 'true' && inputs.package == 'reflex' + # 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. It runs after the lockstep pin, which is what + # turns a sibling's dev pin into the exact version being released. + - name: Reject development-release dependency pins env: - VERSION: ${{ steps.prepare.outputs.version }} - run: uv run --no-config --locked --script scripts/release.py pin-reflex-base + PACKAGE: ${{ inputs.package }} + shell: bash + run: uv run --frozen --package reflex-release reflex-release check-dev-pins "$PACKAGE" - # 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 + # The dynamic versioning backend 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: - TAG: ${{ steps.prepare.outputs.tag }} + TAG: ${{ needs.prepare.outputs.tag }} + shell: bash run: git tag "$TAG" + # --out-dir is absolute so the artifacts land in the repository root + # regardless of the project layout: a uv workspace member would otherwise + # build into the workspace root and a standalone package into its own + # directory. - name: Build - if: steps.prepare.outputs.skipped != 'true' - run: uv build --directory "${{ steps.prepare.outputs.build_dir }}" + env: + BUILD_DIR: ${{ needs.prepare.outputs.build_dir }} + shell: bash + run: uv build --directory "$BUILD_DIR" --out-dir "$GITHUB_WORKSPACE/dist" - - name: Verify built artifact versions - if: steps.prepare.outputs.skipped != 'true' + - name: Upload the built distribution files + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: dist-${{ inputs.package }}--build + path: dist/* + if-no-files-found: error + overwrite: true + + # Everything that turns "some files exist" into "these exact files are what + # the reviewer is approving": the metadata check, the repository's own hook, + # the release notes and the checksum manifest. One code path, whichever job + # produced the files. + collect: + needs: [prepare, build] + # Exactly one build path runs for a given package, so the others report + # 'skipped' — which this tolerates while still failing closed on a build + # that errored, was cancelled, or (in a matrix) lost a single leg. + if: >- + !cancelled() && !failure() && + needs.prepare.outputs.skipped != 'true' + runs-on: ubuntu-latest + permissions: + contents: read + steps: + # Full history and tags: post_build.sh used to run in the build job, whose + # checkout has both, and a hook that inspects them must keep working. + - name: Checkout + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + fetch-tags: true + fetch-depth: 0 + persist-credentials: false + + - name: Install uv + uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0 + with: + version: "0.12.5" + python-version: "3.14.7" + + # One artifact per build job — a platform matrix uploads one per leg — + # merged into a single dist/. The `--` in the pattern is what keeps a + # package from collecting a sibling's artifacts when its name is a prefix + # of theirs (mypkg vs mypkg-base). + - name: Download the built distribution files + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + pattern: dist-${{ inputs.package }}--* + path: dist + merge-multiple: true + + - name: Flatten the downloaded artifacts + shell: bash + run: | + set -euo pipefail + # mkdir, because no matching artifact leaves nothing behind at all — + # verify-dist reports that far better than `find` reports a missing + # directory. + mkdir -p dist + # `uv publish dist/*` uploads files, not directories, so a build + # workflow that kept its files in a subdirectory is normalized here + # rather than failing the release over a layout detail. A name + # collision leaves the directory non-empty and fails the step. + find dist -mindepth 2 -type f -exec mv -n -t dist -- {} + + find dist -mindepth 1 -type d -delete + ls -l dist + + - name: Verify built artifact names and versions env: - VERSION: ${{ steps.prepare.outputs.version }} - run: uv run --no-config --locked --script scripts/release.py verify-dist + PACKAGE: ${{ inputs.package }} + VERSION: ${{ needs.prepare.outputs.version }} + shell: bash + run: uv run --frozen --package reflex-release reflex-release verify-dist - - name: Verify .pyi files in wheel - if: steps.prepare.outputs.skipped != 'true' && inputs.package == 'reflex' + - name: Repository-specific post-build checks + if: hashFiles('.github/scripts/publish/post_build.sh') != '' env: - BUILD_DIR: ${{ steps.prepare.outputs.build_dir }} - run: bash .github/scripts/publish/verify_pyi.sh + PACKAGE: ${{ inputs.package }} + VERSION: ${{ needs.prepare.outputs.version }} + BUILD_DIR: ${{ needs.prepare.outputs.build_dir }} + DIST_DIR: dist + shell: bash + run: bash .github/scripts/publish/post_build.sh - name: Extract release notes from changelog - if: steps.prepare.outputs.skipped != 'true' env: PACKAGE: ${{ inputs.package }} - VERSION: ${{ steps.prepare.outputs.version }} + VERSION: ${{ needs.prepare.outputs.version }} NOTES_PATH: release_notes.md - run: uv run --no-config --locked --script scripts/release.py extract-notes + shell: bash + run: uv run --frozen --package reflex-release reflex-release 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. + # The manifest covers exactly the files that go to PyPI. It lets the + # gated publish job re-verify (with coreutils only) that what it uploads + # is byte-identical to what was built and verified here, it is put in + # front of the approver (job summary), and it is attached to the GitHub + # release so the record of what a version contains outlives this run. - name: Write checksum manifest - if: steps.prepare.outputs.skipped != 'true' - run: sha256sum dist/* release_notes.md > SHA256SUMS + shell: bash + run: | + set -euo pipefail + # Bare filenames, covering exactly the files that go to PyPI: the + # manifest is attached to the GitHub release, so `sha256sum -c` has + # to work in a directory holding the downloaded artifacts. + (cd dist && sha256sum -- *) > SHA256SUMS + { + echo "### Artifacts awaiting approval" + echo + echo '```' + cat SHA256SUMS + echo '```' + } >> "$GITHUB_STEP_SUMMARY" - name: Upload artifacts for the gated publish - if: steps.prepare.outputs.skipped != 'true' uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: name: publish-${{ inputs.package }} @@ -194,47 +301,68 @@ jobs: # The human approval gate: this job targets the `pypi` environment, whose # required reviewers must approve before it starts. It holds the only OIDC # privilege and deliberately runs no repository code and resolves no script - # dependencies — it only uploads the artifact built above. + # dependencies — it only uploads the artifact collected above. publish: - needs: build - if: needs.build.outputs.skipped != 'true' + needs: [prepare, collect] + if: needs.prepare.outputs.skipped != 'true' runs-on: ubuntu-latest environment: name: pypi permissions: id-token: write - # Reading the environment's protection rules needs repository read - # access on GITHUB_TOKEN (contents) plus actions: read. + # 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 }} + ALLOW_SELF_REVIEW: "true" + shell: bash 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 + # 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 ! environment=$(gh api "repos/${GITHUB_REPOSITORY}/environments/pypi" 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 + rules=$(jq -c '[.protection_rules[]?.type]' <<<"$environment") 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" + # Reviewers alone are not a two-person rule: unless self-review is + # prevented, whoever triggered the release can approve their own + # upload. Whether that is acceptable is a policy choice, so it is + # configured (allow-self-review) rather than assumed. + if [[ "$ALLOW_SELF_REVIEW" == "true" ]]; then + echo "pypi environment protection rules: $rules" + echo "Self-review is permitted (allow-self-review); the environment's reviewer list is the control over who can approve this upload." + exit 0 + fi + # tostring, not `// "unknown"`: jq's alternative operator treats a + # literal false as absent, which would turn the case this check exists + # for into a warning. + self_review=$(jq -r '[.protection_rules[]? | select(.type == "required_reviewers") | .prevent_self_review] | first | tostring' <<<"$environment") + # Strict mode is opt-in, so anything short of a proven "true" fails: + # a warning nobody reads is not a two-person rule, and "the API did + # not say" is indistinguishable from "self-review is allowed". + if [[ "$self_review" != "true" ]]; then + echo "::error::This repository sets allow-self-review = false, but the pypi environment does not report that self-review is prevented (prevent_self_review: $self_review), so the person who triggered this release could approve their own upload. Enable 'Prevent self-review' on the pypi environment (Settings -> Environments -> pypi) and re-run. If your GitHub deployment cannot report that setting, set allow-self-review = true to make the reviewer list the control." + exit 1 + fi + echo "pypi environment protection rules: $rules (prevent_self_review: $self_review)" - 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 + version: "0.12.5" + python-version: "3.14.7" - name: Download built artifacts uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 @@ -242,43 +370,57 @@ jobs: name: publish-${{ inputs.package }} - name: Verify artifact integrity - run: sha256sum -c SHA256SUMS + shell: bash + run: cd dist && 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. + # --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 + shell: bash run: uv publish --check-url https://pypi.org/simple/ dist/* tag-and-release: - needs: [build, publish] + needs: [prepare, publish] runs-on: ubuntu-latest permissions: contents: write + actions: read steps: - name: Checkout uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: persist-credentials: false - - name: Download release notes + - name: Install uv + uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0 + with: + version: "0.12.5" + python-version: "3.14.7" + + # The same artifact the approval covered: the release notes and the + # checksum manifest of exactly what was uploaded. + - name: Download release notes and checksums uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 with: name: publish-${{ inputs.package }} - name: Push tag env: - TAG: ${{ needs.build.outputs.tag }} + TAG: ${{ needs.prepare.outputs.tag }} GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - run: bash .github/scripts/publish/push_tag.sh + shell: bash + run: uv run --frozen --package reflex-release reflex-release push-tag - 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 }} + TAG: ${{ needs.prepare.outputs.tag }} + PACKAGE: ${{ inputs.package }} + VERSION: ${{ needs.prepare.outputs.version }} + PRERELEASE: ${{ needs.prepare.outputs.prerelease }} + MARK_LATEST: ${{ needs.prepare.outputs.mark_latest }} NOTES_PATH: release_notes.md + CHECKSUMS_PATH: SHA256SUMS GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - run: bash .github/scripts/publish/create_release.sh + shell: bash + run: uv run --frozen --package reflex-release reflex-release create-release diff --git a/.github/workflows/release_from_changelog.yml b/.github/workflows/release_from_changelog.yml index 54305838062..4f930e4fdbf 100644 --- a/.github/workflows/release_from_changelog.yml +++ b/.github/workflows/release_from_changelog.yml @@ -1,24 +1,27 @@ +# Generated by reflex-release; do not edit by hand. +# Re-run `uv run --frozen --package reflex-release reflex-release sync` after changing [tool.reflex-release] in +# pyproject.toml. `uv run --frozen --package reflex-release reflex-release sync --check` fails when this file drifts. 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. +# to main or a release branch, this workflow compares the newest +# version heading in each package's CHANGELOG.md 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). +# 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. +# Lockstep packages that pin their siblings exactly publish strictly after +# every other package in the batch; detect fails closed if a 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 @@ -45,7 +48,8 @@ jobs: outputs: packages: ${{ steps.detect.outputs.packages }} any: ${{ steps.detect.outputs.any }} - reflex_version: ${{ steps.detect.outputs.reflex_version }} + last_packages: ${{ steps.detect.outputs.last_packages }} + any_last: ${{ steps.detect.outputs.any_last }} steps: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: @@ -53,10 +57,14 @@ jobs: fetch-depth: 0 persist-credentials: false - uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0 + with: + version: "0.12.5" + python-version: "3.14.7" - id: detect env: REF_NAME: ${{ github.ref_name }} - run: uv run --no-config --locked --script scripts/release.py detect + shell: bash + run: uv run --frozen --package reflex-release reflex-release detect publish: needs: detect @@ -65,9 +73,9 @@ jobs: 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. + # 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 @@ -80,18 +88,22 @@ jobs: 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: + # A package that pins its lockstep siblings exactly publishes only after + # every other package in the batch has fully published and tagged. If any + # sibling failed, it is held back — fail closed rather than ship an + # uninstallable pin. + publish-last: needs: [detect, publish] if: >- always() && needs.detect.result == 'success' && - needs.detect.outputs.reflex_version != '' && + needs.detect.outputs.any_last == 'true' && (needs.publish.result == 'success' || needs.publish.result == 'skipped') + strategy: + matrix: + include: ${{ fromJson(needs.detect.outputs.last_packages) }} + fail-fast: false concurrency: - group: publish-reflex + group: publish-${{ matrix.package }} cancel-in-progress: false permissions: contents: write @@ -99,13 +111,13 @@ jobs: actions: read uses: ./.github/workflows/publish.yml with: - package: reflex - version: ${{ needs.detect.outputs.reflex_version }} + package: ${{ matrix.package }} + version: ${{ matrix.version }} # One loud, canonical failure signal for partial releases: red whenever any - # leg of the batch failed or reflex was held back. + # leg of the batch failed or a package was held back. report: - needs: [detect, publish, publish-reflex] + needs: [detect, publish, publish-last] if: always() runs-on: ubuntu-latest permissions: {} @@ -114,15 +126,16 @@ jobs: env: DETECT: ${{ needs.detect.result }} PUBLISH: ${{ needs.publish.result }} - PUBLISH_REFLEX: ${{ needs.publish-reflex.result }} - REFLEX_VERSION: ${{ needs.detect.outputs.reflex_version }} + PUBLISH_LAST: ${{ needs.publish-last.result }} + ANY_LAST: ${{ needs.detect.outputs.any_last }} + shell: bash run: | set -euo pipefail - echo "detect: $DETECT, publish: $PUBLISH, publish-reflex: $PUBLISH_REFLEX" + echo "detect: $DETECT, publish: $PUBLISH, publish-last: $PUBLISH_LAST" 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 + for leg in "detect:$DETECT" "publish:$PUBLISH" "publish-last:$PUBLISH_LAST"; do case "${leg#*:}" in success | skipped) ;; *) @@ -133,10 +146,10 @@ jobs: 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." + echo "::error::Changelog detection did not complete (check for a 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)." + if [[ "$ANY_LAST" == "true" && "$PUBLISH_LAST" != "success" ]]; then + echo "::error::A lockstep package that publishes last 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 diff --git a/AGENTS.md b/AGENTS.md index eae113a8ffa..fe40510b295 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -20,7 +20,8 @@ uv run ruff check . # lint uv run ruff format . # format uv run pyright reflex tests # type check uv run python scripts/check_min_deps.py # validate each package's declared minimum dep versions (pyright in isolated min-version envs; *.dev pins resolve from the local workspace, all other deps from PyPI) -uv run python scripts/check_min_deps.py --check-dev-pins [pkg] # publish gate: fail if pkg (default: all) declares an unpublishable *.dev dependency pin +uv run python scripts/check_min_deps.py --check-dev-pins [pkg] # fail if pkg (default: all) declares an unpublishable *.dev dependency pin (the publish workflow runs the same gate via `reflex-release check-dev-pins`) +uv run reflex-release sync # regenerate the release workflows after editing [tool.reflex-release] or the reflex-release templates uv run python scripts/make_pyi.py # regenerate .pyi stubs uv run pre-commit run --all-files # all pre-commit hooks ``` diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 53dfce66d53..efab20e411c 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -79,10 +79,10 @@ Each PR that changes the source of a published package must add a news fragment **Create a fragment from the CLI:** ```bash -uv run towncrier create --config pyproject.toml --dir packages/reflex-components-lucide 1234.feature.md +uv run reflex-release create --package reflex-components-lucide 1234.feature.md ``` -Drop `--dir` for a fragment against the main `reflex` package. +Drop `--package` 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`). 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. @@ -115,8 +115,10 @@ a release branch — never by tagging manually. The pieces: `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. + - `reflex` and `reflex-base` are a lockstep pair and share one checkbox: + selecting it releases both at the same version. The checkboxes are + generated from the package list, so adding or removing a package means + re-running `uv run reflex-release sync`. 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 @@ -131,6 +133,23 @@ a release branch — never by tagging manually. The pieces: failed or rejected publish leaves no tag behind — fix the problem on top of the changelog bump and the next push retries automatically. +**The release workflows are generated.** `dispatch_release.yml`, +`release_from_changelog.yml`, `publish.yml`, `changelog.yml` and +`auto_release_internal.yml` are rendered by +[`reflex-release`](packages/reflex-release/README.md) — the copy bundled in this +repository, run straight from `uv.lock` — out of the `[tool.reflex-release]` +table in the repo-root `pyproject.toml`. Do not edit them by hand: change the +configuration (or the templates under +`packages/reflex-release/src/reflex_release/templates/`) and regenerate with + +```bash +uv run reflex-release sync +``` + +Because the workflows and the tool that renders them live in the same commit, +`reflex-release sync --check` — which every pull request runs — catches both a +workflow edited in place and a template change that was never applied. + **Where changelogs are published:** the docs site renders every `CHANGELOG.md` in the repo (repo root and `packages/*/`) under [reflex.dev/docs/changelog/](https://reflex.dev/docs/changelog/). The diff --git a/packages/reflex-release/README.md b/packages/reflex-release/README.md index 875b9c7487e..68b648ab01b 100644 --- a/packages/reflex-release/README.md +++ b/packages/reflex-release/README.md @@ -81,6 +81,14 @@ single-package repository usually needs no more than the first two keys. # from; bump it and re-run `sync` to upgrade. cli-command = "uvx reflex-release@0.1.0" +# The uv and Python the generated workflows install, written into every one of +# them verbatim so the release path is reproducible. Both default to a version +# this tool pins, so upgrading reflex-release moves them too — and `sync +# --check` reports that as drift until you regenerate. Set either one to keep +# your own cadence, or to "" to install whatever setup-uv defaults to. +uv-version = "0.12.5" +python-version = "3.14.7" + # Whether the release may be approved by the person who triggered it. True (the # default) keeps GitHub's own behavior: the environment's reviewer list decides # who can release, and one of them can carry a release through end to end. Set @@ -138,7 +146,7 @@ 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, +# checkboxes while they fit under GitHub's twenty-input workflow_dispatch limit, # free text beyond it. Default: "auto". dispatch-package-inputs = "auto" ``` @@ -457,6 +465,10 @@ job this design keeps free of everything but the upload. ### Supply chain +Every generated workflow installs uv and Python at the exact versions +`uv-version` and `python-version` name, so the toolchain the release path runs +on does not move on its own. + The workflows run `uvx reflex-release@`. A published PyPI version is immutable, so the pinned tool cannot change under you — but its dependencies (`packaging`, `towncrier`) resolve fresh on every run, and the tool @@ -495,9 +507,10 @@ Selecting nothing auto-selects: packages with pending news fragments, or — for Because the checkboxes are generated, **adding or removing a package changes `dispatch_release.yml`** — run `reflex-release sync` and commit it with the new -package. The pull-request drift check catches it if you forget. Past ten -packages (GitHub's `workflow_dispatch` input limit) the form falls back to a -comma-separated text field; see `dispatch-package-inputs`. +package. The pull-request drift check catches it if you forget. Past twenty +packages (GitHub's `workflow_dispatch` input limit, one of which the release +action takes) the form falls back to a comma-separated text field; see +`dispatch-package-inputs`. | Action | Result | | --- | --- | diff --git a/packages/reflex-release/news/+dispatch-checkbox-limit.misc.md b/packages/reflex-release/news/+dispatch-checkbox-limit.misc.md new file mode 100644 index 00000000000..d60e51cf59f --- /dev/null +++ b/packages/reflex-release/news/+dispatch-checkbox-limit.misc.md @@ -0,0 +1 @@ +Raise the package-checkbox limit on the Dispatch release form from ten to twenty, matching GitHub's actual `workflow_dispatch` input limit. Repositories with between nine and nineteen releasable packages (or lockstep groups) keep the checkboxes under `dispatch-package-inputs = "auto"` instead of falling back to the comma-separated field. diff --git a/packages/reflex-release/news/+pinned-build-backend.misc.md b/packages/reflex-release/news/+pinned-build-backend.misc.md new file mode 100644 index 00000000000..c60ec709430 --- /dev/null +++ b/packages/reflex-release/news/+pinned-build-backend.misc.md @@ -0,0 +1 @@ +Pin `hatchling` and `uv-dynamic-versioning` exactly in this package's `[build-system] requires`. Build requirements are resolved fresh rather than locked, so a repository that vendors the tool — running it from its own lockfile rather than PyPI — no longer has the backend that builds its release tooling move underneath it. diff --git a/packages/reflex-release/news/+pinned-toolchain.feature.md b/packages/reflex-release/news/+pinned-toolchain.feature.md new file mode 100644 index 00000000000..1dda66cd795 --- /dev/null +++ b/packages/reflex-release/news/+pinned-toolchain.feature.md @@ -0,0 +1 @@ +Add `uv-version` and `python-version` to `[tool.reflex-release]`: every generated workflow now installs uv and Python at those exact versions instead of whatever `setup-uv` resolves at run time. Both default to versions this tool pins, so upgrading reflex-release moves the release toolchain with it and `sync --check` reports that as drift until the workflows are regenerated — the same signal a template change gives. Set either key to keep your own cadence, or to `""` to leave that version to the setup action. diff --git a/packages/reflex-release/pyproject.toml b/packages/reflex-release/pyproject.toml index c977a752ffb..13174a63ab8 100644 --- a/packages/reflex-release/pyproject.toml +++ b/packages/reflex-release/pyproject.toml @@ -32,5 +32,9 @@ fallback-version = "0.0.0dev0" packages = ["src/reflex_release"] [build-system] -requires = ["hatchling", "uv-dynamic-versioning"] +# Exact pins: this package is built from the repository checkout on the release +# path (cli-command runs it out of uv.lock), and build requirements are resolved +# fresh rather than locked. Pinning them keeps the one tool that drives every +# publish from being built by a backend that moved under us. +requires = ["hatchling == 1.31.0", "uv-dynamic-versioning == 0.14.0"] build-backend = "hatchling.build" diff --git a/packages/reflex-release/src/reflex_release/config.py b/packages/reflex-release/src/reflex_release/config.py index a0eeb5d38e7..0644ed7b79e 100644 --- a/packages/reflex-release/src/reflex_release/config.py +++ b/packages/reflex-release/src/reflex_release/config.py @@ -27,6 +27,23 @@ #: The setting naming the workflow to dispatch after each published tag. POST_RELEASE_WORKFLOW_KEY = "post-release-workflow" +#: The uv release the generated workflows install. It is written verbatim into +#: every workflow, so bumping it here surfaces in each consumer repository as +#: workflow drift the next ``sync --check`` reports — which is the point: the +#: tool and the release path it generates move together. A repository that wants +#: its own cadence sets ``uv-version``. +DEFAULT_UV_VERSION = "0.12.5" + +#: The Python the generated workflows run uv with, pinned for the same reason. A +#: repository whose packages cannot build on it sets ``python-version``. +DEFAULT_PYTHON_VERSION = "3.14.7" + +#: What ``uv-version`` and ``python-version`` may contain. Both are interpolated +#: into a double-quoted YAML scalar in the generated workflows, so the allowed +#: characters are those of a version or a specifier and nothing that could end +#: the scalar or open a ``${{ }}`` expression. +_VERSION_PIN_RE = re.compile(r"[A-Za-z0-9<>=~!^][A-Za-z0-9._+*,<>=~!^-]*") + #: 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 @@ -42,8 +59,10 @@ "root-package", "root-source-dirs", "packages-dir", + "python-version", "package-source-subdirs", "release-timezone", + "uv-version", "main-branch", "prerelease-branch-prefix", "hotfix-branch-prefix", @@ -132,6 +151,10 @@ class Config: self-review, so every upload needs a second person. cli_command: How the scaffolded workflows invoke this tool. ``init`` writes it pinned to the version that generated them. + uv_version: The uv release the generated workflows install, pinned + verbatim; empty installs whatever setup-uv defaults to. + python_version: The Python those workflows run uv with, pinned the same + way; empty leaves the choice to uv. dispatch_package_inputs: How the Dispatch release workflow asks which packages to release — ``checkboxes``, a free-text ``text`` field, or ``auto`` (checkboxes while they fit under the GitHub input limit). @@ -172,6 +195,8 @@ class Config: root: Path allow_self_review: bool = True cli_command: str = "uvx reflex-release" + uv_version: str = DEFAULT_UV_VERSION + python_version: str = DEFAULT_PYTHON_VERSION dispatch_package_inputs: str = "auto" news_directory: str = "news" changelog_filename: str = "CHANGELOG.md" @@ -603,6 +628,27 @@ def _string( return value +def _version_pin(table: dict, key: str, default: str) -> str: + """Read a version pin destined for a quoted YAML scalar. + + Args: + table: The table to read from. + key: The setting name. + default: The value to use when the key is absent. + + Returns: + The configured pin, or ``""`` to leave that version unpinned. + """ + value = _string(table, key, default).strip() + if value and not _VERSION_PIN_RE.fullmatch(value): + fail( + f"[tool.{TOOL_TABLE}] {key} must be a version or specifier such as " + f'"1.2.3", ">=1.2" or "latest" (got {value!r}); leave it empty to ' + "install whatever the setup action defaults to" + ) + return value + + def _boolean(table: dict, key: str, default: bool) -> bool: """Read a boolean setting. @@ -820,6 +866,8 @@ def load_config(root: Path) -> Config: root=root, allow_self_review=_boolean(table, "allow-self-review", True), cli_command=_string(table, "cli-command", "uvx reflex-release"), + uv_version=_version_pin(table, "uv-version", DEFAULT_UV_VERSION), + python_version=_version_pin(table, "python-version", DEFAULT_PYTHON_VERSION), dispatch_package_inputs=_string(table, "dispatch-package-inputs", "auto"), news_directory=towncrier.get("directory") or "news", changelog_filename=towncrier.get("filename") or "CHANGELOG.md", diff --git a/packages/reflex-release/src/reflex_release/scaffold.py b/packages/reflex-release/src/reflex_release/scaffold.py index f18e47e5671..1a17a559f75 100644 --- a/packages/reflex-release/src/reflex_release/scaffold.py +++ b/packages/reflex-release/src/reflex_release/scaffold.py @@ -57,8 +57,9 @@ "publish.yml", ) -#: ``workflow_dispatch`` accepts at most ten inputs, one of which is the action. -MAX_DISPATCH_CHECKBOXES = 9 +#: ``workflow_dispatch`` accepts at most twenty inputs, one of which is the +#: action. +MAX_DISPATCH_CHECKBOXES = 19 #: Workflow scaffolded only for repositories that declare internal packages. INTERNAL_WORKFLOW = "auto_release_internal.yml" @@ -320,6 +321,32 @@ def _selects_package(packages: tuple[str, ...]) -> str: return f"contains(fromJson('{listing}'), inputs.package)" +def _uv_setup_block(config: Config) -> str: + """Render the ``with:`` block pinning the uv and Python the workflows use. + + Every generated workflow installs uv the same way, so the pins live in one + rendered block rather than in each template. They are written verbatim, so a + repository that has not overridden them sees a bumped default as workflow + drift the next ``sync --check`` reports. + + Args: + config: The repository configuration. + + Returns: + The block, indented under a ``uses: astral-sh/setup-uv`` step, or an + empty string when neither version is pinned. + """ + pins = [ + f' {key}: "{value}"' + for key, value in ( + ("version", config.uv_version), + ("python-version", config.python_version), + ) + if value + ] + return "\n".join([" with:", *pins]) if pins else "" + + def _custom_build_note(config: Config) -> str: """Render the header comment describing the custom build stage. @@ -476,6 +503,7 @@ def render(name: str, config: Config) -> str: substitutions = { "@@HEADER@@": header, "@@CLI@@": cli, + "@@UV_SETUP_WITH@@": _uv_setup_block(config), "@@MAIN_BRANCH@@": config.main_branch, "@@PRERELEASE_PREFIX@@": config.prerelease_branch_prefix, "@@HOTFIX_PREFIX@@": config.hotfix_branch_prefix, diff --git a/packages/reflex-release/src/reflex_release/templates/workflows/auto_release_internal.yml b/packages/reflex-release/src/reflex_release/templates/workflows/auto_release_internal.yml index 6f1bb47c90a..6aa85625870 100644 --- a/packages/reflex-release/src/reflex_release/templates/workflows/auto_release_internal.yml +++ b/packages/reflex-release/src/reflex_release/templates/workflows/auto_release_internal.yml @@ -39,6 +39,7 @@ jobs: fetch-depth: 0 persist-credentials: false - uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0 +@@UV_SETUP_WITH@@ - id: detect env: DISPATCH_PACKAGE: ${{ inputs.package }} diff --git a/packages/reflex-release/src/reflex_release/templates/workflows/changelog.yml b/packages/reflex-release/src/reflex_release/templates/workflows/changelog.yml index c6d2760688e..2c9fe3febf2 100644 --- a/packages/reflex-release/src/reflex_release/templates/workflows/changelog.yml +++ b/packages/reflex-release/src/reflex_release/templates/workflows/changelog.yml @@ -45,6 +45,7 @@ jobs: fetch-depth: 0 persist-credentials: false - uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0 +@@UV_SETUP_WITH@@ # The heading guard runs even for skip-changelog PRs: that label only # waives the news-fragment requirement, not the publish-trigger guard. # diff --git a/packages/reflex-release/src/reflex_release/templates/workflows/dispatch_release.yml b/packages/reflex-release/src/reflex_release/templates/workflows/dispatch_release.yml index 5c3b3214472..c68fd6c4d91 100644 --- a/packages/reflex-release/src/reflex_release/templates/workflows/dispatch_release.yml +++ b/packages/reflex-release/src/reflex_release/templates/workflows/dispatch_release.yml @@ -81,6 +81,7 @@ jobs: fetch-depth: 0 persist-credentials: false - uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0 +@@UV_SETUP_WITH@@ - name: Plan versions id: plan env: diff --git a/packages/reflex-release/src/reflex_release/templates/workflows/publish.yml b/packages/reflex-release/src/reflex_release/templates/workflows/publish.yml index 68cf855c312..38390e43ec1 100644 --- a/packages/reflex-release/src/reflex_release/templates/workflows/publish.yml +++ b/packages/reflex-release/src/reflex_release/templates/workflows/publish.yml @@ -101,6 +101,7 @@ jobs: - name: Install uv uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0 +@@UV_SETUP_WITH@@ - name: Validate against changelog, branch rules and existing tags id: prepare @@ -130,6 +131,7 @@ jobs: - name: Install uv uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0 +@@UV_SETUP_WITH@@ - name: Pin lockstep siblings to exact versions env: @@ -205,6 +207,7 @@ jobs: - name: Install uv uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0 +@@UV_SETUP_WITH@@ # One artifact per build job — a platform matrix uploads one per leg — # merged into a single dist/. The `--` in the pattern is what keeps a @@ -352,6 +355,7 @@ jobs: - name: Install uv uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0 +@@UV_SETUP_WITH@@ - name: Download built artifacts uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 @@ -383,6 +387,7 @@ jobs: - name: Install uv uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0 +@@UV_SETUP_WITH@@ # The same artifact the approval covered: the release notes and the # checksum manifest of exactly what was uploaded. diff --git a/packages/reflex-release/src/reflex_release/templates/workflows/release_from_changelog.yml b/packages/reflex-release/src/reflex_release/templates/workflows/release_from_changelog.yml index df523d80115..2d9b3f8bf9a 100644 --- a/packages/reflex-release/src/reflex_release/templates/workflows/release_from_changelog.yml +++ b/packages/reflex-release/src/reflex_release/templates/workflows/release_from_changelog.yml @@ -55,6 +55,7 @@ jobs: 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 }} diff --git a/pyproject.toml b/pyproject.toml index 2638de9c41b..0b99e6947db 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -327,6 +327,36 @@ exclude_also = [ [tool.coverage.html] directory = "coverage_html_report" +[tool.reflex-release] +# The release path runs the reflex-release bundled in this repository, resolved +# from uv.lock, rather than a version published to PyPI. That pins the whole +# pipeline to one commit: the workflows below are generated from the templates +# in packages/reflex-release, and the sync --check on every pull request +# compares them against the templates of that same commit. A workflow edited by +# hand and a template updated without regenerating are both drift, and both +# fail the same check. +cli-command = "uv run --frozen --package reflex-release reflex-release" +root-package = "reflex" +root-source-dirs = ["reflex"] +packages-dir = "packages" +package-source-subdirs = ["src"] +# Changelog dates and r/pre- branch names are stamped in the project's +# home timezone, so an evening release is not dated tomorrow by a UTC runner. +release-timezone = "America/Los_Angeles" +# No changelog, no news fragments: patch-released on every push to main that +# touches them. +internal-packages = ["reflex-components-internal", "reflex-site-shared"] +# Ships inside the docs site rather than to PyPI, so pull requests touching it +# need no news fragment. +changelog-exempt-packages = ["integrations-docs"] + +# reflex's metadata pins reflex-base exactly, so the two always release together +# at one version and reflex uploads only after reflex-base is on PyPI. +[[tool.reflex-release.lockstep]] +members = ["reflex", "reflex-base"] +publish-last = ["reflex"] +pin-exact = true + [tool.towncrier] # Shared monorepo config. package/name intentionally empty — each invocation # passes --dir --name --version to target a diff --git a/scripts/release.py b/scripts/release.py deleted file mode 100644 index 16a76a8eaf9..00000000000 --- a/scripts/release.py +++ /dev/null @@ -1,1329 +0,0 @@ -# /// 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