Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
45 commits
Select commit Hold shift + click to select a range
37e4ab3
Add Temporal-backed release automation
eamsden Jul 31, 2026
1feba91
Fix release automation handoff safety
eamsden Aug 1, 2026
b1ed316
Harden Temporal release recovery
eamsden Aug 1, 2026
f9b1050
Address release automation review findings
eamsden Aug 1, 2026
63cd60c
Harden release automation trust boundaries
eamsden Aug 1, 2026
cbbb2b5
Harden Temporal release recovery
eamsden Aug 3, 2026
48b4b77
Use setup-java with GraalVM Community support
eamsden Aug 3, 2026
a076547
Fix native build arguments on macOS
eamsden Aug 3, 2026
ab59289
Stabilize native build containers
eamsden Aug 3, 2026
fcacdfb
Build native images on a clean toolchain
eamsden Aug 3, 2026
5eec993
Align native release builds with Java 23
eamsden Aug 3, 2026
6d18303
Normalize Windows paths for trusted Bash
eamsden Aug 3, 2026
17ec4d1
Stabilize trusted release toolchains
eamsden Aug 3, 2026
c8506c5
Harden release automation recovery boundaries
eamsden Aug 3, 2026
08c4cb3
Isolate native release builds
eamsden Aug 3, 2026
0447e5c
Use SDK team for release approvals
eamsden Aug 3, 2026
231f36d
Move release docs to workflow directory
eamsden Aug 3, 2026
d7ab720
Remove checked-in release documentation
eamsden Aug 3, 2026
dbded67
Fix release recovery authorization
eamsden Aug 5, 2026
925f548
Restore workflow documentation
eamsden Aug 5, 2026
cc27066
Remove stale workflow documentation
eamsden Aug 5, 2026
c4f645c
Fix Maven retry takeover
eamsden Aug 5, 2026
ed7b89c
Simplify release fallback and policy
eamsden Aug 5, 2026
ccc2a5a
Use SDK environment config for releases
eamsden Aug 6, 2026
367046d
Replace S3 release state with GitHub artifacts
eamsden Aug 6, 2026
13d3ffc
Harden release takeover and native builds
eamsden Aug 6, 2026
4d01d75
Avoid interpolating release inputs in shell
eamsden Aug 6, 2026
c5b92ca
Isolate manual Maven authorization
eamsden Aug 6, 2026
8c67c06
Use variables for Temporal connection metadata
eamsden Aug 6, 2026
adf7d2b
Use a GitHub App for release automation
eamsden Aug 6, 2026
31ce1c5
Port release automation to Python
eamsden Aug 6, 2026
2d1791e
Pare down Python release automation
eamsden Aug 10, 2026
279bf50
Document release automation functions
eamsden Aug 10, 2026
5d53540
Trigger releases directly from merges
eamsden Aug 10, 2026
d334326
Use the release commit for automation
eamsden Aug 10, 2026
d01df9a
Fix merge-triggered artifact provenance
eamsden Aug 10, 2026
251d817
Consolidate native image toolchains
eamsden Aug 10, 2026
e99859c
Simplify release publication automation
eamsden Aug 11, 2026
23eb331
Port Maven payload preparation to Python
eamsden Aug 11, 2026
56133bd
Simplify release workflow orchestration
eamsden Aug 11, 2026
d193d7a
Reduce release automation to essential workflow
eamsden Aug 11, 2026
ddcded7
Fix release workflow output directory
eamsden Aug 11, 2026
96e2d9c
Preserve fixed Maven release policies
eamsden Aug 11, 2026
3190f1c
Handle initial release branch pushes
eamsden Aug 11, 2026
6bc0da5
Explain release automation structure
eamsden Aug 12, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .github/release-automation/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
.mypy_cache/
.pytest_cache/
.ruff_cache/
.venv/
**/__pycache__/
19 changes: 19 additions & 0 deletions .github/release-automation/pyproject.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
[project]
name = "sdk-java-release-automation"
version = "0.1.0"
requires-python = ">=3.12"
dependencies = ["temporalio==1.31.0"]
[dependency-groups]
dev = ["mypy==1.19.1", "pytest==9.0.2", "pytest-asyncio==1.3.0", "ruff==0.14.14"]
[tool.pytest.ini_options]
asyncio_mode = "auto"
pythonpath = ["."]
[tool.ruff]
line-length = 140
[tool.ruff.lint]
select = ["E", "F", "I", "UP", "B", "ASYNC"]
ignore = ["E501", "UP047"]

[tool.mypy]
python_version = "3.12"
strict = true
264 changes: 264 additions & 0 deletions .github/release-automation/release_automation/build.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,264 @@
"""Construct immutable release artifacts for the GitHub Actions workflow.

This module has two roles:

* GitHub Actions invokes the ``maven`` and ``native`` CLI commands to create the
signed Maven payload and native release archives.
* ``release.py`` imports only ``unpack_maven`` and ``validate_maven`` to verify
that downloaded artifacts still match the frozen release identity.

``release.py`` never invokes this CLI or rebuilds an artifact during publication.
The artifact boundary between the two modules is GitHub Actions storage.
"""

import base64
import binascii
import gzip
import hashlib
import io
import json
import os
import pathlib
import re
import shutil
import stat
import subprocess
import sys
import tarfile
import tempfile
import xml.etree.ElementTree as ET
import zipfile
from collections.abc import Iterable, Mapping, Sequence
from typing import Any

BUILD_IMAGE = "eclipse-temurin:17-jdk@sha256:91b6210cce02091f6f0798a83ec51aa223828242c5a21a85793bb8c28dc891c4"


# Run source-controlled build tools with an intentionally small environment.
def tool(command: Sequence[str], *, data: bytes | None = None, quiet: bool = False) -> None:
"""Run a build tool without forwarding release credentials."""
allowed = {"PATH", "HOME", "TMPDIR", "LANG", "LC_ALL", "DOCKER_HOST"}
subprocess.run(
command,
check=True,
input=data,
env={key: value for key, value in os.environ.items() if key in allowed},
stdout=subprocess.DEVNULL if quiet else None,
stderr=subprocess.DEVNULL if quiet else None,
)


def unsigned(source: pathlib.Path, work: pathlib.Path, version: str, commit: str) -> pathlib.Path:
"""Build unsigned Maven files in a credential-free container.

Merged source is trusted to define the release, but the Gradle process still does
not need signing material. The container keeps those credentials unavailable while
using the reviewed publishing hooks from the same immutable commit.
"""
candidate, repository = work / "source", work / "repository"
shutil.copytree(source, candidate, symlinks=True)
repository.mkdir()
command = "docker run --rm --pull=missing --network bridge --cap-drop ALL --security-opt no-new-privileges".split()
command += "--env HOME=/tmp --env GRADLE_USER_HOME=/tmp/gradle".split()
command += ["--user", f"{os.getuid()}:{os.getgid()}", "-w", "/workspace"]
command += ["-v", f"{candidate}:/workspace", "-v", f"{repository}:/payload"]
command += [BUILD_IMAGE, "./gradlew", "--no-daemon", "-Dmaven.repo.local=/payload"]
command += [f"-PreleaseVersion={version}", f"-PreleaseCommit={commit}", "publishToMavenLocal"]
tool(command)
return repository / "io" / "temporal"


# Turn the unsigned Gradle output into the exact repository Maven will receive.
def sign(root: pathlib.Path, home: pathlib.Path, env: Mapping[str, str]) -> None:
"""Sign Maven payload files and create required legacy checksums."""
for stale in (path for path in root.rglob("*") if path.suffix in {".asc", ".md5", ".sha1"}):
stale.unlink()
try:
key = base64.b64decode(env["JAR_SIGNING_KEY"], validate=True)
except (KeyError, binascii.Error) as error:
raise ValueError("The signing key is missing or invalid.") from error
key_file = home.parent / "key"
key_file.write_bytes(key)
key_file.chmod(0o600)
home.mkdir(mode=0o700)
tool(["gpg", "--batch", "--homedir", str(home), "--import", str(key_file)], quiet=True)
for path in sorted(root.rglob("*")):
if not path.is_file() or path.is_symlink() or path.suffix not in {".jar", ".pom", ".module"}:
continue
command = "gpg --batch --yes --pinentry-mode loopback --passphrase-fd 0".split()
command += ["--homedir", str(home), "--local-user", env["JAR_SIGNING_KEY_ID"]]
command += ["--armor", "--detach-sign", "--output", f"{path}.asc", str(path)]
tool(command, data=f"{env['JAR_SIGNING_KEY_PASSWORD']}\n".encode())
content = path.read_bytes()
pathlib.Path(f"{path}.md5").write_text(hashlib.md5(content, usedforsecurity=False).hexdigest() + "\n")
pathlib.Path(f"{path}.sha1").write_text(hashlib.sha1(content, usedforsecurity=False).hexdigest() + "\n")


def validate_maven(
root: pathlib.Path, manifest: pathlib.Path, policy: Iterable[str], candidate: Any, exact: bool
) -> list[tuple[str, str, int]]:
"""Validate paths, bytes, coordinates, and POM identity in a Maven payload."""
root, approved, records = root.resolve(), set(policy), []
for line in manifest.read_text().splitlines():
relative, checksum, size_text = line.split("\t")
parts = pathlib.PurePosixPath(relative).parts
if len(parts) != 5 or parts[:2] != ("io", "temporal"):
raise ValueError("Maven path is outside policy.")
artifact, version, filename = parts[2:]
suffix = r"(?:-(?:sources|javadoc))?\.(?:jar|pom|module)(?:\.(?:asc|md5|sha1))?"
if (
artifact not in approved
or version != candidate.version
or not re.fullmatch(re.escape(f"{artifact}-{version}") + suffix, filename)
):
raise ValueError("Maven coordinate is outside policy.")
path, size = (root / relative).resolve(), int(size_text)
if root not in path.parents or not path.is_file() or path.is_symlink():
raise ValueError("Maven payload contains an invalid file.")
data = path.read_bytes()
if len(data) != size or hashlib.sha256(data).hexdigest() != checksum:
raise ValueError("Maven payload checksum differs.")
records.append((relative, checksum, size))
actual = sorted(path.relative_to(root).as_posix() for path in root.rglob("*") if path.is_file())
if [row[0] for row in records] != actual or len(actual) != len(set(actual)):
raise ValueError("Maven payload file set differs.")
for artifact in approved:
pom = root / "io" / "temporal" / artifact / candidate.version / f"{artifact}-{candidate.version}.pom"
document = ET.parse(pom).getroot()
ns = document.tag.partition("}")[0] + "}" if document.tag.startswith("{") else ""
identity = tuple(document.findtext(f"{ns}{field}", "").strip() for field in ("groupId", "artifactId", "version"))
identity += (document.findtext(f"{ns}scm/{ns}tag", "").strip().lower(),)
if identity != ("io.temporal", artifact, candidate.version, candidate.commit):
raise ValueError(f"Maven POM identity differs for {artifact}.")
if exact and not any(path.name.endswith(".asc") for path in pom.parent.iterdir()):
raise ValueError(f"Maven signatures are missing for {artifact}.")
return records


# The signed payload is persisted as one deterministic Actions artifact. These
# helpers are shared with release.py so construction and publication enforce the
# same archive shape and Maven identity.
def archive_maven(bundle: pathlib.Path, output: pathlib.Path) -> None:
"""Create the deterministic tar used as the durable signed payload."""
paths = [bundle / "manifest.tsv", bundle / "repository"] + sorted(path for path in (bundle / "repository").rglob("*") if path.is_file())
with tarfile.open(output, "w") as archive:
for path in paths:
info = archive.gettarinfo(str(path), path.relative_to(bundle).as_posix())
info.uid = info.gid = info.mtime = 0
info.uname = info.gname = ""
if path.is_file():
with path.open("rb") as stream:
archive.addfile(info, stream)
else:
archive.addfile(info)


def unpack_maven(archive: pathlib.Path, output: pathlib.Path) -> tuple[pathlib.Path, pathlib.Path]:
"""Extract only regular files under the expected Maven bundle roots."""
output, seen = output.resolve(), set()
with tarfile.open(archive, "r:") as bundle:
for member in bundle:
name = member.name.rstrip("/")
path = pathlib.PurePosixPath(name)
allowed = name == "manifest.tsv" or name == "repository" or name.startswith("repository/io/temporal/")
if not name or path.is_absolute() or ".." in path.parts or name in seen or not allowed:
raise ValueError("Unexpected Maven 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 = bundle.extractfile(member)
if source is None:
raise ValueError("Unreadable Maven archive member.")
with source, target.open("xb") as destination:
shutil.copyfileobj(source, destination)
else:
raise ValueError("Maven archive links and special files are forbidden.")
return output / "repository", output / "manifest.tsv"


def maven(env: Mapping[str, str]) -> None:
"""Build, sign, validate, and freeze the exact Maven payload."""
source = pathlib.Path.cwd()
output, version, commit = (
pathlib.Path(env["MAVEN_PAYLOAD_OUTPUT"]),
env["MAVEN_PAYLOAD_VERSION"],
env["MAVEN_PAYLOAD_COMMIT"],
)
artifacts = json.loads(env["MAVEN_ARTIFACTS_JSON"])
if subprocess.check_output(["git", "rev-parse", "HEAD^{commit}"], text=True).strip() != commit:
raise ValueError("The source checkout changed.")
output.mkdir(parents=True, exist_ok=True)
with tempfile.TemporaryDirectory(prefix="sdk-java-maven-") as directory:
work, bundle = pathlib.Path(directory), pathlib.Path(directory) / "bundle"

# Gradle runs without signing credentials. Signing happens only after the
# build process exits and only for the fixed Maven project set.
generated = unsigned(source, work / "build", version, commit)
sign(generated, work / "gnupg", env)

# Copy only approved coordinates, describe every byte in the manifest,
# then validate the finished bundle before GitHub Actions uploads it.
repository = bundle / "repository" / "io" / "temporal"
repository.mkdir(parents=True)
for artifact in artifacts:
shutil.copytree(generated / artifact / version, repository / artifact / version)
files = sorted(path for path in (bundle / "repository").rglob("*") if path.is_file())
manifest = bundle / "manifest.tsv"
manifest.write_text(
"".join(
f"{path.relative_to(bundle / 'repository').as_posix()}\t{hashlib.sha256(path.read_bytes()).hexdigest()}\t{path.stat().st_size}\n"
for path in files
)
)
candidate = type("Candidate", (), {"version": version, "commit": commit})()
validate_maven(bundle / "repository", manifest, artifacts, candidate, True)
archive_maven(bundle, output / "maven-payload.tar")


# Native binaries are compiled elsewhere in the matrix. This module only gives
# each one its stable public filename and reproducible archive representation.
def native(source: pathlib.Path, output: pathlib.Path, root: str, name: str, windows: bool) -> None:
"""Create a reproducible one-binary native release archive."""
data = source.read_bytes()
if windows:
entry = zipfile.ZipInfo(f"{root}/{name}", (1980, 1, 1, 0, 0, 0))
entry.compress_type, entry.external_attr = (
zipfile.ZIP_DEFLATED,
(stat.S_IFREG | 0o755) << 16,
)
with zipfile.ZipFile(output, "w") as archive:
archive.writestr(entry, data)
return
tar = io.BytesIO()
with tarfile.open(fileobj=tar, mode="w", format=tarfile.GNU_FORMAT) as archive:
directory, binary = tarfile.TarInfo(root), tarfile.TarInfo(f"{root}/{name}")
directory.type, directory.mode = tarfile.DIRTYPE, 0o755
binary.size, binary.mode = len(data), 0o755
for item in (directory, binary):
item.uid = item.gid = item.mtime = 0
item.uname = item.gname = ""
archive.addfile(directory)
archive.addfile(binary, io.BytesIO(data))
with (
output.open("wb") as raw,
gzip.GzipFile(filename="", mode="wb", fileobj=raw, mtime=0) as compressed,
):
compressed.write(tar.getvalue())


def main() -> None:
"""Dispatch the two build commands called directly by GitHub Actions."""
if sys.argv[1:] == ["maven"]:
maven(os.environ)
elif len(sys.argv) == 7 and sys.argv[1] == "native":
_, _, source, output, root, name, platform = sys.argv
native(pathlib.Path(source), pathlib.Path(output), root, name, platform == "windows-amd64")
else:
raise ValueError("Expected maven or native build command.")


if __name__ == "__main__":
main()
Loading
Loading