CI #22896
Workflow file for this run
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| name: CI | |
| on: | |
| push: | |
| branches: [main] | |
| pull_request: | |
| types: [opened, edited, synchronize, reopened] | |
| merge_group: | |
| types: [checks_requested] | |
| jobs: | |
| # =========================================================================== | |
| # Detect Changes | |
| # =========================================================================== | |
| detect-changes: | |
| name: Detect Changes | |
| runs-on: ubuntu-latest | |
| outputs: | |
| ci_workflow: ${{ steps.filter.outputs.ci_workflow }} | |
| typecheck: ${{ steps.filter.outputs.typecheck }} | |
| node_lint: ${{ steps.filter.outputs.node_lint }} | |
| backend_client: ${{ steps.filter.outputs.backend_client }} | |
| backend_admin: ${{ steps.filter.outputs.backend_admin }} | |
| backend_test: ${{ steps.filter.outputs.backend_test }} | |
| iris: ${{ steps.filter.outputs.iris }} | |
| plag: ${{ steps.filter.outputs.plag }} | |
| go_lint: ${{ steps.filter.outputs.go_lint }} | |
| frontend: ${{ steps.filter.outputs.frontend }} | |
| infra_ansible: ${{ steps.filter.outputs.infra_ansible }} | |
| infra_ssh_keys: ${{ steps.filter.outputs.infra_ssh_keys }} | |
| steps: | |
| - uses: actions/checkout@v6 | |
| - name: Detect changed paths | |
| uses: dorny/paths-filter@v3 | |
| id: filter | |
| with: | |
| filters: | | |
| ci_workflow: | |
| - &ci_workflow_path '.github/workflows/ci.yml' | |
| typecheck: | |
| - *ci_workflow_path | |
| - 'apps/backend/**' | |
| - 'apps/frontend/**' | |
| - 'package.json' | |
| - 'pnpm-lock.yaml' | |
| - 'pnpm-workspace.yaml' | |
| - 'tsconfig*.json' | |
| node_lint: | |
| - *ci_workflow_path | |
| - 'apps/backend/**' | |
| - 'apps/frontend/**' | |
| - 'package.json' | |
| - 'pnpm-lock.yaml' | |
| - 'pnpm-workspace.yaml' | |
| - 'tsconfig*.json' | |
| - 'eslint.config.mjs' | |
| - '.prettierrc' | |
| - '.prettierignore' | |
| - '.npmrc' | |
| - 'scripts/**/*.ts' | |
| - '.github/actions/setup-pnpm/**' | |
| backend_shared: &backend_shared | |
| - *ci_workflow_path | |
| - 'apps/backend/libs/**' | |
| - 'apps/backend/prisma/**' | |
| - 'apps/backend/*.json' | |
| - 'pnpm-lock.yaml' | |
| backend_client: | |
| - *backend_shared | |
| - 'apps/backend/apps/client/**' | |
| backend_admin: | |
| - *backend_shared | |
| - 'apps/backend/apps/admin/**' | |
| backend_test: | |
| - *ci_workflow_path | |
| - 'apps/backend/**' | |
| - 'pnpm-lock.yaml' | |
| iris: | |
| - *ci_workflow_path | |
| - 'apps/iris/**' | |
| plag: | |
| - *ci_workflow_path | |
| - 'apps/plag/**' | |
| go_lint: | |
| - *ci_workflow_path | |
| - 'apps/iris/**' | |
| - 'apps/plag/**' | |
| frontend: | |
| - *ci_workflow_path | |
| - 'apps/frontend/**' | |
| - 'apps/backend/schema.gql' | |
| - 'package.json' | |
| - 'pnpm-lock.yaml' | |
| - 'pnpm-workspace.yaml' | |
| infra_ansible: | |
| - *ci_workflow_path | |
| - 'infra/ansible/**' | |
| infra_ssh_keys: | |
| - *ci_workflow_path | |
| - 'infra/ansible/ssh_publickey/**' | |
| # =========================================================================== | |
| # Common | |
| # =========================================================================== | |
| typecheck: | |
| name: Typecheck | |
| needs: detect-changes | |
| if: needs['detect-changes'].outputs.typecheck == 'true' | |
| runs-on: ubuntu-latest | |
| steps: | |
| - uses: actions/checkout@v6 | |
| - uses: ./.github/actions/setup-pnpm | |
| - name: Generate Prisma Client | |
| run: pnpm --filter="@codedang/backend" exec prisma generate | |
| - name: Check types (backend) | |
| run: pnpm --filter="@codedang/backend" exec tsc --noEmit | |
| - name: Compile GraphQL schema | |
| run: pnpm --filter="@codedang/frontend" run compile | |
| - name: Check types (frontend) | |
| run: pnpm --filter="@codedang/frontend" exec tsc --noEmit | |
| lint-pr-title: | |
| name: Lint PR Title | |
| needs: detect-changes | |
| runs-on: ubuntu-latest | |
| steps: | |
| - uses: actions/checkout@v6 | |
| if: ${{ github.event_name == 'pull_request' }} | |
| - uses: ./.github/actions/setup-pnpm | |
| if: ${{ github.event_name == 'pull_request' }} | |
| with: | |
| install: 'no' | |
| - name: Install root dependencies | |
| if: ${{ github.event_name == 'pull_request' }} | |
| run: pnpm install -w --filter codedang | |
| - name: Lint pull request title | |
| if: ${{ github.event_name == 'pull_request' }} | |
| run: echo "${{ github.event.pull_request.title }}" | pnpm commitlint --verbose | |
| lint-node: | |
| name: Lint Node.js | |
| needs: detect-changes | |
| if: needs['detect-changes'].outputs.node_lint == 'true' | |
| runs-on: ubuntu-latest | |
| steps: | |
| - uses: actions/checkout@v6 | |
| with: | |
| fetch-depth: 0 | |
| - uses: ./.github/actions/setup-pnpm | |
| with: | |
| install: 'no' | |
| # NOTE: eslint-config-next 에서 next 모듈을 찾지 못하는 에러를 해결하기 위해 프론트엔드 의존성을 설치합니다. | |
| - name: Install root and frontend dependencies | |
| run: pnpm install -w --filter frontend | |
| - name: Check style (Node.js) | |
| run: git diff --name-only --diff-filter=ACMRUXB origin/main | xargs -r pnpm prettier -c --ignore-unknown --no-error-on-unmatched-pattern | |
| - name: Lint (Node.js) | |
| run: git diff --name-only --diff-filter=ACMRUXB origin/main | grep -E "(.ts$|.tsx$|.js$|.jsx$)" | grep -v 'next.config.js$' | xargs -r pnpm eslint | |
| # =========================================================================== | |
| # Backend | |
| # =========================================================================== | |
| lint-go: | |
| name: Lint Go | |
| needs: detect-changes | |
| if: needs['detect-changes'].outputs.go_lint == 'true' | |
| runs-on: ubuntu-latest | |
| steps: | |
| - uses: actions/checkout@v6 | |
| - name: Set up Go | |
| uses: actions/setup-go@v5 | |
| with: | |
| go-version-file: apps/iris/go.mod | |
| cache-dependency-path: apps/iris/go.sum | |
| - name: Check Style (Go) | |
| run: | | |
| if [ -n "$(gofmt -l apps/iris apps/plag)" ]; then | |
| echo "Go files must be formatted with gofmt. Please run:" | |
| echo " go fmt ./..." | |
| exit 1 | |
| fi | |
| build-backend-client: | |
| name: Build Backend (client) | |
| needs: detect-changes | |
| if: needs['detect-changes'].outputs.backend_client == 'true' | |
| runs-on: ubuntu-latest | |
| steps: | |
| - uses: actions/checkout@v6 | |
| - uses: ./.github/actions/setup-pnpm | |
| - name: Generate Prisma Client | |
| run: pnpm --filter="@codedang/backend" exec prisma generate | |
| - name: Build | |
| run: pnpm --filter="@codedang/backend" build client | |
| build-backend-admin: | |
| name: Build Backend (admin) | |
| needs: detect-changes | |
| if: needs['detect-changes'].outputs.backend_admin == 'true' | |
| runs-on: ubuntu-latest | |
| steps: | |
| - uses: actions/checkout@v6 | |
| - uses: ./.github/actions/setup-pnpm | |
| - name: Generate Prisma Client | |
| run: pnpm --filter="@codedang/backend" exec prisma generate | |
| - name: Build | |
| run: pnpm --filter="@codedang/backend" build admin | |
| test-backend: | |
| name: Test Backend | |
| needs: detect-changes | |
| if: needs['detect-changes'].outputs.backend_test == 'true' | |
| runs-on: ubuntu-latest | |
| env: | |
| DATABASE_URL: postgresql://postgres:1234@localhost:5432/skkuding?schema=public | |
| TEST_DATABASE_URL: postgresql://postgres:1234@localhost:5432/skkuding?schema=public | |
| MINIO_ENDPOINT_URL: http://localhost:9000 | |
| TESTCASE_BUCKET_NAME: test-bucket | |
| CHECK_RESULT_BUCKET_NAME: codedang-plag-checks | |
| MEDIA_BUCKET_NAME: image-bucket | |
| AWS_ACCESS_KEY_ID: abc123 | |
| AWS_SECRET_ACCESS_KEY: xyz123456 | |
| REDIS_HOST: localhost | |
| REDIS_PORT: 6380 | |
| services: | |
| postgres: | |
| image: postgres:16-alpine | |
| ports: | |
| - 5432:5432 | |
| env: | |
| POSTGRES_USER: postgres | |
| POSTGRES_PASSWORD: 1234 | |
| POSTGRES_DB: skkuding | |
| minio: | |
| # NOTE: GitHub Actions service containers do not support custom commands. | |
| # This image from Bitnami includes the command to run MinIO. | |
| # https://stackoverflow.com/questions/60849745/how-can-i-run-a-command-in-github-action-service-containers | |
| # NOTE: Bitnami images for community-tier users are restricted to the latest tag. | |
| # https://github.com/bitnami/charts/issues/35164 | |
| # NOTE: due to instability of Bitnami image, switch to lazybit's image | |
| # https://stackoverflow.com/questions/64031598/creating-a-minios3-container-inside-a-github-actions-yml-file | |
| image: lazybit/minio:latest | |
| ports: | |
| - 9000:9000 | |
| - 9001:9001 | |
| env: | |
| MINIO_ROOT_USER: ${{ env.AWS_ACCESS_KEY_ID }} | |
| MINIO_ROOT_PASSWORD: ${{ env.AWS_SECRET_ACCESS_KEY }} | |
| MINIO_BROWSER: 'off' | |
| redis: | |
| image: redis:7-alpine | |
| ports: | |
| - 6380:6379 | |
| steps: | |
| - uses: actions/checkout@v6 | |
| - uses: ./.github/actions/setup-pnpm | |
| - name: Check Prisma Migration | |
| run: | | |
| pnpm --filter="@codedang/backend" exec prisma migrate diff \ | |
| --from-migrations ./prisma/migrations \ | |
| --to-schema-datamodel ./prisma/schema.prisma \ | |
| --shadow-database-url ${{ env.DATABASE_URL }} \ | |
| --exit-code || | |
| (echo "::error::Prisma migration is not up to date." \ | |
| "Please run 'pnpm prisma migrate dev' locally and commit the changes." && exit 1) | |
| - name: Migrate Prisma | |
| run: pnpm --filter="@codedang/backend" exec prisma migrate reset --force | |
| - name: Initialize MinIO | |
| run: pnpm run init:storage | |
| env: | |
| MINIO_ACCESS_KEY_ID: ${{ env.AWS_ACCESS_KEY_ID }} | |
| MINIO_SECRET_ACCESS_KEY: ${{ env.AWS_SECRET_ACCESS_KEY }} | |
| - name: Test | |
| run: pnpm --filter="@codedang/backend" test | |
| test-iris: | |
| name: Test Iris | |
| needs: detect-changes | |
| if: needs['detect-changes'].outputs.iris == 'true' | |
| runs-on: ubuntu-latest | |
| steps: | |
| - uses: actions/checkout@v6 | |
| - name: Set up Go | |
| uses: actions/setup-go@v5 | |
| with: | |
| go-version-file: apps/iris/go.mod | |
| cache-dependency-path: apps/iris/go.sum | |
| - name: Test (Go) | |
| run: go test ./... | |
| working-directory: ./apps/iris | |
| test-plag: | |
| name: Test Plag | |
| needs: detect-changes | |
| if: needs['detect-changes'].outputs.plag == 'true' | |
| runs-on: ubuntu-latest | |
| steps: | |
| - uses: actions/checkout@v6 | |
| - name: Set up Go | |
| uses: actions/setup-go@v5 | |
| with: | |
| go-version-file: apps/plag/go.mod | |
| cache-dependency-path: apps/plag/go.sum | |
| - name: Test (Go) | |
| run: go test ./... | |
| working-directory: ./apps/plag | |
| # TODO: write smoke test for backend | |
| # e.g) start backend server and check if it responds to requests | |
| # | |
| # ``` | |
| # pnpm --filter="@codedang/backend" start & | |
| # | |
| # for i in {1..10} | |
| # do | |
| # curl -s http://localhost:3000/health && exit 0 | |
| # sleep 2 | |
| # done | |
| # echo "Server did not respond to health check." | |
| # exit 1 | |
| # ``` | |
| # =========================================================================== | |
| # Frontend | |
| # =========================================================================== | |
| build-frontend: | |
| name: Build Frontend | |
| needs: detect-changes | |
| if: needs['detect-changes'].outputs.frontend == 'true' | |
| runs-on: arc-runner | |
| permissions: | |
| contents: read | |
| packages: write | |
| pull-requests: read | |
| steps: | |
| - uses: actions/checkout@v6 | |
| - name: Setup Docker Buildx | |
| uses: docker/setup-buildx-action@v3 | |
| - name: Login to GitHub Container Registry | |
| uses: docker/login-action@v3 | |
| with: | |
| registry: ghcr.io | |
| username: ${{ github.repository_owner }} | |
| password: ${{ secrets.GITHUB_TOKEN }} | |
| - name: Detect PR number | |
| id: pr | |
| uses: actions/github-script@v7 | |
| with: | |
| script: | | |
| const { owner, repo } = context.repo; | |
| let prNumber = ''; | |
| // pull_request | |
| if (context.eventName === 'pull_request') { | |
| prNumber = String(context.payload.pull_request.number); | |
| } | |
| // merge_group | |
| if (!prNumber && context.eventName === 'merge_group' && context.ref?.includes('gh-readonly-queue')) { | |
| const m = context.ref.match(/pr-(\d+)-/); | |
| if (m) prNumber = m[1]; | |
| } | |
| // push(main) | |
| if (!prNumber) { | |
| try { | |
| const res = await github.rest.repos.listPullRequestsAssociatedWithCommit({ | |
| owner, repo, commit_sha: context.sha, | |
| }); | |
| prNumber = res.data?.[0]?.number ? String(res.data[0].number) : ''; | |
| } catch (e) {} | |
| } | |
| // fallback | |
| if (!prNumber) { | |
| try { | |
| const commit = await github.rest.repos.getCommit({ owner, repo, ref: context.sha }); | |
| const m = commit.data.commit.message.match(/#(\d+)/); | |
| if (m) prNumber = m[1]; | |
| } catch (e) {} | |
| } | |
| core.setOutput('number', prNumber); | |
| - name: Build and push image | |
| uses: docker/build-push-action@v6 | |
| with: | |
| file: ./apps/frontend/Dockerfile # build with root context | |
| push: true | |
| tags: ghcr.io/skkuding/codedang-frontend:${{ github.event.pull_request.head.sha || github.sha }} | |
| cache-from: | | |
| type=registry,ref=ghcr.io/skkuding/codedang-frontend:cache-${{ steps.pr.outputs.number || 'stage' }} | |
| type=registry,ref=ghcr.io/skkuding/codedang-frontend:cache-stage | |
| cache-to: type=registry,ref=ghcr.io/skkuding/codedang-frontend:cache-${{ steps.pr.outputs.number || github.sha }},mode=max | |
| build-args: | | |
| BASEURL=https://stage.codedang.com/api | |
| GQL_BASEURL=https://stage.codedang.com/graphql | |
| # =========================================================================== | |
| # Infra | |
| # =========================================================================== | |
| ansible-lint: | |
| name: Ansible Lint | |
| needs: detect-changes | |
| if: needs['detect-changes'].outputs.infra_ansible == 'true' | |
| runs-on: ubuntu-latest | |
| steps: | |
| - uses: actions/checkout@v6 | |
| - name: Run Ansible Lint | |
| uses: ansible/ansible-lint@v25 | |
| with: | |
| working_directory: infra/ansible/ | |
| check-ssh-public-keys: | |
| name: Check SSH Public Keys | |
| needs: detect-changes | |
| if: needs['detect-changes'].outputs.infra_ssh_keys == 'true' | |
| runs-on: ubuntu-latest | |
| steps: | |
| - uses: actions/checkout@v6 | |
| - name: Check for SSH Public Keys | |
| shell: bash | |
| run: | | |
| set -euo pipefail | |
| shopt -s nullglob | |
| files=(infra/ansible/ssh_publickey/*.pub) | |
| if [ ${#files[@]} -eq 0 ]; then | |
| echo "No public key files found" | |
| exit 0 | |
| fi | |
| for file in "${files[@]}"; do | |
| lineno=0 | |
| while IFS= read -r line; do | |
| lineno=$((lineno + 1)) | |
| [[ "$line" =~ ^[[:space:]]*$ || "$line" =~ ^[[:space:]]*# ]] && continue | |
| read -r type key comment <<< "$line" | |
| # Allowed key types: ed25519 and RSA(3072 or 4096 bits only) | |
| case "$type" in | |
| ssh-ed25519) | |
| tmp=$(mktemp) | |
| printf '%s\n' "$line" > "$tmp" | |
| if ! ssh-keygen -lf "$tmp" > /dev/null 2>&1; then | |
| rm -f "$tmp" | |
| echo "Invalid ed25519 key in ${file}:${lineno}" | |
| exit 1 | |
| fi | |
| rm -f "$tmp" | |
| ;; | |
| ssh-rsa) | |
| tmp=$(mktemp) | |
| printf '%s\n' "$line" > "$tmp" | |
| if ! bits=$(ssh-keygen -lf "$tmp" | awk '{print $1}'); then | |
| rm -f "$tmp" | |
| echo "Wrong type of pubkey at ${file}:${lineno}" | |
| exit 1 | |
| fi | |
| rm -f "$tmp" | |
| if [[ "$bits" -ne 3072 && "$bits" -ne 4096 ]]; then | |
| echo "Invalid RSA key size (${bits} bits) in ${file}:${lineno}" | |
| exit 1 | |
| fi | |
| ;; | |
| *) | |
| echo "Unsupported key type (${type}) in ${file}:${lineno}" | |
| exit 1 | |
| ;; | |
| esac | |
| done < "$file" | |
| done | |
| echo "All keys are valid" | |
| validate-ci-workflow: | |
| name: Validate CI Workflow | |
| needs: detect-changes | |
| if: needs['detect-changes'].outputs.ci_workflow == 'true' | |
| runs-on: ubuntu-latest | |
| steps: | |
| - uses: actions/checkout@v6 | |
| - name: Validate CI gate wiring | |
| shell: ruby {0} | |
| run: | | |
| require 'set' | |
| require 'yaml' | |
| workflow = YAML.load_file('.github/workflows/ci.yml') | |
| jobs = workflow.fetch('jobs') | |
| errors = [] | |
| detect_job = jobs.fetch('detect-changes') | |
| detect_outputs = detect_job.fetch('outputs') | |
| filter_step = detect_job.fetch('steps').find { |step| step['id'] == 'filter' } | |
| filters = YAML.safe_load(filter_step.fetch('with').fetch('filters'), aliases: true) | |
| ci_workflow_paths = filters.fetch('ci_workflow') | |
| detect_outputs.each do |name, expression| | |
| match = expression.match(/\A\$\{\{\s*steps\.filter\.outputs\.([A-Za-z0-9_]+)\s*\}\}\z/) | |
| errors << "detect-changes output #{name} does not map to steps.filter.outputs.#{name}" if match.nil? || match[1] != name | |
| errors << "detect-changes output #{name} has no paths-filter entry" unless filters.key?(name) | |
| next if name == 'ci_workflow' | |
| missing_paths = ci_workflow_paths - filters.fetch(name).flatten | |
| unless missing_paths.empty? | |
| errors << "paths-filter #{name} does not include ci_workflow paths: #{missing_paths.join(', ')}" | |
| end | |
| end | |
| gate_job = jobs.fetch('ci-gate') | |
| gate_needs = Array(gate_job.fetch('needs')) | |
| gate_script = gate_job.fetch('steps').map { |step| step['run'] }.compact.join("\n") | |
| gate_results = gate_script.scan(/"([^":]+):\$\{\{\s*needs/).flatten.to_set | |
| gate_needs.each do |job_name| | |
| errors << "ci-gate needs unknown job #{job_name}" unless jobs.key?(job_name) | |
| errors << "ci-gate does not check result for #{job_name}" unless gate_results.include?(job_name) | |
| end | |
| gate_results.each do |job_name| | |
| errors << "ci-gate checks result for job not listed in needs: #{job_name}" unless gate_needs.include?(job_name) | |
| end | |
| jobs.each do |job_name, job| | |
| next if ['detect-changes', 'ci-gate'].include?(job_name) | |
| errors << "#{job_name} is not listed in ci-gate needs" unless gate_needs.include?(job_name) | |
| condition = job['if'].to_s | |
| condition.scan(/needs\[['"]detect-changes['"]\]\.outputs\.([A-Za-z0-9_]+)/).flatten.each do |output| | |
| errors << "#{job_name} references unknown detect-changes output #{output}" unless detect_outputs.key?(output) | |
| end | |
| end | |
| if errors.empty? | |
| puts 'CI workflow wiring is valid' | |
| else | |
| errors.each { |error| warn "ERROR: #{error}" } | |
| exit 1 | |
| end | |
| # =========================================================================== | |
| # Gate | |
| # =========================================================================== | |
| ci-gate: | |
| name: CI Gate | |
| needs: | |
| - detect-changes | |
| - typecheck | |
| - lint-pr-title | |
| - lint-node | |
| - lint-go | |
| - build-backend-client | |
| - build-backend-admin | |
| - test-backend | |
| - test-iris | |
| - test-plag | |
| - build-frontend | |
| - ansible-lint | |
| - check-ssh-public-keys | |
| - validate-ci-workflow | |
| if: always() | |
| runs-on: ubuntu-latest | |
| steps: | |
| - name: Check CI results | |
| shell: bash | |
| run: | | |
| set -euo pipefail | |
| results=( | |
| "detect-changes:${{ needs['detect-changes'].result }}" | |
| "typecheck:${{ needs.typecheck.result }}" | |
| "lint-pr-title:${{ needs['lint-pr-title'].result }}" | |
| "lint-node:${{ needs['lint-node'].result }}" | |
| "lint-go:${{ needs['lint-go'].result }}" | |
| "build-backend-client:${{ needs['build-backend-client'].result }}" | |
| "build-backend-admin:${{ needs['build-backend-admin'].result }}" | |
| "test-backend:${{ needs['test-backend'].result }}" | |
| "test-iris:${{ needs['test-iris'].result }}" | |
| "test-plag:${{ needs['test-plag'].result }}" | |
| "build-frontend:${{ needs['build-frontend'].result }}" | |
| "ansible-lint:${{ needs['ansible-lint'].result }}" | |
| "check-ssh-public-keys:${{ needs['check-ssh-public-keys'].result }}" | |
| "validate-ci-workflow:${{ needs['validate-ci-workflow'].result }}" | |
| ) | |
| failed=0 | |
| for item in "${results[@]}"; do | |
| name="${item%%:*}" | |
| result="${item#*:}" | |
| echo "${name}: ${result}" | |
| if [[ "${result}" == "failure" || "${result}" == "cancelled" ]]; then | |
| failed=1 | |
| fi | |
| done | |
| if [[ "${failed}" -ne 0 ]]; then | |
| exit 1 | |
| fi |