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/.

Action version note: every uses: reference on this page names the version that was current at the time of writing. Where a page references the organisation's own reusable workflows and composite actions by tag, the tag is an immutable release tag; third-party actions are shown at their floating major tag, a choice many organisations make for highly trusted actions such as actions/checkout (the trade-off is explained in Appendix G). Newer major versions may introduce breaking changes: review the release notes for an action before upgrading, and validate in a non-production pipeline before promoting to protected branches. Dependabot and Renovate (Section 7) open PRs when new versions are published, providing a structured upgrade path.

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.

Context: the runner prerequisites in Appendix E, Installation and Prerequisites.

Expand to show Set Up Salesforce Environment GitHub action
Set Up Salesforce Environment
# .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@v7
      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 --global @salesforce/cli@latest

    - name: Ensure java v11 or greater
      if: inputs.install-code-analyzer == 'true'
      uses: actions/setup-java@v6
      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@v7
      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

The authentication action of this reference implementation: the Apex test, compilation, org scan, and validation workflows use it for every org that they connect to, because the JWT bearer flow is the recommended way for a pipeline to log in. It authenticates the Salesforce CLI to a target org and makes it the default org, as A.3.3 does, so that a later command that names no target org uses it. Each input reaches the shell through env:, not through an expression in the script, so a quote or a $ in a value cannot change the command. The private key is written to a temp file that only its owner can read, only for the duration of the login call, and removed unconditionally in an if: always() step.

Context: Section 7, The Salesforce Authentication Surface.

Expand to show Authenticate to Salesforce (JWT) GitHub action
Authenticate to Salesforce (JWT)
# .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 External Client App consumer key (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: false
    default: target-org

runs:
  using: composite
  steps:
    # Each input reaches the shell through env, not through ${{ }} in the
    # script, so a quote or a $ in a value cannot change the command.
    - name: Write JWT key to temporary file
      shell: bash
      env:
        JWT_KEY: ${{ inputs.jwt-key }}
      run: |
        umask 077
        printf '%s\n' "$JWT_KEY" > /tmp/sf-jwt-key.key

    - name: Authenticate via JWT bearer flow
      shell: bash
      env:
        JWT_CLIENT_ID: ${{ inputs.client-id }}
        LOGIN_USERNAME: ${{ inputs.username }}
        LOGIN_INSTANCE_URL: ${{ inputs.instance-url }}
        ORG_ALIAS: ${{ inputs.alias }}
      run: |
        sf org login jwt \
          --client-id "$JWT_CLIENT_ID" \
          --jwt-key-file /tmp/sf-jwt-key.key \
          --username "$LOGIN_USERNAME" \
          --instance-url "$LOGIN_INSTANCE_URL" \
          --alias "$ORG_ALIAS" \
          --set-default

    - 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

An alternative authentication action that uses an SFDX auth URL rather than JWT. It is kept for completeness, and no workflow of this reference implementation uses it. An auth URL is viable for local development but unsuitable in a pipeline, because a stored auth URL is the riskiest way for a pipeline to log in (Section 7); a pipeline uses the JWT bearer flow of A.3.2. The auth URL is validated for non-empty content before use to surface misconfigured secrets early.

Context: Section 7, The Salesforce Authentication Surface.

Expand to show Authenticate to Salesforce (SFDX URL) GitHub action
Authenticate to Salesforce (SFDX URL)
# .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:
    # Each input reaches the shell through env, not through ${{ }} in the
    # script, so a quote or a $ in a value cannot change the command.
    - name: Write SFDX URL to temporary file
      shell: bash
      env:
        SFDX_URL: ${{ inputs.sfdx-url }}
      run: |
        umask 077
        printf '%s\n' "$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
      env:
        ORG_ALIAS: ${{ inputs.alias }}
      run: |
        sf org login sfdx-url \
          --sfdx-url-file /tmp/sfdx-auth-url.txt \
          --alias "$ORG_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.

Context: the scheduled full-org runs in Section 6, PR-Time: Dependency Analysis and Impact Assessment.

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.

Expand to show List Org Apex GitHub action
List Org Apex
# .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

        # The CLI extracts the zip into a folder with the name of the zip
        # file. Inside it, the Metadata API puts metadata that is in no
        # package into a folder named "unpackaged".
        UNPACKAGED_DIR="$RETRIEVE_DIR/apex/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 writes a job summary: the outcome and the counts, a table of every test that did not pass (class, method, outcome, and message), the stack trace of each of those tests in a block that the reader opens, the classes below the coverage threshold, which the calling workflow passes as an input with no default, and (when org-apex-file is provided) the classes and triggers that no test exercised. Each table is also an output, for a caller that builds a comment or an issue: test outcome, pass/fail counts, overall coverage, failing tests markdown, full coverage markdown, low-coverage markdown, and untested classes and triggers markdown. A cell of a table is made safe first: a |, a line break, or HTML in a message does not break the table.

Context: Section 4, Developer Feedback Quality and Shift-Left Visibility.

Expand to show Apex Test Report GitHub action

# .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. The calling workflow passes it; there is no default here"
    required: true
  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 every test that did not pass (class, method, outcome, and message), and below it the stack traces, in a block that the reader opens"
    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 }}

runs:
  using: composite
  steps:
    - name: Validate results directory exists
      shell: bash
      env:
        RESULTS_DIR: ${{ inputs.results-dir }}
      run: |
        if [ ! -d "$RESULTS_DIR" ]; then
          echo "::error::Apex test results directory not found at path: $RESULTS_DIR"
          exit 1
        fi

    - name: Parse results and write summary
      id: parse
      shell: bash
      env:
        RESULTS_DIR: ${{ inputs.results-dir }}
        COVERAGE_THRESHOLD: ${{ inputs.coverage-threshold }}
        SHOW_ALL_COVERAGE: ${{ inputs.show-all-coverage }}
        ORG_APEX_FILE: ${{ inputs.org-apex-file }}
      run: |
# ================================================================
# Locate the test results JSON
#   Preferred: read test run id from test-run-id.txt and build path
#   Fallback:  match filename pattern test-result-707<id>.json
# ================================================================
RESULTS_FILE=""
TEST_RUN_ID_FILE="$RESULTS_DIR/test-run-id.txt"
if [ -f "$TEST_RUN_ID_FILE" ]; then
  TEST_RUN_ID=$(tr -d '[:space:]' < "$TEST_RUN_ID_FILE")
  if [ -n "$TEST_RUN_ID" ] && [ -f "$RESULTS_DIR/test-result-$TEST_RUN_ID.json" ]; then
    RESULTS_FILE="$RESULTS_DIR/test-result-$TEST_RUN_ID.json"
  fi
fi

if [ -z "$RESULTS_FILE" ]; then
  RESULTS_FILE=$(find "$RESULTS_DIR" -maxdepth 1 -regex '.*/test-result-707[a-zA-Z0-9]+\.json' | head -1)
fi

if [ -z "$RESULTS_FILE" ]; then
  echo "::error::No test results JSON file found in $RESULTS_DIR"
  exit 1
fi

COVERAGE_FILE="$RESULTS_DIR/test-result-codecoverage.json"

# ================================================================
# Extract summary fields (every key is verified to exist in the
# sf apex run test JSON output; setup time is pulled from junit below)
# ================================================================
OUTCOME=$(jq -r '.summary.outcome // "Unknown"' "$RESULTS_FILE")
TESTS_RAN=$(jq -r '.summary.testsRan // 0' "$RESULTS_FILE")
PASSING=$(jq -r '.summary.passing // 0' "$RESULTS_FILE")
FAILING=$(jq -r '.summary.failing // 0' "$RESULTS_FILE")
SKIPPED=$(jq -r '.summary.skipped // 0' "$RESULTS_FILE")
PASS_RATE=$(jq -r '.summary.passRate // "0%"' "$RESULTS_FILE")
FAIL_RATE=$(jq -r '.summary.failRate // "0%"' "$RESULTS_FILE")
COVERAGE=$(jq -r '.summary.testRunCoverage // "N/A"' "$RESULTS_FILE")
ORG_WIDE_COVERAGE=$(jq -r '.summary.orgWideCoverage // "N/A"' "$RESULTS_FILE")
TEST_RUN_ID=$(jq -r '.summary.testRunId // "N/A"' "$RESULTS_FILE")
TEST_START_TIME=$(jq -r '.summary.testStartTime // "N/A"' "$RESULTS_FILE")
TEST_EXEC_TIME=$(jq -r '.summary.testExecutionTime // "N/A"' "$RESULTS_FILE")
TEST_TOTAL_TIME=$(jq -r '.summary.testTotalTime // "N/A"' "$RESULTS_FILE")
COMMAND_TIME=$(jq -r '.summary.commandTime // "N/A"' "$RESULTS_FILE")
HOSTNAME_VAL=$(jq -r '.summary.hostname // "N/A"' "$RESULTS_FILE")
ORG_ID=$(jq -r '.summary.orgId // "N/A"' "$RESULTS_FILE")
USER_ID=$(jq -r '.summary.userId // "N/A"' "$RESULTS_FILE")
USERNAME=$(jq -r '.summary.username // "N/A"' "$RESULTS_FILE")

# --- Derived skip rate (sf does not emit skipRate; compute skipped/testsRan rounded) ---
if [ "${TESTS_RAN:-0}" -gt 0 ] 2>/dev/null; then
  SKIP_RATE=$(awk -v s="$SKIPPED" -v t="$TESTS_RAN" 'BEGIN { printf "%d%%", int(((s*100)/t) + 0.5) }')
else
  SKIP_RATE="0%"
fi

# --- Test setup time: prefer summary.testSetupTimeInMs; fall back to junit XML ---
TEST_SETUP_TIME=$(jq -r '.summary.testSetupTimeInMs // empty' "$RESULTS_FILE")
if [ -n "$TEST_SETUP_TIME" ]; then
  TEST_SETUP_TIME="${TEST_SETUP_TIME} ms"
else
  JUNIT_FILE="$RESULTS_DIR/test-result-$TEST_RUN_ID-junit.xml"
  if [ -f "$JUNIT_FILE" ]; then
    SETUP_MS=$(grep -oE 'name="testSetupTimeInMs"[[:space:]]*value="[^"]*"' "$JUNIT_FILE" 2>/dev/null | sed -E 's/.*value="([^"]*)".*/\1/' | head -1 || true)
    if [ -n "$SETUP_MS" ]; then
      TEST_SETUP_TIME="${SETUP_MS} ms"
    fi
  fi
fi

# --- Expose summary as step outputs ---
echo "outcome=$OUTCOME"     >> "$GITHUB_OUTPUT"
echo "tests-ran=$TESTS_RAN" >> "$GITHUB_OUTPUT"
echo "passing=$PASSING"     >> "$GITHUB_OUTPUT"
echo "failing=$FAILING"     >> "$GITHUB_OUTPUT"
echo "coverage=$COVERAGE"   >> "$GITHUB_OUTPUT"

# ================================================================
# Build failing tests table: one row for every test that did not
# pass and was not skipped, with the whole message.
# cell: text that is safe in a table cell. A "|" does not start a
# column, a line break does not end the row, HTML is shown as text,
# and a missing value is an empty cell.
# A stack trace can have many lines, so it is not in the table. The
# traces are below the table, in a block that the reader opens.
# ================================================================
FAIL_MD=$(jq -r '
  def cell: (. // "") | tostring | gsub("\\|"; "\\|") | gsub("<"; "&lt;") | gsub("\r?\n"; "<br>");
  .tests // []
  | map(select(.Outcome != "Pass" and .Outcome != "Skip"))
  | sort_by(.ApexClass.Name, .MethodName)
  | if length > 0 then
      "| Class | Method | Outcome | Message |\n|---|---|---|---|\n"
      + (map("| `\(.ApexClass.Name)` | `\(.MethodName)` | \(.Outcome | cell) | \(.Message | cell) |") | join("\n"))
      + (map(select((.StackTrace // "") != ""))
        | if length > 0 then
            "\n\n<details>\n<summary>Stack traces of the failing tests</summary>\n\n"
            + (map("**`\(.ApexClass.Name).\(.MethodName)`**\n\n```text\n\(.StackTrace)\n```") | join("\n\n"))
            + "\n\n</details>"
          else "" end)
    else "" end
' "$RESULTS_FILE")

# --- Expose as multiline output ---
{
  echo "failing-markdown<<EOF_FAIL"
  echo "$FAIL_MD"
  echo "EOF_FAIL"
} >> "$GITHUB_OUTPUT"

# ================================================================
# Build coverage tables (only if codecoverage file exists)
# ================================================================
COVERAGE_MD=""
LOW_COV_MD=""
UNTESTED_MD=""
UNTESTED_COUNT=""

if [ -f "$COVERAGE_FILE" ]; then
  # --- Full coverage table (sorted by coverage ascending, threshold status marker) ---
  COV_ROWS=$(jq -r --argjson threshold "$COVERAGE_THRESHOLD" --arg pct "%" '
    map(select(.totalLines > 0))
    | sort_by(.coveredPercent, .name)
    | .[]
    | "| `\(.name)` | \(.coveredPercent)\($pct) | \(if .coveredPercent >= $threshold then "OK" else "Low" end) |"
  ' "$COVERAGE_FILE")

  if [ -n "$COV_ROWS" ]; then
    COVERAGE_MD=$(printf '| Class/Trigger | Coverage | Status |\n|---|---:|:---:|\n%s' "$COV_ROWS")
  fi

  # --- Low-coverage table (below threshold, sorted by coverage ascending) ---
  LOW_COV_ROWS=$(jq -r --argjson threshold "$COVERAGE_THRESHOLD" --arg pct "%" '
    map(select(.totalLines > 0 and .coveredPercent < $threshold))
    | sort_by(.coveredPercent)
    | .[]
    | "| `\(.name)` | \(.coveredPercent)\($pct) |"
  ' "$COVERAGE_FILE")

  if [ -n "$LOW_COV_ROWS" ]; then
    LOW_COV_MD=$(printf '| Class | Coverage |\n|---|---:|\n%s' "$LOW_COV_ROWS")
  fi

  # --- Untested classes and triggers: names in the org Apex list that are missing from the coverage report ---
  if [ -n "$ORG_APEX_FILE" ] && [ -f "$ORG_APEX_FILE" ] && [ -s "$ORG_APEX_FILE" ]; then
    UNTESTED_NAMES=$(comm -23 \
      <(sort -u "$ORG_APEX_FILE" | sed '/^$/d') \
      <(jq -r '.[] | .name' "$COVERAGE_FILE" | sort -u))

    UNTESTED_COUNT=$(printf '%s' "$UNTESTED_NAMES" | awk 'NF' | wc -l | tr -d '[:space:]')

    if [ -n "$UNTESTED_NAMES" ]; then
      UNTESTED_ROWS=$(printf '%s\n' "$UNTESTED_NAMES" | awk 'NF {printf "| `%s` |\n", $0}')
      UNTESTED_MD=$(printf '| Class/Trigger |\n|---|\n%s' "$UNTESTED_ROWS")
    fi
  fi
fi

# --- Expose as multiline outputs ---
{
  echo "coverage-markdown<<EOF_COV"
  echo "$COVERAGE_MD"
  echo "EOF_COV"
} >> "$GITHUB_OUTPUT"

{
  echo "low-coverage-markdown<<EOF_LOW"
  echo "$LOW_COV_MD"
  echo "EOF_LOW"
} >> "$GITHUB_OUTPUT"

{
  echo "untested-markdown<<EOF_UNTESTED"
  echo "$UNTESTED_MD"
  echo "EOF_UNTESTED"
} >> "$GITHUB_OUTPUT"

# ================================================================
# Write GITHUB_STEP_SUMMARY
# ================================================================
{
  echo "## Apex Test Results"
  echo ""
  echo "### Test Summary"
  echo ""
  echo "| Name | Value |"
  echo "|---|---|"
  echo "| Outcome | $OUTCOME |"
  echo "| Tests Ran | $TESTS_RAN |"
  echo "| Passed | $PASSING ($PASS_RATE) |"
  echo "| Failed | $FAILING ($FAIL_RATE) |"
  echo "| Skipped | $SKIPPED ($SKIP_RATE) |"
  if [ -n "$UNTESTED_COUNT" ]; then
    echo "| Untested Classes/Triggers | $UNTESTED_COUNT |"
  fi
  echo "| Test Run Id | \`$TEST_RUN_ID\` |"
  echo "| Test Start Time | $TEST_START_TIME |"
  if [ -n "$TEST_SETUP_TIME" ]; then
    echo "| Test Setup Time | $TEST_SETUP_TIME |"
  fi
  echo "| Test Execution Time | $TEST_EXEC_TIME |"
  echo "| Test Total Time | $TEST_TOTAL_TIME |"
  echo "| Command Time | $COMMAND_TIME |"
  echo "| Hostname | $HOSTNAME_VAL |"
  echo "| Org Id | \`$ORG_ID\` |"
  echo "| User Id | \`$USER_ID\` |"
  echo "| Username | \`$USERNAME\` |"
  echo "| Test Run Coverage | $COVERAGE |"
  echo "| Org Wide Coverage | $ORG_WIDE_COVERAGE |"
  echo ""

  # --- Failing tests (only if there are failures) ---
  if [ -n "$FAIL_MD" ]; then
    echo "### Failing Tests"
    echo ""
    echo "$FAIL_MD"
    echo ""
  fi

  # --- Untested classes and triggers (only when the org Apex list was provided and has missing names) ---
  if [ -n "$UNTESTED_MD" ]; then
    echo "### Untested Classes and Triggers"
    echo ""
    echo "Apex classes and triggers absent from the coverage report: no test in this run exercised them."
    echo ""
    echo "$UNTESTED_MD"
    echo ""
  fi

  # --- Low-coverage table (only if there are classes below threshold) ---
  if [ -n "$LOW_COV_MD" ]; then
    echo "### Classes Below ${COVERAGE_THRESHOLD}% Coverage"
    echo ""
    echo "$LOW_COV_MD"
    echo ""
  fi

  # --- Full coverage table (only if show-all-coverage is true) ---
  if [ "$SHOW_ALL_COVERAGE" = "true" ] && [ -n "$COVERAGE_MD" ]; then
    echo "### Coverage by Class"
    echo ""
    echo "$COVERAGE_MD"
  fi

} >> "$GITHUB_STEP_SUMMARY"

A.3.6 - Parse Code Analyzer Results

Parses the JSON output of sf code-analyzer run and writes a job summary: the number of violations at each severity, and the violations in tables. The input severity-cutoff decides the tables. A cutoff from 1 to 4 gives two tables, one up to the cutoff and one above it. An empty, null, or 5 cutoff gives one table, All Violations, with every violation. Any other value fails the step with an error that names the input, so that no violation is left out of the tables without a word. sf-code-analyzer.yml passes its severity threshold as the cutoff. Each row has the file, the line, the rule, the description, the severity, and the engine. The rows are sorted by the file as the table shows it, and then by line. The file is shown without the folders that every file of the report starts with: when every file is in force-app/main/default, delta/force-app/main/default/classes/Foo.cls is shown as classes/Foo.cls, and a report of one file shows the file name only. The file links to the file at the commit of the run, on the line of the violation, and the link opens in a new tab. Outputs expose total and per-severity violation counts, plus the tables as markdown blocks (the second block is empty when there is one table), which sf-code-analyzer.yml puts into the PR comment. A cell of a table is made safe first. The input write-summary turns off the job summary of the action, for a caller that writes its own: the full scan of sf-code-analyzer.yml writes the counts and the first table only, because the second table of a whole repository can be very long.

Context: Section 4, Developer Feedback Quality and Shift-Left Visibility.

Expand to show Code Analyzer Report GitHub action
Code Analyzer Report
# .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. When it is empty, null, or 5, every violation appears in one table and there is no second table. Any other value that is not 1 to 4 fails the step. The calling workflow passes it; there is no default here"
    required: false
  write-summary:
    description: '"true" to write the counts and both tables to the job summary. "false" to write nothing there and give the tables as outputs only, for a caller that writes its own summary'
    required: false
    default: "true"

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), or of every violation when there is no cutoff (empty, null, or 5)"
    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), empty when there is no cutoff (empty, null, or 5)"
    value: ${{ steps.parse.outputs.low-priority-markdown }}

runs:
  using: composite
  steps:
    - name: Validate results file exists
      shell: bash
      env:
        RESULTS_FILE: ${{ inputs.results-path }}
      run: |
        if [ ! -f "$RESULTS_FILE" ]; then
          echo "::error::Code Analyzer results file not found at path: $RESULTS_FILE"
          exit 1
        fi

    - name: Parse results and write summary
      id: parse
      shell: bash
      env:
        RESULTS_FILE: ${{ inputs.results-path }}
        SEVERITY_CUTOFF: ${{ inputs.severity-cutoff }}
        WRITE_SUMMARY: ${{ inputs.write-summary }}
      run: |
        # --- Extract top-level counts from violationCounts block ---
        TOTAL=$(jq '.violationCounts.total // 0' "$RESULTS_FILE")
        SEV1=$(jq  '.violationCounts.sev1 // 0'  "$RESULTS_FILE")
        SEV2=$(jq  '.violationCounts.sev2 // 0'  "$RESULTS_FILE")
        SEV3=$(jq  '.violationCounts.sev3 // 0'  "$RESULTS_FILE")
        SEV4=$(jq  '.violationCounts.sev4 // 0'  "$RESULTS_FILE")
        SEV5=$(jq  '.violationCounts.sev5 // 0'  "$RESULTS_FILE")

        # --- Expose violation counts as step outputs ---
        echo "total-violations=$TOTAL" >> "$GITHUB_OUTPUT"
        echo "sev1-count=$SEV1"        >> "$GITHUB_OUTPUT"
        echo "sev2-count=$SEV2"        >> "$GITHUB_OUTPUT"
        echo "sev3-count=$SEV3"        >> "$GITHUB_OUTPUT"
        echo "sev4-count=$SEV4"        >> "$GITHUB_OUTPUT"
        echo "sev5-count=$SEV5"        >> "$GITHUB_OUTPUT"

        # ================================================================
        # Helper: render table rows for a given severity range (inclusive).
        # cell: text that is safe in a table cell. A "|" does not start a
        # column, a line break does not end the row, HTML is shown as text,
        # and a missing value is an empty cell.
        # The File cell shows the path without the folders that every file of
        # the report starts with: when every file is in force-app/main/default,
        # delta/force-app/main/default/classes/Foo.cls shows as classes/Foo.cls,
        # and a report of one file shows its name only. It links to the file at
        # this commit, on the line of the violation, and the link opens in
        # a new tab. A scan of the folder "delta" gives paths that start with
        # "delta/", and that prefix is not in the repository, so the link cuts it.
        # ================================================================
        render_rows() {
          local MIN_SEV=$1
          local MAX_SEV=$2
          jq -r --argjson min "$MIN_SEV" --argjson max "$MAX_SEV" \
                --arg server "$GITHUB_SERVER_URL" \
                --arg repo "$GITHUB_REPOSITORY" \
                --arg sha "$GITHUB_SHA" '
            def cell: (. // "") | tostring | gsub("\\|"; "\\|") | gsub("<"; "&lt;") | gsub("\r?\n"; "<br>");
            def repo_path: (. // "") | sub("^delta/"; "");
            def link_path: split("/") | map(@uri) | join("/");
            # The folders that every path in the list starts with
            def common_folders: reduce .[1:][] as $d (.[0] // []; . as $p
              | ([$p, $d] | map(length) | min) as $most
              | (first(range(0; $most) | select($p[.] != $d[.])) // $most) as $n
              | $p[:$n]);
            # How many folders to cut: those of every violation, in both tables
            ([.violations[]? | ((.locations[.primaryLocationIndex // 0] // {}).file // "") | repo_path | split("/") | .[:-1]]
              | common_folders | length) as $cut
            | [
              .violations[]?
              | select(.severity >= $min and .severity <= $max)
              | (.locations[.primaryLocationIndex // 0] // {}) as $loc
              | {
                  severity: .severity,
                  rule:     .rule,
                  engine:   .engine,
                  message:  .message,
                  path:     (($loc.file // "") | repo_path),
                  shown:    (($loc.file // "") | repo_path | split("/") | .[$cut:] | join("/")),
                  line:     ($loc.startLine // 0),
                  line_str: (($loc.startLine // "") | tostring)
                }
            ]
            # Sorted by the file name as the table shows it, then by the whole
            # path, so that the violations of a file are together even when two
            # files have the same name, then by the line, the severity, and the
            # engine.
            | sort_by([.shown, .path, .line, .severity, .engine])
            | .[]
            | "| <a href=\"\($server)/\($repo)/blob/\($sha)/\(.path | link_path)\(if .line > 0 then "#L\(.line)" else "" end)\" target=\"_blank\" rel=\"noopener\">\(.shown | cell)</a> | \(.line_str) | \(.rule | cell) | \(.message | cell) | \(.severity) | \(.engine | cell) |"
          ' "$RESULTS_FILE"
        }

        TABLE_HEADER="| File | Line | Rule | Description | Severity | Engine |"
        TABLE_SEP="|------|-----:|------|-------------|:--------:|--------|"

        # An empty, null, or 5 cutoff puts every violation in one table. Any
        # other value that is not 1 to 4 fails the step: the tables would
        # otherwise leave violations out without a word.
        HAS_CUTOFF=true
        case "$SEVERITY_CUTOFF" in
          ""|null|5) HAS_CUTOFF=false ;;
          1|2|3|4) ;;
          *)
            echo "::error::severity-cutoff must be empty, null, or a severity from 1 to 5. It was: '$SEVERITY_CUTOFF'"
            exit 1
            ;;
        esac

        # ================================================================
        # Build each markdown block independently so they can be emitted
        # both to GITHUB_STEP_SUMMARY and as step outputs.
        # ================================================================

        if [ "$HAS_CUTOFF" = "false" ]; then
          # --- No cutoff: every violation goes in the high-priority block ---
          ALL_ROWS=$(render_rows 1 5)
          if [ -z "$ALL_ROWS" ]; then
            HIGH_MD="_No violations._"
          else
            HIGH_MD=$(printf '%s\n%s\n%s' "$TABLE_HEADER" "$TABLE_SEP" "$ALL_ROWS")
          fi
          LOW_MD=""
        else
          REMAINING_MIN=$(( SEVERITY_CUTOFF + 1 ))

          # --- High-priority block (severity 1 to the cutoff) ---
          HIGH_ROWS=$(render_rows 1 "$SEVERITY_CUTOFF")
          if [ -z "$HIGH_ROWS" ]; then
            HIGH_MD="_No violations in this severity range._"
          else
            HIGH_MD=$(printf '%s\n%s\n%s' "$TABLE_HEADER" "$TABLE_SEP" "$HIGH_ROWS")
          fi

          # --- Low-priority block (above the cutoff, to severity 5) ---
          LOW_ROWS=$(render_rows "$REMAINING_MIN" 5)
          if [ -z "$LOW_ROWS" ]; then
            LOW_MD="_No violations in this severity range._"
          else
            LOW_MD=$(printf '%s\n%s\n%s' "$TABLE_HEADER" "$TABLE_SEP" "$LOW_ROWS")
          fi
        fi

        # --- Expose markdown blocks as step outputs (multiline-safe) ---
        {
          echo "high-priority-markdown<<EOF_HIGH"
          echo "$HIGH_MD"
          echo "EOF_HIGH"
        } >> "$GITHUB_OUTPUT"

        {
          echo "low-priority-markdown<<EOF_LOW"
          echo "$LOW_MD"
          echo "EOF_LOW"
        } >> "$GITHUB_OUTPUT"

        # ================================================================
        # Write $GITHUB_STEP_SUMMARY, unless the caller writes its own
        # ================================================================
        if [ "$WRITE_SUMMARY" != "true" ]; then
          exit 0
        fi
        {
          echo "## Salesforce Code Analyzer Results"
          echo ""

          # --- Violation counts summary table ---
          echo "### Violation Counts"
          echo ""
          echo "| Severity | Count |"
          echo "|----------|------:|"
          echo "| Severity 1 | $SEV1 |"
          echo "| Severity 2 | $SEV2 |"
          echo "| Severity 3 | $SEV3 |"
          echo "| Severity 4 | $SEV4 |"
          echo "| Severity 5 | $SEV5 |"
          echo "| **Total** | **$TOTAL** |"
          echo ""

          if [ "$HAS_CUTOFF" = "false" ]; then
            # --- One table: every violation ---
            echo "### All Violations"
            echo ""
            echo "$HIGH_MD"
          else
            # --- Table 1: severity 1 to the cutoff ---
            echo "### Violations at Severity 1 to ${SEVERITY_CUTOFF}"
            echo ""
            echo "$HIGH_MD"
            echo ""

            # --- Table 2: above the cutoff, to severity 5 ---
            if [ "$REMAINING_MIN" -le 5 ]; then
              echo "### Violations at Severity ${REMAINING_MIN} to 5"
              echo ""
              echo "$LOW_MD"
            fi
          fi

        } >> "$GITHUB_STEP_SUMMARY"

A.3.7 - Parse Deployment Validation Results

Parses the JSON output of sf project deploy start --dry-run --json and returns a report as markdown, both as an output and as a file. The action writes nothing itself: the calling workflow puts the report where it is needed. sf-validate-deployment.yml writes it to the job summary and posts it as the PR comment, so the two always agree. The report has the status, the test level, the number of tests run and failed, and the coverage of the classes in this validation. Below that are four tables, each shown only when it has rows: the component failures, the failing tests with where the error was thrown and the message of each (where is read from the first frame of the stack trace: its line when that frame is the test method, and the class or trigger, the method, and the line when it is not; the stack traces are in a block that the reader opens), the coverage warnings, and the coverage of each class and trigger. A validation that wrote no result, or wrote text that is not JSON, gets a report that says so.

The validation result has no coverage percentage, only the number of lines of each class and trigger, and the number not covered. So the action computes the coverage: of each class from its own lines, and of the validation from the lines of every class and trigger in the result. Both are rounded to the nearest whole number, as Salesforce rounds. A class with no lines, such as an exception class, is left out of the table. With the optional apex-file input, a list of names such as the Apex in the delta, the table lists those names only, and a name with no coverage is marked as not covered. The coverage threshold is an input with no default, which the calling workflow passes. A cell of a table is made safe first.

Context: Section 4, Developer Feedback Quality and Shift-Left Visibility.

Expand to show Deployment Validation Report GitHub action
Deployment Validation Report
# .github/actions/sf-validation-report/action.yml
name: Deployment Validation Report
description: Parse the JSON result of a deployment validation (sf project deploy start --dry-run --json) and return a markdown report, for a job summary, a PR comment, or both.

inputs:
  results-path:
    description: "Path to the JSON result of sf project deploy start --json. When the file is missing or is not JSON, the report says that the validation wrote no result"
    required: true
  coverage-threshold:
    description: "Coverage percentage below which a class or trigger in the coverage table is marked Low. The calling workflow passes it; there is no default here"
    required: true
  test-level:
    description: "The test level of the validation, shown in the report. Leave empty to leave it out"
    required: false
    default: ""
  apex-file:
    description: "Optional path to a file with one Apex class or trigger name per line, such as the classes and triggers of a delta package. When provided, the coverage table lists those names only, and a name with no coverage entry is marked Not covered. When empty, the table lists every class and trigger in the result"
    required: false
    default: ""
  title:
    description: "The heading of the report. A caller that posts the report with post-pr-comment passes '## ' and this title as the marker. Default: 'Metadata Validation and Apex Tests'"
    required: false
    default: "Metadata Validation and Apex Tests"

outputs:
  markdown:
    description: "The whole report as markdown: the heading, the status line, the coverage of the classes in this validation, and the tables that have rows"
    value: ${{ steps.parse.outputs.markdown }}
  markdown-file:
    description: "Path to a file that holds the same markdown. A job summary reads the file, which has no size limit of its own"
    value: ${{ steps.parse.outputs.markdown-file }}
  status:
    description: "'Passed', 'Failed', or 'No result'"
    value: ${{ steps.parse.outputs.status }}
  tests-run:
    description: "Number of Apex tests that ran"
    value: ${{ steps.parse.outputs.tests-run }}
  tests-failed:
    description: "Number of Apex tests that failed"
    value: ${{ steps.parse.outputs.tests-failed }}
  coverage:
    description: "Coverage of the classes in this validation, such as 98%; empty when the result has no coverage"
    value: ${{ steps.parse.outputs.coverage }}

runs:
  using: composite
  steps:
    - name: Parse the validation result and build the report
      id: parse
      shell: bash
      env:
        RESULTS_FILE: ${{ inputs.results-path }}
        THRESHOLD: ${{ inputs.coverage-threshold }}
        TEST_LEVEL: ${{ inputs.test-level }}
        APEX_FILE: ${{ inputs.apex-file }}
        TITLE: ${{ inputs.title }}
      run: |
        REPORT_FILE=$(mktemp "${RUNNER_TEMP:-/tmp}/validation-report.XXXXXX")

        # The report goes out twice: as a file, and as a multiline output.
        publish() {
          echo "markdown-file=$REPORT_FILE" >> "$GITHUB_OUTPUT"
          {
            echo "markdown<<EOF_MARKDOWN"
            cat "$REPORT_FILE"
            echo "EOF_MARKDOWN"
          } >> "$GITHUB_OUTPUT"
        }

        # ================================================================
        # No result: the validation step wrote no file, or wrote text that
        # is not JSON. The report says so, and names the log to read.
        # ================================================================
        if [ ! -f "$RESULTS_FILE" ] || ! jq -e . "$RESULTS_FILE" > /dev/null 2>&1; then
          {
            echo "## $TITLE - NO RESULT"
            echo ""
            echo "The validation wrote no result, so no test ran and no coverage is known. Read the log of the step that runs the validation."
          } > "$REPORT_FILE"
          {
            echo "status=No result"
            echo "tests-run=0"
            echo "tests-failed=0"
            echo "coverage="
          } >> "$GITHUB_OUTPUT"
          publish
          exit 0
        fi

        if [ "$(jq -r '.status' "$RESULTS_FILE")" = "0" ]; then
          STATUS="PASSED"
          STATUS_LABEL="Passed"
        else
          STATUS="FAILED"
          STATUS_LABEL="Failed"
        fi
        TESTS_RUN=$(jq -r '.result.details.runTestResult.numTestsRun // 0' "$RESULTS_FILE")
        TESTS_FAILED=$(jq -r '.result.details.runTestResult.numFailures // 0' "$RESULTS_FILE")

        # The names of the apex-file, as a JSON list; null when there is none.
        APEX_NAMES=null
        if [ -n "$APEX_FILE" ] && [ -f "$APEX_FILE" ]; then
          APEX_NAMES=$(sed 's/[[:space:]]//g' "$APEX_FILE" | jq -R 'select(length > 0)' | jq -s 'unique')
        fi

        # rows: a list, also where the result has one object or nothing.
        # cell: text that is safe in a table cell. A "|" does not start a
        # column, a line break does not end the row, HTML is shown as text,
        # and a missing value is an empty cell.
        # pct: the coverage of one entry, rounded to the nearest whole number,
        # as Salesforce rounds. The result has no percentage of its own.
        DEFS='def rows: if type == "array" then . elif . == null then [] else [.] end;
          def cell: (. // "") | tostring | gsub("\\|"; "\\|") | gsub("<"; "&lt;") | gsub("\r?\n"; "<br>");
          def pct: 100 * ((.numLocations | tonumber) - (.numLocationsNotCovered | tonumber)) / (.numLocations | tonumber) | round;'

        # ================================================================
        # The coverage of the classes and triggers in this validation: the
        # covered lines of all of them over all their lines.
        # ================================================================
        COVERAGE=$(jq -r "$DEFS"'
          (.result.details.runTestResult.codeCoverage | rows) as $cov
          | ([$cov[] | (.numLocations | tonumber)] | add // 0) as $total
          | ([$cov[] | (.numLocationsNotCovered | tonumber)] | add // 0) as $uncovered
          | if $total > 0 then "\(100 * ($total - $uncovered) / $total | round)%" else "" end
        ' "$RESULTS_FILE")
        LINES_NOT_COVERED=$(jq -r "$DEFS"'
          [.result.details.runTestResult.codeCoverage | rows | .[] | (.numLocationsNotCovered | tonumber)] | add // 0
        ' "$RESULTS_FILE")
        LINES_COVERED=$(jq -r "$DEFS"'
          [.result.details.runTestResult.codeCoverage | rows | .[] | (.numLocations | tonumber)] | add // 0
        ' "$RESULTS_FILE")
        LINES_COVERED=$((LINES_COVERED - LINES_NOT_COVERED))

        # ================================================================
        # Tables. Each one is empty when it has no rows, and is then left out.
        # ================================================================
        FAILURE_TABLE=$(jq -r "$DEFS"'
          .result.details.componentFailures | rows
          | if length > 0 then
              "### Component Failures\n\n| Component | Type | Line | Problem |\n|---|---|---:|---|\n"
              + (map("| `\(.fullName)` | \(.componentType | cell) | \(.lineNumber // "" | cell) | \(.problem | cell) |") | join("\n"))
            else "" end
        ' "$RESULTS_FILE")

        # A stack trace can have many lines, so it is not in the table. The
        # traces are below the table, in a block that the reader opens.
        # thrown: where the error was thrown, from the first frame of the stack
        # trace ("Class.Service.run: line 42, column 1"). It is "line 42" when
        # that frame is the test method itself, and "`Service.run`, line 42"
        # when it is somewhere else; empty when the trace has no line.
        TEST_FAILURE_TABLE=$(jq -r "$DEFS"'
          def thrown: . as $test
            | [(.stackTrace // "") | (split("\n") | first // "") | ltrimstr(" ")
               | capture("^(?<frame>.+): line (?<line>[0-9]+), column [0-9]+")] | first
            | if . == null then ""
              elif (.frame | endswith("." + $test.name + "." + $test.methodName)) then "line \(.line)"
              else "`\(.frame | sub("^Class\\."; ""))`, line \(.line)" end;
          .result.details.runTestResult.failures | rows
          | if length > 0 then
              "### Failing Tests\n\n| Test | Method | Thrown at | Message |\n|---|---|---|---|\n"
              + (map("| `\(.name)` | `\(.methodName)` | \(thrown) | \(.message | cell) |") | join("\n"))
              + (map(select((.stackTrace // "") != ""))
                | if length > 0 then
                    "\n\n<details>\n<summary>Stack traces of the failing tests</summary>\n\n"
                    + (map("**`\(.name).\(.methodName)`**\n\n```text\n\(.stackTrace)\n```") | join("\n\n"))
                    + "\n\n</details>"
                  else "" end)
            else "" end
        ' "$RESULTS_FILE")

        COVERAGE_WARNING_TABLE=$(jq -r "$DEFS"'
          .result.details.runTestResult.codeCoverageWarnings | rows
          | if length > 0 then
              "### Coverage Warnings\n\n| Class or trigger | Warning |\n|---|---|\n"
              + (map("| `\(.name // "All")` | \(.message | cell) |") | join("\n"))
            else "" end
        ' "$RESULTS_FILE")

        # Each class and trigger with its coverage and a status. A class with
        # no lines (an exception class, for example) has no coverage to show,
        # so it is left out. With an apex-file, a name with no entry in the
        # result is listed as not covered, unless it is a test class, which
        # has no coverage of its own.
        COVERAGE_TABLE=$(jq -r --argjson names "$APEX_NAMES" --argjson threshold "$THRESHOLD" "$DEFS"'
          (.result.details.runTestResult.codeCoverage | rows) as $cov
          | (if $names == null
              then [ $cov[] | select((.numLocations | tonumber) > 0) | {name, pct: pct} ]
              else [ $names[] as $name
                | ( $cov[] | select(.name == $name and (.numLocations | tonumber) > 0) | {name: $name, pct: pct} ),
                  ( select(($cov | map(.name) | index($name)) == null and ($name | test("Tests?$") | not))
                    | {name: $name, pct: null} ) ]
              end)
          | sort_by([(.pct // -1), .name])
          | if length > 0 then
              "### Coverage by Class and Trigger\n\nThreshold: \($threshold)%\n\n| Class or trigger | Coverage | Status |\n|---|---:|:---:|\n"
              + (map(if .pct == null
                    then "| `\(.name)` | No data | Not covered |"
                    else "| `\(.name)` | \(.pct)% | \(if .pct < $threshold then "Low" else "OK" end) |" end)
                | join("\n"))
            else "" end
        ' "$RESULTS_FILE")

        # ================================================================
        # The report
        # ================================================================
        STATUS_LINE="**Status:** $STATUS_LABEL"
        if [ -n "$TEST_LEVEL" ]; then
          STATUS_LINE="$STATUS_LINE | **Test level:** $TEST_LEVEL"
        fi
        STATUS_LINE="$STATUS_LINE | **Tests run:** $TESTS_RUN | **Tests failed:** $TESTS_FAILED"
        {
          echo "## $TITLE - $STATUS"
          echo ""
          echo "$STATUS_LINE"
          echo ""
          if [ -n "$COVERAGE" ]; then
            # Two spaces at the end of a line end the line, inside one paragraph.
            echo "**Coverage of the classes in this validation:** $COVERAGE  "
            echo "Lines Covered: $LINES_COVERED  "
            echo "Lines Not Covered: $LINES_NOT_COVERED"
          else
            echo "**Coverage of the classes in this validation:** No data"
          fi
          echo ""
          for TABLE in "$FAILURE_TABLE" "$TEST_FAILURE_TABLE" "$COVERAGE_WARNING_TABLE" "$COVERAGE_TABLE"; do
            if [ -n "$TABLE" ]; then
              echo "$TABLE"
              echo ""
            fi
          done
        } > "$REPORT_FILE"

        {
          echo "status=$STATUS_LABEL"
          echo "tests-run=$TESTS_RUN"
          echo "tests-failed=$TESTS_FAILED"
          echo "coverage=$COVERAGE"
        } >> "$GITHUB_OUTPUT"
        publish

A.3.8 - 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.

Context: Section 4, Developer Feedback Quality and Shift-Left Visibility.

Expand to show Post PR Comment GitHub action
Post PR Comment
# .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
            });
          }