S19: establish engineering repository

This commit is contained in:
2026-11-08 15:15:50 +01:00
commit c25ce11a1b
84 changed files with 6599 additions and 0 deletions

62
resources/ci/Ci.java Normal file
View File

@@ -0,0 +1,62 @@
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());
Path root = Path.of("").toAbsolutePath();
var env = Recipe.environment(root, System.getenv());
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.CREATE, 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]), Setup.required(env, "INSECURE_REGISTRY"));
}
case "install-runner" ->
Setup.installRunner(root, env, commands, args.length == 2 && args[1].equals("--prepare-only"));
case "test" -> CiTests.run();
case "deploy", "rollback" -> Release.execute(root, env, args[0]);
case "release-test" -> ReleaseTests.run();
case "configure-deployment" -> ReleaseSetup.configure(root, env);
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]|deploy|rollback|release-test|configure-deployment";
}
}

358
resources/ci/CiTests.java Normal file
View File

@@ -0,0 +1,358 @@
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("recipe supports backend-only and frontend-only projects", () -> {
equal(List.of("backend"), Recipe.roles(Map.of("BACKEND_MODULE", "server")));
equal(List.of("frontend"), Recipe.roles(Map.of("FRONTEND_MODULE", "web")));
failure(() -> Recipe.roles(Map.of()));
failure(() -> Recipe.project(Map.of("PROJECT_NAME", "../unsafe")));
});
test("project configuration is overridden by environment and rejects escaping modules", () -> {
final Path root = Files.createTempDirectory("recipe-config-");
try {
Files.writeString(
root.resolve("engineering.properties"), "PROJECT_NAME=sample\nBACKEND_MODULE=server\n");
equal(
"override",
Recipe.environment(root, Map.of("PROJECT_NAME", "override"))
.get("PROJECT_NAME"));
failure(() -> Recipe.environment(root, Map.of("BACKEND_MODULE", "../outside")));
} finally {
Pipeline.deleteTree(root);
}
});
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, "registry.example:3000");
String first = Files.readString(file);
Setup.configureDocker(file, "registry.example:3000");
equal(first, Files.readString(file));
equal(
List.of("existing:5000", "registry.example:3000"),
Json.object(Json.parse(first)).get("insecure-registries"));
equal(original, Files.readString(dir.resolve("daemon.json.before-engineering")));
} 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, "registry.example:3000"));
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("Unavailable Docker fails before build with a concrete cause", () -> {
try (Fixture f = new Fixture()) {
f.fake.dockerAvailable = false;
try {
f.pipeline.verify();
throw new AssertionError("Expected failure");
} catch (IllegalArgumentException error) {
equal(true, error.getMessage().contains("Docker is unavailable"));
}
equal(
0L,
f.fake.calls.stream()
.filter(a -> a.getFirst().equals("mvn"))
.count());
}
});
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(
List.of("mvn", "-B", "-ntp", "-Dformat.mode=check", "clean", "verify"),
f.fake.calls.stream()
.filter(a -> a.getFirst().equals("mvn"))
.findFirst()
.orElseThrow());
equal(false, Files.exists(f.root.resolve("ci/artifacts/verified-commit")));
equal(0L, f.calls("build"));
equal(0L, f.calls("push"));
}
});
test("Frontend lint runs after install and blocks tests and build on failure", () -> {
try (Fixture f = new Fixture()) {
f.fake.lintFailure = true;
try {
f.pipeline.verifyFrontend();
throw new AssertionError("Expected failure");
} catch (Commands.Failure error) {
equal(1, error.exitCode);
}
equal(List.of(List.of("npm", "ci"), List.of("npm", "run", "lint")), f.fake.calls);
}
});
test("Frontend verification checks before testing and building", () -> {
try (Fixture f = new Fixture()) {
f.pipeline.verifyFrontend();
equal(
List.of(
"ci",
"run lint",
"test -- --reporters=default --reporters=junit --output-file=unit-results/junit.xml",
"run build"),
f.fake.calls.stream()
.map(a -> String.join(" ", a.subList(1, a.size())))
.toList());
}
});
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",
"owner/recipe-demo",
"TARGET_PLATFORM",
"linux/amd64",
"GITEA_SERVER_URL",
"http://registry.example:3000",
"REGISTRY_USER",
"test",
"REGISTRY_TOKEN",
"dummy-token",
"CI_PUBLISH_LOCK_DIR",
root.resolve("lock").toString()));
env.put("PROJECT_NAME", "recipe-demo");
env.put("BACKEND_MODULE", "backend");
env.put("FRONTEND_MODULE", "frontend");
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, lintFailure;
boolean dockerAvailable = true;
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.equals(List.of("npm", "run", "lint")) && lintFailure)
return new Result(1, "deliberate lint 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(dockerAvailable ? 0 : 1, 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, "");
}
}
}

View File

@@ -0,0 +1,96 @@
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();
}
}

154
resources/ci/Json.java Normal file
View File

@@ -0,0 +1,154 @@
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();
}
}
}

513
resources/ci/Pipeline.java Normal file
View File

@@ -0,0 +1,513 @@
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 = 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<String> 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<String> 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<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);
}
}
}

61
resources/ci/Recipe.java Normal file
View File

@@ -0,0 +1,61 @@
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Properties;
/** Project-owned configuration for the shared engineering mechanisms. */
final class Recipe {
private Recipe() {}
static Map<String, String> environment(final Path root, final Map<String, String> input) throws Exception {
final var result = new HashMap<String, String>(input);
final Path file = root.resolve("engineering.properties");
if (Files.exists(file)) {
final var properties = new Properties();
try (final var stream = Files.newInputStream(file)) {
properties.load(stream);
}
for (final String key : properties.stringPropertyNames()) {
result.putIfAbsent(
key, properties.getProperty(key).replace("${user.home}", System.getProperty("user.home")));
}
}
if (result.containsKey("PROJECT_NAME")) {
project(result);
for (final String role : roles(result)) {
final Path module = Path.of(result.get(role.toUpperCase() + "_MODULE"));
Pipeline.check(
!module.isAbsolute() && !module.normalize().startsWith(".."),
"Module must stay in the project");
}
Pipeline.check(
!enabled(result, "BROWSER_TESTS") || roles(result).size() == 2,
"Browser coverage requires both backend and frontend");
}
return result;
}
static String project(final Map<String, String> env) {
final String name = Setup.required(env, "PROJECT_NAME");
Pipeline.check(name.matches("[a-z][a-z0-9-]*"), "Invalid project name");
return name;
}
static List<String> roles(final Map<String, String> env) {
final var roles = new ArrayList<String>();
for (final String role : List.of("backend", "frontend")) {
if (!env.getOrDefault(role.toUpperCase() + "_MODULE", "").isBlank()) {
roles.add(role);
}
}
Pipeline.check(!roles.isEmpty(), "Configure BACKEND_MODULE, FRONTEND_MODULE, or both");
return roles;
}
static boolean enabled(final Map<String, String> env, final String key) {
return Boolean.parseBoolean(env.getOrDefault(key, "false"));
}
}

236
resources/ci/Release.java Normal file
View File

@@ -0,0 +1,236 @@
import java.net.URI;
import java.nio.file.Files;
import java.nio.file.Path;
import java.time.Duration;
import java.time.Instant;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.UUID;
/** Manual release coordinator. All mutation decisions are made under the NAS lock. */
class Release {
final Path root, artifacts;
final Map<String, String> env;
final ReleaseApi api;
final ReleaseRemote remote;
final String mode, increment, retry, pinned, attempt;
ReleaseState.Deployment selected;
boolean locked, pending;
static void execute(Path root, Map<String, String> env, String operation) throws Exception {
validate(env, operation);
String attempt = "release-" + env.get("CI_RUN_ID") + "-" + UUID.randomUUID();
Path artifacts = root.resolve("ci/artifacts/deployment");
Files.createDirectories(artifacts);
var commands = new Commands(Duration.ofMinutes(15));
commands.log = artifacts.resolve(attempt + ".log");
Files.writeString(commands.log, "");
try (var remote = new ReleaseRemote(root, env, commands, attempt)) {
new Release(root, env, new ReleaseApi(env), remote, operation, attempt).run();
}
}
static void validate(Map<String, String> env, String operation) {
Pipeline.check("refs/heads/main".equals(env.get("CI_REF")), "Production workflows must run from main");
Pipeline.check(
"workflow_dispatch".equals(env.get("CI_EVENT")), "Production requires a manual workflow dispatch");
Pipeline.check(Setup.required(env, "CI_RUN_ID").matches("[0-9]+"), "Invalid Gitea run ID");
ReleaseApi.sha(env.get("CI_COMMIT"));
Pipeline.check(List.of("deploy", "rollback").contains(operation), "Unknown release operation");
if (operation.equals("rollback")) return;
String mode = Setup.required(env, "RELEASE_MODE");
String increment = env.getOrDefault("RELEASE_INCREMENT", "");
String retry = env.getOrDefault("RELEASE_VERSION", "");
if (mode.equals("new")) {
Pipeline.check(List.of("patch", "minor", "major").contains(increment), "Choose patch, minor or major");
Pipeline.check(retry.isBlank(), "New release cannot specify an existing version");
} else {
Pipeline.check(mode.equals("retry"), "Choose new or retry release mode");
ReleaseVersion.parse(retry);
// Gitea shows all inputs; the new-release increment is ignored for retries.
}
}
Release(Path root, Map<String, String> env, ReleaseApi api, ReleaseRemote remote, String operation, String attempt)
throws Exception {
this.root = root;
this.env = env;
this.api = api;
this.remote = remote;
this.attempt = attempt;
artifacts = root.resolve("ci/artifacts/deployment");
Files.createDirectories(artifacts);
mode = operation.equals("rollback") ? "rollback" : env.get("RELEASE_MODE");
increment = env.getOrDefault("RELEASE_INCREMENT", "");
retry = env.getOrDefault("RELEASE_VERSION", "");
pinned = ReleaseApi.sha(env.get("CI_COMMIT"));
}
void run() throws Exception {
String outcome = "rejected";
try {
remote.acquire();
locked = true;
remote.call("preflight", null);
ReleaseState before = ReleaseState.parse(remote.call("state", null));
selected = select(before);
System.out.println("Selected " + selected.version() + " at " + selected.sha());
// Validate the recovery pair before any replacement too.
if (before.current() != null) ensure(before.current());
if (mode.equals("new")) {
api.create(selected.version(), selected.sha(), attempt);
Pipeline.check(
selected.sha().equals(api.head()),
"Main advanced; release tagged but production unchanged. Retry explicitly.");
}
var journal = new LinkedHashMap<String, Object>();
journal.put("attempt", attempt);
journal.put("mode", mode);
journal.put("candidate", selected.json());
journal.put("before", Json.parse(before.current() == null ? "{}" : before.json()));
remote.call("pending", Json.write(journal));
pending = true;
outcome = "replacement-started";
try {
remote.call("apply", null, selected.directory());
} catch (Exception failure) {
diagnose(selected);
outcome = recover(before);
throw new IllegalArgumentException(
"Deployment failed: " + outcome + ". See deployment diagnostics and recovery runbook.");
}
// A lost reply here is ambiguous: retain journal and lock, never guess
// whether a state commit happened and launch an overlapping recovery.
outcome = "healthy-state-commit-pending";
remote.call("commit", before.success(selected).json());
outcome = "success";
record(outcome);
remote.call("finish", null);
pending = false;
System.out.println("Production " + selected.version() + " is healthy (" + selected.sha() + ")");
} finally {
try {
record(outcome);
} finally {
if (locked && !pending) remote.release();
else if (locked)
System.err.println("NAS lock and pending journal retained. Follow docs/production-deployment.md for"
+ " recovery.");
}
}
}
ReleaseState.Deployment select(ReleaseState before) throws Exception {
if (mode.equals("rollback")) {
Pipeline.check(before.previous() != null, "No previous successful production release is available");
ensure(before.previous());
return before.previous();
}
List<ReleaseApi.Tag> tags = api.tags();
ReleaseVersion version;
String commit;
if (mode.equals("new")) {
Pipeline.check(
pinned.equals(api.head()), "Main advanced since this dispatch; start a new release workflow");
Pipeline.check(
tags.stream().noneMatch(t -> t.sha().equals(pinned)),
"This commit already has a release tag; use retry or rollback");
version = ReleaseVersion.next(
tags.stream().map(ReleaseApi.Tag::version).toList(), increment);
commit = pinned;
} else {
version = ReleaseVersion.parse(retry);
var tag = tags.stream()
.filter(t -> t.version().equals(version))
.findFirst()
.orElseThrow(() -> new IllegalArgumentException("Retry release tag does not exist"));
api.annotated(tag);
commit = tag.sha();
}
Pipeline.check(
before.highest() == null || version.compareTo(before.highest()) >= (mode.equals("new") ? 1 : 0),
"A newer release has already succeeded; use explicit rollback for older versions");
String build = api.verified(commit);
remote.call("stage", Files.readString(root.resolve("deploy/compose.yaml")));
remote.call("stage-health", Files.readString(root.resolve("deploy/health.sh")));
String prepared = remote.call("prepare", null, version.toString(), commit, prefix());
return new ReleaseState.Deployment(
version, commit, digest(prepared, "BACKEND"), digest(prepared, "FRONTEND"), attempt, build);
}
String prefix() {
var uri = URI.create(api.server);
String authority = uri.getRawAuthority();
String registry = env.getOrDefault("DEPLOY_REGISTRY", "").strip();
if (registry.isEmpty()) registry = authority;
int port = uri.getPort() >= 0 ? uri.getPort() : uri.getScheme().equals("https") ? 443 : 80;
Pipeline.check(
registry.equals(authority) || registry.equals("127.0.0.1:" + port),
"Deployment registry must be the existing Gitea registry or its NAS loopback alias");
String owner = Setup.required(env, "CI_REPOSITORY").split("/")[0].toLowerCase(java.util.Locale.ROOT);
return registry + "/" + owner + "/" + Recipe.project(env) + "-";
}
void ensure(ReleaseState.Deployment release) throws Exception {
remote.call(
"ensure",
null,
release.directory(),
release.version().toString(),
release.sha(),
release.backend(),
release.frontend());
}
String digest(String response, String role) {
if (!Recipe.roles(env).contains(role.toLowerCase(java.util.Locale.ROOT))) return "-";
String value = response.lines()
.filter(line -> line.startsWith("S6_" + role + "="))
.map(line -> line.substring(("S6_" + role + "=").length()))
.reduce((a, b) -> {
throw new IllegalArgumentException("Ambiguous prepared image digest");
})
.orElseThrow(() -> new IllegalArgumentException("Missing prepared image digest"));
Pipeline.check(
value.startsWith(prefix() + role.toLowerCase(java.util.Locale.ROOT) + "@sha256:"),
"Unexpected image repository");
return value;
}
String recover(ReleaseState before) throws Exception {
if (before.current() == null) return "first-release-failed-manual-recovery-required";
try {
remote.call("apply", null, before.current().directory());
} catch (Exception failure) {
diagnose(before.current());
return "recovery-failed-manual-recovery-required";
}
record("failed-previous-release-restored");
remote.call("finish", null);
pending = false;
return "previous-release-restored";
}
void diagnose(ReleaseState.Deployment release) {
try {
Files.writeString(
artifacts.resolve(attempt + "-" + release.directory() + "-containers.log"),
remote.call("diagnose", null, release.directory()));
} catch (Exception ignored) {
System.err.println("Container diagnostics unavailable; use the NAS recovery runbook");
}
}
void record(String outcome) throws Exception {
var data = new LinkedHashMap<String, Object>();
data.put("attempt", attempt);
data.put("mode", mode);
data.put("outcome", outcome);
data.put("time", Instant.now().toString());
data.put("release", selected == null ? null : selected.json());
String json = Json.write(data) + "\n";
Files.writeString(artifacts.resolve(attempt + ".json"), json);
if (locked) remote.call("event", json);
}
}

View File

@@ -0,0 +1,124 @@
import java.net.URI;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.Objects;
/** Gitea 1.25 API adapter. Never include response bodies or tokens in errors. */
class ReleaseApi {
record Tag(ReleaseVersion version, String sha, String object) {}
final String server, token, base;
ReleaseApi(Map<String, String> env) {
server = Setup.required(env, "GITEA_SERVER_URL");
var uri = URI.create(server);
Pipeline.check(
List.of("http", "https").contains(uri.getScheme())
&& uri.getUserInfo() == null
&& uri.getQuery() == null
&& uri.getFragment() == null
&& (uri.getPath().isEmpty() || uri.getPath().equals("/")),
"Gitea must use a server-root URL");
String repository = Setup.required(env, "CI_REPOSITORY");
Pipeline.check(repository.matches("[a-zA-Z0-9_.-]+/[a-zA-Z0-9_.-]+"), "Invalid repository name");
base = "/api/v1/repos/" + repository;
token = Setup.required(env, "RELEASE_TOKEN");
}
Object request(String method, String path, Object body) throws Exception {
return Setup.api(server, token, method, base + path, body);
}
String head() throws Exception {
return sha(
Json.object(Json.object(request("GET", "/branches/main", null)).get("commit"))
.get("id"));
}
static String sha(Object value) {
String result = Objects.toString(value, "");
Pipeline.check(result.matches("[0-9a-f]{40}"), "Invalid release commit SHA");
return result;
}
List<Tag> tags() throws Exception {
List<Tag> tags = new ArrayList<>();
var seen = new java.util.HashSet<String>();
for (int page = 1; ; page++) {
var entries = (List<?>) request("GET", "/tags?limit=50&page=" + page, null);
if (entries.isEmpty()) return tags;
for (Object entry : entries) {
var tag = Json.object(entry);
String name = Objects.toString(tag.get("name"), "");
Pipeline.check(
seen.add(name),
"Remote tag pagination repeated a tag; cannot establish a safe version baseline");
// Unknown version-looking tags require explicit reconciliation, not a second baseline.
if (!name.matches("v?[0-9].*")) continue;
ReleaseVersion version = ReleaseVersion.parse(name);
String commit = sha(Json.object(tag.get("commit")).get("sha"));
String object = sha(tag.get("id"));
tags.add(new Tag(version, commit, object));
}
}
}
void annotated(Tag tag) throws Exception {
var data = Json.object(request("GET", "/git/tags/" + tag.object(), null));
Pipeline.check(
tag.version().toString().equals(data.get("tag"))
&& tag.sha().equals(Json.object(data.get("object")).get("sha")),
"Release tag must be annotated and point directly to its recorded commit");
}
void create(ReleaseVersion version, String commit, String run) throws Exception {
request(
"POST",
"/tags",
Map.of(
"tag_name",
version.toString(),
"target",
commit,
"message",
"Release " + version + "\nCommit: " + commit + "\nDeployment attempt: " + run));
Tag tag = tags().stream()
.filter(t -> t.version().equals(version))
.findFirst()
.orElseThrow();
Pipeline.check(tag.sha().equals(commit), "Created release tag does not match selected commit");
annotated(tag);
}
String verified(String commit) throws Exception {
// A successful build.yaml main-push run includes verification AND successful image publication.
// Do not accept another workflow, a branch build, or merely an existing image/tag.
var seen = new java.util.HashSet<String>();
for (int page = 1; ; page++) {
var response = Json.object(request(
"GET",
"/actions/runs?head_sha=" + commit + "&event=push&branch=main&status=success&limit=50&page=" + page,
null));
var runs = (List<?>) response.get("workflow_runs");
if (runs.isEmpty()) break;
boolean fresh = false;
for (Object entry : runs) {
var run = Json.object(entry);
fresh |= seen.add(Objects.toString(run.get("id")));
if (commit.equals(run.get("head_sha"))
&& "push".equals(run.get("event"))
&& List.of("main", "refs/heads/main").contains(Objects.toString(run.get("head_branch")))
&& "completed".equals(run.get("status"))
&& "success".equals(run.get("conclusion"))
&& "build.yaml@refs/heads/main".equals(run.get("path"))) {
return Objects.toString(run.get("id"));
}
}
// Older Gitea installations may ignore some list filters/pagination.
// Validate each entry locally and stop if a page makes no progress.
if (!fresh) break;
}
throw new IllegalArgumentException("No successful build.yaml main-push run verifies and publishes this SHA");
}
}

View File

@@ -0,0 +1,125 @@
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.attribute.PosixFilePermissions;
import java.time.Duration;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
/** Argument-safe SSH adapter; server-side mkdir lock is shared by every runner. */
class ReleaseRemote implements AutoCloseable {
final Path root, credentials;
final Map<String, String> env;
final Commands commands;
final String owner, directory, host, user, port, script;
ReleaseRemote(Path root, Map<String, String> env, Commands commands, String owner) throws Exception {
this.root = root;
this.env = env;
this.commands = commands;
this.owner = owner;
directory = Setup.required(env, "DEPLOY_DIRECTORY");
host = Setup.required(env, "DEPLOY_HOST");
user = Setup.required(env, "DEPLOY_USER");
port = env.getOrDefault("DEPLOY_SSH_PORT", "22");
Pipeline.check(
directory.matches("/[a-zA-Z0-9_./-]+") && !directory.contains("..") && !directory.equals("/"),
"Use an absolute dedicated deployment directory without spaces or parent traversal");
Pipeline.check(
host.matches("[a-zA-Z0-9][a-zA-Z0-9.-]*")
&& user.matches("[a-zA-Z0-9_][a-zA-Z0-9_-]*")
&& port.matches("[0-9]{1,5}")
&& Integer.parseInt(port) > 0
&& Integer.parseInt(port) <= 65535,
"Invalid deployment SSH host, user or port");
script = "COMPOSE_PROJECT=" + quote(Setup.required(env, "COMPOSE_PROJECT")) + "\n"
+ "DEPLOY_ROLES=" + quote(String.join(" ", Recipe.roles(env))) + "\n"
+ "DEPENDENCY_SERVICES=" + quote(env.getOrDefault("DEPENDENCY_SERVICES", "")) + "\n"
+ "TARGET_PLATFORM=" + quote(env.getOrDefault("TARGET_PLATFORM", "linux/amd64")) + "\n"
+ "REQUIRED_SECRETS=" + quote(env.getOrDefault("REQUIRED_DEPLOY_SECRETS", "")) + "\n"
+ Files.readString(root.resolve("deploy/health.sh")) + "\n"
+ Files.readString(root.resolve("generated/deploy/remote.sh"));
credentials = Files.createTempDirectory("engineering-deploy-ssh-");
try {
writePrivate("identity", Setup.required(env, "DEPLOY_SSH_KEY") + "\n");
writePrivate("known_hosts", Setup.required(env, "DEPLOY_KNOWN_HOSTS") + "\n");
} catch (Exception e) {
close();
throw e;
}
}
void writePrivate(String name, String value) throws Exception {
Path file = credentials.resolve(name);
Files.createFile(file, PosixFilePermissions.asFileAttribute(PosixFilePermissions.fromString("rw-------")));
Files.writeString(file, value);
}
static String quote(String value) {
return "'" + value.replace("'", "'\"'\"'") + "'";
}
String call(String action, String input, String... arguments) throws Exception {
int seconds = switch (action) {
case "apply" -> 300;
case "prepare", "ensure" -> 600;
default -> 60;
};
var parameters = new ArrayList<>(List.of(action, directory, owner));
parameters.addAll(List.of(arguments));
return executeScript(script, input, seconds, parameters);
}
String executeScript(String source, String input, int seconds, List<String> parameters) throws Exception {
String remote = "timeout -k 5 " + seconds + " sh -c " + quote(source) + " --";
for (String argument : parameters) remote += " " + quote(argument);
var args = new ArrayList<>(List.of(
"ssh",
"-F",
"/dev/null",
"-T",
"-o",
"BatchMode=yes",
"-o",
"StrictHostKeyChecking=yes",
"-o",
"IdentitiesOnly=yes",
"-o",
"IdentityAgent=none",
"-o",
"ForwardAgent=no",
"-o",
"ConnectTimeout=10",
"-o",
"ServerAliveInterval=10",
"-o",
"ServerAliveCountMax=3",
"-o",
"LogLevel=ERROR",
"-o",
"UserKnownHostsFile=" + credentials.resolve("known_hosts"),
"-o",
"GlobalKnownHostsFile=/dev/null",
"-i",
credentials.resolve("identity").toString(),
"-p",
port,
user + "@" + host,
remote));
commands.renew(Duration.ofSeconds(seconds + 30));
return commands.execute(args, root, env, input, false).checked();
}
void acquire() throws Exception {
call("lock", null);
}
void release() throws Exception {
call("unlock", null);
}
@Override
public void close() throws Exception {
Pipeline.deleteTree(credentials);
}
}

View File

@@ -0,0 +1,105 @@
import java.net.URI;
import java.nio.file.Files;
import java.nio.file.Path;
import java.time.Duration;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
/** One-time provisioning; secrets stay in owner-only files or Gitea secret storage. */
class ReleaseSetup {
static void configure(Path root, Map<String, String> input) throws Exception {
var env = new HashMap<>(input);
env.putIfAbsent("DEPLOY_SSH_PORT", "22");
Path config = Setup.home().resolve(".config/" + Recipe.project(env) + "-runner");
env.putIfAbsent("GITEA_ADMIN_TOKEN_FILE", config.resolve("admin-token").toString());
env.putIfAbsent("RELEASE_TOKEN_FILE", config.resolve("release-token").toString());
env.putIfAbsent("PULL_TOKEN_FILE", config.resolve("pull-token").toString());
env.putIfAbsent("DEPLOY_SSH_KEY_FILE", config.resolve("deploy-key").toString());
String admin = Setup.secret(env, "GITEA_ADMIN_TOKEN_FILE");
String pull = Setup.secret(env, "PULL_TOKEN_FILE");
env.put("RELEASE_TOKEN", Setup.secret(env, "RELEASE_TOKEN_FILE"));
env.put("DEPLOY_SSH_KEY", Setup.secret(env, "DEPLOY_SSH_KEY_FILE"));
String bind = env.get("DEPLOY_BIND_IP"), port = env.get("DEPLOY_FRONTEND_PORT");
Pipeline.check(
bind.matches("[0-9.]+")
&& port.matches("[0-9]{4,5}")
&& Integer.parseInt(port) >= 1024
&& Integer.parseInt(port) <= 65535,
"Invalid private bind address or port");
var commands = new Commands(Duration.ofMinutes(15));
Path diagnostics = root.resolve("ci/artifacts/deployment");
Files.createDirectories(diagnostics);
commands.log = diagnostics.resolve("setup.log");
Files.writeString(commands.log, "");
String host = env.get("DEPLOY_HOST");
String lookup = env.get("DEPLOY_SSH_PORT").equals("22") ? host : "[" + host + "]:" + env.get("DEPLOY_SSH_PORT");
env.put(
"DEPLOY_KNOWN_HOSTS",
commands.execute(
List.of(
"ssh-keygen",
"-F",
lookup,
"-f",
Setup.home().resolve(".ssh/known_hosts").toString()),
root,
env,
null,
false)
.checked());
Pipeline.check(!env.get("DEPLOY_KNOWN_HOSTS").isBlank(), "A previously trusted NAS hostkey is required");
var api = new ReleaseApi(env);
api.verified(api.head());
var uri = URI.create(api.server);
String registry = env.get("DEPLOY_REGISTRY");
int registryPort = uri.getPort() >= 0 ? uri.getPort() : uri.getScheme().equals("https") ? 443 : 80;
Pipeline.check(
registry.equals(uri.getRawAuthority()) || registry.equals("127.0.0.1:" + registryPort),
"Deployment registry must be the Gitea registry or its NAS loopback alias");
String registryUser =
env.getOrDefault("PULL_USER", env.get("CI_REPOSITORY").split("/")[0]);
Pipeline.check(registryUser.matches("[a-zA-Z0-9_.-]+"), "Invalid registry user");
try (var remote = new ReleaseRemote(root, env, commands, "setup")) {
System.out.println(remote.executeScript(
Files.readString(root.resolve("generated/deploy/provision.sh")),
pull + "\n",
300,
List.of(
remote.directory,
registryUser,
registry,
bind,
port,
Setup.required(env, "DEPLOY_DOCKER_BIN"))));
remote.acquire();
try {
remote.call("preflight", null);
} finally {
remote.release();
}
}
// Provision credentials only after successful NAS login and release-read verification.
for (String secret : List.of("RELEASE_TOKEN", "DEPLOY_SSH_KEY", "DEPLOY_KNOWN_HOSTS"))
Setup.api(
api.server, admin, "PUT", api.base + "/actions/secrets/" + secret, Map.of("data", env.get(secret)));
for (String variable :
List.of("DEPLOY_HOST", "DEPLOY_USER", "DEPLOY_SSH_PORT", "DEPLOY_DIRECTORY", "DEPLOY_REGISTRY"))
variable(api, admin, variable, env.get(variable));
System.out.println(
"Production configuration and Gitea deployment secrets/variables installed; no release was created.");
}
static void variable(ReleaseApi api, String admin, String name, String value) throws Exception {
String path = api.base + "/actions/variables/" + name;
boolean exists;
try {
Setup.api(api.server, admin, "GET", path, null);
exists = true;
} catch (Setup.ApiFailure error) {
if (error.status != 404) throw error;
exists = false;
}
Setup.api(api.server, admin, exists ? "PUT" : "POST", path, Map.of("value", value));
}
}

View File

@@ -0,0 +1,244 @@
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.attribute.PosixFilePermissions;
import java.time.Duration;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
/** Runs the actual NAS script with disposable command adapters, no Docker daemon. */
class ReleaseShellTests {
static void run() throws Exception {
ReleaseTests.test("actual NAS script prepares digest images and checks the published endpoint", () -> {
try (var f = new Fixture()) {
f.prepare();
f.call("pending", "{}", "one").checked();
CiTests.equal("S6_HEALTHY", f.call("apply", null, "one", "one").checked());
String calls = Files.readString(f.directory.resolve("calls"));
CiTests.equal(true, calls.contains("http://backend:8080/q/health/ready"));
CiTests.equal(true, calls.contains("http://127.0.0.1:8088/api/items"));
CiTests.equal(false, calls.contains(" down"));
CiTests.equal(false, calls.contains("prune"));
}
});
ReleaseTests.test("actual NAS script rejects a missing database password before pulling images", () -> {
try (var f = new Fixture()) {
Path config = f.directory.resolve("production.env");
Files.writeString(
config, Files.readString(config).replace("DATABASE_PASSWORD=fixture-only-password\n", ""));
f.call("lock", null, "one").checked();
Commands.Result result = f.call("preflight", null, "one");
CiTests.equal(1, result.code());
CiTests.equal(true, result.output().contains("Missing DATABASE_PASSWORD"));
CiTests.equal(false, Files.exists(f.directory.resolve("live.env")));
}
});
ReleaseTests.test("actual NAS script passes database configuration through sudo", () -> {
try (var f = new Fixture()) {
f.executable("sudo", "#!/bin/sh\n[ \"$1\" != -n ] || shift\nunset DATABASE_PASSWORD\nexec \"$@\"\n");
Path config = f.directory.resolve("production.env");
Files.writeString(config, Files.readString(config).replace("DOCKER_SUDO=0", "DOCKER_SUDO=1"));
f.prepare();
CiTests.equal(
false,
Files.readString(f.directory.resolve("releases/one/release.env"))
.contains("PASSWORD"));
}
});
ReleaseTests.test("actual NAS script rejects wrong image revision before replacement", () -> {
try (var f = new Fixture()) {
f.env.put("WRONG_REVISION", "1");
CiTests.failure(f::prepare);
CiTests.equal(false, Files.exists(f.directory.resolve("live.env")));
}
});
ReleaseTests.test("actual NAS script rejects unavailable image before replacement", () -> {
try (var f = new Fixture()) {
f.env.put("FAIL_PULL", "1");
CiTests.failure(f::prepare);
CiTests.equal(false, Files.exists(f.directory.resolve("live.env")));
}
});
ReleaseTests.test("actual NAS script rejects a changed retained release record", () -> {
try (var f = new Fixture()) {
f.prepare();
CiTests.failure(() -> f.call(
"ensure", null, "one", "one", "v9.0.0", ReleaseTests.SHA, f.backend(), f.frontend())
.checked());
f.call("ensure", null, "one", "one", "v1.0.0", ReleaseTests.SHA, f.backend(), f.frontend())
.checked();
}
});
ReleaseTests.test("actual NAS replacement timeout is bounded and leaves recovery journal", () -> {
try (var f = new Fixture()) {
f.prepare();
f.call("pending", "{}", "one").checked();
f.env.put("FAIL_HEALTH", "1");
CiTests.equal(1, f.call("apply", null, "one", "one", "1").code());
CiTests.equal(true, Files.exists(f.directory.resolve("pending.json")));
// Termination releases only the operation fence, not the deployment lock.
CiTests.equal(false, Files.exists(f.directory.resolve("deploy.lock/operation")));
CiTests.equal(true, Files.exists(f.directory.resolve("deploy.lock/owner")));
f.env.remove("FAIL_HEALTH");
f.call("apply", null, "one", "one").checked();
}
});
ReleaseTests.test("actual NAS script fences overlapping remote replacements", () -> {
try (var f = new Fixture()) {
f.prepare();
Files.createDirectory(f.directory.resolve("deploy.lock/operation"));
CiTests.failure(() -> f.call("apply", null, "one", "one").checked());
CiTests.equal(false, Files.exists(f.directory.resolve("live.env")));
}
});
ReleaseTests.test("actual NAS script stores state atomically and archives resolved journal", () -> {
try (var f = new Fixture()) {
f.call("lock", null, "one").checked();
f.call("pending", "{\"attempt\":\"one\"}", "one").checked();
f.call("commit", "{\"current\":\"one\"}", "one").checked();
CiTests.equal(
"{\"current\":\"one\"}", f.call("state", null, "one").checked());
f.call("event", "{\"outcome\":\"success\"}", "one").checked();
f.call("finish", null, "one").checked();
f.call("unlock", null, "one").checked();
CiTests.equal(true, Files.exists(f.directory.resolve("history/one.json")));
CiTests.equal(false, Files.exists(f.directory.resolve("pending.json")));
}
});
}
static class Fixture implements AutoCloseable {
final Path directory = Files.createTempDirectory("nas-script-test-");
final Map<String, String> env = new HashMap<>(System.getenv());
final String health = "application_health() {\n"
+ "frontend=$(compose ps -q frontend)\n"
+ "docker_cmd exec \"$frontend\" wget -q -T 5 -O /dev/null http://backend:8080/q/health/ready || return"
+ " 1\n"
+ "curl --fail http://127.0.0.1:8088/api/items -o /dev/null\n"
+ "}\n";
final String script = "COMPOSE_PROJECT=recipe-demo-test\n"
+ "DEPLOY_ROLES='backend frontend'\n"
+ "DEPENDENCY_SERVICES=database\n"
+ "TARGET_PLATFORM=linux/amd64\n"
+ "REQUIRED_SECRETS=DATABASE_PASSWORD\n"
+ health + "\n"
+ Files.readString(Path.of("generated/deploy/remote.sh"));
final Commands commands = new Commands(Duration.ofMinutes(1));
Fixture() throws Exception {
Files.createDirectory(directory.resolve("bin"));
executable("docker", DOCKER);
executable(
"curl",
"#!/bin/sh\nprintf 'curl %s\\n' \"$*\" >> \"$FIXTURE/calls\"\n[ \"${FAIL_HEALTH:-0}\" = 0 ]\n");
env.put("PATH", directory.resolve("bin") + ":" + env.get("PATH"));
env.put("FIXTURE", directory.toString());
Files.createDirectory(directory.resolve("docker-auth"));
Files.writeString(directory.resolve("docker-auth/config.json"), "{}");
Files.writeString(
directory.resolve("production.env"),
"DATABASE_PASSWORD=fixture-only-password\n"
+ "BIND_IP=127.0.0.1\n"
+ "FRONTEND_PORT=8088\n"
+ "DOCKER_SUDO=0\n"
+ "DOCKER_BIN="
+ directory.resolve("bin/docker") + "\nDOCKER_CONFIG=" + directory.resolve("docker-auth")
+ "\n");
}
void executable(String name, String body) throws Exception {
Path path = directory.resolve("bin").resolve(name);
Files.writeString(path, body);
Files.setPosixFilePermissions(path, PosixFilePermissions.fromString("rwx------"));
}
Commands.Result call(String action, String input, String owner, String... args) throws Exception {
var command = new ArrayList<>(
List.of("timeout", "-k", "1", "10", "sh", "-c", script, "--", action, directory.toString(), owner));
command.addAll(List.of(args));
commands.renew(Duration.ofSeconds(15));
return commands.execute(command, directory, env, input, false);
}
String backend() {
return "nas:3000/owner/recipe-demo-backend@sha256:" + "1".repeat(64);
}
String frontend() {
return "nas:3000/owner/recipe-demo-frontend@sha256:" + "2".repeat(64);
}
void prepare() throws Exception {
call("lock", null, "one").checked();
call("preflight", null, "one").checked();
call("stage", "services: {}\n", "one").checked();
call("stage-health", health, "one").checked();
String result = call("prepare", null, "one", "v1.0.0", ReleaseTests.SHA, "nas:3000/owner/recipe-demo-")
.checked();
CiTests.equal(true, result.contains("S6_BACKEND=" + backend()));
CiTests.equal(true, result.contains("S6_FRONTEND=" + frontend()));
}
@Override
public void close() throws Exception {
Pipeline.deleteTree(directory);
}
}
static final String DOCKER = """
#!/bin/sh
set -eu
printf 'docker %s\n' "$*" >> "$FIXTURE/calls"
[ "$1" != --config ] || shift 2
case "$1" in
info) echo linux/x86_64;;
pull) [ "${FAIL_PULL:-0}" = 0 ];;
image)
format=$4; image=$5
case "$image" in *backend*) role=backend; digit=1;; *) role=frontend; digit=2;; esac
case "$format" in
*revision*)
if [ "${WRONG_REVISION:-0}" = 1 ]; then echo wrong; else printf '%040d\n' 0 | tr 0 a; fi;;
*Architecture*) echo linux/amd64;;
*RepoDigests*)
printf '%s@sha256:' "${image%:*}"; printf '%064d\n' 0 | tr 0 "$digit";;
*Id*) echo "sha256:$role";;
*) exit 1;;
esac;;
compose)
shift
[ "$1" != version ] || exit 0
shift 4
[ "$1" = --env-file ] || exit 1
production=$2
shift 2
[ "$1" = --env-file ] || exit 1
file=$2
shift 4
# Compose must read the secret from disk, even after sudo clears the environment.
test -f "$production"
. "$production"
[ "$DATABASE_PASSWORD" = fixture-only-password ] || exit 15
case "$1" in
config) test -f "$file";;
up) cp "$file" "$FIXTURE/live.env";;
ps) eval 'role=${'"$#"'}'; echo "$role";;
logs) echo 'sample container diagnostics';;
*) exit 1;;
esac;;
inspect)
. "$FIXTURE/live.env"
format=$3; id=$4
case "$format" in
*State.Running*) echo true;;
*Image*) echo "sha256:$id";;
*revision*) echo "$RELEASE_SHA";;
*release*) echo "$RELEASE_VERSION";;
*) exit 1;;
esac;;
exec) [ "${FAIL_HEALTH:-0}" = 0 ];;
*) exit 1;;
esac
""";
}

View File

@@ -0,0 +1,83 @@
import java.util.LinkedHashMap;
import java.util.Map;
import java.util.Objects;
/** Successful deployment state is separate from immutable allocated version tags. */
record ReleaseState(ReleaseState.Deployment current, ReleaseState.Deployment previous, ReleaseVersion highest) {
record Deployment(
ReleaseVersion version, String sha, String backend, String frontend, String directory, String build) {
Deployment {
ReleaseApi.sha(sha);
Pipeline.check(directory.matches("[a-zA-Z0-9-]+"), "Invalid retained release directory");
Pipeline.check(!backend.equals("-") || !frontend.equals("-"), "A release must contain an image");
for (String image : new String[] {backend, frontend})
Pipeline.check(
(image.equals("-") || image.matches("[a-zA-Z0-9.:/_-]+@sha256:[0-9a-f]{64}")),
"Invalid retained image digest");
}
Map<String, Object> json() {
return Map.of(
"version",
version.toString(),
"sha",
sha,
"backend",
backend,
"frontend",
frontend,
"directory",
directory,
"build",
build);
}
static Deployment parse(Object value) {
if (value == null) return null;
var data = Json.object(value);
return new Deployment(
ReleaseVersion.parse(text(data, "version")),
text(data, "sha"),
text(data, "backend"),
text(data, "frontend"),
text(data, "directory"),
text(data, "build"));
}
}
static String text(Map<String, Object> data, String key) {
return Objects.toString(data.get(key), "");
}
static ReleaseState parse(String value) {
var data = Json.object(Json.parse(value));
if (data.isEmpty()) return new ReleaseState(null, null, null);
Pipeline.check("1".equals(text(data, "schema")), "Unsupported deployment state schema");
Deployment current = Deployment.parse(data.get("current"));
Deployment previous = Deployment.parse(data.get("previous"));
ReleaseVersion highest = ReleaseVersion.parse(text(data, "highest"));
Pipeline.check(
current != null
&& highest.compareTo(current.version()) >= 0
&& (previous == null || highest.compareTo(previous.version()) >= 0),
"Inconsistent successful release state");
return new ReleaseState(current, previous, highest);
}
String json() {
var data = new LinkedHashMap<String, Object>();
data.put("schema", "1");
data.put("current", current == null ? null : current.json());
data.put("previous", previous == null ? null : previous.json());
data.put("highest", highest == null ? null : highest.toString());
return Json.write(data) + "\n";
}
ReleaseState success(Deployment selected) {
ReleaseVersion watermark =
highest == null || selected.version().compareTo(highest) > 0 ? selected.version() : highest;
// A redeploy of the same release must not discard the distinct rollback target.
Deployment fallback = current != null && current.version().equals(selected.version()) ? previous : current;
return new ReleaseState(selected, fallback, watermark);
}
}

View File

@@ -0,0 +1,555 @@
import java.nio.file.Files;
import java.nio.file.Path;
import java.time.Duration;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
/** Release state-machine and real POSIX remote-script tests, without server writes. */
class ReleaseTests {
static final String SHA = "a".repeat(40), OLD = "b".repeat(40);
static int count;
static void test(String name, CiTests.Test test) throws Exception {
test.run();
count++;
System.out.println("PASS release: " + name);
}
static Map<String, String> environment() {
var env = new HashMap<>(System.getenv());
env.putAll(Map.of(
"CI_REF",
"refs/heads/main",
"CI_EVENT",
"workflow_dispatch",
"CI_RUN_ID",
"123",
"CI_COMMIT",
SHA,
"RELEASE_MODE",
"new",
"RELEASE_INCREMENT",
"patch",
"RELEASE_VERSION",
"",
"GITEA_SERVER_URL",
"http://nas:3000",
"CI_REPOSITORY",
"owner/recipe-demo",
"RELEASE_TOKEN",
"test"));
env.putAll(Map.of(
"DEPLOY_HOST",
"nas",
"DEPLOY_USER",
"deploy",
"DEPLOY_DIRECTORY",
"/tmp/production",
"DEPLOY_SSH_KEY",
"test-private-key",
"DEPLOY_KNOWN_HOSTS",
"test-pinned-host"));
return env;
}
static ReleaseState.Deployment deployment(String version, String sha, String directory) {
return new ReleaseState.Deployment(
ReleaseVersion.parse(version),
sha,
"nas:3000/owner/recipe-demo-backend@sha256:" + "1".repeat(64),
"nas:3000/owner/recipe-demo-frontend@sha256:" + "2".repeat(64),
directory,
"10");
}
static ReleaseState prior() {
return new ReleaseState(
deployment("v1.0.1", OLD, "previous-current"),
deployment("v1.0.0", OLD, "previous-old"),
ReleaseVersion.parse("v1.0.1"));
}
static void run() throws Exception {
count = 0;
test("first release and all increments reset the correct components", () -> {
CiTests.equal("v1.0.0", ReleaseVersion.next(List.of(), "major").toString());
var versions = List.of(ReleaseVersion.parse("v1.2.3"), ReleaseVersion.parse("v1.1.9"));
CiTests.equal("v1.2.4", ReleaseVersion.next(versions, "patch").toString());
CiTests.equal("v1.3.0", ReleaseVersion.next(versions, "minor").toString());
CiTests.equal("v2.0.0", ReleaseVersion.next(versions, "major").toString());
CiTests.equal(
"v1.0.100000000000000000000",
ReleaseVersion.next(List.of(ReleaseVersion.parse("v1.0.99999999999999999999")), "patch")
.toString());
for (String invalid : List.of("1.0.0", "v01.0.0", "v1.0.0-rc1", "v1.0.0+build", "v1.0", "v-1.0.0"))
CiTests.failure(() -> ReleaseVersion.parse(invalid));
});
test("branch, nonmanual, malformed input and conflicting new-release inputs are rejected", () -> {
var env = environment();
Release.validate(env, "deploy");
env.put("CI_REF", "refs/heads/feature/S6");
CiTests.failure(() -> Release.validate(env, "deploy"));
env.put("CI_REF", "refs/heads/main");
env.put("CI_EVENT", "push");
CiTests.failure(() -> Release.validate(env, "rollback"));
env.put("CI_EVENT", "workflow_dispatch");
env.put("RELEASE_VERSION", "v1.0.0");
CiTests.failure(() -> Release.validate(env, "deploy"));
env.put("RELEASE_MODE", "retry");
Release.validate(env, "deploy");
env.put("RELEASE_VERSION", "bad");
CiTests.failure(() -> Release.validate(env, "deploy"));
});
test("successful release stages images before tag, checks main again and commits state", () -> {
try (var f = new Fixture()) {
f.release.run();
CiTests.equal("v1.0.0", f.remote.state.current().version().toString());
CiTests.equal(false, f.remote.locked);
CiTests.equal(false, f.remote.pending);
CiTests.equal(true, f.events.indexOf("prepare") < f.events.indexOf("tag"));
CiTests.equal(true, f.events.indexOf("tag") < f.events.lastIndexOf("head"));
CiTests.equal(true, f.events.lastIndexOf("head") < f.events.indexOf("apply:attempt"));
}
});
test("unverified commit never creates a tag or replaces production", () -> {
try (var f = new Fixture()) {
f.api.verified = false;
CiTests.failure(f.release::run);
CiTests.equal(false, f.events.contains("tag"));
CiTests.equal(false, f.events.contains("apply:attempt"));
CiTests.equal(false, f.remote.locked);
}
});
test("missing images prevent tag allocation and replacement", () -> {
try (var f = new Fixture()) {
f.remote.failAction = "prepare";
CiTests.failure(f.release::run);
CiTests.equal(false, f.events.contains("tag"));
CiTests.equal(false, f.remote.pending);
}
});
test("a tagged commit cannot receive another new version", () -> {
try (var f = new Fixture()) {
f.api.tags.add(new ReleaseApi.Tag(ReleaseVersion.parse("v1.0.0"), SHA, OLD));
CiTests.failure(f.release::run);
CiTests.equal(false, f.events.contains("prepare"));
}
});
test("stale dispatch is rejected before pulling or tagging", () -> {
try (var f = new Fixture()) {
f.api.head = OLD;
CiTests.failure(f.release::run);
CiTests.equal(false, f.events.contains("prepare"));
}
});
test("main advancing after tag creation preserves tag without changing production", () -> {
try (var f = new Fixture()) {
f.api.advanceAfterTag = true;
CiTests.failure(f.release::run);
CiTests.equal(1, f.api.tags.size());
CiTests.equal(false, f.remote.pending);
CiTests.equal(false, f.events.contains("apply:attempt"));
}
});
test("unavailable recovery images block replacement", () -> {
try (var f = new Fixture()) {
f.withPrior();
f.remote.failAction = "ensure";
CiTests.failure(f.release::run);
CiTests.equal(false, f.events.contains("tag"));
CiTests.equal(false, f.remote.pending);
}
});
test("failed candidate restores prior version and still fails the workflow", () -> {
try (var f = new Fixture()) {
f.withPrior();
f.remote.failedDirectories.add("attempt");
CiTests.failure(f.release::run);
CiTests.equal(
List.of("apply:attempt", "apply:previous-current"),
f.events.stream().filter(e -> e.startsWith("apply:")).toList());
CiTests.equal(prior(), f.remote.state);
CiTests.equal(false, f.remote.pending);
CiTests.equal(false, f.remote.locked);
CiTests.equal(2, f.api.tags.size());
}
});
test("failed first release retains journal and lock for manual recovery", () -> {
try (var f = new Fixture()) {
f.remote.failedDirectories.add("attempt");
CiTests.failure(f.release::run);
CiTests.equal(true, f.remote.pending);
CiTests.equal(true, f.remote.locked);
CiTests.equal(null, f.remote.state.current());
}
});
test("failed automatic recovery retains journal and lock without retry loop", () -> {
try (var f = new Fixture()) {
f.withPrior();
f.remote.failedDirectories.addAll(List.of("attempt", "previous-current"));
CiTests.failure(f.release::run);
CiTests.equal(
2L,
f.events.stream().filter(e -> e.startsWith("apply:")).count());
CiTests.equal(true, f.remote.pending);
CiTests.equal(true, f.remote.locked);
}
});
test("ambiguous state commit never launches speculative rollback", () -> {
try (var f = new Fixture()) {
f.withPrior();
f.remote.failAction = "commit";
CiTests.failure(f.release::run);
CiTests.equal(
1L,
f.events.stream().filter(e -> e.startsWith("apply:")).count());
CiTests.equal(true, f.remote.pending);
CiTests.equal(true, f.remote.locked);
}
});
test("retry keeps existing version and SHA after main advances", () -> {
try (var f = new Fixture("retry")) {
f.api.head = OLD;
f.release.run();
CiTests.equal(SHA, f.remote.state.current().sha());
CiTests.equal("v1.0.0", f.remote.state.current().version().toString());
CiTests.equal(false, f.events.contains("tag"));
CiTests.equal(true, f.events.contains("annotated"));
}
});
test("retry cannot cross the successful-version high watermark after rollback", () -> {
try (var f = new Fixture("retry")) {
f.remote.state = prior().success(prior().previous());
CiTests.failure(f.release::run);
CiTests.equal(false, f.events.contains("apply:attempt"));
}
});
test("manual rollback preserves version baseline and creates no tag", () -> {
try (var f = new Fixture("rollback")) {
f.remote.state = prior();
f.release.run();
CiTests.equal("v1.0.0", f.remote.state.current().version().toString());
CiTests.equal("v1.0.1", f.remote.state.previous().version().toString());
CiTests.equal("v1.0.1", f.remote.state.highest().toString());
CiTests.equal(false, f.events.contains("tag"));
}
});
test("redeploying current version preserves a distinct rollback target", () -> {
var state = prior();
CiTests.equal(state.previous(), state.success(state.current()).previous());
CiTests.equal(state, ReleaseState.parse(state.json()));
});
test("NAS lock contention cannot allocate tags or replace production", () -> {
try (var f = new Fixture()) {
f.remote.locked = true;
CiTests.failure(f.release::run);
CiTests.equal(false, f.events.contains("tag"));
CiTests.equal(true, f.remote.locked);
}
});
apiTests();
shellTests();
ReleaseShellTests.run();
System.out.println(count + " release behavior tests passed");
}
static class FakeApi extends ReleaseApi {
final List<String> events;
final List<Tag> tags = new ArrayList<>();
String head = SHA;
boolean verified = true, advanceAfterTag;
FakeApi(Map<String, String> env, List<String> events) {
super(env);
this.events = events;
}
@Override
String head() {
events.add("head");
return head;
}
@Override
List<Tag> tags() {
return List.copyOf(tags);
}
@Override
String verified(String sha) {
Pipeline.check(verified, "Unverified test commit");
events.add("verified");
return "10";
}
@Override
void annotated(Tag tag) {
events.add("annotated");
}
@Override
void create(ReleaseVersion version, String sha, String attempt) {
events.add("tag");
tags.add(new Tag(version, sha, OLD));
if (advanceAfterTag) head = OLD;
}
}
static class FakeRemote extends ReleaseRemote {
final List<String> events;
final List<String> failedDirectories = new ArrayList<>();
ReleaseState state = new ReleaseState(null, null, null);
boolean locked, pending;
String failAction = "";
FakeRemote(Path root, Map<String, String> env, List<String> events) throws Exception {
super(root, env, new Commands(Duration.ofMinutes(1)), "attempt");
this.events = events;
}
@Override
String call(String action, String input, String... args) {
events.add(action.equals("apply") ? action + ":" + args[0] : action);
Pipeline.check(!action.equals(failAction), "Injected remote failure");
switch (action) {
case "lock" -> {
Pipeline.check(!locked && !pending, "Locked");
locked = true;
}
case "unlock" -> {
Pipeline.check(!pending, "Pending");
locked = false;
}
case "state" -> {
return state.current() == null ? "{}" : state.json();
}
case "pending" -> pending = true;
case "finish" -> pending = false;
case "commit" -> state = ReleaseState.parse(input);
case "prepare" -> {
return "S6_BACKEND=" + deployment("v1.0.0", SHA, "attempt").backend() + "\nS6_FRONTEND="
+ deployment("v1.0.0", SHA, "attempt").frontend();
}
case "apply" -> Pipeline.check(!failedDirectories.contains(args[0]), "Injected unhealthy release");
default -> {}
}
return "";
}
}
static class Fixture implements AutoCloseable {
final Path root = Files.createTempDirectory("release-test-");
final Map<String, String> env = environment();
final List<String> events = new ArrayList<>();
final FakeApi api;
final FakeRemote remote;
final Release release;
Fixture() throws Exception {
this("new");
}
Fixture(String mode) throws Exception {
Files.createDirectories(root.resolve("deploy"));
Files.createDirectories(root.resolve("generated/deploy"));
env.put("PROJECT_NAME", "recipe-demo");
env.put("BACKEND_MODULE", "backend");
env.put("FRONTEND_MODULE", "frontend");
env.put("COMPOSE_PROJECT", "recipe-demo-test");
Files.writeString(root.resolve("generated/deploy/remote.sh"), "");
Files.writeString(root.resolve("deploy/compose.yaml"), "services: {}\n");
Files.writeString(root.resolve("deploy/health.sh"), "application_health() { true; }\n");
env.put("RELEASE_MODE", mode);
if (mode.equals("retry")) env.put("RELEASE_VERSION", "v1.0.0");
api = new FakeApi(env, events);
if (mode.equals("retry")) api.tags.add(new ReleaseApi.Tag(ReleaseVersion.parse("v1.0.0"), SHA, OLD));
remote = new FakeRemote(root, env, events);
release = new Release(root, env, api, remote, mode.equals("rollback") ? "rollback" : "deploy", "attempt");
}
void withPrior() {
remote.state = prior();
api.tags.add(new ReleaseApi.Tag(ReleaseVersion.parse("v1.0.1"), OLD, OLD));
}
@Override
public void close() throws Exception {
remote.close();
Pipeline.deleteTree(root);
}
}
static void apiTests() throws Exception {
test("NAS registry alias keeps the same repository and rejects unrelated registries", () -> {
try (var f = new Fixture()) {
f.env.put("DEPLOY_REGISTRY", "127.0.0.1:3000");
CiTests.equal("127.0.0.1:3000/owner/recipe-demo-", f.release.prefix());
f.env.put("DEPLOY_REGISTRY", "other-registry:3000");
CiTests.failure(f.release::prefix);
}
});
test("repeating remote tag pages fail closed instead of allocating from incomplete history", () -> {
var api = new ReleaseApi(environment()) {
@Override
Object request(String method, String path, Object body) {
return List.of(Map.of("name", "v1.0.0", "id", OLD, "commit", Map.of("sha", SHA)));
}
};
CiTests.failure(api::tags);
});
test("annotated release tag must identify the recorded version and exact SHA", () -> {
var tag = new ReleaseApi.Tag(ReleaseVersion.parse("v1.0.0"), SHA, OLD);
var api = new ReleaseApi(environment()) {
@Override
Object request(String method, String path, Object body) {
return Map.of("tag", "v1.0.0", "object", Map.of("sha", SHA));
}
};
api.annotated(tag);
var wrong = new ReleaseApi(environment()) {
@Override
Object request(String method, String path, Object body) {
return Map.of("tag", "v1.0.0", "object", Map.of("sha", OLD));
}
};
CiTests.failure(() -> wrong.annotated(tag));
});
test("Gitea API eligibility validates SHA, event, branch, workflow path and successful conclusion", () -> {
var valid = Map.<String, Object>of(
"id",
1,
"head_sha",
SHA,
"event",
"push",
"head_branch",
"main",
"path",
"build.yaml@refs/heads/main",
"status",
"completed",
"conclusion",
"success");
for (String field : List.of("head_sha", "event", "head_branch", "path", "status", "conclusion")) {
var invalid = new HashMap<>(valid);
invalid.put(field, "wrong");
var api = new ReleaseApi(environment()) {
@Override
Object request(String method, String path, Object body) {
return Map.of("workflow_runs", List.of(invalid));
}
};
CiTests.failure(() -> api.verified(SHA));
}
var api = new ReleaseApi(environment()) {
@Override
Object request(String method, String path, Object body) {
return Map.of("workflow_runs", List.of(valid));
}
};
CiTests.equal("1", api.verified(SHA));
});
test("remote tag pagination includes failed-release baseline and rejects ambiguous tag formats", () -> {
var api = new ReleaseApi(environment()) {
@Override
Object request(String method, String path, Object body) {
return path.endsWith("page=1")
? List.of(Map.of("name", "v1.2.3", "id", OLD, "commit", Map.of("sha", SHA)))
: List.of();
}
};
CiTests.equal(
"v1.2.4",
ReleaseVersion.next(
api.tags().stream()
.map(ReleaseApi.Tag::version)
.toList(),
"patch")
.toString());
var invalid = new ReleaseApi(environment()) {
@Override
Object request(String method, String path, Object body) {
return List.of(Map.of("name", "1.0", "id", OLD, "commit", Map.of("sha", SHA)));
}
};
CiTests.failure(invalid::tags);
});
}
static void shellTests() throws Exception {
test("real remote shell atomically excludes a second owner and retains unresolved journal", () -> {
Path directory = Files.createTempDirectory("release-shell-test-");
try {
String script = Files.readString(Path.of("generated/deploy/remote.sh"));
var commands = new Commands(Duration.ofSeconds(30));
var env = environment();
var first = commands.execute(
List.of("sh", "-c", script, "--", "lock", directory.toString(), "one"),
directory,
env,
null,
false);
first.checked();
CiTests.equal(
1,
commands.execute(
List.of("sh", "-c", script, "--", "lock", directory.toString(), "two"),
directory,
env,
null,
false)
.code());
commands.execute(
List.of("sh", "-c", script, "--", "pending", directory.toString(), "one"),
directory,
env,
"{\"candidate\":\"test\"}",
false)
.checked();
CiTests.equal(
1,
commands.execute(
List.of("sh", "-c", script, "--", "unlock", directory.toString(), "one"),
directory,
env,
null,
false)
.code());
CiTests.equal(true, Files.exists(directory.resolve("pending.json")));
commands.execute(
List.of("sh", "-c", script, "--", "finish", directory.toString(), "one"),
directory,
env,
null,
false)
.checked();
commands.execute(
List.of("sh", "-c", script, "--", "unlock", directory.toString(), "one"),
directory,
env,
null,
false)
.checked();
CiTests.equal(false, Files.exists(directory.resolve("deploy.lock")));
CiTests.equal(true, Files.exists(directory.resolve("history/one.json")));
} finally {
Pipeline.deleteTree(directory);
}
});
test("SSH shell quoting preserves metacharacters as data", () -> {
String value = "quote' dollar$ backtick` newline\n";
var commands = new Commands(Duration.ofSeconds(5));
CiTests.equal(
value,
commands.execute(
List.of("sh", "-c", "printf '%s' " + ReleaseRemote.quote(value)),
Path.of("."),
environment(),
null,
false)
.output());
});
}
}

View File

@@ -0,0 +1,39 @@
import java.math.BigInteger;
import java.util.List;
import java.util.regex.Pattern;
/** Strict stable release versions; arbitrary-size integers avoid overflow/reuse. */
record ReleaseVersion(BigInteger major, BigInteger minor, BigInteger patch) implements Comparable<ReleaseVersion> {
private static final Pattern FORMAT = Pattern.compile("v(0|[1-9][0-9]*)\\.(0|[1-9][0-9]*)\\.(0|[1-9][0-9]*)");
static ReleaseVersion parse(String value) {
var match = FORMAT.matcher(value);
Pipeline.check(match.matches(), "Expected release version vMAJOR.MINOR.PATCH");
return new ReleaseVersion(
new BigInteger(match.group(1)), new BigInteger(match.group(2)), new BigInteger(match.group(3)));
}
static ReleaseVersion next(List<ReleaseVersion> versions, String increment) {
Pipeline.check(List.of("patch", "minor", "major").contains(increment), "Choose patch, minor or major");
var highest = versions.stream().max(ReleaseVersion::compareTo);
if (highest.isEmpty()) return parse("v1.0.0");
var current = highest.get();
return switch (increment) {
case "major" -> new ReleaseVersion(current.major.add(BigInteger.ONE), BigInteger.ZERO, BigInteger.ZERO);
case "minor" -> new ReleaseVersion(current.major, current.minor.add(BigInteger.ONE), BigInteger.ZERO);
default -> new ReleaseVersion(current.major, current.minor, current.patch.add(BigInteger.ONE));
};
}
@Override
public int compareTo(ReleaseVersion other) {
int value = major.compareTo(other.major);
if (value == 0) value = minor.compareTo(other.minor);
return value == 0 ? patch.compareTo(other.patch) : value;
}
@Override
public String toString() {
return "v" + major + "." + minor + "." + patch;
}
}

287
resources/ci/Setup.java Normal file
View File

@@ -0,0 +1,287 @@
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 = required(env, "GITEA_SERVER_URL"), base = "/api/v1/repos/" + required(env, "CI_REPOSITORY");
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/" + Recipe.project(env) + "-runner");
privateDirectory(directory);
privateWrite(directory.resolve("registration-token"), Objects.toString(response.get("token")));
System.out.println("Actions, registry secrets and platform configured; runner token saved outside Git.");
}
static void configureDocker(Path file, String registry) 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(registry)) {
System.out.println("Internal registry already configured; no change");
return;
}
registries.add(registry);
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-engineering");
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/" + Recipe.project(env) + "-runner"),
configDir = home().resolve(".config/" + Recipe.project(env) + "-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/runner.service"),
units.resolve(Recipe.project(env) + "-runner.service"),
StandardCopyOption.REPLACE_EXISTING);
Files.copy(
root.resolve("generated/ci/runner/WaitForDocker.java"),
runnerDir.resolve("WaitForDocker.java"),
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");
boolean desktop = dockerHost.endsWith("/.docker/desktop/docker.sock");
Path overrides = units.resolve(Recipe.project(env) + "-runner.service.d");
Path desktopOverride = overrides.resolve("10-docker-desktop.conf");
if (desktop) {
Files.createDirectories(overrides);
Files.copy(
root.resolve("ci/runner/docker-desktop.conf"),
desktopOverride,
StandardCopyOption.REPLACE_EXISTING);
} else {
Files.deleteIfExists(desktopOverride);
}
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",
required(env, "GITEA_SERVER_URL"),
"--name",
required(env, "RUNNER_LABEL"),
"--labels",
required(env, "RUNNER_LABEL") + ":host",
"--token",
token),
runnerDir,
env,
null,
true)
.checked();
Files.setPosixFilePermissions(runnerDir.resolve(".runner"), PosixFilePermissions.fromString("rw-------"));
}
if (desktop) {
commands.execute(
List.of("systemctl", "--user", "enable", "--now", "docker-desktop.service"),
root,
env,
null,
true)
.checked();
// Replace any previous default.target link with the desktop-login target.
commands.execute(
List.of("systemctl", "--user", "disable", Recipe.project(env) + "-runner.service"),
root,
env,
null,
true)
.checked();
}
commands.execute(
List.of("systemctl", "--user", "enable", "--now", Recipe.project(env) + "-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

@@ -0,0 +1,35 @@
import java.time.Duration;
import java.util.concurrent.TimeUnit;
/** Source-launcher startup probe; uses the service's configured DOCKER_HOST. */
class WaitForDocker {
public static void main(String[] args) throws Exception {
long seconds = args.length == 0 ? 120 : Long.parseLong(args[0]);
if (seconds < 1 || seconds > 300) throw new IllegalArgumentException("Timeout must be 1–300 seconds");
long deadline = System.nanoTime() + Duration.ofSeconds(seconds).toNanos();
System.out.println("Waiting for the configured Docker engine...");
while (System.nanoTime() < deadline) {
Process process = new ProcessBuilder("docker", "info", "--format", "{{.ServerVersion}}")
.redirectOutput(ProcessBuilder.Redirect.DISCARD)
.redirectError(ProcessBuilder.Redirect.DISCARD)
.start();
try {
long remaining = Math.max(1, deadline - System.nanoTime());
if (process.waitFor(Math.min(remaining, TimeUnit.SECONDS.toNanos(5)), TimeUnit.NANOSECONDS)
&& process.exitValue() == 0) {
System.out.println("Docker is available; starting the Gitea runner.");
return;
}
} finally {
if (process.isAlive()) {
process.destroyForcibly();
process.waitFor();
}
}
long remaining = deadline - System.nanoTime();
if (remaining > 0) TimeUnit.NANOSECONDS.sleep(Math.min(remaining, TimeUnit.SECONDS.toNanos(2)));
}
System.err.println("Docker did not become available within " + seconds + " seconds; runner not started.");
System.exit(1);
}
}