diff --git a/AGENTS.md b/AGENTS.md
index 4a69073..e2ec33b 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -3,7 +3,8 @@
- Edit resources/ as the source; generated copies in applications are consumers.
- Read resources/CODING_STANDARDS.md before changing Java; project architecture
and metrics apply to consumer backend modules as documented there.
-- Keep upstream skills unchanged and retain resources/skills/upstream.json and licenses.
+- Shared agent skills and their upstream attribution live in landscape/skills/.
+ Keep upstream files unchanged; update shared workflows there, not in engineering.
- Keep project-specific configuration, secrets and waivers out of shared defaults.
- Run the bootstrap, formatter, CI/release regression and generated-example checks.
- Never move a published release tag. Document consumer-file changes in UPGRADING.md.
diff --git a/README.md b/README.md
index baffbc1..99502b9 100644
--- a/README.md
+++ b/README.md
@@ -1,9 +1,9 @@
# Engineering
-Versioned skills, Java standards, formatting, coverage checks, and the Gitea
+Versioned Java standards, formatting, coverage checks, and the Gitea
Actions / Maven Java 25 / npm / Docker Compose recipe. Architecture and
specifications belong to [landscape](http://192.168.2.28:3000/wanjakrah/landscape).
-Source attribution is in SOURCE.md; upstream skill licenses accompany each skill.
+Shared agent skills are maintained in landscape under skills/. Source attribution is in SOURCE.md.
## Start a project
@@ -11,7 +11,7 @@ Clone this repository and check out a released tag. Use JDK 25, Maven, Python 3,
Git, Docker with Compose, and a supported Node version for a frontend.
```sh
-python3 create-project.py my-project ../my-project --version v1.0.0 --components both --runner engineering
+python3 create-project.py my-project ../my-project --version v2.0.0 --components both
```
Choose `backend`, `frontend`, or `both`. The command refuses existing destinations
@@ -21,8 +21,11 @@ while preserving the configured build commands and container contracts.
Initialize the resulting Git repository and commit its generated resources.
Create the remote repository separately, configure its runner and secrets, then
-push. The runner label must match an available **host** runner with the required
-tools. `--runner` deliberately has no machine-specific default.
+push. New projects default to the shared **host** runner label `mini-pc`.
+Register it once at Gitea user scope for the owner of the projects; it then serves
+existing and future repositories of that owner. `--runner` overrides the label for
+another workstation. A label alone does not grant a repository access to a runner.
+Do not install another runner service for every project.
## Consumer layout and configuration
@@ -88,11 +91,58 @@ Manual deployment and rollback workflows require variables `DEPLOY_HOST`,
a successful main-push build.yaml run. Keep build.yaml's filename when adapting
the recipe. Configure Gitea access, registry access and SSH host trust explicitly.
-Runner setup mechanisms are available as `configure-gitea`, `configure-docker`
-and `install-runner`. Supply GITEA_SERVER_URL, CI_REPOSITORY, RUNNER_LABEL and
-INSECURE_REGISTRY where applicable; the latter is only for a deliberately trusted
-internal HTTP registry. Runner configuration and service templates are project
-files under ci/runner/. Installation currently supports Linux x86_64 host runners.
+### Shared workstation runner
+
+Set up the physical mini-pc once, using a consumer generated from this release:
+
+```sh
+export GITEA_SERVER_URL=http://192.168.2.28:3000
+export RUNNER_NAME=mini-pc
+export RUNNER_LABEL=mini-pc
+export RUNNER_SCOPE=user
+# Owner-only PAT file with permissions for the selected runner scope; never a workflow secret.
+export GITEA_ADMIN_TOKEN_FILE="$HOME/.config/mini-pc-runner/admin-token"
+java generated/ci/Ci.java configure-runner
+java generated/ci/Ci.java install-runner
+systemctl --user status mini-pc-runner
+```
+
+User scope uses `/api/v1/user/actions/runners/registration-token`, granting the
+runner access to that user's current and future repositories. Organization and
+instance scopes are optional alternatives (`RUNNER_ORGANIZATION` is required for
+organization scope); instance scope requires a Gitea administrator. Repository
+scope is available only for intentionally isolated runners. For this landscape,
+use user scope for `wanjakrah` and the label `mini-pc`.
+
+The runner has one slot shared by all projects. Its identity, service and state
+are based on RUNNER_NAME, independent of PROJECT_NAME: `~/.local/share/mini-pc-runner`,
+`~/.config/mini-pc-runner` and `mini-pc-runner.service`. Application credentials remain
+in each application's secrets; never copy deployment credentials into runner defaults.
+The host runner executes trusted project code as the workstation user; use it for
+repositories maintained by the trusted owner. A new project's first push needs no
+new workstation registration. Ensure the workstation is awake and Docker is ready.
+
+For each new repository enable Actions, configure its own registry secrets and
+TARGET_PLATFORM, then verify availability before pushing:
+
+```sh
+export CI_REPOSITORY=wanjakrah/my-project
+# Owner PAT with read access to the repository, user profile and user runners.
+java generated/ci/Ci.java check-runner
+```
+
+`configure-gitea` configures the application and does not create runner registrations.
+`configure-runner` obtains the one-time shared registration token; `install-runner`
+installs and registers the workstation. `--prepare-only` installs without registering
+or starting. Installation supports Linux x86_64. `configure-docker` uses
+INSECURE_REGISTRY only for a deliberately trusted internal HTTP registry.
+Runner configuration and service templates are under ci/runner/.
+
+When migrating a repository-scoped runner, keep the old registration and service
+available until the new shared runner is online and has run a test workflow for
+both an existing and a new consumer. Change all workflow labels to `mini-pc`, then
+stop/disable the old service. Preserve its state outside Git for recovery. Do not
+change application-specific deployment credential paths as part of this migration.
## Deployment and recovery
diff --git a/SOURCE.md b/SOURCE.md
index 7e5875d..007088b 100644
--- a/SOURCE.md
+++ b/SOURCE.md
@@ -6,3 +6,6 @@ Source commit: `bd898e92d22f6c412e671908770f4d89c8ae12bc`. Original history rema
Project-specific extraction and generalization are recorded in S19. Upstream
licenses and revision records accompany the resources they cover.
+
+On 2026-11-10, shared agent skills moved to landscape/skills/ by maintainer request.
+Historical release v1.0.0 retains its original attributed snapshot unchanged.
diff --git a/UPGRADING.md b/UPGRADING.md
index 7419ce2..5082ad6 100644
--- a/UPGRADING.md
+++ b/UPGRADING.md
@@ -1,5 +1,25 @@
# Engineering upgrades
+## v2.0.0
+
+Shared skills have moved to landscape/skills/. Upstream files and licenses are
+preserved there. This is a major release because generated/skills/ is removed.
+Update AGENTS.md and review companions to reference landscape's skills and retain
+the project's generated/CODING_STANDARDS.md and backend-review companion.
+Regeneration removes the obsolete skills automatically; never edit generated copies.
+
+New consumers default to the shared mini-pc runner. Existing consumers must change
+all workflow runs-on labels and RUNNER_LABEL to mini-pc, add RUNNER_NAME=mini-pc and
+RUNNER_SCOPE=user, and update ci/runner templates to use the shared identity.
+These project-owned files are not regenerated automatically. Register the workstation
+once at user scope using configure-runner, then install-runner; configure-gitea
+now configures only application Actions, registry secrets and platform. Use
+check-runner for each new repository before its first push. No production deployment
+or migration of application deployment credentials is required.
+
+Java launcher contracts now use varargs and document behavior and failures. Console
+messages use Java 25 IO.println. JDK 25 remains required.
+
## v1.0.0
Initial release. For Agile Dancers, paths to shared Java launchers, formatting,
diff --git a/VALIDATION.md b/VALIDATION.md
index db18efb..6fee642 100644
--- a/VALIDATION.md
+++ b/VALIDATION.md
@@ -28,3 +28,20 @@ No production deployment was performed. Live NAS SSH connectivity, production
credentials and production rollback across existing retained releases remain
operational validation limits. Legacy release-state JSON remains compatible;
pre-S19 retained releases use current health/settings as documented in README.md.
+
+## S19 review follow-up — 2026-11-10
+
+- Skills relocated unchanged to landscape/skills/ with upstream revision records,
+ attribution and licenses. New consumers reference landscape workflows.
+- Eight bootstrap/generator tests passed, including actual Maven bootstrap with
+ generated/ absent, all component selections and the CLI's default mini-pc label.
+- Twenty-one CI tests passed, including shared runner scope/identity and preflight
+ rejection of missing, offline or differently labelled runners.
+- Thirty-four release behavior tests passed against the actual remote shell.
+- Formatter integration passed; changed Java helpers formatted with the pinned engine.
+- User-scoped mini-pc runner registered and online; repository-level API runner
+ listings exclude inherited user runners, so preflight checks the selected scope
+ and verifies that the repository owner matches for user scope.
+
+Consumer builds and actual remote workflow results are recorded in the consuming
+repositories. No production deployment is part of this follow-up.
diff --git a/create-project.py b/create-project.py
index 956f48e..9df369a 100644
--- a/create-project.py
+++ b/create-project.py
@@ -18,7 +18,7 @@ def create(destination, name, components, repository, tag, runner, landscape):
if destination.exists():
raise ValueError('Destination must not exist; existing project files are never overwritten')
commit = subprocess.check_output(['git', '-C', str(HOME), 'rev-parse', tag + '^{commit}'], text=True).strip()
- replacements = {'@PROJECT@': name, '@RUNNER@': runner, '@LANDSCAPE@': landscape,
+ replacements = {'@PROJECT@': name, '@RUNNER@': runner, '@RUNNER_NAME@': runner, '@LANDSCAPE@': landscape,
'@ENGINEERING@': repository.removesuffix('.git'),
'@MODULES@': 'backend' if 'backend' in components else ''}
try:
@@ -41,6 +41,7 @@ def create(destination, name, components, repository, tag, runner, landscape):
'FORMAT_DIRECTORIES': 'backend/src', 'COMPOSE_PROJECT': name + '-production',
'BROWSER_TESTS': 'false', 'CI_DATABASE': 'false',
'TARGET_PLATFORM': 'linux/amd64', 'RUNNER_LABEL': runner,
+ 'RUNNER_NAME': runner, 'RUNNER_SCOPE': 'user',
'FRONTEND_TEST_COMMAND': '["npm","test"]', 'DEPENDENCY_SERVICES': '',
'REQUIRED_DEPLOY_SECRETS': ''}
(destination / 'engineering.properties').write_text(''.join(k + '=' + v + '\n' for k, v in settings.items()))
@@ -78,7 +79,7 @@ def main():
parser.add_argument('--components', choices=['backend', 'frontend', 'both'], default='both')
parser.add_argument('--repository', default='http://192.168.2.28:3000/wanjakrah/engineering.git')
parser.add_argument('--version', required=True)
- parser.add_argument('--runner', required=True, help='Existing Gitea host runner label')
+ parser.add_argument('--runner', default='mini-pc', help='Shared Gitea host runner label (default: mini-pc)')
parser.add_argument('--landscape', default='http://192.168.2.28:3000/wanjakrah/landscape')
args = parser.parse_args()
try:
diff --git a/resources/CODING_STANDARDS.md b/resources/CODING_STANDARDS.md
index aaad96e..9ec97ae 100644
--- a/resources/CODING_STANDARDS.md
+++ b/resources/CODING_STANDARDS.md
@@ -153,7 +153,7 @@ exactly at a blocking limit passes.
repeating checks already enforced by tooling.
- Keep upstream skill files unchanged. Put project-specific code-review guidance
in a companion file referenced by project instructions and used alongside
- `skills/code-review/SKILL.md`.
+ landscape's `skills/code-review/SKILL.md`.
- Waivers must be explicit, narrowly scoped and carry a recorded reason alongside
code or configuration. Agents need maintainer agreement before introducing one.
No blanket suppressions, exclusions of difficult files or weakened thresholds
diff --git a/resources/ci/Ci.java b/resources/ci/Ci.java
index a7b369e..bcf867a 100644
--- a/resources/ci/Ci.java
+++ b/resources/ci/Ci.java
@@ -3,9 +3,15 @@ import java.time.*;
import java.time.format.DateTimeFormatter;
import java.util.*;
-/** Run from the repository root with JDK 25: java ci/Ci.java . */
+/** Run from the repository root with JDK 25: java generated/ci/Ci.java . */
public class Ci {
- public static void main(String[] args) {
+ /**
+ * Runs a CI, release or workstation setup command from the consumer repository root.
+ *
+ * @param args command followed by its optional arguments; configuration comes from the project and environment
+ * @implNote Requires JDK 25. Failures exit nonzero with redacted diagnostics; setup commands can change local or Gitea state.
+ */
+ public static void main(final String... args) {
try {
if (Runtime.version().feature() < 25) throw new IllegalArgumentException("JDK 25 or newer is required");
if (args.length == 0) throw new IllegalArgumentException(usage());
@@ -30,6 +36,8 @@ public class Ci {
file, "CI_RUN_ATTEMPT=" + id + "\n", StandardOpenOption.CREATE, StandardOpenOption.APPEND);
}
case "configure-gitea" -> Setup.configureGitea(env);
+ case "configure-runner" -> Setup.configureRunner(env);
+ case "check-runner" -> Setup.checkRunner(env);
case "configure-docker" -> {
if (args.length != 2) throw new IllegalArgumentException("Supply the daemon.json path");
Setup.configureDocker(Path.of(args[1]), Setup.required(env, "INSECURE_REGISTRY"));
@@ -56,7 +64,8 @@ public class Ci {
}
static String usage() {
- return "Usage: java ci/Ci.java verify|publish|cleanup|attempt|test|configure-gitea|configure-docker"
+ return "Usage: java generated/ci/Ci.java"
+ + " verify|publish|cleanup|attempt|test|configure-gitea|configure-runner|check-runner|configure-docker"
+ " |install-runner [--prepare-only]|deploy|rollback|release-test|configure-deployment";
}
}
diff --git a/resources/ci/CiTests.java b/resources/ci/CiTests.java
index d33485f..dc44690 100644
--- a/resources/ci/CiTests.java
+++ b/resources/ci/CiTests.java
@@ -14,7 +14,7 @@ class CiTests {
static void test(String name, Test test) throws Exception {
test.run();
count++;
- System.out.println("PASS " + name);
+ IO.println("PASS " + name);
}
static void equal(Object expected, Object actual) {
@@ -32,6 +32,55 @@ class CiTests {
static void run() throws Exception {
count = 0;
+ test("shared runner scope and identity are independent of the application", () -> {
+ equal("mini-pc", Setup.runnerName(Map.of("PROJECT_NAME", "first")));
+ equal("mini-pc", Setup.runnerName(Map.of("PROJECT_NAME", "second")));
+ equal("/api/v1/user/actions/runners/registration-token", Setup.runnerRegistrationPath(Map.of()));
+ equal(
+ "/api/v1/admin/actions/runners/registration-token",
+ Setup.runnerRegistrationPath(Map.of("RUNNER_SCOPE", "instance")));
+ equal(
+ "/api/v1/orgs/team/actions/runners/registration-token",
+ Setup.runnerRegistrationPath(
+ Map.of("RUNNER_SCOPE", "organization", "RUNNER_ORGANIZATION", "team")));
+ equal(
+ "/api/v1/repos/owner/app/actions/runners/registration-token",
+ Setup.runnerRegistrationPath(Map.of("RUNNER_SCOPE", "repository", "CI_REPOSITORY", "owner/app")));
+ failure(() -> Setup.runnerRegistrationPath(Map.of("RUNNER_SCOPE", "unknown")));
+ failure(() -> Setup.runnerRegistrationPath(
+ Map.of("RUNNER_SCOPE", "organization", "RUNNER_ORGANIZATION", "../unsafe")));
+ failure(() -> Setup.runnerName(Map.of("RUNNER_NAME", "../unsafe")));
+ });
+ test("runner preflight rejects unavailable offline and differently labelled runners", () -> {
+ equal(false, Setup.hasRunner(Map.of("runners", List.of()), "mini-pc"));
+ equal(
+ false,
+ Setup.hasRunner(
+ Map.of(
+ "runners",
+ List.of(Map.of("status", "offline", "labels", List.of(Map.of("name", "mini-pc"))))),
+ "mini-pc"));
+ equal(
+ false,
+ Setup.hasRunner(
+ Map.of(
+ "runners",
+ List.of(Map.of("status", "online", "labels", List.of(Map.of("name", "other"))))),
+ "mini-pc"));
+ equal(
+ true,
+ Setup.hasRunner(
+ Map.of(
+ "runners",
+ List.of(Map.of(
+ "status",
+ "online",
+ "busy",
+ true,
+ "labels",
+ List.of(Map.of("name", "mini-pc"))))),
+ "mini-pc"));
+ });
test("recipe supports backend-only and frontend-only projects", () -> {
equal(List.of("backend"), Recipe.roles(Map.of("BACKEND_MODULE", "server")));
equal(List.of("frontend"), Recipe.roles(Map.of("FRONTEND_MODULE", "web")));
@@ -253,7 +302,7 @@ class CiTests {
Pipeline.deleteTree(dir);
}
});
- System.out.println(count + " CI tests passed");
+ IO.println(count + " CI tests passed");
}
static class Fixture implements AutoCloseable {
diff --git a/resources/ci/Pipeline.java b/resources/ci/Pipeline.java
index d1ac2da..bcc7a0e 100644
--- a/resources/ci/Pipeline.java
+++ b/resources/ci/Pipeline.java
@@ -377,7 +377,7 @@ class Pipeline {
void publish() throws Exception {
if (!"refs/heads/main".equals(env.get("CI_REF"))) {
- System.out.println("Branch is not main; no publication");
+ IO.println("Branch is not main; no publication");
return;
}
check(
@@ -425,7 +425,7 @@ class Pipeline {
}
for (String role : Recipe.roles(env)) {
if (!commit.equals(mainHead())) {
- System.out.println("Newer main exists; moving tags unchanged");
+ IO.println("Newer main exists; moving tags unchanged");
return;
}
String image = prefix + role;
@@ -462,7 +462,7 @@ class Pipeline {
void ensureImage(String repository, String source) throws Exception {
String ref = repository + ":" + commit;
var manifest = result(false, "docker", "manifest", "inspect", "--insecure", ref);
- if (manifest.code() == 0) System.out.println("Preserving existing " + ref);
+ if (manifest.code() == 0) IO.println("Preserving existing " + ref);
else if (manifest.output().toLowerCase(Locale.ROOT).matches("(?s).*(manifest unknown|no such manifest).*")) {
run("docker", "tag", source, ref);
run("docker", "push", ref);
diff --git a/resources/ci/Release.java b/resources/ci/Release.java
index 395574c..2555ad6 100644
--- a/resources/ci/Release.java
+++ b/resources/ci/Release.java
@@ -75,7 +75,7 @@ class Release {
remote.call("preflight", null);
ReleaseState before = ReleaseState.parse(remote.call("state", null));
selected = select(before);
- System.out.println("Selected " + selected.version() + " at " + selected.sha());
+ IO.println("Selected " + selected.version() + " at " + selected.sha());
// Validate the recovery pair before any replacement too.
if (before.current() != null) ensure(before.current());
if (mode.equals("new")) {
@@ -108,7 +108,7 @@ class Release {
record(outcome);
remote.call("finish", null);
pending = false;
- System.out.println("Production " + selected.version() + " is healthy (" + selected.sha() + ")");
+ IO.println("Production " + selected.version() + " is healthy (" + selected.sha() + ")");
} finally {
try {
record(outcome);
diff --git a/resources/ci/ReleaseSetup.java b/resources/ci/ReleaseSetup.java
index de8fa26..bcad410 100644
--- a/resources/ci/ReleaseSetup.java
+++ b/resources/ci/ReleaseSetup.java
@@ -61,7 +61,7 @@ class ReleaseSetup {
env.getOrDefault("PULL_USER", env.get("CI_REPOSITORY").split("/")[0]);
Pipeline.check(registryUser.matches("[a-zA-Z0-9_.-]+"), "Invalid registry user");
try (var remote = new ReleaseRemote(root, env, commands, "setup")) {
- System.out.println(remote.executeScript(
+ IO.println(remote.executeScript(
Files.readString(root.resolve("generated/deploy/provision.sh")),
pull + "\n",
300,
@@ -86,7 +86,7 @@ class ReleaseSetup {
for (String variable :
List.of("DEPLOY_HOST", "DEPLOY_USER", "DEPLOY_SSH_PORT", "DEPLOY_DIRECTORY", "DEPLOY_REGISTRY"))
variable(api, admin, variable, env.get(variable));
- System.out.println(
+ IO.println(
"Production configuration and Gitea deployment secrets/variables installed; no release was created.");
}
diff --git a/resources/ci/ReleaseTests.java b/resources/ci/ReleaseTests.java
index 0f733b2..8316030 100644
--- a/resources/ci/ReleaseTests.java
+++ b/resources/ci/ReleaseTests.java
@@ -14,7 +14,7 @@ class ReleaseTests {
static void test(String name, CiTests.Test test) throws Exception {
test.run();
count++;
- System.out.println("PASS release: " + name);
+ IO.println("PASS release: " + name);
}
static Map environment() {
@@ -252,7 +252,7 @@ class ReleaseTests {
apiTests();
shellTests();
ReleaseShellTests.run();
- System.out.println(count + " release behavior tests passed");
+ IO.println(count + " release behavior tests passed");
}
static class FakeApi extends ReleaseApi {
diff --git a/resources/ci/Setup.java b/resources/ci/Setup.java
index 0963d5b..20ca904 100644
--- a/resources/ci/Setup.java
+++ b/resources/ci/Setup.java
@@ -79,12 +79,79 @@ class Setup {
exists = false;
}
api(server, admin, exists ? "PUT" : "POST", variable, Map.of("value", platform));
- Map response =
- Json.object(api(server, admin, "POST", base + "/actions/runners/registration-token", null));
- Path directory = home().resolve(".config/" + Recipe.project(env) + "-runner");
+ IO.println("Actions, project registry secrets and platform configured; using the shared runner.");
+ }
+
+ static String runnerName(final Map env) {
+ final String name = env.getOrDefault("RUNNER_NAME", env.getOrDefault("RUNNER_LABEL", "mini-pc"));
+ Pipeline.check(name.matches("[a-z][a-z0-9-]*"), "Invalid runner name");
+ return name;
+ }
+
+ static String runnerRegistrationPath(final Map env) {
+ return switch (env.getOrDefault("RUNNER_SCOPE", "user")) {
+ case "user" -> "/api/v1/user/actions/runners/registration-token";
+ case "instance" -> "/api/v1/admin/actions/runners/registration-token";
+ case "organization" -> {
+ final String organization = required(env, "RUNNER_ORGANIZATION");
+ Pipeline.check(organization.matches("[a-zA-Z0-9_-]+"), "Invalid runner organization");
+ yield "/api/v1/orgs/" + organization + "/actions/runners/registration-token";
+ }
+ case "repository" ->
+ "/api/v1/repos/" + required(env, "CI_REPOSITORY") + "/actions/runners/registration-token";
+ default ->
+ throw new IllegalArgumentException("Runner scope must be user, organization, instance or repository");
+ };
+ }
+
+ static void configureRunner(final Map env) throws Exception {
+ final String admin = secret(env, "GITEA_ADMIN_TOKEN_FILE");
+ final Map response =
+ Json.object(api(required(env, "GITEA_SERVER_URL"), admin, "POST", runnerRegistrationPath(env), null));
+ final Path directory = home().resolve(".config/" + runnerName(env) + "-runner");
privateDirectory(directory);
privateWrite(directory.resolve("registration-token"), Objects.toString(response.get("token")));
- System.out.println("Actions, registry secrets and platform configured; runner token saved outside Git.");
+ IO.println("Shared runner registration token saved outside Git; run install-runner once on the workstation.");
+ }
+
+ static void checkRunner(final Map env) throws Exception {
+ final String token = secret(env, "GITEA_ADMIN_TOKEN_FILE");
+ final String repository = required(env, "CI_REPOSITORY");
+ final String server = required(env, "GITEA_SERVER_URL");
+ final Map project = Json.object(api(server, token, "GET", "/api/v1/repos/" + repository, null));
+ Pipeline.check(Boolean.TRUE.equals(project.get("has_actions")), "Enable Actions for the repository first");
+ if ("user".equals(env.getOrDefault("RUNNER_SCOPE", "user"))) {
+ final Map user = Json.object(api(server, token, "GET", "/api/v1/user", null));
+ Pipeline.check(
+ repository.split("/", 2)[0].equalsIgnoreCase(Objects.toString(user.get("login"))),
+ "User-scoped runner must belong to the repository owner; use organization scope for an"
+ + " organization");
+ }
+ // Gitea's repository runner list excludes inherited user/organization runners.
+ final String base = runnerRegistrationPath(env).replace("/registration-token", "");
+ final Map response =
+ Json.object(api(required(env, "GITEA_SERVER_URL"), token, "GET", base, null));
+ Pipeline.check(
+ hasRunner(response, env.getOrDefault("RUNNER_LABEL", "mini-pc")),
+ "No online shared runner with the configured label is available to this repository");
+ IO.println("Shared runner is online and available to " + required(env, "CI_REPOSITORY"));
+ }
+
+ static boolean hasRunner(final Map response, final String label) {
+ for (final Object entry : (List>) response.getOrDefault("runners", List.of())) {
+ final Map runner = Json.object(entry);
+ if (!"online".equals(runner.get("status"))
+ && !"idle".equals(runner.get("status"))
+ && !"active".equals(runner.get("status"))) {
+ continue;
+ }
+ for (final Object item : (List>) runner.getOrDefault("labels", List.of())) {
+ if (label.equals(Json.object(item).get("name"))) {
+ return true;
+ }
+ }
+ }
+ return false;
}
static void configureDocker(Path file, String registry) throws IOException {
@@ -95,7 +162,7 @@ class Setup {
throw new IllegalArgumentException("Existing insecure-registries must be an array; unchanged");
List