diff --git a/.github/workflows/pr-title-lint.yml b/.github/workflows/pr-title-lint.yml index bbbff15c..f1358eb9 100644 --- a/.github/workflows/pr-title-lint.yml +++ b/.github/workflows/pr-title-lint.yml @@ -4,7 +4,8 @@ name: Lint PR title # docs/CONTRIBUTING.md. Since PRs are squash-merged with the PR title as the # squash commit's title (see the "squash merge" note in docs/CONTRIBUTING.md), # this is what actually lands in main's history -- and what -# scripts/cut-release.sh's breaking-change scan reads. +# .github/workflows/release-please.yml reads to determine version bumps and +# CHANGELOG.md entries (see docs/RELEASING.md). # # pull_request_target (not pull_request) so this also runs for PRs from # forks: it only reads the PR title from the base repo's workflow config, it @@ -32,15 +33,19 @@ jobs: # Derives the enhancement/bug/breaking-change/documentation label from the # PR title so it doesn't have to be kept in sync by hand -- these feed - # .github/release.yml's auto-generated release-note categories. Only ever - # adds a label, never removes one, so a title edit won't strip a label a - # human applied for an unrelated reason (e.g. good first issue). + # .github/release.yml's categories, the fallback used only when notes are + # generated by hand (gh release create --generate-notes); the primary + # changelog is release-please.yml's CHANGELOG.md, which reads the PR title + # directly rather than this label. Only ever adds a label, never removes + # one, so a title edit won't strip a label a human applied for an + # unrelated reason (e.g. good first issue). # # Note: "!" here means the PR breaks osmdiffs' OUTPUT SCHEMA # (conflated.parquet), not that it breaks some public API -- see # docs/CONTRIBUTING.md and docs/RELEASING.md's "Choosing the version - # number". It's a hint for scripts/cut-release.sh, not a substitute for the - # human judgment call that document asks for. + # number". release-please.yml is what actually turns this into a version + # bump; this label isn't a substitute for the human judgment call + # RELEASING.md asks for when merging its release PR. # # No third-party action for this: the one commonly suggested for it # (bcoe/conventional-release-labels) only detects breaking changes via a diff --git a/.github/workflows/release-please.yml b/.github/workflows/release-please.yml new file mode 100644 index 00000000..736353bb --- /dev/null +++ b/.github/workflows/release-please.yml @@ -0,0 +1,48 @@ +name: release-please + +# Maintains a continuously-updated "release PR" (version bump + CHANGELOG.md +# entries, driven by Conventional-Commits PR titles -- see +# docs/CONTRIBUTING.md#pr-titles-conventional-commits) and, once that PR is +# merged, tags the resulting commit and publishes the GitHub Release. See +# docs/RELEASING.md for the full process this fits into, including why "!" +# means OUTPUT-SCHEMA-breaking here, not API-breaking. +# +# Runs as the account-wide "brawer-release-bot" GitHub App (already used the +# same way by brawer/osmviews, brawer/osmviews-rs and brawer/osmviews-py) +# rather than the built-in GITHUB_TOKEN: workflow runs caused by the default +# GITHUB_TOKEN don't trigger other workflow runs (GitHub does this +# deliberately, to prevent recursive-workflow loops -- see +# https://docs.github.com/en/actions/reference/workflows-and-actions/events-that-trigger-workflows#triggering-a-workflow-from-a-workflow). +# Without the App identity, the release PR this action opens would never get +# test.yml's "Execute unit and integration tests" run against it (that +# workflow triggers on `pull_request`), so it could never clear the +# required-status-check the branch ruleset demands, and could never be +# merged. See "Setting up the release-please App" in docs/RELEASING.md for +# how this was installed on this repo. +on: + push: + branches: [main] + workflow_dispatch: # kick a run by hand if a push-triggered one was skipped + +permissions: {} + +jobs: + release-please: + name: Open/update the release PR, or publish a release + runs-on: ubuntu-latest + permissions: {} + # Skip (rather than fail every push to main) until the App is installed + # on this repo and its client ID is set -- see docs/RELEASING.md. + if: vars.RELEASE_PLEASE_APP_CLIENT_ID != '' + steps: + - uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0 + id: app-token + with: + client-id: ${{ vars.RELEASE_PLEASE_APP_CLIENT_ID }} + private-key: ${{ secrets.RELEASE_PLEASE_APP_PRIVATE_KEY }} + permission-contents: write # tag + GitHub release + permission-pull-requests: write # open and update the release PR + + - uses: googleapis/release-please-action@45996ed1f6d02564a971a2fa1b5860e934307cf7 # v5.0.0 + with: + token: ${{ steps.app-token.outputs.token }} diff --git a/.release-please-manifest.json b/.release-please-manifest.json new file mode 100644 index 00000000..2a248c79 --- /dev/null +++ b/.release-please-manifest.json @@ -0,0 +1,3 @@ +{ + ".": "0.8.3" +} diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 00000000..389ff7b7 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,13 @@ +# Changelog + +Maintained automatically by +[release-please](https://github.com/googleapis/release-please) from +Conventional-Commits PR titles (see +[`docs/CONTRIBUTING.md`](docs/CONTRIBUTING.md#pr-titles-conventional-commits) +and [`docs/RELEASING.md`](docs/RELEASING.md)) — don't hand-edit entries for +released versions. + +This file starts from the first release cut after release-please was +adopted. For earlier releases (up to and including +[v0.8.3](https://github.com/brawer/osmdiffs/releases/tag/v0.8.3)), see the +[GitHub Releases page](https://github.com/brawer/osmdiffs/releases). diff --git a/docs/CONTRIBUTING.md b/docs/CONTRIBUTING.md index fc378e2b..5346fb32 100644 --- a/docs/CONTRIBUTING.md +++ b/docs/CONTRIBUTING.md @@ -68,8 +68,9 @@ breaks the output schema*, per the rules in — not that some function signature changed. A `refactor!:` or `chore!:` that happens to rename a Parquet column is exactly as `!` as a `feat!:` that removes one; a `feat:` CLI flag that doesn’t touch the schema isn’t -`!` at all. `cut-release.sh` reads these markers to *suggest* a version -floor, but the actual version is still your call, per `RELEASING.md`. +`!` at all. `release-please` reads these markers to determine the next +version's bump — see “How release-please's version bumps map to this +rule” in `RELEASING.md` for exactly how. ## Where to go next diff --git a/docs/RELEASING.md b/docs/RELEASING.md index 273a89ff..cecaf15e 100644 --- a/docs/RELEASING.md +++ b/docs/RELEASING.md @@ -7,16 +7,27 @@ This document is the practical how-to for cutting a release of ## Quick start -```sh -./scripts/cut-release.sh vX.Y.Z -``` +[`release-please`](https://github.com/googleapis/release-please) keeps a +"release PR" continuously open against `main`, titled something like +"chore(main): release 0.8.4", with `Cargo.toml`/`Cargo.lock`'s version +bump and the new `CHANGELOG.md` entries already prepared from merged PR +titles. To cut a release: -Run it from a clean, up-to-date `main` checkout. It handles everything -else: bumping the version, opening and merging the PR for that, tagging, -and publishing the release. See “What happens automatically” below for -the full sequence — but read “Choosing the version number” first, it’s -the one part of this that actually needs a careful decision, not just -running a command. +1. Open that PR, sanity-check the version number it proposes against + “Choosing the version number” below, and merge it like any other PR + (it goes through the normal required checks and merge queue). +2. Once merged, `release-please` tags that commit and publishes the + GitHub Release (with the same notes) on its own — nothing further to + run. +3. Optionally, verify the release actually came out right (build, + SBOM, attestations): + ```sh + ./scripts/verify-release.sh vX.Y.Z + ``` + +See “What happens automatically” below for the full sequence — but read +“Choosing the version number” first, it’s the one part of this that +actually needs a careful decision, not just clicking merge. ## Choosing the version number @@ -60,51 +71,80 @@ bumps for breaking changes once there’s an actual 1.0.0 to break compatibility with — i.e. once this pipeline has real downstream consumers depending on schema stability, not before. -`cut-release.sh` gives you one piece of help with this call, not a -replacement for it: it scans merged PR titles since the last tag for a -Conventional Commits `!` marker (see -[`CONTRIBUTING.md`](CONTRIBUTING.md#pr-titles-conventional-commits) — on -this project `!` means *output-schema-breaking*, not API-breaking) and -prints what it finds before asking you to confirm the version. Treat it -as a “did you forget something” prompt: false positives and false -negatives are both possible, since a PR’s type is chosen by whoever wrote -the title, not by inspecting the schema diff. +### How release-please's version bumps map to this rule + +`release-please` derives version bumps from Conventional-Commits PR +titles, not from a schema diff — so the mapping in +[`release-please-config.json`](../release-please-config.json) has to +reproduce the two-tier, pre-1.0 rule above using only the commit types +and the `!` marker: + +- `"bump-minor-pre-major": true` — a `!`-marked (breaking, i.e. + *output-schema*-breaking per + [`CONTRIBUTING.md`](CONTRIBUTING.md#pr-titles-conventional-commits)) + commit bumps **minor** instead of major, matching “before 1.0.0, a + schema-breaking release bumps minor” above. +- `"bump-patch-for-minor-pre-major": true` — a non-breaking `feat:` + commit bumps **patch** instead of minor. This isn't a compromise: it's + the same “anything else bumps patch” rule above, exactly. Pre-1.0.0 + there's no separate slot left for “schema evolved compatibly” once + breaking has claimed minor — that's the whole point of the two-tier + collapse, not a gap release-please forces on us. + +Net effect while we're at `0.y.z`: a `!` commit → minor, everything else +(`feat:`, `fix:`, `chore:`, ...) → patch. This is exactly the existing +convention, just executed by config instead of by the old +`cut-release.sh` script's best-effort PR-title scan (that script is +gone; `release-please` reads the same `!` marker directly from commit +history instead). + +This still leans entirely on contributors choosing PR titles correctly — +`release-please` only ever sees the type, never the schema diff. Before +merging the release PR, skim the commits it's based on (linked from the +PR body) against “Choosing the version number” above; if a title was +wrong, edit `Cargo.toml`'s version and the `CHANGELOG.md` entry directly +in the release PR before merging — `release-please` treats that edit as +authoritative on its next run. Once this pipeline has real downstream +consumers depending on schema stability (i.e. we cut an actual 1.0.0), +both flags should be revisited: an ordinary `feat:` would then bump +minor again, and something more deliberate (a `Release-As:` footer, or a +third `!`-only category) would be needed to keep pre-1.0's collapsed +“anything else” tier from silently becoming three real tiers. ## What happens automatically, step by step -Once you run `cut-release.sh vX.Y.Z`: - -1. **Preconditions are checked**: you’re on a clean, up-to-date `main`; - `vX.Y.Z` doesn’t already exist; it’s actually newer than `Cargo.toml`’s - current version; the latest CI run on `main`’s current commit passed. - Any failure here stops immediately, before anything is pushed. -2. **A version-bump PR is opened**: `Cargo.toml`’s version (and - `Cargo.lock`’s self-entry) is bumped on a new branch, and a PR titled - “Bump version to X.Y.Z” is opened against `main`. `main` is protected - (PR required, status checks required, merge queue), so this can’t be - pushed directly. -3. **The script waits** for that PR to clear required checks and the - merge queue, and actually land on `main`. This normally takes a few - minutes (the required test job takes ~4–5 min; the merge queue has a - minimum 3 min wait). -4. **The release is created**: a GitHub Release for `vX.Y.Z` is published - at the new `main` HEAD, with auto-generated notes (categorized per - [`.github/release.yml`](../.github/release.yml)’s label rules). This - creates the underlying git tag as a side effect, and the release is - immutable from this point on (see +1. **Every merge to `main` updates the release PR.** + [`.github/workflows/release-please.yml`](../.github/workflows/release-please.yml) + runs [`release-please`](https://github.com/googleapis/release-please) + on every push to `main`. It parses Conventional-Commits PR titles + since the last release, and keeps one PR open (creating it if it + doesn't exist yet) with `Cargo.toml`/`Cargo.lock`'s version bumped and + the new `CHANGELOG.md` entries drafted — see “How release-please's + version bumps map to this rule” above for exactly how it picks the + version. +2. **You merge that PR when you're ready to release** — like any other + PR: it needs the required check + (`Execute unit and integration tests`) and goes through the merge + queue. `main` is protected, so this can't be pushed directly. +3. **The merge itself publishes the release.** The next + `release-please.yml` run (triggered by that merge landing on `main`) + recognizes its own release commit, tags it, and creates a real GitHub + Release there with the same notes as `CHANGELOG.md`'s new section — + no separate step. This is what makes the release immutable from this + point on (see [`SUPPLY_CHAIN_SECURITY.md`](SUPPLY_CHAIN_SECURITY.md#immutable-releases)). -5. **That tag push triggers +4. **That tag push triggers [`.github/workflows/release.yml`](../.github/workflows/release.yml)**, - entirely independently of the script. `release.yml` itself is just a - thin caller: it hands off to + entirely independently of `release-please`. `release.yml` itself is + just a thin caller: it hands off to [`.github/workflows/release-build.yml`](../.github/workflows/release-build.yml) as a reusable workflow — a separate file with its own identity is what gets this to SLSA Build Level 3 rather than Level 2 (see [`SUPPLY_CHAIN_SECURITY.md`](SUPPLY_CHAIN_SECURITY.md#build-provenance-and-attestations)). The called workflow runs: - `verify-version`: re-checks the tag matches `Cargo.toml`’s version - (a server-side safety net — this should never fail if you used the - script, since the script only ever tags a version it just set). + (a server-side safety net — this should never fail, since the tag + only ever gets created from a commit that just set that version). - `build` (once per architecture, amd64 and arm64): builds the container via [`Containerfile`](../Containerfile), which also generates the SBOM (see @@ -115,23 +155,17 @@ Once you run `cut-release.sh vX.Y.Z`: - `attest`: publishes signed SBOM and build-provenance attestations for both per-architecture images, plus a build-provenance attestation for the manifest list. -6. **The script waits for that workflow to finish**, then runs - [`verify-release.sh`](../scripts/verify-release.sh) to confirm it - actually came out right — not just that it reported success, but that - both a build-provenance and an SBOM attestation genuinely exist for - both architectures (see “Verifying a release” below). +5. **Once that workflow finishes, verify it** (see “Verifying a release” + below) — this step is manual, unlike 1–4. -Steps 1–4 usually take under 10 minutes; steps 5–6 (the actual container -build, plus verification) take roughly another 20–25 minutes. +Steps 1–3 are immediate (a few minutes for required checks plus the +merge queue's minimum wait); step 4 (the actual container build) takes +roughly another 20–25 minutes. ## Verifying a release -This happens automatically as the last step of `cut-release.sh` — you -don’t need to do anything extra. It’s implemented as a separate script, -[`verify-release.sh`](../scripts/verify-release.sh), specifically so it -can also be run standalone at any later time, e.g. if `cut-release.sh` -didn’t get to finish (the machine running it crashed, the connection -dropped, ...), or to double-check an older release: +Unlike the steps above, this one is on you to run — nothing currently +triggers it automatically: ```sh ./scripts/verify-release.sh vX.Y.Z @@ -143,66 +177,120 @@ and an SBOM attestation exist for each per-architecture image — the same two checks described in [`SUPPLY_CHAIN_SECURITY.md`](SUPPLY_CHAIN_SECURITY.md#build-provenance-and-attestations), done for real rather than assumed. This is exactly what was done by hand -to confirm v0.6.9, the first release cut with `cut-release.sh` — see the -comment trail on +to confirm v0.6.9, the first release cut with the old `cut-release.sh` +script — see the comment trail on [brawer/osmdiffs#562](https://github.com/brawer/osmdiffs/pull/562) for that walkthrough, which is what `verify-release.sh` automates. +## Setting up the release-please App + +`release-please.yml` needs to run as a real identity, not the default +`GITHUB_TOKEN` — GitHub deliberately doesn't let a `GITHUB_TOKEN`-authored +push or PR trigger further workflow runs (to prevent recursive-workflow +loops), which means `test.yml`'s `pull_request`-triggered "Execute unit +and integration tests" check would never run against the release PR, and +it could never clear the branch ruleset's required-status-check to be +merged. + +Rather than a Personal Access Token, this uses **`brawer-release-bot`**, +an account-wide GitHub App already installed for +[`osmviews`](https://github.com/brawer/osmviews), +[`osmviews-rs`](https://github.com/brawer/osmviews-rs), and +[`osmviews-py`](https://github.com/brawer/osmviews-py) the same way — an +App's installation token is minted fresh per workflow run and expires in +under an hour, so there's no long-lived credential to rotate the way a +PAT would need. + +One-time setup to add `osmdiffs` to that same App's installations: + +1. On the App's installation page + (`github.com/settings/installations/` — find it from + any of the three repos above under **Settings → GitHub Apps** → + `brawer-release-bot` → **Configure**), add `brawer/osmdiffs` to the + repositories it's installed on. +2. Set the repo **variable** `RELEASE_PLEASE_APP_CLIENT_ID` to the App's + Client ID (the same value already used by the other three repos — + visible on the App's settings page, or via `gh variable get + RELEASE_PLEASE_APP_CLIENT_ID --repo brawer/osmviews-rs`). +3. Set the repo **secret** `RELEASE_PLEASE_APP_PRIVATE_KEY` to a private + key for the App. Secrets can't be read back, so this can't just be + copied from another repo: on the App's settings page, **Generate a + private key** (an App can hold several valid keys at once, so this + doesn't invalidate the other repos' key), then + ```sh + gh secret set RELEASE_PLEASE_APP_PRIVATE_KEY --repo brawer/osmdiffs < path/to/key.pem + ``` + and delete the local `.pem` afterwards. + +Until both the variable and the secret exist, `release-please.yml` skips +itself (`if: vars.RELEASE_PLEASE_APP_CLIENT_ID != ''`) rather than +failing on every push to `main`. + ## Rules -- **Always cut releases with `cut-release.sh`.** Never push a `v*` tag by - hand. `release.yml` would still build and publish a container for it, - but you’d have skipped the version-consistency checks, and immutability +- **Never push a `v*` tag by hand, and never hand-edit `CHANGELOG.md` + for an already-released version.** `release.yml` would still build and + publish a container for a hand-pushed tag, but you'd have skipped + `release-please`'s version-consistency bookkeeping, and immutability protections apply to tags that went through a real GitHub Release — not to a bare tag pushed directly. - **Releases are immutable. If one’s bad, cut a new patch version and leave the bad one as-is.** You can’t fix a published release in place, and you can’t reuse or move its tag even if you delete it. -- **Anyone with write access can cut a release.** There’s no separate - approval gate for this beyond the normal merge-queue checks — we don’t - have enough people for dedicated release roles. +- **Anyone with write access can merge the release PR.** There’s no + separate approval gate for this beyond the normal merge-queue checks — + we don’t have enough people for dedicated release roles. ## If it goes wrong -- **A precondition check fails** (dirty tree, stale `main`, tag exists, - version not newer, CI not green): nothing was pushed. Fix the - underlying issue and re-run. -- **The bump PR fails its required checks**: auto-merge won’t complete, - and the script eventually times out (~25 min) with the PR still open. - Look at why CI failed on that PR, fix it, and either let auto-merge - finish or close the PR and start over. -- **The bump PR merges, but the release itself fails to get created** - (rare): `main` now has the version bump, but re-running the whole - script will fail its “is this version newer” check, since `Cargo.toml` - already matches. Just run the release-creation step directly instead: - `gh release create vX.Y.Z --target main --generate-notes`. +- **The release PR fails its required checks**: it just sits open, + unmergeable, same as any other failing PR. Look at why CI failed + (usually something landed on `main` after the PR was last updated; + `release-please` rebases it automatically on the next push), fix it, + and merge once green. +- **The release PR merges, but no GitHub Release shows up**: check + `release-please.yml`'s run for the merge commit — the App not (or no + longer) being installed on this repo, or the private key having been + deleted from the App without updating the secret here (see + “Setting up the release-please App” above), are the most likely + causes; the job would have been skipped or failed to mint a token. + Once fixed, re-run the failed workflow from the Actions tab; it's safe + to retry. - **The tag/release gets created, but `release.yml` then fails** (e.g. a build failure): the release is already immutable at this point, so there’s no “redo.” Fix whatever broke the build (or `main`), and cut a new patch version. The failed tag’s GitHub Release will just exist without a correspondingly published, attested container — that’s a known, accepted consequence of immutability, not a bug. -- **`cut-release.sh` is interrupted, or times out, while waiting on - `release.yml`** (~20–25 min; a crashed machine, a dropped connection, - or a truly stuck workflow): the release itself is unaffected — it was - already created before this wait began. Just run - `./scripts/verify-release.sh vX.Y.Z` on its own once you’re ready to - check on it again; it picks up wherever the run currently stands. +- **`verify-release.sh` is interrupted, or times out** (~20–25 min; a + crashed machine, a dropped connection, or a truly stuck workflow): the + release itself is unaffected — it was already created before this + wait began. Just re-run + `./scripts/verify-release.sh vX.Y.Z` once you’re ready to check on it + again; it picks up wherever the run currently stands. ## Where things live -- [`scripts/cut-release.sh`](../scripts/cut-release.sh) — the script - itself +- [`release-please-config.json`](../release-please-config.json) / + [`.release-please-manifest.json`](../.release-please-manifest.json) — + `release-please`'s version-bump rules and current-version bookkeeping +- [`.github/workflows/release-please.yml`](../.github/workflows/release-please.yml) — + runs on every push to `main`; opens/updates the release PR, then tags + + publishes the release once that PR is merged +- [`CHANGELOG.md`](../CHANGELOG.md) — maintained by `release-please`; + don't hand-edit entries for already-released versions - [`scripts/verify-release.sh`](../scripts/verify-release.sh) — the - verification step `cut-release.sh` runs at the end (also usable - standalone) + manual post-release verification step (see “Verifying a release” + above) - [`.github/workflows/release.yml`](../.github/workflows/release.yml) — triggers on a pushed tag, calls `release-build.yml` - [`.github/workflows/release-build.yml`](../.github/workflows/release-build.yml) — build, SBOM, attest - [`Containerfile`](../Containerfile) — how the container gets built -- [`.github/release.yml`](../.github/release.yml) — changelog - categorization rules for auto-generated release notes +- [`.github/release.yml`](../.github/release.yml) — categorization rules + for GitHub's own auto-generated release notes; no longer the primary + changelog (that's `CHANGELOG.md` now), kept only as a fallback for + manually running `gh release create --generate-notes` - [`scripts/sbom/README.md`](../scripts/sbom/README.md) — how the SBOM itself is generated diff --git a/release-please-config.json b/release-please-config.json new file mode 100644 index 00000000..2f43bfa5 --- /dev/null +++ b/release-please-config.json @@ -0,0 +1,25 @@ +{ + "$schema": "https://raw.githubusercontent.com/googleapis/release-please/main/schemas/config.json", + "bump-minor-pre-major": true, + "bump-patch-for-minor-pre-major": true, + "packages": { + ".": { + "release-type": "rust", + "package-name": "osmdiffs", + "include-component-in-tag": false, + "changelog-sections": [ + { "type": "feat", "section": "🆕 Enhancements" }, + { "type": "fix", "section": "🐞 Bug Fixes" }, + { "type": "perf", "section": "⚡ Performance" }, + { "type": "revert", "section": "⏪ Reverts" }, + { "type": "docs", "section": "📚 Documentation" }, + { "type": "chore", "section": "🚧 Maintenance" }, + { "type": "refactor", "section": "🚧 Maintenance" }, + { "type": "build", "section": "🚧 Maintenance" }, + { "type": "ci", "section": "🚧 Maintenance" }, + { "type": "test", "section": "🚧 Maintenance" }, + { "type": "style", "section": "🚧 Maintenance" } + ] + } + } +} diff --git a/scripts/README.md b/scripts/README.md index 3347b2d6..63ff88a4 100644 --- a/scripts/README.md +++ b/scripts/README.md @@ -27,15 +27,11 @@ process these fit into. - [`sbom/`](sbom/README.md): generates the Software Bill of Materials (SBOM) for the release container image. -- [`cut-release.sh`](cut-release.sh): cuts a new release (bumps - `Cargo.toml`’s version via a PR, then creates the tagged GitHub Release - that triggers `.github/workflows/release.yml`, then waits for and - verifies the result). Run `./scripts/cut-release.sh vX.Y.Z` from an - up-to-date `main`. -- [`verify-release.sh`](verify-release.sh): the verification step - `cut-release.sh` runs at the end — also usable standalone, e.g. to - double-check an older release. Run - `./scripts/verify-release.sh vX.Y.Z`. +- [`verify-release.sh`](verify-release.sh): confirms a release actually + came out right (build-provenance and SBOM attestations exist for both + architectures) — the one manual step left after + [`release-please`](../.github/workflows/release-please.yml) merges and + publishes a release. Run `./scripts/verify-release.sh vX.Y.Z`. ## Testing development branches diff --git a/scripts/cut-release.sh b/scripts/cut-release.sh deleted file mode 100755 index 456bd09b..00000000 --- a/scripts/cut-release.sh +++ /dev/null @@ -1,230 +0,0 @@ -#!/usr/bin/env sh -# -# SPDX-FileCopyrightText: 2026 Sascha Brawer -# SPDX-License-Identifier: MIT -# -# Cut a new release, end to end: -# 1. Bump Cargo.toml's version (and Cargo.lock's self-entry) on a new -# branch, open a PR, enable auto-merge, and wait for it to clear -# required checks and the merge queue. -# 2. Once that's landed on main, create the GitHub Release there (tag + -# auto-generated notes, from the categories configured in -# .github/release.yml). -# 3. Wait for the resulting .github/workflows/release.yml run (build, -# SBOM, attest) and verify it actually came out right, via -# verify-release.sh. -# -# Step 3 alone can take ~20-25 minutes; it's safe to Ctrl-C this script -# once the release has been created (end of step 2) and, if you do, -# finish verifying it later with: -# ./scripts/verify-release.sh vX.Y.Z -# -# Usage: -# ./scripts/cut-release.sh vX.Y.Z -# -# Version bump rules (SemVer, driven by the pipeline's OUTPUT SCHEMA, not -# just code changes): -# major - output schema changed in a client-breaking way -# minor - output schema evolved in a backward-compatible way -# patch - bugfixes only, no schema changes -# Before asking for confirmation, this script scans merged PR titles since -# the last tag for a Conventional Commits "!" and suggests a version floor -# from that -- see docs/CONTRIBUTING.md and docs/RELEASING.md. It's a hint, -# not authoritative: the version is still your call. -# -# Requirements: git, cargo, gh (authenticated -- run `gh auth login` first) - -set -eu - -SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" - -REQUIRED_CHECK="Execute unit and integration tests" -MERGE_WAIT_TIMEOUT=1500 # 25 min: ~5 min test run + merge-queue wait + slack -MERGE_POLL_INTERVAL=15 - -# ── argument handling ──────────────────────────────────────────────────────── -TAG="${1:?usage: cut-release.sh vX.Y.Z}" -case "$TAG" in - v*.*.*) VERSION="${TAG#v}" ;; - *) echo "ERROR: tag must look like vX.Y.Z (got: $TAG)" >&2; exit 1 ;; -esac -case "$VERSION" in - [0-9]*.[0-9]*.[0-9]*) ;; - *) echo "ERROR: '$VERSION' doesn't look like X.Y.Z" >&2; exit 1 ;; -esac - -# ── pre-flight checks ──────────────────────────────────────────────────────── -for cmd in git cargo gh; do - command -v "$cmd" >/dev/null 2>&1 || { echo "ERROR: $cmd is not installed" >&2; exit 1; } -done -gh auth status >/dev/null 2>&1 || { echo "ERROR: gh is not authenticated -- run 'gh auth login'" >&2; exit 1; } - -BRANCH="$(git rev-parse --abbrev-ref HEAD)" -if [ "$BRANCH" != "main" ]; then - echo "ERROR: must be run from main (currently on '$BRANCH')" >&2 - exit 1 -fi -if [ -n "$(git status --porcelain)" ]; then - echo "ERROR: working tree is not clean" >&2 - exit 1 -fi - -echo "Fetching origin..." -git fetch origin main --tags -q -if [ "$(git rev-parse HEAD)" != "$(git rev-parse origin/main)" ]; then - echo "ERROR: local main is not up to date with origin/main (run 'git pull')" >&2 - exit 1 -fi - -if git rev-parse -q --verify "refs/tags/${TAG}" >/dev/null; then - echo "ERROR: tag '${TAG}' already exists" >&2 - exit 1 -fi - -CURRENT_VERSION="$(awk ' - /^\[package\]/ { in_package=1; next } - /^\[/ { in_package=0 } - in_package && /^version[[:space:]]*=/ { - match($0, /"[^"]*"/) - print substr($0, RSTART+1, RLENGTH-2) - exit - } -' Cargo.toml)" -HIGHEST="$(printf '%s\n%s\n' "$CURRENT_VERSION" "$VERSION" | sort -t. -k1,1n -k2,2n -k3,3n | tail -1)" -if [ "$HIGHEST" != "$VERSION" ] || [ "$VERSION" = "$CURRENT_VERSION" ]; then - echo "ERROR: ${VERSION} is not newer than Cargo.toml's current version (${CURRENT_VERSION})" >&2 - exit 1 -fi - -# ── scan for Conventional-Commits breaking markers since the last release ─── -# This is a hint, not a rule: choosing the version is still your call (see -# "Choosing the version number" in docs/RELEASING.md). Reminder, since it's -# easy to reach for the usual meaning by reflex: on osmdiffs, "!" flags a -# commit that breaks the OUTPUT SCHEMA (conflated.parquet), not one that -# breaks some public API -- see docs/CONTRIBUTING.md. -LAST_TAG="$(git describe --tags --abbrev=0 2>/dev/null || true)" -if [ -n "$LAST_TAG" ]; then - BREAKING_COMMITS="$(git log "${LAST_TAG}..HEAD" --format='%s' \ - | grep -E '^[a-z]+(\([a-zA-Z0-9_./-]+\))?!:' || true)" - if [ -n "$BREAKING_COMMITS" ]; then - echo "Breaking-change ('!') commits since ${LAST_TAG} -- remember, here" - echo "'!' means OUTPUT-SCHEMA-breaking, not API-breaking (see" - echo "docs/CONTRIBUTING.md):" - echo "$BREAKING_COMMITS" | sed 's/^/ - /' - CUR_MAJOR="${CURRENT_VERSION%%.*}" - NEW_MAJOR="${VERSION%%.*}" - # Before 1.0.0, a breaking release bumps MINOR instead of MAJOR (see - # "Choosing the version number" in docs/RELEASING.md) -- MAJOR stays - # 0 throughout initial development (SemVer #4), so requiring a MAJOR - # bump here would never be satisfiable pre-1.0. - if [ "$CUR_MAJOR" -ge 1 ]; then - BREAKING_SLOT_BUMPED=$([ "$NEW_MAJOR" -gt "$CUR_MAJOR" ] && echo yes || echo no) - else - CUR_MINOR="${CURRENT_VERSION#*.}" - CUR_MINOR="${CUR_MINOR%%.*}" - NEW_MINOR="${VERSION#*.}" - NEW_MINOR="${NEW_MINOR%%.*}" - if [ "$NEW_MAJOR" -gt "$CUR_MAJOR" ] || [ "$NEW_MINOR" -gt "$CUR_MINOR" ]; then - BREAKING_SLOT_BUMPED=yes - else - BREAKING_SLOT_BUMPED=no - fi - fi - if [ "$BREAKING_SLOT_BUMPED" = "no" ]; then - echo - echo "WARNING: the above suggests a breaking-version bump (major once" - echo " past 1.0.0, minor for now), but ${TAG} isn't one." - echo " This is only a suggestion from PR titles, not a schema diff --" - echo " double-check against 'Choosing the version number' in" - echo " docs/RELEASING.md before continuing." - printf "Continue with %s anyway? [y/N] " "$VERSION" - read -r REPLY || REPLY="" - case "$REPLY" in - [yY]*) ;; - *) echo "Aborted." >&2; exit 1 ;; - esac - fi - fi -fi - -echo "Checking latest CI status on main..." -LATEST_RUN="$(gh run list --branch main --workflow test.yml --limit 1 \ - --json headSha,status,conclusion -q '.[0]')" -RUN_SHA="$(echo "$LATEST_RUN" | jq -r .headSha)" -RUN_STATUS="$(echo "$LATEST_RUN" | jq -r .status)" -RUN_CONCLUSION="$(echo "$LATEST_RUN" | jq -r .conclusion)" -if [ "$RUN_SHA" != "$(git rev-parse HEAD)" ] || [ "$RUN_STATUS" != "completed" ] || [ "$RUN_CONCLUSION" != "success" ]; then - echo "ERROR: latest '${REQUIRED_CHECK}' run on main is not a successful," >&2 - echo " completed run for the current main HEAD (sha=${RUN_SHA}" >&2 - echo " status=${RUN_STATUS} conclusion=${RUN_CONCLUSION}). Wait for CI" >&2 - echo " to finish, or fix main, before cutting a release." >&2 - exit 1 -fi - -# ── bump the version via a PR (main is protected: no direct pushes) ───────── -BUMP_BRANCH="release/${TAG}" -echo "Creating branch ${BUMP_BRANCH}..." -git checkout -b "$BUMP_BRANCH" -q - -awk -v new_version="$VERSION" ' - /^\[package\]/ { print; in_package=1; next } - /^\[/ { in_package=0 } - in_package && /^version[[:space:]]*=/ { - print "version = \"" new_version "\"" - next - } - { print } -' Cargo.toml > Cargo.toml.new -mv Cargo.toml.new Cargo.toml -cargo check --quiet - -git add Cargo.toml Cargo.lock -git commit -q -m "chore(release): bump version to ${VERSION}" -git push -q -u origin "$BUMP_BRANCH" - -PR_URL="$(gh pr create \ - --title "chore(release): bump version to ${VERSION}" \ - --body "Version bump ahead of releasing ${TAG}. Opened by scripts/cut-release.sh." \ - --base main)" -echo "Opened ${PR_URL}" - -gh pr merge "$PR_URL" --auto --squash -echo "Auto-merge enabled; waiting for '${REQUIRED_CHECK}' and the merge queue..." - -ELAPSED=0 -while :; do - STATE="$(gh pr view "$PR_URL" --json state -q .state)" - case "$STATE" in - MERGED) - echo "Merged." - break - ;; - CLOSED) - echo "ERROR: PR was closed without merging: ${PR_URL}" >&2 - exit 1 - ;; - esac - if [ "$ELAPSED" -ge "$MERGE_WAIT_TIMEOUT" ]; then - echo "ERROR: timed out after ${MERGE_WAIT_TIMEOUT}s waiting for ${PR_URL} to merge." >&2 - echo " Check its status manually; it may still merge on its own." >&2 - exit 1 - fi - sleep "$MERGE_POLL_INTERVAL" - ELAPSED=$((ELAPSED + MERGE_POLL_INTERVAL)) -done - -git checkout main -q -git branch -D "$BUMP_BRANCH" -q - -# ── create the release (tag + auto-generated notes) at the new main HEAD ──── -echo "Creating release ${TAG}..." -gh release create "$TAG" --target main --generate-notes - -# ── wait for release.yml and verify the result ─────────────────────────────── -echo "${TAG} is tagged and released; waiting for .github/workflows/release.yml" \ - "to build, SBOM, and attest the container, then verifying the result." \ - "This can take ~20-25 minutes; safe to Ctrl-C and finish later with" \ - "'./scripts/verify-release.sh ${TAG}'." -"${SCRIPT_DIR}/verify-release.sh" "$TAG" - -echo "Done." diff --git a/scripts/verify-release.sh b/scripts/verify-release.sh index 9275691d..d6952c6a 100755 --- a/scripts/verify-release.sh +++ b/scripts/verify-release.sh @@ -16,10 +16,10 @@ # Usage: # ./scripts/verify-release.sh vX.Y.Z # -# This is called automatically as the last step of cut-release.sh, but is -# also safe to run standalone, any time after a tag was pushed -- e.g. if -# cut-release.sh didn't get to finish (the machine running it crashed, -# the connection dropped, ...), or to double-check an older release. +# This is the one manual step left after release-please.yml merges the +# release PR and publishes the release (see docs/RELEASING.md) -- safe to +# run any time after a tag was pushed, e.g. right after a release, after an +# interrupted or dropped connection, or to double-check an older release. # # Requirements: gh (authenticated -- run `gh auth login` first), jq diff --git a/tests/integration_test.rs b/tests/integration_test.rs index 3608ffc4..7611d392 100644 --- a/tests/integration_test.rs +++ b/tests/integration_test.rs @@ -651,7 +651,7 @@ fn test_no_subcommand() { #[test] fn test_version_flag() { // Asserts against CARGO_PKG_VERSION (rather than a hardcoded string) so - // this doesn't need updating every time cut-release.sh bumps the version. + // this doesn't need updating every time release-please bumps the version. Command::new(cargo_bin!("osmdiffs")) .arg("--version") .assert()