ReleaseRecord.java
package network.ike.workspace;
import java.util.Collections;
import java.util.LinkedHashMap;
import java.util.Map;
/**
* One release cycle of a working set — the typed model behind
* {@code releases/release-<cycle>.yaml} in the workspace root
* (IKE-Network/ike-issues#973; shape settled 2026-08-10: one file per
* cycle, one row per released member, finalized by the cycle's
* workspace-root release).
*
* <p>{@code ws:record-release} appends a {@link MemberRelease} row each
* time a member releases; together with the member's manifest state
* transition (see
* {@link ManifestWriter#recordReleaseAlignment(String, String, String, String)})
* the two writes are the same manifest evolution: the record says what
* released, the state entry makes alignment and the release cascade obey
* it.
*
* <p>Instances are immutable; {@link #withMember(String, MemberRelease)}
* returns an updated copy. Member insertion order is preserved — the
* record file reads in release order.
*
* @param cycle the cycle label, e.g. {@code komet-wsr-1}; never blank
* @param started when the cycle opened (opaque caller-supplied text,
* typically an ISO date); null when unknown
* @param members released members in release order, keyed by subproject
* name; never null, possibly empty
*/
public record ReleaseRecord(
String cycle,
String started,
Map<String, MemberRelease> members) {
/**
* One member's release within a cycle.
*
* @param version the released (de-qualified) version, e.g.
* {@code 3.0.7}; never blank
* @param tag the git release tag, e.g. {@code v3.0.7}; never blank
* @param sha the commit the tag points at; never blank
* @param recorded when the row was written (opaque caller-supplied
* text, typically an ISO date); null when unknown
*/
public record MemberRelease(String version, String tag, String sha,
String recorded) {
/**
* Validate the row: version, tag, and sha are required.
*
* @throws ManifestException if version, tag, or sha is null or blank
*/
public MemberRelease {
requireNonBlank(version, "version");
requireNonBlank(tag, "tag");
requireNonBlank(sha, "sha");
}
}
/**
* Validate and defensively copy: the cycle label is required and the
* member map is copied into an unmodifiable insertion-ordered map.
*
* @throws ManifestException if {@code cycle} is null or blank, or
* {@code members} is null
*/
public ReleaseRecord {
requireNonBlank(cycle, "cycle");
if (members == null) {
throw new ManifestException("members must not be null");
}
members = Collections.unmodifiableMap(new LinkedHashMap<>(members));
}
/**
* Open a new, empty cycle record.
*
* @param cycle the cycle label, e.g. {@code komet-wsr-1}
* @param started when the cycle opened (opaque caller-supplied text);
* may be null
* @return an empty record for the cycle
* @throws ManifestException if {@code cycle} is null or blank
*/
public static ReleaseRecord start(String cycle, String started) {
return new ReleaseRecord(cycle, started, new LinkedHashMap<>());
}
/**
* Return a copy of this record with the given member's row added, or
* replaced when the member already has one (a member re-released
* within the cycle keeps a single row carrying the latest release).
* A replaced member keeps its original position; a new member appends.
*
* @param name the subproject name; never blank
* @param release the member's release row
* @return a new record including the row
* @throws ManifestException if {@code name} is null or blank or
* {@code release} is null
*/
public ReleaseRecord withMember(String name, MemberRelease release) {
requireNonBlank(name, "name");
if (release == null) {
throw new ManifestException("release must not be null");
}
Map<String, MemberRelease> updated = new LinkedHashMap<>(members);
updated.put(name, release);
return new ReleaseRecord(cycle, started, updated);
}
/**
* Reject a null or blank argument with a uniform message.
*
* @param value the argument value to check
* @param name the argument name for the error message
* @throws ManifestException if {@code value} is null or blank
*/
private static void requireNonBlank(String value, String name) {
if (value == null || value.isBlank()) {
throw new ManifestException(name + " must not be null or blank");
}
}
}