A.2 - Reusable Quality Gate Workflows¶
Each reusable workflow implements exactly one quality gate. All are invoked via workflow_call, declare their own permissions (reusable workflows do not inherit from callers), and produce structured output via GITHUB_STEP_SUMMARY and (where applicable) PR comments.
A.2.1 - Prettier Formatting Verification¶
Runs prettier --check against Salesforce DX source files. Fails if any file is not formatted.
# .github/workflows/sf-prettier-verify.yml
# Reusable: runs prettier --check and fails if any file is not formatted.
name: Prettier Formatting Verification
on:
workflow_call:
inputs:
node-version:
description: 'Node.js version to use'
required: false
type: string
default: 'lts/*'
source-path:
description: 'Path to the source directory to check formatting'
required: false
type: string
default: 'force-app'
jobs:
prettier-verify:
name: Formatting Verification
runs-on: ubuntu-latest
permissions:
contents: read
steps:
- name: Checkout repository
uses: actions/checkout@v6
- name: Setup Node.js
uses: actions/setup-node@v6
with:
node-version: ${{ inputs.node-version }}
cache: npm
- name: Install dependencies
run: npm ci
- name: Verify formatting
run: npx prettier --check "${{ inputs.source-path }}/**/*.{cls,cmp,component,css,html,js,json,md,page,trigger,xml,yaml,yml}"
- name: Write job summary on failure
if: failure()
run: |
echo "## Formatting Verification Failed" >> $GITHUB_STEP_SUMMARY
echo "Run \`npx prettier --write\` locally and commit the result." >> $GITHUB_STEP_SUMMARY
A.2.2 - Secret Scanning¶
Placeholder workflow. Replace the run: step with the organization's chosen secret scanning tool. The configuration file should be centralized in the configuration repository to ensure consistent pattern detection across all project repositories.
# .github/workflows/sf-secret-scan.yml
# Reusable: runs secret scanning against changed files.
# Replace the placeholder run step with your organization's chosen tool
# (e.g. Gitleaks, TruffleHog, GitHub Advanced Security).
name: Secret Scanning
on:
workflow_call:
jobs:
secret-scan:
name: Secret Scanning
runs-on: ubuntu-latest
permissions:
contents: read
steps:
- name: Checkout repository
uses: actions/checkout@v6
with:
fetch-depth: 0
- name: Run secret scanning
run: |
echo "Configure secret scanning tool here"
# Replace with the secret scanning tool of your choice.
# Example tools: Gitleaks (https://github.com/gitleaks/gitleaks),
# TruffleHog (https://github.com/trufflesecurity/trufflehog),
# or GitHub Advanced Security native secret scanning.
# Ensure the tool references the centralised configuration
# file from the template repository for consistent pattern detection.
exit 0
A.2.3 - Semantic PR Title Check¶
Enforces Conventional Commits format on PR titles via amannn/action-semantic-pull-request. Because squash merge is the only permitted merge strategy on protected branches, the PR title becomes the commit message on the target branch: enforcing title format at PR open time is the enforcement point for the entire commit history discipline.
# .github/workflows/sf-semver-check.yml
# Reusable: enforces Conventional Commits on PR title.
name: SemVer / Conventional Commits Check
on:
workflow_call:
jobs:
semver-check:
name: PR Title Convention
runs-on: ubuntu-latest
steps:
- name: Validate PR title
uses: amannn/action-semantic-pull-request@v6
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with:
types: |
feat
fix
test
perf
chore
refactor
style
docs
revert
deps
build
ci
requireScope: false
A.2.4 - Destructive Change Detection¶
Detects destructiveChanges.xml in the PR diff, labels the PR with destructive-change, and (in combination with branch protection rules) blocks auto-merge when present. The presence of destructive metadata requires explicit human approval even when all automated gates pass.
# .github/workflows/sf-destructive-check.yml
# Reusable: detects destructiveChanges.xml in PR diff, labels the PR,
# and blocks auto-merge when destructive changes are present.
name: Destructive Change Detection
on:
workflow_call:
outputs:
has-destructive:
description: 'Whether destructive changes were detected'
value: ${{ jobs.destructive-check.outputs.has-destructive }}
jobs:
destructive-check:
name: Destructive Change Detection
runs-on: ubuntu-latest
permissions:
contents: read
pull-requests: write
issues: write
outputs:
has-destructive: ${{ steps.check.outputs.has-destructive }}
steps:
- name: Checkout repository
uses: actions/checkout@v6
with:
fetch-depth: 0
- name: Check for destructive changes
id: check
run: |
if git diff --name-only origin/${{ github.base_ref }}...HEAD \
| grep -qE 'destructiveChanges(Pre)?\.xml'; then
echo "has-destructive=true" >> $GITHUB_OUTPUT
echo "::warning::Destructive changes detected in this PR."
else
echo "has-destructive=false" >> $GITHUB_OUTPUT
fi
- name: Label PR as destructive
if: steps.check.outputs.has-destructive == 'true'
uses: actions/github-script@v9
with:
script: |
await github.rest.issues.addLabels({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: context.issue.number,
labels: ['destructive-change']
});
A.2.5 - Generate Delta Package¶
Generates an sfdx-git-delta deployment package from the PR diff and uploads it as a workflow artifact. All downstream PR jobs (code analysis, Apex tests, Jest tests, deployment validation) download this artifact by name rather than regenerating the delta independently. The artifact-uploaded output is the flag that downstream jobs use to skip cleanly when the PR contains no deployable metadata.
# .github/workflows/sf-generate-delta.yml
# Reusable: generates a sfdx-git-delta deployment package from the PR diff
# and uploads it as a workflow artifact for downstream jobs to consume.
#
# All org-connected PR jobs (code-analyzer in pr mode, apex-tests-pr,
# jest-tests, validate-deployment) download this artifact by name rather
# than regenerating it independently.
name: Generate Delta Package
on:
workflow_call:
inputs:
source-dir:
description: 'Source directory passed to --source-dir. Omit to let sfdx-git-delta infer from sfdx-project.json.'
required: false
type: string
default: 'force-app'
artifact-name:
description: 'Name for the uploaded artifact. Must match the artifact-name input on all downstream jobs.'
required: false
type: string
default: 'delta-package'
artifact-retention-days:
description: 'Days to retain the artifact. 1 is sufficient for intra-workflow use.'
required: false
type: string
default: '1'
outputs:
artifact-uploaded:
description: 'Whether the delta artifact was successfully created and uploaded (true/false)'
value: ${{ jobs.generate-delta.outputs.artifact-uploaded }}
jobs:
generate-delta:
name: Generate Delta Package
runs-on: ubuntu-latest
outputs:
artifact-uploaded: ${{ steps.set-output.outputs.uploaded }}
permissions:
contents: read
steps:
- name: Checkout repository
uses: actions/checkout@v6
with:
fetch-depth: 0
- name: Check for force-app changes
id: check-changes
run: |
CHANGED=$(git diff --name-only origin/${{ github.base_ref }}...HEAD -- ${{ inputs.source-dir }} | wc -l | tr -d ' ')
echo "has-changes=$( [ "$CHANGED" -gt 0 ] && echo true || echo false )" >> $GITHUB_OUTPUT
- name: Set up Salesforce environment
if: steps.check-changes.outputs.has-changes == 'true'
uses: ./.github/actions/setup-sf
with:
install-code-analyzer: 'false'
- name: Generate delta package
if: steps.check-changes.outputs.has-changes == 'true'
run: |
mkdir -p delta
sf sgd source delta \
--from origin/${{ github.base_ref }} \
--to HEAD \
--output delta \
--generate-delta \
--source-dir ${{ inputs.source-dir }}
- name: Upload delta as artifact
id: upload
if: steps.check-changes.outputs.has-changes == 'true'
uses: actions/upload-artifact@v7
with:
name: ${{ inputs.artifact-name }}
path: delta/
retention-days: ${{ inputs.artifact-retention-days }}
- name: Set upload output
id: set-output
run: |
if [ "${{ steps.upload.outcome }}" == "success" ]; then
echo "uploaded=true" >> $GITHUB_OUTPUT
else
echo "uploaded=false" >> $GITHUB_OUTPUT
fi
A.2.6 - Static Code Analysis¶
Runs Salesforce Code Analyzer. Supports two modes: pr (scans the delta artifact, uploads SARIF to Code Scanning, posts a two-table PR comment, fails on blocking violations) and ci (scans the configured workspace, writes job summary only, never fails). Both modes install the CLI, Code Analyzer plugin, Java, and Python via setup-sf.
# .github/workflows/sf-code-analyzer.yml
# Reusable: runs sf code-analyzer run against either the PR delta package
# or a configured workspace path.
#
# mode: pr
# - Downloads the named delta artifact
# - Scans --workspace delta
# - Uploads SARIF to GitHub Code Scanning
# - Posts a structured PR comment (blocking vs. advisory two-table pattern)
# - Fails on violations at or above severity-threshold
#
# mode: ci
# - Scans --workspace <workspace input> (default: force-app)
# - Writes job summary with violation counts
# - Never fails (advisory only - does not block pushes)
name: Code Analyzer
on:
workflow_call:
inputs:
mode:
description: >
"pr" to scan the delta artifact and post a PR comment.
"ci" to scan the configured workspace and write a job summary only.
required: false
type: string
default: 'ci'
workspace:
description: 'Workspace path to analyze (ci mode only; pr mode always uses the delta artifact)'
required: false
type: string
default: 'force-app'
artifact-name:
description: 'Name of the delta package artifact to download (pr mode only)'
required: false
type: string
default: 'delta-package'
rule-selector:
description: 'Code Analyzer rule selector expression'
required: false
type: string
default: 'all:(Recommended,Security)'
severity-threshold:
description: 'Severity level (1, 2, 3, 4, or 5) at or above which the workflow fails (1=Critical, 2=High, 3=Moderate)'
required: false
type: string
default: '2'
jobs:
code-analyzer:
name: Static Code Analysis
runs-on: ubuntu-latest
permissions:
contents: read
security-events: write # for SARIF upload (pr mode)
pull-requests: write # for PR comment (pr mode)
steps:
- name: Checkout repository
uses: actions/checkout@v6
- name: Set up Salesforce environment
uses: ./.github/actions/setup-sf
with:
install-sfdx-git-delta: 'false'
# pr mode: scan the uploaded delta artifact instead of the full workspace
- name: Download delta package
id: download-delta
if: inputs.mode == 'pr'
continue-on-error: true
uses: actions/download-artifact@v8
with:
name: ${{ inputs.artifact-name }}
path: delta/
- name: Resolve workspace path
id: workspace
run: |
if [ "${{ inputs.mode }}" == "pr" ] && [ "${{ steps.download-delta.outcome }}" != "success" ]; then
echo "No delta artifact found — skipping code analysis."
echo "path=" >> $GITHUB_OUTPUT
echo "skip=true" >> $GITHUB_OUTPUT
elif [ "${{ inputs.mode }}" == "pr" ]; then
echo "path=delta" >> $GITHUB_OUTPUT
echo "skip=false" >> $GITHUB_OUTPUT
else
echo "path=${{ inputs.workspace }}" >> $GITHUB_OUTPUT
echo "skip=false" >> $GITHUB_OUTPUT
fi
- name: Run Code Analyzer
if: steps.workspace.outputs.skip != 'true'
id: scan
continue-on-error: true
run: |
sf code-analyzer run \
--workspace ${{ steps.workspace.outputs.path }} \
--rule-selector "${{ inputs.rule-selector }}" \
--severity-threshold ${{ inputs.severity-threshold }} \
--output-file results.json \
--output-file results.sarif
# pr mode only: upload findings to GitHub Code Scanning (Security tab)
- name: Upload SARIF to GitHub Code Scanning
if: inputs.mode == 'pr' && steps.workspace.outputs.skip != 'true'
uses: github/codeql-action/upload-sarif@v4
with:
sarif_file: results.sarif
# Parse results and write detailed job summary (both modes)
- name: Generate violation summary
if: always() && steps.workspace.outputs.skip != 'true' && hashFiles('results.json') != ''
id: report
uses: ./.github/actions/sf-code-analyzer-report
with:
results-path: results.json
severity-cutoff: ${{ inputs.severity-threshold }}
# pr mode: build structured two-table comment body (blocking vs. advisory)
- name: Build PR comment body
if: always() && inputs.mode == 'pr' && steps.report.outcome == 'success'
id: comment-body
env:
THRESHOLD: ${{ inputs.severity-threshold }}
HIGH_MD: ${{ steps.report.outputs.high-priority-markdown }}
LOW_MD: ${{ steps.report.outputs.low-priority-markdown }}
run: |
ADVISORY_MIN=$((THRESHOLD + 1))
{
echo "body<<EOF_BODY"
echo "## Code Analyzer Results"
echo ""
echo "**Blocking Violations (Severity 1-${THRESHOLD})**"
echo ""
echo "$HIGH_MD"
echo ""
echo "**Advisory Violations (Severity ${ADVISORY_MIN}-5)**"
echo ""
echo "$LOW_MD"
echo ""
echo "_Scan run against delta package._"
echo "EOF_BODY"
} >> "$GITHUB_OUTPUT"
# pr mode: post the PR comment
- name: Post PR comment
if: always() && inputs.mode == 'pr' && steps.comment-body.outcome == 'success'
uses: ./.github/actions/post-pr-comment
with:
token: ${{ secrets.GITHUB_TOKEN }}
marker: '## Code Analyzer Results'
body: ${{ steps.comment-body.outputs.body }}
# pr mode only: fail if blocking violations found
- name: Fail if blocking violations found (PR)
if: inputs.mode == 'pr' && steps.workspace.outputs.skip != 'true'
run: |
THRESHOLD=${{ inputs.severity-threshold }}
BLOCKING=$(jq --argjson t "$THRESHOLD" '[.violations[] | select(.severity <= $t)] | length' results.json)
if [ "$BLOCKING" -gt 0 ]; then
echo "::error::$BLOCKING blocking violation(s) detected."
exit 1
fi
A.2.7 - LWC Jest Tests¶
Runs LWC Jest tests with coverage and uploads to Codecov. Downloads the delta artifact and skips execution when no LWC changes are present. The skip-delta-check input bypasses the delta filter: used by the push baseline (ci.yml) where there is no PR diff to reason about.
# .github/workflows/sf-jest-tests.yml
# Reusable: runs LWC Jest tests with coverage, uploads results to Codecov.
# Skips execution when no LWC changes are present in the delta package.
name: LWC Jest Tests
on:
workflow_call:
inputs:
node-version:
description: 'Node.js version'
required: false
type: string
default: 'lts/*'
skip-delta-check:
description: 'Set to true to run Jest regardless of delta content'
required: false
type: boolean
default: false
artifact-name:
description: 'Name of the delta package artifact to download (pr mode only)'
required: false
type: string
default: 'delta-package'
secrets:
CODECOV_TOKEN:
description: 'Codecov upload token (required for private repositories)'
required: false
jobs:
jest-tests:
name: LWC Jest Tests
runs-on: ubuntu-latest
permissions:
contents: read
steps:
- name: Checkout repository
uses: actions/checkout@v6
- name: Download delta package
id: download-delta
if: ${{ !inputs.skip-delta-check }}
continue-on-error: true
uses: actions/download-artifact@v8
with:
name: ${{ inputs.artifact-name }}
path: delta/
- name: Check for LWC changes in delta
id: check-lwc
run: |
if [ "${{ inputs.skip-delta-check }}" == "true" ]; then
echo "run-jest=true" >> $GITHUB_OUTPUT
elif [ "${{ steps.download-delta.outcome }}" != "success" ]; then
echo "No delta artifact found — skipping Jest tests."
echo "run-jest=false" >> $GITHUB_OUTPUT
elif grep -q '<types>' delta/package/package.xml 2>/dev/null; then
echo "--- Analyzing PR content for LWC ---"
pipx install yq >/dev/null
LWCCOUNT=$(cat delta/package/package.xml | xq -j | jq -r '.Package.types | [.] | flatten | map(select(.name | IN("LightningWebComponent"))) | .[] | .members | [.] | flatten | length')
if [ "$LWCCOUNT" -eq 0 ]; then
echo "run-jest=false" >> $GITHUB_OUTPUT
else
echo "run-jest=true" >> $GITHUB_OUTPUT
fi
else
echo "run-jest=false" >> $GITHUB_OUTPUT
fi
- name: Setup Node.js
if: steps.check-lwc.outputs.run-jest == 'true'
uses: actions/setup-node@v6
with:
node-version: ${{ inputs.node-version }}
cache: npm
- name: Install dependencies
if: steps.check-lwc.outputs.run-jest == 'true'
run: npm ci
- name: Run LWC Jest tests with coverage
if: steps.check-lwc.outputs.run-jest == 'true'
run: npm run test:unit:coverage
- name: Upload code coverage for LWC to Codecov
if: steps.check-lwc.outputs.run-jest == 'true'
uses: codecov/codecov-action@v6
with:
token: ${{ secrets.CODECOV_TOKEN }}
flags: LWC
A.2.8 - Apex Tests (PR-Scoped)¶
Runs Apex tests scoped to the PR delta. Uses RunSpecifiedTests when changed Apex classes are detected, falls back to RunLocalTests otherwise. Parses results into a structured PR comment via the apex-test-report composite action and uploads coverage to Codecov.
The apex-list composite action enumerates all ApexClass and ApexTrigger names from the CI org so that the report can surface classes and triggers absent from the coverage report (i.e. never exercised by any test). This list is the distinguishing signal between "low coverage" and "no coverage at all".
# .github/workflows/sf-apex-tests-pr.yml
# Reusable: runs Apex tests scoped to the PR delta.
# Uses RunSpecifiedTests when changed Apex classes are detected,
# falls back to RunLocalTests otherwise. Posts structured results as a PR comment.
name: Apex Tests (PR)
on:
workflow_call:
inputs:
coverage-threshold:
description: 'Minimum Apex coverage percentage to flag in PR comment'
required: false
type: string
default: '80'
artifact-name:
description: 'Name of the delta package artifact to download (pr mode only)'
required: false
type: string
default: 'delta-package'
secrets:
SFDX_AUTH_URL:
required: true
CODECOV_TOKEN:
required: false
jobs:
apex-tests:
name: Apex Tests
runs-on: ubuntu-latest
permissions:
contents: read
pull-requests: write
env:
TEST_RESULTS_DIR: test-results
ORG_ALIAS: ci-org
HAS_CODECOV: ${{ secrets.CODECOV_TOKEN != '' }}
steps:
- name: Checkout repository
uses: actions/checkout@v6
- name: Set up Salesforce environment
uses: ./.github/actions/setup-sf
with:
install-code-analyzer: 'false'
install-sfdx-git-delta: 'false'
- name: Authenticate to CI sandbox (SFDX URL)
uses: ./.github/actions/sf-auth-sfdx-url
with:
sfdx-url: ${{ secrets.SFDX_AUTH_URL }}
alias: ${{ env.ORG_ALIAS }}
- name: Download delta package
id: download-delta
continue-on-error: true
uses: actions/download-artifact@v8
with:
name: ${{ inputs.artifact-name }}
path: delta/
- name: Determine test level from delta
id: check-apex
run: |
if [ "${{ steps.download-delta.outcome }}" != "success" ]; then
echo "No delta artifact found — skipping Apex tests."
echo "test-level=skip" >> $GITHUB_OUTPUT
echo "classes=" >> $GITHUB_OUTPUT
elif grep -q '<types>' delta/package/package.xml 2>/dev/null; then
echo "--- Analyzing PR content for Apex Classes ---"
pipx install yq >/dev/null
CLASSES=$(cat delta/package/package.xml | xq -j | jq -r '.Package.types | [.] | flatten \
| map(select(.name | IN("ApexClass"))) | .[] | .members | [.] \
| flatten | map(select(. | index("*") | not)) | unique | join(" --class-names ")')
if [ -z "$CLASSES" ]; then
echo "test-level=RunLocalTests" >> $GITHUB_OUTPUT
echo "classes=" >> $GITHUB_OUTPUT
else
echo "test-level=RunSpecifiedTests" >> $GITHUB_OUTPUT
echo "classes=$CLASSES" >> $GITHUB_OUTPUT
fi
else
echo "---- No changes to test ----"
echo "test-level=skip" >> $GITHUB_OUTPUT
echo "classes=" >> $GITHUB_OUTPUT
fi
- name: Run Apex tests (RunSpecifiedTests)
if: steps.check-apex.outputs.test-level == 'RunSpecifiedTests'
continue-on-error: true
id: run-specified
run: |
sf apex run test \
--target-org "$ORG_ALIAS" \
--test-level RunSpecifiedTests \
--class-names ${{ steps.check-apex.outputs.classes }} \
--result-format json \
--code-coverage \
--output-dir "$TEST_RESULTS_DIR" \
--wait 60
- name: Run Apex tests (RunLocalTests fallback)
if: steps.check-apex.outputs.test-level == 'RunLocalTests'
continue-on-error: true
id: run-local
run: |
sf apex run test \
--target-org "$ORG_ALIAS" \
--test-level RunLocalTests \
--result-format json \
--code-coverage \
--output-dir "$TEST_RESULTS_DIR" \
--wait 60
- name: List Apex classes and triggers from org
if: always() && (steps.run-specified.outcome == 'success' || steps.run-specified.outcome == 'failure' || steps.run-local.outcome == 'success' || steps.run-local.outcome == 'failure') && steps.check-apex.outputs.test-level != 'skip'
id: org-apex
continue-on-error: true
uses: ./.github/actions/apex-list
with:
target-org: ${{ env.ORG_ALIAS }}
output-file: org-apex.txt
- name: Parse test results and write summary
if: always() && (steps.run-specified.outcome == 'success' || steps.run-specified.outcome == 'failure' || steps.run-local.outcome == 'success' || steps.run-local.outcome == 'failure') && steps.check-apex.outputs.test-level != 'skip'
id: report
uses: ./.github/actions/apex-test-report
with:
results-dir: ${{ env.TEST_RESULTS_DIR }}
coverage-threshold: ${{ inputs.coverage-threshold }}
org-apex-file: ${{ steps.org-apex.outputs.file }}
- name: Build PR comment body
if: always() && steps.report.outcome == 'success'
id: comment-body
env:
OUTCOME: ${{ steps.report.outputs.outcome }}
PASSING: ${{ steps.report.outputs.passing }}
FAILING: ${{ steps.report.outputs.failing }}
COVERAGE: ${{ steps.report.outputs.coverage }}
FAILING_MD: ${{ steps.report.outputs.failing-markdown }}
LOW_COV_MD: ${{ steps.report.outputs.low-coverage-markdown }}
UNTESTED_MD: ${{ steps.report.outputs.untested-markdown }}
THRESHOLD: ${{ inputs.coverage-threshold }}
run: |
STATUS="PASSED"
if [ "$OUTCOME" != "Passed" ]; then STATUS="FAILED"; fi
{
echo "body<<EOF_BODY"
echo "## Apex Test Results - $STATUS"
echo ""
echo "**Outcome:** $OUTCOME | **Passed:** $PASSING | **Failed:** $FAILING | **Coverage:** $COVERAGE"
echo ""
if [ -n "$FAILING_MD" ]; then
echo "### Failing Tests"
echo ""
echo "$FAILING_MD"
echo ""
fi
if [ -n "$LOW_COV_MD" ]; then
echo "### Classes Below ${THRESHOLD}% Coverage"
echo ""
echo "$LOW_COV_MD"
echo ""
fi
if [ -n "$UNTESTED_MD" ]; then
echo "### Untested Classes and Triggers"
echo ""
echo "$UNTESTED_MD"
fi
echo "EOF_BODY"
} >> "$GITHUB_OUTPUT"
- name: Post PR comment
if: always() && steps.comment-body.outcome == 'success'
uses: ./.github/actions/post-pr-comment
with:
token: ${{ secrets.GITHUB_TOKEN }}
marker: '## Apex Test Results'
body: ${{ steps.comment-body.outputs.body }}
- name: Upload coverage to CodeCov
if: (steps.run-specified.outcome == 'success' || steps.run-specified.outcome == 'failure' || steps.run-local.outcome == 'success' || steps.run-local.outcome == 'failure') && env.HAS_CODECOV == 'true' && steps.check-apex.outputs.test-level != 'skip'
uses: codecov/codecov-action@v6
with:
token: ${{ secrets.CODECOV_TOKEN }}
files: ${{ env.TEST_RESULTS_DIR }}/test-result-codecoverage.json
flags: apex
name: apex-coverage-${{ github.event.pull_request.number }}
fail_ci_if_error: false
verbose: false
- name: Fail if tests did not pass
if: |
steps.run-specified.outcome == 'failure' ||
steps.run-local.outcome == 'failure'
run: |
echo "::error::Apex test execution failed."
exit 1
A.2.9 - Apex Tests (Full Org)¶
Runs full-org RunLocalTests. Creates a GitHub issue on failure. Intended for scheduled surveillance, not PR-time gating.
# .github/workflows/sf-apex-tests.yml
# Reusable: runs full-org Apex tests (RunLocalTests).
# Intended for scheduled/nightly surveillance, not PR-time gating.
# Creates a GitHub issue on failure.
name: Apex Tests (Full Org)
on:
workflow_call:
inputs:
wait-minutes:
description: 'Minutes to wait for test run to complete'
required: false
type: string
default: '120'
secrets:
SFDX_AUTH_URL:
required: true
CODECOV_TOKEN:
required: false
jobs:
full-org-tests:
name: Full-Org Apex Test Run
runs-on: ubuntu-latest
permissions:
contents: read
issues: write
env:
TEST_RESULTS_DIR: test-results
ORG_ALIAS: monitor-org
HAS_CODECOV: ${{ secrets.CODECOV_TOKEN != '' }}
steps:
- name: Checkout repository
uses: actions/checkout@v6
- name: Set up Salesforce environment
uses: ./.github/actions/setup-sf
with:
install-code-analyzer: 'false'
install-sfdx-git-delta: 'false'
- name: Authenticate to monitoring org (SFDX URL)
uses: ./.github/actions/sf-auth-sfdx-url
with:
sfdx-url: ${{ secrets.SFDX_AUTH_URL }}
alias: ${{ env.ORG_ALIAS }}
- name: Run full org Apex tests
id: run-tests
continue-on-error: true
run: |
sf apex run test \
--target-org "$ORG_ALIAS" \
--test-level RunLocalTests \
--result-format json \
--code-coverage \
--output-dir "$TEST_RESULTS_DIR" \
--wait ${{ inputs.wait-minutes }}
- name: List Apex classes and triggers from org
if: always() && (steps.run-tests.outcome == 'success' || steps.run-tests.outcome == 'failure')
id: org-apex
continue-on-error: true
uses: ./.github/actions/apex-list
with:
target-org: ${{ env.ORG_ALIAS }}
output-file: org-apex.txt
- name: Parse test results and write summary
if: always() && (steps.run-tests.outcome == 'success' || steps.run-tests.outcome == 'failure')
uses: ./.github/actions/apex-test-report
with:
results-dir: ${{ env.TEST_RESULTS_DIR }}
org-apex-file: ${{ steps.org-apex.outputs.file }}
- name: Upload coverage to CodeCov
if: (steps.run-tests.outcome == 'success' || steps.run-tests.outcome == 'failure') && env.HAS_CODECOV == 'true'
uses: codecov/codecov-action@v6
with:
token: ${{ secrets.CODECOV_TOKEN }}
files: ${{ env.TEST_RESULTS_DIR }}/test-result-codecoverage.json
flags: apex
name: apex-coverage-full-org
fail_ci_if_error: false
verbose: false
- name: Create GitHub issue on failure
if: steps.run-tests.outcome == 'failure'
uses: actions/github-script@v9
with:
script: |
const date = new Date().toISOString().split('T')[0];
await github.rest.issues.create({
owner: context.repo.owner,
repo: context.repo.repo,
title: `[Test Failure] Full-org Apex test run failed - ${date}`,
body: `## Full-Org Apex Test Failure\n\nFailures detected that were not caught by PR-time scoped test execution.\n\nReview the [workflow run](https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }}) for details.\n\n_Detected by nightly surveillance on ${date}._`,
labels: ['test-failure', 'needs-triage']
});
A.2.10 - Apex Compilation Check¶
Retrieves all ApexClass and ApexTrigger metadata from the monitoring org and runs a dry-run deployment with NoTestRun to verify org-wide compilation. Unlike the delta-scoped validation in A.2.11, this check covers the entire org and catches compilation failures not surfaced by PR-scoped gates. Creates a GitHub issue on failure.
# .github/workflows/sf-apex-compilation.yml
# Reusable: verifies that all Apex classes and triggers in the org compile
# cleanly by running a dry-run deployment with NoTestRun.
#
# Unlike sf-validate-deployment.yml (which validates a delta package against
# a manifest), this workflow deploys all ApexClass and ApexTrigger metadata
# types to catch org-wide compilation failures not caught by PR-scoped gates.
# Intended for scheduled/nightly surveillance — not PR-time gating.
# Creates a GitHub issue on failure.
name: Apex Compilation Check
on:
workflow_call:
inputs:
wait-minutes:
description: 'Minutes to wait for the compilation dry-run to complete'
required: false
type: string
default: '60'
secrets:
SFDX_AUTH_URL:
required: true
jobs:
apex-compilation:
name: Apex Compilation Check
runs-on: ubuntu-latest
permissions:
contents: read
issues: write
steps:
- name: Checkout repository
uses: actions/checkout@v6
- name: Set up Salesforce environment
uses: ./.github/actions/setup-sf
with:
install-code-analyzer: 'false'
install-sfdx-git-delta: 'false'
- name: Authenticate to monitoring org (SFDX URL)
uses: ./.github/actions/sf-auth-sfdx-url
with:
sfdx-url: ${{ secrets.SFDX_AUTH_URL }}
alias: monitor-org
- name: Retrieve all Apex from org
run: |
# Pull every ApexClass and ApexTrigger from the org into the default
# package directory so the dry-run deploy below covers the full org,
# not just what happens to be checked in.
sf project retrieve start \
--target-org monitor-org \
--metadata ApexClass ApexTrigger \
--wait ${{ inputs.wait-minutes }}
- name: Run Apex compilation check
id: compile
continue-on-error: true
run: |
# Dry-run deployment of all Apex compiles every class and trigger
# without executing tests, confirming compilation integrity.
sf project deploy start \
--dry-run \
--target-org monitor-org \
--metadata ApexClass ApexTrigger \
--test-level NoTestRun \
--ignore-conflicts \
--wait ${{ inputs.wait-minutes }} \
--json > compile-results.json
- name: Write job summary
if: always()
run: |
echo "## Apex Compilation Check" >> $GITHUB_STEP_SUMMARY
STATUS=$(jq -r '.status' compile-results.json 2>/dev/null || echo "unknown")
if [ "$STATUS" == "0" ]; then
echo "All Apex compiled successfully." >> $GITHUB_STEP_SUMMARY
else
echo "Compilation failures detected." >> $GITHUB_STEP_SUMMARY
jq -r '.result.details.componentFailures[]? | "- \(.fullName): \(.problem)"' \
compile-results.json >> $GITHUB_STEP_SUMMARY 2>/dev/null || true
fi
- name: Create GitHub issue on compilation failure
if: steps.compile.outcome == 'failure'
uses: actions/github-script@v9
with:
script: |
const date = new Date().toISOString().split('T')[0];
await github.rest.issues.create({
owner: context.repo.owner,
repo: context.repo.repo,
title: `[Compilation Failure] Apex compilation check failed - ${date}`,
body: `## Apex Compilation Failure\n\nCompilation failures detected that were not caught at PR time.\n\nReview the [workflow run](https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }}) for details.\n\n_Detected by nightly surveillance on ${date}._`,
labels: ['compilation-failure', 'needs-triage']
});
A.2.11 - Metadata Validation¶
Runs sf project deploy start --dry-run against the target org using the delta package manifest. Guards against empty deltas (skips cleanly rather than erroring against an empty manifest). Posts component failure details as a PR comment.
# .github/workflows/sf-validate-deployment.yml
# Reusable: runs sf project deploy start --dry-run against the target org.
# Validates that the delta package is deployable in context.
# Posts component failure details as a PR comment.
name: Metadata Validation
on:
workflow_call:
inputs:
test-level:
description: 'Test level for validation run'
required: false
type: string
default: 'RunLocalTests'
wait-minutes:
description: 'Minutes to wait for validation to complete'
required: false
type: string
default: '60'
artifact-name:
description: 'Name of the delta package artifact to download (pr mode only)'
required: false
type: string
default: 'delta-package'
secrets:
SFDX_AUTH_URL_VALIDATION:
required: true
jobs:
validate-deploy:
name: Metadata Validation
runs-on: ubuntu-latest
permissions:
contents: read
pull-requests: write
steps:
- name: Checkout repository
uses: actions/checkout@v6
- name: Set up Salesforce environment
uses: ./.github/actions/setup-sf
with:
install-code-analyzer: 'false'
install-sfdx-git-delta: 'false'
- name: Authenticate to validation org (SFDX URL)
uses: ./.github/actions/sf-auth-sfdx-url
with:
sfdx-url: ${{ secrets.SFDX_AUTH_URL_VALIDATION }}
alias: validation-org
- name: Download delta package
id: download-delta
uses: actions/download-artifact@v8
continue-on-error: true
with:
name: ${{ inputs.artifact-name }}
path: delta/
- name: Check for deployable metadata
id: check-delta
run: |
if [ "${{ steps.download-delta.outcome }}" != "success" ] || [ ! -f delta/package/package.xml ]; then
echo "has-metadata=false" >> $GITHUB_OUTPUT
echo "---- No delta package artifact found - exiting gracefully ----"
elif grep -q '<types>' delta/package/package.xml 2>/dev/null; then
echo "has-metadata=true" >> $GITHUB_OUTPUT
echo "--- Deployable metadata detected - proceeding with validation ---"
else
echo "has-metadata=false" >> $GITHUB_OUTPUT
echo "---- No deployable metadata in delta - skipping validation ----"
fi
- name: Validate delta deployment
if: steps.check-delta.outputs.has-metadata == 'true'
run: |
sf project deploy start \
--dry-run \
--target-org validation-org \
--manifest delta/package/package.xml \
--test-level ${{ inputs.test-level }} \
--wait ${{ inputs.wait-minutes }} \
--json > validation-results.json || true
- name: Build PR comment body
if: steps.check-delta.outputs.has-metadata == 'true' && hashFiles('validation-results.json') != ''
id: comment-body
run: |
STATUS_CODE=$(jq -r '.status' validation-results.json)
if [ "$STATUS_CODE" = "0" ]; then
STATUS="PASSED"
STATUS_LABEL="Passed"
else
STATUS="FAILED"
STATUS_LABEL="Failed"
fi
FAILURE_TABLE=$(jq -r '
.result.details.componentFailures
| if type == "array" and length > 0 then
"### Component Failures\n\n| Component | Type | Problem |\n|---|---|---|\n"
+ (map("| `\(.fullName)` | \(.componentType) | \(.problem) |") | join("\n"))
else "" end
' validation-results.json)
{
echo "body<<EOF_BODY"
echo "## Metadata Validation - $STATUS"
echo ""
echo "**Status:** $STATUS_LABEL"
echo ""
if [ -n "$FAILURE_TABLE" ]; then
echo "$FAILURE_TABLE"
fi
echo "EOF_BODY"
} >> "$GITHUB_OUTPUT"
- name: Post PR comment
if: steps.comment-body.outcome == 'success'
uses: ./.github/actions/post-pr-comment
with:
token: ${{ secrets.GITHUB_TOKEN }}
marker: '## Metadata Validation'
body: ${{ steps.comment-body.outputs.body }}
- name: Fail if validation did not pass
if: steps.check-delta.outputs.has-metadata == 'true'
run: |
if [ ! -f validation-results.json ]; then
echo "::error::validation-results.json not found - deployment validation did not produce a results file."
exit 1
fi
STATUS=$(jq -r '.status' validation-results.json)
if [ "$STATUS" != "0" ]; then
echo "::error::Metadata validation failed."
exit 1
fi
A.2.12 - Metadata Drift Detection¶
Retrieves current org metadata via sf project retrieve start, compares against the repository state using git diff, and creates a GitHub issue when drift is detected. The git add -N step is critical: sf project retrieve start writes files as untracked, which git diff ignores by default; git add -N registers them with intent-to-add so new files surface in the diff alongside content changes.
# .github/workflows/sf-drift-detection.yml
# Reusable: compares org state to repository state via sf project retrieve.
# Creates a GitHub issue when drift is detected.
# Intended for scheduled/nightly surveillance workflows.
name: Metadata Drift Detection
on:
workflow_call:
inputs:
manifest-path:
description: 'Path to the package.xml manifest for retrieval'
required: false
type: string
default: 'manifest/package.xml'
secrets:
SFDX_AUTH_URL:
required: true
outputs:
drift-detected:
description: 'Whether drift was detected'
value: ${{ jobs.drift-detection.outputs.drift-detected }}
jobs:
drift-detection:
name: Metadata Drift Detection
runs-on: ubuntu-latest
permissions:
issues: write
contents: read
outputs:
drift-detected: ${{ steps.drift.outputs.drift-detected }}
steps:
- name: Checkout repository
uses: actions/checkout@v6
with:
fetch-depth: 0
- name: Set up Salesforce environment
uses: ./.github/actions/setup-sf
with:
install-code-analyzer: 'false'
install-sfdx-git-delta: 'false'
- name: Authenticate to monitoring org (SFDX URL)
uses: ./.github/actions/sf-auth-sfdx-url
with:
sfdx-url: ${{ secrets.SFDX_AUTH_URL }}
alias: monitor-org
- name: Retrieve current org metadata
run: |
mkdir -p org-state
sf project retrieve start \
--target-org monitor-org \
--manifest ${{ inputs.manifest-path }} \
--output-dir org-state \
--wait 30
- name: Compare org state to repository
id: drift
run: |
# `sf project retrieve start` writes files as untracked — `git diff`
# ignores those. `git add -N` registers them with intent-to-add so
# the subsequent diff surfaces new files alongside content changes.
git add -N org-state/ 2>/dev/null || true
DIFF=$(git diff --stat HEAD -- org-state/ 2>/dev/null || echo "")
if [ -n "$DIFF" ]; then
echo "drift-detected=true" >> $GITHUB_OUTPUT
echo "$DIFF" > drift-report.txt
else
echo "drift-detected=false" >> $GITHUB_OUTPUT
fi
- name: Create GitHub issue for drift
if: steps.drift.outputs.drift-detected == 'true'
uses: actions/github-script@v9
with:
script: |
const fs = require('fs');
const report = fs.readFileSync('drift-report.txt', 'utf8');
const date = new Date().toISOString().split('T')[0];
await github.rest.issues.create({
owner: context.repo.owner,
repo: context.repo.repo,
title: `[Drift Detected] Org metadata divergence - ${date}`,
body: `## Metadata Drift Report\n\nDivergence detected between repository state and org.\n\n### Changed Files\n\`\`\`\n${report}\n\`\`\`\n\nResolve via a PR so the change enters the quality gate chain.\n\n_Detected by nightly surveillance on ${date}._`,
labels: ['drift-detected', 'needs-triage']
});
- name: Write job summary
if: always()
run: |
echo "## Metadata Drift Detection" >> $GITHUB_STEP_SUMMARY
if [ "${{ steps.drift.outputs.drift-detected }}" == "true" ]; then
echo "**Drift detected.** A GitHub issue has been created." >> $GITHUB_STEP_SUMMARY
else
echo "**No drift detected.**" >> $GITHUB_STEP_SUMMARY
fi
A.2.13 - Slack Notification¶
Posts a structured notification to Slack via an incoming webhook. The inputs interface is stable: calling workflows require no changes if the posting mechanism is later swapped for the Slack GitHub App or another tool.
# .github/workflows/sf-notify-slack.yml
# Reusable workflow: posts a notification to a Slack channel.
#
# This implementation uses a raw curl call to a Slack incoming webhook URL,
# stored as the SLACK_WEBHOOK_URL secret. This is the lowest-friction option
# and requires no Slack app installation.
#
# Alternative: replace the curl step with slackapi/slack-github-action
# if your organisation uses the Slack GitHub App rather than incoming webhooks.
# The inputs interface of this reusable workflow is stable across either
# implementation - calling workflows require no changes when the posting
# mechanism is swapped.
name: Notify Slack
on:
workflow_call:
inputs:
status:
description: "Status label - e.g. 'Failure' or 'Warning'"
required: true
type: string
title:
description: 'Short title for the notification'
required: true
type: string
message:
description: 'Body text describing the event'
required: true
type: string
run-url:
description: 'URL to the GitHub Actions workflow run'
required: true
type: string
secrets:
SLACK_WEBHOOK_URL:
required: true
jobs:
notify:
name: Post Slack Notification
runs-on: ubuntu-latest
steps:
- name: Post message to Slack
run: |
curl -s -X POST "${{ secrets.SLACK_WEBHOOK_URL }}" \
-H "Content-Type: application/json" \
-d '{
"blocks": [
{
"type": "header",
"text": {
"type": "plain_text",
"text": "${{ inputs.status }}: ${{ inputs.title }}"
}
},
{
"type": "section",
"text": {
"type": "mrkdwn",
"text": "${{ inputs.message }}"
}
},
{
"type": "actions",
"elements": [
{
"type": "button",
"text": { "type": "plain_text", "text": "View Workflow Run" },
"url": "${{ inputs.run-url }}"
}
]
}
]
}'
A.2.14 - Release Please¶
Maintains a rolling Release PR via googleapis/release-please-action. Every merge to main that carries a Conventional Commits message updates the Release PR's changelog and version. When the Release PR is itself merged, Release Please creates a tagged GitHub Release with the generated changelog as release notes.
Release Please ships a native sfdx release type that understands sfdx-project.json versioning, making this the correct release-type for Salesforce projects.
Token requirement: Release Please must open and update pull requests. If you want subsequent CI workflows to run on the Release PR, the workflow must use a PAT or GitHub App token rather than the default GITHUB_TOKEN (GITHUB_TOKEN-created PRs do not trigger further workflow runs by design).
Version bump rules (for PRs landing as Conventional Commits-formatted squash merges on main):
| Type/Commit Prefix | SemVer bump | Usage | Example |
|---|---|---|---|
feat!: or BREAKING CHANGE: footer |
Major (X.0.0) | A feature that is a breaking change. This is extremely rare, but can happen in packages if significant metadata is deleted. |
feat(permissions)!: replace legacy profile with permission set model |
feat: |
Minor (x.Y.0) | A new feature | feat(case): add approval routing to case escalation flow |
fix: |
Patch (x.y.Z) | A bug fix | fix(contact): handle null account on contact trigger |
docs: |
No bump (changelog entry only) | Changes to documentation only | docs(AccountSelector): add example code to all methods |
test: |
Adding/updating tests only (Agentforce, Apex, Flow, etc.) | test(AccountTriggerHandler): add tests for ServiceNow integration |
|
chore: |
Updating tasks, scripts, changelog, release | chore: update PMD ruleset threshold |
|
ci: |
Changes to CI configuration and scripts (GitHub Actions workflow) | ci(deps): Bump actions/checkout from 3 to 4 |
|
build: |
Changes to dependencies specified in package.json |
build(deps-dev): Bump lint-staged from 13.2.0 to 13.2.1 |
|
revert: |
Revert a previous commit | revert: Revert commits 676304e, a215968 |
|
style: |
Formatting, etc. for non-Salesforce metadata files such as documentation | (hidden - rarely used) | |
refactor: |
Refactoring production code without adding new features, e.g. renaming a variable | (hidden - rarely used) | |
perf: |
Improving performance such as optimizing a query or removing a loop | (hidden - rarely used) |
# .github/workflows/release-please.yml
# Reusable workflow: automated release management via release-please.
# Called from push-quality-baseline.yml on pushes to main only.
#
# Maintains a rolling Release PR that accumulates unreleased changes,
# bumps the version in sfdx-project.json, and creates a tagged GitHub
# Release when the Release PR is merged.
name: Release Please (Reusable)
on:
workflow_call:
secrets:
RELEASE_PLEASE_TOKEN:
description: >
PAT or GitHub App token with contents:write and pull-requests:write.
Required so that the Release PR triggers subsequent CI workflow runs.
The default GITHUB_TOKEN will not trigger further workflows on PRs it creates.
required: true
SLACK_WEBHOOK_URL:
required: false
outputs:
release_created:
description: 'Whether a GitHub Release was created on this run'
value: ${{ jobs.release-please.outputs.release_created }}
tag_name:
description: 'The tag name of the created release, if any'
value: ${{ jobs.release-please.outputs.tag_name }}
version:
description: 'The version string of the created release, if any'
value: ${{ jobs.release-please.outputs.version }}
sha:
description: 'The commit SHA of the created release, if any'
value: ${{ jobs.release-please.outputs.sha }}
# Permissions are declared at the reusable workflow level.
# Reusable workflows do not inherit permissions from calling workflows;
# they must declare their own.
permissions:
contents: write
issues: write
pull-requests: write
jobs:
release-please:
name: Release Please
runs-on: ubuntu-latest
outputs:
release_created: ${{ steps.release.outputs.release_created }}
tag_name: ${{ steps.release.outputs.tag_name }}
version: ${{ steps.release.outputs.version }}
sha: ${{ steps.release.outputs.sha }}
steps:
- name: Run release-please
id: release
uses: googleapis/release-please-action@v5
with:
# PAT required - GITHUB_TOKEN-created PRs do not trigger CI workflows.
token: ${{ secrets.RELEASE_PLEASE_TOKEN }}
# sfdx release type: updates version in sfdx-project.json.
release-type: sfdx
# release-please-config.json defines release strategy and changelog sections.
config-file: release-please-config.json
# .release-please-manifest.json tracks the current released version.
# Must be initialised correctly at project kickstart - an incorrect
# initial version produces unexpected first-release bumps that are
# difficult to reverse once tags are pushed.
manifest-file: .release-please-manifest.json
# Notify Slack only when a GitHub Release is actually created -
# not on every push to main. release_created is false on pushes
# that only update the rolling Release PR without tagging.
notify-release:
name: Notify Slack - New Release
needs: release-please
if: needs.release-please.outputs.release_created == 'true'
uses: ./.github/workflows/sf-notify-slack.yml
with:
status: 'Released'
title: 'New Release: ${{ needs.release-please.outputs.tag_name }}'
message: >
Version ${{ needs.release-please.outputs.version }} has been tagged
and released from commit ${{ needs.release-please.outputs.sha }}.
run-url: >
https://github.com/${{ github.repository }}/releases/tag/${{ needs.release-please.outputs.tag_name }}
secrets:
SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }}
release-please-config.json (placed at the repository root):
{
"$schema": "https://raw.githubusercontent.com/googleapis/release-please/main/schemas/config.json",
"release-type": "sfdx",
"changelog-sections": [
{ "type": "feat", "section": "Features", "hidden": false },
{ "type": "fix", "section": "Bug Fixes", "hidden": false },
{ "type": "test", "section": "Tests", "hidden": false },
{ "type": "chore", "section": "Miscellaneous Chores", "hidden": false },
{ "type": "docs", "section": "Documentation", "hidden": false },
{ "type": "revert", "section": "Reverts", "hidden": false },
{ "type": "deps", "section": "Dependencies", "hidden": false },
{ "type": "build", "section": "Build System", "hidden": false },
{ "type": "ci", "section": "Continuous Integration", "hidden": false },
{ "type": "style", "section": "Styles", "hidden": true },
{ "type": "refactor", "section": "Code Refactoring", "hidden": true },
{ "type": "perf", "section": "Performance Improvements", "hidden": true }
],
"packages": {
".": {}
}
}
.release-please-manifest.json (placed at the repository root). Initialise this file manually before the first release. The value must match the current state of the repository: if pre-release, use 0.1.0; if the codebase has been delivered before without Release Please, set it to the last known version so the first automated release produces the correct next version: