CI: GitHub Action
Automate the whole release loop on GitHub: molt keeps a "Version Packages" pull request in sync with your accumulated changesets, and publishes to PyPI over OIDC the moment that PR merges.
Molt ships a composite GitHub Action that installs itself with uv and runs the release loop for you. Because it is the same molt you run locally, nothing about the automation is GitHub-specific magic -- the Action just wires molt's own commands into the two events that matter.
The Action lives in giancarlosisasi/molt-action, so you reference it as uses: giancarlosisasi/molt-action@v1.
v1 is a moving reference: it tracks the newest 1.x action and moves when the action itself changes. Pin a commit in production (uses: giancarlosisasi/molt-action@<sha>) if you would rather decide when the action changes under you. Dependabot updates a commit pin; it cannot tell you a moving tag moved.
The Action's version and the CLI's version are separate. v1 versions the YAML a runner executes; molt-version pins the molt-release release it installs from PyPI. That repository's CI checks the pairing on every push and once a week, by resolving the Action's inputs against an installed molt-release.
The two-phase loop
Every push to your base branch triggers one of two behaviors, and molt decides which:
- Version phase -- there are pending changesets. Molt opens (or updates) a release pull request titled "Version Packages". That PR contains exactly what
molt versionwould do: bumped manifests, rewritten changelogs, an updated lockfile. As more changesets merge, the same PR is kept up to date -- it is reused, never re-created, so its number and review history survive. - Publish phase -- there are no pending changesets but there are versions on disk that are not yet on PyPI. That is the state right after the release PR merges. Molt builds and publishes the changed packages, git-tags each one, and creates a GitHub Release per package with its changelog entry as the body.
So the human loop is simply: merge feature PRs (each carrying a changeset), watch the "Version Packages" PR accumulate, and merge it when you want to cut a release. Merging it is the release.
One distribution, one version, one tag.
Open a pull request
Your change, plus one file saying what it releases.
src/acme/export.pyyour actual change.changeset/lucky-pandas-sing.mdwritten by molt add
Merge it
The push to your base branch starts the loop.
Does molt find pending changesets?
Version phase
A pull request titled "Version Packages", on the branch changeset-release/main. No approval is asked for and nothing leaves the repository.
pyproject.tomlversion 1.2.0 -> 1.2.1CHANGELOG.mda new 1.2.1 sectionuv.lockrefreshed.changeset/lucky-pandas-sing.mdconsumed, deleted
Merge the release pull request
the releaseThis is the release. You never pick the version; the changesets did.
Publish phase
One package goes out.
- Build the sdist and the wheel
- Upload to PyPI over OIDC, with no stored token
- Push the tag v1.2.1
- Create the release, with the changelog entry as its body
Only one phase runs per push, never both. molt decides which by looking for pending changesets.
The release PR lives on a dedicated branch, changeset-release/<base> (for a main base, changeset-release/main). Molt owns that branch and force-updates it -- do not hand-edit it, your changes are replaced on the next push. The name is deliberately the one changesets uses, so a repository migrating from changesets keeps its existing release branch, its open release PR, and any branch protection rule written against that name.
The commit molt writes on that branch is titled "Version Packages" by default; the version loop takes the message as a parameter, so a workflow may set its own. The pull-request title and the commit message are separate settings: renaming one never renames the other, so a repository with a conventional-commit convention can rename the commit and keep the pull request its reviewers recognise.
Only one phase runs per push -- never both. A run with pending changesets versions; a run with none publishes if a publish command is configured. Pending changesets that release nothing (what molt add --empty writes) are reported as pending and open no pull request, because there is nothing to release.
What the release pull request says
The body lists every package the version run bumped: one ## <name>@<version> section per package, carrying that package's own changelog entry for that version. Sections are ordered public packages first, then highest bump level first, so the release a reader cares about is at the top of a body that may list dozens.
The list is never filtered -- a private package that was bumped is in the body too, because the pull request is the record of what merging it releases.
Very large releases degrade rather than fail. GitHub rejects a pull-request body over 65536 characters, so molt drops the changelog text first (keeping every heading, plus a note saying so) and, if that is still too long, replaces the package list with a single note. The explanatory header and the # Releases heading survive in every case.
What the publish phase creates
After a successful publish, molt creates one GitHub Release per published package, named for that package's tag and carrying that package's changelog entry as the body, marked as a prerelease when the version is a PEP 440 prerelease. This is on by default and can be switched off; switching it off does not change what is published or what is reported.
Which packages went out is read from the publish command's machine-readable output: molt publish writes one JSON event per tag it creates, and the Action reads that. A publish command that is not molt — a shell script, a twine wrapper — is read from its New tag: lines on stdout instead, which is the contract changesets uses. You configure neither; the Action names the destination for the stream itself, and honours a MOLT_OUTPUT you set yourself if you are collecting the events for a later step.
The Action reports each release it created, each one the host already carried, and — when releases are on and the publish command reported no package — that it created none and why. A run that published nothing and a run that published something it failed to detect both write published=false, so the log line is what tells them apart.
A package with no CHANGELOG.md is skipped silently -- that is what "this project keeps no changelog" looks like on disk. A package whose CHANGELOG.md exists but carries no section for the version just published fails the run, naming the package and the version: the file is there, so release notes were meant to be written and are missing. A release the host already carries is not an error -- re-running a publish that half-failed completes the missing releases instead of failing on the finished ones.
No pre mode
Molt has no prerelease state, so a release pull request that carries prereleases looks like any other one. There is no title suffix and no banner. molt pre exists to point you at molt version --pre {a,b,rc,dev}. If you are migrating a workflow from changesets, the title suffix and banner it reads out of .changeset/pre.json have no molt equivalent.
A copy-pasteable workflow
That is the entire release automation for a single package or a monorepo. There is no separate astral-sh/setup-uv step: the Action installs uv itself, from a commit-pinned copy it owns. In production, pin actions/checkout and giancarlosisasi/molt-action to full commit SHAs.
persist-credentials: false is deliberate and the Action supports it. Both phases push -- the version phase force-pushes the release branch, the publish phase pushes the tags -- and molt authenticates them from the GITHUB_TOKEN you pass, through a credential it puts in the environment for the length of its own step. The token is never written to the runner's disk, and no step you add after molt inherits a credential from it. Leaving the checkout's credentials in place instead (persist-credentials: true) also works, but then the push uses the checkout's token rather than the one molt was given, which differ as soon as you pass a GitHub App token or a PAT.
Settings → Actions → General → Workflow permissions → Allow GitHub Actions to create and approve pull requests. It is off by default on personal-account repositories, and without it the version phase fails with GitHub Actions is not permitted to create or approve pull requests. Granting pull-requests: write above does not substitute for it.
Three more settings shape what the loop can do: branch protection on changeset-release/*, a signed-commits rule, and whether checks start on the release pull request. All four are in Repository settings.
Inputs
Every input is optional. Supplying only a token runs the version phase with the defaults below.
You also configure PyPI Trusted Publishing once, on PyPI itself, to trust this repository and workflow. After that there is no API token anywhere in the workflow -- see Publishing.
Understanding the permissions
The three grants map one-to-one onto the three things molt does:
id-token: writeis the important one. It lets the job mint the short-lived OIDC token that PyPI exchanges for a one-time upload token. This is what replaces a storedPYPI_API_TOKENsecret entirely.contents: writelets molt push the release branch, push the annotated tags after a successful publish, and create the GitHub Releases.pull-requests: writelets molt open and update the "Version Packages" PR.
Starting from permissions: {} at the workflow level and granting per job is the recommended posture: nothing has ambient write access, and the OIDC grant exists only where a publish can happen. For a stricter setup, split the version phase and the publish phase into separate jobs so id-token: write is scoped to the publish job alone.
Pinning molt
molt-version pins the exact molt the Action runs. Left empty, it installs the latest release -- it does not read the Action's own ref, because turning a ref like v1 (or a branch, or a SHA) into a PyPI specifier is guesswork, and guessing a version is the opposite of pinning one.
The Action pins the one third-party action it bundles (astral-sh/setup-uv) to a commit SHA, and the Action's own repository runs Dependabot weekly against that pin, so an audit of the third-party actions you inherit has a named owner for the refresh.
Pin it. A pinned version is what stops your releases from changing behavior on a day you did not touch anything, and because the Action installs molt with uvx from a warm uv cache, a pinned version resolves in seconds. uvx also puts that same install on PATH for the commands molt spawns, so publish: molt publish runs the version you pinned and not some other one.
Molt is not tied to GitHub Actions
The Action is a convenience, not a dependency. Every phase above is a plain molt command, and molt publish -- including --filter -- runs the same everywhere. You can drive releases from GitLab CI, a Jenkins box, a cron job, or your laptop with nothing more than uv installed:
This is a deliberate contrast with changesets, whose publish flow is coupled to its own GitHub Action. Molt keeps the orchestration and the CLI as the same code path, so "run it in CI" and "run it by hand" are the same tool.
Forge-agnostic by design
Opening the release PR, attributing authors, and writing release notes all go through molt's forge protocol rather than calling GitHub directly. See Forges for what a forge backend answers, and GitLab, Gitea & others for releasing from another host.
Windows and cross-platform notes
Molt is tested on Windows, macOS, and Linux from the first commit, so the CLI behaves identically wherever your CI runs -- paths resolve correctly and output stays ASCII-clean under Windows' cp1252 console. The release job itself typically runs on Linux, but the tool you are automating does not care which runner you pick.
Action outputs
The Action exposes four outputs so later steps can react to a release:
Every value is written to GITHUB_OUTPUT in the delimiter (heredoc) form, so a value containing a newline or an equals sign survives intact.
A failing run still writes its outputs
A publish that uploads three packages out of five fails the step -- and still reports the three. That is the run where reading published_packages matters most, so guard the reader with if: always() rather than letting it be skipped along with the rest of the job:
published is not a success signal. It says at least one package reached the index, which is exactly as true after a half-publish as after a clean one. A workflow that treats it as "the release worked" must also check the molt step's own outcome.
One case writes nothing, deliberately: a run that fails before it observes anything -- a protected release branch, an unusable configuration -- has no result to report, and reporting published = false about a repository molt never finished reading would be worse than reporting nothing.
Molt also fails the run when a package published but its GitHub Release could not be created. It creates every remaining release first and then fails naming each one it could not create, with the reason -- the packages are already on the index by then, and re-running is safe because a release that already exists is left alone.
Signed commits
If your release branch is protected by a rule that requires signed commits, set commit-mode: api:
In that mode molt does not commit locally at all. It runs the version command on the branch the workflow checked out, works out what changed, and sends those changes to GitHub. GitHub authors the commit, so GitHub signs it with its own key, marks it Verified, and attributes it to whoever owns the token. A branch protection rule that requires signed commits accepts it. The default, git-cli, commits and force-pushes from the runner exactly as before -- and an unsigned commit is what such a rule rejects.
What the mode buys is the signature, and only the signature. Three things it does not change, and a reader who sees "signed commits" will assume all three:
- It does not bypass a review requirement or a required status check. An API commit is subject to branch protection exactly as a push is.
- It does not make the release branch trigger other workflows. A commit authored with the workflow's own
GITHUB_TOKENnever starts another workflow run, over the API just as over a push. - It does not sign tags. Molt pushes tags with git in both modes; a tag ref carries no signature either way.
Using a GitHub App token or a PAT instead of ${{ github.token }} changes two of those: the commit is attributed to the app or the user rather than to github-actions[bot], and the release branch does trigger workflows.
Two observable differences from the default mode, both harmless on their own and worth knowing if you add steps after molt:
- The checkout stays on your base branch. No local
changeset-release/<base>branch is created or checked out, so a later step cannot inspect one locally. It exists on the remote, which is where the pull request reads it from. - No committer identity is configured on the runner. The Action's identity step writes a global git identity, and in
apimode there is nothing local to attribute, so the step is skipped. A later step doing its owngit commitwill need to configure one.
What molt's Action does not do
Deliberate omissions, each with the reason -- a changesets user migrating looks for exactly these.
Migrating from changesets/action
The release branch name is unchanged -- changeset-release/<base> -- so an open release pull request, its number and any branch protection rule written against that name all survive the switch.
See also
- Publishing -- the OIDC upload flow the publish phase runs.
molt version-- what the "Version Packages" PR contains.- Forges -- the seam behind PR creation and release notes.
- The changeset workflow -- the add / version / publish loop this automates.