99import hashlib
1010import json
1111import os
12+ import random
1213import re
1314import shutil
1415import subprocess
1516import sys
1617import tempfile
18+ import time
1719from pathlib import Path
1820from typing import Any
1921
3032LEGACY_ADDED_PATTERN = re .compile (r"^- 本次新增插件:(\d+)$" , re .MULTILINE )
3133LEGACY_PLUGIN_PATTERN = re .compile (r"^### \[([^]]+)]\((https://github\.com/[^)]+)\)$" , re .MULTILINE )
3234
35+ # Transient GitHub/Azure network drops (EOF, SSL_ERROR_SYSCALL, connection reset)
36+ # were flipping whole runs to failure. Retry only the network calls, with backoff.
37+ NETWORK_RETRIES = 3
38+ NETWORK_RETRY_BACKOFF = 1.0
39+
3340
3441class ArchiveError (RuntimeError ):
3542 """A report could not be safely archived."""
3643
3744
3845def command (
39- args : list [str ], * , cwd : Path = ROOT , check : bool = True
46+ args : list [str ],
47+ * ,
48+ cwd : Path = ROOT ,
49+ check : bool = True ,
50+ retries : int = 0 ,
51+ backoff : float = NETWORK_RETRY_BACKOFF ,
4052) -> subprocess .CompletedProcess [str ]:
41- result = subprocess .run (
42- args ,
43- cwd = cwd ,
44- check = False ,
45- capture_output = True ,
46- text = True ,
47- )
48- if check and result .returncode != 0 :
53+ attempt = 0
54+ while True :
55+ result = subprocess .run (
56+ args ,
57+ cwd = cwd ,
58+ check = False ,
59+ capture_output = True ,
60+ text = True ,
61+ )
62+ if result .returncode == 0 or not check :
63+ return result
4964 detail = result .stderr .strip () or result .stdout .strip () or "command failed"
50- raise ArchiveError (f"{ args [0 ]} failed: { detail } " )
51- return result
65+ if attempt >= retries :
66+ raise ArchiveError (f"{ args [0 ]} failed: { detail } " )
67+ # Exponential backoff with jitter, so a transient blip does not burn the run.
68+ delay = backoff * (2 ** attempt ) + random .uniform (0 , backoff )
69+ print (
70+ f"{ args [0 ]} failed (attempt { attempt + 1 } /{ retries + 1 } ), "
71+ f"retrying in { delay :.1f} s: { detail .splitlines ()[0 ][:200 ]} " ,
72+ file = sys .stderr ,
73+ )
74+ time .sleep (delay )
75+ attempt += 1
5276
5377
5478def list_artifacts (gh : str , repository : str ) -> list [dict [str , Any ]]:
@@ -59,7 +83,8 @@ def list_artifacts(gh: str, repository: str) -> list[dict[str, Any]]:
5983 gh ,
6084 "api" ,
6185 f"repos/{ repository } /actions/artifacts?per_page=100&page={ page } " ,
62- ]
86+ ],
87+ retries = NETWORK_RETRIES ,
6388 )
6489 payload = json .loads (result .stdout )
6590 batch = payload .get ("artifacts" , [])
@@ -129,6 +154,11 @@ def append_manifest(path: Path, record: dict[str, Any]) -> None:
129154 os .fsync (handle .fileno ())
130155
131156
157+ def failure_record_path (archive_root : Path , artifact : dict [str , Any ]) -> Path :
158+ """Location of an artifact's last-error marker (written on failure, cleared on success)."""
159+ return archive_root / "failed" / f"{ artifact .get ('id' , 'unknown' )} .json"
160+
161+
132162def publication_commit (repo_root : Path , run_id : str ) -> str | None :
133163 marker = f"<!-- topic-sync:{ run_id } -->"
134164 result = command (
@@ -398,7 +428,11 @@ def sync_reports(
398428 archive_root : Path ,
399429 dry_run : bool = False ,
400430) -> dict [str , int ]:
401- command (["git" , "fetch" , "--quiet" , "origin" , "main" ], cwd = repo_root )
431+ command (
432+ ["git" , "fetch" , "--quiet" , "origin" , "main" ],
433+ cwd = repo_root ,
434+ retries = NETWORK_RETRIES ,
435+ )
402436 artifacts = select_report_artifacts (list_artifacts (gh , repository ))
403437 manifest_records = load_manifest (archive_root / "manifest.jsonl" )
404438 counts = {"archived" : 0 , "duplicate" : 0 , "pending" : 0 , "would_archive" : 0 }
@@ -421,6 +455,7 @@ def sync_reports(
421455 directory ,
422456 ],
423457 cwd = repo_root ,
458+ retries = NETWORK_RETRIES ,
424459 )
425460 report_json = next (Path (directory ).rglob ("report.json" ), None )
426461 if report_json is not None :
@@ -447,6 +482,9 @@ def sync_reports(
447482 dry_run = dry_run ,
448483 )
449484 counts [outcome ] += 1
485+ # The artifact synced cleanly this round; drop any stale failure marker.
486+ if not dry_run :
487+ failure_record_path (archive_root , artifact ).unlink (missing_ok = True )
450488 except (ArchiveError , json .JSONDecodeError , OSError ) as exc :
451489 failures .append (f"{ artifact .get ('name' )} : { exc } " )
452490 failure_record = {
@@ -457,7 +495,7 @@ def sync_reports(
457495 "run_id" : run_id ,
458496 }
459497 atomic_write (
460- archive_root / "failed" / f" { artifact . get ( 'id' , 'unknown' ) } .json" ,
498+ failure_record_path ( archive_root , artifact ) ,
461499 (
462500 json .dumps (failure_record , ensure_ascii = False , indent = 2 , sort_keys = True )
463501 + "\n "
0 commit comments