Skip to content

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 jq against test-result-<runId>.json and test-result-codecoverage.json. See the repository for the complete shell implementation; the interface above is the stable contract consumed by sf-apex-tests.yml and sf-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
            });
          }