innoday-blastoff
A small CLI to batch GitHub releases and hotfixes across multiple repositories grouped by a GitHub topic.
blastoff computes the next semantic version for a group of repos, tags every repository that carries a given GitHub topic, generates a per-repo changelog of merged pull requests, and (optionally) a Claude-authored release summary. Hotfixes bump the patch version, with two supported modes.
- Install / distribution name:
innoday-blastoff(on PyPI) - Import / command name:
blastoff
What it does
- Batch releases by topic — point it at a GitHub org and a topic; it finds every repo carrying that topic and creates the same release tag on each (skipping archived repos and repos that already have the tag).
- Automatic version computation — the next version is resolved from a local config by alias, so you don't hand-type tags. A release bumps the minor version; a hotfix bumps the patch.
- Changelog generation — collects merged PRs across all matched repos since the last release/hotfix and writes a structured per-repo changelog to
CHANGELOG.md. - Optional Claude release summary — when a Claude API key is available, an executive-facing narrative of the release is generated; otherwise blastoff falls back to PR titles.
- Dry run by default — every release/hotfix is a preview unless you add
--release. - Pluggable version backend — release state lives in a local file by default, but you can implement your own
VersionStoreto keep it anywhere (see Extending).
Install
With pip:
pip install innoday-blastoff
With uv:
uv pip install innoday-blastoff
# or add it to a project
uv add innoday-blastoff
The distribution is named innoday-blastoff, but the CLI and the import package are both blastoff:
blastoff --help # the CLI is on PATH after install
python -c "import blastoff" # import by package name
Prerequisites
blastoff talks to the GitHub API and, optionally, to Claude. Configure it with environment variables (a .env file in the working directory is loaded automatically):
| Variable | Required | Purpose |
|---|---|---|
GH_TOKEN |
yes | GitHub token used to list repos and create releases. Scopes: repo, read:org. |
GH_ORG |
yes* | GitHub organization to pull repositories from. |
TOPIC |
yes* | GitHub topic used to select which repos to release. |
CLAUDE_API_KEY |
no | Enables Claude-generated PR summaries (dry run) and the release narrative. Without it, blastoff falls back to PR titles / a plain summary. |
* GH_ORG and TOPIC can also be supplied per-invocation via CLI flags, or resolved from config by alias (see below). When you resolve a release by alias (-c <alias>), the org and topic come from the config entry and you don't need the env vars.
Usage
blastoff has four subcommands: release, hotfix, summarize, and version.
Releases
The version and topic are resolved from config by alias. Do not pass -t/--tag for a topic/alias release — the tag is computed for you from the config's next_version. Passing -t is a one-off override that bypasses config resolution and never mutates stored version state.
# Dry run — shows what would be released, writes nothing (safe to run anytime)
blastoff release -c <alias>
# Actually create the releases
blastoff release -c <alias> --release
# Release only a single repository
blastoff release -c <alias> --repo <repo-name> --release
Useful flags:
--release/-r— perform the release. Omitting it is always a dry run.--repo <name>— restrict to a single repository.--summary "text"— use this text verbatim as the release summary instead of auto-generating one.--changelog-output <path>— where to write the changelog (default:CHANGELOG.md).-o/--org,-k/--token,-c/--topic— overrideGH_ORG,GH_TOKEN,TOPIC.
On a real release, blastoff validates the token, creates the tag on each matching repo, writes the changelog, bumps the stored next_version (minor bump), and records the released version via the configured backend.
Hotfixes
Hotfixes bump the patch version from the last released version. Two modes are supported per alias (set with the hotfix_mode field in config):
| Mode | Behavior | Example |
|---|---|---|
increment (default) |
Bumps the patch digit | v1.3.0 → v1.3.1 |
hotfix |
Appends a -hotfix.N suffix |
v1.3.0 → v1.3.0-hotfix.1 |
blastoff scans existing GitHub releases in the topic to pick the next free hotfix number, so re-runs are safe.
# Dry run
blastoff hotfix -c <alias>
# Create the hotfix releases
blastoff hotfix -c <alias> --release
# Hotfix a single repo
blastoff hotfix -c <alias> --repo <repo-name> --release
# Hotfix a single repo at a specific commit
blastoff hotfix -c <alias> --repo <repo-name> --commit <sha> --release
A full release must have run first (the hotfix base is the config's last_released_version).
Summaries
summarize produces a Markdown release document (written to releases/ by default):
blastoff summarize # all configured aliases
blastoff summarize --alias <alias> # a single alias
blastoff summarize --output-dir <path> # custom output directory
Version config
version inspects the resolved config:
blastoff version print # show the version state for all configured aliases
blastoff version check # validate the config and report any errors
Configuration
Version and release state is read from org-versions.json, found by walking up from the current directory. It is the only file blastoff reads. The resolution logic lives in blastoff/config_loader.py, and the default file backend is blastoff/stores/file_store.py.
Changed in 0.6.0. blastoff used to look for
.innoday/project.ymlfirst and write release state back into it. It no longer reads or writes that file. blastoff is a general-purpose CLI and should not know one platform's folder layout; a platform driving blastoff supplies a release brief instead (below).
org-versions.json
Create it in your project root (or any parent directory), with one entry per alias:
{
"organizations": [
{
"alias": "my-app",
"organization": "my-github-org",
"topics": ["my-release-topic"],
"next_version": "v1.2.0",
"prerelease": null,
"last_released": null,
"last_released_version": null,
"last_hotfix": null,
"hotfix_mode": "increment"
}
]
}
Field reference:
| Field | Required | Meaning |
|---|---|---|
alias |
yes | The name you pass to -c — how you refer to this release group. |
organization |
yes | GitHub organization the repos live in. |
topics |
yes | GitHub topics that select the repos to release. A list; a repo carrying any of them is included. A legacy label string is still read, as a single topic. |
next_version |
yes | The version to release next (e.g. v1.2.0). Bumped automatically after a real release. |
prerelease |
no | Prerelease type: alpha, beta, or rc. |
last_released |
no | ISO-8601 timestamp of the last release (set automatically). |
last_released_version |
no | The last version released (set automatically; used as the hotfix base). |
last_hotfix |
no | The last hotfix tag created (set automatically). |
hotfix_mode |
no | increment (default) or hotfix — see the hotfix table above. |
After a successful release or hotfix, whichever source the config was loaded from is updated automatically with the new version state.
The release brief — being told instead of looking up
-c is a key into the file above: one machine, one JSON, several release groups, and a name to pick between them. That is the right shape when blastoff runs on its own.
It is the wrong shape when something else drives it. A platform already knows the GitHub account, the topics, the version being cut and where the last one ended — it holds all of that as records. Writing those facts into a file for blastoff to re-discover means two copies of one answer kept in step by hand.
So a caller can supply the finished answer:
# everything on the command line
blastoff release --github-org my-gh-org \
--topics my-topic,another-topic \
--version-to-cut v1.2.0 \
--since-version v1.1.0
# or the whole object, which is what a program would do
blastoff release --brief - < brief.json
{
"name": "my-app",
"github_org": "my-gh-org",
"topics": ["my-topic", "another-topic"],
"version": "v1.2.0",
"previous_version": "v1.1.0",
"previous_released_at": "2026-08-20T18:05:51Z",
"ticket_count": 4,
"open_ticket_count": 4
}
github_org, topics and version are required; the rest are optional. There are no last_released/last_hotfix fields — a caller passing a brief already records what shipped, so blastoff has nothing to write back and needs no file of its own. Pair it with --json to get the assembled facts back and write the summary yourself.
content — telling blastoff what is in the release
Given only the fields above, blastoff still has to find the release itself: the repositories, what merged in each since its boundary, what is still open. On a seven-repository project that is roughly thirty-five GitHub calls, which is why it needs a token wherever it happens to be running.
A caller that already has that data — a platform reading GitHub with its own stored credential — can hand it over. When content is present, a preview renders it and makes no GitHub calls at all, so no token is required:
{
"name": "my-app",
"github_org": "my-gh-org",
"topics": ["my-topic"],
"version": "v1.2.0",
"content": {
"window_label": "since v1.1.0 (2026-08-20)",
"commit_count": 39,
"repos": ["service-a", "service-b", "web"],
"included": [
{"repo": "service-a", "commit_count": 4,
"prs": [{"number": 124, "title": "Fix the thing", "author": "someone"}]}
],
"outstanding": [
{"repo": "service-b", "prs": [{"number": 605, "title": "Not in this release"}]}
],
"existing_tags": ["web"]
}
}
| Field | |
|---|---|
repos |
Required. Every repository the release covers, including ones with nothing merged — they still get tagged, so they cannot be inferred from included. |
included |
What is going in, per repo. |
outstanding |
Open pull requests, shown as what is being left behind. |
window_label |
The phrase for the header, e.g. since v1.1.0 (2026-08-20). |
commit_count |
Total across the window; omit when the window is unbounded. |
existing_tags |
Repositories that already carry this version's tag, if you checked. Omit it and the report says would create · existing tag not checked rather than guessing at the one thing it could not look at. |
Tagging still needs a credential. Reading is not writing: --release requires a token whether or not content was supplied.
Extending — custom version backends
Where release state lives is pluggable. blastoff computes versions and tags repos; where the version state is loaded from and where a completed release is recorded is abstracted behind the VersionStore interface (blastoff.stores.VersionStore). The shipped default is FileVersionStore (the file-backed backend above).
To store release state somewhere else — a database, an internal API, a service — implement the three abstract methods and inject your store into the release/hotfix commands:
from blastoff.stores import VersionStore
from blastoff.version_manager import OrgConfig
class MyVersionStore(VersionStore):
def load_org_config(self, alias: str) -> OrgConfig:
"""Return the OrgConfig for `alias` (raise FileNotFoundError if none)."""
...
def save_org_config(self, org: OrgConfig) -> None:
"""Persist an updated OrgConfig back to your backend."""
...
def record_release(
self, org, version, released_at=None, summary=None, changelog=None
) -> None:
"""Record that `version` was released for `org` (called after tagging)."""
...
Both the Release and Hotfix commands expose a version_store attribute; set it before invoking to swap the backend. When left unset it defaults to FileVersionStore, giving you the standalone file-backed behavior described above. blastoff imports nothing from any consumer — the dependency arrow points one way: your code depends on blastoff and implements this interface.
Testing
uv run pytest
The suite is hermetic — no network access required.
Contributing
Contributions are welcome. See CONTRIBUTING.md for setup, the
lint conventions, and the behavioral contracts (dry-run-by-default, idempotent
re-runs, the VersionStore interface) that are easy to break by accident.
Security
blastoff holds a GitHub token with repo scope and can create tags and releases
across every repository carrying a topic. Please report vulnerabilities privately
rather than in a public issue — see SECURITY.md, which also documents
the threat model and what is in scope.
Changelog
See CHANGELOG.md.
Renamed: this package was published as
pixelfuel-blastofffor 0.1.0. It is nowinnoday-blastoff(a separate PyPI project, since PyPI has no rename); the old distribution is yanked. The import package and CLI command are unchanged — both are stillblastoff.
License
MIT — see LICENSE.
To publish a new version to PyPI, see PUBLISHING.md.
Metadata
Release files for innoday-blastoff 0.7.1
For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.
Source distribution (sdist)
| File | Size | Uploaded | |
|---|---|---|---|
| innoday_blastoff-0.7.1.tar.gz | 91.9 kB | Details |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Reset |
|---|---|---|---|---|
| innoday_blastoff-0.7.1-py3-none-any.whl | Python 3 | none | any | Details |
Total release size: 153.8 kB
Release files / innoday_blastoff-0.7.1.tar.gz
| Download URL | innoday_blastoff-0.7.1.tar.gz |
|---|---|
| Size | 91.9 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
1c72cccdde2957ff7850792f4abbb3873b959879cc06a815692ee9c574d43f9b
|
|
BLAKE2b-256 checksum How to use checksums |
34af94a55237f2840529eb97339838c298dcb94763645e7487e7e98b907dca32
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/7.0.0 CPython/3.13.14
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Aug 21, 2026.
Transparency logRelease files / innoday_blastoff-0.7.1-py3-none-any.whl
| Download URL | innoday_blastoff-0.7.1-py3-none-any.whl |
|---|---|
| Size | 61.9 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
fad74a51782e7834cf16bfe17928a7ce2a15bda4c4fd9aa66ae2b6ef06315907
|
|
BLAKE2b-256 checksum How to use checksums |
da84795eecff44040baad371c52345aa5637d3a3b6b61d54c976a2108be3b7cc
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/7.0.0 CPython/3.13.14
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Aug 21, 2026.
Transparency log