Skip to content

Parity 4

Parity 4 #6920

Workflow file for this run

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
});
}