11import asyncio
2- import json
32import os
43import signal
54import subprocess
6- import tempfile
7- from collections .abc import Mapping
5+ from collections .abc import Awaitable , Callable , Mapping , Sequence
86from pathlib import Path
9- from typing import Any , TypeVar , cast
7+ from typing import TypeVar
108
119from temporalio import activity
12- from temporalio .converter import value_to_type
1310from temporalio .exceptions import ApplicationError
1411
1512from .models import (
1613 MavenInspection ,
1714 PublicationInput ,
1815 ReleaseResult ,
1916 candidate_workflow_id ,
20- json_value ,
2117 matches_maven_payload ,
22- maven_artifacts ,
2318 publication_queue ,
2419)
20+ from .publication import PublicationFailure , Publisher
2521
26- T = TypeVar ("T" )
2722PASSTHROUGH_ENV = {"PATH" , "HOME" , "TMPDIR" , "LANG" , "LC_ALL" , "JAVA_HOME" , "CI" }
28- COMMAND_FAILURES = {
29- 42 : ("Immutable external identity or checksum conflict." , "ReleaseIdentityConflict" ),
30- 44 : ("Maven repository identity is not yet visible." , "MavenSubmissionAmbiguous" ),
31- 45 : ("Portal deployment failed validation." , "MavenDeploymentFailed" ),
32- 46 : ("GitHub artifact expired or was deleted." , "ArtifactUnavailable" ),
33- }
23+ Result = TypeVar ("Result" , ReleaseResult , MavenInspection )
3424
3525
36- 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.
26+ async def run_trusted_process (cwd : Path , command : Sequence [ str ] , env : Mapping [str , str ]) -> bytes :
27+ """Run a trusted process with an explicit, credential-minimal environment.
3828
3929 The child gets only a small host-runtime allowlist plus values selected by the
4030 Activity. It runs in its own process group so Temporal cancellation can terminate
4131 the complete command tree, including curl, gh, Docker, Gradle, or GPG children.
4232 """
43- command = ["bash" , str (script .absolute ())]
4433 child_env = {key : value for key , value in os .environ .items () if key in PASSTHROUGH_ENV }
4534 child_env .update (env )
4635 process = await asyncio .create_subprocess_exec (
@@ -68,7 +57,13 @@ async def heartbeat() -> None:
6857 await asyncio .gather (beat , return_exceptions = True )
6958 if process .returncode :
7059 raise subprocess .CalledProcessError (process .returncode , command )
71- return stdout .decode ().splitlines ()
60+ return stdout
61+
62+
63+ async def run_trusted_command (cwd : Path , script : Path , env : Mapping [str , str ]) -> list [str ]:
64+ """Run a trusted shell script; retained as the small process-isolation test seam."""
65+ output = await run_trusted_process (cwd , ["bash" , str (script .absolute ())], env )
66+ return output .decode ().splitlines ()
7267
7368
7469async def _terminate_tree (process : asyncio .subprocess .Process ) -> None :
@@ -86,30 +81,33 @@ async def _terminate_tree(process: asyncio.subprocess.Process) -> None:
8681class PublicationActivities :
8782 def __init__ (
8883 self ,
89- trusted_root : Path ,
9084 source_root : Path ,
9185 environment : Mapping [str , str ],
9286 ) -> None :
93- """Capture trusted/ source roots and a snapshot of the privileged job environment."""
94- self .trusted_root , self . source_root = trusted_root , source_root
87+ """Capture the source root and a snapshot of the privileged job environment."""
88+ self .source_root = source_root
9589 self .environment = dict (environment )
9690
9791 @activity .defn (name = "publishRelease" )
9892 async def publish_release (self , value : PublicationInput ) -> ReleaseResult :
9993 """Reconcile Maven and GitHub to the exact public release state."""
100- return await self ._command (value , "all" , ReleaseResult )
94+ return await self ._run (value , Publisher . publish_release )
10195
10296 @activity .defn (name = "inspectMaven" )
10397 async def inspect_maven (self , value : PublicationInput ) -> MavenInspection :
10498 """Inspect every durable Maven generation without creating or publishing state."""
105- return await self ._command (value , "inspect" , MavenInspection )
106-
107- async def _command (self , value : PublicationInput , stage : str , result_type : type [T ]) -> T :
108- """Validate Activity routing, execute reconciliation, and decode its typed result.
99+ return await self ._run (value , Publisher .inspect_maven )
109100
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.
101+ async def _run (
102+ self ,
103+ value : PublicationInput ,
104+ operation : Callable [[Publisher ], Awaitable [Result ]],
105+ ) -> Result :
106+ """Validate routing and execute one typed publication operation.
107+
108+ Durable semantic outcomes become non-retryable ApplicationErrors. Network,
109+ process, and visibility failures remain ordinary exceptions so Temporal applies
110+ the Workflow's retry policy.
113111 """
114112 try :
115113 expected_commit = self .environment .get ("EXPECTED_COMMIT" )
@@ -121,39 +119,20 @@ async def _command(self, value: PublicationInput, stage: str, result_type: type[
121119 raise ApplicationError (
122120 str (error ), type = "InvalidPublicationInput" , non_retryable = True
123121 ) from error
124- with tempfile .TemporaryDirectory (prefix = "temporal-release-" ) as directory :
125- input_file , output_file = (
126- Path (directory ) / name for name in ("input.json" , "output.json" )
127- )
128- data = json_value (value ) | {
129- "releaseDigest" : value .release .digest (),
130- "mavenArtifacts" : maven_artifacts (value .release .candidate .mavenPolicy ),
131- }
132- input_file .write_text (json .dumps (data , separators = ("," , ":" )))
133- env = {
134- "RELEASE_INPUT_FILE" : str (input_file ),
135- "RELEASE_OUTPUT_FILE" : str (output_file ),
136- "RELEASE_STAGE" : stage ,
137- "TRUSTED_AUTOMATION_ROOT" : str (self .trusted_root ),
138- }
139- for name in ("GH_TOKEN" , "RH_USER" , "RH_PASSWORD" ):
140- if self .environment .get (name ):
141- env [name ] = self .environment [name ]
142- try :
143- output = await run_trusted_command (
144- self .source_root ,
145- self .trusted_root / ".github/scripts/temporal-release/reconcile-publication.sh" ,
146- env ,
147- )
148- except subprocess .CalledProcessError as error :
149- if error .returncode not in COMMAND_FAILURES :
150- raise
151- message , kind = COMMAND_FAILURES [error .returncode ]
152- raise ApplicationError (message , type = kind , non_retryable = True ) from error
153- if output :
154- raise RuntimeError ("Publication command wrote unexpected standard output." )
155- raw : Any = json .loads (output_file .read_text ())
156- return cast (T , value_to_type (result_type , raw ))
122+
123+ async def command (arguments : Sequence [str ], env : Mapping [str , str ]) -> bytes :
124+ """Run publication tools through the Activity's cancellation-aware boundary."""
125+ return await run_trusted_process (self .source_root , arguments , env )
126+
127+ publisher : Publisher | None = None
128+ try :
129+ publisher = Publisher (value , self .source_root , self .environment , command )
130+ return await operation (publisher )
131+ except PublicationFailure as error :
132+ raise ApplicationError (str (error ), type = error .error_type , non_retryable = True ) from error
133+ finally :
134+ if publisher is not None :
135+ publisher .close ()
157136
158137
159138def validate_publication (
0 commit comments