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()