diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 7993dba..fc59c78 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1,64 +1,118 @@ name: Release +# Dispatch from main with the version a merged release PR set, or push its tag. +# A dispatch pushes the tag only after every gate, so a refused dispatch leaves no tag. +# A pushed tag runs the same gates but already exists, and the org forbids deleting tags, +# so a refused pushed tag is spent: fix the cause and release the next patch. on: push: tags: ['v*'] workflow_dispatch: + inputs: + version: + description: 'Version to release, e.g. 0.3.0 (must match VERSION)' + required: true + type: string + dry_run: + description: 'Run every gate and stage the artifacts, but tag, publish and release nothing' + required: false + type: boolean + default: false concurrency: - group: ${{ github.workflow }}-${{ github.ref }} + group: release-${{ github.event_name == 'push' && github.ref_name || format('v{0}', inputs.version) }} cancel-in-progress: false permissions: - contents: write - id-token: write - attestations: write - # the "Require green CI" step reads check-runs for the commit being released - checks: read + contents: read jobs: release: - name: Test, build & publish to Maven Central - # Linux publishes every target. Kotlin/Native cross-compiles the Apple klibs - # (iOS, macOS, tvOS) from any host since 2.1, so a macOS runner buys nothing - # here — kzstd assembles no XCFramework and has no cinterop, so there is - # nothing left that needs Xcode. Verified against 0.2.0: a Linux publish - # produces the same 14 artifacts Maven Central holds, Apple klibs included. - # What a Linux host cannot do is *execute* the Apple test binaries; the - # "Require green CI" step below is what keeps that guarantee. + name: Gate, publish to Maven Central & release + # Kotlin/Native cross-compiles the Apple klibs from Linux and kzstd has no cinterop, so + # Linux publishes every target. It cannot run the Apple tests; the green-CI gate covers them. runs-on: ubuntu-latest + timeout-minutes: 90 + permissions: + contents: write + id-token: write + attestations: write + checks: read + statuses: read + env: + DRY_RUN: ${{ inputs.dry_run || false }} steps: - name: Checkout uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - - name: Read version + - name: Resolve version id: version - run: echo "version=$(cat VERSION | tr -d '[:space:]')" >> "$GITHUB_OUTPUT" + env: + REQUESTED: ${{ inputs.version || github.ref_name }} + run: | + # A dispatch takes the bare version so the concurrency group above is canonical. + if [ "$GITHUB_EVENT_NAME" = workflow_dispatch ] && [ "${REQUESTED#v}" != "$REQUESTED" ]; then + echo "::error::pass the version without a leading v: ${REQUESTED#v}" + exit 1 + fi + echo "version=${REQUESTED#v}" >> "$GITHUB_OUTPUT" - - name: Verify VERSION matches gradle.properties + - name: Release from main only + if: env.DRY_RUN != 'true' run: | - VF="${{ steps.version.outputs.version }}" - VG=$(grep -E "^VERSION_NAME=" gradle.properties | cut -d'=' -f2 | tr -d '[:space:]') - if [ "$VF" != "$VG" ]; then - echo "::error::VERSION ($VF) != gradle.properties VERSION_NAME ($VG)" + git fetch --quiet origin main + if ! git merge-base --is-ancestor HEAD origin/main; then + echo "::error::$GITHUB_REF is not on main; release from main (dry_run may run anywhere)" exit 1 fi - echo "✓ version $VF" - - # The changelog is the third source. `getChangelog` does NOT fail when the - # declared version has no section — it silently returns the most recent - # released one, which would ship the PREVIOUS release's notes as this - # release's body. Check the heading exists here instead. - # awk, not grep -E: the version is a literal and its dots are ERE - # wildcards. `index($0, v) == 1` is "this line starts with exactly - # this string", with no escaping to get wrong. - if ! awk -v v="## [$VF]" 'index($0, v) == 1 { found = 1 } END { exit !found }' CHANGELOG.md; then - echo "::error::CHANGELOG.md has no '## [$VF]' section — run './gradlew patchChangelog' and commit it before releasing." + + - name: Verify the version sources agree + env: + V: ${{ steps.version.outputs.version }} + run: | + VF=$(tr -d '[:space:]' < VERSION) + VG=$(sed -n 's/^VERSION_NAME=//p' gradle.properties | tr -d '[:space:]') + if [ "$V" != "$VF" ] || [ "$V" != "$VG" ]; then + echo "::error::releasing $V, but VERSION is $VF and VERSION_NAME is $VG" exit 1 fi - echo "✓ CHANGELOG.md has a section for $VF" + git fetch --quiet origin "refs/tags/v$V:refs/tags/v$V" 2>/dev/null || true + if git rev-parse --quiet --verify "v$V^{commit}" >/dev/null && + [ "$(git rev-parse "v$V^{commit}")" != "$(git rev-parse HEAD)" ]; then + if [ "$DRY_RUN" = true ]; then + echo "::warning::v$V already exists on another commit; a real release would stop here" + else + echo "::error::v$V already exists on another commit" + exit 1 + fi + fi + echo "releasing $V from $(git rev-parse --short HEAD)" - - name: Setup JDK 21 + - name: Render release notes + env: + V: ${{ steps.version.outputs.version }} + run: | + NOTES=$(scripts/changelog.sh notes "$V") + cat > release-notes.md </dev/null || true - if git rev-parse "v$V" >/dev/null 2>&1; then - echo "exists=true" >> "$GITHUB_OUTPUT" - else - echo "exists=false" >> "$GITHUB_OUTPUT" - fi - - - name: Push release tag to origin - if: github.event_name == 'workflow_dispatch' && steps.tag_state.outputs.exists == 'false' - run: | - V="${{ steps.version.outputs.version }}" - git config user.name "github-actions[bot]" - git config user.email "41898282+github-actions[bot]@users.noreply.github.com" - git tag -a "v$V" -m "Release v$V" - git push origin "v$V" - - - name: Render release notes - # The changelog section for this version IS the release notes. The - # alternative — `generate_release_notes: true` — describes the release a - # second time from commit titles, and the hand-written description and the - # generated one then drift. `getChangelog` reads the same CHANGELOG.md the - # version check above already proved has a section for this version. - env: - DEVELOCITY_ACCESS_KEY: ${{ secrets.DEVELOCITY_ACCESS_KEY }} - run: | - V="${{ steps.version.outputs.version }}" - cat > release-notes.md <> release-notes.md - echo "--- rendered release notes ---" - cat release-notes.md - - name: Stage signed artifacts locally env: ORG_GRADLE_PROJECT_signingInMemoryKey: ${{ secrets.SIGNING_KEY }} - run: ./gradlew publishToMavenLocal --stacktrace + run: ./gradlew publishToMavenLocal --no-configuration-cache --stacktrace - - name: Collect artifacts for attestation + - name: Refuse a -SNAPSHOT dependency + run: scripts/release-checks.sh no-snapshots "${{ steps.version.outputs.version }}" + + - name: Collect every staged artifact for attestation run: | mkdir -p maven-attestation - find ~/.m2/repository/org/meshtastic/kzstd -type f \ - \( -name "*.jar" -o -name "*.klib" -o -name "*.pom" -o -name "*.module" \) \ + find ~/.m2/repository/org/meshtastic -path "*/${{ steps.version.outputs.version }}/*" -type f \ + \( -name '*.jar' -o -name '*.klib' -o -name '*.pom' -o -name '*.module' \) \ -exec cp {} maven-attestation/ \; - ls -lh maven-attestation/ + ls -1 maven-attestation - - name: Attest artifacts + - name: Attest the staged artifacts + if: env.DRY_RUN != 'true' uses: actions/attest-build-provenance@4d101475d8b20a2381f78447822ac1eab6504dd8 # v4.2.2 with: subject-path: maven-attestation/* - name: Check Maven Central state - # Probe whether v$VERSION is already on Maven Central; if so, skip publish - # (Sonatype rejects re-uploads), so a retry after a partial release is safe. - id: mc_check + # Sonatype rejects a re-upload, so a re-run after a partial release skips the publish. + id: central + env: + V: ${{ steps.version.outputs.version }} run: | - V="${{ steps.version.outputs.version }}" - HTTP=$(curl -sS -o /dev/null -w "%{http_code}" \ - "https://repo1.maven.org/maven2/org/meshtastic/kzstd-jvm/$V/kzstd-jvm-$V.pom") - echo "Maven Central POM probe for v$V: HTTP $HTTP" - if [ "$HTTP" = "200" ]; then - echo "already_published=true" >> "$GITHUB_OUTPUT" - else - echo "already_published=false" >> "$GITHUB_OUTPUT" + # Only a 404 means unpublished; anything else stops the release rather than guess. + for _ in 1 2 3 4 5; do + HTTP=$(curl -sS -o /dev/null -w "%{http_code}" \ + "https://repo1.maven.org/maven2/org/meshtastic/kzstd-jvm/$V/kzstd-jvm-$V.pom" || echo 000) + echo "Maven Central POM probe for $V: HTTP $HTTP" + case "$HTTP" in + 200) echo "published=true" >> "$GITHUB_OUTPUT"; exit 0 ;; + 404) echo "published=false" >> "$GITHUB_OUTPUT"; exit 0 ;; + esac + sleep 20 + done + echo "::error::Maven Central did not answer 200 or 404 for $V" + exit 1 + + - name: Push the release tag + if: env.DRY_RUN != 'true' + env: + V: ${{ steps.version.outputs.version }} + run: | + if git rev-parse --quiet --verify "v$V" >/dev/null; then + echo "v$V already on origin" + exit 0 fi + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + git tag -a "v$V" -m "v$V" + git push origin "v$V" - name: Publish to Maven Central - if: steps.mc_check.outputs.already_published == 'false' + if: env.DRY_RUN != 'true' && steps.central.outputs.published != 'true' env: ORG_GRADLE_PROJECT_mavenCentralUsername: ${{ secrets.OSSRH_USERNAME }} ORG_GRADLE_PROJECT_mavenCentralPassword: ${{ secrets.OSSRH_PASSWORD }} ORG_GRADLE_PROJECT_signingInMemoryKey: ${{ secrets.SIGNING_KEY }} - run: ./gradlew publishAllPublicationsToMavenCentralRepository --stacktrace + run: ./gradlew publishAndReleaseToMavenCentral --no-configuration-cache --stacktrace - - name: Create GitHub Release + - name: Create or update the GitHub Release + if: env.DRY_RUN != 'true' uses: softprops/action-gh-release@efb35369e0ad2afab669f228072c1b0d510eae64 # v3.0.3 with: tag_name: v${{ steps.version.outputs.version }} name: v${{ steps.version.outputs.version }} body_path: release-notes.md + + - name: Dry-run summary + if: env.DRY_RUN == 'true' + run: echo "dry run passed every gate for ${{ steps.version.outputs.version }}; nothing was tagged, published or released" diff --git a/AGENTS.md b/AGENTS.md index 1a0d632..51d5550 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -85,8 +85,9 @@ on their own host. ## Publishing Maven Central via the vanniktech plugin (`org.meshtastic:kzstd`); JitPack is a -fallback (`com.github.meshtastic:kzstd`). Releases are tag-driven — see -`RELEASING.md`. +fallback (`com.github.meshtastic:kzstd`). A release is a merged release PR plus a +dispatch of `release.yml`; see `RELEASING.md`. Cut changelog sections with +`scripts/changelog.sh`, never by re-rendering the file. ## Conventions diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index d06459d..7b1108c 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -63,9 +63,9 @@ and `explicitApi()`. After an **intentional** public-API change, run ## Changelog [`CHANGELOG.md`](CHANGELOG.md) is hand-written in -[Keep a Changelog](https://keepachangelog.com/en/1.1.0/) form. The JetBrains -[gradle-changelog-plugin](https://github.com/JetBrains/gradle-changelog-plugin) parses -and renders it and never generates an entry from a commit. +[Keep a Changelog](https://keepachangelog.com/en/1.1.0/) form, and nothing generates an +entry from a commit. `scripts/changelog.sh` cuts and reads sections without re-rendering +the file, so prose under any heading survives a release. Add an entry under `## [Unreleased]` for anything a consumer would notice — a new or changed public API, a behaviour change, a fix to something they could have hit, a @@ -74,12 +74,13 @@ changes and CI work need none. **A change that moves `api/kzstd.api` or `api/kzstd.klib.api` always needs an entry**, and it goes under `### Breaking` if a consumer has to change code rather than just -recompile. `Breaking` leads the group order for that reason: with committed ABI dumps, -the first thing a consumer needs to know is whether recompiling is enough. +recompile. `Breaking` comes first in a section for that reason: with committed ABI +dumps, the first thing a consumer needs to know is whether recompiling is enough. -The changelog is also what the GitHub Release page says — `release.yml` renders -`./gradlew getChangelog --no-header --no-links` into the release body, and GitHub's own -`generate_release_notes` is deliberately off, so a release is described once. +The changelog is also what the GitHub Release page says. `release.yml` puts +`scripts/changelog.sh notes X.Y.Z` in the release body, and GitHub's own +`generate_release_notes` is off, so a release is described once. Releasing is in +[RELEASING.md](RELEASING.md). ## Submitting a change diff --git a/RELEASING.md b/RELEASING.md index efbddc4..2fde59b 100644 --- a/RELEASING.md +++ b/RELEASING.md @@ -1,43 +1,48 @@ # Releasing -kzstd publishes to Maven Central (`org.meshtastic:kzstd`) via the vanniktech -maven-publish plugin, driven by `.github/workflows/release.yml`. JitPack +kzstd publishes `org.meshtastic:kzstd` to Maven Central with the vanniktech +maven-publish plugin, from `.github/workflows/release.yml`. JitPack (`com.github.meshtastic:kzstd`) is a fallback channel. -## One-time setup +## Secrets -The repository needs three GitHub Actions secrets (the vanniktech -`ORG_GRADLE_PROJECT_*` convention): - -- `SIGNING_KEY` — the in-memory GPG signing key. -- `OSSRH_USERNAME` — Sonatype Central Portal username. -- `OSSRH_PASSWORD` — Sonatype Central Portal password. +`SIGNING_KEY` (the in-memory GPG key), `OSSRH_USERNAME` and `OSSRH_PASSWORD` (Central +Portal credentials), passed as the vanniktech `ORG_GRADLE_PROJECT_*` properties. ## Cutting a release -1. Pick the new version `X.Y.Z` (SemVer; pre-1.0 may break in minors). -2. Update `VERSION` **and** `gradle.properties` (`VERSION_NAME`) to `X.Y.Z` — they - must match (the release workflow verifies this and fails loudly otherwise). -3. Run `./gradlew patchChangelog`. It cuts the `## [Unreleased]` entries into a - dated `## [X.Y.Z]` heading, leaves an empty Unreleased behind, and writes the - compare links — reading `VERSION_NAME`, so step 2 has to come first. Review the - diff: it is what the GitHub Release page will say. The release workflow refuses - to publish a version with no section, because `getChangelog` would otherwise fall - back silently to the previous release's notes. -4. If the public API changed, ensure `api/kzstd.api` was refreshed (`./gradlew apiDump`). -5. Commit (signed off) and open/merge the PR. -6. Trigger the release: push a `vX.Y.Z` tag, or run the **Release** workflow via - `workflow_dispatch` (it tags for you). - -The workflow then builds all 13 targets on `macos-latest`, publishes to Maven -Central, and creates a GitHub Release whose body is `./gradlew getChangelog` — not -GitHub's generated commit list, which would describe the release a second time and -drift from the hand-written one. It is **idempotent**: it probes `repo1.maven.org` -first and skips the publish if `X.Y.Z` is already there, so a re-run after a partial -failure is safe. +1. Pick `X.Y.Z` (SemVer; before 1.0 a minor may break). +2. On a branch, set `VERSION` and `VERSION_NAME` in `gradle.properties` to `X.Y.Z`, and + run `scripts/changelog.sh cut X.Y.Z`. That moves `## [Unreleased]` under a dated + `## [X.Y.Z]` heading and updates the compare links, touching nothing else. It refuses + an empty Unreleased. +3. If the public API changed, `./gradlew apiDump` and commit `api/`. +4. Commit `chore(release): X.Y.Z` (signed off), open the PR and merge it. +5. `gh workflow run release.yml --repo meshtastic/kzstd -f version=X.Y.Z`. Add + `-f dry_run=true` to run every gate without tagging or publishing; a dry run may + start from any branch. Pushing a `vX.Y.Z` tag on `main` runs the same workflow. + +## What the workflow checks, in order + +1. The commit is on `main` (skipped for a dry run). +2. The version equals `VERSION` and `VERSION_NAME`, and any existing `vX.Y.Z` tag + points at this commit. +3. `scripts/changelog.sh notes X.Y.Z` finds a non-empty section. It becomes the GitHub + Release body verbatim. +4. Every check the `main` ruleset requires passed on this commit + (`scripts/release-checks.sh green-ci`). That is where the Apple tests ran; this + Linux runner cannot run them. +5. `./gradlew build`, then `publishToMavenLocal` with signing. +6. No staged POM or Gradle module depends on a `-SNAPSHOT` + (`scripts/release-checks.sh no-snapshots`). Central rejects that only after upload. +7. If `X.Y.Z` is already on `repo1.maven.org` the publish is skipped, so a re-run is + safe. + +Then it attests every staged artifact, pushes the annotated `vX.Y.Z` tag if it is +missing, runs `publishAndReleaseToMavenCentral`, and creates or updates the GitHub +Release. ## After releasing -Maven Central → repo1 propagation typically takes 10–30 minutes. Verify -`https://repo1.maven.org/maven2/org/meshtastic/kzstd-jvm/X.Y.Z/` resolves before -downstream consumers bump. +`repo1.maven.org` lags the Central Portal by 10 to 30 minutes. Downstream bumps wait +until `https://repo1.maven.org/maven2/org/meshtastic/kzstd-jvm/X.Y.Z/` resolves. diff --git a/build.gradle.kts b/build.gradle.kts index ef1ca38..7cd93ed 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -10,10 +10,6 @@ plugins { alias(libs.plugins.kover) alias(libs.plugins.spotless) alias(libs.plugins.detekt) - // Manages CHANGELOG.md, which stays hand-written: it parses and renders the - // file and never generates an entry from a commit. `patchChangelog` is the - // release step; `getChangelog` is what release.yml reads for the Release body. - alias(libs.plugins.changelog) } // GROUP / VERSION_NAME come from gradle.properties — the single coordinate source @@ -25,26 +21,6 @@ repositories { mavenCentral() } -changelog { - // The same property the coordinate is published under, so the heading - // `patchChangelog` cuts cannot disagree with the artifact being released. - version = providers.gradleProperty("VERSION_NAME") - repositoryUrl = "https://github.com/meshtastic/kzstd" - // An empty Unreleased fails the bump here, with the plugin's own message. - // The default skips the task green and leaves no heading, which the release - // gate would only catch one tag later. - patchEmpty = false - // Breaking leads: kzstd carries committed ABI dumps, so what a consumer needs - // first is whether recompiling is enough. Keep a Changelog has no word for it. - groups = listOf("Breaking", "Added", "Changed", "Deprecated", "Removed", "Fixed", "Security") - // `patchChangelog` rewrites everything between the title and the first section - // from this value, so anything that must survive a release lives here. - introduction = - """ - All notable changes to kzstd are documented here. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). - """.trimIndent() -} - kotlin { jvmToolchain(21) explicitApi() diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 253e01c..615174c 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -13,9 +13,6 @@ junit-platform = "6.1.3" spotless = "8.10.3" ktlint = "1.8.0" detekt = "1.23.8" -# JetBrains gradle-changelog-plugin: parses and renders the hand-written -# CHANGELOG.md. It never generates an entry from a commit. -changelog = "2.5.0" [libraries] zstd-jni = { module = "com.github.luben:zstd-jni", version.ref = "zstd-jni" } @@ -30,4 +27,3 @@ binary-compat = { id = "org.jetbrains.kotlinx.binary-compatibility-validator", v kover = { id = "org.jetbrains.kotlinx.kover", version.ref = "kover" } spotless = { id = "com.diffplug.spotless", version.ref = "spotless" } detekt = { id = "io.gitlab.arturbosch.detekt", version.ref = "detekt" } -changelog = { id = "org.jetbrains.changelog", version.ref = "changelog" } diff --git a/scripts/changelog.sh b/scripts/changelog.sh new file mode 100755 index 0000000..69357a8 --- /dev/null +++ b/scripts/changelog.sh @@ -0,0 +1,132 @@ +#!/usr/bin/env bash +# Cut and read CHANGELOG.md sections without re-rendering the file. +# +# scripts/changelog.sh cut [] move [Unreleased] under a new "## [] - " heading +# scripts/changelog.sh notes print that section's body verbatim (the release notes) +# +# Every other line of the file is left byte-for-byte as it was. CHANGELOG defaults to the +# CHANGELOG.md beside this script's parent directory. +set -euo pipefail + +die() { + echo "changelog.sh: $*" >&2 + exit 1 +} + +usage() { + sed -n '2,8p' "$0" | sed 's/^# \{0,1\}//' >&2 + exit 2 +} + +file="${CHANGELOG:-$(cd "$(dirname "$0")/.." && pwd)/CHANGELOG.md}" +[ -f "$file" ] || die "no changelog at $file" + +[ $# -ge 2 ] || usage +cmd="$1" +version="${2#v}" +[[ "$version" =~ ^[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z.-]+)?$ ]] || die "'$2' is not a SemVer version" + +# Shared awk: read the file into lines[1..count] and set footer to the first line of the +# trailing block of "[x]: url" link definitions (count + 1 when there is none). A link +# definition inside a section is prose, not footer. +# shellcheck disable=SC2016 # an awk program, expanded by awk +read_file=' + function starts(s, p) { return substr(s, 1, length(p)) == p } + function islink(s) { return s ~ /^\[[^]]+\]: / } + function isblank(s) { return s ~ /^[[:space:]]*$/ } + { lines[++count] = $0 } + function find_footer( i) { + footer = count + 1 + for (i = count; i >= 1 && (isblank(lines[i]) || islink(lines[i])); i--) + if (islink(lines[i])) footer = i + } +' + +# Body of the section headed "## []", up to the next "## " heading or the link footer, +# with leading and trailing blank lines dropped. +section() { + awk -v want="$1" "$read_file"' + END { + find_footer() + h = "## [" want "]" + for (i = 1; i < footer; i++) { + if (inside && starts(lines[i], "## ")) break + if (inside) body[++n] = lines[i] + else if (lines[i] == h || starts(lines[i], h " ")) { inside = 1; found = 1 } + } + if (!found) exit 3 + first = 1; while (first <= n && isblank(body[first])) first++ + last = n; while (last >= first && isblank(body[last])) last-- + for (i = first; i <= last; i++) print body[i] + } + ' "$file" +} + +case "$cmd" in + notes) + body="$(section "$version")" || die "no '## [$version]' section in $file" + [ -n "$body" ] || die "the '## [$version]' section in $file is empty" + printf '%s\n' "$body" + ;; + + cut) + date="${3:-$(date -u +%Y-%m-%d)}" + grep -qxF '## [Unreleased]' "$file" || die "no '## [Unreleased]' heading in $file" + if section "$version" >/dev/null 2>&1; then + die "'## [$version]' already exists in $file" + fi + # A section holding only "### Added"-style headings has nothing to release. + unreleased="$(section Unreleased)" + grep -v -e '^[[:space:]]*$' -e '^#' <<<"$unreleased" >/dev/null || + die "[Unreleased] in $file has no entries to release" + + tmp="$(mktemp "${file}.XXXXXX")" + trap 'rm -f "$tmp"' EXIT + awk -v v="$version" -v d="$date" "$read_file"' + END { + find_footer() + for (i = 1; i < footer; i++) { + # The newest released version, for the compare link. + if (prev == "" && starts(lines[i], "## [") && lines[i] != "## [Unreleased]") { + prev = substr(lines[i], 5); sub(/\].*/, "", prev) + } + } + for (i = footer; i <= count && base == ""; i++) { + # Repository base from an existing GitHub link in the footer. + if (lines[i] ~ /^\[[^]]+\]: https:\/\/github\.com\//) { + base = lines[i]; sub(/^\[[^]]+\]: /, "", base) + split(base, p, "/"); base = p[1] "//" p[3] "/" p[4] "/" p[5] + } + } + if (prev == "") link = base "/releases/tag/v" v + else link = base "/compare/v" prev "...v" v + added = 0 + for (i = 1; i <= count; i++) { + line = lines[i] + if (i < footer && line == "## [Unreleased]") { + print line; print ""; print "## [" v "] - " d + continue + } + if (i >= footer && base != "" && starts(line, "[Unreleased]: ")) { + print "[Unreleased]: " base "/compare/v" v "...HEAD" + print "[" v "]: " link + added = 1 + continue + } + if (i >= footer && base != "" && !added && islink(line)) { + print "[" v "]: " link + added = 1 + } + print line + } + } + ' "$file" >"$tmp" + mv "$tmp" "$file" + trap - EXIT + echo "cut [Unreleased] into [$version] - $date in $file" + ;; + + *) + usage + ;; +esac diff --git a/scripts/release-checks.sh b/scripts/release-checks.sh new file mode 100755 index 0000000..0e18e4e --- /dev/null +++ b/scripts/release-checks.sh @@ -0,0 +1,106 @@ +#!/usr/bin/env bash +# Release gates shared by the release workflow and a maintainer's shell. +# +# scripts/release-checks.sh green-ci [...] +# Wait for every named check run on and fail unless each one passed. With no names, +# the checks the `main` ruleset requires are used. Needs GH_TOKEN and GITHUB_REPOSITORY. +# scripts/release-checks.sh no-snapshots +# Fail if any POM or Gradle module staged in ~/.m2 for depends on a -SNAPSHOT. +# Maven Central rejects such a release only after the tag is already out. +set -euo pipefail + +die() { + echo "::error::$*" >&2 + exit 1 +} + +usage() { + sed -n '2,9p' "$0" | sed 's/^# \{0,1\}//' >&2 + exit 2 +} + +green_ci() { + local sha="$1" + shift + local repo="${GITHUB_REPOSITORY:?GITHUB_REPOSITORY is not set}" + # checks[i] is a required context and apps[i] the app the ruleset pins it to (empty: any). + # No mapfile: macOS runners ship Bash 3.2. + local -a checks=() apps=() + local name app + if [ $# -gt 0 ]; then + for name in "$@"; do + checks+=("$name") + apps+=("") + done + else + while IFS=$'\t' read -r name app; do + [ -n "$name" ] || continue + checks+=("$name") + apps+=("$app") + done < <(gh api "repos/$repo/rules/branches/main" \ + --jq '.[] | select(.type == "required_status_checks") | .parameters.required_status_checks[] | "\(.context)\t\(.integration_id // "")"') + fi + [ ${#checks[@]} -gt 0 ] || die "no required checks on main and none named; refusing to release unchecked" + + local deadline=$((SECONDS + ${GREEN_CI_TIMEOUT:-2700})) + local i check conclusion + local -a filter + for ((i = 0; i < ${#checks[@]}; i++)); do + check="${checks[$i]}" + app="${apps[$i]}" + filter=(-f check_name="$check") + # Only the app the ruleset names may satisfy the check, as for the ruleset itself. + [ -z "$app" ] || filter+=(-f app_id="$app") + while :; do + # check_name filters server-side; the unfiltered list is paginated and can hide the run. + # A re-run adds a run under the same name, so the newest one decides. + if ! conclusion="$(gh api -X GET "repos/$repo/commits/$sha/check-runs" "${filter[@]}" \ + --jq '.check_runs | sort_by(.started_at) | last | if . == null then "" else (.conclusion // "pending") end')"; then + echo "::warning::check-runs query failed for '$check'; retrying" + conclusion="pending" + fi + # A context with no pinned app may be a commit status rather than a check run. + if [ -z "$conclusion" ] && [ -z "$app" ] && ! conclusion="$(gh api "repos/$repo/commits/$sha/status" | + jq -r --arg c "$check" '[.statuses[] | select(.context == $c) | .state] | first // ""')"; then + echo "::warning::status query failed for '$check'; retrying" + conclusion="pending" + fi + # skipped and neutral pass, as they do for the ruleset itself. + case "$conclusion" in + success | skipped | neutral) + echo "ok $check ($conclusion)" + break + ;; + "" | pending) ;; + *) die "'$check' concluded '$conclusion' on $sha" ;; + esac + [ $SECONDS -lt $deadline ] || die "timed out waiting for '$check' on $sha" + echo "wait $check" + sleep 30 + done + done +} + +no_snapshots() { + local version="$1" + local root="${MAVEN_LOCAL:-$HOME/.m2/repository}/org/meshtastic" + local -a staged=() + local path + while IFS= read -r path; do + staged+=("$path") + done < <(find "$root" -path "*/$version/*" \( -name '*.pom' -o -name '*.module' \) 2>/dev/null) + [ ${#staged[@]} -gt 0 ] || die "nothing staged for $version under $root; run publishToMavenLocal first" + local hits + hits="$(grep -H -- '-SNAPSHOT' "${staged[@]}" || true)" + [ -z "$hits" ] || die "$version depends on a -SNAPSHOT:"$'\n'"$hits" + echo "ok ${#staged[@]} staged POM/module files for $version, no -SNAPSHOT dependency" +} + +[ $# -ge 2 ] || usage +cmd="$1" +shift +case "$cmd" in + green-ci) green_ci "$@" ;; + no-snapshots) no_snapshots "${1#v}" ;; + *) usage ;; +esac