From df73ceca5d48182d95f9a5984c91e55ff3e7cff5 Mon Sep 17 00:00:00 2001 From: Alex Archambault Date: Wed, 2 Sep 2026 21:51:04 +0000 Subject: [PATCH 1/2] Generate single JSON file per channel Aggregate the app descriptors of each channel into listings/apps.json and listings/apps-contrib.json in the repository, with a Scala CLI script (.github/scripts/generate-channels.sc). Those files are meant to be consumed by coursier as URL-based channels, in place of the io.get-coursier:apps and io.get-coursier:apps-contrib JARs published on Maven Central. A new update-channels workflow regenerates and commits them upon pushes to main, and the build workflow runs the script on PRs to validate the app descriptors. Co-Authored-By: Claude Fable 5.1 --- .github/scripts/generate-channels.sc | 69 +++++++++++++++++++++++++++ .github/workflows/build.yml | 6 ++- .github/workflows/update-channels.yml | 53 ++++++++++++++++++++ README.md | 37 +++++++++++--- README.template.md | 37 +++++++++++--- 5 files changed, 189 insertions(+), 13 deletions(-) create mode 100644 .github/scripts/generate-channels.sc create mode 100644 .github/workflows/update-channels.yml diff --git a/.github/scripts/generate-channels.sc b/.github/scripts/generate-channels.sc new file mode 100644 index 0000000..41e26d0 --- /dev/null +++ b/.github/scripts/generate-channels.sc @@ -0,0 +1,69 @@ +//> using scala 3.3.6 +//> using jvm 17 +//> using dep com.lihaoyi::os-lib:0.11.8 +//> using dep com.lihaoyi::ujson:4.4.3 + +// Aggregates the app descriptors of each channel (apps/resources/*.json, +// apps-contrib/resources/*.json) into a single JSON file per channel +// (listings/apps.json, listings/apps-contrib.json). +// +// coursier consumes those aggregated files as URL-based channels, see +// https://get-coursier.io/docs/cli-appdescriptors#url-based-channels +// +// Usage: +// scala-cli run .github/scripts/generate-channels.sc # (re-)generate the files +// scala-cli run .github/scripts/generate-channels.sc -- --check # only check they're up-to-date + +val check = args.contains("--check") + +val root = os.pwd +val listingsDir = root / "listings" +val channels = Seq("apps", "apps-contrib") + +def channelContent(channel: String): String = { + val resourcesDir = root / channel / "resources" + val entries = os.list(resourcesDir) + .filter(p => os.isFile(p) && p.last.endsWith(".json")) + .sortBy(_.last) + .map { path => + val name = path.last.stripSuffix(".json") + val json = + try ujson.read(os.read(path)) + catch { + case e: ujson.ParseException => + sys.error(s"Error parsing $path: ${e.getMessage}") + } + json match { + case obj: ujson.Obj => name -> (obj: ujson.Value) + case _ => sys.error(s"$path: expected a JSON object at the root") + } + } + ujson.write(ujson.Obj.from(entries), indent = 2) + System.lineSeparator() +} + +var outdated = List.empty[os.Path] + +for (channel <- channels) { + val dest = listingsDir / s"$channel.json" + val content = channelContent(channel) + if (check) { + val current = if (os.exists(dest)) Some(os.read(dest)) else None + if (!current.contains(content)) + outdated = dest :: outdated + } + else { + os.write.over(dest, content, createFolders = true) + System.err.println(s"Wrote $dest") + } +} + +if (outdated.nonEmpty) { + System.err.println( + outdated.reverse.map(_.relativeTo(root)).mkString( + "Outdated channel file(s): ", + ", ", + "\nRun 'scala-cli run .github/scripts/generate-channels.sc' to update them." + ) + ) + sys.exit(1) +} diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index bf857d8..f2d588a 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -16,5 +16,9 @@ jobs: - uses: coursier/setup-action@v3.0 with: jvm: 8 - apps: sbt + apps: sbt scala-cli - run: sbt publishLocal + # Validates the app descriptors, by aggregating them the way the + # update-channels workflow does (it doesn't check that the files under + # listings/ are up-to-date, CI updates them upon merge on main) + - run: scala-cli run .github/scripts/generate-channels.sc diff --git a/.github/workflows/update-channels.yml b/.github/workflows/update-channels.yml new file mode 100644 index 0000000..815c75b --- /dev/null +++ b/.github/workflows/update-channels.yml @@ -0,0 +1,53 @@ +name: update-channels +on: + push: + branches: + - main + paths: + - apps/resources/** + - apps-contrib/resources/** + - .github/scripts/generate-channels.sc + - .github/workflows/update-channels.yml + workflow_dispatch: {} + +# Regenerates listings/apps.json and listings/apps-contrib.json (the +# URL-based channels read by coursier) from the app descriptors under +# apps/resources and apps-contrib/resources, and opens (or updates) a pull +# request with the changes, if any. +concurrency: update-channels + +jobs: + update-channels: + runs-on: ubuntu-latest + permissions: + contents: write + pull-requests: write + steps: + - uses: actions/checkout@v7 + - uses: coursier/cache-action@v8.1 + - uses: coursier/setup-action@v3.0 + with: + jvm: 17 + apps: scala-cli + - run: scala-cli run .github/scripts/generate-channels.sc + # Note: PRs opened with the default GITHUB_TOKEN don't trigger the + # build workflow. Pass a PAT or GitHub App token via `token` if CI + # should run on them. + - uses: peter-evans/create-pull-request@v7 + with: + add-paths: | + listings/apps.json + listings/apps-contrib.json + commit-message: Update channel JSON files + author: Github Actions + committer: Github Actions + branch: update-channels + delete-branch: true + base: main + title: Update channel JSON files + body: | + Regenerates `listings/apps.json` and `listings/apps-contrib.json` + from the app descriptors under `apps/resources` and + `apps-contrib/resources`. + + Automatically opened by the `update-channels` workflow. diff --git a/README.md b/README.md index cbf105b..699cdc8 100644 --- a/README.md +++ b/README.md @@ -8,9 +8,30 @@ You can find information about creating your own application to be installed with `cs` [here on the website](https://get-coursier.io/docs/cli-install.html#creating-your-own-applications). +Each channel is made of one JSON file per app, under `apps/resources` (Main +channel) and `apps-contrib/resources` (Contrib channel). Those are aggregated +into a single JSON file per channel, [`listings/apps.json`](listings/apps.json) +and [`listings/apps-contrib.json`](listings/apps-contrib.json), that coursier +reads as URL-based channels: + +- Main: https://raw.githubusercontent.com/coursier/apps/main/listings/apps.json +- Contrib: https://raw.githubusercontent.com/coursier/apps/main/listings/apps-contrib.json + +Those aggregated files are generated by the +`.github/scripts/generate-channels.sc` Scala CLI script. Please don't update +them directly: upon pushes to the `main` branch, CI regenerates them and opens +a pull request with the changes, if any. Locally, you can regenerate them with +``` +scala-cli run .github/scripts/generate-channels.sc +``` + +The channels are also published as JARs on Maven Central, as +`io.get-coursier:apps` and `io.get-coursier:apps-contrib`, for former +coursier versions. + ## Main -These are the apps in the default main JAR-based channel, `io.get-coursier:app` -which is used with `cs install` +These are the apps in the default Main channel, used by `cs install` and +`cs launch`. - almond - ammonite @@ -53,7 +74,8 @@ which is used with `cs install` - stc ## Contrib -These apps are available by passing `--contrib` to the `cs install` command. +These apps are available by passing `--contrib` to the `cs install` and +`cs launch` commands. Feel free to send in a PR to add your application here! - amm-runner @@ -128,9 +150,9 @@ Feel free to send in a PR to add your application here! - zookeeper ### Updating the README -This README is auto-generated by the `scripts/generate-readme.sc` Ammonite -script Please don't update the README directly, but rather update the -`README.template.md`. Note that you don't need to manually add your app to it, +This README is auto-generated by the `.github/scripts/generate-readme.sc` +Scala CLI script. Please don't update the README directly, but rather update +the `README.template.md`. Note that you don't need to manually add your app to it, it will be automatically added in CI. ### Testing a change @@ -140,3 +162,6 @@ works correctly, you can do the following while in the workspace. ``` cs launch --default-channels=false --channel ./(apps|apps-contrib)/resources ``` + +This reads the app descriptors straight from the `resources` directories, so +that you don't need to regenerate the files under `listings/` when testing. diff --git a/README.template.md b/README.template.md index daad425..20d8096 100644 --- a/README.template.md +++ b/README.template.md @@ -8,24 +8,46 @@ You can find information about creating your own application to be installed with `cs` [here on the website](https://get-coursier.io/docs/cli-install.html#creating-your-own-applications). +Each channel is made of one JSON file per app, under `apps/resources` (Main +channel) and `apps-contrib/resources` (Contrib channel). Those are aggregated +into a single JSON file per channel, [`listings/apps.json`](listings/apps.json) +and [`listings/apps-contrib.json`](listings/apps-contrib.json), that coursier +reads as URL-based channels: + +- Main: https://raw.githubusercontent.com/coursier/apps/main/listings/apps.json +- Contrib: https://raw.githubusercontent.com/coursier/apps/main/listings/apps-contrib.json + +Those aggregated files are generated by the +`.github/scripts/generate-channels.sc` Scala CLI script. Please don't update +them directly: upon pushes to the `main` branch, CI regenerates them and opens +a pull request with the changes, if any. Locally, you can regenerate them with +``` +scala-cli run .github/scripts/generate-channels.sc +``` + +The channels are also published as JARs on Maven Central, as +`io.get-coursier:apps` and `io.get-coursier:apps-contrib`, for former +coursier versions. + ## Main -These are the apps in the default main JAR-based channel, `io.get-coursier:app` -which is used with `cs install` +These are the apps in the default Main channel, used by `cs install` and +`cs launch`. ```scala mdoc:list:apps ``` ## Contrib -These apps are available by passing `--contrib` to the `cs install` command. +These apps are available by passing `--contrib` to the `cs install` and +`cs launch` commands. Feel free to send in a PR to add your application here! ```scala mdoc:list:apps-contrib ``` ### Updating the README -This README is auto-generated by the `scripts/generate-readme.sc` Ammonite -script Please don't update the README directly, but rather update the -`README.template.md`. Note that you don't need to manually add your app to it, +This README is auto-generated by the `.github/scripts/generate-readme.sc` +Scala CLI script. Please don't update the README directly, but rather update +the `README.template.md`. Note that you don't need to manually add your app to it, it will be automatically added in CI. ### Testing a change @@ -35,3 +57,6 @@ works correctly, you can do the following while in the workspace. ``` cs launch --default-channels=false --channel ./(apps|apps-contrib)/resources ``` + +This reads the app descriptors straight from the `resources` directories, so +that you don't need to regenerate the files under `listings/` when testing. From e835527bcb8da8e09c023577d00798a81efcce22 Mon Sep 17 00:00:00 2001 From: Alex Archambault Date: Thu, 3 Sep 2026 14:00:44 +0200 Subject: [PATCH 2/2] Ensure PRs don't accidentally change listings Prevent pull requests from mixing generated listings/ changes with other file changes. Co-authored-by: Claude Opus 5 --- .github/workflows/check-pr-scope.yml | 45 ++++++++++++++++++++++++++++ 1 file changed, 45 insertions(+) create mode 100644 .github/workflows/check-pr-scope.yml diff --git a/.github/workflows/check-pr-scope.yml b/.github/workflows/check-pr-scope.yml new file mode 100644 index 0000000..944d5a0 --- /dev/null +++ b/.github/workflows/check-pr-scope.yml @@ -0,0 +1,45 @@ +name: check-pr-scope +on: + pull_request: {} + +# The files under listings/ are generated from the app descriptors by +# .github/scripts/generate-channels.sc, and updated on main by the +# update-channels workflow. Mixing generated and hand-written changes in a +# single PR makes them conflict with each other, so a PR must either update +# listings/, or everything else, but not both. + +jobs: + check-pr-scope: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v7 + with: + fetch-depth: 0 + - name: Check changed files + env: + BASE_SHA: ${{ github.event.pull_request.base.sha }} + HEAD_SHA: ${{ github.event.pull_request.head.sha }} + run: | + set -euo pipefail + changed="$(git diff --name-only "$BASE_SHA...$HEAD_SHA")" + listings="$(printf '%s\n' "$changed" | grep '^listings/' || true)" + others="$( printf '%s\n' "$changed" | grep -v '^listings/' || true)" + if [ -n "$listings" ] && [ -n "$others" ]; then + echo "This PR changes both listings/ and other files:" + echo + echo "Under listings/:" + printf '%s\n' "$listings" | sed 's/^/ /' + echo + echo "Elsewhere:" + printf '%s\n' "$others" | sed 's/^/ /' + echo + echo "The files under listings/ are generated (see" \ + ".github/scripts/generate-channels.sc) and updated on main by the" \ + "update-channels workflow. Please split those changes in two PRs." + exit 1 + fi + if [ -n "$listings" ]; then + echo "OK: this PR only changes files under listings/" + else + echo "OK: this PR doesn't change any file under listings/" + fi