update_verified_pins.py 4.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140
  1. #!/usr/bin/env python3
  2. """Promote latest-built commits to the verified PIN_* block in main.yml.
  3. Update mode: builds happen at latest tips. After the build, if the components
  4. built this run all passed, their used SHAs overwrite the audited pins. Previous
  5. audited pins are recorded in .github/pins/history/<date>.json before being
  6. replaced. Only components that were enabled AND passed are promoted; unrelated
  7. pins are left untouched.
  8. """
  9. import json
  10. import os
  11. import re
  12. import sys
  13. from datetime import datetime, timezone
  14. from uuid import uuid4
  15. REPO = os.environ.get("GITHUB_WORKSPACE", ".")
  16. MAIN = os.path.join(REPO, ".github/workflows/main.yml")
  17. # (pin-var, resolved-sha-env, approval-env)
  18. # approval env is "1"/"true" when the component was enabled and all its builds passed.
  19. PINS = [
  20. ("PIN_NOMOUNT", "NOMOUNT_SHA", "APPROVE_NOMOUNT"),
  21. ("PIN_KERNELSU", "KERNELSU_SHA", "APPROVE_KERNELSU"),
  22. ("PIN_RESUKISU", "RESUKISU_SHA", "APPROVE_RESUKISU"),
  23. # SUSFS is intentionally excluded: it is always resolved at latest so it
  24. # stays API-matched to the always-latest KernelSU-Next tree.
  25. ]
  26. SHA_RE = re.compile(r"^[0-9a-f]{40}$")
  27. def approved(env):
  28. return os.environ.get(env, "").strip() in ("1", "true", "True")
  29. def read_pins():
  30. """Return dict {var_name: sha} from the current PIN_* block in main.yml."""
  31. with open(MAIN, "r", encoding="utf-8", newline="") as fh:
  32. text = fh.read()
  33. pins = {}
  34. for var, _, _ in PINS:
  35. pattern = re.compile(
  36. rf'^[ \t]*{re.escape(var)}(?P<assignment>[ \t]*=[^\r\n]*)?[ \t]*(?=\r?$)',
  37. re.MULTILINE,
  38. )
  39. matches = list(pattern.finditer(text))
  40. if len(matches) != 1:
  41. raise ValueError(f"expected exactly one {var} assignment, found {len(matches)}")
  42. value = re.fullmatch(r'="([0-9a-f]{40})"[ \t]*', matches[0].group("assignment") or "")
  43. if value is None:
  44. raise ValueError(f"invalid {var} assignment")
  45. pins[var] = value.group(1)
  46. return pins
  47. def build_changes():
  48. changes = [] # (var, old_sha, new_sha)
  49. promoted = {} # key -> sha for pins being set
  50. for var, sha_env, approve_env in PINS:
  51. new_sha = os.environ.get(sha_env, "").strip().lower()
  52. if not SHA_RE.match(new_sha):
  53. print(f" skip {var}: resolved SHA invalid ({new_sha})", file=sys.stderr)
  54. continue
  55. if not approved(approve_env):
  56. print(f" skip {var}: not approved to promote (disabled or a build failed)", file=sys.stderr)
  57. continue
  58. old_sha = read_pins().get(var)
  59. if old_sha == new_sha:
  60. print(f" unchanged {var} ({new_sha[:8]})")
  61. continue
  62. promoted[var] = new_sha
  63. changes.append((var, old_sha, new_sha))
  64. return changes, promoted
  65. def history_path():
  66. ts = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H%M%SZ")
  67. d = os.path.join(REPO, ".github/pins/history")
  68. os.makedirs(d, exist_ok=True)
  69. return os.path.join(d, f"{ts}-{uuid4().hex[:8]}.json")
  70. def write_history(changes, path):
  71. record = {
  72. "updated_at": datetime.now(timezone.utc).isoformat(),
  73. "previous_verified": {key: (old or "") for key, old, _ in changes},
  74. }
  75. with open(path, "w", encoding="utf-8") as fh:
  76. json.dump(record, fh, indent=2)
  77. fh.write("\n")
  78. print(f" history written: {os.path.relpath(path, REPO)}")
  79. def apply_pins(promoted):
  80. with open(MAIN, "r", encoding="utf-8", newline="") as fh:
  81. text = fh.read()
  82. replacements = []
  83. for key, sha in promoted.items():
  84. pattern = re.compile(
  85. rf'^[ \t]*{re.escape(key)}(?P<assignment>[ \t]*=[^\r\n]*)?[ \t]*(?=\r?$)',
  86. re.MULTILINE,
  87. )
  88. matches = list(pattern.finditer(text))
  89. if len(matches) != 1:
  90. raise ValueError(f"expected exactly one {key} assignment, found {len(matches)}")
  91. match = matches[0]
  92. value = re.fullmatch(r'="([0-9a-f]{40})"[ \t]*', match.group("assignment") or "")
  93. if value is None:
  94. raise ValueError(f"invalid {key} assignment")
  95. line = match.group(0)
  96. indentation = line[: len(line) - len(line.lstrip(" \t"))]
  97. trailing = line[len(line.rstrip(" \t")) :]
  98. replacements.append((match.start(), match.end(), f'{indentation}{key}="{sha}"{trailing}'))
  99. for start, end, replacement in reversed(replacements):
  100. text = text[:start] + replacement + text[end:]
  101. with open(MAIN, "w", encoding="utf-8", newline="") as fh:
  102. fh.write(text)
  103. return True
  104. def main():
  105. changes, promoted = build_changes()
  106. if not changes:
  107. print("No pins to promote (nothing changed or nothing approved).")
  108. return 0
  109. hist = history_path()
  110. write_history(changes, hist)
  111. if not apply_pins(promoted):
  112. return 1
  113. print("Promoted pins:")
  114. for key, old, new in changes:
  115. print(f" {key}: {old[:8] if old else 'none'} -> {new[:8]}")
  116. return 0
  117. if __name__ == "__main__":
  118. sys.exit(main())