diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a2abc37..1b475c7 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -16,7 +16,7 @@ jobs: uses: actions/setup-java@v5 with: distribution: temurin - java-version: '25' + java-version: '17' cache: maven - name: Make Maven wrapper executable diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..fcdecfa --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,120 @@ +name: Release + +on: + workflow_dispatch: + inputs: + release_version: + description: Release version in MAJOR.MINOR.PATCH format + required: true + type: string + +concurrency: + group: release-${{ github.event.repository.default_branch }} + cancel-in-progress: false + +jobs: + publish: + runs-on: ubuntu-latest + permissions: + contents: write + env: + DEFAULT_BRANCH: ${{ github.event.repository.default_branch }} + steps: + - name: Check out default branch + uses: actions/checkout@v6 + with: + fetch-depth: 0 + ref: ${{ github.event.repository.default_branch }} + + - name: Set up Java and Maven Central credentials + uses: actions/setup-java@v5 + with: + distribution: temurin + java-version: '17' + cache: maven + server-id: central + server-username: ${{ secrets.SONATYPE_USERNAME }} + server-password: ${{ secrets.SONATYPE_PASSWORD }} + gpg-private-key: ${{ secrets.GPG_KEY_CONTENTS }} + gpg-passphrase: ${{ secrets.SIGNING_PASSWORD }} + + - name: Make Maven wrapper executable + run: chmod +x ./mvnw + + - name: Derive release metadata + env: + RELEASE_VERSION_INPUT: ${{ inputs.release_version }} + run: | + RELEASE_VERSION="${RELEASE_VERSION_INPUT}" + + if ! [[ "${RELEASE_VERSION}" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then + echo "Release version '${RELEASE_VERSION}' is not in the expected MAJOR.MINOR.PATCH format." >&2 + exit 1 + fi + + IFS='.' read -r major minor patch <> "${GITHUB_ENV}" + echo "NEXT_SNAPSHOT_VERSION=${NEXT_SNAPSHOT_VERSION}" >> "${GITHUB_ENV}" + echo "RELEASE_NOTES_FILE=${RELEASE_NOTES_FILE}" >> "${GITHUB_ENV}" + + - name: Verify release tag does not already exist + run: | + if git rev-parse -q --verify "refs/tags/${RELEASE_VERSION}" >/dev/null; then + echo "Tag ${RELEASE_VERSION} already exists locally." >&2 + exit 1 + fi + if git ls-remote --exit-code --tags origin "refs/tags/${RELEASE_VERSION}" >/dev/null; then + echo "Tag ${RELEASE_VERSION} already exists on origin." >&2 + exit 1 + fi + + - name: Configure git author + run: | + git config user.name "github-actions" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + + - name: Create release commit and tag + run: | + ./mvnw -B versions:set -DnewVersion="${RELEASE_VERSION}" -DgenerateBackupPoms=false + git add pom.xml + git commit -m "Release ${RELEASE_VERSION}" + git tag -a "${RELEASE_VERSION}" -m "Release ${RELEASE_VERSION}" + + - name: Create next snapshot commit + run: | + ./mvnw -B versions:set -DnewVersion="${NEXT_SNAPSHOT_VERSION}" -DgenerateBackupPoms=false + git add pom.xml + git commit -m "Bump version to ${NEXT_SNAPSHOT_VERSION}" + + - name: Push release tag and default branch + run: | + git push --atomic origin HEAD:"${DEFAULT_BRANCH}" "refs/tags/${RELEASE_VERSION}" + + - name: Check out release tag for publishing + run: git checkout --detach "${RELEASE_VERSION}" + + - name: Build, sign, and publish release bundle + run: ./mvnw -B -Prelease deploy + + - name: Create draft GitHub Release + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + if [ -f "${RELEASE_NOTES_FILE}" ]; then + gh release create "${RELEASE_VERSION}" \ + --draft \ + --title "${RELEASE_VERSION}" \ + --notes-file "${RELEASE_NOTES_FILE}" + else + gh release create "${RELEASE_VERSION}" \ + --draft \ + --title "${RELEASE_VERSION}" \ + --generate-notes + fi diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..fe27a60 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Maciej Bartczak + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md index f6d7415..58260b3 100644 --- a/README.md +++ b/README.md @@ -6,6 +6,9 @@ Inspired by [better-result](https://github.com/dmmulroy/better-result). ## Contents +- [Installation](#installation) +- [Java Compatibility](#java-compatibility) +- [Quick Start](#quick-start) - [The Core Types](#the-core-types) - [Creating Results](#creating-results) - [Transforming Success Values](#transforming-success-values) @@ -17,6 +20,65 @@ Inspired by [better-result](https://github.com/dmmulroy/better-result). - [Panic](#panic) - [API Reference](#api-reference) +## Installation + +Maven: + +```xml + + io.github.b6k-dev + outcome + 1.0.0 + +``` + +Gradle (Groovy DSL): + +```groovy +implementation 'io.github.b6k-dev:outcome:1.0.0' +``` + +Gradle (Kotlin DSL): + +```kotlin +implementation("io.github.b6k-dev:outcome:1.0.0") +``` + +## Java Compatibility + +Outcome targets Java 17 and newer. The library API is compiled with `--release 17`, and examples in this README use sealed types, records, and pattern matching features available in modern Java. + +## Quick Start + +```java +import b6k.dev.outcome.Result; + +sealed interface CreateUserError permits InvalidEmail, DuplicateEmail {} +record InvalidEmail(String value) implements CreateUserError {} +record DuplicateEmail(String value) implements CreateUserError {} +record User(String id, String email) {} + +Result createUser(String email) { + if (email == null || email.isBlank() || !email.contains("@")) { + return Result.err(new InvalidEmail(String.valueOf(email))); + } + if (email.equals("ada@example.com")) { + return Result.err(new DuplicateEmail(email)); + } + return Result.ok(new User("u-123", email)); +} + +String message = createUser("ada@example.com").fold( + user -> "Created user " + user.email(), + error -> switch (error) { + case InvalidEmail e -> "Invalid email: " + e.value(); + case DuplicateEmail e -> "Email already exists: " + e.email(); + } +); +``` + +Use `Result.ok(...)` and `Result.err(...)` to model expected outcomes, then compose them with methods like `map`, `flatMap`, `orElse`, and `fold` instead of mixing nullable values, sentinel states, and exceptions. + ## The Core Types Outcome revolves around four ideas: diff --git a/docs/releases/1.0.0.md b/docs/releases/1.0.0.md new file mode 100644 index 0000000..25864eb --- /dev/null +++ b/docs/releases/1.0.0.md @@ -0,0 +1,23 @@ +# Outcome 1.0.0 Release Notes + +## Highlights + +- First public release of Outcome, a lightweight `Result` type for Java + +## Coordinates + +Maven: + +```xml + + io.github.b6k-dev + outcome + 1.0.0 + +``` + +Gradle: + +```groovy +implementation 'io.github.b6k-dev:outcome:1.0.0' +``` \ No newline at end of file diff --git a/pom.xml b/pom.xml index 08ca6d3..f70721c 100644 --- a/pom.xml +++ b/pom.xml @@ -4,16 +4,48 @@ xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd"> 4.0.0 - dev.b6k + io.github.b6k-dev outcome - 1.0-SNAPSHOT + 1.0.0-SNAPSHOT + Outcome + Lightweight Result type for Java with Ok, Err, and Panic + https://github.com/b6k-dev/outcome + + + + MIT License + https://opensource.org/licenses/MIT + repo + + + + + + b6k-dev + Maciej Bartczak + https://github.com/b6k-dev + + + + + scm:git:https://github.com/b6k-dev/outcome.git + scm:git:ssh://git@github.com/b6k-dev/outcome.git + https://github.com/b6k-dev/outcome + HEAD + - 25 - 25 + 17 + 3.9.0 5.12.0 3.27.7 + 3.14.1 + 3.5.0 + 3.3.1 + 3.11.2 + 3.2.8 3.5.2 + 0.9.0 UTF-8 @@ -34,6 +66,37 @@ + + org.apache.maven.plugins + maven-compiler-plugin + ${maven-compiler-plugin.version} + + ${maven.compiler.release} + + + + org.apache.maven.plugins + maven-enforcer-plugin + ${maven-enforcer-plugin.version} + + + enforce-build-environment + + enforce + + + + + [17,) + + + [${maven.version},) + + + + + + org.apache.maven.plugins maven-surefire-plugin @@ -42,4 +105,74 @@ + + + release + + + + org.apache.maven.plugins + maven-source-plugin + ${maven-source-plugin.version} + + + attach-sources + + jar-no-fork + + + + + + org.apache.maven.plugins + maven-javadoc-plugin + ${maven-javadoc-plugin.version} + + ${maven.compiler.release} + ${project.build.sourceEncoding} + + + + attach-javadocs + + jar + + + + + + org.apache.maven.plugins + maven-gpg-plugin + ${maven-gpg-plugin.version} + + + sign-artifacts + verify + + sign + + + + + bc + + + + org.sonatype.central + central-publishing-maven-plugin + ${central-publishing-maven-plugin.version} + true + + central + ${project.artifactId}-${project.version} + false + false + validated + + + + + + + diff --git a/src/test/java/b6k/dev/outcome/ResultTest.java b/src/test/java/b6k/dev/outcome/ResultTest.java index c24377e..7387337 100644 --- a/src/test/java/b6k/dev/outcome/ResultTest.java +++ b/src/test/java/b6k/dev/outcome/ResultTest.java @@ -346,7 +346,7 @@ void panicsWhenSupplierThrows() { @Test void panicsWhenErrorMapperThrows() { - assertThatThrownBy(() -> errResult().unwrapOrElse(_ -> { + assertThatThrownBy(() -> errResult().unwrapOrElse(error -> { throw new IllegalStateException("boom"); })) .isInstanceOf(Panic.class) @@ -363,7 +363,7 @@ class OrElse { void preservesValueForOk() { var called = new AtomicBoolean(false); - var result = okResult().orElse(_ -> { + var result = okResult().orElse(error -> { called.set(true); return Result.ok(23); }); @@ -389,7 +389,7 @@ void recoversErrToOk() { @Test void transformsErrToDifferentErrorType() { - Result result = errResult().orElse(_ -> Result.err(new IllegalStateException("new error"))); + Result result = errResult().orElse(error -> Result.err(new IllegalStateException("new error"))); assertThat(result.unwrapError()) .isInstanceOf(IllegalStateException.class) @@ -419,7 +419,7 @@ void rejectsNullFallback() { @Test void rejectsNullFallbackResult() { - assertThatThrownBy(() -> errResult().orElse(_ -> null)) + assertThatThrownBy(() -> errResult().orElse(error -> null)) .isInstanceOf(NullPointerException.class) .hasMessage("fallback result must not be null"); } @@ -470,7 +470,7 @@ void rejectsNullMapper() { @Test void panicsWhenMapperThrows() { - assertThatThrownBy(() -> errResult().orThrow(_ -> { + assertThatThrownBy(() -> errResult().orThrow(error -> { throw new IllegalStateException("boom"); })) .isInstanceOf(Panic.class) @@ -482,7 +482,7 @@ void panicsWhenMapperThrows() { @Test void rejectsNullMappedThrowable() { - assertThatThrownBy(() -> errResult().orThrow(_ -> null)) + assertThatThrownBy(() -> errResult().orThrow(error -> null)) .isInstanceOf(NullPointerException.class) .hasMessage("errorMapper result must not be null"); } @@ -526,7 +526,7 @@ void rejectsNullMapper() { @Test void rejectsNullMappedValue() { - assertThatThrownBy(() -> okResult().map(_ -> null)) + assertThatThrownBy(() -> okResult().map(value -> null)) .isInstanceOf(NullPointerException.class) .hasMessage("value must not be null"); } @@ -570,7 +570,7 @@ void rejectsNullMapper() { @Test void rejectsNullMappedError() { - assertThatThrownBy(() -> errResult().mapError(_ -> null)) + assertThatThrownBy(() -> errResult().mapError(error -> null)) .isInstanceOf(NullPointerException.class) .hasMessage("error must not be null"); } @@ -595,7 +595,7 @@ void preservesErrorForErr() { @Test void propagatesErrorFromMapper() { - var result = okResult().flatMap(_ -> Result.err("mapper failed")); + var result = okResult().flatMap(value -> Result.err("mapper failed")); assertTrue(result.isErr()); assertEquals("mapper failed", result.unwrapError()); @@ -633,7 +633,7 @@ void rejectsNullMapper() { @Test void rejectsNullMappedResult() { - assertThatThrownBy(() -> okResult().flatMap(_ -> null)) + assertThatThrownBy(() -> okResult().flatMap(value -> null)) .isInstanceOf(NullPointerException.class) .hasMessage("mapper result must not be null"); } @@ -658,7 +658,7 @@ void observesValueForOk() { void preservesErrorForErrWithoutCallingObserver() { var called = new AtomicBoolean(false); - var result = errResult().peek(_ -> called.set(true)); + var result = errResult().peek(value -> called.set(true)); assertEquals(errResult(), result); assertFalse(called.get()); @@ -676,7 +676,7 @@ void rejectsNullObserver() { @Test void panicsWhenObserverThrows() { - assertThatThrownBy(() -> okResult().peek(_ -> { + assertThatThrownBy(() -> okResult().peek(value -> { throw new IllegalStateException("boom"); })) .isInstanceOf(Panic.class) @@ -706,7 +706,7 @@ void observesErrorForErr() { void preservesValueForOkWithoutCallingObserver() { var called = new AtomicBoolean(false); - var result = okResult().peekErr(_ -> called.set(true)); + var result = okResult().peekErr(error -> called.set(true)); assertEquals(okResult(), result); assertFalse(called.get()); @@ -724,7 +724,7 @@ void rejectsNullObserver() { @Test void panicsWhenObserverThrows() { - assertThatThrownBy(() -> errResult().peekErr(_ -> { + assertThatThrownBy(() -> errResult().peekErr(error -> { throw new IllegalStateException("boom"); })) .isInstanceOf(Panic.class) @@ -841,7 +841,7 @@ void rejectsNullMappers() { @Test void panicsWhenOkMapperThrows() { - assertThatThrownBy(() -> okResult().fold(_ -> { + assertThatThrownBy(() -> okResult().fold(value -> { throw new IllegalStateException("boom"); }, String::length)) .isInstanceOf(Panic.class) @@ -853,7 +853,7 @@ void panicsWhenOkMapperThrows() { @Test void panicsWhenErrorMapperThrows() { - assertThatThrownBy(() -> errResult().fold(value -> value * 2, _ -> { + assertThatThrownBy(() -> errResult().fold(value -> value * 2, error -> { throw new IllegalStateException("boom"); })) .isInstanceOf(Panic.class)