Vercel API를 exploit해서 조직의 크기와 상관없이 월 $20만 지불해도 Vercel을 사용할 수 있는 GitHub Actions를 작성해봤습니다.

개념정리 link
toc

Preview 배포와 Production 배포 link
toc

Preview 배포는 실제 사용자에게 공개하지 않는 배포입니다. 저장소에 Pull Request(이하 PR)을 올리면 Vercel plugin이 PR의 내용으로 Preview 배포를 수행하고 PR에 댓글을 답니다. 댓글에는 Preview 배포 상태를 확인할 수 있는 insepctor 링크와 preview 배포에 접근할 수 있는 링크가 있습니다.

Inspector. 배포 상태를 볼 수 있다.
Inspector. 배포 상태를 볼 수 있다.

Production 배포는 실제 사용자에게 공개하는 배포입니다. Preview 배포는 사용자가 접속하는 도메인 네임에 전혀 영향을 주지 않습니다. 특정 Preview 배포에 대해서 ‘Promote to Production’을 하면 해당 Preview 배포로 Production 배포를 수행하면서 사용자가 접속하는 도메인 네임의 사이트에 변경사항이 반영됩니다.

'Promote to Production’을 누르면 실제 도메인 네임의 사이트에 변경사항을 반영한다.
'Promote to Production’을 누르면 실제 도메인 네임의 사이트에 변경사항을 반영한다.

비즈니스 모델 link
toc

Vercel은 무료 plan도 제공하지만 상업적으로 사용하기 위해서는 최소한 Pro plan을 사용해야 합니다.

Vercel Pricing. Hobby: Free, Pro: $20 per user / month, Enterprise: custom

Pro plan을 사용했을 때 과금체계가 흥미로운데, Vercel계정에 연동한 GitHub 계정 수 만큼 과금이 됩니다. Vercel 프로젝트 설정을 하기 위한 첫 번째 배포 말고는 Vercel에서 직접 배포를 수행할 수 없습니다. 코드 저장소에 Vercel plugin을 연동해서 PR이 올라오거나 branch에 commit을 추가하면 Vercel plugin이 배포를 진행합니다. 그리고 Vercel plugin은 연동한 GitHub 계정에 대해서만 작동을 합니다.

연동하지 않으면 배포할 수 없다.
연동하지 않으면 배포할 수 없다.

즉 돈을 내야만 배포를 할 수 있습니다.

비즈니스 모델 해킹 link
toc

하지만 GitHub에 연동해서 변경사항을 배포하는 방식 말고 Vercel에 직접 신호를 줘서 배포하는 방법이 있을 것 같은데.. 해서 찾아보니 배포 API가 있었습니다: Create a new deployment | Vercel

Vercel API: /v13/deployments

이 API를 잘 쓰면 배포할 수 있을 것 같지만, 가장 중요한 gitSource property에 대한 문서가 비어있습니다. 뭐야?

gitSource에 대해서는 상세 설명이 없다.
gitSource에 대해서는 상세 설명이 없다.

저같은 사람이 많아서 숨겨놨나봅니다.. 하지만 문서에서 gitSource를 찾아보니 다행히 response에 동일한 property를 찾을 수 있었습니다. 

  gitSource?:
    | {
        type: "github"
        repoId: string | number
        ref?: string | null
        sha?: string
        prId?: number | null
      }
    | {
        type: "github"
        org: string
        repo: string
        ref?: string | null
        sha?: string
        prId?: number | null
      }
    | {
        type: "gitlab"
        projectId: string | number
        ref?: string | null
        sha?: string
        prId?: number | null
      }
    | {
        type: "bitbucket"
        workspaceUuid?: string
        repoUuid: string
        ref?: string | null
        sha?: string
        prId?: number | null
      }
    | {
        type: "bitbucket"
        owner: string
        slug: string
        ref?: string | null
        sha?: string
        prId?: number | null
      }
    | {
        type: "custom"
        ref: string
        sha: string
        gitUrl: string
      }
    | {
        type: "github"
        ref: string
        sha: string
        repoId: number
        org?: string
        repo?: string
      }
    | {
        type: "gitlab"
        ref: string
        sha: string
        projectId: number
      }
    | {
        type: "bitbucket"
        ref: string
        sha: string
        owner?: string
        slug?: string
        workspaceUuid: string
        repoUuid: string
      }

친절한 설명은 없지만 대충 적당히 값을 넣어서 보내면 될 것 같아서 이것저것 시도해보다가 성공했습니다.

배포 API link
toc

기본 브랜치에 대한 Preview 배포 link
toc

curl -X POST "https://api.vercel.com/v13/deployments?teamId=<hidden>" \
  -H "Content-Type: application/json" \
  -H "Authorization: Bearer <hidden>" \
  -d '{
  "name": "your-project-name", #Vercel project 이름
  "gitSource": {
    "type": "github",
    "org": "your-organization-name", #조직 이름
    "repo": "your-repository-name", #저장소 이름
    "ref": ""
  }
}'

teamId는 Vercel 팀 계정의 콘솔에서 확인할 수 있습니다 (Settings -> General -> Team ID): https://vercel.com/teams/[your-team]/settings

인증토큰은 Vercel 계정 셋팅에서 생성해서 사용할 수 있습니다: https://vercel.com/account/tokens

이 두 가지 값을 Github Organzation의 actions secret에 추가해놓으면 모든 repository의 actions에서 공용으로 사용할 수 있습니다.

배포용 토큰과 teamId는 조직의 모든 private저장소의 actions에서 참조할 수 있다.
배포용 토큰과 teamId는 조직의 모든 private저장소의 actions에서 참조할 수 있다.

request body를 보면 gitSource의 ref가 비어있습니다. ref는 github의 branch나 tag를 의미합니다. 지금처럼 빈 값으로 넣으면 저장소의 default branch에 대해서 preview 배포를 진행합니다.

기본 브랜치에 대한 Production 배포 link
toc

curl -X POST "https://api.vercel.com/v13/deployments?teamId=<hidden>" \
  -H "Content-Type: application/json" \
  -H "Authorization: Bearer <hidden>" \
  -d '{
  "name": "your-project-name",
  "gitSource": {
    "type": "github",
    "org": "your-organization-name",
    "repo": "your-repository-name",
    "ref": ""
  },
  "target": "production"
}'

production 배포를 진행하려면 동일한 request에 "target": "production" property만 추가하면 됩니다. 

특정 브랜치에 대한 Preview 배포 link
toc

curl -X POST "https://api.vercel.com/v13/deployments?teamId=<hidden>" \
  -H "Content-Type: application/json" \
  -H "Authorization: Bearer <hidden>" \
  -d '{
  "name": "your-project-name", #Vercel project 이름
  "gitSource": {
    "type": "github",
    "org": "your-organization-name", #조직 이름
    "repo": "your-repository-name", #저장소 이름
    "ref": "feat/VERHACK-1"
  }
}'

특정 브랜치에 대한 Production 배포 link
toc

curl -X POST "https://api.vercel.com/v13/deployments?teamId=<hidden>" \
  -H "Content-Type: application/json" \
  -H "Authorization: Bearer <hidden>" \
  -d '{
  "name": "your-project-name",
  "gitSource": {
    "type": "github",
    "org": "your-organization-name",
    "repo": "your-repository-name",
    "ref": "feat/VERHACK-1"
  },
  "target": "production"
}'

이제 우리에게 필요한 http request spec은 다 확보했습니다. GitHub Actions를 직접 작성해서 ‘PR이 올라왔을 때’, ‘PR에 커밋이 추가될 때’, ‘특정 branch에 커밋이 추가될 때’ 자동으로 위 API를 호출하면… 조직에 천 명 만 명이 있어도 우리는 월 $20로 Vercel을 사용할 수 있게 됩니다. 물론 동시 빌드 개수의 제한이 있지만, 사람 수대로가 아니라 필요한 동시 배포 개수를 기준으로 과금을 하면 훨씬 비용이 적게 듭니다.

Vercel 배포용 GitHub Actions link
toc

설명 link
toc

your-repository-name 프로젝트에 시험삼아 Vercel 배포용 GitHub Actions를 작성해봤습니다: https://gist.github.com/myeongjae-kim/2b9cdf957813324ea61d344953fe4fe9

name: "Deploy to Vercel"

on:
  pull_request:
    types:
      - opened
      - synchronize
  push:
    branches:
      - beta
      - prod

# secrets을 직접 사용하지 않고 env를 통해 사용함으로써 이 action이 어떤 secret값을 필요로 하는지 한 눈에 볼 수 있도록 한다.
env:
  GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} # 기본 제공 secret https://docs.github.com/en/rest/quickstart#using-github-cli-in-github-actions
  VERCEL_TEAM_ID: ${{ secrets.VERCEL_TEAM_ID }} # 조직 공통
  VERCEL_DEPLOYMENT_TOKEN: ${{ secrets.VERCEL_DEPLOYMENT_TOKEN }} # 조직 공통
  VERCEL_PROJECT_NAME_BETA: ${{ secrets.VERCEL_PROJECT_NAME_BETA }} # 프로젝트별로 repository에서 지정해줘야 한다.
  VERCEL_PROJECT_NAME_PROD: ${{ secrets.VERCEL_PROJECT_NAME_PROD }} # 프로젝트별로 repository에서 지정해줘야 한다.
  MESSAGE_FAILURE: Vercel 배포에 실패했습니다. Github Actions 로그를 확인해주세요.
  MESSAGE_SUCCESS: Vercel 배포 API를 호출했습니다. Vercel 플러그인의 댓글을 기다려주세요.
  MESSAGE_SUCCESS_PROD: Vercel 배포 API를 호출했습니다. Preview 배포를 확인하고 이상이 없다면\nInspector에서 \'Promote to Production\'을 수동으로 눌러 실제 환경에 배포하세요.

jobs:
  deploy-preview-beta:
    if: github.event_name == 'pull_request'
    runs-on: ubuntu-latest
    steps:
      - id: deploy
        run: |
          curl --fail -X POST "https://api.vercel.com/v13/deployments?teamId=${{ env.VERCEL_TEAM_ID }}" \
            -H "Content-Type: application/json" \
            -H "Authorization: Bearer ${{ env.VERCEL_DEPLOYMENT_TOKEN }}" \
            -d '{
            "name": "${{ env.VERCEL_PROJECT_NAME_BETA }}",
            "gitSource": {
              "type": "github",
              "org": "${{ github.event.repository.owner.login }}",
              "repo": "${{ github.event.repository.name }}",
              "ref": "${{ github.head_ref }}"
            }
          }'
      - name: "Add PR comment when failed"
        uses: actions/github-script@v5
        if: failure()
        with:
          github-token: ${{env.GITHUB_TOKEN}}
          script: |
            github.rest.issues.createComment({
              issue_number: context.issue.number,
              owner: context.repo.owner,
              repo: context.repo.repo,
              body: '❌ \'${{ github.workflow }}\' failed: ${{ env.MESSAGE_FAILURE }}'
            })
      - name: "Add PR comment when success"
        uses: actions/github-script@v5
        if: success()
        with:
          github-token: ${{env.GITHUB_TOKEN}}
          script: |
            github.rest.issues.createComment({
              issue_number: context.issue.number,
              owner: context.repo.owner,
              repo: context.repo.repo,
              body: '✅ \'${{ github.workflow }}\' succeeded: ${{ env.MESSAGE_SUCCESS }}'
            })
  deploy-production-beta:
    if: github.ref == 'refs/heads/beta'
    runs-on: ubuntu-latest
    steps:
      - id: deploy
        run: |
          curl --fail -X POST "https://api.vercel.com/v13/deployments?teamId=${{ env.VERCEL_TEAM_ID }}" \
            -H "Content-Type: application/json" \
            -H "Authorization: Bearer ${{ env.VERCEL_DEPLOYMENT_TOKEN }}" \
            -d '{
            "name": "${{ env.VERCEL_PROJECT_NAME_BETA }}",
            "gitSource": {
              "type": "github",
              "org": "${{ github.event.repository.owner.login }}",
              "repo": "${{ github.event.repository.name }}",
              "ref": "beta"
            },
            "target": "production"
          }'
      - name: "Add commit comment when failed"
        uses: actions/github-script@v5
        if: failure()
        with:
          github-token: ${{env.GITHUB_TOKEN}}
          script: |
            github.rest.repos.createCommitComment({
              owner: context.repo.owner,
              repo: context.repo.repo,
              commit_sha: '${{ github.sha }}',
              body: '❌ \'${{ github.workflow }}\' failed: ${{ env.MESSAGE_FAILURE }}'
            })
      - name: "Add commit comment when success"
        uses: actions/github-script@v5
        if: success()
        with:
          github-token: ${{env.GITHUB_TOKEN}}
          script: |
            github.rest.repos.createCommitComment({
              owner: context.repo.owner,
              repo: context.repo.repo,
              commit_sha: '${{ github.sha }}',
              body: '✅ \'${{ github.workflow }}\' succeeded: ${{ env.MESSAGE_SUCCESS }}'
            })
  deploy-preview-prod:
    if: github.ref == 'refs/heads/prod'
    runs-on: ubuntu-latest
    steps:
      - id: deploy
        uses: sergeysova/jq-action@v2
        with:
          cmd: |
            RESPONSE=$(curl --fail -X POST "https://api.vercel.com/v13/deployments?teamId=${{ env.VERCEL_TEAM_ID }}" \
              -H "Content-Type: application/json" \
              -H "Authorization: Bearer ${{ env.VERCEL_DEPLOYMENT_TOKEN }}" \
              -d '{
              "name": "${{ env.VERCEL_PROJECT_NAME_PROD }}",
              "gitSource": {
                "type": "github",
                "org": "${{ github.event.repository.owner.login }}",
                "repo": "${{ github.event.repository.name }}",
                "ref": "prod"
              }
            }')
            echo $RESPONSE | jq -c '{url, inspectorUrl}'
      - name: "Add commit comment when failed"
        uses: actions/github-script@v5
        if: failure()
        with:
          github-token: ${{env.GITHUB_TOKEN}}
          script: |
            github.rest.repos.createCommitComment({
              owner: context.repo.owner,
              repo: context.repo.repo,
              commit_sha: '${{ github.sha }}',
              body: '❌ \'${{ github.workflow }}\' failed: ${{ env.MESSAGE_FAILURE }}'
            })
      - name: "Add commit comment when success"
        uses: actions/github-script@v5
        if: success()
        with:
          github-token: ${{env.GITHUB_TOKEN}}
          script: |
            github.rest.repos.createCommitComment({
              owner: context.repo.owner,
              repo: context.repo.repo,
              commit_sha: '${{ github.sha }}',
              body: '✅ \'${{ github.workflow }}\' succeeded: ${{ env.MESSAGE_SUCCESS_PROD }}\n| Name | Inspect | Preview |\n| :--- | :----- | :------ |\n| **${{ env.VERCEL_PROJECT_NAME_PROD }}** | [Check Inspector](${{ fromJSON(steps.deploy.outputs.value).inspectorUrl }}) | [Visit Preview](https://${{ fromJSON(steps.deploy.outputs.value).url }})'
            })

다음 상황에서 actions가 작동합니다.

on:
  pull_request:
    types:
      - opened #PR이 올라왔을 때
      - synchronize #PR에 커밋을 추가할 때
  push:
    branches:
      - beta #beta 브랜치에 커밋을 추가할 때
      - prod #prod 브랜치에 커밋을 추가할 때

job은 총 3개가 있습니다.

jobs:
  deploy-preview-beta:
    if: github.event_name == 'pull_request'
    ...
  deploy-production-beta:
    if: github.ref == 'refs/heads/beta'
    ...
  deploy-preview-prod:
    if: github.ref == 'refs/heads/prod'
    ...
  • deploy-preview-beta: 모든 PR과 그 변경사항에 대해서 Vercel beta 프로젝트에 preview 배포를 합니다.
  • deploy-production-beta: beta 브랜치에 커밋을 추가하면 Vercel beta 프로젝트에 production 배포를 합니다.
  • deploy-preview-prod: prod 브랜치에 커밋을 추가하면 Vercel prod 프로젝트에 preview 배포를 합니다.

Vercel prod프로젝트에 production 배포를 하면 실제 사용자가 사용하는 제품을 배포하는 것이기 때문에, Vercel에 직접 접속해서 preview 배포에 대해 직접 ‘Promote to Production’을 하는 방식으로 배포를 합니다.

# secrets을 직접 사용하지 않고 env를 통해 사용함으로써 이 action이 어떤 secret값을 필요로 하는지 한 눈에 볼 수 있도록 한다.
env:
  GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} # 조직 공통
  VERCEL_TEAM_ID: ${{ secrets.VERCEL_TEAM_ID }} # 조직 공통
  VERCEL_DEPLOYMENT_TOKEN: ${{ secrets.VERCEL_DEPLOYMENT_TOKEN }} # 조직 공통
  VERCEL_PROJECT_NAME_BETA: ${{ secrets.VERCEL_PROJECT_NAME_BETA }} # 프로젝트별로 repository에서 지정해줘야 한다.
  VERCEL_PROJECT_NAME_PROD: ${{ secrets.VERCEL_PROJECT_NAME_PROD }} # 프로젝트별로 repository에서 지정해줘야 한다.

deploy-vercel.yml action은 5개의 secret 값을 사용하지만 GITHUB_TOKEN, VERCEL_TEAM_ID, VERCEL_DEPLOYMENT_TOKEN은 조직의 모든 private repository에서 별도의 설정 없이 사용할 수 있습니다. 개별 repository별로 VERCEL_PROJECT_NAME_BETA, VERCEL_PROJECT_NAME_PROD 만 secret에 추가해서 deploy-vercel.yml을 변경사항 없이 그대로 사용하면 됩니다.

PR이 올라왔을 때 link
toc

PR이 올라오면 Vercel plugin에서는 계정이 연동 안됐다고 불평하지만 actions에서 강제로 preview 배포를 진행하게 되고 Vercel plugin이 친절하게 댓글을 달아줍니다.

강제 배포에도 진행상황을 친절하게 알려주는 Vercel, 착해!
강제 배포에도 진행상황을 친절하게 알려주는 Vercel, 착해!

PR에 커밋을 추가할 때 link
toc

PR에 커밋을 추가하면 github actions에서 ✅ 'Deploy to Vercel' succeeded: Vercel 배포 API를 호출했습니다. Vercel 플러그인의 댓글을 기다려주세요. 문구의 댓글을 답니다. Vercel plugin은 기존의 댓글을 수정하면서 진행상황을 업데이트합니다.

스크린샷의 첫 번째 댓글을 Vercel plugin이 계속 수정해서 사용한다.
스크린샷의 첫 번째 댓글을 Vercel plugin이 계속 수정해서 사용한다.

beta에 브랜치에 커밋을 추가할 때 link
toc

beta브랜치에 커밋을 추가하면 역시 Vercel plugin은 커밋의 댓글에서 GitHub계정 연동이 안 되어있다고 불평을 합니다. 하지만 강제로 배포하면 역시 친절하게 댓글을 달아줍니다.

커밋에 댓글을 추가하는 Vercel Plugin

prod에 브랜치에 커밋을 추가할 때 link
toc

beta브랜치의 커밋을 fast-foward로 prod브랜치에 반영한 뒤 push를 하면 이번엔 Vercel의 prod project에 강제 배포를 합니다. production 배포에 대해서는 vercel plugin이 따로 진행상황을 알려주지 않기 때문에 github actions에서 링크가 포함되어 있는 댓글을 추가하도록 했습니다.

커밋에 댓글을 추가하는 Vercel Plugin

신규 프로젝트 설정 link
toc

신규 프로젝트는 아래의 절차를 따르면 됩니다.

  • Repository 생성하고 애플리케이션 코드 추가
  • beta, prod branch 설정, beta를 default branch로 설정
  • Vercel Github App에 repository 추가
  • Vercel에서 beta, prod 프로젝트 각각 만들기
  • 프로젝트 생성하면서 첫 번째 배포를 한다.
  • GitHub Repository에 actions용 secret 추가
  • 브랜치 추가하고 github actions 커밋 한 뒤 PR올리기
  • 배포 잘 되는지 확인