Parity 4 #6920
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: Pull Request CI | |
| on: | |
| pull_request: | |
| branches: [ main ] | |
| types: [opened, synchronize, reopened] | |
| workflow_dispatch: | |
| workflow_run: | |
| workflows: ["Copilot coding agent"] | |
| types: [completed] | |
| branches-ignore: | |
| - main | |
| permissions: | |
| contents: read | |
| checks: write | |
| pull-requests: write | |
| security-events: write | |
| packages: write | |
| # Only the latest commit per PR (or ref) needs CI. A new push cancels any | |
| # still-running CI for the same PR so rapid commit bursts don't pile up runs; | |
| # CI settles on the last commit, which is all that matters. | |
| concurrency: | |
| group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.event.workflow_run.head_branch || github.ref }} | |
| cancel-in-progress: true | |
| env: | |
| GH_CI_TOKEN: ${{ secrets.GH_TOKEN != '' && secrets.GH_TOKEN || github.token }} | |
| jobs: | |
| ui-lint: | |
| runs-on: ubuntu-latest | |
| steps: | |
| - uses: actions/checkout@v7 | |
| with: | |
| token: ${{ env.GH_CI_TOKEN }} | |
| ref: ${{ github.event.workflow_run.head_sha || github.sha }} | |
| - name: Set up Node | |
| uses: actions/setup-node@v6 | |
| with: | |
| node-version: '24' | |
| cache: npm | |
| cache-dependency-path: ui/package-lock.json | |
| - name: Install UI dependencies | |
| run: npm --prefix ui ci | |
| - name: Run UI lint | |
| run: npm --prefix ui run lint | |
| - name: Run UI format check | |
| run: npm --prefix ui run fmt:check | |
| - name: Run UI type check | |
| run: npm --prefix ui run check | |
| lint: | |
| runs-on: ubuntu-latest | |
| steps: | |
| - uses: actions/checkout@v7 | |
| with: | |
| token: ${{ env.GH_CI_TOKEN }} | |
| ref: ${{ github.event.workflow_run.head_sha || github.sha }} | |
| - name: Set up Go | |
| uses: actions/setup-go@v6 | |
| with: | |
| go-version-file: go.mod | |
| check-latest: true | |
| cache: true | |
| - name: golangci-lint | |
| uses: golangci/golangci-lint-action@v9 | |
| modernize: | |
| runs-on: ubuntu-latest | |
| steps: | |
| - uses: actions/checkout@v7 | |
| with: | |
| token: ${{ env.GH_CI_TOKEN }} | |
| ref: ${{ github.event.workflow_run.head_sha || github.sha }} | |
| - name: Set up Go | |
| uses: actions/setup-go@v6 | |
| with: | |
| go-version-file: go.mod | |
| check-latest: true | |
| cache: true | |
| - name: go fix (modernize) | |
| run: go fix -diff ./... | |
| govulncheck: | |
| runs-on: ubuntu-latest | |
| steps: | |
| - uses: actions/checkout@v7 | |
| with: | |
| token: ${{ env.GH_CI_TOKEN }} | |
| persist-credentials: false | |
| ref: ${{ github.event.workflow_run.head_sha || github.sha }} | |
| - name: govulncheck | |
| uses: golang/govulncheck-action@v1 | |
| with: | |
| go-version-file: go.mod | |
| go-package: ./... | |
| output-format: sarif | |
| output-file: govulncheck.sarif | |
| - name: Upload govulncheck SARIF | |
| if: always() | |
| uses: github/codeql-action/upload-sarif@v4 | |
| with: | |
| sarif_file: govulncheck.sarif | |
| category: govulncheck | |
| codeql: | |
| runs-on: ubuntu-latest | |
| timeout-minutes: 360 | |
| permissions: | |
| actions: read | |
| contents: read | |
| security-events: write | |
| strategy: | |
| fail-fast: false | |
| matrix: | |
| language: [ 'go' ] | |
| steps: | |
| - name: Checkout repository | |
| uses: actions/checkout@v7 | |
| with: | |
| token: ${{ env.GH_CI_TOKEN }} | |
| ref: ${{ github.event.workflow_run.head_sha || github.sha }} | |
| - name: Initialize CodeQL | |
| uses: github/codeql-action/init@v4 | |
| with: | |
| languages: ${{ matrix.language }} | |
| build-mode: 'autobuild' | |
| - name: Perform CodeQL Analysis | |
| uses: github/codeql-action/analyze@v4 | |
| with: | |
| category: "/language:${{ matrix.language }}" | |
| unit: | |
| runs-on: ubuntu-latest | |
| steps: | |
| - uses: actions/checkout@v7 | |
| with: | |
| token: ${{ env.GH_CI_TOKEN }} | |
| ref: ${{ github.event.workflow_run.head_sha || github.sha }} | |
| - name: Set up Go | |
| uses: actions/setup-go@v6 | |
| with: | |
| go-version-file: go.mod | |
| check-latest: true | |
| cache: true | |
| - name: Run Unit Tests | |
| run: | | |
| COVERPKGS=$(go list ./... | grep -v -E '(test/|/demo$|/modules/|/teststack$)' | tr '\n' ',' | sed 's/,$//') | |
| go tool gotestsum --format pkgname -- \ | |
| -race -shuffle on -short -timeout 5m \ | |
| -coverpkg="$COVERPKGS" -coverprofile=unit-coverage.out -covermode=atomic \ | |
| ./... | |
| - name: Upload Coverage | |
| uses: actions/upload-artifact@v7 | |
| with: | |
| name: unit-coverage | |
| path: unit-coverage.out | |
| retention-days: 1 | |
| build: | |
| runs-on: ubuntu-latest | |
| steps: | |
| - uses: actions/checkout@v7 | |
| with: | |
| token: ${{ env.GH_CI_TOKEN }} | |
| ref: ${{ github.event.workflow_run.head_sha || github.sha }} | |
| - name: Set up Go | |
| uses: actions/setup-go@v6 | |
| with: | |
| go-version-file: go.mod | |
| check-latest: true | |
| cache: true | |
| - name: Build static binary | |
| run: | | |
| mkdir -p dashboard/static/spa | |
| touch dashboard/static/spa/.keep | |
| CGO_ENABLED=0 GOOS=linux go build -tags 'netgo osusergo static_build' -trimpath \ | |
| -ldflags="-w -s" -o bin/gopherstack . | |
| - name: Upload binary | |
| uses: actions/upload-artifact@v7 | |
| with: | |
| name: gopherstack-binary | |
| path: bin/gopherstack | |
| retention-days: 1 | |
| integration: | |
| runs-on: ubuntu-latest | |
| needs: [build] | |
| strategy: | |
| fail-fast: false | |
| matrix: | |
| chunk: [0, 1, 2, 3] | |
| steps: | |
| - uses: actions/checkout@v7 | |
| with: | |
| token: ${{ env.GH_CI_TOKEN }} | |
| ref: ${{ github.event.workflow_run.head_sha || github.sha }} | |
| - name: Set up Go | |
| uses: actions/setup-go@v6 | |
| with: | |
| go-version-file: go.mod | |
| check-latest: true | |
| cache: true | |
| - name: Download binary | |
| uses: actions/download-artifact@v8 | |
| with: | |
| name: gopherstack-binary | |
| path: bin/ | |
| - name: Make binary executable | |
| run: chmod +x bin/gopherstack | |
| - name: Compute test pattern for chunk | |
| id: tests | |
| run: | | |
| TOTAL_CHUNKS=4 | |
| CHUNK=${{ matrix.chunk }} | |
| # Discover all test functions from source (no Docker needed) | |
| TESTS=$(grep -rh '^func Test' test/integration/ | sed 's/func \(Test[^ (]*\).*/\1/' | grep -v '^TestMain$' | sort) | |
| # Round-robin distribute tests across chunks | |
| CHUNK_TESTS=$(echo "$TESTS" | awk "NR % ${TOTAL_CHUNKS} == ${CHUNK}") | |
| COUNT=$(echo "$CHUNK_TESTS" | wc -l | tr -d ' ') | |
| PATTERN=$(echo "$CHUNK_TESTS" | tr '\n' '|' | sed 's/|$//') | |
| echo "pattern=^(${PATTERN})$" >> "$GITHUB_OUTPUT" | |
| echo "Running $COUNT tests in chunk $CHUNK of $TOTAL_CHUNKS" | |
| - name: Run Integration Tests (chunk ${{ matrix.chunk }}) | |
| run: | | |
| echo "bin/gopherstack exists: $(test -f bin/gopherstack && echo yes || echo no)" | |
| go tool gotestsum --format standard-verbose -- \ | |
| -race -shuffle on -timeout 10m -v \ | |
| -run '${{ steps.tests.outputs.pattern }}' \ | |
| -coverpkg=./... -coverprofile=integration-${{ matrix.chunk }}-coverage.out -covermode=atomic \ | |
| ./test/integration/... | |
| - name: Upload Coverage | |
| uses: actions/upload-artifact@v7 | |
| with: | |
| name: integration-${{ matrix.chunk }}-coverage | |
| path: integration-${{ matrix.chunk }}-coverage.out | |
| retention-days: 1 | |
| terraform: | |
| runs-on: ubuntu-latest | |
| needs: [build] | |
| strategy: | |
| fail-fast: false | |
| matrix: | |
| chunk: [0, 1, 2, 3, 4, 5, 6, 7] | |
| steps: | |
| - uses: actions/checkout@v7 | |
| with: | |
| token: ${{ env.GH_CI_TOKEN }} | |
| ref: ${{ github.event.workflow_run.head_sha || github.sha }} | |
| - name: Set up Go | |
| uses: actions/setup-go@v6 | |
| with: | |
| go-version-file: go.mod | |
| check-latest: true | |
| cache: true | |
| - name: Download binary | |
| uses: actions/download-artifact@v8 | |
| with: | |
| name: gopherstack-binary | |
| path: bin/ | |
| - name: Make binary executable | |
| run: chmod +x bin/gopherstack | |
| - name: Install OpenTofu | |
| uses: opentofu/setup-opentofu@v2 | |
| with: | |
| tofu_wrapper: false | |
| tofu_version: "1.11.6" | |
| - name: Cache OpenTofu providers | |
| uses: actions/cache@v6 | |
| with: | |
| path: /tmp/gopherstack-tofu-provider-cache | |
| key: tofu-providers-${{ runner.os }}-aws5 | |
| - name: Compute test pattern for chunk | |
| id: tests | |
| run: | | |
| TOTAL_CHUNKS=8 | |
| CHUNK=${{ matrix.chunk }} | |
| # Discover all test functions from source (no Docker needed) | |
| TESTS=$(grep -rh '^func Test' test/terraform/*_test.go | sed 's/func \(Test[^ (]*\).*/\1/' | sort -u) | |
| # Round-robin distribute tests across chunks | |
| CHUNK_TESTS=$(echo "$TESTS" | awk "NR % ${TOTAL_CHUNKS} == ${CHUNK}") | |
| COUNT=$(echo "$CHUNK_TESTS" | wc -l | tr -d ' ') | |
| PATTERN=$(echo "$CHUNK_TESTS" | tr '\n' '|' | sed 's/|$//') | |
| echo "pattern=^(${PATTERN})$" >> "$GITHUB_OUTPUT" | |
| echo "Running $COUNT tests in chunk $CHUNK of $TOTAL_CHUNKS" | |
| - name: Run OpenTofu Tests (chunk ${{ matrix.chunk }}) | |
| run: | | |
| echo "tofu version: $(tofu version)" | |
| echo "bin/gopherstack exists: $(test -f bin/gopherstack && echo yes || echo no)" | |
| go tool gotestsum --format standard-verbose -- \ | |
| -race -parallel 8 -timeout 15m \ | |
| -run '${{ steps.tests.outputs.pattern }}' \ | |
| -v ./test/terraform/... | |
| e2e: | |
| runs-on: ubuntu-latest | |
| steps: | |
| - uses: actions/checkout@v7 | |
| with: | |
| token: ${{ env.GH_CI_TOKEN }} | |
| ref: ${{ github.event.workflow_run.head_sha || github.sha }} | |
| - name: Set up Go | |
| uses: actions/setup-go@v6 | |
| with: | |
| go-version-file: go.mod | |
| check-latest: true | |
| cache: true | |
| - name: Set up Node.js | |
| uses: actions/setup-node@v6 | |
| with: | |
| node-version: '24' | |
| cache: 'npm' | |
| cache-dependency-path: ui/package-lock.json | |
| - name: Build UI | |
| working-directory: ui | |
| run: | | |
| npm ci | |
| npm run build | |
| - name: Install Playwright Browsers | |
| run: | | |
| go run github.com/mxschmitt/playwright-go/cmd/playwright@v0.6100.0 install --with-deps chromium | |
| - name: Run E2E Tests | |
| run: | | |
| COVERPKGS=$(go list ./... | grep -v -E '(test/|/demo$|/modules/|/teststack$)' | tr '\n' ',' | sed 's/,$//') | |
| go tool gotestsum --format pkgname -- \ | |
| -race -shuffle on -timeout 10m -tags=e2e \ | |
| -coverpkg="$COVERPKGS" -coverprofile=e2e-coverage.out -covermode=atomic \ | |
| ./test/e2e/... | |
| - name: Upload Coverage | |
| uses: actions/upload-artifact@v7 | |
| with: | |
| name: e2e-coverage | |
| path: e2e-coverage.out | |
| retention-days: 1 | |
| ui-test: | |
| runs-on: ubuntu-latest | |
| steps: | |
| - uses: actions/checkout@v7 | |
| with: | |
| token: ${{ env.GH_CI_TOKEN }} | |
| ref: ${{ github.event.workflow_run.head_sha || github.sha }} | |
| - name: Set up Node.js | |
| uses: actions/setup-node@v6 | |
| with: | |
| node-version: '24' | |
| cache: 'npm' | |
| cache-dependency-path: ui/package-lock.json | |
| - name: Install dependencies | |
| working-directory: ui | |
| run: npm ci | |
| - name: Run UI Tests | |
| working-directory: ui | |
| run: npx vitest run | |
| coverage: | |
| runs-on: ubuntu-latest | |
| needs: [unit, integration, e2e] | |
| permissions: | |
| contents: write | |
| checks: write | |
| pull-requests: write | |
| steps: | |
| - uses: actions/checkout@v7 | |
| with: | |
| token: ${{ env.GH_CI_TOKEN }} | |
| fetch-depth: 0 | |
| ref: ${{ github.event.workflow_run.head_sha || github.sha }} | |
| - name: Set up Go | |
| uses: actions/setup-go@v6 | |
| with: | |
| go-version-file: go.mod | |
| check-latest: true | |
| cache: true | |
| - name: Download All Coverage Artifacts | |
| uses: actions/download-artifact@v8 | |
| with: | |
| pattern: '*-coverage' | |
| merge-multiple: true | |
| - name: Merge Coverage Profiles | |
| run: | | |
| # Merge all coverage files into one | |
| echo "mode: atomic" > coverage.out | |
| for f in *-coverage.out; do | |
| if [ -f "$f" ]; then | |
| echo "Merging $f" | |
| tail -n +2 "$f" >> coverage.out | |
| fi | |
| done | |
| go tool cover -func=coverage.out | tail -1 | |
| - name: Check Coverage Thresholds | |
| id: coverage | |
| uses: vladopajic/go-test-coverage@v2 | |
| with: | |
| profile: coverage.out | |
| config: .testcoverage.yml | |
| - name: Generate Smart Coverage Report | |
| if: always() && (github.event_name == 'pull_request' || github.event_name == 'workflow_run') | |
| uses: actions/github-script@v9 | |
| with: | |
| github-token: ${{ env.GH_CI_TOKEN }} | |
| script: | | |
| const fs = require('fs'); | |
| const { execSync } = require('child_process'); | |
| let totalCoverage = '0.0%'; | |
| let newCodeCoverage = 'N/A'; | |
| let newCodeStats = { covered: 0, total: 0 }; | |
| let fileBreakdown = []; | |
| // Resolve PR number and base ref for both pull_request and workflow_run events | |
| const prNumber = context.issue?.number | |
| || context.payload?.workflow_run?.pull_requests?.[0]?.number; | |
| const baseRef = context.payload?.pull_request?.base?.ref | |
| || context.payload?.workflow_run?.pull_requests?.[0]?.base?.ref; | |
| if (!prNumber || !baseRef) { | |
| console.log('No PR context found, skipping coverage comment'); | |
| return; | |
| } | |
| try { | |
| // 1. Total Coverage | |
| const totalOutput = execSync('go tool cover -func=coverage.out | grep total | awk \'{print $3}\'').toString().trim(); | |
| totalCoverage = totalOutput; | |
| // 2. New Code Coverage (Diff-based) | |
| console.log(`Comparing against base ref: ${baseRef}`); | |
| // Ensure base ref is fetched | |
| execSync(`git fetch origin ${baseRef} --depth=1`); | |
| const diff = execSync(`git diff origin/${baseRef}...HEAD -U0`).toString(); | |
| const changedLines = {}; // file -> Set of line numbers | |
| const diffLines = diff.split('\n'); | |
| let currentFile = null; | |
| for (const line of diffLines) { | |
| if (line.startsWith('+++ b/')) { | |
| currentFile = line.substring(6); | |
| changedLines[currentFile] = new Set(); | |
| } else if (line.startsWith('@@ ') && currentFile) { | |
| const match = line.match(/@@ -\d+(?:,\d+)? \+(\d+)(?:,(\d+))? @@/); | |
| if (match) { | |
| const start = parseInt(match[1]); | |
| const count = parseInt(match[2] || '1'); | |
| for (let i = 0; i < count; i++) { | |
| changedLines[currentFile].add(start + i); | |
| } | |
| } | |
| } | |
| } | |
| // 3. Parse coverage.out and calculate stats | |
| const coverageData = fs.readFileSync('coverage.out', 'utf8').split('\n'); | |
| const fileStats = {}; // file -> {covered: 0, total: 0} | |
| for (const line of coverageData) { | |
| if (!line || line.startsWith('mode:')) continue; | |
| const [loc, numStmt, hits] = line.split(' '); | |
| const [fullPath, range] = loc.split(':'); | |
| const [start, end] = range.split(','); | |
| const startLine = parseInt(start.split('.')[0]); | |
| const endLine = parseInt(end.split('.')[0]); | |
| const stmts = parseInt(numStmt); | |
| const hitCount = parseInt(hits); | |
| // Find relative path | |
| const relativePath = fullPath.replace(/^github\.com\/[^\/]+\/[^\/]+\//, ''); | |
| if (changedLines[relativePath]) { | |
| if (!fileStats[relativePath]) fileStats[relativePath] = { covered: 0, total: 0 }; | |
| const fileLines = changedLines[relativePath]; | |
| let blockImpacted = false; | |
| for (let l = startLine; l <= endLine; l++) { | |
| if (fileLines.has(l)) { | |
| blockImpacted = true; | |
| break; | |
| } | |
| } | |
| if (blockImpacted) { | |
| newCodeStats.total += stmts; | |
| fileStats[relativePath].total += stmts; | |
| if (hitCount > 0) { | |
| newCodeStats.covered += stmts; | |
| fileStats[relativePath].covered += stmts; | |
| } | |
| } | |
| } | |
| } | |
| if (newCodeStats.total > 0) { | |
| newCodeCoverage = ((newCodeStats.covered / newCodeStats.total) * 100).toFixed(1) + '%'; | |
| } | |
| fileBreakdown = Object.entries(fileStats) | |
| .map(([file, stats]) => ({ | |
| file, | |
| coverage: stats.total > 0 ? ((stats.covered / stats.total) * 100).toFixed(1) + '%' : '0.0%', | |
| lines: `${stats.covered}/${stats.total}` | |
| })) | |
| .sort((a, b) => a.file.localeCompare(b.file)); | |
| } catch (e) { | |
| console.log('Failed to calculate coverage metrics:', e); | |
| } | |
| const header = '## 📊 Code Coverage Report'; | |
| const totalStatus = parseFloat(totalCoverage) >= 85 ? '✅' : '❌'; | |
| const newStatus = parseFloat(newCodeCoverage) >= 85 || newCodeCoverage === 'N/A' ? '✅' : '⚠️'; | |
| let fileTable = ''; | |
| if (fileBreakdown.length > 0) { | |
| fileTable = '\n### 📄 Impacted Files Breakdown\n\n' + | |
| '| File | New Code Coverage | Lines |\n' + | |
| '| :--- | :--- | :--- |\n' + | |
| fileBreakdown.map(f => `| \`${f.file}\` | ${f.coverage} | ${f.lines} |`).join('\n'); | |
| } | |
| const body = `${header} | |
| | Metric | Value | Status | | |
| | :--- | :--- | :--- | | |
| | **Total Coverage** | ${totalCoverage} | ${totalStatus} | | |
| | **New Code Coverage** | ${newCodeCoverage} (${newCodeStats.covered}/${newCodeStats.total} stmts) | ${newStatus} | | |
| ${fileTable} | |
| > [!TIP] | |
| > This project maintains a minimum coverage threshold of **85%**. Maintain or improve coverage on new code to ensure long-term stability. | |
| --- | |
| *Last updated: ${new Date().toUTCString()}*`; | |
| const { data: comments } = await github.rest.issues.listComments({ | |
| owner: context.repo.owner, | |
| repo: context.repo.repo, | |
| issue_number: prNumber, | |
| }); | |
| const botComment = comments.find(c => c.body.startsWith(header)); | |
| if (botComment) { | |
| await github.rest.issues.updateComment({ | |
| owner: context.repo.owner, | |
| repo: context.repo.repo, | |
| comment_id: botComment.id, | |
| body: body | |
| }); | |
| } else { | |
| await github.rest.issues.createComment({ | |
| owner: context.repo.owner, | |
| repo: context.repo.repo, | |
| issue_number: prNumber, | |
| body: body | |
| }); | |
| } |