This is a Fern-generated SDK. It is published to PyPI as speechify-api.
The source of truth for the API surface lives in the SpeechifyInc/speechify-api
repo (fern/); this repo receives generated code on the sdk-release branch.
Read this before doing anything that touches a release. A botched release here publishes to an immutable public registry and breaks the contract with every customer who installs the package. This file exists because we already did that once — see "Postmortem" below.
Publishing to PyPI is IRREVERSIBLE. A version, once uploaded, can never be overwritten or reused — only yanked (hidden from resolution), and yanking requires a human web session (there is no token/API/CI path for it).
Never trigger a publish until you have, in order:
- Audited the release plumbing (this file, the workflow, the config).
- Confirmed the version is correct in EVERY version-bearing file (see checklist).
- Dry-run built and asserted the artifact version matches the intended tag.
- Got explicit human sign-off for the publish itself.
Do NOT admin-merge release PRs to force a publish. Do NOT bypass required reviews.
The #1 failure mode is a generated version string not getting bumped, so the package publishes under the wrong number (or reports a false version to the API). A single stale string is a published contract break, not a typo.
There are exactly four version literals in the tree, plus the tag. All five must agree in the published artifact — check every one, never check one and assume the rest. Two are committed by the release PR, two are stamped in CI:
| Literal | Who sets it | Correct on main? |
|---|---|---|
pyproject.toml → [tool.poetry].version |
release-please extra-files (toml) |
yes — committed |
.fern/metadata.json → sdkVersion |
release-please extra-files (json) |
yes — committed |
client_wrapper.py → User-Agent |
stamped in CI from the tag | no — stale between releases, by design |
client_wrapper.py → X-Fern-SDK-Version |
stamped in CI from the tag | no — stale between releases, by design |
pyproject.toml→[tool.poetry].version— the artifact version.[project]declaresdynamic = ["version"]and has noversionkey; poetry-core reads[tool.poetry]. Do not add[project].version. Load-bearing:poetry buildreads it, so it must be committed by the release PR.src/speechify/core/client_wrapper.py→User-AgentandX-Fern-SDK-Version.fern/metadata.json→sdkVersion- the git tag created for the release (bare semver —
include-v-in-tag: false)
The tag is the source of truth. Everything else is either bumped to match it by release-please or rewritten to match it at publish time.
Not version literals, so leave them alone:
.release-please-manifest.json— release-please's own state, it owns this.src/speechify/version.py— resolves__version__from installed package metadata at import time. Nothing to bump.
Fast audit:
grep -rnE '[0-9]+\.[0-9]+\.[0-9]+' \
pyproject.toml src/speechify/core/client_wrapper.py .fern/metadata.jsonOn a checked-out main this grep will show client_wrapper.py behind the last
release. That is expected — see the stamping section below. What must never
disagree is the published artifact, which the publish job builds from a stamped
and asserted tree.
X-Fern-SDK-Version / User-Agent are sent on every request. If they lie, your
telemetry, version-gating, and support debugging are all wrong for that release.
- The default
pythonrelease-type does NOT bump thispyproject.toml. Its updater resolvesparsed.project || parsed.tool.poetry— our[project]table exists, so it wins the||, has noversion, declaresdynamic = ["version"], and the updater logsdynamic version found ... Skipping updateand returns the file unchanged. It never falls through to[tool.poetry]. Theextra-filesentry$.tool.poetry.versionis therefore load-bearing, not redundant — delete it and the published artifact silently keeps the old version. - Do not add a
$.project.versionjsonpath. There is no such key. TheGenericTomlupdater logsNo entries modifiedand no-ops, so it buys nothing while implying coverage that does not exist. Adding a static[project].versionto "fix" it would contradictdynamic = ["version"]and break the PEP 621 build. - Never point a
type: "generic"extra-file at a Fern-generated file. Thegenericupdater only rewrites a line carrying anx-release-please-versionmarker, and it no-ops silently when the marker is absent.client_wrapper.pyis generated and cannot be.fernignored, so every regeneration strips the marker and re-arms that trap. This is the exact failure that shipped2.0.1under the3.0.0tag. There is deliberately nogenericentry inrelease-please-config.json— do not add one back. .fern/metadata.jsonwas uncovered until now. It drifted to2.0.1while the package was on3.0.1. It is covered by ajsonupdater on$.sdkVersion. It is not a marker file — thejsonupdater is jsonpath-based and survives regeneration, which is why it is safe to rely on.
A fix that lives inside a generated file is not a fix. Markers are gone.
Instead, the publish job rewrites the two Fern-owned literals from
needs.release-please.outputs.tag_name immediately before building.
The publish job runs three steps in this order, and the order is the design:
- Stamp — rewrite
User-AgentandX-Fern-SDK-Versioninclient_wrapper.pyfrom the tag. - Assert — independently re-read all four literals and compare each to the tag. Fails on a mismatch or on a literal that has gone missing.
- Publish —
poetry publish --build.
Never reorder these, and never let the publish step run without both in front of it. The assertion is a check on the stamp's result, not a substitute for it.
The stamp step fails the build if a target literal is not found. A Fern regeneration that renames or restructures those lines must break the release loudly — a stamp that quietly matches nothing is the same silent-no-op class that caused the 3.0.0 incident. If it fails, fix the pattern in the stamp step and the matching pattern in the assertion; do not delete the check.
include-v-in-tag is false so the tag is bare semver. The stamp tolerates a
leading v, rejects anything that is not semver, and preserves a prerelease or
build suffix verbatim (4.0.0-rc.1 stamps as 4.0.0-rc.1, never 4.0.0).
The stamp edits the CI checkout only. Nothing is committed back. So between
releases, main's client_wrapper.py carries whatever version Fern last
generated, and it will usually look out of date.
This is correct behaviour, not a bug. The published artifact is always right
because it is built from the stamped tree. If you "helpfully" hand-edit those
strings on main, you gain nothing — the next regeneration overwrites you and
the next release stamps over you anyway. Leave them alone.
.fernignore protects these from being overwritten — they are safe:
.github/workflows/ci.yml, .github/workflows/release-please.yml,
.github/workflows/manual-publish.yml, release-please-config.json,
.release-please-manifest.json, CHANGELOG.md, AGENTS.md.
These are regenerated and must be re-checked by hand every single time:
-
src/speechify/core/client_wrapper.py— the two header lines still have the shape the stamp step matches. Do not check the version values themselves; they are stamped at publish time and are expected to be stale:```python "User-Agent": "speechify-api/<semver>", "X-Fern-SDK-Version": "<semver>", ``` Same key names, same quoting, same `speechify-api/` prefix. If a regen changed any of that, update the patterns in **both** the stamp step and the publish assertion in `release-please.yml`. -
pyproject.toml—[tool.poetry].versionstill present;[project]stilldynamicand still has noversionkey. -
.fern/metadata.json—sdkVersionkey still named that. -
release-please-config.json— still has notype: "generic"entry.
Verify the shapes without caring about the values:
grep -nE '"(User-Agent|X-Fern-SDK-Version)":' src/speechify/core/client_wrapper.pyIf any of the above moved or was renamed, fix it before merging — the stamp step and the publish assertion will both fail the release otherwise (by design).
release-please.yml's publish job runs a version/tag assertion after the
stamp step and before poetry publish --build. It fails the job if any of the
four literals disagrees with the tag, or if a literal has gone missing
entirely — a missing literal is the silent-no-op class and is treated as a hard
failure, not a skip. Every value and the tag are printed on failure. Keep the
order stamp → assert → publish.
manual-publish.yml is the recovery path for a one-off republish; it asserts the
built artifact filename against an explicit expected_version input.
- The repo allows squash merge only (merge commits and rebase are disabled).
Squash uses
PR_TITLE+PR_BODY, so the PR body becomes the commit message onmain— that is the only thing release-please parses. - A
Release-As: X.Y.Zfooter must therefore live in the PR body, as the final line, standalone, unindented and without backticks. In a commit message trailer position it is ignored if indented or fenced. BREAKING CHANGE:footers belong in the PR body too, for the same reason.
- Wrong version on PyPI: it is permanent. Fix the version wiring, cut a NEW
correct version, and yank the bad one (human, via
https://pypi.org/manage/project/speechify-api/release/<version>/). - Tag points at a bad commit: the tag/GitHub release can be recreated on the corrected commit (public history mutation — back up the old SHA + notes first).
- Never delete a PyPI release to "reuse" the number. You can't.
A routine regeneration was pushed straight to publish. Failures, in order:
- Breaking-change PRs were admin-merged past the review gate.
- Nothing bumped the version.
git show 2f19983 --statis the evidence: the release commit touched only.release-please-manifest.jsonandCHANGELOG.md. Both updaters had failed silently, for two different reasons:pyproject.tomlwas alreadydynamic = ["version"], so the defaultpythonupdater loggedSkipping updateand left[tool.poetry].versionat2.0.1. The config had noextra-filesTOML entry to catch it.- the
genericentry forclient_wrapper.pywas configured but the file had nox-release-please-versionmarker, so it no-opped without erroring.
- release-please tagged
3.0.0;poetry buildread[tool.poetry].versionand produced2.0.1→speechify-api 2.0.1was published to PyPI (immutable), carrying the 3.0.0 breaking code under a patch number. - Each stale version string was found reactively, one at a time, instead of via one exhaustive version-surface audit.
The follow-up fix at the time was to make [project].version static. That fix
does not survive a Fern regeneration — the next regen restored
dynamic = ["version"] and re-armed the trap. Adding an x-release-please-version
marker to client_wrapper.py was the same mistake wearing a different hat: also
inside a generated file, also stripped by the next regen, also silent. Both are
gone now.
Durable fixes live in exactly three places: .fernignore-protected files
(release-please-config.json), values derived from the tag at publish time (the
stamp step), or an assertion that fails the build (the publish assertion). If a
proposed fix is a literal written into a generated file, it is not durable —
it is a countdown to the next regeneration.
Lesson: audit the whole release surface first, dry-run, get sign-off, then publish. Treat one stale version string as a signal to check every other one, and never rely on a fix that lives in a generated file.