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
86 changes: 79 additions & 7 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,8 @@ name: Release
# Once Maven Central serves both, the Gradle plugins go to the Gradle Plugin Portal, signed with the same
# release key, and the IntelliJ plugin goes to the JetBrains Marketplace, verified against the IDEs it
# supports first. After publishing, a draft GitHub release on the tag collects the runnable jar, the Gradle
# and IDE plugins and the native binaries.
# and IDE plugins, the native binaries and the WebAssembly module, which a job of its own builds on Oracle
# GraalVM.
#
# Needs seven repository secrets: JRELEASER_MAVENCENTRAL_USERNAME and JRELEASER_MAVENCENTRAL_PASSWORD (the
# Central Portal user token), JRELEASER_GPG_SECRET_KEY and JRELEASER_GPG_PASSPHRASE, GRADLE_PUBLISH_KEY and
Expand Down Expand Up @@ -194,6 +195,71 @@ jobs:
if-no-files-found: ignore
retention-days: 7

# The formatter as a WebAssembly module, for the playground on openjavaformat.dev. Built on Oracle GraalVM, the
# only distribution with the Web Image backend (native-image --tool:svm-wasm), and tested in Node against the
# runnable jar of the same tag. Maven Central does not carry it, so only the draft GitHub release waits for it: a
# failure here holds back nothing else, and "Re-run failed jobs" builds it again and then opens the draft.
wasm:
name: wasm
runs-on: ubuntu-latest
timeout-minutes: 60
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false

# The jars, and the runnable jar the module is compared with, compile with Java 21 through JDK21_HOME, as in
# native-images. Installed before GraalVM, so that Gradle still runs on GraalVM.
- name: Install JDK 21 for the jars
id: jdk21
uses: actions/setup-java@b843ebf9b913146b38da2eaa7aeddf76074ae1ad # v6.1.0
with:
distribution: temurin
java-version: '21'

# Pinned to the patch: Web Image is experimental, and its options and output change between releases.
- name: Install Oracle GraalVM 25 for the module
uses: graalvm/setup-graalvm@f70bc8032f90a104502824a54bcb94b6f01715bc # v1.6.8
with:
java-version: '25.0.4'
distribution: 'graalvm'
github-token: ${{ secrets.GITHUB_TOKEN }}

# Web Image runs wasm-opt from the PATH, and needs Binaryen 119 or later; Ubuntu's package is older.
- name: Install Binaryen
env:
BINARYEN_VERSION: version_133
BINARYEN_SHA256: 2dc9c7813f5375db93d96ead4b78222fcc3e2677bbb832297af4797782a37489
run: |
archive="binaryen-${BINARYEN_VERSION}-x86_64-linux.tar.gz"
curl --fail --silent --show-error --location --output "${RUNNER_TEMP}/${archive}" \
"https://github.com/WebAssembly/binaryen/releases/download/${BINARYEN_VERSION}/${archive}"
echo "${BINARYEN_SHA256} ${RUNNER_TEMP}/${archive}" | sha256sum --check
tar -xzf "${RUNNER_TEMP}/${archive}" -C "${RUNNER_TEMP}"
echo "${RUNNER_TEMP}/binaryen-${BINARYEN_VERSION}/bin" >> "$GITHUB_PATH"

# The tests run the module as a page does, and Node 24 has the WebAssembly GC and exception handling it needs.
# No cache: the tests install no packages, and a release must not restore one another run could have written.
- name: Install Node.js 24 for the tests
uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0
with:
node-version: '24'
package-manager-cache: false

# wasmTest: the native image's smoke test, then every test case's input against the runnable jar.
- name: Build and test the module
run: ./gradlew -Pwasm=true :open-java-format-wasm:wasmTest :open-java-format-wasm:wasmZip
env:
JDK21_HOME: ${{ steps.jdk21.outputs.path }}

- name: Hand the module to the GitHub release
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
with:
name: wasm-module
path: open-java-format-wasm/build/distributions/open-java-format-wasm-*.zip
if-no-files-found: error
retention-days: 1

# Both deployments are uploaded and validated by now. Publishing one is a JReleaser run given its ID,
# which builds nothing. One leg per deployment, so that a re-run repeats only the leg that failed.
publish:
Expand Down Expand Up @@ -318,13 +384,13 @@ jobs:
JETBRAINS_MARKETPLACE_TOKEN: ${{ secrets.JETBRAINS_MARKETPLACE_TOKEN }}

# What Maven Central does not carry — the runnable formatter jar, the Gradle plugins' jar, the IntelliJ
# plugin zip, the Eclipse plugin jar, and every platform's native binary as a plain download — goes into
# a draft GitHub release on the tag, each file signed with the release key. Only once both deployments
# are published; publishing the draft is a click on GitHub, and a re-run fails while a release for the
# tag exists.
# plugin zip, the Eclipse plugin jar, every platform's native binary as a plain download, and the
# WebAssembly module — goes into a draft GitHub release on the tag, each file signed with the release key.
# Only once both deployments are published and the module is built; publishing the draft is a click on
# GitHub, and a re-run fails while a release for the tag exists.
github-release:
name: draft GitHub release
needs: publish
needs: [publish, wasm]
runs-on: ubuntu-latest
permissions:
contents: write
Expand All @@ -347,8 +413,14 @@ jobs:
pattern: native-image-*
merge-multiple: true

- name: Collect the WebAssembly module
uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2
with:
name: wasm-module
path: wasm-module

- name: Build the plugins and open the draft release
run: ./gradlew -PreleaseTarget=github -PnativeImages=native-images jreleaserRelease
run: ./gradlew -PreleaseTarget=github -PnativeImages=native-images -PwasmModule=wasm-module jreleaserRelease
env:
JRELEASER_GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
JRELEASER_GPG_SECRET_KEY: ${{ secrets.JRELEASER_GPG_SECRET_KEY }}
Expand Down
19 changes: 18 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ and published from this repository.
| [Maven Central](https://central.sonatype.com/namespace/dev.openjavaformat) | `dev.openjavaformat:open-java-format`, with `-spi`, `-native` and `-jdk-bootstrap`, and the Maven plugin `dev.openjavaformat:fmt-maven-plugin` from [its own repository](https://github.com/openjavaformat/fmt-maven-plugin) |
| [Gradle Plugin Portal](https://plugins.gradle.org/plugin/dev.openjavaformat.java-format) | `dev.openjavaformat.java-format` |
| [JetBrains Marketplace](https://plugins.jetbrains.com/plugin/34359-open-java-format) | the IntelliJ IDEA plugin |
| [GitHub Releases](https://github.com/openjavaformat/open-java-format/releases/latest) | native binaries, a runnable jar, the Gradle, IntelliJ and Eclipse plugins, every file signed |
| [GitHub Releases](https://github.com/openjavaformat/open-java-format/releases/latest) | native binaries, a runnable jar, the Gradle, IntelliJ and Eclipse plugins, the WebAssembly module of [the playground](https://openjavaformat.dev/playground/), every file signed |

Why the project exists is in the [manifesto](https://openjavaformat.dev/manifesto/).

Expand Down Expand Up @@ -161,6 +161,23 @@ GraalVM has to come from outside Gradle: unpacking a JDK that Gradle downloaded
preserve the symlink GraalVM ships at `bin/native-image`, and `nativeCompile` then fails on an empty,
unexecutable file.

### WebAssembly module

The playground on openjavaformat.dev runs the formatter in the browser, compiled to WebAssembly by
GraalVM's Web Image backend. Only the release workflow builds it, in its `wasm` job, and the zip goes
on the GitHub release; `./gradlew build` leaves it out. Web Image ships in Oracle GraalVM only, so the
module needs Oracle GraalVM 25 behind `GRAALVM_HOME`, Binaryen 119 or later (`wasm-opt`) on the
`PATH`, and Node.js 24 or later for the tests:

```bash
./gradlew -Pwasm=true :open-java-format-wasm:wasmTest # build, then test in Node against the runnable jar
./gradlew -Pwasm=true :open-java-format-wasm:wasmDev # a bare page next to the module, to try it
jwebserver -d "$PWD/open-java-format-wasm/build/wasm-dev"
```

Without `-Pwasm=true` the subproject is not part of the build at all: it compiles against
`org.graalvm.webimage.api`, a module of Oracle GraalVM that no other JDK has.

### Running CI locally

The workflows run under act in Docker, so a change to `.github/workflows` can be checked before it
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,8 +13,9 @@ plugins {
//
// `-PreleaseTarget=github` deploys nothing. It is for `jreleaserRelease`, which opens a draft GitHub
// release on the tag with what Maven Central does not carry — the runnable formatter jar, the Gradle
// plugins' jar, the IntelliJ plugin zip, the Eclipse plugin jar and every platform's native binary as a
// plain download — each file signed, plus a checksum file.
// plugins' jar, the IntelliJ plugin zip, the Eclipse plugin jar, every platform's native binary as a
// plain download and the WebAssembly module for openjavaformat.dev's playground — each file signed, plus a
// checksum file.
//
// Publishing is a run of its own: with JRELEASER_MAVENCENTRAL_DEPLOYMENT_ID set, JReleaser publishes that
// deployment, which an earlier run uploaded and the Portal validated, and nothing is built. The ID has to
Expand Down Expand Up @@ -46,8 +47,9 @@ def stagingRepositories = releasedProjects.collect { path ->
}

// The GitHub release takes the binaries the per-platform jobs collected from -PnativeImages=<dir>, exactly
// as the native deployment does.
// as the native deployment does, and the WebAssembly module's zip that the wasm job built from -PwasmModule=<dir>.
def nativeBinaries = []
def wasmModules = []
if (releaseTarget == 'github') {
def collectedNativeImages = providers.gradleProperty('nativeImages').getOrNull()
if (collectedNativeImages == null) {
Expand All @@ -60,6 +62,17 @@ if (releaseTarget == 'github') {
if (nativeBinaries.isEmpty()) {
throw new GradleException("No native images to release in ${collectedNativeImages}")
}

def collectedWasmModule = providers.gradleProperty('wasmModule').getOrNull()
if (collectedWasmModule == null) {
throw new GradleException('-PreleaseTarget=github needs -PwasmModule=<dir> holding open-java-format-wasm-<version>.zip')
}
wasmModules = fileTree(file(collectedWasmModule)) {
include 'open-java-format-wasm-*.zip'
}.files.sort { it.name }
if (wasmModules.size() != 1) {
throw new GradleException("Expected one open-java-format-wasm-<version>.zip in ${collectedWasmModule}, found ${wasmModules.size()}")
}
}

jreleaser {
Expand Down Expand Up @@ -103,9 +116,9 @@ jreleaser {
// Name templates, not Gradle's version: the root project sets its version only after applying this
// plugin. The jars and the zip are built by the tasks jreleaserRelease depends on below.
files {
nativeBinaries.each { binary ->
(nativeBinaries + wasmModules).each { file ->
artifact {
path = binary.absolutePath
path = file.absolutePath
}
}
if (releaseTarget == 'github') {
Expand Down
148 changes: 148 additions & 0 deletions open-java-format-wasm/build.gradle
Original file line number Diff line number Diff line change
@@ -0,0 +1,148 @@
import org.graalvm.buildtools.gradle.tasks.BuildNativeImageTask

buildscript {
repositories {
mavenCentral()
gradlePluginPortal()
}
dependencies {
classpath 'org.graalvm.buildtools:native-gradle-plugin:1.1.17'
}
}

apply plugin: 'org.graalvm.buildtools.native'

description = 'open-java-format compiled to WebAssembly with GraalVM Web Image, for a web page'

dependencies {
implementation project(':open-java-format')
}

// Web Image, `native-image --tool:svm-wasm`, ships in Oracle GraalVM and in no other JDK, and so does the API
// WasmMain is written against: org.graalvm.webimage.api is a module of that JDK, not a Maven artifact. Gradle
// reports Oracle GraalVM's vendor as Oracle, the same as Oracle's plain JDK; nativeImageCapable tells the two apart.
// The jars this module depends on still compile for Java 21.
def oracleGraalVm = { spec ->
spec.languageVersion.set(JavaLanguageVersion.of(25))
spec.vendor.set(JvmVendorSpec.ORACLE)
spec.nativeImageCapable.set(true)
}

java {
toolchain(oracleGraalVm)
}

def exports = [
'jdk.compiler/com.sun.tools.javac.file',
'jdk.compiler/com.sun.tools.javac.main',
'jdk.compiler/com.sun.tools.javac.parser',
'jdk.compiler/com.sun.tools.javac.tree',
'jdk.compiler/com.sun.tools.javac.util',
'jdk.compiler/com.sun.tools.javac.code',
'jdk.compiler/com.sun.tools.javac.api'
]

// Initialized while the image is built, and kept in it. Locations as in open-java-format-native. javac's message
// bundles, and the diagnostic keys and lint categories made from them, end up in the image heap, where Web Image
// accepts only build-time initialized objects. Trees looks javac's methods up as MethodHandles in its static
// initializer: left to run time, that brings in Web Image's general MethodHandle support, and with it float and
// double compare-and-set, which it fails to compile ("unhandled compare" in WasmGCSingleThreadedAtomicsPhase).
// WasmMain$BuildVersion keeps the version passed below.
def buildTimeInitialized = [
'com.sun.tools.javac.file.Locations',
'com.sun.tools.javac.resources',
'com.sun.tools.javac.util.JCDiagnostic$DiagnosticType',
'com.sun.tools.javac.util.JCDiagnostic$Error',
'com.sun.tools.javac.util.JCDiagnostic$Fragment',
'com.sun.tools.javac.util.JCDiagnostic$LintWarning',
'com.sun.tools.javac.code.Lint$LintCategory',
'com.palantir.javaformat.java.Trees',
'com.palantir.javaformat.java.WasmMain$BuildVersion',
]

graalvmNative {
// As in open-java-format-native: this project ships its own metadata.
metadataRepository {
enabled = false
}

binaries.named('main') {
it.javaLauncher.set(javaToolchains.launcherFor(oracleGraalVm))
// The wrapper is open-java-format.js, and it fetches open-java-format.js.wasm from next to itself.
it.imageName.set('open-java-format')
it.mainClass.set('com.palantir.javaformat.java.WasmMain')
it.sharedLibrary.set(false)
it.classpath.from(configurations.named('runtimeClasspath'))
it.configurationFileDirectories.from(
rootProject.file('open-java-format-native/src/main/resources/META-INF/native-image'))
it.buildArgs.addAll(
'--tool:svm-wasm',
'-H:+UnlockExperimentalVMOptions',
// The page starts the module itself, through GraalVM.run, so that it learns when the module is
// ready and when loading it failed. Started by the wrapper, a failure would only reach the console.
'-H:-AutoRunVM',
"--initialize-at-build-time=${buildTimeInitialized.join(',')}".toString(),
"-Dopen-java-format.version=${project.version}".toString(),
'-H:+ReportExceptionStackTraces')
it.jvmArgs.addAll(exports.collect { value -> "--add-exports=${value}=ALL-UNNAMED".toString() })
}
}

def nativeCompile = tasks.named('nativeCompile', BuildNativeImageTask)
def moduleFiles = nativeCompile.flatMap { it.outputDirectory }.map { dir ->
fileTree(dir) { include 'open-java-format.js', 'open-java-format.js.wasm' }
}
def wrapper = nativeCompile.flatMap { it.outputDirectory }.map { it.file('open-java-format.js').asFile.path }

// The tests run the module in Node, as a page would run it. node has to be on the PATH.
def wasmSmokeTest = tasks.register('wasmSmokeTest', Exec) {
description = 'Formats the native image smoke test cases with the module, in Node.'
group = 'verification'
dependsOn nativeCompile
executable 'node'
args file('src/test/js/smoke.mjs')
argumentProviders.add({ [wrapper.get(), project.version.toString()] } as CommandLineArgumentProvider)
}

def allJar = project(':open-java-format').tasks.named('allJar', Jar)
def java21 = javaToolchains.launcherFor { it.languageVersion.set(JavaLanguageVersion.of(21)) }

def wasmDifferentialTest = tasks.register('wasmDifferentialTest', Exec) {
description = 'Formats every test case input with the module and with the runnable jar, and compares.'
group = 'verification'
dependsOn nativeCompile, allJar
executable 'node'
args file('src/test/js/differential.mjs')
argumentProviders.add({
[
wrapper.get(),
java21.get().executablePath.asFile.path,
allJar.get().archiveFile.get().asFile.path,
project(':open-java-format').file('src/test/resources/test-cases').path,
layout.buildDirectory.dir('wasm-differential').get().asFile.path,
]
} as CommandLineArgumentProvider)
}

tasks.register('wasmTest') {
description = 'Runs the module\'s smoke and differential tests in Node.'
group = 'verification'
dependsOn wasmSmokeTest, wasmDifferentialTest
}

// The GitHub release's open-java-format-wasm-<version>.zip: the wrapper and the module, as native-image names them,
// since the wrapper fetches the module by its own name plus .wasm. Not the .wat that native-image writes next to them.
tasks.register('wasmZip', Zip) {
description = 'Packs the module and its wrapper for the GitHub release.'
group = 'distribution'
from(moduleFiles)
}

// `jwebserver -d open-java-format-wasm/build/wasm-dev` serves the bare dev page next to the module.
tasks.register('wasmDev', Sync) {
description = 'Puts the dev page next to the module, for trying it in a browser.'
group = 'build'
from(moduleFiles)
from('src/dev')
into(layout.buildDirectory.dir('wasm-dev'))
}
43 changes: 43 additions & 0 deletions open-java-format-wasm/src/dev/index.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
<!doctype html>
<!--
A bare page for trying the module by hand, served next to it: see the README. Not released; the playground on
openjavaformat.dev is the real page.
-->
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>open-java-format on WebAssembly</title>
<style>
body { font-family: system-ui, sans-serif; margin: 1rem; }
main { display: grid; grid-template-columns: 1fr 1fr; gap: 1rem; }
textarea { width: 100%; height: 70vh; font-family: ui-monospace, monospace; font-size: 13px; }
</style>
</head>
<body>
<p><button id="format" disabled>Format</button> <span id="status">Loading…</span></p>
<main>
<textarea id="source" spellcheck="false">class A{int x;}</textarea>
<textarea id="output" spellcheck="false" readonly></textarea>
</main>
<script src="open-java-format.js"></script>
<script>
const status = document.getElementById("status");
const button = document.getElementById("format");
const started = performance.now();
GraalVM.run([], new GraalVM.Config()).then(() => {
status.textContent = `${openJavaFormat.version}, started in ${Math.round(performance.now() - started)} ms`;
button.disabled = false;
}, error => {
status.textContent = `Failed to start: ${error}`;
});
button.addEventListener("click", () => {
const before = performance.now();
const result = openJavaFormat.format(document.getElementById("source").value);
const millis = (performance.now() - before).toFixed(1);
document.getElementById("output").value = result.ok ? result.output : result.error;
status.textContent = `${openJavaFormat.version}: ${result.ok ? "formatted" : "error"} in ${millis} ms`;
});
</script>
</body>
</html>
Loading
Loading