diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 0ad3cba..8f04f71 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -21,6 +21,9 @@ jobs:
with:
persist-credentials: false
+ - name: Verify release promotion policy
+ run: python scripts/test-release-promotion-policy.py
+
- uses: actions/setup-java@03ad4de0992f5dab5e18fcb136590ce7c4a0ac95
with:
distribution: temurin
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index dbfdf4a..6be7545 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -7,6 +7,7 @@ on:
- "!v*.*.*-RC*"
permissions:
+ actions: read
contents: read
concurrency:
@@ -43,6 +44,11 @@ jobs:
test "$(git rev-parse "${GITHUB_SHA}^{commit}")" = \
"$(git rev-parse "origin/main^{commit}")"
+ - name: Verify release-candidate promotion
+ env:
+ GITHUB_TOKEN: ${{ github.token }}
+ run: python scripts/verify-release-promotion.py
+
- uses: actions/setup-java@03ad4de0992f5dab5e18fcb136590ce7c4a0ac95
with:
distribution: temurin
@@ -57,7 +63,7 @@ jobs:
python scripts/verify-compatibility-matrix.py
./gradlew clean verifyAll --stacktrace
- - name: Publish clean release-candidate artifacts
+ - name: Publish clean release artifacts
run: sh scripts/prepare-compatibility-repository.sh build/compatibility-maven
- name: Verify Gradle Java consumer
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 95d4f98..5047050 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -20,6 +20,7 @@
* Added ten-minute Spring and gRPC boundary examples.
* Added a reproducible release-candidate adopter gate against two pinned external codebases.
* Added a dedicated release-candidate workflow that publishes to Maven Central before running the external adopter gate.
+* Added a final-release promotion gate that requires a successful same-line RC and allows only version substitution between the accepted RC and final release.
### Changed
@@ -28,7 +29,7 @@
* Kept stable Codes identity in the Spring `code` extension for every mapped failure.
* Reworked the Spring orders reference to consume `codes-spring` instead of duplicating adapter behavior.
* Enforced lossless Codes identity compatibility with `google.rpc.ErrorInfo.domain` and `ErrorInfo.reason`.
-* Preserved exposed structured issues through `google.rpc.BadRequest` without normalizing coded issue identity.
+* Restricted exposed `google.rpc.BadRequest` issues to `INVALID_ARGUMENT` and `OUT_OF_RANGE`, requiring request-field paths and rejecting incompatible representations.
* Reworked the gRPC orders reference to consume `codes-grpc-java` instead of constructing rich error details manually.
* Moved publication consumer verification to freshly emptied Maven repositories so compatibility checks cannot be satisfied by stale local artifacts.
* Documented cases where Codes should not be introduced.
diff --git a/codes-grpc-java/src/main/java/io/github/aalsanie/codes/grpc/GoogleRpcOutcomeMapper.java b/codes-grpc-java/src/main/java/io/github/aalsanie/codes/grpc/GoogleRpcOutcomeMapper.java
index 585ddf8..348de95 100644
--- a/codes-grpc-java/src/main/java/io/github/aalsanie/codes/grpc/GoogleRpcOutcomeMapper.java
+++ b/codes-grpc-java/src/main/java/io/github/aalsanie/codes/grpc/GoogleRpcOutcomeMapper.java
@@ -9,6 +9,7 @@
import io.github.aalsanie.codes.Outcome;
import io.github.aalsanie.codes.OutcomeCode;
import io.github.aalsanie.codes.protocol.grpc.GrpcOutcomeMapper;
+import io.github.aalsanie.codes.protocol.grpc.GrpcStatusCode;
import java.util.Objects;
import java.util.regex.Pattern;
@@ -21,9 +22,14 @@
* the Google RPC contract. A mapped outcome whose name cannot be represented as an
* {@code ErrorInfo.reason} is rejected.
*
- *
When a coded {@link Issue} is exposed through {@link BadRequest.FieldViolation#getReason() reason},
- * its namespace must match the enclosing outcome namespace. This keeps the field-level reason
- * scoped by the same {@code ErrorInfo.domain} and avoids silently changing issue identity.
+ *
Structured issues are exposed through {@link BadRequest} only for gRPC
+ * {@code INVALID_ARGUMENT} and {@code OUT_OF_RANGE}. Every exposed issue must have a path that the
+ * application intends as a request-field path. Pathless issues cannot be represented honestly as
+ * {@link BadRequest.FieldViolation} and are rejected instead of being emitted with an empty field.
+ *
+ *
When a coded {@link Issue} is exposed through {@link BadRequest.FieldViolation#getReason()
+ * reason}, its namespace must match the enclosing outcome namespace. This keeps the field-level
+ * reason scoped by the same {@code ErrorInfo.domain} and avoids silently changing issue identity.
*/
public final class GoogleRpcOutcomeMapper {
private static final int MAX_GOOGLE_REASON_LENGTH = 63;
@@ -55,17 +61,17 @@ public MappingResult map(Outcome outcome) {
}
return mapper.map(outcome).fold(
- status -> MappingResult.mapped(createStatus(outcome, status.getValue())),
+ status -> MappingResult.mapped(createStatus(outcome, status)),
MappingResult::unmapped
);
}
- private com.google.rpc.Status createStatus(Outcome outcome, int statusCode) {
+ private com.google.rpc.Status createStatus(Outcome outcome, GrpcStatusCode statusCode) {
OutcomeCode outcomeCode = outcome.getCode();
requireGoogleReason(outcomeCode, "outcome code", "google.rpc.ErrorInfo.reason");
com.google.rpc.Status.Builder status = com.google.rpc.Status.newBuilder()
- .setCode(statusCode)
+ .setCode(statusCode.getValue())
.setMessage(exposure.exposeMessage() ? outcome.getMessage() : outcomeCode.getValue())
.addDetails(Any.pack(
ErrorInfo.newBuilder()
@@ -83,24 +89,32 @@ private com.google.rpc.Status createStatus(Outcome outcome, int statusCode) {
}
if (exposure.exposeIssues() && !outcome.getIssues().isEmpty()) {
- status.addDetails(Any.pack(toBadRequest(outcome)));
+ status.addDetails(Any.pack(toBadRequest(outcome, statusCode)));
}
return status.build();
}
- private static BadRequest toBadRequest(Outcome outcome) {
+ private static BadRequest toBadRequest(Outcome outcome, GrpcStatusCode statusCode) {
+ requireBadRequestStatus(outcome, statusCode);
+
OutcomeCode outcomeCode = outcome.getCode();
BadRequest.Builder request = BadRequest.newBuilder();
for (Issue issue : outcome.getIssues()) {
+ String path = issue.getPath();
+ if (path == null) {
+ throw new IllegalArgumentException(
+ "issue on outcome '" + outcomeCode.getValue()
+ + "' cannot be represented as google.rpc.BadRequest.FieldViolation"
+ + "; exposed gRPC issues must have a request-field path"
+ );
+ }
+
BadRequest.FieldViolation.Builder violation = BadRequest.FieldViolation.newBuilder()
+ .setField(path)
.setDescription(issue.getMessage());
- if (issue.getPath() != null) {
- violation.setField(issue.getPath());
- }
-
OutcomeCode issueCode = issue.getCode();
if (issueCode != null) {
requireFieldViolationIdentity(outcomeCode, issueCode);
@@ -113,6 +127,20 @@ private static BadRequest toBadRequest(Outcome outcome) {
return request.build();
}
+ private static void requireBadRequestStatus(Outcome outcome, GrpcStatusCode statusCode) {
+ if (statusCode == GrpcStatusCode.INVALID_ARGUMENT
+ || statusCode == GrpcStatusCode.OUT_OF_RANGE) {
+ return;
+ }
+
+ throw new IllegalArgumentException(
+ "issues on outcome '" + outcome.getCode().getValue()
+ + "' cannot be represented as google.rpc.BadRequest when mapped to gRPC "
+ + statusCode
+ + "; BadRequest issue exposure requires INVALID_ARGUMENT or OUT_OF_RANGE"
+ );
+ }
+
private static void requireFieldViolationIdentity(
OutcomeCode outcomeCode,
OutcomeCode issueCode
diff --git a/codes-grpc-java/src/test/java/io/github/aalsanie/codes/grpc/GoogleRpcOutcomeMapperTest.java b/codes-grpc-java/src/test/java/io/github/aalsanie/codes/grpc/GoogleRpcOutcomeMapperTest.java
index 87cd051..1202815 100644
--- a/codes-grpc-java/src/test/java/io/github/aalsanie/codes/grpc/GoogleRpcOutcomeMapperTest.java
+++ b/codes-grpc-java/src/test/java/io/github/aalsanie/codes/grpc/GoogleRpcOutcomeMapperTest.java
@@ -29,17 +29,33 @@ class GoogleRpcOutcomeMapperTest {
"The payment was declined."
);
+ private static final OutcomeDefinition CHECKOUT_INVALID = OutcomeDefinition.custom(
+ APP_NAMESPACE,
+ "CHECKOUT_INVALID",
+ OutcomeState.FAILED,
+ "Checkout request is invalid."
+ );
+
+ private static final OutcomeDefinition RANGE_INVALID = OutcomeDefinition.custom(
+ APP_NAMESPACE,
+ "RANGE_INVALID",
+ OutcomeState.FAILED,
+ "Requested range is invalid."
+ );
+
private static final OutcomeCode PAYMENT_METHOD_INVALID = OutcomeCode.of(
APP_NAMESPACE,
"PAYMENT_METHOD_INVALID"
);
private static final GrpcOutcomeMapper APP_MAPPER = GrpcOutcomeMapper.standard()
- .withMapping(PAYMENT_DECLINED, GrpcStatusCode.FAILED_PRECONDITION);
+ .withMapping(PAYMENT_DECLINED, GrpcStatusCode.FAILED_PRECONDITION)
+ .withMapping(CHECKOUT_INVALID, GrpcStatusCode.INVALID_ARGUMENT)
+ .withMapping(RANGE_INVALID, GrpcStatusCode.OUT_OF_RANGE);
@Test
void safeDefaultsExposeOnlyMachineIdentity() throws Exception {
- Outcome outcome = testOutcome();
+ Outcome outcome = paymentDeclinedOutcome();
GoogleRpcOutcomeMapper mapper = mapper(GrpcOutcomeExposure.safeDefaults());
com.google.rpc.Status status = mapper.map(outcome).orNull();
@@ -55,19 +71,20 @@ void safeDefaultsExposeOnlyMachineIdentity() throws Exception {
}
@Test
- void publicErrorsExposeMessageAndStructuredIssuesButNotOccurrenceDetail() throws Exception {
- Outcome outcome = testOutcome();
+ void publicErrorsExposeRequestIssuesButNotOccurrenceDetail() throws Exception {
+ Outcome outcome = validationOutcome();
GoogleRpcOutcomeMapper mapper = mapper(GrpcOutcomeExposure.publicErrors());
com.google.rpc.Status status = mapper.map(outcome).orNull();
assertNotNull(status);
+ assertEquals(3, status.getCode());
assertEquals(outcome.getMessage(), status.getMessage());
assertEquals(2, status.getDetailsCount());
ErrorInfo info = status.getDetails(0).unpack(ErrorInfo.class);
assertEquals(APP_NAMESPACE, info.getDomain());
- assertEquals("PAYMENT_DECLINED", info.getReason());
+ assertEquals("CHECKOUT_INVALID", info.getReason());
BadRequest request = status.getDetails(1).unpack(BadRequest.class);
assertEquals(2, request.getFieldViolationsCount());
@@ -85,7 +102,7 @@ void publicErrorsExposeMessageAndStructuredIssuesButNotOccurrenceDetail() throws
@Test
void explicitDetailExposureAddsDebugInfo() throws Exception {
- Outcome outcome = testOutcome();
+ Outcome outcome = validationOutcome();
GoogleRpcOutcomeMapper mapper = mapper(new GrpcOutcomeExposure(true, true, true));
com.google.rpc.Status status = mapper.map(outcome).orNull();
@@ -97,6 +114,52 @@ void explicitDetailExposureAddsDebugInfo() throws Exception {
assertTrue(status.getDetails(2).is(BadRequest.class));
}
+ @Test
+ void outOfRangeCanExposeBadRequest() throws Exception {
+ Outcome outcome = Outcome.of(
+ RANGE_INVALID,
+ null,
+ List.of(Issue.at("offset", "Offset is outside the available range."))
+ );
+
+ com.google.rpc.Status status = mapper(GrpcOutcomeExposure.publicErrors())
+ .map(outcome)
+ .orNull();
+
+ assertNotNull(status);
+ assertEquals(11, status.getCode());
+ BadRequest request = status.getDetails(1).unpack(BadRequest.class);
+ assertEquals("offset", request.getFieldViolations(0).getField());
+ }
+
+ @Test
+ void rejectsBadRequestIssuesForFailedPrecondition() {
+ IllegalArgumentException exception = assertThrows(
+ IllegalArgumentException.class,
+ () -> mapper(GrpcOutcomeExposure.publicErrors()).map(paymentDeclinedOutcome())
+ );
+
+ assertTrue(exception.getMessage().contains("FAILED_PRECONDITION"));
+ assertTrue(exception.getMessage().contains("INVALID_ARGUMENT or OUT_OF_RANGE"));
+ }
+
+ @Test
+ void rejectsPathlessIssueAsBadRequestFieldViolation() {
+ Outcome outcome = Outcome.of(
+ CHECKOUT_INVALID,
+ null,
+ List.of(Issue.of("The request is invalid."))
+ );
+
+ IllegalArgumentException exception = assertThrows(
+ IllegalArgumentException.class,
+ () -> mapper(GrpcOutcomeExposure.publicErrors()).map(outcome)
+ );
+
+ assertTrue(exception.getMessage().contains("request-field path"));
+ assertTrue(exception.getMessage().contains(CHECKOUT_INVALID.getCode().getValue()));
+ }
+
@Test
void rejectsOutcomeNamesLongerThanErrorInfoReasonAllows() {
OutcomeDefinition incompatible = OutcomeDefinition.custom(
@@ -147,7 +210,7 @@ void rejectsCodedIssueFromDifferentDomainInsteadOfChangingItsIdentity() {
"PAYMENT_METHOD_INVALID"
);
Outcome outcome = Outcome.of(
- PAYMENT_DECLINED,
+ CHECKOUT_INVALID,
null,
List.of(
Issue.at(
@@ -191,9 +254,9 @@ void exposurePoliciesAreExplicit() {
assertTrue(GrpcOutcomeExposure.publicErrors().exposeIssues());
}
- private static Outcome testOutcome() {
+ private static Outcome validationOutcome() {
return Outcome.of(
- PAYMENT_DECLINED,
+ CHECKOUT_INVALID,
"gateway_token=secret-123",
List.of(
Issue.at(
@@ -206,6 +269,14 @@ private static Outcome testOutcome() {
);
}
+ private static Outcome paymentDeclinedOutcome() {
+ return Outcome.of(
+ PAYMENT_DECLINED,
+ "gateway_token=secret-123",
+ List.of(Issue.of("Payment cannot be completed."))
+ );
+ }
+
private static GoogleRpcOutcomeMapper mapper(GrpcOutcomeExposure exposure) {
return new GoogleRpcOutcomeMapper(APP_MAPPER, exposure);
}
diff --git a/codes-grpc-java/src/test/java/io/github/aalsanie/codes/grpc/GrpcStatusRuntimeExceptionRoundTripTest.java b/codes-grpc-java/src/test/java/io/github/aalsanie/codes/grpc/GrpcStatusRuntimeExceptionRoundTripTest.java
index 22873ff..8e44c92 100644
--- a/codes-grpc-java/src/test/java/io/github/aalsanie/codes/grpc/GrpcStatusRuntimeExceptionRoundTripTest.java
+++ b/codes-grpc-java/src/test/java/io/github/aalsanie/codes/grpc/GrpcStatusRuntimeExceptionRoundTripTest.java
@@ -19,7 +19,6 @@
import io.github.aalsanie.codes.protocol.grpc.GrpcStatusCode;
import io.grpc.StatusRuntimeException;
import io.grpc.protobuf.StatusProto;
-import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
@@ -32,11 +31,11 @@ class GrpcStatusRuntimeExceptionRoundTripTest {
private static final String PROTECTED_DETAIL = "gateway_token=secret-123";
private static final String PROTECTED_ISSUE_MESSAGE = "Payment method is invalid.";
- private static final OutcomeDefinition PAYMENT_DECLINED = OutcomeDefinition.custom(
+ private static final OutcomeDefinition CHECKOUT_INVALID = OutcomeDefinition.custom(
APP_NAMESPACE,
- "PAYMENT_DECLINED",
+ "CHECKOUT_INVALID",
OutcomeState.FAILED,
- "The payment was declined."
+ "Checkout request is invalid."
);
private static final OutcomeCode PAYMENT_METHOD_INVALID = OutcomeCode.of(
@@ -45,7 +44,7 @@ class GrpcStatusRuntimeExceptionRoundTripTest {
);
private static final GrpcOutcomeMapper APP_MAPPER = GrpcOutcomeMapper.standard()
- .withMapping(PAYMENT_DECLINED, GrpcStatusCode.FAILED_PRECONDITION);
+ .withMapping(CHECKOUT_INVALID, GrpcStatusCode.INVALID_ARGUMENT);
@Test
void decodedStatusRuntimeExceptionsMatchGoldenContract() throws Exception {
@@ -99,7 +98,7 @@ private static com.google.rpc.Status roundTrip(
assertNotNull(exception);
assertEquals(
- io.grpc.Status.Code.FAILED_PRECONDITION,
+ io.grpc.Status.Code.INVALID_ARGUMENT,
exception.getStatus().getCode()
);
@@ -226,7 +225,7 @@ private static String orAbsent(String value) {
private static Outcome testOutcome() {
return Outcome.of(
- PAYMENT_DECLINED,
+ CHECKOUT_INVALID,
PROTECTED_DETAIL,
List.of(
Issue.at(
diff --git a/compatibility/README.md b/compatibility/README.md
index 29fb9df..b7085f8 100644
--- a/compatibility/README.md
+++ b/compatibility/README.md
@@ -17,7 +17,9 @@ Core semantic snapshots do not freeze human-readable messages, occurrence detail
A change to that fixture is therefore a reviewed Spring wire-contract change.
-`grpc-google-rpc-status.snapshot` is the gRPC adapter wire fixture. It is captured after a `google.rpc.Status` is encoded into a `StatusRuntimeException` and decoded from its trailers again. It verifies exact application identity in `ErrorInfo.domain` and `ErrorInfo.reason`, safe message behavior, explicit detail exposure through `DebugInfo`, and structured issues through `BadRequest`.
+`grpc-google-rpc-status.snapshot` is the gRPC adapter wire fixture. It is captured after a `google.rpc.Status` is encoded into a `StatusRuntimeException` and decoded from its trailers again. It verifies exact application identity in `ErrorInfo.domain` and `ErrorInfo.reason`, safe message behavior, explicit detail exposure through `DebugInfo`, and request-field issues through `BadRequest` for a status whose standard detail is `BadRequest`.
+
+When gRPC issue exposure is enabled, the adapter emits `BadRequest` only for `INVALID_ARGUMENT` and `OUT_OF_RANGE`. Every exposed issue must have a request-field path. Incompatible statuses and pathless issues are rejected instead of being encoded with misleading wire semantics.
A change to that fixture is therefore a reviewed gRPC wire-contract change.
diff --git a/compatibility/grpc-google-rpc-status.snapshot b/compatibility/grpc-google-rpc-status.snapshot
index 0cc005c..37a000e 100644
--- a/compatibility/grpc-google-rpc-status.snapshot
+++ b/compatibility/grpc-google-rpc-status.snapshot
@@ -1,18 +1,18 @@
[safe]
-code=9
-message=com.example.checkout:PAYMENT_DECLINED
+code=3
+message=com.example.checkout:CHECKOUT_INVALID
details=1
identity.domain=com.example.checkout
-identity.reason=PAYMENT_DECLINED
+identity.reason=CHECKOUT_INVALID
debug=
issues=0
[public]
-code=9
-message=The payment was declined.
+code=3
+message=Checkout request is invalid.
details=2
identity.domain=com.example.checkout
-identity.reason=PAYMENT_DECLINED
+identity.reason=CHECKOUT_INVALID
debug=
issues=2
issue.0.field=paymentMethod
@@ -23,11 +23,11 @@ issue.1.reason=
issue.1.description=Amount must be positive.
[explicit]
-code=9
-message=The payment was declined.
+code=3
+message=Checkout request is invalid.
details=3
identity.domain=com.example.checkout
-identity.reason=PAYMENT_DECLINED
+identity.reason=CHECKOUT_INVALID
debug=gateway_token=secret-123
issues=2
issue.0.field=paymentMethod
diff --git a/docs/compatibility-policy.md b/docs/compatibility-policy.md
index cb7e7ac..b7e7faa 100644
--- a/docs/compatibility-policy.md
+++ b/docs/compatibility-policy.md
@@ -2,11 +2,9 @@
Codes is pre-1.0. Minor releases may contain source or binary breaking changes. Breaking changes are documented in `CHANGELOG.md`.
-The published `codes` artifact:
+All published Codes artifacts target Java 17 and support Java and Kotlin consumers.
-* targets Java 17;
-* has zero runtime dependencies;
-* supports Java and Kotlin consumers.
+The published `codes` core artifact has zero runtime dependencies. `codes-spring` and `codes-grpc-java` depend only on the boundary libraries documented in their artifact contracts and verified published POM budgets.
The following are part of the semantic contract:
@@ -15,6 +13,17 @@ The following are part of the semantic contract:
* built-in HTTP mappings;
* built-in gRPC mappings.
-Public Java API compatibility is checked against `api/codes.api`. Semantic compatibility is checked against the snapshots under `compatibility/`.
+Public Java API compatibility is checked independently for:
+
+* `api/codes.api`;
+* `api/codes-spring.api`;
+* `api/codes-grpc-java.api`.
+
+Boundary wire contracts are checked independently from Java API compatibility:
+
+* rendered Spring RFC 9457 problem responses are frozen by `compatibility/spring-http-problems.snapshot`;
+* decoded `google.rpc.Status` payloads are frozen by `compatibility/grpc-google-rpc-status.snapshot`.
+
+The executable compatibility matrix under `compatibility/` covers the supported Spring and gRPC baselines, Java runtimes, Kotlin compilers, Gradle and Maven consumers, and supported CI operating systems. Published POM checks protect the dependency contract of each artifact.
Human-readable messages are not machine identity and may change without changing `OutcomeCode`.
diff --git a/docs/protocol-mappings.md b/docs/protocol-mappings.md
index 3f46507..a3ff566 100644
--- a/docs/protocol-mappings.md
+++ b/docs/protocol-mappings.md
@@ -49,3 +49,13 @@ HttpOutcomeMapper mapper = HttpOutcomeMapper.standard()
`withMapping` rejects duplicate mappings. `withOverride` rejects outcomes that are not already mapped.
HTTP status constants such as `CREATED`, `ACCEPTED`, `NO_CONTENT`, and `PAYLOAD_TOO_LARGE` remain available for explicit application mappings even though those names are not standard application outcomes.
+
+## gRPC structured issues
+
+`GoogleRpcOutcomeMapper` always preserves the stable Codes identity in `ErrorInfo.domain` and `ErrorInfo.reason` for mapped failures.
+
+When issue exposure is enabled, Codes uses `google.rpc.BadRequest` only for outcomes mapped to gRPC `INVALID_ARGUMENT` or `OUT_OF_RANGE`, matching the standard Google RPC error-detail semantics. Every exposed issue must have a path that the application intends as a request-field path.
+
+If an outcome with issues is mapped to another gRPC status while issue exposure is enabled, the adapter rejects the mapping instead of emitting a misleading `BadRequest`. Pathless issues are rejected for the same reason.
+
+A coded issue can populate `BadRequest.FieldViolation.reason` only when its namespace matches the enclosing outcome namespace, because the reason is scoped by the enclosing `ErrorInfo.domain`.
diff --git a/docs/ten-minute-grpc.md b/docs/ten-minute-grpc.md
index f3c53a4..d6ff967 100644
--- a/docs/ten-minute-grpc.md
+++ b/docs/ten-minute-grpc.md
@@ -15,15 +15,15 @@ Codes `0.4.x` declares gRPC Java 1.75.0 as its compatibility floor.
## 2. Define the stable application outcome
```java
-final class PaymentOutcomes {
- static final OutcomeDefinition PAYMENT_DECLINED = OutcomeDefinition.custom(
- "com.example.payments",
- "PAYMENT_DECLINED",
+final class CheckoutOutcomes {
+ static final OutcomeDefinition CHECKOUT_INVALID = OutcomeDefinition.custom(
+ "com.example.checkout",
+ "CHECKOUT_INVALID",
OutcomeState.FAILED,
- "The payment was declined."
+ "Checkout request is invalid."
);
- private PaymentOutcomes() {
+ private CheckoutOutcomes() {
}
}
```
@@ -35,8 +35,8 @@ For `ErrorInfo.reason`, the outcome name must satisfy the Google RPC reason cont
```java
GrpcOutcomeMapper grpc = GrpcOutcomeMapper.standard()
.withMapping(
- PaymentOutcomes.PAYMENT_DECLINED,
- GrpcStatusCode.FAILED_PRECONDITION
+ CheckoutOutcomes.CHECKOUT_INVALID,
+ GrpcStatusCode.INVALID_ARGUMENT
);
GoogleRpcOutcomeMapper errors = new GoogleRpcOutcomeMapper(
@@ -45,15 +45,15 @@ GoogleRpcOutcomeMapper errors = new GoogleRpcOutcomeMapper(
);
```
-`publicErrors()` exposes the reusable message and structured issues. It does not expose occurrence `detail`.
+`publicErrors()` exposes the reusable message and request-field issues. It does not expose occurrence `detail`.
## 4. Send the error through the existing service
```java
Outcome outcome = Outcome.of(
- PaymentOutcomes.PAYMENT_DECLINED,
+ CheckoutOutcomes.CHECKOUT_INVALID,
null,
- List.of(Issue.at("paymentMethod", "Payment method is unavailable."))
+ List.of(Issue.at("paymentMethod", "Payment method is invalid."))
);
StatusRuntimeException error = GrpcOutcomeExceptions
@@ -61,7 +61,7 @@ StatusRuntimeException error = GrpcOutcomeExceptions
.orNull();
if (error == null) {
- throw new IllegalStateException("PAYMENT_DECLINED has no gRPC mapping");
+ throw new IllegalStateException("CHECKOUT_INVALID has no gRPC mapping");
}
responseObserver.onError(error);
@@ -80,17 +80,29 @@ ErrorInfo identity = status.getDetailsList().stream()
.orElseThrow()
.unpack(ErrorInfo.class);
-assert identity.getDomain().equals("com.example.payments");
-assert identity.getReason().equals("PAYMENT_DECLINED");
+assert identity.getDomain().equals("com.example.checkout");
+assert identity.getReason().equals("CHECKOUT_INVALID");
+
+BadRequest request = status.getDetailsList().stream()
+ .filter(any -> any.is(BadRequest.class))
+ .findFirst()
+ .orElseThrow()
+ .unpack(BadRequest.class);
+
+assert request.getFieldViolations(0).getField().equals("paymentMethod");
```
-Structured issues are carried in `google.rpc.BadRequest`. Occurrence detail is carried only when `exposeDetail` is explicitly enabled.
+Structured issues are carried in `google.rpc.BadRequest` only when the mapped gRPC status is `INVALID_ARGUMENT` or `OUT_OF_RANGE`. Every exposed issue must have a path that the application intends as a request-field path. Codes rejects incompatible or pathless issue exposure instead of changing its meaning.
+
+Occurrence detail is carried only when `exposeDetail` is explicitly enabled.
The application identity therefore survives:
```text
-com.example.payments:PAYMENT_DECLINED
- -> gRPC FAILED_PRECONDITION
- -> ErrorInfo.domain = com.example.payments
- -> ErrorInfo.reason = PAYMENT_DECLINED
+com.example.checkout:CHECKOUT_INVALID
+ -> gRPC INVALID_ARGUMENT
+ -> ErrorInfo.domain = com.example.checkout
+ -> ErrorInfo.reason = CHECKOUT_INVALID
```
+
+For outcomes such as `FAILED_PRECONDITION`, Codes does not coerce generic `Issue` values into `PreconditionFailure`; that type has different semantics and requires information the core `Issue` model does not claim to contain.
diff --git a/reference/grpc-orders/build.gradle.kts b/reference/grpc-orders/build.gradle.kts
index 65d6885..917221e 100644
--- a/reference/grpc-orders/build.gradle.kts
+++ b/reference/grpc-orders/build.gradle.kts
@@ -4,7 +4,7 @@ plugins {
id("com.google.protobuf") version "0.10.0"
}
-version = "0.3.0-reference"
+version = "0.4.0-reference"
java {
toolchain {
diff --git a/reference/spring-orders/build.gradle.kts b/reference/spring-orders/build.gradle.kts
index eca19c4..4b439e4 100644
--- a/reference/spring-orders/build.gradle.kts
+++ b/reference/spring-orders/build.gradle.kts
@@ -3,7 +3,7 @@ plugins {
application
}
-version = "0.3.0-reference"
+version = "0.4.0-reference"
java {
toolchain {
diff --git a/scripts/__pycache__/verify-release-promotion.cpython-311.pyc b/scripts/__pycache__/verify-release-promotion.cpython-311.pyc
new file mode 100644
index 0000000..83f4f55
Binary files /dev/null and b/scripts/__pycache__/verify-release-promotion.cpython-311.pyc differ
diff --git a/scripts/test-release-promotion-policy.py b/scripts/test-release-promotion-policy.py
new file mode 100644
index 0000000..9586324
--- /dev/null
+++ b/scripts/test-release-promotion-policy.py
@@ -0,0 +1,144 @@
+#!/usr/bin/env python3
+
+import importlib.util
+import unittest
+from pathlib import Path
+
+SCRIPT = Path(__file__).with_name("verify-release-promotion.py")
+SPEC = importlib.util.spec_from_file_location("verify_release_promotion", SCRIPT)
+if SPEC is None or SPEC.loader is None:
+ raise RuntimeError(f"Cannot load {SCRIPT}")
+MODULE = importlib.util.module_from_spec(SPEC)
+SPEC.loader.exec_module(MODULE)
+
+
+class ReleasePromotionPolicyTest(unittest.TestCase):
+ def test_selects_highest_numeric_rc(self):
+ tag, version = MODULE.select_latest_rc(
+ "0.4.0",
+ ["v0.4.0-RC1", "v0.4.0-RC10", "v0.4.0-RC2", "v0.5.0-RC9"],
+ )
+ self.assertEqual("v0.4.0-RC10", tag)
+ self.assertEqual("0.4.0-RC10", version)
+
+ def test_requires_rc_for_same_final_version(self):
+ with self.assertRaisesRegex(MODULE.PromotionError, "No release candidate"):
+ MODULE.select_latest_rc("0.4.0", ["v0.3.1", "v0.5.0-RC1"])
+
+ def test_accepts_exact_version_only_promotion(self):
+ rc_version = "0.4.0-RC1"
+ final_version = "0.4.0"
+ rc_contents = {
+ path: f"before\nversion={rc_version}\nafter\n"
+ for path in MODULE.PROMOTION_FILES
+ }
+ final_contents = {
+ path: value.replace(rc_version, final_version)
+ for path, value in rc_contents.items()
+ }
+
+ MODULE.validate_promotion_contents(
+ rc_version,
+ final_version,
+ set(MODULE.PROMOTION_FILES),
+ rc_contents,
+ final_contents,
+ )
+
+ def test_rejects_product_code_change(self):
+ rc_version = "0.4.0-RC1"
+ final_version = "0.4.0"
+ rc_contents = {
+ path: rc_version
+ for path in MODULE.PROMOTION_FILES
+ }
+ final_contents = {
+ path: final_version
+ for path in MODULE.PROMOTION_FILES
+ }
+
+ with self.assertRaisesRegex(MODULE.PromotionError, "unexpected changes"):
+ MODULE.validate_promotion_contents(
+ rc_version,
+ final_version,
+ set(MODULE.PROMOTION_FILES) | {"src/main/java/Changed.java"},
+ rc_contents,
+ final_contents,
+ )
+
+ def test_rejects_missing_version_promotion_file(self):
+ rc_version = "0.4.0-RC1"
+ final_version = "0.4.0"
+ rc_contents = {
+ path: rc_version
+ for path in MODULE.PROMOTION_FILES
+ }
+ final_contents = {
+ path: final_version
+ for path in MODULE.PROMOTION_FILES
+ }
+ changed = set(MODULE.PROMOTION_FILES)
+ changed.remove("README.md")
+
+ with self.assertRaisesRegex(MODULE.PromotionError, "missing version-only changes"):
+ MODULE.validate_promotion_contents(
+ rc_version,
+ final_version,
+ changed,
+ rc_contents,
+ final_contents,
+ )
+
+ def test_rejects_non_version_edit_in_allowed_file(self):
+ rc_version = "0.4.0-RC1"
+ final_version = "0.4.0"
+ rc_contents = {
+ path: f"version={rc_version}\n"
+ for path in MODULE.PROMOTION_FILES
+ }
+ final_contents = {
+ path: value.replace(rc_version, final_version)
+ for path, value in rc_contents.items()
+ }
+ final_contents["README.md"] += "extra release edit\n"
+
+ with self.assertRaisesRegex(MODULE.PromotionError, "changes other than replacing"):
+ MODULE.validate_promotion_contents(
+ rc_version,
+ final_version,
+ set(MODULE.PROMOTION_FILES),
+ rc_contents,
+ final_contents,
+ )
+
+ def test_requires_rc_version_token_in_every_promotion_file(self):
+ rc_version = "0.4.0-RC1"
+ final_version = "0.4.0"
+ rc_contents = {
+ path: rc_version
+ for path in MODULE.PROMOTION_FILES
+ }
+ rc_contents["README.md"] = "no version here"
+ final_contents = {
+ path: value.replace(rc_version, final_version)
+ for path, value in rc_contents.items()
+ }
+
+ with self.assertRaisesRegex(MODULE.PromotionError, "policy is stale"):
+ MODULE.validate_promotion_contents(
+ rc_version,
+ final_version,
+ set(MODULE.PROMOTION_FILES),
+ rc_contents,
+ final_contents,
+ )
+
+ def test_rejects_structural_changes(self):
+ with self.assertRaisesRegex(MODULE.PromotionError, "must not create, delete, rename"):
+ MODULE.validate_no_structural_changes(
+ " mode change 100644 => 100755 README.md\n"
+ )
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/scripts/verify-release-promotion.py b/scripts/verify-release-promotion.py
new file mode 100644
index 0000000..eeb6de4
--- /dev/null
+++ b/scripts/verify-release-promotion.py
@@ -0,0 +1,232 @@
+#!/usr/bin/env python3
+
+from __future__ import annotations
+
+import json
+import os
+import re
+import subprocess
+import sys
+import urllib.error
+import urllib.parse
+import urllib.request
+from pathlib import Path
+
+ROOT = Path(__file__).resolve().parent.parent
+WORKFLOW_FILE = "release-candidate.yml"
+PROMOTION_FILES = (
+ "CHANGELOG.md",
+ "README.md",
+ "docs/ten-minute-grpc.md",
+ "docs/ten-minute-spring.md",
+ "gradle.properties",
+)
+
+
+class PromotionError(RuntimeError):
+ pass
+
+
+def run_git(*args: str, check: bool = True) -> subprocess.CompletedProcess[str]:
+ return subprocess.run(
+ ["git", "-C", str(ROOT), *args],
+ check=check,
+ stdout=subprocess.PIPE,
+ stderr=subprocess.PIPE,
+ text=True,
+ )
+
+
+def git_line(*args: str) -> str:
+ return run_git(*args).stdout.strip()
+
+
+def git_file(ref: str, path: str) -> str:
+ return run_git("show", f"{ref}:{path}").stdout
+
+
+def property_value(text: str, key: str) -> str:
+ prefix = f"{key}="
+ matches = [line[len(prefix):] for line in text.splitlines() if line.startswith(prefix)]
+ if len(matches) != 1 or not matches[0]:
+ raise PromotionError(f"Expected exactly one non-empty {key} property")
+ return matches[0]
+
+
+def select_latest_rc(final_version: str, tags: list[str]) -> tuple[str, str]:
+ pattern = re.compile(rf"^v{re.escape(final_version)}-RC([1-9][0-9]*)$")
+ candidates: list[tuple[int, str]] = []
+ for tag in tags:
+ match = pattern.fullmatch(tag)
+ if match:
+ candidates.append((int(match.group(1)), tag))
+
+ if not candidates:
+ raise PromotionError(f"No release candidate tag found for {final_version}")
+
+ _, tag = max(candidates)
+ return tag, tag.removeprefix("v")
+
+
+def validate_promotion_contents(
+ rc_version: str,
+ final_version: str,
+ changed_paths: set[str],
+ rc_contents: dict[str, str],
+ final_contents: dict[str, str],
+) -> None:
+ expected_paths = set(PROMOTION_FILES)
+ if changed_paths != expected_paths:
+ unexpected = sorted(changed_paths - expected_paths)
+ missing = sorted(expected_paths - changed_paths)
+ details: list[str] = []
+ if unexpected:
+ details.append("unexpected changes: " + ", ".join(unexpected))
+ if missing:
+ details.append("missing version-only changes: " + ", ".join(missing))
+ raise PromotionError("RC-to-final promotion is not version-only; " + "; ".join(details))
+
+ for path in PROMOTION_FILES:
+ rc_text = rc_contents[path]
+ final_text = final_contents[path]
+ if rc_version not in rc_text:
+ raise PromotionError(
+ f"Promotion policy is stale: {path} does not contain {rc_version} at the RC tag"
+ )
+ expected = rc_text.replace(rc_version, final_version)
+ if final_text != expected:
+ raise PromotionError(
+ f"{path} contains changes other than replacing {rc_version} with {final_version}"
+ )
+
+
+def validate_no_structural_changes(summary: str) -> None:
+ if summary.strip():
+ raise PromotionError(
+ "RC-to-final promotion must not create, delete, rename, or change file modes: "
+ + summary.strip().replace("\n", "; ")
+ )
+
+
+def github_json(url: str, token: str) -> object:
+ request = urllib.request.Request(
+ url,
+ headers={
+ "Accept": "application/vnd.github+json",
+ "Authorization": f"Bearer {token}",
+ "User-Agent": "codes-release-promotion",
+ "X-GitHub-Api-Version": "2022-11-28",
+ },
+ )
+ try:
+ with urllib.request.urlopen(request, timeout=20) as response:
+ return json.load(response)
+ except urllib.error.HTTPError as error:
+ raise PromotionError(f"GitHub API request failed with HTTP {error.code}: {url}") from error
+ except urllib.error.URLError as error:
+ raise PromotionError(f"GitHub API request failed: {url}: {error.reason}") from error
+
+
+def verify_remote_rc(rc_tag: str, rc_sha: str) -> None:
+ repository = os.environ.get("GITHUB_REPOSITORY")
+ token = os.environ.get("GITHUB_TOKEN")
+ api_url = os.environ.get("GITHUB_API_URL", "https://api.github.com").rstrip("/")
+ if not repository:
+ raise PromotionError("GITHUB_REPOSITORY is required")
+ if not token:
+ raise PromotionError("GITHUB_TOKEN is required")
+
+ query = urllib.parse.urlencode(
+ {
+ "head_sha": rc_sha,
+ "per_page": "100",
+ }
+ )
+ workflow = urllib.parse.quote(WORKFLOW_FILE, safe="")
+ runs_url = f"{api_url}/repos/{repository}/actions/workflows/{workflow}/runs?{query}"
+ runs_payload = github_json(runs_url, token)
+ if not isinstance(runs_payload, dict):
+ raise PromotionError("Unexpected GitHub workflow-runs response")
+
+ runs = runs_payload.get("workflow_runs")
+ if not isinstance(runs, list):
+ raise PromotionError("GitHub workflow-runs response is missing workflow_runs")
+
+ successful = [
+ run for run in runs
+ if isinstance(run, dict)
+ and run.get("head_sha") == rc_sha
+ and run.get("event") == "push"
+ and run.get("conclusion") == "success"
+ ]
+ if not successful:
+ raise PromotionError(
+ f"No successful {WORKFLOW_FILE} push workflow exists for {rc_tag} ({rc_sha})"
+ )
+
+ encoded_tag = urllib.parse.quote(rc_tag, safe="")
+ release_url = f"{api_url}/repos/{repository}/releases/tags/{encoded_tag}"
+ release = github_json(release_url, token)
+ if not isinstance(release, dict):
+ raise PromotionError("Unexpected GitHub release response")
+ if release.get("tag_name") != rc_tag:
+ raise PromotionError(f"GitHub prerelease tag does not match {rc_tag}")
+ if release.get("draft") is not False or release.get("prerelease") is not True:
+ raise PromotionError(f"{rc_tag} must exist as a published GitHub prerelease")
+
+
+def verify() -> None:
+ final_properties = (ROOT / "gradle.properties").read_text(encoding="utf-8")
+ final_version = property_value(final_properties, "VERSION_NAME")
+ if not re.fullmatch(r"[0-9]+\.[0-9]+\.[0-9]+", final_version):
+ raise PromotionError(f"Final version is not a release version: {final_version}")
+
+ tags = [line for line in git_line("tag", "--list", f"v{final_version}-RC*").splitlines() if line]
+ rc_tag, rc_version = select_latest_rc(final_version, tags)
+
+ ancestry = run_git("merge-base", "--is-ancestor", rc_tag, "HEAD", check=False)
+ if ancestry.returncode != 0:
+ raise PromotionError(f"Latest release candidate {rc_tag} is not an ancestor of HEAD")
+
+ rc_sha = git_line("rev-parse", f"{rc_tag}^{{commit}}")
+ final_sha = git_line("rev-parse", "HEAD^{commit}")
+ if rc_sha == final_sha:
+ raise PromotionError("Final release must be a version-only promotion commit after the RC")
+
+ validate_no_structural_changes(run_git("diff", "--summary", rc_tag, "HEAD").stdout)
+
+ changed = {
+ line for line in git_line("diff", "--name-only", rc_tag, "HEAD").splitlines() if line
+ }
+ rc_contents = {path: git_file(rc_tag, path) for path in PROMOTION_FILES}
+ final_contents = {
+ path: (ROOT / path).read_text(encoding="utf-8")
+ for path in PROMOTION_FILES
+ }
+ validate_promotion_contents(
+ rc_version,
+ final_version,
+ changed,
+ rc_contents,
+ final_contents,
+ )
+
+ verify_remote_rc(rc_tag, rc_sha)
+
+ print(
+ f"Verified {rc_tag} ({rc_sha}) -> v{final_version} ({final_sha}) "
+ "as a successful version-only RC promotion."
+ )
+
+
+def main() -> int:
+ try:
+ verify()
+ return 0
+ except PromotionError as error:
+ print(f"Release promotion verification failed: {error}", file=sys.stderr)
+ return 1
+
+
+if __name__ == "__main__":
+ raise SystemExit(main())