import java.io.*; import java.nio.channels.*; import java.nio.file.*; import java.time.*; import java.util.*; /** Verification, publication and cleanup shared by local use and Gitea Actions. */ class Pipeline { final Path root, artifacts; final Map env; final Commands commands; final String commit, runName, backendImage, frontendImage; Path isolatedConfig; Pipeline(Path root, Map environment, Commands commands) throws Exception { this.root = root; this.env = Recipe.environment(root, environment); this.commands = commands; commit = value("CI_COMMIT", () -> capture("git", "rev-parse", "HEAD")); String id = value("CI_RUN_ID", () -> "local"), attempt = value("CI_RUN_ATTEMPT", () -> "1"); check(commit.matches("[0-9a-f]{40}"), "Invalid commit SHA"); check((id + "-" + attempt).matches("[a-zA-Z0-9-]+"), "Invalid run identifier"); runName = Recipe.project(env) + "-" + id + "-" + attempt; backendImage = Recipe.project(env) + "-ci-backend:" + commit + "-" + id + "-" + attempt; frontendImage = Recipe.project(env) + "-ci-frontend:" + commit + "-" + id + "-" + attempt; artifacts = root.resolve("ci/artifacts"); Files.createDirectories(artifacts); } interface Value { String get() throws Exception; } String value(String key, Value defaultValue) throws Exception { if (env.getOrDefault(key, "").isBlank()) env.put(key, defaultValue.get()); return env.get(key); } static void check(boolean condition, String message) { if (!condition) throw new IllegalArgumentException(message); } Commands.Result result(boolean echo, String... args) throws Exception { return commands.execute(List.of(args), root, env, null, echo); } String capture(String... args) throws Exception { return result(false, args).checked(); } void run(String... args) throws Exception { result(true, args).checked(); } void requirePlatform() throws Exception { String target = Setup.required(env, "TARGET_PLATFORM"); var docker = result(false, "docker", "info", "--format", "{{.Architecture}}"); check( docker.code() == 0, "Docker is unavailable; start Docker Desktop before running CI (details in verify.log)"); String arch = docker.output().strip(); arch = switch (arch) { case "x86_64" -> "amd64"; case "aarch64" -> "arm64"; default -> arch; }; String nativePlatform = capture("docker", "info", "--format", "{{.OSType}}") + "/" + arch; check(target.equals(nativePlatform), "Target " + target + " differs from Docker host " + nativePlatform); } void isolateDocker() throws Exception { value("DOCKER_HOST", () -> capture("docker", "context", "inspect", "--format", "{{.Endpoints.docker.Host}}")); env.remove("DOCKER_CONTEXT"); isolatedConfig = Files.createTempDirectory(Recipe.project(env) + "-docker-"); env.put("DOCKER_CONFIG", isolatedConfig.toString()); } void verify() throws Exception { Files.deleteIfExists(artifacts.resolve("verified-commit")); env.put("CI", "true"); commands.log = artifacts.resolve("verify.log"); Files.writeString(commands.log, ""); Thread shutdown = new Thread( () -> { commands.cancel(); try { cleanup(); closeDockerConfig(); } catch (Exception e) { System.err.println("CI shutdown cleanup failed"); } }, "ci-cleanup"); Runtime.getRuntime().addShutdownHook(shutdown); Exception failure = null; try { requirePlatform(); isolateDocker(); check(commit.equals(capture("git", "rev-parse", "HEAD")), "Checkout does not match pushed commit"); run("java", "-version"); if (Recipe.roles(env).contains("frontend")) run("node", "--version"); if (Recipe.roles(env).contains("backend")) run("mvn", "-B", "-ntp", "-Dformat.mode=check", "clean", "verify"); CiTests.run(); ReleaseTests.run(); Path frontend = root.resolve(env.getOrDefault("FRONTEND_MODULE", ".")); if (Recipe.roles(env).contains("frontend")) verifyFrontend(); String platform = env.get("TARGET_PLATFORM"); for (String role : Recipe.roles(env)) { run( "docker", "build", "--platform", platform, "--target", "prebuilt", "--label", "org.opencontainers.image.revision=" + commit, "-t", role.equals("backend") ? backendImage : frontendImage, env.getOrDefault( role.toUpperCase() + "_CONTEXT", role.equals("backend") ? "." : env.get("FRONTEND_MODULE"))); } if (Recipe.enabled(env, "BROWSER_TESTS")) { Files.writeString(artifacts.resolve("containers-started"), ""); run("docker", "network", "create", "--label", Recipe.project(env) + ".ci.run=" + runName, runName); if (Recipe.enabled(env, "CI_DATABASE")) { run( "docker", "run", "-d", "--name", runName + "-database", "--network", runName, "--network-alias", "database", "--env", "POSTGRES_USER=" + env.get("DATABASE_USER"), "--env", "POSTGRES_PASSWORD=ci-only", "--env", "POSTGRES_DB=" + env.get("DATABASE_NAME"), "postgres:17.6"); for (int attempt = 0; attempt < 60; attempt++) { if (result( false, "docker", "exec", runName + "-database", "pg_isready", "-U", env.get("DATABASE_USER")) .code() == 0) break; Thread.sleep(1000); } } run( "docker", "run", "-d", "--name", runName + "-backend", "--label", Recipe.project(env) + ".ci.run=" + runName, "--platform", platform, "--network", runName, "--network-alias", "backend", "--memory", "1g", "--cpus", "2", "--volume", root.resolve(env.get("BACKEND_MODULE") + "/target/coverage-tools/agent.jar") + ":/jacoco-agent.jar:ro", "--env", "JAVA_TOOL_OPTIONS=-javaagent:/jacoco-agent.jar=output=tcpserver,address=*,port=6300", "--env", "DATABASE_URL=jdbc:postgresql://database:5432/" + env.get("DATABASE_NAME"), "--env", "DATABASE_USER=" + env.get("DATABASE_USER"), "--env", "DATABASE_PASSWORD=ci-only", backendImage); run( "docker", "run", "-d", "--name", runName + "-frontend", "--label", Recipe.project(env) + ".ci.run=" + runName, "--platform", platform, "--network", runName, "--network-alias", "frontend", "--memory", "256m", "--cpus", "1", frontendImage); waitForApplication(); String user = capture("id", "-u") + ":" + capture("id", "-g"); run( "docker", "run", "--name", runName + "-browser", "--label", Recipe.project(env) + ".ci.run=" + runName, "--platform", platform, "--network", runName, "--memory", "2g", "--cpus", "2", "--shm-size=1g", "--user", user, "--env", "HOME=/tmp", "--env", "CI=true", "--env", "PLAYWRIGHT_BASE_URL=http://frontend:8080", "--volume", frontend + ":/workspace", "--workdir", "/workspace", env.getOrDefault("PLAYWRIGHT_IMAGE", "mcr.microsoft.com/playwright:v1.64.1-noble"), "npm", "run", "e2e"); run( "docker", "run", "--rm", "--network", runName, "--user", user, "--volume", root.resolve(env.get("BACKEND_MODULE") + "/target") + ":/coverage", "eclipse-temurin:25-jre", "java", "-jar", "/coverage/coverage-tools/cli.jar", "dump", "--address", "backend", "--destfile", "/coverage/playwright.exec"); check( Files.size(root.resolve(env.get("BACKEND_MODULE") + "/target/playwright.exec")) > 0, "Playwright backend coverage is missing"); run( "mvn", "-B", "-ntp", "-pl", env.get("BACKEND_MODULE"), "jacoco:merge@combined", "jacoco:report@combined-report", "exec:exec@combined-coverage"); } Files.writeString(artifacts.resolve("verified-commit"), commit + "\n"); } catch (Exception e) { failure = e; throw e; } finally { try { cleanup(); } catch (Exception e) { if (failure != null) failure.addSuppressed(e); else throw e; } finally { closeDockerConfig(); try { Runtime.getRuntime().removeShutdownHook(shutdown); } catch (IllegalStateException ignored) { /* shutting down */ } } } } void verifyFrontend() throws Exception { Path frontend = root.resolve(env.get("FRONTEND_MODULE")); commands.execute(List.of("npm", "ci"), frontend, env, null, true).checked(); commands.execute(List.of("npm", "run", "lint"), frontend, env, null, true) .checked(); final Object configured = Json.parse( env.getOrDefault( "FRONTEND_TEST_COMMAND", "[\"npm\",\"test\",\"--\",\"--reporters=default\",\"--reporters=junit\",\"--output-file=unit-results/junit.xml\"]")); final List test = ((List) configured).stream().map(Object::toString).toList(); commands.execute(test, frontend, env, null, true).checked(); commands.execute(List.of("npm", "run", "build"), frontend, env, null, true) .checked(); } void waitForApplication() throws Exception { for (int i = 0; i < 60; i++) { if (result( false, "docker", "exec", runName + "-frontend", "wget", "-q", "-O", "-", Setup.required(env, "CI_READINESS_URL")) .code() == 0) return; Thread.sleep(2000); } throw new IllegalArgumentException("Application readiness timed out"); } synchronized void cleanup() throws Exception { // A separate cleanup budget remains available after the build deadline. commands.renew(Duration.ofMinutes(2)); List failures = new ArrayList<>(); for (String path : List.of( env.get("BACKEND_MODULE") + "/target/surefire-reports", env.get("BACKEND_MODULE") + "/target/standards", "generated/tooling/standards/target/surefire-reports", env.get("BACKEND_MODULE") + "/target/site/jacoco", env.get("BACKEND_MODULE") + "/target/site/jacoco-combined", env.get("BACKEND_MODULE") + "/target/failsafe-reports", env.get("FRONTEND_MODULE") + "/unit-results", env.get("FRONTEND_MODULE") + "/test-results", env.get("FRONTEND_MODULE") + "/playwright-report")) { if (Files.isDirectory(root.resolve(path))) { try { copyTree(root.resolve(path), artifacts.resolve(path)); } catch (IOException e) { failures.add(path); } } } for (String data : List.of("jacoco.exec", "playwright.exec", "jacoco-combined.exec")) { final Path source = root.resolve(env.get("BACKEND_MODULE") + "/target").resolve(data); if (Files.isRegularFile(source)) Files.copy(source, artifacts.resolve(data), StandardCopyOption.REPLACE_EXISTING); } if (result(false, "docker", "info").code() == 0) { for (String role : List.of("backend", "frontend", "browser", "database")) { String container = runName + "-" + role; if (result(false, "docker", "container", "inspect", container).code() == 0) { var logs = result(false, "docker", "logs", container); Files.writeString(artifacts.resolve(role + ".log"), logs.output()); if (logs.code() != 0) failures.add(role + " logs"); if (result(false, "docker", "rm", "-f", "-v", container).code() != 0) failures.add(role + " cleanup"); } } if (result(false, "docker", "network", "inspect", runName).code() == 0 && result(false, "docker", "network", "rm", runName).code() != 0) failures.add("network cleanup"); if (failures.isEmpty()) Files.deleteIfExists(artifacts.resolve("containers-started")); } else if (Files.exists(artifacts.resolve("containers-started"))) failures.add("Docker unavailable during cleanup"); check(failures.isEmpty(), "Cleanup incomplete: " + String.join(", ", failures)); } void publish() throws Exception { if (!"refs/heads/main".equals(env.get("CI_REF"))) { System.out.println("Branch is not main; no publication"); return; } check( Files.exists(artifacts.resolve("verified-commit")) && Files.readString(artifacts.resolve("verified-commit")) .strip() .equals(commit), "This commit has not passed verification"); String user = Setup.required(env, "REGISTRY_USER"), token = Setup.required(env, "REGISTRY_TOKEN"); String server = Setup.required(env, "GITEA_SERVER_URL"); String repository = Setup.required(env, "CI_REPOSITORY"); requirePlatform(); commands.log = artifacts.resolve("publish.log"); Files.writeString(commands.log, ""); var uri = java.net.URI.create(server); check( Set.of("http", "https").contains(uri.getScheme()) && (uri.getPath().isEmpty() || uri.getPath().equals("/")), "Registry must be hosted at the Gitea server root"); String registry = uri.getRawAuthority(); String owner = repository.split("/")[0].toLowerCase(Locale.ROOT); check(owner.matches("[a-z0-9][a-z0-9_.-]*"), "Invalid registry namespace"); Path lockDir = Path.of(env.getOrDefault( "CI_PUBLISH_LOCK_DIR", env.getOrDefault( "XDG_STATE_HOME", Setup.home().resolve(".local/state").toString()) + "/" + Recipe.project(env) + "-ci")); Files.createDirectories(lockDir); // Shared host lock: Gitea 1.25 ignores workflow concurrency. try (FileChannel channel = FileChannel.open( lockDir.resolve("publish.lock"), StandardOpenOption.CREATE, StandardOpenOption.WRITE); FileLock lock = awaitLock(channel)) { isolateDocker(); try { commands.execute( List.of("docker", "login", registry, "--username", user, "--password-stdin"), root, env, token, true) .checked(); String prefix = registry + "/" + owner + "/" + Recipe.project(env) + "-"; for (String role : Recipe.roles(env)) { ensureImage(prefix + role, role.equals("backend") ? backendImage : frontendImage); } for (String role : Recipe.roles(env)) { if (!commit.equals(mainHead())) { System.out.println("Newer main exists; moving tags unchanged"); return; } String image = prefix + role; run("docker", "tag", image + ":" + commit, image + ":main"); run("docker", "push", image + ":main"); run("docker", "pull", "--platform", env.get("TARGET_PLATFORM"), image + ":main"); } } finally { closeDockerConfig(); } } } FileLock awaitLock(FileChannel channel) throws Exception { Instant end = Instant.now().plusSeconds(600); while (Instant.now().isBefore(end)) { FileLock lock = channel.tryLock(); if (lock != null) return lock; Thread.sleep(250); } throw new IllegalArgumentException("Publication lock timed out"); } String mainHead() throws Exception { Map branch = Json.object(Setup.api( env.get("GITEA_SERVER_URL"), env.get("REGISTRY_TOKEN"), "GET", "/api/v1/repos/" + env.get("CI_REPOSITORY") + "/branches/main", null)); return Objects.toString(Json.object(branch.get("commit")).get("id")); } 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); else if (manifest.output().toLowerCase(Locale.ROOT).matches("(?s).*(manifest unknown|no such manifest).*")) { run("docker", "tag", source, ref); run("docker", "push", ref); } else throw new IllegalArgumentException( "Registry manifest lookup failed; authentication/network errors are not missing tags"); run("docker", "pull", "--platform", env.get("TARGET_PLATFORM"), ref); check( commit.equals(capture( "docker", "image", "inspect", "--format", "{{index .Config.Labels \"org.opencontainers.image.revision\"}}", ref)), "Existing image has an unexpected source revision"); String digests = capture("docker", "image", "inspect", "--format", "{{range .RepoDigests}}{{println .}}{{end}}", ref); check(digests.contains(repository + "@sha256:"), "Missing registry digest"); Files.writeString( artifacts.resolve(repository.endsWith("backend") ? "backend-digests.txt" : "frontend-digests.txt"), digests + "\n"); } synchronized void closeDockerConfig() throws IOException { if (isolatedConfig != null) { deleteTree(isolatedConfig); isolatedConfig = null; } } static void copyTree(Path source, Path destination) throws IOException { try (var paths = Files.walk(source)) { for (Path path : paths.toList()) { Path target = destination.resolve(source.relativize(path)); if (Files.isDirectory(path)) Files.createDirectories(target); else Files.copy(path, target, StandardCopyOption.REPLACE_EXISTING); } } } static void deleteTree(Path root) throws IOException { if (!Files.exists(root)) return; try (var paths = Files.walk(root)) { for (Path path : paths.sorted(Comparator.reverseOrder()).toList()) Files.delete(path); } } }