Skip to content
Closed
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
117 changes: 117 additions & 0 deletions .claude/skills/releasing/SKILL.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
---
name: releasing
description: Cut and ship a new release of the `mergify` CLI — build the draft release with its platform binaries, publish it, and verify PyPI and the Homebrew tap picked it up. Use when asked to release, cut a release, ship a new version, publish a release, tag a version, or when a release run failed and needs recovery. Triggers on "release", "new version", "cut a release", "ship it", "publish the release", "bump the version", "release failed", "PyPI publish failed".
---

# Releasing `mergify-cli`

Everything is driven by `.github/workflows/release.yml`. **There is no version
to bump in any file** — `pyproject.toml` and `Cargo.toml` keep their placeholder
versions and the workflow stamps the tag in at build time. Never open a "release
prep" PR.

Versions are calver: `YYYY.M.D.N` (no zero padding, `N` starts at 1 each UTC day).

`RELEASING.md` at the repo root is the human-facing runbook and explains *why*
the flow is shaped this way (GitHub's immutable-releases policy). Read it when
something goes wrong or the workflow itself needs changing.

## Guardrails

- **Never run `gh release create` or push a tag by hand.** A release created
outside the workflow has no binaries, and once published it is immutable — the
asset assertion then permanently blocks the PyPI publish for that version.
- **Never click / script "Draft a new release" in the Releases UI.** Same trap.
- **Stage 2 (Publish) is irreversible and outward-facing** — it locks the release
and pushes to PyPI. Always get the user's explicit go-ahead before publishing,
even if they already asked for "a release"; report the draft URL and stop.
- Stage 1 is safe and repeatable: a draft can be deleted and rebuilt.

## Stage 1 — build the draft

Pre-flight (report anything red, don't silently proceed):

```shell
gh run list --workflow=ci.yaml --branch main --limit 1 # main is green
gh release list --limit 3 # no leftover draft
git log --oneline $(git describe --tags --abbrev=0)..origin/main # what ships
```

Trigger it:

```shell
gh workflow run release.yml # auto-picks YYYY.M.D.<next>
# or, only when a specific version is needed:
gh workflow run release.yml -f tag=2026.9.4.1 -f target_commitish=<sha>
```

Leave `tag` empty unless the user asked for a specific version; leave
`target_commitish` empty unless cherry-picking a release off an older line.

Watch it (~5–10 min):

```shell
gh run list --workflow=release.yml --limit 1
gh run watch <run-id> --exit-status
```

It builds the five-target wheel matrix, extracts the `mergify` binary from each
wheel, packages the archives + `SHA256SUMS`, dumps `cli-schema.json`, signs the
binaries with build provenance, and creates the **draft** release with notes
generated from the PRs merged since the previous tag.

Then verify — the draft must carry exactly these seven assets:

```shell
tag=$(gh release list --limit 1 --json tagName --jq '.[0].tagName')
gh release view "$tag" --json isDraft,url,assets --jq '{draft:.isDraft,url:.url,assets:[.assets[].name]}'
```

- `mergify-<tag>-x86_64-unknown-linux-gnu.tar.gz`
- `mergify-<tag>-aarch64-unknown-linux-gnu.tar.gz`
- `mergify-<tag>-x86_64-apple-darwin.tar.gz`
- `mergify-<tag>-aarch64-apple-darwin.tar.gz`
- `mergify-<tag>-x86_64-pc-windows-msvc.zip`
- `SHA256SUMS`
- `cli-schema.json`

Give the user the draft URL and the generated notes to review. **Stop here.**

## Stage 2 — publish

Only after the user explicitly says to publish:

```shell
gh release edit "$tag" --draft=false --latest
```

That fires `release: published`, which re-asserts the seven assets, rebuilds the
wheels with the same version stamp, and pushes to PyPI via Trusted Publishing
(~5–10 min). Watch the `release` run the same way as in stage 1.

Note edits must happen *before* this (`gh release edit "$tag" --notes-file
notes.md`) — drafts are mutable, published releases are not.

## Verify after publishing

```shell
gh run list --workflow=release.yml --limit 2 # publish job green
curl -s https://pypi.org/pypi/mergify-cli/json | jq -r .info.version
gh pr list --repo Mergifyio/homebrew-tap --author mergify-ci-bot
```

The Homebrew formula bump is automatic: the `homebrew-tap-sync` workflow in
`Mergifyio/mergify-ci-bot` opens a PR against `Mergifyio/homebrew-tap` within
~20 minutes of publish, updating `RELEASE` and the four per-arch checksums from
`SHA256SUMS`. It still needs a human to merge it. The docs site picks up
`cli-schema.json` off the latest release on its own — nothing to do there.

## Recovery

| Symptom | Fix |
|---|---|
| Stage 1 failed mid-run | No release exists yet, or a draft does. Delete the draft (`gh release delete <tag> --cleanup-tag`) and re-run stage 1. |
| Draft missing assets | Delete the draft and re-run stage 1 — never backfill by hand. |
| `assert-binaries-present` failed after publish | The release was created outside the workflow and is now immutable. Delete the release *and* its tag, then re-run stage 1 with the same tag. |
| PyPI publish failed (transient / outage) | Wheels are built; re-run just the failed job: `gh run rerun <run-id> --failed`. |
| Wrong version already on PyPI | PyPI versions can't be reused or overwritten. Ship the next `N` — do not try to reuse the tag. |
90 changes: 81 additions & 9 deletions crates/mergify-ci/src/junit_process/command.rs
Original file line number Diff line number Diff line change
Expand Up @@ -171,12 +171,12 @@ async fn run_with_cap(
.map(|c| c.name.clone())
.collect(),
};
let built = spans::build_traces(&parsed, &metadata);
let mut built = spans::build_traces(&parsed, &metadata);

// Cap each gzipped upload at MAX_GZIPPED_UPLOAD_BYTES. A normal
// report is one chunk (byte-identical to before); only an
// oversized payload fans out into several uploads.
let (chunks, oversized_cases, mut upload_error) =
let (chunks, mut oversized_cases, mut upload_error) =
match split::split_request(&built.request, upload_cap) {
Ok(outcome) => (outcome.chunks, outcome.oversized_cases, None),
// gzip is an in-memory write and effectively never fails,
Expand All @@ -192,6 +192,16 @@ async fn run_with_cap(
),
};

// Cases the span builder refused on name length join the ones the split
// refused on payload size: from the user's side both are "this result was
// not uploaded", and one list is what they need to act on.
//
// Moved, not cloned. Every string in here is over MAX_TEST_NAME_BYTES --
// that is why it was refused -- so copying them would allocate another
// 65 kB apiece on the one path guaranteed to be holding the biggest names
// in the run. `built` is not read for this field again.
oversized_cases.append(&mut built.oversized_case_names);

let client = upload::default_client();
// Nothing reached the backend when the split produced no chunks
// (every case individually oversized) or gzip failed. Captured
Expand Down Expand Up @@ -603,10 +613,16 @@ fn gha_oversized_annotation(names: &[String]) -> Option<String> {
return None;
}
Some(format!(
"::warning title=Mergify Test Insights::{n} test result(s) exceeded the upload size \
limit and were skipped: {names}. The rest of the run was uploaded.",
"::warning title=Mergify Test Insights::{n} test result(s) were too large to upload \
and were skipped: {names}. The rest of the run was uploaded.",
n = names.len(),
names = gha_escape_data(&names.join(", ")),
names = gha_escape_data(
&names
.iter()
.map(|n| display_name(n))
.collect::<Vec<_>>()
.join(", "),
),
))
}

Expand Down Expand Up @@ -640,14 +656,33 @@ fn write_upload_error_block(out: &mut String, error: &str, rejected: bool) {
/// is no upload it can fit into. The CI verdict is unaffected (it's
/// computed from the parsed cases, not the upload), so this is a
/// best-effort data-loss notice, not a failure.
/// How much of a skipped test's name the report prints. A name can be the
/// reason it was skipped, so printing it whole would bury the report under
/// the same 65 kB that caused the problem.
const SKIPPED_NAME_DISPLAY_BYTES: usize = 120;

/// `name` cut to [`SKIPPED_NAME_DISPLAY_BYTES`] on a character boundary,
/// marked when it was cut so nobody copies the prefix as the real name.
fn display_name(name: &str) -> String {
if name.len() <= SKIPPED_NAME_DISPLAY_BYTES {
return name.to_string();
}
let mut end = SKIPPED_NAME_DISPLAY_BYTES;
while end > 0 && !name.is_char_boundary(end) {
end -= 1;
}
format!("{}… ({} bytes total)", &name[..end], name.len())
}

fn write_oversized_cases(out: &mut String, names: &[String]) {
out.push_str("\n ⚠️ Some test results were too large to upload\n");
out.push_str(" A single test's output exceeded the upload size limit and was skipped.\n");
out.push_str("\n ⚠️ Some test results were skipped\n");
out.push_str(" A test whose name or output is too large to upload is skipped; the rest\n");
out.push_str(" of the run was uploaded. Rename the test to get its history back.\n");
out.push_str(" Quarantine status and CI outcome are unaffected.\n");
out.push('\n');
out.push_str(" ┌ Skipped\n");
for name in names {
out.push_str(&format!(" │ {name}\n"));
out.push_str(&format!(" │ {}\n", display_name(name)));
}
out.push_str(" └─\n");
}
Expand Down Expand Up @@ -1025,7 +1060,44 @@ mod tests {
.unwrap();
assert!(ann.starts_with("::warning"), "{ann}");
assert!(ann.contains("a.big, b.huge"), "{ann}");
assert!(ann.contains("exceeded the upload size limit"), "{ann}");
assert!(ann.contains("too large to upload"), "{ann}");
}

/// A name can itself be the reason a case was skipped, so neither the
/// report nor the annotation may print it whole -- 65 kB of generated
/// title would bury the very message telling you which test to rename.
#[test]
fn a_skipped_name_is_truncated_for_display() {
let long = "z".repeat(super::SKIPPED_NAME_DISPLAY_BYTES + 500);

let shown = display_name(&long);
assert!(shown.len() < long.len(), "{shown}");
// Marked as cut, and carrying the real size, so nobody copies the
// prefix believing it is the test's name.
assert!(shown.contains('…'), "{shown}");
assert!(
shown.contains(&format!("{} bytes total", long.len())),
"{shown}"
);

let mut report = String::new();
write_oversized_cases(&mut report, std::slice::from_ref(&long));
assert!(!report.contains(&long), "the report printed the whole name");

let ann = temp_env::with_var("GITHUB_ACTIONS", Some("true"), || {
gha_oversized_annotation(std::slice::from_ref(&long))
})
.unwrap();
assert!(
!ann.contains(&long),
"the annotation printed the whole name"
);
}

/// A short name is printed exactly, with no truncation marker.
#[test]
fn a_short_name_is_printed_verbatim() {
assert_eq!(display_name("tests.test_a"), "tests.test_a");
}

// ── End-to-end orchestrator tests. Drive the full `run()`
Expand Down
Loading
Loading