Skip to content

ci: Optimize PR pipeline - path gating, dedup secrets scan, CD push-only #404

ci: Optimize PR pipeline - path gating, dedup secrets scan, CD push-only

ci: Optimize PR pipeline - path gating, dedup secrets scan, CD push-only #404

Workflow file for this run

name: PR Validation
on:
pull_request:
branches: [ "main" ]
types: [opened, synchronize, reopened, ready_for_review]
# Ensure only one workflow runs per PR
concurrency:
group: pr-${{ github.event.pull_request.number }}
cancel-in-progress: true
jobs:
# Validate PR title follows Conventional Commits
validate-pr-title:
name: Validate PR Title
runs-on: ubuntu-latest
if: github.event.pull_request.draft == false && github.event.pull_request.user.login != 'dependabot[bot]'
steps:
- name: Validate PR title
uses: amannn/action-semantic-pull-request@v6
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with:
types: |
feat
fix
docs
style
refactor
perf
test
build
ci
chore
revert
requireScope: false
subjectPattern: ^[A-Z].+$
subjectPatternError: |
The subject "{subject}" found in the pull request title "{title}"
didn't match the configured pattern. Please ensure that the subject
starts with an uppercase character.
# Validate PR has description
validate-pr-description:
name: Validate PR Description
runs-on: ubuntu-latest
if: github.event.pull_request.draft == false && github.event.pull_request.user.login != 'dependabot[bot]'
steps:
- name: Check PR description
uses: actions/github-script@v8
with:
script: |
const prBody = context.payload.pull_request.body || '';
const minLength = 50;
if (prBody.trim().length < minLength) {
core.setFailed(
`PR description is too short (${prBody.trim().length} chars). ` +
`Please provide a meaningful description (minimum ${minLength} chars).`
);
return;
}
// Check for required sections (flexible check)
const hasWhat = /###?\s*What/i.test(prBody);
const hasWhy = /###?\s*Why/i.test(prBody);
const hasTesting = /###?\s*Testing/i.test(prBody) || /\[x\].*test/i.test(prBody);
if (!hasWhat || !hasWhy) {
core.setFailed(
'PR description is missing required sections. ' +
'Please use the PR template and fill in: What, Why, and Testing sections.'
);
return;
}
core.info('✅ PR description looks good!');
# Check PR size
check-pr-size:
name: Check PR Size
runs-on: ubuntu-latest
if: github.event.pull_request.draft == false
steps:
- name: Check PR size
uses: actions/github-script@v8
with:
script: |
const pr = context.payload.pull_request;
const additions = pr.additions || 0;
const deletions = pr.deletions || 0;
const totalChanges = additions + deletions;
const files = await github.paginate(github.rest.pulls.listFiles, {
owner: context.repo.owner,
repo: context.repo.repo,
pull_number: pr.number,
per_page: 100,
});
const isMaintenancePath = (path) => {
return (
path.startsWith('.squad/') ||
path.startsWith('docs/') ||
path.startsWith('.github/instructions/') ||
/(^|\/)README(\..+)?$/i.test(path) ||
/(^|\/)CHANGELOG(\..+)?$/i.test(path) ||
/(^|\/)CONTRIBUTING(\..+)?$/i.test(path) ||
/(^|\/)SECURITY(\..+)?$/i.test(path) ||
/(^|\/)LICENSE(\..+)?$/i.test(path) ||
/(^|\/)BENCHMARK_PR_STATUS(\..+)?$/i.test(path) ||
path.endsWith('.md')
);
};
const maintenanceOnly = files.length > 0 && files.every((f) => isMaintenancePath(f.filename));
// Soft limit (warning) and hard limit (failure)
const hardLimit = 1500;
const softLimit = 400;
if (totalChanges > hardLimit) {
if (maintenanceOnly) {
core.warning(
`⚠️ Large maintenance-only PR (${totalChanges} lines changed). ` +
`Allowing because all changed files are docs/.squad/instructions metadata.`
);
return;
}
core.setFailed(
`⚠️ PR is too large (${totalChanges} lines changed). ` +
`Please consider breaking it into smaller PRs (< ${hardLimit} lines).`
);
return;
}
if (totalChanges > softLimit) {
core.warning(
`⚠️ PR is getting large (${totalChanges} lines changed). ` +
`Consider breaking it into smaller PRs for easier review.`
);
} else {
core.info(`✅ PR size is good (${totalChanges} lines changed)`);
}
# Lint PR changes
lint-check:
name: Lint Check
runs-on: ubuntu-latest
if: github.event.pull_request.draft == false
# Pin the SDK to the version in global.json so that `dotnet format`
# produces identical output regardless of which SDK the runner ships.
# Without this, a newer SDK (e.g. 10.0.201 vs 10.0.103) may apply
# different formatting rules, causing false-positive lint failures.
env:
DOTNET_ROLL_FORWARD: LatestPatch
steps:
- name: Checkout
uses: actions/checkout@v6
with:
fetch-depth: 0
- name: Setup .NET (from global.json)
uses: actions/setup-dotnet@v4
with:
global-json-file: global.json
- name: Install WASM workloads
run: dotnet workload install wasm-experimental wasm-tools
- name: Restore
run: dotnet restore
env:
# Suppress NuGet audit during lint restore — audit is not the lint
# check's concern; the security-scan job handles vulnerability detection.
DOTNET_NUGET_AUDIT: "false"
- name: Get changed C# files
id: changed-files
run: |
# Get list of changed .cs files in the PR
CHANGED_FILES=$(git diff --name-only --diff-filter=ACMRT origin/${{ github.event.pull_request.base.ref }}...HEAD | grep '\.cs$' || true)
if [ -z "$CHANGED_FILES" ]; then
echo "has_cs_files=false" >> $GITHUB_OUTPUT
echo "ℹ️ No C# files changed in this PR"
else
echo "has_cs_files=true" >> $GITHUB_OUTPUT
# Convert newlines to spaces and store
FILES_SPACE_SEPARATED=$(echo "$CHANGED_FILES" | tr '\n' ' ')
echo "files=$FILES_SPACE_SEPARATED" >> $GITHUB_OUTPUT
echo "📝 Changed C# files:"
echo "$CHANGED_FILES"
fi
- name: Format check changed files
if: steps.changed-files.outputs.has_cs_files == 'true'
run: |
# Check formatting only for changed files
FILES="${{ steps.changed-files.outputs.files }}"
echo "🔍 Checking formatting for changed files..."
echo "Files: $FILES"
# Run format check with --include for each file.
# --exclude-diagnostics IDE1006: Exclude naming-rule violations
# (e.g. "Missing prefix: '_'") because dotnet format can detect but
# NOT auto-fix them (renaming is a refactoring, not formatting).
# Without this flag, --verify-no-changes reports false positives for
# files that would be "Formatted" yet produce zero diff.
dotnet format --verify-no-changes --include $FILES --verbosity diagnostic \
--exclude-diagnostics IDE1006
if [ $? -ne 0 ]; then
echo ""
echo "❌ Code formatting issues detected in your changes."
echo "Please run the following command locally:"
echo " dotnet format --include $FILES"
echo ""
exit 1
fi
echo "✅ Code formatting is correct for all changed files"
- name: Skip format check
if: steps.changed-files.outputs.has_cs_files == 'false'
run: |
echo "✅ No C# files to check - skipping format validation"
# Security scan
security-scan:
name: Security Scan
runs-on: ubuntu-latest
if: github.event.pull_request.draft == false
steps:
- name: Checkout
uses: actions/checkout@v6
with:
fetch-depth: 0
- name: Setup .NET 10
uses: actions/setup-dotnet@v4
with:
dotnet-version: '10.0.x'
- name: Install WASM workloads
run: dotnet workload install wasm-experimental wasm-tools
- name: Restore
run: dotnet restore
env:
# Suppress audit during restore — we run our own scan below
DOTNET_NUGET_AUDIT: "false"
- name: Check for vulnerable direct packages
run: |
echo "🔍 Scanning direct packages for vulnerabilities..."
dotnet list package --vulnerable 2>&1 | tee direct-vulnerability-report.txt
if grep -qi "critical\|high" direct-vulnerability-report.txt; then
echo "❌ Critical or High severity vulnerabilities detected in direct dependencies!"
echo "Please update the affected packages before merging."
exit 1
else
echo "✅ No critical or high severity vulnerabilities in direct dependencies"
fi
- name: Check for vulnerable transitive packages
run: |
echo "🔍 Scanning transitive packages for vulnerabilities..."
dotnet list package --vulnerable --include-transitive 2>&1 | tee transitive-vulnerability-report.txt
if grep -qi "critical\|high" transitive-vulnerability-report.txt; then
echo "❌ Critical or High severity vulnerabilities detected in transitive dependencies!"
echo "These usually require updating one or more direct package versions."
echo "Review the report above for details."
# Allow temporary exceptions via ALLOW_TRANSITIVE_VULNS env var.
# Set it to a tracking issue URL to document the exception.
if [ -n "${ALLOW_TRANSITIVE_VULNS:-}" ]; then
if echo "${ALLOW_TRANSITIVE_VULNS}" | grep -Eq '^https?://'; then
echo "⚠️ Temporarily allowing transitive vulnerabilities due to tracked issue:"
echo " ${ALLOW_TRANSITIVE_VULNS}"
echo "Ensure this exception is revisited and removed before the next release."
else
echo "❌ ALLOW_TRANSITIVE_VULNS is set but is not a valid http(s) URL:"
echo " ${ALLOW_TRANSITIVE_VULNS}"
echo "Please set it to a link to the tracking issue or security exception document."
exit 1
fi
else
echo "❌ Failing build because high/critical transitive vulnerabilities were found."
echo "To temporarily allow this (with explicit tracking), set ALLOW_TRANSITIVE_VULNS"
echo "to the URL of the issue or security exception that documents the risk."
exit 1
fi
else
echo "✅ No critical or high severity vulnerabilities in transitive dependencies"
fi
# Template smoke test — scaffold and build each dotnet new template
template-smoke-test:
name: Template Smoke Test
runs-on: ubuntu-latest
if: github.event.pull_request.draft == false
steps:
- name: Checkout
uses: actions/checkout@v6
with:
# Nerdbank.GitVersioning requires full git history to compute versions
fetch-depth: 0
- name: Setup .NET 10
uses: actions/setup-dotnet@v4
with:
dotnet-version: '10.0.x'
- name: Install WASM workloads
run: dotnet workload install wasm-experimental wasm-tools
- name: Pack framework packages to local feed
run: |
LOCAL_FEED=$(pwd)/local-packages
mkdir -p "$LOCAL_FEED"
# Use a version that matches the template's 1.0.0-* floating constraint
PACK_VERSION="1.0.0-ci"
# Pack framework projects in dependency order.
# -p:Version overrides Nerdbank.GitVersioning so ProjectReference
# dependencies also resolve as 1.0.0-ci in the local feed.
for project in \
Picea.Abies/Picea.Abies.csproj \
Picea.Abies.Browser/Picea.Abies.Browser.csproj \
Picea.Abies.Server/Picea.Abies.Server.csproj \
Picea.Abies.Server.Kestrel/Picea.Abies.Server.Kestrel.csproj; do
echo "📦 Packing $project as $PACK_VERSION..."
dotnet pack "$project" -c Release -o "$LOCAL_FEED" \
-p:Version="$PACK_VERSION" \
-p:PackageVersion="$PACK_VERSION"
done
echo ""
echo "📦 Local packages:"
ls -la "$LOCAL_FEED"
env:
DOTNET_NUGET_AUDIT: "false"
- name: Create NuGet config for template builds
run: |
# Create a nuget.config that prioritises the local feed for framework
# packages but falls back to nuget.org for transitive dependencies
# (e.g. Picea, Praefixum, Microsoft.AspNetCore.App).
cat > /tmp/template-nuget.config <<EOF
<?xml version="1.0" encoding="utf-8"?>
<configuration>
<packageSources>
<clear />
<add key="local" value="$(pwd)/local-packages" />
<add key="nuget.org" value="https://api.nuget.org/v3/index.json" />
</packageSources>
</configuration>
EOF
echo "📄 NuGet config:"
cat /tmp/template-nuget.config
- name: Install template pack from source
run: |
dotnet new install Picea.Abies.Templates/
echo ""
echo "📋 Installed templates:"
dotnet new list abies
- name: Detect template-related changes
id: template-changes
run: |
CHANGED=$(git diff --name-only --diff-filter=ACMRT origin/${{ github.event.pull_request.base.ref }}...HEAD | grep '^Picea\.Abies\.Templates/' || true)
if [ -n "$CHANGED" ]; then
echo "has_template_changes=true" >> $GITHUB_OUTPUT
echo "Detected template changes:"
echo "$CHANGED"
else
echo "has_template_changes=false" >> $GITHUB_OUTPUT
echo "No template source changes detected"
fi
- name: Smoke test — abies-browser
run: |
TEMP_DIR=$(mktemp -d)
echo "🧪 Scaffolding abies-browser into $TEMP_DIR..."
dotnet new abies-browser -o "$TEMP_DIR"
echo "🔨 Restoring scaffolded project..."
dotnet restore "$TEMP_DIR" --configfile /tmp/template-nuget.config
echo "🔨 Building scaffolded project..."
dotnet build "$TEMP_DIR" --no-restore
echo "✅ abies-browser template compiles successfully"
rm -rf "$TEMP_DIR"
- name: Smoke test — abies-browser-empty
run: |
TEMP_DIR=$(mktemp -d)
echo "🧪 Scaffolding abies-browser-empty into $TEMP_DIR..."
dotnet new abies-browser-empty -o "$TEMP_DIR"
echo "🔨 Restoring scaffolded project..."
dotnet restore "$TEMP_DIR" --configfile /tmp/template-nuget.config
echo "🔨 Building scaffolded project..."
dotnet build "$TEMP_DIR" --no-restore
echo "✅ abies-browser-empty template compiles successfully"
rm -rf "$TEMP_DIR"
- name: Smoke test — abies-server
run: |
TEMP_DIR=$(mktemp -d)
echo "🧪 Scaffolding abies-server into $TEMP_DIR..."
dotnet new abies-server -o "$TEMP_DIR"
echo "🔨 Restoring scaffolded project..."
dotnet restore "$TEMP_DIR" --configfile /tmp/template-nuget.config
echo "🔨 Building scaffolded project..."
dotnet build "$TEMP_DIR" --no-restore
echo "✅ abies-server template compiles successfully"
rm -rf "$TEMP_DIR"
- name: Security scan scaffolded templates
if: steps.template-changes.outputs.has_template_changes == 'true'
run: |
OUT_ROOT=$(mktemp -d)
echo "Using temp output root: $OUT_ROOT"
dotnet new abies-browser -o "$OUT_ROOT/abies-browser"
dotnet new abies-browser-empty -o "$OUT_ROOT/abies-browser-empty"
dotnet new abies-server -o "$OUT_ROOT/abies-server"
for project in \
"$OUT_ROOT/abies-browser" \
"$OUT_ROOT/abies-browser-empty" \
"$OUT_ROOT/abies-server"; do
dotnet restore "$project" --configfile /tmp/template-nuget.config
dotnet list "$project" package --vulnerable --include-transitive 2>&1 | tee "$project/vulnerability-report.txt"
if grep -qi "critical\|high" "$project/vulnerability-report.txt"; then
echo "❌ High/Critical dependency vulnerabilities in scaffolded project: $project"
exit 1
fi
done
TRIVY_IMAGE=""
for candidate in ghcr.io/aquasecurity/trivy:latest aquasec/trivy:latest; do
if docker pull "$candidate" >/dev/null 2>&1; then
TRIVY_IMAGE="$candidate"
break
fi
done
if [ -z "$TRIVY_IMAGE" ]; then
echo "❌ Could not pull a usable Trivy image."
exit 1
fi
docker run --rm \
-v "$PWD:/src" \
-v "$OUT_ROOT:/scan" \
"$TRIVY_IMAGE" fs \
--severity HIGH,CRITICAL \
--ignore-unfixed \
--scanners vuln,misconfig,secret \
--exit-code 1 \
/scan
python3 -m pip install --user semgrep
export PATH="$HOME/.local/bin:$PATH"
semgrep scan --config .semgrep/rules/template-security.yml "$OUT_ROOT" --error
# Bundle size quality gate
bundle-size-check:
name: Bundle Size Check
runs-on: ubuntu-latest
if: github.event.pull_request.draft == false
steps:
- name: Checkout
uses: actions/checkout@v6
with:
# Nerdbank.GitVersioning requires full git history to compute versions
fetch-depth: 0
- name: Setup .NET 10
uses: actions/setup-dotnet@v4
with:
dotnet-version: '10.0.x'
- name: Install WASM workloads
run: dotnet workload install wasm-experimental wasm-tools
- name: Publish Release (Trimmed)
run: |
dotnet publish Picea.Abies.Conduit.Wasm/Picea.Abies.Conduit.Wasm.csproj \
-c Release
env:
# Suppress NuGet audit — the security-scan job handles vulnerability detection
DOTNET_NUGET_AUDIT: "false"
- name: Measure Bundle Size
id: bundle-size
run: |
# For browser-wasm projects using Microsoft.NET.Sdk, the publish output
# goes to bin/Release/<tfm>/browser-wasm/AppBundle/ (not wwwroot/_framework).
# The _framework directory contains the WASM runtime and assemblies.
PUBLISH_DIR="Picea.Abies.Conduit.Wasm/bin/Release/net10.0/browser-wasm"
# Find the _framework directory in the publish output
FRAMEWORK_DIR=""
for candidate in \
"$PUBLISH_DIR/AppBundle/_framework" \
"$PUBLISH_DIR/publish/wwwroot/_framework" \
"$PUBLISH_DIR/publish/_framework"; do
if [ -d "$candidate" ]; then
FRAMEWORK_DIR="$candidate"
break
fi
done
if [ -z "$FRAMEWORK_DIR" ]; then
echo "❌ Could not find _framework directory in publish output"
echo "Searched in: $PUBLISH_DIR"
echo "Directory contents:"
find "$PUBLISH_DIR" -maxdepth 4 -type d 2>/dev/null || echo " (directory not found)"
exit 1
fi
echo "📂 Found framework directory: $FRAMEWORK_DIR"
# Get total size in bytes
TOTAL_BYTES=$(du -sb "$FRAMEWORK_DIR" | cut -f1)
TOTAL_MB=$((TOTAL_BYTES / 1024 / 1024))
FILE_COUNT=$(find "$FRAMEWORK_DIR" -type f | wc -l)
echo "bundle_size_mb=$TOTAL_MB" >> $GITHUB_OUTPUT
echo "bundle_size_bytes=$TOTAL_BYTES" >> $GITHUB_OUTPUT
echo "file_count=$FILE_COUNT" >> $GITHUB_OUTPUT
echo "📦 WASM Bundle Size Report"
echo "=========================="
echo "Total Size: ${TOTAL_MB}MB ($TOTAL_BYTES bytes)"
echo "File Count: $FILE_COUNT"
echo ""
echo "Largest files:"
find "$FRAMEWORK_DIR" -type f -exec du -h {} + | sort -rh | head -10
- name: Check Bundle Size Limits
run: |
BUNDLE_SIZE=${{ steps.bundle-size.outputs.bundle_size_mb }}
# Validate BUNDLE_SIZE is a valid integer
if ! echo "$BUNDLE_SIZE" | grep -qE '^[0-9]+$'; then
echo "❌ FAILED: Could not determine bundle size (got '$BUNDLE_SIZE')"
exit 1
fi
# Hard limit: 15MB for trimmed Release build
HARD_LIMIT=15
# Soft limit (warning): 10MB
SOFT_LIMIT=10
echo "📊 Bundle Size: ${BUNDLE_SIZE}MB"
echo "🔴 Hard Limit: ${HARD_LIMIT}MB"
echo "🟡 Soft Limit: ${SOFT_LIMIT}MB"
if [ "$BUNDLE_SIZE" -gt "$HARD_LIMIT" ]; then
echo ""
echo "❌ FAILED: Bundle size (${BUNDLE_SIZE}MB) exceeds hard limit (${HARD_LIMIT}MB)"
echo ""
echo "The WASM bundle is too large. Please:"
echo "1. Ensure PublishTrimmed=true is set for Release configuration"
echo "2. Review and remove unnecessary dependencies"
echo "3. Enable InvariantGlobalization if not already done"
echo "4. Consider code splitting if applicable"
exit 1
elif [ "$BUNDLE_SIZE" -gt "$SOFT_LIMIT" ]; then
echo ""
echo "⚠️ WARNING: Bundle size (${BUNDLE_SIZE}MB) exceeds soft limit (${SOFT_LIMIT}MB)"
echo "Consider optimizing bundle size for faster startup times."
else
echo ""
echo "✅ Bundle size is within acceptable limits"
fi
# Lightweight compile check — satisfies the `build` required status check
# (previously provided by cd.yml which now runs push-only).
# Scope: restore + build only. Tests run in cd.yml (push) and e2e.yml (PR).
build:
runs-on: ubuntu-latest
if: github.event.pull_request.draft == false
steps:
- name: Checkout
uses: actions/checkout@v6
with:
fetch-depth: 0
- name: Setup .NET (from global.json)
uses: actions/setup-dotnet@v4
with:
global-json-file: global.json
- name: Install WASM workloads
run: dotnet workload install wasm-experimental wasm-tools
- name: Restore
run: dotnet restore
env:
DOTNET_NUGET_AUDIT: "false"
- name: Build
run: dotnet build --no-restore
# Check for TODO/FIXME without issues
check-todos:
name: Check TODOs
runs-on: ubuntu-latest
if: github.event.pull_request.draft == false
steps:
- name: Checkout
uses: actions/checkout@v6
- name: Check for untracked TODOs
run: |
# Find TODO/FIXME comments without issue references
untracked=$(grep -rIn "TODO\|FIXME" --include="*.cs" --include="*.fs" --exclude-dir=obj --exclude-dir=bin --exclude-dir=.git --binary-files=without-match . | grep -v "#[0-9]" || true)
if [ ! -z "$untracked" ]; then
echo "⚠️ Found TODO/FIXME comments without issue references:"
echo "$untracked"
echo ""
echo "Please either:"
echo "1. Create an issue and reference it (e.g., // TODO #123: description)"
echo "2. Fix the item in this PR"
echo "3. Remove the comment if not needed"
exit 1
else
echo "✅ No untracked TODOs found"
fi
# Summary
pr-validation-summary:
name: PR Validation Summary
runs-on: ubuntu-latest
if: github.event.pull_request.draft == false
needs: [validate-pr-title, validate-pr-description, check-pr-size, lint-check, security-scan, template-smoke-test, bundle-size-check, check-todos, build]
steps:
- name: Check if automated PR
id: check-automated
run: |
if [ "${{ github.event.pull_request.user.login }}" = "dependabot[bot]" ]; then
echo "automated=true" >> $GITHUB_OUTPUT
else
echo "automated=false" >> $GITHUB_OUTPUT
fi
- name: All checks passed
run: |
if [ "${{ steps.check-automated.outputs.automated }}" = "true" ]; then
echo "🤖 Automated PR validation summary"
echo "✅ PR size is reasonable"
echo "✅ Code formatting is correct"
echo "✅ No security vulnerabilities"
echo "✅ Templates scaffold and compile"
echo "✅ Bundle size within limits"
echo "✅ No untracked TODOs"
echo ""
echo "Note: Title and description checks skipped for automated PRs"
else
echo "🎉 All PR validation checks passed!"
echo "✅ PR title follows Conventional Commits"
echo "✅ PR has adequate description"
echo "✅ PR size is reasonable"
echo "✅ Code formatting is correct"
echo "✅ No security vulnerabilities"
echo "✅ Templates scaffold and compile"
echo "✅ Bundle size within limits"
echo "✅ No untracked TODOs"
echo ""
echo "Next steps:"
echo "1. Wait for CD and E2E workflows to complete"
echo "2. Request review from team members"
echo "3. Address any feedback"
echo "4. Merge when approved!"
fi