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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
304 changes: 152 additions & 152 deletions .github/workflows/release.yml

Large diffs are not rendered by default.

8 changes: 4 additions & 4 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <ZIP_URL> # 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
```

`<ZIP_URL>` is the URL the zip will be served from. The daemon is pinned to
Expand All @@ -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

Expand Down
14 changes: 0 additions & 14 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
7 changes: 5 additions & 2 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
93 changes: 52 additions & 41 deletions RELEASING.md
Original file line number Diff line number Diff line change
@@ -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 |
| --- | --- |
Expand All @@ -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.
7 changes: 4 additions & 3 deletions SECURITY.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
25 changes: 0 additions & 25 deletions build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down
4 changes: 0 additions & 4 deletions gradle/libs.versions.toml
Original file line number Diff line number Diff line change
@@ -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"
Expand All @@ -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" }
132 changes: 132 additions & 0 deletions scripts/changelog.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,132 @@
#!/usr/bin/env bash
# Cut and read CHANGELOG.md sections without re-rendering the file.
#
# scripts/changelog.sh cut <version> [<date>] move [Unreleased] under a new "## [<version>] - <date>" heading
# scripts/changelog.sh notes <version> 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 "## [<version>]", 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
Loading
Loading