Skip to content

A.2 - Reusable Quality Gate Workflows

Each reusable workflow implements one quality gate, with one exception: A.2.11 chooses the Apex tests that the validation in A.2.12 runs. 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.

The sections are in the order the gates run. On a pull request, A.2.1 to A.2.7 run first, in parallel. A.2.8 builds the delta package once all seven have passed, so a pull request that fails a quick gate ends there. A.2.9 to A.2.11 work on the delta package, in parallel, and A.2.12 validates it against the org. A.2.13 runs on a push to main, A.2.14 to A.2.16 run on a schedule, and A.2.17 reports for them.

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.2.1 - Prettier Formatting Verification

Runs prettier --check against the whole repository, or against the folders that the caller passes in the source-path input, as a comma-separated list. The sample callers pass no folder. Fails if any file is not formatted. The file types that it checks are the ones that the commit hook formats: the same list is in the lint-staged settings and in the npm scripts (Version Control: Husky and lint-staged).

The standard this gate enforces: the formatting enforcement in Section 5, Extensible Quality Gate Plugins.

Expand to show Prettier Formatting Verification reusable workflow
Prettier Formatting Verification
# .github/workflows/sf-prettier-verify.yml
# Reusable: runs prettier --check and fails if any file is not formatted.
# It checks the folders of the source-path input, or the whole repository
# when the input is empty.

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: "The folders to check, as a comma-separated list. When empty: the whole repository"
        required: false
        type: string

jobs:
  prettier-verify:
    name: Formatting Verification
    runs-on: ubuntu-latest
    permissions:
      contents: read
    steps:
      - name: Checkout repository
        uses: actions/checkout@v7

      - name: Setup Node.js
        uses: actions/setup-node@v7
        with:
          node-version: ${{ inputs.node-version }}
          cache: npm

      - name: Install dependencies
        run: npm ci

      - name: Verify formatting
        env:
          # The input, or the whole repository when it is empty
          FOLDERS: ${{ inputs.source-path || '.' }}
        run: |
          # One pattern for each folder, with the spaces around each comma removed
          patterns=()
          while IFS= read -r folder; do
            patterns+=("$folder/**/*.{apex,app,auradoc,cls,cmp,component,css,design,evt,html,intf,js,json,md,page,tokens,trigger,xml,yaml,yml}")
          done < <(printf '%s' "$FOLDERS" | tr ',' '\n' | sed 's/^[[:space:]]*//; s/[[:space:]]*$//' | grep -v '^$')
          npx prettier --check "${patterns[@]}"

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

The standard this gate enforces: Section 4, PR and Commit Message Conventions.

Expand to show SemVer / Conventional Commits Check reusable workflow
SemVer / Conventional Commits Check
# .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.3 - Secret Scanning

The workflow file is generic; its scan step is a placeholder. Replace the step with the organisation's chosen scanner. The configuration file should be centralised in the configuration repository to ensure consistent pattern detection across all project repositories.

The scanner writes its findings to a JSON report in the Gitleaks format: the rule, the file, the line, and the commit of each finding. A finding fails the job, and the job summary lists the findings in a table. A pushed secret stays on GitHub even after a squash merge, so a finding is also posted to a security channel in Slack when the caller passes the secret SLACK_SECURITY_WEBHOOK. The post links to the file at the line of each finding, to the commit, and to the pull request when there is one. The caller can pass a Slack mention (slack-mention), such as a user group of security officers, and a text for the post (slack-message), such as what to do first. Without the webhook, the job still fails, and nothing is posted.

The standard this gate enforces: Section 7, Secret Scanning: Preventing Credential Leaks. What a scan must do, and some scanners to choose from, are on the Version Control page: Secret Scanning.

Expand to show Secret Scanning reusable workflow
Secret Scanning
# .github/workflows/sf-secret-scan.yml
# Reusable: runs secret scanning against the commits of a push or a pull
# request. Replace the placeholder scan step with your organization's chosen
# tool (e.g. Gitleaks, TruffleHog, GitHub secret scanning).
#
# A finding fails the job. A pushed secret stays on GitHub even after a
# squash merge, so a finding is also posted to a security channel in Slack
# when the caller passes SLACK_SECURITY_WEBHOOK: the post names each
# finding's rule, file, and line, with links to the file and to the commit,
# and to the pull request when there is one, so that a person investigates
# and rotates the secret. A false positive costs a look; a missed secret
# costs a rotation that nobody started.

name: Secret Scanning

on:
  workflow_call:
    inputs:
      slack-mention:
        description: "Optional Slack mention at the top of a post about a finding: <@U0123ABCD> for a person, or <!subteam^S0123ABCD> for a user group"
        required: false
        type: string
        default: ""
      slack-message:
        description: "Optional text for a post about a finding, such as what to do first"
        required: false
        type: string
        default: ""
    secrets:
      SLACK_SECURITY_WEBHOOK:
        description: "Slack incoming webhook of the security channel; without it, a finding fails the job and posts nothing"
        required: false
    outputs:
      finding:
        description: "'true' when the scan found a possible secret"
        value: ${{ jobs.secret-scan.outputs.finding }}
      report:
        description: "The findings, with their links, as the Slack post gives them; empty when nothing was found"
        value: ${{ jobs.secret-scan.outputs.report }}

jobs:
  secret-scan:
    name: Secret Scanning
    runs-on: ubuntu-latest
    permissions:
      contents: read
    outputs:
      finding: ${{ steps.report.outputs.finding }}
      report: ${{ steps.report.outputs.report }}
      notify: ${{ steps.report.outputs.notify }}
    steps:
      - name: Checkout repository
        uses: actions/checkout@v7
        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's own secret scanning (GitHub Secret Protection).
          # Ensure the tool references the centralised configuration
          # file from the template repository for consistent pattern detection.
          # The tool writes its findings to secret-scan-report.json, as a JSON
          # array in the Gitleaks report format: one object for each finding,
          # with RuleID, File, StartLine, and Commit. For another scanner,
          # convert its report to that form, or change the next step.
          echo '[]' > secret-scan-report.json

      - name: Report the findings
        id: report
        env:
          REPO_URL: ${{ github.server_url }}/${{ github.repository }}
          PR_URL: ${{ github.event.pull_request.html_url }}
          HEAD_SHA: ${{ github.event.pull_request.head.sha || github.sha }}
          REF_NAME: ${{ github.head_ref || github.ref_name }}
          ACTOR: ${{ github.actor }}
          MENTION: ${{ inputs.slack-mention }}
          MESSAGE: ${{ inputs.slack-message }}
          HAS_WEBHOOK: ${{ secrets.SLACK_SECURITY_WEBHOOK != '' }}
        run: |
          count="$(jq 'length' secret-scan-report.json)"
          if [ "$count" -eq 0 ]; then
            echo "finding=false" >> "$GITHUB_OUTPUT"
            echo "No secret was found." >> "$GITHUB_STEP_SUMMARY"
            exit 0
          fi

          # One line for each finding: the rule, a link to the file at the
          # line where it was found, and a link to the commit that holds it.
          findings="$(jq -r --arg repo "$REPO_URL" '.[] |
            "• \(.RuleID): <\($repo)/blob/\(.Commit)/\(.File)#L\(.StartLine)|\(.File) line \(.StartLine)>, commit <\($repo)/commit/\(.Commit)|\(.Commit[0:7])>"
          ' secret-scan-report.json)"

          {
            if [ -n "$MENTION" ]; then echo "$MENTION"; fi
            echo "*$count possible secret(s) in ${GITHUB_REPOSITORY}*, pushed by $ACTOR to \`$REF_NAME\`."
            if [ -n "$PR_URL" ]; then echo "Pull request: <$PR_URL|$PR_URL>"; fi
            echo "Commit scanned: <$REPO_URL/commit/$HEAD_SHA|${HEAD_SHA:0:7}>"
            echo "$findings"
            if [ -n "$MESSAGE" ]; then echo "$MESSAGE"; fi
          } > report.txt

          {
            echo "finding=true"
            echo "notify=$HAS_WEBHOOK"
            echo "report<<REPORT_END"
            cat report.txt
            echo "REPORT_END"
          } >> "$GITHUB_OUTPUT"

          {
            echo "### Possible secrets"
            echo ""
            echo "| Rule | File | Line | Commit |"
            echo "| --- | --- | --- | --- |"
            jq -r '.[] | "| \(.RuleID) | \(.File) | \(.StartLine) | \(.Commit[0:7]) |"' secret-scan-report.json
            echo ""
            echo "A pushed secret stays on GitHub after the file is changed. Revoke or rotate it."
          } >> "$GITHUB_STEP_SUMMARY"
          exit 1

  # ── Post a finding to the security channel ──────────────────
  # always() lets this job run after the scan job failed on a finding. It
  # runs only when there was a finding and the caller passed the webhook.

  notify-security:
    name: Notify Security
    needs: secret-scan
    if: always() && needs.secret-scan.outputs.finding == 'true' && needs.secret-scan.outputs.notify == 'true'
    uses: ./.github/workflows/sf-notify-slack.yml
    with:
      status: "Possible secret"
      title: "Secret scan finding in ${{ github.repository }}"
      message: ${{ needs.secret-scan.outputs.report }}
      run-url: "https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }}"
    secrets:
      SLACK_WEBHOOK_URL: ${{ secrets.SLACK_SECURITY_WEBHOOK }}

A.2.4 - Prohibited Metadata Check (Tier 0)

Fails the PR if its diff adds or modifies any Tier 0 path listed in the machine-readable risk matrix, metadata-risk-tiers.yml (Appendix B). It is the required-status-check layer of Tier 0 enforcement: the fallback where GitHub push rulesets are unavailable, and a second line of defence where the push ruleset already rejects the commit. Deletions are deliberately allowed, so a PR that removes a previously committed Tier 0 file passes. Because the workflow reads its patterns from the matrix file, it holds no copy of the Tier 0 list itself.

The standard this gate enforces: Section 6, PR-Time: Metadata Type Risk Classification and the Tier 0 enforcement in Appendix B.

Expand to show Prohibited Metadata Check reusable workflow
Prohibited Metadata Check
# .github/workflows/sf-prohibited-metadata.yml
# Reusable: fails the PR if it adds or modifies any Tier 0 (prohibited)
# metadata path listed in the risk matrix file. Deletions pass, so
# previously committed Tier 0 files can be removed.

name: Prohibited Metadata Check

on:
  workflow_call:
    inputs:
      risk-tiers-path:
        description: "Path to the machine-readable risk matrix"
        type: string
        required: false
        default: ".github/metadata-risk-tiers.yml"

jobs:
  prohibited-metadata:
    name: Prohibited Metadata Check
    runs-on: ubuntu-latest
    permissions:
      contents: read
    steps:
      - name: Checkout repository
        uses: actions/checkout@v7
        with:
          fetch-depth: 0

      - name: Check for Tier 0 paths
        env:
          BASE_REF: ${{ github.base_ref }}
          RISK_TIERS: ${{ inputs.risk-tiers-path }}
        run: |
          # yq is preinstalled on GitHub-hosted Ubuntu runners.
          yq -o=json '.' "$RISK_TIERS" > "$RUNNER_TEMP/tiers.json"
          # A = added, C = copied, M = modified, R = renamed (new path).
          # D (deleted) is excluded so Tier 0 files can be cleaned up.
          git diff --name-only --diff-filter=ACMR "origin/${BASE_REF}...HEAD" \
            > "$RUNNER_TEMP/changed.txt"

          python3 - "$RUNNER_TEMP/tiers.json" "$RUNNER_TEMP/changed.txt" <<'EOF'
          import fnmatch, json, os, sys

          tier0 = json.load(open(sys.argv[1])).get("tier0") or []
          changed = [p for p in open(sys.argv[2]).read().splitlines() if p]
          matches = [p for p in changed if any(fnmatch.fnmatch(p, t) for t in tier0)]

          with open(os.environ["GITHUB_STEP_SUMMARY"], "a") as summary:
              if not matches:
                  summary.write("No Tier 0 paths in this PR.\n")
                  sys.exit(0)
              summary.write("## Prohibited metadata detected\n\n")
              summary.write("These paths are Tier 0 metadata and must never be committed. ")
              summary.write("Manage them directly in each org, remove them from this PR, ")
              summary.write("and confirm they are listed in .forceignore and .gitignore.\n\n")
              for path in matches:
                  summary.write(f"- `{path}`\n")
                  print(f"::error file={path}::Tier 0 prohibited metadata: {path}")
          sys.exit(1)
          EOF

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

The standard this gate enforces: Section 6, PR-Time: Destructive Change Detection and Governance.

Expand to show Destructive Change Detection reusable workflow
Destructive Change Detection
# .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@v7
        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.6 - Metadata Risk Tier

Classifies the PR by the highest metadata risk tier among its changed files, using the same metadata-risk-tiers.yml matrix, and labels the PR risk: tier-0, risk: tier-1, risk: tier-2, or risk: tier-3. Create all four labels in each repository, so that every PR is labelled with its tier. The workflow adds a label only if the repository has it: a missing label gives a notice, and an error gives a warning, so a label never fails the gate. A Tier 0 file makes the PR Tier 0 and gets its label, although the Prohibited Metadata Check fails the PR for it. A metadata file (one ending in -meta.xml) that matches no tier is treated as Tier 2 and raises a warning, so that a new metadata type is added to the matrix rather than silently passing as standard risk.

The standard this gate enforces: Section 6, PR-Time: Metadata Type Risk Classification and the Tier 1 enforcement in Appendix B.

On Tier 0 and Tier 1, the workflow disables auto-merge, so a Tier 1 change is always merged by hand. The architect team's review is not enforced here but by the Tier 1 block in CODEOWNERS (Appendix B) together with required code owner review in the fully protected branch rulesets (Appendix C). A failing check could never be satisfied by an approval, so this workflow passes on Tier 1 rather than failing. Deletions count towards the tier: removing a Tier 1 component is itself a Tier 1 change.

When a PR has a Tier 1 file, whatever its highest tier, the workflow also comments on each Tier 1 file in the pull request diff. The comment is on the file, not on one of its lines: it names the pattern of the tier1 list that the file matches, says why a Tier 1 change needs the architect team's review, and asks the reviewer to resolve the conversation when the review of that file is done. A file that has the comment from an earlier push does not get a second one. The comment does not block the merge by itself. Every branch ruleset requires each review conversation to be resolved before a pull request merges, so a Tier 1 change does not merge until the conversation on each of its Tier 1 files is resolved (Appendix C).

Expand to show Metadata Risk Tier reusable workflow
Metadata Risk Tier
# .github/workflows/sf-risk-tier.yml
# Reusable: labels the PR with the highest metadata risk tier among its
# changed files, Tier 0 included, using the risk matrix file, and disables
# auto-merge on Tier 0 and Tier 1. It also comments on each Tier 1 file in the PR diff, to say why
# the file needs the architect team's review. That review is enforced by
# CODEOWNERS and required code owner review, not by this workflow.

name: Metadata Risk Tier

on:
  workflow_call:
    inputs:
      risk-tiers-path:
        description: "Path to the machine-readable risk matrix"
        type: string
        required: false
        default: ".github/metadata-risk-tiers.yml"
    outputs:
      tier:
        description: "Highest risk tier in the PR (0, 1, 2, or 3)"
        value: ${{ jobs.risk-tier.outputs.tier }}

jobs:
  risk-tier:
    name: Metadata Risk Tier
    runs-on: ubuntu-latest
    permissions:
      contents: read
      pull-requests: write
      issues: write
    outputs:
      tier: ${{ steps.classify.outputs.tier }}
    steps:
      - name: Checkout repository
        uses: actions/checkout@v7
        with:
          fetch-depth: 0

      - name: Classify changed files
        id: classify
        env:
          BASE_REF: ${{ github.base_ref }}
          RISK_TIERS: ${{ inputs.risk-tiers-path }}
        run: |
          # yq is preinstalled on GitHub-hosted Ubuntu runners.
          yq -o=json '.' "$RISK_TIERS" > "$RUNNER_TEMP/tiers.json"
          # All changes count, including deletions.
          git diff --name-only "origin/${BASE_REF}...HEAD" > "$RUNNER_TEMP/changed.txt"

          python3 - "$RUNNER_TEMP/tiers.json" "$RUNNER_TEMP/changed.txt" <<'EOF'
          import fnmatch, json, os, sys

          tiers = json.load(open(sys.argv[1]))
          changed = [p for p in open(sys.argv[2]).read().splitlines() if p]

          def matches(path, tier):
              return any(fnmatch.fnmatch(path, t) for t in tiers.get(tier) or [])

          unclassified = []

          def tier_1_pattern(path):
              return next(t for t in tiers["tier1"] if fnmatch.fnmatch(path, t))

          def tier_of(path):
              for number in (0, 1, 2, 3):
                  if matches(path, f"tier{number}"):
                      return number
              # Unmatched metadata is treated as Tier 2 but flagged, so that
              # the new type is classified in the matrix.
              if path.endswith("-meta.xml"):
                  unclassified.append(path)
              return 2

          # A Tier 0 file is classified and labelled too, although the Prohibited
          # Metadata Check fails the PR for it.
          classified = {p: tier_of(p) for p in changed}
          highest = min(classified.values(), default=3)

          with open(os.environ["GITHUB_OUTPUT"], "a") as output:
              output.write(f"tier={highest}\n")
          # Each Tier 1 file and the pattern it matches, for the comments.
          tier_1 = [{"path": p, "pattern": tier_1_pattern(p)} for p, t in sorted(classified.items()) if t == 1]
          with open(os.path.join(os.environ["RUNNER_TEMP"], "tier1.json"), "w") as out:
              json.dump(tier_1, out)
          with open(os.environ["GITHUB_OUTPUT"], "a") as output:
              output.write(f"tier-1-files={len(tier_1)}\n")
          with open(os.environ["GITHUB_STEP_SUMMARY"], "a") as summary:
              summary.write(f"## Metadata risk: Tier {highest}\n\n")
              summary.write("| File | Tier |\n| --- | --- |\n")
              for path, tier in sorted(classified.items(), key=lambda item: item[1]):
                  summary.write(f"| `{path}` | {tier} |\n")
              if unclassified:
                  summary.write("\n### Unclassified metadata\n\n")
                  summary.write("Treated as Tier 2. Add these types to metadata-risk-tiers.yml.\n\n")
                  for path in unclassified:
                      summary.write(f"- `{path}`\n")
                      print(f"::warning file={path}::Not in the risk matrix; treated as Tier 2.")
          EOF

      - name: Label PR with its risk tier
        uses: actions/github-script@v9
        env:
          TIER: ${{ steps.classify.outputs.tier }}
        with:
          script: |
            // The labels are optional, and a label never fails the gate. The tier's
            // label is added only if the repository has it, so an organisation that
            // does not want the labels does not create them.
            const { owner, repo } = context.repo;
            const issue_number = context.issue.number;
            const label = `risk: tier-${process.env.TIER}`;
            for (const name of ['risk: tier-0', 'risk: tier-1', 'risk: tier-2', 'risk: tier-3']) {
              if (name !== label) {
                await github.rest.issues.removeLabel({ owner, repo, issue_number, name }).catch(() => {});
              }
            }
            try {
              await github.rest.issues.getLabel({ owner, repo, name: label });
            } catch {
              core.notice(`The label "${label}" does not exist in this repository, so the PR is not labelled.`);
              return;
            }
            try {
              await github.rest.issues.addLabels({ owner, repo, issue_number, labels: [label] });
            } catch (error) {
              core.warning(`The PR could not be labelled "${label}": ${error.message}`);
            }

      - name: Disable auto-merge on Tier 0 and Tier 1
        if: steps.classify.outputs.tier == '0' || steps.classify.outputs.tier == '1'
        env:
          GH_TOKEN: ${{ github.token }}
          PR_NUMBER: ${{ github.event.pull_request.number }}
          TIER: ${{ steps.classify.outputs.tier }}
        run: |
          enabled=$(gh pr view "$PR_NUMBER" --repo "$GITHUB_REPOSITORY" \
            --json autoMergeRequest --jq '.autoMergeRequest != null')
          if [ "$enabled" = "true" ]; then
            gh pr merge "$PR_NUMBER" --repo "$GITHUB_REPOSITORY" --disable-auto
            echo "::warning::Auto-merge disabled: Tier ${TIER} metadata is not merged automatically."
          fi
          echo "Tier ${TIER} metadata: auto-merge is not permitted for this PR." >> "$GITHUB_STEP_SUMMARY"

      - name: Comment on each Tier 1 file
        if: steps.classify.outputs.tier-1-files != '0'
        uses: actions/github-script@v9
        env:
          RISK_TIERS: ${{ inputs.risk-tiers-path }}
        with:
          script: |
            // One comment on each Tier 1 file, on the file itself in the diff.
            // A file that has the comment from an earlier push gets no second one.
            const fs = require('fs');
            const path = require('path');
            const { owner, repo } = context.repo;
            const pull_number = context.issue.number;
            const marker = '<!-- metadata-risk-tier-1 -->';
            const files = JSON.parse(fs.readFileSync(path.join(process.env.RUNNER_TEMP, 'tier1.json'), 'utf8'));
            const comments = await github.paginate(github.rest.pulls.listReviewComments, { owner, repo, pull_number, per_page: 100 });
            const commented = new Set(comments.filter((c) => c.body.includes(marker)).map((c) => c.path));
            const uncommented = files.filter((f) => !commented.has(f.path));
            for (const file of uncommented) {
              await github.rest.pulls.createReviewComment({
                owner,
                repo,
                pull_number,
                commit_id: context.payload.pull_request.head.sha,
                path: file.path,
                subject_type: 'file',
                body: [
                  marker,
                  `**Tier 1 metadata.** This file matches \`${file.pattern}\` in the \`tier1\` list of \`${process.env.RISK_TIERS}\`.`,
                  '',
                  'Tier 1 metadata controls access, authentication, or the behaviour of the whole org, so a mistake here can have broad consequences that are hard to reverse. The architect team reviews this change, and the pull request is merged by hand.',
                  '',
                  'Resolve this conversation when the review of this file is done.'
                ].join('\n')
              });
            }
            await core.summary.addRaw(`Tier 1 files: ${files.length}. Comments added: ${uncommented.length}.`).write();

Because the PR callers run on opened, synchronize, and ready_for_review, auto-merge enabled after this workflow has run would not be caught. The small caller in A.1.6 re-runs it whenever auto-merge is enabled.

A.2.7 - Changelog Check

Fails the PR if it changes CHANGELOG.md and it is not the release pull request. Release Please alone writes the changelog, from the titles of the merged pull requests, so a change to it from any other pull request is a mistake, whether a person made it or a tool did.

The gate passes a PR in three cases:

  • It is the release pull request: its branch is in this repository, and has the name that Release Please gives its branch, which starts with release-please--branches--.
  • It does not change the file.
  • Its copy of the file is the copy on the default branch. This is a pull request that carries a release from main to another long-lived branch.

The gate reads the name of the branch, so it stops a mistake and not a person who names a branch in that way on purpose. An organisation that wants to stop that as well adds a branch ruleset that lets only the account of the Release Please token create and update a branch named release-please--**. The ruleset does not limit who merges the release pull request, because a merge writes to the target branch.

The standard this gate enforces: Version Control, Release Please.

Expand to show Changelog Check reusable workflow
Changelog Check
# .github/workflows/sf-changelog-check.yml
# Reusable: fails the PR if it changes the changelog and it is not the
# release pull request. Release Please alone writes the changelog.

name: Changelog Check

on:
  workflow_call:
    inputs:
      changelog-path:
        description: "Path to the changelog that Release Please writes"
        type: string
        required: false
        default: "CHANGELOG.md"

jobs:
  changelog-check:
    name: Changelog Check
    runs-on: ubuntu-latest
    permissions:
      contents: read
    steps:
      - name: Checkout repository
        uses: actions/checkout@v7
        with:
          fetch-depth: 0

      - name: Check that only Release Please changes the changelog
        env:
          BASE_REF: ${{ github.base_ref }}
          HEAD_REF: ${{ github.head_ref }}
          HEAD_REPO: ${{ github.event.pull_request.head.repo.full_name }}
          DEFAULT_BRANCH: ${{ github.event.repository.default_branch }}
          CHANGELOG: ${{ inputs.changelog-path }}
        run: |
          # 1. The release pull request. Release Please names its branch
          #    release-please--branches--<target>, and the branch is in
          #    this repository, not in a fork.
          if [[ "$HEAD_REF" == release-please--branches--* && "$HEAD_REPO" == "$GITHUB_REPOSITORY" ]]; then
            echo "The release pull request: Release Please writes \`$CHANGELOG\`." >> "$GITHUB_STEP_SUMMARY"
            exit 0
          fi

          # 2. The PR does not change the file.
          if git diff --quiet "origin/${BASE_REF}...HEAD" -- "$CHANGELOG"; then
            echo "This PR does not change \`$CHANGELOG\`." >> "$GITHUB_STEP_SUMMARY"
            exit 0
          fi

          # 3. The PR carries a release from the default branch to another
          #    long-lived branch: its copy of the file is the released copy.
          HEAD_BLOB=$(git rev-parse --quiet --verify "HEAD:${CHANGELOG}" || true)
          RELEASED_BLOB=$(git rev-parse --quiet --verify "origin/${DEFAULT_BRANCH}:${CHANGELOG}" || true)
          if [[ -n "$HEAD_BLOB" && "$HEAD_BLOB" == "$RELEASED_BLOB" ]]; then
            echo "This PR carries the released \`$CHANGELOG\` from \`$DEFAULT_BRANCH\`." >> "$GITHUB_STEP_SUMMARY"
            exit 0
          fi

          {
            echo "## The changelog was changed by hand"
            echo ""
            echo "Release Please alone writes \`$CHANGELOG\`, from the titles of the merged pull requests."
            echo "Remove the change to that file from this PR."
          } >> "$GITHUB_STEP_SUMMARY"
          echo "::error file=${CHANGELOG}::Only Release Please changes ${CHANGELOG}. Remove the change from this PR."
          exit 1

A.2.8 - Generate Delta Package

Generates an sfdx-git-delta delta 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.

The delta covers the project metadata directories: the folders of the repository that hold its Salesforce metadata, which sfdx-project.json lists under packageDirectories. The workflow takes the first of these that has a value: the source-dir input; the repository variable SF_METADATA_DIR (A.4); and force-app, the one project metadata directory of a new project. The input and the variable are each a comma-separated list, such as force-app, service-cloud, and each folder goes to sfdx-git-delta as a --source-dir flag of its own. The sample callers pass no input, so a project whose metadata is not in force-app sets the variable once.

Each job that reads the delta package first checks its package.xml with grep -q '<types>'. A delta package with no deployable metadata, as when no metadata changed, has no <types> element, and the job then skips the steps that need an org, or that scan or test the change.

The pattern this workflow implements: differential scanning in Section 5, Configuring GitHub Actions to Enforce Quality Gates and Appendix E, Delta-Scoped Analysis with sfdx-git-delta.

Expand to show Generate Delta Package reusable workflow
Generate Delta Package
# .github/workflows/sf-generate-delta.yml
# Reusable: generates a sfdx-git-delta delta 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-test-selection,
# jest-tests, validate-deployment) download this artifact by name rather
# than regenerating it independently.
#
# The delta covers the project metadata directories: the source-dir input,
# then the repository variable SF_METADATA_DIR, then force-app; the first
# that has a value. Each is a comma-separated list, such as
# "force-app, service-cloud".

name: Generate Delta Package

on:
  workflow_call:
    inputs:
      source-dir:
        description: "The project metadata directories, as a comma-separated list; each is passed to --source-dir. When empty: the repository variable SF_METADATA_DIR, and then force-app"
        required: false
        type: string
      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@v7
        with:
          fetch-depth: 0

      - name: Resolve the project metadata directories
        id: folders
        env:
          # The first that has a value. The input has no default, so that an
          # empty input reaches the repository variable.
          FOLDERS: ${{ inputs.source-dir || vars.SF_METADATA_DIR || 'force-app' }}
        run: |
          # One folder on each line, with the spaces around each comma removed
          {
            echo "list<<EOF"
            printf '%s' "$FOLDERS" | tr ',' '\n' | sed 's/^[[:space:]]*//; s/[[:space:]]*$//' | grep -v '^$'
            echo "EOF"
          } >> "$GITHUB_OUTPUT"

      - name: Check for changes in the project metadata directories
        id: check-changes
        env:
          BASE_REF: ${{ github.base_ref }}
          FOLDERS: ${{ steps.folders.outputs.list }}
        run: |
          mapfile -t folders <<< "$FOLDERS"
          CHANGED=$(git diff --name-only "origin/$BASE_REF...HEAD" -- "${folders[@]}" | 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'
        env:
          BASE_REF: ${{ github.base_ref }}
          FOLDERS: ${{ steps.folders.outputs.list }}
        run: |
          # One --source-dir flag for each folder
          mapfile -t folders <<< "$FOLDERS"
          source_dirs=()
          for folder in "${folders[@]}"; do
            source_dirs+=(--source-dir "$folder")
          done
          mkdir -p delta
          sf sgd source delta \
            --from "origin/$BASE_REF" \
            --to HEAD \
            --output-dir delta \
            --generate-delta \
            "${source_dirs[@]}"

      - 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.9 - Static Code Analysis

The workflow below is a sample for Salesforce Code Analyzer, the tool this reference uses for this gate. An organisation is welcome to use any static analysis tool it wishes: the tools are set out in Section 5, Choosing a Static Analysis Tool and Third-Party and AI-Powered Code Analysis Tools. The severity threshold is an input with no default: the caller sets it once, at the top of its file, and passes it (A.1).

Runs Salesforce Code Analyzer. The ApexGuru engine is not in it: every ApexGuru scan needs a logged-in org and counts against Salesforce's limits on scans, and in basic mode its findings mostly repeat the PMD rules of this gate (Appendix E). Supports three modes: pr (scans the delta artifact, uploads SARIF to Code Scanning, posts a two-table PR comment, stores the complete results as an artifact for a few days, fails on blocking violations), ci (scans the files that the push changed, writes job summary only, never fails for a violation, and fails only when the scan wrote no result), and full (scans the whole repository, writes the violation counts and a table of every blocking violation to the job summary, stores every violation as a CSV file, a JSON file, and a SARIF file in one artifact, never fails). All three install the CLI, Code Analyzer plugin, Java, and Python via setup-sf.

A push never scans the whole repository. In a large repository that scan is slow, and the violations of old code would fill the summary of every push, so nobody could see what a push added. The ci mode builds a delta package of what the push changed, with sfdx-git-delta, and scans that delta package alone, exactly as the pr mode scans the delta package of a pull request. The delta runs from the commit before the push to the new commit, so it holds every commit of the push and not the last one only. The whole repository is scanned every week, by the weekly project scan in A.1.5, and on demand, by the caller in A.1.7.

A push and a pull request scan Salesforce metadata, because that is what a delta package holds. A changed file that is not metadata, such as a script at the root of the repository, is scanned in the editor and by the full scan. The workspace input is a comma-separated list of folders, and the sample callers do not pass it. On a push it names the folders in which sfdx-git-delta looks for changed metadata; when it is empty, the push reads the repository variable SF_METADATA_DIR (A.4), as the delta package of a pull request does (A.2.8), and without the variable it uses the whole repository. In the full scan it names the folders that are scanned, and when it is empty the scan covers the whole repository; the full scan does not read the variable, because the variable names the metadata, and the full scan covers every file. The pr mode does not read the input. What to leave out of every scan is set once, under ignores.files in code-analyzer.yml, so that the editor, a push, a pull request, and the full scan all leave out the same files.

The pr and full modes store the complete results as a CSV file, a JSON file, and a SARIF file in one artifact, which GitHub gives as one zip file. On a pull request the artifact is kept for 7 days, because it serves the review of one pull request; from a full scan it is kept for 30 days. The comment on the pull request is a summary, and the artifact has every violation. It also serves a repository where GitHub code scanning is not available.

One kind of rule sees less on a push and on a pull request: copy-paste detection compares the files of the delta package with each other, and not with the rest of the repository. The full scan compares every file.

The standard this gate enforces: Section 5, Choosing a Static Analysis Tool; the engine reference is Appendix E.

Expand to show Code Analyzer reusable workflow
Code Analyzer
# .github/workflows/sf-code-analyzer.yml
# Reusable: runs sf code-analyzer run against the PR delta package, the
# files that a push changed, or the whole repository.
#
# 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)
#   - Uploads the complete results as a CSV file, a JSON file, and a SARIF
#     file in one artifact, kept for a short time
#   - Fails on violations at or above severity-threshold
#
# mode: ci
#   - Builds a delta package of what the push changed, with sfdx-git-delta,
#     from the commit before the push to the new commit, in the folders of
#     the workspace input, then of the repository variable SF_METADATA_DIR,
#     then of the whole repository; the first that has a value
#   - Scans --workspace delta, as pr mode does
#   - Writes job summary with violation counts
#   - Never fails (advisory only - does not block pushes)
#
# mode: full
#   - Scans the folders of the workspace input, or the whole repository when
#     the input is empty
#   - Uploads the results as a CSV file, a JSON file, and a SARIF file in
#     one artifact, kept for longer
#   - Writes the violation counts and a table of every blocking violation
#     (severity 1 to severity-threshold) to the job summary; the violations
#     below the threshold are in the artifact only
#   - Never fails (a report, not a gate)

name: Code Analyzer

on:
  workflow_call:
    inputs:
      mode:
        description: >
          "pr" to scan the delta artifact and post a PR comment.
          "ci" to scan the delta of the push and write a job summary only.
          "full" to scan the whole repository and store the results as an artifact.
        required: false
        type: string
        default: "ci"
      workspace:
        description: "A comma-separated list of folders. ci mode: the folders where sfdx-git-delta looks for changed metadata; when empty, the repository variable SF_METADATA_DIR, and then the whole repository. full mode: the folders to scan; when empty, the whole repository. pr mode always uses the delta artifact"
        required: false
        type: string
      artifact-name:
        description: "Name of the delta package artifact to download (pr mode only)"
        required: false
        type: string
        default: "delta-package"
      rule-selectors:
        description: "Space-separated Code Analyzer rule selectors; each becomes a --rule-selector flag"
        required: false
        type: string
        default: "Recommended DevSecOps"
      severity-threshold:
        description: "Severity level (1, 2, 3, 4, or 5) at or above which the workflow fails (1=Critical, 2=High, 3=Moderate). The caller sets it once, at the top of its file, and passes it; there is no default here"
        required: true
        type: string
      results-artifact-name:
        description: "Name of the artifact that holds the CSV, JSON, and SARIF results (pr and full modes)"
        required: false
        type: string
        default: "code-analyzer-results"
      results-retention-days:
        description: "Days to keep the results artifact. When empty: 7 days in pr mode, where the results serve one pull request, and 30 days in full mode"
        required: false
        type: string
        default: ""

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@v7
        with:
          # ci mode compares two commits, so it needs the history
          fetch-depth: ${{ inputs.mode == 'ci' && '0' || '1' }}

      # ci and full modes: the folders of the workspace, one on each line
      - name: Resolve the workspace folders
        id: folders
        env:
          # The first that has a value: the input; in ci mode only, the
          # repository variable; and the whole repository. The input has no
          # default, so that an empty input reaches the variable.
          FOLDERS: ${{ inputs.workspace || (inputs.mode == 'ci' && vars.SF_METADATA_DIR) || '.' }}
        run: |
          # One folder on each line, with the spaces around each comma removed
          {
            echo "list<<EOF"
            printf '%s' "$FOLDERS" | tr ',' '\n' | sed 's/^[[:space:]]*//; s/[[:space:]]*$//' | grep -v '^$'
            echo "EOF"
          } >> "$GITHUB_OUTPUT"

      - name: Set up Salesforce environment
        uses: ./.github/actions/setup-sf
        with:
          # ci mode builds its own delta package; pr mode downloads one
          install-sfdx-git-delta: ${{ inputs.mode == 'ci' && 'true' || '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/

      # ci mode: build a delta package of what the push changed, and scan that alone
      - name: Generate the delta of the push
        id: delta
        if: inputs.mode == 'ci'
        env:
          BEFORE: ${{ github.event.before }}
          DEFAULT_BRANCH: ${{ github.event.repository.default_branch }}
          FOLDERS: ${{ steps.folders.outputs.list }}
        run: |
          # From the commit before the push to the new commit: every commit of the
          # push, and not the last one only. A new branch has no commit before it
          # (all zeros), and after a force push the old commit can be gone: take the
          # point where the branch left the default branch then.
          if [ -n "$BEFORE" ] && [ "$BEFORE" != "0000000000000000000000000000000000000000" ] && git cat-file -e "$BEFORE^{commit}" 2>/dev/null; then
            FROM="$BEFORE"
          else
            FROM=$(git merge-base "origin/$DEFAULT_BRANCH" HEAD || true)
          fi
          if [ -z "$FROM" ]; then
            echo "No commit to compare with."
            echo "has-metadata=false" >> "$GITHUB_OUTPUT"
            exit 0
          fi
          # One --source-dir flag for each folder
          mapfile -t folders <<< "$FOLDERS"
          source_dirs=()
          for folder in "${folders[@]}"; do
            source_dirs+=(--source-dir "$folder")
          done
          mkdir -p delta # the plugin does not create its output folder
          sf sgd source delta --from "$FROM" --to HEAD \
            --generate-delta --output-dir delta "${source_dirs[@]}"
          # The manifest names a type only when the push added or changed metadata
          if grep -q '<types>' delta/package/package.xml 2>/dev/null; then
            echo "has-metadata=true" >> "$GITHUB_OUTPUT"
          else
            echo "has-metadata=false" >> "$GITHUB_OUTPUT"
          fi

      - name: Resolve workspace path
        id: workspace
        env:
          HAS_METADATA: ${{ steps.delta.outputs.has-metadata }}
          FOLDERS: ${{ steps.folders.outputs.list }}
        run: |
          FULL_SCAN="To scan the whole repository, run the workflow Scan the Entire Repository with Code Analyzer."
          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"
          elif [ "${{ inputs.mode }}" == "ci" ] && [ "$HAS_METADATA" != "true" ]; then
            echo "_Code analysis did not run: the push added or changed no metadata. ${FULL_SCAN}_" >> "$GITHUB_STEP_SUMMARY"
            echo "path=" >> "$GITHUB_OUTPUT"
            echo "skip=true" >> "$GITHUB_OUTPUT"
          elif [ "${{ inputs.mode }}" == "ci" ]; then
            echo "_Code analysis covers the metadata that the push added or changed. ${FULL_SCAN}_" >> "$GITHUB_STEP_SUMMARY"
            echo "path=delta" >> "$GITHUB_OUTPUT"
            echo "skip=false" >> "$GITHUB_OUTPUT"
          else
            # full mode: the folders of the workspace, one on each line, which
            # are the whole repository by default
            {
              echo "path<<EOF"
              echo "$FOLDERS"
              echo "EOF"
            } >> "$GITHUB_OUTPUT"
            echo "skip=false" >> "$GITHUB_OUTPUT"
          fi

      - name: Run Code Analyzer
        if: steps.workspace.outputs.skip != 'true'
        id: scan
        continue-on-error: true
        env:
          # The full scan takes the selectors from a person, so they reach the
          # script as an environment variable and are never pasted into it
          RULE_SELECTORS: ${{ inputs.rule-selectors }}
          WORKSPACE: ${{ steps.workspace.outputs.path }}
          # The Apex Language Server loads the whole project from here, for the
          # PMD rules AvoidInterfaceAsMapKey and UnusedMethod, which report
          # nothing without it
          PMD_APEX_ROOT_DIRECTORY: ${{ github.workspace }}
        run: |
          selectors=()
          for selector in $RULE_SELECTORS; do
            selectors+=(--rule-selector "$selector")
          done
          # One --workspace flag for each folder: delta in pr and ci modes, the
          # folders of the workspace in full mode
          mapfile -t folders <<< "$WORKSPACE"
          workspaces=()
          for folder in "${folders[@]}"; do
            workspaces+=(--workspace "$folder")
          done
          # pr and full modes: a CSV file as well, for the artifact
          csv=()
          if [ "${{ inputs.mode }}" != "ci" ]; then
            csv=(--output-file results.csv)
          fi
          sf code-analyzer run \
            "${workspaces[@]}" \
            "${selectors[@]}" \
            --severity-threshold ${{ inputs.severity-threshold }} \
            --output-file results.json \
            --output-file results.sarif \
            "${csv[@]}"

      # 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 (pr and ci modes). The full
      # mode takes the table of blocking violations only, for its own summary.
      - 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 }}
          write-summary: ${{ inputs.mode != 'full' }}

      # pr mode: build structured two-table comment body (blocking vs. advisory);
      # at threshold 5 every violation blocks, so there is no advisory table
      - 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 }}
          ARTIFACT: ${{ inputs.results-artifact-name }}
        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 ""
            if [ "$ADVISORY_MIN" -le 5 ]; then
              echo "**Advisory Violations (Severity ${ADVISORY_MIN}-5)**"
              echo ""
              echo "$LOW_MD"
              echo ""
            fi
            echo "_Scan run against delta package. Every violation is in the artifact \`${ARTIFACT}\` of this workflow run, as a CSV file, a JSON file, and a SARIF file._"
            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 }}

      # full mode: the counts and the blocking violations go to the job summary,
      # and every violation goes to the artifact
      - name: Write the violation counts and the blocking violations (full scan)
        if: always() && inputs.mode == 'full' && hashFiles('results.json') != ''
        env:
          RULE_SELECTORS: ${{ inputs.rule-selectors }}
          FOLDERS: ${{ steps.folders.outputs.list }}
          ARTIFACT: ${{ inputs.results-artifact-name }}
          THRESHOLD: ${{ inputs.severity-threshold }}
          HIGH_MD: ${{ steps.report.outputs.high-priority-markdown }}
        run: |
          {
            echo "## Code Analyzer: Full Scan"
            echo ""
            if [ "$FOLDERS" == "." ]; then
              SCOPE="The whole repository"
            else
              # Each folder in backticks, with a comma between two folders
              SCOPE="The folders"
              separator=" "
              while IFS= read -r folder; do
                SCOPE+="${separator}\`${folder}\`"
                separator=", "
              done <<< "$FOLDERS"
            fi
            echo "$SCOPE. Rule selectors: \`$RULE_SELECTORS\`."
            echo ""
            echo "| Severity | Violations |"
            echo "| --- | --- |"
            jq -r '.violationCounts | "| 1 | \(.sev1) |", "| 2 | \(.sev2) |", "| 3 | \(.sev3) |", "| 4 | \(.sev4) |", "| 5 | \(.sev5) |", "| **Total** | **\(.total)** |"' results.json
            echo ""
            echo "### Blocking Violations (Severity 1-${THRESHOLD})"
            echo ""
            echo "${HIGH_MD:-_The table of blocking violations was not made. Read the log of the step Generate violation summary._}"
            echo ""
            echo "Every violation, at every severity, is in the artifact \`$ARTIFACT\`, as a CSV file, a JSON file, and a SARIF file."
          } >> "$GITHUB_STEP_SUMMARY"

      # pr and full modes: one artifact with the three files, which GitHub gives as
      # one zip file. A pull request keeps it for a short time, and a full scan for longer.
      - name: Upload the results
        if: always() && inputs.mode != 'ci' && steps.workspace.outputs.skip != 'true'
        uses: actions/upload-artifact@v7
        with:
          name: ${{ inputs.results-artifact-name }}
          path: |
            results.csv
            results.json
            results.sarif
          retention-days: ${{ inputs.results-retention-days || (inputs.mode == 'pr' && '7' || '30') }}
          if-no-files-found: error
          overwrite: true # a second attempt of the same run replaces the first artifact

      # ci mode: the scan is advisory, so a violation does not fail the job.
      # A scan that wrote no result did not run to its end, and the job says so.
      - name: Fail if the scan wrote no result (CI)
        if: inputs.mode == 'ci' && steps.workspace.outputs.skip != 'true' && hashFiles('results.json') == ''
        run: |
          echo "::error::Code Analyzer wrote no results file. Read the log of the scan step."
          exit 1

      # 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.10 - 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) and by the nightly run, where there is no PR diff to reason about.

The workflow reads the result files of Jest and builds one report, with the same kind of tables as the Apex test report: a test summary, the failing tests with the message of each, the tested components below their line coverage target, and the components that have no test. The report goes to the job summary, with the coverage of every component after it, and, on a pull request, to one comment that each run updates. The result files (the JSON result of Jest, the report, the coverage of each component, and the coverage files) are stored as the artifact jest-results, for a later job or another workflow. The job fails for a failing test, and for a component that has a test and is below its target: jest.config.js sets a coverageThreshold for each component folder that has a test file, and for no other. A component with no test is reported and does not fail the job. The workflow returns an outcome and a short report, which the nightly caller posts to Slack.

The standards this gate enforces: Jest on the Version Control page, and Section 5, LWC Jest Test Execution.

Expand to show LWC Jest Tests reusable workflow
LWC Jest Tests
# .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.
# Reports the failing tests, the line coverage, and each component with no
# test, in the job summary and, on a pull request, in a comment. Stores the
# result files as an artifact, for a later job or another workflow.
#
# The job fails for a failing test. It also fails when a component that has
# a test is below its line coverage target, which is the coverageThreshold
# of jest.config.js. A component with no test has no target: it is reported,
# and it never fails the job.

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"
      results-artifact-name:
        description: "Name of the artifact that holds the Jest result file, the report, and the coverage files"
        required: false
        type: string
        default: "jest-results"
      results-retention-days:
        description: "Days to keep the results artifact"
        required: false
        type: string
        default: "7"
    secrets:
      CODECOV_TOKEN:
        description: "Codecov upload token (required for private repositories)"
        required: false
    outputs:
      outcome:
        description: "'Passed' or 'Failed'; empty when Jest did not run"
        value: ${{ jobs.jest-tests.outputs.outcome }}
      report:
        description: "A short report of the run, pass or fail, to post in a channel; empty when Jest did not run"
        value: ${{ jobs.jest-tests.outputs.report }}

jobs:
  jest-tests:
    name: LWC Jest Tests
    runs-on: ubuntu-latest
    permissions:
      contents: read
      pull-requests: write
    outputs:
      outcome: ${{ steps.jest-report.outputs.outcome }}
      report: ${{ steps.jest-report.outputs.report }}
    steps:
      - name: Checkout repository
        uses: actions/checkout@v7

      - 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
            # Count the Lightning web components of the delta. Their metadata
            # type is LightningComponentBundle. The sum is 0 when the delta
            # has no such type.
            LWCCOUNT=$(cat delta/package/package.xml | xq -j | jq -r '[.Package.types | [.] | flatten | .[] | select(.name == "LightningComponentBundle") | .members | [.] | flatten | length] | add // 0')
            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@v7
        with:
          node-version: ${{ inputs.node-version }}
          cache: npm

      - name: Install dependencies
        if: steps.check-lwc.outputs.run-jest == 'true'
        run: npm ci

      # The delta holds a copy of each changed component, with its tests. Jest
      # runs each test once, from the project and not from the copy.
      - name: Remove the test copies of the delta
        if: steps.check-lwc.outputs.run-jest == 'true' && !inputs.skip-delta-check
        run: find delta -type d -name '__tests__' -prune -exec rm -rf {} +

      # Jest ends with an error for a failing test and for low coverage. The
      # steps below read its result files and decide, so this step does not
      # stop the job. A project with no Jest test at all is not an error.
      - name: Run LWC Jest tests with coverage
        id: run-jest
        if: steps.check-lwc.outputs.run-jest == 'true'
        continue-on-error: true
        run: npm run test:unit:coverage -- -- --json --outputFile=jest-results.json --passWithNoTests

      - name: Build the Jest report
        id: jest-report
        if: steps.check-lwc.outputs.run-jest == 'true'
        env:
          SKIP_DELTA_CHECK: ${{ inputs.skip-delta-check }}
        run: |
          if [ ! -f jest-results.json ]; then
            echo "::error::Jest wrote no result file. See the log of the step that runs the tests."
            exit 1
          fi
          TOTAL=$(jq -r '.numTotalTests' jest-results.json)
          PASSED=$(jq -r '.numPassedTests' jest-results.json)
          FAILED=$(jq -r '.numFailedTests' jest-results.json)
          # A test file that could not run has no failed test, and it is a
          # failure too.
          BROKEN=$(jq -r '[.testResults[] | select(.status == "failed" and ([.assertionResults[] | select(.status == "failed")] | length) == 0)] | length' jest-results.json)

          # The targets are the coverageThreshold of jest.config.js: one for
          # each component folder that has a test. A component with no test
          # has no target.
          node --eval "process.stdout.write(JSON.stringify(require('./jest.config.js').coverageThreshold ?? {}))" > jest-thresholds.json

          # The line coverage of each component: the lines of all its files.
          if [ -f coverage/coverage-summary.json ]; then
            jq --arg root "$PWD/" --slurpfile thresholds jest-thresholds.json '
              $thresholds[0] as $targets
              | [to_entries[] | select(.key != "total")
                  | (.key | ltrimstr($root) | capture("^(?<component>.*/lwc/[^/]+)/") | .component) as $component
                  | {component: $component, covered: .value.lines.covered, total: .value.lines.total}]
              | group_by(.component)
              | map({component: .[0].component, covered: (map(.covered) | add), total: (map(.total) | add)}
                  | .pct = (if .total == 0 then 100 else ((.covered / .total * 10000) | floor) / 100 end)
                  | .target = ($targets["./" + .component + "/"].lines // null))
              | sort_by(.pct, .component)
            ' coverage/coverage-summary.json > jest-components.json
            ALL_COVERED=$(jq -r '.total.lines.pct | if type == "number" then "\(.)%" else "not available" end' coverage/coverage-summary.json)
          else
            echo "[]" > jest-components.json
            ALL_COVERED="not available"
          fi
          TESTED_COUNT=$(jq -r 'length' jest-thresholds.json)
          LOW_COUNT=$(jq -r '[.[] | select(.target != null and .pct < .target)] | length' jest-components.json)
          LOW_TABLE=$(jq -r '
            [.[] | select(.target != null and .pct < .target)]
            | if length > 0 then
                "| Component | Lines Run | Coverage | Target |\n| --- | --- | --- | --- |\n"
                + (map("| `\(.component)` | \(.covered) of \(.total) | \(.pct)% | \(.target)% |") | join("\n"))
              else "" end
          ' jest-components.json)
          COVERAGE_TABLE=$(jq -r '
            if length > 0 then
              "| Component | Lines Run | Coverage | Target |\n| --- | --- | --- | --- |\n"
              + (map("| `\(.component)` | \(.covered) of \(.total) | \(.pct)% | \(if .target == null then "None: No tests found" else "\(.target)%" end) |") | join("\n"))
            else "" end
          ' jest-components.json)

          # The components to look at for a missing test: those of the delta
          # on a pull request, and every component when the delta check is
          # skipped.
          if [ "$SKIP_DELTA_CHECK" = "true" ]; then
            COMPONENTS=$(find . -type d -path '*/lwc/*' -not -path './node_modules/*' -not -path './delta/*' | awk -F/ '$(NF-1) == "lwc"' | sed 's#^\./##' | sort)
          else
            COMPONENTS=$(find delta -type d -path '*/lwc/*' | awk -F/ '$(NF-1) == "lwc"' | sed 's#^delta/##' | sort)
          fi
          MISSING=""
          MISSING_COUNT=0
          while IFS= read -r COMPONENT; do
            if [ -n "$COMPONENT" ] && [ -d "$COMPONENT" ] && ! ls "$COMPONENT"/__tests__/*.test.* >/dev/null 2>&1; then
              MISSING="${MISSING}- \`${COMPONENT}\`"$'\n'
              MISSING_COUNT=$((MISSING_COUNT + 1))
            fi
          done <<< "$COMPONENTS"

          # A cell must not break the table: escape a pipe and an angle
          # bracket, and put a line break tag in place of a new line.
          FAILURE_TABLE=$(jq -r --arg root "$PWD/" '
            def clean: gsub("\u001b\\[[0-9;]*m"; "");
            def cell: tostring | gsub("\\|"; "\\|") | gsub("<"; "&lt;") | gsub("\r?\n"; "<br>");
            def first_lines: clean | split("\n") | map(select(test("\\S"))) | .[0:3] | join("\n");
            [.testResults[] | (.name | ltrimstr($root)) as $file
              | if ([.assertionResults[] | select(.status == "failed")] | length) > 0 then
                  .assertionResults[] | select(.status == "failed")
                  | {file: $file, test: ((.ancestorTitles + [.title]) | join(" > ")), message: ((.failureMessages[0] // "") | first_lines)}
                elif .status == "failed" then
                  {file: $file, test: "The test file could not run", message: ((.message // "") | first_lines)}
                else empty end]
            | if length > 0 then
                "### Failing Tests\n\n| File | Test | Message |\n| --- | --- | --- |\n"
                + (map("| `\(.file)` | \(.test | cell) | \(.message | cell) |") | join("\n"))
              else "" end
          ' jest-results.json)
          # The full message of each failure goes below the table, in a block
          # that the reader opens. A message is cut at 2,000 characters.
          FAILURE_DETAILS=$(jq -r --arg root "$PWD/" '
            def clean: gsub("\u001b\\[[0-9;]*m"; "");
            [.testResults[] | (.name | ltrimstr($root)) as $file
              | .assertionResults[] | select(.status == "failed")
              | "**`\($file)`: \((.ancestorTitles + [.title]) | join(" > "))**\n\n```text\n\((.failureMessages[0] // "") | clean | .[0:2000])\n```"]
            | if length > 0 then
                "<details>\n<summary>The full message of each failing test</summary>\n\n" + join("\n\n") + "\n\n</details>"
              else "" end
          ' jest-results.json)

          TESTS_FAILED=false
          if [ "$FAILED" -gt 0 ] || [ "$BROKEN" -gt 0 ]; then
            TESTS_FAILED=true
          fi
          COVERAGE_FAILED=false
          if [ "$LOW_COUNT" -gt 0 ]; then
            COVERAGE_FAILED=true
          fi
          if [ "$TESTS_FAILED" = "true" ] || [ "$COVERAGE_FAILED" = "true" ]; then
            OUTCOME=Failed
          else
            OUTCOME=Passed
          fi

          if [ "$TESTED_COUNT" -eq 0 ]; then
            COVERAGE_LINE="No component has a Jest test, so no coverage target applies."
          elif [ "$COVERAGE_FAILED" = "true" ]; then
            COVERAGE_LINE="$LOW_COUNT of the $TESTED_COUNT component(s) that have a test are below their line coverage target. This fails the gate."
          else
            COVERAGE_LINE="Each of the $TESTED_COUNT component(s) that have a test meets its line coverage target."
          fi

          # The report: the same tables as the report of the Apex tests.
          {
            echo "## LWC Jest Tests - $OUTCOME"
            echo ""
            echo "### Test Summary"
            echo ""
            echo "| Name | Value |"
            echo "|---|---|"
            echo "| Outcome | $OUTCOME |"
            echo "| Tests Ran | $TOTAL |"
            echo "| Passed | $PASSED |"
            echo "| Failed | $FAILED |"
            echo "| Test Files That Could Not Run | $BROKEN |"
            echo "| Components With a Test | $TESTED_COUNT |"
            echo "| Tested Components Below Their Target | $LOW_COUNT |"
            echo "| Components With No Test | $MISSING_COUNT |"
            echo "| Line Coverage of All Components | $ALL_COVERED |"
            echo ""
            echo "$COVERAGE_LINE"
            echo ""
            if [ -n "$FAILURE_TABLE" ]; then
              echo "$FAILURE_TABLE"
              echo ""
            fi
            if [ -n "$FAILURE_DETAILS" ]; then
              echo "$FAILURE_DETAILS"
              echo ""
            fi
            if [ -n "$LOW_TABLE" ]; then
              echo "### Tested Components Below Their Target"
              echo ""
              echo "$LOW_TABLE"
              echo ""
            fi
            if [ "$MISSING_COUNT" -gt 0 ]; then
              echo "### Components With No Test"
              echo ""
              echo "These components have no Jest test, so no coverage target applies to them:"
              echo ""
              printf '%s' "$MISSING"
              echo ""
            fi
          } > jest-report.md
          # The job summary has the report, and the coverage of every
          # component. The PR comment has the report, without that last table.
          {
            cat jest-report.md
            if [ -n "$COVERAGE_TABLE" ]; then
              echo "### Coverage by Component"
              echo ""
              echo "$COVERAGE_TABLE"
              echo ""
            fi
          } >> "$GITHUB_STEP_SUMMARY"

          if [ "$MISSING_COUNT" -gt 0 ]; then
            echo "::notice::$MISSING_COUNT Lightning web component(s) have no Jest test. See the job summary."
          fi

          {
            echo "outcome=$OUTCOME"
            echo "tests-failed=$TESTS_FAILED"
            echo "coverage-failed=$COVERAGE_FAILED"
            echo "body<<END_OF_JEST_BODY"
            cat jest-report.md
            echo "The job summary of the workflow run has the coverage of every component."
            echo "END_OF_JEST_BODY"
            # One short report for the caller to post in a channel.
            echo "report<<END_OF_JEST_REPORT"
            if [ "$TESTS_FAILED" = "true" ]; then
              echo "Jest tests failed: $FAILED of $TOTAL failed, and $BROKEN test file(s) could not run."
            else
              echo "Jest tests passed: $TOTAL ran and none failed."
            fi
            echo "$COVERAGE_LINE"
            echo "$MISSING_COUNT component(s) have no Jest test."
            echo "END_OF_JEST_REPORT"
          } >> "$GITHUB_OUTPUT"

      # The result files, for a later job or for another workflow to read:
      # the JSON result of Jest, the report, the coverage of each component,
      # and the coverage as a summary and as an lcov file.
      - name: Upload the Jest results
        if: always() && steps.check-lwc.outputs.run-jest == 'true'
        uses: actions/upload-artifact@v7
        with:
          name: ${{ inputs.results-artifact-name }}
          path: |
            jest-results.json
            jest-report.md
            jest-components.json
            coverage/coverage-summary.json
            coverage/lcov.info
          retention-days: ${{ inputs.results-retention-days }}
          if-no-files-found: warn
          overwrite: true # a second attempt of the same run replaces the first artifact

      - name: Post PR comment
        if: github.event_name == 'pull_request' && steps.jest-report.outcome == 'success'
        uses: ./.github/actions/post-pr-comment
        with:
          token: ${{ secrets.GITHUB_TOKEN }}
          marker: "## LWC Jest Tests"
          body: ${{ steps.jest-report.outputs.body }}

      - name: Upload code coverage for LWC to Codecov
        if: steps.check-lwc.outputs.run-jest == 'true' && hashFiles('coverage/lcov.info') != ''
        uses: codecov/codecov-action@v7
        with:
          token: ${{ secrets.CODECOV_TOKEN }}
          flags: LWC

      - name: Fail for a failing test or for low coverage
        if: steps.check-lwc.outputs.run-jest == 'true'
        env:
          TESTS_FAILED: ${{ steps.jest-report.outputs.tests-failed }}
          COVERAGE_FAILED: ${{ steps.jest-report.outputs.coverage-failed }}
        run: |
          if [ "$TESTS_FAILED" != "false" ]; then
            echo "::error::A Jest test failed, or the Jest report could not be built. See the job summary."
            exit 1
          fi
          if [ "$COVERAGE_FAILED" = "true" ]; then
            echo "::error::A Lightning web component that has a test is below its line coverage target. See the job summary."
            exit 1
          fi

A.2.11 - Apex Test Selection

Chooses the Apex tests that a pull request runs. The tests themselves run inside the validation of the delta package (A.2.12), so that the code under test is the pull request's own and not the copy already in the org. This workflow hands that validation a test level and, for RunSpecifiedTests, the list of tests.

The standard this gate enforces: Section 5, Apex Test Execution.

Two GitHub Actions variables decide which test level a pull request uses (A.4), and the caller workflow passes both in. APEX_RUN_RELEVANT_TESTS_ORGANIZATION is set for the organization, and APEX_RUN_RELEVANT_TESTS for a single repository. The organization's variable is read first, and when it has a value the repository's is not read at all:

  • The organization's variable is false: every repository computes a RunSpecifiedTests list. No repository can switch by itself.
  • The organization's variable is true: every repository uses RunRelevantTests.
  • The organization's variable is not set: each repository decides. It uses RunRelevantTests when its own variable is true, and computes the list otherwise.

The computed RunSpecifiedTests list comes from the pull request itself: the Apex it changes, the tests its author lists in its description, and a short list of tests that always run. With RunRelevantTests (Beta, API 66.0 and later), Salesforce selects the tests, and this workflow only passes the test level on.

The two variables have different names for a reason. Where an organization and a repository hold a variable of the same name, GitHub gives a workflow the repository's value only, so a workflow could not tell that the organization had said false.

The variable is there because the move takes time. Before RunRelevantTests selects the right tests, an organisation has to raise its Apex to API 66.0 and link its tests to the code they test (Apex: Test Selection), and it must be willing to use a Beta feature. Until then the computed list does the same job at any API version.

The selection stands on its own. It reads the pull request and the repository, and nothing that an earlier test run left behind in an org, so the same pull request selects the same tests every time it runs.

When a pull request changes Apex and no test is found for it, no test runs, and the validation fails because the changed class or trigger has no coverage. The author then adds the test to the pull request, or names it in the description. It never falls back to running every local test.

Expand to show Apex Test Selection (PR) reusable workflow
Apex Test Selection (PR)
# .github/workflows/sf-apex-test-selection.yml
# Reusable: chooses the Apex tests that a pull request runs.
# The validation of the delta package (sf-validate-deployment.yml) runs them,
# at the test level and with the tests that this workflow outputs.
# It reads the pull request and the repository only: no org, and no earlier run.

name: Apex Test Selection (PR)

on:
  workflow_call:
    inputs:
      organization-run-relevant-tests:
        description: "The organization's setting. 'true' or 'false' decides for every repository; with no value, the repository's setting decides."
        required: false
        type: string
        default: ""
      run-relevant-tests:
        description: "The repository's setting, read only when the organization has none. 'true' selects RunRelevantTests (Beta, API 66.0 and later); any other value computes a RunSpecifiedTests list."
        required: false
        type: string
        default: ""
      always-run-tests:
        description: "Space-separated test classes that run on every pull request (RunSpecifiedTests only)"
        required: false
        type: string
        default: ""
      artifact-name:
        description: "Name of the delta package artifact to download"
        required: false
        type: string
        default: "delta-package"
    outputs:
      test-level:
        description: "RunRelevantTests, RunSpecifiedTests, or OrgDefault when there is no test to name"
        value: ${{ jobs.select-tests.outputs.test-level }}
      tests:
        description: "Space-separated test classes for RunSpecifiedTests"
        value: ${{ jobs.select-tests.outputs.tests }}

jobs:
  select-tests:
    name: Apex Test Selection
    runs-on: ubuntu-latest
    permissions:
      contents: read
      pull-requests: read
    outputs:
      test-level: ${{ steps.select.outputs.test-level }}
      tests: ${{ steps.select.outputs.tests }}
    steps:
      - name: Checkout repository
        uses: actions/checkout@v7

      - name: Download delta package
        continue-on-error: true
        uses: actions/download-artifact@v8
        with:
          name: ${{ inputs.artifact-name }}
          path: delta/

      - name: Select tests
        id: select
        env:
          ORGANIZATION_SETTING: ${{ inputs.organization-run-relevant-tests }}
          REPOSITORY_SETTING: ${{ inputs.run-relevant-tests }}
          ALWAYS_RUN: ${{ inputs.always-run-tests }}
          PR_NUMBER: ${{ github.event.pull_request.number }}
          GH_TOKEN: ${{ github.token }}
        run: |
          set -euo pipefail

          emit() {
            echo "test-level=$1" >> "$GITHUB_OUTPUT"
            echo "tests=$2" >> "$GITHUB_OUTPUT"
          }

          if [ ! -f delta/package/package.xml ] || ! grep -q '<types>' delta/package/package.xml; then
            echo "No deployable metadata in the delta: there are no tests to choose."
            emit "OrgDefault" ""
            exit 0
          fi

          # The variables are text, read in any case. The organization's setting
          # wins when it is "true" or "false", and the repository's is then not
          # read. With no organization setting, the repository's decides.
          ORGANIZATION_SETTING=$(echo "$ORGANIZATION_SETTING" | tr '[:upper:]' '[:lower:]')
          REPOSITORY_SETTING=$(echo "$REPOSITORY_SETTING" | tr '[:upper:]' '[:lower:]')
          case "$ORGANIZATION_SETTING" in
            true | false) RUN_RELEVANT_TESTS="$ORGANIZATION_SETTING" ;;
            *) RUN_RELEVANT_TESTS="$REPOSITORY_SETTING" ;;
          esac
          if [ "$RUN_RELEVANT_TESTS" = "true" ]; then
            echo "RunRelevantTests: Salesforce selects the tests during the validation."
            emit "RunRelevantTests" ""
            exit 0
          fi

          # Source 1: every class and trigger in the delta, tests or not. The
          # deployment ignores a name that is not a test class, so there is no need
          # to tell them apart. An Apex name holds only letters, digits, and
          # underscores; anything else is skipped, because the names go onto a
          # command line.
          find delta -path delta/package -prune -o \( -name '*.cls' -o -name '*.trigger' \) -print |
            sed 's|.*/||; s|\.[^.]*$||' | { grep -E '^[A-Za-z0-9_]+$' || true; } | sort -u > apex-in-delta.txt

          # Source 2: a class in the repository named for a class or trigger in
          # the delta, with the Test or Tests suffix.
          : > suffix-tests.txt
          while read -r name; do
            for candidate in "${name}Test" "${name}Tests"; do
              if [ -n "$(find . -path ./delta -prune -o -name "${candidate}.cls" -print -quit)" ]; then
                echo "$candidate" >> suffix-tests.txt
              fi
            done
          done < apex-in-delta.txt

          # Source 3: the tests that the author lists in the pull request
          # description, one to a line under the heading "Apex Tests to Run". The
          # description is read as it stands now, so an edit and a re-run are
          # enough. A line that is not a single Apex name is skipped.
          gh pr view "$PR_NUMBER" --repo "$GITHUB_REPOSITORY" --json body --jq '.body' | tr -d '\r' > pr-description.md
          awk '
            /^#+[ \t]/ { listed = (tolower($0) ~ /^#+[ \t]+apex tests to run[ \t]*$/); next }
            listed { print }
          ' pr-description.md |
            sed -e 's/^[[:space:]]*//' -e 's/^[-*+][[:space:]]*//' -e 's/`//g' -e 's/[[:space:]]*$//' |
            { grep -E '^[A-Za-z][A-Za-z0-9_]*$' || true; } > tests-in-description.txt

          # Source 4: the tests that run on every pull request.
          echo "$ALWAYS_RUN" | tr ' ' '\n' > always-run.txt

          TESTS=$(cat apex-in-delta.txt suffix-tests.txt tests-in-description.txt always-run.txt |
            sed '/^$/d' | sort -u | paste -sd' ' -)

          # When Apex changed and none of these names is a test, no test runs, and
          # the validation fails: each class and trigger it deploys has no coverage.
          if [ -n "$TESTS" ]; then
            echo "RunSpecifiedTests: $TESTS"
            emit "RunSpecifiedTests" "$TESTS"
          else
            echo "No Apex in the delta and no test is named: the validation uses the default test level of the target org."
            emit "OrgDefault" ""
          fi

How the Test List Is Computed

The RunSpecifiedTests list has four sources, and a test named by more than one of them runs once. None of them is an org: the list does not depend on which tests ran before, or on whether anyone has run a test since.

  • The Apex in the pull request. Every class and trigger in the delta package, tests or not. The tests among them run, and the deployment ignores the other names.
  • Tests named for the changed code. A class in the repository named for a class or trigger in the delta package, with the Test or Tests suffix.
  • The tests the author lists. The pull request description template has a section headed "Apex Tests to Run", with one test class to a line. The author lists there the tests that exercise the change and are neither in the pull request nor named for it.
  • The tests that always run. The always-run-tests input: a short list for the tests that guard what no dependency shows, such as the tests of a Flow or of a validation rule.

The author's list is in the description and not in a committed file, because every pull request would edit that file, and their edits would collide. The section comes from the pull request template, which the configuration repository can supply to every repository (Appendix D):

## Apex Tests to Run

<!-- One test class to a line. The tests in this pull request run without being listed. -->

The workflow reads the description as it stands when it runs. An author who adds a test to the list re-runs the checks, or pushes again; the pull request does not have to be reopened.

A changed class or trigger that the tests on the list cover by less than 75% fails the validation, because under RunSpecifiedTests the coverage requirement applies to each class and trigger in the deployment separately. That is also what asks for a test: when none of the names on the list is a test, no test runs, and the comment on the pull request names each class and trigger at 0%.

A pull request that changes no Apex and names no test runs no Apex tests. The validation is left at the default test level of the target org, and with no Apex in the deployment that default runs none, in a sandbox and in production alike. The always-run list is where the tests of declarative automation belong, so that a change to a Flow or to a validation rule still runs them.

A name on the list that is not a test class does no harm in a deployment. Tested with sf project deploy start --test-level RunSpecifiedTests, the --tests flag ignored a class with no tests, a trigger name, and a name that does not exist, and ran the tests among them. So the sample passes every class and trigger in the pull request without sorting the tests from the rest. sf apex run test is stricter: it accepts a class that is not a test and runs nothing for it, but it rejects a trigger name and fails the whole run.

A.2.12 - Metadata Validation and Apex Tests

Runs sf project deploy start --dry-run against the target org using the delta package manifest, and carries the pull request's Apex tests: the test level, and for RunSpecifiedTests the tests, are the ones that A.2.11 chose. Guards against empty deltas (skips cleanly rather than erroring against an empty manifest), and says so in the job summary. The report of the validation is built once, by the composite action of A.3.7, and the same markdown goes to the job summary and to the PR comment: the status, the tests run and failed, the coverage of the classes in this validation, and tables of the component failures, the failing tests, the coverage warnings, and the coverage of each changed class and trigger. The table of failing tests has every failing test with where the error was thrown and its message. The stack traces are below the table, in a block that the reader opens, because a stack trace can have many lines. A validation that wrote no result gets a report that says so. It uploads nothing to Codecov. The validation is not a deployment: it changes nothing in the org. Its tests are only the ones that bear on the pull request, so its coverage is partial, and the coverage file that the command can write names each class no-map/<name>, which Codecov cannot match to a file. Apex coverage reaches Codecov from the full run of A.2.16. The coverage threshold of the low-coverage table is an input with no default: the caller sets it once, at the top of its file, and passes it (A.1).

The standard this gate enforces: Section 6, PR-Time: Metadata Validation and Deployment Integrity.

The test level is a required input with no default, so that no caller runs without tests by leaving it out. The pull request callers pass what A.2.11 chose. An org whose whole suite runs in a few minutes can pass RunLocalTests and leave the selection out (Test Levels).

Expand to show Metadata Validation and Apex Tests reusable workflow
Metadata Validation and Apex Tests
# .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, and runs the
# pull request's Apex tests inside that validation.
# Writes a report of component failures, failing tests, coverage warnings, and
# coverage to the job summary and to a PR comment (the sf-validation-report action).

name: Metadata Validation and Apex Tests

on:
  workflow_call:
    inputs:
      test-level:
        description: "RunRelevantTests (Beta), RunSpecifiedTests, RunLocalTests, or OrgDefault to leave the choice to the target org"
        required: true
        type: string
      tests:
        description: "Space-separated test classes to run when test-level is RunSpecifiedTests"
        required: false
        type: string
        default: ""
      coverage-threshold:
        description: "Apex coverage percentage below which a changed class or trigger is marked Low in the report. The caller sets it once, at the top of its file, and passes it; there is no default here"
        required: true
        type: string
      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"
      sf-username:
        description: "The username of the integration user that the workflow logs in as, from the caller's SF_USERNAME_<ORG> variable"
        required: true
        type: string
      sf-instance-url:
        description: "The instance URL of the org, from the caller's SF_INSTANCE_URL_<ORG> variable"
        required: true
        type: string
    secrets:
      SF_JWT_KEY:
        description: "The private key of the JWT bearer flow for the org"
        required: true
      SF_CLIENT_ID:
        description: "The client ID (consumer key) of the External Client App in the org"
        required: true

jobs:
  validate-deploy:
    name: Metadata Validation and Apex Tests
    runs-on: ubuntu-latest
    permissions:
      contents: read
      pull-requests: write
    steps:
      - name: Checkout repository
        uses: actions/checkout@v7

      - 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 (JWT)
        uses: ./.github/actions/sf-auth-jwt
        with:
          jwt-key: ${{ secrets.SF_JWT_KEY }}
          client-id: ${{ secrets.SF_CLIENT_ID }}
          username: ${{ inputs.sf-username }}
          instance-url: ${{ inputs.sf-instance-url }}
          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'
        env:
          TEST_LEVEL: ${{ inputs.test-level }}
          TESTS: ${{ inputs.tests }}
          WAIT_MINUTES: ${{ inputs.wait-minutes }}
        run: |
          ARGS=(--dry-run --target-org validation-org --manifest delta/package/package.xml --wait "$WAIT_MINUTES")
          # OrgDefault leaves the test level to the default of the target org.
          if [ "$TEST_LEVEL" != "OrgDefault" ]; then
            ARGS+=(--test-level "$TEST_LEVEL")
          fi
          if [ "$TEST_LEVEL" = "RunSpecifiedTests" ]; then
            read -r -a TEST_NAMES <<< "$TESTS"
            ARGS+=(--tests "${TEST_NAMES[@]}")
          fi
          sf project deploy start "${ARGS[@]}" \
            --json > validation-results.json || true

      # The coverage table of the report lists the Apex in the delta, not every
      # class that the tests happened to touch.
      - name: List the Apex in the delta
        if: steps.check-delta.outputs.has-metadata == 'true'
        run: |
          find delta -path delta/package -prune -o \( -name '*.cls' -o -name '*.trigger' \) -print |
            sed 's|.*/||; s|\.[^.]*$||' | sort -u > delta-apex.txt

      # One report, as markdown, for the job summary and the PR comment. The
      # action also reports a validation that wrote no result.
      - name: Build the validation report
        id: report
        if: steps.check-delta.outputs.has-metadata == 'true'
        uses: ./.github/actions/sf-validation-report
        with:
          results-path: validation-results.json
          coverage-threshold: ${{ inputs.coverage-threshold }}
          test-level: ${{ inputs.test-level }}
          apex-file: delta-apex.txt

      - name: Write the job summary
        if: always() && steps.report.outcome == 'success'
        env:
          REPORT_FILE: ${{ steps.report.outputs.markdown-file }}
        run: cat "$REPORT_FILE" >> "$GITHUB_STEP_SUMMARY"

      - name: Write the job summary (no validation)
        if: steps.check-delta.outputs.has-metadata != 'true'
        run: |
          echo "## Metadata Validation and Apex Tests - SKIPPED" >> "$GITHUB_STEP_SUMMARY"
          echo "" >> "$GITHUB_STEP_SUMMARY"
          echo "The delta has no deployable metadata, so the validation did not run, and no Apex test ran." >> "$GITHUB_STEP_SUMMARY"

      - name: Post PR comment
        if: always() && steps.report.outcome == 'success'
        uses: ./.github/actions/post-pr-comment
        with:
          token: ${{ secrets.GITHUB_TOKEN }}
          marker: "## Metadata Validation and Apex Tests"
          body: ${{ steps.report.outputs.markdown }}

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

The standard this workflow implements: Section 4, PR and Commit Message Conventions.

Release Please ships a native sfdx release type that understands sfdx-project.json versioning, making this the correct release type for Salesforce projects. It is set in the configuration file and not in the workflow: the action reads the configuration file only when the workflow passes no release-type input. The configuration file, the manifest, and the settings that these standards set are in Version Control, Release Please.

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

The changelog: Release Please alone writes CHANGELOG.md. The Changelog Check fails any other pull request that changes it.

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 style(readme): reformat the installation steps
refactor: Refactoring production code without adding new features, e.g. renaming a variable refactor(AccountService): rename the retry counter
perf: Improving performance such as optimising a query or removing a loop perf(AccountSelector): move a query out of a loop
Expand to show Release Please reusable workflow
Release Please (Reusable)
# .github/workflows/release-please.yml
# Reusable workflow: automated release management via release-please.
# Called from ci.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 }}

          # No release-type input: the action reads the configuration file
          # only when that input is absent. The file sets the sfdx release
          # type, which updates the version in sfdx-project.json.

          # release-please-config.json defines release strategy and changelog sections.
          config-file: .github/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: .github/.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 }}

The two files that the workflow reads, release-please-config.json and .release-please-manifest.json, are samples to download in Version Control. Both are placed in .github/, where the workflow above looks for them.

A.2.14 - Org Scan

Scans one org: metadata drift detection, and the compilation check of A.2.15 at the test level that the org accepts. A caller runs it once for each org that it scans, with the org's name, the branch that the org should match, the test level, and the org's JWT credentials (A.3.2); the weekly project scan runs it for each org in a list at the top of its file (A.1.5). It posts the drift report to Slack on every run, drift or none, and a failure notice when a check fails or cannot run. Each org has its own open GitHub issue, because the org's name is in the title. The job of the full-org Apex tests is in the file as a comment, because the nightly test runner (A.1.4) runs them.

The drift detection checks out the org's branch, retrieves the org's metadata over it with sf project retrieve start, compares the result with the last commit of that branch using git, and opens a GitHub issue for the org when drift is detected (or adds a comment to the issue that is already open for it). The run stays green when drift is found: drift is a finding to resolve, and the run did its job.

The comparison reports three kinds of difference:

  • Changed in the org. A file whose content differs from the commit. Whitespace is set aside, because the org returns each file with its own indentation and without a final newline, and that is not drift.
  • In the org and not in the repository. A retrieved file that the repository does not hold. git diff ignores untracked files, so the workflow lists them and then registers them with git add -N. The manifest finds these only for the metadata types it lists with a wildcard.
  • In the repository and not in the org. A component that the manifest names and the org no longer has. The retrieve leaves the local file alone, so the workflow reads what the retrieve could not find from its result.

The comparison covers what the manifest retrieves. A manifest that lists a metadata type with a wildcard reports every component of that type that the org holds and the repository does not, including those the platform creates, such as the sharing rules of each object. Keep the manifest to the types the repository manages, or list what it does not manage in .forceignore, which the retrieve honours. The manifest is the manifest-path input, manifest/package.xml by default; set it to the place of the project's own manifest.

The standard this workflow implements: Section 6, Scheduled: Metadata Drift Detection.

Expand to show Org Scan reusable workflow
Org Scan
# .github/workflows/sf-org-scan.yml
# Reusable: scans one org. A caller runs it once for each org that it scans,
# such as production and a full sandbox.
#   - Drift detection: retrieves the org's metadata over the branch that the
#     org should match, and compares the two with git. When drift is detected
#     it opens a GitHub issue for the org, or adds a comment to the issue that
#     is already open for it.
#   - Compilation check: sf-apex-compilation.yml, at the test level that the
#     org accepts.
#   - Posts the drift report to Slack on every run, drift or none, and a
#     failure notice when a check fails or cannot run.
# Intended for scheduled scans.

name: Org Scan

on:
  workflow_call:
    inputs:
      org-name:
        description: "The name of the org, such as Production or Staging, in the issue titles, the reports, and the notices"
        required: true
        type: string
      branch:
        description: "The branch that the org should match; the drift detection compares the org with it"
        required: true
        type: string
      test-level:
        description: "Test level of the compilation check: RunLocalTests for production, which accepts no other, and NoTestRun for a sandbox"
        required: true
        type: string
      manifest-path:
        description: "Path to the package.xml manifest for retrieval"
        required: false
        type: string
        default: "manifest/package.xml"
      sf-username:
        description: "The username of the integration user that the scan logs in as, from the caller's SF_USERNAME_<ORG> variable"
        required: true
        type: string
      sf-instance-url:
        description: "The instance URL of the org, from the caller's SF_INSTANCE_URL_<ORG> variable"
        required: true
        type: string
    secrets:
      SF_JWT_KEY:
        description: "The private key of the JWT bearer flow for the org"
        required: true
      SF_CLIENT_ID:
        description: "The client ID (consumer key) of the External Client App in the org"
        required: true
      SLACK_WEBHOOK_URL:
        required: true
    outputs:
      drift-detected:
        description: "Whether drift was detected"
        value: ${{ jobs.drift-detection.outputs.drift-detected }}
      report:
        description: "A short report of the comparison, drift or none, to post in a channel"
        value: ${{ jobs.drift-detection.outputs.report }}

jobs:
  # ── Drift detection ─────────────────────────────────────────

  drift-detection:
    name: Metadata Drift Detection
    runs-on: ubuntu-latest
    permissions:
      issues: write
      contents: read
    outputs:
      drift-detected: ${{ steps.drift.outputs.drift-detected }}
      report: ${{ steps.drift.outputs.report }}
    steps:
      - name: Checkout the branch that the org should match
        uses: actions/checkout@v7
        with:
          ref: ${{ inputs.branch }}

      - name: Set up Salesforce environment
        uses: ./.github/actions/setup-sf
        with:
          install-code-analyzer: "false"
          install-sfdx-git-delta: "false"

      - name: Authenticate to scan target org (JWT)
        uses: ./.github/actions/sf-auth-jwt
        with:
          jwt-key: ${{ secrets.SF_JWT_KEY }}
          client-id: ${{ secrets.SF_CLIENT_ID }}
          username: ${{ inputs.sf-username }}
          instance-url: ${{ inputs.sf-instance-url }}
          alias: scan-target-org

      - name: Retrieve current org metadata
        env:
          MANIFEST: ${{ inputs.manifest-path }}
        run: |
          # Retrieve over the checked-out source, so that each component lands
          # where the repository keeps it and git can compare the two.
          # --ignore-conflicts lets a fresh checkout retrieve from an org that
          # tracks source, such as a sandbox.
          sf project retrieve start \
            --target-org scan-target-org \
            --manifest "$MANIFEST" \
            --ignore-conflicts \
            --wait 30 \
            --json > "$RUNNER_TEMP/retrieve-result.json" || {
            jq -r '.message // .' "$RUNNER_TEMP/retrieve-result.json"
            exit 1
          }

      - name: Compare org state to repository
        id: drift
        run: |
          DIRS=()
          while IFS= read -r dir; do
            DIRS+=("$dir")
          done < <(jq -r '.packageDirectories[].path' sfdx-project.json)

          # In the org and not in the repository: retrieved files that git
          # does not track.
          git -c core.quotepath=off ls-files --others --exclude-standard -- "${DIRS[@]}" |
            sort > "$RUNNER_TEMP/only-in-org.txt"

          # git diff ignores untracked files. git add --intent-to-add registers
          # them with no content, so that the comparison below sees them.
          git add --intent-to-add -- "${DIRS[@]}"

          # Changed in the org. Whitespace is set aside: the org returns a
          # file with its own indentation and no final newline.
          git -c core.quotepath=off diff --no-renames --numstat --ignore-all-space --ignore-blank-lines HEAD -- "${DIRS[@]}" |
            cut -f3 | sort > "$RUNNER_TEMP/differing.txt"
          comm -23 "$RUNNER_TEMP/differing.txt" "$RUNNER_TEMP/only-in-org.txt" \
            > "$RUNNER_TEMP/changed-in-org.txt"

          # In the repository and not in the org: the retrieve leaves such a
          # file alone and names the component in its result.
          jq -r '.result.messages[]?.problem // empty' "$RUNNER_TEMP/retrieve-result.json" |
            { grep -i 'cannot be found' || true; } |
            sed -E "s/^Entity of type '([^']+)' named '([^']+)' cannot be found.*/\1: \2/" |
            sort -u > "$RUNNER_TEMP/missing-in-org.txt"

          CHANGED=$(wc -l < "$RUNNER_TEMP/changed-in-org.txt" | tr -d ' ')
          ADDED=$(wc -l < "$RUNNER_TEMP/only-in-org.txt" | tr -d ' ')
          MISSING=$(wc -l < "$RUNNER_TEMP/missing-in-org.txt" | tr -d ' ')
          TOTAL=$((CHANGED + ADDED + MISSING))

          # One report for the job summary, the issue, and the caller. Each
          # list stops at ten lines, so that the whole fits a chat message.
          list() {
            if [ "$2" -gt 0 ]; then
              echo
              echo "$1 ($2):"
              head -n 10 "$3" | sed 's/^/- /'
              if [ "$2" -gt 10 ]; then
                echo "- and $(($2 - 10)) more"
              fi
            fi
          }
          {
            if [ "$TOTAL" -eq 0 ]; then
              echo "No drift: the org matches the repository."
            else
              echo "Drift found: the org and the repository differ in $TOTAL places."
              list "Changed in the org" "$CHANGED" "$RUNNER_TEMP/changed-in-org.txt"
              list "In the org and not in the repository" "$ADDED" "$RUNNER_TEMP/only-in-org.txt"
              list "In the repository and not in the org" "$MISSING" "$RUNNER_TEMP/missing-in-org.txt"
            fi
          } > "$RUNNER_TEMP/drift-report.md"
          cat "$RUNNER_TEMP/drift-report.md"

          if [ "$TOTAL" -gt 0 ]; then
            echo "drift-detected=true" >> "$GITHUB_OUTPUT"
          else
            echo "drift-detected=false" >> "$GITHUB_OUTPUT"
          fi
          {
            echo "report<<END_OF_DRIFT_REPORT"
            cat "$RUNNER_TEMP/drift-report.md"
            echo "END_OF_DRIFT_REPORT"
          } >> "$GITHUB_OUTPUT"

      - name: Open or update the GitHub issue for drift
        if: steps.drift.outputs.drift-detected == 'true'
        uses: actions/github-script@v9
        env:
          ORG_NAME: ${{ inputs.org-name }}
          BRANCH: ${{ inputs.branch }}
        with:
          script: |
            const fs = require('fs');
            const report = fs.readFileSync(`${process.env.RUNNER_TEMP}/drift-report.md`, 'utf8');
            const date = new Date().toISOString().split('T')[0];
            const { owner, repo } = context.repo;
            const orgName = process.env.ORG_NAME;
            const label = 'drift-detected';
            const prefix = `[Drift Detected] ${orgName}:`;
            const title = `${prefix} org metadata divergence - ${date}`;
            const body = [
              `## Metadata Drift Report: ${orgName}`,
              '',
              `Compared with the branch \`${process.env.BRANCH}\`.`,
              '',
              report,
              '',
              'Resolve via a PR so the change enters the quality gate chain.',
              '',
              `_Detected by the scheduled scan of ${orgName} on ${date}._`
            ].join('\n');
            // One open issue for each org. A later run that finds drift in the
            // same org adds a comment to it and does not open a second issue.
            const open = await github.rest.issues.listForRepo({ owner, repo, state: 'open', labels: label, per_page: 100 });
            const issue = open.data.find((item) => !item.pull_request && item.title.startsWith(prefix));
            if (issue) {
              await github.rest.issues.createComment({ owner, repo, issue_number: issue.number, body });
            } else {
              await github.rest.issues.create({ owner, repo, title, body, labels: [label, 'needs-triage'] });
            }

      - name: Write job summary
        if: always()
        env:
          ORG_NAME: ${{ inputs.org-name }}
          BRANCH: ${{ inputs.branch }}
        run: |
          {
            echo "## Metadata Drift Detection: $ORG_NAME"
            echo ""
            echo "Compared with the branch \`$BRANCH\`."
            echo ""
          } >> "$GITHUB_STEP_SUMMARY"
          if [ -f "$RUNNER_TEMP/drift-report.md" ]; then
            cat "$RUNNER_TEMP/drift-report.md" >> "$GITHUB_STEP_SUMMARY"
          else
            echo "The comparison did not run. See the log of the step that failed." >> "$GITHUB_STEP_SUMMARY"
          fi

  # ── Compilation check (parallel with drift detection) ───────

  apex-compilation:
    name: Apex Compilation
    uses: ./.github/workflows/sf-apex-compilation.yml
    with:
      org-name: ${{ inputs.org-name }}
      test-level: ${{ inputs.test-level }}
      sf-username: ${{ inputs.sf-username }}
      sf-instance-url: ${{ inputs.sf-instance-url }}
    secrets:
      SF_JWT_KEY: ${{ secrets.SF_JWT_KEY }}
      SF_CLIENT_ID: ${{ secrets.SF_CLIENT_ID }}

  # ── Full-org Apex tests (after compilation passes) ──────────
  # Commented out: the nightly test runner (scheduled-test-runner.yml) runs
  # the full-org tests every weeknight. If the tests are not run at least weekly
  # somewhere else, uncomment this job so that they run here, add
  # apex-tests to the needs list of notify-failure, pass CODECOV_TOKEN to this
  # workflow, and give the job the coverage threshold as an input, as
  # scheduled-test-runner.yml does.

  # apex-tests:
  #   name: Full-Org Apex Tests
  #   needs: apex-compilation
  #   uses: ./.github/workflows/sf-apex-tests.yml
  #   with:
  #     org-name: ${{ inputs.org-name }}
  #     sf-username: ${{ inputs.sf-username }}
  #     sf-instance-url: ${{ inputs.sf-instance-url }}
  #   secrets:
  #     SF_JWT_KEY: ${{ secrets.SF_JWT_KEY }}
  #     SF_CLIENT_ID: ${{ secrets.SF_CLIENT_ID }}
  #     CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }}

  # ── Drift report (every run, drift or none) ─────────────────
  # Runs when drift detection finished, whatever it found. Drift leaves
  # that job green, so this report is how the team hears of it.

  drift-report:
    name: Drift Report
    needs: drift-detection
    uses: ./.github/workflows/sf-notify-slack.yml
    with:
      status: ${{ needs.drift-detection.outputs.drift-detected == 'true' && 'Drift found' || 'No drift' }}
      title: "Drift Report: ${{ inputs.org-name }}"
      message: ${{ needs.drift-detection.outputs.report }}
      run-url: "https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }}"
    secrets:
      SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }}

  # ── Failure notification ────────────────────────────────────
  # failure() is true when a job this one needs has failed: Apex that does
  # not compile, or a check that could not run, such as a failed login.

  notify-failure:
    name: Notify on Failure
    needs: [drift-detection, apex-compilation]
    if: failure()
    uses: ./.github/workflows/sf-notify-slack.yml
    with:
      status: "Failure"
      title: "Org Scan Failed: ${{ inputs.org-name }}"
      message: "One or more checks of the scan of ${{ inputs.org-name }} failed. Review the workflow run for details."
      run-url: "https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }}"
    secrets:
      SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }}

A.2.15 - Apex Compilation Check

Retrieves all ApexClass and ApexTrigger metadata from the target org and runs a dry-run deployment of all of it to verify org-wide compilation. The test level is an input. The default, NoTestRun, compiles and runs no test, and a sandbox and a scratch org accept it. Production does not: it refuses a validation of Apex without tests ("testLevel of NoTestRun cannot be used in production organizations"), so a caller for production passes RunLocalTests, and the check then runs every local test too. Unlike the delta-scoped validation in A.2.12, this check covers the entire org and catches compilation failures not surfaced by PR-scoped gates. The job summary lists each failure in a table: the component, its type, the line, and the problem. On a failure the workflow opens a GitHub issue for the org, named by its org-name input, or adds a comment to the issue that is already open for that org, and then fails its job, so that the caller's failure notification fires and a job that waits on this one does not run. When the check runs tests, the summary also lists a failing test or a coverage warning.

The standard this workflow implements: the scheduled compilation checks in Section 6, PR-Time: Dependency Analysis and Impact Assessment.

Expand to show Apex Compilation Check reusable workflow
Apex Compilation Check
# .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 of all of them.
#
# Unlike sf-validate-deployment.yml (which validates a delta package against
# a manifest), this workflow test-deploys all ApexClass and ApexTrigger files
# to catch org-wide compilation failures not caught by PR-scoped gates.
# Intended for scheduled surveillance - not PR-time gating.
#
# The test level is an input. NoTestRun (the default) compiles and runs no
# test, intended for scratch and sandbox orgs. Production does not accept a
# validation of Apex without tests, so a caller for production passes
# RunLocalTests, so the check then runs every local test too.
# On failure it opens a GitHub issue for the org (org-name input), or adds a
# comment to the issue that is already open for it, then fails the job.

name: Apex Compilation Check

on:
  workflow_call:
    inputs:
      org-name:
        description: "The name of the org, such as Production or Staging, in the issue title; each org has its own open issue"
        required: true
        type: string
      test-level:
        description: "Test level of the dry-run: NoTestRun for a sandbox or a scratch org, RunLocalTests for production"
        required: false
        type: string
        default: "NoTestRun"
      wait-minutes:
        description: "Minutes to wait for the compilation dry-run to complete"
        required: false
        type: string
        default: "60"
      sf-username:
        description: "The username of the integration user that the workflow logs in as, from the caller's SF_USERNAME_<ORG> variable"
        required: true
        type: string
      sf-instance-url:
        description: "The instance URL of the org, from the caller's SF_INSTANCE_URL_<ORG> variable"
        required: true
        type: string
    secrets:
      SF_JWT_KEY:
        description: "The private key of the JWT bearer flow for the org"
        required: true
      SF_CLIENT_ID:
        description: "The client ID (consumer key) of the External Client App in the org"
        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@v7

      - name: Set up Salesforce environment
        uses: ./.github/actions/setup-sf
        with:
          install-code-analyzer: "false"
          install-sfdx-git-delta: "false"

      - name: Authenticate to target org (JWT)
        uses: ./.github/actions/sf-auth-jwt
        with:
          jwt-key: ${{ secrets.SF_JWT_KEY }}
          client-id: ${{ secrets.SF_CLIENT_ID }}
          username: ${{ inputs.sf-username }}
          instance-url: ${{ inputs.sf-instance-url }}
          alias: target-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. --ignore-conflicts lets a
          # fresh checkout retrieve from an org that tracks source.
          sf project retrieve start \
            --target-org target-org \
            --metadata ApexClass ApexTrigger \
            --ignore-conflicts \
            --wait ${{ inputs.wait-minutes }}

      - name: Run Apex compilation check
        id: compile
        continue-on-error: true
        env:
          TEST_LEVEL: ${{ inputs.test-level }}
        run: |
          # Dry-run deployment of all Apex compiles every class and trigger,
          # confirming compilation integrity. With NoTestRun it runs no test.
          # Production refuses that level ("testLevel of NoTestRun cannot be
          # used in production organizations"), so its caller passes
          # RunLocalTests.

          sf project deploy start \
            --dry-run \
            --target-org target-org \
            --metadata ApexClass ApexTrigger \
            --test-level "$TEST_LEVEL" \
            --ignore-conflicts \
            --wait ${{ inputs.wait-minutes }} \
            --json > compile-results.json

      - name: Write job summary
        if: always()
        run: |
          echo "## Apex Compilation Check" >> "$GITHUB_STEP_SUMMARY"
          # No result file, or a file that is not JSON: the check did not run
          # to its end. That is not the same as a compilation failure.
          if ! jq -e . compile-results.json >/dev/null 2>&1; then
            echo "The check gave no result. Read the log of the step that runs it." >> "$GITHUB_STEP_SUMMARY"
            exit 0
          fi
          STATUS=$(jq -r '.status' compile-results.json)
          if [ "$STATUS" == "0" ]; then
            echo "All Apex compiled successfully." >> "$GITHUB_STEP_SUMMARY"
          else
            # One row for each compile failure. With a test level that runs
            # tests, the Apex can compile and a test or the coverage can
            # fail: then the tests and the warnings are listed. Where the
            # result lists none of these, the message says why the check failed.

            jq -r '
              def rows: if type == "array" then . elif . == null then [] else [.] end;
              def cell: (. // "") | tostring | gsub("\\|"; "\\|") | gsub("<"; "&lt;") | gsub("\r?\n"; "<br>");
              (.result.details.componentFailures | rows) as $failures
              | (.result.details.runTestResult.failures | rows) as $tests
              | (.result.details.runTestResult.codeCoverageWarnings | rows) as $warnings
              | if ($failures | length) > 0 then
                  "Compilation failures detected.\n\n| Component | Type | Line | Problem |\n|---|---|---|---|\n"
                  + ($failures | map("| `\(.fullName)` | \(.componentType | cell) | \(.lineNumber | cell) | \(.problem | cell) |") | join("\n"))
                elif ($tests | length) > 0 or ($warnings | length) > 0 then
                  "All Apex compiled. The validation failed in its tests."
                  + (if ($tests | length) > 0 then
                      "\n\n| Test | Method | Message |\n|---|---|---|\n"
                      + ($tests | map("| `\(.name)` | `\(.methodName)` | \(.message | cell) |") | join("\n"))
                    else "" end)
                  + (if ($warnings | length) > 0 then
                      "\n\n| Class or trigger | Coverage warning |\n|---|---|\n"
                      + ($warnings | map("| `\(.name // "All")` | \(.message | cell) |") | join("\n"))
                    else "" end)
                else
                  "The check failed, and its result lists no component. The message of the result:\n\n"
                  + (.message // "(no message)" | cell)
                end
            ' compile-results.json >> "$GITHUB_STEP_SUMMARY"
          fi

      - name: Open or update the GitHub issue for the compilation failure
        if: steps.compile.outcome == 'failure'
        uses: actions/github-script@v9
        env:
          ORG_NAME: ${{ inputs.org-name }}
        with:
          script: |
            const date = new Date().toISOString().split('T')[0];
            const { owner, repo } = context.repo;
            const runUrl = `https://github.com/${owner}/${repo}/actions/runs/${context.runId}`;
            const orgName = process.env.ORG_NAME;
            const label = 'compilation-failure';
            const prefix = `[Compilation Failure] ${orgName}:`;
            const title = `${prefix} Apex compilation check failed - ${date}`;
            const body = [
              `## Apex Compilation Failure: ${orgName}`,
              '',
              `The scheduled compilation check of ${orgName} failed.`,
              '',
              `The job summary of the [workflow run](${runUrl}) lists each failure.`,
              '',
              `_Detected by the scheduled check of ${orgName} on ${date}._`
            ].join('\n');
            // One open issue for each org. A later failing run for the same
            // org adds a comment to it and does not open a second issue.
            const open = await github.rest.issues.listForRepo({ owner, repo, state: 'open', labels: label, per_page: 100 });
            const issue = open.data.find((item) => !item.pull_request && item.title.startsWith(prefix));
            if (issue) {
              await github.rest.issues.createComment({ owner, repo, issue_number: issue.number, body });
            } else {
              await github.rest.issues.create({ owner, repo, title, body, labels: [label, 'needs-triage'] });
            }

      # The compile step may fail without stopping the job, so that the
      # summary and the issue above still happen. The job has to fail in the
      # end: a caller's failure() is true only for a failed job.
      - name: Fail the job when Apex does not compile
        if: steps.compile.outcome == 'failure'
        run: |
          echo "::error::The Apex compilation check failed. A GitHub issue has the link to the failures."
          exit 1

A.2.16 - Apex Tests (Full Org)

Runs full-org RunLocalTests and returns a short report for the caller to post on every run, pass or fail. The job summary has a table of every failing test with its message, and below the table the stack trace of each, in a block that the reader opens; both come from the apex-test-report action (A.3.5); the short report names ten failing tests at most, because it goes to Slack. On a failure it also opens a GitHub issue for the org, named by its org-name input, with that table and those stack traces, or adds a comment to the issue that is already open for that org, and then fails its job, so that the run shows red. Intended for scheduled surveillance, not PR-time gating. The coverage threshold of the report is an input with no default: the caller sets it once, at the top of its file, and passes it (A.1).

The standard this workflow implements: the scheduled full-org test runs in Section 6, PR-Time: Dependency Analysis and Impact Assessment.

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

Expand to show Apex Tests (Full Org) reusable workflow
Apex Tests (Full Org)
# .github/workflows/sf-apex-tests.yml
# Reusable: runs full-org Apex tests (RunLocalTests).
# Intended for scheduled/nightly surveillance, not PR-time gating.
# Returns a report of the run for the caller to post, pass or fail.
# On failure it opens a GitHub issue for the org (org-name input), with the
# table of every failing test, or adds a comment to the issue that is already
# open for it, then fails the job.

name: Apex Tests (Full Org)

on:
  workflow_call:
    inputs:
      org-name:
        description: "The name of the org, such as Production or Staging, in the issue title; each org has its own open issue"
        required: true
        type: string
      coverage-threshold:
        description: "Apex coverage percentage below which a class is listed in the low-coverage table. The caller sets it once, at the top of its file, and passes it; there is no default here"
        required: true
        type: string
      wait-minutes:
        description: "Minutes to wait for test run to complete"
        required: false
        type: string
        default: "120"
      sf-username:
        description: "The username of the integration user that the workflow logs in as, from the caller's SF_USERNAME_<ORG> variable"
        required: true
        type: string
      sf-instance-url:
        description: "The instance URL of the org, from the caller's SF_INSTANCE_URL_<ORG> variable"
        required: true
        type: string
    secrets:
      SF_JWT_KEY:
        description: "The private key of the JWT bearer flow for the org"
        required: true
      SF_CLIENT_ID:
        description: "The client ID (consumer key) of the External Client App in the org"
        required: true
      CODECOV_TOKEN:
        required: false
    outputs:
      outcome:
        description: "'Passed' or 'Failed'; empty when the run produced no results"
        value: ${{ jobs.full-org-tests.outputs.outcome }}
      report:
        description: "A short report of the run, pass or fail, to post in a channel; empty when the run produced no results"
        value: ${{ jobs.full-org-tests.outputs.report }}

jobs:
  full-org-tests:
    name: Full-Org Apex Test Run
    runs-on: ubuntu-latest
    permissions:
      contents: read
      issues: write
    outputs:
      outcome: ${{ steps.test-report.outputs.outcome }}
      report: ${{ steps.test-report.outputs.report }}
    env:
      TEST_RESULTS_DIR: test-results
      ORG_ALIAS: target-org
      HAS_CODECOV: ${{ secrets.CODECOV_TOKEN != '' }}
    steps:
      - name: Checkout repository
        uses: actions/checkout@v7

      - name: Set up Salesforce environment
        uses: ./.github/actions/setup-sf
        with:
          install-code-analyzer: "false"
          install-sfdx-git-delta: "false"

      - name: Authenticate to target org (JWT)
        uses: ./.github/actions/sf-auth-jwt
        with:
          jwt-key: ${{ secrets.SF_JWT_KEY }}
          client-id: ${{ secrets.SF_CLIENT_ID }}
          username: ${{ inputs.sf-username }}
          instance-url: ${{ inputs.sf-instance-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
        id: results
        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 }}
          coverage-threshold: ${{ inputs.coverage-threshold }}

      # One short report for the caller to post, pass or fail. No report is
      # written when the run produced no results file. It names 10 failing
      # tests at most, because it goes to Slack. The job summary and the
      # GitHub issue have the table of every failing test.
      - name: Build the test report
        id: test-report
        if: always() && (steps.run-tests.outcome == 'success' || steps.run-tests.outcome == 'failure')
        run: |
          RESULTS="$TEST_RESULTS_DIR/test-result-$(cat "$TEST_RESULTS_DIR/test-run-id.txt" 2>/dev/null).json"
          if [ ! -f "$RESULTS" ]; then
            echo "No test results file was written, so there is no report."
            exit 0
          fi
          RAN=$(jq -r '.summary.testsRan' "$RESULTS")
          FAILING=$(jq -r '.summary.failing' "$RESULTS")
          COVERAGE=$(jq -r '.summary.orgWideCoverage' "$RESULTS")
          if [ "$FAILING" -eq 0 ]; then
            OUTCOME=Passed
          else
            OUTCOME=Failed
          fi
          {
            echo "outcome=$OUTCOME"
            echo "report<<END_OF_TEST_REPORT"
            if [ "$OUTCOME" = "Passed" ]; then
              echo "Tests passed: $RAN ran and none failed. Org-wide coverage: $COVERAGE."
            else
              echo "Tests failed: $FAILING of $RAN failed. Org-wide coverage: $COVERAGE."
              echo
              echo "Failing tests ($FAILING):"
              jq -r '.tests[] | select(.Outcome != "Pass" and .Outcome != "Skip") | "- \(.FullName)"' "$RESULTS" | head -n 10
              if [ "$FAILING" -gt 10 ]; then
                echo "- and $((FAILING - 10)) more"
              fi
            fi
            echo "END_OF_TEST_REPORT"
          } >> "$GITHUB_OUTPUT"

      - name: Upload coverage to Codecov
        if: (steps.run-tests.outcome == 'success' || steps.run-tests.outcome == 'failure') && env.HAS_CODECOV == 'true'
        uses: codecov/codecov-action@v7
        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: Open or update the GitHub issue for the failure
        if: steps.run-tests.outcome == 'failure'
        uses: actions/github-script@v9
        env:
          FAILING_TESTS: ${{ steps.results.outputs.failing-markdown }}
          ORG_NAME: ${{ inputs.org-name }}
        with:
          script: |
            const date = new Date().toISOString().split('T')[0];
            const { owner, repo } = context.repo;
            const runUrl = `https://github.com/${owner}/${repo}/actions/runs/${context.runId}`;
            const orgName = process.env.ORG_NAME;
            const label = 'test-failure';
            const prefix = `[Test Failure] ${orgName}:`;
            const title = `${prefix} full-org Apex test run failed - ${date}`;
            // The table of every failing test, from the report step. An issue
            // holds 65,536 characters at most, so a very long table is left
            // out. The job summary of the run always has the whole table.
            let table = process.env.FAILING_TESTS || '';
            if (table.length > 60000) {
              table = 'The table of failing tests is too long for an issue. The job summary of the workflow run has it.';
            }
            const body = [
              `## Full-Org Apex Test Failure: ${orgName}`,
              '',
              'Failures detected that were not caught by PR-time scoped test execution.',
              '',
              table,
              '',
              `Review the [workflow run](${runUrl}) for details.`,
              '',
              `_Detected by the scheduled test run of ${orgName} on ${date}._`
            ].join('\n');
            // One open issue for each org. A later failing run for the same
            // org adds a comment to it and does not open a second issue.
            const open = await github.rest.issues.listForRepo({ owner, repo, state: 'open', labels: label, per_page: 100 });
            const issue = open.data.find((item) => !item.pull_request && item.title.startsWith(prefix));
            if (issue) {
              await github.rest.issues.createComment({ owner, repo, issue_number: issue.number, body });
            } else {
              await github.rest.issues.create({ owner, repo, title, body, labels: [label, 'needs-triage'] });
            }

      # The test step may fail without stopping the job, so that the report,
      # the coverage upload, and the issue above still happen. The job has to
      # fail in the end: a caller's failure() is true only for a failed job.
      - name: Fail the job when the test run failed
        if: steps.run-tests.outcome == 'failure'
        run: |
          echo "::error::The full-org Apex test run failed. A GitHub issue has the failing tests."
          exit 1

The test-results/test-result-codecoverage.json output can be uploaded directly to Codecov without format conversion, as the workflow above does. The --code-coverage flag is required for the coverage file to be generated; omitting it produces test results without per-class coverage data.

The output from sf apex run test --result-format json is well-suited to jq post-processing for the structured PR comment pattern described in Section 4 - extracting failing test names, per-class coverage percentages, and classes below the organisational coverage threshold.

A.2.17 - 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. A message may hold quotes and several lines, as the drift report does, and the job fails when Slack refuses the post.

The workflow posts to one webhook URL. A team that sends each kind of alert to a channel of its own adds a channel input to sf-notify-slack.yml, or makes one notification workflow for each kind of alert. An organisation that uses the Slack GitHub App rather than incoming webhooks replaces the curl step with slackapi/slack-github-action; the inputs stay the same, so the calling workflows do not change.

The pattern this workflow implements: the scheduled-failure alerting in Section 4, Workflow Trigger Strategy.

Expand to show Notify Slack reusable workflow
Notify Slack
# .github/workflows/sf-notify-slack.yml
# Reusable workflow: posts a notification to a Slack channel.
#
# This implementation posts to a Slack incoming webhook URL with curl. The
# URL is 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', 'Warning', or 'No drift'"
        required: true
        type: string
      title:
        description: "Short title for the notification"
        required: true
        type: string
      message:
        description: "Body text describing the event; may hold several lines"
        required: true
        type: string
      run-url:
        description: "URL to the GitHub Actions workflow run"
        required: true
        type: string
    secrets:
      SLACK_WEBHOOK_URL:
        required: true

# A webhook post needs no GITHUB_TOKEN, so the job drops every scope.
permissions: {}

jobs:
  notify:
    name: Post Slack Notification
    runs-on: ubuntu-latest
    steps:
      - name: Post message to Slack
        env:
          # Every value reaches the script through the environment, never
          # written into it. jq --arg encodes each one as JSON data, so a
          # quote or a line break in a message cannot break the payload, and
          # a hostile title cannot run as a command.
          SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }}
          STATUS: ${{ inputs.status }}
          TITLE: ${{ inputs.title }}
          MESSAGE: ${{ inputs.message }}
          RUN_URL: ${{ inputs.run-url }}
        run: |
          # Slack accepts 150 characters in a header and 3,000 in a section,
          # and refuses an empty section.
          payload="$(jq -n \
            --arg header "$STATUS: $TITLE" \
            --arg message "$MESSAGE" \
            --arg run_url "$RUN_URL" \
            '{
              blocks: [
                { type: "header", text: { type: "plain_text", text: $header[0:150] } },
                {
                  type: "section",
                  text: {
                    type: "mrkdwn",
                    text: (if $message == "" then "No details were given." else $message[0:2900] end)
                  }
                },
                {
                  type: "actions",
                  elements: [
                    {
                      type: "button",
                      text: { type: "plain_text", text: "View Workflow Run" },
                      url: $run_url
                    }
                  ]
                }
              ]
            }')"
          # --fail-with-body fails the job when Slack refuses the post, and
          # shows the reason Slack gives.
          curl --silent --show-error --fail-with-body --request POST "$SLACK_WEBHOOK_URL" \
            --header "Content-Type: application/json" \
            --data "$payload"