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 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 tags() throws Exception { List tags = new ArrayList<>(); var seen = new java.util.HashSet(); 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(); 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"); } }