Cert Mirror — fastlane 통합
fastlane match 워크플로우 끝에 ainote cert_mirror 업로드를 붙이는 drop-in 패턴. 검증된 production 코드 (ainote 의 ios_native).
전체 개요는 overview, API 시그니처는 api.
사전 준비
1. UserMirrorCredential 발급 (본인 ainote 계정)
본인 계정에서 1개만 발급받으면 됩니다. 자세한 절차는 api 인증 섹션.
발급 결과로 받는 두 값:
token— 64 hex,Authorization: McpKey <token>헤더hmac_secret— 64 hex, 업로드 body HMAC-SHA256 계산용 (발급 시점에만 평문 확인 가능, 별도 보관 필수)
2. macOS Keychain 에 secret 저장
bash
security add-generic-password -s "<your-app> mirror token" -a "$USER" -w '<TOKEN>'
security add-generic-password -s "<your-app> mirror hmac" -a "$USER" -w '<HMAC_SECRET>'
# MATCH_PASSWORD 도 같은 패턴
security add-generic-password -s "<your-app> match" -a "$USER" -w '<MATCH_PASSWORD>'iCloud Keychain 활성화돼있으면 맥미니 ↔ 맥북 자동 동기화.
Fastfile drop-in
fastlane/Fastfile:
ruby
default_platform(:ios)
MIRROR_URL = ENV["AINOTE_MIRROR_URL"] || "https://api.ainote.dev/api/cert_mirror"
MIRROR_TOKEN = ENV["AINOTE_MIRROR_TOKEN"]
MIRROR_HMAC = ENV["AINOTE_MIRROR_HMAC_SECRET"]
MATCH_REPO = ENV["MATCH_REPO_PATH"] || File.expand_path("~/apple-certs")
platform :ios do
desc "Sync code signing + mirror to ainote"
lane :sync_signing do
key = app_store_connect_api_key(
key_id: ENV["ASC_KEY_ID"],
issuer_id: ENV["ASC_ISSUER_ID"],
key_filepath: ENV["ASC_API_KEY_PATH"],
duration: 1200,
in_house: false,
)
match(type: "development", readonly: false, api_key: key)
match(type: "appstore", readonly: false, api_key: key)
mirror_to_ainote
end
# ────────── private helpers ──────────
private_lane :mirror_to_ainote do
if [MIRROR_URL, MIRROR_TOKEN, MIRROR_HMAC].any?(&:nil?)
UI.important("⚠ mirror env missing — skipping")
next
end
unless Dir.exist?(MATCH_REPO)
UI.important("⚠ MATCH_REPO_PATH not found: #{MATCH_REPO} — skipping")
next
end
require "digest"
require "openssl"
require "net/http"
tarball = nil
begin
Dir.chdir(MATCH_REPO) do
commit_sha = sh("git rev-parse HEAD").strip
unless commit_sha.match?(/\A[0-9a-f]{40}\z/)
UI.user_error!("invalid HEAD sha: #{commit_sha.inspect}")
end
tarball = "/tmp/match-mirror-#{commit_sha[0..7]}.tgz"
# git archive: bsd vs gnu tar portability
sh("git archive --format=tar.gz -o #{tarball} HEAD")
tarball_size = File.size(tarball)
# ── streaming HMAC + SHA256 (메모리에 통째로 안 올림) ──
hmac = OpenSSL::HMAC.new(MIRROR_HMAC, OpenSSL::Digest::SHA256.new)
sha = OpenSSL::Digest::SHA256.new
File.open(tarball, "rb") do |f|
while (chunk = f.read(64 * 1024))
hmac.update(chunk)
sha.update(chunk)
end
end
hmac_hex = hmac.hexdigest
sha256_hex = sha.hexdigest
# ── HTTP POST (body_stream — 업로드도 메모리 스트림 X) ──
uri = URI.parse(MIRROR_URL)
Net::HTTP.start(uri.host, uri.port, use_ssl: uri.scheme == "https",
open_timeout: 10, read_timeout: 60) do |http|
req = Net::HTTP::Post.new(uri)
req["Authorization"] = "McpKey #{MIRROR_TOKEN}"
req["Content-Type"] = "application/octet-stream"
req["Content-Length"] = tarball_size.to_s
req["X-Commit-Sha"] = commit_sha
req["X-Tarball-Sha256"] = sha256_hex
req["X-Mirror-Signature"] = "sha256=#{hmac_hex}"
req["X-Uploaded-By"] = "fastlane:#{ENV['USER'] || 'unknown'}"
req["User-Agent"] = "ainote-fastlane-mirror/1.0" # WAF 우회 필수
File.open(tarball, "rb") do |upload|
req.body_stream = upload
resp = http.request(req)
if resp.code.to_i.between?(200, 299)
UI.success("✓ mirrored sha=#{commit_sha[0..7]} bytes=#{tarball_size}")
else
UI.important("⚠ mirror HTTP #{resp.code}: #{resp.body.to_s[0..200]}")
end
end
end
end
rescue => e
UI.important("⚠ mirror failed: #{e.class}: #{e.message} — git push primary OK, retry next sync")
ensure
File.delete(tarball) if tarball && File.exist?(tarball)
end
end
end핵심 디자인:
- mirror 실패해도 lane 자체는 안 깨짐 (primary git push 는 이미 성공)
body_stream+ chunk loop 로 50MB tarball 도 메모리에 통째로 안 올림git archive사용 —tar --exclude='.git'는 bsd 와 gnu 사이 호환성 깨짐
실행
bash
cd <your-ios-project>
export MATCH_PASSWORD="$(security find-generic-password -s 'your-app match' -w)"
export AINOTE_MIRROR_TOKEN="$(security find-generic-password -s 'your-app mirror token' -w)"
export AINOTE_MIRROR_HMAC_SECRET="$(security find-generic-password -s 'your-app mirror hmac' -w)"
export ASC_KEY_ID="<your ASC key id>"
export ASC_ISSUER_ID="<your ASC issuer>"
export ASC_API_KEY_PATH="$HOME/.appstoreconnect/private_keys/AuthKey_<KEY>.p8"
bundle exec fastlane sync_signing기대 로그:
[hh:mm:ss] $ git rev-parse HEAD
[hh:mm:ss] 268112ce4ed3dec5e04cdf7e517cb72e04772b45
[hh:mm:ss] $ git archive --format=tar.gz -o /tmp/match-mirror-268112ce.tgz HEAD
[hh:mm:ss] ✓ mirrored sha=268112ce bytes=60781검증
bash
TOKEN="$(security find-generic-password -s 'your-app mirror token' -w)"
curl -sS -H "User-Agent: ainote-cli/1.0" \
-H "Authorization: McpKey $TOKEN" \
https://api.ainote.dev/api/cert_mirror | jqjson
{
"blobs": [
{
"commit_sha": "268112ce…",
"sha256": "53c9b81e…",
"byte_size": 60781,
"uploaded_at": "2026-05-16T23:16:43.855Z",
"uploaded_by": "fastlane:seunghan"
}
]
}신규 mac / 재해 복구
primary (apple-certs GitHub repo) 가 살아있는 경우:
bash
gh repo clone <your-org>/apple-certs ~/apple-certs
cd ~/apple-certs && git fetch origin master && git checkout master
# Keychain 에 MATCH_PASSWORD 복원 후 fastlane fetch_signingGitHub repo 가 사라진 경우 — ainote mirror 에서 복원:
bash
TOKEN="$(security find-generic-password -s 'your-app mirror token' -w)"
# 가장 최근 commit_sha 확인
SHA=$(curl -fsSL -H "User-Agent: ainote-cli/1.0" -H "Authorization: McpKey $TOKEN" \
https://api.ainote.dev/api/cert_mirror | jq -r '.blobs[0].commit_sha')
# tarball 다운로드
curl -fsSL -H "User-Agent: ainote-cli/1.0" -H "Authorization: McpKey $TOKEN" \
-o /tmp/apple-certs.tgz \
"https://api.ainote.dev/api/cert_mirror/$SHA/download"
# 새 git repo 로 풀어서 다시 push (GitHub 새 repo 생성 후)
mkdir ~/apple-certs && cd ~/apple-certs
tar -xzf /tmp/apple-certs.tgz
git init -b master && git add -A && git commit -m "restore from ainote mirror sha=$SHA"
git remote add origin git@github.com:<your-org>/apple-certs.git
git push -u origin master함정
| 증상 | 원인 | 해결 |
|---|---|---|
| HTTP 403 "Forbidden. Your request has been blocked" | Render WAF 가 default UA 차단 | User-Agent 헤더 명시 |
| HTTP 422 "X-Mirror-Signature invalid" | MIRROR_HMAC 가 발급 시점 값과 다름 | Keychain 값 확인, 필요시 UserMirrorCredential 재발급 |
fatal: ambiguous argument 'HEAD' | apple-certs 로컬 clone 이 main branch (빈 것) | git fetch origin master && git checkout master |
invalid number: '-----BEGIN' (match 자체 에러) | match 의 api_key_path: 에 raw .p8 전달 — JSON 기대 | api_key: 에 app_store_connect_api_key action 의 hash 전달 |
전체 함정 목록은 overview 참조.