Skip to content

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-reeflexno 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-existing cuts both ways: it is what makes a re-run safe, and it is also why a package whose version did not move is skipped, while the pypi job still reports success. On Release v0.2.0 that job — then named "PyPI publish (reeflex-claude + reeflex-holds + reeflex-mcp)" — was green having uploaded exactly one of the three, because reeflex-claude 0.1.7 and reeflex-mcp 0.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 the pypi job log for Uploading versus Skipping … 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.tomlversion
  • reeflex-holds/pyproject.tomlversion
  • reeflex-mcp/pyproject.tomlversion
  • reeflex-litellm/pyproject.tomlversion
  • reeflex-wordpress/reeflex-gate.php → the Version: header
  • (not n8n-nodes-reeflex/package.json — that package moved to its own repo and is not built or published by release.yml any more.) These are independent — the release tag (v0.1.6) is the umbrella, not each package's version (e.g. v0.1.6 shipped reeflex-claude 0.1.6, reeflex-holds 0.1.0, n8n-nodes-reeflex 0.1.0, reeflex-gate 0.1.5). reeflex-core has no version file to bump: its artefact is the GHCR image, which is tagged from the release tag itself and rebuilt only when reeflex-core/ or the Dockerfile changed.
  • CHANGELOG.md updated.
  • 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 → SettingsPublishingAdd 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-holds and reeflex-mcp already exist on PyPI, so add the publisher on the existing project for each.

reeflex-litellm IS 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/jsonHTTP 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 accountPublishingAdd a new pending publisher) with the four values in the table above plus the project name reeflex-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.1 publishes three packages, pip install reeflex-litellm[proxy] stays a 404, and the /app/onboard LiteLLM tab hands out an install line that resolves nothing. The publish step for it is last in the pypi job so the three configured names upload regardless, and verify-published fails 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:

  1. OIDC trusted publishing (preferred). Configure trusted publishing for n8n-nodes-reeflex on npmjs.com pointing at repo Reeflex-io/reeflex and workflow release.yml. Then no token is needed — id-token: write (already in the job) plus --provenance does both auth and provenance.
  2. NPM_TOKEN repo secret (bootstrap/fallback). Create an automation access token on npmjs.com and store it as the NPM_TOKEN GitHub Actions secret. The npm job reads it via NODE_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:

  1. Create (or use) a wordpress.org account with commit access to the reeflex-gate SVN repository, 2FA on.
  2. Add two GitHub Actions secrets on Reeflex-io/reeflex: WPORG_SVN_USERNAME and WPORG_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.
  3. 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

  1. prepare — resolves the release tag (from the pushed tag, or the workflow_dispatch tag input), detects whether reeflex-core/ or the Dockerfile changed since the previous tag (for the GHCR guard), and extracts each package version for the summary.
  2. build — builds every artifact once from the tagged commit:
  3. the four zips via scripts/build-wp-zips.py (see §5),
  4. python -m build for reeflex-claude, reeflex-holds, reeflex-mcp and reeflex-litellm (sdist + wheel each, into per-project dirs so the publish job can offer them independently),
  5. SHA256SUMS over 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 build on the one workflow whose output a customer installs is what PR #123 closed. (n8n-nodes-reeflex is not here any more: it releases from its own repo.)
  6. verify-disttwine check --strict over every dist, then a clean venv per package installed from those wheels and invoked, with the negative controls. github-release, pypi and ghcr all need this, so nothing publishes over an artefact that was not installed and run first.
  7. github-release — attaches all artifacts + SHA256SUMS to the release for the tag (creating the release if needed).
  8. pypi — offers reeflex-claude, reeflex-holds, reeflex-mcp and reeflex-litellm via OIDC (skip-existing: true), each uploading only if its version moved. reeflex-litellm is last: see the pending-publisher note in §2.1.
  9. ghcr — builds + pushes reeflex-core tagged with the release tag and latest, only when core changed (§4).
  10. verify-published — installs the versions this tag names from PyPI into clean venvs and invokes them. It does not read the pypi job's result and runs even when that job failed, because "so what is on the index?" is exactly the question at that moment.
  11. 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 root Dockerfile between the previous tag and this tag. If nothing changed there, the GHCR job is skipped and the published latest stays where it was.
  • Override with the workflow_dispatch input push_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:

python scripts/build-wp-zips.py --out dist-artifacts
  • reeflex-gate-wordpress-standard.zip — everything under a top-level reeflex-gate/ folder: the loader reeflex-gate.php, all reeflex-gate/class-*.php, index.php, languages/index.php, uninstall.php, readme.txt, license.txt.
  • reeflex-gate-wordpress-mu.zip — the loader reeflex-gate.php at the ZIP root (mu-plugins auto-loads only top-level .php) plus the class-*.php, index.php, and languages/index.php in a reeflex-gate/ subfolder. No readme/license/uninstall.
  • reeflex-verify.zipreeflex-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:

  1. Build the zips locally: python scripts/build-wp-zips.py --out reeflex/releases/v0.1.7
  2. Build the Python dists: python -m build reeflex-claude and python -m build reeflex-holds; copy the dist/* into reeflex/releases/v0.1.7/.
  3. Build the npm tarball: cd n8n-nodes-reeflex && npm ci && npm run build && npm pack; move the .tgz into the release dir.
  4. Hash everything: sha256sum -b * > SHA256SUMS in the release dir.
  5. Publish (each is a GATE — human GO):
  6. GitHub Release: gh release create v0.1.7 <files> --notes-file RELEASE-NOTES.md (or gh release upload onto an existing release), then hash-verify the uploaded assets read-back.
  7. PyPI: twine upload with the token from Vault /credentials/pypi (bootstrap only; prefer OIDC).
  8. npm: npm publish --access public with the token from Vault /credentials/npm (bootstrap only; prefer OIDC).
  9. 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_TOKEN from Vault and docker 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-published before 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 the pypi job 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 (and reeflex-holds / reeflex-mcp / reeflex-litellm), npm view n8n-nodes-reeflex version, gh release view <tag>, and (if pushed) an anonymous docker 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.