Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 3 additions & 2 deletions .github/workflows/release-retry.yml
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
name: Release Retry

# Applying `release:retry` to a MERGED standing PR re-dispatches its publish via release.yml. The
# publish is idempotent (already-published versions are skipped; tags and GitHub releases only
# follow a clean publish), so a retry after a partial failure publishes only the missing packages.
# publish skips versions already on the registry. Batch mode pushes tags and GitHub releases only
# after every package succeeds. Per-package mode may already have pushed tags for packages that
# published; do not delete those. A retry publishes only the packages that did not land.
#
# Listens for `labeled` because label events fire even on merged PRs. Only users with triage
# rights can apply labels, so permission gating comes free.
Expand Down
2 changes: 1 addition & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ Releases run in standing-PR mode with sync versioning; the standing release PR l
Violating these breaks releases.

- **Three-stage pipeline** version → notes → publish. `VersionOutput` (`packages/core/src/types.ts`) is the JSON contract between stages **and** is persisted in standing-PR manifests — new fields must be optional; consumers must tolerate their absence (old manifests live in open PRs).
- **Roll-forward** version bumps land on `main` before publishing. A failed publish is never recovered by reverting version commits — publishes are idempotent (already-published versions skipped; tags/Releases created only after a clean publish) and are retried or superseded next release.
- **Roll-forward** in standing-PR mode the version bump is on `main` before publishing. In direct batch mode it is not pushed until every publish succeeds. Tags are created locally before publishing and pushed after a clean publish (once at the end in batch mode, after each package in per-package mode). A failed publish is never recovered by reverting commits or by deleting a tag whose version was published. Already-published versions are skipped on retry. See `docs/architecture.md`.
- **Release-commit prefix** subjects starting with `chore: release ` match `release.ci.skipPatterns` and suppress standing-PR update runs. Keep the prefix on release commits; nothing else may use it.
- **`[skip ci]` asymmetry** the direct-release commit on `main` (`version.commitMessage`) includes it to stop CI loops — leave it. The standing-PR branch's prep commit must NOT: a squash merge inherits its message, and `[skip ci]` on `main` would suppress the publish workflow.
- **Marker comments** every bot comment is keyed by a distinct HTML marker and posted idempotently (update-in-place). Machine state uses its own marker line — never parse the human prose.
Expand Down
34 changes: 25 additions & 9 deletions docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ A conceptual overview for contributors and users evaluating ReleaseKit.

**notes** consumes `VersionOutput` and renders the `CHANGELOG.md` plus per-release notes (the GitHub release body, or optional per-version files) via Liquid/Handlebars/EJS templates. LLM enhancement is an optional pass inside this stage.

**publish** consumes the same `VersionOutput` and runs: git commit, git tag, `npm publish`, `cargo publish`, post-publish verification, git push, and GitHub Release creation — in that order.
**publish** consumes the same `VersionOutput`. It prepares files, creates the commit and tags locally, publishes to npm, crates.io, and pub.dev, then pushes and creates GitHub Releases. The order depends on the mode — see [Publish order and partial failure](#publish-order-and-partial-failure).

### The `VersionOutput` shape

Expand Down Expand Up @@ -147,19 +147,35 @@ npm and Rust packages can coexist in a single repo under a single config. The ve
- `version.cargo.paths` lists directories containing `Cargo.toml` files (auto-detected by default).
- `publish.npm.enabled` and `publish.cargo.enabled` both default to `true`; set either to `false` to version and tag without publishing ([ADR-0004](./adr/0004-ecosystem-enablement-detection-enables-config-opts-out.md)).

Each Rust crate appears as a named entry in `VersionOutput.updates` alongside npm packages; the publish pipeline runs `cargo publish` per-crate after npm.
Each Rust crate appears as a named entry in `VersionOutput.updates` alongside npm packages. Within a publish step the registries run in order: npm, then crates.io, then pub.dev.

---

## Idempotency guarantees
## Publish order and partial failure

The publish stage is safe to retry after a partial failure:
Direct `release` computes versions in memory, then runs the scope, skip-pattern, and minimum-change guards. It writes manifests, then changelogs, and only then starts publish.

- Packages already at the target version on the registry are skipped.
- Git tags that already exist are not recreated (the commit is still pushed if missing).
- GitHub Releases that already exist for a tag are not duplicated.
Publish always prepares first (copy files, rewrite `Cargo.toml`, sync `Cargo.lock`) and creates the release commit and **every tag locally, before any registry publish**. In standing-PR mode the squash merge already put that commit on the remote, so publish only creates tags.

The pipeline is **fail-fast** within a single run: the first registry failure stops immediately and defers the git push, so the version commit and tag remain local until the issue is fixed and the command is retried.
The run is **fail-fast**: the first registry failure stops the pipeline.

**Batch mode** — no update carries its own `tag` (sync with one shared tag):

1. Publish npm, then crates.io, then pub.dev.
2. Verify. Verification only warns.
3. Only if every publish succeeded: push the branch, then the tags, then create GitHub Releases.

Nothing is pushed on failure. In direct mode the version bump is **not** on `main`. Packages that already published stay on the registry. Re-run; those versions are skipped. Do not delete a tag for a version that was published.

**Per-package mode** — any update has a `tag` (single, async, groups, or sync with `packageSpecificTags`):

For each update: publish that package (npm, then crates.io, then pub.dev) → verify → push the branch → push its tag. GitHub Releases are created only after every package succeeds.

On failure, earlier packages' tags **and the release commit are already on the remote**, and no package has a GitHub Release. Do not delete those tags. Roll forward with the next release.

**Standing-PR mode** uses the same batch or per-package publish, but the version commit is already on `main` from the merge. Recover with the `release:retry` label on the merged standing PR (or `standing-pr publish --pr`). Do not delete a tag whose version was published.

Already-published versions are skipped on a later run. A local tag that points at the current commit is not recreated, and an existing GitHub Release for a tag is not duplicated. A retry never reverts a commit or deletes a tag.

---

Expand All @@ -170,7 +186,7 @@ Given the git tags, the commits since the last tag, and `releasekit.config.json`
1. Computes the next semver for each package from conventional commits (or a forced `--bump`).
2. Writes updated manifest files and emits `VersionOutput`.
3. Renders changelogs and optional release notes from `VersionOutput`, optionally passing them through an LLM.
4. Creates a git commit and tags, pushes to the remote, publishes each package to npm and/or crates.io, verifies the publish, and creates a GitHub Release.
4. Publishes, then pushes and creates GitHub Releases, in the order in [Publish order and partial failure](#publish-order-and-partial-failure). Tags are created locally before publishing and pushed only after a clean publish (once, at the end, in batch mode; after each package in per-package mode).

If the head commit message matches a skip pattern (default: `chore: release `), ReleaseKit exits early so that its own release commits do not trigger an infinite loop. If there are no releasable commits since the last tag, it exits with code 0 and skips all remaining steps.

Expand Down
34 changes: 13 additions & 21 deletions docs/troubleshooting.md
Original file line number Diff line number Diff line change
Expand Up @@ -162,16 +162,12 @@ env:

**Symptom:** The publish stage fails with `error: tag vX.Y.Z already exists` when creating git tags.

**Cause:** The tag was created locally during a previous partial run but the push failed (or was never attempted). The tag exists locally but not on the remote, or it exists on both after a prior partial publish.
**Cause:** A previous run already created the tag. Tags are created locally before publishing. In batch mode a failed run leaves them local only. In per-package mode a package that published has already had its tag pushed.

**Fix:** Delete the tag locally and on the remote, then re-run:
**Fix:** Do not delete a tag whose version was published. Confirm with `git ls-remote --tags origin`.

```bash
git tag -d vX.Y.Z
git push origin :refs/tags/vX.Y.Z
```

Confirm with `git ls-remote --tags origin` before re-running the release.
- **Tag only exists locally** (batch failure before the push): delete that local tag (`git tag -d vX.Y.Z`) if it points at a different commit, then re-run **`publish`** with the original version output. Do not re-run `release` on that commit: its subject matches `release.ci.skipPatterns` (`chore: release `), so `release` exits before publishing. Re-running the failed workflow job checks out the pre-release commit and is the right direct-mode retry.
- **Tag exists on the remote:** the version was published. Leave the tag. Re-run a batch publish (already-published versions are skipped) or roll forward — see [Recovering from a failed publish](#recovering-from-a-failed-publish).

---

Expand Down Expand Up @@ -224,17 +220,13 @@ There is no OIDC equivalent for crates.io at this time.

## Recovering from a failed publish

A publish run can fail partway through: some packages reach the registry, the next one errors, and the run stops. ReleaseKit is built so this state is **recoverable by retrying** — you never have to revert version commits or hand-clean the registry.

### The roll-forward model

Version bumps are committed to `main` (or squash-merged from the standing PR) **before** publishing begins. ReleaseKit never rewrites that history to "undo" a failed publish. Instead, the release rolls **forward**: you retry the same release, or you supersede it with the next one.
A publish run can fail partway through: some packages reach the registry, the next one errors, and the run stops. ReleaseKit never rewrites history to undo that. Already-published versions are skipped on a later run. Do not delete a tag for a version that was published.

Concretely, a partial-publish failure leaves the repository in this state:
What was pushed depends on the mode. The order is in [Publish order and partial failure](./architecture.md#publish-order-and-partial-failure).

- **Versions are already on `main`** — the bumped `package.json` / `Cargo.toml` files and changelogs are committed.
- **Some packages are on the registry** — every package the pipeline reached before the failure is published at its new version.
- **No tags, no GitHub releases** — consumer tags and GitHub releases are only created **after** the publish succeeds, so a failed run leaves none of them behind. There is nothing half-created to clean up.
- **Batch mode** (one shared tag): nothing is pushed. In a direct release the version commit is still local. Re-run; packages already on the registry are skipped, then the branch, tags, and GitHub Releases are pushed.
- **Per-package mode** (each update has its own tag): the release commit and the tags of packages that published are already on the remote. No GitHub Release was created. Roll forward with the next release. Do not delete those tags.
- **Standing-PR mode:** the merge already put the version commit on `main`. Add `release:retry` to the merged standing PR (below). Do not delete a tag whose version was published.

### The failure report

Expand All @@ -248,7 +240,7 @@ The report contains a per-package ledger (✅ published / ⏭ skipped / ❌ fail

### Recovery path 1 — retry (reconcile the same release)

Re-run the publish for the failed release. Because the publish path **skips versions already on the registry** and only creates tags / GitHub releases after a clean publish, retrying simply re-publishes the packages that did not land the first time and then creates the tags and releases.
Re-run when the failed run was batch mode or a standing-PR publish. The publish path skips versions already on the registry. In batch mode the retry then pushes the branch, tags, and GitHub Releases. In per-package mode the commit and earlier tags are already on the remote — roll forward instead of trying to undo them.

- **Standing-pr mode:** add the **`release:retry`** label to the merged standing PR. A workflow validates that the PR is a merged standing PR, dispatches the manifest-driven publish for it, and removes the label — so each application is exactly one retry and you can re-apply the label to retry again. (Applying it to a non-standing or unmerged PR is a no-op: the workflow leaves a comment explaining why and removes the label.) Equivalently, dispatch the release workflow yourself targeting the merged standing PR (`standing-pr publish --pr <number>`). The label name is configurable via `ci.labels.retry`.
- **Direct / label mode:** use GitHub's **"Re-run failed jobs"** on the failed workflow run.
Expand All @@ -260,15 +252,15 @@ A successful retry marks the failure report resolved and clears the supersede wa
If you would rather move on, **merge the next standing PR**. In sync mode the release partially self-heals at the new version:

- Every package — including the ones that failed before — publishes at the new version.
- The failed release's baseline tag was never pushed, so the next changelog correctly spans both releases' commits.
- In batch mode the failed release's tag was never pushed, so the next changelog spans both releases' commits. In per-package mode, tags for packages that did publish are already on the remote; leave them.

The remaining gap is cosmetic: there is no tag or GitHub release for the **failed** version, and the registry has a version hole for the packages that didn't publish in the failed run. Nothing is broken; the history simply skips a version for those packages.
The remaining gap is cosmetic: there may be no GitHub Release for the failed version, and the registry has a version hole for the packages that didn't publish. Nothing is broken; the history simply skips a version for those packages.

### The supersede warning on the next standing PR

So a failed publish does not silently fade away, ReleaseKit warns while a partially-published release remains unresolved. The **next** standing PR's body and the release preview include a block such as:

> ⚠️ **Previous release v0.24.0 is partially published** (2/4 packages on the registry; no tags/GitHub release created). Retry it via `release:retry` on the merged standing PR (#42), **or** merging this PR supersedes it — the next release re-publishes everything at the new version.
> ⚠️ **Previous release v0.24.0 is partially published** (2/4 packages on the registry). Retry it via `release:retry` on the merged standing PR (#42), **or** merging this PR supersedes it — the next release publishes the rest at the new version. Do not delete tags for versions that were published.

This gives you an honest choice between the two recovery paths. The warning is keyed off the failure report on the most recently merged standing PR and its resolved status, so it clears automatically once the failed release is retried successfully or superseded by the next release.

Expand Down
16 changes: 3 additions & 13 deletions packages/publish/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -51,19 +51,9 @@ releasekit-publish --input version-output.json

## Pipeline Stages

The publish pipeline runs in order:
Publish prepares files, creates the commit and tags locally, then publishes. What happens after a failure depends on the mode. The full order — batch, per-package, and standing-PR — is in [Publish order and partial failure](../../docs/architecture.md#publish-order-and-partial-failure).

1. **Input** - Parse version JSON from stdin or file
2. **Prepare** - Copy files (e.g., LICENSE), update Cargo.toml versions
3. **Git Commit** - Create version bump commit
4. **Git Tag** - Create git tags for each package
5. **npm Publish** - Publish to npm registry
6. **Cargo Publish** - Publish to crates.io
7. **Verify** - Verify packages are available on registries
8. **Git Push** - Push commits and tags to remote
9. **GitHub Release** - Create GitHub releases

The pipeline is **fail-fast**: the first package publish failure throws immediately. Git push and GitHub release are skipped, so the version commit and tag remain local until the issue is fixed and the release is retried.
Registries run npm, then crates.io, then pub.dev. The first registry failure stops the run. Already-published versions are skipped on a re-run. Do not delete a tag for a version that was published.

### Auto-retry for transient registry errors

Expand All @@ -75,7 +65,7 @@ Before failing the stage, the npm and cargo publish steps automatically retry **
|------|-------------|---------|
| `--input <path>` | Path to version output JSON | stdin |
| `--config <path>` | Path to releasekit config | `releasekit.config.json` |
| `--registry <type>` | Registry to publish to: `npm`, `cargo`, `all` | `all` |
| `--registry <type>` | Registry to publish to: `npm`, `cargo`, `pub`, `all` | `all` |
| `--npm-auth <method>` | NPM auth method: `oidc`, `token`, `auto` | `auto` |
| `--dry-run` | Simulate all operations | `false` |
| `--skip-git` | Skip git commit/tag/push (also skips GitHub release — no tag to release against) | `false` |
Expand Down
2 changes: 1 addition & 1 deletion packages/release/docs/ci-setup.md
Original file line number Diff line number Diff line change
Expand Up @@ -778,7 +778,7 @@ Result: the standing PR is up-to-date by the time the workflow exits — no stal

#### Retrying a failed publish

If a standing-PR publish fails partway through (some packages on the registry, no tags/GitHub release), add the **`release:retry`** label to the **merged** standing PR. The `retry-publish` job in the workflow template above:
If a standing-PR publish fails partway through, add the **`release:retry`** label to the **merged** standing PR. The version commit is already on `main`. GitHub Releases are created only after every package succeeds. In per-package mode, tags for packages that published may already be on the remote — do not delete them. The `retry-publish` job in the workflow template above:

1. Validates via its `if` guard that the PR is a merged standing PR and the label is `release:retry` — note the guard hardcodes the label name (workflow `if` expressions can't read releasekit config), so renaming the label via `ci.labels.retry` requires updating the guard to match.
2. Re-runs the manifest-driven publish for that PR (`standing-pr publish --pr <n>`) — idempotent, so it re-publishes only the packages that did not land, then pushes tags and creates GitHub releases.
Expand Down
Loading
Loading