มาทำ Versioning + GitOps + K8s บน Monorepo กันเถอะ! Part IV

Versioning and GitOps pipeline

For English version, please visit the link https://github.com/saenyakorn/monorepo-versioning-gitops/

ความเดิมตอนที่แล้ว

เราได้คุยกันเรื่อง

  1. Gitflow คืออะไรล่ะ?
  2. Workflow ทั้งหมดตั้งแต่ต้นยันจบ บน Monorepo
  3. ArgoCD มาช่วยอะไร?

📜 เนื้อหาวันนี้

โดยรวมตอนนี้จะเป็นการเล่าถึง implementation ของตอนที่แล้ว จะเขียนอะไรยังไงให้เกิด workflow แบบตอนที่แล้ว มากไปกว่านั้น จะทำยังไงให้ workflow ทำงานเร็วที่สุดด้วย cache และ parallelization ตอนนี้จะประกอบไปด้วย

  1. Reusable workflow คืออะไร? แล้วมาช่วยอะไร?
  2. Reusable workflow สำหรับการ update GitOps
  3. Reusable workflow สำหรับการ build และ publish Docker image
  4. Workflow ของการ deploy without versioning — ตอนที่เราต้องการจะ deploy ของขึ้น dev
  5. Workflow ของการ deploy with versioning — ตอนที่เราต้องการจะเอาของขึ้น beta หรือ main
  6. บทส่งท้าย
  7. ตัวอย่างตอนต่อไป! (implementation สุดยุบยับของการ Pre-release)

❓ Reusable workflow คืออะไร? แล้วมาช่วยอะไร?

ต้องขอเกริ่นก่อนเลยว่า implementation ทั้งหมดจะ base on GitHub Action ทั้งหมดเลย ซึ่ง reusable workflow ก็เป็นหนึ่งใน feature ของ GitHub Action ที่มีประโยชน์มาก โดยเฉพาะเมื่อ workflow เราใหญ่มาก ๆ และมีการทำงานที่คล้ายๆ กัน

ขอยกตัวอย่างที่เราจะใช้กัน เช่น เมื่อเราลองเอา pipeline ของการ deploy ขึ้น dev, beta, และ main มาเทียบกัน (รูปจากตอนที่แล้ว)

compare dev, beta, and main release and deploy pipelines

จะสังเกตว่า workflow มันจะมีส่วนที่ซ้ำซ้อนกันอยู่ 3 จุดสำหรับ นั่นก็คือออ

  1. Release (สีเขียว)— Flow ของ sub-workflow นี้ค่อนข้าง simple ก็คือ รับ image name และ version จะนั้นก็ build Docker พร้อม tag ไปตามที่รับ input มาแล้วก็ publish Docker ขึ้น registry
  2. Deployment (สีฟ้า) — Flow ของการ update GitOps manifest file นี้ก็ค่อนข้าง simple เช่นกัน นั่นก็คือ รับ image name, version และ target environment มา จากนั้นก็ไปหาว่า kustomize manifest file อยู่ที่แล้วจากนั้นก็ update version บน image ที่รับมา แล้วเลือกว่าจะ “commit and push” หรือ “Open PR”
  3. Preparing (สีส้ม) — ส่วนนี้ก็เป็นส่วนหนึ่งที่เราสามารถ reuse workflow ได้ แต่ส่วนนี้เราสามารถใช้ 3rd-party workflow เข้ามาช่วยได้โดยไม่ต้องเขียนเอง

หรือก็คือเราสามารถมอง sub-workflows เป็น function ที่รับ input ที่ pattern ที่ตายตัวและให้มันทำอะไรสักอย่างออกมาเป็น output ได้นั่นเอง ซึ่งเราจะสร้าง reusable workflows สำหรับ 2 sub-workflows ซึ่งได้แก่ Release และ Deployment

⭐️ Reusable workflow สำหรับการ update GitOps

ก่อนอื่นเราต้องกำหนดงานที่ชัดเจน input และ output ซะก่อน

Task:

  1. Workflow นี้จะต้องสามารถ checkout ไปที่ GitOps repository
  2. เพื่อเข้าไปแก้ kustomize manifest files ในแต่ละ environment ได้หลาย ๆ services พร้อมกัน
  3. ต้องเลือกได้ว่าเมื่อแก้เสร็จแล้วจะให้ “commit and push” หรือ “open PR”

Input:

  • packages — Array ของ object ที่ระบุว่าต้องการ update version ของ image ชื่ออะไรด้วย version ใหม่อะไร หน้าตาจะประมาณนี้
[
{
"name": "web",
"imageTag": "1.1.0-beta.0"
}
]
  • GitOps repository — โดยทั่วไปแล้วเราจะไม่เก็บ kustomize manifest file ไว้รวมกับ application ดังนั้นเราเลยต้องระบุด้วยว่าจะให้ GitHub Action checkout ไปที่ไหน
  • Environment — ต้องระบุด้วยว่าอยากให้ GitHub Action update service version บน environment อะไร (e.g. prod, beta, dev) ซึ่งโดยทั่วไปแล้วเราจะเก็บ kustomize แยกตาม environment ด้วย folder
  • Mode — ก็คือเราต้องเลือกได้ว่าอยากให้ GitHub Action ทำการ “commit and push” หรือ “open PR”
  • GH_TOKEN (secret) —เนื่องจากว่า GitOps repository มักจะเป็น private repository ที่มีเพียงคน level สูง ๆ ที่เข้าได้ แต่คนที่เป็นคน trigger action ส่วนใหญ่จะเป็นเพียง developer ตัวน้อย ๆ ที่ไม่มีสิทธิ์เข้าไปดู ดังนั้นเลยต้องขอให้คน level สูง ๆ ใส่ GitHub Personal Access Token (PAT) ลงไปใน secret ของ repository ด้วย

เมื่อ define task และ input แล้ว workflow ที่ได้ก็จะหน้าตาประมาณนี้ (คำอธิบายอยู่ด้านล่าง)

ตัวเต็ม https://github.com/saenyakorn/monorepo-versioning-gitops/blob/beta/.github/workflows/update-gitops.yaml

# File: .github/workflows/update-gitops.yaml
name: Update GitOps repository

on:
# (1) ----- NEED EXPLAINATION
workflow_call:
inputs:
packages:
description: 'Packages to update'
required: true
type: string
environment:
description: 'Environment e.g. beta, prod, main used to define path to kustomize overlay'
required: true
type: string
image-prefix:
description: 'Prefix of image e.g. thinc-org/cugetreg'
required: true
type: string
gitops-repository:
description: 'Target GitOps repository'
required: true
type: string
gitops-ref:
description: 'Target GitOps ref'
type: string
default: refs/heads/master
container-registry:
description: 'Container registry e.g. ghcr.io'
type: string
default: ghcr.io
mode:
description: 'Mode of the action, pr or commit'
type: string
default: pr
secrets:
GH_TOKEN:
description: 'GitHub token used to checkout GitOps repository and open PR'
required: true

jobs:
update-gitops:
name: Update GitOps and Open PR

runs-on: ubuntu-latest

steps:
# (2) ----- NEED EXPLAINATION
- name: Checkout Repo
uses: actions/checkout@v3
with:
repository: ${{ inputs.gitops-repository }}
ref: ${{ inputs.gitops-ref }}
token: ${{ secrets.GH_TOKEN }}

- name: Setup Kustomize
uses: multani/action-setup-kustomize@v1
with:
version: 5.0.0

# (3) ----- NEED EXPLAINATION
- name: Update kustomize configuration
run: |
for row in $(echo "$PACKAGES" | jq -r '.[] | @base64'); do
_jq() {
echo ${row} | base64 --decode | jq -r ${1}
}
NAME=$(_jq '.name')
IMAGE_TAG=$(_jq '.imageTag')
PREFIX=${{ inputs.container-registry }}/${{ inputs.image-prefix }}
KUSTOMIZE_PATH=k8s/$NAME/overlays/${{ inputs.environment }}
[ -d "$KUSTOMIZE_PATH" ] && bash -c "cd $KUSTOMIZE_PATH && kustomize edit set image $NAME=$PREFIX/$NAME:$IMAGE_TAG"
echo "${NAME}:${IMAGE_TAG} is updated"
done
env:
PACKAGES: ${{ inputs.packages }}

- name: Show Git Status
run: git status

# (4) ----- NEED EXPLAINATION
- name: Prepare Pull Request body
id: pr-body
if: ${{ inputs.mode == 'pr' }}
run: |
echo "DATE=$(date +'%d/%m/%Y')" >> $GITHUB_OUTPUT
UPDATED_PACKAGES=$(echo "$PACKAGES" | jq -r '.[] | "- \(.name): \(.imageTag)"')
echo "UPDATED_PACKAGES<<EOF" >> $GITHUB_ENV
echo "$UPDATED_PACKAGES" >> $GITHUB_ENV
echo "EOF" >> $GITHUB_ENV
env:
PACKAGES: ${{ inputs.packages }}

# (5) ----- NEED EXPLAINATION
- name: Create Pull Request to GitOps
uses: peter-evans/create-pull-request@v4
if: ${{ inputs.mode == 'pr' }}
with:
token: ${{ secrets.GH_TOKEN }}
commit-message: update gitops
branch: update-gitops
base: ${{ inputs.gitops-ref }}
title: Update GitOps
body: |
# Update GitOps

Date ${{ steps.pr-body.outputs.DATE }}

> This PR is automatically generated

## Updated Packages

${{ env.UPDATED_PACKAGES }}

# (6) ----- NEED EXPLAINATION
- name: Add, commit and push to the repository
uses: stefanzweifel/git-auto-commit-action@v4
if: ${{ inputs.mode == 'commit' }}
with:
commit_message: Update GitOps
  1. on.workflow_call — เป็น field ที่เอาไว้ระบุ input ของ workflow เวลาที่ workflow อื่นมาเรียกใช้ นอกจาก input ที่ระบุไว้ข้างบนแล้ว ก็มี input อื่น ๆ ด้วยนิดหน่อย สามารถอ่านได้ใน description ของ input ได้เลย
  2. เป็นขั้น checkout GitOps repository ด้วย GH_TOKEN ของคนที่มีสิทธิ์เข้าถึง
  3. อันนี้เป็นขั้นวน loop เพื่อนั่งไล่แก่ kustomize manifest file ซึ่งหลัก ๆ คือ cd เข้าไปที่ target folder จากนั้นใช้ kustomize edit เพื่อแก้ version ของ target image
  4. เป็นขั้นเตรียมการ pull request description (ถ้า mode = “pr” ) ซึ่งโดยปกติแล้ว GitHub Action ยังไม่ support multiline output ก็เลยต้องใช้ workaround ท่านี้ไปก่อน https://trstringer.com/github-actions-multiline-strings/
  5. เป็นขั้นเปิด pull request (ถ้า mode = “pr” ) ไปที่ GitOps repository
  6. ถ้า mode = “commit” จะเป็นการ commit and push ไปที่ GitOps repository แทน

⭐️ Reusable workflow สำหรับการ build และ publish Docker image

เหมือนเดิมเลย เราต้องกำหนดหน้าที่, input และ output ซะก่อน

Task:

  1. Workflow นี้จะต้องสามารถ checkout application repository ได้ ด้วย tag version
  2. Workflow นี้จะต้องสามารถ build and publish Docker image แบบ parallel ได้
  3. Workflow นี้จะต้อง cache Docker layer ได้ด้วย
  4. เมื่อ Publish เสร็จแล้วจะต้องไปแก้ GitOps repository ด้วย (ถ้า user ระบุว่าอยากให้อัพเดท)

Input:

  • packages — คล้าย ๆ ของ Update GitOps workflow เลย แต่ต่างกันนิดหน่อยตรงที่มี property ref เพิ่มขึ้นมา เพื่อระบุว่าเราอยากจะ checkout ที่ tag ไหน หน้าตาของ payload จะประมาณนี้
[
{
"name": "web",
"ref": "refs/tags/web@1.0.0-beta.0",
"imageTag": "1.1.0-beta.0"
}
]

หรือ

[
{
"name": "web",
"ref": "refs/heads/dev",
"imageTag": "<git-sha256>"
}
]
  • container-registry —เป็นการระบุว่าเราอยากจะ publish Docker image ไปไว้ที่ไหน (ซึ่งตอนนี้ container registry เดียวที่ support ที่ GitHub Action เพราะไม่ได้ทำ login step สำหรับ container registry อื่น)
  • นอกนั้นจะเป็น input จาก update-gitops.yaml เพราะใน workflow อันนี้จะมีการเรียกใช้ update-gitops.yaml ด้วย

ตัวเต็ม https://github.com/saenyakorn/monorepo-versioning-gitops/blob/beta/.github/workflows/deploy-docker.yaml

# File: .github/workflows/deploy-docker.yaml
name: Build and Publish Docker image

on:
workflow_call:
inputs:
packages:
description: Packages to build and publish
required: true
type: string
environment:
description: Environment to build and publish e.g. prod, beta, dev
required: true
type: string
container-registry:
description: Container registry to push the image to
default: ghcr.io
type: string
image-prefix:
description: Image prefix of the built image
type: string
gitops-repository:
description: Target repository for updating deployment declaration
type: string
default: ${{ github.repository }}
gitops-ref:
description: Target ref for updating deployment declaration
type: string
default: master
update-mode:
description: Mode of updating deployment declaration, pr or commit
type: string
default: pr
push:
description: Enable pushing the image to the container registry
type: boolean
default: true
update:
description: Enable updating deployment declaration in the target repository
type: boolean
default: true
secrets:
GH_TOKEN:
description: GitHub token used to checkout target repository and open PR
required: true

jobs:
build-and-publish-docker-image:
name: Build and Publish Docker image

runs-on: ubuntu-latest

# (1) ----- NEED EXPLAINATION
strategy:
matrix:
packages: ${{ fromJson(inputs.packages) }}

outputs:
DOCKERFILE_EXISTS: ${{ steps.check-dockerfile.outputs.DOCKERFILE_EXISTS }}

steps:
# (2) ----- NEED EXPLAINATION
- name: Checkout with tags
uses: actions/checkout@v3
with:
fetch-depth: 0
ref: ref/tags/${{ matrix.packages.name }}@${{ matrix.packages.imageTag }}

# (3) ----- NEED EXPLAINATION
- name: Check if Dockerfile exists
id: check-dockerfile
run: |
if [ ! -f apps/${{ matrix.packages.name }}/Dockerfile ]; then
echo "Dockerfile does not exist"
echo "DOCKERFILE_EXISTS=false" >> $GITHUB_OUTPUT
else
echo "Dockerfile exists"
echo "DOCKERFILE_EXISTS=true" >> $GITHUB_OUTPUT
fi

- name: Set up Docker Buildx
if: steps.check-dockerfile.outputs.DOCKERFILE_EXISTS == 'true'
uses: docker/setup-buildx-action@v2

- name: Login to Container Registry
if: steps.check-dockerfile.outputs.DOCKERFILE_EXISTS == 'true'
uses: docker/login-action@v1
with:
registry: ${{ inputs.container-registry }}
username: ${{ github.actor }}
password: ${{ github.token }}

# (4) ----- NEED EXPLAINATION
- name: Set frontend env
if: ${{ contains(fromJson('["web", "docs"]'), matrix.packages.name) && steps.check-dockerfile.outputs.DOCKERFILE_EXISTS == 'true' }}
env:
APP_NAME: ${{ matrix.packages.name }}
BRANCH: ${{ github.ref_name }}
run: |
if [[ $BRANCH == 'main' && -f "apps/$APP_NAME/.env.prod" ]]; then
mv apps/$APP_NAME/.env.prod apps/$APP_NAME/.env
echo "$APP_NAME env is set to production"
elif [[ $BRANCH == 'beta' && -f "apps/$APP_NAME/.env.beta" ]]; then
mv apps/$APP_NAME/.env.beta apps/$APP_NAME/.env
echo "$APP_NAME env is set to beta"
elif [[ $BRANCH == 'dev' && -f "apps/$APP_NAME/.env.dev" ]]; then
mv apps/$APP_NAME/.env.dev apps/$APP_NAME/.env
echo "$APP_NAME env is set to dev"
else
echo "$APP_NAME env is not set. This could be because branch '$BRANCH' is not in [main, beta, dev] or .env file for the branch is not found"
fi

# (5) ----- NEED EXPLAINATION
- name: Build and push Docker image
if: steps.check-dockerfile.outputs.DOCKERFILE_EXISTS == 'true'
uses: docker/build-push-action@v3
with:
context: .
file: apps/${{ matrix.packages.name }}/Dockerfile
tags: ${{ inputs.container-registry }}/${{ inputs.image-prefix }}/${{ matrix.packages.name }}:${{ matrix.packages.imageTag }}
push: ${{ inputs.push }}
cache-from: type=gha
cache-to: type=gha,mode=max

update-gitops:
needs: build-and-publish-docker-image

# (6) ----- NEED EXPLAINATION
if: inputs.update

uses: ./.github/workflows/update-gitops.yaml
with:
packages: ${{ inputs.packages }}
environment: ${{ inputs.environment }}
image-prefix: ${{ inputs.image-prefix }}
gitops-repository: ${{ inputs.gitops-repository }}
gitops-ref: ${{ inputs.gitops-ref }}
container-registry: ${{ inputs.container-registry }}
mode: ${{ inputs.update-mode }}
secrets:
GH_TOKEN: ${{ secrets.GH_TOKEN }}
  1. stretegy.matrix — เป็น feature ของ GitHub Action ที่ทำให้เราสามารถรัน job แบบ parallel ได้ ในที่นี้คือให้สร้าง job มาเป็นจำนวน N job โดยที่ N คือขนาด array ของ packages
  2. ในขั้นตอนของการ checkout เราจะ checkout ไปที่ specific tag เช่น web@1.2.0 เพื่อ make sure จริง ๆ ว่าเรากำลังจะ build Docker ที่เป็น version นั้นจริง ๆ หรือ branch นั้นจริง ๆ
  3. เช็คก่อนว่า service นั้นมี Docker file ไหม ถ้าไม่มีก็จะ skips step ที่เหลือทั้งหมด
  4. ในส่วนของ frontend, เนื่องจากว่า .env ของ frontend เป็นแบบ builttime environment ดังนั้นเราเลยต้องมาคอยสลับ .env ให้เหมาะสนในแต่ละ environment เช่น ถ้าเป็น dev environment เราจะเอา .env.dev มาแทนที่ .env
  5. ในส่วนนี้คือการ build Docker และ publish ขึ้น container registry ธรรมดา แต่จะสังเกตว่าใน step นี้เราได้ทำ cache ไว้แล้วดูจาก cache-fron และ cache-to ในบรรทัดล่าง อ่านเพิ่มเติมเกี่ยวกับการทำ Docker cache บน GitHub Action ได้ที่นี่
  6. เราจะทำการ update GitOps repository ก็ต่อเมื่อ user ใส่ update field มาเป็น true (ซึ่ง default เป็น true)

เมื่อได้ reusable workflow มาครบเรียบร้อยแล้ว ต่อไปเราจะเริ่มทำ release and deploy workflow จริง ๆ กัน!

⭐️ Workflow ของการ release and deploy without versioning

Workflow นี้จะเป็น workflow สำหรับการ deploy ขึ้น dev environment โดยไม่มีการทำ versioning ซึ่งจะถูก trigger ก็ต่อเมื่อมี changes ถูก push ขึ้นมาบน dev branch และจะเทียบ diff กับ branch beta เพื่อหา affected packages

เราสามารถเขียน workflow ได้แบบนี้เลยย

https://github.com/saenyakorn/monorepo-versioning-gitops/blob/beta/.github/workflows/release-without-versioning.yaml

# File: .github/workflows/release-without-versioning.yaml
name: Release without versioning

on:
push:
branches:
- dev

# (1) ----- NEED EXPLAINATION
concurrency: ${{ github.workflow }}-${{ github.ref }}

permissions:
actions: read
checks: read
contents: write
deployments: read
issues: write
discussions: read
packages: write
pull-requests: write
repository-projects: write
security-events: read
statuses: write

jobs:
release:
name: Release without versioning

runs-on: ubuntu-latest

if: ${{ github.event_name != 'workflow_dispatch' }}

outputs:
affectedPackages: ${{ steps.affected-packages.outputs.affectPackages }}
environment: ${{ steps.get-deploy-environment.outputs.ENVIRONMENT }}

steps:
- name: Checkout Repo
uses: actions/checkout@v3
with:
fetch-depth: 0

- name: Setup Node.js
uses: actions/setup-node@v3
with:
node-version: 18

- name: Install pnpm
uses: pnpm/action-setup@v2.2.2
id: pnpm-install
with:
version: 8
run_install: false

- name: Install turbo
run: pnpm install --global turbo

# (2) ----- NEED EXPLAINATION
- name: Get affected app names
id: affected-apps
run: |
PACKAGES=$(pnpm turbo run build --filter='...[origin/beta]' --dry=json | jq -c '.packages | map(select(. != "//"))')
echo "packages=$PACKAGES" >> $GITHUB_OUTPUT
echo "packages=$PACKAGES"

# (3) ----- NEED EXPLAINATION
- name: Generate affected packages
id: affected-packages
run: |
PACKAGES='${{ steps.affected-apps.outputs.packages }}'
PACKAGES_OUTPUT=$(echo $PACKAGES | jq -c 'map_values({name:.,ref:"refs/heads/${{ github.ref_name }}",imageTag:"${{ github.ref_name }}-${{ github.sha }}"})')
echo "affectPackages=$PACKAGES_OUTPUT" >> $GITHUB_OUTPUT
echo "affectPackages=$PACKAGES_OUTPUT"

# (4) ----- NEED EXPLAINATION
- name: Get deploy environment
id: get-deploy-environment
run: |
if [[ ${{ github.ref_name }} == "main" ]]; then
echo "Deploy environment is production"
echo "ENVIRONMENT=prod" >> $GITHUB_OUTPUT
elif [[ ${{ github.ref_name }} == "beta" ]]; then
echo "Deploy environment is beta"
echo "ENVIRONMENT=beta" >> $GITHUB_OUTPUT
elif [[ ${{ github.ref_name }} == "dev" ]]; then
echo "Deploy environment is dev"
echo "ENVIRONMENT=dev" >> $GITHUB_OUTPUT
fi

deploy-with-docker:
if: needs.release.outputs.affectedPackages != '[]'
needs:
- release
uses: ./.github/workflows/deploy-docker.yaml
with:
packages: ${{ needs.release.outputs.affectedPackages }}
environment: ${{ needs.release.outputs.environment }}
container-registry: ghcr.io
image-prefix: saenyakorn/monorepo-demo
gitops-repository: saenyakorn/monorepo-versioning-gitops
gitops-ref: main
update-mode: commit
secrets:
GH_TOKEN: ${{ secrets.GH_TOKEN }}
  1. เป็นการบังคับให้ GitHub Action workflow นี้ต้องทำงาน 1 อันเท่านั้นในเวลาหนึ่ง ๆ เพื่อป้องกันไม่ให้เกิดปัญหา workflow ที่ถูก trigger ทีหลังเสร็จก่อน workflow อันแรก ซึ่งจะทำให้ deployment ไม่ตรงกับที่เราต้องการ
  2. Get affected app names เป็นการหาว่า “เมื่อเทียบกับ branch beta มี package ไหนที่ถูกแก้ไขบ้าง” ซึ่งผลลัพธ์ที่ได้ออกมาจะเป็น array of string เช่น [“web”, “docs”] เป็นต้น
  3. เป็นการแปลงผลลัพธ์ที่ได้จากข้อ 2 มาแปลงเป็น array of object ที่มีหน้าตาตรงกับ input ของ reusable workflow ที่เราพูดถึงกันไป
  4. เป็นการหาว่า สำหรับ branch นี้ เราจะมีชื่อเรียก environment ว่าอะไร เช่น ถ้าอยู่ branch “main” เราจะเรียกว่า “prod” environment เป็นต้น

หลังจากที่เตรียมข้อมูลเสร็จแล้ว เราก็จะส่งข้อมูลทั้งหมดไปให้ “build and publish Docker image” reusable workflow ต่อไป

เพราะฉะนั้นหน้าตาของ workflow จะประมาณนี้เลยย

ตัวอย่าง workflow visualization ของ release-without-versioning workflow

⭐️ Workflow ของการ deploy with versioning — ตอนที่เราต้องการจะเอาของขึ้น beta หรือ main

จะคล้าย ๆ กับแบบ without versioning ที่เราได้เรียนกันไปเมื่อ section ที่แล้ว เพิ่มเติมคือมีเรื่อง versioning เพิ่มเข้ามาด้วย ซึ่ง workflow จะถูก trigger เฉพาะเมื่อมี changes ถูก push ขึ้นที่ branch “main” หรือ “beta” เท่านั้น

เราสามารถเขียน workflow ได้แบบนี้เลยย

ตัวเต็ม https://github.com/saenyakorn/monorepo-versioning-gitops/blob/beta/.github/workflows/release.yaml

# File: .guthub/workflows/release.yaml
name: Release

on:
push:
branches:
- main
- beta
# (1) ----- NEED EXPLAINATION
workflow_dispatch:
inputs:
app:
description: "The app to release. The name should be the same as name in package.json"
required: true
version:
description: "The version to release. The version should be the same as version in package.json. For example, 1.0.0"
required: true
environment:
type: choice
description: "The environment to release. The name should be the same as name in package.json"
required: true

concurrency: ${{ github.workflow }}-${{ github.ref }}

permissions:
actions: read
checks: read
contents: write
deployments: read
issues: write
discussions: read
packages: write
pull-requests: write
repository-projects: write
security-events: read
statuses: write

jobs:
release:
name: Versioning

runs-on: ubuntu-latest

# (2) ----- NEED EXPLAINATION
if: ${{ github.event_name != 'workflow_dispatch' }}

# (3) ----- NEED EXPLAINATION
outputs:
published: ${{ steps.changesets.outputs.published }}
publishedPackages: ${{ steps.changesets.outputs.publishedPackages }}
hasChangesets: ${{ steps.changesets.outputs.hasChangesets }}
pullRequestNumber: ${{ steps.changesets.outputs.pullRequestNumber }}
environment: ${{ steps.get-deploy-environment.outputs.ENVIRONMENT }}
transformedPackages: ${{ steps.transform-packages.outputs.packages }}

steps:
- name: Checkout Repo
uses: actions/checkout@v2
with:
# related to issue, https://github.com/changesets/action/issues/201
fetch-depth: 0

- name: Setup Node.js
uses: actions/setup-node@v3
with:
node-version: 18

- name: Install pnpm
uses: pnpm/action-setup@v2.2.2
id: pnpm-install
with:
version: 8
run_install: false

- name: Get pnpm store directory
id: pnpm-cache
run: |
echo "::set-output name=pnpm_cache_dir::$(pnpm store path)"

- name: Setup pnpm cache
uses: actions/cache@v3
with:
path: ${{ steps.pnpm-cache.outputs.pnpm_cache_dir }}
key: ${{ runner.os }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }}
restore-keys: |
${{ runner.os }}-pnpm-store-

- name: Install dependencies
run: pnpm install --frozen-lockfile --prefer-offline

# (4) ----- NEED EXPLAINATION
- name: Create Versioning Pull Request
id: changesets
uses: changesets/action@v1.4.2
env:
GITHUB_TOKEN: ${{ github.token }}
with:
createGithubReleases: true
version: pnpm changeset version
publish: pnpm release

# (5) ----- NEED EXPLAINATION
- name: Transform Packages
id: transform-packages
run: |
PACKAGES=$(echo $PACKAGES | jq -c 'map_values({name:.name,ref:"refs/tags/\(.name)@\(.version)",imageTag:.version})')
echo "packages=$PACKAGES" >> $GITHUB_OUTPUT
env:
PACKAGES: ${{ steps.changesets.outputs.publishedPackages }}

- name: Get deploy environment
id: get-deploy-environment
run: |
if [[ ${{ github.ref_name }} == "main" ]]; then
echo "Deploy environment is production"
echo "ENVIRONMENT=prod" >> $GITHUB_OUTPUT
elif [[ ${{ github.ref_name }} == "beta" ]]; then
echo "Deploy environment is beta"
echo "ENVIRONMENT=beta" >> $GITHUB_OUTPUT
elif [[ ${{ github.ref_name }} == "dev" ]]; then
echo "Deploy environment is dev"
echo "ENVIRONMENT=dev" >> $GITHUB_OUTPUT
fi

- name: Echo Changeset output
run: |
echo "Changeset published - ${{ steps.changesets.outputs.published }}"
echo "Changeset publishedPackages - ${{ toJSON(steps.changesets.outputs.publishedPackages) }}"
echo "Changeset hasChangesets - ${{ steps.changesets.outputs.hasChangesets }}"
echo "Changeset pullRequestNumber - ${{ steps.changesets.outputs.pullRequestNumber }}"
echo "Packages to build - ${{ toJSON(steps.transform-packages.outputs.packages) }}"

deploy-with-docker:
needs:
- release
if: ${{ needs.release.outputs.published == 'true' }}
uses: ./.github/workflows/deploy-docker.yaml
with:
packages: ${{ needs.release.outputs.transformedPackages }}
environment: ${{ needs.release.outputs.environment }}
container-registry: ghcr.io
image-prefix: saenyakorn/monorepo-demo
gitops-repository: saenyakorn/monorepo-versioning-gitops
gitops-ref: main
update-mode: pr
secrets:
GH_TOKEN: ${{ secrets.GH_TOKEN }}

# (6) ----- NEED EXPLAINATION
deploy-with-docker-dispatch:
if: ${{ github.event_name == 'workflow_dispatch' }}
uses: ./.github/workflows/deploy-docker.yaml
with:
packages: |
[
{
"name": "${{ github.event.inputs.app }}",
"ref": "refs/tags/${{ github.event.inputs.app }}@${{ github.event.inputs.version }}",
"imageTag": "${{ github.event.inputs.version }}"
}
]
environment: ${{ github.event.inputs.environment }}
container-registry: ghcr.io
image-prefix: saenyakorn/monorepo-demo
gitops-repository: saenyakorn/monorepo-versioning-gitops
gitops-ref: main
update-mode: commit
secrets:
GH_TOKEN: ${{ secrets.GH_TOKEN }}
  1. workflow_dispatch —เป็น feature ของ GitHub Action ที่จะสามารถให้เราสามารถ trigger workflow ได้เองเพียงกดปุ่มใน GitHub ซึ่งในที่นี้เราจะใช้เมื่อต้องการ re-deploy service ใด ๆ ด้วย version ใด ๆ
  2. สำหรับ workflow_dispatch เราจะ skip การทำ versioning ไปเลย เลยต้องใส่ condition ไว้
  3. jobs.outputs — คือเป็นการประกาศตัวแปรที่เราสามารถส่งค่าจาก job หนึ่งไปยังอีก job หนึ่งได้
  4. ในส่วนนี้จะค่อนข้าง magic นิดหนึ่ง เพราะมันจะทำหน้าที่ 2 อย่างนั่นคืออ “เปิด PR Version Packages เมื่อมี changeset file” กับ “Release เมื่อ Version Package ถูก merge” ซึ่งถ้าไม่ใช่การ release แล้ว workflow จะจบเพียงเท่านี้และรอ developer merge “Version Packages” นอกจากนี้ step นี้ยังคอยทำหน้าที่ push Git tags และ generate GitHub release notes ให้ด้วยถ้าเป็นการ release
  5. ถ้าเป็น flow release (หลังจาก merge Verion Packages) เราจะได้ publishedPackages จาก Changesets Action ซึ่งเราจะทำการ tranform array of object นั้นก่อน ให้เป็น array of object หน้าตาแบบที่เราต้องการ
  6. deploy Docker with workflow dispatch จะแยกออกจาก flow ปกติ เพราะว่ามีบาง field ที่มี payload ที่ไม่เหมือนกัน

สุดท้ายแล้ว workflow จะหน้าตาออกมาประมาณนี้

ตัวอย่าง workflow visualization ของ release workflow

บทส่งท้าย

วันนี้เราได้คุยเรื่อง

  1. Reusable workflow คืออะไร? แล้วมาช่วยอะไร? — คล้าย ๆ กับเป็น function ของ workflow
  2. Reusable workflow สำหรับการ update GitOps — สำหรับ update kustomize manifest file
  3. Reusable workflow สำหรับการ build และ publish Docker image — สำหรับ publish Docker แบบ parallel
  4. Workflow ของการ deploy without versioning — ตอนที่เราต้องการจะ deploy ของขึ้น dev
  5. Workflow ของการ deploy with versioning — ตอนที่เราต้องการจะเอาของขึ้น beta หรือ main

จบไปแล้วกับมหากาฬ workflows จำนวนมหาศาลซึ่งก็ยุบยับพอตัว 5555555 แต่!! workflow ข้างบนนี้ยังมีปัญหาอยู่ นั่นก็คือออ มันยังไม่สามารถทำ pre-release ได้ เพราะขาดการตั้งค่า Changeset config ให้เป็น pre-release mode แล้วเราจะ automated process นี้ยังไงล่ะ?​! โปรดติดตามตอนต่อไปได้เลย! (สุดท้ายแล้ว!)

(🚧 รอแปปป ยังเขียนไม่เสร็จจ้า)

Appendix

ทั้งนี้ถ้าใครใจร้อนอยากเห็น full implementation แล้วสามารถแวะมาดูได้ที่นี่เลย https://github.com/saenyakorn/monorepo-versioning-gitops ซึ่งก็มีเขียนอธิบายพอสังเขปบวกกับวิธีการ setup แล้วเรียบร้อย feel free ที่จะก๊อปโค๊ตได้เลยนะครับ ตามอัธยาศัย

ปล. ถ้าชอบก็อย่าลืมกด Star ใน GitHub เป็นกำลังใจให้ด้วยนะครับ 🫠

--

--