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/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/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()); + } + } +} 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() {