Skip to content

Cutting a release

One version for the whole repository, and one thing you type. Actions → Release → Run workflow → 0.3.0 → Run. Nothing happens on your machine.

.github/workflows/release.yml writes that number into the three pyproject.toml files and uv.lock, commits it, tags v0.3.0, and builds everything from that tag: .tar.gz and .zip archives on the release page, the server image on Docker Hub, and this site under its own version.

Before the first one

Two repository secrets, set once under Settings → Secrets and variables → Actions:

Secret What it is
DOCKERHUB_USERNAME The Docker Hub account that owns lmstch/resumix.
DOCKERHUB_TOKEN An access token for it, not the account password.

Without them the image job fails at docker login, after the image has been built and checked — and because package waits on every job, no release is published until they exist.

GitHub Pages, once, under Settings → Pages: source GitHub Actions. Not Deploy from a branch — gh-pages is still where every version is kept, but the docs job uploads that branch and deploys it itself. Choosing the branch instead hands publishing to the build Pages runs on a push, and that build never fires here: mike pushes as github-actions[bot], and a GITHUB_TOKEN push starts no workflow run. The branch would move and the site would not. Nothing else writes that branch — it belongs to mike.

One consequence worth knowing: with this source, pushing gh-pages by hand publishes nothing. A mike deploy --push from a laptop updates the store and leaves the site where it was. Releasing is what publishes.

The workflow also declares permissions: contents: write. The repository default for GITHUB_TOKEN is read-only, and this workflow pushes a commit, a tag and a release; without that block the push is refused with a 403. The docs job asks for two more of its own — pages: write and id-token: write — because it deploys the site rather than leaving that to a branch build.

Running it

Type the number bare: 0.3.0, not v0.3.0. The tag gets the v.

It refuses, before writing anything, when:

Refusal Why
the version is not MAJOR.MINOR.PATCH the tag, the archive name and the image tag are all built from it
you dispatched from a branch other than main a release that is not on the main line is a release nobody can reconstruct
v<version> is already on the remote re-releasing a version silently is how two different binaries end up with one number

What happens

flowchart TB
    TAG["<b>tag</b><br/>validate · rewrite three pyprojects<br/>uv lock · commit · tag · push --atomic"]
    B1["<b>build</b> — ubuntu<br/>pyinstaller → resumix<br/>assert <i>resumix version</i>"]
    B2["<b>build</b> — windows<br/>pyinstaller → resumix.exe<br/>assert <i>resumix version</i>"]
    IMG["<b>image</b><br/>DOCKER_BUILDKIT=0 docker build<br/>assert /healthz version<br/>docker push :x.y.z and :latest"]
    DOC["<b>docs</b><br/>mike deploy x.y.z latest<br/>mike set-default latest<br/>→ gh-pages → deploy-pages"]
    PKG["<b>package</b><br/>download both binaries<br/>assemble · tar.gz<br/>gh release create"]
    TAG --> B1 --> PKG
    TAG --> B2 --> PKG
    TAG --> IMG --> PKG
    TAG --> DOC --> PKG

The tag is pushed first, before anything is built. That order is forced: the binaries have to carry the version, so the bump has to precede the build, and the build jobs can only check out a commit that exists on the remote. Every "tag last" arrangement ends up shipping the bump as a patch and re-applying it in three jobs — more moving parts for the same result.

package waits for every other job, so the release page never announces a version whose server image is not on Docker Hub or whose docs are not up. The image job logs in to the registry after the build and the health check, so a broken image never gets as far as authenticating.

Where the version lives

There is no __version__ string in the source to forget to bump. One number is written in one place and everything else reads it:

Where Who writes it
contracts/, server/, client/ pyproject.toml the workflow, with sed
uv.lock uv lock, in the same commit
resumix version, and every --verbose run importlib.metadata.version("resumix-client")
/healthz and the OpenAPI document importlib.metadata.version("resumix-server")

uv.lock is in that list because it records each workspace member's version as well. A bump that did not regenerate it would commit a lock that disagrees with the manifests, which uv lock --check and uv sync --locked both refuse. The binaries themselves would still be right — uv sync --frozen builds the workspace members from source, so the manifest wins — but a repository whose lock contradicts its manifests is a trap for the next person.

What guards the version in the artefacts is not the lock but the assertions: both build jobs run resumix version on the binary they just produced, and the image job reads /healthz from the container it just built, before any of it is published.

What gets published

Artifact Where Name
both executables and the files beside them the release page resumix-x.y.z.tar.gz and resumix-x.y.z.zip
the server image Docker Hub lmstch/resumix:x.y.z and :latest
this site GitHub Pages, from gh-pages /x.y.z/, and /latest/ which / redirects to

The archive is flat on purpose:

resumix                      the Linux executable, already +x
resumix.exe                  the Windows executable
README.md                    the client README, links rewritten for this layout
resumix.toml                 points at http://localhost:8080; edit it
candidate_profile.json       the fictional set, ready to edit
candidate_data.json
candidate_preferences.md
candidate_signature.png
posting.txt                  something to try it on

The client looks for its files by name in the folder you run it in — see discovery.py — so a flat archive is a working folder as soon as it is extracted. There is nothing to move and no config to rename:

mkdir resumix && tar xzf resumix-0.3.0.tar.gz -C resumix
cd resumix
./resumix submit-raw posting.txt --server https://your-server

Extract it into a folder of its own, as above. A flat archive unpacks nine files wherever you stand.

The site's own versions

mike keeps one built copy of doc/ per release on the gh-pages branch, plus a versions.json that the selector in the header reads:

gh-pages/
├── 0.2.0/           one full site per release
├── 0.3.0/
├── latest/          an alias, moved by --update-aliases
├── versions.json    what the selector offers
└── index.html       redirects / to latest/

The docs version is the release version, in full — 0.3.0, not 0.3. mike's own convention is major.minor, so that patches share a docs version, but one number for the whole repository is worth more here than a shorter selector: a tag, an archive, an image tag and a docs URL that all read 0.3.0 need no explaining.

mike set-default --push latest runs on every release. It is idempotent, and doing it every time is what lets the first release set the redirect up with no manual step.

Locally, mkdocs serve and mkdocs build are unchanged and unversioned. The selector is built at runtime from a versions.json that only the published site has, so it will not appear in a local preview — that is expected, not a misconfiguration.

When it fails

What failed What is left behind What to do
tag nothing — the branch and the tag are pushed with --atomic, so they land together or not at all fix it and dispatch again
a later job, transiently — a runner flake, a registry 5xx the bump commit and the tag, no release Re-run failed jobs. tag is skipped and its sha output carries over, so the re-run builds the same commit.
a later job, for a real reason — the Windows build broke, the image fails its health check, a doc link is dead the same the release was never valid. Fix it on main and release the next patch number.

docs and image both publish before package does, so a failure after them can leave a site and an image for a version that has no release page. Both are safe to redo: mike deploy overwrites the version's directory, and docker push overwrites the tag.

The last row is the one that looks wrong and is not. Tags are free, a skipped patch number costs nothing, and the alternative — teaching the workflow to delete and re-push its own tag — is a feature nobody asked for that can destroy a good release.

One thing worth knowing: the release commit does not run CI. Pushes made with GITHUB_TOKEN do not trigger further workflow runs. Its only difference from main is three version strings and the lock, and both uv sync --frozen and the Docker build fail loudly on a broken lock — but the rule that follows from it is: release only from a green main.