Metadata-Version: 2.4
Name: pixelfuel-blastoff
Version: 0.1.0
Summary: Release management CLI for Haviland Software projects
Author-email: Karl Haviland <me@karlhaviland.com>
License-Expression: MIT
Project-URL: Homepage, https://github.com/havilandsoftware/pixelfuel-blastoff
Project-URL: Repository, https://github.com/havilandsoftware/pixelfuel-blastoff
Project-URL: Issues, https://github.com/havilandsoftware/pixelfuel-blastoff/issues
Keywords: release,deployment,github,cli,versioning,semver
Classifier: Development Status :: 4 - Beta
Classifier: Intended Audience :: Developers
Classifier: Environment :: Console
Classifier: Operating System :: OS Independent
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3.8
Classifier: Programming Language :: Python :: 3.9
Classifier: Programming Language :: Python :: 3.10
Classifier: Programming Language :: Python :: 3.11
Classifier: Programming Language :: Python :: 3.12
Classifier: Topic :: Software Development :: Build Tools
Classifier: Topic :: Software Development :: Version Control
Requires-Python: >=3.8
Description-Content-Type: text/markdown
License-File: LICENSE
Requires-Dist: python-dotenv
Requires-Dist: plumbum
Requires-Dist: requests
Requires-Dist: pytz
Requires-Dist: anthropic
Requires-Dist: pyyaml
Dynamic: license-file

# pixelfuel-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:** `pixelfuel-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 `VersionStore` to keep it anywhere (see [Extending](#extending--custom-version-backends)).

## Install

With `pip`:

```bash
pip install pixelfuel-blastoff
```

With [`uv`](https://docs.astral.sh/uv/):

```bash
uv pip install pixelfuel-blastoff
# or add it to a project
uv add pixelfuel-blastoff
```

The distribution is named `pixelfuel-blastoff`, but the CLI and the import package are both `blastoff`:

```bash
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.

```bash
# 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` — override `GH_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.

```bash
# 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):

```bash
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:

```bash
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 a local file. blastoff looks for two sources, in order, walking up from the current directory:

1. **`.innoday/project.yml`** — a project config file blastoff reads if present. Add a `release_configs` list to it (this is optional; it exists so blastoff can share a project's config file rather than requiring a separate one).
2. **`org-versions.json`** — the standalone fallback, holding local per-machine release state.

The resolution logic lives in `blastoff/config_loader.py`, and the default file backend is `blastoff/stores/file_store.py`.

### `org-versions.json`

Create it in your project root (or any parent directory), with one entry per alias:

```json
{
  "organizations": [
    {
      "alias": "my-app",
      "organization": "my-github-org",
      "label": "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. |
| `label` | yes | GitHub topic that selects the repos to release. |
| `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.

### `.innoday/project.yml` — `release_configs`

If you keep a `.innoday/project.yml`, blastoff will read a `release_configs` list from it (checked before `org-versions.json`), with the same fields per alias:

```yaml
release_configs:
  - alias: my-app
    organization: my-github-org
    label: my-release-topic
    next_version: v1.2.0
    prerelease: null
    last_released: null
    last_released_version: null
    last_hotfix: null
    hotfix_mode: increment
```

## 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:

```python
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

```bash
uv run pytest
```

## License

MIT — see [LICENSE](LICENSE).

To publish a new version to PyPI, see [PUBLISHING.md](PUBLISHING.md).
