@amsterdamdatalabs/adl-agent-control-linux-x64 0.1.9 → 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/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 +70 -75
- 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 +91 -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
package/bin/adl
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
1
|
[diffend] Oversized file quarantined before diffing.
|
|
2
2
|
name: package/bin/adl
|
|
3
|
-
size:
|
|
4
|
-
sha256:
|
|
3
|
+
size: 82925024 bytes
|
|
4
|
+
sha256: bf72c4f43099dc33afd161d5e8205d2fbfc5607e4b3aafac4ac0644224b75337
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@amsterdamdatalabs/adl-agent-control-linux-x64",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.10",
|
|
4
4
|
"description": "adl CLI binary for linux/x64 (agent identity + Azure DevOps front door — see docs/architecture/cli-design.md)",
|
|
5
5
|
"license": "UNLICENSED",
|
|
6
6
|
"os": [
|
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Single-flight, detached `code-review-graph update --skip-flows`.
|
|
3
|
+
*
|
|
4
|
+
* Why this exists: on a large repo `update` takes ~13 s, but the
|
|
5
|
+
* `PostToolUse` hook that triggers it is killed by the host at 5 s
|
|
6
|
+
* (`assets/hooks.json`, `"timeout": 5`), so a synchronous run never
|
|
7
|
+
* finishes and the graph goes stale on every edit. The hook therefore only
|
|
8
|
+
* DECIDES here and returns at once; the work runs in a detached process
|
|
9
|
+
* group that outlives the hook.
|
|
10
|
+
*
|
|
11
|
+
* Protocol (all state under `<root>/.adl/`, already git-ignored):
|
|
12
|
+
* - `crg-update.lock` holds the pid of the process running the update.
|
|
13
|
+
* - `crg-update.pending` marker: "an edit landed while an update ran".
|
|
14
|
+
* - `logs/crg-update.log` the update's own stdout+stderr (append).
|
|
15
|
+
*
|
|
16
|
+
* `requestUpdate` (the hook side):
|
|
17
|
+
* 1. Acquire the lock atomically (`wx`), holding the hook's own pid as a
|
|
18
|
+
* placeholder so a concurrent hook sees a live owner, then spawn the
|
|
19
|
+
* runner and overwrite the lock with the runner's pid.
|
|
20
|
+
* 2. Lock already held by a live process (and younger than
|
|
21
|
+
* `MAX_LOCK_AGE_MS`, a guard against pid reuse after a crash/reboot):
|
|
22
|
+
* touch the pending marker and spawn nothing. A dead pid, or an
|
|
23
|
+
* over-age lock, is stale: it is removed and step 1 retried once.
|
|
24
|
+
* 3. After touching the marker, re-check the owner: if it exited in the
|
|
25
|
+
* window between the first check and the marker write, acquire and
|
|
26
|
+
* spawn instead, so the queued edit is never stranded.
|
|
27
|
+
*
|
|
28
|
+
* `RUNNER_SCRIPT` (the detached side) runs the update, and while the
|
|
29
|
+
* pending marker exists clears it and runs once more. On its way out it
|
|
30
|
+
* releases the lock, then re-checks the marker once and re-acquires the
|
|
31
|
+
* lock (noclobber) if a hook queued in the meantime, closing the last gap.
|
|
32
|
+
*/
|
|
33
|
+
|
|
34
|
+
import { closeSync, mkdirSync, openSync, readFileSync, rmSync, statSync, writeFileSync } from 'node:fs'
|
|
35
|
+
import { join } from 'node:path'
|
|
36
|
+
|
|
37
|
+
export const LOCK_REL = '.adl/crg-update.lock'
|
|
38
|
+
export const PENDING_REL = '.adl/crg-update.pending'
|
|
39
|
+
export const UPDATE_LOG_REL = '.adl/logs/crg-update.log'
|
|
40
|
+
|
|
41
|
+
/** A lock older than this is presumed left by a crash, whatever its pid says. */
|
|
42
|
+
export const MAX_LOCK_AGE_MS = 30 * 60 * 1000
|
|
43
|
+
|
|
44
|
+
export const RUNNER_SCRIPT = [
|
|
45
|
+
'lock="$1"; pending="$2"; shift 2',
|
|
46
|
+
'while :; do',
|
|
47
|
+
' rm -f "$pending"',
|
|
48
|
+
' "$@"',
|
|
49
|
+
' if [ -e "$pending" ]; then continue; fi',
|
|
50
|
+
' rm -f "$lock"',
|
|
51
|
+
' if [ -e "$pending" ] && (set -C; echo $$ > "$lock") 2>/dev/null; then continue; fi',
|
|
52
|
+
' break',
|
|
53
|
+
'done',
|
|
54
|
+
].join('\n')
|
|
55
|
+
|
|
56
|
+
export interface SpawnSpec {
|
|
57
|
+
readonly cmd: string
|
|
58
|
+
readonly args: readonly string[]
|
|
59
|
+
readonly cwd: string
|
|
60
|
+
readonly env: Readonly<Record<string, string | undefined>>
|
|
61
|
+
/** Absolute path the child's stdout+stderr are appended to. */
|
|
62
|
+
readonly logFile: string
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export interface BackgroundDeps {
|
|
66
|
+
/** Start a detached, unref'd child; return its pid. Must not wait for it. */
|
|
67
|
+
readonly spawn: (spec: SpawnSpec) => number
|
|
68
|
+
readonly isAlive: (pid: number) => boolean
|
|
69
|
+
readonly now: () => number
|
|
70
|
+
readonly selfPid: () => number
|
|
71
|
+
/** Resolve `bin` against `path` (the hook's own PATH); `null` when it is not there. */
|
|
72
|
+
readonly resolve: (bin: string, path: string | undefined) => string | null
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export type UpdateRequest = 'spawned' | 'queued'
|
|
76
|
+
|
|
77
|
+
const UPDATE_ARGS = ['update', '--skip-flows'] as const
|
|
78
|
+
|
|
79
|
+
function isPidAlive(pid: number): boolean {
|
|
80
|
+
try {
|
|
81
|
+
process.kill(pid, 0)
|
|
82
|
+
return true
|
|
83
|
+
} catch (error) {
|
|
84
|
+
// EPERM: the process exists but belongs to someone else.
|
|
85
|
+
return (error as NodeJS.ErrnoException).code === 'EPERM'
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function spawnDetached(spec: SpawnSpec): number {
|
|
90
|
+
const fd = openSync(spec.logFile, 'a', 0o600)
|
|
91
|
+
try {
|
|
92
|
+
const proc = Bun.spawn([spec.cmd, ...spec.args], {
|
|
93
|
+
cwd: spec.cwd,
|
|
94
|
+
env: spec.env as Record<string, string>,
|
|
95
|
+
stdin: 'ignore',
|
|
96
|
+
stdout: fd,
|
|
97
|
+
stderr: fd,
|
|
98
|
+
// Own session/process group: survives the hook, unreachable by the host's timeout kill.
|
|
99
|
+
detached: true,
|
|
100
|
+
})
|
|
101
|
+
proc.unref()
|
|
102
|
+
return proc.pid
|
|
103
|
+
} finally {
|
|
104
|
+
closeSync(fd)
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
export const defaultBackgroundDeps: BackgroundDeps = {
|
|
109
|
+
spawn: spawnDetached,
|
|
110
|
+
isAlive: isPidAlive,
|
|
111
|
+
now: () => Date.now(),
|
|
112
|
+
selfPid: () => process.pid,
|
|
113
|
+
resolve: (bin, path) => Bun.which(bin, { PATH: path ?? '' }),
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function ownerIsLive(deps: BackgroundDeps, lockPath: string): boolean {
|
|
117
|
+
try {
|
|
118
|
+
const pid = Number.parseInt(readFileSync(lockPath, 'utf8').trim(), 10)
|
|
119
|
+
const ageMs = deps.now() - statSync(lockPath).mtimeMs
|
|
120
|
+
return Number.isInteger(pid) && pid > 0 && ageMs < MAX_LOCK_AGE_MS && deps.isAlive(pid)
|
|
121
|
+
} catch {
|
|
122
|
+
// Vanished between the caller's check and this read: nobody owns it.
|
|
123
|
+
return false
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/** `wx` create: true if this call created the lock, false if it already exists. */
|
|
128
|
+
function tryAcquire(lockPath: string, pid: number): boolean {
|
|
129
|
+
try {
|
|
130
|
+
writeFileSync(lockPath, `${pid}\n`, { flag: 'wx', mode: 0o600 })
|
|
131
|
+
return true
|
|
132
|
+
} catch (error) {
|
|
133
|
+
if ((error as NodeJS.ErrnoException).code === 'EEXIST') return false
|
|
134
|
+
throw error
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
function startRunner(deps: BackgroundDeps, root: string, env: SpawnSpec['env'], lockPath: string, pendingPath: string): void {
|
|
139
|
+
const pid = deps.spawn({
|
|
140
|
+
cmd: 'sh',
|
|
141
|
+
args: ['-c', RUNNER_SCRIPT, 'sh', lockPath, pendingPath, 'code-review-graph', ...UPDATE_ARGS],
|
|
142
|
+
cwd: root,
|
|
143
|
+
env,
|
|
144
|
+
logFile: join(root, UPDATE_LOG_REL),
|
|
145
|
+
})
|
|
146
|
+
writeFileSync(lockPath, `${pid}\n`, { mode: 0o600 })
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/** Acquire + spawn. On a spawn failure the placeholder lock is released so the next edit can retry. */
|
|
150
|
+
function acquireAndStart(deps: BackgroundDeps, root: string, env: SpawnSpec['env'], lockPath: string, pendingPath: string): boolean {
|
|
151
|
+
if (!tryAcquire(lockPath, deps.selfPid())) return false
|
|
152
|
+
try {
|
|
153
|
+
startRunner(deps, root, env, lockPath, pendingPath)
|
|
154
|
+
} catch (error) {
|
|
155
|
+
rmSync(lockPath, { force: true })
|
|
156
|
+
throw error
|
|
157
|
+
}
|
|
158
|
+
return true
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
export function requestUpdate(deps: BackgroundDeps, root: string, env: SpawnSpec['env']): UpdateRequest {
|
|
162
|
+
mkdirSync(join(root, '.adl', 'logs'), { recursive: true, mode: 0o700 })
|
|
163
|
+
const lockPath = join(root, LOCK_REL)
|
|
164
|
+
const pendingPath = join(root, PENDING_REL)
|
|
165
|
+
|
|
166
|
+
if (acquireAndStart(deps, root, env, lockPath, pendingPath)) return 'spawned'
|
|
167
|
+
if (!ownerIsLive(deps, lockPath)) {
|
|
168
|
+
rmSync(lockPath, { force: true })
|
|
169
|
+
if (acquireAndStart(deps, root, env, lockPath, pendingPath)) return 'spawned'
|
|
170
|
+
}
|
|
171
|
+
writeFileSync(pendingPath, '', { mode: 0o600 })
|
|
172
|
+
// The owner may have exited between the check and the marker write.
|
|
173
|
+
if (!ownerIsLive(deps, lockPath)) {
|
|
174
|
+
rmSync(lockPath, { force: true })
|
|
175
|
+
// The new runner clears the marker as its first step, so the queued edit is covered.
|
|
176
|
+
if (acquireAndStart(deps, root, env, lockPath, pendingPath)) return 'spawned'
|
|
177
|
+
}
|
|
178
|
+
return 'queued'
|
|
179
|
+
}
|
|
@@ -13,9 +13,10 @@
|
|
|
13
13
|
*/
|
|
14
14
|
|
|
15
15
|
import type { HookInvocation, HookResult } from '../contract.ts'
|
|
16
|
+
import { defaultBackgroundDeps } from './background.ts'
|
|
16
17
|
import { bunRunner, createCrgHandler } from './index.ts'
|
|
17
18
|
|
|
18
|
-
const crgHandler = createCrgHandler({ run: bunRunner })
|
|
19
|
+
const crgHandler = createCrgHandler({ run: bunRunner, background: defaultBackgroundDeps })
|
|
19
20
|
|
|
20
21
|
export async function handleCrg(inv: HookInvocation): Promise<HookResult> {
|
|
21
22
|
return crgHandler(inv)
|
|
@@ -47,6 +47,7 @@
|
|
|
47
47
|
|
|
48
48
|
import type { HookHandler, HookInvocation, HookResult } from '../contract.ts'
|
|
49
49
|
import { spawnWithTimeout } from '../../lib/spawn-with-timeout.ts'
|
|
50
|
+
import { defaultBackgroundDeps, requestUpdate, type BackgroundDeps } from './background.ts'
|
|
50
51
|
import { toDecideInput } from '../../pre-tool-use/handler.ts'
|
|
51
52
|
import { invocations } from '../../rules/shell/invocation.ts'
|
|
52
53
|
|
|
@@ -99,8 +100,38 @@ function missingBinaryNote(subcommand: string, result: RunResult): string {
|
|
|
99
100
|
return `crg ${subcommand}: ${BIN} ${reason}\n`
|
|
100
101
|
}
|
|
101
102
|
|
|
102
|
-
|
|
103
|
-
|
|
103
|
+
const UPDATE_LABEL = 'code-review-graph update --skip-flows'
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* `after-file-edit.sh`: `update --skip-flows`, stdout `{}`, always exit 0.
|
|
107
|
+
*
|
|
108
|
+
* With `background` (production wiring) the update is spawned DETACHED and
|
|
109
|
+
* single-flight (see `./background.ts`) and the hook returns at once: the
|
|
110
|
+
* update takes longer than the host's 5 s hook timeout on a large repo, so a
|
|
111
|
+
* synchronous run was killed every time and the graph never refreshed.
|
|
112
|
+
* Without it (unit tests of the plain runner path) the update runs inline.
|
|
113
|
+
*/
|
|
114
|
+
async function afterFileEdit(run: Runner, inv: HookInvocation, background: BackgroundDeps | undefined): Promise<HookResult> {
|
|
115
|
+
if (background !== undefined) {
|
|
116
|
+
// Fail loud up front: the detached runner's own "command not found" would land only in crg-update.log.
|
|
117
|
+
if (background.resolve(BIN, inv.env.PATH) === null) {
|
|
118
|
+
const note = missingBinaryNote('after-file-edit', { status: null, stdout: '', stderr: '' })
|
|
119
|
+
inv.record?.({ reason: `ERROR ${BIN} not on PATH`, message: note.trim() })
|
|
120
|
+
return { stdout: '{}\n', stderr: note, exitCode: 0 }
|
|
121
|
+
}
|
|
122
|
+
try {
|
|
123
|
+
const outcome = requestUpdate(background, inv.root, inv.env)
|
|
124
|
+
inv.record?.({
|
|
125
|
+
reason: 'PASS',
|
|
126
|
+
downstream: outcome === 'spawned' ? `spawned ${UPDATE_LABEL} (background)` : 'update already running; queued',
|
|
127
|
+
})
|
|
128
|
+
return { stdout: '{}\n', stderr: '', exitCode: 0 }
|
|
129
|
+
} catch (error) {
|
|
130
|
+
const message = error instanceof Error ? error.message : String(error)
|
|
131
|
+
inv.record?.({ reason: `ERROR background ${UPDATE_LABEL}`, message })
|
|
132
|
+
return { stdout: '{}\n', stderr: `crg after-file-edit: could not start background update: ${message}\n`, exitCode: 0 }
|
|
133
|
+
}
|
|
134
|
+
}
|
|
104
135
|
const result = await runCrg(run, inv, ['update', '--skip-flows'])
|
|
105
136
|
return { stdout: '{}\n', stderr: missingBinaryNote('after-file-edit', result), exitCode: 0 }
|
|
106
137
|
}
|
|
@@ -156,11 +187,11 @@ async function sessionStop(run: Runner, inv: HookInvocation): Promise<HookResult
|
|
|
156
187
|
return { stdout: `${JSON.stringify({ message, passed: true })}\n`, stderr: note, exitCode: 0 }
|
|
157
188
|
}
|
|
158
189
|
|
|
159
|
-
export function createCrgHandler(deps: { run: Runner }): HookHandler {
|
|
190
|
+
export function createCrgHandler(deps: { run: Runner; background?: BackgroundDeps }): HookHandler {
|
|
160
191
|
return async (inv: HookInvocation): Promise<HookResult> => {
|
|
161
192
|
switch (inv.subcommand) {
|
|
162
193
|
case 'after-file-edit':
|
|
163
|
-
return afterFileEdit(deps.run, inv)
|
|
194
|
+
return afterFileEdit(deps.run, inv, deps.background)
|
|
164
195
|
case 'pre-commit':
|
|
165
196
|
return preCommit(deps.run, inv)
|
|
166
197
|
case 'session-stop':
|
|
@@ -205,5 +236,5 @@ export const bunRunner: Runner = async (cmd, args, opts) => {
|
|
|
205
236
|
}
|
|
206
237
|
}
|
|
207
238
|
|
|
208
|
-
export const handleCrg: HookHandler = createCrgHandler({ run: bunRunner })
|
|
239
|
+
export const handleCrg: HookHandler = createCrgHandler({ run: bunRunner, background: defaultBackgroundDeps })
|
|
209
240
|
export default handleCrg
|
|
@@ -31,15 +31,14 @@ export const gitRm: RuleSpec = {
|
|
|
31
31
|
},
|
|
32
32
|
message(_finding: Finding, ctx: MessageContext) {
|
|
33
33
|
return {
|
|
34
|
-
user: 'Blocked git rm. It deletes tracked content and stages the deletion in one step; agents
|
|
34
|
+
user: 'Blocked git rm. It deletes tracked content and stages the deletion in one step; agents remove a tracked file with trash instead.',
|
|
35
35
|
agent:
|
|
36
36
|
'DENIED git rm. Every form is refused - with flags or without, --cached included. `git rm` deletes ' +
|
|
37
37
|
'tracked content AND stages the deletion in one step, so it is both the delete and the commit-prep ' +
|
|
38
|
-
'for it
|
|
39
|
-
'
|
|
40
|
-
'
|
|
41
|
-
|
|
42
|
-
`that already applies to committing a protected tree. ${gitAgentTail(ctx.snippet)}`,
|
|
38
|
+
'for it. There is no flag that makes it acceptable and no `git rm` that this hook allows. ' +
|
|
39
|
+
'If a tracked file genuinely has to go, run `trash <path>` (recoverable from ~/.Trash; fs.trash only ' +
|
|
40
|
+
'refuses the working directory, a system root, $HOME and .git), then `git add <path>` to stage the ' +
|
|
41
|
+
`deletion with the rest of the change. ${gitAgentTail(ctx.snippet)}`,
|
|
43
42
|
}
|
|
44
43
|
},
|
|
45
44
|
corpus: {
|
|
@@ -37,7 +37,7 @@ export const toolGrep: RuleSpec = {
|
|
|
37
37
|
category: 'tool',
|
|
38
38
|
severity: 'deny',
|
|
39
39
|
enabledByDefault: true,
|
|
40
|
-
summary: 'Deny the grep family; steer at
|
|
40
|
+
summary: 'Deny the grep family; steer at ast-grep / search / crg / rg / ctx_search.',
|
|
41
41
|
params: noParams,
|
|
42
42
|
match(inv: Invocation): Finding | null {
|
|
43
43
|
if (inv.kind !== 'grep') return null
|
|
@@ -45,15 +45,15 @@ export const toolGrep: RuleSpec = {
|
|
|
45
45
|
},
|
|
46
46
|
message(_finding: Finding, ctx: MessageContext) {
|
|
47
47
|
return {
|
|
48
|
-
user: 'Blocked grep.
|
|
48
|
+
user: 'Blocked grep. Pick the tool for the question: `ast-grep` for code shapes, `search` for docs by meaning, `crg` for callers and impact, `rg` for literal text, `ctx_search` for the indexed code search.',
|
|
49
49
|
agent:
|
|
50
|
-
'DENIED grep.
|
|
51
|
-
'
|
|
52
|
-
'
|
|
53
|
-
'
|
|
50
|
+
'DENIED grep. Match the tool to the question - in this order, all on PATH and already allowed:\n' +
|
|
51
|
+
' ast-grep code STRUCTURE: shapes rather than strings (`ast-grep -p "foo($A)" -l ts`)\n' +
|
|
52
|
+
' search docs and plans by MEANING: hybrid semantic + keyword (`search query "<text>"`)\n' +
|
|
53
|
+
' crg the code GRAPH: callers, callees, impact (`crg query callers_of <fn>`, `crg impact`)\n' +
|
|
54
|
+
' rg LITERAL text or regex, including stdin: `npm test 2>&1 | rg "pass|fail"`\n' +
|
|
54
55
|
' ctx_search the indexed code search (action=symbol for an exact symbol)\n' +
|
|
55
|
-
'
|
|
56
|
-
'so there is no capability being removed here, only a slower tool. ' +
|
|
56
|
+
'A pure output filter is no exception: `rg` reads stdin exactly as grep does. ' +
|
|
57
57
|
`Blocked command: ${ctx.snippet}`,
|
|
58
58
|
}
|
|
59
59
|
},
|
|
@@ -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
|
|
@@ -216,9 +231,13 @@ export const REPO_STANDARD = {
|
|
|
216
231
|
* — Tarun, 2026-09-27, cross-checked against
|
|
217
232
|
* /Users/tarunrana/Documents/ADL-ISMS-Policies/.scratchpad/ci_build_service_grant.py,
|
|
218
233
|
* which grants exactly these bits on adl-agent-control). CI needs to push
|
|
219
|
-
* and complete PRs on the repo
|
|
220
|
-
*
|
|
221
|
-
*
|
|
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).
|
|
222
241
|
*
|
|
223
242
|
* NOT forcePush (Tarun, 2026-09-30): the bump is a plain fast-forward
|
|
224
243
|
* `git push origin HEAD:integration`; agent-identities' build service has
|
|
@@ -229,33 +248,28 @@ export const REPO_STANDARD = {
|
|
|
229
248
|
repo: { allow: ["genericContribute", "pullRequestContribute"] } as {
|
|
230
249
|
allow: readonly PermissionBitName[];
|
|
231
250
|
},
|
|
232
|
-
integrationRef: {
|
|
251
|
+
integrationRef: { deny: ["policyExempt"] } as { deny: readonly PermissionBitName[] },
|
|
233
252
|
mainRef: { deny: ["policyExempt"] } as { deny: readonly PermissionBitName[] },
|
|
234
253
|
},
|
|
235
254
|
/**
|
|
236
255
|
* checkBuildServiceCanRelease() (file header, "checkBuildServiceCanRelease()"):
|
|
237
|
-
* the
|
|
238
|
-
*
|
|
239
|
-
*
|
|
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`.
|
|
240
259
|
*
|
|
241
|
-
* `policyExempt` ("Bypass policies when pushing") is
|
|
242
|
-
*
|
|
243
|
-
*
|
|
244
|
-
*
|
|
245
|
-
*
|
|
246
|
-
*
|
|
247
|
-
*
|
|
248
|
-
* `
|
|
249
|
-
* `
|
|
250
|
-
*
|
|
251
|
-
* contradicted this observed reality. `genericContribute` and
|
|
252
|
-
* `pullRequestContribute` stay checked at repo scope — both bits ARE
|
|
253
|
-
* present there for real, and PR operations are a repo-level, not
|
|
254
|
-
* 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.
|
|
255
270
|
*/
|
|
256
271
|
buildServiceRelease: {
|
|
257
272
|
repoScoped: ["genericContribute", "pullRequestContribute"] as const,
|
|
258
|
-
integrationRefScoped: ["policyExempt"] as const,
|
|
259
273
|
},
|
|
260
274
|
},
|
|
261
275
|
} as const;
|
|
@@ -518,11 +532,7 @@ function checkMainMergeStrategy(policies: readonly PolicyConfiguration[], drifts
|
|
|
518
532
|
});
|
|
519
533
|
}
|
|
520
534
|
|
|
521
|
-
function checkMainCommentsAndBuild(
|
|
522
|
-
policies: readonly PolicyConfiguration[],
|
|
523
|
-
hasPipeline: boolean,
|
|
524
|
-
drifts: RepoDoctorDrift[],
|
|
525
|
-
): void {
|
|
535
|
+
function checkMainCommentsAndBuild(policies: readonly PolicyConfiguration[], drifts: RepoDoctorDrift[]): void {
|
|
526
536
|
const comments = enabledPolicyOfType(policies, POLICY_TYPE.commentRequirements);
|
|
527
537
|
if (!comments) {
|
|
528
538
|
drifts.push({
|
|
@@ -531,21 +541,27 @@ function checkMainCommentsAndBuild(
|
|
|
531
541
|
});
|
|
532
542
|
}
|
|
533
543
|
|
|
534
|
-
|
|
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).
|
|
535
547
|
const build = enabledPolicyOfType(policies, POLICY_TYPE.build);
|
|
536
|
-
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
|
+
}
|
|
537
554
|
}
|
|
538
555
|
|
|
539
556
|
function checkMainPolicies(
|
|
540
557
|
policies: readonly PolicyConfiguration[],
|
|
541
|
-
hasPipeline: boolean,
|
|
542
558
|
strictMainBranchRules: boolean,
|
|
543
559
|
drifts: RepoDoctorDrift[],
|
|
544
560
|
): void {
|
|
545
561
|
checkMainRequiredReviewer(policies, drifts);
|
|
546
562
|
checkMainMinimumReviewers(policies, strictMainBranchRules, drifts);
|
|
547
563
|
checkMainMergeStrategy(policies, drifts);
|
|
548
|
-
checkMainCommentsAndBuild(policies,
|
|
564
|
+
checkMainCommentsAndBuild(policies, drifts);
|
|
549
565
|
}
|
|
550
566
|
|
|
551
567
|
/**
|
|
@@ -823,10 +839,11 @@ async function resolveBuildServiceDescriptor(namespaceId: string, projectId: str
|
|
|
823
839
|
}
|
|
824
840
|
|
|
825
841
|
/**
|
|
826
|
-
* CI needs to push
|
|
827
|
-
*
|
|
828
|
-
*
|
|
829
|
-
*
|
|
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).
|
|
830
847
|
*/
|
|
831
848
|
async function checkBuildServicePermissions(
|
|
832
849
|
namespaceId: string,
|
|
@@ -885,11 +902,14 @@ async function checkBuildServicePermissions(
|
|
|
885
902
|
drifts,
|
|
886
903
|
);
|
|
887
904
|
if (integrationEffective) {
|
|
888
|
-
|
|
889
|
-
|
|
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) {
|
|
890
910
|
drifts.push({
|
|
891
|
-
id: `permissions.build-service.
|
|
892
|
-
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)`,
|
|
893
913
|
});
|
|
894
914
|
}
|
|
895
915
|
}
|
|
@@ -941,16 +961,6 @@ function releaseFixPath(repository: string, identityDisplayName: string, bitName
|
|
|
941
961
|
return `Project settings → Repositories → \`${repository}\` → Security → \`${identityDisplayName}\` → \`${GIT_REPO_PERMISSION_UI_LABEL[bitName]}\` = Allow`;
|
|
942
962
|
}
|
|
943
963
|
|
|
944
|
-
/** 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). */
|
|
945
|
-
function releaseFixPathForRef(
|
|
946
|
-
repository: string,
|
|
947
|
-
ref: string,
|
|
948
|
-
identityDisplayName: string,
|
|
949
|
-
bitName: PermissionBitName,
|
|
950
|
-
): string {
|
|
951
|
-
return `Repos → \`${repository}\` → Branches → \`${ref}\` → ⋯ → Branch security → \`${identityDisplayName}\` → \`${GIT_REPO_PERMISSION_UI_LABEL[bitName]}\` = Allow`;
|
|
952
|
-
}
|
|
953
|
-
|
|
954
964
|
/**
|
|
955
965
|
* Which build service identity actually runs the pipeline, per Build —
|
|
956
966
|
* General Settings — Get's `enforceJobAuthScope` (file header) — "Limit job
|
|
@@ -973,12 +983,10 @@ async function fetchEnforceJobAuthScope(c: AdoClient): Promise<boolean | undefin
|
|
|
973
983
|
/**
|
|
974
984
|
* One identity's answer to "can it release": resolve its descriptor by exact
|
|
975
985
|
* display name (Identities — Read Identities, file header), then check
|
|
976
|
-
* `buildServiceRelease.repoScoped` on the repository token
|
|
977
|
-
*
|
|
978
|
-
*
|
|
979
|
-
* buildServiceRelease's own doc comment
|
|
980
|
-
* in this org, verified live against real bypass-on-push evidence, and
|
|
981
|
-
* 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
|
|
982
990
|
* failure mode is its own loud, distinct drift — an unresolvable identity,
|
|
983
991
|
* an ACL the calling agent cannot read, and each missing permission named
|
|
984
992
|
* with its fix path — never a silent pass.
|
|
@@ -986,7 +994,6 @@ async function fetchEnforceJobAuthScope(c: AdoClient): Promise<boolean | undefin
|
|
|
986
994
|
async function checkOneBuildServiceCanRelease(
|
|
987
995
|
namespaceId: string,
|
|
988
996
|
repoToken: string,
|
|
989
|
-
integrationRefToken: string,
|
|
990
997
|
repository: string,
|
|
991
998
|
displayName: string,
|
|
992
999
|
drifts: RepoDoctorDrift[],
|
|
@@ -1013,8 +1020,7 @@ async function checkOneBuildServiceCanRelease(
|
|
|
1013
1020
|
}
|
|
1014
1021
|
|
|
1015
1022
|
// LOW, independent review 2026-09-30: name WHICH token failed — "the ACL"
|
|
1016
|
-
// alone left a human unable to tell
|
|
1017
|
-
// 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.
|
|
1018
1024
|
async function effectiveAt(token: string, tokenLabel: string): Promise<{ allow: number; deny: number } | undefined> {
|
|
1019
1025
|
let effective: EffectivePermissionsResult;
|
|
1020
1026
|
try {
|
|
@@ -1039,16 +1045,6 @@ async function checkOneBuildServiceCanRelease(
|
|
|
1039
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)}`,
|
|
1040
1046
|
});
|
|
1041
1047
|
}
|
|
1042
|
-
|
|
1043
|
-
const integrationEffective = await effectiveAt(integrationRefToken, "refs/heads/integration");
|
|
1044
|
-
if (!integrationEffective) return;
|
|
1045
|
-
for (const bitName of REPO_STANDARD.permissions.buildServiceRelease.integrationRefScoped) {
|
|
1046
|
-
if ((effectiveAllowBits(integrationEffective) & GIT_REPO_PERMISSION_BIT[bitName]) !== 0) continue;
|
|
1047
|
-
drifts.push({
|
|
1048
|
-
id: `permissions.release.${slug}.missing-${bitName}`,
|
|
1049
|
-
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)}`,
|
|
1050
|
-
});
|
|
1051
|
-
}
|
|
1052
1048
|
}
|
|
1053
1049
|
|
|
1054
1050
|
/**
|
|
@@ -1079,9 +1075,8 @@ async function checkBuildServiceCanRelease(
|
|
|
1079
1075
|
: [projectScoped, collectionScoped];
|
|
1080
1076
|
|
|
1081
1077
|
const repoToken = `repoV2/${projectId}/${repositoryId}`;
|
|
1082
|
-
const integrationRefToken = `${repoToken}/refs/heads/${refNameHexUtf16Le("integration")}`;
|
|
1083
1078
|
for (const displayName of displayNames) {
|
|
1084
|
-
await checkOneBuildServiceCanRelease(namespaceId, repoToken,
|
|
1079
|
+
await checkOneBuildServiceCanRelease(namespaceId, repoToken, repository, displayName, drifts);
|
|
1085
1080
|
}
|
|
1086
1081
|
}
|
|
1087
1082
|
|
|
@@ -1140,7 +1135,7 @@ export async function repoDoctorCheck(options: RepoDoctorOptions = {}): Promise<
|
|
|
1140
1135
|
const integrationPolicies = await fetchBranchPolicies(c, repositoryId, "integration");
|
|
1141
1136
|
const mainPolicies = await fetchBranchPolicies(c, repositoryId, "main");
|
|
1142
1137
|
checkIntegrationPolicies(integrationPolicies, hasPipeline, drifts);
|
|
1143
|
-
checkMainPolicies(mainPolicies,
|
|
1138
|
+
checkMainPolicies(mainPolicies, strictMainBranchRules, drifts);
|
|
1144
1139
|
|
|
1145
1140
|
await checkAgentPermissions(projectId, repositoryId, strictMainBranchRules, drifts);
|
|
1146
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"
|
|
@@ -1,13 +1,22 @@
|
|
|
1
1
|
#!/usr/bin/env bash
|
|
2
2
|
set -euo pipefail
|
|
3
3
|
|
|
4
|
-
#
|
|
5
|
-
#
|
|
6
|
-
#
|
|
7
|
-
#
|
|
8
|
-
#
|
|
9
|
-
#
|
|
10
|
-
#
|
|
4
|
+
# STAGE 1 of the PR pipeline (PR -> integration): the PR build owns package
|
|
5
|
+
# version allocation. Every PR into `integration` must carry exactly
|
|
6
|
+
# `<integration version> + patch 1`; this script makes it so, on the PR's
|
|
7
|
+
# SOURCE branch, before anything else validates it:
|
|
8
|
+
#
|
|
9
|
+
# version-bump.sh --file package.json --pr-source <feature branch> \
|
|
10
|
+
# --base integration --push
|
|
11
|
+
#
|
|
12
|
+
# The Azure Pipelines PR build checks out the detached refs/pull/N/merge
|
|
13
|
+
# commit with persisted credentials, so `git push` authenticates as the
|
|
14
|
+
# project build service. The bump commit is created on the source branch's own
|
|
15
|
+
# tip (never on the merge commit), carries no ***NO_CI*** (feature branches
|
|
16
|
+
# have no CI trigger, so the push re-queues the PR build, which then
|
|
17
|
+
# validates the bumped commit), and is pushed with a plain push — never
|
|
18
|
+
# forced. When the source is already at the target version nothing is
|
|
19
|
+
# committed and the run continues. No npm query, no second PR.
|
|
11
20
|
|
|
12
21
|
# CI never carries an agent identity: the push below authenticates as the
|
|
13
22
|
# project build service through the checkout's persisted System.AccessToken.
|
|
@@ -20,8 +29,8 @@ die() {
|
|
|
20
29
|
}
|
|
21
30
|
|
|
22
31
|
version_file=''
|
|
23
|
-
|
|
24
|
-
|
|
32
|
+
pr_source=''
|
|
33
|
+
base_branch=''
|
|
25
34
|
push_bump=false
|
|
26
35
|
|
|
27
36
|
while [[ $# -gt 0 ]]; do
|
|
@@ -31,14 +40,15 @@ while [[ $# -gt 0 ]]; do
|
|
|
31
40
|
version_file="$2"
|
|
32
41
|
shift 2
|
|
33
42
|
;;
|
|
34
|
-
--
|
|
35
|
-
[[ $# -ge 2 ]] || die '--
|
|
36
|
-
|
|
43
|
+
--pr-source)
|
|
44
|
+
[[ $# -ge 2 ]] || die '--pr-source requires a branch ref'
|
|
45
|
+
pr_source="$2"
|
|
37
46
|
shift 2
|
|
38
47
|
;;
|
|
39
|
-
--
|
|
40
|
-
|
|
41
|
-
|
|
48
|
+
--base)
|
|
49
|
+
[[ $# -ge 2 ]] || die '--base requires a branch name'
|
|
50
|
+
base_branch="$2"
|
|
51
|
+
shift 2
|
|
42
52
|
;;
|
|
43
53
|
--push)
|
|
44
54
|
push_bump=true
|
|
@@ -51,46 +61,73 @@ while [[ $# -gt 0 ]]; do
|
|
|
51
61
|
done
|
|
52
62
|
|
|
53
63
|
[[ "$version_file" == 'package.json' ]] || die 'only --file package.json is supported'
|
|
54
|
-
[[ -
|
|
55
|
-
[[ -n "$
|
|
56
|
-
[[ "$integration_mode" == true ]] || die '--integration is required'
|
|
64
|
+
[[ -n "$pr_source" ]] || die '--pr-source is required'
|
|
65
|
+
[[ -n "$base_branch" ]] || die '--base is required'
|
|
57
66
|
[[ "$push_bump" == true ]] || die '--push is required'
|
|
58
67
|
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
current_branch="${BUILD_SOURCEBRANCH#refs/heads/}"
|
|
70
|
-
;;
|
|
71
|
-
'')
|
|
72
|
-
die 'cannot determine the current branch: HEAD is detached (git branch --show-current is empty) and BUILD_SOURCEBRANCH is not set'
|
|
73
|
-
;;
|
|
74
|
-
*)
|
|
75
|
-
die "BUILD_SOURCEBRANCH '$BUILD_SOURCEBRANCH' is not a branch ref (expected 'refs/heads/<name>')"
|
|
76
|
-
;;
|
|
77
|
-
esac
|
|
68
|
+
# Accept `refs/heads/x` (Build.SourceBranch / System.PullRequest.SourceBranch)
|
|
69
|
+
# or a bare `x`; anything else under refs/ is not a branch.
|
|
70
|
+
source_branch="${pr_source#refs/heads/}"
|
|
71
|
+
case "$source_branch" in
|
|
72
|
+
'') die '--pr-source names an empty branch' ;;
|
|
73
|
+
refs/*) die "--pr-source '$pr_source' is not a branch ref (expected 'refs/heads/<name>' or '<name>')" ;;
|
|
74
|
+
esac
|
|
75
|
+
base_branch="${base_branch#refs/heads/}"
|
|
76
|
+
if [[ "$source_branch" == "$base_branch" || "$source_branch" == 'main' || "$source_branch" == 'integration' ]]; then
|
|
77
|
+
die "refusing to bump '$source_branch': the PR source must be a feature branch, not the base ('$base_branch'), main or integration"
|
|
78
78
|
fi
|
|
79
|
-
[[ "$current_branch" == "$integration_branch" ]] || \
|
|
80
|
-
die "integration mode expects branch '$integration_branch', currently on '$current_branch'"
|
|
81
|
-
|
|
82
|
-
current_version="$(node -e 'const version = JSON.parse(require("node:fs").readFileSync(process.argv[1], "utf8")).version; if (typeof version !== "string") process.exit(1); process.stdout.write(version)' "$version_file")" || \
|
|
83
|
-
die "cannot read a string version from $version_file"
|
|
84
79
|
|
|
85
|
-
|
|
86
|
-
|
|
80
|
+
git diff --quiet || die 'working tree is not clean before version bump'
|
|
81
|
+
git diff --cached --quiet || die 'index is not clean before version bump'
|
|
82
|
+
repo_root="$(git rev-parse --show-toplevel)" || die 'cannot resolve the repository root'
|
|
83
|
+
# The script runs from the package directory (e.g. apps/cli); the manifest's
|
|
84
|
+
# repository-relative path is how it is read from the base branch.
|
|
85
|
+
repo_prefix="$(git rev-parse --show-prefix)" || die 'cannot resolve the working directory inside the repository'
|
|
86
|
+
repo_relative_file="${repo_prefix}${version_file}"
|
|
87
|
+
|
|
88
|
+
# Read both tips fresh from origin. The PR build's checkout is the detached
|
|
89
|
+
# merge commit; bumping that would push a commit that is not the branch's own
|
|
90
|
+
# history, so the working tree is moved onto a REAL branch at the source tip.
|
|
91
|
+
git fetch origin \
|
|
92
|
+
"refs/heads/$base_branch:refs/remotes/origin/$base_branch" \
|
|
93
|
+
"refs/heads/$source_branch:refs/remotes/origin/$source_branch" || \
|
|
94
|
+
die "cannot fetch '$base_branch' and '$source_branch' from origin"
|
|
95
|
+
|
|
96
|
+
# The source must already contain the base's tip: the version it is bumped to
|
|
97
|
+
# is derived from that tip, so a source behind it would be validated against a
|
|
98
|
+
# merge the bump never saw.
|
|
99
|
+
if ! git merge-base --is-ancestor "refs/remotes/origin/$base_branch" "refs/remotes/origin/$source_branch"; then
|
|
100
|
+
missing_count="$(git rev-list --count "refs/remotes/origin/$source_branch..refs/remotes/origin/$base_branch")"
|
|
101
|
+
base_short="$(git rev-parse --short "refs/remotes/origin/$base_branch")"
|
|
102
|
+
echo "version-bump: FAIL — $source_branch does not contain $base_branch's tip ($base_short); reconcile first: git pull origin $base_branch && git push" >&2
|
|
103
|
+
echo "version-bump: $missing_count commit(s) on $base_branch are missing from $source_branch" >&2
|
|
104
|
+
exit 1
|
|
87
105
|
fi
|
|
88
106
|
|
|
107
|
+
git checkout -q -B "$source_branch" "refs/remotes/origin/$source_branch" || \
|
|
108
|
+
die "cannot check out '$source_branch' at its origin tip"
|
|
109
|
+
|
|
110
|
+
[[ -f "$version_file" ]] || die "manifest not found: $version_file"
|
|
111
|
+
|
|
112
|
+
read_version() {
|
|
113
|
+
node -e 'const version = JSON.parse(require("node:fs").readFileSync(0, "utf8")).version; if (typeof version !== "string") process.exit(1); process.stdout.write(version)'
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
base_version="$(git show "refs/remotes/origin/$base_branch:$repo_relative_file" | read_version)" || \
|
|
117
|
+
die "cannot read a string version from $repo_relative_file on origin/$base_branch"
|
|
118
|
+
if [[ ! "$base_version" =~ ^([0-9]+)\.([0-9]+)\.([0-9]+)$ ]]; then
|
|
119
|
+
die "cannot parse '$base_version' (origin/$base_branch) as a release version x.y.z"
|
|
120
|
+
fi
|
|
89
121
|
next_version="${BASH_REMATCH[1]}.${BASH_REMATCH[2]}.$((BASH_REMATCH[3] + 1))"
|
|
90
122
|
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
123
|
+
current_version="$(read_version < "$version_file")" || die "cannot read a string version from $version_file"
|
|
124
|
+
|
|
125
|
+
if [[ "$current_version" == "$next_version" ]]; then
|
|
126
|
+
echo "version-bump: $source_branch already at $next_version (integration $base_version); nothing to bump"
|
|
127
|
+
echo '##vso[task.setvariable variable=bumped;isOutput=true]false'
|
|
128
|
+
exit 0
|
|
129
|
+
fi
|
|
130
|
+
|
|
94
131
|
node -e '
|
|
95
132
|
const fs = require("node:fs")
|
|
96
133
|
const [file, version] = process.argv.slice(1)
|
|
@@ -105,7 +142,7 @@ node -e '
|
|
|
105
142
|
# package.json can reach. No file, or no `pre_bump` key: no scripts, and
|
|
106
143
|
# everything below this comment is a no-op, so the resulting commit is
|
|
107
144
|
# byte-identical to the behaviour before this feature existed (see
|
|
108
|
-
# packages/repo-controls/tests/version-bump-pre-bump.test.ts).
|
|
145
|
+
# packages/repo-controls/tests/version-bump-pr-source-pre-bump.test.ts).
|
|
109
146
|
config_path="$repo_root/adl-config.toml"
|
|
110
147
|
pre_bump_list=''
|
|
111
148
|
if [[ -f "$config_path" ]]; then
|
|
@@ -189,6 +226,9 @@ fi
|
|
|
189
226
|
|
|
190
227
|
git -C "$repo_root" add -A
|
|
191
228
|
git diff --cached --quiet && die "version bump produced no staged change (expected at least $version_file)"
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
229
|
+
# No ***NO_CI***: a feature branch has no CI trigger, so this push is what
|
|
230
|
+
# re-queues the PR build against the bumped commit.
|
|
231
|
+
git commit -m "chore: bump version $current_version to $next_version"
|
|
232
|
+
git push origin "HEAD:refs/heads/$source_branch"
|
|
233
|
+
echo "version-bump: bumped $source_branch from $current_version to $next_version (integration $base_version); pushed — this run ends, the re-queued PR build validates the bumped commit"
|
|
234
|
+
echo '##vso[task.setvariable variable=bumped;isOutput=true]true'
|
|
@@ -168,9 +168,12 @@ export interface AzureDevOpsGovernanceOptions {
|
|
|
168
168
|
}
|
|
169
169
|
|
|
170
170
|
/**
|
|
171
|
-
* One read-only receipt per promotion guard.
|
|
172
|
-
*
|
|
173
|
-
*
|
|
171
|
+
* One read-only receipt per promotion guard. The only required guard on the
|
|
172
|
+
* target branch is the minimum-reviewer policy (Tarun, 2026-09-30): a build
|
|
173
|
+
* policy is NOT required, because the integration -> main promotion PR gets no
|
|
174
|
+
* build validation and CD runs after the merge. Unreadable evidence is a
|
|
175
|
+
* failure, never a warning: a commit cannot claim the remote guard is armed
|
|
176
|
+
* when that fact could not be established.
|
|
174
177
|
*/
|
|
175
178
|
export function azureDevOpsGovernanceChecks(options: AzureDevOpsGovernanceOptions): AzureDevOpsGovernanceCheck[] {
|
|
176
179
|
const checks: AzureDevOpsGovernanceCheck[] = []
|
|
@@ -231,18 +234,16 @@ export function azureDevOpsGovernanceChecks(options: AzureDevOpsGovernanceOption
|
|
|
231
234
|
message: `missing blocking minimum-reviewer policy requiring one approval and resetOnSourcePush=true for ${prefix}`,
|
|
232
235
|
},
|
|
233
236
|
)
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
:
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
},
|
|
246
|
-
)
|
|
237
|
+
// No build-policy requirement on `main` (Tarun, 2026-09-30): the promotion PR
|
|
238
|
+
// gets no build validation, so an absent build policy is not a failure. A
|
|
239
|
+
// present one is only recorded, never flagged here (the azdo repo doctor
|
|
240
|
+
// owns that).
|
|
241
|
+
if (receipts.build) {
|
|
242
|
+
checks.push({
|
|
243
|
+
id: 'azure-devops.build-policy',
|
|
244
|
+
status: 'ok',
|
|
245
|
+
message: `blocking build policy #${id(receipts.build)} uses definition ${receipts.build.settings?.buildDefinitionId} for ${prefix}`,
|
|
246
|
+
})
|
|
247
|
+
}
|
|
247
248
|
return checks
|
|
248
249
|
}
|
|
@@ -63,6 +63,8 @@ export const PROMOTE_REL = '.workflows/promote.ts'
|
|
|
63
63
|
export const AUTO_COMPLETE_REL = '.workflows/scripts/enable-integration-auto-complete.mjs'
|
|
64
64
|
/** The standalone immutable package-version allocator for integration CI. */
|
|
65
65
|
export const VERSION_BUMP_REL = '.workflows/version-bump.sh'
|
|
66
|
+
/** The standalone CI stage-2 guard: a PR branch must contain integration's tip and be at its version + 1 patch. */
|
|
67
|
+
export const RECONCILE_GUARD_REL = '.workflows/reconcile-guard.sh'
|
|
66
68
|
|
|
67
69
|
export const MANAGED_ASSETS = [
|
|
68
70
|
{ rel: PROMOTE_REL, source: join(PACKAGE_ROOT, 'assets', 'promote.ts'), mode: 0o755, doctorId: 'promote' },
|
|
@@ -78,6 +80,12 @@ export const MANAGED_ASSETS = [
|
|
|
78
80
|
mode: 0o755,
|
|
79
81
|
doctorId: 'version-bump',
|
|
80
82
|
},
|
|
83
|
+
{
|
|
84
|
+
rel: RECONCILE_GUARD_REL,
|
|
85
|
+
source: join(PACKAGE_ROOT, 'assets', 'reconcile-guard.sh'),
|
|
86
|
+
mode: 0o755,
|
|
87
|
+
doctorId: 'reconcile-guard',
|
|
88
|
+
},
|
|
81
89
|
] as const
|
|
82
90
|
/** Shared with adl hook ([hooks]/[rules]/[repo]) and adl-extensions ([skills]); this package owns only [controls]. */
|
|
83
91
|
export const CONFIG_REL = 'adl-config.toml'
|
|
@@ -430,8 +430,9 @@ function dirtyPaths(porcelain: string): string[] {
|
|
|
430
430
|
const hasOrigin = staged === 'R' || staged === 'C' || worktree === 'R' || worktree === 'C'
|
|
431
431
|
const origin = hasOrigin ? records[++index] : undefined
|
|
432
432
|
// An untracked path has no index entry, so it is necessarily omitted from
|
|
433
|
-
// this commit. Keep it:
|
|
434
|
-
//
|
|
433
|
+
// this commit. Keep it: other top-level dot paths must be staged rather than
|
|
434
|
+
// silently left behind, and a protected root (.workflows, .agents, .claude,
|
|
435
|
+
// .codex, .cursor, .kimi-code, .opencode) is refused outright (integrity.ts).
|
|
435
436
|
if (staged === '?' && worktree === '?') {
|
|
436
437
|
paths.push(record.slice(3))
|
|
437
438
|
continue
|
|
@@ -9,7 +9,21 @@
|
|
|
9
9
|
|
|
10
10
|
export const WIRING: readonly string[] = ['adl-config.toml']
|
|
11
11
|
|
|
12
|
-
|
|
12
|
+
// Tarun, 2026-09-30: `.workflows/` and every agent surface root are protected
|
|
13
|
+
// like `workflows/` — a dirty path under any of them blocks a normal commit and
|
|
14
|
+
// only Tarun lands it. They were "centrally managed dotfiles" an agent could
|
|
15
|
+
// commit alongside a related change; that let an agent land gate-defining files.
|
|
16
|
+
const FIXED_ROOTS: readonly string[] = [
|
|
17
|
+
'compliance',
|
|
18
|
+
'workflows',
|
|
19
|
+
'.workflows',
|
|
20
|
+
'.agents',
|
|
21
|
+
'.claude',
|
|
22
|
+
'.codex',
|
|
23
|
+
'.cursor',
|
|
24
|
+
'.kimi-code',
|
|
25
|
+
'.opencode',
|
|
26
|
+
]
|
|
13
27
|
|
|
14
28
|
/** A declared pattern, as a regex. `*` stays in one directory; `**` crosses them. */
|
|
15
29
|
function patternToRegExp(pattern: string): RegExp {
|