---
name: promotion-branches
description: How the promotion branches feature works (enablePromotionBranches, sf hardis:project:promotion:create) across sfdx-hardis and vscode-sfdx-hardis, and every file to touch when changing it. Use when working on promotion branches, on the Pull Request scope of deploy:smart, on release notes filtering, or on the DevOps Pipeline windows, counters and toggles.
user-invocable: false
---
# Promotion branches (Beta)
A **promotion branch** carries a chosen subset of the User Stories waiting in a major branch to the
next major branch, ahead of the rest of the promotion window. It is a selective merge: instead of
merging `uat` into `preprod` and shipping everything, you assemble a branch holding only the
approved stories.
Feature switch: **`enablePromotionBranches`** in `config/.sfdx-hardis.yml`, default `false`.
Required with it: **`allowedPromotionSteps`**, the source and target branches a release manager
may assemble a promotion between (`- source: uat` / `target: preprod`). `promotion:create`
refuses to run while the list is missing.
Everything below is inert while it is off, with one exception noted in [Filtering](#filtering-what-moves-the-pull-requests).
The feature is in **Beta** and must be labelled as such in docs and UIs, the way backpromote is.
User documentation: `docs/salesforce-devops-promotion-branches.md`
().
End to end test runbook: the `promotion-branches-e2e` skill.
## Mental model
- **Promotion branch** = `promotion///-`, for example
`promotion/uat/preprod/2026-09-06-1430` (UTC minute, `-2`, `-3`... only when that name is
taken). The shape is **fixed, not configurable**. Only
`sf hardis:project:promotion:create` builds one, with `git cherry-pick -x` (`-m 1` on merge
commits).
- **Declaration** = the promotion Pull Request lists what it carries in a ```yaml block of its
description:
```yaml
promotionPullRequests: [482, 487]
```
This is the only link back to the stories: a cherry-pick rewrites the commit SHAs, so SHA
matching cannot find them. A branch named `promotion/...` **without** the block, or a branch with
the block but not named `promotion/...`, is an ordinary branch and gets a warning.
- **Carried story** = a Pull Request named in that block. sfdx-hardis pulls it into the scope of the
job so its deployment actions run, its Apex test classes are selected and its custom behaviors
are inherited, exactly as if it had been merged directly.
- **Vehicle** = a Pull Request that *moves* other Pull Requests rather than carrying work of its
own: a merge between two major branches (always), and a promotion Pull Request (only when the
feature is on). Everything else, whatever it is named (`feature/`, `fix/`, `retrofit/`,
`hotfix/`...), carries its own change and is a User Story.
- **Single place** = a Pull Request number appears in exactly one branch of the DevOps Pipeline, the
branch it actually reached. A story a promotion carried away is listed in the target branch, not
in the one it was merged into.
- **One promotion in flight per step** = at most one open promotion Pull Request between two given
major branches. `promotion:create` closes the ones it supersedes (after asking a human, silently
in agent mode), and the DevOps Pipeline draws the open one on the arrow between the two branch
nodes instead of giving it a branch node of its own.
## Invariants
Break one of these and the feature is wrong, whatever the tests say.
1. **With `enablePromotionBranches: false`, deployment behaviour is byte for byte what it was.** A
`promotion/...` branch is an ordinary feature branch. The only allowed difference is one
informational log line. This is proven by the A/B regression check in the
`promotion-branches-e2e` skill.
2. **A promotion never runs actions against the wrong org.** A `promotion/uat/preprod/...` branch
whose Pull Request targets `main` is retargeted by hand: its declaration is ignored
(`isPromotionPullRequestForItsTarget`), scope is the Pull Request alone, with a warning.
3. **A Pull Request number appears in a single place** in the pipeline diagram: the branch the
promotion carried it to (`promotedAway` hides it from the branch it left).
4. **User facing lists show User Stories only.** Vehicles are hidden unless the **Show merge and
promotion Pull Requests** toggle is on (diagram and modal), or `--include-promotions` is passed
(release notes).
5. **Nothing is silently dropped.** A declared Pull Request that cannot be read is reported, not
skipped in silence; a promotion whose stories could not be resolved stays in the release notes.
6. **Expansion is multi level.** A `preprod -> main` promotion can carry a `uat -> preprod`
promotion, which carries stories. Both levels must resolve.
7. **Conflict markers never reach an org, and the reviewer is told why.**
`assertNoPromotionConflictMarkers` greps every tracked file, not only the package directories,
except the git glob patterns of `promotionConflictMarkersIgnoredFiles` (files that hold markers
on purpose, like the merge conflict lab of the training). It reads the checked out files only,
never the history, so a shallow CI clone is enough. It stops the job before anything is deployed, so no other code would ever post a Pull Request
comment: it sets `deployErrorsMarkdownBody` / `status: 'invalid'` on the Pull Request data and
calls `GitProvider.managePostPullRequestComment(checkOnly)` **before** throwing. A red job with
no comment on the very Pull Request that has to be fixed is not an acceptable outcome.
8. **A promotion is never closed before its replacement exists.** `promotion:create` closes the
superseded Pull Requests only after the new one has been created, so a failure while
cherry-picking cannot leave a pipeline step with no promotion open.
9. **Not knowing is not a reason to act.** When the git provider cannot list the open Pull
Requests, nothing is closed and the command says so.
10. **A vehicle is never carried.** The candidate list of `promotion:create` associates a commit
with the Pull Requests of the commits it brought in, and a sync merge from a major branch
matches that major branch's own Pull Request. `dropVehiclePullRequests` removes them (and their
deployment actions) before anything is offered, declared or cherry-picked.
11. **A branch merged twice is listed once.** `shouldAddVirtualPullRequest` refuses the number-less
entry when a real Pull Request of the same branch is already in the group.
12. **Superseding gives the stories back.** A promotion this run is about to close is not evidence
that its stories are already promoted (`countsAsAlreadyPromoted`): otherwise agreeing to
supersede it would leave nothing to assemble.
13. **A vehicle is expanded before it is dropped.** A promotion merged into the source branch
arrives in the next branch as one cherry-picked merge commit that names the promotion, not the
stories under it: the `-x` trailers only survive one level. `expandPromotionsInGroups` replaces
it with what its `promotionPullRequests` block declares, recursively, before invariant 10
removes it. Without that, a candidate two levels down carries no number, cannot be selected,
and its stories are stranded one branch short of production.
14. **Which commits a merge brought in is a question about the graph, never about the dates.** A
cherry-picked commit keeps the author date it had on the branch it came from, so it is older
than the merge before it. `attributeCommitsToFirstParents` walks
`git rev-list --parents`, stopping at the other first-parent commits, oldest merge first.
Attributing by date puts the stories of a promotion under the wrong merge, and a promotion
assembled from that grouping declares stories whose metadata it does not carry.
15. **Every yaml block of a description counts, and a list adds up.** `mergePrDescriptionYamlBlocks`
concatenates list values across blocks (without duplicates) and keeps the last value for
anything else, so appending a block to name one more Apex test class does not drop the ones
declared above it.
16. **Nobody promotes on a step the project never declared.** `allowedPromotionSteps` is required as
soon as `enablePromotionBranches` is on: `promotion:create` stops with
`promotionCreateAllowedStepsRequired` while the list is missing, and with
`promotionCreateAllowedStepsInvalid` when it is there but unreadable. Guessing "every major
branch to every merge target" would be a decision the command has no business making. The pure
helpers still treat an empty list as no restriction, which is what keeps the deployment jobs and
the pipeline diagram working on a project that is mid-configuration. The rule gates
**creation**, never deployment: a promotion assembled outside the list is deployed with a
warning, since refusing it would block a branch that is already merged.
17. **A vehicle is opened up, never offered whole.** A first-parent commit of the source branch that
only moves other merges (`integration -> uat`, a promotion merged into its target) is replaced
by the first-parent commits it brought in (`splitVehicleMerges`, called from
`listMergedPrsWithCommits` only when `promotion:create` / `list-candidates` asks for it). On a
pipeline where stories are merged into `integration`, every first-parent commit of `uat` is one
of those syncs: without this, the whole promotion window is a single selectable row and carrying
one story carries them all. Two guards keep it honest: only a merge with exactly two parents is
opened up (an octopus merge would lose every side but the second), and only when everything it
brought in is inside the window being listed, which keeps a back-merge from the target branch
whole instead of turning it into a page of rows already delivered. The vehicle stays a boundary
of `attributeCommitsToFirstParents` (`extraBoundaries`) so the merge after it does not swallow
it.
18. **A configuration file can stop being readable mid-command.** `promotion:create` commits git
conflict markers on purpose, and `config/.sfdx-hardis.yml` is a file like any other:
`loadFromConfigFile` keeps the last configuration it read from each set of files and falls back
to it with a warning (`configFileUnreadableUsingPrevious`, `configFileConflictMarkers`) rather
than crashing halfway, with a branch assembled and no Pull Request. It only falls back to
something it actually read: a project whose configuration is broken from the start still stops.
19. **A conflict answer can be given once for the whole promotion.** The prompt of
`cherryPickCandidates` offers `commit-with-markers-all` next to the three
`PromotionConflictChoice` values: it commits the markers like `commit-with-markers` and fills
`rememberedChoice`, so the rest of the window is handled without asking again
(`promotionCreateConflictRemembered` says so in the log). It is a **prompt answer, not a flag
value**: `PROMOTION_CONFLICT_CHOICES` stays `skip` / `commit-with-markers` / `abort`, because
`--on-conflict` already applies to every conflict. A promotion window usually conflicts on the
same files story after story, so answering ten times in a row is answering once.
20. **A resolution nobody can read is not a resolution.** `buildConflictResolutionPrompt` asks the
coding agent for a commit message whose body carries one line per conflicting file, naming the
story, what the target side had, what the story added and what was kept, and forbids
"solved conflicts" / "merged both versions". The reviewer of the promotion Pull Request reads
the resolutions from `git log`, without opening the diff.
21. **A branch that was just pushed may not be visible to the provider yet.**
`GitProvider.createPullRequest` takes `{ retries, retryDelayMs }` and
`pushAndCreatePromotionPullRequest` passes 3 retries: GitLab answers
`{"source_branch":["does not exist"]}` on a ref its API has not indexed, a fraction of a second
after the push that created it. Each attempt starts with `findOpenPullRequest`, so a create that
succeeded while reporting an error is picked up instead of being created twice, and
`GitProvider.lastPullRequestCreationError` carries the reason into
`promotionCreatePullRequestManual`: the fallback message names what the provider said instead
of guessing at a missing token. When it still fails, `GitProvider.getPullRequestCreateUrl`
hands the user the provider's own "new Pull Request" form with the source branch, the target
branch, the title and the description already filled in. Each provider class builds its own URL
(`GithubProvider.getPullRequestCreateUrl` and the three others, static, from the git remote, so
a project with no token gets one too); `buildPrCreateUrl` drops the description and keeps the
link when the URL would go past `MAX_PR_CREATE_URL_LENGTH`, and the caller then says where to
paste it from. Retyping a promotion description is not an option: the `promotionPullRequests`
block is what the deployment jobs read.
22. **The git remote is the authority on which project the API talks to.** Outside a GitLab CI job
(`!process.env.GITLAB_CI`), `GitlabProvider.autoDetectSettings` checks a `CI_PROJECT_ID` coming
from the environment or a `.env` file against the project path of `remote.origin.url`, and
replaces it with a warning (`gitlabProjectIdMismatch`) when they disagree. A leftover
`CI_PROJECT_ID` from another repository makes every call answer about that other project, which
looks exactly like a branch that does not exist.
23. **A promotion branch is validated, never deployed.** It is the source branch of a Pull
Request, like a feature branch: the only job it may run is that Pull Request's validation. A
deployment job triggered by the push that created it would send the promotion to the target org
before anyone reviewed or merged it. `assertPromotionBranchIsNotDeployed` stops `deploy:smart`
at the top of `run()` and names the CI setting to fix, because the answer is to fix the
trigger, not to let the job continue. Silent when the feature is off, on a validation job, and
with `SFDX_HARDIS_DEPLOY_BEFORE_MERGE`, where a deployment legitimately runs from the source
branch. It raises an error and posts **no** Pull Request comment: the job that trips it is the
branch pipeline of the push, not the validation, and failing the Pull Request over it would say
the promotion is broken when it is the pipeline configuration that is.
24. **A promotion branch always runs two pipelines, and only one of them is its Pull Request's.**
It is pushed to the server, so it gets a branch pipeline of its own next to the Pull Request
validation pipeline, on the same commit. In vscode-sfdx-hardis,
`GitProviderGitlab.pickMergeRequestPipeline` reads the newest `merge_request_event` pipeline
(the newest of all of them when the project runs none), which is how GitLab picks the
`head_pipeline` its own merge request page shows; reporting every pipeline of the commit drew a
green merge request red on the DevOps Pipeline diagram. GitLab is the only provider concerned:
GitHub already asks for `event: "pull_request"` runs, Azure DevOps and Bitbucket read builds
and statuses attached to the Pull Request. Deployment status is a different question, answered
by `getJobsForBranchLatestCommit`, which leaves the merge request pipelines out.
Related project-side trap: an unanchored `DEPLOY_BRANCHES` regex
(`/(integration|uat|preprod|main)/` instead of `/^(...)$/`) matches
`promotion/integration/uat/...`, so every promotion branch push starts the deployment job and
fails it. The sfdx-hardis default template is anchored.
25. **A User Story is offered once, whatever brought it in.** A story a promotion cherry-picked into
a branch and an ordinary sync merge delivered again afterwards sits in the window twice, as two
commits: `dropOfferedTwice` keeps the first candidate row, which is the one the command already
cherry-picks, so the table a release manager reads never lists the same number twice. Only an
exact repeat of the same set of numbers is dropped: a row grouping several Pull Requests (a
back-merge, an octopus merge) never hides the finer rows of the stories it holds, and a row with
no number is a commit of its own and always stays.
26. **Every provider writes its own merge sentence.** `extractPrNumbersFromMessage` and
`mergedSourceBranches` read the Pull Request number and the merged branch out of a merge commit
message, and the shapes differ: `Merge pull request #N from owner/branch` (GitHub),
`See merge request group/repo!N` (GitLab), `Merged PR N:` (Azure DevOps squash) and
`Merge pull request N from source into target` (Azure DevOps completing without fast-forward,
which is what keeps the `-x` trailers). Miss one and every commit a promotion cherry-picked
comes back as a row with no number, so the story it carried can no longer be selected.
27. **A description a provider refuses is a promotion nobody can review.** Azure DevOps caps a
description at 4000 characters and a promotion whose cherry-picks conflicted embeds a coding
agent prompt that goes past it: `buildPromotionPullRequestBody` takes the provider's
`getMaxPullRequestDescriptionLength()` and drops the embedded prompt (saved in `hardis-report/`
either way) rather than letting the creation fail with the branch already pushed. The yaml
declaration, the carried table and the conflicting file list always survive.
28. **A promotion branch name is never handed out twice.** `buildPromotionBranchName` stamps the
UTC date and minute (`-`, UTC so two machines in different time zones
compute the same name), and `computePromotionBranchName` adds `-2`, `-3`... only when that
name is taken, one past the highest counter found, never back into a gap. "Taken" is read from
everything that can still carry a name after the branch is gone:
`listExistingPromotionBranchNames` collects local branches, `git ls-remote`, stale
remote-tracking refs, the merge commits of the target branch (`extractPromotionBranchNames`,
the merge sentence of all four providers) and the promotion Pull Requests merged into it in the
last two days (rebase, fast-forward and Azure squash merges leave no merge commit naming the
branch). `createPromotionBranch` passes `refuseExisting`, so a name that still exists stops the
command instead of resuming a branch already merged. `parsePromotionBranchName` (and its mirror
in the extension, plus the fallback regex of `pipeline.js`) still accepts the
`-` names of the first releases: a promotion assembled before the upgrade
can still be open, or waiting in a branch for the next step.
29. **A backpromote never touches a promotion branch, and a promotion never sees a backpromote branch.**
`hardis:work:backpromote` deploys what was merged in a parent major branch into a developer sandbox
from its own technical branch `backpromote//` (a child of the parent
branch holding the manual merges only, see the `backpromote` skill). Whatever branch is checked out
when it starts, it commits nothing there: it switches to the backpromote branch. `backpromote/*`
branches join `promotion/*` and `retrofit/*` in `classifyBackpromoteCurrentBranch` and in the
extension mirror: the DevOps Pipeline, the release notes and the promotion candidates ignore them,
and no CI job runs on them. The backpromote history lives in the "Backpromotes" Pull Request
comment, which is not the CI/CD "Deployment Actions" comment: neither side reads the other's.
30. **What a story already reached is read from the merged promotions of the step, not from the
windows.** The window of a branch runs since its last direct merge into the next branch, and a
project using promotions may never merge `uat` into `preprod` directly again, so a promotion
merged into `preprod` leaves the `preprod` window at the next go-live while the stories it
carried stay in the `uat` window for months (issue #2260: 292 counted, 129 pending). In the
extension, `listMergedPromotionsOfSteps` (`orgConfigUtils.ts`) asks the provider
(`listMergedPullRequestsIntoBranch`) for the merged promotions of every `branch -> merge
target` step, bounded by the oldest Pull Request of the source window, and feeds them into the
index next to the ones the windows hold; `enforceSinglePlacePerPullRequest` stays as the
fallback for a provider that answers nothing. In the CLI, the branch mode of
`hardis:doc:release-notes` subtracts the same stories (`dropAlreadyPromotedPullRequests`),
while `deploy:smart` keeps them in scope on purpose (no-op redeploy, actions skipped).
## sfdx-hardis (CLI)
| File | Role |
|----------------------------------------------------------------------------------------------------------------------------------------------------------------------------|--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
| `src/common/utils/promotionBranchUtils.ts` | Pure logic: naming, parsing the declaration, classification, expansion, inherited behaviors, promotion index. No I/O. |
| `src/common/utils/promotionCreateUtils.ts` | Everything the two promotion commands need: the configuration gate, branch resolution, candidate listing, already-promoted detection, candidate table and summaries, cherry-picking, conflict handling, Pull Request body. |
| `src/commands/hardis/project/promotion/create.ts` | The command. Flags: `--source-branch`, `--target-branch`, `--pull-requests`, `--skip-pull-request`, `--include-already-promoted`, `--on-conflict`, `--agent`. |
| `src/commands/hardis/project/promotion/list-candidates.ts` | Read-only listing of the candidates, for agents. Flags: `--source-branch`, `--target-branch`, `--include-already-promoted`, `--agent`. Creates, pushes and closes nothing. |
| `src/common/utils/pullRequestUtils.ts` | Resolves the declared Pull Requests from the git provider, walks the downstream promotions, merges the yaml blocks of a description. |
| `src/common/utils/backpromoteUtils.ts` | `listMergedPrsWithCommits`, which the candidate list is built from: `attributeCommitsToFirstParents` decides which commits a merge brought in, `splitVehicleMerges` opens up the merges that only move other merges. |
| `src/common/gitProvider/gitProviderRoot.ts` + the four providers | `closePullRequest()` (close on GitHub/GitLab, abandon on Azure DevOps, decline on Bitbucket), used to supersede the promotion already open, and `getPullRequestCreateUrl()` on each provider class (`buildPrCreateUrl` / `PullRequestCreateUrlResult` live in the root). |
| `src/commands/hardis/project/deploy/smart.ts` | Applies the inherited custom behaviors and the conflict-marker gate. |
| `src/common/utils/prePostCommandUtils.ts` | Deployment actions of the carried stories, promotion scope wording. |
| `src/common/utils/releaseNotesUtils.ts` | Leaves the vehicles out, `--include-promotions` brings them back. |
| `src/common/gitProvider/index.ts` | `inheritedCustomBehaviors` + the `inheritedCustomBehaviorsPrId` guard, `createPullRequest` retries + `lastPullRequestCreationError`. |
| `config/sfdx-hardis.jsonschema.json` | `enablePromotionBranches` and `allowedPromotionSteps` properties (required for any new config key). |
| `test/common/utils/promotionBranchUtils.test.ts`, `promotionCreateUtils.test.ts`, `releaseNotesPromotion.test.ts`, `backpromoteUtils.test.ts`, `prDescriptionYaml.test.ts` | Unit tests. |
Reading the flag: `getConfig('branch')` (project config merged with the running branch's config),
via `getPromotionBranchConfig(config)`, which also parses `allowedPromotionSteps` into
`config.allowedSteps`. Both are **project level** settings: the extension exposes them at project
scope only, because `promotion:create` runs from any branch and would not see a branch file.
### Allowed steps
`parsePromotionSteps` reads the list (objects, or a `"uat > preprod"` string for a hand-edited
config; no target means any target of that source). `isPromotionStepAllowed`,
`allowedPromotionSourceBranches`, `allowedPromotionTargetBranches` and `formatPromotionSteps` are
what the callers use. `resolvePromotionSourceAndTarget` filters both prompts and refuses a flag
naming a step outside the list; `warnAboutPromotionPullRequestMisuse` warns in the deployment job;
the extension mirrors the same functions in `pipeline.js` (it cannot import them: an LWC module of
the webview only resolves other `s/` modules). `_isPromotionSourceAllowed` asks for a step naming
the branch **and** a target it can reach, a merge target read from `pipelineData.links`, so a step
pointing somewhere the pipeline does not go opens nothing. `showCreatePromotionButton` ends with it
and `modalHideCheckboxColumn` is its negation, so the button and the per-story checkboxes appear
and disappear together. An empty list keeps them, on purpose: the command then answers with
`promotionCreateAllowedStepsRequired`, which names the setting, where a missing button would say
nothing.
### Pull Request scope kinds
`single-pr`, `batch`, `go-live`, `check`, `promotion`, `promotion-check`. A promotion job resolves
to `promotion` / `promotion-check` and its scope is *the declared stories plus the promotion Pull
Request itself*.
### Custom behavior keywords
`NO_DELTA`, `PURGE_FLOW_VERSIONS` and `DESTRUCTIVE_CHANGES_AFTER_DEPLOYMENT` are recognized
anywhere in a description. **`FLOW_DELETE_INTERVIEWS` is not**: interview deletion is irreversible,
so it only counts on a line of its own, as a bullet, or as a ticked checkbox. A promotion inherits
the keywords of the stories it carries, and **only** those: a keyword of a story left behind must
not leak in.
## vscode-sfdx-hardis (extension)
| File | Role |
|------------------------------------------------------|-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
| `src/utils/pipeline/promotionBranchUtils.ts` | Mirror of the CLI pure logic, plus the pipeline rules: `isVehiclePullRequest`, `userStoryPullRequests`, `visiblePullRequests`, `annotateAlreadyPromoted`, `enforceSinglePlacePerPullRequest`. |
| `src/commands/showPipeline.ts` | Fetches the declared Pull Requests (in parallel), builds the windows. |
| `src/utils/orgConfigUtils.ts` | `completeMajorOrgsWithPromotionBranches`: expands the windows, reads the merged promotions of each step from the provider (`listMergedPromotionsOfSteps`), annotates, enforces the invariant. |
| `src/utils/gitProviders/*.ts` | `listMergedPullRequestsIntoBranch(target, updatedAfter)` on the four providers and the mock, sharing the per-branch fetch of the window listings. |
| `src/pipeline-data-provider.ts` | Feeds the mermaid builder. |
| `src/utils/pipeline/branchStrategyMermaidBuilder.ts` | Node counters (`data-count`, User Stories not promoted away), and the open promotion drawn on the major-to-major edge (`isPromotionOfStep`). |
| `src/webviews/lwc-ui/modules/s/pipeline/pipeline.js` | Branch window modal: filtering, the vehicles toggle, the per-Pull-Request checkboxes and the **Create promotion** button. |
| `src/utils/pipeline/sfdxHardisConfigHelper.ts` | `enablePromotionBranches` and `allowedPromotionSteps` sit in the **Danger Zone** of Pipeline Settings, scope `["global"]`. |
| `src/hardis-commands-provider.ts` | Command palette entry for `hardis:project:promotion:create`. |
## Filtering: what moves the Pull Requests
The rule the lists and counters follow, in the diagram, the modal and the release notes:
| Pull Request | promotions OFF | promotions ON |
|-----------------------------------------------------------|----------------|---------------|
| `feature/`, `fix/`, `retrofit/`, `hotfix/`, anything else | listed | listed |
| `uat -> preprod` (major to major) | **hidden** | **hidden** |
| `promotion/uat/preprod/...` | listed | **hidden** |
Major-to-major merges are filtered for **every** project: such a merge is plumbing in any pipeline.
A `promotion/` branch is only a vehicle when the feature is enabled, because otherwise it really is
an ordinary branch, which is exactly how the deployment jobs treat it.
What brings the vehicles back: the **Merges and promotions** chip of the Pull Requests list
(`s/pullRequestList`, next to its text filter), and `--include-promotions` on `hardis:doc:release-notes`.
## Performance
A big project has hundreds of Pull Requests, so:
- `buildPromotionIndex` parses each promotion description once and indexes by story number;
never re-parse per story.
- Provider queries for already-promoted detection are bounded by `oldestCandidateDate`.
- The extension fetches the declared Pull Requests in parallel and caches the merged ones.
## When you change something
- New config property: add it to `config/sfdx-hardis.jsonschema.json`, and to
`resources/sfdx-hardis.jsonschema.json` in the extension if the settings UI must know it before
sfdx-hardis publishes the schema.
- New user-visible string: `t()` plus the key in **all 9 locales** (`node scripts/i18n-upsert.mjs`
in the CLI, the 9 `src/i18n/*.json` in the extension).
- Any behaviour change: re-run the A/B regression check with the feature **off** before saying it
is safe. A green unit suite has already missed a flag-off regression once.
- The monitoring `AGENTS.md` explains promotion branches and the Pull Request scope of
`deploy:smart` to coding agents: when their behaviour changes, load the `monitoring-agents-md`
skill and update its "How the CI/CD pipeline works" section.
- The feature is in Beta: keep the "(Beta)" mention in the docs page, the JSON schema description
and the settings UI. It moved from "(experimental)" in September 2026, so a new mention is written
"(Beta)", capital B, like backpromote.