All checks were successful
Build, test and publish / verify (push) Successful in 1m40s
224 lines
14 KiB
Java
224 lines
14 KiB
Java
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<String,String> env;
|
|
final Commands commands;
|
|
final String commit, runName, backendImage, frontendImage;
|
|
Path isolatedConfig;
|
|
|
|
Pipeline(Path root, Map<String,String> environment, Commands commands) throws Exception {
|
|
this.root = root;
|
|
this.env = new HashMap<>(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 = "agiledancers-" + id + "-" + attempt;
|
|
backendImage = "agiledancers-ci-backend:" + commit + "-" + id + "-" + attempt;
|
|
frontendImage = "agiledancers-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("agiledancers-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"); run("node", "--version"); run("mvn", "-B", "-ntp", "clean", "verify");
|
|
CiTests.run();
|
|
Path frontend = root.resolve("agiledancers-fe");
|
|
verifyFrontend();
|
|
String platform = env.get("TARGET_PLATFORM");
|
|
run("docker", "build", "--platform", platform, "--target", "prebuilt", "--label",
|
|
"org.opencontainers.image.revision=" + commit, "-t", backendImage, ".");
|
|
run("docker", "build", "--platform", platform, "--target", "prebuilt", "--label",
|
|
"org.opencontainers.image.revision=" + commit, "-t", frontendImage, "agiledancers-fe");
|
|
Files.writeString(artifacts.resolve("containers-started"), "");
|
|
run("docker", "network", "create", "--label", "agiledancers.ci.run=" + runName, runName);
|
|
run("docker", "run", "-d", "--name", runName + "-backend", "--label", "agiledancers.ci.run=" + runName,
|
|
"--platform", platform, "--network", runName, "--network-alias", "backend", "--memory", "1g", "--cpus", "2", backendImage);
|
|
run("docker", "run", "-d", "--name", runName + "-frontend", "--label", "agiledancers.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", "agiledancers.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", "mcr.microsoft.com/playwright:v1.64.1-noble",
|
|
"npm", "run", "e2e");
|
|
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("agiledancers-fe");
|
|
commands.execute(List.of("npm", "ci"), frontend, env, null, true).checked();
|
|
commands.execute(List.of("npm", "run", "lint"), frontend, env, null, true).checked();
|
|
commands.execute(List.of("npm", "test", "--", "--reporters=default", "--reporters=junit",
|
|
"--output-file=unit-results/junit.xml"), 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", "-",
|
|
"http://127.0.0.1:8080/api/dance-schools").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<String> failures = new ArrayList<>();
|
|
for (String path : List.of("agiledancers-be/target/surefire-reports", "agiledancers-be/target/failsafe-reports",
|
|
"agiledancers-fe/unit-results", "agiledancers-fe/test-results", "agiledancers-fe/playwright-report")) {
|
|
if (Files.isDirectory(root.resolve(path))) {
|
|
try { copyTree(root.resolve(path), artifacts.resolve(path)); } catch (IOException e) { failures.add(path); }
|
|
}
|
|
}
|
|
if (result(false,"docker", "info").code() == 0) {
|
|
for (String role : List.of("backend", "frontend", "browser")) {
|
|
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", 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()) + "/agiledancers-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 + "/agiledancers-";
|
|
ensureImage(prefix + "backend", backendImage);
|
|
ensureImage(prefix + "frontend", frontendImage);
|
|
for (String role : List.of("backend", "frontend")) {
|
|
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<String,Object> 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); }
|
|
}
|
|
}
|