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
4318af0
Add Temporal-backed release automation
eamsden Jul 31, 2026
7bb3c9f
Fix release automation handoff safety
eamsden Aug 1, 2026
4dbc47e
Harden Temporal release recovery
eamsden Aug 1, 2026
c4acfad
Address release automation review findings
eamsden Aug 1, 2026
86d1d27
Harden release automation trust boundaries
eamsden Aug 1, 2026
e9a7a4a
Harden Temporal release recovery
eamsden Aug 3, 2026
e4de259
Use setup-java with GraalVM Community support
eamsden Aug 3, 2026
6d62735
Fix native build arguments on macOS
eamsden Aug 3, 2026
3347d5c
Stabilize native build containers
eamsden Aug 3, 2026
7263f99
Build native images on a clean toolchain
eamsden Aug 3, 2026
2e23047
Align native release builds with Java 23
eamsden Aug 3, 2026
38f2bf1
Normalize Windows paths for trusted Bash
eamsden Aug 3, 2026
3055e44
Stabilize trusted release toolchains
eamsden Aug 3, 2026
dd78ee2
Harden release automation recovery boundaries
eamsden Aug 3, 2026
cf6aeca
Isolate native release builds
eamsden Aug 3, 2026
09f7fcb
Use SDK team for release approvals
eamsden Aug 3, 2026
9674115
Move release docs to workflow directory
eamsden Aug 3, 2026
360792a
Remove checked-in release documentation
eamsden Aug 3, 2026
d224b18
Fix release recovery authorization
eamsden Aug 5, 2026
8a9e53e
Restore workflow documentation
eamsden Aug 5, 2026
6c9e101
Remove stale workflow documentation
eamsden Aug 5, 2026
d9a3c8f
Fix Maven retry takeover
eamsden Aug 5, 2026
5a1c1f1
Simplify release fallback and policy
eamsden Aug 5, 2026
f1f7d2a
Use SDK environment config for releases
eamsden Aug 6, 2026
50e6dea
Replace S3 release state with GitHub artifacts
eamsden Aug 6, 2026
0eae2c5
Harden release takeover and native builds
eamsden Aug 6, 2026
3af63ab
Avoid interpolating release inputs in shell
eamsden Aug 6, 2026
22bfebd
Isolate manual Maven authorization
eamsden Aug 6, 2026
97a3f77
Use variables for Temporal connection metadata
eamsden Aug 6, 2026
2bccce6
Use a GitHub App for release automation
eamsden Aug 6, 2026
74eaaa6
Port release automation to Python
eamsden Aug 6, 2026
fc4dd6f
Pare down Python release automation
eamsden Aug 10, 2026
1df33ba
Document release automation functions
eamsden Aug 10, 2026
d1dfe64
Trigger releases directly from merges
eamsden Aug 10, 2026
c3f2876
Use the release commit for automation
eamsden Aug 10, 2026
196d705
Fix merge-triggered artifact provenance
eamsden Aug 10, 2026
f2a53ef
Consolidate native image toolchains
eamsden Aug 10, 2026
2379f6f
Simplify release publication automation
eamsden Aug 11, 2026
a382765
Port Maven payload preparation to Python
eamsden Aug 11, 2026
4ae2cc9
Simplify release workflow orchestration
eamsden Aug 11, 2026
5d55b13
Reduce release automation to essential workflow
eamsden Aug 11, 2026
388ba1c
Fix release workflow output directory
eamsden Aug 11, 2026
16fd4f6
Preserve fixed Maven release policies
eamsden Aug 11, 2026
f03523b
Handle initial release branch pushes
eamsden Aug 11, 2026
6a5fc24
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