Skip to content

Commit 1692d09

Browse files
committed
Document release automation functions
1 parent e29f2fa commit 1692d09

14 files changed

Lines changed: 250 additions & 0 deletions

‎.github/release-automation/release_automation/activities.py‎

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,12 @@
3434

3535

3636
async def run_trusted_command(cwd: Path, script: Path, env: Mapping[str, str]) -> list[str]:
37+
"""Run trusted shell reconciliation with an explicit, credential-minimal environment.
38+
39+
The child gets only a small host-runtime allowlist plus values selected by the
40+
Activity. It runs in its own process group so Temporal cancellation can terminate
41+
the complete command tree, including curl, gh, Docker, Gradle, or GPG children.
42+
"""
3743
command = ["bash", str(script.absolute())]
3844
child_env = {key: value for key, value in os.environ.items() if key in PASSTHROUGH_ENV}
3945
child_env.update(env)
@@ -46,6 +52,7 @@ async def run_trusted_command(cwd: Path, script: Path, env: Mapping[str, str]) -
4652
)
4753

4854
async def heartbeat() -> None:
55+
"""Heartbeat while the external process runs so Temporal detects worker loss."""
4956
while process.returncode is None:
5057
await asyncio.sleep(15)
5158
activity.heartbeat("External release command is running.")
@@ -65,6 +72,7 @@ async def heartbeat() -> None:
6572

6673

6774
async def _terminate_tree(process: asyncio.subprocess.Process) -> None:
75+
"""Terminate a cancelled command group gracefully, then forcefully if necessary."""
6876
if process.returncode is not None:
6977
return
7078
os.killpg(process.pid, signal.SIGTERM)
@@ -82,18 +90,27 @@ def __init__(
8290
source_root: Path,
8391
environment: Mapping[str, str],
8492
) -> None:
93+
"""Capture trusted/source roots and a snapshot of the privileged job environment."""
8594
self.trusted_root, self.source_root = trusted_root, source_root
8695
self.environment = dict(environment)
8796

8897
@activity.defn(name="publishRelease")
8998
async def publish_release(self, value: PublicationInput) -> ReleaseResult:
99+
"""Reconcile Maven and GitHub to the exact public release state."""
90100
return await self._command(value, "all", ReleaseResult)
91101

92102
@activity.defn(name="inspectMaven")
93103
async def inspect_maven(self, value: PublicationInput) -> MavenInspection:
104+
"""Inspect every durable Maven generation without creating or publishing state."""
94105
return await self._command(value, "inspect", MavenInspection)
95106

96107
async def _command(self, value: PublicationInput, stage: str, result_type: type[T]) -> T:
108+
"""Validate Activity routing, execute reconciliation, and decode its typed result.
109+
110+
Semantic conflicts and Maven ambiguity use stable exit codes that become typed,
111+
non-retryable ApplicationErrors. Transient process and network failures remain
112+
ordinary Activity failures and therefore use Temporal's configured retry policy.
113+
"""
97114
try:
98115
trusted_commit = self.environment.get("TRUSTED_WORKER_COMMIT")
99116
expected_run = self.environment.get("EXPECTED_RUN_ID")
@@ -145,6 +162,13 @@ def validate_publication(
145162
expected_run: str,
146163
trusted_commit: str,
147164
) -> None:
165+
"""Bind privileged work to the expected Workflow, run, queue, and automation commit.
166+
167+
Queue isolation alone is not authorization: a caller able to address Temporal could
168+
otherwise schedule an Activity on a known queue. These comparisons ensure the
169+
Activity was emitted by the exact durable release execution represented by the
170+
protected Actions job.
171+
"""
148172
validate_publication_input(value)
149173
if (
150174
info.workflow_id,
@@ -161,6 +185,7 @@ def validate_publication(
161185

162186

163187
def validate_publication_input(value: PublicationInput) -> None:
188+
"""Validate immutable payload identity and contiguous Maven generation history."""
164189
payload = value.mavenPayload
165190
if payload is None:
166191
raise ValueError("The frozen Maven payload is missing.")

‎.github/release-automation/release_automation/cli.py‎

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@
3939

4040

4141
def output(name: str, value: Any) -> None:
42+
"""Write an Actions output, falling back to readable local command output."""
4243
text = str(value).replace("\n", " ") if value is not None else ""
4344
if destination := os.environ.get("GITHUB_OUTPUT"):
4445
with Path(destination).open("a") as stream:
@@ -48,16 +49,19 @@ def output(name: str, value: Any) -> None:
4849

4950

5051
def required(env: Mapping[str, str], name: str) -> str:
52+
"""Read a required Actions value without silently accepting an empty secret."""
5153
if not (value := env.get(name)):
5254
raise ValueError(f"Required Actions value is missing: {name}")
5355
return value
5456

5557

5658
def read(path: str | Path, kind: type[T]) -> T:
59+
"""Decode JSON through Temporal's converter so CLI and Workflow types agree."""
5760
return cast(T, value_to_type(kind, json.loads(Path(path).read_text())))
5861

5962

6063
async def connect(env: Mapping[str, str]) -> Client:
64+
"""Connect to the configured Temporal namespace using its single API credential."""
6165
return await Client.connect(
6266
required(env, "TEMPORAL_ADDRESS"),
6367
namespace=required(env, "TEMPORAL_NAMESPACE"),
@@ -67,6 +71,7 @@ async def connect(env: Mapping[str, str]) -> Client:
6771

6872

6973
def policy_for_settings(settings: Path) -> str:
74+
"""Recognize the checked-out Gradle project set as a fixed Maven policy."""
7075
projects = [
7176
match.group(1)
7277
for line in settings.read_text().splitlines()
@@ -76,10 +81,17 @@ def policy_for_settings(settings: Path) -> str:
7681

7782

7883
def git(*args: str, cwd: Path | None = None) -> str:
84+
"""Run a read-only Git command and return its whitespace-trimmed output."""
7985
return subprocess.check_output(["git", *args], cwd=cwd, text=True).strip()
8086

8187

8288
def candidate_from_push(env: Mapping[str, str]) -> CandidateIdentity:
89+
"""Authorize a candidate from one newly added release-note file.
90+
91+
The push workflow is the approval boundary. This function binds that approval to
92+
exact source and automation commits, verifies ancestry, and refuses ambiguous or
93+
non-regular release-note changes before any Temporal execution is started.
94+
"""
8395
if env.get("GITHUB_REPOSITORY") != REPOSITORY:
8496
raise ValueError("This automation only releases temporalio/sdk-java.")
8597
commit, automation = required(env, "RELEASE_COMMIT"), required(env, "RELEASE_AUTOMATION_REF")
@@ -121,15 +133,23 @@ def candidate_from_push(env: Mapping[str, str]) -> CandidateIdentity:
121133

122134

123135
async def workflows(client: Client) -> list[WorkflowExecution]:
136+
"""List only running sdk-java ReleaseWorkflow executions for discovery."""
124137
query = "ExecutionStatus = 'Running' AND WorkflowType = 'ReleaseWorkflow'"
125138
return [item async for item in client.list_workflows(query)]
126139

127140

128141
async def memo(execution: WorkflowExecution, key: str, kind: type[T]) -> T | None:
142+
"""Decode one typed memo field without requiring a live Workflow Worker."""
129143
return await execution.memo_value(key, None, type_hint=kind)
130144

131145

132146
async def start_candidate(client: Client, candidate: CandidateIdentity) -> None:
147+
"""Start or reuse the one Workflow execution for an immutable candidate.
148+
149+
Before starting, all running executions are checked for a conflicting use of the
150+
same release tag. Temporal's duplicate policies then make repeated delivery of the
151+
same authorized push idempotent without allowing identity replacement.
152+
"""
133153
candidate.validate()
134154
for execution in await workflows(client):
135155
existing = await memo(execution, "CandidateIdentity", CandidateIdentity)
@@ -153,6 +173,7 @@ async def update(
153173
name: str,
154174
*args: Any,
155175
) -> None:
176+
"""Run a short-lived Worker while delivering an update to an exact execution."""
156177
if not run_id:
157178
raise ValueError("Release Workflow execution is invalid.")
158179
async with Worker(client, task_queue=workflow_queue(workflow_id), workflows=[ReleaseWorkflow]):
@@ -162,13 +183,20 @@ async def update(
162183

163184

164185
def workflow_queue(workflow_id: str) -> str:
186+
"""Recover and validate the candidate-specific queue encoded in a Workflow ID."""
165187
prefix = "sdk-java-release-candidate/"
166188
if not workflow_id.startswith(prefix):
167189
raise ValueError("Release Workflow ID is invalid.")
168190
return candidate_queue_from_digest(workflow_id.removeprefix(prefix))
169191

170192

171193
def verify_candidate_origin(candidate: CandidateIdentity, run: Mapping[str, Any]) -> None:
194+
"""Reconfirm that GitHub authorized the candidate from the expected push workflow.
195+
196+
Discovery repeats this check on every scheduled pass so a forged Temporal memo
197+
cannot cause privileged work. Repository, workflow path, source SHA, branch policy,
198+
event type, and numeric run identity must all match the frozen candidate.
199+
"""
172200
repository = run.get("head_repository")
173201
if (
174202
run.get("id") != candidate.githubRunId
@@ -183,6 +211,7 @@ def verify_candidate_origin(candidate: CandidateIdentity, run: Mapping[str, Any]
183211

184212

185213
def github_run(run_id: int) -> Mapping[str, Any]:
214+
"""Fetch the GitHub Actions run used as candidate authorization evidence."""
186215
return cast(
187216
Mapping[str, Any],
188217
json.loads(
@@ -196,6 +225,12 @@ def github_run(run_id: int) -> Mapping[str, Any]:
196225
async def release_jobs(
197226
execution: WorkflowExecution, automation: str
198227
) -> dict[str, list[dict[str, Any]]]:
228+
"""Translate one durable release snapshot into minimal Actions matrix entries.
229+
230+
Native entries contain no credentials and are emitted only for missing platforms.
231+
A publication entry is emitted only after the immutable release identity exists;
232+
its queue is derived from the current durable Maven generation.
233+
"""
199234
candidate = await memo(execution, "CandidateIdentity", CandidateIdentity)
200235
if (
201236
candidate is None
@@ -244,6 +279,7 @@ async def release_jobs(
244279

245280

246281
async def discover(client: Client, env: Mapping[str, str]) -> None:
282+
"""Emit build and publication matrices for all valid running releases."""
247283
jobs: dict[str, list[dict[str, Any]]] = {"build": [], "publication": []}
248284
automation = required(env, "RELEASE_AUTOMATION_REF")
249285
for execution in await workflows(client):
@@ -262,6 +298,12 @@ async def discover(client: Client, env: Mapping[str, str]) -> None:
262298

263299

264300
async def run_worker(client: Client, queue: str, env: Mapping[str, str]) -> None:
301+
"""Poll one release Workflow queue and one exact publication generation queue.
302+
303+
Hosting both Workers in the publication job removes a separate Actions worker
304+
matrix. Activities still exist only on the privileged generation-specific queue,
305+
while deterministic Workflow code polls the candidate-specific queue.
306+
"""
265307
if not queue.startswith("sdk-java-release-") or "-publication-g" not in queue:
266308
raise ValueError("Refusing to poll a non-publication Task Queue.")
267309
workflow_id = required(env, "EXPECTED_WORKFLOW_ID")
@@ -286,6 +328,7 @@ async def run_worker(client: Client, queue: str, env: Mapping[str, str]) -> None
286328

287329

288330
async def async_main(argv: list[str], env: Mapping[str, str]) -> None:
331+
"""Dispatch the small command surface used by the release workflows."""
289332
client = await connect(env)
290333
match argv:
291334
case ["start"]:
@@ -316,6 +359,7 @@ async def async_main(argv: list[str], env: Mapping[str, str]) -> None:
316359

317360

318361
def main() -> None:
362+
"""Validate the trusted checkout layout and run the asynchronous CLI."""
319363
if not (ROOT / ".github/scripts/temporal-release/reconcile-publication.sh").is_file():
320364
raise RuntimeError("The trusted repository root has an unexpected layout.")
321365
asyncio.run(async_main(sys.argv[1:], os.environ))

‎.github/release-automation/release_automation/maven_payload.py‎

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,12 @@ def validate(
1616
commit: str,
1717
exact: bool,
1818
) -> None:
19+
"""Validate the exact signed Maven repository tree against fixed sdk-java policy.
20+
21+
Every manifest path, digest, size, coordinate, classifier, signature/checksum sidecar,
22+
and POM identity is checked. In build mode the per-artifact file set must also be
23+
complete, preventing a partially generated payload from becoming durable release data.
24+
"""
1925
root = root.resolve()
2026
approved = set(policy.read_text().splitlines())
2127
records = []
@@ -70,6 +76,12 @@ def validate(
7076

7177

7278
def extract(archive_path: pathlib.Path, output: pathlib.Path) -> None:
79+
"""Extract only the expected regular-file Maven bundle without tar traversal.
80+
81+
Tar extraction is implemented explicitly instead of using extractall so absolute
82+
paths, parent traversal, duplicates, links, devices, and other special members are
83+
rejected before they can touch the publication workspace.
84+
"""
7385
output = output.resolve()
7486
seen = set()
7587
with tarfile.open(archive_path, "r:") as archive:
@@ -100,6 +112,7 @@ def extract(archive_path: pathlib.Path, output: pathlib.Path) -> None:
100112

101113

102114
def main() -> None:
115+
"""Dispatch trusted payload validation or validation-after-safe-extraction."""
103116
mode, source, root, policy, version, commit = sys.argv[1:]
104117
root_path = pathlib.Path(root)
105118
if mode == "extract":

0 commit comments

Comments
 (0)