A pull request that drafts itself

Pick a branch and an Azure DevOps extension screens its diff for secrets, drafts the pull request's title, description and change list, and creates it only as a draft. Replayed here on a real pull request from this site.

Competency
Azure DevOps extensions
Context
Quidne · Azure DevOps extensions
Built with
Azure DevOps extension SDK, Azure Functions, Azure AI Foundry, TypeScript, C#

Try it

QuidnePublicWebsite Branches PR drafting extension

Branch task/jk-pipeline-environments

7 commits ahead of main · 7 files · +420 −63

What the extension sends

Commits unique to the branch

  1. Record draft PR 273 in the pipeline environments tracker
  2. Close chunk 2 of the pipeline environments tracker
  3. Close chunk 1 of the pipeline environments tracker
  4. Drop dependsOn from the manual deploy stages
  5. Give each environment its own deploy stage
  6. Add the pipeline environments tracker
  7. Add the pipeline environments workstream plan

Changed files

  • pipelines/01-site.yml +26 −63
  • pipelines/templates/stage-site-deploy.yml +59 −0
  • pipelines/variables/site-dev.yml +8 −0
  • pipelines/variables/site-prod.yml +8 −0
  • pipelines/variables/site-uat.yml +7 −0
  • workstreams/quidne-pipeline-environments.md +139 −0
  • workstreams/quidne-pipeline-environments.tracker.md +173 −0

Diff, 3 lines of context

diff --git a/pipelines/01-site.yml b/pipelines/01-site.yml
index 7c401b8..fc10ab7 100644
--- a/pipelines/01-site.yml
+++ b/pipelines/01-site.yml
@@ -1,13 +1,3 @@
-parameters:
-  - name: target
-    displayName: Target environment
-    type: string
-    default: DEV
-    values:
-      - DEV
-      - UAT
-      - PROD
-
 resources:
   repositories:
     - repository: DeployUtilities
@@ -37,30 +27,6 @@ variables:
   - name: contentArtifact
     value: site-content
 
-  # verifyHostname empty means the SWA default hostname. DEV keeps that; PROD verifies the apex, the
-  # name that serves production.
-  - ${{ if eq(parameters.target, 'DEV') }}:
-      - name: serviceConnection
-        value: dev-quidne-site
-      - name: adoEnvironment
-        value: dev-quidne-site
-      - name: verifyHostname
-        value: ''
-  - ${{ if eq(parameters.target, 'UAT') }}:
-      - name: serviceConnection
-        value: uat-quidne-site
-      - name: adoEnvironment
-        value: uat-quidne-site
-      - name: verifyHostname
-        value: uat.quidne.com
-  - ${{ if eq(parameters.target, 'PROD') }}:
-      - name: serviceConnection
-        value: prod-quidne-site
-      - name: adoEnvironment
-        value: prod-quidne-site
-      - name: verifyHostname
-        value: quidne.com
-
 stages:
   - stage: Build
     displayName: Build
@@ -115,33 +81,30 @@ stages:
               contentArtifact: ${{ variables.contentArtifact }}
               manifestArtifact: ${{ variables.manifestArtifact }}
 
-  - stage: Deploy
-    displayName: Deploy ${{ parameters.target }}
-    dependsOn: Build
-    condition: succeeded()
-    jobs:
-      - deployment: DeploySite
-        displayName: Deploy and verify ${{ parameters.target }}
-        environment: ${{ variables.adoEnvironment }}
-        pool:
-          vmImage: ubuntu-latest
-        strategy:
-          runOnce:
-            deploy:
-              steps:
-                - template: templates/steps-site-deploy.yml
-                  parameters:
-                    target: ${{ parameters.target }}
-                    serviceConnection: ${{ variables.serviceConnection }}
-                    siteRelativePath: ${{ variables.siteRelativePath }}
-                    deployUtilsRelativePath: ${{ variables.deployUtilsRelativePath }}
-                    contentArtifact: ${{ variables.contentArtifact }}
+  # UAT and PROD depend on Build alone and start by hand; the environments' checks gate them.
+  - template: templates/stage-site-deploy.yml
+    parameters:
+      environment: DEV
+      manual: false
+      siteRelativePath: ${{ variables.siteRelativePath }}
+      deployUtilsRelativePath: ${{ variables.deployUtilsRelativePath }}
+      contentArtifact: ${{ variables.contentArtifact }}
+      manifestArtifact: ${{ variables.manifestArtifact }}
+
+  - template: templates/stage-site-deploy.yml
+    parameters:
+      environment: UAT
+      manual: true
+      siteRelativePath: ${{ variables.siteRelativePath }}
+      deployUtilsRelativePath: ${{ variables.deployUtilsRelativePath }}
+      contentArtifact: ${{ variables.contentArtifact }}
+      manifestArtifact: ${{ variables.manifestArtifact }}
 
-                - template: templates/steps-site-verify.yml
-                  parameters:
-                    target: ${{ parameters.target }}
-                    serviceConnection: ${{ variables.serviceConnection }}
-                    siteRelativePath: ${{ variables.siteRelativePath }}
-                    deployUtilsRelativePath: ${{ variables.deployUtilsRelativePath }}
-                    manifestArtifact: ${{ variables.manifestArtifact }}
-                    verifyHostname: ${{ variables.verifyHostname }}
+  - template: templates/stage-site-deploy.yml
+    parameters:
+      environment: PROD
+      manual: true
+      siteRelativePath: ${{ variables.siteRelativePath }}
+      deployUtilsRelativePath: ${{ variables.deployUtilsRelativePath }}
+      contentArtifact: ${{ variables.contentArtifact }}
+      manifestArtifact: ${{ variables.manifestArtifact }}
diff --git a/pipelines/templates/stage-site-deploy.yml b/pipelines/templates/stage-site-deploy.yml
new file mode 100644
index 0000000..9e44d13
--- /dev/null
+++ b/pipelines/templates/stage-site-deploy.yml
@@ -0,0 +1,59 @@
+parameters:
+  - name: environment
+    type: string
+    values:
+      - DEV
+      - UAT
+      - PROD
+  - name: manual
+    type: boolean
+  - name: siteRelativePath
+    type: string
+  - name: deployUtilsRelativePath
+    type: string
+  - name: contentArtifact
+    type: string
+  - name: manifestArtifact
+    type: string
+
+stages:
+  - stage: Deploy${{ parameters.environment }}
+    displayName: Deploy ${{ parameters.environment }}
+    # ADO refuses dependsOn on a manual stage. It still cannot deploy an unbuilt run: Package publishes
+    # the artifact only after every validator passes, and the download fails without it.
+    ${{ if parameters.manual }}:
+      trigger: manual
+      lockBehavior: sequential
+    ${{ else }}:
+      dependsOn: Build
+      condition: succeeded('Build')
+      # Only the newest merge matters on DEV; an older run waiting on the lock is superseded.
+      lockBehavior: runLatest
+    variables:
+      - template: ../variables/site-${{ lower(parameters.environment) }}.yml
+    jobs:
+      - deployment: DeploySite
+        displayName: Deploy and verify ${{ parameters.environment }}
+        environment: ${{ variables.adoEnvironment }}
+        pool:
+          vmImage: ubuntu-latest
+        strategy:
+          runOnce:
+            deploy:
+              steps:
+                - template: steps-site-deploy.yml
+                  parameters:
+                    target: ${{ parameters.environment }}
+                    serviceConnection: ${{ variables.serviceConnection }}
+                    siteRelativePath: ${{ parameters.siteRelativePath }}
+                    deployUtilsRelativePath: ${{ parameters.deployUtilsRelativePath }}
+                    contentArtifact: ${{ parameters.contentArtifact }}
+
+                - template: steps-site-verify.yml
+                  parameters:
+                    target: ${{ parameters.environment }}
+                    serviceConnection: ${{ variables.serviceConnection }}
+                    siteRelativePath: ${{ parameters.siteRelativePath }}
+                    deployUtilsRelativePath: ${{ parameters.deployUtilsRelativePath }}
+                    manifestArtifact: ${{ parameters.manifestArtifact }}
+                    verifyHostname: ${{ variables.verifyHostname }}
diff --git a/pipelines/variables/site-dev.yml b/pipelines/variables/site-dev.yml
new file mode 100644
index 0000000..ff76525
--- /dev/null
+++ b/pipelines/variables/site-dev.yml
@@ -0,0 +1,8 @@
+variables:
+  - name: serviceConnection
+    value: dev-quidne-site
+  - name: adoEnvironment
+    value: dev-quidne-site
+  # Empty means the SWA default hostname.
+  - name: verifyHostname
+    value: ''
diff --git a/pipelines/variables/site-prod.yml b/pipelines/variables/site-prod.yml
new file mode 100644
index 0000000..127b593
--- /dev/null
+++ b/pipelines/variables/site-prod.yml
@@ -0,0 +1,8 @@
+variables:
+  - name: serviceConnection
+    value: prod-quidne-site
+  - name: adoEnvironment
+    value: prod-quidne-site
+  # The apex, the name that serves production.
+  - name: verifyHostname
+    value: quidne.com
diff --git a/pipelines/variables/site-uat.yml b/pipelines/variables/site-uat.yml
new file mode 100644
index 0000000..4f29099
--- /dev/null
+++ b/pipelines/variables/site-uat.yml
@@ -0,0 +1,7 @@
+variables:
+  - name: serviceConnection
+    value: uat-quidne-site
+  - name: adoEnvironment
+    value: uat-quidne-site
+  - name: verifyHostname
+    value: uat.quidne.com
diff --git a/workstreams/quidne-pipeline-environments.md b/workstreams/quidne-pipeline-environments.md
new file mode 100644
index 0000000..c010189
--- /dev/null
+++ b/workstreams/quidne-pipeline-environments.md
@@ -0,0 +1,139 @@
+# Quidne site pipeline: a stage per environment, gated by the environment
+
+Planned 2026-09-27.
+
+## Context
+
+`pipelines/01-site.yml` (ADO definition 13) has one Build stage and one Deploy stage whose environment is
+picked by a `target` parameter (default `DEV`). A merge to `main` runs it with the default, so CI only ever
+reaches DEV; UAT and PROD move only when someone queues a fresh run with `target` changed, which rebuilds
+from source. The runs on 2026-09-27 show the drift that allows:
+
+| Environment | Last deploy | Commit |
+|---|---|---|
+| DEV | run 915, 2026-09-27, `individualCI` | `7bf1ffa` (PR 270, theme and copy) |
+| PROD | run 893, 2026-09-25, manual | `416af79` (PR 269, rollout) |
+| UAT | run 831, 2026-09-23, manual | `e62f999`, before PR 269 |
+
+PR 269 reached PROD without passing UAT, and UAT is two merges behind. Nothing ties a PROD run to the build
+DEV verified.
+
+Azure DevOps state (read 2026-09-27):
+
+| Resource | Id | Checks | Pipeline permissions |
+|---|---|---|---|
+| Environment `dev-quidne-site` | 7 | none (deliberate, tracker chunk 22) | definition 13 only |
+| Environment `uat-quidne-site` | 5 | Approval 29, 43200 min | definition 13 only |
+| Environment `prod-quidne-site` | 6 | Approval 30, 43200 min | definition 13 only |
+| Connection `dev-quidne-site` | `8008da15-…` | none | definition 13 only |
+| Connection `uat-quidne-site` | `182752ae-…` | none | definition 13 only |
+| Connection `prod-quidne-site` | `907b9382-…` | none | definition 13 only |
+
+All three connections are workload identity federation, subscription-scoped. Because none carries a check,
+any job in definition 13 can use the PROD connection outside the PROD environment and skip approval 30.
+
+Decisions taken with the developer on 2026-09-27:
+
+| Question | Decision | Why |
+|---|---|---|
+| Shape | One pipeline, one Build per run, a stage per environment | Build once; the artifact promoted is the one built |
+| DEV | Automatic on every run, including CI on `main` | Unchanged behaviour for merges |
+| UAT, PROD | **Manual stages** (`trigger: manual`), each `dependsOn: Build` only | Developer wants neither gated on DEV; promotion is a deliberate act |
+| Gates | On the ADO environments and service connections, not in YAML | Enterprise norm: YAML says where, the environment says who and when |
+| `target` parameter | Removed | The stages are the only route; redeploy = rerun a stage |
+| Per-env values | One variables template per environment | No secrets involved, so a Library group adds nothing but a UI to drift in |
+
+## Target shape
+
+```
+Build (validate x3 → Package) ──► DeployDEV   automatic                     env 7: exclusive lock
+                              ├─► DeployUAT   trigger: manual               env 5: branch control, lock, approval 29
+                              └─► DeployPROD  trigger: manual               env 6: branch control, lock, approval 30
+```
+
+```
+pipelines/01-site.yml                      trigger/pr unchanged; no parameters; Build stage unchanged
+pipelines/variables/site-dev.yml           serviceConnection, adoEnvironment, verifyHostname ''
+pipelines/variables/site-uat.yml           … verifyHostname uat.quidne.com
+pipelines/variables/site-prod.yml          … verifyHostname quidne.com
+pipelines/templates/stage-site-deploy.yml  parameters: environment (DEV|UAT|PROD), manual (bool)
+                                           emits the stage, its variables template, the deployment job,
+                                           and the existing steps-site-deploy / steps-site-verify
+```
+
+`stage-site-deploy.yml` sets `trigger: manual` when `manual` is true, `dependsOn: Build`,
+`condition: succeeded('Build')`, and `lockBehavior: runLatest` for DEV (only the newest merge matters) or
+`sequential` for UAT/PROD. The `steps-site-*` templates are untouched: they already take the environment, the
+connection and the hostname as parameters.
+
+## Phase A: YAML (work branch, DEV only)
+
+1. Add the three variables templates and `stage-site-deploy.yml`. Templates hold no logic beyond the
+   manual/lock switch.
+2. Rewrite `01-site.yml`: drop `parameters` and the three `${{ if }}` variable blocks; keep the shared
+   variables; `stages:` = Build unchanged, then the stage template three times.
+3. Queue definition 13 against the work branch. Expect: Build and DeployDEV run and verify against the DEV
+   default hostname; DeployUAT and DeployPROD show as waiting for a manual start.
+
+Exit: that branch run green on DEV, and the run page shows both manual stages startable.
+
+## Phase B: environment and connection checks (ADO, developer-applied)
+
+Keep approvals 29 and 30 exactly as they are. Add:
+
+1. **Branch control** on environments 5 and 6: allowed `refs/heads/main`; "verify branch protection" on.
+2. **Exclusive lock** on environments 5, 6 and 7.
+3. **Branch control** on connections `uat-quidne-site` and `prod-quidne-site`, same settings. This closes
+   the gap where a job could borrow the PROD connection outside the PROD environment.
+4. Optional, decide at the time: **Required template** on the PROD connection, pinning it to
+   `pipelines/templates/stage-site-deploy.yml` on `main`.
+
+Proof, from the Phase A branch run: start DeployUAT. Branch control must refuse it before approval 29 is
+requested. That refusal is the evidence the gate works; do not approve anything from a branch run.
+
+Exit: checks listed by `az rest … pipelines/checks/configurations?resourceType=environment&resourceId=<id>`
+for 5, 6, 7 and `resourceType=endpoint` for the two connections; the branch-run refusal recorded.
+
+## Phase C: merge and first promotion
+
+1. Draft PR → `main`; the developer publishes and completes it.
+2. The CI run on `main` builds and deploys DEV.
+3. Start DeployUAT on that run → approval 29 → `Invoke-VerifySite` against `uat.quidne.com` passes all
+   checks. This also brings UAT from `e62f999` up to date.
+4. When the developer chooses, start DeployPROD on the **same run** → approval 30 → verify against
+   `quidne.com`.
+
+Exit: one run whose three stages all succeeded, with UAT and PROD serving the artifact DEV verified.
+
+## Phase D: record
+
+- Tracker standing facts: the environment/connection check ids, the promotion procedure (open the run, start
+  the stage, approve), rollback (start DeployPROD on an older retained run), and what a run with unstarted
+  manual stages reports as its status.
+- The contact form plan's Phase D already points here; `02-api.yml` reuses `stage-site-deploy.yml`'s shape
+  with its own templates.
+
+## Check items before Phase A
+
+1. `trigger: manual` on a stage: confirm the hosted ADO accepts it for definition 13, and what the run's
+   overall status reads while manual stages are unstarted (in progress vs succeeded).
+2. Retention: project and definition retention for runs and pipeline artifacts. A manual stage can only be
+   started while its run and artifacts are retained, which bounds how long a build stays promotable and how
+   far back a rollback can reach.
+3. `lockBehavior` at stage level with an environment exclusive lock behaves as documented on this org.
+4. Branch control's "verify branch protection" passes against `main`'s current branch policies.
+
+## Risks
+
+- A queued manual run on a hotfix commit still deploys DEV first. Acceptable: DEV has no approval and is
+  where a hotfix should land anyway.
+- Retention expiry makes an old build unpromotable; the fallback is a fresh run from `main`.
+- Environment and connection checks govern ADO runs only. `local-deploy-site.ps1` uses the developer's own
+  credentials and stays an ungated route to every environment, as it is today.
+- Starting DeployPROD on an older run is a rollback, and it is gated like any other PROD deploy.
+
+## Out of scope
+
+`02-api.yml` (contact form Phase D), infrastructure changes, and moving values into ADO Library groups.
+
+**Execution tracker:** [quidne-pipeline-environments.tracker.md](quidne-pipeline-environments.tracker.md) — live status of this work.
diff --git a/workstreams/quidne-pipeline-environments.tracker.md b/workstreams/quidne-pipeline-environments.tracker.md
new file mode 100644
index 0000000..dcecab3
--- /dev/null
+++ b/workstreams/quidne-pipeline-environments.tracker.md
@@ -0,0 +1,173 @@
+# Quidne site pipeline: a stage per environment, gated by the environment — tracker
+
+**Parent:** [quidne-pipeline-environments.md](quidne-pipeline-environments.md) · **Started:** 2026-09-27
+**Status:** Active
+
+This file is the live status of the work; the parent document is the reasoning and does not change.
+Item numbers match the parent's. Work discovered during a session is prefixed `D`.
+
+Item numbers are **section-scoped**: `CK.<n>` is the parent's "Check items before Phase A" list, and
+`A.<n>`–`C.<n>` are the numbered steps of Phases A–C. Phase D is two unnumbered bullets, so `D.1`/`D.2` are
+boundaries this tracker created; discovered work therefore starts at `DX1` to stay clear of them.
+
+---
+
+## State of play
+
+*Update at the end of every session. Table only — narrative belongs in the commit message and PR.*
+
+| | |
+|---|---|
+| **Branch** | `task/jk-pipeline-environments` |
+| **Built / deployed** | Branch YAML deployed DEV at `6d8cd63` (run 918); checks 41–47 live on envs 5/6/7 and both connections. PROD `416af79` (run 893), UAT `e62f999` (run 831) |
+| **Deploy queue** | — |
+| **In flight** | Chunk 3 (C.1, C.2) · `task/jk-pipeline-environments` · Durin · 2026-09-27 |
+| **Blocked** | PR 273 (draft) waits on the developer to publish and complete it |
+| **Promoted** | all of the parent (CK.1–CK.4, A.1–A.3, B.1–B.4, C.1–C.4, D.1–D.2) · not yet promoted: none |
+| **Next** | Chunk 3 (C.1, C.2) |
+
+---
+
+## Chunks
+
+| Chunk | Items | Ends at (verifiable condition) | Why here | Status |
+|---|---|---|---|---|
+| 1 | CK.1, CK.2, A.1, A.2, A.3 | A branch run of definition 13 green on DEV, DeployUAT and DeployPROD shown as manually startable, retention figures recorded | Every check in Phase B is proven against manual stages, so they must exist first. *Small:* four YAML files and one run; expect to carry on | Done |
+| 2 | B.1, B.2, B.3, B.4, CK.3 | Checks listed on environments 5, 6, 7 and both connections; starting DeployUAT on a branch run is refused by branch control | The refusal proof needs chunk 1's startable stages | Done |
+| 3 | C.1, C.2 | The CI run on the merge commit has Build and DeployDEV green | Nothing after this verifies from a branch; completing the PR is the developer's | Not started |
+| 4 | C.3, C.4, CK.4, D.1, D.2 | One run from `main` with DeployDEV, DeployUAT and DeployPROD all succeeded; facts recorded | Needs chunk 3's run from `main`, and approvals 29 and 30 from the developer | Not started |
+
+---
+
+## Costs
+
+| Chunk | Context at close | Turns | Continued |
+|---|---|---|---|
+| 1 | 202k | 202 | yes |
+| 2 | 230k | 268 | no |
+
+---
+
+## Conventions
+
+*Recorded once at init. Every later session reads them here instead of asking again. The two rows below
+`Tracker system and fields` are the exception — they are maintained after init, not only elicited at it.*
+
+| | |
+|---|---|
+| **Verification gate** | see the section at the bottom of this file |
+| **Branch / PR convention** | `<prefix>/<initials>-<slug>` from the default branch; commit and push per chunk; publish and complete are the developer's. **Exception: two draft PRs** — C.1's carries the YAML mid-workstream, because chunk 4 can only verify from `main`; a second at termination carries the tracker closure |
+| **Tracker system and fields** | None |
+| **Sizing model** | `claude-opus-5-5` · large (chunks 1–4, 2026-09-27) |
+| **Parent fingerprint** | `c0101894fcdd42272194d70d13f8f186a86e9080` (`git rev-parse HEAD:workstreams/quidne-pipeline-environments.md`, 2026-09-27, after the footer) |
+| **Unit of work** | one chunk per session — a session may carry on past a boundary once the chunk is closed |
+
+---
+
+## Items
+
+### C.1. Draft PR and merge
+
+**Status:** In progress · Chunk 3 · **Blocking:** chunk 2
+
+**As a** developer **I want** the new pipeline on `main` **So that** UAT and PROD can be promoted under
+branch control.
+
+**Acceptance criteria**
+- [x] Draft PR `task/jk-pipeline-environments` → `main` POSTed by the session — PR 273
+- [ ] PR completed by the developer
+
+### C.2. CI run on `main` deploys DEV
+
+**Status:** Not started · Chunk 3 · **Blocking:** C.1
+
+**As a** developer **I want** the merge's CI run to deploy DEV on its own **So that** merge behaviour is
+unchanged.
+
+**Acceptance criteria**
+- [ ] The `individualCI` run on the merge commit has Build and DeployDEV succeeded; UAT and PROD waiting for a manual start
+
+### C.3. Promote to UAT
+
+**Status:** Not started · Chunk 4 · **Blocking:** C.2
+
+**As a** release owner **I want** UAT deployed from C.2's run **So that** UAT serves the build DEV
+verified, and catches up from `e62f999`.
+
+**Acceptance criteria**
+- [ ] DeployUAT started on C.2's run; branch control passes; approval 29 granted by the developer
+- [ ] `Invoke-VerifySite` against `uat.quidne.com` passes every check
+
+### C.4. Promote to PROD
+
+**Status:** Not started · Chunk 4 · **Blocking:** C.2
+
+**As a** release owner **I want** PROD deployed from the same run **So that** PROD serves the exact
+artifact DEV and UAT verified.
+
+**Acceptance criteria**
+- [ ] DeployPROD started on C.2's run when the developer chooses; approval 30 granted
+- [ ] `Invoke-VerifySite` against `quidne.com` passes every check
+
+### CK.4. Branch protection verification passes on `main`
+
+**Status:** Not started · Chunk 4 · **Blocking:** C.3
+
+**As a** developer **I want** "verify branch protection" confirmed against `main`'s policies **So that**
+the setting does not refuse legitimate promotions.
+
+**Acceptance criteria**
+- [ ] C.3's branch control check passes with verify branch protection on; if it refuses, the policy gap is recorded and the setting decided with the developer
+
+### D.1. Record standing facts
+
+**Status:** Not started · Chunk 4 · **Blocking:** C.3, C.4
+
+**As a** future session **I want** the check ids, promotion and rollback procedure recorded **So that**
+nobody rediscovers them.
+
+**Acceptance criteria**
+- [ ] Check ids for environments 5, 6, 7 and both connections; promote (open run → start stage → approve); rollback (start DeployPROD on an older retained run); CK.1's status finding — all in this tracker
+
+### D.2. Contact form plan points here
+
+**Status:** Not started · Chunk 4 · **Blocking:** —
+
+**As a** contact-form session **I want** Phase D of that plan to reuse this shape **So that** `02-api.yml`
+is built once.
+
+**Acceptance criteria**
+- [ ] The dependency line in `workstreams/quidne-contact-form-function.md` Phase D is present on `task/jk-contact-form-function` (`8e71df3`) or on `main`
+
+---
+
+## Completed
+
+*One line per row: item, chunk, date, one pointer. Evidence narrative belongs in the commit message.*
+
+| # | Item | Chunk | Landed |
+|---|---|---|---|
+| CK.1 | Manual stage semantics: no `dependsOn` allowed; run reads completed/succeeded with manual stages pending | 1 | 2026-09-27 · run 916 |
+| CK.2 | Retention: runs 30 d, artifacts 30 d, 3 per protected branch, no definition override | 1 | 2026-09-27 · `_apis/build/retention` |
+| A.1 | Variables templates and `stage-site-deploy.yml` | 1 | 2026-09-27 · `pipelines/templates/stage-site-deploy.yml` |
+| A.2 | `01-site.yml` rewritten to Build + three stages, `target` gone | 1 | 2026-09-27 · `6d8cd63` |
+| A.3 | Branch run green on DEV, UAT/PROD pending manual | 1 | 2026-09-27 · run 916 (`20260927.2`) |
+| B.1 | Branch control (`main` only) on environments 5 and 6 | 2 | 2026-09-27 · checks 42, 43 |
+| B.2 | Exclusive lock on environments 5, 6, 7 | 2 | 2026-09-27 · checks 46, 47, 41 |
+| B.3 | Branch control on the UAT and PROD connections; branch-run UAT start refused | 2 | 2026-09-27 · checks 44, 45 · run 916 |
+| B.4 | Required template on the PROD connection: skipped for now by the developer | 2 | 2026-09-27 · revisit with `02-api` |
+| CK.3 | DEV lock serialised two back-to-back runs, no overlap; `runLatest` supersession not exercised (needs 3+ waiting) | 2 | 2026-09-27 · runs 917, 918 |
+
+---
+
+## Verification gate
+
+Run all of it after every change.
+
+1. **Pester, all suites:** `pwsh.exe -NoProfile -Command "Invoke-Pester -Path tests"` — every test passes;
+   the baseline count is recorded at chunk 1's first run.
+2. **ADO YAML preview:** `az rest --method post` to
+   `https://dev.azure.com/quidne/Quidne/_apis/pipelines/13/preview?api-version=7.1` with a body file
+   `{ "previewRun": true, "resources": { "repositories": { "self": { "refName": "refs/heads/<branch>" } } } }`
+   — returns `finalYaml` and no error. Validates without running anything.
+3. **The chunk's own `Ends at` condition**, from the Chunks table.

Screening

Sent as is: 539 diff lines and 7 commit subjects checked against 6 rules · 0 Block · 0 Warn

Draft

68 / 80

2778 / 4,000

44,884 input tokens · 1,454 output · 18.7 s

Simulation of a real run: pull request 273 of this site's repository at 59f35ab, composed the way the extension composes a request and answered by Claude Code on claude-opus-5-5 on 1 October 2026, not by the extension's own model deployment. The portal chrome is our own drawing.

  1. Start from the branch

    The action sits on the branch menu, before any pull request exists. The extension gathers what a reviewer would need: the commits only this branch has, any work items they name, and the diff against main.

  2. Screened before it is sent

    Nothing reaches the model until the request passes screening. A key, token or connection string stops it. An email address or other personal value needs the author to acknowledge it, and then only a redacted copy is sent.

  3. A draft in the team's shape

    The answer comes back in a fixed shape: an imperative title under 80 characters, one paragraph, and one bullet per changed file. Here it also noticed that the plan still says something the YAML no longer does.

  4. Only ever a draft

    The author edits anything they like, then creates the pull request. The request body cannot say anything but draft, and it carries no reviewers, so nobody is notified until the author publishes it.

  5. Your turn

    Press Generate PR draft. To watch screening intervene, tick the email option first: the request then needs your acknowledgement, and the line is redacted before it would be sent.

What it shows

An Azure DevOps extension that writes the first draft of a pull request from the branch's own evidence: its commits and its diff. The request is screened for credentials and personal data before it leaves, and the author edits the result and creates it. The extension can only create a draft, and it never approves, votes, comments or adds reviewers.

This page replays that flow on pull request 273 of this site's repository. The request is composed the way the extension's backend composes one. The answer was recorded from Claude Code, not from the extension's own model, and the screening rules here are a short version of the extension's.

What you get

  • An artefact: a private extension with a branch action that drafts the pull request, a pull-request action that drafts the deployment and change forms and emails them to the requester, and one view of every open pull request across the organisation.
  • A proof: its only write is a draft pull request, created with the author's own identity through a request body that cannot say anything but draft.
  • A number: 509 tests, 465 on the backend and 44 in the extension, gate every package.

After we leave, a project admin turns it on per project. Your team sets its limits, caching and screening through infrastructure parameters, and a model change is one line in the shared AI platform's configuration, with no change to the extension.

How it is built

The extension is a set of portal contributions behind one feature flag: a branch action, a pull-request action, a dialog and a hub. One build is packaged twice, as development and production variants under separate publishers, so both can be installed side by side. Publishing signs in with a workload identity, not a stored token.

The dialog calls an Azure Functions backend with a token Azure DevOps signs for the extension. The backend builds the diff, screens it, and calls a model on a shared Azure AI Foundry account through its managed identity. The account has key authentication turned off, and the app asks for a role, not a model name. Prompts and completions are never logged, and debugging transcripts expire after seven days.

For this page, tools/record-pr-draft.py composes the same request from git and the pull request's commit list. A read-only Claude Code session answers it, and the exporter writes the data and this page's static frame. A test fails the build if the draft's change list stops naming exactly the files in the recorded diff, or the draft breaks the extension's limits.