/ scripts / auths_workflows / artifact_signing.py
artifact_signing.py
  1  #!/usr/bin/env python3
  2  """
  3  Test the full release artifact signing workflow locally (macOS aarch64).
  4  
  5  Usage:
  6      python scripts/auths_workflows/artifact_signing.py          # run the full workflow
  7      python scripts/auths_workflows/artifact_signing.py --skip-build  # reuse existing build
  8  
  9  What it does:
 10      1. Checks prerequisites (cargo, auths identity, device keys)
 11      2. Builds release binaries (cargo build --release -p auths-cli)
 12      3. Packages them into auths-macos-aarch64.tar.gz (same as CI)
 13      4. Generates SHA256 checksum
 14      5. Signs the artifact with `auths artifact sign`
 15      6. Displays the .auths.json attestation
 16      7. Verifies the attestation with `auths artifact verify`
 17      8. Cleans up
 18  
 19  Requires:
 20      - macOS aarch64
 21      - cargo on PATH
 22      - auths identity set up (`auths status` shows identity)
 23      - At least one device key alias (`auths key list`)
 24  
 25  This mirrors the release.yml workflow so you can validate signing
 26  works before pushing a tag.
 27  """
 28  
 29  import json
 30  import os
 31  import shutil
 32  import subprocess
 33  import sys
 34  import tempfile
 35  from pathlib import Path
 36  
 37  REPO_ROOT = Path(__file__).resolve().parents[2]
 38  TARGET = "aarch64-apple-darwin"
 39  ASSET_NAME = "auths-macos-aarch64"
 40  EXT = ".tar.gz"
 41  BINARIES = ["auths", "auths-sign", "auths-verify"]
 42  
 43  # ANSI colors
 44  GREEN = "\033[92m"
 45  YELLOW = "\033[93m"
 46  RED = "\033[91m"
 47  CYAN = "\033[96m"
 48  BOLD = "\033[1m"
 49  RESET = "\033[0m"
 50  
 51  
 52  def step(n: int, msg: str) -> None:
 53      print(f"\n{BOLD}{CYAN}[Step {n}]{RESET} {BOLD}{msg}{RESET}")
 54  
 55  
 56  def ok(msg: str) -> None:
 57      print(f"  {GREEN}✓{RESET} {msg}")
 58  
 59  
 60  def warn(msg: str) -> None:
 61      print(f"  {YELLOW}⚠{RESET} {msg}")
 62  
 63  
 64  def fail(msg: str) -> None:
 65      print(f"  {RED}✗{RESET} {msg}", file=sys.stderr)
 66  
 67  
 68  def run(cmd: list[str], **kwargs) -> subprocess.CompletedProcess:
 69      """Run a command, print it, and return the result."""
 70      display = " ".join(cmd)
 71      print(f"  $ {display}", flush=True)
 72      return subprocess.run(cmd, **kwargs)
 73  
 74  
 75  def run_checked(cmd: list[str], **kwargs) -> subprocess.CompletedProcess:
 76      result = run(cmd, capture_output=True, text=True, **kwargs)
 77      if result.returncode != 0:
 78          fail(f"Command failed (exit {result.returncode})")
 79          if result.stderr.strip():
 80              print(f"    stderr: {result.stderr.strip()}")
 81          sys.exit(1)
 82      return result
 83  
 84  
 85  def main() -> None:
 86      skip_build = "--skip-build" in sys.argv
 87  
 88      print(f"{BOLD}{'='*60}{RESET}")
 89      print(f"{BOLD}  Artifact Signing Workflow — Local Test{RESET}")
 90      print(f"{BOLD}{'='*60}{RESET}")
 91  
 92      # ── Step 1: Prerequisites ──
 93      step(1, "Checking prerequisites")
 94  
 95      if shutil.which("cargo") is None:
 96          fail("cargo not found on PATH")
 97          sys.exit(1)
 98      ok("cargo found")
 99  
100      if shutil.which("auths") is None:
101          fail("auths not found on PATH. Run: cargo install --path crates/auths-cli")
102          sys.exit(1)
103      ok("auths found")
104  
105      # Check identity exists
106      result = run_checked(["auths", "status"], cwd=REPO_ROOT)
107      if "not initialized" in result.stdout.lower():
108          fail("No auths identity found. Run: auths init")
109          sys.exit(1)
110      ok("auths identity exists")
111  
112      print(f"\n  {CYAN}--- auths status ---{RESET}")
113      for line in result.stdout.strip().splitlines():
114          print(f"  {line}")
115  
116      # Check device keys
117      key_result = run_checked(["auths", "key", "list"], cwd=REPO_ROOT)
118      if not key_result.stdout.strip():
119          fail("No device keys found. You need at least one key alias.")
120          sys.exit(1)
121      ok("device keys found")
122  
123      print(f"\n  {CYAN}--- auths key list ---{RESET}")
124      for line in key_result.stdout.strip().splitlines():
125          print(f"  {line}")
126  
127      # Ask user which device key alias to use
128      print()
129      device_alias = input(f"  {BOLD}Enter device-key-alias to use for signing:{RESET} ").strip()
130      if not device_alias:
131          fail("No alias provided.")
132          sys.exit(1)
133  
134      # Optionally ask for identity key alias
135      identity_alias = input(
136          f"  {BOLD}Enter identity-key-alias (leave blank for device-only):{RESET} "
137      ).strip()
138  
139      # ── Step 2: Build ──
140      work_dir = Path(tempfile.mkdtemp(prefix="auths-release-test-"))
141      print(f"\n  Working directory: {work_dir}")
142  
143      if skip_build:
144          step(2, "Skipping build (--skip-build)")
145          # Verify binaries exist
146          for binary in BINARIES:
147              path = REPO_ROOT / "target" / "release" / binary
148              if not path.exists():
149                  fail(f"Binary not found: {path}")
150                  fail("Run without --skip-build first.")
151                  shutil.rmtree(work_dir)
152                  sys.exit(1)
153          ok("Existing release binaries found")
154      else:
155          step(2, "Building release binaries")
156          result = run(
157              ["cargo", "build", "--release", "--package", "auths-cli"],
158              cwd=REPO_ROOT,
159          )
160          if result.returncode != 0:
161              fail("Build failed")
162              shutil.rmtree(work_dir)
163              sys.exit(1)
164          ok("Build complete")
165  
166      # ── Step 3: Package ──
167      step(3, "Packaging binaries into tarball")
168      staging = work_dir / "staging"
169      staging.mkdir()
170  
171      for binary in BINARIES:
172          src = REPO_ROOT / "target" / "release" / binary
173          if src.exists():
174              shutil.copy2(src, staging / binary)
175              ok(f"Copied {binary}")
176          else:
177              warn(f"Binary not found: {binary} (skipped)")
178  
179      tarball = work_dir / f"{ASSET_NAME}{EXT}"
180      run_checked(
181          ["tar", "-czf", str(tarball), "-C", str(staging), "."],
182      )
183      size_mb = tarball.stat().st_size / (1024 * 1024)
184      ok(f"Created {tarball.name} ({size_mb:.1f} MB)")
185  
186      # ── Step 4: SHA256 checksum ──
187      step(4, "Generating SHA256 checksum")
188      checksum_file = work_dir / f"{ASSET_NAME}{EXT}.sha256"
189      result = run_checked(["shasum", "-a", "256", str(tarball)])
190      checksum_line = result.stdout.strip()
191      checksum_file.write_text(checksum_line + "\n")
192      ok(f"Checksum: {checksum_line.split()[0]}")
193  
194      # ── Step 5: Sign artifact ──
195      step(5, "Signing artifact with auths")
196      sign_cmd = [
197          "auths", "artifact", "sign", str(tarball),
198          "--device-key-alias", device_alias,
199          "--note", "Local signing test",
200      ]
201      if identity_alias:
202          sign_cmd.extend(["--identity-key-alias", identity_alias])
203  
204      result = run(sign_cmd, cwd=REPO_ROOT)
205      if result.returncode != 0:
206          fail("Artifact signing failed")
207          print(f"\n  Working directory preserved at: {work_dir}")
208          sys.exit(1)
209  
210      attestation_file = Path(f"{tarball}.auths.json")
211      if not attestation_file.exists():
212          fail(f"Expected attestation file not found: {attestation_file}")
213          print(f"\n  Working directory preserved at: {work_dir}")
214          sys.exit(1)
215      ok(f"Created {attestation_file.name}")
216  
217      # ── Step 6: Display attestation ──
218      step(6, "Attestation contents")
219      attestation_raw = attestation_file.read_text()
220      try:
221          attestation = json.loads(attestation_raw)
222          formatted = json.dumps(attestation, indent=2)
223          print(f"\n  {CYAN}--- {attestation_file.name} ---{RESET}")
224          for line in formatted.splitlines():
225              print(f"  {line}")
226      except json.JSONDecodeError:
227          print(f"  (raw): {attestation_raw[:500]}")
228  
229      # ── Step 7: Verify attestation ──
230      step(7, "Verifying attestation")
231      result = run(
232          ["auths", "artifact", "verify", str(tarball)],
233          cwd=REPO_ROOT,
234          capture_output=True,
235          text=True,
236      )
237      if result.returncode == 0:
238          ok("Verification passed")
239          if result.stdout.strip():
240              for line in result.stdout.strip().splitlines():
241                  print(f"  {line}")
242      else:
243          warn(f"Verification returned exit {result.returncode}")
244          if result.stdout.strip():
245              for line in result.stdout.strip().splitlines():
246                  print(f"  {line}")
247          if result.stderr.strip():
248              for line in result.stderr.strip().splitlines():
249                  print(f"  {line}")
250  
251      # ── Step 8: Summary ──
252      step(8, "Cleanup and summary")
253      print(f"\n  {CYAN}Files produced:{RESET}")
254      for f in sorted(work_dir.iterdir()):
255          if f.is_file():
256              size = f.stat().st_size
257              print(f"    {f.name:50s} {size:>10,} bytes")
258      for f in [attestation_file]:
259          if f.exists() and f.parent != work_dir:
260              size = f.stat().st_size
261              print(f"    {f.name:50s} {size:>10,} bytes")
262  
263      # Clean up
264      shutil.rmtree(work_dir)
265      if attestation_file.exists():
266          attestation_file.unlink()
267      ok("Cleaned up temp files")
268  
269      print(f"\n{BOLD}{GREEN}{'='*60}{RESET}")
270      print(f"{BOLD}{GREEN}  Artifact signing workflow completed successfully!{RESET}")
271      print(f"{BOLD}{GREEN}{'='*60}{RESET}")
272      print(f"\n  This confirms the release.yml signing step will work in CI.")
273      print(f"  Make sure these GitHub secrets are set (via 'just ci-setup'):")
274      print(f"    • AUTHS_CI_PASSPHRASE")
275      print(f"    • AUTHS_CI_KEYCHAIN")
276      print(f"    • AUTHS_CI_IDENTITY_BUNDLE\n")
277  
278  
279  if __name__ == "__main__":
280      main()