Skip to content

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 | jq
json
{
  "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_signing

GitHub 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 참조.

다음