Skip to content

Commit 30575fc

Browse files
committed
Add draft GitHub release, X.Y.Z.N versions and Plugin Portal publishing
- release.yml opens a draft GitHub release on the tag with a runnable open-java-format-<version>-all.jar, the IntelliJ plugin zip, the Eclipse plugin jar and every native binary, each signed, plus a checksum file - Releases accept X.Y.Z.N; the IntelliJ plugin compares such versions itself instead of through sls-versions, which is gone - The Gradle plugins publish to the Gradle Plugin Portal by hand with `mise run release:gradle-plugin`, refusing anything but a release version
1 parent bd71bd7 commit 30575fc

12 files changed

Lines changed: 338 additions & 30 deletions

File tree

‎.github/workflows/release.yml‎

Lines changed: 50 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,18 @@
11
name: Release
22

33
# Tags only, and the tag is the version: the build reads it from GITHUB_REF_NAME and jreleaserDeploy
4-
# refuses anything that is not a clean X.Y.Z.
4+
# refuses anything that is not a clean X.Y.Z or X.Y.Z.N.
55
#
66
# Two deployments per tag, because a published groupId:artifactId:version can never gain files
77
# afterwards: the jars go up as one, and every platform's native binary as another. Both are uploaded
88
# and validated only — the Portal holds them until someone presses publish.
99
#
10+
# After both, a draft GitHub release on the tag collects the runnable jar, the IDE plugins and the native
11+
# binaries.
12+
#
1013
# Needs four repository secrets: JRELEASER_MAVENCENTRAL_USERNAME and JRELEASER_MAVENCENTRAL_PASSWORD
11-
# (the Central Portal user token) plus JRELEASER_GPG_SECRET_KEY and JRELEASER_GPG_PASSPHRASE.
14+
# (the Central Portal user token) plus JRELEASER_GPG_SECRET_KEY and JRELEASER_GPG_PASSPHRASE. The draft
15+
# release uses the workflow's own GITHUB_TOKEN.
1216
on:
1317
push:
1418
tags:
@@ -140,3 +144,47 @@ jobs:
140144
path: build/jreleaser/trace.log
141145
if-no-files-found: ignore
142146
retention-days: 7
147+
148+
# What Maven Central does not carry — the runnable formatter jar, the IntelliJ plugin zip, the Eclipse
149+
# plugin jar, and every platform's native binary as a plain download — goes into a draft GitHub release
150+
# on the tag, each file signed with the release key. Only once both deployments are in; publishing the
151+
# draft is a click on GitHub, and a re-run fails while a release for the tag exists.
152+
github-release:
153+
name: draft GitHub release
154+
needs: [jars, native-deploy]
155+
runs-on: ubuntu-latest
156+
permissions:
157+
contents: write
158+
steps:
159+
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
160+
with:
161+
fetch-depth: 0
162+
163+
- name: Install JDK 21
164+
uses: actions/setup-java@dd06d9cba3e5552c54d9f8ea23572deb30010f7c # v6.0.0
165+
with:
166+
distribution: temurin
167+
java-version: '21'
168+
169+
- name: Collect every platform's binary
170+
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
171+
with:
172+
path: native-images
173+
pattern: native-image-*
174+
merge-multiple: true
175+
176+
- name: Build the IDE plugins and open the draft release
177+
run: ./gradlew -PreleaseTarget=github -PnativeImages=native-images jreleaserRelease
178+
env:
179+
JRELEASER_GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
180+
JRELEASER_GPG_SECRET_KEY: ${{ secrets.JRELEASER_GPG_SECRET_KEY }}
181+
JRELEASER_GPG_PASSPHRASE: ${{ secrets.JRELEASER_GPG_PASSPHRASE }}
182+
183+
- name: Keep JReleaser's log
184+
if: ${{ failure() }}
185+
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
186+
with:
187+
name: jreleaser-log-github
188+
path: build/jreleaser/trace.log
189+
if-no-files-found: ignore
190+
retention-days: 7

‎README.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -119,7 +119,7 @@ replaced with something a stranger can run.
119119
| Concern | Today | Replacement |
120120
|---|---|---|
121121
| CI | ~~CircleCI (`.circleci/config.yml`, generated by Palantir's Excavator)~~ | **Done** — [`.github/workflows/ci.yml`](.github/workflows/ci.yml), one explicit job per platform |
122-
| Release | ~~Palantir Autorelease + `com.palantir.gradle.externalpublish`~~ | **In progress** — JReleaser → Maven Central Portal, configured as [`buildSrc`](buildSrc/src/main/groovy) conventions and triggered by a tag ([`release.yml`](.github/workflows/release.yml)): one deployment for the jars, one for the native images of every platform. Uploads only for now — each deployment waits in the Portal until someone publishes it. Still to do: the Gradle plugin and the IDE plugins, and signing keys in a protected GitHub environment |
122+
| Release | ~~Palantir Autorelease + `com.palantir.gradle.externalpublish`~~ | **In progress** — JReleaser → Maven Central Portal, configured as [`buildSrc`](buildSrc/src/main/groovy) conventions and triggered by a tag ([`release.yml`](.github/workflows/release.yml)): one deployment for the jars, one for the native images of every platform. Uploads only for now — each deployment waits in the Portal until someone publishes it. The same tag opens a draft GitHub release with a runnable formatter jar (`java -jar`, no flags), the IntelliJ plugin zip, the Eclipse plugin jar and the native binaries, each file signed. The Gradle plugins go to the Gradle Plugin Portal by hand, with `mise run release:gradle-plugin`, once those jars are published. Still to do: the JetBrains Marketplace, and signing keys in a protected GitHub environment |
123123
| Dependency updates | Excavator (`.excavator.yml`) | Renovate |
124124
| Auto-merge | Bulldozer (`.bulldozer.yml`) | GitHub merge queue + auto-merge |
125125
| Changelog | Palantir changelog-app (`.changelog.yml`) | Release Drafter, changelog entry in the PR template |

‎build.gradle‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ buildscript {
77

88
dependencies {
99
classpath 'com.palantir.jakartapackagealignment:jakarta-package-alignment:0.7.0'
10-
classpath 'com.gradle.publish:plugin-publish-plugin:2.1.1'
10+
classpath 'com.gradle.publish:plugin-publish-plugin:2.2.1'
1111
classpath 'com.palantir.baseline-error-prone:gradle-baseline-error-prone:0.7.0'
1212
classpath 'com.palantir.baseline:gradle-baseline-java:7.9.0'
1313
classpath 'com.palantir.gradle.failure-reports:gradle-failure-reports:1.21.0'

‎buildSrc/src/main/groovy/open-java-format.release-conventions.gradle‎

Lines changed: 82 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -10,8 +10,13 @@ plugins {
1010
// jobs, long after the jars are staged. `-PreleaseTarget=native` deploys the binaries those jobs
1111
// collected, the default deploys the jars; on a tag both read the same version from it.
1212
//
13-
// Not released yet: gradle-open-java-format, whose plugin markers and Gradle Plugin Portal listing are
14-
// a decision of their own. The Eclipse and IntelliJ plugins have no Maven publications at all.
13+
// `-PreleaseTarget=github` deploys nothing. It is for `jreleaserRelease`, which opens a draft GitHub
14+
// release on the tag with what Maven Central does not carry — the runnable formatter jar, the IntelliJ
15+
// plugin zip, the Eclipse plugin jar and every platform's native binary as a plain download — each file
16+
// signed, plus a checksum file.
17+
//
18+
// gradle-open-java-format is not released here: its plugins go to the Gradle Plugin Portal by hand, with
19+
// `mise run release:gradle-plugin`, once the jars they depend on are published.
1520
def releaseTarget = providers.gradleProperty('releaseTarget').getOrElse('jars')
1621
def releasedProjects
1722
if (releaseTarget == 'jars') {
@@ -22,13 +27,32 @@ if (releaseTarget == 'jars') {
2227
]
2328
} else if (releaseTarget == 'native') {
2429
releasedProjects = [':open-java-format-native']
30+
} else if (releaseTarget == 'github') {
31+
releasedProjects = []
2532
} else {
26-
throw new GradleException("Unknown -PreleaseTarget=${releaseTarget}: use 'jars' or 'native'")
33+
throw new GradleException("Unknown -PreleaseTarget=${releaseTarget}: use 'jars', 'native' or 'github'")
2734
}
2835
def stagingRepositories = releasedProjects.collect { path ->
2936
relativePath(project(path).layout.buildDirectory.dir('staging-deploy'))
3037
}
3138

39+
// The GitHub release takes the binaries the per-platform jobs collected from -PnativeImages=<dir>, exactly
40+
// as the native deployment does.
41+
def nativeBinaries = []
42+
if (releaseTarget == 'github') {
43+
def collectedNativeImages = providers.gradleProperty('nativeImages').getOrNull()
44+
if (collectedNativeImages == null) {
45+
throw new GradleException('-PreleaseTarget=github needs -PnativeImages=<dir> holding the native binaries')
46+
}
47+
nativeBinaries = fileTree(file(collectedNativeImages)) {
48+
include 'open-java-format-*'
49+
exclude '**/*.txt'
50+
}.files.sort { it.name }
51+
if (nativeBinaries.isEmpty()) {
52+
throw new GradleException("No native images to release in ${collectedNativeImages}")
53+
}
54+
}
55+
3256
jreleaser {
3357
dependsOnAssemble = false
3458

@@ -50,7 +74,38 @@ jreleaser {
5074

5175
release {
5276
github {
53-
enabled = false
77+
enabled = releaseTarget == 'github'
78+
// The tag is pushed by hand and is the version; the release only attaches to it.
79+
skipTag = true
80+
tagName = '{{projectVersion}}'
81+
releaseName = '{{projectVersion}}'
82+
// Published by hand after a look, like the deployments waiting in the Portal.
83+
draft = true
84+
changelog {
85+
formatted = 'ALWAYS'
86+
format = '- {{commitShortHash}} {{commitTitle}}'
87+
}
88+
}
89+
}
90+
91+
// Name templates, not Gradle's version: the root project sets its version only after applying this
92+
// plugin. The jars and the zip are built by the tasks jreleaserRelease depends on below.
93+
files {
94+
nativeBinaries.each { binary ->
95+
artifact {
96+
path = binary.absolutePath
97+
}
98+
}
99+
if (releaseTarget == 'github') {
100+
artifact {
101+
path = 'open-java-format/build/libs/open-java-format-{{projectVersion}}-all.jar'
102+
}
103+
artifact {
104+
path = 'open-java-format-idea-plugin/build/distributions/open-java-format-idea-plugin-{{projectVersion}}.zip'
105+
}
106+
artifact {
107+
path = 'open-java-format-eclipse-plugin/build/libs/open-java-format-eclipse-plugin-{{projectVersion}}.jar'
108+
}
54109
}
55110
}
56111

@@ -71,7 +126,8 @@ jreleaser {
71126
// The deployer's name selects its credentials — the Central Portal user token, as
72127
// JRELEASER_DEPLOY_MAVEN_MAVENCENTRAL_SONATYPE_USERNAME and _PASSWORD.
73128
sonatype {
74-
active = 'ALWAYS'
129+
// Nothing is deployed when the target is the GitHub release.
130+
active = releaseTarget == 'github' ? 'NEVER' : 'ALWAYS'
75131
url = 'https://central.sonatype.com/api/v1/publisher'
76132
namespace = 'dev.openjavaformat'
77133
applyMavenCentralRules = true
@@ -86,18 +142,31 @@ jreleaser {
86142
}
87143
}
88144

89-
// A release cannot be taken back, and the version comes from `git describe`: anywhere but a clean
90-
// checkout of a release tag it reads like 2.97.0-41-gf6667a2d.dirty. Refuse to upload that.
91-
def releaseVersion = provider { version.toString() }
92-
93145
tasks.named('jreleaserDeploy') {
94146
releasedProjects.each { path ->
95147
dependsOn "${path}:publishAllPublicationsToStagingRepository"
96148
}
97-
doFirst {
98-
if (!dryrun.getOrElse(false) && !(releaseVersion.get() ==~ /\d+\.\d+\.\d+/)) {
99-
throw new GradleException("Refusing to release ${releaseVersion.get()}: check out a release tag on a "
100-
+ 'clean working tree, or pass --dryrun to rehearse without uploading.')
149+
}
150+
151+
tasks.named('jreleaserRelease') {
152+
if (releaseTarget == 'github') {
153+
dependsOn ':open-java-format:allJar', ':open-java-format-idea-plugin:buildPlugin',
154+
':open-java-format-eclipse-plugin:jar'
155+
}
156+
}
157+
158+
// A release cannot be taken back, and the version comes from `git describe`: anywhere but a clean
159+
// checkout of a release tag it reads like 2.97.0-41-gf6667a2d.dirty. Refuse to upload that. A release is
160+
// upstream's X.Y.Z, optionally with a build number of our own after it: 2.98.0.1.
161+
def releaseVersion = provider { version.toString() }
162+
163+
['jreleaserDeploy', 'jreleaserRelease'].each { taskName ->
164+
tasks.named(taskName) {
165+
doFirst {
166+
if (!dryrun.getOrElse(false) && !(releaseVersion.get() ==~ /\d+\.\d+\.\d+(\.\d+)?/)) {
167+
throw new GradleException("Refusing to release ${releaseVersion.get()}: check out a release tag on a "
168+
+ 'clean working tree, or pass --dryrun to rehearse without uploading.')
169+
}
101170
}
102171
}
103172
}

‎gradle-open-java-format/build.gradle‎

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,6 @@
1-
apply plugin: 'java-gradle-plugin'
1+
// For the Gradle Plugin Portal: publishPlugins, run by hand once the version's jars are published on Maven
2+
// Central — the plugin depends on them. It applies java-gradle-plugin and maven-publish itself.
3+
apply plugin: 'com.gradle.plugin-publish'
24
apply plugin: 'groovy'
35
apply plugin: 'open-java-format.publishing-conventions'
46

@@ -156,3 +158,15 @@ tasks.named("test").configure {
156158
exclude '**/FormatDiffTest.class'
157159
}
158160
}
161+
162+
// The Portal never takes a version back, so only a release version goes up — X.Y.Z or X.Y.Z.N, as for Maven
163+
// Central in open-java-format.release-conventions. --validate-only checks the metadata of any version.
164+
def portalVersion = provider { project.version.toString() }
165+
tasks.named('publishPlugins') {
166+
doFirst {
167+
if (!validateOnly.getOrElse(false) && !(portalVersion.get() ==~ /\d+\.\d+\.\d+(\.\d+)?/)) {
168+
throw new GradleException("Refusing to publish ${portalVersion.get()} to the Gradle Plugin Portal: check "
169+
+ 'out a release tag on a clean working tree, or pass --validate-only.')
170+
}
171+
}
172+
}

‎gradle/libs.versions.toml‎

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -40,5 +40,4 @@ junit-jupiter-migrationsupport = { module = "org.junit.jupiter:junit-jupiter-mig
4040
junit-platform-launcher = { module = "org.junit.platform:junit-platform-launcher", version = "6.1.3" }
4141
junit-vintage-engine = { module = "org.junit.vintage:junit-vintage-engine", version = "6.1.3" }
4242
palantir-platform = { module = "com.palantir.gradle.utils:platform", version = "0.29.0" }
43-
slsVersions = { module = "com.palantir.sls.versions:sls-versions", version = "1.18.0" }
4443
spotless-gradlePlugin = { module = "com.diffplug.spotless:spotless-plugin-gradle", version = "8.10.2" }

‎mise.toml‎

Lines changed: 33 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,12 +14,44 @@ run = "act push --dryrun"
1414

1515
[tasks."ci:build"]
1616
description = "Run the linux jar+native build job locally in Docker (slow: downloads JDKs, runs all tests)"
17-
run = "act push --job build"
17+
run = "act push --job build --env JAVA_HOME=/opt/hostedtoolcache/Java_Temurin-Hotspot_jdk/21.0.12-101.0.LTS/arm64"
1818

1919
[tasks."ci:native"]
2020
description = "Run the linux-aarch64 native job locally in Docker"
2121
run = "act push --job native --matrix platform:linux-aarch64"
2222

23+
# ---- Releases ----
24+
# JReleaser stages, signs and uploads to the Maven Central Portal the modules listed in
25+
# buildSrc/src/main/groovy/open-java-format.release-conventions.gradle. The PGP key and the Portal
26+
# token come from JRELEASER_* variables or ~/.jreleaser/config.properties. Only a clean checkout of a
27+
# release tag is accepted. Run these through mise, not ./gradlew: JReleaser's POM check finds Java
28+
# through JAVA_HOME, which mise sets, and without it logs an error and carries on unchecked.
29+
#
30+
# These deploy the jars. The native images are a deployment of their own — one binary per platform,
31+
# built by four CI jobs and uploaded together by .github/workflows/release.yml on a tag, because a
32+
# published version can never gain files afterwards.
33+
34+
[tasks.release]
35+
description = "Stage, sign, and upload the release to the Maven Central Portal (publishing is a click there)"
36+
run = "./gradlew clean jreleaserDeploy"
37+
38+
[tasks."release:dry-run"]
39+
description = "Rehearse the release: stage, sign and check the artifacts, upload nothing"
40+
run = "./gradlew clean jreleaserDeploy --dryrun"
41+
42+
# The Gradle plugins go to the Gradle Plugin Portal instead, by hand and last: they depend on the version's
43+
# jars, so publish only once that deployment is live on Maven Central. The key and secret are
44+
# gradle.publish.key and gradle.publish.secret in ~/.gradle/gradle.properties. The Portal never takes a
45+
# version back; publishPlugins refuses anything but a release tag's version.
46+
47+
[tasks."release:gradle-plugin"]
48+
description = "Publish the Gradle plugins to the Gradle Plugin Portal (once the jars are live on Maven Central)"
49+
run = "./gradlew :gradle-open-java-format:publishPlugins"
50+
51+
[tasks."release:gradle-plugin:validate"]
52+
description = "Check the Gradle plugins' Portal metadata, publish nothing"
53+
run = "./gradlew :gradle-open-java-format:publishPlugins --validate-only"
54+
2355
# ---- GitHub Actions secrets ----
2456
# Read from 1Password at apply time; nothing secret is stored in the repo. Requires gh
2557
# (authenticated with admin on this repository) and the 1Password CLI (`op signin` first).

‎open-java-format-idea-plugin/build.gradle‎

Lines changed: 0 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -65,10 +65,6 @@ dependencies {
6565

6666
implementation project(':open-java-format-jdk-bootstrap')
6767
implementation libs.caffeine
68-
implementation(libs.slsVersions) {
69-
// Has class version conflicts with slf4j brought in by Intellij
70-
exclude group: 'org.slf4j', module: 'slf4j-api'
71-
}
7268
implementation libs.palantir.platform
7369

7470
formatter project(':open-java-format')
Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
/*
2+
* (c) Copyright 2026 Palantir Technologies Inc. All rights reserved.
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
package com.palantir.javaformat.intellij;
18+
19+
import java.util.Arrays;
20+
import java.util.Optional;
21+
import java.util.regex.Matcher;
22+
import java.util.regex.Pattern;
23+
24+
/**
25+
* A formatter version as this project writes them: dot-separated numbers — upstream's X.Y.Z, or 2.98.0.1 with a build
26+
* number of our own — optionally followed by what {@code git describe} adds after a tag, such as {@code -3-gabc1234},
27+
* and {@code .dirty}. Numbers compare numerically with a missing one counting as zero; commits after the tag only break
28+
* a tie.
29+
*/
30+
final class FormatterVersion implements Comparable<FormatterVersion> {
31+
private static final Pattern VERSION = Pattern.compile("(\\d+(?:\\.\\d+)*)(?:-(\\d+)-g\\p{XDigit}+)?(?:\\.dirty)?");
32+
33+
private final int[] numbers;
34+
private final int commitsAfterTag;
35+
36+
private FormatterVersion(int[] numbers, int commitsAfterTag) {
37+
this.numbers = numbers;
38+
this.commitsAfterTag = commitsAfterTag;
39+
}
40+
41+
static Optional<FormatterVersion> parse(String version) {
42+
Matcher matcher = VERSION.matcher(version);
43+
if (!matcher.matches()) {
44+
return Optional.empty();
45+
}
46+
try {
47+
int[] numbers = Arrays.stream(matcher.group(1).split("\\."))
48+
.mapToInt(Integer::parseInt)
49+
.toArray();
50+
int commitsAfterTag = matcher.group(2) == null ? 0 : Integer.parseInt(matcher.group(2));
51+
return Optional.of(new FormatterVersion(numbers, commitsAfterTag));
52+
} catch (NumberFormatException e) {
53+
return Optional.empty();
54+
}
55+
}
56+
57+
@Override
58+
public int compareTo(FormatterVersion other) {
59+
for (int i = 0; i < Math.max(numbers.length, other.numbers.length); i++) {
60+
int difference = Integer.compare(numberAt(i), other.numberAt(i));
61+
if (difference != 0) {
62+
return difference;
63+
}
64+
}
65+
return Integer.compare(commitsAfterTag, other.commitsAfterTag);
66+
}
67+
68+
private int numberAt(int index) {
69+
return index < numbers.length ? numbers[index] : 0;
70+
}
71+
}

0 commit comments

Comments
 (0)