[CI/CD] GitHub Actions로 TestFlight 배포 자동화하기

post-thumbnail

개요

안녕하세요,

iOS에서 Continuous Deployment (CD)의 핵심은 개발이 완료된 후 Signing → Archiving & Exporting → Uploading하는 일련의 과정을 자동화하는 것입니다.

CD를 도입하기 전에 가장 먼저 고려해야 할 점은 "프로젝트의 장기성 "입니다. CD를 구축하는 것 또한 리소스가 필요한 작업이기 때문에 프로젝트의 유지보수가 필요하지 않은 경우 CD의 효과가 제한될 수 있습니다.

그러나 iOS 개발을 진행하면서 테스트 플라이트에 배포하는 작업에 어려움을 겪는다면, 혹은 GitHub에 코드를 푸시하면 자동으로 특정 작업이 수행되길 바란다면 이 글을 참고하시면 도움이 될 것입니다.

Why GitHub Actions

2024년 3월 현재, iOS CD를 구축하는 데 선택할 수 있는 대안으로는 Xcode Cloud와 GitHub Actions가 있습니다. Xcode Cloud는 2022년에 베타 버전이 출시되었으며, 이후로 점차 사용자들이 늘어나고 있습니다.

Xcode Cloud의 주요 장점은 First-Party 툴임에도 불구하고 베타 OS를 사용할 수 있다는 것입니다. 반면, GitHub Actions는 YAML 스크립트를 사용하여 더 유연한 작업 프로세스를 구현할 수 있습니다. 저희 팀은 특히 SFTP를 통한 앱 파일 업로드 등과 같은 다양한 작업을 쉽게 연동할 수 있는 GitHub Actions를 선택하였습니다. 또한 GitHub Actions에는 이미 다양한 Action들이 제공되어 있어 우리의 요구사항에 쉽게 대응할 수 있었습니다.

또한, GitHub Actions를 사용함으로써 다른 플랫폼에서도 일관된 CD를 구축할 수 있으므로 유연성과 효율성을 높일 수 있습니다.

결과와 후기

CD의 목표는 다음과 같습니다:

  1. 특정 (Release) 브랜치에 Push/Merge를 하면 배포 작업을 수행합니다.
  2. TestFlight에 등록하고 실행 가능한 .ipa 파일을 Artifact에 업로드합니다.
  3. 슬랙에 연동하여 특정 채널에 결과를 알립니다.

결론적으로, 원하는 브랜치에 코드가 올라간 후 위 작업들이 수행되고 슬랙으로 알림이 오는 데 약 3분이 소요되었습니다. 이는 프로젝트 규모와 IPA 파일의 크기에 따라 다를 수 있습니다.

위 CD 파이프라인을 통해 개발 및 배포 과정을 자동화하면 개발자들은 더 많은 시간을 코드 작성과 개선에 집중할 수 있으며, 릴리스 프로세스도 훨씬 빠르고 안정적으로 이루어질 수 있습니다. 이러한 결과는 팀의 생산성 향상과 소프트웨어 품질 향상에 도움이 됩니다.

전체 Script 코드

name: Action Test

on:
  push:
    branches: ["action-test"]
  # pull_request:
  #   branches: [ "action-test" ]

jobs:
  build:
    name: Build and Deploy Appknot Portal
    runs-on: macos-14 #가상머신 OS 버전 선택, 24.03. 기준 stable latest는 12이나 빌드가 되지 않는 문제로 14로 지정
    env:
      XC_ARCHIVE: ${{ 'AppknotPortal-iOS.xcarchive' }}
      XC_EXPORT_PATH: ${{ './artifacts' }}
      KEYCHAIN: ${{ 'test.keychain' }}
      SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }}

      # certificate
      ENCRYPTED_CERT_FILE_PATH: ${{ '.github/secrets/Appknot-iOS-Distribution.p12.gpg' }}
      DECRYPTED_CERT_FILE_PATH: ${{ '.github/secrets/Appknot-iOS-Distribution.p12' }}
      CERT_ENCRYPTION_KEY: ${{ secrets.CERTS_ENCRYPTION_PWD }} # gpg로 파일 암호화할 때 사용한 암호

      # provisioning
      ENCRYPTED_PROVISION_FILE_PATH: ${{ '.github/secrets/Appknot_Portal_Distribution_Unlisted.mobileprovision.gpg' }}
      DECRYPTED_PROVISION_FILE_PATH: ${{ '.github/secrets/Appknot_Portal_Distribution_Unlisted.mobileprovision' }}

      #appclip provisioning
      ENCRYPTED_CLIP_PROVISION_FILE_PATH: ${{ '.github/secrets/Appknot_Portal_App_Clip_Appstore.mobileprovision.gpg' }}
      DECRYPTED_CLIP_PROVISION_FILE_PATH: ${{ '.github/secrets/Appknot_Portal_App_Clip_Appstore.mobileprovision' }}

      PROVISIONING_ENCRYPTION_KEY: ${{ secrets.PROVISION_ENCRYPTION_PWD }} # gpg로 파일 암호화할 때 사용한 암호.

      # certification export key
      # 인증서 내보내기 할 때 사용한 암호
      CERT_EXPORT_KEY: ${{ secrets.CERT_EXPORT_PWD }}

    steps:
      #최신 xcode 선택
      - name: Select latest Xcode
        run: "sudo xcode-select -s /Applications/Xcode.app"

      # 러너가 레포 체크아웃
      - name: Checkout
        uses: actions/checkout@v3

      # 키체인 초기화 - 임시 키체인 생성
      - name: Configure Keychain
        run: |
          security create-keychain -p "" "$KEYCHAIN"
          security list-keychains -s "$KEYCHAIN"
          security default-keychain -s "$KEYCHAIN"
          security unlock-keychain -p "" "$KEYCHAIN"
          security set-keychain-settings

      # 인증서 복호화 및 설치
      - name: Configure Code Signing # Corrected indentation
        run: |
          gpg -d -o "$DECRYPTED_CERT_FILE_PATH" --pinentry-mode=loopback --passphrase "$CERT_ENCRYPTION_KEY" "$ENCRYPTED_CERT_FILE_PATH"
          gpg -d -o "$DECRYPTED_PROVISION_FILE_PATH" --pinentry-mode=loopback --passphrase "$PROVISIONING_ENCRYPTION_KEY" "$ENCRYPTED_PROVISION_FILE_PATH"
          # Appclip
          gpg -d -o "$DECRYPTED_CLIP_PROVISION_FILE_PATH" --pinentry-mode=loopback --passphrase "$PROVISIONING_ENCRYPTION_KEY" "$ENCRYPTED_CLIP_PROVISION_FILE_PATH"

          security import "$DECRYPTED_CERT_FILE_PATH" -k "$KEYCHAIN" -P "$CERT_EXPORT_KEY" -A
          security set-key-partition-list -S apple-tool:,apple: -s -k "" "$KEYCHAIN"

          # Xcode에서 찾을 수 있는 프로비저닝 프로필 설치하기 위해 우선 프로비저닝 디렉토리를 생성
          mkdir -p "$HOME/Library/MobileDevice/Provisioning Profiles"
            # 모든 프로비저닝 프로파일을 rename 하고 위에서 만든 디렉토리로 복사하는 과정
            for PROVISION in `ls .github/secrets/*.mobileprovision`
            do
              echo ALVIN PRIVISION DEBUG
              UUID=`/usr/libexec/PlistBuddy -c 'Print :UUID' /dev/stdin <<< $(security cms -D -i ./$PROVISION)`
            cp "./$PROVISION" "$HOME/Library/MobileDevice/Provisioning Profiles/$UUID.mobileprovision"
          done

      #Archive & Export
      - name: Archive & Export
        run: |
          pod install
          xcodebuild archive -workspace AppknotPortal.xcworkspace -scheme AppknotPortal-iOS -configuration release -archivePath $XC_ARCHIVE
          xcodebuild -exportArchive -archivePath $XC_ARCHIVE -exportOptionsPlist ExportOptions.plist -exportPath "$XC_EXPORT_PATH" -allowProvisioningUpdates

      #Artifact 업로드
      - name: Upload Artifact
        uses: actions/upload-artifact@v2
        with:
          name: Artifacts
          path: ./artifacts

      #TestFlight 업로드
      - name: Upload app to TestFlight
        uses: apple-actions/upload-testflight-build@v1
        with:
          app-path: "./artifacts/AppknotPortal.ipa"
          issuer-id: ${{ secrets.APPSTORE_ISSUER_ID }}
          api-key-id: ${{ secrets.APPSTORE_API_KEY_ID }}
          api-private-key: ${{ secrets.APPSTORE_API_PRIVATE_KEY }}

      #Slack 알림
      - name: Notify to Slack
        uses: 8398a7/action-slack@v3
        with:
          status: ${{ job.status }}
          fields: repo,message,commit,author,action,eventName,ref,workflow,job,took,pullRequest # selectable (default: repo,message)
        env:
          SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }} # required
        if: always() # Pick up events even if the job fails or is canceled.

      

주의할 점

  • YAML 파일에서는 space와 tab의 위치를 정확히 지정해야 합니다. 잘못된 들여쓰기는 원치 않는 동작을 초래할 수 있습니다.
  • 경로와 관련된 에러가 많이 발생할 수 있습니다. 디버깅을 위해 echo나 ls와 같은 Linux 명령어를 적극 활용하여 디버깅 라인들을 출력하는 것이 도움이 됩니다.
  • 원하는 workflow가 만들어질 때까지는 계속해서 실행해야 할 것입니다. 저는 약 50번 이상 실행해보았습니다.
  • 포털 앱은 AppClip 타겟이 따로 존재합니다. 따라서 이 글에는 앱과 함께 추가 프로파일에 대한 내용이 포함되어 있으니, 스크립트 코드를 확인할 때 참고하시기 바랍니다.

방법

0. 환경

  • Xcode 15
  • MacOS 14.2 Sonoma
  • CocoaPods
  • Minimum Deployment Target - iOS14

1. Workflow 생성

  • GitHub Actions에서 새로운 workflow를 만들면 main 브렌치에 yml 파일이 생성됩니다. 원하는 파일이름을 적으시고 저는 iOS라고 지었습니다.

  • yml파일에 Workflow의 이름을 적어줍니다.
name: Action Test

2. Trigger

  • Runner는 GitHub Actions의 가상머신 인스턴스입니다.
  • Runner가 workflow를 실행하는 트리거를 설정합니다. 저는 action-test 브랜치에 코드가 푸시될 때만 트리거가 작동하도록 설정했습니다. 또한 PR 등 다양한 트리거 조건을 지정할 수 있습니다.
name: Action Test

on:
  push:
    branches: [ "action-test" ]
  # pull_request:
  #   branches: [ "action-test" ]

Triggering a workflow - GitHub Docs

3. Jobs

  • 실질적인 작업 내용을 정의합니다. Workflow는 1개 이상의 Jobs들로 구성됩니다. 그리고 각각의 Job은 steps란 더 작은 단위의 테스크들로 구성됩니다.
  • runs-on 에서 가상머신의 OS와 버전을 지정합니다. 현 시점에서 macos-latest 옵션은 MacOS 12를 사용하게 됩니다. 저 같은 경우 빌드가 되지 않는 문제가 있어 MacOS 14로 명시하였습니다.
  • 여기서 지정한 이름은 workflow 내에 나타나게 됩니다.
jobs:
  build:
    name: Build and Deploy Appknot Portal
    runs-on: macos-14 #가상머신 OS 버전 선택, 24.03. 기준 stable latest는 12이나 빌드가 되지 않는 문제로 14로 지정

name: Build and Deploy Appknot Portal

The components of GitHub Actions - GitHub Docs

About GitHub-hosted runners - GitHub Docs

4.1. 암복호화

💡 제일 복잡한 부분입니다. 프로젝트마다 필요한 Certificate과 Profile이 다르므로 유연하게 봐주시면 감사하겠습니다.
  • 앱을 가상머신에서 배포하기 위해서는 인증서 파일과 프로비저닝 프로필이 필요합니다. 그러나 이러한 파일들은 보안상의 이유로 그대로 저장소에 업로드할 수 없으므로, 암호화 및 복호화 과정을 거쳐야 합니다.
  • 암호화 및 복호화 과정은 다음과 같습니다:
    1. Certificate .p12 파일을 내보내고 프로비저닝 프로필을 생성하고 다운로드합니다.
    2. GnuPG2를 사용하여 .p12 및 프로비저닝 프로필 파일을 암호화합니다.
    3. 암호화된 파일을 저장소에 업로드합니다.
    4. GitHub Secrets에 각 파일의 복호화 키를 등록합니다.
    5. GitHub Actions에서 암호화된 파일과 키를 사용하여 복호화하여 앱에 서명합니다.

4.1.1. Certificate & Provision

  • 애플 개발자 사이트에서 Certificate을 생성 후 다운받아 실행후 키체인에 저장합니다.
  • 키체인을 열어 p12로 내보내고 암호를 꼭 안전한곳에 저장합니다(깃헙에 등록해야함).

  • 마찬가지로 프로파일을 생성후 다운받습니다.

  • 타겟 빌드세팅에 프로파일이 등록되어있는지 확인합니다.

4.1.2. GnuPG2로 암호화

  • 암호화할 certificate과 profile

  • 터미널에서 gnupg2 설치
brew install gnupg2

  • Certificate 암호화 후 암호 저장(추후 깃헙에 등록)
gpg -c myCertificate.p12
gpg -c myProfile.mobileprovision

  • 프로파일도 동일한 과정을 거칩니다.

4.1.3. 암호화된 파일 리포에 업로드

  • .gpg 파일들을 프로젝트 리포내에 직접 올려줍니다.
  • 저는 숨겨진 .github폴더 내에 secrets폴더를 생성하여 그 안에 넣었습니다. 꼭 이 위치에 저장해야하는것은 아닙니다.

4.1.4. GitHub Secrets

  • 사용한 암호들을 GitHub에 등록합니다. 이렇게 하면 YAML 파일 내에서 script로 암호에 접근할 수 있습니다.
  • 프로젝트 세팅 → Security → Secrets and Variable에서 새로운 secret을 등록합니다.

  • 인증서 내보내기, GnuPG 암호화 할 때 사용한 암호들을 모두 등록시킵니다. 이 화면은 추후 Appstore Connect API를 사용할 때 Key 등록을 위해 다시 들어오게 될겁니다.
  • 프로젝트마다 차이가 있으니 유연하게 보셔야하며 secret의 이름은 원하시는대로 지으면 되지만 추후 환경변수로 사용될 것이니 되도록 대문자로 작성하여 나머지 script 코드와 구별을 추천드립니다.

4.1.5. 복호화 & Signing

  • 여기까지 오시느라 수고하셨습니다. 등록된 certificate, profile들과 secrets암호들을 사용하여 프로젝트 빌드 후 signing 하는 과정은 Code Signing 파트에 나와있습니다.

4.2. 환경 변수

  • 암호화 및 복호화 과정 및 추후 스크립트 작성 시 편의를 위해 미리 환경 변수를 정의하여 사용합니다.
  • 주요 환경 변수는 다음과 같습니다:
    • .xcarchive 및 임시 keychain 파일 이름과 경로
    • Certificate .p12 파일 이름과 경로
    • 프로비저닝 프로필 .mobileprovision 파일 이름과 경로
    • GitHub Secrets에 등록된 각종 암호들
env:
  XC_ARCHIVE: ${{ 'AppknotPortal-iOS.xcarchive' }}
  XC_EXPORT_PATH: ${{ './artifacts' }}
  KEYCHAIN: ${{ 'test.keychain' }}
  SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }}
	
	# certificate 
	ENCRYPTED_CERT_FILE_PATH: ${{ '.github/secrets/Appknot-iOS-Distribution.p12.gpg' }}
	DECRYPTED_CERT_FILE_PATH: ${{ '.github/secrets/Appknot-iOS-Distribution.p12' }}
	CERT_ENCRYPTION_KEY: ${{ secrets.CERTS_ENCRYPTION_PWD }} # gpg로 파일 암호화할 때 사용한 암호
	# provisioning
	ENCRYPTED_PROVISION_FILE_PATH: ${{ '.github/secrets/Appknot_Portal_Distribution_Unlisted.mobileprovision.gpg' }}
	DECRYPTED_PROVISION_FILE_PATH: ${{ '.github/secrets/Appknot_Portal_Distribution_Unlisted.mobileprovision' }}
	
	#appclip provisioning
	ENCRYPTED_CLIP_PROVISION_FILE_PATH: ${{ '.github/secrets/Appknot_Portal_App_Clip_Appstore.mobileprovision.gpg' }}
	DECRYPTED_CLIP_PROVISION_FILE_PATH: ${{ '.github/secrets/Appknot_Portal_App_Clip_Appstore.mobileprovision' }}
	
	PROVISIONING_ENCRYPTION_KEY: ${{ secrets.PROVISION_ENCRYPTION_PWD }} # gpg로 파일 암호화할 때 사용한 암호.
    
    # certification export key
    # 인증서 내보내기 할 때 사용한 암호
    CERT_EXPORT_KEY: ${{ secrets.CERT_EXPORT_PWD }}
	

5. Steps

  • 여기부턴 대부분의 설정이 끝나고 본격적인 작업 script들이 정의됩니다.

5.1 Xcode & Checkout

  • 최신 Xcode버전을 선택하고 해당 프로젝트를 checkout합니다.
  • run은 명령어를 실행하고 uses는 GitHub Actions에 미리 정의된 action을 사용한다는 의미입니다.
steps:
      #최신 xcode 선택
      - name: Select latest Xcode
        run: "sudo xcode-select -s /Applications/Xcode.app"
      
      # 러너가 레포 체크아웃
      - name: Checkout
        uses: actions/checkout@v3

5.2 Keychain

  • 인증서를 가져올 때 사용하기 위해 임시 키체인을 생성합니다.
# 키체인 초기화 - 임시 키체인 생성
- name: Configure Keychain 
  run: |
    security create-keychain -p "" "$KEYCHAIN"
    security list-keychains -s "$KEYCHAIN"
    security default-keychain -s "$KEYCHAIN"
    security unlock-keychain -p "" "$KEYCHAIN"
    security set-keychain-settings

5.3 Signing

  • gpg를 사용하여 인증서와 프로파일을 복호화합니다.
  • 복호화된 인증서를 임시 키체인을 사용하여 가져옵니다.
  • 여기서 사용하는 security 명령어는 MacOS bash 명령어로 keychain, 인증서, 등을 관리하는 명령어입니다.
    security command - macOS - SS64.com
  • Xcode가 프로비저닝 프로필을 찾는 디렉터리($HOME/Library/MobileDevice/Provisioning Profiles)를 생성합니다.
  • .github/secrets/ 디렉토리에 저장된 모든 .mobileprovision 파일을 순환합니다.
  • 각 프로비저닝 프로필 파일에 대해, /usr/libexec/PlistBuddy를 사용하여 UUID를 추출하고 UUID를 파일 이름으로 하는 지정된 디렉터리에 프로비저닝 프로필을 복사합니다.
      # 인증서 복호화 및 설치
      - name: Configure Code Signing # Corrected indentation
        run: |
          gpg -d -o "$DECRYPTED_CERT_FILE_PATH" --pinentry-mode=loopback --passphrase "$CERT_ENCRYPTION_KEY" "$ENCRYPTED_CERT_FILE_PATH"
          gpg -d -o "$DECRYPTED_PROVISION_FILE_PATH" --pinentry-mode=loopback --passphrase "$PROVISIONING_ENCRYPTION_KEY" "$ENCRYPTED_PROVISION_FILE_PATH"
          # Appclip
          gpg -d -o "$DECRYPTED_CLIP_PROVISION_FILE_PATH" --pinentry-mode=loopback --passphrase "$PROVISIONING_ENCRYPTION_KEY" "$ENCRYPTED_CLIP_PROVISION_FILE_PATH"

          security import "$DECRYPTED_CERT_FILE_PATH" -k "$KEYCHAIN" -P "$CERT_EXPORT_KEY" -A
          security set-key-partition-list -S apple-tool:,apple: -s -k "" "$KEYCHAIN"

          # Xcode에서 찾을 수 있는 프로비저닝 프로필 설치하기 위해 우선 프로비저닝 디렉토리를 생성
          mkdir -p "$HOME/Library/MobileDevice/Provisioning Profiles"
            # 모든 프로비저닝 프로파일을 rename 하고 위에서 만든 디렉토리로 복사하는 과정
            for PROVISION in `ls .github/secrets/*.mobileprovision`
            do
              echo ALVIN PRIVISION DEBUG
              UUID=`/usr/libexec/PlistBuddy -c 'Print :UUID' /dev/stdin <<< $(security cms -D -i ./$PROVISION)`
            cp "./$PROVISION" "$HOME/Library/MobileDevice/Provisioning Profiles/$UUID.mobileprovision"
          done

5.4 Export

  • pod 설치 후 xcodebuild tool을 사용하여 archive 후 export 합니다.
  • ExportOptions.plist는 export 할 때 필요한 정보들을 xcodebuild에 제공합니다.
  • Xcode에서 archiving 후 export를 하면 자동으로 생성되며 이렇게 얻은 plist파일을 깃 리포에 직접 넣어주시고 명령어에 포함시키면 됩니다.

#아카이브 & IPA생성
- name: Archive & Export
  run: |
    pod install
    xcodebuild archive -workspace AppknotPortal.xcworkspace -scheme AppknotPortal-iOS -configuration release -archivePath $XC_ARCHIVE
    xcodebuild -exportArchive -archivePath $XC_ARCHIVE -exportOptionsPlist ExportOptions.plist -exportPath "$XC_EXPORT_PATH" -allowProvisioningUpdates

5.5.1 Artifact Upload (선택)

  • Artifact에 올려 직접 export된 파일들을 받을 수 있습니다.
#Artifact 업로드
- name: Upload Artifact
  uses: actions/upload-artifact@v2
  with:
    name: Artifacts
    path: ./artifacts

5.5.2 Testflight Upload

💡 이걸 하려고 지금까지 왔죠!
  • 의외로 간단하게 이미 Marketplace에 존재하는 action을 통해서 할 수 있습니다.
  • 이 액션은 내부적으로 altool을 사용합니다.
    #TestFlight 업로드
    - name: Upload app to TestFlight
      uses: apple-actions/upload-testflight-build@v1
      with:
        app-path: "./artifacts/AppknotPortal.ipa"
        issuer-id: ${{ secrets.APPSTORE_ISSUER_ID }}
        api-key-id: ${{ secrets.APPSTORE_API_KEY_ID }}
        api-private-key: ${{ secrets.APPSTORE_API_PRIVATE_KEY }}
    Upload app to TestFlight - GitHub Marketplace
  • 필요한 3개의 Secret값들은 Appstore Connect에서 받은후 GitHub에 Secret등록해주시면 됩니다.
    앱스토어 커넥트

💡 주의할 점은 Private Key파일(.p8)을 다운받은 후 열게되면 다음과 같이 내용이 나오는데 Begin, End를 포함한 파일 전체 내용을 다 넣어야 합니다. 가운데 내용만 넣게 되면 JWT 생성 오류가 뜨는데 이것 때문에 많은 시행착오를 겪었습니다.

5.6 Slack (Bonus)

#Slack Notification
- name: Post to Slack
  uses: 8398a7/action-slack@v3
  with:
    status: ${{ job.status }}
    fields: repo,message,commit,author,action,eventName,ref,workflow,job,took,pullRequest # selectable (default: repo,message)
  env:
    SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }} # required
  if: always() # Pick up events even if the job fails or is canceled.
  • workflow의 실행(run)결과를 받기 위해 슬랙과 연동을 해줍니다.

워크스페이스에 깃헙 앱 추가

  • Slack API에서 앱을 추가합니다.
    Slack API

  • Incoming Webhooks에서 URL을 GitHub Secret에 등록합니다.

마치며

이렇게 GitHub Actions를 사용하여 iOS에서 기본적인 Testflight CD를 도입하는 방법에 대해 알아봤습니다. 깃헙 액션의 최대 장점은 커뮤니티와 그것을 기반한 Marketplace라고 생각합니다. 또한 깃헙이라는 플랫폼 안에서 iOS를 넘어 거의 모든 분야에서 사용할 수 있기 때문에 포텐셜이 매우 높다고 생각합니다.

이어서는 Match를 사용한 Certificate & Profile관리에 대해 알아보겠습니다.

긴 글 읽어주셔서 감사합니다 😃

References

GitHub Actions 이해 - GitHub Docs

빌드관리 - Apple Developer

Github Actions 를 이용한 TestFlight 업로드 자동화 - Medium

Github Actions Archive and export - Tistory

0개의 댓글