Releasing Reeflex¶
Reeflex is a monorepo that ships to five channels from one tagged commit. This
document describes the one-gesture release flow driven by
.github/workflows/release.yml, the
one-time registry setup a human must do, and the manual fallback (the
process used for v0.1.6, before this workflow existed).
1. The one gesture¶
A release is a single act:
# from a clean, green `main` at the commit you want to release
git tag -s v0.1.7 -m "Reeflex v0.1.7"
git push origin v0.1.7
Pushing a v* tag triggers release.yml, which fans out to:
| Channel | Package | Auth |
|---|---|---|
| GitHub Release | all artifacts + SHA256SUMS attached to the tag |
GITHUB_TOKEN |
| PyPI | reeflex-claude, reeflex-holds, reeflex-mcp and reeflex-litellm — each only if its own version moved (see the note below) |
OIDC Trusted Publishing |
| npm | n8n-nodes-reeflex — no longer released from this repo, it has its own (Reeflex-io/n8n-nodes-reeflex) with its own publish workflow |
n/a here |
| GHCR | ghcr.io/reeflex-io/reeflex-core only if core changed — tagged <tag> and latest. This image is the release artefact for core: release.yml builds no sdist or wheel for it, and reeflex-core/ carries no pyproject.toml to build one from |
GITHUB_TOKEN |
| WP.org SVN | reeflex-gate (trunk + tag + assets) — inert until the slot exists (§2.5) |
WPORG_SVN_USERNAME / WPORG_SVN_PASSWORD |
| Job summary | a channel checklist (version + URL + ✅/⏭️/❌ per channel) | — |
Two jobs in that workflow are not channels and are the ones to read when you want to know whether a release works rather than whether it ran:
| Job | What its green means |
|---|---|
verify-dist |
the wheels this run built install into clean venvs and their real subcommands behave — asserted before anything is published, so a broken artefact never reaches PyPI. github-release, pypi and ghcr all need it. |
verify-published |
pip install of the versions this tag names, from the index, then invoke: reeflex-litellm tenancy against the shipped example map and against a default catch-all map that must be refused, reeflex-claude connect --dry-run plus a rfx_gate_ token that must be refused before any request. |
Both run scripts/verify_release_artifacts.py, which takes a --python and
runs by hand on a devbox against any venv. Every assertion in it is paired with
a control, because "the command printed something" also passes on a build whose
validator was deleted.
Publishing a GitHub Release through the web UI also works: the UI creates
the tag, and the tag push is what triggers release.yml. The same "release
published" event also triggers .github/workflows/wporg-deploy.yml (§2.5) —
one gesture, both workflows. (If a release object already exists for the tag,
release.yml just attaches assets to it and leaves the body untouched.)
Manual re-run / re-release: use the workflow_dispatch trigger (Actions →
Release → Run workflow). It requires a tag input (an existing tag, e.g.
v0.1.7) and offers a push_core boolean (see §4). Re-runs are safe: PyPI uses
skip-existing, npm skips an already-published version, and GHCR just re-pushes
the same tag.
GATE. Every publish is a public, largely irreversible act (PyPI/npm do not allow re-uploading a version). Per the project gates, pushing a release tag requires a human GO. The workflow automates the mechanics, not the decision.
A TAG REPUBLISHES NOTHING (RFX-246).
skip-existingcuts both ways: it is what makes a re-run safe, and it is also why a package whoseversiondid not move is skipped, while thepypijob still reports success. OnRelease v0.2.0that job — then named "PyPI publish (reeflex-claude + reeflex-holds + reeflex-mcp)" — was green having uploaded exactly one of the three, becausereeflex-claude0.1.7 andreeflex-mcp0.1.3 deliberately did not move. Correct behaviour, false claim; the name and the run summary are fixed, and the bump below is the step that decides what ships. To check after the fact, grep thepypijob log forUploadingversusSkipping … already exist— a green tick is not the evidence.
Pre-flight (before you tag)¶
- CI is green on the commit you are tagging.
- Versions are bumped in each package that changed. A package not bumped here does not ship, whatever the tag says:
reeflex-claude/pyproject.toml→versionreeflex-holds/pyproject.toml→versionreeflex-mcp/pyproject.toml→versionreeflex-litellm/pyproject.toml→versionreeflex-wordpress/reeflex-gate.php→ theVersion:header- (not
n8n-nodes-reeflex/package.json— that package moved to its own repo and is not built or published byrelease.ymlany more.) These are independent — the release tag (v0.1.6) is the umbrella, not each package's version (e.g. v0.1.6 shippedreeflex-claude 0.1.6,reeflex-holds 0.1.0,n8n-nodes-reeflex 0.1.0,reeflex-gate 0.1.5).reeflex-corehas no version file to bump: its artefact is the GHCR image, which is tagged from the release tag itself and rebuilt only whenreeflex-core/or theDockerfilechanged. CHANGELOG.mdupdated.- A local copy of the built artifacts lands in
reeflex/releases/<tag>/(local = source of truth, GitHub = publication). The workflow builds its own copies; keep the local ones for the record.
2. One-time registry setup (a human, once)¶
The workflow is tokenless by design for PyPI and prefers tokenless for npm. This requires configuring the registries once.
2.1 PyPI — Trusted Publishers (OIDC) for all FOUR projects¶
For each of reeflex-claude, reeflex-holds, reeflex-mcp and
reeflex-litellm, add a GitHub Actions Trusted Publisher (PyPI → project →
Settings → Publishing → Add a new publisher):
| Field | Value |
|---|---|
| Owner | Reeflex-io |
| Repository | reeflex |
| Workflow name | release.yml |
| Environment | (leave blank, unless you enable the pypi environment — see below) |
reeflex-claude,reeflex-holdsandreeflex-mcpalready exist on PyPI, so add the publisher on the existing project for each.
reeflex-litellmIS THE NEW-NAME CASE, AND IT IS BLOCKING (2026-09-08). Measured the day it was added to the matrix:https://pypi.org/pypi/reeflex-litellm/json→ HTTP 404. The project does not exist, and OIDC Trusted Publishing cannot create one — so its publish step fails until a human uses PyPI's "pending publisher" flow (pypi.org → Your account → Publishing → Add a new pending publisher) with the four values in the table above plus the project namereeflex-litellm. The first successful OIDC publish then creates the project and converts the pending publisher into a normal one.Nothing in CI can do this and nothing in CI can detect it in advance, which is why it is called out here rather than left to the run: without it,
v0.2.1publishes three packages,pip install reeflex-litellm[proxy]stays a 404, and the/app/onboardLiteLLM tab hands out an install line that resolves nothing. The publish step for it is last in thepypijob so the three configured names upload regardless, andverify-publishedfails naming the version it could not install — the release says which channel is real instead of going green over one that is not (RFX-246).
Optional hardening: create a GitHub Environment named pypi, uncomment
environment: pypi in the pypi job, and set the Environment field on both
PyPI publishers to pypi. This lets you gate the publish behind required
reviewers.
No PyPI token is stored in the repo. The Vault entry /credentials/pypi
(account + 2FA + a legacy API token) is bootstrap/fallback only — see §3.
2.2 npm — trusted publishing OR the NPM_TOKEN secret¶
Two options; prefer the first if your npm account supports it:
- OIDC trusted publishing (preferred). Configure trusted publishing for
n8n-nodes-reeflexon npmjs.com pointing at repoReeflex-io/reeflexand workflowrelease.yml. Then no token is needed —id-token: write(already in the job) plus--provenancedoes both auth and provenance. NPM_TOKENrepo secret (bootstrap/fallback). Create an automation access token on npmjs.com and store it as theNPM_TOKENGitHub Actions secret. Thenpmjob reads it viaNODE_AUTH_TOKEN. This is the current bootstrap path until trusted publishing is set up.
The npm token in Vault /credentials/npm is the source for the NPM_TOKEN
secret; the secret is the fallback, not the primary path.
2.3 GHCR — nothing to configure¶
The ghcr job authenticates with the run's GITHUB_TOKEN (packages: write).
The image ghcr.io/reeflex-io/reeflex-core is already public. No PAT is needed
for pushing from CI. (The Vault /credentials/GHCR_TOKEN is only for
pushing from a workstation/VM by hand.)
2.5 WP.org SVN — reeflex-gate, inert until the slot exists (RFX-22)¶
.github/workflows/wporg-deploy.yml deploys reeflex-wordpress/ (trunk +
version tag + listing assets) to the WordPress.org plugin SVN via
10up/action-wordpress-plugin-deploy,
on every published GitHub Release. The plugin (reeflex-gate) is, as of this
writing, still in the wordpress.org directory review queue — there is no SVN
slot yet, so this workflow has nothing to authenticate to.
One-time human setup, once the review clears and a slot is granted:
- Create (or use) a wordpress.org account with commit access to the
reeflex-gateSVN repository, 2FA on. - Add two GitHub Actions secrets on
Reeflex-io/reeflex:WPORG_SVN_USERNAMEandWPORG_SVN_PASSWORD. Never a literal in the repo, a workflow file, or a log — by-reference only, same invariant as every other credential in this project. - That's it. The next published release deploys automatically; no workflow re-run, no manual SVN step, nothing else to configure.
Until then, the workflow's guard job detects the missing secrets and
skips the deploy job with a ::notice:: explaining why — it cannot fail a
release or half-publish. See the comment header of wporg-deploy.yml for the
full behavior (including why the SVN version tag is read from
reeflex-gate.php / readme.txt, not the git release tag — the two are
independent, §1 above).
Listing assets (icon, banner, screenshots) live at
reeflex-wordpress/.wordpress-org/ if present; that directory does not exist
yet (verified against every repo on the org as of RFX-22 — no brand/ or
listing-asset directory exists anywhere). The deploy action skips the asset
copy gracefully when the directory is absent, so this is a no-op today, not a
blocker — dropping icon-128x128.png / icon-256x256.png /
banner-772x250.png / banner-1544x500.png / screenshot-*.png there is all
a future change needs.
2.6 Secrets summary¶
| Secret | Used by | Status |
|---|---|---|
GITHUB_TOKEN (built-in) |
GitHub Release, GHCR | always |
| PyPI OIDC (no secret) | PyPI publish | primary |
| npm OIDC (no secret) | npm publish | preferred |
NPM_TOKEN (repo secret) |
npm publish | bootstrap/fallback |
PYPI_API_TOKEN (repo secret) |
PyPI publish | bootstrap only, commented out |
WPORG_SVN_USERNAME / WPORG_SVN_PASSWORD (repo secrets) |
WP.org SVN deploy | not yet set — plugin still in review (RFX-22) |
The Vault tokens (/credentials/pypi, /credentials/npm) stay bootstrap /
fallback only. The steady state is OIDC — no long-lived publish token in the
repo.
3. What the workflow does, job by job¶
- prepare — resolves the release tag (from the pushed tag, or the
workflow_dispatchtaginput), detects whetherreeflex-core/or theDockerfilechanged since the previous tag (for the GHCR guard), and extracts each package version for the summary. - build — builds every artifact once from the tagged commit:
- the four zips via
scripts/build-wp-zips.py(see §5), python -m buildforreeflex-claude,reeflex-holds,reeflex-mcpandreeflex-litellm(sdist + wheel each, into per-project dirs so the publish job can offer them independently),SHA256SUMSover all of them. Uploads them as workflow artifacts for the publish jobs. The build front-end is pinned in.github/requirements-build.txt— an--upgrade buildon the one workflow whose output a customer installs is what PR #123 closed. (n8n-nodes-reeflexis not here any more: it releases from its own repo.)- verify-dist —
twine check --strictover every dist, then a clean venv per package installed from those wheels and invoked, with the negative controls.github-release,pypiandghcrallneedthis, so nothing publishes over an artefact that was not installed and run first. - github-release — attaches all artifacts +
SHA256SUMSto the release for the tag (creating the release if needed). - pypi — offers
reeflex-claude,reeflex-holds,reeflex-mcpandreeflex-litellmvia OIDC (skip-existing: true), each uploading only if its version moved.reeflex-litellmis last: see the pending-publisher note in §2.1. - ghcr — builds + pushes
reeflex-coretagged with the release tag andlatest, only when core changed (§4). - verify-published — installs the versions this tag names from PyPI into
clean venvs and invokes them. It does not read the
pypijob's result and runs even when that job failed, because "so what is on the index?" is exactly the question at that moment. - summary — writes the channel checklist to the job summary, including a row per PyPI package and the two verify results.
4. The GHCR guard (why core does not always ship)¶
The core image is expensive to churn and is versioned on its own cadence. The
ghcr job runs only when the release actually changes core. The rule:
- On a tag release, the workflow diffs
reeflex-core/and the rootDockerfilebetween the previous tag and this tag. If nothing changed there, the GHCR job is skipped and the publishedlateststays where it was. - Override with the
workflow_dispatchinputpush_core: true(forces a build + push regardless of the diff). - The first tag (no previous tag) always builds.
This encodes what happened for v0.1.6: it was an adapters/surfaces release
with no core change, so GHCR stayed at v0.1.5
(ghcr.io/reeflex-io/reeflex-core:v0.1.5). The workflow reproduces that
automatically — v0.1.6 would have skipped GHCR.
When core does change, the image is pushed as both :v0.1.x and :latest.
5. WordPress zip layout (and the file counts)¶
scripts/build-wp-zips.py builds the four zip artifacts with Python's stdlib
zipfile (the runner has no zip binary). It is the single source of truth for
packaging and can be run locally:
reeflex-gate-wordpress-standard.zip— everything under a top-levelreeflex-gate/folder: the loaderreeflex-gate.php, allreeflex-gate/class-*.php,index.php,languages/index.php,uninstall.php,readme.txt,license.txt.reeflex-gate-wordpress-mu.zip— the loaderreeflex-gate.phpat the ZIP root (mu-plugins auto-loads only top-level.php) plus theclass-*.php,index.php, andlanguages/index.phpin areeflex-gate/subfolder. No readme/license/uninstall.reeflex-verify.zip—reeflex-verify.py+ its README.reeflex-test-abilities.zip— the WP test-abilities plugin.
File counts (as of v0.1.6): 14 in standard, 11 in mu. The class-*.php list
is globbed, so adding a class grows both zips automatically. HIL Phase 2 added
two classes (the holds-store + normalizer split), moving the counts from the
earlier 12 standard / 9 mu to 14 standard / 11 mu. A future maintainer who
adds or removes a class should expect these numbers to move — that is expected,
not a regression.
The archives are written deterministically (fixed member order + fixed mtime), so re-running on unchanged sources yields byte-identical zips.
6. Manual fallback (the v0.1.6 process)¶
If Actions is unavailable, or before the registry-side OIDC is configured, a release can be built and published by hand. This is exactly what was done for v0.1.6:
- Build the zips locally:
python scripts/build-wp-zips.py --out reeflex/releases/v0.1.7 - Build the Python dists:
python -m build reeflex-claudeandpython -m build reeflex-holds; copy thedist/*intoreeflex/releases/v0.1.7/. - Build the npm tarball:
cd n8n-nodes-reeflex && npm ci && npm run build && npm pack; move the.tgzinto the release dir. - Hash everything:
sha256sum -b * > SHA256SUMSin the release dir. - Publish (each is a GATE — human GO):
- GitHub Release:
gh release create v0.1.7 <files> --notes-file RELEASE-NOTES.md(orgh release uploadonto an existing release), then hash-verify the uploaded assets read-back. - PyPI:
twine uploadwith the token from Vault/credentials/pypi(bootstrap only; prefer OIDC). - npm:
npm publish --access publicwith the token from Vault/credentials/npm(bootstrap only; prefer OIDC). - GHCR: build on the VM and push only if core changed (v0.1.6 did not push
core — it stayed v0.1.5). Fetch
GHCR_TOKENfrom Vault anddocker login --password-stdin; log out afterward.
The local release directory reeflex/releases/<tag>/ is the source of truth; the
GitHub Release is the publication of those exact bytes.
7. After a release¶
- Confirm the job summary checklist is all ✅ (or the expected ⏭️ for GHCR on an adapters-only release).
- Read
verify-publishedbefore you read the ✅s. A green checklist row says a job exited 0; that job says a customer's command works. If it failed on a package thepypijob reported green, believe it — that is the RFX-246 gap closing rather than a flake. - Verify each channel by read-back:
pip index versions reeflex-claude(andreeflex-holds/reeflex-mcp/reeflex-litellm),npm view n8n-nodes-reeflex version,gh release view <tag>, and (if pushed) an anonymousdocker pull ghcr.io/reeflex-io/reeflex-core:<tag>. - Re-run the artefact checks by hand if you want them outside the workflow:
python -m venv /tmp/v && /tmp/v/bin/pip install 'reeflex-litellm[proxy]==<v>' && python scripts/verify_release_artifacts.py --python /tmp/v/bin/python --expect-litellm <v> --skip-claude. - Update
MEMORY/ the report channel with the published versions + digests.