From b9c766949c630c86ee67867551265145b0f1ddf9 Mon Sep 17 00:00:00 2001 From: David Tooke Date: Fri, 9 Oct 2026 16:59:58 -0400 Subject: [PATCH 1/2] Make zsh's completion script loadable from $fpath as _relix `relix completion zsh` printed bash's script, which zsh runs through bashcompinit when it is sourced. Homebrew installs zsh completions as a file on $fpath instead, which compinit binds by its first line and runs as a function on the first Tab. The script now starts with `#compdef relix`, and ends by completing the word it was called for when it runs inside a completion; sourced, it does what it did before. Part of #15. --- .../relix/cli/command/Completion.java | 32 ++++++++++++++++--- .../cli/command/ManagementCommandsTest.java | 14 ++++++++ 2 files changed, 42 insertions(+), 4 deletions(-) diff --git a/src/main/java/com/darkcollective/relix/cli/command/Completion.java b/src/main/java/com/darkcollective/relix/cli/command/Completion.java index 59992bc..c47cf2b 100644 --- a/src/main/java/com/darkcollective/relix/cli/command/Completion.java +++ b/src/main/java/com/darkcollective/relix/cli/command/Completion.java @@ -30,9 +30,10 @@ * Completion scripts for the command, generated from its picocli model, so that they * cannot drift from the options it parses (design §7.3). * - *

bash's is picocli's own, which also runs under zsh through {@code bashcompinit}. - * fish and PowerShell have none from picocli, so theirs are written here: each completes - * the commands, then each command's subcommands and options. + *

bash's is picocli's own, which also runs under zsh through {@code bashcompinit}; zsh's + * wraps it so that it works both sourced and as {@code _relix} on {@code $fpath}, which is + * where Homebrew installs it. fish and PowerShell have none from picocli, so theirs are + * written here: each completes the commands, then each command's subcommands and options. */ final class Completion { @@ -62,12 +63,35 @@ private Completion() { static String script(Shell shell, CommandLine root) { String name = root.getCommandName(); return switch (shell) { - case BASH, ZSH -> AutoComplete.bash(name, root); + case BASH -> AutoComplete.bash(name, root); + case ZSH -> zsh(name, root); case FISH -> fish(name, root.getCommandSpec()); case POWERSHELL -> powershell(name, root.getCommandSpec()); }; } + // ------------------------------------------------------------------------- + // zsh + // ------------------------------------------------------------------------- + + /** + * bash's script, made a zsh completion function too. On {@code $fpath} the file is the + * body of {@code _relix}, which compinit binds to the command by its first line. Its + * first call defines the bash completion and rebinds the command to it, so it then + * completes the word it was called for; sourced, outside a completion, it does not. + */ + private static String zsh(String name, CommandLine root) { + return "#compdef " + name + "\n" + + "# zsh completion for " + name + ", generated from its command model: bash's, run\n" + + "# through bashcompinit.\n" + + "# Install: " + name + " completion zsh > \"${fpath[1]}/_" + name + "\"\n" + + "# or: source <(" + name + " completion zsh)\n\n" + + AutoComplete.bash(name, root) + + "\nif (( ${+compstate} )); then\n" + + " _bash_complete -F _complete_" + name + " -o default\n" + + "fi\n"; + } + // ------------------------------------------------------------------------- // fish // ------------------------------------------------------------------------- diff --git a/src/test/java/com/darkcollective/relix/cli/command/ManagementCommandsTest.java b/src/test/java/com/darkcollective/relix/cli/command/ManagementCommandsTest.java index 2f75f96..27d996e 100644 --- a/src/test/java/com/darkcollective/relix/cli/command/ManagementCommandsTest.java +++ b/src/test/java/com/darkcollective/relix/cli/command/ManagementCommandsTest.java @@ -251,6 +251,20 @@ void script(String shell) { assertThat(result.out()).contains("keywords").contains("trace"); } + @Test + @DisplayName("zsh's is a completion function too, for $fpath, that completes on its first call") + void zshFunction() { + String script = cli().run("completion", "zsh").out(); + + assertThat(script).startsWith("#compdef relix\n"); + assertThat(script).endsWith(""" + if (( ${+compstate} )); then + _bash_complete -F _complete_relix -o default + fi + """); + assertThat(script).contains("complete -F _complete_relix -o default relix"); + } + @Test @DisplayName("an unknown shell is a usage error") void unknown() { From 101fa215829c30c1c4a8c42d2427e5f0c241854e Mon Sep 17 00:00:00 2001 From: David Tooke Date: Fri, 9 Oct 2026 16:59:59 -0400 Subject: [PATCH 2/2] Package the command: jlink image, AppCDS, man pages, completions, release ./gradlew releaseArchive builds this platform's archive: the jlink image, with relix-solver-ojalgo and relix-docs, picocli and the H2 driver force-merged; man/man1, one page per command from picocli's ManPageGenerator through Asciidoctor's manpage backend; and completions/ for bash, zsh, fish and PowerShell. H2 is now a runtime dependency, the one driver the image bundles. Each stage of a pipeline starts a JVM, so the image carries an AppCDS archive (lib/relix.jsa), trained at build time on a run that joins and groups a bound CSV file, over the JDK archive jlink generates. The launcher maps it and sends the JVM's warnings to standard error rather than into the rows. The generated launchers are patched: the sh one resolves a relative symbolic link, as Homebrew installs it, and the Windows one no longer runs from its own bin directory, which took relative paths and .relix/ discovery away from the user's. ./gradlew packageTest unpacks the archive with the system's tar and runs its launcher: a script on stdin over a catalog in a temporary tree (a CSV source and an H2 connection, trusted first), tsv out; OPTIMIZE and relix doc, for the providers jlink would drop unnoticed; -Xshare:on with the command's classes loaded from the archive, and the same flag refusing a missing one; and man over every page. CI runs it on each platform. The Release workflow builds and tests the five platform archives. On a tag it attaches them and checksums.txt to the GitHub Release and pushes the formula and the manifest, written whole from packaging/, to the tap and the bucket; run by hand it is a dry run, which checks their checksums against the archives and publishes nothing. Part of #15. --- .github/workflows/ci.yml | 7 +- .github/workflows/release.yml | 198 +++++++++++++ CONTRIBUTING.md | 34 +++ README.md | 21 ++ build.gradle | 253 +++++++++++++++++ docs/design.md | 5 +- packaging/homebrew/relix.rb | 46 +++ packaging/release.py | 107 +++++++ packaging/scoop/relix.json | 44 +++ .../cli/packaging/ReleaseArchiveTest.java | 261 ++++++++++++++++++ .../relix/cli/packaging/ReleaseFiles.java | 116 ++++++++ 11 files changed, 1089 insertions(+), 3 deletions(-) create mode 100644 .github/workflows/release.yml create mode 100644 packaging/homebrew/relix.rb create mode 100644 packaging/release.py create mode 100644 packaging/scoop/relix.json create mode 100644 src/packageTest/java/com/darkcollective/relix/cli/packaging/ReleaseArchiveTest.java create mode 100644 src/packaging/java/com/darkcollective/relix/cli/packaging/ReleaseFiles.java diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index dd9945a..64b8cc5 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,5 +1,5 @@ -# The gate: `./gradlew clean build` on Linux, macOS and Windows, for every pull request -# and every push to main or develop. +# The gate: `./gradlew clean build`, then the platform archive's tests, on Linux, macOS +# and Windows, for every pull request and every push to main or develop. name: CI on: @@ -33,6 +33,9 @@ jobs: if: runner.os == 'Linux' run: sudo apt-get update && sudo apt-get install -y zsh fish - run: ./gradlew clean build + # The platform archive, run as it ships: a pipeline through its launcher, the CDS + # archive mapped, the man pages rendered. + - run: ./gradlew packageTest - name: Upload test and coverage reports if: always() uses: actions/upload-artifact@v6 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..fdd3747 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,198 @@ +# Releases the command (design §7.3): a platform archive for each of macOS, Linux and +# Windows, attached with their checksums to this repository's GitHub Release, and the +# Homebrew formula and Scoop manifest that install them, pushed to the tap +# (DarkCollective/homebrew-relix) and the bucket (DarkCollective/scoop-relix). +# +# A tag vX.Y.Z publishes X.Y.Z. A tag with a suffix (v1.2.0-rc1) publishes a pre-release +# and leaves the tap and the bucket alone. +# +# Run from the Actions tab, it is a dry run: it builds and tests every archive, writes the +# formula and the manifest and checks their checksums against the archives, and uploads +# them as the run's `release-files` artifact. It publishes nothing, and since it may run +# on develop, whose engine pin is a snapshot, it skips checkEnginePin. +name: Release + +on: + push: + tags: ['v[0-9]*.[0-9]*.[0-9]*'] + workflow_dispatch: + inputs: + version: + description: 'The version to build, without the v (a dry run publishes nothing)' + required: true + default: '0.0.0-dryrun' + +permissions: + contents: read + +jobs: + version: + name: Version + runs-on: ubuntu-latest + outputs: + version: ${{ steps.version.outputs.version }} + publish: ${{ steps.version.outputs.publish }} + stable: ${{ steps.version.outputs.stable }} + steps: + - id: version + env: + EVENT: ${{ github.event_name }} + TAG: ${{ github.ref_name }} + INPUT: ${{ inputs.version }} + run: | + if [ "$EVENT" = push ]; then + version="${TAG#v}" + publish=true + else + version="$INPUT" + publish=false + fi + if ! printf '%s' "$version" | grep -Eq '^[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z.]+)?$'; then + echo "::error::'$version' is not a version such as 1.2.0 or 1.2.0-rc1" + exit 1 + fi + case "$version" in *-*) stable=false ;; *) stable=true ;; esac + { + echo "version=$version" + echo "publish=$publish" + echo "stable=$stable" + } >> "$GITHUB_OUTPUT" + + package: + name: Package (${{ matrix.platform }}) + needs: version + strategy: + fail-fast: true + matrix: + include: + - { os: macos-latest, platform: macos-aarch64 } + - { os: macos-15-intel, platform: macos-x86_64 } + - { os: ubuntu-latest, platform: linux-x86_64 } + - { os: ubuntu-24.04-arm, platform: linux-aarch64 } + - { os: windows-latest, platform: windows-x86_64 } + runs-on: ${{ matrix.os }} + timeout-minutes: 45 + env: + VERSION: ${{ needs.version.outputs.version }} + # A dry run may build develop, whose engine pin is a snapshot; a release may not. + SKIP_PIN: ${{ needs.version.outputs.publish != 'true' && '-x checkEnginePin' || '' }} + steps: + - uses: actions/checkout@v7 + # Corretto's JDK carries the jmods jlink links from. + - uses: actions/setup-java@v6 + with: + distribution: corretto + java-version: '21' + - uses: gradle/actions/setup-gradle@v6 + # The gate, then the archive, then the archive's own tests: a pipeline through its + # launcher, the CDS archive mapped, the man pages rendered. + - name: Build, package and test the archive + shell: bash + run: ./gradlew clean build releaseArchive packageTest -PcliVersion="$VERSION" $SKIP_PIN + - name: Check the archive is this platform's + shell: bash + run: | + case "${{ matrix.platform }}" in windows-*) ext=zip ;; *) ext=tar.gz ;; esac + test -f "build/distributions/relix-$VERSION-${{ matrix.platform }}.$ext" + - uses: actions/upload-artifact@v6 + with: + name: archive-${{ matrix.platform }} + path: build/distributions/relix-* + if-no-files-found: error + - name: Upload test reports + if: failure() + uses: actions/upload-artifact@v6 + with: + name: reports-${{ matrix.platform }} + path: build/reports/ + if-no-files-found: ignore + + release-files: + name: Checksums, formula and manifest + needs: [version, package] + runs-on: ubuntu-latest + env: + VERSION: ${{ needs.version.outputs.version }} + steps: + - uses: actions/checkout@v7 + - uses: actions/download-artifact@v7 + with: + pattern: archive-* + path: dist + merge-multiple: true + # Fails unless every archive the formula and the manifest name is in dist with the + # checksum they give it. + - run: python3 packaging/release.py "$VERSION" dist release-files + - uses: actions/upload-artifact@v6 + with: + name: release-files + path: release-files/ + if-no-files-found: error + + publish: + name: Publish the release + needs: [version, release-files] + if: needs.version.outputs.publish == 'true' + runs-on: ubuntu-latest + permissions: + contents: write + env: + VERSION: ${{ needs.version.outputs.version }} + STABLE: ${{ needs.version.outputs.stable }} + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + steps: + - uses: actions/download-artifact@v7 + with: + pattern: archive-* + path: dist + merge-multiple: true + - uses: actions/download-artifact@v7 + with: + name: release-files + path: release-files + - name: Create the GitHub Release + run: | + prerelease=() + if [ "$STABLE" != true ]; then prerelease=(--prerelease); fi + gh release create "v$VERSION" --repo "$GITHUB_REPOSITORY" --verify-tag \ + --title "relix $VERSION" --generate-notes "${prerelease[@]}" \ + dist/relix-"$VERSION"-* release-files/checksums.txt + + tap: + name: Update the Homebrew tap and the Scoop bucket + needs: [version, publish] + if: needs.version.outputs.stable == 'true' + runs-on: ubuntu-latest + env: + VERSION: ${{ needs.version.outputs.version }} + steps: + - uses: actions/download-artifact@v7 + with: + name: release-files + path: release-files + - name: Push the formula to DarkCollective/homebrew-relix + env: + TOKEN: ${{ secrets.HOMEBREW_TAP_PAT }} + run: | + git clone "https://x-access-token:${TOKEN}@github.com/DarkCollective/homebrew-relix.git" tap + mkdir -p tap/Formula + cp release-files/relix.rb tap/Formula/relix.rb + cd tap + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + git add Formula/relix.rb + git commit -m "relix $VERSION" + git push + - name: Push the manifest to DarkCollective/scoop-relix + env: + TOKEN: ${{ secrets.SCOOP_BUCKET_PAT }} + run: | + git clone "https://x-access-token:${TOKEN}@github.com/DarkCollective/scoop-relix.git" bucket + mkdir -p bucket/bucket + cp release-files/relix.json bucket/bucket/relix.json + cd bucket + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + git add bucket/relix.json + git commit -m "relix $VERSION" + git push diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index c012117..98b2ec5 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -118,6 +118,22 @@ below. You need no Java 21 installed beforehand: Gradle provisions it. the build unpacks into `build/engine-docs`. A page that disagrees is fixed in relix-core. +### The release archive + +The command ships as a platform archive: a jlink image with an AppCDS archive, the man +pages and the completion scripts (design §7.3). CI also runs its tests on each +platform, after `build`: + +```bash +./gradlew packageTest +``` + +It builds the archive (`releaseArchive`, a minute or so), unpacks it with the system's +`tar`, and runs the launcher it holds: a script on standard input over a catalog in a +temporary tree, OPTIMIZE and `relix doc` (each finds a provider that jlink would leave +out unnoticed), `-Xshare:on` (which fails unless the CDS archive is used), and `man` over +every page. The man pages need `man` installed, and are not checked on Windows. + ### Building against a local engine checkout To try an engine change before it is published, build against your relix-core @@ -138,6 +154,24 @@ that builds on Gradle 9. An older one fails while configuring, in a plugin that Gradle 9 broke (for example `info.solidsoft.pitest`, which reports an unknown property `baseDir`). +### Cutting a release (maintainers) + +1. Pin a released engine in `build.gradle` (`checkEnginePin` refuses a snapshot) and + promote to `main`. +2. Run the **Release** workflow from the Actions tab with the version. It is a dry run: + it builds and tests the five platform archives, writes the Homebrew formula and the + Scoop manifest from [`packaging/`](packaging), checks their checksums against the + archives, and uploads them as the `release-files` artifact. Nothing is published. +3. Tag `main`: `git tag -a v1.2.0 -m "relix 1.2.0" && git push origin v1.2.0`. The + workflow runs again, attaches the archives and `checksums.txt` to the GitHub Release, + and pushes the formula to `DarkCollective/homebrew-relix` and the manifest to + `DarkCollective/scoop-relix`, with the `HOMEBREW_TAP_PAT` and `SCOOP_BUCKET_PAT` + secrets. A tag with a suffix (`v1.2.0-rc1`) makes a pre-release and leaves the tap and + the bucket alone. + +The formula and the manifest are written whole from `packaging/` on every release, so a +change to either is made there, not in the tap or the bucket. + ## Branches and commits Work on a branch named with a prefix — `feat/`, `fix/`, `docs/`, `chore/` or diff --git a/README.md b/README.md index dd79954..92adf9a 100644 --- a/README.md +++ b/README.md @@ -28,6 +28,27 @@ Requires Java 21, which the Gradle wrapper provisions if it is missing: [CONTRIBUTING.md](CONTRIBUTING.md#the-build-gate) describes what the build checks. +`./gradlew releaseArchive` builds this platform's archive in `build/distributions`: a +self-contained image (no Java needed to run it) with `bin/relix`, the man pages in +`man/man1` and the completion scripts in `completions/`. + +## Installing + +Once there is a release: + +```bash +brew install DarkCollective/relix/relix +``` + +```powershell +scoop bucket add relix https://github.com/DarkCollective/scoop-relix +scoop install relix +``` + +Or download the archive for your platform from the +[releases](https://github.com/DarkCollective/relix-cli/releases), check it against +`checksums.txt`, unpack it and put its `bin/` on your `PATH`. + ## Licence [Apache License 2.0](LICENSE.txt). See [CONTRIBUTING.md](CONTRIBUTING.md) to take diff --git a/build.gradle b/build.gradle index b7f2b1c..f0b356a 100644 --- a/build.gradle +++ b/build.gradle @@ -7,7 +7,11 @@ plugins { id 'java' + id 'application' id 'jacoco' + // The platform image (design §7.3). forceMerge folds the jars that are not proper + // modules into one that is, which jlink needs. + id 'org.beryx.jlink' version '4.1.1' } group = 'com.darkcollective.relix' @@ -44,7 +48,21 @@ java { } } +sourceSets { + // What generates the man pages and completion scripts the release archive carries. + // It runs in the build and ships in nothing. + packaging { + compileClasspath += sourceSets.main.output + runtimeClasspath += sourceSets.main.output + } + // The tests of the packaged command: they run its launcher, not its classes. + packageTest +} + configurations { + packagingImplementation.extendsFrom implementation + packagingRuntimeOnly.extendsFrom runtimeOnly + // The pinned engine's manuals, which the documentation guards read. engineManuals { canBeConsumed = false @@ -52,6 +70,9 @@ configurations { } } +// The H2 driver: the image bundles it, and the package tests write their database with it. +def h2Driver = 'com.h2database:h2:2.5.252' + dependencies { // The engine, for the command and the library its embed API is. implementation "com.darkcollective.relix:relix:${relixEngineVersion}" @@ -68,11 +89,27 @@ dependencies { implementation 'info.picocli:picocli:4.7.7' annotationProcessor 'info.picocli:picocli-codegen:4.7.7' + // The one JDBC driver the image carries: the database that needs no setup. The + // engine finds it through DriverManager and nothing here names it; every other + // driver is installed with `relix drivers install`. + runtimeOnly h2Driver + testImplementation platform('org.junit:junit-bom:6.1.3') testImplementation 'org.junit.jupiter:junit-jupiter' testImplementation 'org.assertj:assertj-core:3.27.7' testRuntimeOnly 'org.junit.platform:junit-platform-launcher' + packagingImplementation 'info.picocli:picocli-codegen:4.7.7' + packagingImplementation 'org.asciidoctor:asciidoctorj:3.0.1' + + packageTestImplementation platform('org.junit:junit-bom:6.1.3') + packageTestImplementation 'org.junit.jupiter:junit-jupiter' + packageTestImplementation 'org.assertj:assertj-core:3.27.7' + packageTestRuntimeOnly 'org.junit.platform:junit-platform-launcher' + // The database the pipeline test's catalog connects to is written with the driver + // the image bundles. + packageTestRuntimeOnly h2Driver + engineManuals "com.darkcollective.relix:relix:${relixEngineVersion}:manuals@zip" } @@ -196,3 +233,219 @@ jar { 'Relix-Engine-Version': relixEngineVersion) } } + +// ── The release archive (design §7.3) ───────────────────────────────────────────────── +// +// ./gradlew releaseArchive build/distributions/relix--.tar.gz +// (.zip on Windows), for the platform the build runs on +// ./gradlew packageTest runs the archive's launcher: a pipeline, the CDS +// archive, the man pages +// +// The archive holds the jlink image (bin/relix, the runtime and an AppCDS archive), +// man/man1, completions/ and the licence. Each platform's is built on that platform. + +application { + applicationName = 'relix' + mainModule = 'com.darkcollective.relix.cli' + mainClass = 'com.darkcollective.relix.cli.Main' +} + +def os = org.gradle.internal.os.OperatingSystem.current() +def arch = System.getProperty('os.arch') in ['aarch64', 'arm64'] ? 'aarch64' : 'x86_64' +def platform = "${os.isMacOsX() ? 'macos' : os.isWindows() ? 'windows' : 'linux'}-${arch}" +def releaseName = "relix-${version}" +def imageDir = layout.buildDirectory.dir('image') +def cdsArchive = 'lib/relix.jsa' + +// Each stage of a pipeline starts a JVM, so the image carries an AppCDS archive of the +// classes a run loads, and the launcher maps it. The JVM's warnings go to standard error, +// not to standard output where they would join the rows. +def launcherJvmArgs = ['-XX:SharedArchiveFile={{BIN_DIR}}/../' + cdsArchive, + '-Xlog:all=off', '-Xlog:all=warning:stderr'] + +jlink { + options = ['--strip-debug', '--compress', 'zip-6', '--no-header-files', '--no-man-pages', + // The JDK's own CDS archive, which the application's extends. + '--generate-cds-archive', + // The solver is a provider nothing `requires`, so jlink would leave it out + // and OPTIMIZE would find no solver. It is named as a root. + '--add-modules', 'com.darkcollective.relix.solver.ojalgo'] + launcher { + name = 'relix' + jvmArgs = launcherJvmArgs + } + // H2 is not a named module, and picocli is one only from Java 9's versioned entries; + // jlink takes neither, so both are merged into one module that is... + forceMerge('picocli', 'h2') + // ...which re-declares H2's driver, for DriverManager to find it in the image, and + // reads the JDK modules H2 may touch (JNDI data sources, JMX, logging). + mergedModule { + requires 'java.sql' + requires 'java.naming' + requires 'java.security.sasl' + requires 'java.management' + requires 'java.logging' + provides('java.sql.Driver').with('org.h2.Driver') + } +} + +// The script the CDS archive is trained on: a run that parses, analyses and executes a +// join and an aggregate over a bound CSV file, and prints tsv. The classes it loads are +// the ones every run loads. +def trainingDir = layout.buildDirectory.dir('cds-training') +def writeTrainingRun = tasks.register('writeTrainingRun') { + description = 'Writes the script and data the AppCDS archive is trained on.' + def dir = trainingDir + outputs.dir(dir) + doLast { + def root = dir.get().asFile + root.deleteDir() + new File(root, 'home').mkdirs() + new File(root, 'people.csv').text = 'id,name,city\n1,ada,london\n2,bob,paris\n3,cy,london\n' + new File(root, 'training.relix').text = '''\ +Cities := [ +| city | country | +|--------|---------| +| london | uk | +| paris | fr | +]; +query { τ country (γ country, COUNT(*) → people (σ id > 0 (People ⋈ Cities))) }; +''' + } +} + +tasks.named('jlink').configure { + dependsOn writeTrainingRun + def image = imageDir + def training = trainingDir + def jvmArgs = launcherJvmArgs + def archive = cdsArchive + def module = "${application.mainModule.get()}/${application.mainClass.get()}" + doLast { + def root = image.get().asFile + // The generated launcher resolves a symbolic link before finding the runtime + // beside it: Homebrew runs it through one, which is relative to its directory. + def script = new File(root, 'bin/relix') + if (script.exists()) { + script.text = script.text.replace('DIR="${0%/*}"', '''\ +LINK="$0" +while [ -h "$LINK" ]; do + TARGET=$(readlink "$LINK") + case "$TARGET" in + /*) LINK="$TARGET" ;; + *) LINK="$(dirname "$LINK")/$TARGET" ;; + esac +done +DIR="$(cd "$(dirname "$LINK")" && pwd)"''') + } + // The Windows launcher would run from its own directory, which takes relative + // paths and .relix/ discovery away from the user's. + def batch = new File(root, 'bin/relix.bat') + if (batch.exists()) { + batch.text = batch.text.readLines() + .findAll { !(it.trim() in ['pushd %DIR%', 'popd']) } + .join('\r\n') + '\r\n' + } + + // The training run, with the launcher's options but writing the archive instead + // of mapping it. + def dir = training.get().asFile + def java = new File(root, "bin/java${os.isWindows() ? '.exe' : ''}").absolutePath + def command = [java, "-XX:ArchiveClassesAtExit=${new File(root, archive)}"] + + jvmArgs.findAll { !it.startsWith('-XX:SharedArchiveFile') } + + ["-Duser.home=${new File(dir, 'home')}", + '-m', module, '-o', 'tsv', '-i', 'People=csv:people.csv', 'training.relix'] + def process = new ProcessBuilder(command*.toString()).directory(dir).redirectErrorStream(true).start() + def output = process.inputStream.getText('UTF-8') + if (process.waitFor() != 0 || !output.contains('uk\t2')) { + throw new GradleException("The AppCDS training run failed:\n${output}") + } + if (!new File(root, archive).isFile()) { + throw new GradleException("The AppCDS training run wrote no ${archive}:\n${output}") + } + } +} + +// The man pages and completion scripts, from the command's picocli model. +def releaseFilesDir = layout.buildDirectory.dir('release-files') +def releaseFiles = tasks.register('releaseFiles', JavaExec) { + description = 'Generates the man pages and completion scripts from the command model.' + // The jar, not the classes: the man pages' version comes from its manifest. + classpath = files(tasks.named('jar')) + sourceSets.packaging.runtimeClasspath - sourceSets.main.output + mainClass = 'com.darkcollective.relix.cli.packaging.ReleaseFiles' + def dir = releaseFilesDir + outputs.dir(dir) + inputs.property('version', version.toString()) + doFirst { dir.get().asFile.deleteDir() } + argumentProviders.add({ + def root = dir.get().asFile + [new File(root, 'adoc').path, new File(root, 'man/man1').path, new File(root, 'completions').path] + } as CommandLineArgumentProvider) +} + +def stageRelease = tasks.register('stageRelease', Sync) { + description = 'Lays out the release archive\'s contents in build/release.' + dependsOn tasks.named('jlink') + from(imageDir) + from(releaseFiles) { + include 'man/**', 'completions/**' + } + from(layout.projectDirectory) { + include 'LICENSE.txt', 'NOTICE', 'README.md' + } + def staged = layout.buildDirectory.dir("release/${releaseName}") + into staged + // The JDK's CDS archives are read-only, which a copy over them fails on. + doFirst { staged.get().asFile.deleteDir() } +} + +def releaseArchive = tasks.register('releaseArchive', os.isWindows() ? Zip : Tar) { + group = 'distribution' + description = "Builds the release archive for this platform (${platform})." + from(stageRelease) { + into releaseName + } + archiveFileName = "${releaseName}-${platform}.${os.isWindows() ? 'zip' : 'tar.gz'}" + destinationDirectory = layout.buildDirectory.dir('distributions') + if (!os.isWindows()) { + compression = Compression.GZIP + } + preserveFileTimestamps = false + reproducibleFileOrder = true + // The launcher, the runtime's binaries and jspawnhelper must stay executable; an + // archive otherwise gives every file the same mode. + useFileSystemPermissions() +} + +// The archive, unpacked by the system's tar as a user or Homebrew unpacks it, so that +// the tests run what ships rather than what was staged. +def unpackedRelease = layout.buildDirectory.dir('package-test') +def unpackRelease = tasks.register('unpackRelease', Exec) { + description = 'Unpacks the release archive into build/package-test with tar.' + def archive = releaseArchive.flatMap { it.archiveFile } + def dir = unpackedRelease + inputs.file(archive) + outputs.dir(dir) + doFirst { + def root = dir.get().asFile + root.deleteDir() + root.mkdirs() + } + // Windows' own tar is bsdtar, which reads a zip; a GNU tar earlier on the PATH does not. + executable os.isWindows() ? "${System.getenv('SystemRoot')}\\System32\\tar.exe" : 'tar' + argumentProviders.add({ + ['-xf', archive.get().asFile.absolutePath, '-C', dir.get().asFile.absolutePath] + } as CommandLineArgumentProvider) +} + +tasks.register('packageTest', Test) { + group = 'verification' + description = 'Runs the release archive\'s launcher: a pipeline, the CDS archive, the man pages.' + testClassesDirs = sourceSets.packageTest.output.classesDirs + classpath = sourceSets.packageTest.runtimeClasspath + useJUnitPlatform() + dependsOn unpackRelease + def unpacked = unpackedRelease.map { it.dir(releaseName) } + inputs.dir(unpackedRelease).withPropertyName('release') + systemProperty 'relix.release', unpacked.get().asFile.absolutePath +} diff --git a/docs/design.md b/docs/design.md index 4ea099e..6ae03dd 100644 --- a/docs/design.md +++ b/docs/design.md @@ -387,7 +387,10 @@ sees. `brew tap DarkCollective/relix` resolves) and the Scoop bucket (`DarkCollective/scoop-relix`) stay separate repositories, updated by the release job; they stay private until the first release and must be public from then on, - as must the archives they point at. + as must the archives they point at. The formula and the manifest are written whole + from templates in this repository's `packaging/`, with each release's checksums, + and a dry run of the release workflow checks those against the archives before + anything is published. - Windows: `isatty` via `System.console()`; `SIGPIPE` does not exist and the `EPIPE` path covers it; paths in `-i` accept both separators. diff --git a/packaging/homebrew/relix.rb b/packaging/homebrew/relix.rb new file mode 100644 index 0000000..3718a8e --- /dev/null +++ b/packaging/homebrew/relix.rb @@ -0,0 +1,46 @@ +# The relix formula. The release workflow of DarkCollective/relix-cli writes it from +# packaging/homebrew/relix.rb there, with the release's version and checksums: change it +# there, since a change made in the tap is overwritten by the next release. +class Relix < Formula + desc "Run Relix relational-algebra scripts as a stage of a Unix pipeline" + homepage "https://github.com/DarkCollective/relix-cli" + version "@VERSION@" + license "Apache-2.0" + + on_macos do + on_arm do + url "https://github.com/DarkCollective/relix-cli/releases/download/v#{version}/relix-#{version}-macos-aarch64.tar.gz" + sha256 "@SHA256:macos-aarch64@" + end + on_intel do + url "https://github.com/DarkCollective/relix-cli/releases/download/v#{version}/relix-#{version}-macos-x86_64.tar.gz" + sha256 "@SHA256:macos-x86_64@" + end + end + + on_linux do + on_arm do + url "https://github.com/DarkCollective/relix-cli/releases/download/v#{version}/relix-#{version}-linux-aarch64.tar.gz" + sha256 "@SHA256:linux-aarch64@" + end + on_intel do + url "https://github.com/DarkCollective/relix-cli/releases/download/v#{version}/relix-#{version}-linux-x86_64.tar.gz" + sha256 "@SHA256:linux-x86_64@" + end + end + + def install + libexec.install Dir["*"] + bin.install_symlink libexec/"bin/relix" + man1.install Dir[libexec/"man/man1/*.1"] + bash_completion.install libexec/"completions/relix.bash" => "relix" + zsh_completion.install libexec/"completions/_relix" + fish_completion.install libexec/"completions/relix.fish" + end + + test do + (testpath/"people.csv").write "id,name\n1,ada\n2,bob\n" + output = pipe_output("#{bin}/relix -i People=csv:people.csv", "query { SELECT id > 1 (People) };\n") + assert_equal "id\tname\n2\tbob\n", output + end +end diff --git a/packaging/release.py b/packaging/release.py new file mode 100644 index 0000000..5fc5bf5 --- /dev/null +++ b/packaging/release.py @@ -0,0 +1,107 @@ +#!/usr/bin/env python3 +"""Write a release's checksums, Homebrew formula and Scoop manifest, and check them. + +Usage: + release.py VERSION DIST OUT + +DIST holds the five platform archives the release workflow built. OUT receives: + + checksums.txt the archives' SHA-256 sums, as `sha256sum` prints them + relix.rb packaging/homebrew/relix.rb with the version and checksums filled in + relix.json packaging/scoop/relix.json, likewise + +Then it reads the formula and the manifest back and fails unless every archive each +one names is in DIST under that name with that checksum, which is what a dry-run +release proves before anything is published. +""" +import hashlib +import json +import pathlib +import re +import sys + +PLATFORMS = ["macos-aarch64", "macos-x86_64", "linux-aarch64", "linux-x86_64", "windows-x86_64"] +PACKAGING = pathlib.Path(__file__).resolve().parent + + +def archive_name(version: str, platform: str) -> str: + extension = "zip" if platform.startswith("windows") else "tar.gz" + return f"relix-{version}-{platform}.{extension}" + + +def sha256(path: pathlib.Path) -> str: + digest = hashlib.sha256() + with path.open("rb") as f: + for block in iter(lambda: f.read(1 << 20), b""): + digest.update(block) + return digest.hexdigest() + + +def fill(template: str, version: str, sums: dict) -> str: + text = template.replace("@VERSION@", version) + for platform, digest in sums.items(): + text = text.replace(f"@SHA256:{platform}@", digest) + left = re.findall(r"@[A-Z0-9:_-]+@", text) + if left: + sys.exit(f"placeholders left unfilled: {', '.join(sorted(set(left)))}") + return text + + +def formula_archives(formula: str, version: str) -> dict: + """The archive name and checksum of each url/sha256 pair in the formula.""" + pairs = re.findall(r'url "([^"]+)"\s*\n\s*sha256 "([0-9a-f]{64})"', formula) + return {url.replace("#{version}", version).rsplit("/", 1)[1]: digest for url, digest in pairs} + + +def manifest_archives(manifest: dict) -> dict: + """The archive name and checksum of each architecture in the manifest.""" + return {entry["url"].rsplit("/", 1)[1]: entry["hash"] + for entry in manifest["architecture"].values()} + + +def check(name: str, named: dict, expected: set, dist: pathlib.Path) -> list: + problems = [] + if set(named) != expected: + problems.append(f"{name} names {sorted(named)}, not {sorted(expected)}") + for archive, digest in named.items(): + path = dist / archive + if not path.is_file(): + problems.append(f"{name} names {archive}, which is not in {dist}") + elif sha256(path) != digest: + problems.append(f"{name} gives {archive} the checksum {digest}, not {sha256(path)}") + return problems + + +def main() -> None: + if len(sys.argv) != 4: + sys.exit(__doc__) + version, dist, out = sys.argv[1], pathlib.Path(sys.argv[2]), pathlib.Path(sys.argv[3]) + if not re.fullmatch(r"\d+\.\d+\.\d+(-[0-9A-Za-z.]+)?", version): + sys.exit(f"'{version}' is not a version such as 1.2.0 or 1.2.0-rc1") + + archives = {platform: dist / archive_name(version, platform) for platform in PLATFORMS} + missing = [str(path) for path in archives.values() if not path.is_file()] + if missing: + sys.exit("missing archives:\n " + "\n ".join(missing)) + sums = {platform: sha256(path) for platform, path in archives.items()} + + out.mkdir(parents=True, exist_ok=True) + (out / "checksums.txt").write_text( + "".join(f"{sums[p]} {archives[p].name}\n" for p in PLATFORMS), encoding="utf-8") + formula = fill((PACKAGING / "homebrew" / "relix.rb").read_text(encoding="utf-8"), version, sums) + (out / "relix.rb").write_text(formula, encoding="utf-8") + manifest = fill((PACKAGING / "scoop" / "relix.json").read_text(encoding="utf-8"), version, sums) + (out / "relix.json").write_text(manifest, encoding="utf-8") + + unix = {archive_name(version, p) for p in PLATFORMS if not p.startswith("windows")} + windows = {archive_name(version, p) for p in PLATFORMS if p.startswith("windows")} + problems = (check("relix.rb", formula_archives(formula, version), unix, dist) + + check("relix.json", manifest_archives(json.loads(manifest)), windows, dist)) + if problems: + sys.exit("\n".join(problems)) + print(f"relix {version}: checksums.txt, relix.rb and relix.json in {out}; " + f"every archive they name matches its checksum") + + +if __name__ == "__main__": + main() diff --git a/packaging/scoop/relix.json b/packaging/scoop/relix.json new file mode 100644 index 0000000..dfd2fcd --- /dev/null +++ b/packaging/scoop/relix.json @@ -0,0 +1,44 @@ +{ + "##": "The relix manifest. The release workflow of DarkCollective/relix-cli writes it from packaging/scoop/relix.json there, with the release's version and checksum: change it there, since a change made in the bucket is overwritten by the next release.", + "version": "@VERSION@", + "description": "Run Relix relational-algebra scripts as a stage of a Unix pipeline", + "homepage": "https://github.com/DarkCollective/relix-cli", + "license": "Apache-2.0", + "architecture": { + "64bit": { + "url": "https://github.com/DarkCollective/relix-cli/releases/download/v@VERSION@/relix-@VERSION@-windows-x86_64.zip", + "hash": "@SHA256:windows-x86_64@", + "extract_dir": "relix-@VERSION@" + } + }, + "bin": "bin\\relix.bat", + "post_install": [ + "$completion = Join-Path (appdir $app $global) 'current\\completions\\relix.ps1'", + "$marker = '# relix completion, added by scoop'", + "$line = \"if (Test-Path '$completion') { . '$completion' } $marker\"", + "$profileFile = $PROFILE.CurrentUserAllHosts", + "if (!(Test-Path $profileFile)) { New-Item -ItemType File -Force -Path $profileFile | Out-Null }", + "if (!(Select-String -Path $profileFile -SimpleMatch $marker -Quiet)) { Add-Content -Path $profileFile -Value $line }" + ], + "uninstaller": { + "script": [ + "$profileFile = $PROFILE.CurrentUserAllHosts", + "if (Test-Path $profileFile) {", + " $kept = Get-Content $profileFile | Where-Object { $_ -notlike '*# relix completion, added by scoop' }", + " Set-Content -Path $profileFile -Value $kept", + "}" + ] + }, + "notes": "Tab completion for relix is loaded by your PowerShell profile ($PROFILE.CurrentUserAllHosts); open a new session to use it.", + "checkver": { + "github": "https://github.com/DarkCollective/relix-cli" + }, + "autoupdate": { + "architecture": { + "64bit": { + "url": "https://github.com/DarkCollective/relix-cli/releases/download/v$version/relix-$version-windows-x86_64.zip", + "extract_dir": "relix-$version" + } + } + } +} diff --git a/src/packageTest/java/com/darkcollective/relix/cli/packaging/ReleaseArchiveTest.java b/src/packageTest/java/com/darkcollective/relix/cli/packaging/ReleaseArchiveTest.java new file mode 100644 index 0000000..5b9f3fd --- /dev/null +++ b/src/packageTest/java/com/darkcollective/relix/cli/packaging/ReleaseArchiveTest.java @@ -0,0 +1,261 @@ +/* + * Copyright 2026 Darkcollective, LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.darkcollective.relix.cli.packaging; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.condition.DisabledOnOs; +import org.junit.jupiter.api.condition.OS; +import org.junit.jupiter.api.io.TempDir; + +import java.io.IOException; +import java.io.OutputStream; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.sql.Connection; +import java.sql.DriverManager; +import java.sql.Statement; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.TimeUnit; +import java.util.stream.Stream; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * The release archive's contents, run as a user runs them: through the launcher, in a + * process of its own (design §7.3). What a packaging mistake breaks — a provider jlink + * left out, a driver DriverManager cannot see, a CDS archive the JVM will not map — the + * unit tests cannot see, because they run on the build's class path. + * + *

{@code relix.release} names the unpacked archive; {@code ./gradlew + * packageTest} builds and unpacks it first. + */ +class ReleaseArchiveTest { + + private static final boolean WINDOWS = System.getProperty("os.name").startsWith("Windows"); + + private final Path release = Path.of(System.getProperty("relix.release")); + + @TempDir + Path tree; + + private Path home; + + @BeforeEach + void home() throws IOException { + home = Files.createDirectories(tree.resolve("home")); + } + + @Test + @DisplayName("runs in a pipe: a script on stdin, a catalog from the directory tree, tsv out") + void pipeline() throws Exception { + Path project = Files.createDirectories(home.resolve("project")); + Path catalog = Files.createDirectories(project.resolve(".relix/catalog")); + // The connection is to an H2 database, so the bundled driver has to be found. + // Its names are folded to lower case, as the engine writes them unquoted. + String url = "jdbc:h2:file:" + project.resolve("shop").toAbsolutePath().toString().replace('\\', '/') + + ";DATABASE_TO_LOWER=TRUE"; + try (Connection connection = DriverManager.getConnection(url); + Statement statement = connection.createStatement()) { + statement.execute("CREATE TABLE cities (city VARCHAR(20), country VARCHAR(20))"); + statement.execute("INSERT INTO cities VALUES ('london', 'uk'), ('paris', 'fr')"); + } + Files.writeString(catalog.resolve("people.csv"), "id,name,city\n1,ada,london\n2,bob,paris\n"); + Files.writeString(catalog.resolve("10-shop.relix"), """ + connection shop from database { url: "%s" }; + source People from csv("people.csv") { + header: true, + schema: { id: NUMBER, name: STRING, city: STRING } + }; + """.formatted(url)); + + Run trust = relix(project, "", "catalog", "trust"); + assertThat(trust.status()).as(trust.toString()).isZero(); + + Run run = relix(project, "query { τ name (π name, country (People ⋈ shop.cities)) };\n"); + + assertThat(run.status()).as(run.toString()).isZero(); + assertThat(run.out()).isEqualTo("name\tcountry\nada\tuk\nbob\tfr\n"); + } + + @Test + @DisabledOnOs(OS.WINDOWS) + @DisplayName("runs through a relative symbolic link, as Homebrew installs it") + void symbolicLink() throws Exception { + Path bin = Files.createDirectories(tree.resolve("prefix/bin")).toRealPath(); + Path link = Files.createSymbolicLink(bin.resolve("relix"), + bin.relativize(release.resolve("bin/relix").toRealPath())); + + Run run = run(home, "", List.of(), link, "version"); + + assertThat(run.status()).as(run.toString()).isZero(); + assertThat(run.out()).startsWith("relix "); + } + + @Test + @DisplayName("finds the solver: OPTIMIZE plans") + void solver() throws Exception { + Run run = relix(home, """ + Items := [ + | item | value | weight | + |------|-------|--------| + | keep | 6 | 2 | + | drop | 5 | 3 | + ]; + query { OPTIMIZE MAXIMIZE SUM(value) SUBJECT TO SUM(weight) <= 4 (Items) }; + """); + + assertThat(run.status()).as(run.toString()).isZero(); + assertThat(run.out()).isEqualTo("item\tvalue\tweight\nkeep\t6\t2\n"); + } + + @Test + @DisplayName("finds the reference: doc shows a page") + void reference() throws Exception { + Run run = relix(home, "", "doc", "select"); + + assertThat(run.status()).as(run.toString()).isZero(); + assertThat(run.out()).contains("Selection (σ / SELECT)"); + } + + @Test + @DisplayName("maps its CDS archive: -Xshare:on, which fails without one, starts it from the archive") + void cdsArchive() throws Exception { + // Relative to the run's directory: -Xlog splits on ':', which a Windows path holds. + Run run = relix(home, "", List.of("-Xshare:on", "-Xlog:class+load=info:file=class-load.log"), "version"); + Path log = home.resolve("class-load.log"); + + assertThat(run.status()).as(run.toString()).isZero(); + assertThat(run.out()).startsWith("relix "); + assertThat(Files.readString(log)) + .as("the command's own classes come from the application's archive") + .containsPattern("com\\.darkcollective\\.relix\\.cli\\.Main source: shared objects file \\(top\\)"); + } + + @Test + @DisplayName("-Xshare:on refuses to start without its archive, so the test above can fail") + void cdsArchiveRequired() throws Exception { + Path java = release.resolve("bin").resolve(WINDOWS ? "java.exe" : "java"); + Process process = new ProcessBuilder(java.toString(), "-Xshare:on", + "-XX:SharedArchiveFile=" + tree.resolve("missing.jsa"), "-version") + .redirectErrorStream(true).start(); + String output = new String(process.getInputStream().readAllBytes(), StandardCharsets.UTF_8); + + assertThat(process.waitFor(60, TimeUnit.SECONDS)).isTrue(); + assertThat(process.exitValue()).as(output).isNotZero(); + } + + @Test + @DisabledOnOs(OS.WINDOWS) + @DisplayName("ships a man page per command, each of which man renders") + void manPages() throws Exception { + List pages; + try (Stream files = Files.list(release.resolve("man/man1"))) { + pages = files.sorted().toList(); + } + assertThat(pages).extracting(page -> page.getFileName().toString()) + .contains("relix.1", "relix-run.1", "relix-catalog-trust.1", "relix-completion.1"); + + for (Path page : pages) { + // man-db's man takes a file with -l; macOS's (mandoc's) takes one as a path. + List command = OS.LINUX.isCurrentOs() + ? List.of("man", "-l", page.toString()) + : List.of("man", page.toAbsolutePath().toString()); + ProcessBuilder builder = new ProcessBuilder(command).redirectErrorStream(true); + builder.environment().put("MANPAGER", "cat"); + builder.environment().put("MANWIDTH", "80"); + Process process = builder.start(); + process.getOutputStream().close(); + String output = new String(process.getInputStream().readAllBytes(), StandardCharsets.UTF_8) + .replaceAll(".\b", ""); + + assertThat(process.waitFor(60, TimeUnit.SECONDS)).isTrue(); + assertThat(process.exitValue()).as(page + "\n" + output).isZero(); + assertThat(output).as(page.toString()).contains("NAME", "SYNOPSIS", "relix"); + } + } + + @Test + @DisplayName("ships a completion script per shell, zsh's loadable from $fpath") + void completions() throws IOException { + Path completions = release.resolve("completions"); + + for (String file : List.of("relix.bash", "_relix", "relix.fish", "relix.ps1")) { + assertThat(completions.resolve(file)).isNotEmptyFile(); + } + assertThat(Files.readAllLines(completions.resolve("_relix")).getFirst()).isEqualTo("#compdef relix"); + } + + // ------------------------------------------------------------------------- + + private record Run(int status, String out, String err) { + } + + private Run relix(Path directory, String stdin, String... args) throws Exception { + return relix(directory, stdin, List.of(), args); + } + + /** + * Runs the archive's launcher in {@code directory}, with {@code stdin} on standard + * input, the temporary home as its home, and {@code jvmOptions} for its JVM. + */ + private Run relix(Path directory, String stdin, List jvmOptions, String... args) throws Exception { + return run(directory, stdin, jvmOptions, release.resolve("bin").resolve(WINDOWS ? "relix.bat" : "relix"), args); + } + + private Run run(Path directory, String stdin, List jvmOptions, Path launcher, String... args) + throws Exception { + List command = new ArrayList<>(List.of(launcher.toString())); + command.addAll(List.of(args)); + ProcessBuilder builder = new ProcessBuilder(command).directory(directory.toFile()); + Map environment = builder.environment(); + Map kept = new HashMap<>(); + for (String name : List.of("PATH", "SystemRoot", "TEMP", "TMP", "COMSPEC", "PATHEXT")) { + if (environment.containsKey(name)) { + kept.put(name, environment.get(name)); + } + } + environment.clear(); + environment.putAll(kept); + environment.put("HOME", home.toString()); + List options = new ArrayList<>(List.of("-Duser.home=" + home)); + options.addAll(jvmOptions); + environment.put("JDK_JAVA_OPTIONS", String.join(" ", options)); + Process process = builder.start(); + CompletableFuture out = CompletableFuture.supplyAsync(() -> read(process, true)); + CompletableFuture err = CompletableFuture.supplyAsync(() -> read(process, false)); + try (OutputStream in = process.getOutputStream()) { + in.write(stdin.getBytes(StandardCharsets.UTF_8)); + } + assertThat(process.waitFor(120, TimeUnit.SECONDS)).as("relix finished").isTrue(); + return new Run(process.exitValue(), new String(out.get(), StandardCharsets.UTF_8), + new String(err.get(), StandardCharsets.UTF_8)); + } + + private static byte[] read(Process process, boolean out) { + try { + return (out ? process.getInputStream() : process.getErrorStream()).readAllBytes(); + } catch (IOException e) { + throw new IllegalStateException(e); + } + } +} diff --git a/src/packaging/java/com/darkcollective/relix/cli/packaging/ReleaseFiles.java b/src/packaging/java/com/darkcollective/relix/cli/packaging/ReleaseFiles.java new file mode 100644 index 0000000..7ab53e6 --- /dev/null +++ b/src/packaging/java/com/darkcollective/relix/cli/packaging/ReleaseFiles.java @@ -0,0 +1,116 @@ +/* + * Copyright 2026 Darkcollective, LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.darkcollective.relix.cli.packaging; + +import com.darkcollective.relix.cli.Main; +import com.darkcollective.relix.cli.command.RelixCommand; +import com.darkcollective.relix.cli.io.Host; +import com.darkcollective.relix.cli.io.Interruption; +import org.asciidoctor.Asciidoctor; +import org.asciidoctor.Options; +import org.asciidoctor.SafeMode; +import picocli.CommandLine; +import picocli.codegen.docgen.manpage.ManPageGenerator; + +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.io.PrintStream; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.List; +import java.util.Map; +import java.util.stream.Stream; + +/** + * Writes what the release archive carries beside the image: a man page per command and a + * completion script per shell, both from the command's picocli model, so that neither can + * drift from what {@code --help} prints (design §7.3). It runs in the build and is not + * shipped. + * + *

The man pages are picocli's {@code ManPageGenerator} AsciiDoc, converted to troff by + * Asciidoctor's {@code manpage} backend. + */ +public final class ReleaseFiles { + + /** Each shell's script, and the name its file has in the archive. */ + private static final Map COMPLETIONS = Map.of( + "bash", "relix.bash", + "zsh", "_relix", + "fish", "relix.fish", + "powershell", "relix.ps1"); + + private ReleaseFiles() { + } + + /** + * Writes the man pages and the completion scripts. + * + * @param args the directory for the man pages' AsciiDoc, the directory for the pages + * themselves ({@code man1}), and the directory for the completion scripts + * @throws IOException if one cannot be written + */ + public static void main(String[] args) throws IOException { + if (args.length != 3) { + throw new IllegalArgumentException("usage: ReleaseFiles ADOC_DIR MAN1_DIR COMPLETIONS_DIR"); + } + manPages(Path.of(args[0]), Path.of(args[1])); + completions(Path.of(args[2])); + } + + private static void manPages(Path adoc, Path man1) throws IOException { + Files.createDirectories(adoc); + Files.createDirectories(man1); + CommandLine command = new CommandLine(new RelixCommand(Host.system(), new Interruption())); + int status = ManPageGenerator.generateManPage(adoc.toFile(), null, new boolean[0], true, + command.getCommandSpec()); + if (status != 0) { + throw new IOException("ManPageGenerator exited " + status); + } + List pages; + try (Stream files = Files.list(adoc)) { + pages = files.filter(file -> file.toString().endsWith(".adoc")).sorted().toList(); + } + try (Asciidoctor asciidoctor = Asciidoctor.Factory.create()) { + for (Path page : pages) { + asciidoctor.convertFile(page.toFile(), Options.builder() + .backend("manpage") + .safe(SafeMode.UNSAFE) + .toDir(man1.toFile()) + .mkDirs(true) + .build()); + } + } + } + + private static void completions(Path directory) throws IOException { + Files.createDirectories(directory); + for (Map.Entry shell : COMPLETIONS.entrySet()) { + ByteArrayOutputStream out = new ByteArrayOutputStream(); + ByteArrayOutputStream err = new ByteArrayOutputStream(); + Host host = new Host(new ByteArrayInputStream(new byte[0]), out, + new PrintStream(err, true, StandardCharsets.UTF_8), Map.of(), + Path.of("").toAbsolutePath(), Path.of(System.getProperty("user.home")), false, false); + int status = Main.run(host, "completion", shell.getKey()); + if (status != 0) { + throw new IOException("relix completion " + shell.getKey() + " exited " + status + ": " + + err.toString(StandardCharsets.UTF_8)); + } + Files.write(directory.resolve(shell.getValue()), out.toByteArray()); + } + } +}