A.1 - Caller Workflows¶
Caller workflows are the thin top-level files wired to GitHub Actions trigger events: push, pull_request, schedule, workflow_dispatch. Each caller composes one or more reusable workflows (defined in A.2) into a complete pipeline for that trigger. The caller is responsible for event filtering, concurrency scoping, and secret forwarding; it does not contain the quality gate logic itself.
A caller that passes a number to a workflow it calls sets that number once, at the top of its file, in an env: block: the severity threshold at which a Code Analyzer violation blocks (2), and the Apex coverage below which a class is listed (85%). A first job, Variables, turns each value into an output, and every call that takes the value reads it from there, because GitHub does not let a call to a reusable workflow read env directly. The called workflows and actions take each number as an input with no default, so the file at the top is the one place to change it. A value that a later change has to carry down the tree of workflows and actions goes in the same block. The coverage threshold of the samples, 85%, is above the Salesforce platform minimum of 75%; the organisation sets it to match the standard agreed at project kickstart.
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.1.1 - CI on Push¶
Lightweight push-time quality baseline. Runs on every push to provide immediate feedback before a PR is opened. The secret scan runs on every branch, on its own, because a pushed secret would stay on GitHub even after a squash merge. A branch whose name starts with feat, where work in progress is pushed, gets only that scan and the formatting check: its code analysis and Jest tests run on its pull request. Every other branch gets every check. On pushes to main, also triggers automated release management via release-please. The severity threshold of the static analysis is set at the top of the file and passed by the Variables job.
The static analysis job scans only changed files: the ci mode of A.2.9 builds a delta package with sfdx-git-delta, from the commit before the push to the new commit, and scans that delta package just as a pull request scans its own. A push never scans the whole repository; that scan runs on demand (A.1.7).
The standards behind this workflow: Section 4, Workflow Trigger Strategy and Push-Time: Lightweight Local Feedback and Prevention.
Expand to show CI on Push caller workflow
# .github/workflows/ci.yml
# Push Quality Baseline: runs lightweight quality checks on every push to
# every branch. The checks are the secret scan, the formatting check, the
# Code Analyzer scan, and the Jest tests.
# - main and a pipeline branch: every check.
# - A feature branch (its name starts with feat): the secret scan and the
# formatting check only. Its Code Analyzer scan and Jest tests run on its
# pull request.
# - Any other branch: every check, as on main.
# On pushes to main, it also triggers release management.
name: CI on Push
on:
push:
branches:
- "**"
concurrency:
group: push-${{ github.ref }}
cancel-in-progress: true
env:
# Every value that a called workflow or action takes as a parameter is set
# here, once. The Variables job below turns each one into an output, and
# the calls read it from there. A value that a later change has to carry
# down the tree of workflows and actions goes here too.
BLOCKING_THRESHOLD: 2 # Code Analyzer violations at this severity or above block
SECURITY_SLACK_MENTION: "" # Who a secret-scan finding notifies, e.g. <!subteam^S0123ABCD> for a user group
SECURITY_SLACK_MESSAGE: "Revoke or rotate the credential first, then reply here with what was done." # Text of a secret-scan finding's post
jobs:
# ── Variables: the values at the top of the file, as outputs ─────
# A call to a reusable workflow cannot read env in its with: block, so
# this job turns each value into an output that every call can read.
variables:
name: Variables
runs-on: ubuntu-latest
outputs:
blocking-threshold: ${{ steps.set.outputs.blocking-threshold }}
security-slack-mention: ${{ steps.set.outputs.security-slack-mention }}
security-slack-message: ${{ steps.set.outputs.security-slack-message }}
steps:
- name: Set the variables
id: set
run: |
{
echo "blocking-threshold=$BLOCKING_THRESHOLD"
echo "security-slack-mention=$SECURITY_SLACK_MENTION"
echo "security-slack-message=$SECURITY_SLACK_MESSAGE"
} >> "$GITHUB_OUTPUT"
# ── Secret scan (every branch, on its own) ──────────────────
# A secret that is pushed stays on GitHub, even after a squash merge, so
# this scan runs on every push to every branch. It waits for no check, only
# for the Variables job, and a finding is posted to the security channel.
secret-scan:
name: Secrets
needs: variables
uses: ./.github/workflows/sf-secret-scan.yml
with:
slack-mention: ${{ needs.variables.outputs.security-slack-mention }}
slack-message: ${{ needs.variables.outputs.security-slack-message }}
secrets:
SLACK_SECURITY_WEBHOOK: ${{ secrets.SLACK_SECURITY_WEBHOOK }}
# ── Formatting (runs first) ─────────────────────────────────
prettier-verify:
name: Formatting
uses: ./.github/workflows/sf-prettier-verify.yml
# ── Quality gates (after formatting; not on a feat branch) ──
code-analyzer:
name: Code Analysis
needs: [variables, prettier-verify]
if: ${{ !startsWith(github.ref_name, 'feat') }}
uses: ./.github/workflows/sf-code-analyzer.yml
with:
mode: ci # scans only the delta package of this push
severity-threshold: ${{ needs.variables.outputs.blocking-threshold }}
jest-tests:
name: Jest Tests
needs: prettier-verify
if: ${{ !startsWith(github.ref_name, 'feat') }}
uses: ./.github/workflows/sf-jest-tests.yml
with:
skip-delta-check: true
secrets:
CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }}
# ── Release Management (main branch only, after all gates) ──
release-please:
name: Release
needs: [secret-scan, code-analyzer, jest-tests]
if: github.ref == 'refs/heads/main'
uses: ./.github/workflows/release-please.yml
secrets:
RELEASE_PLEASE_TOKEN: ${{ secrets.RELEASE_PLEASE_TOKEN }}
SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }}
A.1.2 - PR to Pipeline Branches¶
Full quality gate suite for pull requests targeting the non-prod long-lived branches (staging, qa, and int). Authenticates to the sandbox of the target branch for both Apex tests and deployment validation, by the JWT bearer flow (A.3.2). Each branch has credentials of its own, named with the branch in capitals as their suffix: the secrets SF_JWT_KEY_STAGING and SF_CLIENT_ID_STAGING and the variables SF_USERNAME_STAGING and SF_INSTANCE_URL_STAGING, and likewise _QA and _INT. The caller's variables job turns the target branch into that suffix: in capitals, with every character other than a letter or a digit made an underscore, because a secret's name allows only letters, digits, and underscores. A branch named release-uat reads SF_JWT_KEY_RELEASE_UAT. The branch-specific split between this file and pr-prod.yml exists solely so that each caller can pass the correct credentials; the underlying reusable workflows are identical. A new long-lived branch, such as a pre-production hotfix branch, joins this caller: add the branch to its branches list, and add the four credentials of the branch's org, with the branch as their suffix (A.4). A new environment is a change to a caller, not to a reusable workflow.
The gates run in four stages, and a Variables job, which is not a gate, runs beside the first: it passes the severity threshold and the coverage threshold from the top of the file to the gates that take them. The seven quick gates (A.2.1 to A.2.7) run first, in parallel. The delta package (A.2.8) needs all seven, so a pull request that fails a quick gate ends in seconds, with the longer jobs skipped and no org touched. The three gates that work on the delta package (A.2.9 to A.2.11) run next, in parallel, and the validation (A.2.12) last. The gate table shows the stage of each gate.
The standards behind this workflow: Section 4, Workflow Trigger Strategy and Section 4, PR Review Governance and Auto-Assignment; the rulesets of these branches are in Appendix C.
The workflow ends with a Quality Gates job, the only check the branch rulesets require (Appendix C). It depends on every other job, runs even when they fail or are skipped, and fails only if one of them failed or was cancelled. The workflow has no paths: filter: a workflow skipped by a filter leaves its required checks pending and blocks the merge, so it runs on every PR, and gates with nothing to check skip themselves.
Expand to show PR to Pipeline caller workflow
# .github/workflows/pr-pipeline.yml
# PR Quality Gates (pipeline-bound): comprehensive quality gate suite for pull
# requests targeting non-prod long-lived branches. Authenticates to the
# sandbox of the target branch for both Apex tests and deployment validation.
name: PR to Pipeline
on:
pull_request:
branches: [staging, qa, int]
types: [opened, synchronize, ready_for_review]
concurrency:
group: pr-pipeline-${{ github.event.pull_request.number }}
cancel-in-progress: true
env:
# Every value that a called workflow or action takes as a parameter is set
# here, once. The Variables job below turns each one into an output, and
# the calls read it from there. A value that a later change has to carry
# down the tree of workflows and actions goes here too.
BLOCKING_THRESHOLD: 2 # Code Analyzer violations at this severity or above block
APEX_COVERAGE_THRESHOLD: 85 # Apex classes below this coverage are listed
SECURITY_SLACK_MENTION: "" # Who a secret-scan finding notifies, e.g. <!subteam^S0123ABCD> for a user group
SECURITY_SLACK_MESSAGE: "Revoke or rotate the credential first, then reply here with what was done." # Text of a secret-scan finding's post
jobs:
# ── Variables: the values at the top of the file, as outputs ─────
# A call to a reusable workflow cannot read env in its with: block, so
# this job turns each value into an output that every call can read.
variables:
name: Variables
runs-on: ubuntu-latest
outputs:
blocking-threshold: ${{ steps.set.outputs.blocking-threshold }}
apex-coverage-threshold: ${{ steps.set.outputs.apex-coverage-threshold }}
security-slack-mention: ${{ steps.set.outputs.security-slack-mention }}
security-slack-message: ${{ steps.set.outputs.security-slack-message }}
org-suffix: ${{ steps.set.outputs.org-suffix }}
steps:
- name: Set the variables
id: set
run: |
{
echo "blocking-threshold=$BLOCKING_THRESHOLD"
echo "apex-coverage-threshold=$APEX_COVERAGE_THRESHOLD"
echo "security-slack-mention=$SECURITY_SLACK_MENTION"
echo "security-slack-message=$SECURITY_SLACK_MESSAGE"
# The suffix of the secrets and variables of the sandbox that the pull
# request validates in: the target branch in capitals, with every
# character other than a letter or a digit made an underscore, which a
# secret's name allows; STAGING, QA, or INT, and RELEASE_UAT for release-uat.
echo "org-suffix=$(printf '%s' "$GITHUB_BASE_REF" | tr '[:lower:]' '[:upper:]' | tr -c 'A-Z0-9' '_')"
} >> "$GITHUB_OUTPUT"
# ── Stage 1: the quick gates, in parallel ──────────────────
prettier-verify:
name: Formatting
uses: ./.github/workflows/sf-prettier-verify.yml
semver-check:
name: PR Title
uses: ./.github/workflows/sf-semver-check.yml
secret-scan:
name: Secrets
needs: variables
uses: ./.github/workflows/sf-secret-scan.yml
with:
slack-mention: ${{ needs.variables.outputs.security-slack-mention }}
slack-message: ${{ needs.variables.outputs.security-slack-message }}
secrets:
SLACK_SECURITY_WEBHOOK: ${{ secrets.SLACK_SECURITY_WEBHOOK }}
prohibited-metadata:
name: Prohibited Metadata
uses: ./.github/workflows/sf-prohibited-metadata.yml
destructive-check:
name: Destructive Changes
uses: ./.github/workflows/sf-destructive-check.yml
risk-tier:
name: Risk Tier
uses: ./.github/workflows/sf-risk-tier.yml
changelog-check:
name: Changelog
uses: ./.github/workflows/sf-changelog-check.yml
# ── Stage 2: the delta package, once every quick gate has passed ──
# A failed quick gate skips this job and everything that needs it,
# so the run ends here instead of after the validation.
generate-delta:
name: Delta Package
needs:
[
prettier-verify,
semver-check,
secret-scan,
prohibited-metadata,
destructive-check,
risk-tier,
changelog-check
]
uses: ./.github/workflows/sf-generate-delta.yml
# ── Stage 3: the gates that work on the delta package, in parallel ──
code-analyzer:
name: Code Analysis
needs: [variables, generate-delta]
if: needs.generate-delta.outputs.artifact-uploaded == 'true'
uses: ./.github/workflows/sf-code-analyzer.yml
with:
mode: pr
severity-threshold: ${{ needs.variables.outputs.blocking-threshold }}
jest-tests:
name: Jest Tests
needs: generate-delta
if: needs.generate-delta.outputs.artifact-uploaded == 'true'
uses: ./.github/workflows/sf-jest-tests.yml
secrets:
CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }}
apex-tests:
name: Apex Test Selection
needs: generate-delta
if: needs.generate-delta.outputs.artifact-uploaded == 'true'
uses: ./.github/workflows/sf-apex-test-selection.yml
with:
# The organization's setting wins when it has one; otherwise the
# repository's 'true' selects RunRelevantTests. Until then the list is computed.
organization-run-relevant-tests: ${{ vars.APEX_RUN_RELEVANT_TESTS_ORGANIZATION }}
run-relevant-tests: ${{ vars.APEX_RUN_RELEVANT_TESTS }}
# ── Stage 4: the validation against the org ───────────────
validate-deploy:
name: Validation
# Destructive change detection is a stage 1 gate, so the delta package
# already guarantees it; the validation names only what it uses.
needs: [variables, generate-delta, code-analyzer, apex-tests]
if: needs.generate-delta.outputs.artifact-uploaded == 'true' && !failure() && !cancelled()
uses: ./.github/workflows/sf-validate-deployment.yml
with:
test-level: ${{ needs.apex-tests.outputs.test-level }}
tests: ${{ needs.apex-tests.outputs.tests }}
coverage-threshold: ${{ needs.variables.outputs.apex-coverage-threshold }}
# The credentials of the sandbox of the branch that the pull request
# targets, by the suffix that the variables job sets.
sf-username: ${{ vars[format('SF_USERNAME_{0}', needs.variables.outputs.org-suffix)] }}
sf-instance-url: ${{ vars[format('SF_INSTANCE_URL_{0}', needs.variables.outputs.org-suffix)] }}
secrets:
SF_JWT_KEY: ${{ secrets[format('SF_JWT_KEY_{0}', needs.variables.outputs.org-suffix)] }}
SF_CLIENT_ID: ${{ secrets[format('SF_CLIENT_ID_{0}', needs.variables.outputs.org-suffix)] }}
# ── Required check (the only check the rulesets name) ───────
quality-gates:
name: Quality Gates
needs:
[
variables,
prettier-verify,
semver-check,
secret-scan,
prohibited-metadata,
destructive-check,
risk-tier,
changelog-check,
generate-delta,
code-analyzer,
jest-tests,
apex-tests,
validate-deploy
]
if: always()
runs-on: ubuntu-latest
steps:
- name: Fail if any gate failed or was cancelled
env:
RESULTS: ${{ toJSON(needs.*.result) }}
run: |
# A skipped gate (nothing for it to check) counts as passing.
echo "Gate results: $RESULTS"
if echo "$RESULTS" | grep -qE '"(failure|cancelled)"'; then
echo "::error::One or more quality gates failed or were cancelled."
exit 1
fi
Expand to show the differences between PR to Pipeline and PR to Production
The differences between pr-pipeline.yml (A.1.2) and pr-prod.yml (A.1.3). A line that starts with - is in pr-pipeline.yml only, and a line that starts with + is in pr-prod.yml only.
--- .github/workflows/pr-pipeline.yml
+++ .github/workflows/pr-prod.yml
@@ -1,17 +1,17 @@
-# .github/workflows/pr-pipeline.yml
-# PR Quality Gates (pipeline-bound): comprehensive quality gate suite for pull
-# requests targeting non-prod long-lived branches. Authenticates to the
-# sandbox of the target branch for both Apex tests and deployment validation.
+# .github/workflows/pr-prod.yml
+# PR Quality Gates (prod-bound): comprehensive quality gate suite for pull
+# requests targeting `main`. Authenticates to the production org for both
+# Apex tests and deployment validation.
-name: PR to Pipeline
+name: PR to Production
on:
pull_request:
- branches: [staging, qa, int]
+ branches: [main]
types: [opened, synchronize, ready_for_review]
concurrency:
- group: pr-pipeline-${{ github.event.pull_request.number }}
+ group: pr-prod-${{ github.event.pull_request.number }}
cancel-in-progress: true
env:
@@ -36,7 +36,6 @@
apex-coverage-threshold: ${{ steps.set.outputs.apex-coverage-threshold }}
security-slack-mention: ${{ steps.set.outputs.security-slack-mention }}
security-slack-message: ${{ steps.set.outputs.security-slack-message }}
- org-suffix: ${{ steps.set.outputs.org-suffix }}
steps:
- name: Set the variables
id: set
@@ -46,11 +45,6 @@
echo "apex-coverage-threshold=$APEX_COVERAGE_THRESHOLD"
echo "security-slack-mention=$SECURITY_SLACK_MENTION"
echo "security-slack-message=$SECURITY_SLACK_MESSAGE"
- # The suffix of the secrets and variables of the sandbox that the pull
- # request validates in: the target branch in capitals, with every
- # character other than a letter or a digit made an underscore, which a
- # secret's name allows; STAGING, QA, or INT, and RELEASE_UAT for release-uat.
- echo "org-suffix=$(printf '%s' "$GITHUB_BASE_REF" | tr '[:lower:]' '[:upper:]' | tr -c 'A-Z0-9' '_')"
} >> "$GITHUB_OUTPUT"
# ── Stage 1: the quick gates, in parallel ──────────────────
@@ -150,13 +144,11 @@
test-level: ${{ needs.apex-tests.outputs.test-level }}
tests: ${{ needs.apex-tests.outputs.tests }}
coverage-threshold: ${{ needs.variables.outputs.apex-coverage-threshold }}
- # The credentials of the sandbox of the branch that the pull request
- # targets, by the suffix that the variables job sets.
- sf-username: ${{ vars[format('SF_USERNAME_{0}', needs.variables.outputs.org-suffix)] }}
- sf-instance-url: ${{ vars[format('SF_INSTANCE_URL_{0}', needs.variables.outputs.org-suffix)] }}
+ sf-username: ${{ vars.SF_USERNAME_PROD }}
+ sf-instance-url: ${{ vars.SF_INSTANCE_URL_PROD }}
secrets:
- SF_JWT_KEY: ${{ secrets[format('SF_JWT_KEY_{0}', needs.variables.outputs.org-suffix)] }}
- SF_CLIENT_ID: ${{ secrets[format('SF_CLIENT_ID_{0}', needs.variables.outputs.org-suffix)] }}
+ SF_JWT_KEY: ${{ secrets.SF_JWT_KEY_PROD }}
+ SF_CLIENT_ID: ${{ secrets.SF_CLIENT_ID_PROD }}
# ── Required check (the only check the rulesets name) ───────
A.1.3 - PR to Production Branch¶
Identical gate chain to A.1.2, targeted at main. Authenticates to the production org for both Apex tests and deployment validation, by the JWT bearer flow, with the secrets SF_JWT_KEY_PROD and SF_CLIENT_ID_PROD and the variables SF_USERNAME_PROD and SF_INSTANCE_URL_PROD.
The standards behind this workflow are the same as A.1.2's; the production ruleset it runs under is in Appendix C.
Expand to show PR to Production caller workflow
# .github/workflows/pr-prod.yml
# PR Quality Gates (prod-bound): comprehensive quality gate suite for pull
# requests targeting `main`. Authenticates to the production org for both
# Apex tests and deployment validation.
name: PR to Production
on:
pull_request:
branches: [main]
types: [opened, synchronize, ready_for_review]
concurrency:
group: pr-prod-${{ github.event.pull_request.number }}
cancel-in-progress: true
env:
# Every value that a called workflow or action takes as a parameter is set
# here, once. The Variables job below turns each one into an output, and
# the calls read it from there. A value that a later change has to carry
# down the tree of workflows and actions goes here too.
BLOCKING_THRESHOLD: 2 # Code Analyzer violations at this severity or above block
APEX_COVERAGE_THRESHOLD: 85 # Apex classes below this coverage are listed
SECURITY_SLACK_MENTION: "" # Who a secret-scan finding notifies, e.g. <!subteam^S0123ABCD> for a user group
SECURITY_SLACK_MESSAGE: "Revoke or rotate the credential first, then reply here with what was done." # Text of a secret-scan finding's post
jobs:
# ── Variables: the values at the top of the file, as outputs ─────
# A call to a reusable workflow cannot read env in its with: block, so
# this job turns each value into an output that every call can read.
variables:
name: Variables
runs-on: ubuntu-latest
outputs:
blocking-threshold: ${{ steps.set.outputs.blocking-threshold }}
apex-coverage-threshold: ${{ steps.set.outputs.apex-coverage-threshold }}
security-slack-mention: ${{ steps.set.outputs.security-slack-mention }}
security-slack-message: ${{ steps.set.outputs.security-slack-message }}
steps:
- name: Set the variables
id: set
run: |
{
echo "blocking-threshold=$BLOCKING_THRESHOLD"
echo "apex-coverage-threshold=$APEX_COVERAGE_THRESHOLD"
echo "security-slack-mention=$SECURITY_SLACK_MENTION"
echo "security-slack-message=$SECURITY_SLACK_MESSAGE"
} >> "$GITHUB_OUTPUT"
# ── Stage 1: the quick gates, in parallel ──────────────────
prettier-verify:
name: Formatting
uses: ./.github/workflows/sf-prettier-verify.yml
semver-check:
name: PR Title
uses: ./.github/workflows/sf-semver-check.yml
secret-scan:
name: Secrets
needs: variables
uses: ./.github/workflows/sf-secret-scan.yml
with:
slack-mention: ${{ needs.variables.outputs.security-slack-mention }}
slack-message: ${{ needs.variables.outputs.security-slack-message }}
secrets:
SLACK_SECURITY_WEBHOOK: ${{ secrets.SLACK_SECURITY_WEBHOOK }}
prohibited-metadata:
name: Prohibited Metadata
uses: ./.github/workflows/sf-prohibited-metadata.yml
destructive-check:
name: Destructive Changes
uses: ./.github/workflows/sf-destructive-check.yml
risk-tier:
name: Risk Tier
uses: ./.github/workflows/sf-risk-tier.yml
changelog-check:
name: Changelog
uses: ./.github/workflows/sf-changelog-check.yml
# ── Stage 2: the delta package, once every quick gate has passed ──
# A failed quick gate skips this job and everything that needs it,
# so the run ends here instead of after the validation.
generate-delta:
name: Delta Package
needs:
[
prettier-verify,
semver-check,
secret-scan,
prohibited-metadata,
destructive-check,
risk-tier,
changelog-check
]
uses: ./.github/workflows/sf-generate-delta.yml
# ── Stage 3: the gates that work on the delta package, in parallel ──
code-analyzer:
name: Code Analysis
needs: [variables, generate-delta]
if: needs.generate-delta.outputs.artifact-uploaded == 'true'
uses: ./.github/workflows/sf-code-analyzer.yml
with:
mode: pr
severity-threshold: ${{ needs.variables.outputs.blocking-threshold }}
jest-tests:
name: Jest Tests
needs: generate-delta
if: needs.generate-delta.outputs.artifact-uploaded == 'true'
uses: ./.github/workflows/sf-jest-tests.yml
secrets:
CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }}
apex-tests:
name: Apex Test Selection
needs: generate-delta
if: needs.generate-delta.outputs.artifact-uploaded == 'true'
uses: ./.github/workflows/sf-apex-test-selection.yml
with:
# The organization's setting wins when it has one; otherwise the
# repository's 'true' selects RunRelevantTests. Until then the list is computed.
organization-run-relevant-tests: ${{ vars.APEX_RUN_RELEVANT_TESTS_ORGANIZATION }}
run-relevant-tests: ${{ vars.APEX_RUN_RELEVANT_TESTS }}
# ── Stage 4: the validation against the org ───────────────
validate-deploy:
name: Validation
# Destructive change detection is a stage 1 gate, so the delta package
# already guarantees it; the validation names only what it uses.
needs: [variables, generate-delta, code-analyzer, apex-tests]
if: needs.generate-delta.outputs.artifact-uploaded == 'true' && !failure() && !cancelled()
uses: ./.github/workflows/sf-validate-deployment.yml
with:
test-level: ${{ needs.apex-tests.outputs.test-level }}
tests: ${{ needs.apex-tests.outputs.tests }}
coverage-threshold: ${{ needs.variables.outputs.apex-coverage-threshold }}
sf-username: ${{ vars.SF_USERNAME_PROD }}
sf-instance-url: ${{ vars.SF_INSTANCE_URL_PROD }}
secrets:
SF_JWT_KEY: ${{ secrets.SF_JWT_KEY_PROD }}
SF_CLIENT_ID: ${{ secrets.SF_CLIENT_ID_PROD }}
# ── Required check (the only check the rulesets name) ───────
quality-gates:
name: Quality Gates
needs:
[
variables,
prettier-verify,
semver-check,
secret-scan,
prohibited-metadata,
destructive-check,
risk-tier,
changelog-check,
generate-delta,
code-analyzer,
jest-tests,
apex-tests,
validate-deploy
]
if: always()
runs-on: ubuntu-latest
steps:
- name: Fail if any gate failed or was cancelled
env:
RESULTS: ${{ toJSON(needs.*.result) }}
run: |
# A skipped gate (nothing for it to check) counts as passing.
echo "Gate results: $RESULTS"
if echo "$RESULTS" | grep -qE '"(failure|cancelled)"'; then
echo "::error::One or more quality gates failed or were cancelled."
exit 1
fi
Expand to show the differences between PR to Pipeline and PR to Production
The differences between pr-pipeline.yml (A.1.2) and pr-prod.yml (A.1.3). A line that starts with - is in pr-pipeline.yml only, and a line that starts with + is in pr-prod.yml only.
--- .github/workflows/pr-pipeline.yml
+++ .github/workflows/pr-prod.yml
@@ -1,17 +1,17 @@
-# .github/workflows/pr-pipeline.yml
-# PR Quality Gates (pipeline-bound): comprehensive quality gate suite for pull
-# requests targeting non-prod long-lived branches. Authenticates to the
-# sandbox of the target branch for both Apex tests and deployment validation.
+# .github/workflows/pr-prod.yml
+# PR Quality Gates (prod-bound): comprehensive quality gate suite for pull
+# requests targeting `main`. Authenticates to the production org for both
+# Apex tests and deployment validation.
-name: PR to Pipeline
+name: PR to Production
on:
pull_request:
- branches: [staging, qa, int]
+ branches: [main]
types: [opened, synchronize, ready_for_review]
concurrency:
- group: pr-pipeline-${{ github.event.pull_request.number }}
+ group: pr-prod-${{ github.event.pull_request.number }}
cancel-in-progress: true
env:
@@ -36,7 +36,6 @@
apex-coverage-threshold: ${{ steps.set.outputs.apex-coverage-threshold }}
security-slack-mention: ${{ steps.set.outputs.security-slack-mention }}
security-slack-message: ${{ steps.set.outputs.security-slack-message }}
- org-suffix: ${{ steps.set.outputs.org-suffix }}
steps:
- name: Set the variables
id: set
@@ -46,11 +45,6 @@
echo "apex-coverage-threshold=$APEX_COVERAGE_THRESHOLD"
echo "security-slack-mention=$SECURITY_SLACK_MENTION"
echo "security-slack-message=$SECURITY_SLACK_MESSAGE"
- # The suffix of the secrets and variables of the sandbox that the pull
- # request validates in: the target branch in capitals, with every
- # character other than a letter or a digit made an underscore, which a
- # secret's name allows; STAGING, QA, or INT, and RELEASE_UAT for release-uat.
- echo "org-suffix=$(printf '%s' "$GITHUB_BASE_REF" | tr '[:lower:]' '[:upper:]' | tr -c 'A-Z0-9' '_')"
} >> "$GITHUB_OUTPUT"
# ── Stage 1: the quick gates, in parallel ──────────────────
@@ -150,13 +144,11 @@
test-level: ${{ needs.apex-tests.outputs.test-level }}
tests: ${{ needs.apex-tests.outputs.tests }}
coverage-threshold: ${{ needs.variables.outputs.apex-coverage-threshold }}
- # The credentials of the sandbox of the branch that the pull request
- # targets, by the suffix that the variables job sets.
- sf-username: ${{ vars[format('SF_USERNAME_{0}', needs.variables.outputs.org-suffix)] }}
- sf-instance-url: ${{ vars[format('SF_INSTANCE_URL_{0}', needs.variables.outputs.org-suffix)] }}
+ sf-username: ${{ vars.SF_USERNAME_PROD }}
+ sf-instance-url: ${{ vars.SF_INSTANCE_URL_PROD }}
secrets:
- SF_JWT_KEY: ${{ secrets[format('SF_JWT_KEY_{0}', needs.variables.outputs.org-suffix)] }}
- SF_CLIENT_ID: ${{ secrets[format('SF_CLIENT_ID_{0}', needs.variables.outputs.org-suffix)] }}
+ SF_JWT_KEY: ${{ secrets.SF_JWT_KEY_PROD }}
+ SF_CLIENT_ID: ${{ secrets.SF_CLIENT_ID_PROD }}
# ── Required check (the only check the rulesets name) ───────
A.1.4 - Scheduled Nightly Test Runner¶
Full-org Apex test run every weeknight, Monday to Friday, and a run of every Jest test of the Lightning web components. Catches failures in components that depend on recently changed code but were not included in PR-scoped test execution. Slack gets each test report on every run that produced results, pass or fail, and a failure notice when a run cannot finish. The Jest report names the tested components that are below their coverage target, and the number of components with no test. The Apex coverage threshold of the report is set at the top of the file and passed by the Variables job; the Jest targets are in jest.config.js. The production run does not compile all Apex first: in production that check needs every local test, and it runs once a week, on Saturday (A.1.5).
A pipeline sandbox, such as the full sandbox that the staging branch deploys to, gets a compilation check and a full-org test run every weeknight too. They are separate jobs, side by side. The compilation check runs with NoTestRun (A.2.15), so it is fast, and it reports a class that no longer compiles even when no test fails, as happens when a class is deleted and nothing tested the classes that used it. The test run keeps its own report, and a compile failure does not stop it. The sandbox's coverage is not sent to Codecov, which keeps production's. Scratch orgs and the sandboxes of single features get neither check.
The schedule is 0 22 * * 1-5, 10:00 PM from Monday to Friday. GitHub reads a cron schedule in UTC, so adjust the time to the business hours of the organisation, and away from other scheduled work.
The standards behind this workflow: the scheduled full-org test runs in Section 6, PR-Time: Dependency Analysis and Impact Assessment and Section 5, Apex Test Execution.
Expand to show Nightly Apex Test Runner caller workflow
# .github/workflows/scheduled-test-runner.yml
# Nightly test runner: runs full-org Apex tests against production every weeknight,
# writes results to GITHUB_STEP_SUMMARY, and optionally uploads coverage to Codecov.
# It also runs every Jest test of the Lightning web components, with coverage.
# Posts each test report to Slack on every run that produced results, and a
# failure notice when a run cannot finish.
# It does not compile production's Apex first: production accepts that
# check only with every local test, so the check runs once a week, in
# scheduled-project-scan.yml. It also checks a pipeline sandbox, here
# staging's full sandbox: a compilation check with NoTestRun and a full-org
# test run, as separate jobs, so that a class that no longer compiles is
# reported even when no test fails. Scratch orgs and feature sandboxes get
# neither.
name: Nightly Apex Test Runner
on:
schedule:
- cron: "0 22 * * 1-5" # Monday to Friday at 10:00 PM UTC; the weekly project scan runs on Saturday
workflow_dispatch: # Allow manual trigger
env:
# Every value that a called workflow or action takes as a parameter is set
# here, once. The Variables job below turns each one into an output, and
# the calls read it from there. A value that a later change has to carry
# down the tree of workflows and actions goes here too.
APEX_COVERAGE_THRESHOLD: 85 # Apex classes below this coverage are listed
jobs:
# ── Variables: the values at the top of the file, as outputs ─────
# A call to a reusable workflow cannot read env in its with: block, so
# this job turns each value into an output that every call can read.
variables:
name: Variables
runs-on: ubuntu-latest
outputs:
apex-coverage-threshold: ${{ steps.set.outputs.apex-coverage-threshold }}
steps:
- name: Set the variables
id: set
run: |
{
echo "apex-coverage-threshold=$APEX_COVERAGE_THRESHOLD"
} >> "$GITHUB_OUTPUT"
# ── Full-org Apex tests (production) ────────────────────────
production-apex-tests:
name: Production Full-Org Apex Tests
needs: variables
uses: ./.github/workflows/sf-apex-tests.yml
with:
org-name: Production
coverage-threshold: ${{ needs.variables.outputs.apex-coverage-threshold }}
sf-username: ${{ vars.SF_USERNAME_PROD }}
sf-instance-url: ${{ vars.SF_INSTANCE_URL_PROD }}
secrets:
SF_JWT_KEY: ${{ secrets.SF_JWT_KEY_PROD }}
SF_CLIENT_ID: ${{ secrets.SF_CLIENT_ID_PROD }}
CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }}
# ── Test report (every run that produced results) ───────────
# always() lets this job run after a failed test run, which turns its
# job red and still returns its report.
production-test-report:
name: Production Test Report
needs: production-apex-tests
if: always() && needs.production-apex-tests.outputs.report != ''
uses: ./.github/workflows/sf-notify-slack.yml
with:
status: ${{ needs.production-apex-tests.outputs.outcome == 'Passed' && 'Tests passed' || 'Tests failed' }}
title: "Nightly Apex Test Report"
message: ${{ needs.production-apex-tests.outputs.report }}
run-url: "https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }}"
secrets:
SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }}
# ── Pipeline sandbox: compilation check and full-org tests ──
# The two jobs run side by side, so that a compile failure does not hide
# the test results. The compilation check runs with its default test
# level, NoTestRun, which a sandbox accepts. The test run sends no
# coverage to Codecov, which keeps production's coverage. For another
# pipeline sandbox, repeat these three jobs with that sandbox's name and the
# suffix of its secrets and variables.
sandbox-compilation:
name: Sandbox Apex Compilation
uses: ./.github/workflows/sf-apex-compilation.yml
with:
org-name: Staging
sf-username: ${{ vars.SF_USERNAME_STAGING }}
sf-instance-url: ${{ vars.SF_INSTANCE_URL_STAGING }}
secrets:
SF_JWT_KEY: ${{ secrets.SF_JWT_KEY_STAGING }}
SF_CLIENT_ID: ${{ secrets.SF_CLIENT_ID_STAGING }}
sandbox-apex-tests:
name: Sandbox Full-Org Apex Tests
needs: variables
uses: ./.github/workflows/sf-apex-tests.yml
with:
org-name: Staging
coverage-threshold: ${{ needs.variables.outputs.apex-coverage-threshold }}
sf-username: ${{ vars.SF_USERNAME_STAGING }}
sf-instance-url: ${{ vars.SF_INSTANCE_URL_STAGING }}
secrets:
SF_JWT_KEY: ${{ secrets.SF_JWT_KEY_STAGING }}
SF_CLIENT_ID: ${{ secrets.SF_CLIENT_ID_STAGING }}
sandbox-test-report:
name: Sandbox Test Report
needs: sandbox-apex-tests
if: always() && needs.sandbox-apex-tests.outputs.report != ''
uses: ./.github/workflows/sf-notify-slack.yml
with:
status: ${{ needs.sandbox-apex-tests.outputs.outcome == 'Passed' && 'Tests passed' || 'Tests failed' }}
title: "Nightly Sandbox Apex Test Report"
message: ${{ needs.sandbox-apex-tests.outputs.report }}
run-url: "https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }}"
secrets:
SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }}
# ── Jest tests for Lightning web components ─────────────────
# Every Jest test runs, with coverage. The report says if a test failed,
# how many components that have a test are below their line coverage
# target, and how many components have no test.
jest-tests:
name: LWC Jest Tests
uses: ./.github/workflows/sf-jest-tests.yml
with:
skip-delta-check: true
secrets:
CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }}
jest-report:
name: Jest Report
needs: jest-tests
if: always() && needs.jest-tests.outputs.report != ''
uses: ./.github/workflows/sf-notify-slack.yml
with:
status: ${{ needs.jest-tests.outputs.outcome == 'Passed' && 'Tests passed' || 'Tests failed' }}
title: "Nightly Jest Test Report"
message: ${{ needs.jest-tests.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. A failed test
# run has its report above, so this notice is for a failure that has no
# report: sandbox Apex that does not compile, or a run that could not
# finish, such as a failed login.
notify-failure:
name: Notify on Failure
needs:
[
production-apex-tests,
sandbox-compilation,
sandbox-apex-tests,
jest-tests
]
if: >-
failure() && (
needs.sandbox-compilation.result == 'failure' ||
needs.production-apex-tests.outputs.report == '' ||
needs.sandbox-apex-tests.outputs.report == '' ||
needs.jest-tests.outputs.report == ''
)
uses: ./.github/workflows/sf-notify-slack.yml
with:
status: "Failure"
title: "Nightly Test Runner Failed"
message: "The sandbox Apex does not compile, or a test run could not finish. 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.1.5 - Scheduled Weekly Project Scan¶
A weekly scan of the repository and of each org in a list, on Saturday, when the nightly test runner does not run. Runs a full Code Analyzer scan of the repository once, and the org scan of A.2.14 for each org: metadata drift detection against the branch that the org should match, and Apex compilation.
The list of orgs is at the top of the file, one entry for each org, with its name, its branch, the test level of its compilation check, and the suffix of its JWT credentials, such as PROD for the secrets SF_JWT_KEY_PROD and SF_CLIENT_ID_PROD and the variables SF_USERNAME_PROD and SF_INSTANCE_URL_PROD. The sample lists production. An organisation that wants to scan a full sandbox as well adds an entry for it, such as staging with its branch, NoTestRun, and STAGING. Each org is scanned in its own job, so a failure in one org does not stop the scan of another. An org that needs a different day gets a copy of this caller with its own schedule and its own list; the steps stay in the org scan.
The full scan is the full mode of A.2.9, as in the on-demand scan (A.1.7). The gates on a push and on a pull request scan only what a change touches, so a file that no change touches is not scanned again. A rule can still come to report that file: an API version, for example, falls below the floor as new releases come out. The weekly scan reports such a file before a developer changes it and meets the violation at the gate. It never fails on a violation. Its job summary has the counts for each severity and a table of every blocking violation, and every violation is in an artifact of the run.
Production accepts the compilation check only with tests, so its entry has RunLocalTests, and the check runs every local test once a week (A.2.15). The full-org Apex test run with its report is left out, because the nightly test runner (A.1.4) runs it every weeknight: its job stays in the org scan as a comment, to uncomment where tests would not otherwise run at least weekly. Slack gets the drift report of each org on every run, drift or none, and a failure notice when a check fails or cannot run.
The schedule is 0 22 * * 6, 10:00 PM on Saturday. It is in UTC, as the schedule of the nightly test runner is, and is adjusted in the same way (A.1.4).
The standards behind this workflow: Section 6, Scheduled: Metadata Drift Detection, and, for the full scan, Section 5, Configuring GitHub Actions to Enforce Quality Gates.
Expand to show Weekly Project Scan caller workflow
# .github/workflows/scheduled-project-scan.yml
# Weekly Project Scan: a scheduled scan of the repository and of each org in
# the ORGS list below.
# - A full Code Analyzer scan of the repository, once. It reports only: it
# never fails on a violation. It writes the blocking violations to the job
# summary and stores every violation in an artifact, so that code that no
# change touches is still reported.
# - An org scan (sf-org-scan.yml) of each org in the list: drift detection
# against the branch that the org should match, and the compilation check.
# Each org scan posts its drift report to Slack on every run, opens or
# updates a GitHub issue for that org for drift and for a failure, and
# notifies Slack when one of its checks fails.
# The full-org Apex tests with their report run every weeknight in
# scheduled-test-runner.yml, so sf-org-scan.yml has their job commented out.
name: Weekly Project Scan
on:
schedule:
- cron: "0 22 * * 6" # Saturdays at 10:00 PM UTC, when the nightly test runner does not run
workflow_dispatch: # Allow manual trigger
env:
# Every value that a called workflow or action takes as a parameter is set
# here, once. The Variables job below turns each one into an output, and
# the calls read it from there. A value that a later change has to carry
# down the tree of workflows and actions goes here too.
BLOCKING_THRESHOLD: 2 # the full scan lists violations up to this severity in the job summary; it never fails
# The orgs to scan, one entry each:
# name the org's name in the issues, the reports, and the notices
# branch the branch that the org should match
# test-level RunLocalTests for production, which accepts no other;
# NoTestRun for a sandbox
# suffix the suffix of the org's secrets SF_JWT_KEY_<suffix> and
# SF_CLIENT_ID_<suffix>, and of its variables
# SF_USERNAME_<suffix> and SF_INSTANCE_URL_<suffix>
# To scan a full sandbox as well, add an entry such as
# {"name": "Staging", "branch": "staging", "test-level": "NoTestRun", "suffix": "STAGING"}
# An org that needs a different day gets a copy of this file with its own
# cron and its own list; the steps stay in sf-org-scan.yml.
ORGS: >-
[
{"name": "Production", "branch": "main", "test-level": "RunLocalTests", "suffix": "PROD"}
]
jobs:
# ── Variables: the values at the top of the file, as outputs ─────
# A call to a reusable workflow cannot read env in its with: block, so
# this job turns each value into an output that every call can read.
variables:
name: Variables
runs-on: ubuntu-latest
outputs:
blocking-threshold: ${{ steps.set.outputs.blocking-threshold }}
orgs: ${{ steps.set.outputs.orgs }}
steps:
- name: Set the variables
id: set
run: |
{
echo "blocking-threshold=$BLOCKING_THRESHOLD"
echo "orgs=$(jq -c . <<< "$ORGS")"
} >> "$GITHUB_OUTPUT"
# ── Full Code Analyzer scan of the repository ───────────────
# The pull request gates scan only what a change touches. This scan
# reports what has aged past a rule since, such as an API version below
# the floor, before a change to the file meets it at the gate.
full-scan:
name: Full Code Analyzer Scan
needs: variables
uses: ./.github/workflows/sf-code-analyzer.yml
with:
mode: full
severity-threshold: ${{ needs.variables.outputs.blocking-threshold }}
# ── Org scan: one for each org in ORGS ──────────────────────
# fail-fast is off, so that a failure in one org does not cancel the scan
# of another. Each org scan sends its own report and failure notice.
org-scan:
name: Org Scan (${{ matrix.org.name }})
needs: variables
strategy:
fail-fast: false
matrix:
org: ${{ fromJSON(needs.variables.outputs.orgs) }}
uses: ./.github/workflows/sf-org-scan.yml
with:
org-name: ${{ matrix.org.name }}
branch: ${{ matrix.org.branch }}
test-level: ${{ matrix.org.test-level }}
sf-username: ${{ vars[format('SF_USERNAME_{0}', matrix.org.suffix)] }}
sf-instance-url: ${{ vars[format('SF_INSTANCE_URL_{0}', matrix.org.suffix)] }}
secrets:
SF_JWT_KEY: ${{ secrets[format('SF_JWT_KEY_{0}', matrix.org.suffix)] }}
SF_CLIENT_ID: ${{ secrets[format('SF_CLIENT_ID_{0}', matrix.org.suffix)] }}
SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }}
# ── Failure notification for the full scan ──────────────────
# failure() is true when the full scan could not run or could not finish.
# A violation that it finds is not a failure. Each org scan notifies its
# own failures.
notify-failure:
name: Notify on Failure
needs: full-scan
if: failure()
uses: ./.github/workflows/sf-notify-slack.yml
with:
status: "Failure"
title: "Weekly Project Scan Failed"
message: "The full Code Analyzer scan of the weekly project scan 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.1.6 - Risk Tier on Auto-Merge¶
Re-runs the Metadata Risk Tier workflow whenever auto-merge is enabled on a PR, so a Tier 1 PR cannot be set to auto-merge after its gates have already run. It runs only the classification, not the full gate chain.
The standards behind this workflow: Section 6, PR-Time: Metadata Type Risk Classification and the Tier 1 enforcement in Appendix B.
Expand to show Risk Tier on Auto-Merge caller workflow
# .github/workflows/pr-risk-tier.yml
# Re-classifies a PR when auto-merge is enabled, so that auto-merge is
# disabled again on Tier 1 changes.
name: Risk Tier on Auto-Merge
on:
pull_request:
types: [auto_merge_enabled]
jobs:
risk-tier:
name: Risk Tier
uses: ./.github/workflows/sf-risk-tier.yml
A.1.7 - On Demand Full Repository Scan¶
Scans the whole repository with Salesforce Code Analyzer when a person starts it, from the "Run workflow" button on the Actions tab. No push, pull request, or schedule runs it. It is a report and not a gate: it never fails on a violation.
The results are a CSV file, a JSON file, and a SARIF file in one artifact, which GitHub gives as one zip file to download. The job summary has the number of violations at each severity and a table of every blocking violation; the violations below the threshold are in the artifact only. This is the scan for a team that measures or cleans up the code that was in the repository before the gates were: the gates themselves look only at what a change touches (A.1.1 on a push, and the delta package on a pull request).
The caller sets the severity threshold at the top of its file, as the pull request callers do: the scan never fails, and the threshold only decides where the first table of the report ends. The caller passes no folder, so the scan covers the whole repository, as the full scan does when its workspace input is empty. Code Analyzer leaves out node_modules and every folder whose name starts with a dot, and whatever ignores.files names in code-analyzer.yml. The default rule selection is the selection of the pull request gate, so the report shows what the gate would say of every file.
The standard behind this workflow: Section 5, Configuring GitHub Actions to Enforce Quality Gates; the scan itself is the full mode of A.2.9.
Expand to show Scan the Entire Repository with Code Analyzer caller workflow
# .github/workflows/manual-code-analyzer-scan.yml
# On demand: scans the entire repository with Salesforce Code Analyzer.
# Start it from the Actions tab with the "Run workflow" button.
# The results are a CSV file, a JSON file, and a SARIF file in one artifact,
# which GitHub gives as one zip file. It reports only: it never fails on a
# violation.
name: Scan the Entire Repository with Code Analyzer
on:
workflow_dispatch:
inputs:
rule-selectors:
description: "Space-separated Code Analyzer rule selectors. The default is the selection of the pull request gate"
required: false
type: string
default: "Recommended DevSecOps"
env:
# Every value that a called workflow or action takes as a parameter is set
# here, once. The Variables job below turns each one into an output, and
# the calls read it from there. A value that a later change has to carry
# down the tree of workflows and actions goes here too.
BLOCKING_THRESHOLD: 2 # the first table of the report ends at this severity; the scan never fails
jobs:
# ── Variables: the values at the top of the file, as outputs ─────
# A call to a reusable workflow cannot read env in its with: block, so
# this job turns each value into an output that every call can read.
variables:
name: Variables
runs-on: ubuntu-latest
outputs:
blocking-threshold: ${{ steps.set.outputs.blocking-threshold }}
steps:
- name: Set the variables
id: set
run: |
{
echo "blocking-threshold=$BLOCKING_THRESHOLD"
} >> "$GITHUB_OUTPUT"
full-scan:
name: Full Scan
needs: variables
uses: ./.github/workflows/sf-code-analyzer.yml
with:
mode: full
rule-selectors: ${{ inputs.rule-selectors }}
severity-threshold: ${{ needs.variables.outputs.blocking-threshold }}
A.1.8 - Dependabot Pull Request Rename¶
Gives a pull request from Dependabot a Conventional Commit title, with the name of the package as its scope. Dependabot's own title is Bump js-yaml from 4.3.0 to 4.3.2, or, where it has been given a prefix, build(deps-dev): bump js-yaml from 4.3.0 to 4.3.2. Either becomes build(js-yaml): bump js-yaml from 4.3.0 to 4.3.2, which is the form of a title from Renovate. A title with no type gets ci for a GitHub Action and build for anything else. A scope that somebody chose is left alone.
Renovate needs no such workflow, because its titles are set in its configuration (Version Control: Renovate and Dependabot). Dependabot's titles can be given a prefix in a dependabot.yml file, and not the name of the package as a scope, so this workflow does the rest, and it makes that file unnecessary for the title.
The workflow and the title gate both start when the pull request opens. The gate reads the title at the moment it runs, so when it runs before the rename it fails on Dependabot's own title; run that check again, and it reads the new one. The edit that this workflow makes starts no workflow run of its own, because it is made with the workflow's token.
The standard behind this workflow: Section 4, PR and Commit Message Conventions; the gate that checks the title is A.2.2.
Expand to show Dependabot Pull Request Rename caller workflow
# .github/workflows/dependabot-rename.yml
# Gives a pull request from Dependabot a Conventional Commit title, with the
# package name as its scope, which is the form of Renovate's titles:
# Bump js-yaml from 4.3.0 to 4.3.2
# build(deps-dev): bump js-yaml from 4.3.0 to 4.3.2
# both become
# build(js-yaml): bump js-yaml from 4.3.0 to 4.3.2
# A title with no type gets one: ci for a GitHub Action, and build for
# anything else. Only a missing scope or Dependabot's generic deps or deps-dev
# scope is replaced; any other scope is left alone.
#
# Runs only when a PR is opened or edited, and only when Dependabot itself is
# the actor (a human editing the title is skipped). The title edit made here
# uses GITHUB_TOKEN, which does not trigger further workflow runs, so the
# `edited` trigger cannot loop.
name: Dependabot Pull Request Rename
on:
pull_request:
types:
- opened
- edited
# Least privilege: no permissions by default; the job requests only what it
# needs.
permissions: {}
jobs:
pull-request-title-change:
runs-on: ubuntu-latest
if: ${{ github.actor == 'dependabot[bot]' }}
permissions:
pull-requests: write
steps:
- name: Fetch Dependabot metadata
id: dependabot-fetch
uses: dependabot/fetch-metadata@v3
- name: Give the title a type, and the package name as its scope
env:
TITLE: ${{ github.event.pull_request.title }}
PACKAGE_NAMES: ${{ steps.dependabot-fetch.outputs.dependency-names }}
ECOSYSTEM: ${{ steps.dependabot-fetch.outputs.package-ecosystem }}
PR_URL: ${{ github.event.pull_request.html_url }}
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
echo "Current title: $TITLE"
if [[ -z "$PACKAGE_NAMES" ]]; then
echo "No dependency names in Dependabot metadata; leaving title unchanged."
exit 0
fi
# type, optional (scope), optional breaking-change !, then ": description"
pattern='^([a-z]+)(\(([^)]*)\))?(!)?: (.*)$'
if [[ "$TITLE" =~ $pattern ]]; then
type="${BASH_REMATCH[1]}"
scope="${BASH_REMATCH[3]}"
breaking="${BASH_REMATCH[4]}"
description="${BASH_REMATCH[5]}"
else
# No type: ci for a GitHub Action, and build for anything else
case "$ECOSYSTEM" in
github_actions | github-actions) type="ci" ;;
*) type="build" ;;
esac
scope=""
breaking=""
# "Bump x from 1 to 2" becomes "bump x from 1 to 2"
description="$(tr '[:upper:]' '[:lower:]' <<< "${TITLE:0:1}")${TITLE:1}"
fi
case "$scope" in
"" | deps | deps-dev) ;;
*)
echo "Scope '$scope' is not a Dependabot default; leaving title unchanged."
exit 0
;;
esac
new_title="${type}(${PACKAGE_NAMES})${breaking}: ${description}"
echo "New title: $new_title"
gh pr edit "$PR_URL" --title "$new_title"