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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 5 additions & 3 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,10 @@
# 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.
# A tag vX.Y.Z publishes X.Y.Z. A tag with a suffix (v1.2.0-rc1) publishes a GitHub
# pre-release; the tap and the bucket follow it all the same, so `brew install relix` and
# `scoop install relix` give the newest release, a release candidate included. Both order
# 1.2.0-rc1 below 1.2.0, so the final release replaces its candidates as the install.
#
# 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
Expand Down Expand Up @@ -127,6 +129,7 @@ jobs:
pattern: archive-*
path: dist
merge-multiple: true
- run: python3 -m unittest discover -s packaging -p 'test_*.py'
# 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
Expand Down Expand Up @@ -168,7 +171,6 @@ jobs:
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 }}
Expand Down
5 changes: 3 additions & 2 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -168,8 +168,9 @@ property `baseDir`).
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.
secrets. A tag with a suffix (`v1.2.0-rc1`) makes a GitHub pre-release and updates the
tap and the bucket in the same way, so they carry the newest release candidate until
the final release replaces it (Homebrew and Scoop both order `1.2.0-rc1` below `1.2.0`).

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.
Expand Down
9 changes: 6 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,8 +19,8 @@ Its documentation is the command-line section of the Relix programming guide, in

## Status

Under development. There is no release yet, and the command line described above is
the design being built, not a shipped interface.
Released. 0.1.x are stable releases; 1.0.0 is being prepared through release candidates
(1.0.0-rcN), which GitHub lists as pre-releases.

## Building

Expand Down Expand Up @@ -54,7 +54,10 @@ scoop bucket add relix https://github.com/DarkCollective/scoop-relix
scoop install relix
```

Or download the archive for your platform from the
Homebrew and Scoop install the newest release, a release candidate included: while 1.0.0
is being prepared, `relix` is the newest `1.0.0-rcN`, and the final 1.0.0 replaces it
(both order a candidate below its release). To install without a package manager,
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`.

Expand Down
42 changes: 38 additions & 4 deletions build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -11,12 +11,13 @@ plugins {
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'
id 'org.beryx.jlink' version '4.2.0'
}

group = 'com.darkcollective.relix'
// A release is built with -PcliVersion=<version>; anything else is a snapshot.
version = findProperty('cliVersion') ?: '0.1.0-SNAPSHOT'
// A release is built with -PcliVersion=<version>; anything else is a snapshot of the line
// being worked towards (1.0.0, which sorts above 0.1.x and below its own release candidates).
version = findProperty('cliVersion') ?: '1.0.0-SNAPSHOT'

// The engine pin: the one place the engine's version is named.
//
Expand All @@ -25,7 +26,7 @@ version = findProperty('cliVersion') ?: '0.1.0-SNAPSHOT'
// branch instead (1.0.0-SNAPSHOT), which relix-core publishes to Central's snapshot
// repository on every push. A release pins a released engine (checkEnginePin).
// -PrelixEngineVersion=<v> overrides it for one build, to try a candidate engine.
ext.relixEngineVersion = (findProperty('relixEngineVersion') ?: '1.0.0-rc12') as String
ext.relixEngineVersion = (findProperty('relixEngineVersion') ?: '1.0.0-rc13') as String

def engineIsSnapshot = relixEngineVersion.endsWith('-SNAPSHOT')

Expand Down Expand Up @@ -379,6 +380,39 @@ DIR="$(cd "$(dirname "$LINK")" && pwd)"''')
}
}

// The release scripts' own tests (packaging/test_release.py): the formula and the manifest
// written for a release candidate and a final release, and the rule that the tap and the
// bucket follow both. Skipped, with a warning, on a machine with no Python; the release
// workflow runs them too.
def packagingScriptTest = tasks.register('packagingScriptTest') {
description = 'Runs the release scripts\' tests.'
inputs.dir(layout.projectDirectory.dir('packaging'))
inputs.file(layout.projectDirectory.file('.github/workflows/release.yml'))
def dir = layout.projectDirectory.dir('packaging').asFile
def stamp = layout.buildDirectory.file('packagingScriptTest.stamp')
outputs.file(stamp)
doLast {
def python = ['python3', 'python'].find {
try { new ProcessBuilder(it, '--version').redirectErrorStream(true).start().waitFor() == 0 }
catch (IOException ignored) { false }
}
if (python == null) {
logger.warn('No Python on the PATH: the release scripts\' tests were not run.')
} else {
def process = new ProcessBuilder(python, '-m', 'unittest', 'discover', '-s', dir.path, '-p', 'test_*.py')
.redirectErrorStream(true).start()
def output = process.inputStream.text
if (process.waitFor() != 0) {
throw new GradleException("The release scripts' tests failed:\n${output}")
}
}
stamp.get().asFile.text = 'ok'
}
}
tasks.named('check') {
dependsOn packagingScriptTest
}

// 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) {
Expand Down
2 changes: 1 addition & 1 deletion docs/guide/command-line/commands/doc.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ relix doc [OPTIONS] [TOPIC]

| Option | Description |
|---|---|
| `--function` | Look TOPIC up among the functions only, or list only them. |
| `--function` | Look TOPIC up among the functions only, or list only them. TOPIC may also be a function category (string, json), which lists its functions. |
| `--markdown` | Print the page's markdown as it is, not rendered. |
| `--no-pager` | Write the page to standard output even on a terminal. |
| `-o`, `--output=FORMAT` | The rows' format: table, tsv, csv, ndjson, json or markdown (default: $RELIX_OUTPUT, else relixrc's, else table on a terminal, tsv in a pipe). |
Expand Down
2 changes: 1 addition & 1 deletion docs/guide/command-line/commands/relix.md
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,7 @@ Every command takes these, before or after its name.
| `--max-materialized-rows=N` | Fail a query in which one blocking operator buffers more than N rows. |
| `--max-processed-rows=N` | Fail a query that processes more than N rows in all. |
| `--timeout=DURATION` | Fail a query that runs longer than DURATION: 500ms, 30s, 5m, 1h, or ISO-8601 (PT30S). |
| `--sandbox=FILE` | Run under the sandbox FILE describes. |
| `--sandbox=FILE` | Run under the sandbox FILE describes: a JSON file, relative paths in it resolving against its own directory, with optional keys "declarations" (a .relix file of the only external sources, connections and imports scripts may use) and "limits" (maxInputChars, maxOutputRows, maxMaterializedRows, maxFixpointRounds, maxProcessedRows, timeoutMillis). An unknown key is an error. Where FILE and a --max-* option or --timeout both limit, the smaller applies. |
| `--remote` | Permit http(s) file locations. |
| `--allow-download` | Permit fetching a missing JDBC driver or connector during the run. |
| `-h`, `--help` | Show this help message and exit. |
Expand Down
100 changes: 100 additions & 0 deletions packaging/test_release.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
"""Tests of release.py and of the release workflow's rule for the tap and the bucket.

Run with: python3 -m unittest discover -s packaging -p 'test_*.py'
"""
import json
import pathlib
import re
import tempfile
import unittest

import release

ROOT = pathlib.Path(__file__).resolve().parent.parent
WORKFLOW = (ROOT / ".github" / "workflows" / "release.yml").read_text(encoding="utf-8")


def archives(version: str, directory: pathlib.Path) -> None:
for platform in release.PLATFORMS:
(directory / release.archive_name(version, platform)).write_bytes(platform.encode())


class ReleaseFiles(unittest.TestCase):
def render(self, version: str):
with tempfile.TemporaryDirectory() as tmp:
dist, out = pathlib.Path(tmp, "dist"), pathlib.Path(tmp, "out")
dist.mkdir()
archives(version, dist)
import sys
argv, sys.argv = sys.argv, ["release.py", version, str(dist), str(out)]
try:
release.main()
finally:
sys.argv = argv
return ((out / "relix.rb").read_text(encoding="utf-8"),
json.loads((out / "relix.json").read_text(encoding="utf-8")),
(out / "checksums.txt").read_text(encoding="utf-8"))

def test_a_release_candidate_fills_the_formula_and_the_manifest(self):
formula, manifest, sums = self.render("1.0.0-rc3")
self.assertIn('version "1.0.0-rc3"', formula)
self.assertEqual("1.0.0-rc3", manifest["version"])
self.assertEqual("relix-1.0.0-rc3-windows-x86_64.zip",
manifest["architecture"]["64bit"]["url"].rsplit("/", 1)[1])
self.assertEqual("relix-1.0.0-rc3", manifest["architecture"]["64bit"]["extract_dir"])
self.assertEqual(5, len(sums.splitlines()))

def test_a_stable_release_fills_them_too(self):
formula, manifest, _ = self.render("1.0.0")
self.assertIn('version "1.0.0"', formula)
self.assertEqual("1.0.0", manifest["version"])

def test_the_formula_names_the_archives_release_py_checks(self):
formula, _, _ = self.render("1.0.0-rc3")
self.assertEqual(
{release.archive_name("1.0.0-rc3", p) for p in release.PLATFORMS if "windows" not in p},
set(release.formula_archives(formula, "1.0.0-rc3")))

def test_a_missing_archive_fails(self):
with tempfile.TemporaryDirectory() as tmp:
import sys
argv, sys.argv = sys.argv, ["release.py", "1.0.0-rc3", tmp, tmp + "/out"]
try:
with self.assertRaises(SystemExit):
release.main()
finally:
sys.argv = argv

def test_a_version_that_is_not_one_fails(self):
import sys
argv, sys.argv = sys.argv, ["release.py", "rc3", ".", "out"]
try:
with self.assertRaises(SystemExit):
release.main()
finally:
sys.argv = argv


class TapWorkflow(unittest.TestCase):
def job(self, name: str) -> str:
match = re.search(rf"^ {name}:\n(.*?)(?=^ \w[\w-]*:\n|\Z)", WORKFLOW, re.S | re.M)
self.assertIsNotNone(match, f"no job {name}")
return match.group(1)

def test_the_tap_job_runs_for_a_release_candidate(self):
tap = self.job("tap")
self.assertNotIn("stable", tap, "the tap job must not be skipped for a pre-release")
self.assertNotRegex(tap, r"(?m)^ if:")
self.assertIn("publish", re.search(r"needs: (.*)", tap).group(1))

def test_both_package_managers_are_pushed(self):
tap = self.job("tap")
self.assertIn("homebrew-relix", tap)
self.assertIn("scoop-relix", tap)

def test_a_candidate_is_still_a_github_pre_release(self):
self.assertIn("--prerelease", self.job("publish"))


if __name__ == "__main__":
unittest.main()
24 changes: 20 additions & 4 deletions src/main/java/com/darkcollective/relix/cli/command/DocCommand.java
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,7 @@
* <p>A topic is found by glyph, keyword or name: {@code σ}, {@code select} and
* {@code selection} are one page. A language keyword keeps a name it shares with a
* function, so {@code relix doc fix} is the recursion operator and
* {@code relix doc --function fix} is the function. The page is rendered for a terminal
* {@code relix doc --function fix} is the function. With {@code --function}, a category name lists its functions. The page is rendered for a terminal
* and, on one, shown through {@code $PAGER}. With no topic, the pages are listed, as rows.
*
* <p>Pages are found by {@code relix-docs}' {@link ReferenceLookup}, over the language pages
Expand All @@ -67,7 +67,7 @@ final class DocCommand implements Callable<Integer> {
String topic;

@Option(names = "--function",
description = "Look TOPIC up among the functions only, or list only them.")
description = "Look TOPIC up among the functions only, or list only them. TOPIC may also be a function category (string, json), which lists its functions.")
boolean function;

@Option(names = "--markdown", description = "Print the page's markdown as it is, not rendered.")
Expand All @@ -87,6 +87,17 @@ public Integer call() {
list(invocation, index);
return ExitCode.SUCCESS.status();
}
if (function && index.function(topic).isEmpty()) {
// A function category, such as string or json, lists its functions. The
// categories are those the installed libraries declare, not a list kept here.
Optional<List<ReferencePage>> category = index.functionsByCategory().entrySet().stream()
.filter(entry -> entry.getKey().equalsIgnoreCase(topic))
.map(java.util.Map.Entry::getValue).findFirst();
if (category.isPresent()) {
listing(invocation, category.get());
return ExitCode.SUCCESS.status();
}
}
Optional<ReferencePage> found = function ? index.function(topic) : index.entry(topic);
ReferencePage page = found.orElseThrow(() -> new CommandFailure(ExitCode.USAGE,
"no reference page for '" + topic + "'" + (function ? " among the functions" : "")
Expand All @@ -107,13 +118,18 @@ public Integer call() {

/** The language pages, in the reference's order, unless --function; then the functions'. */
private void list(Invocation invocation, ReferenceLookup index) {
Listing listing = new Listing("topic", ScalarType.STRING, "category", ScalarType.STRING,
"title", ScalarType.STRING, "summary", ScalarType.STRING);
List<ReferencePage> pages = new ArrayList<>();
if (!function) {
pages.addAll(RelixDocs.referencePages());
}
index.functionsByCategory().values().forEach(pages::addAll);
listing(invocation, pages);
}

/** The given pages as the listing's rows. */
private void listing(Invocation invocation, List<ReferencePage> pages) {
Listing listing = new Listing("topic", ScalarType.STRING, "category", ScalarType.STRING,
"title", ScalarType.STRING, "summary", ScalarType.STRING);
for (ReferencePage page : pages) {
listing.add(page.symbol(), page.category(), page.title(), page.summary());
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -93,7 +93,14 @@ final class GlobalOptions {
Duration timeout;

@Option(names = "--sandbox", paramLabel = "FILE", scope = ScopeType.INHERIT,
description = "Run under the sandbox FILE describes.")
description = {
"Run under the sandbox FILE describes: a JSON file, relative paths in it",
"resolving against its own directory, with optional keys \"declarations\"",
"(a .relix file of the only external sources, connections and imports scripts",
"may use) and \"limits\" (maxInputChars, maxOutputRows, maxMaterializedRows,",
"maxFixpointRounds, maxProcessedRows, timeoutMillis). An unknown key is an",
"error. Where FILE and a --max-* option or --timeout both limit, the smaller",
"applies."})
Path sandbox;

@Option(names = "--remote", scope = ScopeType.INHERIT,
Expand Down
37 changes: 31 additions & 6 deletions src/main/java/com/darkcollective/relix/cli/io/Host.java
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
*/
package com.darkcollective.relix.cli.io;

import java.io.Console;
import java.io.FileDescriptor;
import java.io.FileOutputStream;
import java.io.InputStream;
Expand Down Expand Up @@ -67,18 +68,42 @@ public record Host(InputStream in, OutputStream out, PrintStream err,
* @return the real streams, environment and directories
*/
public static Host system() {
// On Java 21 a null console is the pipe check: System.console() is null when
// standard input or output is redirected, and it cannot tell which. Both are taken
// for pipes then, so `echo … | relix` writes tsv to a terminal; pass -o to choose.
// Java 22 returns a console for a pipe too, and this has to move to
// Console.isTerminal() with it.
boolean terminal = System.console() != null;
// Java 21: System.console() is null when standard input or output is redirected, and
// it cannot tell which, so both are taken for pipes then and `echo … | relix` writes
// tsv to a terminal; pass -o to choose. Java 22 returns a console for a pipe too and
// adds Console.isTerminal(), which is asked for when it exists. The command is built
// on 21, so the method is reached reflectively.
boolean terminal = terminal(System.console() != null, isTerminal(System.console()));
return new Host(System.in, new FileOutputStream(FileDescriptor.out), System.err,
System.getenv(),
Path.of(System.getProperty("user.dir")), Path.of(System.getProperty("user.home")),
terminal, terminal);
}

/**
* Whether the process is on a terminal.
*
* @param console the JVM's console, or null when it has none
* @param verdict what {@code Console.isTerminal()} says of it, or null on a JVM that
* has no such method (before 22), where a console is all there is to go on
* @return false without a console; otherwise the verdict, else true
*/
static boolean terminal(boolean console, Boolean verdict) {
return console && (verdict == null || verdict);
}

/** {@code Console.isTerminal()} of the JVM's console, or null before Java 22. */
private static Boolean isTerminal(Console console) {
if (console == null) {
return null;
}
try {
return (Boolean) Console.class.getMethod("isTerminal").invoke(console);
} catch (ReflectiveOperationException e) {
return null;
}
}

/**
* An environment variable, when it is set and not empty.
*
Expand Down
Loading
Loading