Pull requests
Branch from main, open a PR to main, cherry-pick to develop, merge develop first, then merge main.
Every change lands on two branches: develop (what deploys to the dev environment) and main
(what production tracks). The sequence below keeps those branches in sync. Branch from main, open
the production PR first, then cherry-pick the same commits onto develop.
The two-PR path looks like this:
Diagram source
flowchart LR
mainBranch["main"] --> feature["feature branch"]
feature --> prMain["PR to main"]
prMain --> cherry["cherry-pick"]
cherry --> prDev["PR to develop"]
prDev --> mergeDev["merge develop"]
mergeDev --> testDev["test on dev"]
testDev --> mergeMain["merge main"]Cherry-pick only from main to develop, never the other way. Merge the develop PR first,
then the main PR. Cherry-picking from develop to main, merging main first, or branching
from develop is what gets the two branches out of sync.
Branch from main
Create the feature branch from main, not from develop.
git fetch origin
git checkout main
git pull
git checkout -b your-feature-branchPush to Azure DevOps
Push the branch to the Azure DevOps remote once the work is ready to review.
git push -u origin your-feature-branchOpen a PR to main
In Azure DevOps, open a pull request that targets main. Do not merge it yet. This is the
production PR; it stays open until the change has been tested on dev.
Cherry-pick to develop
Use Azure DevOps' cherry-pick action on that PR. Target develop. That creates a second pull
request with the same changes, now aimed at develop. Prefix the develop PR title with [dev]
so the two PRs are easy to tell apart. Never cherry-pick from develop to main.
You can automate preparation of both PRs locally with tools/pr-flow:
./tools/pr-flow # confirm each step
./tools/pr-flow --yes # approve all steps (-y also works)Install fzf and Azure CLI, and add the Azure DevOps extension:
brew install fzf azure-cli
az extension add --name azure-devops
az loginAzure CLI must be authenticated with access to this repository; Git must also be able to
fetch and push origin. The script uses Azure CLI's repository and organization detection
from the checkout and checks PR access before pushing. If your organization uses a different
Azure DevOps authentication method, configure that before running the script.
Start with a clean working tree. The script fetches origin and opens an fzf picker.
The picker keeps its purpose, workflow, and confirmation mode visible above the search prompt.
Type to narrow the branch list, Tab to complete the highlighted name, and Enter to select;
Escape cancels. Local and origin/ branches appear separately because their tips can differ.
Select the original feature branch based on main.
The workflow is:
- Show all selected-branch commits not reachable from the freshly fetched
origin/main. These are the feature commits, regardless of author; this is not an authorship filter. - Ask to push the selected branch and create a PR targeting
main, or reuse an active PR with that source and target. Declining stops the workflow. - Compare the feature against
develop. If a direct PR would include inherited or already-applied commits, ask to create<feature>-devfromorigin/developand cherry-pick the feature commits in order. Patch-equivalent changes already ondevelopare skipped. Verify the expected commit count and ancestry. If the original branch already has only the intended commits, use it directly instead. If all changes are already ondevelop, no dev PR is needed. - Ask to push the dev PR's source branch and open or reuse an active PR targeting
develop. - Print a summary with PR links and any prepared dev branch, including when a later step fails.
Only y or yes accepts an interactive prompt; Enter, no, or end-of-input declines.
--yes approves all three steps, but still uses the branch picker and stops on errors.
PR titles come from the selected HEAD's subject; the develop title has a [dev] prefix.
Review the PR descriptions and validation results before merging. The script never merges or
sets auto-complete on either PR.
The helper supports linear feature histories with multiple commits. It refuses main, develop,
-dev source branches, feature-only merge commits, and existing destination branches instead of
resetting or overwriting them. If a cherry-pick conflicts, the script leaves the sequence available
for git cherry-pick --continue or git cherry-pick --abort; it does not publish the dev branch.
After a partially completed run that created a -dev branch, inspect and finish that branch manually
before retrying. Existing active PRs are reused, but existing dev branches are not rebuilt automatically.
Run python3 tools/pr-flow_test.py for disposable-repository tests. Azure is mocked and Git pushes
only to temporary local repositories; the tests do not create real PRs. Run this suite manually
when changing the helper; it is not wired into CI.
Merge develop and test
Merge the develop PR. That deploys to the dev environment. Test the change there.
Preserve the individual cherry-picked commits when completing this PR; do not squash several
commits into one. The develop-first check compares each original commit with its equivalent
patch on develop.
Merge main
When the change looks good on dev, requeue the develop-first build validation on the original
PR to main. Updating develop does not automatically rerun a policy attached to main.
Once the check and the other required policies pass, merge the original PR.
Go validation
.azure/go-checks.yml starts with a Detect Go-relevant changes job on
the Microsoft-hosted ubuntu-latest pool. It compares the PR with its target branch and checks
whether any file changed under services/, mcp/, cmd/, or common/, or whether go.mod or
go.sum changed. If none did, the other three jobs are skipped and the build validation still
passes. Manual runs always continue to the other three jobs.
The other three jobs are Go checks and generated clients, Go tests, and Go lint check.
They run on go-build-pool. When they run, all three must pass for the required build validation
to succeed. They start at the same time on separate agents after the first job finishes. If Azure
has only one available agent or parallel-job slot, a job queues until capacity becomes available.
The checks job builds the services in the diff and verifies generated output. See
HTTP client generation and validation
for the generation checks. go vet is not a separate step; golangci-lint runs it.
A diff with no Go packages and no generator input skips the rest of this job after
selection, including Go installation and cache restoration. Frontend application
source, docs/, and bruno/ are not generator input. A service or mcp pipeline file or k8s
values file checks the devops catalog only. A frontend pipeline file does not pass the first job,
so it runs no Go jobs.
The lint job runs golangci-lint on the same service selection and reports issues introduced
since the merge base with the target branch. The binary is the published release archive for
the version pinned in the pipeline, checked against the release checksums. Its analysis cache
is restored with the Go build cache.
The test job compares the PR with its target branch and runs make test-ci with the race
detector. Each selected service is a separate go test process, and several of those processes
run at once. Up to twice the agent CPU count of packages run at once, because most test time
is spent waiting on Postgres. Each service gets an equal share of that budget, but never more
than the CPU count. When more services are selected than there are CPUs, one process runs per
CPU. On an 8-core agent, one or two services each test 8 packages at once, three services
each test 5, and eight or more services each test 2.
Before tests start, the run prints every selected service and the number of top-level Test
and Fuzz functions in their test files. Each finished test is printed with how many
top-level tests have finished out of that number, the percentage complete, how many services
are done, and which services are still running. Subtests are not counted, because they are
created while the tests run. When every service has finished, the log prints one collapsed group per
service, in selection order, containing that service's full test output. It also
includes services that import changed non-internal Go packages, such as generated clients and
event payloads, following Go imports transitively through non-internal packages. Changing a
service's internal implementation or tests selects that service without selecting its HTTP
callers. This selection scans exact import-path strings conservatively, so a comment containing
an import can select extra tests.
A change under common/, cmd/, or go.mod tests every service, plus the packages
outside services/ (reported as shared). Those are the only paths that do. Everything
else outside a service skips tests. A Go package consumer outside services/ does not
widen the run. The log lists every path that forced the full run.
Changes limited to docs/, frontend/, bruno/, the root openapi.yaml, or a markdown file
in the repository root skip tests, cache restoration, and database setup. Files within a
service still select that service, including prompts and test fixtures. Manual and scheduled
pipeline runs always run the full suite; this pipeline does not configure a schedule.
The job publishes one JUnit file per service to Azure's Tests tab, including results from
failed runs. Skipped tests do not publish a report. It uses committed specs and clients, so it
does not repeat generation. It starts a fresh Postgres 16 container with pgvector, matching the
major version deployed in dev and production. The data directory is a memory filesystem with
fsync off, because every test creates a database, and the container is discarded afterward.
max_connections is 800 so the parallel packages can share that server.
Each database test gets its own database, copied from a template database that has all of the
service's migrations applied. The first test binary for a service builds the template, and
the others wait for it, so migrations run once per service rather than once per test. The
template's name includes a hash of the migration files, so changing a migration builds a new
template. A test that stops at an earlier migration version runs migrations on an empty
database instead. CI=true makes an unavailable database fail the tests instead of skipping
them.
Both the checks and test jobs, and the lint job, use Azure's built-in pipeline cache for
downloaded Go modules and for compiled build output. Caches are stored outside the checkout.
Jobs use separate caches, keyed by operating system, architecture, Go version, job name, and
go.sum. The key does not include the commit, so a cache is saved once per dependency set
and later runs restore it instead of uploading a new archive every time. If the workspace has
less than 8 GB free, the build cache is not restored. If a restored build cache grows past
3 GB, it is cleared before it can be saved. The lint job caches its analysis the same way,
and caches the linter binary by version.
Before restoring modules, each job clears its own local module-cache directory with
go clean -modcache, which handles Go's read-only module files. This prevents cache
extraction from colliding with files retained on reused agents; the remote cache is kept.
Each job also removes its local build-cache directory before a restore, for the same reason.
Module and build caches are saved after successful jobs when the cache key is new. PR runs
can reuse caches from their own earlier runs or their target branch; caches created by one PR
are not shared with other PRs. A successful manual pipeline run on main or develop can
populate caches for PRs targeting that branch. Caching reduces downloads and compilation;
database setup and test execution still run, and -count=1 prevents Go from reusing test
results.
Disk diagnostics report free space and cache sizes at startup, after module restoration, and after the job's work, including failures.
The test job installs Docker, the C compiler, and make if missing, and starts Docker if needed.
The Linux agent must support Docker and allow passwordless sudo for package installation,
service startup, and Docker commands. Installation is skipped when Docker is already in the
agent image. The job starts Postgres on a dynamically assigned loopback port, waits for it to
be healthy, and removes its container and volume afterward, including when tests fail.
Tests that explicitly require external services or provider credentials remain opt-in.
This job does not start the full local stack or run frontend tests.
To run the suite locally, start the local Postgres service, then run make test.
Use make test-ci to produce test-results.xml for the full suite. To match the pipeline's
per-service run, use make test-ci TEST_SERVICES="billing leases". That writes
test-results/<service>.xml. TEST_PACKAGES still selects packages in one process when
TEST_SERVICES is unset. Missing local Postgres causes database tests to skip; set CI=true
when you want that to fail locally as it does in the pipeline.
An existing required Go checks build-validation policy covers all four jobs. No separate test pipeline or branch policy is needed.
Develop-first validation
.azure/develop-first.yml checks that every commit introduced by a PR to
main has already reached develop, either as the same commit or as an equivalent patch.
A commit is the same commit when it is an ancestor of develop, for example when the develop PR was
merged from the same branch. Cherry-picking changes commit IDs, so otherwise the check compares
patch IDs from git patch-id. The patches
are taken with no context lines, so a cherry-pick still matches when the lines around a change
differ between the branches. For example, an ADR row appended to docs/decisions/README.md
matches even when develop already lists ADRs that main does not. It checks the PR's reviewed
source commit rather than Azure's temporary merge commit. Commits already on main are excluded,
so historical differences between the two branches do not block new work. The logic is in
tools/ci/develop-first.sh. Run tools/ci/develop-first_test.sh to test it
against disposable repositories. Run it manually when changing the script; it is not wired into CI.
The main PR is expected to fail this check until its develop PR merges. If you add more commits
to the main PR, cherry-pick those to develop too, merge them, and requeue validation.
Keep the feature branch linear: rebase onto main instead of merging main into it. The
check rejects merge commits because patch comparison would skip their conflict resolutions.
Squashing multiple commits on the develop side, or changing a patch while resolving a
cherry-pick conflict, can prevent a match. If the check fails after the develop PR merges,
inspect the reported commits and reconcile the changes across both PRs before retrying.
This check establishes historical patch presence, not successful deployment or testing. A patch
later reverted on develop still exists in its history and can pass. Continue to test on dev
before completing the main PR.
Enable the Azure policy
Adding the YAML file does not enable enforcement. An administrator must configure Azure DevOps:
- Land the pipeline file through the two-PR workflow above: merge into
develop, thenmain. - Create an Azure Pipeline using the existing
.azure/develop-first.ymlfile onmain. - Under Repos → Branches → main → Branch policies → Build validation, add that pipeline. Set the trigger to Automatic, the policy requirement to Required, leave the path filter blank, and set build expiration to Immediately when main is updated.
- Ensure ordinary contributors cannot Bypass policies when completing pull requests or Bypass policies when pushing. Protect changes to the gate YAML with required reviewers.
- Verify on a test PR that validation fails before the develop PR merges and passes after it merges and validation is requeued. The test must preserve the individual commits on develop.
During rollout, the policy can be configured as optional for this test, then made required at the enforcement date. Once required, it also applies to existing open PRs. A manual pipeline run outside a PR fails intentionally because it has no reviewed source commit to validate.
See Azure Repos build validation for policy settings and git patch-id for patch comparison.