# Release process

femforge and femforge-workbench release together under one version. A release is a version bump and a changelog section merged to `main`, then a `v` tag that a workflow turns into PyPI uploads and a GitHub release. The Elmer image and the docs site publish on their own schedules from `main`. This page covers all three.

## Versions

Every version string is a static literal, and `tests/test_release.py` keeps them in lockstep:

| File | Line | Check |
| --- | --- | --- |
| `pyproject.toml` | `version = "X.Y.Z"` | `test_package_versions_match` |
| `packages/femforge-workbench/pyproject.toml` | `version = "X.Y.Z"` | `test_package_versions_match` |
| `src/femforge/__init__.py` | `__version__ = "X.Y.Z"` | `test_package_versions_match` |
| `pyproject.toml` | the `workbench` extra pins `femforge-workbench==X.Y.Z` | `test_workbench_extra_pins_the_workbench_version` |
| `packages/femforge-workbench/pyproject.toml` | the dependency `femforge[viz]==X.Y.Z` | `test_workbench_depends_on_the_same_femforge_version` |
| `CITATION.cff` | `version: X.Y.Z` | `test_citation_version_matches` |
| `CHANGELOG.md` | a `## [X.Y.Z]` section | `test_changelog_has_release_section` |
| `src/femforge/templates/new/main.py` | the script header pins `femforge[workbench]~=X.Y.0` | `test_the_project_template_pins_this_minor_release_and_python` |

Set `date-released` in `CITATION.cff` to the release date as well. Versions follow [Semantic Versioning](<https://semver.org/spec/v2.0.0.html>).

## Changelog

`CHANGELOG.md` follows [Keep a Changelog 1.1](<https://keepachangelog.com/en/1.1.0/>). Pull requests add lines under `## [Unreleased]`. At release time, move those lines into a new dated section and update the compare links at the bottom of the file:

```markdown
## [Unreleased]

## [0.2.0] - 2026-11-20

### Added

- A radiation boundary condition.

[Unreleased]: https://github.com/femforge-io/femforge/compare/v0.2.0...HEAD
[0.2.0]: https://github.com/femforge-io/femforge/compare/v0.1.0...v0.2.0
[0.1.0]: https://github.com/femforge-io/femforge/releases/tag/v0.1.0
```

The release workflow copies the section into the GitHub release notes, so write it for users.

## Cut a release

1. Open a pull request that sets the new version in every file of the [versions table](<#versions>), moves the Unreleased lines into the new section, and updates the compare links and `date-released`.
2. Run the [gates](<https://docs.femforge.io/contributing/#run-the-gates>), then merge.
3. Tag the merge commit on `main` and push the tag:

   ```console
   git tag -a v0.2.0 -m "femforge 0.2.0"
   git push origin v0.2.0
   ```

## The release workflow

> [!NOTE]
>
> `.github/workflows/release.yml` runs only in the public repository `femforge-io/femforge`, because its first job checks `github.repository`.

A tag that matches `v[0-9]+.[0-9]+.[0-9]+*` starts the workflow. Its jobs run in order, and a failure stops the release:

| Job | What it does |
| --- | --- |
| `client` | builds the workbench client with Node 24 and uploads the bundle |
| `build` | checks that the tag is on `main`, that both packages carry the tag version (`uv version --short --frozen --package`), that `CHANGELOG.md` has the section and that `CITATION.cff` matches; then builds both packages with `uv build --all-packages --no-sources`, runs `twine check --strict`, and installs both wheels into a clean virtual environment to assert `femforge.__version__` |
| `pypi` | uploads the wheels and sdists through PyPI trusted publishing, in the `pypi` environment, with no stored token |
| `github-release` | creates the GitHub release for the tag with the changelog section as its notes and the wheels and sdists attached |

A version that holds a letter, such as `0.2.0rc1`, becomes a GitHub prerelease.

## Elmer image releases

The image `ghcr.io/femforge-io/femforge-elmer` has its own version, `IMAGE_VERSION` in `src/femforge/elmer/runtime/docker_image.py`, next to `IMAGE_DIGEST`, the sha256 of the released Dockerfile. When you change the recipe, raise the version in the same pull request:

```console
uv run femforge image bump
```

The command rewrites both lines when the recipe differs from the released one and does nothing otherwise. `--part` picks `major`, `minor` or `patch`, the default. It refuses to run from an installed package, since it edits the source.

The Elmer image workflow (`.github/workflows/image.yml`) runs on a push to `main` that touches `docker_image.py`, `src/femforge/execution/docker/` or the workflow itself, and on demand. It runs only in `femforge-io/femforge`:

1. `femforge image tag` resolves the tag. A recipe change without a bump yields a `-dev.` tag, and the job fails.
2. When GHCR already holds the tag, the job skips the remaining steps. Version tags are immutable.
3. It builds the image with `femforge image build --no-pull` and runs the Docker end-to-end test through it.
4. It pushes the image.
5. It downloads the source of the installed `elmerfem-csc` package and attaches it to a `femforge-elmer-v<version>` GitHub release, which never becomes the latest release.

The base-image workflow (`.github/workflows/base-image.yml`) runs on the first of each month and on demand. When the digest of `ubuntu:24.04` has moved from the one `BASE_IMAGE` pins, it pins the new digest, runs `femforge image bump --part patch`, and opens a pull request. That pull request starts no checks by itself; close and reopen it to run them. Merging it publishes the new image.

## Docs deploy

The `deploy` job of `.github/workflows/docs.yml` publishes the site after each push to `main` in `femforge-io/femforge`. It takes the site the `docs` job built and runs `npx wrangler@4 deploy` with the Cloudflare credentials of the `docs` environment. `wrangler.jsonc` names the Worker `femforge-docs`, serves `./site` with its 404 page, and routes `docs.femforge.io` to it.

## The placeholder releases

PyPI holds placeholders `femforge 0.0.1` and `femforge-workbench 0.0.1` with no code in them. Once `0.1.0` is on PyPI, both placeholders are yanked, so an unpinned install never resolves to them.
