From 605b587aa6b25e4887155846d3a003b570de5cc0 Mon Sep 17 00:00:00 2001 From: James Rich <2199651+jamesarich@users.noreply.github.com> Date: Sat, 3 Oct 2026 16:00:52 -0500 Subject: [PATCH 1/4] ci(release): gate on named CI checks and staged POMs, cut the changelog verbatim Bring kp1812 onto the org's KMP library release standard, with kzstd as the reference. The workflow is dispatched with an explicit `version` and an optional `dry_run`, and pushes the annotated tag only after every gate passes, so a refused release leaves no tag. `scripts/changelog.sh` cuts and reads `CHANGELOG.md` without re-rendering it, replacing the `org.jetbrains.changelog` plugin, whose `getChangelog` drops prose under `###` headings. The empty group skeleton under Unreleased goes with it, since a cut would carry those headings into the release body. `scripts/release-checks.sh` waits on the two macOS CI jobs by name, because `main` has no ruleset requiring checks, and refuses a staged POM or module that depends on a `-SNAPSHOT`. Attestation now covers every staged module, not only the root coordinate. The publish runs `publishAndReleaseToMavenCentral`, so Central releases the deployment without a manual portal step. The swift-package job checks out the commit the release job gated, and on a dry run builds and zips the XCFramework but attests and uploads nothing. Signed-off-by: James Rich <2199651+jamesarich@users.noreply.github.com> --- .github/workflows/release.yml | 285 ++++++++++++++++------------------ AGENTS.md | 8 +- CHANGELOG.md | 14 -- CONTRIBUTING.md | 7 +- RELEASING.md | 93 ++++++----- SECURITY.md | 7 +- build.gradle.kts | 25 --- gradle/libs.versions.toml | 4 - scripts/changelog.sh | 114 ++++++++++++++ scripts/release-checks.sh | 77 +++++++++ 10 files changed, 391 insertions(+), 243 deletions(-) create mode 100755 scripts/changelog.sh create mode 100755 scripts/release-checks.sh diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index aca4aba..f761112 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1,66 +1,118 @@ name: Release +# Dispatch from main with the version a merged release PR set, or push its tag. +# Every gate runs before the tag is pushed, so a refused release leaves no tag behind. on: push: tags: ['v*'] workflow_dispatch: + inputs: + version: + description: 'Version to release, e.g. 0.2.0 (must match VERSION)' + required: true + type: string + dry_run: + description: 'Run every gate and build 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, and kp1812 has no cinterop, so - # nothing here needs Xcode; the swift-package job builds the XCFramework. This - # is the shape kzstd verified against its 0.2.0: a Linux publish produced 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 kp1812 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 + env: + DRY_RUN: ${{ inputs.dry_run || false }} outputs: version: ${{ steps.version.outputs.version }} + sha: ${{ steps.version.outputs.sha }} 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: | + echo "version=${REQUESTED#v}" >> "$GITHUB_OUTPUT" + echo "sha=$(git rev-parse HEAD)" >> "$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 }} ORG_GRADLE_PROJECT_signingInMemoryKeyPassword: ${{ secrets.SIGNING_PASSWORD }} - run: ./gradlew publishToMavenLocal --stacktrace + run: ./gradlew publishToMavenLocal --no-configuration-cache --stacktrace + + - name: Refuse a -SNAPSHOT dependency + run: scripts/release-checks.sh no-snapshots "${{ steps.version.outputs.version }}" - - name: Collect artifacts for attestation + - name: Collect every staged artifact for attestation run: | mkdir -p maven-attestation - find ~/.m2/repository/org/meshtastic/kp1812 -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/kp1812-jvm/$V/kp1812-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" + echo "Maven Central POM probe for $V: HTTP $HTTP" + echo "published=$([ "$HTTP" = 200 ] && echo true || echo false)" >> "$GITHUB_OUTPUT" + + - 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 }} ORG_GRADLE_PROJECT_signingInMemoryKeyPassword: ${{ secrets.SIGNING_PASSWORD }} - 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" + swift-package: name: XCFramework & Swift package manifest # SwiftPM takes a binaryTarget by URL and checksum, so the zip and the Package.swift # naming it are attached to the release together. Only macOS can link the frameworks. + # A dry run builds and zips them, and attests and uploads nothing. needs: release runs-on: macos-latest + timeout-minutes: 60 + permissions: + contents: write + id-token: write + attestations: write env: VERSION: ${{ needs.release.outputs.version }} + DRY_RUN: ${{ inputs.dry_run || false }} steps: - - name: Checkout the release tag + - name: Checkout the commit the release job gated uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: - ref: v${{ needs.release.outputs.version }} + ref: ${{ needs.release.outputs.sha }} - - name: Setup JDK + - name: Setup JDK 25 uses: actions/setup-java@de7274f081f381c8f8158605e0321c36c376e2e6 # v6.0.1 with: distribution: temurin @@ -267,6 +250,7 @@ jobs: run: scripts/swift-package.sh "https://github.com/${{ github.repository }}/releases/download/v${VERSION}/Kp1812.xcframework.zip" - name: Attest the zip + if: env.DRY_RUN != 'true' uses: actions/attest-build-provenance@4d101475d8b20a2381f78447822ac1eab6504dd8 # v4.2.2 with: subject-path: build/swift-package/Kp1812.xcframework.zip @@ -274,6 +258,7 @@ jobs: - name: Attach to the release # --clobber replaces both files on a re-run, so the manifest's checksum always names # the zip next to it. + if: env.DRY_RUN != 'true' env: GH_TOKEN: ${{ github.token }} run: | diff --git a/AGENTS.md b/AGENTS.md index b241db6..60c1799 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -75,7 +75,7 @@ The repo is a single module: one small model, no `build-logic`, and no BOM. ./gradlew mingwX64Test # Windows host ./gradlew assembleKp1812ReleaseXCFramework # macOS host scripts/swift-package.sh # SwiftPM zip and Package.swift -./gradlew getChangelog --unreleased --no-header --no-links # what a release would say +scripts/changelog.sh notes X.Y.Z # what a release says ``` `` is the URL the zip will be served from. The daemon is pinned to @@ -84,9 +84,9 @@ machine has none, so the launcher JDK doesn't matter. ## Publishing -Maven Central via the vanniktech plugin (`org.meshtastic:kp1812`), from -`.github/workflows/release.yml` on a `v*` tag or a manual dispatch. See -`RELEASING.md`. +Maven Central via the vanniktech plugin (`org.meshtastic:kp1812`). 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/CHANGELOG.md b/CHANGELOG.md index 414bd3a..52ef06f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,20 +6,6 @@ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ## [Unreleased] -### Breaking - -### Added - -### Changed - -### Deprecated - -### Removed - -### Fixed - -### Security - ## [0.1.0] - 2026-10-02 ### Added diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index a95cd8e..dfaa31c 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -42,8 +42,11 @@ dump taken elsewhere silently drops them. Commit both `api/kp1812.api` and something they could have hit. Anything that moves an `api/*.api` dump always needs one, under `### Breaking` if the consumer has to change code rather than recompile. Refactors and test-only changes need none. -- Releases are cut from `main` following `RELEASING.md`, so don't bump versions - in a feature PR. +- `CHANGELOG.md` is hand-written and nothing generates it. At release + `scripts/changelog.sh` cuts the Unreleased section without re-rendering the + file, and that section is the GitHub Release body verbatim. +- Releases are cut from `main` following [RELEASING.md](RELEASING.md), so don't + bump versions in a feature PR. ## Equation numbers are part of the code diff --git a/RELEASING.md b/RELEASING.md index 7a94eda..285cff3 100644 --- a/RELEASING.md +++ b/RELEASING.md @@ -1,12 +1,14 @@ # Releasing kp1812 -kp1812 publishes to Maven Central (`org.meshtastic:kp1812`) through the -vanniktech maven-publish plugin, driven by `.github/workflows/release.yml`. +kp1812 publishes `org.meshtastic:kp1812` to Maven Central with the vanniktech +maven-publish plugin, from `.github/workflows/release.yml`. Each release also +carries a static `Kp1812.xcframework.zip` and the `Package.swift` that names it, +for SwiftPM. -## One-time setup +## Secrets -The repository needs these GitHub Actions secrets, named for the vanniktech -`ORG_GRADLE_PROJECT_*` convention: +The repository needs these GitHub Actions secrets, passed as the vanniktech +`ORG_GRADLE_PROJECT_*` properties: | Secret | Holds | | --- | --- | @@ -21,43 +23,52 @@ Export the key with `gpg --pinentry-mode loopback --armor --export-secret-keys ## Cutting a release -1. Pick the new version `X.Y.Z` (SemVer; before 1.0 a minor version can break). -2. Set `VERSION_NAME` in `gradle.properties` **and** mirror it into `VERSION`. - The workflow fails unless they match. -3. Run `./gradlew patchChangelog`. It reads `VERSION_NAME`, so step 2 comes - first. It moves the `## [Unreleased]` entries under a dated `## [X.Y.Z]` - heading, leaves an empty Unreleased behind, and writes the compare links. -4. Review the changelog diff, which becomes the GitHub release page. The task - fails if Unreleased is empty (`patchEmpty = false`), because a release with - nothing to say skipped the changelog. -5. If the public API changed, regenerate the dump **on macOS** (`./gradlew +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, regenerate the dump **on macOS** (`./gradlew apiDump`) and commit both `api/kp1812.api` and `api/kp1812.klib.api`. -6. Commit (signed off), open the PR, and merge it. -7. Trigger the release: push a `vX.Y.Z` tag, or run the **Release** workflow - through `workflow_dispatch`, which tags for you. Tags in this org are - immutable. - -## What the workflow does - -- Verifies that `VERSION`, `VERSION_NAME`, and a `## [X.Y.Z]` heading in - `CHANGELOG.md` agree. `getChangelog` doesn't fail on a missing section. It - prints the previous release instead, so the heading check is what stops that. -- **Waits for the two macOS CI jobs** on the commit being released: the Apple - test binaries and the full klib ABI check. The publish runs on Linux, which - cross-compiles every target but can't execute the Apple tests, so this guard - keeps that guarantee. -- Builds and tests everything a Linux host can run, stages the artifacts, - attests them, renders the changelog section into the release notes, and only - then publishes. -- 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. -- Then, on macOS, builds `Kp1812.xcframework` from the tag and zips it with - `scripts/swift-package.sh`. It attaches the zip and the `Package.swift` that - names it (URL and checksum) to the release. A re-run replaces both, so the - checksum always matches the zip beside it. +4. Commit `chore(release): X.Y.Z` (signed off), open the PR and merge it. +5. `gh workflow run release.yml --repo meshtastic/kp1812 -f version=X.Y.Z`. Add + `-f dry_run=true` to run every gate and build everything without tagging, + publishing or uploading; 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. The two macOS CI jobs passed on this commit (`scripts/release-checks.sh + green-ci`): the Apple test binaries and the full klib ABI check. The workflow + names them, because `main` has no ruleset requiring checks. This Linux runner + cross-compiles every target but cannot run the Apple tests. +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`, which releases the +deployment on Central without a manual step in the portal, and creates or +updates the GitHub Release. + +## XCFramework and Swift package + +A second job, on macOS, checks out the same commit, builds `Kp1812.xcframework` +and zips it with `scripts/swift-package.sh`. It attests the zip and attaches it +to the release with the `Package.swift` that names it by URL and checksum. A +re-run replaces both, so the checksum always matches the zip beside it. A dry +run builds and zips, and attests and uploads nothing. ## After releasing -Propagation from the Central Portal to repo1 takes 10 to 30 minutes. Before a -consumer bumps, confirm that -`https://repo1.maven.org/maven2/org/meshtastic/kp1812-jvm/X.Y.Z/` resolves. +`repo1.maven.org` lags the Central Portal by 10 to 30 minutes. Downstream bumps +wait until `https://repo1.maven.org/maven2/org/meshtastic/kp1812-jvm/X.Y.Z/` +resolves. diff --git a/SECURITY.md b/SECURITY.md index 4b1d677..7209b7e 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -31,9 +31,10 @@ That makes the realistic concerns narrow: `IllegalArgumentException` rather than producing a silent nonsense result. A malformed profile should never loop forever or allocate without bound, and one that does is a bug worth reporting here. -- **Supply chain.** `.github/workflows/release.yml` publishes releases from a - tagged commit, signed, with build provenance attested. Verify signatures if - you consume from Maven Central. +- **Supply chain.** `.github/workflows/release.yml` publishes every artifact + GPG-signed, with build provenance attested for each one. The `vX.Y.Z` tag it + pushes is annotated and unsigned. Verify signatures if you consume from Maven + Central. A prediction you disagree with isn't a security issue. P.1812 is a statistical model with substantial inherent uncertainty, and accuracy reports are welcome as diff --git a/build.gradle.kts b/build.gradle.kts index eedf8b5..3a269e6 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -11,10 +11,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 and VERSION_NAME come from gradle.properties, the single coordinate source the @@ -26,27 +22,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/kp1812" - // 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 heading check would only catch one tag later. - patchEmpty = false - // Breaking leads: kp1812 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 this project are documented here. The format follows - [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 { // The toolchain fixes the bytecode at 21 but not the API: built on a newer JDK, a call to // a 22+ method compiles, publishes, and fails on a consumer's 21 at runtime. -Xjdk-release diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 426702c..9ce1a56 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -1,8 +1,5 @@ [versions] kotlin = "2.4.20" -# JetBrains gradle-changelog-plugin: parses and renders the hand-written -# CHANGELOG.md. It never generates an entry from a commit. -changelog = "2.5.0" dokka = "2.2.0" binary-compat = "0.18.2" kover = "0.9.10" @@ -21,4 +18,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..1fc1e7e --- /dev/null +++ b/scripts/changelog.sh @@ -0,0 +1,114 @@ +#!/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" + +# 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" ' + function starts(s, p) { return substr(s, 1, length(p)) == p } + starts($0, "## ") { + if (inside) exit + h = "## [" want "]" + if ($0 == h || starts($0, h " ")) { inside = 1; found = 1; next } + } + inside && /^\[[^]]+\]: / { exit } + inside { lines[++n] = $0 } + END { + if (!found) exit 3 + first = 1; while (first <= n && lines[first] ~ /^[[:space:]]*$/) first++ + last = n; while (last >= first && lines[last] ~ /^[[:space:]]*$/) last-- + for (i = first; i <= last; i++) print lines[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" ' + function starts(s, p) { return substr(s, 1, length(p)) == p } + # The newest released version, for the compare link. + starts($0, "## [") && $0 != "## [Unreleased]" && prev == "" { + prev = substr($0, 5); sub(/\].*/, "", prev) + } + # Repository base from any existing GitHub link in the footer. + /^\[[^]]+\]: https:\/\/github\.com\// && base == "" { + base = $0; sub(/^\[[^]]+\]: /, "", base) + n = split(base, p, "/"); base = p[1] "//" p[3] "/" p[4] "/" p[5] + } + { lines[++count] = $0 } + END { + 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 (line == "## [Unreleased]") { + print line; print ""; print "## [" v "] - " d + continue + } + if (base != "" && starts(line, "[Unreleased]: ")) { + print "[Unreleased]: " base "/compare/v" v "...HEAD" + print "[" v "]: " link + added = 1 + continue + } + if (base != "" && !added && 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..646b3f9 --- /dev/null +++ b/scripts/release-checks.sh @@ -0,0 +1,77 @@ +#!/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}" + local -a checks=("$@") + if [ ${#checks[@]} -eq 0 ]; then + mapfile -t checks < <(gh api "repos/$repo/rules/branches/main" \ + --jq '.[] | select(.type == "required_status_checks") | .parameters.required_status_checks[].context') + 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 check conclusion + for check in "${checks[@]}"; do + while :; do + # check_name filters server-side; the unfiltered list is paginated and can hide the run. + if ! conclusion="$(gh api -X GET "repos/$repo/commits/$sha/check-runs" \ + -f check_name="$check" --jq '[.check_runs[].conclusion] | if length == 0 then "" else (map(. // "pending") | first) end')"; then + echo "::warning::check-runs query failed for '$check'; retrying" + conclusion="" + fi + 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 + mapfile -t staged < <(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 From 7632480368c298736a46abc4bea9f4ad51a43865 Mon Sep 17 00:00:00 2001 From: James Rich <2199651+jamesarich@users.noreply.github.com> Date: Sat, 3 Oct 2026 16:06:05 -0500 Subject: [PATCH 2/4] ci(release): run release-checks.sh on the Bash 3.2 macOS runners ship Signed-off-by: James Rich <2199651+jamesarich@users.noreply.github.com> --- scripts/release-checks.sh | 19 ++++++++++++++----- 1 file changed, 14 insertions(+), 5 deletions(-) diff --git a/scripts/release-checks.sh b/scripts/release-checks.sh index 646b3f9..6c8c13e 100755 --- a/scripts/release-checks.sh +++ b/scripts/release-checks.sh @@ -23,9 +23,15 @@ green_ci() { local sha="$1" shift local repo="${GITHUB_REPOSITORY:?GITHUB_REPOSITORY is not set}" - local -a checks=("$@") - if [ ${#checks[@]} -eq 0 ]; then - mapfile -t checks < <(gh api "repos/$repo/rules/branches/main" \ + # No mapfile: macOS runners ship Bash 3.2. + local -a checks=() + local name + if [ $# -gt 0 ]; then + checks=("$@") + else + while IFS= read -r name; do + [ -n "$name" ] && checks+=("$name") + done < <(gh api "repos/$repo/rules/branches/main" \ --jq '.[] | select(.type == "required_status_checks") | .parameters.required_status_checks[].context') fi [ ${#checks[@]} -gt 0 ] || die "no required checks on main and none named; refusing to release unchecked" @@ -58,8 +64,11 @@ green_ci() { no_snapshots() { local version="$1" local root="${MAVEN_LOCAL:-$HOME/.m2/repository}/org/meshtastic" - local -a staged - mapfile -t staged < <(find "$root" -path "*/$version/*" \( -name '*.pom' -o -name '*.module' \) 2>/dev/null) + 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)" From 9988a271716e3a69410b27606d79209238a834be Mon Sep 17 00:00:00 2001 From: James Rich <2199651+jamesarich@users.noreply.github.com> Date: Sat, 3 Oct 2026 17:22:38 -0500 Subject: [PATCH 3/4] ci(release): fail closed on the Central probe, read the newest check run - the Central probe treats only a 404 as unpublished and stops on any other answer - a dispatch takes the bare version, so one release has one concurrency group - green-ci decides from the newest run of each check and falls back to the commit status for a status-only context - changelog.sh treats only the trailing block of link definitions as the footer, so a definition inside a section stays in the release notes Signed-off-by: James Rich <2199651+jamesarich@users.noreply.github.com> --- .github/workflows/release.yml | 23 +++++++++-- scripts/changelog.sh | 72 ++++++++++++++++++++++------------- scripts/release-checks.sh | 14 +++++-- 3 files changed, 75 insertions(+), 34 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index f761112..d368032 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -36,6 +36,7 @@ jobs: id-token: write attestations: write checks: read + statuses: read env: DRY_RUN: ${{ inputs.dry_run || false }} outputs: @@ -50,6 +51,11 @@ jobs: 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" echo "sha=$(git rev-parse HEAD)" >> "$GITHUB_OUTPUT" @@ -163,10 +169,19 @@ jobs: env: V: ${{ steps.version.outputs.version }} run: | - HTTP=$(curl -sS -o /dev/null -w "%{http_code}" \ - "https://repo1.maven.org/maven2/org/meshtastic/kp1812-jvm/$V/kp1812-jvm-$V.pom") - echo "Maven Central POM probe for $V: HTTP $HTTP" - echo "published=$([ "$HTTP" = 200 ] && echo true || echo 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/kp1812-jvm/$V/kp1812-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' diff --git a/scripts/changelog.sh b/scripts/changelog.sh index 1fc1e7e..69357a8 100755 --- a/scripts/changelog.sh +++ b/scripts/changelog.sh @@ -26,23 +26,38 @@ 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" ' - function starts(s, p) { return substr(s, 1, length(p)) == p } - starts($0, "## ") { - if (inside) exit - h = "## [" want "]" - if ($0 == h || starts($0, h " ")) { inside = 1; found = 1; next } - } - inside && /^\[[^]]+\]: / { exit } - inside { lines[++n] = $0 } + 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 && lines[first] ~ /^[[:space:]]*$/) first++ - last = n; while (last >= first && lines[last] ~ /^[[:space:]]*$/) last-- - for (i = first; i <= last; i++) print lines[i] + 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" } @@ -67,35 +82,38 @@ case "$cmd" in tmp="$(mktemp "${file}.XXXXXX")" trap 'rm -f "$tmp"' EXIT - awk -v v="$version" -v d="$date" ' - function starts(s, p) { return substr(s, 1, length(p)) == p } - # The newest released version, for the compare link. - starts($0, "## [") && $0 != "## [Unreleased]" && prev == "" { - prev = substr($0, 5); sub(/\].*/, "", prev) - } - # Repository base from any existing GitHub link in the footer. - /^\[[^]]+\]: https:\/\/github\.com\// && base == "" { - base = $0; sub(/^\[[^]]+\]: /, "", base) - n = split(base, p, "/"); base = p[1] "//" p[3] "/" p[4] "/" p[5] - } - { lines[++count] = $0 } + 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 (line == "## [Unreleased]") { + if (i < footer && line == "## [Unreleased]") { print line; print ""; print "## [" v "] - " d continue } - if (base != "" && starts(line, "[Unreleased]: ")) { + if (i >= footer && base != "" && starts(line, "[Unreleased]: ")) { print "[Unreleased]: " base "/compare/v" v "...HEAD" print "[" v "]: " link added = 1 continue } - if (base != "" && !added && line ~ /^\[[^]]+\]: /) { + if (i >= footer && base != "" && !added && islink(line)) { print "[" v "]: " link added = 1 } diff --git a/scripts/release-checks.sh b/scripts/release-checks.sh index 6c8c13e..0ae3495 100755 --- a/scripts/release-checks.sh +++ b/scripts/release-checks.sh @@ -41,11 +41,19 @@ green_ci() { for check in "${checks[@]}"; do while :; do # check_name filters server-side; the unfiltered list is paginated and can hide the run. - if ! conclusion="$(gh api -X GET "repos/$repo/commits/$sha/check-runs" \ - -f check_name="$check" --jq '[.check_runs[].conclusion] | if length == 0 then "" else (map(. // "pending") | first) end')"; then + # 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" -f check_name="$check" \ + --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="" + conclusion="pending" fi + # A ruleset may require a commit status rather than a check run. + if [ -z "$conclusion" ] && ! 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)" From 071012a1a84476d04c3dcb148109d075f931cfe6 Mon Sep 17 00:00:00 2001 From: James Rich <2199651+jamesarich@users.noreply.github.com> Date: Sat, 3 Oct 2026 17:51:21 -0500 Subject: [PATCH 4/4] ci(release): accept a required check only from the app the ruleset pins green-ci reads the app each required check is pinned to from the main ruleset and accepts only a run from that app, as the ruleset does; a context with no pinned app may still be a commit status. Signed-off-by: James Rich <2199651+jamesarich@users.noreply.github.com> --- scripts/release-checks.sh | 34 +++++++++++++++++++++++----------- 1 file changed, 23 insertions(+), 11 deletions(-) diff --git a/scripts/release-checks.sh b/scripts/release-checks.sh index 0ae3495..0e18e4e 100755 --- a/scripts/release-checks.sh +++ b/scripts/release-checks.sh @@ -23,32 +23,44 @@ 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=() - local name + local -a checks=() apps=() + local name app if [ $# -gt 0 ]; then - checks=("$@") + for name in "$@"; do + checks+=("$name") + apps+=("") + done else - while IFS= read -r name; do - [ -n "$name" ] && checks+=("$name") + 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') + --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 check conclusion - for check in "${checks[@]}"; do + 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" -f check_name="$check" \ + 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 ruleset may require a commit status rather than a check run. - if [ -z "$conclusion" ] && ! conclusion="$(gh api "repos/$repo/commits/$sha/status" | + # 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"