@amsterdamdatalabs/adl-agent-control-linux-x64 0.1.8 → 0.1.10

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -85,13 +85,24 @@
85
85
  * as its own finding. checkBuildServiceCanRelease() answers one narrow
86
86
  * question directly, per the real failure above: can the identity that
87
87
  * ACTUALLY RUNS THE PIPELINE (repo scope) push a `***NO_CI***` bump commit
88
- * to `integration` (GenericContribute), bypass `integration`'s own policies
89
- * while doing so (PolicyExempt), and create/update the `integration -> main`
90
- * PR and set auto-complete (PullRequestContribute) — and it FAILS loudly,
88
+ * to the PR's source (feature) branch (GenericContribute), and create/update
89
+ * the `integration -> main` PR and set auto-complete
90
+ * (PullRequestContribute) — and it FAILS loudly,
91
91
  * naming the permission, the identity's display name and the fix path, on
92
92
  * any of: the permission not effectively allowed, the identity not
93
93
  * resolvable, or the ACL itself not readable by the calling agent.
94
94
  *
95
+ * New release flow (Tarun, 2026-09-30): the CI pipeline runs on PRs into
96
+ * `integration` and its stage 1 pushes the version bump to the PR's SOURCE
97
+ * (feature) branch; a promote pipeline on `integration` pushes only opens the
98
+ * `integration -> main` PR; a CD pipeline runs on pushes to `main`. Nothing
99
+ * pushes directly to `integration` any more, so (a) the build service no
100
+ * longer needs PolicyExempt on `integration` — and, by least privilege,
101
+ * having it there is drift; (b) the release-identity check no longer needs
102
+ * PolicyExempt; (c) the promotion PR gets NO build validation (Tarun's
103
+ * approval is the only gate; CD runs after the merge), so `main` no longer
104
+ * requires a build-validation policy and an enabled one is drift.
105
+ *
95
106
  * Policy type GUIDs (verified against the azure-devops-cli-extension source,
96
107
  * packages/repo-controls already hardcodes the first three the same way):
97
108
  * minimum reviewers fa4e907d-c16b-4a4c-9dfa-4906e5d171dd
@@ -178,7 +189,11 @@ export const REPO_STANDARD = {
178
189
  forbiddenReviewerPolicies: [POLICY_TYPE.requiredReviewers] as const,
179
190
  mergeStrategy: { allowSquash: false, allowRebase: false, allowRebaseMerge: false, allowNoFastForward: true },
180
191
  commentRequirementsRequired: true,
181
- buildValidationRequiredWhenPipelineExists: true,
192
+ // Tarun, 2026-09-30, new release flow: the integration -> main promotion
193
+ // PR must NOT run a build (Tarun's approval is the only gate; the CD
194
+ // pipeline runs after the merge, on pushes to main). An ENABLED build
195
+ // policy on main is drift (`main.build-validation-present`).
196
+ buildValidationForbidden: true,
182
197
  },
183
198
  // One table for every effective-permission expectation this checker reads
184
199
  // via the Git Repositories security namespace ACL, so the standard never
@@ -206,7 +221,9 @@ export const REPO_STANDARD = {
206
221
  allow: ["genericContribute", "pullRequestContribute"],
207
222
  deny: ["forcePush", "policyExempt", "pullRequestBypassPolicy"],
208
223
  },
209
- "agent-reviewer": { allow: ["pullRequestContribute"], deny: ["genericContribute"] },
224
+ // Tarun, 2026-09-30: no agent gets force push — agent-reviewer included,
225
+ // in every project (only its genericContribute deny is Enact-only).
226
+ "agent-reviewer": { allow: ["pullRequestContribute"], deny: ["genericContribute", "forcePush"] },
210
227
  } as Record<string, { allow: readonly PermissionBitName[]; deny: readonly PermissionBitName[] }>,
211
228
  /**
212
229
  * The project's build service identity (display name `Build\<projectId>`,
@@ -214,41 +231,45 @@ export const REPO_STANDARD = {
214
231
  * — Tarun, 2026-09-27, cross-checked against
215
232
  * /Users/tarunrana/Documents/ADL-ISMS-Policies/.scratchpad/ci_build_service_grant.py,
216
233
  * which grants exactly these bits on adl-agent-control). CI needs to push
217
- * tags/branches and complete PRs on the repo, and needs PolicyExempt only
218
- * on `integration` (so a version-bump commit made by CI itself is not
219
- * blocked by its own policies) — never on `main`.
234
+ * and complete PRs on the repo (Contribute is also what the version-bump
235
+ * push to the PR's SOURCE feature branch needs — Tarun, 2026-09-30, new
236
+ * release flow). It needs PolicyExempt NOWHERE: nothing pushes directly to
237
+ * `integration` any more (the bump lands on the feature branch), so
238
+ * PolicyExempt on `integration` is no longer required and, by least
239
+ * privilege, is drift (`permissions.build-service.has-policyExempt-integration`);
240
+ * never on `main` either (existing rule).
241
+ *
242
+ * NOT forcePush (Tarun, 2026-09-30): the bump is a plain fast-forward
243
+ * `git push origin HEAD:integration`; agent-identities' build service has
244
+ * no ForcePush and bumped 0.4.2 → 0.4.3 fine. The grant script's
245
+ * ForcePush has no consumer, so force push is required of no identity.
220
246
  */
221
247
  buildService: {
222
- repo: { allow: ["genericContribute", "forcePush", "pullRequestContribute"] } as {
248
+ repo: { allow: ["genericContribute", "pullRequestContribute"] } as {
223
249
  allow: readonly PermissionBitName[];
224
250
  },
225
- integrationRef: { allow: ["policyExempt"] } as { allow: readonly PermissionBitName[] },
251
+ integrationRef: { deny: ["policyExempt"] } as { deny: readonly PermissionBitName[] },
226
252
  mainRef: { deny: ["policyExempt"] } as { deny: readonly PermissionBitName[] },
227
253
  },
228
254
  /**
229
255
  * checkBuildServiceCanRelease() (file header, "checkBuildServiceCanRelease()"):
230
- * the exact three permissions the release flow needs from whichever
231
- * build service identity actually runs the pipeline — the real failure
232
- * this check exists for was a 403 on `pullRequestContribute`.
256
+ * the permissions the release flow needs from whichever build service
257
+ * identity actually runs the pipeline — the real failure this check
258
+ * exists for was a 403 on `pullRequestContribute`.
233
259
  *
234
- * `policyExempt` ("Bypass policies when pushing") is checked at the
235
- * `integration` REF token, never the repo-scope token — verified live
236
- * 2026-09-30 against adl-agent-control: the Infrastructure-AzDo Build
237
- * Service demonstrably bypassed `integration`'s own policies to push
238
- * `chore: bump version 0.1.6 to 0.1.7 ***NO_CI***` (c966e6a) straight to
239
- * that protected branch, yet its repo-scope effective allow bitmask
240
- * (16430) does not include PolicyExempt (128) at all — only its
241
- * `refs/heads/integration`-scope effective allow (16558) does. Checking
242
- * `policyExempt` at repo scope produced a false
243
- * `permissions.release.<slug>.missing-policyExempt` finding that
244
- * contradicted this observed reality. `genericContribute` and
245
- * `pullRequestContribute` stay checked at repo scope — both bits ARE
246
- * present there for real, and PR operations are a repo-level, not
247
- * branch-level, permission.
260
+ * `policyExempt` ("Bypass policies when pushing") is NOT required any
261
+ * more (Tarun, 2026-09-30, new release flow): the version bump now lands
262
+ * on the PR's SOURCE (feature) branch, not on `integration`, so nothing
263
+ * needs to bypass `integration`'s policies. (History: it used to be
264
+ * checked at the `integration` REF token, never repo scope — verified
265
+ * live 2026-09-30 against adl-agent-control, where the build service
266
+ * pushed `chore: bump version 0.1.6 to 0.1.7 ***NO_CI***` (c966e6a)
267
+ * straight to `integration`.) `genericContribute` (the bump push to the
268
+ * feature branch) and `pullRequestContribute` stay checked at repo scope
269
+ * — PR operations are a repo-level, not branch-level, permission.
248
270
  */
249
271
  buildServiceRelease: {
250
272
  repoScoped: ["genericContribute", "pullRequestContribute"] as const,
251
- integrationRefScoped: ["policyExempt"] as const,
252
273
  },
253
274
  },
254
275
  } as const;
@@ -511,11 +532,7 @@ function checkMainMergeStrategy(policies: readonly PolicyConfiguration[], drifts
511
532
  });
512
533
  }
513
534
 
514
- function checkMainCommentsAndBuild(
515
- policies: readonly PolicyConfiguration[],
516
- hasPipeline: boolean,
517
- drifts: RepoDoctorDrift[],
518
- ): void {
535
+ function checkMainCommentsAndBuild(policies: readonly PolicyConfiguration[], drifts: RepoDoctorDrift[]): void {
519
536
  const comments = enabledPolicyOfType(policies, POLICY_TYPE.commentRequirements);
520
537
  if (!comments) {
521
538
  drifts.push({
@@ -524,21 +541,27 @@ function checkMainCommentsAndBuild(
524
541
  });
525
542
  }
526
543
 
527
- if (!hasPipeline) return;
544
+ // Tarun, 2026-09-30, new release flow: the promotion PR must not run a
545
+ // build — main no longer requires build validation, and an enabled one is
546
+ // drift (regardless of whether a pipeline exists).
528
547
  const build = enabledPolicyOfType(policies, POLICY_TYPE.build);
529
- if (!build) drifts.push({ id: "main.build-validation-missing", message: "missing build validation" });
548
+ if (build) {
549
+ drifts.push({
550
+ id: "main.build-validation-present",
551
+ message: `main has an enabled build-validation policy (#${build.id}); the promotion PR must not run a build (CD runs after merge) — Tarun's approval is the only gate`,
552
+ });
553
+ }
530
554
  }
531
555
 
532
556
  function checkMainPolicies(
533
557
  policies: readonly PolicyConfiguration[],
534
- hasPipeline: boolean,
535
558
  strictMainBranchRules: boolean,
536
559
  drifts: RepoDoctorDrift[],
537
560
  ): void {
538
561
  checkMainRequiredReviewer(policies, drifts);
539
562
  checkMainMinimumReviewers(policies, strictMainBranchRules, drifts);
540
563
  checkMainMergeStrategy(policies, drifts);
541
- checkMainCommentsAndBuild(policies, hasPipeline, drifts);
564
+ checkMainCommentsAndBuild(policies, drifts);
542
565
  }
543
566
 
544
567
  /**
@@ -816,10 +839,11 @@ async function resolveBuildServiceDescriptor(namespaceId: string, projectId: str
816
839
  }
817
840
 
818
841
  /**
819
- * CI needs to push and complete PRs on the repo itself, and needs to bypass
820
- * policy only on `integration` (so its own version-bump commit is not
821
- * blocked by the very policies it satisfies) — never on `main`, where a
822
- * bypass would defeat the human-reviewer gate entirely.
842
+ * CI needs to push (the version bump to the PR's source branch) and complete
843
+ * PRs on the repo itself. It needs to bypass policy NOWHERE (Tarun,
844
+ * 2026-09-30, new release flow: nothing pushes directly to `integration`
845
+ * any more, so PolicyExempt there is drift; on `main` a bypass would defeat
846
+ * the human-reviewer gate entirely).
823
847
  */
824
848
  async function checkBuildServicePermissions(
825
849
  namespaceId: string,
@@ -878,11 +902,14 @@ async function checkBuildServicePermissions(
878
902
  drifts,
879
903
  );
880
904
  if (integrationEffective) {
881
- for (const bitName of REPO_STANDARD.permissions.buildService.integrationRef.allow) {
882
- if ((effectiveAllowBits(integrationEffective) & GIT_REPO_PERMISSION_BIT[bitName]) === 0) {
905
+ // Tarun, 2026-09-30, new release flow: nothing pushes directly to
906
+ // integration any more, so PolicyExempt there is no longer required and
907
+ // is flagged when effectively allowed (least privilege).
908
+ for (const bitName of REPO_STANDARD.permissions.buildService.integrationRef.deny) {
909
+ if ((effectiveAllowBits(integrationEffective) & GIT_REPO_PERMISSION_BIT[bitName]) !== 0) {
883
910
  drifts.push({
884
- id: `permissions.build-service.missing-${bitName}-integration`,
885
- message: `the project build service does not effectively have ${bitName} on refs/heads/integration`,
911
+ id: `permissions.build-service.has-${bitName}-integration`,
912
+ message: `the project build service effectively has ${bitName} on refs/heads/integration, which the standard forbids (nothing pushes directly to integration; the version bump lands on the PR's source branch)`,
886
913
  });
887
914
  }
888
915
  }
@@ -934,16 +961,6 @@ function releaseFixPath(repository: string, identityDisplayName: string, bitName
934
961
  return `Project settings → Repositories → \`${repository}\` → Security → \`${identityDisplayName}\` → \`${GIT_REPO_PERMISSION_UI_LABEL[bitName]}\` = Allow`;
935
962
  }
936
963
 
937
- /** Same as releaseFixPath() but for a bit that is granted per-BRANCH (verified live 2026-09-30: PolicyExempt on this org's build service lives on refs/heads/integration's own security page, never the repo-root one). */
938
- function releaseFixPathForRef(
939
- repository: string,
940
- ref: string,
941
- identityDisplayName: string,
942
- bitName: PermissionBitName,
943
- ): string {
944
- return `Repos → \`${repository}\` → Branches → \`${ref}\` → ⋯ → Branch security → \`${identityDisplayName}\` → \`${GIT_REPO_PERMISSION_UI_LABEL[bitName]}\` = Allow`;
945
- }
946
-
947
964
  /**
948
965
  * Which build service identity actually runs the pipeline, per Build —
949
966
  * General Settings — Get's `enforceJobAuthScope` (file header) — "Limit job
@@ -966,12 +983,10 @@ async function fetchEnforceJobAuthScope(c: AdoClient): Promise<boolean | undefin
966
983
  /**
967
984
  * One identity's answer to "can it release": resolve its descriptor by exact
968
985
  * display name (Identities — Read Identities, file header), then check
969
- * `buildServiceRelease.repoScoped` on the repository token AND
970
- * `buildServiceRelease.integrationRefScoped` on the `integration` ref token
971
- * — NOT the same token for all three (see REPO_STANDARD.permissions.
972
- * buildServiceRelease's own doc comment: PolicyExempt is a per-BRANCH grant
973
- * in this org, verified live against real bypass-on-push evidence, and
974
- * checking it at repo scope produced a false "missing" finding). Every
986
+ * `buildServiceRelease.repoScoped` on the repository token. PolicyExempt is
987
+ * no longer part of what a release needs (Tarun, 2026-09-30, new release
988
+ * flow: the bump lands on the PR's source branch, not on integration — see
989
+ * REPO_STANDARD.permissions.buildServiceRelease's own doc comment). Every
975
990
  * failure mode is its own loud, distinct drift — an unresolvable identity,
976
991
  * an ACL the calling agent cannot read, and each missing permission named
977
992
  * with its fix path — never a silent pass.
@@ -979,7 +994,6 @@ async function fetchEnforceJobAuthScope(c: AdoClient): Promise<boolean | undefin
979
994
  async function checkOneBuildServiceCanRelease(
980
995
  namespaceId: string,
981
996
  repoToken: string,
982
- integrationRefToken: string,
983
997
  repository: string,
984
998
  displayName: string,
985
999
  drifts: RepoDoctorDrift[],
@@ -1006,8 +1020,7 @@ async function checkOneBuildServiceCanRelease(
1006
1020
  }
1007
1021
 
1008
1022
  // LOW, independent review 2026-09-30: name WHICH token failed — "the ACL"
1009
- // alone left a human unable to tell a repo-scope 403 from a
1010
- // refs/heads/integration-scope one without re-running this by hand.
1023
+ // alone left a human unable to tell which token's read 403'd.
1011
1024
  async function effectiveAt(token: string, tokenLabel: string): Promise<{ allow: number; deny: number } | undefined> {
1012
1025
  let effective: EffectivePermissionsResult;
1013
1026
  try {
@@ -1032,16 +1045,6 @@ async function checkOneBuildServiceCanRelease(
1032
1045
  message: `'${displayName}' does not effectively have ${GIT_REPO_PERMISSION_UI_LABEL[bitName]} on '${repository}' — the release pipeline will 403 the way agent-identities build 4897 did. Fix: ${releaseFixPath(repository, displayName, bitName)}`,
1033
1046
  });
1034
1047
  }
1035
-
1036
- const integrationEffective = await effectiveAt(integrationRefToken, "refs/heads/integration");
1037
- if (!integrationEffective) return;
1038
- for (const bitName of REPO_STANDARD.permissions.buildServiceRelease.integrationRefScoped) {
1039
- if ((effectiveAllowBits(integrationEffective) & GIT_REPO_PERMISSION_BIT[bitName]) !== 0) continue;
1040
- drifts.push({
1041
- id: `permissions.release.${slug}.missing-${bitName}`,
1042
- message: `'${displayName}' does not effectively have ${GIT_REPO_PERMISSION_UI_LABEL[bitName]} on 'refs/heads/integration' of '${repository}' — its own version-bump commit would be blocked by integration's own policies. Fix: ${releaseFixPathForRef(repository, "integration", displayName, bitName)}`,
1043
- });
1044
- }
1045
1048
  }
1046
1049
 
1047
1050
  /**
@@ -1072,9 +1075,8 @@ async function checkBuildServiceCanRelease(
1072
1075
  : [projectScoped, collectionScoped];
1073
1076
 
1074
1077
  const repoToken = `repoV2/${projectId}/${repositoryId}`;
1075
- const integrationRefToken = `${repoToken}/refs/heads/${refNameHexUtf16Le("integration")}`;
1076
1078
  for (const displayName of displayNames) {
1077
- await checkOneBuildServiceCanRelease(namespaceId, repoToken, integrationRefToken, repository, displayName, drifts);
1079
+ await checkOneBuildServiceCanRelease(namespaceId, repoToken, repository, displayName, drifts);
1078
1080
  }
1079
1081
  }
1080
1082
 
@@ -1133,7 +1135,7 @@ export async function repoDoctorCheck(options: RepoDoctorOptions = {}): Promise<
1133
1135
  const integrationPolicies = await fetchBranchPolicies(c, repositoryId, "integration");
1134
1136
  const mainPolicies = await fetchBranchPolicies(c, repositoryId, "main");
1135
1137
  checkIntegrationPolicies(integrationPolicies, hasPipeline, drifts);
1136
- checkMainPolicies(mainPolicies, hasPipeline, strictMainBranchRules, drifts);
1138
+ checkMainPolicies(mainPolicies, strictMainBranchRules, drifts);
1137
1139
 
1138
1140
  await checkAgentPermissions(projectId, repositoryId, strictMainBranchRules, drifts);
1139
1141
  await checkBuildServicePermissions(
@@ -59,6 +59,11 @@ have needed):
59
59
  evidence blocks the push, never a warning, never a silent pass. A push
60
60
  already requires network access to the remote, so a network + token
61
61
  requirement here is not a new cost the way it would be at commit time.
62
+ The only guard required on the target branch is the minimum-reviewer
63
+ policy (one approval, `resetOnSourcePush=true`). A build policy is NOT
64
+ required (Tarun, 2026-09-30, three-pipeline release flow): the promotion PR
65
+ runs no build and Tarun's approval is the only gate, so an absent build policy
66
+ is not a failure (a present one is recorded as `ok`, never flagged here).
62
67
  - **A bare `adl-repo-controls doctor` run** (not through either hook) keeps
63
68
  reporting everything, exactly as before — this split only changes which
64
69
  hook exercises which checks, never what `doctor` itself is capable of
@@ -214,6 +219,13 @@ deactivates its hook path. Canonical logs, their root ignore, quarantined log
214
219
  evidence, and runtime caches remain. `adl-config.toml` itself is never deleted — only the
215
220
  `[controls]` span this package appended is ever spliced back out.
216
221
 
222
+ **promote** creates or reuses the active PR from `[controls.release].source`
223
+ to `target` and arms auto-complete (the installed `.workflows/promote.ts`;
224
+ see the assets list below). It requires the target's minimum-reviewer policy
225
+ and does not require a build policy: the promotion PR runs no build (Tarun,
226
+ 2026-09-30). It is what the Promote pipeline (`promote.yml`, triggered by a
227
+ merge into `integration`) runs.
228
+
217
229
  **doctor** is read-only and exits 1 on any failure: bash >= 4, bun, binary on
218
230
  PATH, the bundled compliance package present at its packaged path, repository
219
231
  root, `[controls]` config validity, (warning) `coverage/coverage-summary.json`
@@ -225,7 +237,7 @@ lock, each stub (present, unedited, executable), `core.hooksPath`, `.gitignore`
225
237
  lines, and (warning) lock version skew. When `[controls.release]` is declared,
226
238
  it also derives the configured `origin` remote, loads the validated canonical
227
239
  Agent Vault file before reading Azure DevOps, and records separate fail-closed receipts for the HTTPS handle,
228
- required owner reviewer, minimum-review reset, and build policy.
240
+ required owner reviewer, minimum-review reset, and (only when one is present) build policy.
229
241
 
230
242
  **run** executes a bundled enact-m5 tool against the repository with the config
231
243
  exported: `azdo-push` (Azure DevOps push with push lock + behind-origin refusal),
@@ -289,7 +301,25 @@ you own. Nothing is parsed and re-serialized; text is spliced into the table's s
289
301
  | `release.source` | no | `""` | `"integration"` | source branch for the managed promotion PR; no branch pair is guessed |
290
302
  | `release.target` | no | `""` | `"main"` | target branch for the managed promotion PR |
291
303
  | `release.merge_strategy` | no | `"squash"` | `"no-fast-forward"` | one of `squash`, `rebase`, `rebase-merge`, or `no-fast-forward` — sent to Azure DevOps as the matching wire value (`squash`, `rebase`, `rebaseMerge`, `noFastForward`) |
292
- | `release.pre_bump` | no | `[]` | — | ordered, repository-relative script paths `version-bump.sh` runs from the repo root before the bump commit |
304
+ | `release.pre_bump` | no | `[]` | — | ordered, repository-relative script paths `version-bump.sh` runs from the repo root before the bump commit (the CI pipeline's Bump stage, on the PR's source branch) |
305
+
306
+ ### Release scripts — Bump and Guard (Tarun, 2026-09-30)
307
+
308
+ One clean hard cutover to three pipelines (CI on PRs into `integration`,
309
+ Promote on `integration`, CD on `main`; `docs/architecture/cli-design.md` §6a).
310
+ Two managed scripts run in the CI pipeline, and neither ever pushes to
311
+ `integration`:
312
+
313
+ - **`.workflows/version-bump.sh --file package.json --pr-source <source> --base
314
+ integration --push`** (Bump stage) sets the PR's SOURCE branch to
315
+ `integration`'s version + 1 patch, running `release.pre_bump` scripts in the
316
+ same commit, and pushes it (a plain push as the build service, never an
317
+ agent identity, never force). It refuses a source that does not contain
318
+ `integration`'s tip. The old `--integration` mode is gone: it is an unknown
319
+ argument.
320
+ - **`.workflows/reconcile-guard.sh`** (Guard stage) fails, printing the
321
+ reconcile command (`git pull origin integration && git push`), unless the
322
+ source contains `integration`'s tip and is at `integration` + 1.
293
323
 
294
324
  ### `release.pre_bump` — multi-language version bumps
295
325
 
@@ -365,12 +395,14 @@ sed -i.bak -E "s/^const Version = \".*\"/const Version = \"${ADL_NEXT_VERSION}\"
365
395
  rm -f "${file}.bak"
366
396
  ```
367
397
 
368
- `install` copies two package-owned standalone CI assets byte-for-byte:
398
+ `install` copies four package-owned standalone CI assets byte-for-byte:
369
399
 
370
400
  - `.workflows/promote.ts`
371
401
  - `.workflows/scripts/enable-integration-auto-complete.mjs`
402
+ - `.workflows/version-bump.sh`
403
+ - `.workflows/reconcile-guard.sh`
372
404
 
373
- Both installed assets are real executable files, never symlinks. Their SHA-256
405
+ All installed assets are real executable files, never symlinks. Their SHA-256
374
406
  hashes live in `.workflows/repo-controls.lock.json`, and `doctor` fails if either
375
407
  file, mode, or lock entry drifts from the canonical source under `assets/`. A
376
408
  repository opts into promotion by declaring `[controls.release]` and wiring its
@@ -380,10 +412,11 @@ without deleting the source branch or transitioning work items — it never
380
412
  names a reviewer on the PR (Tarun, 2026-09-27: naming one, typically the
381
413
  person who pushed to `integration`, is a self-approval risk and contradicts
382
414
  "agent-reviewer is optional, never required"). Before it reads or mutates PRs,
383
- it proves the exact target branch has enabled blocking repository-wide
384
- policies for at least one approval with `resetOnSourcePush = true` (the
385
- author's own vote does not count towards it) and a build definition; a
386
- missing or weaker policy fails promotion. Pre-commit calls the same read-only
415
+ it proves the exact target branch has an enabled blocking repository-wide
416
+ policy for at least one approval with `resetOnSourcePush = true` (the
417
+ author's own vote does not count towards it); a missing or weaker policy fails
418
+ promotion. No build policy is required (Tarun, 2026-09-30: the promotion PR
419
+ runs no build; the approval is the only gate). Pre-commit calls the same read-only
387
420
  doctor report and never repairs or stages the managed surface: run
388
421
  `adl repo controls install --repo .` explicitly to repair it.
389
422
 
@@ -399,6 +432,11 @@ and `X.Y.Z` subjects exempt; no staged `.scratchpad` paths or new references;
399
432
 
400
433
  ### Managed dot paths and protected controls
401
434
 
435
+ `.workflows/` is the adl-owned managed root: every file `install` writes there,
436
+ including the scripts `.workflows/promote.ts`, `.workflows/version-bump.sh` and
437
+ `.workflows/reconcile-guard.sh`, is hashed in `.workflows/repo-controls.lock.json`
438
+ and repaired by the next `install`; do not hand-edit it.
439
+
402
440
  The pre-commit gate has two deliberately different outcomes:
403
441
 
404
442
  - A changed or untracked **top-level** dot path is **commit-required**.
@@ -30,6 +30,11 @@
30
30
  * forget the other, and the derived form satisfies this package's own
31
31
  * commit-msg grammar for any pair.
32
32
  *
33
+ * The target branch's gate is Tarun's approval alone (Tarun, 2026-09-30): the
34
+ * promotion PR gets NO build validation and CD runs after the merge into the
35
+ * target. The pre-flight proves the minimum-reviewer policy (>=1 approver,
36
+ * resetOnSourcePush=true) and does not require a build policy.
37
+ *
33
38
  * There is no default pair. Defaulting to integration -> main would put one
34
39
  * repository's branch names back into the package by the back door.
35
40
  */
@@ -192,6 +197,12 @@ export interface PromotionPolicyReceipts {
192
197
  * only counts when it is enabled, blocking, and scoped exactly to the target
193
198
  * branch; a project-wide or path-filtered lookalike cannot guard a promotion.
194
199
  *
200
+ * The target's gate is the minimum-reviewer policy ONLY (Tarun, 2026-09-30):
201
+ * the integration -> main promotion PR gets no build validation, Tarun's
202
+ * approval is the only gate, and CD runs after the merge into main. Promotion
203
+ * therefore never requires a build policy. `build` is still reported so a
204
+ * caller can show one that happens to be present, but nothing here demands it.
205
+ *
195
206
  * No named-reviewer policy is checked here, deliberately (Tarun,
196
207
  * 2026-09-27): the two-branch standard's `main` gate is "exactly one human
197
208
  * reviewer, author's vote excluded" via the minimum-reviewer policy below —
@@ -245,17 +256,11 @@ async function verifyPolicyGate(
245
256
  )
246
257
  const policies = response.value ?? []
247
258
  const receipts = promotionPolicyReceipts(policies, { repositoryId, targetRef })
248
- const { minimumReviewer, build } = receipts
249
- if (!minimumReviewer) {
259
+ if (!receipts.minimumReviewer) {
250
260
  throw new Error(
251
261
  `promotion requires an enabled blocking minimum-reviewer policy for repository ${repositoryId} ${targetRef} with minimumApproverCount>=1 and resetOnSourcePush=true`,
252
262
  )
253
263
  }
254
- if (!build) {
255
- throw new Error(
256
- `promotion requires an enabled blocking build policy for repository ${repositoryId} ${targetRef} with buildDefinitionId>0`,
257
- )
258
- }
259
264
  }
260
265
 
261
266
  export async function ensurePromotionPullRequest({
@@ -0,0 +1,113 @@
1
+ #!/usr/bin/env bash
2
+ set -euo pipefail
3
+
4
+ # Stage 2 of the CI pipeline for pull requests into `integration` (stage 1
5
+ # bumps the PR branch, stage 3 runs the tests). It forces the PR author,
6
+ # usually an agent, to reconcile with integration before anything merges:
7
+ #
8
+ # Check A the PR branch contains integration's tip
9
+ # Check B the PR branch's manifest version is integration's + 1 patch
10
+ #
11
+ # Read-only: this script only fetches and reads. It never pushes, and the only
12
+ # refs it writes are the two remote-tracking refs it fetches.
13
+ #
14
+ # Run from the package directory (like version-bump.sh); the manifest is
15
+ # resolved repo-relative through `git rev-parse --show-prefix`.
16
+
17
+ # CI never carries an agent identity. Never set one here, and never inherit
18
+ # one from the caller.
19
+ unset ADL_AGENT_ID_AGENT
20
+
21
+ die() {
22
+ echo "reconcile-guard: $*" >&2
23
+ exit 1
24
+ }
25
+
26
+ version_file=''
27
+ pr_source=''
28
+ base=''
29
+
30
+ while [[ $# -gt 0 ]]; do
31
+ case "$1" in
32
+ --file)
33
+ [[ $# -ge 2 ]] || die '--file requires a path'
34
+ version_file="$2"
35
+ shift 2
36
+ ;;
37
+ --pr-source)
38
+ [[ $# -ge 2 ]] || die '--pr-source requires a branch'
39
+ pr_source="$2"
40
+ shift 2
41
+ ;;
42
+ --base)
43
+ [[ $# -ge 2 ]] || die '--base requires a branch'
44
+ base="$2"
45
+ shift 2
46
+ ;;
47
+ *)
48
+ die "unknown argument: $1"
49
+ ;;
50
+ esac
51
+ done
52
+
53
+ [[ -n "$version_file" ]] || die '--file is required'
54
+ [[ "$version_file" == 'package.json' ]] || die 'only --file package.json is supported'
55
+ [[ -n "$pr_source" ]] || die '--pr-source is required'
56
+ [[ -n "$base" ]] || die '--base is required'
57
+
58
+ # Azure Pipelines hands over Build.SourceBranch-style refs (refs/heads/x).
59
+ branch="${pr_source#refs/heads/}"
60
+ base="${base#refs/heads/}"
61
+ [[ -n "$branch" ]] || die "--pr-source '$pr_source' names no branch"
62
+
63
+ case "$branch" in
64
+ main | integration | "$base")
65
+ die "refusing to guard '$branch': it is not a pull-request source branch"
66
+ ;;
67
+ esac
68
+
69
+ prefix="$(git rev-parse --show-prefix)" || die 'not inside a git work tree'
70
+ manifest_path="${prefix}${version_file}"
71
+
72
+ base_ref="refs/remotes/origin/$base"
73
+ branch_ref="refs/remotes/origin/$branch"
74
+
75
+ git fetch --quiet origin "+refs/heads/$base:$base_ref" || die "cannot fetch origin $base"
76
+ git fetch --quiet origin "+refs/heads/$branch:$branch_ref" || die "cannot fetch origin $branch"
77
+
78
+ base_short="$(git rev-parse --short "$base_ref")" || die "cannot resolve $base_ref"
79
+
80
+ if ! git merge-base --is-ancestor "$base_ref" "$branch_ref"; then
81
+ missing="$(git rev-list --count "$branch_ref..$base_ref")"
82
+ echo "reconcile-guard: FAIL — $branch does not contain integration's tip ($base_short). Reconcile: git pull origin integration && git push" >&2
83
+ echo "reconcile-guard: $missing integration commit(s) missing from $branch" >&2
84
+ exit 1
85
+ fi
86
+
87
+ read_version() {
88
+ local ref="$1" text
89
+ text="$(git show "$ref:$manifest_path")" || die "cannot read $manifest_path at $ref"
90
+ printf '%s' "$text" | node -e '
91
+ const version = JSON.parse(require("node:fs").readFileSync(0, "utf8")).version
92
+ if (typeof version !== "string") process.exit(1)
93
+ process.stdout.write(version)
94
+ ' || die "cannot read a string version from $manifest_path at $ref"
95
+ }
96
+
97
+ parse_version() {
98
+ [[ "$1" =~ ^([0-9]+)\.([0-9]+)\.([0-9]+)$ ]] || die "cannot parse '$1' as a release version x.y.z"
99
+ }
100
+
101
+ base_version="$(read_version "$base_ref")"
102
+ branch_version="$(read_version "$branch_ref")"
103
+
104
+ parse_version "$base_version"
105
+ target="${BASH_REMATCH[1]}.${BASH_REMATCH[2]}.$((BASH_REMATCH[3] + 1))"
106
+ parse_version "$branch_version"
107
+
108
+ if [[ "$branch_version" != "$target" ]]; then
109
+ echo "reconcile-guard: FAIL — $branch version $branch_version is not integration $base_version + 1 patch ($target). Stage 1 bumps it; re-run the PR build after reconciling." >&2
110
+ exit 1
111
+ fi
112
+
113
+ echo "reconcile-guard: PASS — $branch contains integration $base_short and is at $target"