@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.
- package/bin/adl +2 -2
- package/bin/git +89 -8
- package/package.json +1 -1
- package/packages/agent-hooks/src/hooks/crg/background.ts +179 -0
- package/packages/agent-hooks/src/hooks/crg/handler.ts +2 -1
- package/packages/agent-hooks/src/hooks/crg/index.ts +36 -5
- package/packages/agent-hooks/src/rules/impl/git-paths.ts +5 -6
- package/packages/agent-hooks/src/rules/impl/tool-grep.ts +8 -8
- package/packages/azdo/src/azdo-repo-doctor.ts +79 -77
- package/packages/repo-controls/README.md +46 -8
- package/packages/repo-controls/assets/promote.ts +12 -7
- package/packages/repo-controls/assets/reconcile-guard.sh +113 -0
- package/packages/repo-controls/assets/version-bump.sh +96 -51
- package/packages/repo-controls/src/azure-devops-governance.ts +17 -16
- package/packages/repo-controls/src/constants.ts +8 -0
- package/packages/repo-controls/src/hook.ts +3 -2
- package/packages/repo-controls/src/integrity.ts +15 -1
|
@@ -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
|
|
89
|
-
*
|
|
90
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
*
|
|
218
|
-
*
|
|
219
|
-
*
|
|
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", "
|
|
248
|
+
repo: { allow: ["genericContribute", "pullRequestContribute"] } as {
|
|
223
249
|
allow: readonly PermissionBitName[];
|
|
224
250
|
},
|
|
225
|
-
integrationRef: {
|
|
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
|
|
231
|
-
*
|
|
232
|
-
*
|
|
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
|
|
235
|
-
*
|
|
236
|
-
*
|
|
237
|
-
*
|
|
238
|
-
*
|
|
239
|
-
*
|
|
240
|
-
*
|
|
241
|
-
* `
|
|
242
|
-
* `
|
|
243
|
-
*
|
|
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
|
-
|
|
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 (
|
|
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,
|
|
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
|
|
820
|
-
*
|
|
821
|
-
*
|
|
822
|
-
*
|
|
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
|
-
|
|
882
|
-
|
|
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.
|
|
885
|
-
message: `the project build service
|
|
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
|
|
970
|
-
*
|
|
971
|
-
*
|
|
972
|
-
* buildServiceRelease's own doc comment
|
|
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
|
|
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,
|
|
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,
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
385
|
-
author's own vote does not count towards it)
|
|
386
|
-
|
|
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
|
-
|
|
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"
|