Run CI orchestration and setup as Java 25 source programs
Some checks failed
Build, test and publish / verify (push) Failing after 11s

This commit is contained in:
2026-10-29 09:07:41 +01:00
parent 834584dae0
commit 069b62ae92
20 changed files with 821 additions and 536 deletions

View File

@@ -22,18 +22,18 @@ jobs:
- name: Identify workflow attempt - name: Identify workflow attempt
# Gitea 1.25 does not populate gitea.run_attempt. Use a unique value for # Gitea 1.25 does not populate gitea.run_attempt. Use a unique value for
# container names and diagnostic artifacts, including reruns of one SHA. # container names and diagnostic artifacts, including reruns of one SHA.
run: printf 'CI_RUN_ATTEMPT=%s\n' "$(date -u +%Y%m%d%H%M%S%N)" >> "$GITHUB_ENV" run: java ci/Ci.java attempt
- name: Build and test both images - name: Build and test both images
run: timeout --signal=TERM --kill-after=60s 75m bash ci/verify.sh run: java ci/Ci.java verify
- name: Publish verified main images - name: Publish verified main images
if: success() && gitea.ref == 'refs/heads/main' if: success() && gitea.ref == 'refs/heads/main'
env: env:
REGISTRY_USER: ${{ secrets.REGISTRY_USER }} REGISTRY_USER: ${{ secrets.REGISTRY_USER }}
REGISTRY_TOKEN: ${{ secrets.REGISTRY_TOKEN }} REGISTRY_TOKEN: ${{ secrets.REGISTRY_TOKEN }}
run: timeout --signal=TERM --kill-after=60s 15m bash ci/publish.sh run: java ci/Ci.java publish
- name: Collect diagnostics and clean up test containers - name: Collect diagnostics and clean up test containers
if: always() if: always()
run: bash ci/cleanup.sh run: java ci/Ci.java cleanup
- name: Retain diagnostics for fourteen days - name: Retain diagnostics for fourteen days
if: always() if: always()
uses: https://github.com/actions/upload-artifact@ff15f0306b3f739f7b6fd43fb5d26cd321bd4de5 uses: https://github.com/actions/upload-artifact@ff15f0306b3f739f7b6fd43fb5d26cd321bd4de5

54
ci/Ci.java Normal file
View File

@@ -0,0 +1,54 @@
import java.nio.file.*;
import java.time.*;
import java.time.format.DateTimeFormatter;
import java.util.*;
/** Run from the repository root with JDK 25: java ci/Ci.java <command>. */
public class Ci {
public static void main(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());
var env = new HashMap<>(System.getenv());
Path root = Path.of("").toAbsolutePath();
var commands = new Commands(Duration.ofMinutes(args[0].equals("publish") ? 15 : 75));
switch (args[0]) {
case "verify", "publish", "cleanup" -> {
var pipeline = new Pipeline(root, env, commands);
switch (args[0]) {
case "verify" -> pipeline.verify();
case "publish" -> pipeline.publish();
default -> pipeline.cleanup();
}
}
case "attempt" -> {
Path file = Path.of(Setup.required(env, "GITHUB_ENV"));
String id = DateTimeFormatter.ofPattern("yyyyMMddHHmmssSSSSSSSSS")
.withZone(ZoneOffset.UTC).format(Instant.now());
Files.writeString(file, "CI_RUN_ATTEMPT=" + id + "\n", StandardOpenOption.APPEND);
}
case "configure-gitea" -> Setup.configureGitea(env);
case "configure-docker" -> {
if (args.length != 2) throw new IllegalArgumentException("Supply the daemon.json path");
Setup.configureDocker(Path.of(args[1]));
}
case "install-runner" -> Setup.installRunner(root, env, commands,
args.length == 2 && args[1].equals("--prepare-only"));
case "test" -> CiTests.run();
default -> throw new IllegalArgumentException(usage());
}
} catch (Commands.Failure e) {
System.err.println(e.getMessage());
System.exit(e.exitCode);
} catch (Exception e) {
// Never dump HTTP bodies, process arguments, environment or tokens.
System.err.println(e instanceof IllegalArgumentException || e instanceof Setup.ApiFailure ? e.getMessage()
: "CI failed (" + e.getClass().getSimpleName() + "). Check diagnostics and configuration.");
System.exit(1);
}
}
static String usage() {
return "Usage: java ci/Ci.java verify|publish|cleanup|attempt|test|configure-gitea|configure-docker <path>|install-runner [--prepare-only]";
}
}

156
ci/CiTests.java Normal file
View File

@@ -0,0 +1,156 @@
import java.nio.file.*;
import java.time.Duration;
import java.util.*;
/** Dependency-free behavior tests: java ci/Ci.java test. */
class CiTests {
static final String SHA = "a".repeat(40);
static int count;
interface Test { void run() throws Exception; }
static void test(String name, Test test) throws Exception {
test.run(); count++; System.out.println("PASS " + name);
}
static void equal(Object expected, Object actual) {
if (!Objects.equals(expected,actual)) throw new AssertionError("Expected " + expected + ", got " + actual);
}
static void failure(Test action) throws Exception {
try { action.run(); } catch (Exception expected) { return; }
throw new AssertionError("Expected failure");
}
static void run() throws Exception {
count = 0;
test("JSON preserves configuration values and escaped text", () -> {
String json = "{\"text\":\"line\\nquote\\\"\\u20ac\",\"values\":[null,true,false,1,-2.5e2],\"nested\":{}}";
Object data = Json.parse(json); equal(data,Json.parse(Json.write(data)));
equal("line\nquote\"€",Json.object(data).get("text"));
});
test("JSON rejects malformed/ambiguous input", () -> {
for (String value : List.of("{\"a\":null,\"a\":1}","{} []","[1,]","01","\"\\uZZZZ\"","\"line\n\"","truefalse","+1"))
failure(() -> Json.parse(value));
});
test("Docker configuration merge is idempotent and preserves backup", () -> {
Path dir = Files.createTempDirectory("ci-docker-test-");
try {
Path file = dir.resolve("daemon.json");
String original = "{\"features\":{\"buildkit\":true},\"insecure-registries\":[\"existing:5000\"]}";
Files.writeString(file,original);
Setup.configureDocker(file); String first = Files.readString(file); Setup.configureDocker(file);
equal(first,Files.readString(file));
equal(List.of("existing:5000","192.168.2.28:3000"),Json.object(Json.parse(first)).get("insecure-registries"));
equal(original,Files.readString(dir.resolve("daemon.json.before-agiledancers")));
} finally { Pipeline.deleteTree(dir); }
});
test("Invalid Docker configuration is unchanged", () -> {
Path dir = Files.createTempDirectory("ci-docker-test-");
try {
Path file = dir.resolve("daemon.json"); String original = "{\"insecure-registries\":\"invalid\"}";
Files.writeString(file,original); failure(() -> Setup.configureDocker(file)); equal(original,Files.readString(file));
} finally { Pipeline.deleteTree(dir); }
});
test("Feature branches never contact registry", () -> {
try (Fixture f = new Fixture()) {
f.pipeline.env.put("CI_REF","refs/heads/feature/test"); f.pipeline.publish(); equal(0L,f.calls("login")); equal(0L,f.calls("push"));
}
});
test("Unverified commit cannot publish", () -> {
try (Fixture f = new Fixture()) {
Files.delete(f.root.resolve("ci/artifacts/verified-commit")); failure(f.pipeline::publish); equal(0L,f.calls("push"));
}
});
test("Publication pullback and rerun preserve commit tags", () -> {
try (Fixture f = new Fixture()) {
f.pipeline.publish(); equal(4L,f.calls("push")); f.pipeline.publish(); equal(6L,f.calls("push")); equal(8L,f.calls("pull"));
}
});
test("Partial publication is completed without overwriting backend", () -> {
try (Fixture f = new Fixture()) {
f.fake.pushFailure = "frontend:" + SHA; failure(f.pipeline::publish);
equal(0L,f.fake.calls.stream().filter(a -> a.contains("push") && a.getLast().endsWith(":main")).count());
f.fake.pushFailure = ""; f.pipeline.publish();
equal(1L,f.fake.calls.stream().filter(a -> a.contains("push") && a.getLast().endsWith("backend:" + SHA)).count());
}
});
test("Authentication error is not a missing tag", () -> {
try (Fixture f = new Fixture()) { f.fake.manifestError = true; failure(f.pipeline::publish); equal(0L,f.calls("push")); }
});
test("Pull failure blocks moving tags", () -> {
try (Fixture f = new Fixture()) {
f.fake.pullFailure = "backend:" + SHA; failure(f.pipeline::publish);
equal(0L,f.fake.calls.stream().filter(a -> a.contains("push") && a.getLast().endsWith(":main")).count());
}
});
test("Older commit cannot move main", () -> {
try (Fixture f = new Fixture()) { f.fake.head = "c".repeat(40); f.pipeline.publish(); equal(2L,f.calls("push")); }
});
test("Architecture mismatch blocks publication", () -> {
try (Fixture f = new Fixture()) { f.pipeline.env.put("TARGET_PLATFORM","linux/arm64"); failure(f.pipeline::publish); equal(0L,f.calls("push")); }
});
test("Failed verification removes success marker and preserves exit status", () -> {
try (Fixture f = new Fixture()) {
f.fake.mavenFailure = true;
try { f.pipeline.verify(); throw new AssertionError("Expected failure"); }
catch (Commands.Failure error) { equal(42,error.exitCode); }
equal(false,Files.exists(f.root.resolve("ci/artifacts/verified-commit")));
equal(0L,f.calls("build")); equal(0L,f.calls("push"));
}
});
test("Process execution enforces its deadline", () -> {
Path dir = Files.createTempDirectory("ci-process-test-");
try {
Path file = dir.resolve("Sleep.java");
Files.writeString(file,"class Sleep { public static void main(String[] args) throws Exception { Thread.sleep(10000); } }");
Commands runner = new Commands(Duration.ofMillis(100));
try {
runner.execute(List.of(System.getProperty("java.home") + "/bin/java",file.toString()),dir,System.getenv(),null,false);
throw new AssertionError("Expected timeout");
} catch (Commands.Failure error) { equal(124,error.exitCode); }
} finally { Pipeline.deleteTree(dir); }
});
System.out.println(count + " CI tests passed");
}
static class Fixture implements AutoCloseable {
final Path root = Files.createTempDirectory("ci-publication-test-");
final FakeCommands fake = new FakeCommands();
final Pipeline pipeline;
Fixture() throws Exception {
Map<String,String> env = new HashMap<>(System.getenv());
env.putAll(Map.of("CI_COMMIT",SHA,"CI_RUN_ID","test","CI_RUN_ATTEMPT","1","CI_REF","refs/heads/main",
"CI_REPOSITORY","wanjakrah/agiledancers","TARGET_PLATFORM","linux/amd64","GITEA_SERVER_URL","http://192.168.2.28:3000",
"REGISTRY_USER","test","REGISTRY_TOKEN","dummy-token","CI_PUBLISH_LOCK_DIR",root.resolve("lock").toString()));
pipeline = new Pipeline(root,env,fake) { @Override String mainHead() { return fake.head; } };
Files.writeString(root.resolve("ci/artifacts/verified-commit"),SHA + "\n");
}
long calls(String verb) { return fake.calls.stream().filter(a -> a.size()>1 && a.getFirst().equals("docker") && a.get(1).equals(verb)).count(); }
public void close() throws Exception { pipeline.closeDockerConfig(); Pipeline.deleteTree(root); }
}
static class FakeCommands extends Commands {
final List<List<String>> calls = new ArrayList<>();
final Map<String,String> images = new HashMap<>(), tags = new HashMap<>();
String head = SHA, pushFailure = "", pullFailure = "";
boolean manifestError, mavenFailure;
FakeCommands() { super(Duration.ofMinutes(1)); }
@Override Result execute(List<String> args, Path cwd, Map<String,String> env, String input, boolean echo) {
calls.add(List.copyOf(args));
if (args.getFirst().equals("git")) return new Result(0,SHA);
if (args.getFirst().equals("mvn") && mavenFailure) return new Result(42,"deliberate failure");
if (!args.getFirst().equals("docker")) return new Result(0,"");
String command = args.get(1), ref = args.getLast();
if (command.equals("info")) return new Result(0,args.contains("{{.Architecture}}") ? "amd64" : "linux");
if (command.equals("context")) return new Result(0,"unix:///tmp/test-docker.sock");
if (command.equals("manifest")) {
if (manifestError) return new Result(1,"unauthorized");
return images.containsKey(ref) ? new Result(0,"{}") : new Result(1,"manifest unknown");
}
if (command.equals("tag")) tags.put(ref,images.getOrDefault(args.get(2),SHA));
if (command.equals("push")) {
if (!pushFailure.isEmpty() && ref.contains(pushFailure)) return new Result(1,"push failed");
images.put(ref,tags.getOrDefault(ref,SHA));
}
if (command.equals("pull") && !pullFailure.isEmpty() && ref.contains(pullFailure)) return new Result(1,"pull failed");
if (command.equals("image")) return new Result(0,args.stream().anyMatch(a -> a.contains("RepoDigests"))
? ref.substring(0,ref.lastIndexOf(':')) + "@sha256:" + "b".repeat(64) : images.getOrDefault(ref,"missing"));
if (command.equals("container") || command.equals("network") && args.contains("inspect")) return new Result(1,"");
return new Result(0,"");
}
}
}

71
ci/Commands.java Normal file
View File

@@ -0,0 +1,71 @@
import java.io.*;
import java.nio.charset.StandardCharsets;
import java.nio.file.*;
import java.time.*;
import java.util.*;
import java.util.concurrent.*;
/** Executes argument lists directly, without shell interpolation or credential logging. */
class Commands {
record Result(int code, String output) {
String checked() {
if (code != 0) throw new Failure(code, "Command failed with exit status " + code);
return output.strip();
}
}
static class Failure extends RuntimeException {
final int exitCode;
Failure(int exitCode, String message) { super(message); this.exitCode = exitCode; }
}
Instant deadline;
volatile Process active;
Path log;
void renew(Duration timeout) { deadline = Instant.now().plus(timeout); }
void cancel() { Process process = active; if (process != null) stop(process); }
Commands(Duration timeout) { deadline = Instant.now().plus(timeout); }
Result execute(List<String> args, Path cwd, Map<String, String> env, String input, boolean echo) throws Exception {
if (!Instant.now().isBefore(deadline)) throw new Failure(124, "CI time limit exceeded");
ProcessBuilder builder = new ProcessBuilder(args).directory(cwd.toFile()).redirectErrorStream(true);
builder.environment().clear();
builder.environment().putAll(env);
Process process = builder.start();
active = process;
ByteArrayOutputStream captured = new ByteArrayOutputStream();
Path destination = log;
try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
Future<?> reader = executor.submit(() -> {
try (InputStream stream = process.getInputStream();
OutputStream file = destination == null ? OutputStream.nullOutputStream()
: Files.newOutputStream(destination, StandardOpenOption.CREATE, StandardOpenOption.APPEND)) {
byte[] buffer = new byte[8192];
int count;
while ((count = stream.read(buffer)) != -1) {
captured.write(buffer, 0, count);
if (echo) { System.out.write(buffer, 0, count); System.out.flush(); }
file.write(buffer, 0, count);
}
} catch (IOException e) { throw new UncheckedIOException(e); }
});
try {
try (OutputStream stdin = process.getOutputStream()) {
if (input != null) stdin.write(input.getBytes(StandardCharsets.UTF_8));
}
if (!process.waitFor(Math.max(1, Duration.between(Instant.now(), deadline).toMillis()), TimeUnit.MILLISECONDS)) {
stop(process);
throw new Failure(124, "CI time limit exceeded");
}
reader.get(10, TimeUnit.SECONDS);
return new Result(process.exitValue(), captured.toString(StandardCharsets.UTF_8));
} finally {
if (process.isAlive()) stop(process);
active = null;
}
}
}
private static void stop(Process process) {
process.descendants().forEach(ProcessHandle::destroyForcibly);
process.destroyForcibly();
}
}

102
ci/Json.java Normal file
View File

@@ -0,0 +1,102 @@
import java.math.BigDecimal;
import java.util.*;
/** Small dependency-free JSON codec for API/configuration objects, not application data. */
final class Json {
static Object parse(String source) {
Parser parser = new Parser(source);
Object result = parser.value(); parser.space();
if (parser.pos != source.length()) throw parser.invalid();
return result;
}
@SuppressWarnings("unchecked")
static Map<String, Object> object(Object value) {
if (!(value instanceof Map<?, ?>)) throw new IllegalArgumentException("Expected a JSON object");
return (Map<String, Object>) value;
}
static String write(Object value) {
if (value == null) return "null";
if (value instanceof String text) {
StringBuilder out = new StringBuilder("\"");
for (char c : text.toCharArray()) {
switch (c) {
case '"' -> out.append("\\\""); case '\\' -> out.append("\\\\");
case '\n' -> out.append("\\n"); case '\r' -> out.append("\\r"); case '\t' -> out.append("\\t");
default -> { if (c < 32) out.append(String.format("\\u%04x", (int)c)); else out.append(c); }
}
}
return out.append('"').toString();
}
if (value instanceof Boolean || value instanceof BigDecimal || value instanceof Integer || value instanceof Long)
return value.toString();
if (value instanceof Map<?, ?> map) {
StringJoiner out = new StringJoiner(",", "{", "}");
map.forEach((k,v) -> out.add(write(Objects.toString(k)) + ":" + write(v))); return out.toString();
}
if (value instanceof List<?> list) {
StringJoiner out = new StringJoiner(",", "[", "]"); list.forEach(v -> out.add(write(v))); return out.toString();
}
throw new IllegalArgumentException("Unsupported JSON value");
}
private static final class Parser {
final String source; int pos;
Parser(String source) { this.source = source; }
IllegalArgumentException invalid() { return new IllegalArgumentException("Invalid JSON at offset " + pos); }
void space() { while (pos < source.length() && " \r\n\t".indexOf(source.charAt(pos)) >= 0) pos++; }
boolean take(char c) { space(); if (pos < source.length() && source.charAt(pos) == c) { pos++; return true; } return false; }
Object value() {
space(); if (pos >= source.length()) throw invalid();
char c = source.charAt(pos);
if (c == '"') return string();
if (take('{')) {
Map<String,Object> out = new LinkedHashMap<>();
if (take('}')) return out;
do {
space(); if (pos >= source.length() || source.charAt(pos) != '"') throw invalid();
String key = string(); if (!take(':') || out.containsKey(key)) throw invalid();
out.put(key, value());
} while (take(','));
if (!take('}')) throw invalid(); return out;
}
if (take('[')) {
List<Object> out = new ArrayList<>(); if (take(']')) return out;
do { out.add(value()); } while (take(','));
if (!take(']')) throw invalid(); return out;
}
for (String token : List.of("true", "false", "null")) {
if (source.startsWith(token, pos)) {
pos += token.length(); return token.equals("null") ? null : Boolean.valueOf(token);
}
}
int start = pos;
while (pos < source.length() && "-+0123456789.eE".indexOf(source.charAt(pos)) >= 0) pos++;
String number = source.substring(start,pos);
if (!number.matches("-?(0|[1-9][0-9]*)(\\.[0-9]+)?([eE][+-]?[0-9]+)?")) throw invalid();
return new BigDecimal(number);
}
String string() {
pos++; StringBuilder out = new StringBuilder();
while (pos < source.length()) {
char c = source.charAt(pos++);
if (c == '"') return out.toString();
if (c < 32) throw invalid();
if (c != '\\') { out.append(c); continue; }
if (pos >= source.length()) throw invalid();
char escaped = source.charAt(pos++);
switch (escaped) {
case '"', '\\', '/' -> out.append(escaped);
case 'b' -> out.append('\b'); case 'f' -> out.append('\f');
case 'n' -> out.append('\n'); case 'r' -> out.append('\r'); case 't' -> out.append('\t');
case 'u' -> {
if (pos + 4 > source.length()) throw invalid();
String digits = source.substring(pos,pos+4);
if (!digits.matches("[0-9a-fA-F]{4}")) throw invalid();
out.append((char) Integer.parseInt(digits,16)); pos += 4;
}
default -> throw invalid();
}
}
throw invalid();
}
}
}

216
ci/Pipeline.java Normal file
View File

@@ -0,0 +1,216 @@
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");
String arch = capture("docker", "info", "--format", "{{.Architecture}}");
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");
commands.execute(List.of("npm", "ci"), 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();
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 waitForApplication() throws Exception {
for (int i = 0; i < 60; i++) {
if (result(false,"docker", "exec", runName + "-frontend", "wget", "-q", "-O", "-",
"http://localhost: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); }
}
}

146
ci/Setup.java Normal file
View File

@@ -0,0 +1,146 @@
import java.io.*;
import java.net.*;
import java.net.http.*;
import java.nio.file.*;
import java.nio.file.attribute.*;
import java.security.MessageDigest;
import java.time.Duration;
import java.util.*;
class Setup {
static Path home() { return Path.of(System.getProperty("user.home")); }
static String required(Map<String,String> env, String key) {
String value = env.get(key);
if (value == null || value.isBlank()) throw new IllegalArgumentException("Missing " + key);
return value;
}
static String secret(Map<String,String> env, String key) throws IOException {
Path path = Path.of(required(env,key));
Set<PosixFilePermission> permissions = Files.getPosixFilePermissions(path);
if (permissions.stream().anyMatch(p -> p.name().startsWith("GROUP_") || p.name().startsWith("OTHERS_")))
throw new IllegalArgumentException(key + " must refer to an owner-only file (mode 600)");
String token = Files.readString(path).strip();
if (token.isEmpty()) throw new IllegalArgumentException(key + " is empty");
return token;
}
static class ApiFailure extends IOException {
final int status;
ApiFailure(int status) { super("Gitea HTTP " + status); this.status = status; }
}
static Object api(String server, String token, String method, String path, Object body) throws Exception {
Pipeline.check(token.matches("[a-zA-Z0-9_-]+"), "Invalid token format");
HttpRequest request = HttpRequest.newBuilder(URI.create(server.replaceAll("/+$", "") + path))
.timeout(Duration.ofSeconds(30)).header("Authorization", "token " + token)
.header("Content-Type", "application/json")
.method(method, body == null ? HttpRequest.BodyPublishers.noBody() : HttpRequest.BodyPublishers.ofString(Json.write(body)))
.build();
try (HttpClient client = HttpClient.newBuilder().connectTimeout(Duration.ofSeconds(30)).build()) {
var response = client.send(request, HttpResponse.BodyHandlers.ofString());
if (response.statusCode() < 200 || response.statusCode() >= 300) throw new ApiFailure(response.statusCode());
return response.body().isBlank() ? null : Json.parse(response.body());
}
}
static void configureGitea(Map<String,String> env) throws Exception {
String admin = secret(env,"GITEA_ADMIN_TOKEN_FILE"), registry = secret(env,"REGISTRY_TOKEN_FILE");
String user = required(env,"REGISTRY_USER"), platform = required(env,"TARGET_PLATFORM");
Pipeline.check(Set.of("linux/amd64","linux/arm64").contains(platform), "Set TARGET_PLATFORM from the verified Synology architecture");
String server = "http://192.168.2.28:3000", base = "/api/v1/repos/wanjakrah/agiledancers";
api(server,admin,"PATCH",base,Map.of("has_actions",true));
api(server,admin,"PUT",base + "/actions/secrets/REGISTRY_USER",Map.of("data",user));
api(server,admin,"PUT",base + "/actions/secrets/REGISTRY_TOKEN",Map.of("data",registry));
String variable = base + "/actions/variables/TARGET_PLATFORM";
boolean exists;
try { api(server,admin,"GET",variable,null); exists = true; }
catch (ApiFailure e) { if (e.status != 404) throw e; exists = false; }
api(server,admin,exists ? "PUT" : "POST",variable,Map.of("value",platform));
Map<String,Object> response = Json.object(api(server,admin,"POST",base + "/actions/runners/registration-token",null));
Path directory = home().resolve(".config/agiledancers-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.");
}
static void configureDocker(Path file) throws IOException {
Map<String,Object> data = Files.exists(file) ? Json.object(Json.parse(Files.readString(file))) : new LinkedHashMap<>();
Object entry = data.getOrDefault("insecure-registries", new ArrayList<>());
if (!(entry instanceof List<?>)) throw new IllegalArgumentException("Existing insecure-registries must be an array; unchanged");
List<Object> registries = new ArrayList<>((List<?>)entry);
if (registries.contains("192.168.2.28:3000")) { System.out.println("Internal registry already configured; no change"); return; }
registries.add("192.168.2.28:3000"); data.put("insecure-registries",registries);
Path absolute = file.toAbsolutePath(), parent = absolute.getParent(); Files.createDirectories(parent);
if (Files.exists(file)) {
Path backup = file.resolveSibling(file.getFileName() + ".before-agiledancers");
if (!Files.exists(backup)) Files.copy(file,backup,StandardCopyOption.COPY_ATTRIBUTES);
}
Path temp = Files.createTempFile(parent,"daemon-", ".json");
try {
Files.writeString(temp,Json.write(data) + "\n");
if (Files.exists(file)) Files.setPosixFilePermissions(temp,Files.getPosixFilePermissions(file));
Files.move(temp,absolute,StandardCopyOption.ATOMIC_MOVE,StandardCopyOption.REPLACE_EXISTING);
} finally { Files.deleteIfExists(temp); }
System.out.println("Internal registry added; restart/apply Docker Engine configuration to activate");
}
static void installRunner(Path root, Map<String,String> env, Commands commands, boolean prepareOnly) throws Exception {
Pipeline.check(System.getProperty("os.name").equals("Linux") && Set.of("amd64","x86_64").contains(System.getProperty("os.arch")),
"Installer targets the existing Linux x86_64 mini-PC");
Path runnerDir = home().resolve(".local/share/agiledancers-runner"), configDir = home().resolve(".config/agiledancers-runner");
Path bin = home().resolve(".local/bin"), units = home().resolve(".config/systemd/user");
for (Path path : List.of(runnerDir,configDir,bin,units)) privateDirectory(path);
for (String tool : List.of("node","java","mvn","docker","git","systemctl")) executable(env,tool);
String javaHome = System.getProperty("java.home");
String version = commands.execute(List.of("node","--version"),root,env,null,false).checked();
String[] parts = version.replaceFirst("^v", "").split("\\.");
int major = Integer.parseInt(parts[0]), minor = Integer.parseInt(parts[1]), patch = Integer.parseInt(parts[2]);
Pipeline.check((major == 22 && (minor > 22 || minor == 22 && patch >= 3)) || (major == 24 && minor >= 15) || major >= 26,
"Supported Node version required");
Path runner = bin.resolve("act_runner");
if (!Files.isExecutable(runner)) {
URI uri = URI.create("https://dl.gitea.com/act_runner/0.2.13/act_runner-0.2.13-linux-amd64");
byte[] bytes;
try (HttpClient client = HttpClient.newBuilder().followRedirects(HttpClient.Redirect.NORMAL).build()) {
var response = client.send(HttpRequest.newBuilder(uri).timeout(Duration.ofMinutes(2)).build(),HttpResponse.BodyHandlers.ofByteArray());
Pipeline.check(response.statusCode() == 200, "Runner download failed"); bytes = response.body();
}
String hash = HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(bytes));
Pipeline.check(hash.equals("3acac8b506ac8cadc88a55155b5d6378f0fab0b8f62d1e0c0450f4ccd69733e2"),"Runner checksum mismatch");
Files.write(runner,bytes); Files.setPosixFilePermissions(runner,PosixFilePermissions.fromString("rwxr-xr-x"));
}
String actual = commands.execute(List.of(runner.toString(),"--version"),root,env,null,false).checked();
Pipeline.check(actual.contains("v0.2.13"), "Expected runner v0.2.13");
privateWrite(runnerDir.resolve("config.yaml"),Files.readString(root.resolve("ci/runner/config.yaml")));
Files.copy(root.resolve("ci/runner/agiledancers-runner.service"),units.resolve("agiledancers-runner.service"),StandardCopyOption.REPLACE_EXISTING);
String dockerHost = env.get("DOCKER_HOST");
if (dockerHost == null || dockerHost.isBlank()) dockerHost = commands.execute(List.of("docker","context","inspect","--format","{{.Endpoints.docker.Host}}"),root,env,null,false).checked();
String path = javaHome + "/bin:" + executable(env,"node").getParent() + ":" + executable(env,"mvn").getParent() + ":/usr/local/bin:/usr/bin:/bin";
privateWrite(configDir.resolve("environment"),"JAVA_HOME=" + quoted(javaHome) + "\nPATH=" + quoted(path) + "\nDOCKER_HOST=" + quoted(dockerHost) + "\n");
commands.execute(List.of("systemctl","--user","daemon-reload"),root,env,null,true).checked();
if (prepareOnly) { System.out.println("Runner installed; registration and startup still required."); return; }
commands.execute(List.of("docker","info"),root,env,null,false).checked();
if (!Files.exists(runnerDir.resolve(".runner"))) {
Path tokenPath = Path.of(env.getOrDefault("RUNNER_REGISTRATION_TOKEN_FILE",configDir.resolve("registration-token").toString()));
Map<String,String> tokenEnv = Map.of("RUNNER_TOKEN_FILE",tokenPath.toString());
String token = secret(tokenEnv,"RUNNER_TOKEN_FILE");
commands.execute(List.of(runner.toString(),"register","--no-interactive","--instance","http://192.168.2.28:3000",
"--name","agiledancers-mini-pc","--labels","agiledancers-mini-pc:host","--token",token),runnerDir,env,null,true).checked();
Files.setPosixFilePermissions(runnerDir.resolve(".runner"),PosixFilePermissions.fromString("rw-------"));
}
commands.execute(List.of("systemctl","--user","enable","--now","agiledancers-runner.service"),root,env,null,true).checked();
System.out.println("Runner registered and started; keep the mini-PC and Docker available.");
}
static Path executable(Map<String,String> env, String name) {
for (String dir : env.getOrDefault("PATH", "").split(File.pathSeparator)) {
Path path = Path.of(dir,name); if (Files.isRegularFile(path) && Files.isExecutable(path)) return path.toAbsolutePath();
}
throw new IllegalArgumentException("Missing executable: " + name);
}
static String quoted(String value) {
Pipeline.check(!value.contains("\n") && !value.contains("\r"), "Invalid service environment value");
return "\"" + value.replace("\\","\\\\").replace("\"","\\\"") + "\"";
}
static void privateDirectory(Path path) throws IOException {
Files.createDirectories(path); Files.setPosixFilePermissions(path,PosixFilePermissions.fromString("rwx------"));
}
static void privateWrite(Path path, String text) throws IOException {
if (!Files.exists(path)) Files.createFile(path,PosixFilePermissions.asFileAttribute(PosixFilePermissions.fromString("rw-------")));
Files.setPosixFilePermissions(path,PosixFilePermissions.fromString("rw-------")); Files.writeString(path,text);
}
}

View File

@@ -1,29 +0,0 @@
#!/usr/bin/env bash
source "$(dirname -- "$0")/common.sh"
status=0
# Copy only test diagnostics, never environment dumps, Docker inspect or credentials.
for report in agiledancers-be/target/surefire-reports agiledancers-be/target/failsafe-reports \
agiledancers-fe/unit-results agiledancers-fe/test-results agiledancers-fe/playwright-report; do
if [[ -d "$report" ]]; then
destination="$ARTIFACTS/$report"
mkdir -p "$destination"
cp -R "$report/." "$destination/" || status=1
fi
done
if docker info >/dev/null 2>&1; then
for role in backend frontend browser; do
container="$RUN_NAME-$role"
if docker container inspect "$container" >/dev/null 2>&1; then
docker logs "$container" > "$ARTIFACTS/$role.log" 2>&1 || status=1
docker rm -f "$container" >/dev/null || status=1
fi
done
if docker network inspect "$RUN_NAME" >/dev/null 2>&1; then
docker network rm "$RUN_NAME" >/dev/null || status=1
fi
rm -f "$ARTIFACTS/containers-started"
elif [[ -f "$ARTIFACTS/containers-started" ]]; then
echo "Docker is unavailable; cleanup could not be confirmed" >&2
status=1
fi
exit "$status"

View File

@@ -1,40 +0,0 @@
#!/usr/bin/env bash
# Shared by host-runner scripts. No credentials are persisted in the workspace.
set -Eeuo pipefail
CI_ROOT=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.." && pwd)
cd "$CI_ROOT"
ARTIFACTS="$CI_ROOT/ci/artifacts"
mkdir -p "$ARTIFACTS"
: "${CI_COMMIT:=$(git rev-parse HEAD)}"
: "${CI_RUN_ID:=local}"
: "${CI_RUN_ATTEMPT:=1}"
[[ "$CI_COMMIT" =~ ^[0-9a-f]{40}$ ]] || { echo 'Invalid commit SHA' >&2; exit 1; }
[[ "$CI_RUN_ID-$CI_RUN_ATTEMPT" =~ ^[a-zA-Z0-9-]+$ ]] || { echo 'Invalid run identifier' >&2; exit 1; }
RUN_NAME="agiledancers-${CI_RUN_ID}-${CI_RUN_ATTEMPT}"
BACKEND_IMAGE="agiledancers-ci-backend:${CI_COMMIT}-${CI_RUN_ID}-${CI_RUN_ATTEMPT}"
FRONTEND_IMAGE="agiledancers-ci-frontend:${CI_COMMIT}-${CI_RUN_ID}-${CI_RUN_ATTEMPT}"
require_platform() {
: "${TARGET_PLATFORM:?Set TARGET_PLATFORM to the verified Synology platform (for example linux/amd64)}"
local arch native
arch=$(docker info --format '{{.Architecture}}')
case "$arch" in x86_64|amd64) arch=amd64;; aarch64|arm64) arch=arm64;; esac
native="$(docker info --format '{{.OSType}}')/$arch"
[[ "$TARGET_PLATFORM" == "$native" ]] || {
echo "Target $TARGET_PLATFORM differs from Docker host $native. Resolve and test the architecture before running CI." >&2
return 1
}
}
# Anonymous build pulls must not invoke the developer's desktop credential helper.
# Publication uses the same isolation and logs in only to our registry.
isolate_docker_config() {
if [[ -z "${DOCKER_HOST:-}" ]]; then
DOCKER_HOST=$(docker context inspect --format '{{.Endpoints.docker.Host}}')
export DOCKER_HOST
fi
unset DOCKER_CONTEXT
CI_DOCKER_CONFIG=$(mktemp -d)
DOCKER_CONFIG=$CI_DOCKER_CONFIG
export DOCKER_CONFIG
}

View File

@@ -1,67 +0,0 @@
#!/usr/bin/env python3
"""Configure repository Actions through the Gitea API; read tokens from local files."""
import json
import os
from pathlib import Path
import sys
import urllib.error
import urllib.request
BASE = 'http://192.168.2.28:3000/api/v1/repos/wanjakrah/agiledancers'
def read_secret(variable):
path = Path(os.environ[variable]).expanduser()
if path.stat().st_mode & 0o077:
raise ValueError(f'{variable} must refer to a file readable only by its owner (chmod 600)')
value = path.read_text().strip()
if not value:
raise ValueError(f'{variable} is empty')
return value
def main():
admin = read_secret('GITEA_ADMIN_TOKEN_FILE')
registry = read_secret('REGISTRY_TOKEN_FILE')
user = os.environ['REGISTRY_USER']
platform = os.environ['TARGET_PLATFORM']
if platform not in ('linux/amd64', 'linux/arm64'):
raise ValueError('Set TARGET_PLATFORM from the verified Synology architecture')
def api(method, path, body):
request = urllib.request.Request(BASE + path, method=method,
data=json.dumps(body).encode() if body is not None else None, headers={
'Authorization': 'token ' + admin, 'Content-Type': 'application/json'})
with urllib.request.urlopen(request, timeout=30) as response:
data = response.read()
return json.loads(data) if data else None
api('PATCH', '', {'has_actions': True})
for name, value in [('REGISTRY_USER', user), ('REGISTRY_TOKEN', registry)]:
api('PUT', '/actions/secrets/' + name, {'data': value})
try:
api('GET', '/actions/variables/TARGET_PLATFORM', None)
except urllib.error.HTTPError as error:
if error.code != 404:
raise
api('POST', '/actions/variables/TARGET_PLATFORM', {'value': platform})
else:
api('PUT', '/actions/variables/TARGET_PLATFORM', {'value': platform})
# Store registration credentials outside Git; never print API responses/tokens.
result = api('POST', '/actions/runners/registration-token', None)
token_path = Path.home() / '.config/agiledancers-runner/registration-token'
token_path.parent.mkdir(mode=0o700, parents=True, exist_ok=True)
fd = os.open(token_path, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
os.fchmod(fd, 0o600)
with os.fdopen(fd, 'w') as output:
output.write(result['token'])
print('Actions, registry secrets and target platform configured; runner token saved outside Git.')
if __name__ == '__main__':
try:
main()
except urllib.error.HTTPError as error:
print(f'Gitea configuration failed: HTTP {error.code}', file=sys.stderr)
sys.exit(1)
except (OSError, ValueError, KeyError, urllib.error.URLError) as error:
# Do not include request bodies, headers, response bodies or token values.
print(f'Gitea configuration failed ({type(error).__name__}); check local files and connection.', file=sys.stderr)
sys.exit(1)

View File

@@ -1,16 +0,0 @@
#!/usr/bin/env python3
"""Read the current main head without placing an access token in arguments/logs."""
import json
import os
import sys
import urllib.error
import urllib.request
url = os.environ['GITEA_SERVER_URL'].rstrip('/') + '/api/v1/repos/' + os.environ['CI_REPOSITORY'] + '/branches/main'
request = urllib.request.Request(url, headers={'Authorization': 'token ' + os.environ['REGISTRY_TOKEN']})
try:
with urllib.request.urlopen(request, timeout=30) as response:
print(json.load(response)['commit']['id'])
except (urllib.error.URLError, KeyError, ValueError) as error:
print(f'Cannot verify current main head ({type(error).__name__})', file=sys.stderr)
sys.exit(1)

View File

@@ -1,76 +0,0 @@
#!/usr/bin/env bash
source "$(dirname -- "$0")/common.sh"
# Also enforce this inside the script, independently of the workflow expression.
[[ "${CI_REF:-}" == refs/heads/main ]] || { echo 'Branch is not main; no publication'; exit 0; }
[[ -f "$ARTIFACTS/verified-commit" && "$(cat "$ARTIFACTS/verified-commit")" == "$CI_COMMIT" ]] || {
echo 'This commit has not passed all verification steps' >&2; exit 1;
}
: "${REGISTRY_USER:?Missing REGISTRY_USER secret}"
: "${REGISTRY_TOKEN:?Missing REGISTRY_TOKEN secret}"
: "${GITEA_SERVER_URL:?Missing Gitea server URL}"
: "${CI_REPOSITORY:?Missing repository}"
require_platform
exec > >(tee "$ARTIFACTS/publish.log") 2>&1
REGISTRY=${GITEA_SERVER_URL#http://}
REGISTRY=${REGISTRY#https://}
REGISTRY=${REGISTRY%/}
[[ "$REGISTRY" != */* ]] || { echo 'Registry must be hosted at the Gitea server root'; exit 1; }
OWNER=${CI_REPOSITORY%%/*}
OWNER=${OWNER,,}
[[ "$OWNER" =~ ^[a-z0-9][a-z0-9_.-]*$ ]] || { echo 'Invalid registry namespace'; exit 1; }
# Gitea 1.25 ignores workflow concurrency. All publications on the sole runner
# share this lock, outside checkout directories and across workflow attempts.
lock_dir=${CI_PUBLISH_LOCK_DIR:-${XDG_STATE_HOME:-$HOME/.local/state}/agiledancers-ci}
mkdir -p "$lock_dir"
exec 9>"$lock_dir/publish.lock"
flock -w 600 9
# Preserve the selected daemon and isolate credentials from the developer's config.
isolate_docker_config
trap 'rm -rf -- "$DOCKER_CONFIG"' EXIT
trap 'exit 143' TERM
trap 'exit 130' INT
printf '%s' "$REGISTRY_TOKEN" | docker login "$REGISTRY" --username "$REGISTRY_USER" --password-stdin
ensure_commit_image() {
local role=$1 source=$2 repository="$REGISTRY/$OWNER/agiledancers-$1" ref error
ref="$repository:$CI_COMMIT"
error=$(mktemp)
if docker manifest inspect --insecure "$ref" > /dev/null 2>"$error"; then
echo "Preserving existing $ref"
elif grep -Eqi 'manifest unknown|no such manifest' "$error"; then
docker tag "$source" "$ref"
docker push "$ref"
else
# Authentication/network/server errors are never treated as missing tags.
cat "$error" >&2
rm -f "$error"
return 1
fi
rm -f "$error"
docker pull --platform "$TARGET_PLATFORM" "$ref"
[[ "$(docker image inspect --format '{{index .Config.Labels "org.opencontainers.image.revision"}}' "$ref")" == "$CI_COMMIT" ]] || {
echo "Existing $role image has an unexpected source revision" >&2; return 1;
}
docker image inspect --format '{{range .RepoDigests}}{{println .}}{{end}}' "$ref" \
> "$ARTIFACTS/$role-digests.txt"
grep -Fq "$repository@sha256:" "$ARTIFACTS/$role-digests.txt" || { echo 'Missing registry digest'; return 1; }
}
ensure_commit_image backend "$BACKEND_IMAGE"
ensure_commit_image frontend "$FRONTEND_IMAGE"
# Both immutable images are now pull-verified. Only current main may move aliases.
head=$(python3 ci/main-head.py)
if [[ "$head" != "$CI_COMMIT" ]]; then
echo 'A newer main commit exists; leaving moving tags unchanged.'
exit 0
fi
for role in backend frontend; do
repository="$REGISTRY/$OWNER/agiledancers-$role"
# Recheck under the publication lock before each moving-tag write.
[[ "$(python3 ci/main-head.py)" == "$CI_COMMIT" ]] || {
echo 'Main advanced during publication; leaving remaining moving tags unchanged.'
exit 0
}
docker tag "$repository:$CI_COMMIT" "$repository:main"
docker push "$repository:main"
docker pull --platform "$TARGET_PLATFORM" "$repository:main"
done

View File

@@ -1,32 +0,0 @@
#!/usr/bin/env python3
"""Merge this project's HTTP registry into a selected daemon JSON file."""
import json
from pathlib import Path
import shutil
import sys
if len(sys.argv) != 2:
sys.exit('Usage: python3 ci/runner/configure-docker.py <daemon.json>')
path = Path(sys.argv[1]).expanduser()
data = json.loads(path.read_text()) if path.exists() else {}
registries = data.setdefault('insecure-registries', [])
if not isinstance(registries, list):
sys.exit('Existing insecure-registries must be an array; configuration left unchanged')
if '192.168.2.28:3000' in registries:
print('Internal registry already configured; no change')
sys.exit(0)
registries.append('192.168.2.28:3000')
path.parent.mkdir(parents=True, exist_ok=True)
if path.exists():
backup = path.with_name(path.name + '.before-agiledancers')
if not backup.exists():
shutil.copy2(path, backup)
# Write in the same directory, then replace atomically.
temporary = path.with_name(path.name + '.agiledancers.tmp')
with temporary.open('x') as output:
json.dump(data, output, indent=2)
output.write('\n')
if path.exists():
shutil.copymode(path, temporary)
temporary.replace(path)
print('Internal registry added; restart/apply Docker Engine configuration to activate')

View File

@@ -1,52 +0,0 @@
#!/usr/bin/env bash
# Run as the development user, not root. Configuration stays outside the checkout.
set -Eeuo pipefail
script_dir=$(cd -- "$(dirname -- "$0")" && pwd)
runner_dir="$HOME/.local/share/agiledancers-runner"
config_dir="$HOME/.config/agiledancers-runner"
install -d -m 700 "$runner_dir" "$config_dir" "$HOME/.local/bin" "$HOME/.config/systemd/user"
[[ "$(uname -s)/$(uname -m)" == Linux/x86_64 ]] || { echo 'This installer targets the existing Linux x86_64 mini-PC'; exit 1; }
for tool in node java mvn docker curl python3 flock timeout; do command -v "$tool" >/dev/null; done
: "${JAVA_HOME:?Set JAVA_HOME to your JDK 25 directory}"
"$JAVA_HOME/bin/java" -version 2>&1 | grep -Eq 'version "25[.\"]' || { echo 'JDK 25 required'; exit 1; }
node -e 'const [a,b,c]=process.versions.node.split(".").map(Number); if (!((a===22&&(b>22||(b===22&&c>=3)))||(a===24&&(b>15||(b===15&&c>=0)))||a>=26)) process.exit(1)'
if [[ ! -x "$HOME/.local/bin/act_runner" ]]; then
download_dir=$(mktemp -d)
trap 'rm -rf -- "$download_dir"' EXIT
curl --fail --location --retry 3 \
https://dl.gitea.com/act_runner/0.2.13/act_runner-0.2.13-linux-amd64 -o "$download_dir/act_runner"
printf '%s %s\n' 3acac8b506ac8cadc88a55155b5d6378f0fab0b8f62d1e0c0450f4ccd69733e2 "$download_dir/act_runner" | sha256sum --check
install -m 755 "$download_dir/act_runner" "$HOME/.local/bin/act_runner"
fi
"$HOME/.local/bin/act_runner" --version | grep -F 'v0.2.13'
install -m 600 "$script_dir/config.yaml" "$runner_dir/config.yaml"
install -m 644 "$script_dir/agiledancers-runner.service" "$HOME/.config/systemd/user/agiledancers-runner.service"
# Resolve paths now: user services do not inherit an interactive shell's PATH/context.
node_dir=$(dirname -- "$(command -v node)")
maven_dir=$(dirname -- "$(command -v mvn)")
docker_host=${DOCKER_HOST:-$(docker context inspect --format '{{.Endpoints.docker.Host}}')}
umask 077
printf 'JAVA_HOME=%s\nPATH=%s/bin:%s:%s:/usr/local/bin:/usr/bin:/bin\nDOCKER_HOST=%s\n' \
"$JAVA_HOME" "$JAVA_HOME" "$node_dir" "$maven_dir" "$docker_host" > "$config_dir/environment"
systemctl --user daemon-reload
if [[ "${1:-}" == --prepare-only ]]; then
echo 'Runner installed; registration and startup still required.'
exit 0
fi
docker info >/dev/null
if [[ ! -f "$runner_dir/.runner" ]]; then
token_file=${RUNNER_REGISTRATION_TOKEN_FILE:-$config_dir/registration-token}
[[ -f "$token_file" ]] || { echo "Place the repository runner registration token in $token_file (mode 600), then rerun."; exit 1; }
registration_token=$(cat "$token_file")
[[ -n "$registration_token" ]] || { echo 'Empty registration token'; exit 1; }
(
cd "$runner_dir"
"$HOME/.local/bin/act_runner" register --no-interactive \
--instance http://192.168.2.28:3000 --name agiledancers-mini-pc \
--labels agiledancers-mini-pc:host --token "$registration_token"
chmod 600 .runner
)
unset registration_token
fi
systemctl --user enable --now agiledancers-runner.service
echo 'Runner registered and started. The mini-PC and Docker must remain available.'

View File

@@ -1,29 +0,0 @@
import json
from pathlib import Path
import subprocess
import tempfile
import unittest
SCRIPT = Path(__file__).resolve().parents[1] / 'runner/configure-docker.py'
class DockerConfigurationTest(unittest.TestCase):
def test_merge_preserves_existing_settings_and_is_idempotent(self):
with tempfile.TemporaryDirectory() as directory:
path = Path(directory) / 'daemon.json'
original = {'features': {'buildkit': True}, 'insecure-registries': ['existing:5000']}
path.write_text(json.dumps(original))
for _ in range(2):
result = subprocess.run(['python3', str(SCRIPT), str(path)], capture_output=True, text=True)
self.assertEqual(0, result.returncode, result.stderr)
actual = json.loads(path.read_text())
self.assertEqual(original['features'], actual['features'])
self.assertEqual(['existing:5000', '192.168.2.28:3000'], actual['insecure-registries'])
self.assertEqual(original, json.loads(path.with_name('daemon.json.before-agiledancers').read_text()))
def test_invalid_configuration_is_left_unchanged(self):
with tempfile.TemporaryDirectory() as directory:
path = Path(directory) / 'daemon.json'
original = '{"insecure-registries": "invalid"}'
path.write_text(original)
result = subprocess.run(['python3', str(SCRIPT), str(path)], capture_output=True, text=True)
self.assertNotEqual(0, result.returncode)
self.assertEqual(original, path.read_text())

View File

@@ -1,115 +0,0 @@
"""Exercise real shell control flow against a stateful registry/daemon double."""
import json
import os
from pathlib import Path
import shutil
import subprocess
import tempfile
import unittest
ROOT = Path(__file__).resolve().parents[2]
SHA = 'a' * 40
REGISTRY = '192.168.2.28:3000/wanjakrah/agiledancers-'
DOCKER = r'''#!/usr/bin/env python3
import json, os, pathlib, sys
p = pathlib.Path(os.environ['FAKE_STATE'])
s = json.loads(p.read_text())
a = sys.argv[1:]
s['calls'].append(a)
p.write_text(json.dumps(s))
if a[0] == 'info':
print('amd64' if 'Architecture' in ' '.join(a) else 'linux')
elif a[0] == 'context': print('unix:///tmp/test-docker.sock')
elif a[0] == 'login': sys.stdin.read()
elif a[:2] == ['manifest', 'inspect']:
if os.environ.get('FAKE_MANIFEST_ERROR'):
print('unauthorized: authentication required', file=sys.stderr); sys.exit(1)
if a[-1] not in s['images']:
print('manifest unknown', file=sys.stderr); sys.exit(1)
elif a[0] == 'push':
if os.environ.get('FAKE_PUSH_FAILURE', '') in a[-1] and os.environ.get('FAKE_PUSH_FAILURE'):
sys.exit(1)
s['images'][a[-1]] = os.environ['CI_COMMIT']; p.write_text(json.dumps(s))
elif a[0] == 'pull':
if os.environ.get('FAKE_PULL_FAILURE', '') in a[-1] and os.environ.get('FAKE_PULL_FAILURE'):
sys.exit(1)
elif a[:2] == ['image', 'inspect']:
if 'RepoDigests' in ' '.join(a): print(a[-1].rsplit(':', 1)[0] + '@sha256:' + 'b' * 64)
else: print(s['images'].get(a[-1], 'missing'))
elif a[:2] == ['container', 'inspect'] or a[:2] == ['network', 'inspect']: sys.exit(1)
'''
class PublicationTest(unittest.TestCase):
def setUp(self):
self.temp = tempfile.TemporaryDirectory()
self.addCleanup(self.temp.cleanup)
self.root = Path(self.temp.name)
shutil.copytree(ROOT / 'ci', self.root / 'ci', ignore=shutil.ignore_patterns('artifacts', '__pycache__'))
(self.root / 'ci/main-head.py').write_text("import os; print(os.environ.get('FAKE_MAIN_HEAD', os.environ['CI_COMMIT']))\n")
self.bin = self.root / 'bin'; self.bin.mkdir()
(self.bin / 'docker').write_text(DOCKER); (self.bin / 'docker').chmod(0o755)
self.state = self.root / 'state.json'
self.state.write_text(json.dumps({'images': {}, 'calls': []}))
artifacts = self.root / 'ci/artifacts'; artifacts.mkdir()
(artifacts / 'verified-commit').write_text(SHA + '\n')
self.env = dict(os.environ, PATH=str(self.bin) + ':' + os.environ['PATH'],
CI_COMMIT=SHA, CI_REF='refs/heads/main', CI_REPOSITORY='wanjakrah/agiledancers',
CI_RUN_ID='test', CI_RUN_ATTEMPT='1', TARGET_PLATFORM='linux/amd64',
GITEA_SERVER_URL='http://192.168.2.28:3000', REGISTRY_USER='test', REGISTRY_TOKEN='not-a-secret',
CI_PUBLISH_LOCK_DIR=str(self.root / 'lock'), FAKE_STATE=str(self.state))
def run_script(self, script='publish.sh', **extra):
return subprocess.run(['bash', str(self.root / 'ci' / script)], cwd=self.root,
env=dict(self.env, **extra), capture_output=True, text=True, timeout=20)
def calls(self, command):
return [a for a in json.loads(self.state.read_text())['calls'] if a[0] == command]
def test_feature_branch_does_not_contact_registry(self):
result = self.run_script(CI_REF='refs/heads/feature/test')
self.assertEqual(0, result.returncode, result.stderr)
self.assertEqual([], self.calls('login'))
self.assertEqual([], self.calls('push'))
def test_unverified_commit_cannot_publish(self):
(self.root / 'ci/artifacts/verified-commit').unlink()
self.assertNotEqual(0, self.run_script().returncode)
self.assertEqual([], self.calls('push'))
def test_publish_pull_and_rerun_preserves_commit_tags(self):
result = self.run_script()
self.assertEqual(0, result.returncode, result.stdout + result.stderr)
self.assertEqual(4, len(self.calls('push')))
result = self.run_script()
self.assertEqual(0, result.returncode, result.stdout + result.stderr)
self.assertEqual(6, len(self.calls('push'))) # only main aliases on rerun
self.assertEqual(8, len(self.calls('pull')))
def test_partial_publication_is_completed_without_overwriting_backend(self):
self.assertNotEqual(0, self.run_script(FAKE_PUSH_FAILURE='frontend:' + SHA).returncode)
self.assertFalse(any(a[-1].endswith(':main') for a in self.calls('push')))
result = self.run_script()
self.assertEqual(0, result.returncode, result.stdout + result.stderr)
backend_pushes = [a for a in self.calls('push') if a[-1] == REGISTRY + 'backend:' + SHA]
self.assertEqual(1, len(backend_pushes))
def test_registry_auth_failure_is_not_treated_as_missing_image(self):
self.assertNotEqual(0, self.run_script(FAKE_MANIFEST_ERROR='1').returncode)
self.assertEqual([], self.calls('push'))
def test_pull_failure_blocks_main_aliases(self):
self.assertNotEqual(0, self.run_script(FAKE_PULL_FAILURE='backend:' + SHA).returncode)
self.assertFalse(any(a[-1].endswith(':main') for a in self.calls('push')))
def test_old_commit_does_not_move_main(self):
result = self.run_script(FAKE_MAIN_HEAD='c' * 40)
self.assertEqual(0, result.returncode, result.stdout + result.stderr)
self.assertEqual(2, len(self.calls('push')))
self.assertTrue(all(a[-1].endswith(':' + SHA) for a in self.calls('push')))
def test_architecture_mismatch_fails_before_publication(self):
self.assertNotEqual(0, self.run_script(TARGET_PLATFORM='linux/arm64').returncode)
self.assertEqual([], self.calls('push'))
def test_failed_verification_removes_stale_success_marker_and_never_builds_images(self):
for command, content in [('java', 'exit 0'), ('node', 'exit 0'), ('mvn', 'exit 42'),
('git', 'echo ' + SHA)]:
path = self.bin / command; path.write_text('#!/bin/sh\n' + content + '\n'); path.chmod(0o755)
result = self.run_script('verify.sh')
self.assertEqual(42, result.returncode, result.stdout + result.stderr)
self.assertFalse((self.root / 'ci/artifacts/verified-commit').exists())
self.assertEqual([], self.calls('build'))
self.assertEqual([], self.calls('push'))
if __name__ == '__main__':
unittest.main()

View File

@@ -1,58 +0,0 @@
#!/usr/bin/env bash
source "$(dirname -- "$0")/common.sh"
export CI=true
# Start fresh: an earlier attempt's success marker must never authorize publishing.
rm -f "$ARTIFACTS/verified-commit"
exec > >(tee "$ARTIFACTS/verify.log") 2>&1
finish() {
local status=$?
trap - EXIT
bash "$CI_ROOT/ci/cleanup.sh" || { (( status != 0 )) || status=1; }
if [[ -n "${CI_DOCKER_CONFIG:-}" ]]; then rm -rf -- "$CI_DOCKER_CONFIG"; fi
exit "$status"
}
trap finish EXIT
trap 'exit 143' TERM
trap 'exit 130' INT
require_platform
isolate_docker_config
[[ "$(git rev-parse HEAD)" == "$CI_COMMIT" ]] || { echo 'Checkout does not match pushed commit'; exit 1; }
java -version
node --version
mvn -B -ntp clean verify
python3 -m unittest discover -s ci/tests -v
(
cd agiledancers-fe
npm ci
npm test -- --reporters=default --reporters=junit --output-file=unit-results/junit.xml
npm run build
)
docker build --platform "$TARGET_PLATFORM" --target prebuilt \
--label "org.opencontainers.image.revision=$CI_COMMIT" -t "$BACKEND_IMAGE" .
docker build --platform "$TARGET_PLATFORM" --target prebuilt \
--label "org.opencontainers.image.revision=$CI_COMMIT" -t "$FRONTEND_IMAGE" agiledancers-fe
touch "$ARTIFACTS/containers-started"
docker network create --label "agiledancers.ci.run=$RUN_NAME" "$RUN_NAME"
docker run -d --name "$RUN_NAME-backend" --label "agiledancers.ci.run=$RUN_NAME" \
--platform "$TARGET_PLATFORM" --network "$RUN_NAME" --network-alias backend \
--memory 1g --cpus 2 "$BACKEND_IMAGE"
docker run -d --name "$RUN_NAME-frontend" --label "agiledancers.ci.run=$RUN_NAME" \
--platform "$TARGET_PLATFORM" --network "$RUN_NAME" --network-alias frontend \
--memory 256m --cpus 1 "$FRONTEND_IMAGE"
# The browser, tests, readiness checks and API cleanup all use the frontend proxy.
# Install this test container's browser dependencies for the exact lockfile version.
docker run --name "$RUN_NAME-browser" --label "agiledancers.ci.run=$RUN_NAME" \
--platform "$TARGET_PLATFORM" --network "$RUN_NAME" --memory 2g --cpus 2 --shm-size=1g \
--user "$(id -u):$(id -g)" --env HOME=/tmp --env CI=true \
--env PLAYWRIGHT_BASE_URL=http://frontend:8080 \
--volume "$CI_ROOT/agiledancers-fe:/workspace" --workdir /workspace \
mcr.microsoft.com/playwright:v1.64.1-noble bash -euc '
for attempt in $(seq 1 60); do
if curl --fail --silent http://frontend:8080/api/dance-schools >/dev/null; then break; fi
if [ "$attempt" = 60 ]; then echo "Application readiness timed out" >&2; exit 1; fi
sleep 2
done
npm run e2e
'
printf '%s\n' "$CI_COMMIT" > "$ARTIFACTS/verified-commit"

View File

@@ -5,6 +5,26 @@ backend en frontend, bouwt twee images en draait Playwright tegen die containers
Alleen `main` mag daarna images publiceren. Er vindt geen deployment plaats. Alleen `main` mag daarna images publiceren. Er vindt geen deployment plaats.
Tagpushes en aparte pull-requestevents starten geen extra workflow. Tagpushes en aparte pull-requestevents starten geen extra workflow.
## Java-bronbestanden uitvoeren
De CI- en setuphulpmiddelen zijn Java 25-bronbestanden. Start ze direct met
`java ci/Ci.java <opdracht>` vanuit de repositoryroot. De Java-launcher compileert
de benodigde bronbestanden in het geheugen. Er is geen aparte `javac`-stap,
Maven-module, Python-installatie of scriptingframework nodig. De helper gebruikt
alleen de JDK, inclusief de HTTP-client, process builder en bestandsloten.
Beschikbare opdrachten: `verify`, `publish`, `cleanup`, `attempt`, `test`,
`configure-gitea`, `configure-docker <daemon.json>` en
`install-runner [--prepare-only]`. `verify` heeft een intern tijdsbudget van
75 minuten; `publish` 15 minuten. Opruimen krijgt een apart budget van 2 minuten.
Zorg dat `java` naar JDK 25 verwijst, bijvoorbeeld met:
```sh
export JAVA_HOME=/usr/lib/jvm/java-25-openjdk-amd64
export PATH="$JAVA_HOME/bin:$PATH"
```
## Machines en voorwaarden ## Machines en voorwaarden
Gitea 1.25.4 en de registry staan op de bestaande Synology, Gitea 1.25.4 en de registry staan op de bestaande Synology,
@@ -14,8 +34,8 @@ wakker blijven; Docker en de runner moeten draaien. Anders blijven jobs wachten.
Een host-runner voert repositorycode uit als de ontwikkelgebruiker: gebruik deze Een host-runner voert repositorycode uit als de ontwikkelgebruiker: gebruik deze
runner alleen voor deze vertrouwde repository. runner alleen voor deze vertrouwde repository.
De runner heeft Git, Bash, JDK 25, Maven 3.9+, Node 22.22.3+/24.15.0+/26+, npm, De runner heeft Git, Bash, JDK 25, Maven 3.9+, Node 22.22.3+/24.15.0+/26+, npm
Python 3, curl, flock (util-linux), timeout (coreutils) en een werkende Docker-daemon en een werkende Docker-daemon
nodig. De huidige mini-pc heeft de ontwikkeltools en Docker Desktop al geïnstalleerd. nodig. De huidige mini-pc heeft de ontwikkeltools en Docker Desktop al geïnstalleerd.
De workflow gebruikt één job tegelijk (`capacity: 1`), één Playwright-worker en De workflow gebruikt één job tegelijk (`capacity: 1`), één Playwright-worker en
CPU/geheugenlimieten voor de tijdelijke applicatie- en browsercontainers. CPU/geheugenlimieten voor de tijdelijke applicatie- en browsercontainers.
@@ -52,7 +72,7 @@ Je kunt de instelling ook samenvoegen met behoud van bestaande instellingen en
backup via: backup via:
```sh ```sh
python3 ci/runner/configure-docker.py "$HOME/.docker/daemon.json" java ci/Ci.java configure-docker "$HOME/.docker/daemon.json"
``` ```
Voor Docker Engine op Linux is dat dezelfde toevoeging aan Voor Docker Engine op Linux is dat dezelfde toevoeging aan
@@ -88,7 +108,7 @@ Het gewone jobtoken van Gitea 1.25 biedt geen packagepublicatie; gebruik een PAT
Gebruik een repository-runnerregistratietoken, geen instancebrede registratie. Gebruik een repository-runnerregistratietoken, geen instancebrede registratie.
Sla tokenwaarden buiten Git op en plaats ze niet in opdrachten, screenshots of logs. Sla tokenwaarden buiten Git op en plaats ze niet in opdrachten, screenshots of logs.
Voor reproduceerbare configuratie bestaat `ci/configure-gitea.py`. Maak twee Voor reproduceerbare configuratie bestaat `java ci/Ci.java configure-gitea`. Maak twee
bestanden buiten de repository met mode `600`: één voor een PAT met voldoende bestanden buiten de repository met mode `600`: één voor een PAT met voldoende
repositorybeheerrechten (repository Actions, secrets, variabelen en runnerregistratie), repositorybeheerrechten (repository Actions, secrets, variabelen en runnerregistratie),
en één voor de publicatie-PAT. De beheer-PAT hoeft niet als workflowsecret te worden en één voor de publicatie-PAT. De beheer-PAT hoeft niet als workflowsecret te worden
@@ -100,7 +120,7 @@ export GITEA_ADMIN_TOKEN_FILE="$HOME/.config/agiledancers-runner/admin-token"
export REGISTRY_TOKEN_FILE="$HOME/.config/agiledancers-runner/registry-token" export REGISTRY_TOKEN_FILE="$HOME/.config/agiledancers-runner/registry-token"
export REGISTRY_USER=wanjakrah export REGISTRY_USER=wanjakrah
export TARGET_PLATFORM=linux/amd64 # alleen als dit op de Synology is vastgesteld export TARGET_PLATFORM=linux/amd64 # alleen als dit op de Synology is vastgesteld
python3 ci/configure-gitea.py java ci/Ci.java configure-gitea
``` ```
Het script stelt de repository-instellingen in via de API en schrijft het Het script stelt de repository-instellingen in via de API en schrijft het
@@ -118,9 +138,9 @@ testdiagnostiek; gepubliceerde containerimages worden niet automatisch verwijder
```sh ```sh
export JAVA_HOME=/usr/lib/jvm/java-25-openjdk-amd64 export JAVA_HOME=/usr/lib/jvm/java-25-openjdk-amd64
bash ci/runner/install.sh --prepare-only java ci/Ci.java install-runner --prepare-only
# Na Dockerconfiguratie en het opslaan van het registratietoken: # Na Dockerconfiguratie en het opslaan van het registratietoken:
bash ci/runner/install.sh java ci/Ci.java install-runner
systemctl --user status agiledancers-runner systemctl --user status agiledancers-runner
journalctl --user -u agiledancers-runner -f journalctl --user -u agiledancers-runner -f
``` ```
@@ -168,8 +188,8 @@ De frontend luistert op containerpoort 8080, ondersteunt SPA-routes en proxy't
8080. Voorbeelden voor eventueel handmatig starten staan los van de pipeline; 8080. Voorbeelden voor eventueel handmatig starten staan los van de pipeline;
de pipeline start geen blijvende applicatie op de NAS. de pipeline start geen blijvende applicatie op de NAS.
`ci/verify.sh` maakt een succesmarker pas na alle builds en tests. `java ci/Ci.java verify` maakt een succesmarker pas na alle builds en tests.
`ci/publish.sh` vereist die marker en hoofdbranchref. Onder een gedeeld `flock` `java ci/Ci.java publish` vereist die marker en hoofdbranchref. Onder een gedeeld Java-bestandslot (`FileChannel`)
buiten de checkout worden bestaande commitimages hergebruikt en ontbrekende buiten de checkout worden bestaande commitimages hergebruikt en ontbrekende
images geüpload. Iedere image wordt teruggehaald, op revisielabel gecontroleerd en images geüpload. Iedere image wordt teruggehaald, op revisielabel gecontroleerd en
met digest vastgelegd in de diagnostiek. Authenticatie- of netwerkfouten tellen met digest vastgelegd in de diagnostiek. Authenticatie- of netwerkfouten tellen
@@ -192,7 +212,7 @@ niet in; de workflow maakt daarom zelf een unieke identificatie per poging. Geen
inspect met mogelijke credentials wordt opgenomen. inspect met mogelijke credentials wordt opgenomen.
Bij mislukking blijven de oorspronkelijke exitstatus en rapporten behouden. Bij mislukking blijven de oorspronkelijke exitstatus en rapporten behouden.
Een trap én een `always()`-stap verzamelen diagnostiek en verwijderen de tijdelijke Een Java-`finally`/shutdown-hook én een `always()`-stap verzamelen diagnostiek en verwijderen de tijdelijke
containers en het netwerk met hun unieke runnaam. Een harde machine-uitval kan containers en het netwerk met hun unieke runnaam. Een harde machine-uitval kan
opruimen onderbreken: controleer daarna uitsluitend achtergebleven resources met opruimen onderbreken: controleer daarna uitsluitend achtergebleven resources met
label `agiledancers.ci.run`; gebruik geen algemene Docker-prune op de ontwikkel-PC. label `agiledancers.ci.run`; gebruik geen algemene Docker-prune op de ontwikkel-PC.
@@ -206,7 +226,7 @@ blijven de committags bruikbaar en worden de oude `main`-updates overgeslagen.
Lokale controle van publicatie-/foutgedrag zonder registrywijzigingen: Lokale controle van publicatie-/foutgedrag zonder registrywijzigingen:
```sh ```sh
python3 -m unittest discover -s ci/tests -v java ci/Ci.java test
``` ```
Volledige lokale verificatie met Docker en de vastgestelde platformwaarde: Volledige lokale verificatie met Docker en de vastgestelde platformwaarde:
@@ -214,7 +234,7 @@ Volledige lokale verificatie met Docker en de vastgestelde platformwaarde:
```sh ```sh
export JAVA_HOME=/usr/lib/jvm/java-25-openjdk-amd64 export JAVA_HOME=/usr/lib/jvm/java-25-openjdk-amd64
export TARGET_PLATFORM=linux/amd64 # eerst de Synology controleren export TARGET_PLATFORM=linux/amd64 # eerst de Synology controleren
bash ci/verify.sh java ci/Ci.java verify
``` ```
## Operationele oplevering ## Operationele oplevering

View File

@@ -1,7 +1,8 @@
# S4 verification evidence # S4 verification evidence
Date: 2026-10-28. Status: repository implementation and local container verification Date: 2026-10-28. Status: repository implementation and local container verification
passed; operational Gitea acceptance remains pending. S4 is **not complete**. passed; Gitea and runner configuration are operational. Main publication is
awaiting approval to merge PR #1. S4 is **not complete**.
## Verified locally ## Verified locally
@@ -56,7 +57,8 @@ Local image IDs (not registry digests):
`linux/amd64`; repository variable `TARGET_PLATFORM=linux/amd64` configured. `linux/amd64`; repository variable `TARGET_PLATFORM=linux/amd64` configured.
- [x] Activate and verify the Docker Desktop HTTP-registry setting. - [x] Activate and verify the Docker Desktop HTTP-registry setting.
- [x] Repository `REGISTRY_USER`/`REGISTRY_TOKEN` secrets configured using the - [x] Repository `REGISTRY_USER`/`REGISTRY_TOKEN` secrets configured using the
supplied token files. Registry access is verified by actual publication below. supplied token files. A temporary Docker login with the registry token
succeeded; actual publication still awaits a main-branch merge.
- [x] Runner ID 1 (`agiledancers-mini-pc`) registered, service started and - [x] Runner ID 1 (`agiledancers-mini-pc`) registered, service started and
online in Gitea. Real feature/failure jobs ran on this runner. online in Gitea. Real feature/failure jobs ran on this runner.
- [ ] Confirm server settings permit every branchpush (including messages with - [ ] Confirm server settings permit every branchpush (including messages with
@@ -86,3 +88,30 @@ attempt identifier for containers and diagnostic artifact names. The Gitea v3
artifact-list API returns an empty list despite successful uploads; artifacts artifact-list API returns an empty list despite successful uploads; artifacts
were verified by downloading the run-page archive URLs. This is separate from were verified by downloading the run-page archive URLs. This is separate from
artifact availability and does not require a server upgrade. artifact availability and does not require a server upgrade.
## Ready for review; main merge not authorized
[PR #1](http://192.168.2.28:3000/wanjakrah/agiledancers/pulls/1) remains open at
the maintainer's explicit request. No main merge or application image publication
was performed.
[Feature run #3](http://192.168.2.28:3000/wanjakrah/agiledancers/actions/runs/3),
API run/job ID 4, commit `834584dae06b5976936a5e739871f1164cf81bcd`, succeeded.
The two container E2E tests passed and diagnostics were uploaded as
`diagnostics-4-20261028164607460012324`, confirming unique attempt names work.
The main branch currently contains only its initial commit. PR #1 therefore
includes the prior application history from `feature/setup` as well as S4. Its
scope is documented in the PR description. A main merge needs explicit approval;
only then can real main publication/pullback/rerun acceptance be completed.
## Java 25 helper migration
CI orchestration, setup and behavior tests now run through `java ci/Ci.java`.
The shell/Python helpers have been removed. The source launcher compiles helper
classes in memory without external dependencies or preview flags. Fourteen Java
behavior tests pass, including JSON/configuration preservation, publication gates,
partial recovery and process deadlines. A real feature run validates this revision
before updating the existing open PR; no merge to main is authorized.

View File

@@ -175,7 +175,12 @@ image builds and both Playwright tests against the containers passed. Eleven
isolated CI publication/configuration tests pass. The runner binary and user-service isolated CI publication/configuration tests pass. The runner binary and user-service
configuration are installed on the mini-PC. configuration are installed on the mini-PC.
Operational acceptance is still pending: authenticated Synology architecture The maintainer confirmed the Synology as `x86_64` / `linux/amd64`. Gitea secrets
verification, Gitea credentials/secrets, runner registration/startup and real and the target-platform variable are configured; the runner is registered and
feature/failure/main pipeline runs with registry pullback evidence. The spec stays online. Registry login succeeded. Real feature runs #1 and #3 passed, including
IN_PROGRESS until those requirements are met. container E2E; deliberate failure run #2 blocked the build/publication path and
provided downloadable diagnostics.
PR #1 is open. The maintainer explicitly declined merging it for now. Main image
publication, pullback and rerun acceptance therefore remain pending. The spec
stays IN_PROGRESS until those requirements are met.