A.3 - Composite Actions¶
Composite actions handle the repeated step sequences that appear across many reusable workflows: Salesforce CLI installation, org authentication, result parsing, PR commenting. Each is a single action.yml file in its own directory under .github/actions/.
A.3.1 - Set Up Salesforce Environment¶
Single entry point for Node.js, npm ci, Salesforce CLI, and (optionally) the Code Analyzer and sfdx-git-delta plugins. Workflows that do not need Code Analyzer (e.g. sf-apex-tests.yml) disable its installation to avoid the Java and Python setup cost. Workflows that do not consume a delta artifact disable sfdx-git-delta for the same reason.
# .github/actions/setup-sf/action.yml
name: Set Up Salesforce Environment
description: >
Sets up Node.js, runs npm ci, installs the Salesforce CLI,
and optionally installs the Code Analyzer and sfdx-git-delta plugins.
inputs:
node-version:
description: 'Node.js version to install'
required: false
default: 'lts/*'
java-version:
description: "Java version (installed only when install-code-analyzer is 'true'; required by PMD and CPD engines)"
required: false
default: '>=11'
python-version:
description: "Python version (installed only when install-code-analyzer is 'true'; required by Flow Analyzer engine)"
required: false
default: '>=3.10'
install-code-analyzer:
description: "Install the Code Analyzer plugin. Pass 'false' to skip."
required: false
default: 'true'
install-sfdx-git-delta:
description: "Install the sfdx-git-delta plugin. Pass 'false' to skip."
required: false
default: 'true'
runs:
using: composite
steps:
- name: Setup Node.js
uses: actions/setup-node@v6
with:
node-version: ${{ inputs.node-version }}
cache: npm
- name: Install npm dependencies
shell: bash
run: npm ci
- name: Install Salesforce CLI
shell: bash
run: npm install -g @salesforce/cli@latest
- name: Ensure java v11 or greater
if: inputs.install-code-analyzer == 'true'
uses: actions/setup-java@v5
with:
java-version: ${{ inputs.java-version }}
distribution: 'zulu'
- name: Ensure python v3.10 or greater
if: inputs.install-code-analyzer == 'true'
uses: actions/setup-python@v6
with:
python-version: ${{ inputs.python-version }}
- name: Install Code Analyzer plugin
if: inputs.install-code-analyzer == 'true'
shell: bash
run: sf plugins install code-analyzer@latest
- name: Install sfdx-git-delta plugin
if: inputs.install-sfdx-git-delta == 'true'
shell: bash
run: echo y | sf plugins install sfdx-git-delta
A.3.2 - Authenticate via JWT Bearer Flow¶
Authenticates the Salesforce CLI to a target org via JWT bearer flow. The private key is written to a temp file only for the duration of the login call and removed unconditionally in an if: always() step.
# .github/actions/sf-auth-jwt/action.yml
name: Authenticate to Salesforce (JWT)
description: Authenticates to a Salesforce org using the JWT bearer flow.
inputs:
jwt-key:
description: The JWT private key (PEM format).
required: true
client-id:
description: The Connected App client ID.
required: true
username:
description: The Salesforce username to authenticate as.
required: true
instance-url:
description: The Salesforce instance URL.
required: true
alias:
description: The local alias to assign to the authenticated org.
required: true
default: target-org
runs:
using: composite
steps:
- name: Write JWT key to temporary file
shell: bash
run: echo "${{ inputs.jwt-key }}" > /tmp/sf-jwt-key.key
- name: Authenticate via JWT bearer flow
shell: bash
run: |
sf org login jwt \
--client-id "${{ inputs.client-id }}" \
--jwt-key-file /tmp/sf-jwt-key.key \
--username "${{ inputs.username }}" \
--instance-url "${{ inputs.instance-url }}" \
--alias "${{ inputs.alias }}"
- name: Remove JWT key file
shell: bash
if: always()
run: rm -f /tmp/sf-jwt-key.key
A.3.3 - Authenticate via SFDX Auth URL¶
Alternative authentication action that uses an SFDX auth URL rather than JWT. Used by the Apex test, compilation, drift detection, and validation workflows in this reference implementation. The auth URL is validated for non-empty content before use to surface misconfigured secrets early.
# .github/actions/sf-auth-sfdx-url/action.yml
name: Authenticate to Salesforce (SFDX URL)
description: Authenticates to a Salesforce org using an SFDX auth URL.
inputs:
sfdx-url:
description: The SFDX auth URL for the target org.
required: true
alias:
description: The local alias to assign to the authenticated org.
required: false
default: target-org
runs:
using: composite
steps:
- name: Write SFDX URL to temporary file
shell: bash
run: echo "${{ inputs.sfdx-url }}" > /tmp/sfdx-auth-url.txt
- name: Validate SFDX URL file
shell: bash
run: |
FILE_SIZE=$(wc -c < /tmp/sfdx-auth-url.txt | tr -d ' ')
if [ "$FILE_SIZE" -le 1 ]; then
echo "::error::SFDX URL is empty or not set. Ensure the secret is configured correctly."
rm -f /tmp/sfdx-auth-url.txt
exit 1
fi
- name: Authenticate via SFDX URL
shell: bash
run: |
sf org login sfdx-url \
--sfdx-url-file /tmp/sfdx-auth-url.txt \
--alias "${{ inputs.alias }}" \
--set-default
- name: Remove SFDX URL file
shell: bash
if: always()
run: rm -f /tmp/sfdx-auth-url.txt
A.3.4 - List Org Apex¶
Retrieves all ApexClass and ApexTrigger metadata from the target org and writes a newline-separated list of non-test class names and trigger names. Consumed by apex-test-report (A.3.5) so the report can flag classes and triggers present in the org but absent from the coverage report: the "untested Apex" signal.
A class is treated as a test class (and excluded from the list) if its source contains @IsTest (case-insensitive). All triggers are included by definition.
# .github/actions/apex-list/action.yml
name: List Org Apex
description: Retrieve ApexClass and ApexTrigger metadata from the target org and write all non-test class names plus all trigger names, one per line, to a file.
inputs:
target-org:
description: 'Salesforce org alias or username to retrieve from'
required: true
output-file:
description: 'Path to write the newline-separated list of non-test class names and trigger names'
required: false
default: 'org-apex.txt'
outputs:
file:
description: 'Path to the generated list file'
value: ${{ steps.build.outputs.file }}
count:
description: 'Number of names written to the list file'
value: ${{ steps.build.outputs.count }}
runs:
using: composite
steps:
- name: Retrieve ApexClass and ApexTrigger and build list
id: build
shell: bash
env:
TARGET_ORG: ${{ inputs.target-org }}
OUTPUT_FILE: ${{ inputs.output-file }}
run: |
# Retrieve unpackaged ApexClass + ApexTrigger metadata as a zip
RETRIEVE_DIR=$(mktemp -d)
sf project retrieve start \
--target-org "$TARGET_ORG" \
--metadata ApexClass ApexTrigger \
--target-metadata-dir "$RETRIEVE_DIR" \
--unzip \
--zip-file-name apex.zip
UNPACKAGED_DIR="$RETRIEVE_DIR/unpackaged"
if [ ! -d "$UNPACKAGED_DIR" ]; then
echo "::error::Retrieve did not produce an unpackaged directory at $UNPACKAGED_DIR"
exit 1
fi
# Include a class iff its source does NOT contain @IsTest
# (case-insensitive). All triggers are included by definition.
: > "$OUTPUT_FILE"
CLASSES_DIR="$UNPACKAGED_DIR/classes"
if [ -d "$CLASSES_DIR" ]; then
while IFS= read -r -d '' CLS_FILE; do
if ! grep -qiE '@isTest' "$CLS_FILE"; then
basename "$CLS_FILE" .cls >> "$OUTPUT_FILE"
fi
done < <(find "$CLASSES_DIR" -type f -name '*.cls' -print0)
fi
TRIGGERS_DIR="$UNPACKAGED_DIR/triggers"
if [ -d "$TRIGGERS_DIR" ]; then
while IFS= read -r -d '' TRG_FILE; do
basename "$TRG_FILE" .trigger >> "$OUTPUT_FILE"
done < <(find "$TRIGGERS_DIR" -type f -name '*.trigger' -print0)
fi
# Normalize: remove empties, dedupe, sort
sort -u -o "$OUTPUT_FILE" "$OUTPUT_FILE"
sed -i.bak '/^$/d' "$OUTPUT_FILE" && rm -f "$OUTPUT_FILE.bak"
COUNT=$(wc -l < "$OUTPUT_FILE" | tr -d '[:space:]')
echo "file=$OUTPUT_FILE" >> "$GITHUB_OUTPUT"
echo "count=$COUNT" >> "$GITHUB_OUTPUT"
rm -rf "$RETRIEVE_DIR"
A.3.5 - Parse Apex Test Results¶
Parses the JSON output of sf apex run test and produces a structured job summary plus markdown tables suitable for PR comment assembly. Outputs exposed for downstream PR comment construction: test outcome, pass/fail counts, overall coverage, failing tests markdown, full coverage markdown, low-coverage markdown, and (when org-apex-file is provided) untested classes and triggers markdown.
# .github/actions/apex-test-report/action.yml
name: Apex Test Report
description: Parse Salesforce Apex test results and write a job summary with test outcome, failing tests, and per-class coverage.
inputs:
results-dir:
description: 'Path to the Apex test results directory (output of sf apex run test --output-dir)'
required: true
coverage-threshold:
description: 'Coverage percentage below which classes are flagged in the low-coverage table. Default: 80'
required: false
default: '80'
show-all-coverage:
description: 'Show the full Coverage by Class table in the summary. Default: false'
required: false
default: 'false'
org-apex-file:
description: "Optional path to a file with one Apex class or trigger name per line (typically produced by the apex-list action). When provided, any name in that list missing from the coverage report is flagged in an 'Untested Classes and Triggers' section."
required: false
default: ''
outputs:
outcome:
description: 'Test run outcome (Passed/Failed)'
value: ${{ steps.parse.outputs.outcome }}
tests-ran:
description: 'Total number of tests executed'
value: ${{ steps.parse.outputs.tests-ran }}
passing:
description: 'Number of passing tests'
value: ${{ steps.parse.outputs.passing }}
failing:
description: 'Number of failing tests'
value: ${{ steps.parse.outputs.failing }}
coverage:
description: 'Overall test run coverage percentage'
value: ${{ steps.parse.outputs.coverage }}
failing-markdown:
description: 'Markdown table of failing tests'
value: ${{ steps.parse.outputs.failing-markdown }}
coverage-markdown:
description: 'Markdown table of all class coverage (sorted by name)'
value: ${{ steps.parse.outputs.coverage-markdown }}
low-coverage-markdown:
description: 'Markdown table of classes below the coverage threshold'
value: ${{ steps.parse.outputs.low-coverage-markdown }}
untested-markdown:
description: 'Markdown table of Apex classes and triggers absent from the coverage report (never exercised by a test). Empty unless org-apex-file is provided.'
value: ${{ steps.parse.outputs.untested-markdown }}
# Implementation: see the repository action.yml. The step:
# 1. Locates the test results JSON (preferring test-run-id.txt, falling back to filename pattern).
# 2. Extracts summary fields via jq and exposes them as step outputs.
# 3. Builds failing tests, low-coverage, and full-coverage markdown tables.
# 4. When org-apex-file is provided, computes the set difference between the org Apex list
# and the coverage report names and produces the "untested" markdown.
# 5. Writes $GITHUB_STEP_SUMMARY with all of the above.
The full implementation uses
jqagainsttest-result-<runId>.jsonandtest-result-codecoverage.json. See the repository for the complete shell implementation; the interface above is the stable contract consumed bysf-apex-tests.ymlandsf-apex-tests-pr.yml.
A.3.6 - Parse Code Analyzer Results¶
Parses the JSON output of sf code-analyzer run and produces a structured job summary plus markdown tables separated by severity cutoff. Outputs expose total and per-severity violation counts, plus the high-priority and low-priority markdown blocks consumed by sf-code-analyzer.yml for PR comment assembly.
# .github/actions/sf-code-analyzer-report/action.yml
name: Code Analyzer Report
description: Parse Salesforce Code Analyzer results and write a job summary with violation counts and markdown tables.
inputs:
results-path:
description: 'Path to the Salesforce Code Analyzer results JSON file (e.g. code-analyzer-results.json)'
required: true
severity-cutoff:
description: 'Violations with severity <= this value appear in the first (high-priority) table. Remaining severities go in the second table. Default: 2'
required: false
default: '2'
file-path-strip-prefix:
description: "String to strip from the beginning of every file path in the output tables. Leave empty to skip stripping. Default: 'force-app/main/'"
required: false
default: 'force-app/main/'
outputs:
total-violations:
description: 'Total number of violations found'
value: ${{ steps.parse.outputs.total-violations }}
sev1-count:
description: 'Number of severity 1 violations'
value: ${{ steps.parse.outputs.sev1-count }}
sev2-count:
description: 'Number of severity 2 violations'
value: ${{ steps.parse.outputs.sev2-count }}
sev3-count:
description: 'Number of severity 3 violations'
value: ${{ steps.parse.outputs.sev3-count }}
sev4-count:
description: 'Number of severity 4 violations'
value: ${{ steps.parse.outputs.sev4-count }}
sev5-count:
description: 'Number of severity 5 violations'
value: ${{ steps.parse.outputs.sev5-count }}
high-priority-markdown:
description: 'Markdown table of violations within the high-priority severity range (sev 1 – cutoff)'
value: ${{ steps.parse.outputs.high-priority-markdown }}
low-priority-markdown:
description: 'Markdown table of violations outside the high-priority severity range (sev cutoff+1 – 5)'
value: ${{ steps.parse.outputs.low-priority-markdown }}
# Implementation: the step uses jq against the Code Analyzer JSON output.
# The file-path-strip-prefix input trims a common prefix from every file path in
# the rendered tables so long paths like "force-app/main/default/classes/Foo.cls"
# render as "default/classes/Foo.cls". See the repository for the full shell.
A.3.7 - Post or Update PR Comment¶
Posts a structured comment to the current PR. Uses a marker string (a leading line passed as an input) to find and update an existing bot-authored comment rather than posting duplicates: ensures the PR shows one Code Analyzer comment, one Apex Test Results comment, one Metadata Validation comment, each updated in place on every push.
# .github/actions/post-pr-comment/action.yml
name: Post PR Comment
description: Posts or updates a structured comment on the current pull request. Updates in place to avoid comment spam.
inputs:
token:
description: 'GitHub token with pull-requests:write permission'
required: true
marker:
description: 'Unique string at the start of the comment body used to identify and update existing comments'
required: true
body:
description: 'Full markdown body of the comment'
required: true
runs:
using: composite
steps:
- name: Post or update PR comment
uses: actions/github-script@v9
env:
COMMENT_MARKER: ${{ inputs.marker }}
COMMENT_BODY: ${{ inputs.body }}
with:
github-token: ${{ inputs.token }}
script: |
const marker = process.env.COMMENT_MARKER;
const body = process.env.COMMENT_BODY;
const { data: comments } = await github.rest.issues.listComments({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: context.issue.number
});
const existing = comments.find(c => c.body.startsWith(marker) && c.user.type === 'Bot');
if (existing) {
await github.rest.issues.updateComment({
owner: context.repo.owner,
repo: context.repo.repo,
comment_id: existing.id,
body
});
} else {
await github.rest.issues.createComment({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: context.issue.number,
body
});
}