reflex-release 0.1.0a1__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,35 @@
1
+ **/.DS_Store
2
+ **/*.pyc
3
+ **/__pycache__/
4
+ .pytest_cache/
5
+ .ruff_cache/
6
+ **/.ruff_cache/
7
+ .mypy_cache/
8
+ assets/external/*
9
+ dist/*
10
+ examples/
11
+ .web
12
+ .states
13
+ .idea
14
+ .vscode
15
+ .coverage
16
+ .coverage.*
17
+ .venv
18
+ venv
19
+ requirements.txt
20
+ .pyi_generator_last_run
21
+ .pyi_generator_diff
22
+ reflex.db
23
+ .codspeed
24
+ .env
25
+ .env.*
26
+ node_modules
27
+ package-lock.json
28
+ *.pyi
29
+ .pre-commit-config.yaml
30
+ .claude/.worktrees
31
+ .claude/settings.local.json
32
+ CLAUDE.local.md
33
+
34
+ # Backups written by scripts/delete_automated_releases.sh
35
+ automated-releases-backup-*.json
@@ -0,0 +1,5 @@
1
+ ## v0.1.0a1 (2026-08-13)
2
+
3
+ ### Features
4
+
5
+ - New package: `reflex-release` extracts Reflex's changelog-driven release pipeline — the towncrier news-fragment workflow, the changelog-as-source-of-truth publish detection, and the human-gated PyPI upload — into a reusable tool other repositories can adopt with `uvx reflex-release init`. It scaffolds the GitHub Actions workflows into the consuming repository (required for PyPI trusted publishing, which validates the workflow's own repository) and keeps them in step with `reflex-release sync --check`. Repository shape lives in a `[tool.reflex-release]` table: single package or monorepo, branch policy, lockstep groups that release together at one version, and internal packages that release without a changelog. Every upload waits on an approval from the `pypi` environment's required reviewers, and the artifacts it covers are named by SHA-256 in the run summary and in a manifest attached to the GitHub release. See `packages/reflex-release/README.md` for setup, configuration and the security model. ([#6868](https://github.com/reflex-dev/reflex/issues/6868))
@@ -0,0 +1,605 @@
1
+ Metadata-Version: 2.4
2
+ Name: reflex-release
3
+ Version: 0.1.0a1
4
+ Summary: Changelog-driven release automation: towncrier news fragments, GitHub Actions workflows and human-gated PyPI publishing for single-package repos and monorepos.
5
+ Project-URL: Repository, https://github.com/reflex-dev/reflex
6
+ Author-email: Reflex <maintainers@reflex.dev>
7
+ Maintainer-email: Reflex <maintainers@reflex.dev>
8
+ License: Apache-2.0
9
+ Keywords: changelog,github-actions,pypi,release,towncrier
10
+ Requires-Python: >=3.10
11
+ Requires-Dist: packaging<27,>=24.2
12
+ Requires-Dist: tomli>=2.0.1; python_version < '3.11'
13
+ Requires-Dist: towncrier<26,>=24.8.0
14
+ Requires-Dist: tzdata>=2024.1; sys_platform == 'win32'
15
+ Description-Content-Type: text/markdown
16
+
17
+ # reflex-release
18
+
19
+ Changelog-driven release automation for Python repositories — one package or a
20
+ whole monorepo. It is the release pipeline Reflex uses, packaged so other
21
+ repositories can standardize on it.
22
+
23
+ **The `CHANGELOG.md` files are the source of truth for publishing.** A package
24
+ is published exactly when the newest version heading in its changelog has no
25
+ matching git tag. Tags are created only *after* a successful upload, so a failed
26
+ build or a rejected approval never leaves a tag or a GitHub release behind — you
27
+ fix the problem on top of the changelog bump and the next push retries.
28
+
29
+ Every upload is gated on a human: the job that holds the PyPI credential targets
30
+ a `pypi` GitHub environment whose required reviewers must approve it, and the
31
+ workflow refuses to run if those reviewers are not configured.
32
+
33
+ ```
34
+ news fragments ──▶ Dispatch release ──▶ CHANGELOG.md bump ──▶ push to main
35
+ (towncrier) (PR, or prerelease branch) │
36
+
37
+ GitHub release ◀── tag ◀── upload ◀── build + human approval
38
+ ```
39
+
40
+ ## Quick start
41
+
42
+ ```bash
43
+ cd your-repo
44
+ uvx reflex-release init
45
+ ```
46
+
47
+ That adds the `[tool.reflex-release]` and `[tool.towncrier]` tables to your
48
+ `pyproject.toml` (leaving either alone if it already exists), creates the
49
+ `news/` directories, writes four GitHub Actions workflows, and prints the
50
+ repository settings you still have to configure by hand.
51
+
52
+ Review the diff, work through the [GitHub setup](#github-setup) checklist,
53
+ give every package a [tag-derived version](#tag-derived-versions), and commit.
54
+
55
+ ## What you get
56
+
57
+ | File | Trigger | What it does |
58
+ | --- | --- | --- |
59
+ | `.github/workflows/dispatch_release.yml` | manual | Materializes news fragments into `CHANGELOG.md` at the next version. Final releases land through a pull request; prereleases go straight to an `r/pre-*` branch. |
60
+ | `.github/workflows/release_from_changelog.yml` | push to `main`, `r/pre-**`, `r/hotfix/**` | Publishes any changelog version that has no git tag. |
61
+ | `.github/workflows/publish.yml` | called by the two above, or manual | Builds one package, waits for `pypi` environment approval, uploads, then tags and creates the GitHub release. |
62
+ | `.github/workflows/changelog.yml` | pull request | Requires a news fragment for every package the PR touches, rejects hand-written version headings, and fails if the generated workflows have drifted. |
63
+ | `.github/workflows/auto_release_internal.yml` | push to `main` | Only for repos with `internal-packages`: patch-releases them whenever they change. |
64
+
65
+ ### Why the workflows are copied, not referenced
66
+
67
+ Three constraints rule out `uses: reflex-dev/reflex-release/.github/workflows/...`:
68
+
69
+ - **Trusted publishing.** PyPI validates the OIDC `job_workflow_ref` claim,
70
+ which names the repository owning the workflow file. A publish workflow
71
+ hosted elsewhere cannot be trusted by your project's publisher.
72
+ - **`release_from_changelog` calls `publish.yml`** through a `./` path, and
73
+ `./` resolves against the repository holding the calling file. Hosted here,
74
+ it would call *this* project's publish workflow instead of yours.
75
+ - **Triggers belong to the file that declares them.** `on: pull_request`,
76
+ `on: push` and the `workflow_dispatch` inputs must be files in your
77
+ repository regardless of where the job bodies live — so every workflow needs
78
+ a local file anyway.
79
+
80
+ That leaves the job bodies of two workflows as the only shareable part, which
81
+ is not worth a floating cross-repository dependency on the most privileged path
82
+ in your release.
83
+
84
+ Drift is handled instead of avoided: the files are generated, `reflex-release
85
+ sync` regenerates them, and the pull-request workflow runs `sync --check`, so a
86
+ stale workflow is a red PR rather than a surprise at release time. Upgrading is
87
+ a one-line `cli-command` bump plus `sync`.
88
+
89
+ ## Configuration
90
+
91
+ Everything lives in one table in the repo-root `pyproject.toml`. A
92
+ single-package repository usually needs no more than the first two keys.
93
+
94
+ ```toml
95
+ [tool.reflex-release]
96
+ # How the generated workflows invoke this tool. `init` pins the version it ran
97
+ # from; bump it and re-run `sync` to upgrade.
98
+ cli-command = "uvx reflex-release@0.1.0"
99
+
100
+ # Whether the release may be approved by the person who triggered it. True (the
101
+ # default) keeps GitHub's own behavior: the environment's reviewer list decides
102
+ # who can release, and one of them can carry a release through end to end. Set
103
+ # it to false to require a second person, in which case the publish job also
104
+ # asserts that the pypi environment has 'Prevent self-review' enabled.
105
+ allow-self-review = true
106
+
107
+ # The package built from the repository root. Omit it when the root is not a
108
+ # package (a pure monorepo of sub-packages).
109
+ root-package = "mypkg"
110
+
111
+ # Directories whose changes require a news fragment for the root package.
112
+ # Defaults to ["src"] for a src layout, else the directory named after the
113
+ # package (mypkg -> mypkg/, my-pkg -> my_pkg/), else nothing.
114
+ root-source-dirs = ["src"]
115
+
116
+ # Where sub-packages live; each directory with a pyproject.toml is a package.
117
+ # Ignored when the directory does not exist. Default: "packages".
118
+ packages-dir = "packages"
119
+
120
+ # Sub-package subdirectories that hold publishable source. A sub-package with
121
+ # none of them counts its whole directory (minus news/ and CHANGELOG.md).
122
+ package-source-subdirs = ["src"]
123
+
124
+ # Changelog dates and prerelease branch names are stamped in this timezone, so
125
+ # an evening release is not dated tomorrow by a UTC runner. Default: "UTC".
126
+ release-timezone = "America/Los_Angeles"
127
+
128
+ # Branch policy. Final versions publish only from main-branch or a hotfix
129
+ # branch; prereleases only from a prerelease or hotfix branch.
130
+ main-branch = "main"
131
+ prerelease-branch-prefix = "r/pre-"
132
+ hotfix-branch-prefix = "r/hotfix/"
133
+ release-branch-prefix = "release/"
134
+
135
+ # Version prefix of the git tags: the root package is tagged
136
+ # "<tag-prefix><version>" (v1.2.3), a sub-package
137
+ # "<package>-<tag-prefix><version>" (widget-core-v1.2.3).
138
+ tag-prefix = "v"
139
+
140
+ # The package whose final releases are marked "Latest" on GitHub.
141
+ # Default: the root package. Set to nothing to never mark one.
142
+ latest-release-package = "mypkg"
143
+
144
+ # Packages released by patch-bumping their newest tag on every push that
145
+ # touches them, with no changelog and no news fragments.
146
+ internal-packages = []
147
+
148
+ # Packages excluded from the pull-request news-fragment requirement.
149
+ changelog-exempt-packages = []
150
+
151
+ # How the Dispatch release form asks which packages to release: one checkbox
152
+ # per package ("checkboxes"), a comma-separated field ("text"), or "auto" —
153
+ # checkboxes while they fit under GitHub's ten-input workflow_dispatch limit,
154
+ # free text beyond it. Default: "auto".
155
+ dispatch-package-inputs = "auto"
156
+ ```
157
+
158
+ Package names are **directory names**: `mypkg` for the root package (whatever
159
+ you called it) and the directory name under `packages/` for the rest.
160
+
161
+ ### Lockstep packages
162
+
163
+ Packages that must always release together at the same version — typically
164
+ because one pins the other exactly — form a lockstep group:
165
+
166
+ ```toml
167
+ [[tool.reflex-release.lockstep]]
168
+ members = ["mypkg", "mypkg-base"]
169
+ # Members that publish only after every other member is uploaded and tagged.
170
+ publish-last = ["mypkg"]
171
+ # Rewrite each publish-last member's requirement on its siblings to
172
+ # "== <version>" before building.
173
+ pin-exact = true
174
+ ```
175
+
176
+ This gives you, for free:
177
+
178
+ - selecting one member in *Dispatch release* selects the whole group, and all
179
+ members are planned at one version (the highest baseline among them);
180
+ - `release_from_changelog` publishes `publish-last` members only after the rest
181
+ of the batch succeeded — never shipping a wheel whose exact pin does not
182
+ exist on PyPI yet;
183
+ - a member with nothing to report still gets its section, holding towncrier's
184
+ "No significant changes." placeholder — nobody hand-writes a changelog entry
185
+ just to satisfy the invariant, and a member does not even need a `news/`
186
+ directory;
187
+ - detection **fails closed** if one member's changelog is bumped without the
188
+ other's (re-dispatching a release fixes it, materializing the whole group).
189
+
190
+ `pin-exact` rewrites the requirement in the publishing package's
191
+ `pyproject.toml` at build time only; it is never committed.
192
+
193
+ ## Adding towncrier
194
+
195
+ `init` writes this for you if `[tool.towncrier]` is absent. If you configure it
196
+ by hand, the important part is that `package`/`name` stay empty: one shared
197
+ configuration serves every package, because each invocation passes
198
+ `--dir <package path>` to select one package's `news/` directory
199
+ ([towncrier monorepo docs](https://towncrier.readthedocs.io/en/stable/monorepo.html)).
200
+
201
+ ```toml
202
+ [tool.towncrier]
203
+ package = ""
204
+ name = ""
205
+ directory = "news"
206
+ filename = "CHANGELOG.md"
207
+ title_format = "## {version} ({project_date})"
208
+ issue_format = "[#{issue}](https://github.com/OWNER/REPO/issues/{issue})"
209
+ start_string = "<!-- towncrier release notes start -->\n"
210
+
211
+ [[tool.towncrier.type]]
212
+ directory = "breaking"
213
+ name = "Breaking Changes"
214
+ showcontent = true
215
+
216
+ # ... deprecation, feature, bugfix, performance, docs, misc
217
+ ```
218
+
219
+ Version headings are written as `## v1.2.3 (2026-01-01)`. A custom
220
+ `title_format` is honored, including when prerelease sections are collapsed —
221
+ but the version must lead the heading, because the same parser reads headings
222
+ back to decide what to publish. `reflex-release sync` fails on a format it
223
+ could not parse back rather than letting releases go undetected.
224
+
225
+ Then create one `news/` directory per package:
226
+
227
+ ```
228
+ news/ # fragments for the root package
229
+ packages/widget-core/news/ # fragments for packages/widget-core
230
+ ```
231
+
232
+ A fragment is a markdown file named `<pr-number>.<type>.md` holding one or two
233
+ sentences written for someone reading release notes:
234
+
235
+ ```bash
236
+ uvx reflex-release create 1234.feature.md # root package
237
+ uvx reflex-release create --package widget-core 1234.bugfix.md # sub-package
238
+ ```
239
+
240
+ Before you know the PR number, use an orphan fragment (`+something.feature.md`)
241
+ and rename it later. CI requires a fragment for every package whose source the
242
+ PR touches; the `skip-changelog` label waives that for changes that genuinely
243
+ are not user-facing.
244
+
245
+ ## Adding sub-packages
246
+
247
+ If the repository has no `packages/` directory, everything above still works —
248
+ `packages-dir` is ignored when the directory is missing and the root package is
249
+ the only one. To grow into a monorepo:
250
+
251
+ 1. Create `packages/<name>/pyproject.toml` with its own `[project]` table.
252
+ `<name>` is the package's identifier everywhere in this tool.
253
+ 2. Add `packages/<name>/news/`.
254
+ 3. Give it a [tag-derived version](#tag-derived-versions) with
255
+ `pattern-prefix = "<name>-"`.
256
+ 4. If it is a uv workspace member, add it under `[tool.uv.workspace] members`
257
+ and `[tool.uv.sources]` as usual.
258
+
259
+ Nothing else is generated per package: the *Dispatch release* workflow takes a
260
+ free-form package list and auto-selects packages with pending fragments, so
261
+ adding a package needs no workflow change.
262
+
263
+ ## Tag-derived versions
264
+
265
+ The publish workflow tags the checkout locally and then builds, so each
266
+ package's build backend must derive its version from git tags. With
267
+ [uv-dynamic-versioning](https://github.com/ninoseki/uv-dynamic-versioning):
268
+
269
+ ```toml
270
+ # repo root — tags are v1.2.3
271
+ [build-system]
272
+ requires = ["hatchling", "uv-dynamic-versioning"]
273
+ build-backend = "hatchling.build"
274
+
275
+ [tool.hatch.version]
276
+ source = "uv-dynamic-versioning"
277
+
278
+ [tool.uv-dynamic-versioning]
279
+ fallback-version = "0.0.0dev0"
280
+ ```
281
+
282
+ ```toml
283
+ # packages/widget-core — tags are widget-core-v1.2.3
284
+ [tool.uv-dynamic-versioning]
285
+ pattern-prefix = "widget-core-"
286
+ fallback-version = "0.0.0dev0"
287
+ ```
288
+
289
+ Any backend works as long as the tag prefixes match; `hatch-vcs` and
290
+ `setuptools-scm` need their `tag_regex`/`git_describe_command` pointed at the
291
+ same prefixes. The `verify-dist` step fails the build when the produced
292
+ artifacts do not carry the expected distribution name and version, so a
293
+ misconfigured prefix is caught before anything is uploaded rather than shipping
294
+ `0.0.0dev0` to PyPI.
295
+
296
+ If your repository already tags releases some other way, set `tag-prefix` to
297
+ match — it applies to both the root package (`<tag-prefix>1.2.3`) and
298
+ sub-packages (`<name>-<tag-prefix>1.2.3`), so `tag-prefix = ""` gives you bare
299
+ `1.2.3` and `widget-core-1.2.3` tags.
300
+
301
+ ## GitHub setup
302
+
303
+ Once per repository. **Items 1, 2 and 5 are what make the pipeline safe** — the
304
+ rest is ergonomics. See [Security model](#security-model) for why.
305
+
306
+ 1. **`pypi` environment** (Settings → Environments): create it and add
307
+ **required reviewers**. Every upload — alphas and internal packages included
308
+ — waits for one of them to approve it, and `publish.yml` fails closed if it
309
+ starts without reviewers configured. **That list is who can release**, so
310
+ keep it to people you would trust to publish unilaterally; by default one of
311
+ them can trigger and approve the same release (see
312
+ [`allow-self-review`](#configuration)). Optionally restrict deployment
313
+ branches to `main`, `r/pre-*` and `r/hotfix/*`.
314
+ 2. **PyPI trusted publishing** for each distribution: owner + repository,
315
+ workflow `publish.yml`, **environment `pypi`**. No API token is stored
316
+ anywhere. Naming the environment is not optional bookkeeping — it is what
317
+ binds the upload credential to the reviewer-gated job. Leave it blank and any
318
+ job in `publish.yml` can mint an upload token, approval or not.
319
+ 3. **Actions settings** → General → enable *Allow GitHub Actions to create and
320
+ approve pull requests*, so release actions can open their PR.
321
+ 4. **Labels**: create `skip-changelog` and `changelog-version-edit`.
322
+ 5. **Branch and tag rulesets**: require review on `main`, and **restrict who may
323
+ create or push `r/pre-**`, `r/hotfix/**` and `release/**`** to maintainers
324
+ plus the `github-actions[bot]` app. Those branches publish; a repository that
325
+ skips this lets anyone with write access publish from a branch they create,
326
+ without review. Also protect the tags — `<tag-prefix>*` and
327
+ `<package>-<tag-prefix>*`, so `v*` and `<package>-v*` by default — from
328
+ deletion and force-pushes: a tag is the record that a version was published.
329
+ 6. **CODEOWNERS on `.github/workflows/` and `pyproject.toml`**, so changes to the
330
+ release path and to `cli-command` need a specific reviewer.
331
+
332
+ ## Security model
333
+
334
+ The pipeline's guarantee is: **nothing reaches PyPI without a human approving a
335
+ specific, already-built artifact.** Everything else exists to make that approval
336
+ meaningful. What follows is the reasoning, and the ways an adopting repository
337
+ can undermine it.
338
+
339
+ ### Trust boundaries
340
+
341
+ | Stage | Privileges | Runs |
342
+ | --- | --- | --- |
343
+ | `build` | `contents: read`. No OIDC, no secrets. | Your repository's code: the build backend, its hooks, `post_build.sh`. |
344
+ | `publish` | `id-token: write` — the only job that can mint a PyPI token. | Nothing from your repository. Downloads the artifact, checks it, runs `uv publish`. |
345
+ | `tag-and-release` | `contents: write`. | `git` and `gh`, after a successful upload. |
346
+ | `materialize` (dispatch) | `contents`/`pull-requests`/`actions: write`. | towncrier and `git`/`gh`; writes changelogs, opens the PR. |
347
+
348
+ The important split is the first two rows: **arbitrary repository code executes
349
+ only where there is nothing to steal**, and the job holding the credential runs
350
+ no repository code at all. A malicious build backend or `post_build.sh` can
351
+ corrupt the artifact — a reviewer approving it is the control — but it cannot
352
+ reach the token.
353
+
354
+ Every checkout uses `persist-credentials: false`, no `${{ }}` expression is
355
+ interpolated into a shell script (inputs travel through `env:`), and no workflow
356
+ uses `pull_request_target`, so nothing runs privileged against fork code.
357
+
358
+ ### Who can approve, and self-review
359
+
360
+ The `pypi` environment's reviewer list is the set of people who can release.
361
+ By default (`allow-self-review = true`) GitHub lets one of them approve a
362
+ release they triggered themselves, so a single reviewer can carry a release end
363
+ to end. That is the right posture when the reviewer list is already a small,
364
+ trusted group — it is the same trust you place in anyone who can merge to the
365
+ main branch — and it keeps a routine release from needing a second person on
366
+ call.
367
+
368
+ What you keep either way: the upload cannot happen without an explicit,
369
+ attributed, logged approval by someone on that list; the artifact is built and
370
+ inspectable before the approval; the changelog, branch rules and tag ordering
371
+ are unchanged.
372
+
373
+ What you give up: a compromised or malicious account *in the reviewer list* can
374
+ publish without anyone else involved. If that is not acceptable — a
375
+ widely-scoped reviewer list, or a package where a single bad release is
376
+ expensive — set `allow-self-review = false` and enable **Prevent self-review**
377
+ on the environment. The publish job then checks the environment on every run and
378
+ fails unless it can prove the setting is on. "The API did not report it" (older
379
+ GitHub Enterprise Server, or an API change) is indistinguishable from
380
+ "self-review is allowed", so it fails there too: strict mode is opt-in, and a
381
+ warning nobody reads is not a two-person rule. A deployment that cannot report
382
+ the setting has to set `allow-self-review = true` and rely on the reviewer list.
383
+
384
+ Either way, the reviewer list is the control worth auditing.
385
+
386
+ ### What the approval actually covers
387
+
388
+ The reviewer approves the `publish` job of a specific run. At that point the
389
+ version, the changelog section and the built artifact already exist and are
390
+ visible in the run. **Check the version and the package in the run name**, and
391
+ that the run was triggered by a merge you recognize. The build job's summary
392
+ lists the SHA-256 of every file that will be uploaded, so what you are approving
393
+ is named down to the byte before you approve it.
394
+
395
+ After the build and before the approval, `verify-dist` checks each file's core
396
+ metadata against the release: not just the version — which lockstep siblings
397
+ share — but the **distribution name**, since every package in a repository
398
+ publishes through the same trusted-publishing identity and nothing else would
399
+ stop a misconfigured build from uploading one package under another's approval.
400
+
401
+ The `SHA256SUMS` manifest travels *inside* the same artifact, so it proves the
402
+ upload matches the build — it is an integrity check against truncation and
403
+ partial downloads, **not** a defense against a compromised build job, which
404
+ could write both the files and the manifest. The defense there is that the
405
+ artifact can only be written by the build job of the same run, and that the run
406
+ is triggered by a branch your ruleset controls.
407
+
408
+ The manifest is attached to the GitHub release as well, so the record of what a
409
+ version contains survives the workflow artifact's retention window. It lists the
410
+ distribution files by bare filename — exactly the files that went to PyPI — so
411
+ `sha256sum -c SHA256SUMS` works in a directory holding the downloaded wheel and
412
+ sdist, years later.
413
+
414
+ For cryptographic provenance rather than a self-attested manifest — a PyPI
415
+ [attestation](https://docs.pypi.org/attestations/) tying the artifact to the
416
+ workflow that built it — publish with `pypa/gh-action-pypi-publish` in place of
417
+ `uv publish`. That is a deliberate omission here, not an oversight: it puts a
418
+ third-party action inside the one job holding the OIDC credential, which is the
419
+ job this design keeps free of everything but the upload.
420
+
421
+ ### Ways to weaken it
422
+
423
+ - **No ruleset on the publishing branches.** `r/pre-**` and `r/hotfix/**`
424
+ publish by design, and `r/hotfix/**` publishes *final* versions without a pull
425
+ request. Without a ruleset, "publishing requires review on `main`" is not
426
+ true: anyone with write access can create a hotfix branch and request a
427
+ release. This is the single most common way to deploy this pipeline unsafely.
428
+ - **Trusted publisher without the environment.** Covered above; it turns the
429
+ approval into an advisory step.
430
+ - **`internal-packages`.** Those release on every push to the main branch with
431
+ no changelog and no fragment — merge access is release access (still behind
432
+ the `pypi` approval). Use it only for packages where that is acceptable.
433
+ - **An unpinned `cli-command`.** `init` pins the version it ran from; `--pin
434
+ none` leaves the release path resolving whatever is newest at run time.
435
+ - **Weakening `publish.yml` in a pull request.** The generated workflows are
436
+ ordinary files: a merged change can remove the environment or the assertions.
437
+ `sync --check` catches *drift*, but a change that edits the workflow and
438
+ `cli-command` together is self-consistent and passes. CODEOWNERS is the
439
+ control; review those diffs as release-critical.
440
+
441
+ ### Supply chain
442
+
443
+ The workflows run `uvx reflex-release@<pinned version>`. A published PyPI
444
+ version is immutable, so the pinned tool cannot change under you — but its
445
+ dependencies (`packaging`, `towncrier`) resolve fresh on every run, and the tool
446
+ runs in jobs holding `contents: write`. If you want the release path fully
447
+ locked, vendor the tool instead of resolving it:
448
+
449
+ ```toml
450
+ # pyproject.toml — a dev dependency, resolved by your lockfile
451
+ [tool.reflex-release]
452
+ cli-command = "uv run --frozen reflex-release"
453
+ ```
454
+
455
+ Note that the pull-request checks (`check-headings`, `changelog-check`,
456
+ `sync --check`) execute the tool named by the *pull request's own*
457
+ configuration, in a read-only job with no secrets. That is the same exposure as
458
+ running a test suite on a contributed branch, but it is why `cli-command` is a
459
+ review-sensitive field.
460
+
461
+ ### Assumptions
462
+
463
+ - **GitHub-hosted runners.** The isolation of the unprivileged build job is the
464
+ runner's. On self-hosted runners, "no secrets in the build job" only holds if
465
+ the runner itself holds none.
466
+ - **`gh` and `jq` are on the runner** (they are on GitHub-hosted images).
467
+ - **Never store a PyPI API token.** If you must have one, put it in the `pypi`
468
+ environment's secrets, never in repository secrets, where the build job could
469
+ read it.
470
+
471
+ ## Cutting a release
472
+
473
+ Run **Dispatch release** from the Actions tab. Each package gets its own
474
+ checkbox, generated from your configuration; a lockstep group gets a single
475
+ checkbox covering all its members, since they only ever release together.
476
+ Selecting nothing auto-selects: packages with pending news fragments, or — for
477
+ `release-from-prerelease` — packages whose changelog is topped by an alpha.
478
+
479
+ Because the checkboxes are generated, **adding or removing a package changes
480
+ `dispatch_release.yml`** — run `reflex-release sync` and commit it with the new
481
+ package. The pull-request drift check catches it if you forget. Past ten
482
+ packages (GitHub's `workflow_dispatch` input limit) the form falls back to a
483
+ comma-separated text field; see `dispatch-package-inputs`.
484
+
485
+ | Action | Result |
486
+ | --- | --- |
487
+ | `new-prerelease-patch` / `-minor` / `-major` | Starts an alpha train: `1.2.4a1`, `1.3.0a1`, `2.0.0a1`. Pushed to `r/pre-<date>`; builds immediately, uploads after approval. |
488
+ | `continued-prerelease` | Next alpha (`a2`, `a3`, …). Must be dispatched **on** the train's `r/pre-*` branch. |
489
+ | `release-from-prerelease` | Turns the train into its final version and collapses every alpha section into one — alpha headings never ship in a final changelog. Opens a PR. |
490
+ | `release-patch` / `-minor` / `-major` | Final version straight from `main`. Opens a PR. |
491
+ | `release-post` | `1.2.3.post1`, for packaging-only fixes. Opens a PR. |
492
+
493
+ Release actions open a pull request; **merging it is what publishes.** The push
494
+ to `main` triggers `release_from_changelog`, which builds every untagged
495
+ changelog version and waits for the `pypi` approval before uploading. Only then
496
+ are the tag and GitHub release created — titled with the tag (`v1.2.3`) for the
497
+ root package, and `<package>@<version>` for a sub-package.
498
+
499
+ Every *Dispatch release* run links what it produced in its job summary and as a
500
+ run annotation: the pull request it opened, or the prerelease branch it pushed
501
+ and that branch's `release_from_changelog` runs.
502
+
503
+ To pull new work into a running prerelease train, merge `main` into the
504
+ `r/pre-*` branch and dispatch `continued-prerelease` on it.
505
+
506
+ Hotfixes: branch `r/hotfix/1.2` from the tag, dispatch on that branch, and both
507
+ alphas and final versions publish directly from it. A hotfix of an older line is
508
+ not marked "Latest" on GitHub.
509
+
510
+ ## Internal packages
511
+
512
+ Packages listed in `internal-packages` skip the changelog entirely: every push
513
+ to `main` that touches them patch-bumps the newest tag and publishes (still
514
+ behind the `pypi` approval) — merge access to those paths is release access, so
515
+ weigh it against [Ways to weaken it](#ways-to-weaken-it). They need no `news/` directory and are excluded
516
+ from the fragment check. Adding or removing one changes
517
+ `auto_release_internal.yml`, so re-run `reflex-release sync`.
518
+
519
+ ## Post-build hook
520
+
521
+ Create `.github/scripts/publish/post_build.sh` for repository-specific artifact
522
+ checks. It runs in the unprivileged build job — after a successful build, with
523
+ `PACKAGE`, `VERSION` and `BUILD_DIR` in the environment, no secrets and no OIDC
524
+ — and a non-zero exit fails the release before anything is uploaded:
525
+
526
+ ```bash
527
+ #!/usr/bin/env bash
528
+ set -euo pipefail
529
+ [[ "$PACKAGE" == "mypkg" ]] || exit 0
530
+ unzip -l "$BUILD_DIR"/dist/*.whl | grep -q '\.pyi$' || {
531
+ echo "Error: no .pyi files in the wheel"; exit 1
532
+ }
533
+ ```
534
+
535
+ ## Keeping the workflows current
536
+
537
+ Bump `cli-command` in `pyproject.toml`, run `reflex-release sync`, commit the
538
+ result. The generated `changelog.yml` already runs `sync --check` on every pull
539
+ request, so a workflow that no longer matches your configuration — or a package
540
+ added without re-syncing — fails CI there rather than at release time.
541
+
542
+ `sync` refuses to overwrite a workflow file it did not generate unless you pass
543
+ `--force`, so a hand-written `publish.yml` is never clobbered silently.
544
+
545
+ ## Commands
546
+
547
+ Workflow steps pass their inputs as environment variables; every option also has
548
+ a flag for running the same command by hand.
549
+
550
+ | Command | Purpose |
551
+ | --- | --- |
552
+ | `init` | Configure the repository and write the workflows. |
553
+ | `sync [--check]` | Regenerate the workflows, or fail on drift. |
554
+ | `create [--package P] NAME` | Create a news fragment. |
555
+ | `packages` | List releasable packages. |
556
+ | `plan` | Compute the next version of each selected package. |
557
+ | `materialize` | Run towncrier and (for `release-from-prerelease`) collapse alphas. |
558
+ | `open-release-pr` / `push-prerelease` | Commit the changelogs and deliver them. |
559
+ | `detect` | List packages whose newest changelog version has no tag. |
560
+ | `prepare-publish` | Validate a package/version and emit build metadata. |
561
+ | `pin-lockstep` | Pin lockstep siblings exactly before building. |
562
+ | `verify-dist` | Check the built artifacts are this package at the target version. |
563
+ | `check-dev-pins` | Reject `*.dev` dependency pins in published metadata. |
564
+ | `extract-notes` | Write a version's changelog section for the release body. |
565
+ | `push-tag` / `create-release` | Tag and publish the GitHub release. |
566
+ | `check-headings` | Reject hand-written changelog version headings (PR CI). |
567
+ | `changelog-check` | Require news fragments for changed packages (PR CI). |
568
+ | `detect-internal` | List internal packages touched by a push. |
569
+
570
+ ## Adopting it in a repository that already has releases
571
+
572
+ - **An existing hand-written `CHANGELOG.md`**: keep it. Only the *newest*
573
+ version heading matters, and existing versions are already tagged, so nothing
574
+ is detected as due. Headings must be parseable as `## v1.2.3 (date)` or
575
+ `## 1.2.3`; anything else is ignored, which is safe but means those versions
576
+ cannot be re-released.
577
+ - **Existing tags**: they are the fallback baseline for packages without a
578
+ changelog, and they are what makes an already-published version a no-op. Make
579
+ sure they match `tag-prefix` — `<tag-prefix>1.2.3` for the root package and
580
+ `<package>-<tag-prefix>1.2.3` for a sub-package.
581
+ - **Publishing with an API token today**: switch to trusted publishing before
582
+ the first run. `publish.yml` requests no secret other than `GITHUB_TOKEN`.
583
+ - **First release of a brand-new package**: it needs no `CHANGELOG.md` up
584
+ front — the first *Dispatch release* creates one. With no tags and no
585
+ changelog, `release-minor` produces `0.1.0`. Publishing it still needs an
586
+ explicit version: only `internal-packages` may publish without a changelog.
587
+
588
+ ## Why the pipeline is shaped this way
589
+
590
+ - **Changelog as the trigger.** The artifact humans review (the changelog bump)
591
+ is the same artifact that authorizes the release, so a version cannot ship
592
+ without its release notes, and release notes cannot be written for a version
593
+ that never ships.
594
+ - **Tags after upload.** A tag means "this is on PyPI". Retrying is a normal
595
+ push, never a tag deletion.
596
+ - **Approval holds the only credential.** The gated job runs no repository code
597
+ and resolves no dependencies — it verifies a checksum manifest and uploads the
598
+ artifact that was built and validated before the approval.
599
+ - **Detection fails closed.** A broken lockstep pair, a version the branch may
600
+ not publish, or a `*.dev` pin stops the batch rather than shipping something
601
+ uninstallable.
602
+
603
+ ## License
604
+
605
+ Apache-2.0.