diff --git a/.github/release-automation/.gitignore b/.github/release-automation/.gitignore new file mode 100644 index 0000000000..67bcc2f727 --- /dev/null +++ b/.github/release-automation/.gitignore @@ -0,0 +1,2 @@ +.gradle/ +build/ diff --git a/.github/release-automation/build.gradle b/.github/release-automation/build.gradle new file mode 100644 index 0000000000..f9a283648b --- /dev/null +++ b/.github/release-automation/build.gradle @@ -0,0 +1,63 @@ +plugins { + id 'application' + id 'com.diffplug.spotless' version '7.0.2' +} + +repositories { + mavenCentral() +} + +dependencies { + implementation 'io.temporal:temporal-envconfig' + implementation 'io.temporal:temporal-sdk' + implementation 'com.google.code.gson:gson:2.10.1' + runtimeOnly 'org.slf4j:slf4j-simple:1.7.36' + + testImplementation 'io.temporal:temporal-testing' + testImplementation 'junit:junit:4.13.2' + testImplementation 'org.mockito:mockito-core:5.14.2' +} + +java { + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 +} + +application { + mainClass = 'io.temporal.releaseautomation.ReleaseAutomationMain' +} + +tasks.named('run') { + // The trusted scripts live at repository-relative paths. Do not let Gradle's + // application plugin make the standalone build directory the trust root. + systemProperty 'releaseAutomation.repositoryRoot', file('../..').canonicalPath +} + +tasks.withType(JavaCompile).configureEach { + options.encoding = 'UTF-8' + options.compilerArgs.addAll([ + '-Xlint:all', + '-Werror', + '-parameters' + ]) +} + +tasks.withType(Test).configureEach { + systemProperty 'releaseAutomation.repositoryRoot', file('../..').canonicalPath + useJUnit() + testLogging { + events 'passed', 'skipped', 'failed' + exceptionFormat 'full' + } +} + +spotless { + java { + googleJavaFormat('1.28.0') + target 'src/**/*.java' + } + groovyGradle { + greclipse() + target '*.gradle' + } +} diff --git a/.github/release-automation/docker/native-image-java23/dockerfile b/.github/release-automation/docker/native-image-java23/dockerfile new file mode 100644 index 0000000000..1c3eee968c --- /dev/null +++ b/.github/release-automation/docker/native-image-java23/dockerfile @@ -0,0 +1,20 @@ +FROM ghcr.io/graalvm/native-image-community:23@sha256:5a28da013d97ac66033b6c836015c7ee7f5d3e07c705d51f1e6c26a04fd17b8e AS graalvm +FROM ubuntu:18.04@sha256:152dc042452c496007f07ca9127571cb9c29697f42acbfad72324b2bb2e43c98 +ENV JAVA_HOME=/usr/lib64/graalvm/graalvm-community-java23 +ENV PATH="${JAVA_HOME}/bin:${PATH}" +COPY --from=graalvm ${JAVA_HOME} ${JAVA_HOME} +# Launchpad PPA fingerprint: C8EC952E2A0E1FBDC5090F6A2C277A0A352154E5. +COPY ubuntu-toolchain-r-test.asc /usr/share/keyrings/ubuntu-toolchain-r-test.asc +RUN apt-get update --allow-releaseinfo-change && \ + DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends \ + ca-certificates && \ + printf '%s\n' \ + 'deb [signed-by=/usr/share/keyrings/ubuntu-toolchain-r-test.asc] https://ppa.launchpadcontent.net/ubuntu-toolchain-r/test/ubuntu bionic main' \ + > /etc/apt/sources.list.d/ubuntu-toolchain-r-test.list && \ + apt-get update && \ + DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends \ + git build-essential zlib1g-dev gcc-10 g++-10 && \ + update-alternatives --install /usr/bin/gcc gcc /usr/bin/gcc-10 60 \ + --slave /usr/bin/g++ g++ /usr/bin/g++-10 && \ + rm -rf /var/lib/apt/lists/* +RUN git config --global --add safe.directory '*' diff --git a/.github/release-automation/docker/native-image-java23/ubuntu-toolchain-r-test.asc b/.github/release-automation/docker/native-image-java23/ubuntu-toolchain-r-test.asc new file mode 100644 index 0000000000..f797413dbd --- /dev/null +++ b/.github/release-automation/docker/native-image-java23/ubuntu-toolchain-r-test.asc @@ -0,0 +1,31 @@ +-----BEGIN PGP PUBLIC KEY BLOCK----- +Comment: Hostname: +Version: Hockeypuck 2.2 + +xsFNBGYzeoEBEADEVzm0fPTo1KfunTjKV5ZP0Mb86Z6izCXSYbeYt/iAaIfK7a8x +c13I/uAOxzjcuZHD4sQh7/22I74ignCSexoJD3PnytpZtAxNjyj3jkJpIrdFOVd4 +Cmua5tnEI1Av+B21YLlYYftK2wR+Z6hpsz3NyQ8URR1SCX04BOV9AGxcWb7izkV9 +RHu6xR4x8kAjuTObhhqw/ZR73+U9mig785qmrtapQkF/IanYj2OofqUJwosDo8YA +shi01735JP9s5do4nnsb6w6xcYQ0HlGn3AxR1k4WA2CK/90QybOmVbLz7IOF3Ou3 +cPfoDzSxAiQemge2msJWPaQLSy/HPUivPB2uNPxkcNIHUVwbjgIig0yaN0EOsveg +o1m6QvS8sm1MEA2ySl+9+q8wdqTnXbSgA3hq/5OoG4rvHD9GUAd6oRxbz/wdOU3J +7BlAhEMAHgeQ9DyIzRP9lGeU3QnWNA+WFiiDrLljf+6xewe5iyqLxPqvCkFUDPCF +emp2oIqeWf6l04IMtM4SJz2AnU1EvytprEEvntMvHMXlBBinw1Q5r3ES/QlPfWr4 +o5CUpu2P3GpZtflDe7jvccyJUMDtlqOVv7wBZPMZykjTAPQ16clGdtIqWdQSk3/b +UA96RSdpM4gMGXFBGLBoAd7CptafRvPjpRnbMxs2jIxicnASfwJALEFgbQARAQAB +zT9MYXVuY2hwYWQgUFBBIGZvciBQUEEgZm9yIFVidW50dSBUb29sY2hhaW4gVXBs +b2FkcyAocmVzdHJpY3RlZCnCwY4EEwEKADgWIQTI7JUuKg4fvcUJD2osJ3oKNSFU +5QUCZjN6gQIbAwULCQgHAgYVCgkICwIEFgIDAQIeAQIXgAAKCRAsJ3oKNSFU5TkT +EACpFJ+q0xqfAmRF1886cgtVw+ruJTqfsxdj7ms1os3/I5VXLhPMbUg56mtK9Okj +0Nf9rY5bnDagJobjtS2JgzMbYWXocPJuwK6ZvmMD5tFSsTov19R3SyIT2x3JAy7S +/LNWqVeZS41RtM8uSu56PhghE7aAP9PigFTI3CUargpx1DQh6xiHKRR57ZMrLcd4 +l6tj8yb4Hl+OeFRQMcoim696lPMSqzVtJ0epP7yNsz2LFaRmkRP/hf+S4JlSs8uC +wzAl9eJR2h1v1WoBr0pAW2gjjd8VyKWhaKjneicJPqo5GDvhDl+lbrpqSwSHENlH +DVyC+XvAjvTf4JhTQE/M1L/sGrqqa65n+WF4ZqauiDvn1bUw0vnL0oaiK/hyH1YG +XW7khJ7JDlw/6LFNZ6Ih0UuVRTzVBBBz3Sipw5hgM9ztKEYLMI5zlwWGlhEjMnxD +X5WauIUWjPv3dKQp1qTthN7Fgv3/2J6j+2/tMAfWeWB1tL8H4lVPUCfcpQpkTP/E +HTnB3oaRiYy6GDuGKsNkhEUvlV2AnVAxO17IN5QQ2lMQ261zaPj9y9v1oCQlzGfm +UKRJWFGbsooapdm7DTrqrfO+zyRXWPe6GRnYlmeXTRWi8CEIXpCkhhf8rma8KNnW +S96jsTSU97Lo0hnIbL+4o3qf2BiYcfIwfoIXzUaj5ajGEA== +=CwJW +-----END PGP PUBLIC KEY BLOCK----- diff --git a/.github/release-automation/docker/native-image-musl-java23/dockerfile b/.github/release-automation/docker/native-image-musl-java23/dockerfile new file mode 100644 index 0000000000..18a2eedb8c --- /dev/null +++ b/.github/release-automation/docker/native-image-musl-java23/dockerfile @@ -0,0 +1,14 @@ +FROM ghcr.io/graalvm/native-image-community:23@sha256:5a28da013d97ac66033b6c836015c7ee7f5d3e07c705d51f1e6c26a04fd17b8e AS graalvm +FROM ubuntu:22.04@sha256:0e0a0fc6d18feda9db1590da249ac93e8d5abfea8f4c3c0c849ce512b5ef8982 +ENV JAVA_HOME=/usr/lib64/graalvm/graalvm-community-java23 +ENV PATH="${JAVA_HOME}/bin:${PATH}" +COPY --from=graalvm ${JAVA_HOME} ${JAVA_HOME} +RUN apt-get update && \ + DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends \ + git build-essential curl ca-certificates binutils make tar gzip && \ + rm -rf /var/lib/apt/lists/* +COPY install-musl.sh /opt/install-musl.sh +RUN chmod +x /opt/install-musl.sh && cd /opt && ./install-musl.sh +ENV MUSL_HOME=/opt/musl-toolchain +ENV PATH="/opt/musl-toolchain/bin:${PATH}" +RUN git config --global --add safe.directory '*' diff --git a/.github/release-automation/docker/native-image-musl-java23/install-musl.sh b/.github/release-automation/docker/native-image-musl-java23/install-musl.sh new file mode 100644 index 0000000000..8ecbea32ba --- /dev/null +++ b/.github/release-automation/docker/native-image-musl-java23/install-musl.sh @@ -0,0 +1,24 @@ +#!/usr/bin/env bash +set -euo pipefail +MUSL_VERSION=1.2.5 +ZLIB_VERSION=1.2.13 +export MUSL_HOME=/opt/musl-toolchain +curl --fail --location --retry 5 --retry-all-errors \ + --output "musl-$MUSL_VERSION.tar.gz" \ + "https://musl.libc.org/releases/musl-$MUSL_VERSION.tar.gz" +curl --fail --location --retry 5 --retry-all-errors \ + --output "zlib-$ZLIB_VERSION.tar.gz" \ + "https://github.com/madler/zlib/releases/download/v$ZLIB_VERSION/zlib-$ZLIB_VERSION.tar.gz" +tar -xzf "musl-$MUSL_VERSION.tar.gz" +cd "musl-$MUSL_VERSION" +./configure --prefix="$MUSL_HOME" --static +make -j"$(nproc)" +make install +cd .. +ln -sf "$MUSL_HOME/bin/musl-gcc" "$MUSL_HOME/bin/x86_64-linux-musl-gcc" +export PATH="$MUSL_HOME/bin:$PATH" +tar -xzf "zlib-$ZLIB_VERSION.tar.gz" +cd "zlib-$ZLIB_VERSION" +CC=musl-gcc ./configure --prefix="$MUSL_HOME" --static +make -j"$(nproc)" +make install diff --git a/.github/release-automation/settings.gradle b/.github/release-automation/settings.gradle new file mode 100644 index 0000000000..4d0612d218 --- /dev/null +++ b/.github/release-automation/settings.gradle @@ -0,0 +1,16 @@ +pluginManagement { + repositories { + gradlePluginPortal() + mavenCentral() + } +} + +rootProject.name = 'temporal-release-automation' + +includeBuild('../..') { + dependencySubstitution { + substitute module('io.temporal:temporal-envconfig') using project(':temporal-envconfig') + substitute module('io.temporal:temporal-sdk') using project(':temporal-sdk') + substitute module('io.temporal:temporal-testing') using project(':temporal-testing') + } +} diff --git a/.github/release-automation/src/main/java/io/temporal/releaseautomation/ApprovalEvidence.java b/.github/release-automation/src/main/java/io/temporal/releaseautomation/ApprovalEvidence.java new file mode 100644 index 0000000000..49baa35cc9 --- /dev/null +++ b/.github/release-automation/src/main/java/io/temporal/releaseautomation/ApprovalEvidence.java @@ -0,0 +1,73 @@ +package io.temporal.releaseautomation; + +import java.util.Locale; +import java.util.regex.Pattern; + +public final class ApprovalEvidence { + private static final Pattern SHA = Pattern.compile("[0-9a-f]{40}"); + private static final Pattern ACTOR = Pattern.compile("[A-Za-z0-9-]{1,39}"); + private static final Pattern WORKFLOW_ID = Pattern.compile("sdk-java-release/[0-9a-f]{64}"); + private static final Pattern RUN_ID = Pattern.compile("[0-9a-fA-F-]{16,64}"); + + public String releaseDigest; + public String workflowId; + public String runId; + public long githubApprovalRunId; + public String githubActor; + public long githubIssueNumber; + public String githubIssueNodeId; + public String githubIssueBodySha256; + public String trustedWorkerCommit; + + public ApprovalEvidence() {} + + public ApprovalEvidence( + String releaseDigest, + String workflowId, + String runId, + long githubApprovalRunId, + String githubActor, + long githubIssueNumber, + String githubIssueNodeId, + String githubIssueBodySha256, + String trustedWorkerCommit) { + this.releaseDigest = releaseDigest.toLowerCase(Locale.ROOT); + this.workflowId = workflowId; + this.runId = runId; + this.githubApprovalRunId = githubApprovalRunId; + this.githubActor = githubActor; + this.githubIssueNumber = githubIssueNumber; + this.githubIssueNodeId = githubIssueNodeId; + this.githubIssueBodySha256 = githubIssueBodySha256; + this.trustedWorkerCommit = trustedWorkerCommit.toLowerCase(Locale.ROOT); + validate(); + } + + public void validate() { + if (releaseDigest == null || !releaseDigest.matches("[0-9a-f]{64}")) { + throw new IllegalArgumentException("Approval release digest is invalid."); + } + if (workflowId == null || !WORKFLOW_ID.matcher(workflowId).matches()) { + throw new IllegalArgumentException("Approval workflow ID is invalid."); + } + if (runId == null || !RUN_ID.matcher(runId).matches()) { + throw new IllegalArgumentException("Approval run ID is invalid."); + } + if (githubApprovalRunId <= 0) { + throw new IllegalArgumentException("GitHub approval run ID is invalid."); + } + if (githubActor == null || !ACTOR.matcher(githubActor).matches()) { + throw new IllegalArgumentException("GitHub approval actor is invalid."); + } + if (githubIssueNumber <= 0 + || githubIssueNodeId == null + || !githubIssueNodeId.matches("[A-Za-z0-9_=-]{8,128}") + || githubIssueBodySha256 == null + || !githubIssueBodySha256.matches("[0-9a-f]{64}")) { + throw new IllegalArgumentException("GitHub approval issue identity is invalid."); + } + if (trustedWorkerCommit == null || !SHA.matcher(trustedWorkerCommit).matches()) { + throw new IllegalArgumentException("Trusted worker commit must be a full SHA."); + } + } +} diff --git a/.github/release-automation/src/main/java/io/temporal/releaseautomation/ApprovalRequest.java b/.github/release-automation/src/main/java/io/temporal/releaseautomation/ApprovalRequest.java new file mode 100644 index 0000000000..f9b8e0e971 --- /dev/null +++ b/.github/release-automation/src/main/java/io/temporal/releaseautomation/ApprovalRequest.java @@ -0,0 +1,83 @@ +package io.temporal.releaseautomation; + +public final class ApprovalRequest { + public String releaseDigest; + public String workflowId; + public String runId; + public long githubRunId; + public long githubIssueNumber; + public String githubIssueNodeId; + public String githubIssueBodySha256; + public String githubIssueCreator; + public String trustedWorkerCommit; + + public ApprovalRequest() {} + + public ApprovalRequest( + String releaseDigest, + String workflowId, + String runId, + long githubRunId, + long githubIssueNumber, + String githubIssueNodeId, + String githubIssueBodySha256, + String githubIssueCreator, + String trustedWorkerCommit) { + this.releaseDigest = releaseDigest; + this.workflowId = workflowId; + this.runId = runId; + this.githubRunId = githubRunId; + this.githubIssueNumber = githubIssueNumber; + this.githubIssueNodeId = githubIssueNodeId; + this.githubIssueBodySha256 = githubIssueBodySha256; + this.githubIssueCreator = githubIssueCreator; + this.trustedWorkerCommit = trustedWorkerCommit; + validate(); + } + + public void validate() { + if (releaseDigest == null + || !releaseDigest.matches("[0-9a-f]{64}") + || workflowId == null + || !workflowId.matches("sdk-java-release/[0-9a-f]{64}") + || runId == null + || !runId.matches("[0-9a-fA-F-]{16,64}") + || githubRunId <= 0 + || githubIssueNumber <= 0 + || githubIssueNodeId == null + || !githubIssueNodeId.matches("[A-Za-z0-9_=-]{8,128}") + || githubIssueBodySha256 == null + || !githubIssueBodySha256.matches("[0-9a-f]{64}") + || githubIssueCreator == null + || !githubIssueCreator.matches("[A-Za-z0-9-]{1,39}") + || trustedWorkerCommit == null + || !trustedWorkerCommit.matches("[0-9a-f]{40}")) { + throw new IllegalArgumentException("Invalid release-specific approval request."); + } + } + + public boolean matches(ApprovalEvidence evidence) { + validate(); + evidence.validate(); + return releaseDigest.equals(evidence.releaseDigest) + && workflowId.equals(evidence.workflowId) + && runId.equals(evidence.runId) + && githubIssueNumber == evidence.githubIssueNumber + && githubIssueNodeId.equals(evidence.githubIssueNodeId) + && githubIssueBodySha256.equals(evidence.githubIssueBodySha256) + && trustedWorkerCommit.equals(evidence.trustedWorkerCommit); + } + + public boolean sameIssue(ApprovalRequest other) { + validate(); + other.validate(); + return releaseDigest.equals(other.releaseDigest) + && workflowId.equals(other.workflowId) + && runId.equals(other.runId) + && githubIssueNumber == other.githubIssueNumber + && githubIssueNodeId.equals(other.githubIssueNodeId) + && githubIssueBodySha256.equals(other.githubIssueBodySha256) + && githubIssueCreator.equals(other.githubIssueCreator) + && trustedWorkerCommit.equals(other.trustedWorkerCommit); + } +} diff --git a/.github/release-automation/src/main/java/io/temporal/releaseautomation/ArtifactEntry.java b/.github/release-automation/src/main/java/io/temporal/releaseautomation/ArtifactEntry.java new file mode 100644 index 0000000000..b45d4f1e51 --- /dev/null +++ b/.github/release-automation/src/main/java/io/temporal/releaseautomation/ArtifactEntry.java @@ -0,0 +1,44 @@ +package io.temporal.releaseautomation; + +import java.util.Locale; +import java.util.regex.Pattern; + +public final class ArtifactEntry implements Comparable { + private static final Pattern NAME = Pattern.compile("[A-Za-z0-9][A-Za-z0-9._-]*"); + private static final Pattern HASH = Pattern.compile("[0-9a-f]{64}"); + + public String name; + public String sha256; + public long size; + + public ArtifactEntry() {} + + public ArtifactEntry(String name, String sha256, long size) { + this.name = name; + this.sha256 = sha256.toLowerCase(Locale.ROOT); + this.size = size; + validate(); + } + + public void validate() { + if (name == null || !NAME.matcher(name).matches()) { + throw new IllegalArgumentException("Artifact name must be a basename."); + } + if (sha256 == null || !HASH.matcher(sha256).matches()) { + throw new IllegalArgumentException("Artifact hash must be SHA-256."); + } + if (size <= 0) { + throw new IllegalArgumentException("Artifact size must be positive."); + } + } + + String canonicalForm() { + validate(); + return name + "\t" + sha256 + "\t" + size; + } + + @Override + public int compareTo(ArtifactEntry other) { + return name.compareTo(other.name); + } +} diff --git a/.github/release-automation/src/main/java/io/temporal/releaseautomation/ArtifactManifest.java b/.github/release-automation/src/main/java/io/temporal/releaseautomation/ArtifactManifest.java new file mode 100644 index 0000000000..56ab06f056 --- /dev/null +++ b/.github/release-automation/src/main/java/io/temporal/releaseautomation/ArtifactManifest.java @@ -0,0 +1,46 @@ +package io.temporal.releaseautomation; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.HashSet; +import java.util.List; +import java.util.Set; + +public final class ArtifactManifest { + public List artifacts = new ArrayList<>(); + + public ArtifactManifest() {} + + public ArtifactManifest(List artifacts) { + this.artifacts = new ArrayList<>(artifacts); + validate(); + } + + public void validate() { + if (artifacts == null || artifacts.isEmpty()) { + throw new IllegalArgumentException("The artifact manifest must not be empty."); + } + Set names = new HashSet<>(); + for (GithubArtifactReceipt artifact : artifacts) { + artifact.validate(); + if (!names.add(artifact.artifactName)) { + throw new IllegalArgumentException("Duplicate artifact name: " + artifact.artifactName); + } + } + } + + public String canonicalForm() { + validate(); + List sorted = new ArrayList<>(artifacts); + Collections.sort(sorted); + StringBuilder result = new StringBuilder(); + for (GithubArtifactReceipt artifact : sorted) { + result.append(artifact.canonicalForm()).append('\n'); + } + return result.toString(); + } + + public String digest() { + return Digests.sha256(canonicalForm()); + } +} diff --git a/.github/release-automation/src/main/java/io/temporal/releaseautomation/CandidateIdentity.java b/.github/release-automation/src/main/java/io/temporal/releaseautomation/CandidateIdentity.java new file mode 100644 index 0000000000..8632c0e260 --- /dev/null +++ b/.github/release-automation/src/main/java/io/temporal/releaseautomation/CandidateIdentity.java @@ -0,0 +1,82 @@ +package io.temporal.releaseautomation; + +import java.util.Locale; +import java.util.regex.Pattern; + +public final class CandidateIdentity { + public static final String REPOSITORY = ReleasePolicy.REPOSITORY; + private static final Pattern TAG = Pattern.compile("v[0-9]+\\.[0-9]+\\.[0-9]+(?:-RC[0-9]+)?"); + private static final Pattern SHA = Pattern.compile("[0-9a-f]{40}"); + private static final Pattern HASH = Pattern.compile("[0-9a-f]{64}"); + + public String tag; + public String commitSha; + public String releaseNotesSha256; + public String trustedAutomationCommit; + public String mavenPolicy; + + public CandidateIdentity() {} + + public CandidateIdentity( + String tag, + String commitSha, + String releaseNotesSha256, + String trustedAutomationCommit, + String mavenPolicy) { + this.tag = tag; + this.commitSha = commitSha.toLowerCase(Locale.ROOT); + this.releaseNotesSha256 = releaseNotesSha256.toLowerCase(Locale.ROOT); + this.trustedAutomationCommit = trustedAutomationCommit.toLowerCase(Locale.ROOT); + this.mavenPolicy = mavenPolicy; + validate(); + } + + public void validate() { + require(tag != null && TAG.matcher(tag).matches(), "Invalid release tag."); + require(commitSha != null && SHA.matcher(commitSha).matches(), "Commit must be a full SHA."); + require( + releaseNotesSha256 != null && HASH.matcher(releaseNotesSha256).matches(), + "Release notes hash must be SHA-256."); + require( + trustedAutomationCommit != null && SHA.matcher(trustedAutomationCommit).matches(), + "Trusted automation commit must be a full SHA."); + ReleasePolicy.mavenArtifacts(mavenPolicy); + } + + public String canonicalForm() { + validate(); + return REPOSITORY + + "\n" + + version() + + "\n" + + tag + + "\n" + + commitSha + + "\n" + + releaseNotesPath() + + "\n" + + releaseNotesSha256 + + "\n" + + trustedAutomationCommit + + "\n" + + mavenPolicy; + } + + public String digest() { + return Digests.sha256(canonicalForm()); + } + + public String version() { + return tag.substring(1); + } + + public String releaseNotesPath() { + return "releases/" + tag; + } + + private static void require(boolean condition, String message) { + if (!condition) { + throw new IllegalArgumentException(message); + } + } +} diff --git a/.github/release-automation/src/main/java/io/temporal/releaseautomation/CandidateStatus.java b/.github/release-automation/src/main/java/io/temporal/releaseautomation/CandidateStatus.java new file mode 100644 index 0000000000..13586a1d83 --- /dev/null +++ b/.github/release-automation/src/main/java/io/temporal/releaseautomation/CandidateStatus.java @@ -0,0 +1,24 @@ +package io.temporal.releaseautomation; + +import java.util.ArrayList; +import java.util.List; + +public final class CandidateStatus { + public CandidateIdentity identity; + public List pendingPlatforms = new ArrayList<>(); + public List artifacts = new ArrayList<>(); + public ReleaseIdentity releaseIdentity; + + public CandidateStatus() {} + + CandidateStatus( + CandidateIdentity identity, + List pendingPlatforms, + List artifacts, + ReleaseIdentity releaseIdentity) { + this.identity = identity; + this.pendingPlatforms = new ArrayList<>(pendingPlatforms); + this.artifacts = new ArrayList<>(artifacts); + this.releaseIdentity = releaseIdentity; + } +} diff --git a/.github/release-automation/src/main/java/io/temporal/releaseautomation/CandidateWorkflow.java b/.github/release-automation/src/main/java/io/temporal/releaseautomation/CandidateWorkflow.java new file mode 100644 index 0000000000..a9d04f7c55 --- /dev/null +++ b/.github/release-automation/src/main/java/io/temporal/releaseautomation/CandidateWorkflow.java @@ -0,0 +1,18 @@ +package io.temporal.releaseautomation; + +import io.temporal.workflow.QueryMethod; +import io.temporal.workflow.UpdateMethod; +import io.temporal.workflow.WorkflowInterface; +import io.temporal.workflow.WorkflowMethod; + +@WorkflowInterface +public interface CandidateWorkflow { + @WorkflowMethod + ReleaseIdentity prepare(CandidateIdentity candidate); + + @UpdateMethod + CandidateStatus recordArtifact(String platform, GithubArtifactReceipt artifact); + + @QueryMethod + CandidateIdentity candidate(); +} diff --git a/.github/release-automation/src/main/java/io/temporal/releaseautomation/CandidateWorkflowImpl.java b/.github/release-automation/src/main/java/io/temporal/releaseautomation/CandidateWorkflowImpl.java new file mode 100644 index 0000000000..a7d95ddbc1 --- /dev/null +++ b/.github/release-automation/src/main/java/io/temporal/releaseautomation/CandidateWorkflowImpl.java @@ -0,0 +1,93 @@ +package io.temporal.releaseautomation; + +import io.temporal.api.enums.v1.ParentClosePolicy; +import io.temporal.workflow.Async; +import io.temporal.workflow.ChildWorkflowOptions; +import io.temporal.workflow.UpdateValidatorMethod; +import io.temporal.workflow.Workflow; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; + +public final class CandidateWorkflowImpl implements CandidateWorkflow { + static final String STATUS_MEMO_KEY = "CandidateStatus"; + private CandidateIdentity candidate; + private final List artifacts = new ArrayList<>(); + private final List pendingPlatforms = new ArrayList<>(); + private ReleaseIdentity releaseIdentity; + + @Override + public ReleaseIdentity prepare(CandidateIdentity candidateIdentity) { + candidateIdentity.validate(); + candidate = candidateIdentity; + pendingPlatforms.addAll(ReleasePolicy.NATIVE_PLATFORMS); + upsertStatus(); + Workflow.await(() -> pendingPlatforms.isEmpty()); + releaseIdentity = + new ReleaseIdentity( + candidateIdentity, new ArtifactManifest(artifacts), Workflow.getInfo().getRunId()); + // Visibility can now discover and start a Worker for the child queue before the child's + // first Workflow Task has written its own status memo. + upsertStatus(); + ReleaseWorkflow child = + Workflow.newChildWorkflowStub( + ReleaseWorkflow.class, + ChildWorkflowOptions.newBuilder() + .setWorkflowId(QueueNames.releaseWorkflowId(releaseIdentity)) + .setTaskQueue(QueueNames.releaseWorkflow(releaseIdentity)) + .setMemo( + Collections.singletonMap( + ReleaseWorkflowImpl.IDENTITY_MEMO_KEY, releaseIdentity)) + .setParentClosePolicy(ParentClosePolicy.PARENT_CLOSE_POLICY_ABANDON) + .build()); + Async.function(child::release, releaseIdentity); + Workflow.getWorkflowExecution(child).get(); + return releaseIdentity; + } + + @Override + public CandidateStatus recordArtifact(String platform, GithubArtifactReceipt artifact) { + validateArtifact(platform, artifact); + if (pendingPlatforms.remove(platform)) { + artifacts.add(artifact); + } + upsertStatus(); + return status(); + } + + @UpdateValidatorMethod(updateName = "recordArtifact") + public void validateArtifact(String platform, GithubArtifactReceipt artifact) { + if (candidate == null) { + throw new IllegalStateException("Candidate is not waiting for this native platform."); + } + artifact.validate(); + String expectedFile = ReleasePolicy.nativeArtifactName(candidate.version(), platform); + if (!ReleasePolicy.githubNativeArtifactName(candidate, platform).equals(artifact.artifactName) + || artifact.files.size() != 1 + || !expectedFile.equals(artifact.files.get(0).name)) { + throw new IllegalArgumentException("GitHub artifact does not match the native platform."); + } + if (!pendingPlatforms.contains(platform)) { + for (GithubArtifactReceipt existing : artifacts) { + if (existing.artifactName.equals(artifact.artifactName) + && existing.canonicalForm().equals(artifact.canonicalForm())) { + return; + } + } + throw new IllegalStateException("Candidate already recorded another native artifact."); + } + } + + @Override + public CandidateIdentity candidate() { + return candidate; + } + + private CandidateStatus status() { + return new CandidateStatus(candidate, pendingPlatforms, artifacts, releaseIdentity); + } + + private void upsertStatus() { + Workflow.upsertMemo(Collections.singletonMap(STATUS_MEMO_KEY, status())); + } +} diff --git a/.github/release-automation/src/main/java/io/temporal/releaseautomation/ControlEvidence.java b/.github/release-automation/src/main/java/io/temporal/releaseautomation/ControlEvidence.java new file mode 100644 index 0000000000..0530f5729d --- /dev/null +++ b/.github/release-automation/src/main/java/io/temporal/releaseautomation/ControlEvidence.java @@ -0,0 +1,84 @@ +package io.temporal.releaseautomation; + +public final class ControlEvidence { + public String action; + public String releaseDigest; + public String workflowId; + public String runId; + public long githubRunId; + public String githubActor; + public String tag; + public String commitSha; + public String reason; + public long recordedAtMillis; + public int mavenSubmissionGeneration = -1; + public String authorizationSha256; + public MavenInspection mavenInspection; + public boolean manualMavenRequested; + + public ControlEvidence() {} + + public ControlEvidence( + String action, + String releaseDigest, + String workflowId, + String runId, + long githubRunId, + String githubActor, + String tag, + String commitSha, + String reason) { + this.action = action; + this.releaseDigest = releaseDigest; + this.workflowId = workflowId; + this.runId = runId; + this.githubRunId = githubRunId; + this.githubActor = githubActor; + this.tag = tag; + this.commitSha = commitSha; + this.reason = reason; + validate(); + } + + public void validate() { + if (!("pause".equals(action) + || "resume".equals(action) + || "handoff-manual".equals(action) + || "retry-maven-submission".equals(action)) + || releaseDigest == null + || !releaseDigest.matches("[0-9a-f]{64}") + || workflowId == null + || !workflowId.matches("sdk-java-release/[0-9a-f]{64}") + || runId == null + || !runId.matches("[0-9a-fA-F-]{16,64}") + || githubRunId <= 0 + || githubActor == null + || !githubActor.matches("[A-Za-z0-9-]{1,39}") + || tag == null + || !tag.matches("v[0-9]+\\.[0-9]+\\.[0-9]+(?:-RC[0-9]+)?") + || commitSha == null + || !commitSha.matches("[0-9a-f]{40}") + || reason == null + || reason.isEmpty()) { + throw new IllegalArgumentException("Invalid authenticated release control evidence."); + } + if ("retry-maven-submission".equals(action) + && (mavenSubmissionGeneration <= 0 + || authorizationSha256 == null + || !authorizationSha256.matches("[0-9a-f]{64}") + || mavenInspection == null)) { + throw new IllegalArgumentException( + "Maven retry control requires an exact authorized inspection."); + } + if ("retry-maven-submission".equals(action)) { + mavenInspection.validate(releaseDigest); + if (!Digests.sha256(mavenInspection.canonicalForm(releaseDigest)) + .equals(authorizationSha256)) { + throw new IllegalArgumentException("Maven retry inspection digest differs."); + } + } + if (manualMavenRequested && !"handoff-manual".equals(action)) { + throw new IllegalArgumentException("Manual Maven intent is only valid for a manual handoff."); + } + } +} diff --git a/.github/release-automation/src/main/java/io/temporal/releaseautomation/Digests.java b/.github/release-automation/src/main/java/io/temporal/releaseautomation/Digests.java new file mode 100644 index 0000000000..409cf90c7b --- /dev/null +++ b/.github/release-automation/src/main/java/io/temporal/releaseautomation/Digests.java @@ -0,0 +1,22 @@ +package io.temporal.releaseautomation; + +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.util.HexFormat; + +final class Digests { + private static final HexFormat HEX = HexFormat.of(); + + private Digests() {} + + static String sha256(String value) { + try { + byte[] digest = + MessageDigest.getInstance("SHA-256").digest(value.getBytes(StandardCharsets.UTF_8)); + return HEX.formatHex(digest); + } catch (NoSuchAlgorithmException e) { + throw new IllegalStateException("SHA-256 is required by the Java runtime.", e); + } + } +} diff --git a/.github/release-automation/src/main/java/io/temporal/releaseautomation/DiscoveryJob.java b/.github/release-automation/src/main/java/io/temporal/releaseautomation/DiscoveryJob.java new file mode 100644 index 0000000000..ba3d9f7c84 --- /dev/null +++ b/.github/release-automation/src/main/java/io/temporal/releaseautomation/DiscoveryJob.java @@ -0,0 +1,35 @@ +package io.temporal.releaseautomation; + +final class DiscoveryJob { + String role; + String runner; + String distribution; + String taskQueue; + String platform; + String workflowId; + String runId; + String tag; + String version; + String commitSha; + String notesSha256; + String manifestSha256; + String releaseDigest; + String candidateDigest; + String candidateRunId; + String approvalIssueNumber; + String approvalIssueNodeId; + String approvalIssueBodySha256; + String automationCommit; + String javaVersion; + String assetPlatform; + String archiveExtension; + String binaryName; + + DiscoveryJob(String role, String taskQueue) { + this.role = role; + this.taskQueue = taskQueue; + this.runner = "ubuntu-latest"; + this.distribution = "temurin"; + this.javaVersion = "17"; + } +} diff --git a/.github/release-automation/src/main/java/io/temporal/releaseautomation/GithubArtifactReceipt.java b/.github/release-automation/src/main/java/io/temporal/releaseautomation/GithubArtifactReceipt.java new file mode 100644 index 0000000000..1b31eed4c0 --- /dev/null +++ b/.github/release-automation/src/main/java/io/temporal/releaseautomation/GithubArtifactReceipt.java @@ -0,0 +1,107 @@ +package io.temporal.releaseautomation; + +import java.time.Instant; +import java.time.format.DateTimeParseException; +import java.util.ArrayList; +import java.util.Collections; +import java.util.HashSet; +import java.util.List; +import java.util.Set; +import java.util.regex.Pattern; + +public final class GithubArtifactReceipt implements Comparable { + private static final Pattern NAME = Pattern.compile("[A-Za-z0-9][A-Za-z0-9._-]*"); + private static final Pattern DIGEST = Pattern.compile("sha256:[0-9a-f]{64}"); + + public long artifactId; + public long workflowRunId; + public String artifactName; + public String githubDigest; + public String createdAt; + public String expiresAt; + public List files = new ArrayList<>(); + + public GithubArtifactReceipt() {} + + public GithubArtifactReceipt( + long artifactId, + long workflowRunId, + String artifactName, + String githubDigest, + String createdAt, + String expiresAt, + List files) { + this.artifactId = artifactId; + this.workflowRunId = workflowRunId; + this.artifactName = artifactName; + this.githubDigest = githubDigest; + this.createdAt = createdAt; + this.expiresAt = expiresAt; + this.files = new ArrayList<>(files); + validate(); + } + + public void validate() { + if (artifactId <= 0 || workflowRunId <= 0) { + throw new IllegalArgumentException("GitHub artifact and run IDs must be positive."); + } + if (artifactName == null || !NAME.matcher(artifactName).matches()) { + throw new IllegalArgumentException("GitHub artifact name is invalid."); + } + if (githubDigest == null || !DIGEST.matcher(githubDigest).matches()) { + throw new IllegalArgumentException("GitHub artifact digest must be SHA-256."); + } + Instant created = parseInstant(createdAt, "creation"); + Instant expires = parseInstant(expiresAt, "expiration"); + if (!expires.isAfter(created)) { + throw new IllegalArgumentException("GitHub artifact expiration must follow creation."); + } + if (files == null || files.isEmpty()) { + throw new IllegalArgumentException("GitHub artifact must contain expected files."); + } + Set names = new HashSet<>(); + for (ArtifactEntry file : files) { + file.validate(); + if (!names.add(file.name)) { + throw new IllegalArgumentException("Duplicate GitHub artifact filename: " + file.name); + } + } + } + + public String canonicalForm() { + validate(); + List sorted = new ArrayList<>(files); + Collections.sort(sorted); + StringBuilder result = + new StringBuilder() + .append(artifactId) + .append('\n') + .append(workflowRunId) + .append('\n') + .append(artifactName) + .append('\n') + .append(githubDigest) + .append('\n') + .append(createdAt) + .append('\n') + .append(expiresAt) + .append('\n'); + for (ArtifactEntry file : sorted) { + result.append(file.canonicalForm()).append('\n'); + } + return result.toString(); + } + + @Override + public int compareTo(GithubArtifactReceipt other) { + return artifactName.compareTo(other.artifactName); + } + + private static Instant parseInstant(String value, String field) { + try { + return Instant.parse(value); + } catch (DateTimeParseException | NullPointerException e) { + throw new IllegalArgumentException("GitHub artifact " + field + " time is invalid.", e); + } + } +} diff --git a/.github/release-automation/src/main/java/io/temporal/releaseautomation/ManualMavenAttempt.java b/.github/release-automation/src/main/java/io/temporal/releaseautomation/ManualMavenAttempt.java new file mode 100644 index 0000000000..6aeca7f3dc --- /dev/null +++ b/.github/release-automation/src/main/java/io/temporal/releaseautomation/ManualMavenAttempt.java @@ -0,0 +1,43 @@ +package io.temporal.releaseautomation; + +public final class ManualMavenAttempt { + public String state; + public String tag; + public String commitSha; + public String releaseDigest; + public String githubActor; + public long githubRunId; + + public ManualMavenAttempt() {} + + ManualMavenAttempt( + String state, + String tag, + String commitSha, + String releaseDigest, + String githubActor, + long githubRunId) { + this.state = state; + this.tag = tag; + this.commitSha = commitSha; + this.releaseDigest = releaseDigest; + this.githubActor = githubActor; + this.githubRunId = githubRunId; + validate(); + } + + public void validate() { + if (!("STARTED".equals(state) || "COMPLETED".equals(state)) + || tag == null + || !tag.matches("v[0-9]+\\.[0-9]+\\.[0-9]+(?:-RC[0-9]+)?") + || commitSha == null + || !commitSha.matches("[0-9a-f]{40}") + || releaseDigest == null + || !releaseDigest.matches("[0-9a-f]{64}") + || githubActor == null + || !githubActor.matches("[A-Za-z0-9-]{1,39}") + || githubRunId <= 0) { + throw new IllegalArgumentException("Invalid manual Maven attempt evidence."); + } + } +} diff --git a/.github/release-automation/src/main/java/io/temporal/releaseautomation/MavenGenerationInspection.java b/.github/release-automation/src/main/java/io/temporal/releaseautomation/MavenGenerationInspection.java new file mode 100644 index 0000000000..cab4c61fce --- /dev/null +++ b/.github/release-automation/src/main/java/io/temporal/releaseautomation/MavenGenerationInspection.java @@ -0,0 +1,60 @@ +package io.temporal.releaseautomation; + +public final class MavenGenerationInspection implements Comparable { + public int generation; + public String description; + public String repositoryId; + public String repositoryState; + public String portalDeploymentId; + public String portalDeploymentState; + + public MavenGenerationInspection() {} + + public void validate(String releaseDigest) { + MavenGenerationState state = new MavenGenerationState(releaseDigest, generation); + if (!state.description.equals(description)) { + throw new IllegalArgumentException("Inspected Maven generation description is invalid."); + } + state.sonatypeRepositoryId = repositoryId; + state.portalDeploymentId = portalDeploymentId; + state.validate(releaseDigest); + if (!("absent".equals(repositoryState) + || "open".equals(repositoryState) + || "closed".equals(repositoryState) + || "released".equals(repositoryState))) { + throw new IllegalArgumentException("Inspected Sonatype repository state is invalid."); + } + if (!("".equals(portalDeploymentState) + || "PENDING".equals(portalDeploymentState) + || "VALIDATING".equals(portalDeploymentState) + || "VALIDATED".equals(portalDeploymentState) + || "PUBLISHING".equals(portalDeploymentState) + || "PUBLISHED".equals(portalDeploymentState) + || "FAILED".equals(portalDeploymentState))) { + throw new IllegalArgumentException("Inspected Portal state is invalid."); + } + } + + String canonicalForm() { + return generation + + "\n" + + description + + "\n" + + value(repositoryId) + + "\n" + + repositoryState + + "\n" + + value(portalDeploymentId) + + "\n" + + portalDeploymentState; + } + + @Override + public int compareTo(MavenGenerationInspection other) { + return Integer.compare(generation, other.generation); + } + + private static String value(String value) { + return value == null ? "" : value; + } +} diff --git a/.github/release-automation/src/main/java/io/temporal/releaseautomation/MavenGenerationState.java b/.github/release-automation/src/main/java/io/temporal/releaseautomation/MavenGenerationState.java new file mode 100644 index 0000000000..2ef9dd6534 --- /dev/null +++ b/.github/release-automation/src/main/java/io/temporal/releaseautomation/MavenGenerationState.java @@ -0,0 +1,36 @@ +package io.temporal.releaseautomation; + +public final class MavenGenerationState { + public int generation; + public String description; + public boolean submissionStarted; + public String sonatypeRepositoryId; + public String portalDeploymentId; + + public MavenGenerationState() {} + + MavenGenerationState(String releaseDigest, int generation) { + if (generation < 0 || releaseDigest == null || !releaseDigest.matches("[0-9a-f]{64}")) { + throw new IllegalArgumentException("Maven generation identity is invalid."); + } + this.generation = generation; + this.description = "sdk-java:" + releaseDigest + ":" + generation; + } + + public void validate(String releaseDigest) { + if (generation < 0 + || !new MavenGenerationState(releaseDigest, generation).description.equals(description)) { + throw new IllegalArgumentException("Maven generation description is invalid."); + } + if (sonatypeRepositoryId != null + && !sonatypeRepositoryId.isEmpty() + && !sonatypeRepositoryId.matches("[A-Za-z0-9._-]+")) { + throw new IllegalArgumentException("Sonatype repository ID is invalid."); + } + if (portalDeploymentId != null + && !portalDeploymentId.isEmpty() + && !portalDeploymentId.matches("[0-9a-fA-F-]{16,64}")) { + throw new IllegalArgumentException("Portal deployment ID is invalid."); + } + } +} diff --git a/.github/release-automation/src/main/java/io/temporal/releaseautomation/MavenInspection.java b/.github/release-automation/src/main/java/io/temporal/releaseautomation/MavenInspection.java new file mode 100644 index 0000000000..3ec9ba11ba --- /dev/null +++ b/.github/release-automation/src/main/java/io/temporal/releaseautomation/MavenInspection.java @@ -0,0 +1,46 @@ +package io.temporal.releaseautomation; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.HashSet; +import java.util.List; +import java.util.Set; + +public final class MavenInspection { + public int centralPresent; + public int centralMissing; + public List generations = new ArrayList<>(); + + public MavenInspection() {} + + public void validate(String releaseDigest) { + if (centralPresent < 0 + || centralMissing < 0 + || centralPresent + centralMissing <= 0 + || centralPresent + centralMissing > ReleasePolicy.MAVEN_ARTIFACTS.size()) { + throw new IllegalArgumentException("Inspected Maven Central state is invalid."); + } + if (generations == null) { + throw new IllegalArgumentException("Inspected Maven generations are missing."); + } + Set found = new HashSet<>(); + for (MavenGenerationInspection generation : generations) { + generation.validate(releaseDigest); + if (!found.add(generation.generation)) { + throw new IllegalArgumentException("Inspected Maven generation is duplicated."); + } + } + } + + public String canonicalForm(String releaseDigest) { + validate(releaseDigest); + List sorted = new ArrayList<>(generations); + Collections.sort(sorted); + StringBuilder result = + new StringBuilder().append(centralPresent).append('\n').append(centralMissing).append('\n'); + for (MavenGenerationInspection generation : sorted) { + result.append(generation.canonicalForm()).append('\n'); + } + return result.toString(); + } +} diff --git a/.github/release-automation/src/main/java/io/temporal/releaseautomation/MavenReceipt.java b/.github/release-automation/src/main/java/io/temporal/releaseautomation/MavenReceipt.java new file mode 100644 index 0000000000..c75850ab9a --- /dev/null +++ b/.github/release-automation/src/main/java/io/temporal/releaseautomation/MavenReceipt.java @@ -0,0 +1,20 @@ +package io.temporal.releaseautomation; + +public final class MavenReceipt { + public String mavenCentralUrl; + public String sonatypeRepositoryId; + public String portalDeploymentId; + + public MavenReceipt() {} + + public MavenReceipt(String mavenCentralUrl, String sonatypeRepositoryId) { + this(mavenCentralUrl, sonatypeRepositoryId, null); + } + + public MavenReceipt( + String mavenCentralUrl, String sonatypeRepositoryId, String portalDeploymentId) { + this.mavenCentralUrl = mavenCentralUrl; + this.sonatypeRepositoryId = sonatypeRepositoryId; + this.portalDeploymentId = portalDeploymentId; + } +} diff --git a/.github/release-automation/src/main/java/io/temporal/releaseautomation/OwnershipActivities.java b/.github/release-automation/src/main/java/io/temporal/releaseautomation/OwnershipActivities.java new file mode 100644 index 0000000000..5f571febba --- /dev/null +++ b/.github/release-automation/src/main/java/io/temporal/releaseautomation/OwnershipActivities.java @@ -0,0 +1,13 @@ +package io.temporal.releaseautomation; + +import io.temporal.activity.ActivityInterface; +import io.temporal.activity.ActivityMethod; + +@ActivityInterface +public interface OwnershipActivities { + @ActivityMethod + OwnershipStatus claimTemporal(ReleaseIdentity release); + + @ActivityMethod + OwnershipStatus handoffManual(ReleaseIdentity release, ControlEvidence evidence); +} diff --git a/.github/release-automation/src/main/java/io/temporal/releaseautomation/OwnershipActivitiesImpl.java b/.github/release-automation/src/main/java/io/temporal/releaseautomation/OwnershipActivitiesImpl.java new file mode 100644 index 0000000000..832ccfcbc2 --- /dev/null +++ b/.github/release-automation/src/main/java/io/temporal/releaseautomation/OwnershipActivitiesImpl.java @@ -0,0 +1,61 @@ +package io.temporal.releaseautomation; + +import io.temporal.api.enums.v1.WorkflowIdConflictPolicy; +import io.temporal.api.enums.v1.WorkflowIdReusePolicy; +import io.temporal.client.WorkflowClient; +import io.temporal.client.WorkflowOptions; + +public final class OwnershipActivitiesImpl implements OwnershipActivities { + private final WorkflowClient client; + + public OwnershipActivitiesImpl(WorkflowClient client) { + this.client = client; + } + + @Override + public OwnershipStatus claimTemporal(ReleaseIdentity release) { + release.validate(); + return claim(client, OwnershipClaim.temporal(release)); + } + + @Override + public OwnershipStatus handoffManual(ReleaseIdentity release, ControlEvidence evidence) { + release.validate(); + evidence.validate(); + return claim( + client, + OwnershipClaim.manual( + release.candidate.tag, + release.candidate.commitSha, + release.digest(), + evidence.githubActor, + evidence.githubRunId, + true)); + } + + static OwnershipStatus claim(WorkflowClient client, OwnershipClaim claim) { + claim.validate(); + ReleaseOwnershipWorkflow workflow = stub(client, claim.tag); + WorkflowClient.start(workflow::manage, claim); + return workflow.claim(claim); + } + + static OwnershipStatus status(WorkflowClient client, String tag) { + return client + .newWorkflowStub(ReleaseOwnershipWorkflow.class, QueueNames.ownershipWorkflowId(tag)) + .status(); + } + + static ReleaseOwnershipWorkflow stub(WorkflowClient client, String tag) { + return client.newWorkflowStub( + ReleaseOwnershipWorkflow.class, + WorkflowOptions.newBuilder() + .setWorkflowId(QueueNames.ownershipWorkflowId(tag)) + .setTaskQueue(QueueNames.ownership(tag)) + .setWorkflowIdReusePolicy( + WorkflowIdReusePolicy.WORKFLOW_ID_REUSE_POLICY_REJECT_DUPLICATE) + .setWorkflowIdConflictPolicy( + WorkflowIdConflictPolicy.WORKFLOW_ID_CONFLICT_POLICY_USE_EXISTING) + .build()); + } +} diff --git a/.github/release-automation/src/main/java/io/temporal/releaseautomation/OwnershipClaim.java b/.github/release-automation/src/main/java/io/temporal/releaseautomation/OwnershipClaim.java new file mode 100644 index 0000000000..7f6ed96e30 --- /dev/null +++ b/.github/release-automation/src/main/java/io/temporal/releaseautomation/OwnershipClaim.java @@ -0,0 +1,71 @@ +package io.temporal.releaseautomation; + +public final class OwnershipClaim { + public String tag; + public String commitSha; + public String releaseDigest; + public String owner; + public String githubActor; + public long githubRunId; + public boolean handoffConfirmed; + + public OwnershipClaim() {} + + static OwnershipClaim temporal(ReleaseIdentity release) { + OwnershipClaim claim = new OwnershipClaim(); + claim.tag = release.candidate.tag; + claim.commitSha = release.candidate.commitSha; + claim.releaseDigest = release.digest(); + claim.owner = "TEMPORAL"; + claim.validate(); + return claim; + } + + static OwnershipClaim manual( + String tag, + String commitSha, + String releaseDigest, + String githubActor, + long githubRunId, + boolean handoffConfirmed) { + OwnershipClaim claim = new OwnershipClaim(); + claim.tag = tag; + claim.commitSha = commitSha; + claim.releaseDigest = releaseDigest; + claim.owner = "MANUAL"; + claim.githubActor = githubActor; + claim.githubRunId = githubRunId; + claim.handoffConfirmed = handoffConfirmed; + claim.validate(); + return claim; + } + + public void validate() { + if (tag == null || !tag.matches("v[0-9]+\\.[0-9]+\\.[0-9]+(?:-RC[0-9]+)?")) { + throw new IllegalArgumentException("Ownership tag is invalid."); + } + if (commitSha == null || !commitSha.matches("[0-9a-f]{40}")) { + throw new IllegalArgumentException("Ownership commit must be a full SHA."); + } + if (!("TEMPORAL".equals(owner) || "MANUAL".equals(owner))) { + throw new IllegalArgumentException("Ownership controller is invalid."); + } + if ("TEMPORAL".equals(owner) + && (releaseDigest == null || !releaseDigest.matches("[0-9a-f]{64}"))) { + throw new IllegalArgumentException("Temporal ownership requires a release digest."); + } + if ("MANUAL".equals(owner) + && (githubActor == null || githubActor.isEmpty() || githubRunId <= 0)) { + throw new IllegalArgumentException("Manual ownership requires its authenticated GitHub run."); + } + if ("MANUAL".equals(owner) + && releaseDigest != null + && !releaseDigest.isEmpty() + && !releaseDigest.matches("[0-9a-f]{64}")) { + throw new IllegalArgumentException("Manual ownership release digest is invalid."); + } + if (handoffConfirmed && (releaseDigest == null || releaseDigest.isEmpty())) { + throw new IllegalArgumentException("A confirmed handoff requires the exact release digest."); + } + } +} diff --git a/.github/release-automation/src/main/java/io/temporal/releaseautomation/OwnershipStatus.java b/.github/release-automation/src/main/java/io/temporal/releaseautomation/OwnershipStatus.java new file mode 100644 index 0000000000..36852be20c --- /dev/null +++ b/.github/release-automation/src/main/java/io/temporal/releaseautomation/OwnershipStatus.java @@ -0,0 +1,27 @@ +package io.temporal.releaseautomation; + +public final class OwnershipStatus { + public String tag; + public String commitSha; + public String releaseDigest; + public String owner; + public String githubActor; + public long githubRunId; + public long recordedAtMillis; + public String manualMavenState; + public String manualMavenActor; + public long manualMavenRunId; + + public OwnershipStatus() {} + + OwnershipStatus(OwnershipClaim claim, long recordedAtMillis) { + this.tag = claim.tag; + this.commitSha = claim.commitSha; + this.releaseDigest = claim.releaseDigest; + this.owner = claim.owner; + this.githubActor = claim.githubActor; + this.githubRunId = claim.githubRunId; + this.recordedAtMillis = recordedAtMillis; + this.manualMavenState = "MANUAL".equals(claim.owner) ? "NOT_STARTED" : ""; + } +} diff --git a/.github/release-automation/src/main/java/io/temporal/releaseautomation/ProcessSupport.java b/.github/release-automation/src/main/java/io/temporal/releaseautomation/ProcessSupport.java new file mode 100644 index 0000000000..49a3d6dfda --- /dev/null +++ b/.github/release-automation/src/main/java/io/temporal/releaseautomation/ProcessSupport.java @@ -0,0 +1,171 @@ +package io.temporal.releaseautomation; + +import io.temporal.activity.Activity; +import io.temporal.activity.ActivityExecutionContext; +import io.temporal.client.ActivityCompletionException; +import java.io.BufferedReader; +import java.io.IOException; +import java.io.InputStreamReader; +import java.nio.charset.StandardCharsets; +import java.nio.file.Path; +import java.time.Duration; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; +import java.util.Map; +import java.util.concurrent.Executors; +import java.util.concurrent.ScheduledExecutorService; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicReference; +import java.util.stream.Collectors; + +final class ProcessSupport { + private static final String WINDOWS_GIT_BASH = "C:\\Program Files\\Git\\bin\\bash.exe"; + + private ProcessSupport() {} + + static List bash(Path script) { + return Arrays.asList(bashExecutable(), bashPath(script.toAbsolutePath().toString())); + } + + static String bashExecutable() { + return java.io.File.separatorChar == '\\' ? WINDOWS_GIT_BASH : "bash"; + } + + static String bashPath(String path) { + String normalized = path.replace('\\', '/'); + if (normalized.length() >= 3 + && Character.isLetter(normalized.charAt(0)) + && normalized.charAt(1) == ':' + && normalized.charAt(2) == '/') { + return "/" + Character.toLowerCase(normalized.charAt(0)) + normalized.substring(2); + } + return normalized; + } + + static List run(Path workingDirectory, List command, Map env) { + ProcessBuilder builder = new ProcessBuilder(command); + builder.directory(workingDirectory.toFile()); + builder.redirectError(ProcessBuilder.Redirect.INHERIT); + Map processEnvironment = builder.environment(); + Map inheritedEnvironment = new java.util.HashMap<>(processEnvironment); + processEnvironment.clear(); + for (String name : + Arrays.asList( + "PATH", + "Path", + "SystemRoot", + "COMSPEC", + "PATHEXT", + "HOME", + "USERPROFILE", + "TMPDIR", + "TMP", + "TEMP", + "LANG", + "LC_ALL", + "JAVA_HOME", + "GRAALVM_HOME", + "CI", + "SystemDrive")) { + String value = inheritedEnvironment.get(name); + if (value != null) { + processEnvironment.put(name, value); + } + } + processEnvironment.putAll(env); + ScheduledExecutorService heartbeat = Executors.newSingleThreadScheduledExecutor(); + try { + ActivityExecutionContext activityContext = Activity.getExecutionContext(); + Process process = builder.start(); + AtomicReference cancellation = new AtomicReference<>(); + heartbeat.scheduleAtFixedRate( + () -> { + try { + activityContext.heartbeat("External release command is running."); + } catch (ActivityCompletionException e) { + cancellation.compareAndSet(null, e); + terminateProcessTree(process); + } + }, + 15, + 15, + TimeUnit.SECONDS); + List output = new ArrayList<>(); + try (BufferedReader reader = + new BufferedReader( + new InputStreamReader(process.getInputStream(), StandardCharsets.UTF_8))) { + String line; + while ((line = reader.readLine()) != null) { + output.add(line); + } + } + int status = process.waitFor(); + if (cancellation.get() != null) { + throw cancellation.get(); + } + if (status != 0) { + throw new CommandFailedException(status, command.get(0)); + } + return output; + } catch (IOException e) { + throw new IllegalStateException("Unable to start release command.", e); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + throw new IllegalStateException("Release command was interrupted.", e); + } finally { + heartbeat.shutdownNow(); + try { + heartbeat.awaitTermination(Duration.ofSeconds(5).toMillis(), TimeUnit.MILLISECONDS); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } + } + } + + static void terminateProcessTree(Process process) { + List descendants = process.descendants().collect(Collectors.toList()); + for (int index = descendants.size() - 1; index >= 0; index--) { + descendants.get(index).destroy(); + } + process.destroy(); + waitForExit(process, descendants, 5); + for (int index = descendants.size() - 1; index >= 0; index--) { + ProcessHandle descendant = descendants.get(index); + if (descendant.isAlive()) { + descendant.destroyForcibly(); + } + } + if (process.isAlive()) { + process.destroyForcibly(); + } + waitForExit(process, descendants, 5); + } + + private static void waitForExit(Process process, List descendants, int seconds) { + long deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(seconds); + while (System.nanoTime() < deadline + && (process.isAlive() || descendants.stream().anyMatch(ProcessHandle::isAlive))) { + try { + Thread.sleep(50); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + return; + } + } + } + + static final class CommandFailedException extends RuntimeException { + private static final long serialVersionUID = 1L; + private final int status; + + CommandFailedException(int status, String command) { + super(command + " exited with status " + status + "."); + this.status = status; + } + + int getStatus() { + return status; + } + } +} diff --git a/.github/release-automation/src/main/java/io/temporal/releaseautomation/PublicationActivities.java b/.github/release-automation/src/main/java/io/temporal/releaseautomation/PublicationActivities.java new file mode 100644 index 0000000000..fdbd3b85c3 --- /dev/null +++ b/.github/release-automation/src/main/java/io/temporal/releaseautomation/PublicationActivities.java @@ -0,0 +1,25 @@ +package io.temporal.releaseautomation; + +import io.temporal.activity.ActivityInterface; +import io.temporal.activity.ActivityMethod; + +@ActivityInterface +public interface PublicationActivities { + @ActivityMethod + void preflight(PublicationInput input); + + @ActivityMethod + String reconcileMavenRepository(PublicationInput input, boolean allowCreation); + + @ActivityMethod + String reconcileMavenPortal(PublicationInput input); + + @ActivityMethod + MavenReceipt publishMaven(PublicationInput input); + + @ActivityMethod + String reconcileGithubDraft(PublicationInput input); + + @ActivityMethod + ReleaseResult publishGithubRelease(PublicationInput input, String mavenCentralUrl); +} diff --git a/.github/release-automation/src/main/java/io/temporal/releaseautomation/PublicationActivitiesImpl.java b/.github/release-automation/src/main/java/io/temporal/releaseautomation/PublicationActivitiesImpl.java new file mode 100644 index 0000000000..9e3c325ea0 --- /dev/null +++ b/.github/release-automation/src/main/java/io/temporal/releaseautomation/PublicationActivitiesImpl.java @@ -0,0 +1,219 @@ +package io.temporal.releaseautomation; + +import com.google.gson.Gson; +import io.temporal.activity.Activity; +import io.temporal.client.WorkflowClient; +import io.temporal.failure.ApplicationFailure; +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.function.Consumer; + +public final class PublicationActivitiesImpl implements PublicationActivities { + private final Path trustedRoot; + private final Path sourceRoot; + private final Map environment; + private final Consumer completion; + private final Runnable started; + private final WorkflowClient client; + + public PublicationActivitiesImpl( + Path trustedRoot, + Path sourceRoot, + WorkflowClient client, + Map environment, + Runnable started, + Consumer completion) { + this.trustedRoot = trustedRoot; + this.sourceRoot = sourceRoot; + this.client = client; + this.environment = new HashMap<>(environment); + this.started = started; + this.completion = completion; + } + + @Override + public void preflight(PublicationInput input) { + run(input, "preflight", Void.class); + } + + @Override + public String reconcileMavenRepository(PublicationInput input, boolean allowCreation) { + return run(input, "maven-repository", String.class, allowCreation); + } + + @Override + public String reconcileMavenPortal(PublicationInput input) { + return run(input, "maven-portal", String.class); + } + + @Override + public MavenReceipt publishMaven(PublicationInput input) { + return run(input, "maven-publish", MavenReceipt.class); + } + + @Override + public String reconcileGithubDraft(PublicationInput input) { + return run(input, "github-draft", String.class); + } + + @Override + public ReleaseResult publishGithubRelease(PublicationInput input, String mavenCentralUrl) { + return run(input, "github-publish", ReleaseResult.class); + } + + private T run(PublicationInput input, String stage, Class resultType) { + return run(input, stage, resultType, false); + } + + private T run( + PublicationInput input, String stage, Class resultType, boolean allowCreation) { + started.run(); + try { + T result = runCommand(input, stage, resultType, allowCreation); + completion.accept(null); + return result; + } catch (Throwable failure) { + completion.accept(failure); + throw failure; + } + } + + private T runCommand( + PublicationInput input, String stage, Class resultType, boolean allowCreation) { + try { + PublicationInput expected = + new Gson() + .fromJson( + new String( + Files.readAllBytes(requiredPath("RELEASE_EXPECTATION_FILE")), + StandardCharsets.UTF_8), + PublicationInput.class); + PublicationGuard.validate( + input, + expected, + Activity.getExecutionContext().getInfo(), + required("TRUSTED_WORKER_COMMIT")); + OwnershipStatus ownership = + OwnershipActivitiesImpl.status(client, input.release.candidate.tag); + if (ownership == null + || !"TEMPORAL".equals(ownership.owner) + || !input.release.candidate.commitSha.equals(ownership.commitSha) + || !input.release.digest().equals(ownership.releaseDigest)) { + throw new IllegalArgumentException( + "Temporal does not own this exact tag, commit, and release identity."); + } + } catch (IOException e) { + throw new IllegalStateException("Unable to read the privileged release expectation.", e); + } catch (IllegalArgumentException e) { + throw ApplicationFailure.newNonRetryableFailure(e.getMessage(), "InvalidApproval"); + } + + Path inputFile = null; + Path outputFile = null; + Path mavenArtifactsFile = null; + try { + inputFile = Files.createTempFile("temporal-release-input-", ".json"); + outputFile = Files.createTempFile("temporal-release-output-", ".json"); + mavenArtifactsFile = Files.createTempFile("temporal-release-maven-artifacts-", ".json"); + Files.write(inputFile, new Gson().toJson(input).getBytes(StandardCharsets.UTF_8)); + Files.write( + mavenArtifactsFile, + new Gson() + .toJson(ReleasePolicy.mavenArtifacts(input.release.candidate.mavenPolicy)) + .getBytes(StandardCharsets.UTF_8)); + Map commandEnvironment = new HashMap<>(); + commandEnvironment.put("RELEASE_INPUT_FILE", inputFile.toString()); + commandEnvironment.put("RELEASE_OUTPUT_FILE", outputFile.toString()); + commandEnvironment.put("RELEASE_MAVEN_ARTIFACTS_FILE", mavenArtifactsFile.toString()); + commandEnvironment.put("RELEASE_STAGE", stage); + commandEnvironment.put( + "RELEASE_ALLOW_MAVEN_REPOSITORY_CREATION", Boolean.toString(allowCreation)); + commandEnvironment.put("TRUSTED_AUTOMATION_ROOT", trustedRoot.toString()); + copy(commandEnvironment, "TRUSTED_WORKER_COMMIT"); + copy(commandEnvironment, "GH_TOKEN"); + if (stage.startsWith("maven-")) { + copy(commandEnvironment, "RH_USER"); + copy(commandEnvironment, "RH_PASSWORD"); + } + List output = + ProcessSupport.run( + sourceRoot, + ProcessSupport.bash( + trustedRoot.resolve(".github/scripts/temporal-release/reconcile-publication.sh")), + commandEnvironment); + if (!output.isEmpty()) { + throw new IllegalStateException("Publication command wrote unexpected standard output."); + } + if (Void.class.equals(resultType)) { + return null; + } + return new Gson() + .fromJson(new String(Files.readAllBytes(outputFile), StandardCharsets.UTF_8), resultType); + } catch (ProcessSupport.CommandFailedException e) { + if (e.getStatus() == 42) { + throw ApplicationFailure.newNonRetryableFailure( + "An immutable external release identity or checksum conflicts.", + "ReleaseIdentityConflict"); + } + if (e.getStatus() == 43) { + throw ApplicationFailure.newNonRetryableFailure( + "GitHub approval evidence is invalid.", "InvalidApproval"); + } + if (e.getStatus() == 44) { + throw ApplicationFailure.newNonRetryableFailure( + "A durable Maven intent has no discoverable Sonatype repository; an authenticated release manager must inspect Sonatype before authorizing another submission generation.", + "MavenSubmissionAmbiguous"); + } + if (e.getStatus() == 45) { + throw ApplicationFailure.newNonRetryableFailure( + "The exact Publisher Portal deployment failed validation.", "MavenDeploymentFailed"); + } + if (e.getStatus() == 46) { + throw ApplicationFailure.newNonRetryableFailure( + "An exact GitHub Actions artifact expired or was deleted.", "ArtifactUnavailable"); + } + throw e; + } catch (IOException e) { + throw new IllegalStateException("Unable to exchange publication state with the script.", e); + } finally { + delete(inputFile); + delete(outputFile); + delete(mavenArtifactsFile); + } + } + + private void copy(Map commandEnvironment, String name) { + String value = environment.get(name); + if (value != null && !value.isEmpty()) { + commandEnvironment.put(name, value); + } + } + + private String required(String name) { + String value = environment.get(name); + if (value == null || value.isEmpty()) { + throw new IllegalArgumentException("Required Worker value is missing: " + name); + } + return value; + } + + private Path requiredPath(String name) { + return Paths.get(required(name)); + } + + private static void delete(Path path) { + if (path != null) { + try { + Files.deleteIfExists(path); + } catch (IOException ignored) { + // The runner is ephemeral and this file contains identities, not credentials. + } + } + } +} diff --git a/.github/release-automation/src/main/java/io/temporal/releaseautomation/PublicationGuard.java b/.github/release-automation/src/main/java/io/temporal/releaseautomation/PublicationGuard.java new file mode 100644 index 0000000000..74408430e0 --- /dev/null +++ b/.github/release-automation/src/main/java/io/temporal/releaseautomation/PublicationGuard.java @@ -0,0 +1,93 @@ +package io.temporal.releaseautomation; + +import com.google.gson.Gson; +import io.temporal.activity.ActivityInfo; +import java.util.HashSet; +import java.util.Set; + +final class PublicationGuard { + private static final Gson GSON = new Gson(); + + private PublicationGuard() {} + + static void validate( + PublicationInput input, + PublicationInput expected, + ActivityInfo activity, + String trustedWorkerCommit) { + validateInput(input); + validateInput(expected); + requireEqual("privileged publication input", GSON.toJson(expected), GSON.toJson(input)); + requireEqual("Activity Workflow ID", input.workflowId, activity.getWorkflowId()); + requireEqual("Activity run ID", input.runId, activity.getWorkflowRunId()); + requireEqual("approval Workflow ID", input.workflowId, input.approval.workflowId); + requireEqual("approval run ID", input.runId, input.approval.runId); + requireEqual("release digest", input.release.digest(), input.approval.releaseDigest); + requireEqual( + "frozen trusted Worker commit", + input.release.candidate.trustedAutomationCommit, + input.approval.trustedWorkerCommit); + requireEqual("trusted Worker commit", input.approval.trustedWorkerCommit, trustedWorkerCommit); + requireEqual( + "publication Task Queue", + QueueNames.publication(input.release, input.mavenSubmissionGeneration), + activity.getActivityTaskQueue()); + } + + private static void validateInput(PublicationInput input) { + if (input == null + || input.release == null + || input.approvalRequest == null + || input.approval == null) { + throw new IllegalArgumentException("Publication input is incomplete."); + } + input.release.validate(); + input.approvalRequest.validate(); + input.approval.validate(); + if (!input.approvalRequest.matches(input.approval)) { + throw new IllegalArgumentException("Approval does not match its durable request."); + } + if (input.mavenSubmissionGeneration < 0) { + throw new IllegalArgumentException("Maven submission generation is invalid."); + } + if (input.mavenSubmissionGeneration == 0 && input.mavenRetryAuthorization != null) { + throw new IllegalArgumentException("Initial Maven submission has retry authorization."); + } + if (input.mavenSubmissionGeneration > 0) { + if (input.mavenRetryAuthorization == null) { + throw new IllegalArgumentException("A Maven retry has no external authorization binding."); + } + input.mavenRetryAuthorization.validate(); + if (input.mavenRetryAuthorization.mavenSubmissionGeneration + != input.mavenSubmissionGeneration) { + throw new IllegalArgumentException("Maven retry generation does not match."); + } + } + if (input.mavenPayload == null) { + throw new IllegalArgumentException("The frozen Maven payload is missing."); + } + input.mavenPayload.validate(); + if (!ReleasePolicy.githubMavenArtifactName(input.release) + .equals(input.mavenPayload.artifactName) + || input.mavenPayload.files.size() != 1 + || !"maven-payload.tar".equals(input.mavenPayload.files.get(0).name)) { + throw new IllegalArgumentException("The frozen Maven payload identity is invalid."); + } + if (input.mavenGenerations == null) { + throw new IllegalArgumentException("Maven generation state is missing."); + } + Set generations = new HashSet<>(); + for (MavenGenerationState generation : input.mavenGenerations) { + generation.validate(input.release.digest()); + if (!generations.add(generation.generation)) { + throw new IllegalArgumentException("Maven generation state is duplicated."); + } + } + } + + private static void requireEqual(String field, String expected, String actual) { + if (expected == null || !expected.equals(actual)) { + throw new IllegalArgumentException(field + " does not match the privileged Actions run."); + } + } +} diff --git a/.github/release-automation/src/main/java/io/temporal/releaseautomation/PublicationInput.java b/.github/release-automation/src/main/java/io/temporal/releaseautomation/PublicationInput.java new file mode 100644 index 0000000000..770883c6c0 --- /dev/null +++ b/.github/release-automation/src/main/java/io/temporal/releaseautomation/PublicationInput.java @@ -0,0 +1,31 @@ +package io.temporal.releaseautomation; + +import java.util.ArrayList; +import java.util.List; + +public final class PublicationInput { + public ReleaseIdentity release; + public ApprovalEvidence approval; + public ApprovalRequest approvalRequest; + public String workflowId; + public String runId; + public int mavenSubmissionGeneration; + public ControlEvidence mavenRetryAuthorization; + public GithubArtifactReceipt mavenPayload; + public List mavenGenerations = new ArrayList<>(); + + public PublicationInput() {} + + public PublicationInput( + ReleaseIdentity release, + ApprovalRequest approvalRequest, + ApprovalEvidence approval, + String workflowId, + String runId) { + this.release = release; + this.approvalRequest = approvalRequest; + this.approval = approval; + this.workflowId = workflowId; + this.runId = runId; + } +} diff --git a/.github/release-automation/src/main/java/io/temporal/releaseautomation/QueueNames.java b/.github/release-automation/src/main/java/io/temporal/releaseautomation/QueueNames.java new file mode 100644 index 0000000000..92fba96925 --- /dev/null +++ b/.github/release-automation/src/main/java/io/temporal/releaseautomation/QueueNames.java @@ -0,0 +1,80 @@ +package io.temporal.releaseautomation; + +import java.util.Locale; +import java.util.regex.Pattern; + +public final class QueueNames { + private static final Pattern PLATFORM = Pattern.compile("[a-z0-9-]+"); + + private QueueNames() {} + + public static String candidateWorkflow(CandidateIdentity identity) { + return candidateWorkflowFromDigest(identity.digest()); + } + + public static String build(CandidateIdentity identity, String platform) { + return buildFromDigest(identity.digest(), platform); + } + + static String candidateWorkflowFromDigest(String digest) { + validateDigest(digest); + return "sdk-java-release-candidate-" + shortDigest(digest) + "-workflow"; + } + + static String buildFromDigest(String digest, String platform) { + validateDigest(digest); + String normalized = platform.toLowerCase(Locale.ROOT); + if (!PLATFORM.matcher(normalized).matches()) { + throw new IllegalArgumentException("Invalid build platform."); + } + return "sdk-java-release-candidate-" + shortDigest(digest) + "-build-" + normalized; + } + + public static String releaseWorkflow(ReleaseIdentity identity) { + return "sdk-java-release-" + shortDigest(identity.digest()) + "-workflow"; + } + + public static String publication(ReleaseIdentity identity) { + return publication(identity, 0); + } + + public static String publication(ReleaseIdentity identity, int mavenSubmissionGeneration) { + if (mavenSubmissionGeneration < 0) { + throw new IllegalArgumentException("Maven submission generation cannot be negative."); + } + return "sdk-java-release-" + + shortDigest(identity.digest()) + + "-publication-g" + + mavenSubmissionGeneration; + } + + public static String candidateWorkflowId(CandidateIdentity identity) { + return "sdk-java-release-candidate/" + identity.digest(); + } + + public static String releaseWorkflowId(ReleaseIdentity identity) { + return "sdk-java-release/" + identity.digest(); + } + + public static String ownership(String tag) { + return "sdk-java-release-ownership-" + + shortDigest(Digests.sha256(ReleasePolicy.REPOSITORY + "\n" + tag)); + } + + public static String ownershipWorkflowId(String tag) { + if (tag == null || !tag.matches("v[0-9]+\\.[0-9]+\\.[0-9]+(?:-RC[0-9]+)?")) { + throw new IllegalArgumentException("Invalid ownership tag."); + } + return "sdk-java-release-ownership/" + tag; + } + + private static String shortDigest(String digest) { + return digest.substring(0, 32); + } + + private static void validateDigest(String digest) { + if (digest == null || !digest.matches("[0-9a-f]{64}")) { + throw new IllegalArgumentException("Invalid sdk-java release digest."); + } + } +} diff --git a/.github/release-automation/src/main/java/io/temporal/releaseautomation/ReleaseAutomationMain.java b/.github/release-automation/src/main/java/io/temporal/releaseautomation/ReleaseAutomationMain.java new file mode 100644 index 0000000000..8aa58327bb --- /dev/null +++ b/.github/release-automation/src/main/java/io/temporal/releaseautomation/ReleaseAutomationMain.java @@ -0,0 +1,1339 @@ +package io.temporal.releaseautomation; + +import com.google.gson.Gson; +import com.google.gson.JsonObject; +import io.temporal.api.common.v1.WorkflowExecution; +import io.temporal.api.enums.v1.WorkflowIdConflictPolicy; +import io.temporal.api.enums.v1.WorkflowIdReusePolicy; +import io.temporal.api.history.v1.HistoryEvent; +import io.temporal.api.history.v1.WorkflowExecutionStartedEventAttributes; +import io.temporal.client.WorkflowClient; +import io.temporal.client.WorkflowExecutionMetadata; +import io.temporal.client.WorkflowOptions; +import io.temporal.client.WorkflowStub; +import io.temporal.client.WorkflowTargetOptions; +import io.temporal.envconfig.ClientConfigProfile; +import io.temporal.envconfig.LoadClientConfigProfileOptions; +import io.temporal.failure.ApplicationFailure; +import io.temporal.serviceclient.WorkflowServiceStubs; +import io.temporal.worker.Worker; +import io.temporal.worker.WorkerFactory; +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.time.Duration; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicReference; +import java.util.function.Consumer; +import java.util.regex.Matcher; +import java.util.regex.Pattern; +import java.util.stream.Collectors; + +public final class ReleaseAutomationMain { + private static final Gson GSON = new Gson(); + private static final Path REPOSITORY_ROOT = repositoryRoot(); + + private ReleaseAutomationMain() {} + + public static void main(String[] args) throws Exception { + if (args.length == 0) { + throw new IllegalArgumentException( + "Expected candidate-outputs, maven-policy, platform-matrix, start-candidate, record-artifact, record-maven-payload, claim-manual-ownership, start-manual-maven, complete-manual-maven, discover, approval-target, approval-request, approve, control, inspect, inspect-if-present, publication-input, or worker."); + } + Map environment = System.getenv(); + if ("candidate-outputs".equals(args[0])) { + requireArguments(args, 2); + candidateOutputs(read(args[1], CandidateIdentity.class)); + return; + } + if ("maven-policy".equals(args[0])) { + requireArguments(args, 2); + mavenPolicy(Paths.get(args[1])); + return; + } + if ("platform-matrix".equals(args[0])) { + requireArguments(args, 1); + JsonObject matrix = new JsonObject(); + matrix.add("include", GSON.toJsonTree(ReleasePolicy.PLATFORMS)); + writeOutput("matrix", GSON.toJson(matrix)); + return; + } + ClientConfigProfile temporalConfig = loadTemporalClientConfig(environment); + WorkflowServiceStubs temporalService = + WorkflowServiceStubs.newServiceStubs(temporalConfig.toWorkflowServiceStubsOptions()); + try { + WorkflowClient temporalClient = + WorkflowClient.newInstance(temporalService, temporalConfig.toWorkflowClientOptions()); + switch (args[0]) { + case "start-candidate": + requireArguments(args, 2); + startCandidate(temporalClient, read(args[1], CandidateIdentity.class)); + return; + case "record-artifact": + requireArguments(args, 5); + recordArtifact( + temporalClient, + args[1], + args[2], + args[3], + read(args[4], GithubArtifactReceipt.class)); + return; + case "record-maven-payload": + requireArguments(args, 4); + recordMavenPayload( + temporalClient, args[1], args[2], read(args[3], GithubArtifactReceipt.class)); + return; + case "claim-manual-ownership": + requireArguments(args, 5); + claimManualOwnership( + temporalClient, + environment, + args[1], + args[2], + "-".equals(args[3]) ? "" : args[3], + Boolean.parseBoolean(args[4])); + return; + case "start-manual-maven": + case "complete-manual-maven": + requireArguments(args, 4); + recordManualMaven( + temporalClient, + environment, + args[1], + args[2], + args[3], + "start-manual-maven".equals(args[0]) ? "STARTED" : "COMPLETED"); + return; + case "discover": + requireArguments(args, 2); + discover(temporalClient, args[1], environment); + return; + case "approve": + requireArguments(args, 1); + approve(temporalClient, environment); + return; + case "approval-request": + requireArguments(args, 1); + requestApproval(temporalClient, environment); + return; + case "approval-target": + requireArguments(args, 1); + approvalTarget(temporalClient, environment); + return; + case "control": + requireArguments(args, 4); + control(temporalClient, environment, args[1], args[2], args[3]); + return; + case "inspect": + requireArguments(args, 3); + inspect(temporalClient, args[1], args[2]); + return; + case "inspect-if-present": + requireArguments(args, 3); + inspectIfPresent(temporalClient, args[1], args[2]); + return; + case "publication-input": + requireArguments(args, 4); + publicationInput(temporalClient, args[1], args[2], Paths.get(args[3])); + return; + case "worker": + requireArguments(args, 3); + runWorker(temporalClient, args[1], args[2], environment); + return; + default: + throw new IllegalArgumentException("Unknown command: " + args[0]); + } + } finally { + temporalService.shutdown(); + } + } + + static ClientConfigProfile loadTemporalClientConfig(Map environment) + throws IOException { + Map temporalEnvironment = + Map.of( + "TEMPORAL_ADDRESS", requiredTemporalSetting(environment, "TEMPORAL_ADDRESS"), + "TEMPORAL_NAMESPACE", requiredTemporalSetting(environment, "TEMPORAL_NAMESPACE"), + "TEMPORAL_API_KEY", requiredTemporalSetting(environment, "TEMPORAL_API_KEY")); + return ClientConfigProfile.load( + LoadClientConfigProfileOptions.newBuilder() + .setDisableFile(true) + .setEnvOverrides(temporalEnvironment) + .build()); + } + + private static String requiredTemporalSetting(Map environment, String name) { + String value = environment.get(name); + if (value == null || value.isEmpty()) { + throw new IllegalArgumentException("Required Temporal Cloud setting is missing: " + name); + } + return value; + } + + private static void candidateOutputs(CandidateIdentity candidate) { + candidate.validate(); + writeOutput("candidate_digest", candidate.digest()); + writeOutput("automation_commit", candidate.trustedAutomationCommit); + writeOutput("commit_sha", candidate.commitSha); + writeOutput("notes_sha256", candidate.releaseNotesSha256); + writeOutput("tag", candidate.tag); + writeOutput("version", candidate.version()); + writeOutput("maven_policy", candidate.mavenPolicy); + } + + private static void mavenPolicy(Path settingsFile) throws IOException { + Pattern include = Pattern.compile("^include ['\"]([^'\"]+)['\"]$"); + List projects = new ArrayList<>(); + for (String line : Files.readAllLines(settingsFile, StandardCharsets.UTF_8)) { + Matcher match = include.matcher(line); + if (match.matches()) { + projects.add(match.group(1)); + } + } + String policy = ReleasePolicy.mavenPolicyForProjects(projects); + writeOutput("maven_policy", policy); + writeOutput("maven_artifacts_json", GSON.toJson(ReleasePolicy.mavenArtifacts(policy))); + } + + private static void startCandidate(WorkflowClient client, CandidateIdentity candidate) { + candidate.validate(); + for (WorkflowExecutionMetadata execution : + client.listExecutions("WorkflowType = 'CandidateWorkflow'").collect(Collectors.toList())) { + CandidateIdentity existing = + (CandidateIdentity) execution.getMemo("CandidateIdentity", CandidateIdentity.class); + if (existing != null + && candidate.tag.equals(existing.tag) + && !candidate.digest().equals(existing.digest())) { + throw new IllegalStateException( + "The release tag already identifies another immutable candidate."); + } + } + CandidateWorkflow workflow = + client.newWorkflowStub( + CandidateWorkflow.class, + WorkflowOptions.newBuilder() + .setWorkflowId(QueueNames.candidateWorkflowId(candidate)) + .setTaskQueue(QueueNames.candidateWorkflow(candidate)) + .setWorkflowIdReusePolicy( + WorkflowIdReusePolicy.WORKFLOW_ID_REUSE_POLICY_REJECT_DUPLICATE) + .setWorkflowIdConflictPolicy( + WorkflowIdConflictPolicy.WORKFLOW_ID_CONFLICT_POLICY_USE_EXISTING) + .setMemo(Collections.singletonMap("CandidateIdentity", candidate)) + .build()); + WorkflowExecution execution = WorkflowClient.start(workflow::prepare, candidate); + WorkflowExecutionMetadata description = WorkflowStub.fromTyped(workflow).describe(); + List history = + client.fetchHistory(execution.getWorkflowId(), execution.getRunId()).getEvents(); + if (history.isEmpty() || !history.get(0).hasWorkflowExecutionStartedEventAttributes()) { + throw new IllegalStateException("Candidate Workflow has no immutable start event."); + } + WorkflowExecutionStartedEventAttributes started = + history.get(0).getWorkflowExecutionStartedEventAttributes(); + if (started.getInput().getPayloadsCount() != 1) { + throw new IllegalStateException("Candidate Workflow start input is not exact."); + } + CandidateIdentity startedCandidate = + client + .getOptions() + .getDataConverter() + .fromPayloads( + 0, + Optional.of(started.getInput()), + CandidateIdentity.class, + CandidateIdentity.class); + validateCandidateStart(execution, description, started, startedCandidate, candidate); + writeOutput("workflow_id", execution.getWorkflowId()); + writeOutput("run_id", execution.getRunId()); + writeOutput("candidate_digest", candidate.digest()); + writeOutput("task_queue", QueueNames.candidateWorkflow(candidate)); + } + + private static void recordArtifact( + WorkflowClient client, + String workflowId, + String runId, + String platform, + GithubArtifactReceipt artifact) { + if (!workflowId.startsWith("sdk-java-release-candidate/") || runId == null || runId.isEmpty()) { + throw new IllegalArgumentException("Candidate Workflow execution is invalid."); + } + artifact.validate(); + WorkerFactory factory = WorkerFactory.newInstance(client); + factory + .newWorker( + QueueNames.candidateWorkflowFromDigest( + workflowId.substring(workflowId.indexOf('/') + 1))) + .registerWorkflowImplementationTypes(CandidateWorkflowImpl.class); + factory.start(); + try { + CandidateWorkflow workflow = + client.newWorkflowStub( + CandidateWorkflow.class, + WorkflowTargetOptions.newBuilder() + .setWorkflowExecution( + WorkflowExecution.newBuilder() + .setWorkflowId(workflowId) + .setRunId(runId) + .build()) + .build()); + CandidateStatus status = workflow.recordArtifact(platform, artifact); + writeOutput("pending_platforms", Integer.toString(status.pendingPlatforms.size())); + } finally { + factory.shutdown(); + } + } + + private static void recordMavenPayload( + WorkflowClient client, String tag, String commitSha, GithubArtifactReceipt artifact) { + WorkflowExecutionMetadata metadata = findRelease(client, tag, commitSha); + withReleaseWorker( + client, + metadata, + workflow -> { + ReleaseStatus status = workflow.recordMavenPayload(artifact); + writeIdentityOutputs(metadata, status.identity, status.phase); + writeStatusOutputs(status); + }); + } + + private static void claimManualOwnership( + WorkflowClient client, + Map env, + String tag, + String commitSha, + String releaseDigest, + boolean handoffConfirmed) { + String actor = required(env, "GITHUB_TRIGGERING_ACTOR"); + verifyApprover(actor); + long githubRunId = Long.parseLong(required(env, "GITHUB_RUN_ID")); + OwnershipClaim claim = + OwnershipClaim.manual(tag, commitSha, releaseDigest, actor, githubRunId, handoffConfirmed); + WorkerFactory factory = WorkerFactory.newInstance(client); + factory + .newWorker(QueueNames.ownership(tag)) + .registerWorkflowImplementationTypes(ReleaseOwnershipWorkflowImpl.class); + factory.start(); + try { + OwnershipStatus status = OwnershipActivitiesImpl.claim(client, claim); + if (!"MANUAL".equals(status.owner)) { + throw new IllegalStateException( + "The automatic release still owns this tag; complete its handoff first."); + } + writeOutput("owner", status.owner); + writeOutput("release_digest", value(status.releaseDigest)); + writeOutput("manual_maven_state", value(status.manualMavenState)); + } finally { + factory.shutdown(); + } + } + + private static void recordManualMaven( + WorkflowClient client, + Map env, + String tag, + String commitSha, + String releaseDigest, + String state) { + String actor = required(env, "GITHUB_TRIGGERING_ACTOR"); + verifyApprover(actor); + long githubRunId = Long.parseLong(required(env, "GITHUB_RUN_ID")); + ManualMavenAttempt attempt = + new ManualMavenAttempt(state, tag, commitSha, releaseDigest, actor, githubRunId); + WorkerFactory factory = WorkerFactory.newInstance(client); + factory + .newWorker(QueueNames.ownership(tag)) + .registerWorkflowImplementationTypes(ReleaseOwnershipWorkflowImpl.class); + factory.start(); + try { + OwnershipStatus status = OwnershipActivitiesImpl.stub(client, tag).recordManualMaven(attempt); + writeOutput("manual_maven_state", status.manualMavenState); + } finally { + factory.shutdown(); + } + } + + private static void discover( + WorkflowClient client, String scope, Map environment) { + String trustedAutomationCommits = + required(environment, "RELEASE_AUTOMATION_REF") + + "," + + optional(environment, "RELEASE_AUTOMATION_COMPATIBLE_REFS", ""); + for (String commit : trustedAutomationCommits.split(",")) { + if (!commit.isEmpty() && !commit.matches("[0-9a-f]{40}")) { + throw new IllegalArgumentException( + "Trusted release-automation refs must be full commit SHAs."); + } + } + List jobs; + if ("unprivileged".equals(scope)) { + jobs = discoverUnprivileged(client, trustedAutomationCommits); + } else if ("publication".equals(scope)) { + jobs = discoverPublication(client, trustedAutomationCommits); + } else if ("approvals".equals(scope)) { + jobs = discoverApprovals(client, trustedAutomationCommits); + } else { + throw new IllegalArgumentException( + "Discovery scope must be unprivileged, publication, or approvals."); + } + JsonObject matrix = new JsonObject(); + matrix.add("include", GSON.toJsonTree(jobs)); + writeOutput("matrix", GSON.toJson(matrix)); + writeOutput("count", Integer.toString(jobs.size())); + } + + private static List discoverUnprivileged( + WorkflowClient client, String trustedAutomationCommit) { + List jobs = new ArrayList<>(); + for (WorkflowExecutionMetadata execution : openExecutions(client, "CandidateWorkflow")) { + try { + List executionJobs = new ArrayList<>(); + discoverCandidate(execution, executionJobs, trustedAutomationCommit); + jobs.addAll(executionJobs); + } catch (RuntimeException e) { + reportSkippedExecution(execution, e); + } + } + for (WorkflowExecutionMetadata execution : openExecutions(client, "ReleaseWorkflow")) { + try { + List executionJobs = new ArrayList<>(); + discoverRelease(execution, executionJobs, trustedAutomationCommit); + jobs.addAll(executionJobs); + } catch (RuntimeException e) { + reportSkippedExecution(execution, e); + } + } + return jobs; + } + + private static void discoverCandidate( + WorkflowExecutionMetadata execution, + List jobs, + String trustedAutomationCommit) { + String workflowId = execution.getExecution().getWorkflowId(); + String prefix = "sdk-java-release-candidate/"; + if (!workflowId.startsWith(prefix)) { + throw new IllegalStateException("Unexpected sdk-java candidate Workflow ID."); + } + String digest = workflowId.substring(prefix.length()); + CandidateIdentity candidate = + (CandidateIdentity) execution.getMemo("CandidateIdentity", CandidateIdentity.class); + if (candidate == null || !candidate.digest().equals(digest)) { + throw new IllegalStateException("Candidate memo does not match its Workflow ID."); + } + requireTrustedDiscoveryCommit(candidate.trustedAutomationCommit, trustedAutomationCommit); + DiscoveryJob candidateJob = + new DiscoveryJob("candidate", QueueNames.candidateWorkflowFromDigest(digest)); + candidateJob.automationCommit = candidate.trustedAutomationCommit; + candidateJob.candidateDigest = digest; + candidateJob.workflowId = execution.getExecution().getWorkflowId(); + candidateJob.runId = execution.getExecution().getRunId(); + jobs.add(candidateJob); + CandidateStatus candidateStatus = + (CandidateStatus) + execution.getMemo(CandidateWorkflowImpl.STATUS_MEMO_KEY, CandidateStatus.class); + List pendingPlatforms = + candidateStatus == null ? ReleasePolicy.NATIVE_PLATFORMS : candidateStatus.pendingPlatforms; + for (String platform : pendingPlatforms) { + DiscoveryJob build = new DiscoveryJob("build", QueueNames.buildFromDigest(digest, platform)); + build.platform = platform; + build.candidateDigest = digest; + build.tag = candidate.tag; + build.version = candidate.version(); + build.commitSha = candidate.commitSha; + build.notesSha256 = candidate.releaseNotesSha256; + build.automationCommit = candidate.trustedAutomationCommit; + build.workflowId = execution.getExecution().getWorkflowId(); + build.runId = execution.getExecution().getRunId(); + ReleasePolicy.PlatformSpec platformSpec = ReleasePolicy.platform(platform); + build.runner = platformSpec.runner; + if (!platformSpec.distribution.isEmpty()) { + build.distribution = platformSpec.distribution; + build.javaVersion = platformSpec.javaVersion; + } + build.assetPlatform = platformSpec.assetPlatform; + build.archiveExtension = platformSpec.archiveExtension; + build.binaryName = platformSpec.binaryName; + jobs.add(build); + } + } + + private static void discoverRelease( + WorkflowExecutionMetadata execution, + List jobs, + String trustedAutomationCommit) { + ReleaseStatus status = releaseStatus(execution); + ReleaseIdentity releaseIdentity = + status == null + ? (ReleaseIdentity) + execution.getMemo(ReleaseWorkflowImpl.IDENTITY_MEMO_KEY, ReleaseIdentity.class) + : status.identity; + if (releaseIdentity == null) { + throw new IllegalStateException("Release Workflow has no immutable identity memo."); + } + releaseIdentity.validate(); + validateReleaseParent(execution, releaseIdentity); + requireTrustedDiscoveryCommit( + releaseIdentity.candidate.trustedAutomationCommit, trustedAutomationCommit); + if (!QueueNames.releaseWorkflowId(releaseIdentity) + .equals(execution.getExecution().getWorkflowId()) + || !QueueNames.releaseWorkflow(releaseIdentity).equals(execution.getTaskQueue())) { + throw new IllegalStateException("Release identity memo does not match Workflow routing."); + } + if (status != null + && ("PAUSED".equals(status.phase) + || "BLOCKED".equals(status.phase) + || "HANDED_OFF".equals(status.phase))) { + return; + } + DiscoveryJob release = new DiscoveryJob("release", execution.getTaskQueue()); + release.automationCommit = releaseIdentity.candidate.trustedAutomationCommit; + release.workflowId = execution.getExecution().getWorkflowId(); + release.runId = execution.getExecution().getRunId(); + release.tag = releaseIdentity.candidate.tag; + release.commitSha = releaseIdentity.candidate.commitSha; + jobs.add(release); + } + + private static List discoverPublication( + WorkflowClient client, String trustedAutomationCommit) { + List jobs = new ArrayList<>(); + for (WorkflowExecutionMetadata execution : openExecutions(client, "ReleaseWorkflow")) { + try { + List executionJobs = new ArrayList<>(); + discoverPublication(execution, executionJobs, trustedAutomationCommit); + jobs.addAll(executionJobs); + } catch (RuntimeException e) { + reportSkippedExecution(execution, e); + } + } + return jobs; + } + + private static void discoverPublication( + WorkflowExecutionMetadata execution, + List jobs, + String trustedAutomationCommit) { + ReleaseStatus status = releaseStatus(execution); + if (status == null || status.identity == null || status.approval == null) { + return; + } + if (!("PREFLIGHT".equals(status.phase) + || "AWAITING_MAVEN_PAYLOAD".equals(status.phase) + || "MAVEN_REPOSITORY".equals(status.phase) + || "MAVEN_PORTAL".equals(status.phase) + || "MAVEN_PUBLISH".equals(status.phase) + || "GITHUB_DRAFT".equals(status.phase) + || "PUBLISH_GITHUB".equals(status.phase))) { + return; + } + if (status.nextRetryAtMillis > System.currentTimeMillis()) { + return; + } + ReleaseIdentity identity = status.identity; + identity.validate(); + validateReleaseParent(execution, identity); + requireTrustedDiscoveryCommit( + identity.candidate.trustedAutomationCommit, trustedAutomationCommit); + if (!QueueNames.releaseWorkflowId(identity).equals(execution.getExecution().getWorkflowId()) + || !QueueNames.releaseWorkflow(identity).equals(execution.getTaskQueue()) + || !execution.getExecution().getRunId().equals(status.approval.runId)) { + throw new IllegalStateException("Approved release memo does not match its execution."); + } + DiscoveryJob job = + new DiscoveryJob( + "publication", QueueNames.publication(identity, status.mavenSubmissionGeneration)); + job.tag = identity.candidate.tag; + job.commitSha = identity.candidate.commitSha; + job.automationCommit = identity.candidate.trustedAutomationCommit; + job.workflowId = execution.getExecution().getWorkflowId(); + job.runId = execution.getExecution().getRunId(); + job.releaseDigest = identity.digest(); + jobs.add(job); + } + + private static List discoverApprovals( + WorkflowClient client, String trustedAutomationCommit) { + List jobs = new ArrayList<>(); + for (WorkflowExecutionMetadata execution : openExecutions(client, "ReleaseWorkflow")) { + try { + List executionJobs = new ArrayList<>(); + discoverApproval(execution, executionJobs, trustedAutomationCommit); + jobs.addAll(executionJobs); + } catch (RuntimeException e) { + reportSkippedExecution(execution, e); + } + } + return jobs; + } + + private static void discoverApproval( + WorkflowExecutionMetadata execution, + List jobs, + String trustedAutomationCommit) { + ReleaseStatus status = releaseStatus(execution); + if (status == null + || status.identity == null + || !"AWAITING_APPROVAL".equals(status.phase) + || status.approval != null) { + return; + } + ReleaseIdentity identity = status.identity; + identity.validate(); + validateReleaseParent(execution, identity); + requireTrustedDiscoveryCommit( + identity.candidate.trustedAutomationCommit, trustedAutomationCommit); + DiscoveryJob job = + new DiscoveryJob( + status.approvalRequest == null ? "approval" : "approval-recovery", + execution.getTaskQueue()); + job.workflowId = execution.getExecution().getWorkflowId(); + job.runId = execution.getExecution().getRunId(); + job.tag = identity.candidate.tag; + job.commitSha = identity.candidate.commitSha; + job.notesSha256 = identity.candidate.releaseNotesSha256; + job.manifestSha256 = identity.manifestSha256; + job.releaseDigest = identity.digest(); + job.candidateRunId = identity.candidateRunId; + job.automationCommit = identity.candidate.trustedAutomationCommit; + if (status.approvalRequest != null) { + job.approvalIssueNumber = Long.toString(status.approvalRequest.githubIssueNumber); + job.approvalIssueNodeId = status.approvalRequest.githubIssueNodeId; + job.approvalIssueBodySha256 = status.approvalRequest.githubIssueBodySha256; + } + jobs.add(job); + } + + private static void approve(WorkflowClient client, Map env) { + String actor = required(env, "GITHUB_TRIGGERING_ACTOR"); + verifyApprover(actor); + String eventName = required(env, "GITHUB_EVENT_NAME"); + if (!("issues".equals(eventName) || "schedule".equals(eventName))) { + throw new IllegalStateException( + "Approval must be delivered by the issue event or its scheduled recovery."); + } + long issueNumber = Long.parseLong(required(env, "APPROVAL_ISSUE_NUMBER")); + WorkflowExecutionMetadata metadata = findApprovalIssue(client, issueNumber); + if (!metadata.getTaskQueue().startsWith("sdk-java-release-")) { + throw new IllegalStateException("Release Workflow uses an unexpected Task Queue."); + } + WorkerFactory factory = WorkerFactory.newInstance(client); + factory + .newWorker(metadata.getTaskQueue()) + .registerWorkflowImplementationTypes(ReleaseWorkflowImpl.class); + factory.start(); + try { + WorkflowExecution execution = metadata.getExecution(); + ReleaseWorkflow workflow = releaseStub(client, execution); + ReleaseStatus status = workflow.status(); + if (status == null || !"AWAITING_APPROVAL".equals(status.phase)) { + throw new IllegalStateException("The only open release is not awaiting approval."); + } + ReleaseIdentity identity = status.identity; + identity.validate(); + requireTrustedCommit(identity, env); + if (!QueueNames.releaseWorkflowId(identity).equals(execution.getWorkflowId()) + || !QueueNames.releaseWorkflow(identity).equals(metadata.getTaskQueue())) { + throw new IllegalStateException("Release identity does not match its Workflow routing."); + } + ApprovalEvidence evidence = + new ApprovalEvidence( + identity.digest(), + execution.getWorkflowId(), + execution.getRunId(), + Long.parseLong(required(env, "GITHUB_RUN_ID")), + actor, + issueNumber, + required(env, "APPROVAL_ISSUE_NODE_ID"), + required(env, "APPROVAL_ISSUE_BODY_SHA256"), + identity.candidate.trustedAutomationCommit); + workflow.approve(evidence); + writeIdentityOutputs(metadata, identity, "APPROVED"); + } finally { + factory.shutdown(); + } + } + + private static void requestApproval(WorkflowClient client, Map env) { + String expectedWorkflowId = required(env, "EXPECTED_WORKFLOW_ID"); + List pending = + openExecutions(client, "ReleaseWorkflow").stream() + .filter( + execution -> { + ReleaseStatus status = validReleaseStatusOrNull(execution); + return status != null + && expectedWorkflowId.equals(execution.getExecution().getWorkflowId()); + }) + .collect(Collectors.toList()); + if (pending.size() != 1) { + throw new IllegalStateException( + "Approval request does not identify exactly one open release; found " + + pending.size() + + "."); + } + WorkflowExecutionMetadata metadata = pending.get(0); + withReleaseWorker( + client, + metadata, + workflow -> { + ReleaseStatus status = workflow.status(); + ReleaseIdentity identity = status.identity; + requireTrustedCommit(identity, env); + ApprovalRequest request = + new ApprovalRequest( + identity.digest(), + metadata.getExecution().getWorkflowId(), + metadata.getExecution().getRunId(), + Long.parseLong(required(env, "GITHUB_RUN_ID")), + Long.parseLong(required(env, "APPROVAL_ISSUE_NUMBER")), + required(env, "APPROVAL_ISSUE_NODE_ID"), + required(env, "APPROVAL_ISSUE_BODY_SHA256"), + required(env, "APPROVAL_ISSUE_CREATOR"), + identity.candidate.trustedAutomationCommit); + if (status.approvalRequest == null) { + workflow.requestApproval(request); + } else if (!status.approvalRequest.sameIssue(request)) { + throw new IllegalStateException( + "The release already has a different immutable approval issue."); + } + writeIdentityOutputs(metadata, identity, status.phase); + }); + } + + private static void approvalTarget(WorkflowClient client, Map env) { + WorkflowExecutionMetadata target = + findApprovalIssue(client, Long.parseLong(required(env, "APPROVAL_ISSUE_NUMBER"))); + ReleaseStatus status = validatedReleaseStatus(target); + writeIdentityOutputs(target, status.identity, status.phase); + } + + private static void control( + WorkflowClient client, Map env, String action, String tag, String commitSha) { + String actor = optional(env, "CONTROL_GITHUB_ACTOR", required(env, "GITHUB_TRIGGERING_ACTOR")); + verifyApprover(actor); + long githubRunId = + Long.parseLong(optional(env, "CONTROL_GITHUB_RUN_ID", required(env, "GITHUB_RUN_ID"))); + WorkflowExecutionMetadata metadata = findRelease(client, tag, commitSha); + withReleaseWorker( + client, + metadata, + workflow -> { + ReleaseStatus status = workflow.status(); + ReleaseIdentity identity = status.identity; + requireTrustedCommit(identity, env); + ControlEvidence evidence = new ControlEvidence(); + evidence.action = action; + evidence.releaseDigest = identity.digest(); + evidence.workflowId = metadata.getExecution().getWorkflowId(); + evidence.runId = metadata.getExecution().getRunId(); + evidence.githubRunId = githubRunId; + evidence.githubActor = actor; + evidence.tag = tag; + evidence.commitSha = commitSha; + evidence.reason = fixedControlReason(action); + if ("handoff-manual".equals(action)) { + String requested = optional(env, "MANUAL_MAVEN_REQUESTED", "false"); + if (!("true".equals(requested) || "false".equals(requested))) { + throw new IllegalArgumentException("MANUAL_MAVEN_REQUESTED must be true or false."); + } + evidence.manualMavenRequested = Boolean.parseBoolean(requested); + } + if ("retry-maven-submission".equals(action)) { + evidence.mavenSubmissionGeneration = + Integer.parseInt(required(env, "MAVEN_RETRY_GENERATION")); + try { + evidence.mavenInspection = + read(required(env, "MAVEN_RETRY_INSPECTION_FILE"), MavenInspection.class); + } catch (IOException e) { + throw new IllegalStateException("Unable to read the exact Maven inspection.", e); + } + evidence.authorizationSha256 = + Digests.sha256(evidence.mavenInspection.canonicalForm(identity.digest())); + } + evidence.validate(); + ReleaseStatus updated = workflow.control(evidence); + writeIdentityOutputs(metadata, identity, updated.phase); + writeStatusOutputs(updated); + if ("handoff-manual".equals(action)) { + Path handoff = + Paths.get(required(env, "RUNNER_TEMP")).resolve("sdk-java-release-handoff.json"); + try { + Files.write(handoff, GSON.toJson(updated).getBytes(StandardCharsets.UTF_8)); + } catch (IOException e) { + throw new IllegalStateException("Unable to write the handoff receipt.", e); + } + writeOutput("handoff_file", handoff.toString()); + } + }); + } + + private static void inspect(WorkflowClient client, String tag, String commitSha) { + WorkflowExecutionMetadata metadata = findReleaseIncludingClosed(client, tag, commitSha); + ReleaseStatus status = validatedReleaseStatus(metadata); + writeIdentityOutputs(metadata, status.identity, status.phase); + writeStatusOutputs(status); + } + + private static void inspectIfPresent(WorkflowClient client, String tag, String commitSha) { + List matches = + client + .listExecutions("WorkflowType = 'ReleaseWorkflow'") + .filter( + execution -> { + ReleaseStatus status = validReleaseStatusOrNull(execution); + return status != null + && status.identity != null + && tag.equals(status.identity.candidate.tag) + && commitSha.equals(status.identity.candidate.commitSha); + }) + .collect(Collectors.toList()); + if (matches.isEmpty()) { + writeOutput("found", "false"); + writeOutput("phase", "NO_WORKFLOW"); + return; + } + if (matches.size() != 1) { + throw new IllegalStateException("Tag and SHA identify multiple release executions."); + } + ReleaseStatus status = validatedReleaseStatus(matches.get(0)); + writeIdentityOutputs(matches.get(0), status.identity, status.phase); + writeStatusOutputs(status); + writeOutput("found", "true"); + } + + private static void publicationInput( + WorkflowClient client, String tag, String commitSha, Path output) throws IOException { + WorkflowExecutionMetadata metadata = findRelease(client, tag, commitSha); + ReleaseStatus status = validatedReleaseStatus(metadata); + if (status == null || status.identity == null || status.approval == null) { + throw new IllegalStateException("The exact release has no approved publication input."); + } + PublicationInput input = + new PublicationInput( + status.identity, + status.approvalRequest, + status.approval, + metadata.getExecution().getWorkflowId(), + metadata.getExecution().getRunId()); + input.mavenSubmissionGeneration = status.mavenSubmissionGeneration; + input.mavenRetryAuthorization = status.mavenRetryAuthorization; + input.mavenPayload = status.mavenPayload; + input.mavenGenerations = new ArrayList<>(status.mavenGenerations); + Files.write(output, GSON.toJson(input).getBytes(StandardCharsets.UTF_8)); + writeIdentityOutputs(metadata, status.identity, status.phase); + writeStatusOutputs(status); + writeOutput("approval_run_id", Long.toString(status.approval.githubApprovalRunId)); + writeOutput("approval_actor", status.approval.githubActor); + writeOutput("approval_issue_number", Long.toString(status.approval.githubIssueNumber)); + writeOutput("approval_issue_node_id", status.approval.githubIssueNodeId); + writeOutput("approval_issue_body_sha256", status.approval.githubIssueBodySha256); + writeOutput("maven_submission_generation", Integer.toString(status.mavenSubmissionGeneration)); + writeOutput( + "maven_retry_authorization_sha256", + status.mavenRetryAuthorization == null + ? "" + : status.mavenRetryAuthorization.authorizationSha256); + writeOutput("publication_input_file", output.toString()); + } + + private static void runWorker( + WorkflowClient client, String role, String taskQueue, Map env) + throws InterruptedException { + if (!taskQueue.startsWith("sdk-java-release-")) { + throw new IllegalArgumentException("Refusing to poll a non-release Task Queue."); + } + WorkerFactory factory = WorkerFactory.newInstance(client); + Worker worker = factory.newWorker(taskQueue); + CountDownLatch activityCompleted = new CountDownLatch(1); + CountDownLatch activityStarted = new CountDownLatch(1); + AtomicReference activityFailure = new AtomicReference<>(); + Consumer recordActivityCompletion = + failure -> { + activityFailure.set(failure); + activityCompleted.countDown(); + }; + switch (role) { + case "candidate": + worker.registerWorkflowImplementationTypes(CandidateWorkflowImpl.class); + break; + case "release": + worker.registerWorkflowImplementationTypes(ReleaseWorkflowImpl.class); + registerOwnershipWorker(factory, client, required(env, "RELEASE_TAG"), true); + break; + case "publication": + registerOwnershipWorker(factory, client, required(env, "RELEASE_TAG"), false); + worker.registerActivitiesImplementations( + new PublicationActivitiesImpl( + REPOSITORY_ROOT, + sourceRoot(env), + client, + env, + activityStarted::countDown, + recordActivityCompletion)); + break; + default: + throw new IllegalArgumentException("Unknown Worker role: " + role); + } + factory.start(); + boolean activityRole = "publication".equals(role); + boolean processed = false; + if (activityRole + && activityStarted.await(Duration.ofMinutes(2).toMillis(), TimeUnit.MILLISECONDS)) { + processed = activityCompleted.await(Duration.ofMinutes(98).toMillis(), TimeUnit.MILLISECONDS); + } else if (!activityRole) { + processed = awaitWindow(Duration.ofMinutes(10)); + failOnUnrecoveredWorkflowTaskFailure(client, env); + } + writeOutput( + "worker_outcome", processed ? "activity-attempt-finished" : "capacity-window-ended"); + factory.shutdown(); + factory.awaitTermination(10, java.util.concurrent.TimeUnit.MINUTES); + if (activityFailure.get() != null) { + Throwable failure = activityFailure.get(); + if (failure instanceof ApplicationFailure + && ((ApplicationFailure) failure).isNonRetryable()) { + throw new IllegalStateException( + "The release Activity reached a durable non-retryable failure.", failure); + } + writeOutput("worker_outcome", "activity-attempt-failed-temporal-will-retry"); + throw new IllegalStateException( + "The release Activity attempt failed; Temporal retained its durable retry state and scheduled recovery.", + failure); + } + } + + private static void registerOwnershipWorker( + WorkerFactory factory, WorkflowClient client, String tag, boolean registerActivities) { + Worker ownershipWorker = factory.newWorker(QueueNames.ownership(tag)); + ownershipWorker.registerWorkflowImplementationTypes(ReleaseOwnershipWorkflowImpl.class); + if (registerActivities) { + ownershipWorker.registerActivitiesImplementations(new OwnershipActivitiesImpl(client)); + } + } + + private static boolean awaitWindow(Duration pollingWindow) throws InterruptedException { + Thread.sleep(pollingWindow.toMillis()); + return false; + } + + private static void failOnUnrecoveredWorkflowTaskFailure( + WorkflowClient client, Map env) { + String workflowId = required(env, "EXPECTED_WORKFLOW_ID"); + String runId = env.get("EXPECTED_RUN_ID"); + io.temporal.common.WorkflowExecutionHistory history = + runId == null || runId.isEmpty() + ? client.fetchHistory(workflowId) + : client.fetchHistory(workflowId, runId); + String failure = unrecoveredWorkflowFailure(history.getEvents()); + if (failure != null) { + writeOutput("worker_outcome", failure); + throw new IllegalStateException("The release Workflow failed: " + failure + "."); + } + } + + static String unrecoveredWorkflowFailure( + Iterable events) { + long lastCompletedTask = -1; + long lastFailedTask = -1; + String terminalFailure = null; + for (io.temporal.api.history.v1.HistoryEvent event : events) { + switch (event.getEventType()) { + case EVENT_TYPE_WORKFLOW_TASK_COMPLETED: + lastCompletedTask = event.getEventId(); + break; + case EVENT_TYPE_WORKFLOW_TASK_FAILED: + lastFailedTask = event.getEventId(); + break; + case EVENT_TYPE_WORKFLOW_TASK_TIMED_OUT: + lastFailedTask = event.getEventId(); + break; + case EVENT_TYPE_WORKFLOW_EXECUTION_FAILED: + terminalFailure = "workflow-execution-failed"; + break; + case EVENT_TYPE_WORKFLOW_EXECUTION_TIMED_OUT: + terminalFailure = "workflow-execution-timed-out"; + break; + case EVENT_TYPE_WORKFLOW_EXECUTION_TERMINATED: + terminalFailure = "workflow-execution-terminated"; + break; + case EVENT_TYPE_WORKFLOW_EXECUTION_CANCELED: + terminalFailure = "workflow-execution-canceled"; + break; + default: + break; + } + } + if (terminalFailure != null) { + return terminalFailure; + } + return lastFailedTask > lastCompletedTask ? "workflow-task-failed-or-timed-out" : null; + } + + private static List openExecutions( + WorkflowClient client, String workflowType) { + String query = + "ExecutionStatus = 'Running' AND WorkflowType = '" + workflowType.replace("'", "''") + "'"; + return client.listExecutions(query).collect(Collectors.toList()); + } + + private static void reportSkippedExecution( + WorkflowExecutionMetadata execution, RuntimeException failure) { + System.err.println( + "Skipping malformed release execution " + + execution.getExecution().getWorkflowId() + + ": " + + value(failure.getMessage())); + } + + private static ReleaseWorkflow releaseStub(WorkflowClient client, WorkflowExecution execution) { + return client.newWorkflowStub( + ReleaseWorkflow.class, + WorkflowTargetOptions.newBuilder().setWorkflowExecution(execution).build()); + } + + private static ReleaseStatus releaseStatus(WorkflowExecutionMetadata execution) { + return (ReleaseStatus) + execution.getMemo(ReleaseWorkflowImpl.STATUS_MEMO_KEY, ReleaseStatus.class); + } + + private static ReleaseStatus validatedReleaseStatus(WorkflowExecutionMetadata execution) { + ReleaseStatus status = releaseStatus(execution); + if (status == null || status.identity == null) { + throw new IllegalStateException("Release execution has no immutable status identity."); + } + status.identity.validate(); + validateReleaseParent(execution, status.identity); + if (!QueueNames.releaseWorkflowId(status.identity) + .equals(execution.getExecution().getWorkflowId()) + || !QueueNames.releaseWorkflow(status.identity).equals(execution.getTaskQueue())) { + throw new IllegalStateException("Release status identity does not match Workflow routing."); + } + if (status.approvalRequest != null) { + status.approvalRequest.validate(); + if (!execution.getExecution().getRunId().equals(status.approvalRequest.runId)) { + throw new IllegalStateException("Approval request is bound to another Workflow run."); + } + } + if (status.approval != null) { + status.approval.validate(); + if (!execution.getExecution().getRunId().equals(status.approval.runId)) { + throw new IllegalStateException("Approval is bound to another Workflow run."); + } + } + return status; + } + + private static ReleaseStatus validReleaseStatusOrNull(WorkflowExecutionMetadata execution) { + try { + return validatedReleaseStatus(execution); + } catch (RuntimeException failure) { + reportSkippedExecution(execution, failure); + return null; + } + } + + static void validateReleaseParent(WorkflowExecutionMetadata execution, ReleaseIdentity identity) { + WorkflowExecution parent = execution.getParentExecution(); + WorkflowExecution root = execution.getRootExecution(); + String expectedParent = QueueNames.candidateWorkflowId(identity.candidate); + if (identity.candidateRunId == null + || identity.candidateRunId.isEmpty() + || parent == null + || !expectedParent.equals(parent.getWorkflowId()) + || !identity.candidateRunId.equals(parent.getRunId()) + || root == null + || !expectedParent.equals(root.getWorkflowId()) + || !identity.candidateRunId.equals(root.getRunId())) { + throw new IllegalStateException( + "Release execution is not the child of its immutable Candidate Workflow."); + } + } + + private static WorkflowExecutionMetadata findApprovalIssue( + WorkflowClient client, long issueNumber) { + List matches = + openExecutions(client, "ReleaseWorkflow").stream() + .filter( + execution -> { + ReleaseStatus status = validReleaseStatusOrNull(execution); + return status != null + && "AWAITING_APPROVAL".equals(status.phase) + && status.approvalRequest != null + && status.approvalRequest.githubIssueNumber == issueNumber; + }) + .collect(Collectors.toList()); + if (matches.size() != 1) { + throw new IllegalStateException( + "The Actions run is not bound to exactly one pending release."); + } + return matches.get(0); + } + + private static WorkflowExecutionMetadata findRelease( + WorkflowClient client, String tag, String commitSha) { + return findRelease(openExecutions(client, "ReleaseWorkflow"), tag, commitSha); + } + + private static WorkflowExecutionMetadata findReleaseIncludingClosed( + WorkflowClient client, String tag, String commitSha) { + List open = openExecutions(client, "ReleaseWorkflow"); + try { + return findRelease(open, tag, commitSha); + } catch (IllegalStateException ignored) { + return findRelease( + client.listExecutions("WorkflowType = 'ReleaseWorkflow'").collect(Collectors.toList()), + tag, + commitSha); + } + } + + private static WorkflowExecutionMetadata findRelease( + List executions, String tag, String commitSha) { + List matches = + executions.stream() + .filter( + execution -> { + ReleaseStatus status = validReleaseStatusOrNull(execution); + return status != null + && status.identity != null + && tag.equals(status.identity.candidate.tag) + && commitSha.equals(status.identity.candidate.commitSha); + }) + .collect(Collectors.toList()); + if (matches.size() != 1) { + throw new IllegalStateException("Tag and SHA do not identify exactly one release execution."); + } + return matches.get(0); + } + + private static void withReleaseWorker( + WorkflowClient client, + WorkflowExecutionMetadata metadata, + Consumer operation) { + WorkerFactory factory = WorkerFactory.newInstance(client); + factory + .newWorker(metadata.getTaskQueue()) + .registerWorkflowImplementationTypes(ReleaseWorkflowImpl.class); + ReleaseStatus status = releaseStatus(metadata); + ReleaseIdentity identity = + status == null + ? (ReleaseIdentity) + metadata.getMemo(ReleaseWorkflowImpl.IDENTITY_MEMO_KEY, ReleaseIdentity.class) + : status.identity; + if (identity == null) { + throw new IllegalStateException("Release Workflow has no ownership identity."); + } + registerOwnershipWorker(factory, client, identity.candidate.tag, true); + factory.start(); + try { + operation.accept(releaseStub(client, metadata.getExecution())); + } finally { + factory.shutdown(); + } + } + + private static void requireTrustedCommit(ReleaseIdentity identity, Map env) { + if (!identity.candidate.trustedAutomationCommit.equals( + required(env, "RELEASE_AUTOMATION_REF"))) { + throw new IllegalStateException( + "Actions did not check out this release's trusted Worker commit."); + } + } + + private static void requireTrustedDiscoveryCommit(String actual, String allowedCommits) { + if (!Arrays.asList(allowedCommits.split(",")).contains(actual)) { + throw new IllegalStateException( + "Release identity selects an automation commit outside the protected allowlist."); + } + } + + private static void writeIdentityOutputs( + WorkflowExecutionMetadata metadata, ReleaseIdentity identity, String phase) { + writeOutput("workflow_id", metadata.getExecution().getWorkflowId()); + writeOutput("run_id", metadata.getExecution().getRunId()); + writeOutput("tag", identity.candidate.tag); + writeOutput("commit_sha", identity.candidate.commitSha); + writeOutput("notes_sha256", identity.candidate.releaseNotesSha256); + writeOutput("manifest_sha256", identity.manifestSha256); + writeOutput("release_digest", identity.digest()); + writeOutput("automation_commit", identity.candidate.trustedAutomationCommit); + writeOutput("phase", phase); + } + + private static void writeStatusOutputs(ReleaseStatus status) { + writeOutput("paused_from", value(status.pausedFrom)); + writeOutput("handed_off_from", value(status.handedOffFrom)); + writeOutput("last_completed_stage", value(status.lastCompletedStage)); + writeOutput("last_error", value(status.lastError)); + writeOutput("blocked_at_millis", Long.toString(status.blockedAtMillis)); + writeOutput("maven_central_url", value(status.mavenCentralUrl)); + writeOutput("sonatype_repository_id", value(status.sonatypeRepositoryId)); + writeOutput("portal_deployment_id", value(status.portalDeploymentId)); + writeOutput("github_draft_url", value(status.githubDraftUrl)); + writeOutput("github_release_url", value(status.githubReleaseUrl)); + writeOutput("maven_submission_generation", Integer.toString(status.mavenSubmissionGeneration)); + writeOutput("stage_attempt", Integer.toString(status.stageAttempt)); + writeOutput("stage_started_at_millis", Long.toString(status.stageStartedAtMillis)); + writeOutput("next_retry_at_millis", Long.toString(status.nextRetryAtMillis)); + writeOutput( + "maven_started", + Boolean.toString( + status.mavenGenerations != null + && status.mavenGenerations.stream() + .anyMatch(generation -> generation.submissionStarted))); + writeOutput( + "maven_complete", + Boolean.toString(status.mavenCentralUrl != null && !status.mavenCentralUrl.isEmpty())); + writeOutput("ownership_owner", status.ownership == null ? "" : value(status.ownership.owner)); + writeOutput( + "manual_maven_state", + status.ownership == null ? "" : value(status.ownership.manualMavenState)); + writeOutput("maven_payload_recorded", Boolean.toString(status.mavenPayload != null)); + } + + private static String value(String value) { + return value == null ? "" : value.replace('\n', ' '); + } + + private static String optional(Map environment, String name, String fallback) { + String value = environment.get(name); + return value == null || value.isEmpty() ? fallback : value; + } + + private static String fixedControlReason(String action) { + switch (action) { + case "pause": + return "Release manager paused Temporal publication."; + case "resume": + return "Release manager resumed Temporal publication."; + case "handoff-manual": + return "Release manager transferred ownership to the existing manual workflow."; + case "retry-maven-submission": + return "Release manager inspected Sonatype and authorized one new staging generation."; + default: + throw new IllegalArgumentException("Unknown release control action."); + } + } + + private static T read(String path, Class type) throws IOException { + return GSON.fromJson( + new String(Files.readAllBytes(Paths.get(path)), StandardCharsets.UTF_8), type); + } + + private static void writeOutput(String name, String value) { + String output = System.getenv("GITHUB_OUTPUT"); + if (output == null || output.isEmpty()) { + System.out.println(name + "=" + value); + return; + } + try { + Files.write( + Paths.get(output), + Arrays.asList(name + "=" + value), + StandardCharsets.UTF_8, + java.nio.file.StandardOpenOption.APPEND); + } catch (IOException e) { + throw new IllegalStateException("Unable to write GitHub Actions output.", e); + } + } + + private static String required(Map env, String name) { + String value = env.get(name); + if (value == null || value.isEmpty()) { + throw new IllegalArgumentException("Required Actions value is missing: " + name); + } + return value; + } + + private static Path sourceRoot(Map env) { + Path path = Paths.get(required(env, "RELEASE_SOURCE_DIR")).toAbsolutePath().normalize(); + if (!Files.isDirectory(path)) { + throw new IllegalArgumentException("RELEASE_SOURCE_DIR is not a directory."); + } + return path; + } + + static void validateCandidateStart( + WorkflowExecution execution, + WorkflowExecutionMetadata description, + WorkflowExecutionStartedEventAttributes started, + CandidateIdentity startedCandidate, + CandidateIdentity expected) { + expected.validate(); + startedCandidate.validate(); + String expectedWorkflowId = QueueNames.candidateWorkflowId(expected); + String expectedTaskQueue = QueueNames.candidateWorkflow(expected); + CandidateIdentity memo = + (CandidateIdentity) description.getMemo("CandidateIdentity", CandidateIdentity.class); + if (!execution.equals(description.getExecution()) + || !expectedWorkflowId.equals(execution.getWorkflowId()) + || !execution + .getRunId() + .matches("[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}") + || !"CandidateWorkflow".equals(description.getWorkflowType()) + || !expectedTaskQueue.equals(description.getTaskQueue()) + || memo == null + || !expected.canonicalForm().equals(memo.canonicalForm()) + || !"CandidateWorkflow".equals(started.getWorkflowType().getName()) + || !expectedTaskQueue.equals(started.getTaskQueue().getName()) + || !expected.canonicalForm().equals(startedCandidate.canonicalForm())) { + throw new IllegalStateException( + "Candidate Workflow start receipt does not match the immutable candidate."); + } + } + + private static void verifyApprover(String actor) { + ProcessBuilder process = + new ProcessBuilder( + "bash", + REPOSITORY_ROOT + .resolve(".github/scripts/temporal-release/verify-approver.sh") + .toAbsolutePath() + .toString() + .replace('\\', '/'), + actor) + .directory(REPOSITORY_ROOT.toFile()) + .inheritIO(); + try { + int status = process.start().waitFor(); + if (status == 43) { + throw new IllegalArgumentException("GitHub actor is not a temporalio/sdk team member."); + } + if (status != 0) { + throw new IllegalStateException( + "GitHub temporalio/sdk team membership is temporarily unavailable."); + } + } catch (IOException e) { + throw new IllegalStateException("Unable to run the fixed approver check.", e); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + throw new IllegalStateException("Approver check was interrupted.", e); + } + } + + private static Path repositoryRoot() { + String configured = System.getProperty("releaseAutomation.repositoryRoot"); + if (configured == null || configured.isEmpty()) { + throw new IllegalStateException("The trusted repository root JVM property is missing."); + } + Path root = Paths.get(configured).toAbsolutePath().normalize(); + if (!Files.isRegularFile(root.resolve(".github/scripts/temporal-release/verify-approver.sh"))) { + throw new IllegalStateException("The trusted repository root has an unexpected layout."); + } + return root; + } + + private static void requireArguments(String[] args, int expected) { + if (args.length != expected) { + throw new IllegalArgumentException("Unexpected command arguments."); + } + } +} diff --git a/.github/release-automation/src/main/java/io/temporal/releaseautomation/ReleaseIdentity.java b/.github/release-automation/src/main/java/io/temporal/releaseautomation/ReleaseIdentity.java new file mode 100644 index 0000000000..34d8365e97 --- /dev/null +++ b/.github/release-automation/src/main/java/io/temporal/releaseautomation/ReleaseIdentity.java @@ -0,0 +1,71 @@ +package io.temporal.releaseautomation; + +import java.util.HashSet; +import java.util.Set; + +public final class ReleaseIdentity { + public CandidateIdentity candidate; + public ArtifactManifest manifest; + public String manifestSha256; + public String candidateRunId; + + public ReleaseIdentity() {} + + public ReleaseIdentity(CandidateIdentity candidate, ArtifactManifest manifest) { + this(candidate, manifest, ""); + } + + public ReleaseIdentity( + CandidateIdentity candidate, ArtifactManifest manifest, String candidateRunId) { + this.candidate = candidate; + this.manifest = manifest; + this.manifestSha256 = manifest.digest(); + this.candidateRunId = candidateRunId; + validate(); + } + + public void validate() { + if (candidate == null || manifest == null) { + throw new IllegalArgumentException("Candidate and artifact manifest are required."); + } + candidate.validate(); + manifest.validate(); + if (candidateRunId == null + || (!candidateRunId.isEmpty() + && !candidateRunId.matches( + "[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}"))) { + throw new IllegalArgumentException("Candidate Workflow Run ID is invalid."); + } + if (!manifest.digest().equals(manifestSha256)) { + throw new IllegalArgumentException("Artifact manifest hash does not match its contents."); + } + Set actual = new HashSet<>(); + Set actualReceipts = new HashSet<>(); + for (GithubArtifactReceipt artifact : manifest.artifacts) { + if (artifact.files.size() != 1) { + throw new IllegalArgumentException("Native GitHub artifact identity is invalid."); + } + actualReceipts.add(artifact.artifactName); + actual.add(artifact.files.get(0).name); + } + Set expected = new HashSet<>(); + Set expectedReceipts = new HashSet<>(); + for (String platform : ReleasePolicy.NATIVE_PLATFORMS) { + expected.add(ReleasePolicy.nativeArtifactName(candidate.version(), platform)); + expectedReceipts.add(ReleasePolicy.githubNativeArtifactName(candidate, platform)); + } + if (!actual.equals(expected) || !actualReceipts.equals(expectedReceipts)) { + throw new IllegalArgumentException( + "Artifact manifest is not the fixed sdk-java platform set."); + } + } + + public String canonicalForm() { + validate(); + return candidate.canonicalForm() + "\n" + manifestSha256 + "\n" + manifest.canonicalForm(); + } + + public String digest() { + return Digests.sha256(canonicalForm()); + } +} diff --git a/.github/release-automation/src/main/java/io/temporal/releaseautomation/ReleaseOwnershipWorkflow.java b/.github/release-automation/src/main/java/io/temporal/releaseautomation/ReleaseOwnershipWorkflow.java new file mode 100644 index 0000000000..f34a3288a8 --- /dev/null +++ b/.github/release-automation/src/main/java/io/temporal/releaseautomation/ReleaseOwnershipWorkflow.java @@ -0,0 +1,21 @@ +package io.temporal.releaseautomation; + +import io.temporal.workflow.QueryMethod; +import io.temporal.workflow.UpdateMethod; +import io.temporal.workflow.WorkflowInterface; +import io.temporal.workflow.WorkflowMethod; + +@WorkflowInterface +public interface ReleaseOwnershipWorkflow { + @WorkflowMethod + void manage(OwnershipClaim initialClaim); + + @UpdateMethod + OwnershipStatus claim(OwnershipClaim claim); + + @UpdateMethod + OwnershipStatus recordManualMaven(ManualMavenAttempt attempt); + + @QueryMethod + OwnershipStatus status(); +} diff --git a/.github/release-automation/src/main/java/io/temporal/releaseautomation/ReleaseOwnershipWorkflowImpl.java b/.github/release-automation/src/main/java/io/temporal/releaseautomation/ReleaseOwnershipWorkflowImpl.java new file mode 100644 index 0000000000..3a8de9be0d --- /dev/null +++ b/.github/release-automation/src/main/java/io/temporal/releaseautomation/ReleaseOwnershipWorkflowImpl.java @@ -0,0 +1,111 @@ +package io.temporal.releaseautomation; + +import io.temporal.workflow.UpdateValidatorMethod; +import io.temporal.workflow.Workflow; +import java.util.Collections; + +public final class ReleaseOwnershipWorkflowImpl implements ReleaseOwnershipWorkflow { + static final String STATUS_MEMO_KEY = "ReleaseOwnership"; + private OwnershipStatus status; + + @Override + public void manage(OwnershipClaim initialClaim) { + initialClaim.validate(); + status = new OwnershipStatus(initialClaim, Workflow.currentTimeMillis()); + upsertStatus(); + Workflow.await(() -> false); + } + + @Override + public OwnershipStatus claim(OwnershipClaim claim) { + validateClaim(claim); + if ("MANUAL".equals(status.owner) && "TEMPORAL".equals(claim.owner)) { + return status; + } + OwnershipStatus updated = new OwnershipStatus(claim, Workflow.currentTimeMillis()); + if ("MANUAL".equals(status.owner) && "MANUAL".equals(claim.owner)) { + updated.manualMavenState = status.manualMavenState; + updated.manualMavenActor = status.manualMavenActor; + updated.manualMavenRunId = status.manualMavenRunId; + } + status = updated; + upsertStatus(); + return status; + } + + @Override + public OwnershipStatus recordManualMaven(ManualMavenAttempt attempt) { + validateManualMaven(attempt); + status.manualMavenState = attempt.state; + status.manualMavenActor = attempt.githubActor; + status.manualMavenRunId = attempt.githubRunId; + status.recordedAtMillis = Workflow.currentTimeMillis(); + upsertStatus(); + return status; + } + + @UpdateValidatorMethod(updateName = "recordManualMaven") + public void validateManualMaven(ManualMavenAttempt attempt) { + attempt.validate(); + if (status == null + || !"MANUAL".equals(status.owner) + || !status.tag.equals(attempt.tag) + || !status.commitSha.equals(attempt.commitSha) + || !status.releaseDigest.equals(attempt.releaseDigest)) { + throw new IllegalArgumentException( + "Manual Maven evidence does not match the durable release ownership."); + } + if ("STARTED".equals(attempt.state)) { + if (!"NOT_STARTED".equals(status.manualMavenState)) { + throw new IllegalStateException( + "Manual Maven publication already started; inspect remote state before continuing."); + } + } else if (!"STARTED".equals(status.manualMavenState) + || !attempt.githubActor.equals(status.manualMavenActor) + || attempt.githubRunId != status.manualMavenRunId) { + throw new IllegalStateException( + "Only the exact GitHub run that started manual Maven publication may complete it."); + } + } + + @UpdateValidatorMethod(updateName = "claim") + public void validateClaim(OwnershipClaim claim) { + claim.validate(); + if (status == null) { + throw new IllegalStateException("Ownership Workflow is not initialized."); + } + if (!status.tag.equals(claim.tag) || !status.commitSha.equals(claim.commitSha)) { + throw new IllegalArgumentException("The release tag is already owned by another commit."); + } + if ("TEMPORAL".equals(status.owner) + && "MANUAL".equals(claim.owner) + && (!claim.handoffConfirmed || !status.releaseDigest.equals(claim.releaseDigest))) { + throw new IllegalArgumentException( + "Manual takeover requires the exact Temporal handoff result."); + } + if (status.owner.equals(claim.owner) + && !sameOrUnspecifiedManualDigest(status.releaseDigest, claim.releaseDigest, claim.owner)) { + throw new IllegalArgumentException("The ownership release digest cannot change."); + } + } + + @Override + public OwnershipStatus status() { + return status; + } + + private void upsertStatus() { + Workflow.upsertMemo(Collections.singletonMap(STATUS_MEMO_KEY, status)); + } + + private static boolean sameOrUnspecifiedManualDigest( + String current, String requested, String owner) { + if (current == null ? requested == null : current.equals(requested)) { + return true; + } + return "MANUAL".equals(owner) + && (current == null || current.isEmpty()) + && requested != null + && !requested.isEmpty(); + } +} diff --git a/.github/release-automation/src/main/java/io/temporal/releaseautomation/ReleasePolicy.java b/.github/release-automation/src/main/java/io/temporal/releaseautomation/ReleasePolicy.java new file mode 100644 index 0000000000..5e5025a3e2 --- /dev/null +++ b/.github/release-automation/src/main/java/io/temporal/releaseautomation/ReleasePolicy.java @@ -0,0 +1,191 @@ +package io.temporal.releaseautomation; + +import java.util.Arrays; +import java.util.Collections; +import java.util.HashSet; +import java.util.List; +import java.util.Set; + +public final class ReleasePolicy { + public static final String MAVEN_POLICY_CURRENT = "current"; + public static final String MAVEN_POLICY_CLASSIC = "classic"; + public static final String MAVEN_POLICY_CLASSIC_ALPHA = "classic-alpha"; + public static final String MAVEN_POLICY_CLASSIC_ALPHA_LITE = "classic-alpha-lite"; + public static final String REPOSITORY = "temporalio/sdk-java"; + public static final String MAVEN_GROUP = "io.temporal"; + public static final String MAVEN_CENTRAL_BASE = "https://repo1.maven.org/maven2"; + public static final String NATIVE_JAVA_DISTRIBUTION = "graalvm-community"; + public static final String NATIVE_JAVA_VERSION = "23"; + public static final List PLATFORMS = + Collections.unmodifiableList( + Arrays.asList( + new PlatformSpec("linux-amd64-musl", "ubuntu-latest", "linux", "amd64", true), + new PlatformSpec("linux-amd64", "ubuntu-latest", "linux", "amd64", false), + new PlatformSpec("macos-amd64", "macos-15-intel", "macOS", "amd64", false), + new PlatformSpec("macos-arm64", "macos-latest", "macOS", "arm64", false), + new PlatformSpec("linux-arm64", "ubuntu-24.04-arm", "linux", "arm64", false), + new PlatformSpec("windows-amd64", "windows-latest", "windows", "amd64", false))); + public static final List NATIVE_PLATFORMS = platformNames(); + public static final List MAVEN_ARTIFACTS = + Collections.unmodifiableList( + Arrays.asList( + "temporal-aws-lambda", + "temporal-bom", + "temporal-envconfig", + "temporal-kotlin", + "temporal-opentelemetry", + "temporal-opentracing", + "temporal-remote-data-encoder", + "temporal-sdk", + "temporal-serviceclient", + "temporal-shaded", + "temporal-spring-ai", + "temporal-spring-boot-autoconfigure", + "temporal-spring-boot-starter", + "temporal-test-server", + "temporal-testing", + "temporal-workflowcheck", + "temporal-workflowstreams")); + private static final List CLASSIC_MAVEN_ARTIFACTS = + Collections.unmodifiableList( + Arrays.asList( + "temporal-bom", + "temporal-kotlin", + "temporal-opentracing", + "temporal-remote-data-encoder", + "temporal-sdk", + "temporal-serviceclient", + "temporal-shaded", + "temporal-spring-boot-autoconfigure", + "temporal-spring-boot-starter", + "temporal-test-server", + "temporal-testing")); + private static final List CLASSIC_ALPHA_MAVEN_ARTIFACTS = + Collections.unmodifiableList( + Arrays.asList( + "temporal-bom", + "temporal-kotlin", + "temporal-opentracing", + "temporal-remote-data-encoder", + "temporal-sdk", + "temporal-serviceclient", + "temporal-shaded", + "temporal-spring-boot-autoconfigure-alpha", + "temporal-spring-boot-starter-alpha", + "temporal-test-server", + "temporal-testing")); + private static final List CLASSIC_ALPHA_LITE_MAVEN_ARTIFACTS = + Collections.unmodifiableList( + Arrays.asList( + "temporal-kotlin", + "temporal-opentracing", + "temporal-remote-data-encoder", + "temporal-sdk", + "temporal-serviceclient", + "temporal-spring-boot-autoconfigure-alpha", + "temporal-spring-boot-starter-alpha", + "temporal-test-server", + "temporal-testing")); + + private ReleasePolicy() {} + + public static List mavenArtifacts(String policy) { + if (MAVEN_POLICY_CURRENT.equals(policy)) { + return MAVEN_ARTIFACTS; + } + if (MAVEN_POLICY_CLASSIC.equals(policy)) { + return CLASSIC_MAVEN_ARTIFACTS; + } + if (MAVEN_POLICY_CLASSIC_ALPHA.equals(policy)) { + return CLASSIC_ALPHA_MAVEN_ARTIFACTS; + } + if (MAVEN_POLICY_CLASSIC_ALPHA_LITE.equals(policy)) { + return CLASSIC_ALPHA_LITE_MAVEN_ARTIFACTS; + } + throw new IllegalArgumentException("Unsupported sdk-java Maven release policy."); + } + + public static String mavenPolicyForProjects(List projects) { + Set actual = new HashSet<>(projects); + if (actual.size() != projects.size()) { + throw new IllegalArgumentException("sdk-java settings contain duplicate projects."); + } + if (actual.equals(new HashSet<>(MAVEN_ARTIFACTS))) { + return MAVEN_POLICY_CURRENT; + } + if (actual.equals(new HashSet<>(CLASSIC_MAVEN_ARTIFACTS))) { + return MAVEN_POLICY_CLASSIC; + } + if (actual.equals(new HashSet<>(CLASSIC_ALPHA_MAVEN_ARTIFACTS))) { + return MAVEN_POLICY_CLASSIC_ALPHA; + } + if (actual.equals(new HashSet<>(CLASSIC_ALPHA_LITE_MAVEN_ARTIFACTS))) { + return MAVEN_POLICY_CLASSIC_ALPHA_LITE; + } + throw new IllegalArgumentException( + "The immutable source does not match a reviewed sdk-java Maven policy."); + } + + static String nativeArtifactName(String version, String platform) { + PlatformSpec spec = platform(platform); + return "temporal-test-server_" + version + "_" + spec.assetPlatform + spec.archiveExtension; + } + + static String githubNativeArtifactName(CandidateIdentity candidate, String platform) { + candidate.validate(); + platform(platform); + return "sdk-java-release-native-" + candidate.digest() + "-" + platform; + } + + static String githubMavenArtifactName(ReleaseIdentity release) { + release.validate(); + return "sdk-java-release-maven-" + release.digest(); + } + + static PlatformSpec platform(String id) { + for (PlatformSpec platform : PLATFORMS) { + if (platform.id.equals(id)) { + return platform; + } + } + throw new IllegalArgumentException("Unknown sdk-java native release platform."); + } + + private static List platformNames() { + String[] names = new String[PLATFORMS.size()]; + for (int i = 0; i < PLATFORMS.size(); i++) { + names[i] = PLATFORMS.get(i).id; + } + return Collections.unmodifiableList(Arrays.asList(names)); + } + + public static final class PlatformSpec { + public final String id; + public final String runner; + public final String osFamily; + public final String arch; + public final boolean musl; + public final String artifactLabel; + public final String assetPlatform; + public final String archiveExtension; + public final String binaryName; + public final String distribution; + public final String javaVersion; + + private PlatformSpec(String id, String runner, String osFamily, String arch, boolean musl) { + this.id = id; + this.runner = runner; + this.osFamily = osFamily; + this.arch = arch; + this.musl = musl; + this.artifactLabel = osFamily + "_" + arch + (musl ? "_musl" : ""); + this.assetPlatform = + (id.startsWith("macos-") ? "macOS" + id.substring(5) : id).replace('-', '_'); + this.archiveExtension = "windows".equals(osFamily) ? ".zip" : ".tar.gz"; + this.binaryName = + "windows".equals(osFamily) ? "temporal-test-server.exe" : "temporal-test-server"; + this.distribution = "linux".equals(osFamily) ? "" : NATIVE_JAVA_DISTRIBUTION; + this.javaVersion = "linux".equals(osFamily) ? "" : NATIVE_JAVA_VERSION; + } + } +} diff --git a/.github/release-automation/src/main/java/io/temporal/releaseautomation/ReleaseResult.java b/.github/release-automation/src/main/java/io/temporal/releaseautomation/ReleaseResult.java new file mode 100644 index 0000000000..81dc01cf35 --- /dev/null +++ b/.github/release-automation/src/main/java/io/temporal/releaseautomation/ReleaseResult.java @@ -0,0 +1,15 @@ +package io.temporal.releaseautomation; + +public final class ReleaseResult { + public String releaseDigest; + public String githubReleaseUrl; + public String mavenCentralUrl; + + public ReleaseResult() {} + + public ReleaseResult(String releaseDigest, String githubReleaseUrl, String mavenCentralUrl) { + this.releaseDigest = releaseDigest; + this.githubReleaseUrl = githubReleaseUrl; + this.mavenCentralUrl = mavenCentralUrl; + } +} diff --git a/.github/release-automation/src/main/java/io/temporal/releaseautomation/ReleaseStatus.java b/.github/release-automation/src/main/java/io/temporal/releaseautomation/ReleaseStatus.java new file mode 100644 index 0000000000..85324f5580 --- /dev/null +++ b/.github/release-automation/src/main/java/io/temporal/releaseautomation/ReleaseStatus.java @@ -0,0 +1,32 @@ +package io.temporal.releaseautomation; + +import java.util.ArrayList; +import java.util.List; + +public final class ReleaseStatus { + public String phase; + public ReleaseIdentity identity; + public ApprovalRequest approvalRequest; + public ApprovalEvidence approval; + public ControlEvidence control; + public String pausedFrom; + public String handedOffFrom; + public String lastCompletedStage; + public String lastError; + public long blockedAtMillis; + public String mavenCentralUrl; + public String sonatypeRepositoryId; + public String portalDeploymentId; + public String githubDraftUrl; + public String githubReleaseUrl; + public int mavenSubmissionGeneration; + public ControlEvidence mavenRetryAuthorization; + public GithubArtifactReceipt mavenPayload; + public List mavenGenerations = new ArrayList<>(); + public OwnershipStatus ownership; + public int stageAttempt; + public long stageStartedAtMillis; + public long nextRetryAtMillis; + + public ReleaseStatus() {} +} diff --git a/.github/release-automation/src/main/java/io/temporal/releaseautomation/ReleaseWorkflow.java b/.github/release-automation/src/main/java/io/temporal/releaseautomation/ReleaseWorkflow.java new file mode 100644 index 0000000000..adc62b92ba --- /dev/null +++ b/.github/release-automation/src/main/java/io/temporal/releaseautomation/ReleaseWorkflow.java @@ -0,0 +1,27 @@ +package io.temporal.releaseautomation; + +import io.temporal.workflow.QueryMethod; +import io.temporal.workflow.UpdateMethod; +import io.temporal.workflow.WorkflowInterface; +import io.temporal.workflow.WorkflowMethod; + +@WorkflowInterface +public interface ReleaseWorkflow { + @WorkflowMethod + ReleaseResult release(ReleaseIdentity identity); + + @UpdateMethod + ReleaseStatus requestApproval(ApprovalRequest request); + + @UpdateMethod + ReleaseStatus approve(ApprovalEvidence evidence); + + @UpdateMethod + ReleaseStatus recordMavenPayload(GithubArtifactReceipt artifact); + + @UpdateMethod + ReleaseStatus control(ControlEvidence evidence); + + @QueryMethod + ReleaseStatus status(); +} diff --git a/.github/release-automation/src/main/java/io/temporal/releaseautomation/ReleaseWorkflowImpl.java b/.github/release-automation/src/main/java/io/temporal/releaseautomation/ReleaseWorkflowImpl.java new file mode 100644 index 0000000000..7b1a825947 --- /dev/null +++ b/.github/release-automation/src/main/java/io/temporal/releaseautomation/ReleaseWorkflowImpl.java @@ -0,0 +1,632 @@ +package io.temporal.releaseautomation; + +import io.temporal.activity.ActivityCancellationType; +import io.temporal.activity.ActivityOptions; +import io.temporal.common.RetryOptions; +import io.temporal.failure.ApplicationFailure; +import io.temporal.workflow.CancellationScope; +import io.temporal.workflow.UpdateValidatorMethod; +import io.temporal.workflow.Workflow; +import java.time.Duration; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; + +public final class ReleaseWorkflowImpl implements ReleaseWorkflow { + static final String STATUS_MEMO_KEY = "ReleaseStatus"; + static final String IDENTITY_MEMO_KEY = "ReleaseIdentity"; + private ReleaseIdentity identity; + private ApprovalRequest approvalRequest; + private ApprovalEvidence approval; + private ControlEvidence control; + private String phase = "INITIALIZING"; + private String pausedFrom; + private String handedOffFrom; + private String lastCompletedStage; + private String lastError; + private long blockedAtMillis; + private String mavenCentralUrl; + private String sonatypeRepositoryId; + private String portalDeploymentId; + private String githubDraftUrl; + private String githubReleaseUrl; + private int mavenSubmissionGeneration; + private ControlEvidence mavenRetryAuthorization; + private GithubArtifactReceipt mavenPayload; + private final List mavenGenerations = new ArrayList<>(); + private OwnershipStatus ownership; + private int stageAttempt; + private long stageStartedAtMillis; + private long nextRetryAtMillis; + private boolean pauseRequested; + private boolean handoffRequested; + private CancellationScope activeActivity; + + @Override + public ReleaseResult release(ReleaseIdentity releaseIdentity) { + releaseIdentity.validate(); + identity = releaseIdentity; + ownership = ownershipActivities().claimTemporal(identity); + if ("MANUAL".equals(ownership.owner)) { + handedOffFrom = "INITIALIZING"; + enterHandedOff(); + return awaitHandoff(); + } + awaitApproval(); + if (handoffRequested) { + enterHandedOff(); + return awaitHandoff(); + } + + awaitMavenPayload(); + if (handoffRequested) { + return awaitHandoff(); + } + runStage("PREFLIGHT", () -> publicationActivities().preflight(publicationInput())); + if (handoffRequested) { + return awaitHandoff(); + } + runMaven(); + if (handoffRequested) { + return awaitHandoff(); + } + runStage( + "GITHUB_DRAFT", + () -> githubDraftUrl = publicationActivities().reconcileGithubDraft(publicationInput())); + final ReleaseResult[] result = new ReleaseResult[1]; + runStage( + "PUBLISH_GITHUB", + () -> + result[0] = + publicationActivities().publishGithubRelease(publicationInput(), mavenCentralUrl)); + if (handoffRequested) { + enterHandedOff(); + return awaitHandoff(); + } + phase = "PUBLISHED"; + githubReleaseUrl = result[0].githubReleaseUrl; + upsertStatus(); + return result[0]; + } + + @Override + public ReleaseStatus requestApproval(ApprovalRequest request) { + validateApprovalRequest(request); + approvalRequest = request; + upsertStatus(); + return status(); + } + + @UpdateValidatorMethod(updateName = "requestApproval") + public void validateApprovalRequest(ApprovalRequest request) { + if (identity == null || !"AWAITING_APPROVAL".equals(phase) || approvalRequest != null) { + throw new IllegalStateException("The release cannot accept an approval request."); + } + request.validate(); + validateExecutionIdentity(request.releaseDigest, request.workflowId, request.runId); + if (!identity.candidate.trustedAutomationCommit.equals(request.trustedWorkerCommit)) { + throw new IllegalArgumentException("Approval request uses another trusted Worker commit."); + } + } + + @Override + public ReleaseStatus approve(ApprovalEvidence evidence) { + validateApproval(evidence); + approval = evidence; + upsertStatus(); + return status(); + } + + @UpdateValidatorMethod(updateName = "approve") + public void validateApproval(ApprovalEvidence evidence) { + if (identity == null || !"AWAITING_APPROVAL".equals(phase) || approvalRequest == null) { + throw new IllegalStateException("The release is not awaiting approval."); + } + evidence.validate(); + validateExecutionIdentity(evidence.releaseDigest, evidence.workflowId, evidence.runId); + if (!approvalRequest.matches(evidence)) { + throw new IllegalArgumentException("Approval does not match the recorded approval request."); + } + } + + @Override + public ReleaseStatus recordMavenPayload(GithubArtifactReceipt artifact) { + validateMavenPayload(artifact); + if (mavenPayload == null) { + mavenPayload = artifact; + } + upsertStatus(); + return status(); + } + + @UpdateValidatorMethod(updateName = "recordMavenPayload") + public void validateMavenPayload(GithubArtifactReceipt artifact) { + if (identity == null + || approval == null + || "PUBLISHED".equals(phase) + || "HANDED_OFF".equals(phase)) { + throw new IllegalStateException("The release cannot accept a Maven payload."); + } + artifact.validate(); + if (!ReleasePolicy.githubMavenArtifactName(identity).equals(artifact.artifactName) + || artifact.files.size() != 1 + || !"maven-payload.tar".equals(artifact.files.get(0).name)) { + throw new IllegalArgumentException("The Maven GitHub artifact identity is invalid."); + } + if (mavenPayload != null && !mavenPayload.canonicalForm().equals(artifact.canonicalForm())) { + throw new IllegalStateException("The release already recorded another Maven payload."); + } + } + + @Override + public ReleaseStatus control(ControlEvidence evidence) { + validateControl(evidence); + control = evidence; + control.recordedAtMillis = Workflow.currentTimeMillis(); + if ("pause".equals(evidence.action)) { + pauseRequested = true; + beginQuiescing(); + cancelActiveActivity(); + Workflow.await(() -> "PAUSED".equals(phase) || "HANDED_OFF".equals(phase)); + } else if ("resume".equals(evidence.action)) { + pauseRequested = false; + phase = pausedFrom; + pausedFrom = null; + lastError = null; + blockedAtMillis = 0; + } else if ("retry-maven-submission".equals(evidence.action)) { + boolean nextGeneration = evidence.mavenSubmissionGeneration > mavenSubmissionGeneration; + adoptInspectedGeneration(evidence.mavenInspection); + mavenSubmissionGeneration = evidence.mavenSubmissionGeneration; + mavenRetryAuthorization = evidence; + phase = nextGeneration ? "MAVEN_REPOSITORY" : pausedFrom; + pausedFrom = null; + lastError = null; + blockedAtMillis = 0; + } else { + handoffRequested = true; + pauseRequested = false; + beginQuiescing(); + cancelActiveActivity(); + if (activeActivity == null) { + enterHandedOff(); + } + Workflow.await(() -> "HANDED_OFF".equals(phase)); + ownership = ownershipActivities().handoffManual(identity, evidence); + if (!"MANUAL".equals(ownership.owner)) { + throw new IllegalStateException("Temporal ownership handoff did not complete."); + } + } + upsertStatus(); + return status(); + } + + @UpdateValidatorMethod(updateName = "control") + public void validateControl(ControlEvidence evidence) { + if (identity == null || "PUBLISHED".equals(phase) || "HANDED_OFF".equals(phase)) { + throw new IllegalStateException("The release is not controllable."); + } + evidence.validate(); + validateExecutionIdentity(evidence.releaseDigest, evidence.workflowId, evidence.runId); + if (!identity.candidate.tag.equals(evidence.tag) + || !identity.candidate.commitSha.equals(evidence.commitSha)) { + throw new IllegalArgumentException("Control evidence does not match the exact tag and SHA."); + } + if ("resume".equals(evidence.action) && !("PAUSED".equals(phase) || "BLOCKED".equals(phase))) { + throw new IllegalStateException("Only a paused or blocked release can resume."); + } + if ("retry-maven-submission".equals(evidence.action)) { + if (evidence.mavenInspection.centralPresent + evidence.mavenInspection.centralMissing + != ReleasePolicy.mavenArtifacts(identity.candidate.mavenPolicy).size()) { + throw new IllegalArgumentException("Maven inspection does not match the release policy."); + } + validateInspectedGenerations(mavenGenerations, evidence.mavenInspection); + boolean nextGeneration = + "BLOCKED".equals(phase) + && pausedFrom != null + && pausedFrom.startsWith("MAVEN_") + && lastError != null + && (lastError.contains("MavenSubmissionAmbiguous") + || lastError.contains("MavenDeploymentFailed")) + && evidence.mavenSubmissionGeneration == mavenSubmissionGeneration + 1; + boolean replaceAuthorization = + "BLOCKED".equals(phase) + && pausedFrom != null + && pausedFrom.startsWith("MAVEN_") + && lastError != null + && lastError.contains("InvalidApproval") + && mavenSubmissionGeneration > 0 + && evidence.mavenSubmissionGeneration == mavenSubmissionGeneration; + if (!(nextGeneration || replaceAuthorization)) { + throw new IllegalStateException( + "Maven authorization must advance an ambiguous attempt or replace stale evidence."); + } + if (nextGeneration && evidence.mavenInspection.centralPresent != 0) { + throw new IllegalStateException( + "A new Maven generation requires Central to be completely absent."); + } + if (nextGeneration) { + for (MavenGenerationInspection inspected : evidence.mavenInspection.generations) { + boolean failedPortal = "FAILED".equals(inspected.portalDeploymentState); + if (!("absent".equals(inspected.repositoryState) + || (failedPortal && "released".equals(inspected.repositoryState))) + || !(inspected.portalDeploymentState.isEmpty() || failedPortal)) { + throw new IllegalStateException( + "A new Maven generation requires every earlier attempt to be inactive."); + } + } + } + } else if ("handoff-manual".equals(evidence.action)) { + validateManualHandoff(mavenGenerations, mavenCentralUrl, evidence.manualMavenRequested); + } + } + + static void validateManualHandoff( + List generations, String centralUrl, boolean manualMavenRequested) { + boolean mavenStarted = false; + for (MavenGenerationState generation : generations) { + mavenStarted |= generation.submissionStarted; + } + boolean mavenCompleted = centralUrl != null && !centralUrl.isEmpty(); + if (manualMavenRequested && mavenStarted) { + throw new IllegalStateException( + "Manual Maven publication cannot take over after automatic Maven submission started."); + } + if (!manualMavenRequested && mavenStarted && !mavenCompleted) { + throw new IllegalStateException( + "Manual non-Maven takeover requires automatic Maven publication to be complete."); + } + } + + @Override + public ReleaseStatus status() { + ReleaseStatus status = new ReleaseStatus(); + status.phase = phase; + status.identity = identity; + status.approvalRequest = approvalRequest; + status.approval = approval; + status.control = control; + status.pausedFrom = pausedFrom; + status.handedOffFrom = handedOffFrom; + status.lastCompletedStage = lastCompletedStage; + status.lastError = lastError; + status.blockedAtMillis = blockedAtMillis; + status.mavenCentralUrl = mavenCentralUrl; + status.sonatypeRepositoryId = sonatypeRepositoryId; + status.portalDeploymentId = portalDeploymentId; + status.githubDraftUrl = githubDraftUrl; + status.githubReleaseUrl = githubReleaseUrl; + status.mavenSubmissionGeneration = mavenSubmissionGeneration; + status.mavenRetryAuthorization = mavenRetryAuthorization; + status.mavenPayload = mavenPayload; + status.mavenGenerations = new ArrayList<>(mavenGenerations); + status.ownership = ownership; + status.stageAttempt = stageAttempt; + status.stageStartedAtMillis = stageStartedAtMillis; + status.nextRetryAtMillis = nextRetryAtMillis; + return status; + } + + private void awaitApproval() { + phase = "AWAITING_APPROVAL"; + upsertStatus(); + while (approval == null && !handoffRequested) { + handlePause("AWAITING_APPROVAL"); + Workflow.await(() -> approval != null || pauseRequested || handoffRequested); + } + } + + private void awaitMavenPayload() { + while (mavenPayload == null && !handoffRequested) { + handlePause("AWAITING_MAVEN_PAYLOAD"); + phase = "AWAITING_MAVEN_PAYLOAD"; + upsertStatus(); + Workflow.await(() -> mavenPayload != null || pauseRequested || handoffRequested); + } + } + + private void runMaven() { + while (!handoffRequested) { + runStage( + "MAVEN_REPOSITORY", + () -> { + MavenGenerationState current = currentMavenGeneration(); + boolean allowCreation = !current.submissionStarted; + if (allowCreation) { + current.submissionStarted = true; + upsertStatus(); + } + current.sonatypeRepositoryId = + publicationActivities().reconcileMavenRepository(publicationInput(), allowCreation); + sonatypeRepositoryId = current.sonatypeRepositoryId; + upsertStatus(); + }); + if (handoffRequested) { + return; + } + if (currentMavenGeneration().sonatypeRepositoryId == null) { + return; + } + int generation = mavenSubmissionGeneration; + runStage( + "MAVEN_PORTAL", + () -> { + MavenGenerationState current = currentMavenGeneration(); + current.portalDeploymentId = + publicationActivities().reconcileMavenPortal(publicationInput()); + portalDeploymentId = current.portalDeploymentId; + upsertStatus(); + }); + if (generation != mavenSubmissionGeneration || handoffRequested) { + continue; + } + if (currentMavenGeneration().portalDeploymentId == null) { + return; + } + runStage( + "MAVEN_PUBLISH", + () -> { + MavenReceipt receipt = publicationActivities().publishMaven(publicationInput()); + mavenCentralUrl = receipt.mavenCentralUrl; + sonatypeRepositoryId = receipt.sonatypeRepositoryId; + portalDeploymentId = receipt.portalDeploymentId; + }); + if (generation == mavenSubmissionGeneration) { + return; + } + } + } + + private void runStage(String stage, Runnable action) { + int retry = 0; + while (!handoffRequested) { + handlePause(stage); + if (handoffRequested) { + return; + } + phase = stage; + stageAttempt++; + stageStartedAtMillis = Workflow.currentTimeMillis(); + nextRetryAtMillis = 0; + upsertStatus(); + activeActivity = Workflow.newCancellationScope(action); + try { + activeActivity.run(); + activeActivity = null; + lastCompletedStage = stage; + lastError = null; + blockedAtMillis = 0; + nextRetryAtMillis = 0; + upsertStatus(); + return; + } catch (RuntimeException e) { + activeActivity = null; + if (handoffRequested) { + enterHandedOff(); + return; + } + if (!pauseRequested && isNonRetryable(e)) { + pausedFrom = stage; + phase = "BLOCKED"; + lastError = safeFailure(e); + blockedAtMillis = Workflow.currentTimeMillis(); + upsertStatus(); + Workflow.await(() -> !"BLOCKED".equals(phase) || handoffRequested); + if (!stage.equals(phase)) { + return; + } + } else if (!pauseRequested) { + lastError = safeFailure(e); + long delayMinutes = Math.min(15, 2L << Math.min(retry, 3)); + retry++; + nextRetryAtMillis = + Workflow.currentTimeMillis() + Duration.ofMinutes(delayMinutes).toMillis(); + upsertStatus(); + Workflow.await( + Duration.ofMinutes(delayMinutes), () -> pauseRequested || handoffRequested); + } + } + } + } + + private void handlePause(String resumeStage) { + if (!pauseRequested) { + return; + } + pausedFrom = resumeStage; + phase = "PAUSED"; + upsertStatus(); + Workflow.await(() -> !pauseRequested || handoffRequested); + if (!handoffRequested) { + phase = resumeStage; + pausedFrom = null; + upsertStatus(); + } + } + + private void enterHandedOff() { + if (handedOffFrom == null) { + handedOffFrom = pausedFrom == null ? phase : pausedFrom; + } + phase = "HANDED_OFF"; + pausedFrom = null; + upsertStatus(); + } + + private void beginQuiescing() { + if (activeActivity != null) { + if (pausedFrom == null) { + pausedFrom = phase; + } + phase = "QUIESCING"; + upsertStatus(); + } else if (handoffRequested) { + enterHandedOff(); + } else if (!"PAUSED".equals(phase)) { + pausedFrom = phase; + phase = "PAUSED"; + upsertStatus(); + } + } + + private static String safeFailure(RuntimeException failure) { + Throwable current = failure; + while (current != null) { + if (current instanceof ApplicationFailure) { + ApplicationFailure applicationFailure = (ApplicationFailure) current; + return applicationFailure.getType() + ": " + value(applicationFailure); + } + current = current.getCause(); + } + return failure.getClass().getSimpleName() + ": " + value(failure); + } + + private static String value(Throwable failure) { + String message = failure.getMessage(); + if (message == null || message.isEmpty()) { + return failure.getClass().getSimpleName(); + } + return message; + } + + private static boolean isNonRetryable(Throwable failure) { + Throwable current = failure; + while (current != null) { + if (current instanceof ApplicationFailure) { + return ((ApplicationFailure) current).isNonRetryable(); + } + current = current.getCause(); + } + return false; + } + + private void cancelActiveActivity() { + if (activeActivity != null) { + activeActivity.cancel("Authenticated release control requested cancellation."); + } + } + + private PublicationInput publicationInput() { + PublicationInput input = + new PublicationInput( + identity, + approvalRequest, + approval, + Workflow.getInfo().getWorkflowId(), + Workflow.getInfo().getRunId()); + input.mavenSubmissionGeneration = mavenSubmissionGeneration; + input.mavenRetryAuthorization = mavenRetryAuthorization; + input.mavenPayload = mavenPayload; + input.mavenGenerations = new ArrayList<>(mavenGenerations); + return input; + } + + private MavenGenerationState currentMavenGeneration() { + for (MavenGenerationState generation : mavenGenerations) { + if (generation.generation == mavenSubmissionGeneration) { + generation.validate(identity.digest()); + return generation; + } + } + MavenGenerationState generation = + new MavenGenerationState(identity.digest(), mavenSubmissionGeneration); + mavenGenerations.add(generation); + upsertStatus(); + return generation; + } + + private void adoptInspectedGeneration(MavenInspection inspection) { + for (MavenGenerationInspection inspected : inspection.generations) { + for (MavenGenerationState generation : mavenGenerations) { + if (generation.generation == inspected.generation) { + if (generation.sonatypeRepositoryId == null + || generation.sonatypeRepositoryId.isEmpty()) { + generation.sonatypeRepositoryId = inspected.repositoryId; + } else if (inspected.repositoryId != null + && !inspected.repositoryId.isEmpty() + && !generation.sonatypeRepositoryId.equals(inspected.repositoryId)) { + throw new IllegalArgumentException("Inspected Sonatype repository ID differs."); + } + if (generation.portalDeploymentId == null || generation.portalDeploymentId.isEmpty()) { + generation.portalDeploymentId = inspected.portalDeploymentId; + } else if (inspected.portalDeploymentId != null + && !inspected.portalDeploymentId.isEmpty() + && !generation.portalDeploymentId.equals(inspected.portalDeploymentId)) { + throw new IllegalArgumentException("Inspected Portal deployment ID differs."); + } + generation.validate(identity.digest()); + } + } + } + } + + static void validateInspectedGenerations( + List durableGenerations, MavenInspection inspection) { + if (inspection.generations.size() != durableGenerations.size()) { + throw new IllegalArgumentException( + "The Maven inspection does not cover every durable generation."); + } + for (MavenGenerationState generation : durableGenerations) { + boolean found = false; + for (MavenGenerationInspection inspected : inspection.generations) { + if (generation.generation == inspected.generation) { + found = true; + break; + } + } + if (!found) { + throw new IllegalArgumentException( + "The Maven inspection does not cover every durable generation."); + } + } + } + + private PublicationActivities publicationActivities() { + return Workflow.newActivityStub( + PublicationActivities.class, + ActivityOptions.newBuilder() + .setTaskQueue(QueueNames.publication(identity, mavenSubmissionGeneration)) + .setStartToCloseTimeout(Duration.ofMinutes(90)) + .setHeartbeatTimeout(Duration.ofMinutes(1)) + .setCancellationType(ActivityCancellationType.WAIT_CANCELLATION_COMPLETED) + .setRetryOptions( + RetryOptions.newBuilder() + .setInitialInterval(Duration.ofMinutes(2)) + .setMaximumInterval(Duration.ofMinutes(15)) + .setMaximumAttempts(1) + .setDoNotRetry("ReleaseIdentityConflict", "InvalidApproval") + .build()) + .build()); + } + + private OwnershipActivities ownershipActivities() { + return Workflow.newActivityStub( + OwnershipActivities.class, + ActivityOptions.newBuilder() + .setTaskQueue(QueueNames.ownership(identity.candidate.tag)) + .setStartToCloseTimeout(Duration.ofMinutes(2)) + .setRetryOptions( + RetryOptions.newBuilder() + .setInitialInterval(Duration.ofSeconds(10)) + .setMaximumInterval(Duration.ofMinutes(2)) + .build()) + .build()); + } + + private ReleaseResult awaitHandoff() { + Workflow.await(() -> false); + throw new IllegalStateException("A handed-off release cannot resume automatically."); + } + + private void validateExecutionIdentity(String releaseDigest, String workflowId, String runId) { + if (!identity.digest().equals(releaseDigest) + || !Workflow.getInfo().getWorkflowId().equals(workflowId) + || !Workflow.getInfo().getRunId().equals(runId)) { + throw new IllegalArgumentException("Evidence does not identify this exact release run."); + } + } + + private void upsertStatus() { + Workflow.upsertMemo(Collections.singletonMap(STATUS_MEMO_KEY, status())); + } +} diff --git a/.github/release-automation/src/test/java/io/temporal/releaseautomation/CandidateWorkflowTest.java b/.github/release-automation/src/test/java/io/temporal/releaseautomation/CandidateWorkflowTest.java new file mode 100644 index 0000000000..d8df6faa9b --- /dev/null +++ b/.github/release-automation/src/test/java/io/temporal/releaseautomation/CandidateWorkflowTest.java @@ -0,0 +1,47 @@ +package io.temporal.releaseautomation; + +import static org.junit.Assert.assertEquals; + +import io.temporal.client.WorkflowClient; +import io.temporal.client.WorkflowOptions; +import io.temporal.client.WorkflowStub; +import io.temporal.testing.TestWorkflowEnvironment; +import io.temporal.worker.Worker; +import org.junit.Test; + +public class CandidateWorkflowTest { + @Test + public void recordsExactGithubArtifactsThenStartsTheReleaseChild() { + CandidateIdentity candidate = ReleaseFixtures.candidate(); + try (TestWorkflowEnvironment environment = TestWorkflowEnvironment.newInstance()) { + Worker candidateWorker = environment.newWorker(QueueNames.candidateWorkflow(candidate)); + candidateWorker.registerWorkflowImplementationTypes(CandidateWorkflowImpl.class); + environment.start(); + + CandidateWorkflow workflow = + environment + .getWorkflowClient() + .newWorkflowStub( + CandidateWorkflow.class, + WorkflowOptions.newBuilder() + .setWorkflowId(QueueNames.candidateWorkflowId(candidate)) + .setTaskQueue(QueueNames.candidateWorkflow(candidate)) + .build()); + WorkflowClient.start(workflow::prepare, candidate); + int index = 1; + for (String platform : ReleasePolicy.NATIVE_PLATFORMS) { + String file = ReleasePolicy.nativeArtifactName(candidate.version(), platform); + workflow.recordArtifact( + platform, + ReleaseFixtures.artifact( + ReleasePolicy.githubNativeArtifactName(candidate, platform), + file, + index, + 1001 + index)); + index++; + } + ReleaseIdentity actual = WorkflowStub.fromTyped(workflow).getResult(ReleaseIdentity.class); + assertEquals(ReleaseFixtures.release().digest(), actual.digest()); + } + } +} diff --git a/.github/release-automation/src/test/java/io/temporal/releaseautomation/DigestsTest.java b/.github/release-automation/src/test/java/io/temporal/releaseautomation/DigestsTest.java new file mode 100644 index 0000000000..dd20b2589c --- /dev/null +++ b/.github/release-automation/src/test/java/io/temporal/releaseautomation/DigestsTest.java @@ -0,0 +1,14 @@ +package io.temporal.releaseautomation; + +import static org.junit.Assert.assertEquals; + +import org.junit.Test; + +public class DigestsTest { + @Test + public void sha256UsesUtf8AndLowercaseHex() { + assertEquals( + "4c2ec6c321cf1e7ff2ebc3f02efb49505f7af84e58f2bb0a08c3c170af665f6b", + Digests.sha256("Temporal ☃")); + } +} diff --git a/.github/release-automation/src/test/java/io/temporal/releaseautomation/ProcessSupportTest.java b/.github/release-automation/src/test/java/io/temporal/releaseautomation/ProcessSupportTest.java new file mode 100644 index 0000000000..c9d3c3c877 --- /dev/null +++ b/.github/release-automation/src/test/java/io/temporal/releaseautomation/ProcessSupportTest.java @@ -0,0 +1,90 @@ +package io.temporal.releaseautomation; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; + +import io.temporal.activity.ActivityInterface; +import io.temporal.activity.ActivityMethod; +import io.temporal.testing.TestActivityEnvironment; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.util.Collections; +import java.util.List; +import java.util.concurrent.TimeUnit; +import java.util.stream.Collectors; +import org.junit.Rule; +import org.junit.Test; +import org.junit.rules.TemporaryFolder; + +public class ProcessSupportTest { + @Rule public final TemporaryFolder temporaryFolder = new TemporaryFolder(); + + @Test + public void shellScriptsUseExplicitBashAndPortableSeparators() { + List command = ProcessSupport.bash(Paths.get("trusted\\release-script.sh")); + assertEquals( + java.io.File.separatorChar == '\\' ? "C:\\Program Files\\Git\\bin\\bash.exe" : "bash", + command.get(0)); + assertTrue(command.get(1).endsWith("trusted/release-script.sh")); + assertTrue(!command.get(1).contains("\\")); + assertEquals( + "/d/trusted/release-script.sh", ProcessSupport.bashPath("D:\\trusted\\release-script.sh")); + } + + @Test + public void explicitBashCommandRunsInsideActivityEnvironment() throws Exception { + Path script = temporaryFolder.newFile("release-script.sh").toPath(); + Files.write( + script, + "#!/usr/bin/env bash\nprintf 'trusted-worker-output\\n'\n" + .getBytes(StandardCharsets.UTF_8)); + + TestActivityEnvironment environment = TestActivityEnvironment.newInstance(); + try { + environment.registerActivitiesImplementations(new ShellActivityImpl()); + ShellActivity activity = environment.newActivityStub(ShellActivity.class); + + assertEquals( + Collections.singletonList("trusted-worker-output"), + activity.run(script.toAbsolutePath().toString())); + } finally { + environment.close(); + } + } + + @Test + public void terminationStopsTheWholeProcessTree() throws Exception { + Process process = new ProcessBuilder("bash", "-c", "sleep 30 & wait").start(); + List descendants = Collections.emptyList(); + long deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(5); + while (descendants.isEmpty() && System.nanoTime() < deadline) { + descendants = process.descendants().collect(Collectors.toList()); + Thread.sleep(25); + } + assertFalse("Expected the shell command to start a child process.", descendants.isEmpty()); + + ProcessSupport.terminateProcessTree(process); + + assertFalse(process.isAlive()); + assertTrue(descendants.stream().noneMatch(ProcessHandle::isAlive)); + } + + @ActivityInterface + public interface ShellActivity { + @ActivityMethod + List run(String script); + } + + public static final class ShellActivityImpl implements ShellActivity { + @Override + public List run(String script) { + return ProcessSupport.run( + Paths.get("").toAbsolutePath(), + ProcessSupport.bash(Paths.get(script)), + Collections.emptyMap()); + } + } +} diff --git a/.github/release-automation/src/test/java/io/temporal/releaseautomation/PublicationGuardTest.java b/.github/release-automation/src/test/java/io/temporal/releaseautomation/PublicationGuardTest.java new file mode 100644 index 0000000000..bff9679e5f --- /dev/null +++ b/.github/release-automation/src/test/java/io/temporal/releaseautomation/PublicationGuardTest.java @@ -0,0 +1,112 @@ +package io.temporal.releaseautomation; + +import static org.junit.Assert.assertThrows; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +import com.google.gson.Gson; +import io.temporal.activity.ActivityInfo; +import org.junit.Test; + +public class PublicationGuardTest { + @Test + public void validatesOneExactPrivilegedInput() { + PublicationInput input = input(0); + PublicationInput expected = copy(input); + ActivityInfo info = activity(input); + + PublicationGuard.validate( + input, expected, info, input.release.candidate.trustedAutomationCommit); + + input.approval.githubActor = "another-manager"; + assertThrows( + IllegalArgumentException.class, + () -> + PublicationGuard.validate( + input, expected, info, input.release.candidate.trustedAutomationCommit)); + } + + @Test + public void mavenRetryRequiresTheExactProtectedAuthorization() { + PublicationInput input = input(1); + input.mavenRetryAuthorization = authorization(input); + PublicationInput expected = copy(input); + ActivityInfo info = activity(input); + + PublicationGuard.validate( + input, expected, info, input.release.candidate.trustedAutomationCommit); + + input.mavenRetryAuthorization = null; + assertThrows( + IllegalArgumentException.class, + () -> + PublicationGuard.validate( + input, expected, info, input.release.candidate.trustedAutomationCommit)); + } + + private static PublicationInput input(int generation) { + ReleaseIdentity release = ReleaseFixtures.release(); + String workflowId = QueueNames.releaseWorkflowId(release); + String runId = "11111111-2222-3333-4444-555555555555"; + ApprovalEvidence approval = + new ApprovalEvidence( + release.digest(), + workflowId, + runId, + 1234, + "release-manager", + 42, + "ISSUE_node_42", + "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + release.candidate.trustedAutomationCommit); + ApprovalRequest request = + new ApprovalRequest( + release.digest(), + workflowId, + runId, + 1200, + 42, + "ISSUE_node_42", + "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "approval-bot", + release.candidate.trustedAutomationCommit); + PublicationInput input = new PublicationInput(release, request, approval, workflowId, runId); + input.mavenSubmissionGeneration = generation; + input.mavenPayload = ReleaseFixtures.mavenArtifact(release); + return input; + } + + private static ControlEvidence authorization(PublicationInput input) { + ControlEvidence authorization = new ControlEvidence(); + authorization.action = "retry-maven-submission"; + authorization.releaseDigest = input.release.digest(); + authorization.workflowId = input.workflowId; + authorization.runId = input.runId; + authorization.githubRunId = 5678; + authorization.githubActor = "release-manager"; + authorization.tag = input.release.candidate.tag; + authorization.commitSha = input.release.candidate.commitSha; + authorization.reason = "Protected test authorization."; + authorization.mavenSubmissionGeneration = input.mavenSubmissionGeneration; + authorization.mavenInspection = new MavenInspection(); + authorization.mavenInspection.centralMissing = ReleasePolicy.MAVEN_ARTIFACTS.size(); + authorization.authorizationSha256 = + Digests.sha256(authorization.mavenInspection.canonicalForm(input.release.digest())); + authorization.validate(); + return authorization; + } + + private static ActivityInfo activity(PublicationInput input) { + ActivityInfo info = mock(ActivityInfo.class); + when(info.getWorkflowId()).thenReturn(input.workflowId); + when(info.getWorkflowRunId()).thenReturn(input.runId); + when(info.getActivityTaskQueue()) + .thenReturn(QueueNames.publication(input.release, input.mavenSubmissionGeneration)); + return info; + } + + private static PublicationInput copy(PublicationInput input) { + Gson gson = new Gson(); + return gson.fromJson(gson.toJson(input), PublicationInput.class); + } +} diff --git a/.github/release-automation/src/test/java/io/temporal/releaseautomation/ReleaseAutomationMainTest.java b/.github/release-automation/src/test/java/io/temporal/releaseautomation/ReleaseAutomationMainTest.java new file mode 100644 index 0000000000..3ec7932535 --- /dev/null +++ b/.github/release-automation/src/test/java/io/temporal/releaseautomation/ReleaseAutomationMainTest.java @@ -0,0 +1,189 @@ +package io.temporal.releaseautomation; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertThrows; +import static org.junit.Assert.assertTrue; + +import io.temporal.api.common.v1.Memo; +import io.temporal.api.common.v1.WorkflowExecution; +import io.temporal.api.common.v1.WorkflowType; +import io.temporal.api.enums.v1.EventType; +import io.temporal.api.history.v1.HistoryEvent; +import io.temporal.api.history.v1.WorkflowExecutionStartedEventAttributes; +import io.temporal.api.taskqueue.v1.TaskQueue; +import io.temporal.api.workflow.v1.WorkflowExecutionInfo; +import io.temporal.client.WorkflowExecutionMetadata; +import io.temporal.common.converter.DataConverter; +import io.temporal.common.converter.DefaultDataConverter; +import io.temporal.envconfig.ClientConfigProfile; +import io.temporal.serviceclient.WorkflowServiceStubsOptions; +import java.util.Arrays; +import java.util.Collections; +import java.util.HashMap; +import java.util.Map; +import org.junit.Test; + +public class ReleaseAutomationMainTest { + @Test + public void loadsRequiredTemporalCloudEnvironmentWithoutFileFallback() throws Exception { + Map environment = new HashMap<>(); + environment.put("TEMPORAL_ADDRESS", "example.tmprl.cloud:7233"); + environment.put("TEMPORAL_NAMESPACE", "sdk-java.release"); + environment.put("TEMPORAL_API_KEY", "secret"); + environment.put("TEMPORAL_CONFIG_FILE", "/does/not/exist"); + environment.put("TEMPORAL_PROFILE", "must-not-load"); + environment.put("TEMPORAL_TLS", "false"); + + ClientConfigProfile profile = ReleaseAutomationMain.loadTemporalClientConfig(environment); + + assertEquals("example.tmprl.cloud:7233", profile.getAddress()); + assertEquals("sdk-java.release", profile.getNamespace()); + assertEquals("secret", profile.getApiKey()); + WorkflowServiceStubsOptions serviceOptions = profile.toWorkflowServiceStubsOptions(); + assertEquals("example.tmprl.cloud:7233", serviceOptions.getTarget()); + assertEquals(1, serviceOptions.getGrpcMetadataProviders().size()); + assertTrue(serviceOptions.getEnableHttps()); + assertEquals("sdk-java.release", profile.toWorkflowClientOptions().getNamespace()); + } + + @Test + public void rejectsMissingOrEmptyTemporalCloudEnvironment() { + Map completeEnvironment = new HashMap<>(); + completeEnvironment.put("TEMPORAL_ADDRESS", "example.tmprl.cloud:7233"); + completeEnvironment.put("TEMPORAL_NAMESPACE", "sdk-java.release"); + completeEnvironment.put("TEMPORAL_API_KEY", "secret"); + + for (String name : completeEnvironment.keySet()) { + Map missing = new HashMap<>(completeEnvironment); + missing.remove(name); + IllegalArgumentException missingError = + assertThrows( + IllegalArgumentException.class, + () -> ReleaseAutomationMain.loadTemporalClientConfig(missing)); + assertEquals( + "Required Temporal Cloud setting is missing: " + name, missingError.getMessage()); + + Map empty = new HashMap<>(completeEnvironment); + empty.put(name, ""); + IllegalArgumentException emptyError = + assertThrows( + IllegalArgumentException.class, + () -> ReleaseAutomationMain.loadTemporalClientConfig(empty)); + assertEquals("Required Temporal Cloud setting is missing: " + name, emptyError.getMessage()); + } + } + + @Test + public void reportsUnrecoveredWorkflowTaskTimeout() { + assertEquals( + "workflow-task-failed-or-timed-out", + ReleaseAutomationMain.unrecoveredWorkflowFailure( + Arrays.asList( + event(1, EventType.EVENT_TYPE_WORKFLOW_TASK_COMPLETED), + event(2, EventType.EVENT_TYPE_WORKFLOW_TASK_TIMED_OUT)))); + } + + @Test + public void laterWorkflowTaskCompletionRecoversTaskFailure() { + assertNull( + ReleaseAutomationMain.unrecoveredWorkflowFailure( + Arrays.asList( + event(1, EventType.EVENT_TYPE_WORKFLOW_TASK_FAILED), + event(2, EventType.EVENT_TYPE_WORKFLOW_TASK_COMPLETED)))); + } + + @Test + public void reportsTerminalWorkflowFailure() { + assertEquals( + "workflow-execution-failed", + ReleaseAutomationMain.unrecoveredWorkflowFailure( + Collections.singletonList(event(3, EventType.EVENT_TYPE_WORKFLOW_EXECUTION_FAILED)))); + } + + @Test + public void candidateStartReceiptRequiresExactExecutionMemoQueueAndInput() { + CandidateIdentity candidate = ReleaseFixtures.candidate(); + WorkflowExecution execution = + WorkflowExecution.newBuilder() + .setWorkflowId(QueueNames.candidateWorkflowId(candidate)) + .setRunId("11111111-2222-3333-4444-555555555555") + .build(); + WorkflowExecutionMetadata description = candidateDescription(candidate, execution); + WorkflowExecutionStartedEventAttributes started = candidateStart(candidate); + + ReleaseAutomationMain.validateCandidateStart( + execution, description, started, candidate, candidate); + + WorkflowExecution wrongRun = execution.toBuilder().setRunId("different-run-0000").build(); + assertThrows( + IllegalStateException.class, + () -> + ReleaseAutomationMain.validateCandidateStart( + wrongRun, description, started, candidate, candidate)); + CandidateIdentity wrongInput = ReleaseFixtures.candidate(); + wrongInput.commitSha = "ffffffffffffffffffffffffffffffffffffffff"; + assertThrows( + IllegalStateException.class, + () -> + ReleaseAutomationMain.validateCandidateStart( + execution, description, started, wrongInput, candidate)); + } + + @Test + public void releaseParentRequiresExactCandidateRunReceipt() { + ReleaseIdentity release = ReleaseFixtures.release(); + WorkflowExecution candidateExecution = + WorkflowExecution.newBuilder() + .setWorkflowId(QueueNames.candidateWorkflowId(release.candidate)) + .setRunId(release.candidateRunId) + .build(); + WorkflowExecutionInfo info = + WorkflowExecutionInfo.newBuilder() + .setExecution( + WorkflowExecution.newBuilder() + .setWorkflowId(QueueNames.releaseWorkflowId(release)) + .setRunId("release-run-0000")) + .setParentExecution(candidateExecution) + .setRootExecution(candidateExecution) + .build(); + WorkflowExecutionMetadata metadata = + new WorkflowExecutionMetadata(info, DefaultDataConverter.STANDARD_INSTANCE); + ReleaseAutomationMain.validateReleaseParent(metadata, release); + + release.candidateRunId = "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee"; + assertThrows( + IllegalStateException.class, + () -> ReleaseAutomationMain.validateReleaseParent(metadata, release)); + } + + private static HistoryEvent event(long id, EventType type) { + return HistoryEvent.newBuilder().setEventId(id).setEventType(type).build(); + } + + private static WorkflowExecutionMetadata candidateDescription( + CandidateIdentity candidate, WorkflowExecution execution) { + DataConverter converter = DefaultDataConverter.STANDARD_INSTANCE; + Memo memo = + Memo.newBuilder() + .putFields("CandidateIdentity", converter.toPayload(candidate).get()) + .build(); + WorkflowExecutionInfo info = + WorkflowExecutionInfo.newBuilder() + .setExecution(execution) + .setType(WorkflowType.newBuilder().setName("CandidateWorkflow")) + .setTaskQueue(QueueNames.candidateWorkflow(candidate)) + .setMemo(memo) + .build(); + return new WorkflowExecutionMetadata(info, converter); + } + + private static WorkflowExecutionStartedEventAttributes candidateStart( + CandidateIdentity candidate) { + return WorkflowExecutionStartedEventAttributes.newBuilder() + .setWorkflowType(WorkflowType.newBuilder().setName("CandidateWorkflow")) + .setTaskQueue(TaskQueue.newBuilder().setName(QueueNames.candidateWorkflow(candidate))) + .setInput(DefaultDataConverter.STANDARD_INSTANCE.toPayloads(candidate).get()) + .build(); + } +} diff --git a/.github/release-automation/src/test/java/io/temporal/releaseautomation/ReleaseFixtures.java b/.github/release-automation/src/test/java/io/temporal/releaseautomation/ReleaseFixtures.java new file mode 100644 index 0000000000..b213651e81 --- /dev/null +++ b/.github/release-automation/src/test/java/io/temporal/releaseautomation/ReleaseFixtures.java @@ -0,0 +1,51 @@ +package io.temporal.releaseautomation; + +import java.util.ArrayList; +import java.util.List; + +final class ReleaseFixtures { + private ReleaseFixtures() {} + + static CandidateIdentity candidate() { + return new CandidateIdentity( + "v1.2.3", + "0123456789abcdef0123456789abcdef01234567", + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "abcdefabcdefabcdefabcdefabcdefabcdefabcd", + ReleasePolicy.MAVEN_POLICY_CURRENT); + } + + static ReleaseIdentity release() { + CandidateIdentity candidate = candidate(); + List artifacts = new ArrayList<>(); + int index = 1; + for (String platform : ReleasePolicy.NATIVE_PLATFORMS) { + String name = ReleasePolicy.nativeArtifactName(candidate.version(), platform); + artifacts.add( + artifact( + ReleasePolicy.githubNativeArtifactName(candidate, platform), + name, + index++, + 1000 + index)); + } + return new ReleaseIdentity( + candidate, new ArtifactManifest(artifacts), "11111111-2222-3333-4444-555555555555"); + } + + static GithubArtifactReceipt mavenArtifact(ReleaseIdentity release) { + return artifact(ReleasePolicy.githubMavenArtifactName(release), "maven-payload.tar", 99, 9000); + } + + static GithubArtifactReceipt artifact( + String artifactName, String fileName, int index, long size) { + return new GithubArtifactReceipt( + 1000 + index, + 2000 + index, + artifactName, + "sha256:" + String.format("%064x", index + 100), + "2026-01-01T00:00:00Z", + "2026-04-01T00:00:00Z", + java.util.Collections.singletonList( + new ArtifactEntry(fileName, String.format("%064x", index), size))); + } +} diff --git a/.github/release-automation/src/test/java/io/temporal/releaseautomation/ReleaseIdentityTest.java b/.github/release-automation/src/test/java/io/temporal/releaseautomation/ReleaseIdentityTest.java new file mode 100644 index 0000000000..6d1b3fa9f7 --- /dev/null +++ b/.github/release-automation/src/test/java/io/temporal/releaseautomation/ReleaseIdentityTest.java @@ -0,0 +1,148 @@ +package io.temporal.releaseautomation; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotEquals; +import static org.junit.Assert.assertThrows; +import static org.junit.Assert.assertTrue; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import org.junit.Test; + +public class ReleaseIdentityTest { + @Test + public void identityAndQueuesAreStableAndReleaseSpecific() { + ReleaseIdentity release = ReleaseFixtures.release(); + assertEquals(64, release.digest().length()); + assertEquals( + "sdk-java-release-" + release.digest().substring(0, 32) + "-publication-g0", + QueueNames.publication(release)); + assertNotEquals(QueueNames.publication(release, 0), QueueNames.publication(release, 1)); + assertNotEquals( + QueueNames.candidateWorkflow(release.candidate), QueueNames.releaseWorkflow(release)); + assertTrue( + release.manifest.artifacts.stream() + .anyMatch( + artifact -> + artifact + .files + .get(0) + .name + .equals("temporal-test-server_1.2.3_macOS_amd64.tar.gz"))); + } + + @Test + public void artifactOrderDoesNotChangeIdentity() { + ReleaseIdentity release = ReleaseFixtures.release(); + ArrayList reversed = new ArrayList<>(release.manifest.artifacts); + Collections.reverse(reversed); + ReleaseIdentity other = + new ReleaseIdentity(ReleaseFixtures.candidate(), new ArtifactManifest(reversed)); + assertEquals(release.digest(), other.digest()); + } + + @Test + public void candidateRunReceiptDoesNotChangeImmutableReleaseDigest() { + ReleaseIdentity release = ReleaseFixtures.release(); + ReleaseIdentity other = + new ReleaseIdentity( + release.candidate, release.manifest, "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee"); + assertEquals(release.digest(), other.digest()); + other.candidateRunId = "not-a-run"; + assertThrows(IllegalArgumentException.class, other::validate); + } + + @Test + public void fixedPlatformSetIsRequired() { + ReleaseIdentity release = ReleaseFixtures.release(); + release.manifest.artifacts.remove(0); + release.manifestSha256 = release.manifest.digest(); + assertThrows(IllegalArgumentException.class, release::validate); + } + + @Test + public void githubArtifactRoutingNameMustMatchCandidate() { + ReleaseIdentity original = ReleaseFixtures.release(); + original.manifest.artifacts.get(0).artifactName = "another-artifact"; + original.manifestSha256 = original.manifest.digest(); + assertThrows(IllegalArgumentException.class, original::validate); + } + + @Test + public void exactMavenPublicationSetIsHardcoded() { + assertEquals(17, ReleasePolicy.MAVEN_ARTIFACTS.size()); + assertEquals("io.temporal", ReleasePolicy.MAVEN_GROUP); + assertEquals("https://repo1.maven.org/maven2", ReleasePolicy.MAVEN_CENTRAL_BASE); + assertEquals("graalvm-community", ReleasePolicy.NATIVE_JAVA_DISTRIBUTION); + assertEquals("23", ReleasePolicy.NATIVE_JAVA_VERSION); + assertTrue(ReleasePolicy.MAVEN_ARTIFACTS.contains("temporal-sdk")); + assertTrue(ReleasePolicy.MAVEN_ARTIFACTS.contains("temporal-workflowstreams")); + for (String policy : + Arrays.asList( + ReleasePolicy.MAVEN_POLICY_CURRENT, + ReleasePolicy.MAVEN_POLICY_CLASSIC, + ReleasePolicy.MAVEN_POLICY_CLASSIC_ALPHA, + ReleasePolicy.MAVEN_POLICY_CLASSIC_ALPHA_LITE)) { + assertEquals( + policy, ReleasePolicy.mavenPolicyForProjects(ReleasePolicy.mavenArtifacts(policy))); + } + assertThrows( + IllegalArgumentException.class, + () -> ReleasePolicy.mavenPolicyForProjects(Collections.singletonList("temporal-sdk"))); + } + + @Test + public void approvalIsBoundToTheRecordedGithubIssue() { + ReleaseIdentity release = ReleaseFixtures.release(); + String workflowId = QueueNames.releaseWorkflowId(release); + String runId = "11111111-2222-3333-4444-555555555555"; + ApprovalRequest request = + new ApprovalRequest( + release.digest(), + workflowId, + runId, + 100, + 42, + "ISSUE_node_42", + "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "approval-bot", + release.candidate.trustedAutomationCommit); + ApprovalEvidence exact = + new ApprovalEvidence( + release.digest(), + workflowId, + runId, + 100, + "release-manager", + 42, + "ISSUE_node_42", + "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + release.candidate.trustedAutomationCommit); + ApprovalEvidence replay = + new ApprovalEvidence( + release.digest(), + workflowId, + runId, + 99, + "release-manager", + 43, + "ISSUE_node_43", + "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc", + release.candidate.trustedAutomationCommit); + ApprovalRequest retriedRequest = + new ApprovalRequest( + release.digest(), + workflowId, + runId, + 101, + 42, + "ISSUE_node_42", + "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "approval-bot", + release.candidate.trustedAutomationCommit); + assertTrue(request.matches(exact)); + assertTrue(!request.matches(replay)); + assertTrue(request.sameIssue(retriedRequest)); + } +} diff --git a/.github/release-automation/src/test/java/io/temporal/releaseautomation/ReleaseWorkflowTest.java b/.github/release-automation/src/test/java/io/temporal/releaseautomation/ReleaseWorkflowTest.java new file mode 100644 index 0000000000..b7a6e800a1 --- /dev/null +++ b/.github/release-automation/src/test/java/io/temporal/releaseautomation/ReleaseWorkflowTest.java @@ -0,0 +1,381 @@ +package io.temporal.releaseautomation; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertThrows; +import static org.junit.Assert.assertTrue; + +import io.temporal.api.common.v1.WorkflowExecution; +import io.temporal.client.WorkflowClient; +import io.temporal.client.WorkflowOptions; +import io.temporal.client.WorkflowStub; +import io.temporal.client.WorkflowTargetOptions; +import io.temporal.failure.ApplicationFailure; +import io.temporal.testing.TestWorkflowEnvironment; +import io.temporal.worker.Worker; +import java.time.Duration; +import java.util.List; +import java.util.concurrent.atomic.AtomicInteger; +import org.junit.Test; + +public class ReleaseWorkflowTest { + @Test + public void publishesAfterExactApprovalAndMavenArtifactUpdate() { + ReleaseIdentity release = ReleaseFixtures.release(); + try (TestWorkflowEnvironment environment = TestWorkflowEnvironment.newInstance()) { + registerReleaseAndOwnership(environment, release); + registerPublication(environment, release, successfulActivities()); + environment.start(); + StartedRelease started = startRelease(environment, release); + environment.sleep(Duration.ofSeconds(1)); + requestApproveAndRecordPayload(started, release); + + ReleaseResult result = + WorkflowStub.fromTyped(started.workflow).getResult(ReleaseResult.class); + assertEquals(release.digest(), result.releaseDigest); + assertEquals("PUBLISHED", started.workflow.status().phase); + } + } + + @Test + public void handoffTransfersTagOwnershipToTheExistingManualWorkflow() { + ReleaseIdentity release = ReleaseFixtures.release(); + try (TestWorkflowEnvironment environment = TestWorkflowEnvironment.newInstance()) { + registerReleaseAndOwnership(environment, release); + environment.start(); + StartedRelease started = startRelease(environment, release); + environment.sleep(Duration.ofSeconds(1)); + + ReleaseStatus handedOff = + started.workflow.control( + control("handoff-manual", started.runId, release, 202, "release-manager")); + assertEquals("HANDED_OFF", handedOff.phase); + assertEquals("MANUAL", handedOff.ownership.owner); + assertEquals( + "MANUAL", + OwnershipActivitiesImpl.status(environment.getWorkflowClient(), release.candidate.tag) + .owner); + } + } + + @Test + public void anExistingManualReleaseOwnsTheTagWithoutAnAutomaticWorkflow() { + ReleaseIdentity release = ReleaseFixtures.release(); + try (TestWorkflowEnvironment environment = TestWorkflowEnvironment.newInstance()) { + registerReleaseAndOwnership(environment, release); + environment.start(); + OwnershipStatus ownership = + OwnershipActivitiesImpl.claim( + environment.getWorkflowClient(), + OwnershipClaim.manual( + release.candidate.tag, + release.candidate.commitSha, + release.digest(), + "release-manager", + 201, + false)); + assertEquals("MANUAL", ownership.owner); + ReleaseOwnershipWorkflow ownershipWorkflow = + environment + .getWorkflowClient() + .newWorkflowStub( + ReleaseOwnershipWorkflow.class, + QueueNames.ownershipWorkflowId(release.candidate.tag)); + OwnershipStatus startedMaven = + ownershipWorkflow.recordManualMaven( + new ManualMavenAttempt( + "STARTED", + release.candidate.tag, + release.candidate.commitSha, + release.digest(), + "release-manager", + 201)); + assertEquals("STARTED", startedMaven.manualMavenState); + OwnershipStatus completedMaven = + ownershipWorkflow.recordManualMaven( + new ManualMavenAttempt( + "COMPLETED", + release.candidate.tag, + release.candidate.commitSha, + release.digest(), + "release-manager", + 201)); + assertEquals("COMPLETED", completedMaven.manualMavenState); + + StartedRelease started = startRelease(environment, release); + environment.sleep(Duration.ofSeconds(1)); + assertEquals("HANDED_OFF", started.workflow.status().phase); + assertEquals("MANUAL", started.workflow.status().ownership.owner); + } + } + + @Test + public void aCurrentManagerCanRebindAnAmbiguousMavenGeneration() { + ReleaseIdentity release = ReleaseFixtures.release(); + AtomicInteger repositoryAttempts = new AtomicInteger(); + PublicationActivities activities = + new SuccessfulActivities() { + @Override + public String reconcileMavenRepository(PublicationInput input, boolean allowCreation) { + int attempt = repositoryAttempts.getAndIncrement(); + if (attempt == 0) { + assertEquals(0, input.mavenSubmissionGeneration); + throw ApplicationFailure.newNonRetryableFailure( + "repository creation was ambiguous", "MavenSubmissionAmbiguous"); + } + assertEquals(1, input.mavenSubmissionGeneration); + if (attempt == 1) { + assertEquals("first-manager", input.mavenRetryAuthorization.githubActor); + throw ApplicationFailure.newNonRetryableFailure( + "the authorizer is no longer active", "InvalidApproval"); + } + assertEquals("current-manager", input.mavenRetryAuthorization.githubActor); + return "io-temporal-1001"; + } + }; + try (TestWorkflowEnvironment environment = TestWorkflowEnvironment.newInstance()) { + registerReleaseAndOwnership(environment, release); + registerPublication(environment, release, activities); + Worker retryWorker = environment.newWorker(QueueNames.publication(release, 1)); + retryWorker.registerActivitiesImplementations(activities); + environment.start(); + StartedRelease started = startRelease(environment, release); + environment.sleep(Duration.ofSeconds(1)); + requestApproveAndRecordPayload(started, release); + environment.sleep(Duration.ofSeconds(1)); + + ReleaseStatus blocked = started.workflow.status(); + assertEquals("BLOCKED", blocked.phase); + assertTrue(blocked.lastError.contains("MavenSubmissionAmbiguous")); + assertThrows( + IllegalArgumentException.class, + () -> + ReleaseWorkflowImpl.validateInspectedGenerations( + blocked.mavenGenerations, + mavenRetry(started.runId, release, 400, "first-manager", 1, 0).mavenInspection)); + started.workflow.control(mavenRetry(started.runId, release, 401, "first-manager", 1, 1)); + environment.sleep(Duration.ofSeconds(1)); + assertTrue(started.workflow.status().lastError.contains("InvalidApproval")); + started.workflow.control(mavenRetry(started.runId, release, 402, "current-manager", 1, 2)); + + WorkflowStub.fromTyped(started.workflow).getResult(ReleaseResult.class); + assertEquals(3, repositoryAttempts.get()); + } + } + + @Test + public void handoffAfterAutomaticMavenCompletionPreservesSubmissionState() { + ReleaseIdentity release = ReleaseFixtures.release(); + PublicationActivities activities = + new SuccessfulActivities() { + @Override + public String reconcileGithubDraft(PublicationInput input) { + throw ApplicationFailure.newNonRetryableFailure( + "draft publication is unavailable", "ReleaseIdentityConflict"); + } + }; + try (TestWorkflowEnvironment environment = TestWorkflowEnvironment.newInstance()) { + registerReleaseAndOwnership(environment, release); + registerPublication(environment, release, activities); + environment.start(); + StartedRelease started = startRelease(environment, release); + environment.sleep(Duration.ofSeconds(1)); + requestApproveAndRecordPayload(started, release); + environment.sleep(Duration.ofSeconds(1)); + + ReleaseStatus blocked = started.workflow.status(); + assertEquals("BLOCKED", blocked.phase); + assertTrue(blocked.mavenGenerations.get(0).submissionStarted); + assertTrue(blocked.mavenCentralUrl != null && !blocked.mavenCentralUrl.isEmpty()); + ReleaseStatus handedOff = + started.workflow.control( + control("handoff-manual", started.runId, release, 500, "release-manager")); + assertEquals("HANDED_OFF", handedOff.phase); + assertEquals("GITHUB_DRAFT", handedOff.handedOffFrom); + assertTrue(handedOff.mavenGenerations.get(0).submissionStarted); + assertEquals("MANUAL", handedOff.ownership.owner); + } + } + + @Test + public void partialAutomaticMavenCannotBeHandedToTheLegacyManualPublisher() { + ReleaseIdentity release = ReleaseFixtures.release(); + MavenGenerationState started = new MavenGenerationState(release.digest(), 0); + started.submissionStarted = true; + assertThrows( + IllegalStateException.class, + () -> ReleaseWorkflowImpl.validateManualHandoff(List.of(started), null, false)); + assertThrows( + IllegalStateException.class, + () -> ReleaseWorkflowImpl.validateManualHandoff(List.of(started), null, true)); + ReleaseWorkflowImpl.validateManualHandoff( + List.of(started), "https://central.example/artifact", false); + } + + private static void registerReleaseAndOwnership( + TestWorkflowEnvironment environment, ReleaseIdentity release) { + Worker releaseWorker = environment.newWorker(QueueNames.releaseWorkflow(release)); + releaseWorker.registerWorkflowImplementationTypes(ReleaseWorkflowImpl.class); + Worker ownershipWorker = environment.newWorker(QueueNames.ownership(release.candidate.tag)); + ownershipWorker.registerWorkflowImplementationTypes(ReleaseOwnershipWorkflowImpl.class); + ownershipWorker.registerActivitiesImplementations( + new OwnershipActivitiesImpl(environment.getWorkflowClient())); + } + + private static void registerPublication( + TestWorkflowEnvironment environment, + ReleaseIdentity release, + PublicationActivities activities) { + Worker worker = environment.newWorker(QueueNames.publication(release)); + worker.registerActivitiesImplementations(activities); + } + + private static PublicationActivities successfulActivities() { + return new SuccessfulActivities(); + } + + private static class SuccessfulActivities implements PublicationActivities { + @Override + public void preflight(PublicationInput input) {} + + @Override + public String reconcileMavenRepository(PublicationInput input, boolean allowCreation) { + return "io-temporal-1000"; + } + + @Override + public String reconcileMavenPortal(PublicationInput input) { + return "12345678-1234-1234-1234-123456789abc"; + } + + @Override + public MavenReceipt publishMaven(PublicationInput input) { + return new MavenReceipt( + "https://central.example/artifact", + "io-temporal-1000", + "12345678-1234-1234-1234-123456789abc"); + } + + @Override + public String reconcileGithubDraft(PublicationInput input) { + return "https://github.example/draft"; + } + + @Override + public ReleaseResult publishGithubRelease(PublicationInput input, String mavenCentralUrl) { + return new ReleaseResult( + input.release.digest(), "https://github.example/release", mavenCentralUrl); + } + } + + private static StartedRelease startRelease( + TestWorkflowEnvironment environment, ReleaseIdentity release) { + ReleaseWorkflow starter = + environment + .getWorkflowClient() + .newWorkflowStub( + ReleaseWorkflow.class, + WorkflowOptions.newBuilder() + .setWorkflowId(QueueNames.releaseWorkflowId(release)) + .setTaskQueue(QueueNames.releaseWorkflow(release)) + .build()); + WorkflowExecution execution = WorkflowClient.start(starter::release, release); + ReleaseWorkflow workflow = + environment + .getWorkflowClient() + .newWorkflowStub( + ReleaseWorkflow.class, + WorkflowTargetOptions.newBuilder().setWorkflowExecution(execution).build()); + return new StartedRelease(workflow, execution.getRunId()); + } + + private static void requestApproveAndRecordPayload( + StartedRelease started, ReleaseIdentity release) { + String workflowId = QueueNames.releaseWorkflowId(release); + started.workflow.requestApproval( + new ApprovalRequest( + release.digest(), + workflowId, + started.runId, + 300, + 43, + "ISSUE_node_43", + "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc", + "approval-bot", + release.candidate.trustedAutomationCommit)); + started.workflow.approve( + new ApprovalEvidence( + release.digest(), + workflowId, + started.runId, + 300, + "release-manager", + 43, + "ISSUE_node_43", + "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc", + release.candidate.trustedAutomationCommit)); + started.workflow.recordMavenPayload(ReleaseFixtures.mavenArtifact(release)); + } + + private static ControlEvidence control( + String action, String runId, ReleaseIdentity release, long githubRunId, String actor) { + return new ControlEvidence( + action, + release.digest(), + QueueNames.releaseWorkflowId(release), + runId, + githubRunId, + actor, + release.candidate.tag, + release.candidate.commitSha, + "Test control evidence."); + } + + private static ControlEvidence mavenRetry( + String runId, + ReleaseIdentity release, + long githubRunId, + String actor, + int generation, + int inspectedGenerations) { + ControlEvidence evidence = new ControlEvidence(); + evidence.action = "retry-maven-submission"; + evidence.releaseDigest = release.digest(); + evidence.workflowId = QueueNames.releaseWorkflowId(release); + evidence.runId = runId; + evidence.githubRunId = githubRunId; + evidence.githubActor = actor; + evidence.tag = release.candidate.tag; + evidence.commitSha = release.candidate.commitSha; + evidence.reason = "Test Maven retry authorization."; + evidence.mavenSubmissionGeneration = generation; + evidence.mavenInspection = new MavenInspection(); + evidence.mavenInspection.centralMissing = + ReleasePolicy.mavenArtifacts(release.candidate.mavenPolicy).size(); + for (int inspectedGeneration = 0; + inspectedGeneration < inspectedGenerations; + inspectedGeneration++) { + MavenGenerationInspection inspected = new MavenGenerationInspection(); + inspected.generation = inspectedGeneration; + inspected.description = "sdk-java:" + release.digest() + ":" + inspectedGeneration; + inspected.repositoryId = ""; + inspected.repositoryState = "absent"; + inspected.portalDeploymentId = ""; + inspected.portalDeploymentState = ""; + evidence.mavenInspection.generations.add(inspected); + } + evidence.authorizationSha256 = + Digests.sha256(evidence.mavenInspection.canonicalForm(release.digest())); + evidence.validate(); + return evidence; + } + + private static final class StartedRelease { + final ReleaseWorkflow workflow; + final String runId; + + private StartedRelease(ReleaseWorkflow workflow, String runId) { + this.workflow = workflow; + this.runId = runId; + } + } +} diff --git a/.github/scripts/temporal-release/build-and-sign-maven-payload.sh b/.github/scripts/temporal-release/build-and-sign-maven-payload.sh new file mode 100755 index 0000000000..319ee8e77b --- /dev/null +++ b/.github/scripts/temporal-release/build-and-sign-maven-payload.sh @@ -0,0 +1,57 @@ +#!/usr/bin/env bash + +set -euo pipefail + +fail() { echo "build-and-sign-maven-payload: $*" >&2; exit 1; } +conflict() { echo "build-and-sign-maven-payload: immutable conflict: $*" >&2; exit 42; } + +for name in JAR_SIGNING_KEY JAR_SIGNING_KEY_ID JAR_SIGNING_KEY_PASSWORD \ + MAVEN_PAYLOAD_COMMIT MAVEN_PAYLOAD_OUTPUT MAVEN_PAYLOAD_VERSION; do + [[ -n ${!name:-} ]] || fail "$name is required." +done +[[ $MAVEN_PAYLOAD_COMMIT =~ ^[0-9a-f]{40}$ ]] || conflict "the source SHA is invalid." +[[ $MAVEN_PAYLOAD_VERSION =~ ^[0-9]+\.[0-9]+\.[0-9]+(-RC[0-9]+)?$ ]] || + conflict "the release version is invalid." +[[ -x ./gradlew ]] || conflict "the immutable source has no Gradle wrapper." + +# Candidate-controlled Gradle code runs in a separate PID and mount namespace. The pinned image is +# given only the public source, disposable output/cache directories, and the immutable release +# identity. It receives no host environment, cloud token, publication token, or signing material. +image='eclipse-temurin:17-jdk@sha256:91b6210cce02091f6f0798a83ec51aa223828242c5a21a85793bb8c28dc891c4' +sandbox=$(mktemp -d) +gnupg=$(mktemp -d) +key_file=$(mktemp) +trap 'rm -rf "$sandbox" "$gnupg" "$key_file"' EXIT +mkdir -p "$MAVEN_PAYLOAD_OUTPUT" "$sandbox/gradle" "$sandbox/home" "$sandbox/source" +chmod 0700 "$gnupg" +cp -a "$PWD/." "$sandbox/source/" || fail "the immutable source sandbox could not be created." + +docker run --rm --pull=missing --network bridge --cap-drop ALL \ + --security-opt no-new-privileges --pids-limit 2048 \ + --user "$(id -u):$(id -g)" --workdir /workspace \ + --env HOME=/candidate-home --env GRADLE_USER_HOME=/gradle-home \ + --mount "type=bind,src=$sandbox/source,dst=/workspace" \ + --mount "type=bind,src=$MAVEN_PAYLOAD_OUTPUT,dst=/payload" \ + --mount "type=bind,src=$sandbox/gradle,dst=/gradle-home" \ + --mount "type=bind,src=$sandbox/home,dst=/candidate-home" \ + "$image" ./gradlew --no-daemon -Dmaven.repo.local=/payload \ + "-PreleaseVersion=$MAVEN_PAYLOAD_VERSION" "-PreleaseCommit=$MAVEN_PAYLOAD_COMMIT" \ + publishToMavenLocal >&2 || fail "the isolated Gradle payload build failed." + +# Discard every candidate-produced signature/checksum before applying the trusted release key. +find "$MAVEN_PAYLOAD_OUTPUT/io/temporal" -type f \ + \( -name '*.asc' -o -name '*.md5' -o -name '*.sha1' \) -delete +printf '%s' "$JAR_SIGNING_KEY" | base64 --decode >"$key_file" || + fail "the protected signing key is not valid base64." +gpg --batch --homedir "$gnupg" --import "$key_file" >/dev/null 2>&1 || + fail "the protected signing key could not be imported." + +while IFS= read -r -d '' payload; do + gpg --batch --yes --homedir "$gnupg" --pinentry-mode loopback \ + --passphrase "$JAR_SIGNING_KEY_PASSWORD" --local-user "$JAR_SIGNING_KEY_ID" \ + --armor --detach-sign --output "$payload.asc" "$payload" || + fail "trusted signing failed for ${payload#"$MAVEN_PAYLOAD_OUTPUT/"}." + md5sum "$payload" | awk '{print $1}' >"$payload.md5" + sha1sum "$payload" | awk '{print $1}' >"$payload.sha1" +done < <(find "$MAVEN_PAYLOAD_OUTPUT/io/temporal" -type f \ + \( -name '*.jar' -o -name '*.pom' -o -name '*.module' \) -print0 | sort -z) diff --git a/.github/scripts/temporal-release/build-native-binary.sh b/.github/scripts/temporal-release/build-native-binary.sh new file mode 100755 index 0000000000..89d69915b4 --- /dev/null +++ b/.github/scripts/temporal-release/build-native-binary.sh @@ -0,0 +1,116 @@ +#!/usr/bin/env bash + +set -euo pipefail + +fail() { + echo "build-native-binary: $*" >&2 + exit 1 +} + +conflict() { + echo "build-native-binary: immutable candidate conflict: $*" >&2 + exit 42 +} + +sha256_file() { + if command -v sha256sum >/dev/null 2>&1; then + sha256sum "$1" | awk '{print $1}' + else + shasum -a 256 "$1" | awk '{print $1}' + fi +} + +native_path() { + if command -v cygpath >/dev/null 2>&1; then + cygpath -u "$1" + else + printf '%s\n' "$1" + fi +} + +required=( + RELEASE_CANDIDATE_DIGEST RELEASE_COMMIT RELEASE_NOTES_FILE RELEASE_NOTES_SHA256 + RELEASE_PLATFORM RELEASE_PREBUILT_NATIVE_DIR RELEASE_TAG RELEASE_VERSION + TRUSTED_AUTOMATION_COMMIT TRUSTED_AUTOMATION_ROOT +) +for variable in "${required[@]}"; do + [[ -n ${!variable:-} ]] || fail "Required value $variable is missing." +done + +for credential in \ + TEMPORAL_API_KEY GH_TOKEN \ + ACTIONS_ID_TOKEN_REQUEST_URL ACTIONS_ID_TOKEN_REQUEST_TOKEN; do + [[ -z ${!credential:-} ]] || fail "Credential $credential must not reach candidate compilation." +done + +trusted_root=$(native_path "$TRUSTED_AUTOMATION_ROOT") +output_root=$(native_path "$RELEASE_PREBUILT_NATIVE_DIR") +[[ $(git rev-parse --verify HEAD^{commit}) == "$RELEASE_COMMIT" ]] || + conflict "the checkout is not $RELEASE_COMMIT." +[[ $(git -C "$trusted_root" rev-parse --verify HEAD^{commit}) == "$TRUSTED_AUTOMATION_COMMIT" ]] || + conflict "the trusted automation checkout changed." +[[ $RELEASE_NOTES_FILE == "releases/$RELEASE_TAG" && -s $RELEASE_NOTES_FILE ]] || + conflict "the release-note identity is invalid." +[[ $(sha256_file "$RELEASE_NOTES_FILE") == "$RELEASE_NOTES_SHA256" ]] || + conflict "the release-note checksum changed." + +case "$RELEASE_PLATFORM" in + linux-amd64-musl | linux-amd64 | macos-amd64 | macos-arm64 | linux-arm64 | windows-amd64) ;; + *) fail "Temporal scheduled an unknown sdk-java release platform." ;; +esac + +hook_backup=$(mktemp) +cp gradle/versioning.gradle "$hook_backup" +cp "$trusted_root/gradle/versioning.gradle" gradle/versioning.gradle +restore_hook() { cp "$hook_backup" gradle/versioning.gradle; } +trap restore_hook EXIT + +case "$RELEASE_PLATFORM" in + linux-amd64-musl) + image_id_file=$(mktemp) + docker build --iidfile "$image_id_file" \ + "$trusted_root/.github/release-automation/docker/native-image-musl-java23" 1>&2 + docker run --rm -w /github/workspace -v "$PWD:/github/workspace" \ + "$(<"$image_id_file")" \ + ./gradlew "-PreleaseVersion=$RELEASE_VERSION" -PnativeBuild -PnativeBuildMusl \ + :temporal-test-server:nativeCompile 1>&2 + ;; + linux-amd64 | linux-arm64) + image_id_file=$(mktemp) + docker build --iidfile "$image_id_file" \ + "$trusted_root/.github/release-automation/docker/native-image-java23" 1>&2 + docker run --rm -w /github/workspace -v "$PWD:/github/workspace" \ + "$(<"$image_id_file")" \ + ./gradlew "-PreleaseVersion=$RELEASE_VERSION" -PnativeBuild \ + :temporal-test-server:nativeCompile 1>&2 + ;; + macos-amd64 | macos-arm64 | windows-amd64) + ./gradlew "-PreleaseVersion=$RELEASE_VERSION" -PnativeBuild \ + :temporal-test-server:nativeCompile 1>&2 + ;; +esac + +restore_hook +trap - EXIT +git diff --exit-code 1>&2 || conflict "the build modified tracked source files." + +binary=temporal-test-server/build/native/nativeCompile/temporal-test-server +[[ $RELEASE_PLATFORM == windows-amd64 ]] && binary=${binary}.exe +[[ -f $binary && ! -L $binary && -s $binary ]] || fail "The native executable is invalid." +mkdir -p "$output_root" +[[ -z $(find "$output_root" -mindepth 1 -maxdepth 1 -print -quit) ]] || + fail "The native output directory is not empty." +cp "$binary" "$output_root/$(basename "$binary")" +jq -n \ + --arg candidateDigest "$RELEASE_CANDIDATE_DIGEST" \ + --arg commitSha "$RELEASE_COMMIT" \ + --arg platform "$RELEASE_PLATFORM" \ + --arg releaseNotesPath "$RELEASE_NOTES_FILE" \ + --arg releaseNotesSha256 "$RELEASE_NOTES_SHA256" \ + --arg tag "$RELEASE_TAG" \ + --arg trustedAutomationCommit "$TRUSTED_AUTOMATION_COMMIT" \ + --arg version "$RELEASE_VERSION" \ + '{candidateDigest:$candidateDigest,commitSha:$commitSha,platform:$platform, + releaseNotesPath:$releaseNotesPath,releaseNotesSha256:$releaseNotesSha256,tag:$tag, + trustedAutomationCommit:$trustedAutomationCommit,version:$version}' \ + >"$output_root/metadata.json" diff --git a/.github/scripts/temporal-release/create-sonatype-repository.sh b/.github/scripts/temporal-release/create-sonatype-repository.sh new file mode 100755 index 0000000000..7be0cbdc0f --- /dev/null +++ b/.github/scripts/temporal-release/create-sonatype-repository.sh @@ -0,0 +1,33 @@ +#!/usr/bin/env bash + +set -euo pipefail + +fail() { echo "create-sonatype-repository: $*" >&2; exit 1; } +conflict() { echo "create-sonatype-repository: immutable conflict: $*" >&2; exit 42; } + +for name in RH_PASSWORD RH_USER SONATYPE_REPOSITORY_DESCRIPTION; do + [[ -n ${!name:-} ]] || fail "$name is required." +done +[[ $SONATYPE_REPOSITORY_DESCRIPTION =~ ^sdk-java:[0-9a-f]{64}:[0-9]+$ ]] || + conflict "the repository description is outside sdk-java release policy." + +base=https://ossrh-staging-api.central.sonatype.com +work=$(mktemp -d) +trap 'rm -rf "$work"' EXIT +curl --silent --show-error --fail --user "$RH_USER:$RH_PASSWORD" \ + --header 'Accept: application/json' "$base/service/local/staging/profiles" \ + >"$work/profiles.json" || fail "Sonatype profiles are temporarily unavailable." +profile_id=$(jq -er '[.data[] | select(.name == "io.temporal") | .id] | + if length == 1 then .[0] else error("expected one io.temporal profile") end' \ + "$work/profiles.json") || conflict "Sonatype did not return one fixed io.temporal profile." +jq -n --arg description "$SONATYPE_REPOSITORY_DESCRIPTION" \ + '{data:{description:$description}}' >"$work/start.json" +status=$(curl --silent --show-error --output "$work/response.json" --write-out '%{http_code}' \ + --request POST --user "$RH_USER:$RH_PASSWORD" --header 'Content-Type: application/json' \ + --data-binary "@$work/start.json" \ + "$base/service/local/staging/profiles/$profile_id/start") || + fail "Sonatype repository creation was unavailable." +case "$status" in 200 | 201) ;; *) fail "Sonatype returned HTTP $status while creating the repository." ;; esac +jq -er '.data.stagedRepositoryId | + select(type == "string" and test("^[A-Za-z0-9._-]+$"))' "$work/response.json" || + fail "Sonatype accepted creation without returning a repository ID." diff --git a/.github/scripts/temporal-release/download-github-artifact.sh b/.github/scripts/temporal-release/download-github-artifact.sh new file mode 100755 index 0000000000..05b9433495 --- /dev/null +++ b/.github/scripts/temporal-release/download-github-artifact.sh @@ -0,0 +1,112 @@ +#!/usr/bin/env bash + +set -euo pipefail + +fail() { echo "download-github-artifact: $*" >&2; exit 1; } +conflict() { echo "download-github-artifact: immutable artifact conflict: $*" >&2; exit 42; } +unavailable() { echo "download-github-artifact: exact artifact unavailable: $*" >&2; exit 46; } + +sha256_file() { + if command -v sha256sum >/dev/null 2>&1; then sha256sum "$1" | awk '{print $1}' + else shasum -a 256 "$1" | awk '{print $1}'; fi +} + +for variable in GH_TOKEN GITHUB_ARTIFACT_DESTINATION; do + [[ -n ${!variable:-} ]] || fail "Required value $variable is missing." +done +work=$(mktemp -d) +trap 'rm -rf "$work"' EXIT +receipt=${GITHUB_ARTIFACT_RECEIPT_FILE:-} +if [[ -n $receipt ]]; then + jq -e '.artifactId > 0 and .workflowRunId > 0 and + (.artifactName | test("^[A-Za-z0-9][A-Za-z0-9._-]*$")) and + (.githubDigest | test("^sha256:[0-9a-f]{64}$")) and + (.files | type == "array" and length > 0)' "$receipt" >/dev/null || + conflict "the Temporal artifact receipt is invalid." + artifact_id=$(jq -er .artifactId "$receipt") + workflow_run_id=$(jq -er .workflowRunId "$receipt") + artifact_name=$(jq -er .artifactName "$receipt") + github_digest=$(jq -er .githubDigest "$receipt") + created_at=$(jq -er .createdAt "$receipt") + expires_at=$(jq -er .expiresAt "$receipt") +else + for variable in GITHUB_ARTIFACT_ID GITHUB_ARTIFACT_RUN_ID GITHUB_ARTIFACT_NAME GITHUB_ARTIFACT_DIGEST; do + [[ -n ${!variable:-} ]] || fail "Required value $variable is missing." + done + [[ $GITHUB_ARTIFACT_ID =~ ^[1-9][0-9]*$ && $GITHUB_ARTIFACT_RUN_ID =~ ^[1-9][0-9]*$ ]] || + conflict "the discovered GitHub artifact IDs are invalid." + [[ $GITHUB_ARTIFACT_NAME =~ ^[A-Za-z0-9][A-Za-z0-9._-]*$ ]] || + conflict "the discovered GitHub artifact name is invalid." + [[ $GITHUB_ARTIFACT_DIGEST =~ ^sha256:[0-9a-f]{64}$ ]] || + conflict "the discovered GitHub artifact digest is invalid." + artifact_id=$GITHUB_ARTIFACT_ID + workflow_run_id=$GITHUB_ARTIFACT_RUN_ID + artifact_name=$GITHUB_ARTIFACT_NAME + github_digest=$GITHUB_ARTIFACT_DIGEST + created_at= + expires_at= +fi +metadata_file=$work/metadata.json +status=$(curl --silent --show-error --location --output "$metadata_file" --write-out '%{http_code}' \ + --header "Authorization: Bearer $GH_TOKEN" --header 'Accept: application/vnd.github+json' \ + --header 'X-GitHub-Api-Version: 2022-11-28' \ + "https://api.github.com/repos/temporalio/sdk-java/actions/artifacts/$artifact_id") || + fail "GitHub artifact metadata is temporarily unavailable." +case $status in 200) ;; 404) unavailable "artifact $artifact_id was deleted." ;; *) + fail "GitHub returned HTTP $status for artifact metadata." ;; esac +metadata=$(<"$metadata_file") +jq_args=(--argjson id "$artifact_id" --argjson run "$workflow_run_id" --arg name "$artifact_name" + --arg digest "$github_digest") +metadata_filter='.id == $id and .workflow_run.id == $run and .name == $name and .digest == $digest' +if [[ -n $receipt ]]; then + jq_args+=(--arg created "$created_at" --arg expires "$expires_at") + metadata_filter+=' and .created_at == $created and .expires_at == $expires' +fi +jq -e "${jq_args[@]}" "$metadata_filter" <<<"$metadata" >/dev/null || + conflict "artifact $artifact_id immutable metadata changed." +[[ $(jq -r .expired <<<"$metadata") == false ]] || unavailable "artifact $artifact_id expired." + +archive=$(mktemp) +status=$(curl --silent --show-error --location --output "$archive" --write-out '%{http_code}' \ + --header "Authorization: Bearer $GH_TOKEN" --header 'Accept: application/vnd.github+json' \ + --header 'X-GitHub-Api-Version: 2022-11-28' \ + "https://api.github.com/repos/temporalio/sdk-java/actions/artifacts/$artifact_id/zip") || + fail "The exact GitHub artifact download is temporarily unavailable." +case $status in 200) ;; 404 | 410) unavailable "artifact $artifact_id has no downloadable archive." ;; *) + fail "GitHub returned HTTP $status for the artifact download." ;; esac +[[ "sha256:$(sha256_file "$archive")" == "$github_digest" ]] || + conflict "the downloaded GitHub artifact archive digest differs." +unzip -Z1 "$archive" | sort >"$work/archive-files.txt" || + conflict "the downloaded GitHub artifact is not a valid ZIP archive." +while IFS= read -r name; do + [[ $name =~ ^[A-Za-z0-9][A-Za-z0-9._-]*$ ]] || + conflict "the downloaded GitHub artifact contains an unsafe path." +done <"$work/archive-files.txt" +[[ -z $(uniq -d "$work/archive-files.txt") ]] || + conflict "the downloaded GitHub artifact contains duplicate entries." +if [[ -n $receipt ]]; then + jq -r '.files[].name' "$receipt" | sort >"$work/receipt-files.txt" + cmp "$work/receipt-files.txt" "$work/archive-files.txt" >/dev/null || + conflict "the downloaded GitHub artifact archive entries differ from Temporal state." +fi +mkdir -p "$GITHUB_ARTIFACT_DESTINATION" +[[ -z $(find "$GITHUB_ARTIFACT_DESTINATION" -mindepth 1 -print -quit) ]] || + fail "The artifact destination is not empty." +unzip -q "$archive" -d "$GITHUB_ARTIFACT_DESTINATION" +actual=$work/downloaded-artifact-files.jsonl +: >"$actual" +while IFS= read -r file; do + [[ -f $file && ! -L $file ]] || conflict "the downloaded artifact has an invalid entry." + name=$(basename "$file") + [[ $file == "$GITHUB_ARTIFACT_DESTINATION/$name" ]] || + conflict "the downloaded artifact contains a directory." + jq -cn --arg name "$name" --arg sha256 "$(sha256_file "$file")" \ + --argjson size "$(wc -c <"$file" | tr -d ' ')" \ + '{name:$name,sha256:$sha256,size:$size}' >>"$actual" +done < <(find "$GITHUB_ARTIFACT_DESTINATION" -mindepth 1 -type f | sort) +if [[ -n $receipt ]]; then + jq -S '.files | sort_by(.name)' "$receipt" >"$work/expected-artifact-files.json" + jq -sS 'sort_by(.name)' "$actual" >"$work/actual-artifact-files.json" + cmp "$work/expected-artifact-files.json" "$work/actual-artifact-files.json" >/dev/null || + conflict "the downloaded artifact files differ from Temporal state." +fi diff --git a/.github/scripts/temporal-release/find-github-artifact.sh b/.github/scripts/temporal-release/find-github-artifact.sh new file mode 100755 index 0000000000..dbc01c21c4 --- /dev/null +++ b/.github/scripts/temporal-release/find-github-artifact.sh @@ -0,0 +1,28 @@ +#!/usr/bin/env bash + +set -euo pipefail + +fail() { echo "find-github-artifact: $*" >&2; exit 1; } +conflict() { echo "find-github-artifact: immutable artifact conflict: $*" >&2; exit 42; } +unavailable() { echo "find-github-artifact: exact artifact unavailable: $*" >&2; exit 46; } + +[[ -n ${GH_TOKEN:-} && -n ${GITHUB_ARTIFACT_NAME:-} ]] || fail "GitHub access and artifact name are required." +[[ $GITHUB_ARTIFACT_NAME =~ ^[A-Za-z0-9][A-Za-z0-9._-]*$ ]] || conflict "the artifact name is invalid." +response=$(gh api --paginate --slurp --method GET \ + repos/temporalio/sdk-java/actions/artifacts -f name="$GITHUB_ARTIFACT_NAME" -f per_page=100) || + fail "GitHub artifact discovery is unavailable." +matches=$(jq --arg name "$GITHUB_ARTIFACT_NAME" \ + '[.[].artifacts[] | select(.name == $name)]' <<<"$response") +total=$(jq 'length' <<<"$matches") +(( total <= 1 )) || conflict "more than one artifact has the immutable routing name." +live=$(jq '[.[] | select(.expired == false)] | length' <<<"$matches") +if (( live == 0 )); then + (( total == 0 )) || + unavailable "the only artifact with the immutable routing name expired." + echo 'found=false' + exit 0 +fi +jq -er ' + [.[] | select(.expired == false)][0] | + "found=true\nartifact_id=\(.id)\nworkflow_run_id=\(.workflow_run.id)\ngithub_digest=\(.digest)"' \ + <<<"$matches" diff --git a/.github/scripts/temporal-release/github-artifact-receipt.sh b/.github/scripts/temporal-release/github-artifact-receipt.sh new file mode 100755 index 0000000000..852392fa90 --- /dev/null +++ b/.github/scripts/temporal-release/github-artifact-receipt.sh @@ -0,0 +1,56 @@ +#!/usr/bin/env bash + +set -euo pipefail + +fail() { echo "github-artifact-receipt: $*" >&2; exit 1; } +conflict() { echo "github-artifact-receipt: immutable artifact conflict: $*" >&2; exit 42; } + +sha256_file() { + if command -v sha256sum >/dev/null 2>&1; then sha256sum "$1" | awk '{print $1}' + else shasum -a 256 "$1" | awk '{print $1}'; fi +} + +required=( + GH_TOKEN GITHUB_ARTIFACT_CONTENT_DIR GITHUB_ARTIFACT_ID GITHUB_ARTIFACT_NAME + GITHUB_ARTIFACT_RECEIPT_FILE GITHUB_ARTIFACT_RUN_ID +) +for variable in "${required[@]}"; do + [[ -n ${!variable:-} ]] || fail "Required value $variable is missing." +done +[[ $GITHUB_ARTIFACT_ID =~ ^[1-9][0-9]*$ && $GITHUB_ARTIFACT_RUN_ID =~ ^[1-9][0-9]*$ ]] || + conflict "the GitHub artifact IDs are invalid." +[[ -d $GITHUB_ARTIFACT_CONTENT_DIR ]] || fail "The artifact content directory is missing." +[[ -z $(find "$GITHUB_ARTIFACT_CONTENT_DIR" -mindepth 1 -maxdepth 1 ! -type f -print -quit) ]] || + conflict "the artifact contains a directory, link, or other non-file entry." + +metadata=$(gh api "repos/temporalio/sdk-java/actions/artifacts/$GITHUB_ARTIFACT_ID") || + fail "The exact GitHub artifact is unavailable." +jq -e --argjson id "$GITHUB_ARTIFACT_ID" --argjson run "$GITHUB_ARTIFACT_RUN_ID" \ + --arg name "$GITHUB_ARTIFACT_NAME" \ + '.id == $id and .name == $name and .workflow_run.id == $run and .expired == false and + (.digest | test("^sha256:[0-9a-f]{64}$")) and + (.created_at | type == "string") and (.expires_at | type == "string")' \ + <<<"$metadata" >/dev/null || conflict "the GitHub artifact metadata differs." + +work=$(mktemp -d) +trap 'rm -rf "$work"' EXIT +files=$work/github-artifact-files.jsonl +: >"$files" +while IFS= read -r file; do + [[ -f $file && ! -L $file ]] || conflict "the artifact contains a non-file entry." + name=$(basename "$file") + [[ $name =~ ^[A-Za-z0-9][A-Za-z0-9._-]*$ ]] || conflict "the artifact filename is invalid." + sha=$(sha256_file "$file") + size=$(wc -c <"$file" | tr -d ' ') + jq -cn --arg name "$name" --arg sha256 "$sha" --argjson size "$size" \ + '{name:$name,sha256:$sha256,size:$size}' >>"$files" +done < <(find "$GITHUB_ARTIFACT_CONTENT_DIR" -mindepth 1 -maxdepth 1 -type f | sort) +[[ -s $files ]] || conflict "the artifact has no files." +jq -s --argjson artifactId "$GITHUB_ARTIFACT_ID" \ + --argjson workflowRunId "$GITHUB_ARTIFACT_RUN_ID" \ + --arg artifactName "$GITHUB_ARTIFACT_NAME" --arg githubDigest "$(jq -er .digest <<<"$metadata")" \ + --arg createdAt "$(jq -er .created_at <<<"$metadata")" \ + --arg expiresAt "$(jq -er .expires_at <<<"$metadata")" \ + '{artifactId:$artifactId,workflowRunId:$workflowRunId,artifactName:$artifactName, + githubDigest:$githubDigest,createdAt:$createdAt,expiresAt:$expiresAt,files:.}' \ + "$files" >"$GITHUB_ARTIFACT_RECEIPT_FILE" diff --git a/.github/scripts/temporal-release/package-native-artifact.sh b/.github/scripts/temporal-release/package-native-artifact.sh new file mode 100755 index 0000000000..fa2dc64d51 --- /dev/null +++ b/.github/scripts/temporal-release/package-native-artifact.sh @@ -0,0 +1,84 @@ +#!/usr/bin/env bash + +set -euo pipefail + +fail() { echo "package-native-artifact: $*" >&2; exit 1; } +conflict() { echo "package-native-artifact: immutable artifact conflict: $*" >&2; exit 42; } + +required=( + RELEASE_ASSET_PLATFORM RELEASE_ARCHIVE_EXTENSION RELEASE_BINARY_NAME + RELEASE_CANDIDATE_DIGEST RELEASE_COMMIT RELEASE_NOTES_FILE RELEASE_NOTES_SHA256 + RELEASE_OUTPUT_DIR RELEASE_PLATFORM RELEASE_PREBUILT_NATIVE_DIR RELEASE_TAG RELEASE_VERSION + TRUSTED_AUTOMATION_COMMIT TRUSTED_AUTOMATION_ROOT +) +for variable in "${required[@]}"; do + [[ -n ${!variable:-} ]] || fail "Required value $variable is missing." +done + +[[ $(git -C "$TRUSTED_AUTOMATION_ROOT" rev-parse --verify HEAD^{commit}) == \ + "$TRUSTED_AUTOMATION_COMMIT" ]] || conflict "the trusted automation checkout changed." +prebuilt=$RELEASE_PREBUILT_NATIVE_DIR +binary=$prebuilt/$RELEASE_BINARY_NAME +[[ -f $binary && ! -L $binary && -s $binary ]] || conflict "the native executable is invalid." +[[ -f $prebuilt/metadata.json && ! -L $prebuilt/metadata.json ]] || + conflict "the native metadata is invalid." +entries=$(find "$prebuilt" -mindepth 1 -maxdepth 1 -exec basename {} \; | sort | tr '\n' ' ') +[[ $entries == "metadata.json $RELEASE_BINARY_NAME " ]] || + conflict "the native output contains unexpected files." +jq -e --arg candidateDigest "$RELEASE_CANDIDATE_DIGEST" \ + --arg commitSha "$RELEASE_COMMIT" --arg platform "$RELEASE_PLATFORM" \ + --arg releaseNotesPath "$RELEASE_NOTES_FILE" \ + --arg releaseNotesSha256 "$RELEASE_NOTES_SHA256" --arg tag "$RELEASE_TAG" \ + --arg trustedAutomationCommit "$TRUSTED_AUTOMATION_COMMIT" --arg version "$RELEASE_VERSION" \ + 'keys == ["candidateDigest","commitSha","platform","releaseNotesPath", + "releaseNotesSha256","tag","trustedAutomationCommit","version"] and + .candidateDigest == $candidateDigest and .commitSha == $commitSha and + .platform == $platform and .releaseNotesPath == $releaseNotesPath and + .releaseNotesSha256 == $releaseNotesSha256 and .tag == $tag and + .trustedAutomationCommit == $trustedAutomationCommit and .version == $version' \ + "$prebuilt/metadata.json" >/dev/null || conflict "the native output identity changed." + +archive_root="temporal-test-server_${RELEASE_VERSION}_${RELEASE_ASSET_PLATFORM}" +artifact_name=${archive_root}${RELEASE_ARCHIVE_EXTENSION} +mkdir -p "$RELEASE_OUTPUT_DIR" +[[ -z $(find "$RELEASE_OUTPUT_DIR" -mindepth 1 -maxdepth 1 -print -quit) ]] || + fail "The native artifact output directory is not empty." +python3 - "$binary" "$RELEASE_OUTPUT_DIR/$artifact_name" "$archive_root" \ + "$RELEASE_BINARY_NAME" "$RELEASE_PLATFORM" <<'PY' +import gzip +import io +import pathlib +import stat +import sys +import tarfile +import zipfile + +source, output, root, binary_name, platform = sys.argv[1:] +data = pathlib.Path(source).read_bytes() +if platform == "windows-amd64": + info = zipfile.ZipInfo(f"{root}/{binary_name}", (1980, 1, 1, 0, 0, 0)) + info.compress_type = zipfile.ZIP_DEFLATED + info.external_attr = (stat.S_IFREG | 0o755) << 16 + with zipfile.ZipFile(output, "w") as archive: + archive.writestr(info, data) +else: + tar_bytes = io.BytesIO() + with tarfile.open(fileobj=tar_bytes, mode="w", format=tarfile.GNU_FORMAT) as archive: + directory = tarfile.TarInfo(root) + directory.type = tarfile.DIRTYPE + directory.mode = 0o755 + directory.uid = directory.gid = directory.mtime = 0 + directory.uname = directory.gname = "" + archive.addfile(directory) + entry = tarfile.TarInfo(f"{root}/{binary_name}") + entry.size = len(data) + entry.mode = 0o755 + entry.uid = entry.gid = entry.mtime = 0 + entry.uname = entry.gname = "" + archive.addfile(entry, io.BytesIO(data)) + with open(output, "wb") as raw: + with gzip.GzipFile(filename="", mode="wb", fileobj=raw, mtime=0) as compressed: + compressed.write(tar_bytes.getvalue()) +PY +[[ -s $RELEASE_OUTPUT_DIR/$artifact_name ]] || fail "The native archive was not created." +printf '%s\n' "$artifact_name" diff --git a/.github/scripts/temporal-release/prepare-maven-payload.sh b/.github/scripts/temporal-release/prepare-maven-payload.sh new file mode 100755 index 0000000000..304c0f4160 --- /dev/null +++ b/.github/scripts/temporal-release/prepare-maven-payload.sh @@ -0,0 +1,135 @@ +#!/usr/bin/env bash + +set -euo pipefail + +fail() { echo "prepare-maven-payload: $*" >&2; exit 1; } +conflict() { echo "prepare-maven-payload: immutable payload conflict: $*" >&2; exit 42; } + +required=( + JAR_SIGNING_KEY JAR_SIGNING_KEY_ID JAR_SIGNING_KEY_PASSWORD MAVEN_ARTIFACTS_FILE + MAVEN_PAYLOAD_COMMIT MAVEN_PAYLOAD_OUTPUT MAVEN_PAYLOAD_RELEASE_DIGEST MAVEN_PAYLOAD_VERSION + TRUSTED_AUTOMATION_COMMIT TRUSTED_AUTOMATION_ROOT +) +for variable in "${required[@]}"; do + [[ -n ${!variable:-} ]] || fail "Required value $variable is missing." +done +[[ $MAVEN_PAYLOAD_COMMIT =~ ^[0-9a-f]{40}$ && + $MAVEN_PAYLOAD_RELEASE_DIGEST =~ ^[0-9a-f]{64}$ && + $MAVEN_PAYLOAD_VERSION =~ ^[0-9]+\.[0-9]+\.[0-9]+(-RC[0-9]+)?$ ]] || + conflict "the immutable Maven identity is invalid." +[[ $(git rev-parse --verify HEAD^{commit}) == "$MAVEN_PAYLOAD_COMMIT" ]] || + conflict "the source checkout changed." +[[ $(git -C "$TRUSTED_AUTOMATION_ROOT" rev-parse --verify HEAD^{commit}) == \ + "$TRUSTED_AUTOMATION_COMMIT" ]] || conflict "the trusted automation checkout changed." +[[ -z $(find "$MAVEN_PAYLOAD_OUTPUT" -mindepth 1 -print -quit 2>/dev/null) ]] || + fail "The Maven payload output directory is not empty." + +work=$(mktemp -d) +versioning_backup=$work/versioning.gradle +publishing_backup=$work/publishing.gradle +build_backup=$work/build.gradle +cp gradle/versioning.gradle "$versioning_backup" +cp gradle/publishing.gradle "$publishing_backup" +cp build.gradle "$build_backup" +restore() { + cp "$versioning_backup" gradle/versioning.gradle + cp "$publishing_backup" gradle/publishing.gradle + cp "$build_backup" build.gradle + rm -rf "$work" +} +trap restore EXIT +cp "$TRUSTED_AUTOMATION_ROOT/gradle/versioning.gradle" gradle/versioning.gradle +cp "$TRUSTED_AUTOMATION_ROOT/gradle/publishing.gradle" gradle/publishing.gradle +python3 - build.gradle <<'PY' || conflict "the trusted Gradle hooks do not match sdk-java." +import pathlib, re, sys +path = pathlib.Path(sys.argv[1]) +source = path.read_text() +matches = list(re.finditer(r"id ['\"]io\.github\.gradle-nexus\.publish-plugin['\"] version ['\"][^'\"]+['\"]", source)) +if len(matches) != 1: + raise SystemExit("Expected exactly one Gradle Nexus publish plugin declaration") +source = source[:matches[0].start()] + "id 'io.github.gradle-nexus.publish-plugin' version '1.3.0'" + source[matches[0].end():] +path.write_text(source) +PY + +generated=$work/generated +bundle=$work/bundle +repository=$bundle/repository +manifest=$bundle/manifest.tsv +mkdir -p "$generated" "$repository/io/temporal" "$MAVEN_PAYLOAD_OUTPUT" +MAVEN_PAYLOAD_OUTPUT=$generated \ + "$TRUSTED_AUTOMATION_ROOT/.github/scripts/temporal-release/build-and-sign-maven-payload.sh" +mapfile -t artifacts < <(jq -er '.[]' "$MAVEN_ARTIFACTS_FILE") +[[ ${#artifacts[@]} -gt 0 ]] || conflict "the Maven policy is empty." +for artifact in "${artifacts[@]}"; do + source_dir=$generated/io/temporal/$artifact/$MAVEN_PAYLOAD_VERSION + [[ -d $source_dir ]] || conflict "Gradle did not generate $artifact." + mkdir -p "$repository/io/temporal/$artifact" + cp -R "$source_dir" "$repository/io/temporal/$artifact/$MAVEN_PAYLOAD_VERSION" +done +: >"$manifest" +while IFS= read -r -d '' payload; do + relative=${payload#"$repository/"} + printf '%s\t%s\t%s\n' "$relative" "$(sha256sum "$payload" | awk '{print $1}')" \ + "$(wc -c <"$payload" | tr -d ' ')" >>"$manifest" +done < <(find "$repository/io/temporal" -type f -print0 | sort -z) +printf '%s\n' "${artifacts[@]}" >"$work/approved-artifacts.txt" +python3 - "$repository" "$manifest" "$work/approved-artifacts.txt" \ + "$MAVEN_PAYLOAD_VERSION" "$MAVEN_PAYLOAD_COMMIT" <<'PY' || + conflict "the frozen Maven payload violates sdk-java policy." +import hashlib, pathlib, re, sys, xml.etree.ElementTree as ET +root = pathlib.Path(sys.argv[1]).resolve() +manifest = pathlib.Path(sys.argv[2]) +approved = set(pathlib.Path(sys.argv[3]).read_text().splitlines()) +version, commit = sys.argv[4:] +records = [] +for line in manifest.read_text().splitlines(): + relative, sha, size = line.split("\t") + parts = pathlib.PurePosixPath(relative).parts + if len(parts) != 5 or parts[:2] != ("io", "temporal"): + raise SystemExit("payload path is outside fixed Maven coordinates") + artifact, found_version, filename = parts[2:] + if artifact not in approved or found_version != version: + raise SystemExit("payload contains an unapproved Maven coordinate") + escaped = re.escape(f"{artifact}-{version}") + pattern = escaped + r"(?:-(?:sources|javadoc))?\.(?:jar|pom|module)(?:\.(?:asc|md5|sha1))?" + if not re.fullmatch(pattern, filename): + raise SystemExit("payload contains an unapproved Maven filename") + path = (root / relative).resolve() + data = path.read_bytes() + if root not in path.parents or not path.is_file() or path.is_symlink(): + raise SystemExit("payload path is not a regular file") + if hashlib.sha256(data).hexdigest() != sha or len(data) != int(size): + raise SystemExit("payload manifest checksum or size differs") + records.append(relative) +if records != sorted(set(records)) or not records: + raise SystemExit("payload manifest is empty, duplicated, or unsorted") +actual = sorted(str(path.relative_to(root)).replace("\\", "/") + for path in (root / "io" / "temporal").rglob("*") if path.is_file()) +if actual != records: + raise SystemExit("payload archive and manifest contain different file sets") +for artifact in approved: + directory = root / "io" / "temporal" / artifact / version + pom = directory / f"{artifact}-{version}.pom" + bases = {f"{artifact}-{version}.pom", f"{artifact}-{version}.module"} + if artifact != "temporal-bom": + bases.update({f"{artifact}-{version}.jar", f"{artifact}-{version}-sources.jar", + f"{artifact}-{version}-javadoc.jar"}) + expected = set(bases) + for base in bases: + expected.update({base + ".asc", base + ".md5", base + ".sha1"}) + if {path.name for path in directory.iterdir() if path.is_file()} != expected: + raise SystemExit(f"Maven payload file set differs for {artifact}") + document = ET.parse(pom).getroot() + ns = document.tag.partition("}")[0] + "}" if document.tag.startswith("{") else "" + identity = (document.findtext(f"{ns}groupId", "").strip(), + document.findtext(f"{ns}artifactId", "").strip(), + document.findtext(f"{ns}version", "").strip(), + document.findtext(f"{ns}scm/{ns}tag", "").strip().lower()) + if identity != ("io.temporal", artifact, version, commit): + raise SystemExit(f"generated POM identity differs for {artifact}") +PY +tar --sort=name --mtime='UTC 1970-01-01' --owner=0 --group=0 --numeric-owner \ + -cf "$MAVEN_PAYLOAD_OUTPUT/maven-payload.tar" -C "$bundle" manifest.tsv repository +[[ -s $MAVEN_PAYLOAD_OUTPUT/maven-payload.tar ]] || fail "The Maven payload archive is empty." +restore +trap - EXIT diff --git a/.github/scripts/temporal-release/reconcile-publication.sh b/.github/scripts/temporal-release/reconcile-publication.sh new file mode 100755 index 0000000000..11fbbf3f97 --- /dev/null +++ b/.github/scripts/temporal-release/reconcile-publication.sh @@ -0,0 +1,749 @@ +#!/usr/bin/env bash + +set -euo pipefail + +fail() { echo "reconcile-publication: $*" >&2; exit 1; } +conflict() { echo "reconcile-publication: immutable release conflict: $*" >&2; exit 42; } +invalid_approval() { echo "reconcile-publication: invalid approval: $*" >&2; exit 43; } +maven_ambiguous() { echo "reconcile-publication: ambiguous Maven submission: $*" >&2; exit 44; } + +required=( + GH_TOKEN RELEASE_INPUT_FILE RELEASE_MAVEN_ARTIFACTS_FILE RELEASE_OUTPUT_FILE RELEASE_STAGE + TRUSTED_AUTOMATION_ROOT TRUSTED_WORKER_COMMIT +) +if [[ $RELEASE_STAGE == maven-* || $RELEASE_STAGE == inspect ]]; then + required+=(RH_PASSWORD RH_USER) +fi +for variable in "${required[@]}"; do + [[ -n ${!variable:-} ]] || fail "Required value $variable is missing." +done + +repository=temporalio/sdk-java +maven_group=io.temporal +central_base=https://repo1.maven.org/maven2 +tag=$(jq -er .release.candidate.tag "$RELEASE_INPUT_FILE") +version=${tag#v} +commit=$(jq -er .release.candidate.commitSha "$RELEASE_INPUT_FILE") +notes_file=releases/$tag +notes_hash=$(jq -er .release.candidate.releaseNotesSha256 "$RELEASE_INPUT_FILE") +trusted_commit=$(jq -er .release.candidate.trustedAutomationCommit "$RELEASE_INPUT_FILE") +maven_policy=$(jq -er .release.candidate.mavenPolicy "$RELEASE_INPUT_FILE") +manifest_hash=$(jq -er .release.manifestSha256 "$RELEASE_INPUT_FILE") +release_digest=$(jq -er .approval.releaseDigest "$RELEASE_INPUT_FILE") +workflow_id=$(jq -er .workflowId "$RELEASE_INPUT_FILE") +run_id=$(jq -er .runId "$RELEASE_INPUT_FILE") +submission_generation=$(jq -er .mavenSubmissionGeneration "$RELEASE_INPUT_FILE") + +[[ $tag =~ ^v[0-9]+\.[0-9]+\.[0-9]+(-RC[0-9]+)?$ && + $commit =~ ^[0-9a-f]{40}$ && $notes_hash =~ ^[0-9a-f]{64}$ && + $manifest_hash =~ ^[0-9a-f]{64}$ && $release_digest =~ ^[0-9a-f]{64}$ && + $submission_generation =~ ^[0-9]+$ && $trusted_commit == "$TRUSTED_WORKER_COMMIT" ]] || + conflict "the Activity input violates sdk-java release policy." +[[ $(git rev-parse --verify HEAD^{commit}) == "$commit" ]] || + conflict "the source checkout is not the approved commit." +[[ -s $notes_file && ! -L $notes_file ]] || conflict "the release notes are unavailable." +[[ $(sha256sum "$notes_file" | awk '{print $1}') == "$notes_hash" ]] || + conflict "the release notes changed." + +work=$(mktemp -d) +trap 'rm -rf "$work"' EXIT +mkdir -p "$work/assets" "$work/existing" +mapfile -t maven_artifacts < <(jq -er '.[]' "$RELEASE_MAVEN_ARTIFACTS_FILE") + +verify_source_maven_policy() { + mapfile -t source_projects < <(sed -n -E "s/^include ['\"]([^'\"]+)['\"]$/\1/p" settings.gradle | sort) + mapfile -t policy_projects < <(printf '%s\n' "${maven_artifacts[@]}" | sort) + [[ ${source_projects[*]} == "${policy_projects[*]}" ]] || + conflict "the immutable source projects differ from the Maven policy." + case "$maven_policy:${#policy_projects[@]}" in + current:17 | classic:11 | classic-alpha:11 | classic-alpha-lite:9) ;; + *) conflict "the Maven policy is not a reviewed sdk-java profile." ;; + esac +} + +verify_approval() { + local approval_run approval_actor approval_run_id issue issue_number issue_creator body_hash status + approval_actor=$(jq -er .approval.githubActor "$RELEASE_INPUT_FILE") + approval_run_id=$(jq -er .approval.githubApprovalRunId "$RELEASE_INPUT_FILE") + issue_number=$(jq -er .approval.githubIssueNumber "$RELEASE_INPUT_FILE") + issue_creator=$(jq -er .approvalRequest.githubIssueCreator "$RELEASE_INPUT_FILE") + approval_run=$(gh api "repos/temporalio/sdk-java/actions/runs/$approval_run_id") || + fail "The approval Actions run is temporarily unavailable." + jq -e --arg actor "$approval_actor" ' + (.status == "in_progress" or .status == "completed") and + .path == ".github/workflows/temporal-release-approve.yml" and + ((.event == "issues" and .actor.login == $actor) or .event == "schedule")' \ + <<<"$approval_run" >/dev/null || invalid_approval "the GitHub approval run differs." + issue=$(gh api "repos/temporalio/sdk-java/issues/$issue_number") || + fail "The exact approval issue is temporarily unavailable." + body_hash=$(jq -j .body <<<"$issue" | sha256sum | awk '{print $1}') + jq -e --arg actor "$approval_actor" --arg creator "$issue_creator" \ + --arg node "$(jq -er .approval.githubIssueNodeId "$RELEASE_INPUT_FILE")" \ + --argjson number "$issue_number" ' + .number == $number and .node_id == $node and .state == "closed" and .locked == true and + .closed_by.login == $actor and .user.login == $creator' <<<"$issue" >/dev/null || + invalid_approval "the locked approval issue differs." + [[ $body_hash == $(jq -er .approval.githubIssueBodySha256 "$RELEASE_INPUT_FILE") ]] || + invalid_approval "the approval issue body changed." + set +e + "$TRUSTED_AUTOMATION_ROOT/.github/scripts/temporal-release/verify-approver.sh" \ + "$approval_actor" >/dev/null + status=$? + set -e + [[ $status -eq 0 ]] || { + [[ $status -eq 43 ]] && invalid_approval "the approver is not an active sdk team member." + fail "Release-manager membership is temporarily unavailable." + } +} + +download_receipt() { + local receipt=$1 destination=$2 + GH_TOKEN=$GH_TOKEN GITHUB_ARTIFACT_RECEIPT_FILE=$receipt \ + GITHUB_ARTIFACT_DESTINATION=$destination \ + "$TRUSTED_AUTOMATION_ROOT/.github/scripts/temporal-release/download-github-artifact.sh" +} + +verify_artifact_origin() { + local receipt=$1 expected_path=$2 run_id run + run_id=$(jq -er .workflowRunId "$receipt") + run=$(gh api "repos/temporalio/sdk-java/actions/runs/$run_id") || + fail "The originating GitHub Actions run is temporarily unavailable." + jq -e --argjson id "$run_id" --arg path "$expected_path" ' + .id == $id and .path == $path and .head_branch == "main" and + .head_repository.full_name == "temporalio/sdk-java" and + (.status == "in_progress" or .status == "completed") and + (if $path == ".github/workflows/temporal-release-resume.yml" + then (.event == "schedule" or .event == "workflow_dispatch") + else (.event == "workflow_run" or .event == "schedule" or .event == "workflow_dispatch") + end)' <<<"$run" >/dev/null || conflict "the artifact originated from another workflow run." +} + +materialize_native_assets() { + local count index receipt + count=$(jq '.release.manifest.artifacts | length' "$RELEASE_INPUT_FILE") + [[ $count -eq 6 ]] || conflict "the native artifact receipt set is incomplete." + for ((index = 0; index < count; index++)); do + receipt=$work/native-$index.json + jq ".release.manifest.artifacts[$index]" "$RELEASE_INPUT_FILE" >"$receipt" + verify_artifact_origin "$receipt" .github/workflows/temporal-release-resume.yml + mkdir "$work/native-$index" + download_receipt "$receipt" "$work/native-$index" + find "$work/native-$index" -mindepth 1 -maxdepth 1 -type f -exec cp {} "$work/assets/" \; + done + [[ $(find "$work/assets" -mindepth 1 -maxdepth 1 -type f | wc -l | tr -d ' ') -eq 6 ]] || + conflict "the downloaded native asset set differs." + (cd "$work/assets" && sha256sum *.tar.gz *.zip | sort -k2) >"$work/assets/SHA256SUMS" +} + +materialize_maven_payload() { + local receipt=$work/maven-receipt.json archive=$work/maven-download/maven-payload.tar + local bundle=$work/maven-bundle + jq .mavenPayload "$RELEASE_INPUT_FILE" >"$receipt" + verify_artifact_origin "$receipt" .github/workflows/temporal-release-publish.yml + mkdir "$work/maven-download" + download_receipt "$receipt" "$work/maven-download" + [[ -s $archive ]] || conflict "the exact Maven payload archive is absent." + mkdir "$bundle" + python3 - "$archive" "$bundle" <<'PY' || conflict "the Maven archive is unsafe." +import pathlib, sys, tarfile + +archive_path, output_path = sys.argv[1:] +output = pathlib.Path(output_path).resolve() +seen = set() +with tarfile.open(archive_path, "r:") as archive: + for member in archive: + name = member.name.rstrip("/") + path = pathlib.PurePosixPath(name) + if (not name or path.is_absolute() or ".." in path.parts or name in seen or + not (name == "manifest.tsv" or name == "repository" or + name == "repository/io" or name == "repository/io/temporal" or + name.startswith("repository/io/temporal/"))): + raise SystemExit("unexpected archive path") + seen.add(name) + target = output.joinpath(*path.parts) + if member.isdir(): + target.mkdir(parents=True, exist_ok=True) + elif member.isfile(): + target.parent.mkdir(parents=True, exist_ok=True) + source = archive.extractfile(member) + if source is None: + raise SystemExit("missing archive file data") + with target.open("xb") as destination: + destination.write(source.read()) + else: + raise SystemExit("archive links and special files are forbidden") +PY + payload_root=$bundle/repository + payload_manifest=$bundle/manifest.tsv + [[ -s $payload_manifest && -d $payload_root/io/temporal ]] || + conflict "the Maven bundle is incomplete." + printf '%s\n' "${maven_artifacts[@]}" >"$work/approved-maven-artifacts.txt" + python3 - "$payload_root" "$payload_manifest" "$work/approved-maven-artifacts.txt" \ + "$version" "$commit" <<'PY' || conflict "the Maven bundle violates sdk-java policy." +import hashlib, pathlib, re, sys, xml.etree.ElementTree as ET +root = pathlib.Path(sys.argv[1]).resolve() +manifest = pathlib.Path(sys.argv[2]) +approved = set(pathlib.Path(sys.argv[3]).read_text().splitlines()) +version, commit = sys.argv[4:] +records = [] +for line in manifest.read_text().splitlines(): + relative, sha, size = line.split("\t") + parts = pathlib.PurePosixPath(relative).parts + if len(parts) != 5 or parts[:2] != ("io", "temporal"): + raise SystemExit("path outside Maven policy") + artifact, found_version, filename = parts[2:] + pattern = re.escape(f"{artifact}-{version}") + r"(?:-(?:sources|javadoc))?\.(?:jar|pom|module)(?:\.(?:asc|md5|sha1))?" + if artifact not in approved or found_version != version or not re.fullmatch(pattern, filename): + raise SystemExit("coordinate outside Maven policy") + path = (root / relative).resolve() + if root not in path.parents or not path.is_file() or path.is_symlink(): + raise SystemExit("invalid Maven file") + data = path.read_bytes() + if hashlib.sha256(data).hexdigest() != sha or len(data) != int(size): + raise SystemExit("Maven checksum differs") + records.append(relative) +if records != sorted(set(records)): + raise SystemExit("Maven manifest is unsorted or duplicated") +actual = sorted(str(path.relative_to(root)).replace("\\", "/") for path in root.rglob("*") if path.is_file()) +if actual != records: + raise SystemExit("Maven file set differs") +for artifact in approved: + pom = root / "io" / "temporal" / artifact / version / f"{artifact}-{version}.pom" + document = ET.parse(pom).getroot() + ns = document.tag.partition("}")[0] + "}" if document.tag.startswith("{") else "" + identity = (document.findtext(f"{ns}groupId", "").strip(), + document.findtext(f"{ns}artifactId", "").strip(), + document.findtext(f"{ns}version", "").strip(), + document.findtext(f"{ns}scm/{ns}tag", "").strip().lower()) + if identity != ("io.temporal", artifact, version, commit): + raise SystemExit("Maven POM identity differs") +PY +} + +central_state() { + present=0 + missing=0 + verify_source_maven_policy + for artifact in "${maven_artifacts[@]}"; do + pom=$work/$artifact.pom + status=$(curl --silent --show-error --location --output "$pom" --write-out '%{http_code}' \ + "$central_base/io/temporal/$artifact/$version/$artifact-$version.pom") || + fail "Maven Central is temporarily unavailable." + case $status in + 200) + identity=$(python3 - "$pom" <<'PY' +import sys, xml.etree.ElementTree as ET +root = ET.parse(sys.argv[1]).getroot() +ns = root.tag.partition("}")[0] + "}" if root.tag.startswith("{") else "" +print("\t".join((root.findtext(f"{ns}groupId", "").strip(), + root.findtext(f"{ns}artifactId", "").strip(), + root.findtext(f"{ns}version", "").strip(), + root.findtext(f"{ns}scm/{ns}tag", "").strip().lower()))) +PY +) + [[ $identity == "$maven_group"$'\t'"$artifact"$'\t'"$version"$'\t'"$commit" ]] || + conflict "$artifact exists with another immutable identity." + present=$((present + 1)) + ;; + 404) missing=$((missing + 1)) ;; + *) fail "Maven Central returned HTTP $status for $artifact." ;; + esac + done +} + +validate_central_payload() { + while IFS=$'\t' read -r relative sha size; do + file=$work/central-$(printf '%s' "$relative" | sha256sum | awk '{print $1}') + status=$(curl --silent --show-error --location --output "$file" --write-out '%{http_code}' \ + "$central_base/$relative") || fail "Maven Central payload is temporarily unavailable." + [[ $status == 200 ]] || fail "Maven Central returned HTTP $status for $relative." + [[ $(sha256sum "$file" | awk '{print $1}') == "$sha" && + $(wc -c <"$file" | tr -d ' ') == "$size" ]] || + conflict "Maven Central payload $relative differs." + done <"$payload_manifest" +} + +sonatype_snapshot() { + curl --silent --show-error --fail --user "$RH_USER:$RH_PASSWORD" \ + --header 'Accept: application/json' \ + https://ossrh-staging-api.central.sonatype.com/service/local/staging/profile_repositories \ + >"$work/profile-repositories.json" || fail "Sonatype repositories are unavailable." + jq -e '((.data // .profileRepositories) | type == "array")' \ + "$work/profile-repositories.json" >/dev/null || fail "Sonatype repository data is invalid." + portal_token=$(printf '%s:%s' "$RH_USER" "$RH_PASSWORD" | base64 | tr -d '\n') + curl --silent --show-error --fail --header "Authorization: Bearer $portal_token" \ + --header 'Accept: application/json' \ + 'https://ossrh-staging-api.central.sonatype.com/manual/search/repositories?ip=any&profile_id=io.temporal' \ + >"$work/manual-repositories.json" || fail "Publisher Portal state is unavailable." + jq -e '(.repositories // []) | type == "array"' "$work/manual-repositories.json" >/dev/null || + fail "Publisher Portal repository data is invalid." +} + +generation_state() { + jq -cer --argjson generation "$1" \ + '[.mavenGenerations[] | select(.generation == $generation)] | + if length == 1 then .[0] else error("missing Maven generation") end' \ + "$RELEASE_INPUT_FILE" +} + +find_repository_by_description() { + local description=$1 + jq -rn --arg description "$description" --slurpfile profiles "$work/profile-repositories.json" \ + --slurpfile manual "$work/manual-repositories.json" ' + [($profiles[0].data // $profiles[0].profileRepositories // [])[] | + select(.description == $description) | .repositoryId // .id] + + [($manual[0].repositories // [])[] | select(.description == $description) | .key] | + unique | if length <= 1 then .[0] // "" else error("multiple exact repositories") end' +} + +portal_status() { + local deployment_id=$1 + curl --silent --show-error --fail --request POST \ + --header "Authorization: Bearer $portal_token" --header 'Accept: application/json' \ + "https://central.sonatype.com/api/v1/publisher/status?id=$deployment_id" \ + >"$work/portal-status.json" || fail "Publisher Portal deployment is unavailable." + jq -er --arg id "$deployment_id" 'select(.deploymentId == $id) | .deploymentState' \ + "$work/portal-status.json" +} + +validate_retry_authorization() { + (( submission_generation > 0 )) || return + local actor run run_json status + actor=$(jq -er .mavenRetryAuthorization.githubActor "$RELEASE_INPUT_FILE") + run=$(jq -er .mavenRetryAuthorization.githubRunId "$RELEASE_INPUT_FILE") + jq -e --argjson generation "$submission_generation" ' + .mavenRetryAuthorization.action == "retry-maven-submission" and + .mavenRetryAuthorization.mavenSubmissionGeneration == $generation and + (.mavenRetryAuthorization.authorizationSha256 | test("^[0-9a-f]{64}$")) and + .mavenRetryAuthorization.mavenInspection != null' "$RELEASE_INPUT_FILE" >/dev/null || + conflict "the Maven retry authorization differs." + run_json=$(gh api "repos/temporalio/sdk-java/actions/runs/$run") || + fail "The Maven retry authorization run is unavailable." + jq -e --arg actor "$actor" ' + .event == "workflow_dispatch" and .path == ".github/workflows/temporal-release-control.yml" and + .actor.login == $actor and (.status == "in_progress" or .status == "completed")' \ + <<<"$run_json" >/dev/null || invalid_approval "the Maven retry run differs." + set +e + "$TRUSTED_AUTOMATION_ROOT/.github/scripts/temporal-release/verify-approver.sh" "$actor" >/dev/null + status=$? + set -e + [[ $status -eq 0 ]] || { + [[ $status -eq 43 ]] && invalid_approval "the Maven retry authorizer is not active." + fail "Maven retry authorizer membership is unavailable." + } +} + +validate_prior_generations_inactive() { + (( submission_generation > 0 )) || return + local row generation description repository_id discovered_id portal_id state profile_count manual_count + while IFS= read -r row; do + generation=$(jq -er .generation <<<"$row") + (( generation < submission_generation )) || continue + description=$(jq -er .description <<<"$row") + repository_id=$(jq -r '.sonatypeRepositoryId // ""' <<<"$row") + discovered_id=$(find_repository_by_description "$description") || + conflict "multiple repositories match earlier Maven generation $generation." + [[ -z $repository_id || -z $discovered_id || $repository_id == "$discovered_id" ]] || + conflict "earlier Maven generation $generation has another repository identity." + [[ -n $repository_id ]] || repository_id=$discovered_id + portal_id=$(jq -r '.portalDeploymentId // ""' <<<"$row") + if [[ -n $repository_id && -z $portal_id ]]; then + portal_id=$(jq -r --arg id "$repository_id" \ + '[.repositories[] | select(.key == $id) | .portal_deployment_id] | first // ""' \ + "$work/manual-repositories.json") + fi + state= + [[ -z $portal_id ]] || state=$(portal_status "$portal_id") + [[ -z $state || $state == FAILED ]] || + maven_ambiguous "earlier Maven generation $generation has Portal state $state." + if [[ -n $repository_id ]]; then + profile_count=$(jq --arg id "$repository_id" ' + [((.data // .profileRepositories // [])[]) | select((.repositoryId // .id) == $id)] | + length' "$work/profile-repositories.json") + manual_count=$(jq --arg id "$repository_id" \ + '[.repositories[] | select(.key == $id)] | length' "$work/manual-repositories.json") + (( profile_count == 0 )) || + maven_ambiguous "earlier Maven generation $generation is still staged." + if [[ $state == FAILED ]]; then + (( manual_count == 1 )) || + maven_ambiguous "failed Maven generation $generation has ambiguous repository state." + jq -e --arg id "$repository_id" --arg portal "$portal_id" ' + [.repositories[] | select(.key == $id)] | length == 1 and + .[0].state == "released" and .[0].portal_deployment_id == $portal' \ + "$work/manual-repositories.json" >/dev/null || + maven_ambiguous "failed Maven generation $generation is not inactive." + else + (( manual_count == 0 )) || + maven_ambiguous "earlier Maven generation $generation is still live." + fi + fi + done < <(jq -c '.mavenGenerations[] | select(.submissionStarted == true)' "$RELEASE_INPUT_FILE") +} + +inspect_staging_payload() { + : >"$work/remote-missing.tsv" + while IFS=$'\t' read -r relative sha size; do + remote=$work/remote-$(printf '%s' "$relative" | sha256sum | awk '{print $1}') + status=$(curl --silent --show-error --location --output "$remote" --write-out '%{http_code}' \ + --user "$RH_USER:$RH_PASSWORD" \ + "https://ossrh-staging-api.central.sonatype.com/service/local/repositories/$repository_id/content/$relative") || + fail "Unable to inspect staged Maven file $relative." + case $status in + 200) + [[ $(sha256sum "$remote" | awk '{print $1}') == "$sha" && + $(wc -c <"$remote" | tr -d ' ') == "$size" ]] || + conflict "staged Maven file $relative differs." + ;; + 404) printf '%s\t%s\t%s\n' "$relative" "$sha" "$size" >>"$work/remote-missing.tsv" ;; + *) fail "Sonatype returned HTTP $status for $relative." ;; + esac + done <"$payload_manifest" +} + +upload_missing_payload() { + while IFS=$'\t' read -r relative _ _; do + [[ -n $relative ]] || continue + curl --silent --show-error --fail --user "$RH_USER:$RH_PASSWORD" \ + --upload-file "$payload_root/$relative" \ + "https://ossrh-staging-api.central.sonatype.com/service/local/staging/deployByRepositoryId/$repository_id/$relative" \ + >/dev/null || fail "Unable to upload staged Maven file $relative." + done <"$work/remote-missing.tsv" +} + +verify_staging_file_set() { + : >"$work/staging-files.txt" + printf '\n' >"$work/staging-directories.txt" + while IFS= read -r directory; do + suffix=${directory:+$directory/} + listing=$work/listing-$(printf '%s' "$directory" | sha256sum | awk '{print $1}').json + curl --silent --show-error --fail --user "$RH_USER:$RH_PASSWORD" \ + --header 'Accept: application/json' \ + "https://ossrh-staging-api.central.sonatype.com/service/local/repositories/$repository_id/content/$suffix" \ + >"$listing" || fail "Unable to enumerate the staged repository." + jq -r --arg directory "$directory" ' + .data[] | select(.leaf == true) | .relativePath | ltrimstr("/") | + if contains("/") then . else ($directory + "/" + .) end' "$listing" | + sed 's#^/##' >>"$work/staging-files.txt" + while IFS= read -r child; do + child=${child#/} + [[ $child == */* || -z $directory ]] || child=$directory/$child + grep -Fxq "$child" "$work/staging-directories.txt" || + printf '%s\n' "$child" >>"$work/staging-directories.txt" + done < <(jq -r '.data[] | select(.leaf == false) | .relativePath' "$listing") + done <"$work/staging-directories.txt" + cut -f1 "$payload_manifest" | sort >"$work/expected-staging-files.txt" + sort -u "$work/staging-files.txt" -o "$work/staging-files.txt" + cmp "$work/expected-staging-files.txt" "$work/staging-files.txt" >/dev/null || + conflict "the staged Maven repository file set differs." +} + +reconcile_maven_repository() { + local state description stored_id external_id allow + central_state + [[ $present -eq 0 || $present -eq ${#maven_artifacts[@]} ]] || + fail "Maven publication is partially visible." + state=$(generation_state "$submission_generation") + description=sdk-java:$release_digest:$submission_generation + [[ $(jq -er .description <<<"$state") == "$description" && + $(jq -r .submissionStarted <<<"$state") == true ]] || + conflict "the durable Maven generation intent differs." + stored_id=$(jq -r '.sonatypeRepositoryId // ""' <<<"$state") + sonatype_snapshot + if [[ -n $stored_id ]]; then + jq -n --arg value "$stored_id" '$value' >"$RELEASE_OUTPUT_FILE" + return + fi + external_id=$(find_repository_by_description "$description") || + conflict "multiple Sonatype repositories match the generation." + if [[ -n $external_id ]]; then + jq -n --arg value "$external_id" '$value' >"$RELEASE_OUTPUT_FILE" + return + fi + [[ $present -eq 0 ]] || maven_ambiguous "Central is complete without a repository identity." + validate_prior_generations_inactive + allow=${RELEASE_ALLOW_MAVEN_REPOSITORY_CREATION:-false} + [[ $allow == true ]] || maven_ambiguous "the durable intent has no discoverable repository." + repository_id=$(SONATYPE_REPOSITORY_DESCRIPTION=$description \ + "$TRUSTED_AUTOMATION_ROOT/.github/scripts/temporal-release/create-sonatype-repository.sh") + [[ $repository_id =~ ^[A-Za-z0-9._-]+$ ]] || fail "Sonatype did not return a repository ID." + jq -n --arg value "$repository_id" '$value' >"$RELEASE_OUTPUT_FILE" +} + +reconcile_maven_portal() { + local state description repository_state portal_id close_status + materialize_maven_payload + state=$(generation_state "$submission_generation") + description=sdk-java:$release_digest:$submission_generation + repository_id=$(jq -er .sonatypeRepositoryId <<<"$state") + sonatype_snapshot + profile_description=$(jq -r --arg id "$repository_id" ' + [((.data // .profileRepositories // [])[]) | + select((.repositoryId // .id) == $id) | .description] | first // ""' \ + "$work/profile-repositories.json") + [[ -z $profile_description || $profile_description == "$description" ]] || + conflict "the Sonatype repository ID has another description." + repository_state=$(jq -r --arg id "$repository_id" \ + '[.repositories[] | select(.key == $id) | .state] | first // ""' \ + "$work/manual-repositories.json") + portal_id=$(jq -r --arg id "$repository_id" \ + '[.repositories[] | select(.key == $id) | .portal_deployment_id] | first // ""' \ + "$work/manual-repositories.json") + [[ -n $repository_state ]] || { + [[ -n $profile_description ]] || maven_ambiguous "the repository disappeared from Sonatype." + repository_state=open + } + if [[ $repository_state == open ]]; then + inspect_staging_payload + upload_missing_payload + inspect_staging_payload + [[ ! -s $work/remote-missing.tsv ]] || fail "The staged Maven payload is incomplete." + verify_staging_file_set + jq -n --arg id "$repository_id" --arg description "$description" \ + '{data:{stagedRepositoryIds:[$id],description:$description}}' >"$work/close.json" + close_status=$(curl --silent --show-error --output "$work/close-response" \ + --write-out '%{http_code}' --request POST --user "$RH_USER:$RH_PASSWORD" \ + --header 'Content-Type: application/json' --data-binary @"$work/close.json" \ + https://ossrh-staging-api.central.sonatype.com/service/local/staging/bulk/close) || + fail "Unable to close the Sonatype repository." + case $close_status in 200 | 201 | 202 | 204) ;; *) + fail "Sonatype returned HTTP $close_status while closing the repository." ;; esac + fail "Sonatype accepted the repository close; Temporal will reconcile again." + fi + [[ $repository_state == closed || $repository_state == released ]] || + conflict "Sonatype returned unsupported repository state $repository_state." + [[ $portal_id =~ ^[0-9a-fA-F-]{16,64}$ ]] || fail "The Portal deployment ID is not visible yet." + jq -n --arg value "$portal_id" '$value' >"$RELEASE_OUTPUT_FILE" +} + +publish_maven() { + local state deployment_state publish_status + materialize_maven_payload + state=$(generation_state "$submission_generation") + repository_id=$(jq -er .sonatypeRepositoryId <<<"$state") + portal_id=$(jq -er .portalDeploymentId <<<"$state") + sonatype_snapshot + deployment_state=$(portal_status "$portal_id") + case $deployment_state in + VALIDATED) + publish_status=$(curl --silent --show-error --output "$work/publish-response" \ + --write-out '%{http_code}' --request POST --header "Authorization: Bearer $portal_token" \ + "https://central.sonatype.com/api/v1/publisher/deployment/$portal_id") || + fail "Unable to publish the exact Portal deployment." + [[ $publish_status == 204 ]] || fail "Portal returned HTTP $publish_status while publishing." + fail "Portal accepted publication; Temporal will reconcile again." + ;; + PENDING | VALIDATING | PUBLISHING) fail "Portal deployment is $deployment_state." ;; + FAILED) echo "reconcile-publication: exact Portal deployment failed validation." >&2; exit 45 ;; + PUBLISHED) ;; + *) conflict "Portal returned unsupported deployment state $deployment_state." ;; + esac + central_state + [[ $missing -eq 0 && $present -eq ${#maven_artifacts[@]} ]] || + fail "The complete Maven release is not visible yet." + validate_central_payload + jq -n --arg mavenCentralUrl \ + "https://central.sonatype.com/artifact/io.temporal/temporal-sdk/$version" \ + --arg sonatypeRepositoryId "$repository_id" --arg portalDeploymentId "$portal_id" \ + '{mavenCentralUrl:$mavenCentralUrl,sonatypeRepositoryId:$sonatypeRepositoryId, + portalDeploymentId:$portalDeploymentId}' >"$RELEASE_OUTPUT_FILE" +} + +release_json() { + local releases + releases=$(gh api --paginate --slurp 'repos/temporalio/sdk-java/releases?per_page=100') || + fail "GitHub releases are temporarily unavailable." + jq -c --arg tag "$tag" '[.[][]] | map(select(.tag_name == $tag)) | first // empty' <<<"$releases" +} + +github_optional_get() { + local path=$1 output=$2 status + status=$(curl --silent --show-error --location --output "$output" --write-out '%{http_code}' \ + --header "Authorization: Bearer $GH_TOKEN" --header 'Accept: application/vnd.github+json' \ + --header 'X-GitHub-Api-Version: 2022-11-28' "https://api.github.com/$path") || + fail "GitHub is temporarily unavailable." + case $status in 200) return 0 ;; 404) : >"$output"; return 1 ;; *) + fail "GitHub returned HTTP $status while reading $path." ;; esac +} + +ensure_exact_tag() { + local file=$work/tag.json + if github_optional_get "repos/temporalio/sdk-java/git/ref/tags/$tag" "$file"; then + [[ $(jq -r .object.type "$file") == commit && $(jq -r .object.sha "$file") == "$commit" ]] || + conflict "the Git tag points at another object." + return + fi + gh api --method POST repos/temporalio/sdk-java/git/refs \ + --raw-field ref="refs/tags/$tag" --raw-field sha="$commit" >/dev/null || { + github_optional_get "repos/temporalio/sdk-java/git/ref/tags/$tag" "$file" || + fail "The exact Git tag could not be reconciled." + [[ $(jq -r .object.type "$file") == commit && $(jq -r .object.sha "$file") == "$commit" ]] || + conflict "the concurrently created tag differs." + } +} + +verify_release_metadata() { + local release=$1 draft=$2 prerelease=false + [[ $tag == *-RC* ]] && prerelease=true + jq -e --arg tag "$tag" --arg commit "$commit" --rawfile notes "$notes_file" \ + --argjson prerelease "$prerelease" --argjson draft "$draft" ' + .tag_name == $tag and .name == $tag and .body == $notes and .draft == $draft and + .prerelease == $prerelease and .target_commitish == $commit' <<<"$release" >/dev/null || + conflict "GitHub release metadata differs." +} + +verify_exact_github_assets() { + local release=$1 name state size expected + mapfile -t expected < <(find "$work/assets" -mindepth 1 -maxdepth 1 -type f -exec basename {} \; | sort) + mapfile -t actual < <(jq -r '.assets[].name' <<<"$release" | sort) + [[ ${actual[*]} == "${expected[*]}" ]] || fail "The GitHub asset set is not complete." + while IFS=$'\t' read -r name state size; do + [[ $state == uploaded ]] || conflict "GitHub asset $name has unsupported state $state." + [[ $size == $(wc -c <"$work/assets/$name" | tr -d ' ') ]] || + conflict "GitHub asset $name has the wrong size." + gh release download "$tag" --repo temporalio/sdk-java --pattern "$name" \ + --dir "$work/existing" --clobber >/dev/null || fail "Unable to download asset $name." + cmp "$work/assets/$name" "$work/existing/$name" >/dev/null || + conflict "GitHub asset $name differs." + done < <(jq -r '.assets[] | [.name,.state,.size] | @tsv' <<<"$release") +} + +verify_github_preflight() { + local release draft remote + if github_optional_get "repos/temporalio/sdk-java/git/ref/tags/$tag" "$work/preflight-tag.json"; then + [[ $(jq -r .object.type "$work/preflight-tag.json") == commit && + $(jq -r .object.sha "$work/preflight-tag.json") == "$commit" ]] || + conflict "the existing tag points at another object." + fi + release=$(release_json) + [[ -n $release ]] || return + draft=$(jq -r .draft <<<"$release") + verify_release_metadata "$release" "$draft" + while IFS= read -r remote; do + [[ -f $work/assets/$remote ]] || conflict "the release has unexpected asset $remote." + done < <(jq -r '.assets[].name' <<<"$release") + [[ $draft == true ]] || verify_exact_github_assets "$release" +} + +reconcile_github_draft() { + local release draft name state size id + materialize_native_assets + ensure_exact_tag + release=$(release_json) + if [[ -z $release ]]; then + args=(release create "$tag" --repo temporalio/sdk-java --draft --target "$commit" \ + --title "$tag" --notes-file "$notes_file") + [[ $tag == *-RC* ]] && args+=(--prerelease) + gh "${args[@]}" >/dev/null + release=$(release_json) + fi + [[ -n $release ]] || fail "The GitHub draft is not visible yet." + draft=$(jq -r .draft <<<"$release") + verify_release_metadata "$release" "$draft" + while IFS=$'\t' read -r id name state size; do + [[ -f $work/assets/$name ]] || conflict "the release has unexpected asset $name." + if [[ $draft == true && $state == starter && $size == 0 ]]; then + gh api --method DELETE "repos/temporalio/sdk-java/releases/assets/$id" >/dev/null + elif [[ $state != uploaded ]]; then + conflict "GitHub asset $name has unsupported state $state." + fi + done < <(jq -r '.assets[] | [.id,.name,.state,.size] | @tsv' <<<"$release") + release=$(release_json) + for asset in "$work/assets"/*; do + name=$(basename "$asset") + if jq -e --arg name "$name" '.assets[] | select(.name == $name)' <<<"$release" >/dev/null; then + gh release download "$tag" --repo temporalio/sdk-java --pattern "$name" \ + --dir "$work/existing" --clobber >/dev/null + cmp "$asset" "$work/existing/$name" >/dev/null || conflict "GitHub asset $name differs." + else + [[ $draft == true ]] || conflict "the public release is missing asset $name." + gh release upload "$tag" "$asset" --repo temporalio/sdk-java >/dev/null + fi + done + release=$(release_json) + verify_exact_github_assets "$release" + jq -n --arg value "$(jq -er .html_url <<<"$release")" '$value' >"$RELEASE_OUTPUT_FILE" +} + +publish_github() { + local release + materialize_maven_payload + central_state + [[ $missing -eq 0 && $present -eq ${#maven_artifacts[@]} ]] || + fail "Maven Central is incomplete immediately before GitHub publication." + validate_central_payload + reconcile_github_draft + release=$(release_json) + if [[ $(jq -r .draft <<<"$release") == true ]]; then + ensure_exact_tag + verify_exact_github_assets "$release" + gh release edit "$tag" --repo temporalio/sdk-java --draft=false >/dev/null + fi + release=$(release_json) + verify_release_metadata "$release" false + verify_exact_github_assets "$release" + jq -n --arg releaseDigest "$release_digest" --arg githubReleaseUrl "$(jq -er .html_url <<<"$release")" \ + --arg mavenCentralUrl "https://central.sonatype.com/artifact/io.temporal/temporal-sdk/$version" \ + '{releaseDigest:$releaseDigest,githubReleaseUrl:$githubReleaseUrl, + mavenCentralUrl:$mavenCentralUrl}' >"$RELEASE_OUTPUT_FILE" +} + +inspect_maven() { + local row generation description repository_id repository_state portal_id portal_state profile + central_state + sonatype_snapshot + : >"$work/inspections.jsonl" + while IFS= read -r row; do + generation=$(jq -er .generation <<<"$row") + description=$(jq -er .description <<<"$row") + repository_id=$(jq -r '.sonatypeRepositoryId // ""' <<<"$row") + if [[ -z $repository_id ]]; then + repository_id=$(find_repository_by_description "$description") || + conflict "multiple repositories match Maven generation $generation." + fi + repository_state=absent + portal_id=$(jq -r '.portalDeploymentId // ""' <<<"$row") + portal_state="" + if [[ -n $repository_id ]]; then + repository_state=$(jq -r --arg id "$repository_id" \ + '[.repositories[] | select(.key == $id) | .state] | first // ""' \ + "$work/manual-repositories.json") + profile=$(jq -r --arg id "$repository_id" ' + [((.data // .profileRepositories // [])[]) | + select((.repositoryId // .id) == $id)] | first // empty' \ + "$work/profile-repositories.json") + [[ -n $repository_state ]] || repository_state=$([[ -n $profile ]] && echo open || echo absent) + [[ -n $portal_id ]] || portal_id=$(jq -r --arg id "$repository_id" \ + '[.repositories[] | select(.key == $id) | .portal_deployment_id] | first // ""' \ + "$work/manual-repositories.json") + fi + [[ -z $portal_id ]] || portal_state=$(portal_status "$portal_id") + jq -cn --argjson generation "$generation" --arg description "$description" \ + --arg repositoryId "$repository_id" --arg repositoryState "$repository_state" \ + --arg portalDeploymentId "$portal_id" --arg portalDeploymentState "$portal_state" \ + '{generation:$generation,description:$description,repositoryId:$repositoryId, + repositoryState:$repositoryState,portalDeploymentId:$portalDeploymentId, + portalDeploymentState:$portalDeploymentState}' >>"$work/inspections.jsonl" + done < <(jq -c '.mavenGenerations[]' "$RELEASE_INPUT_FILE") + jq -s --argjson centralPresent "$present" --argjson centralMissing "$missing" \ + '{centralPresent:$centralPresent,centralMissing:$centralMissing,generations:.}' \ + "$work/inspections.jsonl" >"$RELEASE_OUTPUT_FILE" +} + +if [[ $RELEASE_STAGE != inspect ]]; then + verify_approval + validate_retry_authorization +fi +case $RELEASE_STAGE in + inspect) inspect_maven ;; + preflight) + materialize_native_assets + materialize_maven_payload + verify_github_preflight + ;; + maven-repository) reconcile_maven_repository ;; + maven-portal) reconcile_maven_portal ;; + maven-publish) publish_maven ;; + github-draft) reconcile_github_draft ;; + github-publish) publish_github ;; + *) fail "Temporal scheduled an unknown publication stage." ;; +esac diff --git a/.github/scripts/temporal-release/resolve-candidate.sh b/.github/scripts/temporal-release/resolve-candidate.sh new file mode 100755 index 0000000000..022e84b4b9 --- /dev/null +++ b/.github/scripts/temporal-release/resolve-candidate.sh @@ -0,0 +1,81 @@ +#!/usr/bin/env bash + +set -euo pipefail + +fail() { + echo "resolve-candidate: $*" >&2 + exit 1 +} + +[[ ${GITHUB_REPOSITORY:-} == temporalio/sdk-java ]] || + fail "This automation only releases temporalio/sdk-java." +[[ ${BASE_SHA:-} =~ ^[0-9a-f]{40}$ ]] || fail "BASE_SHA must be a full commit SHA." +[[ ${RELEASE_COMMIT:-} =~ ^[0-9a-f]{40}$ ]] || + fail "RELEASE_COMMIT must be a full commit SHA." +[[ ${RELEASE_AUTOMATION_REF:-} =~ ^[0-9a-f]{40}$ ]] || + fail "RELEASE_AUTOMATION_REF must be a full commit SHA." +[[ ${TRUSTED_AUTOMATION_ROOT:-} && -d $TRUSTED_AUTOMATION_ROOT ]] || + fail "The trusted automation checkout is missing." +[[ -n ${GITHUB_OUTPUT:-} && -n ${RUNNER_TEMP:-} ]] || fail "GitHub Actions paths are missing." + +git merge-base --is-ancestor "$BASE_SHA" "$RELEASE_COMMIT" || + fail "The previous push SHA is not an ancestor of the release commit." +[[ $(git rev-parse --verify HEAD^{commit}) == "$RELEASE_COMMIT" ]] || + fail "The checkout is not the immutable release commit." + +fields=() +while IFS= read -r -d '' field; do + fields+=("$field") +done < <(git diff --name-status --no-renames -z "$BASE_SHA" "$RELEASE_COMMIT" -- releases/) + +[[ ${#fields[@]} -eq 2 ]] || + fail "The push must contain exactly one release-note change." +[[ ${fields[0]} == A ]] || fail "The release-note change must add a new file." +notes_file=${fields[1]} +[[ $notes_file =~ ^releases/(v[0-9]+\.[0-9]+\.[0-9]+(-RC[0-9]+)?)$ ]] || + fail "The release-note filename is invalid." +tag=${BASH_REMATCH[1]} + +read -r mode type _ < <(git ls-tree "$RELEASE_COMMIT" -- "$notes_file") +[[ $mode == 100644 && $type == blob ]] || fail "Release notes must be a regular file." +[[ -s $notes_file && ! -L $notes_file ]] || fail "Release notes must be nonempty and not a symlink." +notes_sha256=$(sha256sum "$notes_file" | awk '{print $1}') + +policy_output=$(mktemp) +GITHUB_OUTPUT=$policy_output "$TRUSTED_AUTOMATION_ROOT/gradlew" \ + -p "$TRUSTED_AUTOMATION_ROOT/.github/release-automation" --no-daemon run \ + --args="maven-policy $PWD/settings.gradle" >/dev/null +maven_policy=$(awk -F= '$1 == "maven_policy" {print $2}' "$policy_output" | tail -1) +[[ -n $maven_policy ]] || fail "The fixed Java Maven policy did not classify this source." + +set +e +git ls-remote --exit-code --tags https://github.com/temporalio/sdk-java.git \ + "refs/tags/$tag" >/dev/null 2>&1 +tag_status=$? +set -e +case "$tag_status" in + 0) fail "The release tag already exists." ;; + 2) ;; + *) fail "Unable to determine whether the release tag exists." ;; +esac + +candidate_file="$RUNNER_TEMP/sdk-java-release-candidate.json" +jq -n \ + --arg tag "$tag" \ + --arg commitSha "$RELEASE_COMMIT" \ + --arg releaseNotesSha256 "$notes_sha256" \ + --arg trustedAutomationCommit "$RELEASE_AUTOMATION_REF" \ + --arg mavenPolicy "$maven_policy" \ + '{tag: $tag, commitSha: $commitSha, releaseNotesSha256: $releaseNotesSha256, + trustedAutomationCommit: $trustedAutomationCommit, mavenPolicy: $mavenPolicy}' \ + >"$candidate_file" + +{ + printf 'candidate_file=%s\n' "$candidate_file" + printf 'tag=%s\n' "$tag" + printf 'version=%s\n' "${tag#v}" + printf 'commit=%s\n' "$RELEASE_COMMIT" + printf 'notes_sha256=%s\n' "$notes_sha256" + printf 'automation_commit=%s\n' "$RELEASE_AUTOMATION_REF" + printf 'maven_policy=%s\n' "$maven_policy" +} >>"$GITHUB_OUTPUT" diff --git a/.github/scripts/temporal-release/test-github-artifacts.sh b/.github/scripts/temporal-release/test-github-artifacts.sh new file mode 100755 index 0000000000..e903200a3c --- /dev/null +++ b/.github/scripts/temporal-release/test-github-artifacts.sh @@ -0,0 +1,147 @@ +#!/usr/bin/env bash + +set -euo pipefail + +root=$(cd "$(dirname "$0")" && pwd) +work=$(mktemp -d) +trap 'rm -rf "$work"' EXIT +mkdir "$work/bin" "$work/content" + +cat >"$work/bin/gh" <<'FAKE_GH' +#!/usr/bin/env bash +set -euo pipefail +if [[ ${FAKE_GH_RESPONSE_FILE:-} ]]; then + cat "$FAKE_GH_RESPONSE_FILE" +else + cat "$FAKE_GH_METADATA_FILE" +fi +FAKE_GH + +cat >"$work/bin/curl" <<'FAKE_CURL' +#!/usr/bin/env bash +set -euo pipefail +output= +arguments=("$@") +for ((index = 0; index < ${#arguments[@]}; index++)); do + if [[ ${arguments[$index]} == --output ]]; then + output=${arguments[$((index + 1))]} + fi +done +url=${arguments[$((${#arguments[@]} - 1))]} +status=${FAKE_CURL_STATUS:-200} +if [[ $status == 200 ]]; then + if [[ $url == */zip ]]; then cp "$FAKE_CURL_ZIP" "$output" + else cp "$FAKE_CURL_METADATA" "$output" + fi +else + : >"$output" +fi +printf '%s' "$status" +FAKE_CURL +chmod +x "$work/bin/gh" "$work/bin/curl" + +cat >"$work/live.json" <<'JSON' +[{"artifacts":[{"id":11,"name":"exact-name","expired":false, +"digest":"sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", +"workflow_run":{"id":22}}]}] +JSON +PATH="$work/bin:$PATH" FAKE_GH_RESPONSE_FILE="$work/live.json" \ + GH_TOKEN=test GITHUB_ARTIFACT_NAME=exact-name \ + "$root/find-github-artifact.sh" >"$work/find.out" +grep -Fxq 'found=true' "$work/find.out" +grep -Fxq 'artifact_id=11' "$work/find.out" +grep -Fxq 'workflow_run_id=22' "$work/find.out" + +cat >"$work/duplicate.json" <<'JSON' +[{"artifacts":[ +{"id":11,"name":"exact-name","expired":false,"workflow_run":{"id":22}}, +{"id":12,"name":"exact-name","expired":false,"workflow_run":{"id":23}}]}] +JSON +set +e +PATH="$work/bin:$PATH" FAKE_GH_RESPONSE_FILE="$work/duplicate.json" \ + GH_TOKEN=test GITHUB_ARTIFACT_NAME=exact-name \ + "$root/find-github-artifact.sh" >/dev/null 2>&1 +status=$? +set -e +[[ $status -eq 42 ]] + +cat >"$work/expired.json" <<'JSON' +[{"artifacts":[{"id":11,"name":"exact-name","expired":true,"workflow_run":{"id":22}}]}] +JSON +set +e +PATH="$work/bin:$PATH" FAKE_GH_RESPONSE_FILE="$work/expired.json" \ + GH_TOKEN=test GITHUB_ARTIFACT_NAME=exact-name \ + "$root/find-github-artifact.sh" >/dev/null 2>&1 +status=$? +set -e +[[ $status -eq 46 ]] + +printf 'release bytes' >"$work/content/release.tar.gz" +cat >"$work/metadata.json" <<'JSON' +{"id":11,"name":"exact-name","expired":false, +"digest":"sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", +"created_at":"2026-08-01T00:00:00Z","expires_at":"2026-10-30T00:00:00Z", +"workflow_run":{"id":22}} +JSON +PATH="$work/bin:$PATH" FAKE_GH_METADATA_FILE="$work/metadata.json" GH_TOKEN=test \ + GITHUB_ARTIFACT_CONTENT_DIR="$work/content" GITHUB_ARTIFACT_ID=11 \ + GITHUB_ARTIFACT_NAME=exact-name GITHUB_ARTIFACT_RECEIPT_FILE="$work/receipt.json" \ + GITHUB_ARTIFACT_RUN_ID=22 "$root/github-artifact-receipt.sh" +jq -e '.artifactId == 11 and .workflowRunId == 22 and .files[0].name == "release.tar.gz" and + .files[0].size == 13 and (.files[0].sha256 | test("^[0-9a-f]{64}$"))' \ + "$work/receipt.json" >/dev/null + +mkdir "$work/content/unexpected" +set +e +PATH="$work/bin:$PATH" FAKE_GH_METADATA_FILE="$work/metadata.json" GH_TOKEN=test \ + GITHUB_ARTIFACT_CONTENT_DIR="$work/content" GITHUB_ARTIFACT_ID=11 \ + GITHUB_ARTIFACT_NAME=exact-name GITHUB_ARTIFACT_RECEIPT_FILE="$work/rejected.json" \ + GITHUB_ARTIFACT_RUN_ID=22 "$root/github-artifact-receipt.sh" >/dev/null 2>&1 +status=$? +set -e +[[ $status -eq 42 ]] +rmdir "$work/content/unexpected" + +(cd "$work/content" && zip -q "$work/artifact.zip" release.tar.gz) +archive_digest=$(sha256sum "$work/artifact.zip" | awk '{print $1}') +jq --arg digest "sha256:$archive_digest" '.githubDigest = $digest' \ + "$work/receipt.json" >"$work/download-receipt.json" +jq --arg digest "sha256:$archive_digest" '.digest = $digest' \ + "$work/metadata.json" >"$work/download-metadata.json" +PATH="$work/bin:$PATH" FAKE_CURL_METADATA="$work/download-metadata.json" \ + FAKE_CURL_ZIP="$work/artifact.zip" GH_TOKEN=test \ + GITHUB_ARTIFACT_DESTINATION="$work/download" \ + GITHUB_ARTIFACT_RECEIPT_FILE="$work/download-receipt.json" \ + "$root/download-github-artifact.sh" +cmp "$work/content/release.tar.gz" "$work/download/release.tar.gz" + +PATH="$work/bin:$PATH" FAKE_CURL_METADATA="$work/download-metadata.json" \ + FAKE_CURL_ZIP="$work/artifact.zip" GH_TOKEN=test \ + GITHUB_ARTIFACT_DESTINATION="$work/discovered-download" \ + GITHUB_ARTIFACT_DIGEST="sha256:$archive_digest" GITHUB_ARTIFACT_ID=11 \ + GITHUB_ARTIFACT_NAME=exact-name GITHUB_ARTIFACT_RUN_ID=22 \ + "$root/download-github-artifact.sh" +cmp "$work/content/release.tar.gz" "$work/discovered-download/release.tar.gz" + +jq '.digest = "sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"' \ + "$work/download-metadata.json" >"$work/changed-metadata.json" +set +e +PATH="$work/bin:$PATH" FAKE_CURL_METADATA="$work/changed-metadata.json" \ + FAKE_CURL_ZIP="$work/artifact.zip" GH_TOKEN=test \ + GITHUB_ARTIFACT_DESTINATION="$work/conflict" \ + GITHUB_ARTIFACT_RECEIPT_FILE="$work/download-receipt.json" \ + "$root/download-github-artifact.sh" >/dev/null 2>&1 +status=$? +set -e +[[ $status -eq 42 ]] + +jq '.expired = true' "$work/download-metadata.json" >"$work/expired-metadata.json" +set +e +PATH="$work/bin:$PATH" FAKE_CURL_METADATA="$work/expired-metadata.json" \ + FAKE_CURL_ZIP="$work/artifact.zip" GH_TOKEN=test \ + GITHUB_ARTIFACT_DESTINATION="$work/expired-download" \ + GITHUB_ARTIFACT_RECEIPT_FILE="$work/download-receipt.json" \ + "$root/download-github-artifact.sh" >/dev/null 2>&1 +status=$? +set -e +[[ $status -eq 46 ]] diff --git a/.github/scripts/temporal-release/test-native-packaging.sh b/.github/scripts/temporal-release/test-native-packaging.sh new file mode 100755 index 0000000000..698a07a556 --- /dev/null +++ b/.github/scripts/temporal-release/test-native-packaging.sh @@ -0,0 +1,44 @@ +#!/usr/bin/env bash + +set -euo pipefail + +root=$(cd "$(dirname "$0")/../../.." && pwd) +script=$root/.github/scripts/temporal-release/package-native-artifact.sh +work=$(mktemp -d) +trap 'rm -rf "$work"' EXIT +commit=$(git -C "$root" rev-parse HEAD) +candidate=aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa +notes=bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb + +package() { + local platform=$1 asset_platform=$2 extension=$3 binary_name=$4 output=$5 + mkdir "$work/prebuilt-$platform" "$output" + printf 'native executable bytes' >"$work/prebuilt-$platform/$binary_name" + jq -n --arg candidateDigest "$candidate" --arg commitSha "$commit" \ + --arg platform "$platform" --arg releaseNotesPath releases/v1.2.3 \ + --arg releaseNotesSha256 "$notes" --arg tag v1.2.3 \ + --arg trustedAutomationCommit "$commit" --arg version 1.2.3 \ + '{candidateDigest:$candidateDigest,commitSha:$commitSha,platform:$platform, + releaseNotesPath:$releaseNotesPath,releaseNotesSha256:$releaseNotesSha256,tag:$tag, + trustedAutomationCommit:$trustedAutomationCommit,version:$version}' \ + >"$work/prebuilt-$platform/metadata.json" + RELEASE_ASSET_PLATFORM=$asset_platform RELEASE_ARCHIVE_EXTENSION=$extension \ + RELEASE_BINARY_NAME=$binary_name RELEASE_CANDIDATE_DIGEST=$candidate \ + RELEASE_COMMIT=$commit RELEASE_NOTES_FILE=releases/v1.2.3 RELEASE_NOTES_SHA256=$notes \ + RELEASE_OUTPUT_DIR=$output RELEASE_PLATFORM=$platform \ + RELEASE_PREBUILT_NATIVE_DIR="$work/prebuilt-$platform" RELEASE_TAG=v1.2.3 \ + RELEASE_VERSION=1.2.3 TRUSTED_AUTOMATION_COMMIT=$commit TRUSTED_AUTOMATION_ROOT=$root \ + "$script" >/dev/null +} + +package linux-amd64 linux_amd64 .tar.gz temporal-test-server "$work/linux-one" +mv "$work/prebuilt-linux-amd64" "$work/first-linux-input" +package linux-amd64 linux_amd64 .tar.gz temporal-test-server "$work/linux-two" +cmp "$work/linux-one/temporal-test-server_1.2.3_linux_amd64.tar.gz" \ + "$work/linux-two/temporal-test-server_1.2.3_linux_amd64.tar.gz" +tar -tzf "$work/linux-one/temporal-test-server_1.2.3_linux_amd64.tar.gz" | + grep -Fxq 'temporal-test-server_1.2.3_linux_amd64/temporal-test-server' + +package windows-amd64 windows_amd64 .zip temporal-test-server.exe "$work/windows" +unzip -Z1 "$work/windows/temporal-test-server_1.2.3_windows_amd64.zip" | + grep -Fxq 'temporal-test-server_1.2.3_windows_amd64/temporal-test-server.exe' diff --git a/.github/scripts/temporal-release/validate-publication-model.sh b/.github/scripts/temporal-release/validate-publication-model.sh new file mode 100755 index 0000000000..88125936cc --- /dev/null +++ b/.github/scripts/temporal-release/validate-publication-model.sh @@ -0,0 +1,105 @@ +#!/usr/bin/env bash + +set -euo pipefail + +fail() { echo "validate-publication-model: $*" >&2; exit 1; } + +[[ ${RELEASE_CANDIDATE_FILE:-} && -s $RELEASE_CANDIDATE_FILE ]] || + fail "RELEASE_CANDIDATE_FILE is missing." +[[ ${TRUSTED_AUTOMATION_ROOT:-} && -d $TRUSTED_AUTOMATION_ROOT ]] || + fail "TRUSTED_AUTOMATION_ROOT is missing." + +version=$(jq -er '.tag | ltrimstr("v")' "$RELEASE_CANDIDATE_FILE") +commit=$(jq -er '.commitSha' "$RELEASE_CANDIDATE_FILE") +maven_policy=$(jq -er '.mavenPolicy' "$RELEASE_CANDIDATE_FILE") +policy_output=$(mktemp) +GITHUB_OUTPUT=$policy_output "$TRUSTED_AUTOMATION_ROOT/gradlew" \ + -p "$TRUSTED_AUTOMATION_ROOT/.github/release-automation" --no-daemon run \ + --args="maven-policy $PWD/settings.gradle" >/dev/null +[[ $(awk -F= '$1 == "maven_policy" {print $2}' "$policy_output" | tail -1) == "$maven_policy" ]] || + fail "The candidate Maven policy differs from the immutable source." +mapfile -t expected < <( + awk -F= '$1 == "maven_artifacts_json" {sub(/^[^=]*=/, ""); print}' "$policy_output" | + jq -er '.[]' +) + +versioning_backup=$(mktemp) +publishing_backup=$(mktemp) +build_backup=$(mktemp) +cp gradle/versioning.gradle "$versioning_backup" +cp gradle/publishing.gradle "$publishing_backup" +cp build.gradle "$build_backup" +restore_hooks() { + cp "$versioning_backup" gradle/versioning.gradle + cp "$publishing_backup" gradle/publishing.gradle + cp "$build_backup" build.gradle +} +trap restore_hooks EXIT +if [[ $TRUSTED_AUTOMATION_ROOT/gradle/versioning.gradle != "$PWD/gradle/versioning.gradle" ]]; then + cp "$TRUSTED_AUTOMATION_ROOT/gradle/versioning.gradle" gradle/versioning.gradle +fi +if [[ $TRUSTED_AUTOMATION_ROOT/gradle/publishing.gradle != "$PWD/gradle/publishing.gradle" ]]; then + cp "$TRUSTED_AUTOMATION_ROOT/gradle/publishing.gradle" gradle/publishing.gradle +fi +python3 - build.gradle <<'PY' +import pathlib, re, sys +path = pathlib.Path(sys.argv[1]) +source = path.read_text() +matches = list(re.finditer(r"id ['\"]io\.github\.gradle-nexus\.publish-plugin['\"] version ['\"][^'\"]+['\"]", source)) +if len(matches) != 1: + raise SystemExit("Expected exactly one Gradle Nexus publish plugin declaration") +source = source[:matches[0].start()] + "id 'io.github.gradle-nexus.publish-plugin' version '1.3.0'" + source[matches[0].end():] +path.write_text(source) +PY + +./gradlew --no-daemon "-PreleaseVersion=$version" "-PreleaseCommit=$commit" \ + -PreleaseDigest=0000000000000000000000000000000000000000000000000000000000000000 \ + -PmavenSubmissionGeneration=0 tasks --all >"${RUNNER_TEMP:-/tmp}/sdk-java-release-tasks.txt" +for task in initializeSonatypeStagingRepository findSonatypeStagingRepository \ + closeSonatypeStagingRepository; do + grep -Eq "^$task([[:space:]]|$)" "${RUNNER_TEMP:-/tmp}/sdk-java-release-tasks.txt" || + fail "Required trusted publication task $task is unavailable." +done +for artifact in "${expected[@]}"; do + grep -Eq "^$artifact:publishToSonatype([[:space:]]|$)" \ + "${RUNNER_TEMP:-/tmp}/sdk-java-release-tasks.txt" || + fail "Required trusted publication task $artifact:publishToSonatype is unavailable." +done + +./gradlew --no-daemon "-PreleaseVersion=$version" "-PreleaseCommit=$commit" \ + -PreleaseDigest=0000000000000000000000000000000000000000000000000000000000000000 \ + -PmavenSubmissionGeneration=0 \ + generatePomFileForMavenJavaPublication >/dev/null + +mapfile -t pom_files < <(find . -path '*/build/publications/mavenJava/pom-default.xml' -type f) +[[ ${#pom_files[@]} -eq ${#expected[@]} ]] || + fail "Generated POM count does not match the reviewed Maven policy." +pom_report="${RUNNER_TEMP:-/tmp}/sdk-java-generated-poms.tsv" +python3 - "$commit" "$version" "${pom_files[@]}" >"$pom_report" <<'PY' +import sys +import xml.etree.ElementTree as ET + +commit = sys.argv[1] +version = sys.argv[2] +for path in sys.argv[3:]: + root = ET.parse(path).getroot() + ns = root.tag.partition("}")[0] + "}" if root.tag.startswith("{") else "" + artifact = root.findtext(f"{ns}artifactId", "").strip() + group = root.findtext(f"{ns}groupId", "").strip() + actual_version = root.findtext(f"{ns}version", "").strip() + tag = root.findtext(f"{ns}scm/{ns}tag", "").strip().lower() + if group != "io.temporal" or actual_version != version or tag != commit: + raise SystemExit(f"{path} does not contain exact group, version, and source SHA") + print(f"{artifact}\t{path}") +PY +mapfile -t actual < <(cut -f1 "$pom_report" | sort) +mapfile -t expected_sorted < <(printf '%s\n' "${expected[@]}" | sort) +[[ ${actual[*]} == "${expected_sorted[*]}" ]] || + fail "Generated POM coordinates do not match the reviewed Maven policy." + +restore_hooks +trap - EXIT +cmp -s "$versioning_backup" gradle/versioning.gradle && + cmp -s "$publishing_backup" gradle/publishing.gradle && + cmp -s "$build_backup" build.gradle || + fail "Publication-model validation did not restore the trusted publication hooks." diff --git a/.github/scripts/temporal-release/verify-approver.sh b/.github/scripts/temporal-release/verify-approver.sh new file mode 100755 index 0000000000..d0b4017da6 --- /dev/null +++ b/.github/scripts/temporal-release/verify-approver.sh @@ -0,0 +1,40 @@ +#!/usr/bin/env bash + +set -euo pipefail + +[[ $# -eq 1 && $1 =~ ^[A-Za-z0-9-]{1,39}$ ]] || { + echo "verify-approver: expected one GitHub login." >&2 + exit 43 +} +[[ -n ${GH_TOKEN:-} ]] || { + echo "verify-approver: GH_TOKEN is missing." >&2 + exit 43 +} + +response=$(mktemp) +set +e +status=$(curl --silent --show-error --location --output "$response" --write-out '%{http_code}' \ + --header "Authorization: Bearer $GH_TOKEN" --header 'Accept: application/vnd.github+json' \ + "https://api.github.com/orgs/temporalio/teams/sdk/memberships/$1") +curl_status=$? +set -e +[[ $curl_status -eq 0 ]] || { + echo "verify-approver: GitHub membership is temporarily unavailable." >&2 + exit 1 +} +[[ $status != 404 ]] || { + echo "verify-approver: $1 is not a temporalio/sdk team member." >&2 + exit 43 +} +[[ $status == 200 ]] || { + echo "verify-approver: GitHub returned HTTP $status; retry later." >&2 + exit 1 +} +state=$(jq -er .state "$response") || { + echo "verify-approver: GitHub returned invalid membership state." >&2 + exit 1 +} +[[ $state == active ]] || { + echo "verify-approver: $1 does not have active temporalio/sdk team membership." >&2 + exit 43 +} diff --git a/.github/workflows/README.md b/.github/workflows/README.md deleted file mode 100644 index 885fe8a227..0000000000 --- a/.github/workflows/README.md +++ /dev/null @@ -1,38 +0,0 @@ -# sdk-java Github Workflows - -## Prepare Release (prepare-release.yml) - -This is a [manually triggered](https://docs.github.com/en/actions/managing-workflow-runs/manually-running-a-workflow) workflow that uses the gradle build files already present in the sdk-java repository to prepare release artifacts for publication. This workflow takes a tag string and a git ref to use in peparing a release. There is an expectation that if preparing a given release (e.g. v1.2.3) then there exists a file in the repository, releases/ (i.e. releases/v1.2.3) containing release notes. This file must be present on the ref passed to the workflow invocation. - -This workflow requires five secrets: - -- `JAR_SIGNING_KEY` -- `JAR_SIGNING_KEY_ID` -- `JAR_SIGNING_KEY_PASSWORD` -- `RH_PASSWORD` -- `RH_USER` - - The results of running this workflow are - - - A *DRAFT* Github release will be created - - Signed jars *STAGED* to the Sonatype Nexus artifact repository - - To complete the release, the releaser should - - - Validate and publish the Github release - - Approve and publish the jars via the Sonatype UI - -### Testing - -This workflow does not publish release artifacts in a way that is externally -visible and thus it is safe to execute at any time as long as the resulting -draft release and unpublished jars are cleaned up. - -Workflows can also be invoked from the `gh` cli. To invoke this workflow and watch its progress - -```.sh -$ gh workflow run --repo temporalio/sdk-java --field tag=v1.2.3 prepare-release.yml -$ gh run list --workflow prepare-release.yml --repo temporalio/sdk-java -$ # Note ID of your workflow run in the output of the command above -$ gh run watch --repo temporalio/sdk-java -``` diff --git a/.github/workflows/build-native-image.yml b/.github/workflows/build-native-image.yml index 33515cde6c..702dc3ed14 100644 --- a/.github/workflows/build-native-image.yml +++ b/.github/workflows/build-native-image.yml @@ -1,5 +1,6 @@ -name: Build native image +name: Native image builder (reusable) permissions: + actions: read contents: read defaults: run: @@ -9,110 +10,195 @@ on: inputs: ref: type: string - description: "Git ref from which to release" + description: "Exact full 40-character release commit SHA" + required: true + tag: + type: string + description: "Exact release tag" + required: true + notes_sha256: + type: string + description: "Exact release-note SHA-256" required: true - default: "main" upload_artifact: type: boolean description: "Upload the native test server executable as an artifact" - required: false - default: false + required: true + default: true workflow_call: inputs: + automation_ref: + type: string + description: "Frozen trusted automation commit for release build containers" + required: false + default: "" ref: type: string - description: "Git ref from which to release" + description: "Exact full 40-character release commit SHA" required: true - default: "main" + tag: + type: string + description: "Exact release tag" + required: false + default: "" + notes_sha256: + type: string + description: "Exact release-note SHA-256" + required: false + default: "" upload_artifact: type: boolean description: "Upload the native test server executable as an artifact" required: false default: false env: + AUTOMATION_REF: ${{ inputs.automation_ref || vars.RELEASE_AUTOMATION_REF || github.workflow_sha }} INPUT_REF: ${{ inputs.ref }} + INPUT_TAG: ${{ inputs.tag }} + INPUT_NOTES_SHA256: ${{ inputs.notes_sha256 }} jobs: + policy: + runs-on: ubuntu-latest + outputs: + matrix: ${{ steps.matrix.outputs.matrix }} + steps: + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + with: + persist-credentials: false + ref: ${{ env.AUTOMATION_REF }} + - name: Verify trusted policy checkout + run: '[[ "$AUTOMATION_REF" =~ ^[0-9a-f]{40}$ && "$(git rev-parse HEAD)" == "$AUTOMATION_REF" ]]' + - uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5 + with: + distribution: temurin + java-version: "17" + - uses: gradle/actions/setup-gradle@ac396bf1a80af16236baf54bd7330ae21dc6ece5 # v6 + - name: Load the fixed sdk-java platform matrix + id: matrix + run: ./gradlew -p .github/release-automation run --args=platform-matrix + build_native_images: name: Build native test server + needs: policy strategy: fail-fast: false - matrix: - include: - - runner: ubuntu-latest - os_family: linux - arch: amd64 - musl: true - - runner: ubuntu-latest - os_family: linux - arch: amd64 - musl: false - - runner: macos-15-intel - os_family: macOS - arch: amd64 - - runner: macos-latest - os_family: macOS - arch: arm64 - - runner: ubuntu-24.04-arm - os_family: linux - arch: arm64 - - runner: windows-latest - os_family: windows - arch: amd64 + matrix: ${{ fromJSON(needs.policy.outputs.matrix) }} runs-on: ${{ matrix.runner }} steps: + - name: Validate exact immutable inputs + run: | + [[ "$INPUT_REF" =~ ^[0-9a-f]{40}$ ]] + [[ "$AUTOMATION_REF" =~ ^[0-9a-f]{40}$ ]] + if [[ -n "$INPUT_TAG" || -n "$INPUT_NOTES_SHA256" ]]; then + [[ "$INPUT_TAG" =~ ^v[0-9]+\.[0-9]+\.[0-9]+(-RC[0-9]+)?$ ]] + [[ "$INPUT_NOTES_SHA256" =~ ^[0-9a-f]{64}$ ]] + fi + - name: Adopt an artifact from an earlier attempt of this run + id: existing + if: inputs.upload_artifact + env: + GH_TOKEN: ${{ github.token }} + run: | + artifact_name='${{ matrix.artifactLabel }}' + artifacts=$(gh api --paginate --slurp \ + "repos/temporalio/sdk-java/actions/runs/$GITHUB_RUN_ID/artifacts?per_page=100") + count=$(jq --arg name "$artifact_name" \ + '[.[].artifacts[] | select(.name == $name and .expired == false)] | length' \ + <<<"$artifacts") + [[ $count -le 1 ]] + if [[ $count -eq 1 ]]; then + echo "exists=true" >>"$GITHUB_OUTPUT" + else + echo "exists=false" >>"$GITHUB_OUTPUT" + fi - name: Checkout repo + if: steps.existing.outputs.exists != 'true' uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: fetch-depth: 0 + persist-credentials: false submodules: recursive ref: ${{ env.INPUT_REF }} + - name: Verify checkout and release-note identity + if: inputs.tag != '' && steps.existing.outputs.exists != 'true' + run: | + [[ "$(git rev-parse HEAD)" == "$INPUT_REF" ]] + [[ -s "releases/$INPUT_TAG" && ! -L "releases/$INPUT_TAG" ]] + [[ "$(sha256sum "releases/$INPUT_TAG" | awk '{print $1}')" == "$INPUT_NOTES_SHA256" ]] + if git rev-parse --verify "refs/tags/$INPUT_TAG" >/dev/null 2>&1; then + [[ "$(git rev-list -n1 "$INPUT_TAG")" == "$INPUT_REF" ]] + else + git tag "$INPUT_TAG" "$INPUT_REF" + fi + + - name: Checkout frozen fallback build containers + if: matrix.osFamily == 'linux' && steps.existing.outputs.exists != 'true' + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + with: + path: manual-tools + persist-credentials: false + ref: ${{ env.AUTOMATION_REF }} + + - name: Verify frozen fallback build containers + if: matrix.osFamily == 'linux' && steps.existing.outputs.exists != 'true' + run: '[[ "$(git -C manual-tools rev-parse HEAD)" == "$AUTOMATION_REF" ]]' - name: Set up Java - if: matrix.os_family != 'linux' - uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5 + if: matrix.osFamily != 'linux' && steps.existing.outputs.exists != 'true' + uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5.7.0 with: - java-version: 23 - distribution: "graalvm" + java-version: ${{ matrix.javaVersion }} + distribution: ${{ matrix.distribution }} - name: Set up Gradle - if: matrix.os_family != 'linux' + if: matrix.osFamily != 'linux' && steps.existing.outputs.exists != 'true' uses: gradle/actions/setup-gradle@ac396bf1a80af16236baf54bd7330ae21dc6ece5 # v6 - name: Build native test server (non-Docker) - if: matrix.os_family != 'linux' + if: matrix.osFamily != 'linux' && steps.existing.outputs.exists != 'true' run: | - ./gradlew -PnativeBuild :temporal-test-server:nativeCompile + gradle_args=(-PnativeBuild :temporal-test-server:nativeCompile) + [[ -n "$INPUT_TAG" ]] && gradle_args=("-PreleaseVersion=${INPUT_TAG#v}" "${gradle_args[@]}") + ./gradlew "${gradle_args[@]}" - name: Build native test server (Docker non-musl) - if: matrix.os_family == 'linux' && matrix.musl == false + if: >- + matrix.osFamily == 'linux' && matrix.musl == false && + steps.existing.outputs.exists != 'true' run: | IMAGE_ID_FILE="$(mktemp)" - docker build --iidfile "$IMAGE_ID_FILE" ./docker/native-image + docker_context=manual-tools/.github/release-automation/docker/native-image-java23 + docker build --iidfile "$IMAGE_ID_FILE" "$docker_context" IMAGE_ID="$(cat "$IMAGE_ID_FILE")" + gradle_args=(-PnativeBuild :temporal-test-server:nativeCompile) + [[ -n "$INPUT_TAG" ]] && gradle_args=("-PreleaseVersion=${INPUT_TAG#v}" "${gradle_args[@]}") docker run \ --rm -w /github/workspace -v "$(pwd):/github/workspace" \ - "$IMAGE_ID" \ - sh -c "./gradlew -PnativeBuild :temporal-test-server:nativeCompile" + "$IMAGE_ID" ./gradlew "${gradle_args[@]}" - name: Build native test server (Docker musl) - if: matrix.os_family == 'linux' && matrix.musl == true + if: >- + matrix.osFamily == 'linux' && matrix.musl == true && + steps.existing.outputs.exists != 'true' run: | IMAGE_ID_FILE="$(mktemp)" - docker build --iidfile "$IMAGE_ID_FILE" ./docker/native-image-musl + docker_context=manual-tools/.github/release-automation/docker/native-image-musl-java23 + docker build --iidfile "$IMAGE_ID_FILE" "$docker_context" IMAGE_ID="$(cat "$IMAGE_ID_FILE")" + gradle_args=(-PnativeBuild -PnativeBuildMusl :temporal-test-server:nativeCompile) + [[ -n "$INPUT_TAG" ]] && gradle_args=("-PreleaseVersion=${INPUT_TAG#v}" "${gradle_args[@]}") docker run \ --rm -w /github/workspace -v "$(pwd):/github/workspace" \ - "$IMAGE_ID" \ - sh -c "./gradlew -PnativeBuild -PnativeBuildMusl :temporal-test-server:nativeCompile" + "$IMAGE_ID" ./gradlew "${gradle_args[@]}" # path ends in a wildcard because on windows the file ends in '.exe' - name: Upload executable to workflow - if: ${{ inputs.upload_artifact }} + if: inputs.upload_artifact && steps.existing.outputs.exists != 'true' uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7 with: - name: ${{ matrix.musl && format('{0}_{1}_musl', matrix.os_family, matrix.arch) || format('{0}_{1}', matrix.os_family, matrix.arch)}} + name: ${{ matrix.artifactLabel }} path: | temporal-test-server/build/native/nativeCompile/temporal-test-server* if-no-files-found: error - retention-days: 1 + retention-days: 90 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 1995170ef0..8bb753e475 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -248,4 +248,4 @@ jobs: name: Build native test server uses: ./.github/workflows/build-native-image.yml with: - ref: ${{ github.event.pull_request.head.sha }} + ref: ${{ github.event.pull_request.head.sha || github.sha }} diff --git a/.github/workflows/prepare-release.yml b/.github/workflows/prepare-release.yml index b43ce62c88..56069bbb65 100644 --- a/.github/workflows/prepare-release.yml +++ b/.github/workflows/prepare-release.yml @@ -1,7 +1,9 @@ name: Prepare release + defaults: run: shell: bash -euo pipefail -O nullglob {0} + on: workflow_dispatch: inputs: @@ -9,105 +11,287 @@ on: type: string description: "Release version tag (e.g. v1.2.3)" required: true - ref: + commit_sha: type: string - description: "Git ref from which to release" + description: "Exact full 40-character release commit SHA" required: true - default: "main" do_build_native_images: type: boolean description: "Native Test Server" required: true - default: "true" + default: true do_publish_jars: type: boolean description: "Publish Java Artifacts" required: true - default: "true" + default: true permissions: contents: read +concurrency: + group: sdk-java-release-publication + cancel-in-progress: false + env: - INPUT_REF: ${{ github.event.inputs.ref }} - INPUT_TAG: ${{ github.event.inputs.tag }} + INPUT_COMMIT: ${{ inputs.commit_sha }} + INPUT_TAG: ${{ inputs.tag }} + TEMPORAL_ADDRESS: ${{ vars.TEMPORAL_RELEASE_ADDRESS }} + TEMPORAL_NAMESPACE: ${{ vars.TEMPORAL_RELEASE_NAMESPACE }} jobs: + prepare: + name: Validate and take ownership + if: github.ref == 'refs/heads/main' + runs-on: ubuntu-latest + environment: release-publication + permissions: + contents: read + outputs: + automatic_maven_complete: ${{ steps.final.outputs.maven_complete }} + automatic_maven_started: ${{ steps.final.outputs.maven_started }} + notes_sha256: ${{ steps.identity.outputs.notes_sha256 }} + release_digest: ${{ steps.ownership.outputs.release_digest }} + trusted_automation_commit: ${{ steps.identity.outputs.trusted_automation_commit }} + steps: + - name: Validate exact inputs + run: | + [[ "$INPUT_COMMIT" =~ ^[0-9a-f]{40}$ ]] + [[ "$INPUT_TAG" =~ ^v[0-9]+\.[0-9]+\.[0-9]+(-RC[0-9]+)?$ ]] + [[ '${{ vars.RELEASE_AUTOMATION_REF }}' =~ ^[0-9a-f]{40}$ ]] + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + with: + fetch-depth: 0 + path: source + persist-credentials: false + ref: ${{ inputs.commit_sha }} + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + with: + path: automation + persist-credentials: false + ref: ${{ vars.RELEASE_AUTOMATION_REF }} + - uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5 + with: + distribution: temurin + java-version: "17" + - uses: gradle/actions/setup-gradle@ac396bf1a80af16236baf54bd7330ae21dc6ece5 # v6 + - name: Mint approval GitHub App token + id: github-token + uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0 + with: + client-id: ${{ vars.RELEASE_GITHUB_APP_CLIENT_ID }} + private-key: ${{ secrets.RELEASE_GITHUB_APP_PRIVATE_KEY }} + owner: temporalio + repositories: sdk-java + permission-members: read + - name: Validate source identity and release manager + id: identity + env: + GH_TOKEN: ${{ steps.github-token.outputs.token }} + run: | + [[ "$(git -C source rev-parse HEAD)" == "$INPUT_COMMIT" ]] + [[ -s "source/releases/$INPUT_TAG" && ! -L "source/releases/$INPUT_TAG" ]] + automation_commit=$(git -C automation rev-parse HEAD) + [[ "$automation_commit" == '${{ vars.RELEASE_AUTOMATION_REF }}' ]] + automation/.github/scripts/temporal-release/verify-approver.sh "$GITHUB_ACTOR" + { + echo "notes_sha256=$(sha256sum "source/releases/$INPUT_TAG" | awk '{print $1}')" + echo "trusted_automation_commit=$automation_commit" + } >>"$GITHUB_OUTPUT" + - name: Inspect automatic release state + id: before + working-directory: automation + env: + TEMPORAL_API_KEY: ${{ secrets.TEMPORAL_RELEASE_APPROVAL_API_KEY }} + run: >- + ./gradlew -p .github/release-automation run + --args="inspect-if-present $INPUT_TAG $INPUT_COMMIT" + - name: Validate automatic handoff target + if: steps.before.outputs.found == 'true' + env: + AUTOMATION_COMMIT: ${{ steps.before.outputs.automation_commit }} + MANUAL_MAVEN_REQUESTED: ${{ inputs.do_publish_jars }} + PHASE: ${{ steps.before.outputs.phase }} + run: | + [[ ",${{ vars.RELEASE_AUTOMATION_REF }},${{ vars.RELEASE_AUTOMATION_COMPATIBLE_REFS }}," == \ + *",$AUTOMATION_COMMIT,"* ]] + [[ "$PHASE" != PUBLISHED ]] + if [[ "$MANUAL_MAVEN_REQUESTED" == true && \ + '${{ steps.before.outputs.maven_started }}' == true ]]; then + echo "Automatic Maven publication already started. Do not hand off with Maven enabled." >&2 + exit 1 + fi + if [[ "$MANUAL_MAVEN_REQUESTED" != true && \ + '${{ steps.before.outputs.maven_started }}' == true && \ + '${{ steps.before.outputs.maven_complete }}' != true ]]; then + echo "Automatic Maven publication is incomplete. Recover it in Temporal before handoff." >&2 + exit 1 + fi + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + if: steps.before.outputs.found == 'true' && steps.before.outputs.phase != 'HANDED_OFF' + with: + path: handoff-before + persist-credentials: false + ref: ${{ steps.before.outputs.automation_commit }} + - name: Stop automatic release before takeover + if: steps.before.outputs.found == 'true' && steps.before.outputs.phase != 'HANDED_OFF' + working-directory: handoff-before + env: + GH_TOKEN: ${{ steps.github-token.outputs.token }} + GITHUB_TRIGGERING_ACTOR: ${{ github.actor }} + MANUAL_MAVEN_REQUESTED: ${{ inputs.do_publish_jars }} + RELEASE_AUTOMATION_REF: ${{ steps.before.outputs.automation_commit }} + TEMPORAL_API_KEY: ${{ secrets.TEMPORAL_RELEASE_APPROVAL_API_KEY }} + run: >- + ./gradlew -p .github/release-automation run + --args="control handoff-manual $INPUT_TAG $INPUT_COMMIT" + - name: Claim durable tag ownership in Temporal + id: ownership + working-directory: automation + env: + GH_TOKEN: ${{ steps.github-token.outputs.token }} + GITHUB_TRIGGERING_ACTOR: ${{ github.actor }} + TEMPORAL_API_KEY: ${{ secrets.TEMPORAL_RELEASE_APPROVAL_API_KEY }} + run: | + digest='${{ steps.before.outputs.release_digest }}' + if [[ -z "$digest" ]]; then + digest=$(printf '%s\n%s\n%s' \ + "$INPUT_TAG" "$INPUT_COMMIT" '${{ steps.identity.outputs.notes_sha256 }}' | + sha256sum | awk '{print $1}') + fi + ./gradlew -p .github/release-automation run \ + --args="claim-manual-ownership $INPUT_TAG $INPUT_COMMIT $digest \ + ${{ steps.before.outputs.found == 'true' }}" + - name: Read the authoritative post-handoff state + id: final + working-directory: automation + env: + TEMPORAL_API_KEY: ${{ secrets.TEMPORAL_RELEASE_APPROVAL_API_KEY }} + run: >- + ./gradlew -p .github/release-automation run + --args="inspect-if-present $INPUT_TAG $INPUT_COMMIT" + - name: Validate any automatic release that raced with takeover + if: steps.final.outputs.found == 'true' + env: + AUTOMATION_COMMIT: ${{ steps.final.outputs.automation_commit }} + OWNER: ${{ steps.final.outputs.ownership_owner }} + PHASE: ${{ steps.final.outputs.phase }} + run: | + [[ ",${{ vars.RELEASE_AUTOMATION_REF }},${{ vars.RELEASE_AUTOMATION_COMPATIBLE_REFS }}," == \ + *",$AUTOMATION_COMMIT,"* ]] + [[ "$PHASE" != PUBLISHED ]] + [[ "$PHASE" == HANDED_OFF || "$OWNER" == MANUAL ]] create_draft_release: - name: Create Github draft release + name: Create GitHub draft release + needs: prepare runs-on: ubuntu-latest permissions: contents: write steps: - - name: Audit gh version - run: gh --version - - - name: Check for existing release - id: check_release - run: | - echo "::echo::on" - gh release view --repo "$GITHUB_REPOSITORY" "$INPUT_TAG" \ - && echo "::set-output name=already_exists::true" \ - || echo "::set-output name=already_exists::false" + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + with: + persist-credentials: false + ref: ${{ inputs.commit_sha }} + - name: Create or validate draft env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + GH_TOKEN: ${{ github.token }} + run: | + if release=$(gh release view --repo "$GITHUB_REPOSITORY" "$INPUT_TAG" \ + --json body,isDraft,name,targetCommitish 2>/dev/null); then + jq -e --arg tag "$INPUT_TAG" --arg commit "$INPUT_COMMIT" \ + --rawfile notes "releases/$INPUT_TAG" \ + '.isDraft == true and .name == $tag and .targetCommitish == $commit and + .body == $notes' <<<"$release" >/dev/null + else + gh release create "$INPUT_TAG" --draft --repo "$GITHUB_REPOSITORY" \ + --title "$INPUT_TAG" --target "$INPUT_COMMIT" --notes-file "releases/$INPUT_TAG" + fi - - name: Checkout repo - if: steps.check_release.outputs.already_exists == 'false' - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + start_manual_maven: + name: Record the manual Maven attempt + if: inputs.do_publish_jars + needs: [prepare, create_draft_release] + runs-on: ubuntu-latest + environment: release-publication + permissions: + contents: read + steps: + - name: Refuse a second Maven controller + env: + MAVEN_STARTED: ${{ needs.prepare.outputs.automatic_maven_started }} + run: | + if [[ "$MAVEN_STARTED" == true ]]; then + echo "Automatic Maven publication already started. Re-run with Publish Java Artifacts disabled." >&2 + exit 1 + fi + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: - ref: ${{ env.INPUT_REF }} - - - name: Create release - if: steps.check_release.outputs.already_exists == 'false' - run: > - gh release create - "$INPUT_REF" - --draft - --repo "$GITHUB_REPOSITORY" - --title "$INPUT_TAG" - --target "$INPUT_REF" - --notes-file releases/"$INPUT_TAG" + persist-credentials: false + ref: ${{ needs.prepare.outputs.trusted_automation_commit }} + - uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5 + with: + distribution: temurin + java-version: "17" + - uses: gradle/actions/setup-gradle@ac396bf1a80af16236baf54bd7330ae21dc6ece5 # v6 + - name: Mint approval GitHub App token + id: github-token + uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0 + with: + client-id: ${{ vars.RELEASE_GITHUB_APP_CLIENT_ID }} + private-key: ${{ secrets.RELEASE_GITHUB_APP_PRIVATE_KEY }} + owner: temporalio + repositories: sdk-java + permission-members: read + - name: Record the single manual Maven attempt before publication env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + GH_TOKEN: ${{ steps.github-token.outputs.token }} + GITHUB_TRIGGERING_ACTOR: ${{ github.actor }} + TEMPORAL_API_KEY: ${{ secrets.TEMPORAL_RELEASE_APPROVAL_API_KEY }} + run: >- + ./gradlew -p .github/release-automation --no-daemon run + --args="start-manual-maven $INPUT_TAG $INPUT_COMMIT + ${{ needs.prepare.outputs.release_digest }}" publish_java_artifacts: name: Publish Java Artifacts - if: github.event.inputs.do_publish_jars == 'true' + if: inputs.do_publish_jars + needs: [prepare, create_draft_release, start_manual_maven] runs-on: ubuntu-latest - needs: create_draft_release + environment: release-publication steps: - - name: Checkout repo - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: - ref: ${{ env.INPUT_REF }} - - # Our custom gradle version sniffing builds the maven release artifact - # names out of the git tag ... but the repo isn't tagged (yet) so add a - # tag to the _local_ clone just to get the right jar names. This tag - # does not get pushed back to the origin. Once the artifacts have been - # inspected and verified, the manual act of publishing the draft GH - # release creates the tag. - - name: Temporary tag + fetch-depth: 0 + persist-credentials: false + ref: ${{ inputs.commit_sha }} + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + with: + path: trusted-release + persist-credentials: false + ref: ${{ needs.prepare.outputs.trusted_automation_commit }} + - name: Use trusted exact-version publication hooks + run: | + cp trusted-release/gradle/versioning.gradle gradle/versioning.gradle + cp trusted-release/gradle/publishing.gradle gradle/publishing.gradle + [[ "$(git -C trusted-release rev-parse HEAD)" == \ + '${{ needs.prepare.outputs.trusted_automation_commit }}' ]] + - name: Temporary local tag run: git tag "$INPUT_TAG" - - - name: Set up Java - uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5 + - uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5 with: java-version: "23" - distribution: "temurin" - - - name: Set up Gradle - uses: gradle/actions/setup-gradle@ac396bf1a80af16236baf54bd7330ae21dc6ece5 # v6 - + distribution: temurin + - uses: gradle/actions/setup-gradle@ac396bf1a80af16236baf54bd7330ae21dc6ece5 # v6 - name: Set up signing key - run: mkdir -p "$HOME/.gnupg" && echo -n "$KEY" | base64 -d > "$HOME/.gnupg/secring.gpg" env: KEY: ${{ secrets.JAR_SIGNING_KEY }} - - # Prefer env variables here rather than inline ${{ secrets.FOO }} to - # decrease the likelihood that secrets end up printed to stdout. - - name: Set up secret gradle properties + run: mkdir -p "$HOME/.gnupg" && echo -n "$KEY" | base64 -d >"$HOME/.gnupg/secring.gpg" + - name: Set up secret Gradle properties + env: + KEY_ID: ${{ secrets.JAR_SIGNING_KEY_ID }} + KEY_PASSWORD: ${{ secrets.JAR_SIGNING_KEY_PASSWORD }} + RH_PASSWORD: ${{ secrets.RH_PASSWORD }} + RH_USER: ${{ secrets.RH_USER }} run: | mkdir -p "$HOME/.gradle" envsubst >"$HOME/.gradle/gradle.properties" <- + ./gradlew "-PreleaseVersion=${INPUT_TAG#v}" "-PreleaseCommit=$INPUT_COMMIT" + "-PreleaseDigest=${{ needs.prepare.outputs.release_digest }}" + -PmavenSubmissionGeneration=0 + publishToSonatype closeSonatypeStagingRepository + + complete_manual_maven: + name: Record completed manual Maven publication + if: inputs.do_publish_jars + needs: [prepare, publish_java_artifacts] + runs-on: ubuntu-latest + environment: release-publication + permissions: + contents: read + steps: + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + with: + persist-credentials: false + ref: ${{ needs.prepare.outputs.trusted_automation_commit }} + - uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5 + with: + distribution: temurin + java-version: "17" + - uses: gradle/actions/setup-gradle@ac396bf1a80af16236baf54bd7330ae21dc6ece5 # v6 + - name: Mint approval GitHub App token + id: github-token + uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0 + with: + client-id: ${{ vars.RELEASE_GITHUB_APP_CLIENT_ID }} + private-key: ${{ secrets.RELEASE_GITHUB_APP_PRIVATE_KEY }} + owner: temporalio + repositories: sdk-java + permission-members: read + - name: Record completed manual Maven publication + env: + GH_TOKEN: ${{ steps.github-token.outputs.token }} + GITHUB_TRIGGERING_ACTOR: ${{ github.actor }} + TEMPORAL_API_KEY: ${{ secrets.TEMPORAL_RELEASE_APPROVAL_API_KEY }} + run: >- + ./gradlew -p .github/release-automation --no-daemon run + --args="complete-manual-maven $INPUT_TAG $INPUT_COMMIT + ${{ needs.prepare.outputs.release_digest }}" build_native_images: name: Build native test server - needs: create_draft_release - if: github.event.inputs.do_build_native_images == 'true' + needs: [prepare, create_draft_release] + if: inputs.do_build_native_images uses: ./.github/workflows/build-native-image.yml with: + automation_ref: ${{ needs.prepare.outputs.trusted_automation_commit }} + notes_sha256: ${{ needs.prepare.outputs.notes_sha256 }} + ref: ${{ inputs.commit_sha }} + tag: ${{ inputs.tag }} upload_artifact: true - ref: ${{ github.event.inputs.ref }} attach_to_release: name: Attach native executables to release @@ -141,45 +363,25 @@ jobs: runs-on: ubuntu-latest permissions: contents: write - actions: write steps: - - name: Audit gh version - run: gh --version - - # when no artifact is specified, all artifacts are downloaded and expanded into CWD - - name: Fetch executables - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8 - - # example: linux_amd64/ -> temporal-test-server_1.2.3_linux_amd64 - # the name of the directory created becomes the basename of the archive (*.tar.gz or *.zip) and - # the root directory of the contents of the archive. - - name: Rename dirs + - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8 + - name: Package release archives run: | - version="$(sed 's/^v//'<<<"$INPUT_TAG")" + version=${INPUT_TAG#v} for dir in *; do mv "$dir" "temporal-test-server_${version}_${dir}"; done - - - name: Tar (linux, macOS) - run: for dir in *{linux,macOS}*; do tar cvzf "${dir}.tar.gz" "$dir"; done - - - name: Zip (windows) - run: for dir in *windows*; do zip -r "${dir}.zip" "$dir"; done - - - name: Upload release archives - uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7 - with: - name: release-archives - path: | - *.zip - *.tar.gz - if-no-files-found: error - retention-days: 1 - - - name: Upload + for dir in *{linux,macOS}*; do tar cvzf "${dir}.tar.gz" "$dir"; done + for dir in *windows*; do zip -r "${dir}.zip" "$dir"; done + sha256sum *.tar.gz *.zip | sort -k2 >SHA256SUMS + - name: Upload missing exact assets + env: + GH_TOKEN: ${{ github.token }} run: | - until gh release upload --clobber --repo $GITHUB_REPOSITORY "$INPUT_TAG" *.zip *.tar.gz; do - echo "Failed to upload release artifacts. Will retry in 20s" - sleep 20 + mkdir existing + for asset in SHA256SUMS *.zip *.tar.gz; do + if gh release download --repo "$GITHUB_REPOSITORY" "$INPUT_TAG" \ + --pattern "$(basename "$asset")" --dir existing 2>/dev/null; then + cmp "$asset" "existing/$(basename "$asset")" + else + gh release upload --repo "$GITHUB_REPOSITORY" "$INPUT_TAG" "$asset" + fi done - timeout-minutes: 10 - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/temporal-release-approve.yml b/.github/workflows/temporal-release-approve.yml new file mode 100644 index 0000000000..6b4af8f925 --- /dev/null +++ b/.github/workflows/temporal-release-approve.yml @@ -0,0 +1,328 @@ +name: Approve Temporal-backed release +run-name: ${{ github.event_name == 'issues' && 'Approve exact release by closing its locked issue' || 'Create exact release approval requests' }} + +on: + workflow_dispatch: + schedule: + - cron: "4,19,34,49 * * * *" + issues: + types: + - closed + +permissions: + contents: read + issues: read + +env: + RELEASE_AUTOMATION_COMPATIBLE_REFS: ${{ vars.RELEASE_AUTOMATION_COMPATIBLE_REFS }} + TEMPORAL_ADDRESS: ${{ vars.TEMPORAL_RELEASE_ADDRESS }} + TEMPORAL_NAMESPACE: ${{ vars.TEMPORAL_RELEASE_NAMESPACE }} + TEMPORAL_API_KEY: ${{ secrets.TEMPORAL_RELEASE_APPROVAL_API_KEY }} + RELEASE_AUTOMATION_REF: ${{ vars.RELEASE_AUTOMATION_REF }} + GITHUB_TRIGGERING_ACTOR: ${{ github.actor }} + APPROVAL_ISSUE_NUMBER: ${{ github.event.issue.number || 0 }} + +jobs: + discover_requests: + if: >- + (github.event_name == 'workflow_dispatch' || github.event_name == 'schedule') && + github.ref == 'refs/heads/main' + runs-on: ubuntu-latest + environment: release-control + outputs: + request_count: ${{ steps.split.outputs.request_count }} + request_matrix: ${{ steps.split.outputs.request_matrix }} + recovery_count: ${{ steps.split.outputs.recovery_count }} + recovery_matrix: ${{ steps.split.outputs.recovery_matrix }} + steps: + - name: Validate trusted automation pin + run: '[[ "$RELEASE_AUTOMATION_REF" =~ ^[0-9a-f]{40}$ ]]' + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + with: + ref: ${{ vars.RELEASE_AUTOMATION_REF }} + - name: Verify trusted checkout + run: '[[ "$(git rev-parse HEAD)" == "$RELEASE_AUTOMATION_REF" ]]' + - uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5 + with: + distribution: temurin + java-version: "17" + - uses: gradle/actions/setup-gradle@ac396bf1a80af16236baf54bd7330ae21dc6ece5 # v6 + - name: Discover every unrequested exact release + id: discover + run: ./gradlew -p .github/release-automation run --args="discover approvals" + - name: Split new requests from closed-issue recovery + id: split + env: + DISCOVERY_MATRIX: ${{ steps.discover.outputs.matrix }} + run: | + request=$(jq -c '{include:[.include[] | select(.role == "approval")]}' \ + <<<"$DISCOVERY_MATRIX") + recovery=$(jq -c '{include:[.include[] | select(.role == "approval-recovery")]}' \ + <<<"$DISCOVERY_MATRIX") + { + echo "request_count=$(jq '.include | length' <<<"$request")" + echo "request_matrix=$request" + echo "recovery_count=$(jq '.include | length' <<<"$recovery")" + echo "recovery_matrix=$recovery" + } >>"$GITHUB_OUTPUT" + + request: + name: Request approval for ${{ matrix.tag }} + needs: discover_requests + if: needs.discover_requests.outputs.request_count != '0' + strategy: + fail-fast: false + matrix: ${{ fromJSON(needs.discover_requests.outputs.request_matrix) }} + runs-on: ubuntu-latest + environment: release-control + permissions: + contents: read + concurrency: + group: approval-request-${{ matrix.workflowId }} + cancel-in-progress: false + steps: + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + with: + ref: ${{ matrix.automationCommit }} + - name: Verify frozen Worker checkout + env: + FROZEN_AUTOMATION_COMMIT: ${{ matrix.automationCommit }} + IDENTITY_AUTOMATION_COMMIT: ${{ matrix.automationCommit }} + run: '[[ "$IDENTITY_AUTOMATION_COMMIT" == "$FROZEN_AUTOMATION_COMMIT" && "$(git rev-parse HEAD)" == "$FROZEN_AUTOMATION_COMMIT" ]]' + - uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5 + with: + distribution: temurin + java-version: "17" + - uses: gradle/actions/setup-gradle@ac396bf1a80af16236baf54bd7330ae21dc6ece5 # v6 + - name: Mint approval GitHub App token + id: github-token + uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0 + with: + client-id: ${{ vars.RELEASE_GITHUB_APP_CLIENT_ID }} + private-key: ${{ secrets.RELEASE_GITHUB_APP_PRIVATE_KEY }} + owner: temporalio + repositories: sdk-java + permission-issues: write + - name: Create immutable approval issue + id: issue + env: + GH_TOKEN: ${{ steps.github-token.outputs.token }} + GITHUB_APP_SLUG: ${{ steps.github-token.outputs.app-slug }} + COMMIT_SHA: ${{ matrix.commitSha }} + MANIFEST_SHA256: ${{ matrix.manifestSha256 }} + NOTES_SHA256: ${{ matrix.notesSha256 }} + RELEASE_DIGEST: ${{ matrix.releaseDigest }} + RELEASE_TAG: ${{ matrix.tag }} + RUN_ID: ${{ matrix.runId }} + TRUSTED_AUTOMATION_COMMIT: ${{ matrix.automationCommit }} + WORKFLOW_ID: ${{ matrix.workflowId }} + run: | + body=$(printf '%s\n' \ + '## Exact sdk-java release approval' \ + '' \ + "- Tag: \`$RELEASE_TAG\`" \ + "- Full commit SHA: \`$COMMIT_SHA\`" \ + "- Release-note SHA-256: \`$NOTES_SHA256\`" \ + "- Artifact-manifest SHA-256: \`$MANIFEST_SHA256\`" \ + "- Release digest: \`$RELEASE_DIGEST\`" \ + "- Temporal Workflow: \`$WORKFLOW_ID\`" \ + "- Temporal Run: \`$RUN_ID\`" \ + '' \ + 'An active temporalio/sdk team member approves this exact identity by clicking **Close issue**. Do not edit this issue.') + printf '%s' "$body" >"$RUNNER_TEMP/approval-body.md" + title="[sdk-java release approval] $RELEASE_TAG at $COMMIT_SHA" + [[ "$GITHUB_APP_SLUG" =~ ^[a-z0-9-]+$ ]] + approval_creator="${GITHUB_APP_SLUG}[bot]" + issues=$(gh api --paginate --slurp \ + 'repos/temporalio/sdk-java/issues?state=all&per_page=100') + matches=$(jq -c --arg title "$title" --arg body "$body" --arg creator "$approval_creator" \ + '[.[][] | select((has("pull_request") | not) and .title == $title and + .body == $body and .user.login == $creator)]' \ + <<<"$issues") + [[ $(jq 'length' <<<"$matches") -le 1 ]] + if [[ $(jq 'length' <<<"$matches") -eq 1 ]]; then + issue=$(jq -c '.[0]' <<<"$matches") + else + issue=$(gh api --method POST repos/temporalio/sdk-java/issues \ + --raw-field title="$title" --raw-field body="$body") + fi + [[ $(jq -r .user.login <<<"$issue") == "$approval_creator" ]] + number=$(jq -er .number <<<"$issue") + node_id=$(jq -er .node_id <<<"$issue") + body_sha256=$(sha256sum "$RUNNER_TEMP/approval-body.md" | awk '{print $1}') + gh api --method PUT "repos/temporalio/sdk-java/issues/$number/lock" + { + echo "number=$number" + echo "node_id=$node_id" + echo "body_sha256=$body_sha256" + echo "creator=$approval_creator" + echo "url=$(jq -er .html_url <<<"$issue")" + } >>"$GITHUB_OUTPUT" + - name: Record release-specific approval request + env: + APPROVAL_ISSUE_BODY_SHA256: ${{ steps.issue.outputs.body_sha256 }} + APPROVAL_ISSUE_CREATOR: ${{ steps.issue.outputs.creator }} + APPROVAL_ISSUE_NODE_ID: ${{ steps.issue.outputs.node_id }} + APPROVAL_ISSUE_NUMBER: ${{ steps.issue.outputs.number }} + EXPECTED_WORKFLOW_ID: ${{ matrix.workflowId }} + GH_TOKEN: ${{ steps.github-token.outputs.token }} + RELEASE_AUTOMATION_REF: ${{ matrix.automationCommit }} + run: ./gradlew -p .github/release-automation run --args="approval-request" + - name: Summarize approval request + env: + APPROVAL_URL: ${{ steps.issue.outputs.url }} + run: | + { + echo "## Exact approval requested" + echo + echo "Inspect the immutable identity in [$APPROVAL_URL]($APPROVAL_URL), then approve by clicking **Close issue**." + echo "The locked issue does not expire and requires no text input." + } >>"$GITHUB_STEP_SUMMARY" + + recover_closed_issue: + name: Recover approval for ${{ matrix.tag }} + needs: discover_requests + if: needs.discover_requests.outputs.recovery_count != '0' + strategy: + fail-fast: false + matrix: ${{ fromJSON(needs.discover_requests.outputs.recovery_matrix) }} + runs-on: ubuntu-latest + environment: release-control + concurrency: + group: approval-request-${{ matrix.workflowId }} + cancel-in-progress: false + steps: + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + with: + ref: ${{ matrix.automationCommit }} + - name: Verify frozen Worker checkout + env: + FROZEN_AUTOMATION_COMMIT: ${{ matrix.automationCommit }} + IDENTITY_AUTOMATION_COMMIT: ${{ matrix.automationCommit }} + run: '[[ "$IDENTITY_AUTOMATION_COMMIT" == "$FROZEN_AUTOMATION_COMMIT" && "$(git rev-parse HEAD)" == "$FROZEN_AUTOMATION_COMMIT" ]]' + - uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5 + with: + distribution: temurin + java-version: "17" + - uses: gradle/actions/setup-gradle@ac396bf1a80af16236baf54bd7330ae21dc6ece5 # v6 + - name: Mint approval GitHub App token + id: github-token + uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0 + with: + client-id: ${{ vars.RELEASE_GITHUB_APP_CLIENT_ID }} + private-key: ${{ secrets.RELEASE_GITHUB_APP_PRIVATE_KEY }} + owner: temporalio + repositories: sdk-java + permission-issues: read + permission-members: read + - name: Inspect the exact bound issue + id: issue + env: + EXPECTED_BODY_SHA256: ${{ matrix.approvalIssueBodySha256 }} + EXPECTED_NODE_ID: ${{ matrix.approvalIssueNodeId }} + GH_TOKEN: ${{ steps.github-token.outputs.token }} + ISSUE_NUMBER: ${{ matrix.approvalIssueNumber }} + run: | + issue=$(gh api "repos/temporalio/sdk-java/issues/$ISSUE_NUMBER") + [[ $(jq -r .state <<<"$issue") == closed ]] || exit 0 + body=$(jq -r .body <<<"$issue") + body_sha256=$(printf '%s' "$body" | sha256sum | awk '{print $1}') + jq -e --arg node "$EXPECTED_NODE_ID" \ + '.node_id == $node and .locked == true and + (.closed_by.login | test("^[A-Za-z0-9-]{1,39}$"))' <<<"$issue" >/dev/null + [[ "$body_sha256" == "$EXPECTED_BODY_SHA256" ]] + { + echo "closed=true" + echo "actor=$(jq -er .closed_by.login <<<"$issue")" + echo "body_sha256=$body_sha256" + } >>"$GITHUB_OUTPUT" + - name: Recover the approval Update from the durable closed issue + if: steps.issue.outputs.closed == 'true' + env: + APPROVAL_ISSUE_BODY_SHA256: ${{ steps.issue.outputs.body_sha256 }} + APPROVAL_ISSUE_NODE_ID: ${{ matrix.approvalIssueNodeId }} + APPROVAL_ISSUE_NUMBER: ${{ matrix.approvalIssueNumber }} + GH_TOKEN: ${{ steps.github-token.outputs.token }} + GITHUB_EVENT_NAME: schedule + GITHUB_TRIGGERING_ACTOR: ${{ steps.issue.outputs.actor }} + RELEASE_AUTOMATION_REF: ${{ matrix.automationCommit }} + run: ./gradlew -p .github/release-automation run --args="approve" + + approve: + if: >- + github.event_name == 'issues' && github.ref == 'refs/heads/main' && + startsWith(github.event.issue.title, '[sdk-java release approval]') + runs-on: ubuntu-latest + environment: release-control + steps: + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + with: + path: bootstrap + ref: ${{ vars.RELEASE_AUTOMATION_REF }} + - name: Verify bootstrap checkout + env: + RELEASE_AUTOMATION_REF: ${{ vars.RELEASE_AUTOMATION_REF }} + run: '[[ "$RELEASE_AUTOMATION_REF" =~ ^[0-9a-f]{40}$ && "$(git -C bootstrap rev-parse HEAD)" == "$RELEASE_AUTOMATION_REF" ]]' + - uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5 + with: + distribution: temurin + java-version: "17" + - uses: gradle/actions/setup-gradle@ac396bf1a80af16236baf54bd7330ae21dc6ece5 # v6 + - name: Resolve the release bound to this issue + id: target + working-directory: bootstrap + run: ./gradlew -p .github/release-automation run --args="approval-target" + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + with: + path: automation + ref: ${{ steps.target.outputs.automation_commit }} + - name: Verify protected automation identity + env: + IDENTITY_AUTOMATION_COMMIT: ${{ steps.target.outputs.automation_commit }} + RELEASE_AUTOMATION_REF: ${{ vars.RELEASE_AUTOMATION_REF }} + run: | + [[ ",$RELEASE_AUTOMATION_REF,$RELEASE_AUTOMATION_COMPATIBLE_REFS," == \ + *",$IDENTITY_AUTOMATION_COMMIT,"* ]] + [[ "$(git -C automation rev-parse HEAD)" == "$IDENTITY_AUTOMATION_COMMIT" ]] + - name: Mint approval GitHub App token + id: github-token + uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0 + with: + client-id: ${{ vars.RELEASE_GITHUB_APP_CLIENT_ID }} + private-key: ${{ secrets.RELEASE_GITHUB_APP_PRIVATE_KEY }} + owner: temporalio + repositories: sdk-java + permission-members: read + - name: Read exact closed-issue event identity + id: event + run: | + python3 - "$GITHUB_EVENT_PATH" "$GITHUB_OUTPUT" <<'PY' + import hashlib, json, sys + issue = json.load(open(sys.argv[1], encoding="utf-8"))["issue"] + with open(sys.argv[2], "a", encoding="utf-8") as output: + output.write(f"number={issue['number']}\n") + output.write(f"node_id={issue['node_id']}\n") + output.write(f"body_sha256={hashlib.sha256(issue['body'].encode()).hexdigest()}\n") + PY + - name: Submit approval Update for the exact closed issue + id: approve + working-directory: automation + env: + APPROVAL_ISSUE_BODY_SHA256: ${{ steps.event.outputs.body_sha256 }} + APPROVAL_ISSUE_NODE_ID: ${{ steps.event.outputs.node_id }} + APPROVAL_ISSUE_NUMBER: ${{ steps.event.outputs.number }} + GH_TOKEN: ${{ steps.github-token.outputs.token }} + RELEASE_AUTOMATION_REF: ${{ steps.target.outputs.automation_commit }} + run: ./gradlew -p .github/release-automation run --args="approve" + - name: Summarize accepted approval + env: + RELEASE_DIGEST: ${{ steps.approve.outputs.release_digest }} + RELEASE_TAG: ${{ steps.approve.outputs.tag }} + run: | + { + echo "## Release approved" + echo + echo "- Tag: \`$RELEASE_TAG\`" + echo "- Release digest: \`$RELEASE_DIGEST\`" + echo "- Approval issue: \`#$APPROVAL_ISSUE_NUMBER\`" + } >>"$GITHUB_STEP_SUMMARY" diff --git a/.github/workflows/temporal-release-automation-ci.yml b/.github/workflows/temporal-release-automation-ci.yml new file mode 100644 index 0000000000..3d82570c7a --- /dev/null +++ b/.github/workflows/temporal-release-automation-ci.yml @@ -0,0 +1,81 @@ +name: Temporal release automation CI + +defaults: + run: + shell: bash + +on: + pull_request: + paths: + - ".github/release-automation/**" + - ".github/scripts/temporal-release/**" + - ".github/workflows/temporal-release-*.yml" + - ".github/workflows/prepare-release.yml" + - ".github/workflows/build-native-image.yml" + - "gradle/publishing.gradle" + - "gradle/versioning.gradle" + push: + branches: + - main + paths: + - ".github/release-automation/**" + - ".github/scripts/temporal-release/**" + - ".github/workflows/temporal-release-*.yml" + - ".github/workflows/prepare-release.yml" + - ".github/workflows/build-native-image.yml" + - "gradle/publishing.gradle" + - "gradle/versioning.gradle" + +permissions: + contents: read + +jobs: + local_only: + name: Format, compile, and run local tests + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + with: + submodules: recursive + - uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5 + with: + distribution: temurin + java-version: "21" + - uses: gradle/actions/setup-gradle@ac396bf1a80af16236baf54bd7330ae21dc6ece5 # v6 + - name: Check shell syntax + run: | + for script in .github/scripts/temporal-release/*.sh \ + .github/release-automation/docker/native-image-musl-java23/install-musl.sh; do + bash -n "$script" + done + - name: Test exact GitHub artifact handling + run: | + .github/scripts/temporal-release/test-github-artifacts.sh + .github/scripts/temporal-release/test-native-packaging.sh + - name: Parse workflow YAML + run: >- + ruby -e 'require "yaml"; Dir[".github/workflows/*.yml"].each { + |workflow| YAML.parse_file(workflow) }' + - name: Verify formatting and local tests + run: ./gradlew -p .github/release-automation spotlessCheck test + + process_portability: + name: Trusted process launch (${{ matrix.os }}) + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, windows-latest] + runs-on: ${{ matrix.os }} + steps: + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + with: + submodules: recursive + - uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5 + with: + distribution: temurin + java-version: "21" + - uses: gradle/actions/setup-gradle@ac396bf1a80af16236baf54bd7330ae21dc6ece5 # v6 + - name: Verify explicit Bash process construction + run: >- + ./gradlew -p .github/release-automation test + --tests io.temporal.releaseautomation.ProcessSupportTest diff --git a/.github/workflows/temporal-release-candidate-retry.yml b/.github/workflows/temporal-release-candidate-retry.yml new file mode 100644 index 0000000000..0dce2777f3 --- /dev/null +++ b/.github/workflows/temporal-release-candidate-retry.yml @@ -0,0 +1,31 @@ +name: Retry failed Temporal release bootstrap + +on: + workflow_run: + workflows: + - Start Temporal-backed release candidate + types: + - completed + +permissions: + actions: write + contents: read + +jobs: + retry_failed_jobs: + if: >- + github.event.workflow_run.conclusion == 'failure' && + github.event.workflow_run.run_attempt < 6 + runs-on: ubuntu-latest + steps: + - name: Validate and retry the exact failed push run + env: + FAILED_RUN_ID: ${{ github.event.workflow_run.id }} + GH_TOKEN: ${{ github.token }} + run: | + jq -e '.workflow_run.name == "Start Temporal-backed release candidate" and + (.workflow_run.event == "push" or .workflow_run.event == "workflow_run") and + .workflow_run.head_repository.full_name == "temporalio/sdk-java" and + (.workflow_run.head_sha | test("^[0-9a-f]{40}$"))' "$GITHUB_EVENT_PATH" >/dev/null + gh api --method POST \ + "repos/temporalio/sdk-java/actions/runs/$FAILED_RUN_ID/rerun-failed-jobs" diff --git a/.github/workflows/temporal-release-candidate.yml b/.github/workflows/temporal-release-candidate.yml new file mode 100644 index 0000000000..9720ee68b3 --- /dev/null +++ b/.github/workflows/temporal-release-candidate.yml @@ -0,0 +1,165 @@ +name: Start Temporal-backed release candidate + +defaults: + run: + shell: bash + +on: + push: + branches: + - main + - "releases/**" + - "v*.*.x" + - "*.*.x" + - "release_*_*_x" + paths: + - "releases/v*" + workflow_run: + workflows: + - Continuous Integration + types: + - completed + schedule: + - cron: "1,16,31,46 * * * *" + workflow_dispatch: + +permissions: + contents: read + +env: + TEMPORAL_ADDRESS: ${{ vars.TEMPORAL_RELEASE_ADDRESS }} + TEMPORAL_NAMESPACE: ${{ vars.TEMPORAL_RELEASE_NAMESPACE }} + TEMPORAL_API_KEY: ${{ secrets.TEMPORAL_RELEASE_UNPRIVILEGED_API_KEY }} + +jobs: + discover: + if: github.event_name != 'schedule' || github.ref == 'refs/heads/main' + runs-on: ubuntu-latest + outputs: + count: ${{ steps.matrix.outputs.count }} + matrix: ${{ steps.matrix.outputs.matrix }} + steps: + - name: Discover exact release-note commits + id: matrix + env: + GH_TOKEN: ${{ github.token }} + RELEASE_AUTOMATION_REF: ${{ vars.RELEASE_AUTOMATION_REF }} + WORKFLOW_RUN_BRANCH: ${{ github.event.workflow_run.head_branch }} + WORKFLOW_RUN_CONCLUSION: ${{ github.event.workflow_run.conclusion }} + WORKFLOW_RUN_EVENT: ${{ github.event.workflow_run.event }} + WORKFLOW_RUN_REPOSITORY: ${{ github.event.workflow_run.head_repository.full_name }} + WORKFLOW_RUN_SHA: ${{ github.event.workflow_run.head_sha }} + run: | + [[ "$RELEASE_AUTOMATION_REF" =~ ^[0-9a-f]{40}$ ]] + : >"$RUNNER_TEMP/candidates.jsonl" + add_source() { + local source_sha=$1 base_sha=$2 + [[ "$source_sha" =~ ^[0-9a-f]{40}$ && "$base_sha" =~ ^[0-9a-f]{40}$ ]] + jq -cn --arg sourceSha "$source_sha" --arg baseSha "$base_sha" \ + --arg automationCommit "$RELEASE_AUTOMATION_REF" \ + '{sourceSha:$sourceSha,baseSha:$baseSha,automationCommit:$automationCommit}' \ + >>"$RUNNER_TEMP/candidates.jsonl" + } + if [[ "$GITHUB_EVENT_NAME" == push ]]; then + base_sha='${{ github.event.before }}' + [[ "$base_sha" =~ ^0+$ ]] && base_sha=$(gh api \ + "repos/temporalio/sdk-java/commits/$GITHUB_SHA" --jq '.parents[0].sha') + add_source "$GITHUB_SHA" "$base_sha" + elif [[ "$GITHUB_EVENT_NAME" == workflow_run ]]; then + if [[ "$WORKFLOW_RUN_CONCLUSION" == success && "$WORKFLOW_RUN_EVENT" == push && + "$WORKFLOW_RUN_REPOSITORY" == temporalio/sdk-java && + "$WORKFLOW_RUN_BRANCH" != main && + ( "$WORKFLOW_RUN_BRANCH" == v*.*.x || "$WORKFLOW_RUN_BRANCH" == *.*.x || + "$WORKFLOW_RUN_BRANCH" == release_*_*_x || + "$WORKFLOW_RUN_BRANCH" == releases/* ) ]]; then + parent=$(gh api "repos/temporalio/sdk-java/commits/$WORKFLOW_RUN_SHA" \ + --jq '.parents[0].sha') + add_source "$WORKFLOW_RUN_SHA" "$parent" + fi + else + gh api --paginate --slurp 'repos/temporalio/sdk-java/git/matching-refs/tags/' | + jq -r '.[][] | .ref' | sort -u >"$RUNNER_TEMP/tag-refs.txt" + gh api --paginate --slurp 'repos/temporalio/sdk-java/branches?per_page=100' \ + >"$RUNNER_TEMP/branches.json" + while IFS=$'\t' read -r branch head; do + if [[ "$branch" != main && "$branch" != v*.*.x && "$branch" != *.*.x && + "$branch" != release_*_*_x && "$branch" != releases/* ]]; then + continue + fi + gh api "repos/temporalio/sdk-java/git/trees/$head?recursive=1" \ + >"$RUNNER_TEMP/tree.json" + while IFS= read -r path; do + tag=${path#releases/} + [[ "$tag" =~ ^v[0-9]+\.[0-9]+\.[0-9]+(-RC[0-9]+)?$ ]] || continue + grep -Fxq "refs/tags/$tag" "$RUNNER_TEMP/tag-refs.txt" && continue + commits=$(gh api --method GET repos/temporalio/sdk-java/commits \ + -f path="$path" -f sha="$branch" -f per_page=1) + add_source "$(jq -er '.[0].sha' <<<"$commits")" \ + "$(jq -er '.[0].parents[0].sha' <<<"$commits")" + done < <(jq -r '.tree[] | select(.type == "blob") | .path | + select(startswith("releases/v"))' "$RUNNER_TEMP/tree.json") + done < <(jq -r '.[][] | [.name,.commit.sha] | @tsv' "$RUNNER_TEMP/branches.json") + fi + candidates=$(jq -sc 'unique_by(.sourceSha)' "$RUNNER_TEMP/candidates.jsonl") + { + echo "count=$(jq 'length' <<<"$candidates")" + echo "matrix=$(jq -c '{include:.}' <<<"$candidates")" + } >>"$GITHUB_OUTPUT" + + start: + needs: discover + if: needs.discover.outputs.count != '0' + strategy: + fail-fast: false + matrix: ${{ fromJSON(needs.discover.outputs.matrix) }} + runs-on: ubuntu-latest + timeout-minutes: 20 + concurrency: + group: temporal-release-bootstrap-${{ matrix.sourceSha }} + cancel-in-progress: false + steps: + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + with: + fetch-depth: 0 + path: source + persist-credentials: false + ref: ${{ matrix.sourceSha }} + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + with: + path: automation + persist-credentials: false + ref: ${{ matrix.automationCommit }} + - name: Verify trusted checkout + env: + RELEASE_AUTOMATION_REF: ${{ vars.RELEASE_AUTOMATION_REF }} + run: | + [[ "${{ matrix.automationCommit }}" == "$RELEASE_AUTOMATION_REF" ]] + [[ "$(git -C automation rev-parse HEAD)" == "$RELEASE_AUTOMATION_REF" ]] + - uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5 + with: + distribution: temurin + java-version: "17" + - uses: gradle/actions/setup-gradle@ac396bf1a80af16236baf54bd7330ae21dc6ece5 # v6 + - name: Resolve immutable candidate + id: metadata + working-directory: source + env: + BASE_SHA: ${{ matrix.baseSha }} + RELEASE_AUTOMATION_REF: ${{ matrix.automationCommit }} + RELEASE_COMMIT: ${{ matrix.sourceSha }} + TRUSTED_AUTOMATION_ROOT: ${{ github.workspace }}/automation + run: ../automation/.github/scripts/temporal-release/resolve-candidate.sh + - name: Start or attach to Candidate Workflow + id: temporal + working-directory: automation + env: + CANDIDATE_FILE: ${{ steps.metadata.outputs.candidate_file }} + run: ./gradlew -p .github/release-automation run --args="start-candidate $CANDIDATE_FILE" + - name: Poll only this candidate Workflow queue + working-directory: automation + env: + EXPECTED_RUN_ID: ${{ steps.temporal.outputs.run_id }} + EXPECTED_WORKFLOW_ID: ${{ steps.temporal.outputs.workflow_id }} + run: >- + ./gradlew -p .github/release-automation run + --args="worker candidate ${{ steps.temporal.outputs.task_queue }}" diff --git a/.github/workflows/temporal-release-control.yml b/.github/workflows/temporal-release-control.yml new file mode 100644 index 0000000000..235d73e96c --- /dev/null +++ b/.github/workflows/temporal-release-control.yml @@ -0,0 +1,252 @@ +name: Control Temporal-backed release +run-name: ${{ inputs.action }} ${{ inputs.tag }} at ${{ inputs.commit_sha }} + +on: + workflow_dispatch: + inputs: + action: + description: Authenticated control operation + required: true + type: choice + options: + - inspect + - pause + - resume + - handoff-manual + - retry-maven-submission + tag: + description: Exact release tag + required: true + type: string + commit_sha: + description: Exact full 40-character release commit SHA + required: true + type: string + +permissions: + contents: read + +env: + CONTROL_ACTION: ${{ inputs.action }} + RELEASE_COMMIT: ${{ inputs.commit_sha }} + RELEASE_TAG: ${{ inputs.tag }} + TEMPORAL_ADDRESS: ${{ vars.TEMPORAL_RELEASE_ADDRESS }} + TEMPORAL_NAMESPACE: ${{ vars.TEMPORAL_RELEASE_NAMESPACE }} + TEMPORAL_API_KEY: ${{ secrets.TEMPORAL_RELEASE_APPROVAL_API_KEY }} + RELEASE_AUTOMATION_REF: ${{ vars.RELEASE_AUTOMATION_REF }} + RELEASE_AUTOMATION_COMPATIBLE_REFS: ${{ vars.RELEASE_AUTOMATION_COMPATIBLE_REFS }} + GITHUB_TRIGGERING_ACTOR: ${{ github.actor }} + +jobs: + resolve: + if: github.ref == 'refs/heads/main' + runs-on: ubuntu-latest + environment: release-control + outputs: + automation_commit: ${{ steps.target.outputs.automation_commit }} + maven_complete: ${{ steps.target.outputs.maven_complete }} + maven_started: ${{ steps.target.outputs.maven_started }} + phase: ${{ steps.target.outputs.phase }} + paused_from: ${{ steps.target.outputs.paused_from }} + release_digest: ${{ steps.target.outputs.release_digest }} + steps: + - name: Validate exact inputs and trusted automation pin + env: + COMMIT_SHA: ${{ inputs.commit_sha }} + RELEASE_TAG: ${{ inputs.tag }} + run: | + [[ "$RELEASE_AUTOMATION_REF" =~ ^[0-9a-f]{40}$ ]] + [[ "$COMMIT_SHA" =~ ^[0-9a-f]{40}$ ]] + [[ "$RELEASE_TAG" =~ ^v[0-9]+\.[0-9]+\.[0-9]+(-RC[0-9]+)?$ ]] + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + with: + path: bootstrap + ref: ${{ vars.RELEASE_AUTOMATION_REF }} + - name: Verify bootstrap checkout + run: '[[ "$(git -C bootstrap rev-parse HEAD)" == "$RELEASE_AUTOMATION_REF" ]]' + - uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5 + with: + distribution: temurin + java-version: "17" + - uses: gradle/actions/setup-gradle@ac396bf1a80af16236baf54bd7330ae21dc6ece5 # v6 + - name: Resolve the release's frozen Worker version + id: target + working-directory: bootstrap + run: >- + ./gradlew -p .github/release-automation run + --args="inspect $RELEASE_TAG $RELEASE_COMMIT" + - name: Enforce the protected Worker allowlist + env: + FROZEN_AUTOMATION_COMMIT: ${{ steps.target.outputs.automation_commit }} + run: | + [[ "$FROZEN_AUTOMATION_COMMIT" =~ ^[0-9a-f]{40}$ ]] + while IFS= read -r compatible; do + [[ -z "$compatible" || "$compatible" =~ ^[0-9a-f]{40}$ ]] + done < <(tr ',' '\n' <<<"$RELEASE_AUTOMATION_COMPATIBLE_REFS") + [[ ",$RELEASE_AUTOMATION_REF,$RELEASE_AUTOMATION_COMPATIBLE_REFS," == \ + *",$FROZEN_AUTOMATION_COMMIT,"* ]] + - name: Summarize durable state + env: + PHASE: ${{ steps.target.outputs.phase }} + RELEASE_DIGEST: ${{ steps.target.outputs.release_digest }} + LAST_ERROR: ${{ steps.target.outputs.last_error }} + MAVEN_COMPLETE: ${{ steps.target.outputs.maven_complete }} + MAVEN_STARTED: ${{ steps.target.outputs.maven_started }} + PAUSED_FROM: ${{ steps.target.outputs.paused_from }} + SONATYPE_REPOSITORY_ID: ${{ steps.target.outputs.sonatype_repository_id }} + PORTAL_DEPLOYMENT_ID: ${{ steps.target.outputs.portal_deployment_id }} + run: | + { + echo "## Durable release state" + echo + echo "- Phase: \`$PHASE\`" + echo "- Paused from: \`${PAUSED_FROM:-none}\`" + echo "- Maven started: \`$MAVEN_STARTED\`" + echo "- Maven complete: \`$MAVEN_COMPLETE\`" + echo "- Release digest: \`$RELEASE_DIGEST\`" + echo "- Last error: \`${LAST_ERROR:-none}\`" + echo "- Sonatype repository: \`${SONATYPE_REPOSITORY_ID:-none}\`" + echo "- Portal deployment: \`${PORTAL_DEPLOYMENT_ID:-none}\`" + } >>"$GITHUB_STEP_SUMMARY" + + control: + needs: resolve + if: inputs.action != 'inspect' && inputs.action != 'retry-maven-submission' + runs-on: ubuntu-latest + environment: release-control + steps: + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + with: + ref: ${{ needs.resolve.outputs.automation_commit }} + - name: Verify frozen Worker checkout + env: + FROZEN_AUTOMATION_COMMIT: ${{ needs.resolve.outputs.automation_commit }} + IDENTITY_AUTOMATION_COMMIT: ${{ needs.resolve.outputs.automation_commit }} + run: '[[ "$IDENTITY_AUTOMATION_COMMIT" == "$FROZEN_AUTOMATION_COMMIT" && "$(git rev-parse HEAD)" == "$FROZEN_AUTOMATION_COMMIT" ]]' + - uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5 + with: + distribution: temurin + java-version: "17" + - uses: gradle/actions/setup-gradle@ac396bf1a80af16236baf54bd7330ae21dc6ece5 # v6 + - name: Mint approval GitHub App token + id: github-token + uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0 + with: + client-id: ${{ vars.RELEASE_GITHUB_APP_CLIENT_ID }} + private-key: ${{ secrets.RELEASE_GITHUB_APP_PRIVATE_KEY }} + owner: temporalio + repositories: sdk-java + permission-members: read + - name: Submit authenticated release-specific Update + env: + GH_TOKEN: ${{ steps.github-token.outputs.token }} + RELEASE_AUTOMATION_REF: ${{ needs.resolve.outputs.automation_commit }} + run: >- + ./gradlew -p .github/release-automation run + --args="control $CONTROL_ACTION $RELEASE_TAG $RELEASE_COMMIT" + + authorize_maven_retry: + needs: resolve + if: inputs.action == 'retry-maven-submission' + runs-on: ubuntu-latest + timeout-minutes: 45 + environment: release-publication + permissions: + contents: read + steps: + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + with: + fetch-depth: 0 + path: source + persist-credentials: false + ref: ${{ inputs.commit_sha }} + submodules: recursive + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + with: + path: automation + ref: ${{ needs.resolve.outputs.automation_commit }} + - name: Verify frozen Worker checkout + env: + FROZEN_AUTOMATION_COMMIT: ${{ needs.resolve.outputs.automation_commit }} + IDENTITY_AUTOMATION_COMMIT: ${{ needs.resolve.outputs.automation_commit }} + run: '[[ "$IDENTITY_AUTOMATION_COMMIT" == "$FROZEN_AUTOMATION_COMMIT" && "$(git -C automation rev-parse HEAD)" == "$FROZEN_AUTOMATION_COMMIT" ]]' + - uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5 + with: + distribution: temurin + java-version: "17" + - uses: gradle/actions/setup-gradle@ac396bf1a80af16236baf54bd7330ae21dc6ece5 # v6 + - name: Mint approval GitHub App token + id: approval-token + uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0 + with: + client-id: ${{ vars.RELEASE_GITHUB_APP_CLIENT_ID }} + private-key: ${{ secrets.RELEASE_GITHUB_APP_PRIVATE_KEY }} + owner: temporalio + repositories: sdk-java + permission-members: read + - name: Mint publication inspection GitHub App token + id: publication-token + uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0 + with: + client-id: ${{ vars.RELEASE_GITHUB_APP_CLIENT_ID }} + private-key: ${{ secrets.RELEASE_GITHUB_APP_PRIVATE_KEY }} + owner: temporalio + repositories: sdk-java + permission-actions: read + permission-contents: read + permission-issues: read + permission-members: read + - name: Authenticate the retry author + env: + GH_TOKEN: ${{ steps.approval-token.outputs.token }} + run: automation/.github/scripts/temporal-release/verify-approver.sh "$GITHUB_TRIGGERING_ACTOR" + - name: Export the exact approved publication input + id: input + working-directory: automation + run: >- + ./gradlew -p .github/release-automation run + --args="publication-input $RELEASE_TAG $RELEASE_COMMIT $RUNNER_TEMP/publication-input.json" + - name: Perform a fresh exact external inspection + working-directory: source + env: + GH_TOKEN: ${{ steps.publication-token.outputs.token }} + RELEASE_INPUT_FILE: ${{ runner.temp }}/publication-input.json + RELEASE_MAVEN_ARTIFACTS_FILE: ${{ runner.temp }}/maven-artifacts.json + RELEASE_OUTPUT_FILE: ${{ runner.temp }}/fresh-inspection.json + RELEASE_STAGE: inspect + RH_PASSWORD: ${{ secrets.RH_PASSWORD }} + RH_USER: ${{ secrets.RH_USER }} + TRUSTED_AUTOMATION_ROOT: ${{ github.workspace }}/automation + TRUSTED_WORKER_COMMIT: ${{ needs.resolve.outputs.automation_commit }} + run: | + outputs="$RUNNER_TEMP/maven-policy.outputs" + GITHUB_OUTPUT="$outputs" "$TRUSTED_AUTOMATION_ROOT/gradlew" \ + -p "$TRUSTED_AUTOMATION_ROOT/.github/release-automation" run \ + --args="maven-policy $PWD/settings.gradle" >/dev/null + awk -F= '$1 == "maven_artifacts_json" {sub(/^[^=]*=/, ""); print}' \ + "$outputs" >"$RELEASE_MAVEN_ARTIFACTS_FILE" + bash "$TRUSTED_AUTOMATION_ROOT/.github/scripts/temporal-release/reconcile-publication.sh" + - name: Select the inspected generation + id: authorization + env: + CURRENT_GENERATION: ${{ steps.input.outputs.maven_submission_generation }} + LAST_ERROR: ${{ steps.input.outputs.last_error }} + run: | + if [[ "$LAST_ERROR" == *InvalidApproval* && + $CURRENT_GENERATION -gt 0 ]]; then + generation=$CURRENT_GENERATION + else + [[ $(jq -r .centralPresent "$RUNNER_TEMP/fresh-inspection.json") == 0 ]] + generation=$((CURRENT_GENERATION + 1)) + fi + echo "generation=$generation" >>"$GITHUB_OUTPUT" + - name: Submit Update bound to the exact inspection + working-directory: automation + env: + GH_TOKEN: ${{ steps.approval-token.outputs.token }} + MAVEN_RETRY_GENERATION: ${{ steps.authorization.outputs.generation }} + MAVEN_RETRY_INSPECTION_FILE: ${{ runner.temp }}/fresh-inspection.json + RELEASE_AUTOMATION_REF: ${{ needs.resolve.outputs.automation_commit }} + run: >- + ./gradlew -p .github/release-automation run + --args="control retry-maven-submission $RELEASE_TAG $RELEASE_COMMIT" diff --git a/.github/workflows/temporal-release-preflight.yml b/.github/workflows/temporal-release-preflight.yml new file mode 100644 index 0000000000..b68e05ffc3 --- /dev/null +++ b/.github/workflows/temporal-release-preflight.yml @@ -0,0 +1,105 @@ +name: Release-note candidate preflight + +on: + pull_request: + paths: + - "releases/v*" + schedule: + - cron: "2,17,32,47 * * * *" + workflow_dispatch: + +permissions: + contents: read + pull-requests: read + +jobs: + discover: + runs-on: ubuntu-latest + outputs: + count: ${{ steps.matrix.outputs.count }} + matrix: ${{ steps.matrix.outputs.matrix }} + steps: + - name: Discover main or maintenance release-note proposals + id: matrix + env: + GH_TOKEN: ${{ github.token }} + run: | + : >"$RUNNER_TEMP/preflight.jsonl" + if [[ "$GITHUB_EVENT_NAME" == pull_request ]]; then + jq -cn --arg head '${{ github.event.pull_request.head.sha }}' \ + --arg base '${{ github.event.pull_request.base.sha }}' \ + --argjson number '${{ github.event.pull_request.number }}' \ + '{headSha:$head,baseSha:$base,pullRequest:$number}' \ + >>"$RUNNER_TEMP/preflight.jsonl" + else + gh api --paginate --slurp 'repos/temporalio/sdk-java/pulls?state=open&per_page=100' \ + >"$RUNNER_TEMP/pulls.json" + while IFS=$'\t' read -r number base head branch; do + if [[ "$branch" != main && "$branch" != v*.x && "$branch" != *.*.x && + "$branch" != release_*_x && "$branch" != releases/* ]]; then + continue + fi + files=$(gh api --paginate --slurp \ + "repos/temporalio/sdk-java/pulls/$number/files?per_page=100") + jq -e '[.[][] | select(.filename | startswith("releases/v"))] | length > 0' \ + <<<"$files" >/dev/null || continue + jq -cn --arg head "$head" --arg base "$base" --argjson number "$number" \ + '{headSha:$head,baseSha:$base,pullRequest:$number}' \ + >>"$RUNNER_TEMP/preflight.jsonl" + done < <(jq -r '.[][] | [.number,.base.sha,.head.sha,.base.ref] | @tsv' \ + "$RUNNER_TEMP/pulls.json") + fi + matrix=$(jq -sc '{include:.}' "$RUNNER_TEMP/preflight.jsonl") + { + echo "count=$(jq '.include | length' <<<"$matrix")" + echo "matrix=$matrix" + } >>"$GITHUB_OUTPUT" + + validate: + needs: discover + if: needs.discover.outputs.count != '0' + strategy: + fail-fast: false + matrix: ${{ fromJSON(needs.discover.outputs.matrix) }} + runs-on: ubuntu-latest + steps: + - name: Validate trusted automation pin + env: + RELEASE_AUTOMATION_REF: ${{ vars.RELEASE_AUTOMATION_REF }} + run: '[[ "$RELEASE_AUTOMATION_REF" =~ ^[0-9a-f]{40}$ ]]' + - name: Checkout immutable proposed source + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + with: + fetch-depth: 0 + path: source + persist-credentials: false + ref: ${{ matrix.headSha }} + - name: Checkout trusted release automation + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + with: + path: automation + ref: ${{ vars.RELEASE_AUTOMATION_REF }} + - name: Verify trusted checkout + env: + RELEASE_AUTOMATION_REF: ${{ vars.RELEASE_AUTOMATION_REF }} + run: '[[ "$(git -C automation rev-parse HEAD)" == "$RELEASE_AUTOMATION_REF" ]]' + - uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5 + with: + distribution: temurin + java-version: "17" + - uses: gradle/actions/setup-gradle@ac396bf1a80af16236baf54bd7330ae21dc6ece5 # v6 + - name: Validate immutable candidate metadata and prerequisites + id: candidate + working-directory: source + env: + BASE_SHA: ${{ matrix.baseSha }} + RELEASE_AUTOMATION_REF: ${{ vars.RELEASE_AUTOMATION_REF }} + RELEASE_COMMIT: ${{ matrix.headSha }} + TRUSTED_AUTOMATION_ROOT: ${{ github.workspace }}/automation + run: ../automation/.github/scripts/temporal-release/resolve-candidate.sh + - name: Validate generated group, artifacts, version, SHA, and Nexus tasks + working-directory: source + env: + RELEASE_CANDIDATE_FILE: ${{ steps.candidate.outputs.candidate_file }} + TRUSTED_AUTOMATION_ROOT: ${{ github.workspace }}/automation + run: ../automation/.github/scripts/temporal-release/validate-publication-model.sh diff --git a/.github/workflows/temporal-release-publish.yml b/.github/workflows/temporal-release-publish.yml new file mode 100644 index 0000000000..2acef6b2f5 --- /dev/null +++ b/.github/workflows/temporal-release-publish.yml @@ -0,0 +1,243 @@ +name: Publish approved Temporal-backed release + +defaults: + run: + shell: bash + +on: + workflow_run: + workflows: + - Approve Temporal-backed release + types: + - completed + schedule: + - cron: "10,25,40,55 * * * *" + workflow_dispatch: + +permissions: + contents: read + +env: + TEMPORAL_ADDRESS: ${{ vars.TEMPORAL_RELEASE_ADDRESS }} + TEMPORAL_NAMESPACE: ${{ vars.TEMPORAL_RELEASE_NAMESPACE }} + +jobs: + discover: + if: >- + github.ref == 'refs/heads/main' && + (github.event_name != 'workflow_run' || github.event.workflow_run.conclusion == 'success') + runs-on: ubuntu-latest + environment: release-control + outputs: + count: ${{ steps.temporal.outputs.count }} + matrix: ${{ steps.temporal.outputs.matrix }} + steps: + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + with: + ref: ${{ vars.RELEASE_AUTOMATION_REF }} + - uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5 + with: + distribution: temurin + java-version: "17" + - uses: gradle/actions/setup-gradle@ac396bf1a80af16236baf54bd7330ae21dc6ece5 # v6 + - name: Discover approved publication queues + id: temporal + env: + RELEASE_AUTOMATION_COMPATIBLE_REFS: ${{ vars.RELEASE_AUTOMATION_COMPATIBLE_REFS }} + RELEASE_AUTOMATION_REF: ${{ vars.RELEASE_AUTOMATION_REF }} + TEMPORAL_API_KEY: ${{ secrets.TEMPORAL_RELEASE_UNPRIVILEGED_API_KEY }} + run: ./gradlew -p .github/release-automation run --args="discover publication" + + publish: + needs: discover + if: needs.discover.outputs.count != '0' + strategy: + fail-fast: false + matrix: ${{ fromJSON(needs.discover.outputs.matrix) }} + runs-on: ubuntu-latest + timeout-minutes: 120 + concurrency: + group: sdk-java-release-publication + cancel-in-progress: false + environment: release-publication + permissions: + actions: read + contents: write + steps: + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + with: + path: automation + persist-credentials: false + ref: ${{ matrix.automationCommit }} + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + with: + fetch-depth: 0 + path: source + persist-credentials: false + ref: ${{ matrix.commitSha }} + submodules: recursive + - name: Verify frozen automation and source + env: + RELEASE_AUTOMATION_COMPATIBLE_REFS: ${{ vars.RELEASE_AUTOMATION_COMPATIBLE_REFS }} + RELEASE_AUTOMATION_REF: ${{ vars.RELEASE_AUTOMATION_REF }} + run: | + [[ ",${RELEASE_AUTOMATION_REF},${RELEASE_AUTOMATION_COMPATIBLE_REFS}," == \ + *",${{ matrix.automationCommit }},"* ]] + [[ "$(git -C automation rev-parse HEAD)" == "${{ matrix.automationCommit }}" ]] + [[ "$(git -C source rev-parse HEAD)" == "${{ matrix.commitSha }}" ]] + - uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5 + with: + distribution: temurin + java-version: "17" + - uses: gradle/actions/setup-gradle@ac396bf1a80af16236baf54bd7330ae21dc6ece5 # v6 + - name: Read durable release state after acquiring publication ownership + id: before + working-directory: automation + env: + TEMPORAL_API_KEY: ${{ secrets.TEMPORAL_RELEASE_PUBLICATION_API_KEY }} + run: >- + ./gradlew -p .github/release-automation run + --args="publication-input ${{ matrix.tag }} ${{ matrix.commitSha }} + $RUNNER_TEMP/publication-input-before.json" + - name: Refuse stale publication discovery + env: + OWNER: ${{ steps.before.outputs.ownership_owner }} + PHASE: ${{ steps.before.outputs.phase }} + run: | + [[ "$OWNER" == TEMPORAL ]] + [[ "$PHASE" == AWAITING_MAVEN_PAYLOAD || "$PHASE" == PREFLIGHT || + "$PHASE" == MAVEN_REPOSITORY || "$PHASE" == MAVEN_PORTAL || + "$PHASE" == MAVEN_PUBLISH || "$PHASE" == GITHUB_DRAFT || + "$PHASE" == PUBLISH_GITHUB ]] + - name: Mint artifact discovery GitHub App token + id: discovery-token + uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0 + with: + client-id: ${{ vars.RELEASE_GITHUB_APP_CLIENT_ID }} + private-key: ${{ secrets.RELEASE_GITHUB_APP_PRIVATE_KEY }} + owner: temporalio + repositories: sdk-java + permission-actions: read + - name: Find an unrecorded Maven payload artifact + id: existing + if: steps.before.outputs.maven_payload_recorded != 'true' + env: + GH_TOKEN: ${{ steps.discovery-token.outputs.token }} + GITHUB_ARTIFACT_NAME: sdk-java-release-maven-${{ matrix.releaseDigest }} + run: automation/.github/scripts/temporal-release/find-github-artifact.sh >>"$GITHUB_OUTPUT" + - name: Build and sign the frozen Maven payload + if: steps.before.outputs.maven_payload_recorded != 'true' && steps.existing.outputs.found != 'true' + working-directory: source + env: + JAR_SIGNING_KEY: ${{ secrets.JAR_SIGNING_KEY }} + JAR_SIGNING_KEY_ID: ${{ secrets.JAR_SIGNING_KEY_ID }} + JAR_SIGNING_KEY_PASSWORD: ${{ secrets.JAR_SIGNING_KEY_PASSWORD }} + MAVEN_ARTIFACTS_FILE: ${{ runner.temp }}/maven-artifacts.json + MAVEN_PAYLOAD_COMMIT: ${{ matrix.commitSha }} + MAVEN_PAYLOAD_OUTPUT: ${{ runner.temp }}/maven-artifact + MAVEN_PAYLOAD_RELEASE_DIGEST: ${{ matrix.releaseDigest }} + TRUSTED_AUTOMATION_COMMIT: ${{ matrix.automationCommit }} + TRUSTED_AUTOMATION_ROOT: ${{ github.workspace }}/automation + run: | + version=${{ matrix.tag }} + export MAVEN_PAYLOAD_VERSION=${version#v} + outputs="$RUNNER_TEMP/maven-policy.outputs" + GITHUB_OUTPUT="$outputs" "$TRUSTED_AUTOMATION_ROOT/gradlew" \ + -p "$TRUSTED_AUTOMATION_ROOT/.github/release-automation" run \ + --args="maven-policy $PWD/settings.gradle" >/dev/null + awk -F= '$1 == "maven_artifacts_json" {sub(/^[^=]*=/, ""); print}' \ + "$outputs" >"$MAVEN_ARTIFACTS_FILE" + "$TRUSTED_AUTOMATION_ROOT/.github/scripts/temporal-release/prepare-maven-payload.sh" + - name: Upload the frozen Maven payload + id: upload + if: steps.before.outputs.maven_payload_recorded != 'true' && steps.existing.outputs.found != 'true' + uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7 + with: + name: sdk-java-release-maven-${{ matrix.releaseDigest }} + path: ${{ runner.temp }}/maven-artifact + if-no-files-found: error + retention-days: 90 + - name: Mint artifact inspection GitHub App token + id: artifact-token + if: steps.before.outputs.maven_payload_recorded != 'true' + uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0 + with: + client-id: ${{ vars.RELEASE_GITHUB_APP_CLIENT_ID }} + private-key: ${{ secrets.RELEASE_GITHUB_APP_PRIVATE_KEY }} + owner: temporalio + repositories: sdk-java + permission-actions: read + - name: Download a recovered Maven payload by exact ID + if: steps.before.outputs.maven_payload_recorded != 'true' && steps.existing.outputs.found == 'true' + env: + GH_TOKEN: ${{ steps.artifact-token.outputs.token }} + GITHUB_ARTIFACT_DESTINATION: ${{ runner.temp }}/maven-artifact + GITHUB_ARTIFACT_DIGEST: ${{ steps.existing.outputs.github_digest }} + GITHUB_ARTIFACT_ID: ${{ steps.existing.outputs.artifact_id }} + GITHUB_ARTIFACT_NAME: sdk-java-release-maven-${{ matrix.releaseDigest }} + GITHUB_ARTIFACT_RUN_ID: ${{ steps.existing.outputs.workflow_run_id }} + run: automation/.github/scripts/temporal-release/download-github-artifact.sh + - name: Create the Maven artifact receipt + if: steps.before.outputs.maven_payload_recorded != 'true' + env: + GH_TOKEN: ${{ steps.artifact-token.outputs.token }} + GITHUB_ARTIFACT_CONTENT_DIR: ${{ runner.temp }}/maven-artifact + GITHUB_ARTIFACT_ID: ${{ steps.existing.outputs.artifact_id || steps.upload.outputs.artifact-id }} + GITHUB_ARTIFACT_NAME: sdk-java-release-maven-${{ matrix.releaseDigest }} + GITHUB_ARTIFACT_RECEIPT_FILE: ${{ runner.temp }}/maven-receipt.json + GITHUB_ARTIFACT_RUN_ID: ${{ steps.existing.outputs.workflow_run_id || github.run_id }} + run: automation/.github/scripts/temporal-release/github-artifact-receipt.sh + - name: Record the Maven artifact in Release Workflow state + if: steps.before.outputs.maven_payload_recorded != 'true' + working-directory: automation + env: + TEMPORAL_API_KEY: ${{ secrets.TEMPORAL_RELEASE_PUBLICATION_API_KEY }} + run: >- + ./gradlew -p .github/release-automation run + --args="record-maven-payload ${{ matrix.tag }} ${{ matrix.commitSha }} + $RUNNER_TEMP/maven-receipt.json" + - name: Export the exact Activity expectation + id: final + working-directory: automation + env: + TEMPORAL_API_KEY: ${{ secrets.TEMPORAL_RELEASE_PUBLICATION_API_KEY }} + run: >- + ./gradlew -p .github/release-automation run + --args="publication-input ${{ matrix.tag }} ${{ matrix.commitSha }} + $RUNNER_TEMP/publication-input.json" + - name: Recheck ownership immediately before polling + env: + OWNER: ${{ steps.final.outputs.ownership_owner }} + PHASE: ${{ steps.final.outputs.phase }} + run: | + [[ "$OWNER" == TEMPORAL ]] + [[ "$PHASE" != PAUSED && "$PHASE" != BLOCKED && "$PHASE" != HANDED_OFF && + "$PHASE" != PUBLISHED ]] + - name: Mint publication GitHub App token + id: publication-token + uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0 + with: + client-id: ${{ vars.RELEASE_GITHUB_APP_CLIENT_ID }} + private-key: ${{ secrets.RELEASE_GITHUB_APP_PRIVATE_KEY }} + owner: temporalio + repositories: sdk-java + permission-actions: read + permission-contents: write + permission-issues: read + permission-members: read + - name: Poll only the approved publication queue + timeout-minutes: 55 + working-directory: automation + env: + GH_TOKEN: ${{ steps.publication-token.outputs.token }} + RELEASE_AUTOMATION_REF: ${{ matrix.automationCommit }} + RELEASE_EXPECTATION_FILE: ${{ runner.temp }}/publication-input.json + RELEASE_SOURCE_DIR: ${{ github.workspace }}/source + RELEASE_TAG: ${{ matrix.tag }} + RH_PASSWORD: ${{ secrets.RH_PASSWORD }} + RH_USER: ${{ secrets.RH_USER }} + TEMPORAL_API_KEY: ${{ secrets.TEMPORAL_RELEASE_PUBLICATION_API_KEY }} + TRUSTED_WORKER_COMMIT: ${{ matrix.automationCommit }} + run: >- + ./gradlew -p .github/release-automation run + --args="worker publication ${{ matrix.taskQueue }}" diff --git a/.github/workflows/temporal-release-resume.yml b/.github/workflows/temporal-release-resume.yml new file mode 100644 index 0000000000..5d682196e5 --- /dev/null +++ b/.github/workflows/temporal-release-resume.yml @@ -0,0 +1,254 @@ +name: Resume Temporal-backed releases + +defaults: + run: + shell: bash + +on: + schedule: + - cron: "7,22,37,52 * * * *" + workflow_dispatch: + +permissions: + contents: read + +jobs: + discover: + if: github.ref == 'refs/heads/main' + runs-on: ubuntu-latest + environment: release-control + outputs: + build_count: ${{ steps.split.outputs.build_count }} + build_matrix: ${{ steps.split.outputs.build_matrix }} + worker_count: ${{ steps.split.outputs.worker_count }} + worker_matrix: ${{ steps.split.outputs.worker_matrix }} + steps: + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + with: + ref: ${{ vars.RELEASE_AUTOMATION_REF }} + - uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5 + with: + distribution: temurin + java-version: "17" + - uses: gradle/actions/setup-gradle@ac396bf1a80af16236baf54bd7330ae21dc6ece5 # v6 + - name: Discover open release queues + id: temporal + env: + RELEASE_AUTOMATION_COMPATIBLE_REFS: ${{ vars.RELEASE_AUTOMATION_COMPATIBLE_REFS }} + RELEASE_AUTOMATION_REF: ${{ vars.RELEASE_AUTOMATION_REF }} + TEMPORAL_ADDRESS: ${{ vars.TEMPORAL_RELEASE_ADDRESS }} + TEMPORAL_API_KEY: ${{ secrets.TEMPORAL_RELEASE_UNPRIVILEGED_API_KEY }} + TEMPORAL_NAMESPACE: ${{ vars.TEMPORAL_RELEASE_NAMESPACE }} + run: ./gradlew -p .github/release-automation run --args="discover unprivileged" + - name: Split builds from Workers + id: split + env: + MATRIX: ${{ steps.temporal.outputs.matrix }} + run: | + builds=$(jq -c '{include:[.include[] | select(.role == "build")]}' <<<"$MATRIX") + workers=$(jq -c '{include:[.include[] | select(.role != "build")]}' <<<"$MATRIX") + { + echo "build_count=$(jq '.include | length' <<<"$builds")" + echo "build_matrix=$builds" + echo "worker_count=$(jq '.include | length' <<<"$workers")" + echo "worker_matrix=$workers" + } >>"$GITHUB_OUTPUT" + + build_native: + needs: discover + if: needs.discover.outputs.build_count != '0' + strategy: + fail-fast: false + matrix: ${{ fromJSON(needs.discover.outputs.build_matrix) }} + runs-on: ${{ matrix.runner }} + timeout-minutes: 120 + permissions: + actions: read + contents: read + concurrency: + group: temporal-release-native-${{ matrix.candidateDigest }}-${{ matrix.platform }} + cancel-in-progress: false + steps: + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + with: + fetch-depth: 0 + path: source + persist-credentials: false + ref: ${{ matrix.commitSha }} + submodules: recursive + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + with: + path: automation + persist-credentials: false + ref: ${{ matrix.automationCommit }} + - name: Verify trusted checkout + env: + RELEASE_AUTOMATION_COMPATIBLE_REFS: ${{ vars.RELEASE_AUTOMATION_COMPATIBLE_REFS }} + RELEASE_AUTOMATION_REF: ${{ vars.RELEASE_AUTOMATION_REF }} + run: | + [[ ",${RELEASE_AUTOMATION_REF},${RELEASE_AUTOMATION_COMPATIBLE_REFS}," == \ + *",${{ matrix.automationCommit }},"* ]] + [[ "$(git -C automation rev-parse HEAD)" == "${{ matrix.automationCommit }}" ]] + - name: Find an existing exact artifact + id: existing + env: + GH_TOKEN: ${{ github.token }} + GITHUB_ARTIFACT_NAME: sdk-java-release-native-${{ matrix.candidateDigest }}-${{ matrix.platform }} + run: automation/.github/scripts/temporal-release/find-github-artifact.sh >>"$GITHUB_OUTPUT" + - uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5.7.0 + if: steps.existing.outputs.found != 'true' && (startsWith(matrix.platform, 'macos-') || startsWith(matrix.platform, 'windows-')) + with: + distribution: ${{ matrix.distribution }} + java-version: ${{ matrix.javaVersion }} + - uses: gradle/actions/setup-gradle@ac396bf1a80af16236baf54bd7330ae21dc6ece5 # v6 + if: steps.existing.outputs.found != 'true' && (startsWith(matrix.platform, 'macos-') || startsWith(matrix.platform, 'windows-')) + - name: Compile without release credentials + if: steps.existing.outputs.found != 'true' + working-directory: source + env: + RELEASE_CANDIDATE_DIGEST: ${{ matrix.candidateDigest }} + RELEASE_COMMIT: ${{ matrix.commitSha }} + RELEASE_NOTES_FILE: releases/${{ matrix.tag }} + RELEASE_NOTES_SHA256: ${{ matrix.notesSha256 }} + RELEASE_PLATFORM: ${{ matrix.platform }} + RELEASE_PREBUILT_NATIVE_DIR: ${{ runner.temp }}/native-output + RELEASE_TAG: ${{ matrix.tag }} + RELEASE_VERSION: ${{ matrix.version }} + TRUSTED_AUTOMATION_COMMIT: ${{ matrix.automationCommit }} + TRUSTED_AUTOMATION_ROOT: ${{ github.workspace }}/automation + run: bash "$TRUSTED_AUTOMATION_ROOT/.github/scripts/temporal-release/build-native-binary.sh" + - name: Package the deterministic release archive + if: steps.existing.outputs.found != 'true' + working-directory: source + env: + RELEASE_ARCHIVE_EXTENSION: ${{ matrix.archiveExtension }} + RELEASE_ASSET_PLATFORM: ${{ matrix.assetPlatform }} + RELEASE_BINARY_NAME: ${{ matrix.binaryName }} + RELEASE_CANDIDATE_DIGEST: ${{ matrix.candidateDigest }} + RELEASE_COMMIT: ${{ matrix.commitSha }} + RELEASE_NOTES_FILE: releases/${{ matrix.tag }} + RELEASE_NOTES_SHA256: ${{ matrix.notesSha256 }} + RELEASE_OUTPUT_DIR: ${{ runner.temp }}/native-artifact + RELEASE_PLATFORM: ${{ matrix.platform }} + RELEASE_PREBUILT_NATIVE_DIR: ${{ runner.temp }}/native-output + RELEASE_TAG: ${{ matrix.tag }} + RELEASE_VERSION: ${{ matrix.version }} + TRUSTED_AUTOMATION_COMMIT: ${{ matrix.automationCommit }} + TRUSTED_AUTOMATION_ROOT: ${{ github.workspace }}/automation + run: bash "$TRUSTED_AUTOMATION_ROOT/.github/scripts/temporal-release/package-native-artifact.sh" + - name: Upload the exact native archive + id: upload + if: steps.existing.outputs.found != 'true' + uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7 + with: + name: sdk-java-release-native-${{ matrix.candidateDigest }}-${{ matrix.platform }} + path: ${{ runner.temp }}/native-artifact + if-no-files-found: error + retention-days: 90 + record_native: + needs: [discover, build_native] + if: needs.discover.outputs.build_count != '0' && needs.build_native.result == 'success' + strategy: + fail-fast: false + matrix: ${{ fromJSON(needs.discover.outputs.build_matrix) }} + runs-on: ubuntu-latest + timeout-minutes: 20 + environment: release-control + permissions: + actions: read + contents: read + concurrency: + group: temporal-release-native-record-${{ matrix.candidateDigest }}-${{ matrix.platform }} + cancel-in-progress: false + steps: + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + with: + path: automation + persist-credentials: false + ref: ${{ matrix.automationCommit }} + - name: Verify the fresh trusted checkout + env: + RELEASE_AUTOMATION_COMPATIBLE_REFS: ${{ vars.RELEASE_AUTOMATION_COMPATIBLE_REFS }} + RELEASE_AUTOMATION_REF: ${{ vars.RELEASE_AUTOMATION_REF }} + run: | + [[ ",${RELEASE_AUTOMATION_REF},${RELEASE_AUTOMATION_COMPATIBLE_REFS}," == \ + *",${{ matrix.automationCommit }},"* ]] + [[ "$(git -C automation rev-parse HEAD)" == "${{ matrix.automationCommit }}" ]] + [[ -z "$(git -C automation status --short)" ]] + - name: Find the exact artifact from the credential-free build job + id: artifact + env: + GH_TOKEN: ${{ github.token }} + GITHUB_ARTIFACT_NAME: sdk-java-release-native-${{ matrix.candidateDigest }}-${{ matrix.platform }} + run: | + automation/.github/scripts/temporal-release/find-github-artifact.sh >>"$GITHUB_OUTPUT" + - name: Require the exact artifact + env: + FOUND: ${{ steps.artifact.outputs.found }} + run: '[[ "$FOUND" == true ]]' + - name: Download the artifact safely by exact ID + env: + GH_TOKEN: ${{ github.token }} + GITHUB_ARTIFACT_DESTINATION: ${{ runner.temp }}/native-artifact + GITHUB_ARTIFACT_DIGEST: ${{ steps.artifact.outputs.github_digest }} + GITHUB_ARTIFACT_ID: ${{ steps.artifact.outputs.artifact_id }} + GITHUB_ARTIFACT_NAME: sdk-java-release-native-${{ matrix.candidateDigest }}-${{ matrix.platform }} + GITHUB_ARTIFACT_RUN_ID: ${{ steps.artifact.outputs.workflow_run_id }} + run: automation/.github/scripts/temporal-release/download-github-artifact.sh + - name: Create the exact Temporal artifact receipt + env: + GH_TOKEN: ${{ github.token }} + GITHUB_ARTIFACT_CONTENT_DIR: ${{ runner.temp }}/native-artifact + GITHUB_ARTIFACT_ID: ${{ steps.artifact.outputs.artifact_id }} + GITHUB_ARTIFACT_NAME: sdk-java-release-native-${{ matrix.candidateDigest }}-${{ matrix.platform }} + GITHUB_ARTIFACT_RECEIPT_FILE: ${{ runner.temp }}/native-receipt.json + GITHUB_ARTIFACT_RUN_ID: ${{ steps.artifact.outputs.workflow_run_id }} + run: automation/.github/scripts/temporal-release/github-artifact-receipt.sh + - uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5 + with: + distribution: temurin + java-version: "17" + - uses: gradle/actions/setup-gradle@ac396bf1a80af16236baf54bd7330ae21dc6ece5 # v6 + - name: Record the exact artifact in Candidate Workflow state + working-directory: automation + env: + TEMPORAL_ADDRESS: ${{ vars.TEMPORAL_RELEASE_ADDRESS }} + TEMPORAL_API_KEY: ${{ secrets.TEMPORAL_RELEASE_UNPRIVILEGED_API_KEY }} + TEMPORAL_NAMESPACE: ${{ vars.TEMPORAL_RELEASE_NAMESPACE }} + run: >- + ./gradlew -p .github/release-automation run + --args="record-artifact ${{ matrix.workflowId }} ${{ matrix.runId }} + ${{ matrix.platform }} $RUNNER_TEMP/native-receipt.json" + + workers: + needs: discover + if: needs.discover.outputs.worker_count != '0' + strategy: + fail-fast: false + matrix: ${{ fromJSON(needs.discover.outputs.worker_matrix) }} + runs-on: ubuntu-latest + timeout-minutes: 20 + environment: release-control + concurrency: + group: temporal-release-${{ matrix.taskQueue }} + cancel-in-progress: false + steps: + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + with: + ref: ${{ matrix.automationCommit }} + - uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5 + with: + distribution: temurin + java-version: "17" + - uses: gradle/actions/setup-gradle@ac396bf1a80af16236baf54bd7330ae21dc6ece5 # v6 + - name: Poll only the release-specific Workflow queue + env: + EXPECTED_RUN_ID: ${{ matrix.runId }} + EXPECTED_WORKFLOW_ID: ${{ matrix.workflowId }} + RELEASE_TAG: ${{ matrix.tag }} + TEMPORAL_ADDRESS: ${{ vars.TEMPORAL_RELEASE_ADDRESS }} + TEMPORAL_API_KEY: ${{ secrets.TEMPORAL_RELEASE_UNPRIVILEGED_API_KEY }} + TEMPORAL_NAMESPACE: ${{ vars.TEMPORAL_RELEASE_NAMESPACE }} + run: >- + ./gradlew -p .github/release-automation run + --args="worker ${{ matrix.role }} ${{ matrix.taskQueue }}" diff --git a/gradle/publishing.gradle b/gradle/publishing.gradle index fdde671b93..2b962d9442 100644 --- a/gradle/publishing.gradle +++ b/gradle/publishing.gradle @@ -1,4 +1,30 @@ +def releaseCommit = rootProject.findProperty('releaseCommit')?.toString()?.toLowerCase() +def releaseDigest = rootProject.findProperty('releaseDigest')?.toString()?.toLowerCase() +def mavenSubmissionGeneration = rootProject.findProperty('mavenSubmissionGeneration')?.toString() +if (releaseCommit != null && !(releaseCommit ==~ /^[0-9a-f]{40}$/)) { + throw new GradleException( + "Invalid releaseCommit '${releaseCommit}'. Expected a full 40-character commit SHA.") +} +if (releaseDigest != null && !(releaseDigest ==~ /^[0-9a-f]{64}$/)) { + throw new GradleException('Invalid releaseDigest. Expected a SHA-256 digest.') +} +if (mavenSubmissionGeneration != null && !(mavenSubmissionGeneration ==~ /^[0-9]+$/)) { + throw new GradleException('Invalid mavenSubmissionGeneration.') +} +def stagingDescription = releaseDigest == null ? null : + "sdk-java:${releaseDigest}:${mavenSubmissionGeneration ?: '0'}" + nexusPublishing { + if (stagingDescription != null) { + repositoryDescription = stagingDescription + } + if (releaseCommit != null) { + transitionCheckOptions { + // Network calls inside Gradle are bounded. Temporal owns cross-attempt retry and backoff. + maxRetries = 12 + delayBetween = java.time.Duration.ofSeconds(10) + } + } // to release to sonatype use ./gradlew publishToSonatype repositories { sonatype { @@ -10,6 +36,12 @@ nexusPublishing { } } +if (stagingDescription != null) { + tasks.named('findSonatypeStagingRepository') { + descriptionRegex = '^' + java.util.regex.Pattern.quote(stagingDescription) + '$' + } +} + subprojects { apply plugin: 'maven-publish' apply plugin: 'signing' @@ -53,6 +85,9 @@ subprojects { connection = 'scm:git@github.com:temporalio/sdk-java.git' developerConnection = 'scm:git@github.com:temporalio/sdk-java.git' url = 'https://github.com/temporalio/sdk-java.git' + if (releaseCommit != null) { + tag = releaseCommit + } } licenses { diff --git a/gradle/versioning.gradle b/gradle/versioning.gradle index 7cdddffae3..e4017d5234 100644 --- a/gradle/versioning.gradle +++ b/gradle/versioning.gradle @@ -21,6 +21,15 @@ ext.getTag = { -> // 0.20.2-RC1-g000a42a -> 0.20.2-SNAPSHOT // 0.20.2-RC1-somepostfix-g000a42a -> 0.20.2-SNAPSHOT ext.getVersionName = { -> + if (rootProject.hasProperty('releaseVersion')) { + String releaseVersion = rootProject.property('releaseVersion').toString() + if (!(releaseVersion ==~ /^\d+[.]\d+[.]\d+(?:-RC\d+)?$/)) { + throw new GradleException( + "Invalid releaseVersion '${releaseVersion}'. Expected X.Y.Z or X.Y.Z-RCN.") + } + return releaseVersion + } + String tag = getTag() // The last element of describe should start with g according to git describe format @@ -57,4 +66,4 @@ version = getVersionName() subprojects { group = 'io.temporal' version = getVersionName() -} \ No newline at end of file +}