banner
innei

innei

写代码是因为爱,写到世界充满爱!
github
telegram
twitter

跨倉庫全自動構建項目並部署到伺服器

最近,Vercel 又又對價格進行了調整,讓 Hobby 越來越不夠用了,所以放棄了使用 Vercel,轉向私有伺服器部署 Next.js 專案。

對於私有伺服器的部署體驗是非常不友好的。第一,沒有 Vercel 這樣的全自動部署,也不能及時回滾。第二,Next.js 專案構建需要非常大的內存和 CPU 資源,一般的輕量伺服器可能在構建過程中不是爆堆就是宕機了。

目標#

  • 利用 GitHub 去構建一個通用產物,不受構建時的環境變量影響。(後者你可以通過 一次構建多處部署 - Next.js Runtime Env
    這篇文章了解更多)
  • 如何推送構建產物到遠程伺服器
  • 如何跨源碼倉庫外運行構建的工作流(這個需求是因為對於閉源倉庫,GitHub CI 的時長和其他都有限制;另一個,這樣工作流配置倉庫可以開源,而源碼倉庫可以閉源)
  • 如何實現回滾(可以不那麼方便但是可用)

流程#

根據上面的目標,我們可以構想出我們需要做的事,大概是這樣的一個構建流程。

  1. 從源碼倉庫檢出代碼,而不是工作流倉庫,這點很重要。
  2. 常規的代碼構建
  3. 區分版本,然後推送到伺服器
  4. 完成構建

當源碼倉庫發生代碼變動,需要重新執行工作流倉庫的流水線。

image

兩個倉庫#

明確了上面的流程,我們現在就需要創建一個倉庫專門供跑 CI 用,也就是上面說的工作流倉庫。

然後我們,編寫工作流配置。

name: Build and Deploy

on:
  push:
    branches:
      - main

jobs:
  build:
    name: Build artifact
    runs-on: ubuntu-latest

    strategy:
      matrix:
        node-version: [20.x]
    steps:
      - uses: actions/checkout@v4
        with:
          repository: innei-dev/shiroi # 這裡改成你的私有源碼庫
          token: ${{ secrets.GH_PAT }} # 這裡需要你可以訪問私有倉庫的 Token
          fetch-depth: 0
          lfs: true

      - name: Checkout LFS objects
        run: git lfs checkout
      - uses: pnpm/action-setup@v2
        with:
          version: ${{ env.PNPM_VERSION }}
      - name: Use Node.js ${{ matrix.node-version }}
        uses: actions/setup-node@v4
        with:
          node-version: ${{ matrix.node-version }}
          cache: 'pnpm'
      - name: Install dependencies
        run: pnpm install
      - uses: actions/cache@v4
        with:
          path: |
            ~/.npm
            ${{ github.workspace }}/.next/cache
          key: ${{ runner.os }}-nextjs-${{ hashFiles('**/pnpm-lock.yaml') }}-${{ hashFiles('**/*.js', '**/*.jsx', '**/*.ts', '**/*.tsx') }}
          restore-keys: |
            ${{ runner.os }}-nextjs-${{ hashFiles('**/pnpm-lock.yaml') }}-
      - name: Build project
        run: |
          sh ./ci-release-build.sh # 這裡是你的構建腳本

上面的註釋的地方需要注意。

構建和部署#

構建和跨 Job 共享產物#

接下來我們來寫部署的工作配置。

在跨 Job 之間的產物共享,需要使用 Artifact。在構建的流程中,上傳最後的產物作為 Artifact,然後在下一個 Job 中下載 Artifact 中使用。

jobs:
  build:
    # ...
    - uses: actions/upload-artifact@v4
      with:
        name: dist # 上傳名
        path: assets/release.zip # 源文件路徑
        retention-days: 7

  deploy:
    name: Deploy artifact
    runs-on: ubuntu-latest
    needs: build
    steps:
      - name: Download artifact
        uses: actions/download-artifact@v4
        with:
          name: dist # 下載上傳的文件

Important

使用這種方式會導致構建產物泄露,因為倉庫是開源的,那麼上傳的產物任何人(登錄 GitHub)都可以下載。

People who are signed into GitHub and have read access to a repository can download workflow artifacts.

由於上面的方式並不安全,所以我們這裡使用 CI cache 去實現相同的功能。

jobs:
  build:
    # ...
    - name: Cache Build Artifacts
      id: cache-primes
      uses: actions/cache/save@v4
      with:
        path: assets
        key: ${{ github.run_number }}-release # 使用 工作流序號 作为 key

  deploy:
    name: Deploy artifact
    runs-on: ubuntu-latest
    needs: build
    steps:
      - name: Restore cached Build Artifacts
        id: cache-primes-restore
        uses: actions/cache/restore@v4
        with: # 還原產物
          path: |
            assets
          key: ${{ github.run_number }}-release

使用 SSH 傳輸產物到遠程伺服器#

上面完成了產物的構建,接下來寫部署到伺服器的流程。

jobs:
  deploy:
    name: Deploy artifact
    runs-on: ubuntu-latest
    needs: build
    steps:
      - name: Restore cached Build Artifacts
        id: cache-primes-restore
        uses: actions/cache/restore@v4
        with:
          path: |
            assets
          key: ${{ github.run_number }}-release
      - name: Move assets to root
        run: mv assets/release.zip release.zip

      - name: copy file via ssh password
        uses: appleboy/[email protected]
        with:
          host: ${{ secrets.HOST }}
          username: ${{ secrets.USER }}
          password: ${{ secrets.PASSWORD }}
          key: ${{ secrets.KEY }}
          port: ${{ secrets.PORT }}
          source: 'release.zip'
          target: '/tmp/shiro'

      - name: Exec deploy script with SSH
        uses: appleboy/ssh-action@master

        with:
          command_timeout: 5m
          host: ${{ secrets.HOST }}
          username: ${{ secrets.USER }}
          password: ${{ secrets.PASSWORD }}
          key: ${{ secrets.KEY }}
          port: ${{ secrets.PORT }}
          script_stop: true
          script: |
            set -e
            source $HOME/.bashrc
            workdir=$HOME/shiro/${{ github.run_number }}
            mkdir -p $workdir
            mv /tmp/shiro/release.zip $workdir/release.zip
            rm -r /tmp/shiro
            cd $workdir
            unzip -o $workdir/release.zip
            cp $HOME/shiro/.env $workdir/standalone/.env
            export NEXT_SHARP_PATH=$(npm root -g)/sharp
            # https://github.com/Unitech/pm2/issues/3054
            cd $workdir/standalone
            pm2 stop $workdir/standalone/ecosystem.config.js && pm2 delete $workdir/standalone/ecosystem.config.js && pm2 start $workdir/standalone/ecosystem.config.js
            rm $workdir/release.zip
            pm2 save
            echo "Deployed successfully"

這裡我們使用 SSH + SCP 的方式把構建產物上傳到伺服器,然後直接相關的腳本。

我們使用 GitHub Workflow Id 作為當前的構建的標識,在伺服器部署目錄中進行區分。這樣的話每次部署產物都存在伺服器上,方便日後的回滾,雖然回滾的過程比較傳統,但是你說實現沒實現吧。

我這邊是用 PM2 去托管專案,當然你可以使用其他的方式。

跨倉庫調用工作流#

當源碼倉庫有新的提交之後需要觸發工作流倉庫的重新執行流水線。

這裡我們可以用 API 調用。

在源碼倉庫中,增加一個新的工作流。

name: Trigger Target Workflow

on:
  push:
    branches:
      - main

jobs:
  trigger:
    runs-on: ubuntu-latest

    steps:
      - name: Trigger Workflow in Another Repository
        run: |
          repo_owner="innei-dev"
          repo_name="shiroi-deploy-action"
          event_type="trigger-workflow"

          curl -L \
            -X POST \
            -H "Accept: application/vnd.github+json" \
            -H "Authorization: Bearer ${{ secrets.PAT }}" \
            -H "X-GitHub-Api-Version: 2022-11-28" \
            https://api.github.com/repos/$repo_owner/$repo_name/dispatches \
            -d "{\"event_type\": \"$event_type\", \"client_payload\": {}}"

這裡借助 GitHub API 調用工作流倉庫的流水線重新執行。

然後需要修改被調用方的工作流配置:

on:
  push:
    branches:
      - main

  repository_dispatch:
    types: [trigger-workflow]

permissions: write-all

types 是調用方定義的,需要保持一致。

現在當源碼觸發到 main 的更新,就會通過 API 接口,調用起 repository_dispatchtypes 相匹配的工作流。

image

防止重複版本的構建#

上面的配置基本已經可用,但是還有些地方我們需要判斷下。

例如,相同的 commit 被認為是重複的,他應該只會被構建和推送部署一次。如果重複命中,應該跳過整個構建和部署流程。

這裡我們利用每次的 commit hash 去判斷,保存上次成功部署的 hash,和正在進行的 commit hash 比對,如果一致就跳過。

我們可以用文件的方式記錄上次的 commit hash(你也可以用 artifact,至於為什麼我使用文件請看下節的內容)。

我們把每次構建完成的 commit hash 保存在當前倉庫下的 build_hash 文件下。

這裡我們需要好幾個流程去做這個事,首先讀取當前倉庫下的 build_hash 並保存在 GITHUB_OUTPUT 中供後續的流程讀取。

然後下個流程,檢出源碼倉庫,讀取源碼倉庫的 commit hash,和 build_hash 比對,輸出一個 boolean 值,同樣保存在 GITHUB_OUTPUT 中。

下個流程,利用 if 直接判斷是否應該退出整個流程(因為後續的流程都依賴這個,所以等於全部退出了)。

最後一個流程,在完成部署之後,保存當前的 commit hash 到 repo 中,我們使用了 Push action 去做。

然後因為這樣導致每次 Bot 會 push 一個新的 commit,這個也不應該跑這個工作流。所以在第一個流程中 if 根據 commit message 去做下守衛。

參考配置如下:

name: Build and Deploy

on:
  push:
    branches:
      - main

permissions: write-all

env:
  PNPM_VERSION: 9.x.x
  HASH_FILE: build_hash

jobs:
  prepare:
    name: Prepare
    runs-on: ubuntu-latest
    if: ${{ github.event.head_commit.message != 'Update hash file' }}

    outputs:
      hash_content: ${{ steps.read_hash.outputs.hash_content }}

    - name: Read HASH_FILE content
      id: read_hash
      run: |
        content=$(cat ${{ env.HASH_FILE }}) || true
        echo "hash_content=$content" >> "$GITHUB_OUTPUT"


  check:
    name: Check Should Rebuild
    runs-on: ubuntu-latest
    needs: prepare
    outputs:
      canceled: ${{ steps.use_content.outputs.canceled }}

    steps:
      - uses: actions/checkout@v4
        with:
          repository: innei-dev/shiroi
          token: ${{ secrets.GH_PAT }}
          fetch-depth: 0
          lfs: true

      - name: Use content from prev job and compare
        id: use_content
        env:
          FILE_HASH: ${{ needs.prepare.outputs.hash_content }}
        run: |
          file_hash=$FILE_HASH
          current_hash=$(git rev-parse --short HEAD)
          echo "File Hash: $file_hash"
          echo "Current Git Hash: $current_hash"
          if [ "$file_hash" == "$current_hash" ]; then
            echo "Hashes match. Stopping workflow."
            echo "canceled=true" >> $GITHUB_OUTPUT
          else
            echo "Hashes do not match. Continuing workflow."
          fi


  build:
    name: Build artifact
    runs-on: ubuntu-latest
    needs: check
    if: ${{needs.check.outputs.canceled != 'true'}}

    # .... other build job config

  store:
    name: Store artifact commit version
    runs-on: ubuntu-latest
    needs: [deploy, build] # 依賴 build 和 deploy 流程
    steps:
      - name: Checkout
        uses: actions/checkout@v4
        with:
          persist-credentials: false
          fetch-depth: 0

      - name: Use outputs from build
        env:
          SHA_SHORT: ${{ needs.build.outputs.sha_short }}
          BRANCH: ${{ needs.build.outputs.branch }}
        run: |
          echo "SHA Short from build: $SHA_SHORT"
          echo "Branch from build: $BRANCH"
      - name: Write hash to file
        env:
          SHA_SHORT: ${{ needs.build.outputs.sha_short }}

        run: echo $SHA_SHORT > ${{ env.HASH_FILE }}
      - name: Commit files
        run: |
          git config --local user.email "41898282+github-actions[bot]@users.noreply.github.com"
          git config --local user.name "github-actions[bot]"
          git add ${{ env.HASH_FILE }}
          git commit -a -m "Update hash file"
      - name: Push changes
        uses: ad-m/github-push-action@master
        with:
          github_token: ${{ secrets.GITHUB_TOKEN }}
          branch: ${{ github.ref }}

永不會被禁用的 Cronjob 執行#

為了能讓這個工作流定時的去跑,可以使用 schedule:

name: Build and Deploy

on:
  push:
    branches:
      - main
  schedule:
    - cron: '0 3 * * *'

  repository_dispatch:
    types: [trigger-workflow]

由於 GitHub action 的限制,當一個倉庫在 3 個月內沒有活動時,工作流會被禁用。所以上一節中我們用了提交的方式去防止被禁用。在每次構建去上傳 hash,也是個很好的選擇。

完事了,以上就是全部的內容了。

完整的配置在這裡:

此文由 Mix Space 同步更新至 xLog
原始鏈接為 https://innei.in/posts/tech/automatically-build-projects-across-repositories-and-deploy-to-servers


載入中......
此文章數據所有權由區塊鏈加密技術和智能合約保障僅歸創作者所有。