@amsterdamdatalabs/adl-agent-control-linux-x64 0.1.9 → 0.1.12

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.
Files changed (41) hide show
  1. package/bin/adl +2 -2
  2. package/bin/git +1 -1
  3. package/bin/git-ado +2 -2
  4. package/bin/lib/bun-compile-build.sh +1 -1
  5. package/bin/lib/fleet-host.sh +59 -0
  6. package/bin/lib/lean-ctx-observe-hooks.sh +9 -9
  7. package/bin/reconcile +222 -249
  8. package/bin/reconcile-host +111 -0
  9. package/bin/setup +216 -0
  10. package/bin/sync-and-reconcile +52 -39
  11. package/bin/validate-manifest +15 -44
  12. package/package.json +1 -3
  13. package/packages/agent-hooks/package.json +1 -2
  14. package/packages/agent-hooks/src/hooks/crg/background.ts +179 -0
  15. package/packages/agent-hooks/src/hooks/crg/handler.ts +2 -1
  16. package/packages/agent-hooks/src/hooks/crg/index.ts +36 -5
  17. package/packages/agent-hooks/src/rules/impl/fs-trash.ts +2 -2
  18. package/packages/agent-hooks/src/rules/impl/git-paths.ts +5 -6
  19. package/packages/agent-hooks/src/rules/impl/tool-grep.ts +8 -8
  20. package/packages/agent-settings/assets/GLOBAL-AGENTS.md +4 -2
  21. package/packages/agent-settings/src/global-surface-sweep.ts +5 -15
  22. package/packages/agent-skills/package.json +1 -1
  23. package/packages/azdo/package.json +1 -2
  24. package/packages/azdo/src/azdo-repo-doctor.ts +70 -75
  25. package/packages/fleet/manifest.json +5 -24
  26. package/packages/fleet/manifest.schema.json +8 -58
  27. package/packages/fleet/src/install-embedding-model.ts +1 -1
  28. package/packages/fleet/src/repo-maintain.ts +403 -0
  29. package/packages/fleet/src/scratch-reap.ts +1 -1
  30. package/packages/repo-controls/README.md +46 -8
  31. package/packages/repo-controls/assets/promote.ts +12 -7
  32. package/packages/repo-controls/assets/reconcile-guard.sh +113 -0
  33. package/packages/repo-controls/assets/version-bump.sh +91 -51
  34. package/packages/repo-controls/package.json +0 -1
  35. package/packages/repo-controls/src/azure-devops-governance.ts +17 -16
  36. package/packages/repo-controls/src/constants.ts +8 -0
  37. package/packages/repo-controls/src/hook.ts +3 -2
  38. package/packages/repo-controls/src/integrity.ts +15 -1
  39. package/packages/fleet/jobs/git-maintain/src/main.ts +0 -848
  40. package/packages/fleet/systemd/adl-azure-agent-update.service +0 -13
  41. package/packages/fleet/systemd/adl-azure-agent-update.timer +0 -10
@@ -0,0 +1,403 @@
1
+ /**
2
+ * Repository maintenance: the repository step of `adl reconcile`
3
+ * (`adl maintain --location DIR`). It replaces the retired git-maintain
4
+ * LaunchAgent (2026-09-30, Tarun: "one binary controlling everything").
5
+ *
6
+ * Scope is exactly ONE directory, the host's `repo_location` from the fleet
7
+ * manifest, and only its IMMEDIATE children that carry `adl-config.toml` and
8
+ * are git top-levels. Nothing else is ever touched or mentioned: every child
9
+ * is resolved (symlinks followed) and must still be a direct child of the
10
+ * location BEFORE any file inside it is stat'ed, so a symlink can never make
11
+ * this step read, fetch into or install into anything outside the location.
12
+ * A directory without the marker is not this step's business at all.
13
+ *
14
+ * Per repository, in order:
15
+ * 1. `git fetch origin --prune`;
16
+ * 2. fast-forward the local `integration` and `main` (whichever exist on
17
+ * both sides) -- only ever a true fast-forward. A branch checked out in a
18
+ * worktree (this one or another) is moved only when that worktree is
19
+ * clean (`merge --ff-only`), else WARN and skip; a branch checked out
20
+ * nowhere is moved with a compare-and-swap `update-ref`;
21
+ * 3. the safe-only local branch prune (ported from git-maintain): protected
22
+ * integration/main/master/develop and HEAD are never deleted, the KEEP
23
+ * most recent branches are kept, and only "[gone]" upstreams (-D) or
24
+ * branches merged into the baseline (-d) are deleted;
25
+ * 4. `adl install --repo <repo>` -- the existing per-repo enrolment.
26
+ *
27
+ * This runs as the device administrator, never root (the caller,
28
+ * bin/reconcile-host, drops privileges and `adl maintain --expect-user`
29
+ * refuses otherwise). It never migrates enact-config.toml: a repo that has
30
+ * both markers is a named failure.
31
+ *
32
+ * Reporting: plain progress lines go to `out`; every warning and failure goes
33
+ * to `err` as `WARN repo-maintain: ...` / `ERROR repo-maintain: ...` (the
34
+ * level word `adl logs` filters on). A failure never stops the other
35
+ * repositories; the run returns `ok: false` when any occurred.
36
+ */
37
+ import { spawnSync } from 'node:child_process'
38
+ import { readdirSync, realpathSync, statSync, type Dirent } from 'node:fs'
39
+ import { basename, dirname, isAbsolute, join } from 'node:path'
40
+ import { NO_AGENT_IDENTITY_MESSAGE, resolveAgentIdentity } from '../../azdo/src/lib/agent-identity.ts'
41
+
42
+ export const TCC_DENIED_MARKER = 'TCC denied'
43
+
44
+ /** The file whose presence puts a repository in this step's scope. */
45
+ export const MARKER = 'adl-config.toml'
46
+ /** The retired marker; a repo with both is ambiguous and fails loud. */
47
+ const LEGACY_MARKER = 'enact-config.toml'
48
+
49
+ /** Branches fast-forwarded, in this order. */
50
+ const SYNCED_BRANCHES = ['integration', 'main'] as const
51
+ const PROTECTED = new Set(['integration', 'main', 'master', 'develop'])
52
+ const DEFAULT_KEEP = 3
53
+
54
+ /** Case-insensitive, by name and by realpath: never an Azure Pipelines agent install. */
55
+ const FORBIDDEN_PATH_PATTERN = /azagent|vsts\.agent/i
56
+
57
+ export interface MaintainOptions {
58
+ /** Absolute path of the host's `repo_location`. */
59
+ readonly location: string
60
+ /** The running adl binary, named in the Full Disk Access remedy. */
61
+ readonly executable: string
62
+ /** How to run `adl`: `<command> <...leadingArgs> install --repo <repo>`. */
63
+ readonly install: { readonly command: string; readonly leadingArgs: readonly string[] }
64
+ /** Most recent non-protected local branches kept by the prune. Default 3. */
65
+ readonly keep?: number
66
+ /** Report only: fetches, but never moves a ref, deletes a branch or runs install. */
67
+ readonly dryRun?: boolean
68
+ /** Environment for identity resolution and every child. Default process.env. */
69
+ readonly env?: NodeJS.ProcessEnv
70
+ readonly out?: (line: string) => void
71
+ readonly err?: (line: string) => void
72
+ }
73
+
74
+ export interface MaintainResult {
75
+ readonly ok: boolean
76
+ readonly repositories: number
77
+ readonly failures: readonly string[]
78
+ }
79
+
80
+ /**
81
+ * Lines explaining why `root` could not be listed. EPERM from readdir is what
82
+ * macOS privacy protection (TCC) returns -- plain permission bits give EACCES
83
+ * -- so it names the exact binary the owner must grant Full Disk Access.
84
+ */
85
+ export function listFailureLines(root: string, error: unknown, executable: string): readonly string[] {
86
+ const message = error instanceof Error ? error.message : String(error)
87
+ const code = (error as { code?: unknown } | null)?.code
88
+ if (code === 'ENOTDIR') return [`${root}: ${message}`]
89
+ const lines = [`${root}: cannot list: ${message}`]
90
+ if (code === 'EPERM') {
91
+ lines.push(
92
+ `${root}: ${TCC_DENIED_MARKER}: macOS privacy protection blocked ${executable}; ` +
93
+ `one-time fix: System Settings › Privacy & Security › Full Disk Access › + › add ${executable}`,
94
+ )
95
+ }
96
+ return lines
97
+ }
98
+
99
+ interface GitResult {
100
+ readonly ok: boolean
101
+ readonly stdout: string
102
+ readonly stderr: string
103
+ }
104
+
105
+ function errorCode(error: unknown): string | undefined {
106
+ const code = (error as { code?: unknown } | null)?.code
107
+ return typeof code === 'string' ? code : undefined
108
+ }
109
+
110
+ function lines(output: string): string[] {
111
+ return output.split('\n').filter((line) => line.length > 0)
112
+ }
113
+
114
+ function isForbiddenAzurePipelinesPath(path: string): boolean {
115
+ if (FORBIDDEN_PATH_PATTERN.test(path)) return true
116
+ try {
117
+ return FORBIDDEN_PATH_PATTERN.test(realpathSync(path))
118
+ } catch {
119
+ return false
120
+ }
121
+ }
122
+
123
+ /** `packages/<name>/enact-config.toml` -- the per-package copies of the retired marker. */
124
+ function legacyMarkers(repo: string): string[] {
125
+ const found: string[] = []
126
+ const tryStat = (relative: string): void => {
127
+ try {
128
+ if (statSync(join(repo, relative)).isFile()) found.push(relative)
129
+ } catch (error) {
130
+ if (errorCode(error) !== 'ENOENT' && errorCode(error) !== 'ENOTDIR') throw error
131
+ }
132
+ }
133
+ tryStat(LEGACY_MARKER)
134
+ let packages: Dirent[] = []
135
+ try {
136
+ packages = readdirSync(join(repo, 'packages'), { withFileTypes: true })
137
+ } catch (error) {
138
+ if (errorCode(error) !== 'ENOENT' && errorCode(error) !== 'ENOTDIR') throw error
139
+ }
140
+ for (const entry of packages.sort((a, b) => a.name.localeCompare(b.name))) {
141
+ if (entry.isDirectory()) tryStat(join('packages', entry.name, LEGACY_MARKER))
142
+ }
143
+ return found
144
+ }
145
+
146
+ export function maintainRepositories(options: MaintainOptions): MaintainResult {
147
+ const env = options.env ?? process.env
148
+ const keep = options.keep ?? DEFAULT_KEEP
149
+ const dry = options.dryRun ?? false
150
+ const out = options.out ?? ((line: string) => void process.stdout.write(`${line}\n`))
151
+ const errOut = options.err ?? ((line: string) => void process.stderr.write(`${line}\n`))
152
+ const info = (message: string): void => out(`repo-maintain: ${message}`)
153
+ const warn = (message: string): void => errOut(`WARN repo-maintain: ${message}`)
154
+ const failures: string[] = []
155
+ const fail = (message: string, subject: string): void => {
156
+ errOut(`ERROR repo-maintain: ${message}`)
157
+ failures.push(subject)
158
+ }
159
+
160
+ let identityEnv: NodeJS.ProcessEnv = {}
161
+ const childEnv = (): NodeJS.ProcessEnv => ({ ...env, ...identityEnv })
162
+ const git = (repo: string, ...args: string[]): GitResult => {
163
+ const result = spawnSync('git', ['-C', repo, ...args], { encoding: 'utf8', env: childEnv() })
164
+ if (result.error !== undefined) throw result.error
165
+ return { ok: result.status === 0, stdout: result.stdout, stderr: result.stderr ?? '' }
166
+ }
167
+
168
+ const { location } = options
169
+ if (!isAbsolute(location)) {
170
+ fail(`location must be an absolute path: ${location}`, location)
171
+ return { ok: false, repositories: 0, failures }
172
+ }
173
+ if (isForbiddenAzurePipelinesPath(location)) {
174
+ fail(`${location}: refusing an Azure Pipelines agent path`, location)
175
+ return { ok: false, repositories: 0, failures }
176
+ }
177
+
178
+ // Resolve the Azure DevOps agent identity ONCE, before touching any repo
179
+ // (F6): this job runs with no TTY and no ADL_AGENT_ID_AGENT, and bin/git-ado
180
+ // fails closed without one. A real resolution error is fatal; "nothing
181
+ // configured" is only fatal for Azure DevOps remotes, which are skipped
182
+ // loudly below rather than fetched with no identity.
183
+ let agent: string | undefined
184
+ try {
185
+ agent = resolveAgentIdentity(env).agent
186
+ } catch (error) {
187
+ const message = error instanceof Error ? error.message : String(error)
188
+ if (message !== NO_AGENT_IDENTITY_MESSAGE) {
189
+ fail(`cannot resolve an Azure DevOps agent identity: ${message}`, 'identity')
190
+ return { ok: false, repositories: 0, failures }
191
+ }
192
+ warn(
193
+ 'no Azure DevOps agent identity resolved (no ADL_AGENT_ID_AGENT, no default-agent file): ' +
194
+ 'Azure DevOps repos will be skipped this run, not silently fetched with no identity',
195
+ )
196
+ }
197
+ if (agent !== undefined) identityEnv = { ADL_AGENT_ID_AGENT: agent }
198
+
199
+ info(`start location=${location} keep=${keep} dry=${dry ? 1 : 0}`)
200
+
201
+ let entries: Dirent[]
202
+ let locationReal: string
203
+ try {
204
+ locationReal = realpathSync(location)
205
+ entries = readdirSync(locationReal, { withFileTypes: true })
206
+ } catch (error) {
207
+ for (const line of listFailureLines(location, error, options.executable)) errOut(`ERROR repo-maintain: ${line}`)
208
+ failures.push(location)
209
+ return { ok: false, repositories: 0, failures }
210
+ }
211
+
212
+ const seen = new Set<string>()
213
+ let repositories = 0
214
+ for (const entry of entries.sort((a, b) => a.name.localeCompare(b.name))) {
215
+ // Files and other non-directories (a symlink is the one kind resolved below).
216
+ if (!entry.isDirectory() && !entry.isSymbolicLink()) continue
217
+ const candidate = join(locationReal, entry.name)
218
+
219
+ // 1. Resolve, then scope-check, BEFORE looking inside. A broken symlink
220
+ // points nowhere and so carries no marker; any other failure to
221
+ // resolve is reported, because it may hide an in-scope repository.
222
+ let real: string
223
+ try {
224
+ real = realpathSync(candidate)
225
+ } catch (error) {
226
+ if (errorCode(error) === 'ENOENT') continue
227
+ fail(`${candidate}: cannot resolve: ${error instanceof Error ? error.message : String(error)}`, candidate)
228
+ continue
229
+ }
230
+ if (dirname(real) !== locationReal) continue
231
+ if (seen.has(real)) continue
232
+
233
+ // 2. The marker decides scope. Not there: not this step's business, and
234
+ // silent. Anything other than "not there" is a named failure.
235
+ try {
236
+ if (!statSync(join(real, MARKER)).isFile()) continue
237
+ } catch (error) {
238
+ const code = errorCode(error)
239
+ if (code === 'ENOENT' || code === 'ENOTDIR') continue
240
+ fail(`${real}: cannot read ${MARKER}: ${error instanceof Error ? error.message : String(error)}`, real)
241
+ continue
242
+ }
243
+ seen.add(real)
244
+
245
+ // 3. In scope from here on.
246
+ let legacy: string[]
247
+ try {
248
+ legacy = legacyMarkers(real)
249
+ } catch (error) {
250
+ fail(`${real}: cannot read: ${error instanceof Error ? error.message : String(error)}`, real)
251
+ continue
252
+ }
253
+ if (legacy.length > 0) {
254
+ fail(`${real}: duplicate markers: ${MARKER} and ${LEGACY_MARKER} (${legacy.join(', ')}); migrate or remove one`, real)
255
+ continue
256
+ }
257
+ if (!isGitToplevel(real)) {
258
+ warn(`${real}: ${MARKER} but not a git top-level; skipped`)
259
+ continue
260
+ }
261
+ repositories += 1
262
+ maintainRepo(real)
263
+ }
264
+
265
+ info(`done (${repositories} repositories, ${failures.length} failed)`)
266
+ return { ok: failures.length === 0, repositories, failures }
267
+
268
+ function isGitToplevel(directory: string): boolean {
269
+ const top = spawnSync('git', ['-C', directory, 'rev-parse', '--show-toplevel'], { encoding: 'utf8', env: childEnv() })
270
+ if (top.error !== undefined || top.status !== 0) return false
271
+ try {
272
+ return realpathSync(top.stdout.trim()) === directory
273
+ } catch {
274
+ return false
275
+ }
276
+ }
277
+
278
+ function baselineFor(repo: string): string {
279
+ for (const branch of ['integration', 'main', 'master']) {
280
+ if (git(repo, 'show-ref', '--verify', '--quiet', `refs/remotes/origin/${branch}`).ok) return branch
281
+ }
282
+ return ''
283
+ }
284
+
285
+ function maintainRepo(repo: string): void {
286
+ const name = basename(repo)
287
+
288
+ // An Azure DevOps remote needs an agent identity; without one the fetch
289
+ // would only fail (or be refused) far from the cause.
290
+ if (agent === undefined) {
291
+ const url = git(repo, 'remote', 'get-url', 'origin')
292
+ if (url.ok && /^https:\/\/[^/]*dev\.azure\.com\//.test(url.stdout.trim())) {
293
+ fail(`${name}: skipping: Azure DevOps remote but no agent identity resolved (see the start-of-run warning)`, repo)
294
+ return
295
+ }
296
+ }
297
+
298
+ const head = git(repo, 'symbolic-ref', '--short', '-q', 'HEAD')
299
+ const current = head.ok ? head.stdout.trim() : '(detached)'
300
+
301
+ // 1. fetch + prune. Report what git SAID, never a guess at what it meant.
302
+ const fetched = git(repo, 'fetch', 'origin', '--prune', '--quiet')
303
+ if (!fetched.ok) {
304
+ const reason = fetched.stderr.trim().split('\n').pop() || 'no stderr from git'
305
+ fail(`${name}: fetch failed, skipping repo: ${reason}`, repo)
306
+ return
307
+ }
308
+
309
+ // 2. fast-forward integration and main.
310
+ for (const branch of SYNCED_BRANCHES) fastForward(repo, name, branch)
311
+
312
+ // 3. guarded prune.
313
+ prune(repo, name, current)
314
+
315
+ // 4. the per-repo enrolment.
316
+ const argv = [...options.install.leadingArgs, 'install', '--repo', repo]
317
+ const shown = `adl install --repo ${repo}`
318
+ if (dry) {
319
+ info(`${name}: [dry] would run ${shown}`)
320
+ return
321
+ }
322
+ const result = spawnSync(options.install.command, argv, { encoding: 'utf8', env: childEnv() })
323
+ if (result.error !== undefined) {
324
+ fail(`${name}: could not run ${options.install.command}: ${result.error.message}`, repo)
325
+ } else if (result.status !== 0) {
326
+ fail(`${name}: ${shown} exited ${result.status}: ${(result.stderr ?? '').trim()}`, repo)
327
+ } else {
328
+ info(`${name}: ${shown}: ok`)
329
+ }
330
+ }
331
+
332
+ function fastForward(repo: string, name: string, branch: string): void {
333
+ const local = `refs/heads/${branch}`
334
+ const remote = `refs/remotes/origin/${branch}`
335
+ if (!git(repo, 'show-ref', '--verify', '--quiet', local).ok) return
336
+ if (!git(repo, 'show-ref', '--verify', '--quiet', remote).ok) return
337
+ const localSha = git(repo, 'rev-parse', local).stdout.trim()
338
+ const remoteSha = git(repo, 'rev-parse', remote).stdout.trim()
339
+ if (localSha === remoteSha) return
340
+ if (!git(repo, 'merge-base', '--is-ancestor', local, remote).ok) {
341
+ // Not a fast-forward (local has commits origin lacks). Never moved.
342
+ if (!git(repo, 'merge-base', '--is-ancestor', remote, local).ok) {
343
+ warn(`${name}: ${branch} has diverged from origin/${branch}; not moved`)
344
+ }
345
+ return
346
+ }
347
+ // Where is it checked out, if anywhere? (`%(worktreepath)` covers this
348
+ // worktree and every linked one.)
349
+ const worktree = git(repo, 'for-each-ref', local, '--format=%(worktreepath)').stdout.trim()
350
+ if (worktree !== '') {
351
+ if (git(worktree, 'status', '--porcelain').stdout.trim() !== '') {
352
+ warn(`${name}: ${branch} checked out but dirty; skipped fast-forward`)
353
+ } else if (dry) {
354
+ info(`${name}: [dry] would fast-forward ${branch} (checked out)`)
355
+ } else if (git(worktree, 'merge', '--ff-only', `origin/${branch}`, '--quiet').ok) {
356
+ info(`${name}: ${branch} fast-forwarded (checked out)`)
357
+ } else {
358
+ fail(`${name}: ${branch} could not be fast-forwarded (checked out)`, repo)
359
+ }
360
+ return
361
+ }
362
+ if (dry) {
363
+ info(`${name}: [dry] would fast-forward ${branch} (background)`)
364
+ } else if (git(repo, 'update-ref', '-m', `adl maintain: fast-forward ${branch}`, local, remoteSha, localSha).ok) {
365
+ info(`${name}: ${branch} ref fast-forwarded (background)`)
366
+ } else {
367
+ fail(`${name}: ${branch} ref could not be fast-forwarded (background)`, repo)
368
+ }
369
+ }
370
+
371
+ function prune(repo: string, name: string, current: string): void {
372
+ const base = baselineFor(repo)
373
+ const keptRecent = new Set(
374
+ lines(git(repo, 'for-each-ref', '--sort=-committerdate', 'refs/heads/', '--format=%(refname:short)').stdout)
375
+ .filter((branch) => !PROTECTED.has(branch) && branch !== current)
376
+ .slice(0, keep),
377
+ )
378
+ const merged =
379
+ base === ''
380
+ ? new Set<string>()
381
+ : new Set(lines(git(repo, 'branch', '--merged', `origin/${base}`, '--format=%(refname:short)').stdout))
382
+
383
+ for (const line of lines(git(repo, 'for-each-ref', 'refs/heads/', '--format=%(refname:short)|%(upstream:track)').stdout)) {
384
+ const separator = line.indexOf('|')
385
+ const branch = line.slice(0, separator)
386
+ const track = line.slice(separator + 1)
387
+ if (PROTECTED.has(branch) || branch === current || keptRecent.has(branch)) continue
388
+
389
+ if (track.includes('[gone]')) {
390
+ // remote branch deleted after merge -> proven safe
391
+ if (dry) info(`${name}: [dry] would delete (upstream gone) ${branch}`)
392
+ else if (git(repo, 'branch', '-D', branch).ok) info(`${name}: deleted (upstream gone) ${branch}`)
393
+ else warn(`${name}: could not delete (upstream gone) ${branch}`)
394
+ } else if (base !== '' && merged.has(branch)) {
395
+ if (dry) info(`${name}: [dry] would delete (merged into ${base}) ${branch}`)
396
+ else if (git(repo, 'branch', '-d', branch).ok) info(`${name}: deleted (merged into ${base}) ${branch}`)
397
+ else info(`${name}: kept (${branch}: -d refused)`)
398
+ } else {
399
+ info(`${name}: kept (unconfirmed merged) ${branch}`)
400
+ }
401
+ }
402
+ }
403
+ }
@@ -20,7 +20,7 @@
20
20
  * `git worktree prune` is not a substitute for any of this. It only clears
21
21
  * metadata for worktrees whose directory has ALREADY been deleted.
22
22
  *
23
- * Invoked by reconcile's six-hourly pass (bin/reconcile), gated on
23
+ * Invoked by reconcile's 12-hourly pass (bin/reconcile), gated on
24
24
  * config.scratch_reap.enabled and the per-device scratch_repos list. Devices
25
25
  * that declare no scratch_repos are never touched — a developer machine's
26
26
  * scratch worktrees are working state, not fleet state.
@@ -59,6 +59,11 @@ have needed):
59
59
  evidence blocks the push, never a warning, never a silent pass. A push
60
60
  already requires network access to the remote, so a network + token
61
61
  requirement here is not a new cost the way it would be at commit time.
62
+ The only guard required on the target branch is the minimum-reviewer
63
+ policy (one approval, `resetOnSourcePush=true`). A build policy is NOT
64
+ required (Tarun, 2026-09-30, three-pipeline release flow): the promotion PR
65
+ runs no build and Tarun's approval is the only gate, so an absent build policy
66
+ is not a failure (a present one is recorded as `ok`, never flagged here).
62
67
  - **A bare `adl-repo-controls doctor` run** (not through either hook) keeps
63
68
  reporting everything, exactly as before — this split only changes which
64
69
  hook exercises which checks, never what `doctor` itself is capable of
@@ -214,6 +219,13 @@ deactivates its hook path. Canonical logs, their root ignore, quarantined log
214
219
  evidence, and runtime caches remain. `adl-config.toml` itself is never deleted — only the
215
220
  `[controls]` span this package appended is ever spliced back out.
216
221
 
222
+ **promote** creates or reuses the active PR from `[controls.release].source`
223
+ to `target` and arms auto-complete (the installed `.workflows/promote.ts`;
224
+ see the assets list below). It requires the target's minimum-reviewer policy
225
+ and does not require a build policy: the promotion PR runs no build (Tarun,
226
+ 2026-09-30). It is what the Promote pipeline (`promote.yml`, triggered by a
227
+ merge into `integration`) runs.
228
+
217
229
  **doctor** is read-only and exits 1 on any failure: bash >= 4, bun, binary on
218
230
  PATH, the bundled compliance package present at its packaged path, repository
219
231
  root, `[controls]` config validity, (warning) `coverage/coverage-summary.json`
@@ -225,7 +237,7 @@ lock, each stub (present, unedited, executable), `core.hooksPath`, `.gitignore`
225
237
  lines, and (warning) lock version skew. When `[controls.release]` is declared,
226
238
  it also derives the configured `origin` remote, loads the validated canonical
227
239
  Agent Vault file before reading Azure DevOps, and records separate fail-closed receipts for the HTTPS handle,
228
- required owner reviewer, minimum-review reset, and build policy.
240
+ required owner reviewer, minimum-review reset, and (only when one is present) build policy.
229
241
 
230
242
  **run** executes a bundled enact-m5 tool against the repository with the config
231
243
  exported: `azdo-push` (Azure DevOps push with push lock + behind-origin refusal),
@@ -289,7 +301,25 @@ you own. Nothing is parsed and re-serialized; text is spliced into the table's s
289
301
  | `release.source` | no | `""` | `"integration"` | source branch for the managed promotion PR; no branch pair is guessed |
290
302
  | `release.target` | no | `""` | `"main"` | target branch for the managed promotion PR |
291
303
  | `release.merge_strategy` | no | `"squash"` | `"no-fast-forward"` | one of `squash`, `rebase`, `rebase-merge`, or `no-fast-forward` — sent to Azure DevOps as the matching wire value (`squash`, `rebase`, `rebaseMerge`, `noFastForward`) |
292
- | `release.pre_bump` | no | `[]` | — | ordered, repository-relative script paths `version-bump.sh` runs from the repo root before the bump commit |
304
+ | `release.pre_bump` | no | `[]` | — | ordered, repository-relative script paths `version-bump.sh` runs from the repo root before the bump commit (the CI pipeline's Bump stage, on the PR's source branch) |
305
+
306
+ ### Release scripts — Bump and Guard (Tarun, 2026-09-30)
307
+
308
+ One clean hard cutover to three pipelines (CI on PRs into `integration`,
309
+ Promote on `integration`, CD on `main`; `docs/architecture/cli-design.md` §6a).
310
+ Two managed scripts run in the CI pipeline, and neither ever pushes to
311
+ `integration`:
312
+
313
+ - **`.workflows/version-bump.sh --file package.json --pr-source <source> --base
314
+ integration --push`** (Bump stage) sets the PR's SOURCE branch to
315
+ `integration`'s version + 1 patch, running `release.pre_bump` scripts in the
316
+ same commit, and pushes it (a plain push as the build service, never an
317
+ agent identity, never force). It refuses a source that does not contain
318
+ `integration`'s tip. The old `--integration` mode is gone: it is an unknown
319
+ argument.
320
+ - **`.workflows/reconcile-guard.sh`** (Guard stage) fails, printing the
321
+ reconcile command (`git pull origin integration && git push`), unless the
322
+ source contains `integration`'s tip and is at `integration` + 1.
293
323
 
294
324
  ### `release.pre_bump` — multi-language version bumps
295
325
 
@@ -365,12 +395,14 @@ sed -i.bak -E "s/^const Version = \".*\"/const Version = \"${ADL_NEXT_VERSION}\"
365
395
  rm -f "${file}.bak"
366
396
  ```
367
397
 
368
- `install` copies two package-owned standalone CI assets byte-for-byte:
398
+ `install` copies four package-owned standalone CI assets byte-for-byte:
369
399
 
370
400
  - `.workflows/promote.ts`
371
401
  - `.workflows/scripts/enable-integration-auto-complete.mjs`
402
+ - `.workflows/version-bump.sh`
403
+ - `.workflows/reconcile-guard.sh`
372
404
 
373
- Both installed assets are real executable files, never symlinks. Their SHA-256
405
+ All installed assets are real executable files, never symlinks. Their SHA-256
374
406
  hashes live in `.workflows/repo-controls.lock.json`, and `doctor` fails if either
375
407
  file, mode, or lock entry drifts from the canonical source under `assets/`. A
376
408
  repository opts into promotion by declaring `[controls.release]` and wiring its
@@ -380,10 +412,11 @@ without deleting the source branch or transitioning work items — it never
380
412
  names a reviewer on the PR (Tarun, 2026-09-27: naming one, typically the
381
413
  person who pushed to `integration`, is a self-approval risk and contradicts
382
414
  "agent-reviewer is optional, never required"). Before it reads or mutates PRs,
383
- it proves the exact target branch has enabled blocking repository-wide
384
- policies for at least one approval with `resetOnSourcePush = true` (the
385
- author's own vote does not count towards it) and a build definition; a
386
- missing or weaker policy fails promotion. Pre-commit calls the same read-only
415
+ it proves the exact target branch has an enabled blocking repository-wide
416
+ policy for at least one approval with `resetOnSourcePush = true` (the
417
+ author's own vote does not count towards it); a missing or weaker policy fails
418
+ promotion. No build policy is required (Tarun, 2026-09-30: the promotion PR
419
+ runs no build; the approval is the only gate). Pre-commit calls the same read-only
387
420
  doctor report and never repairs or stages the managed surface: run
388
421
  `adl repo controls install --repo .` explicitly to repair it.
389
422
 
@@ -399,6 +432,11 @@ and `X.Y.Z` subjects exempt; no staged `.scratchpad` paths or new references;
399
432
 
400
433
  ### Managed dot paths and protected controls
401
434
 
435
+ `.workflows/` is the adl-owned managed root: every file `install` writes there,
436
+ including the scripts `.workflows/promote.ts`, `.workflows/version-bump.sh` and
437
+ `.workflows/reconcile-guard.sh`, is hashed in `.workflows/repo-controls.lock.json`
438
+ and repaired by the next `install`; do not hand-edit it.
439
+
402
440
  The pre-commit gate has two deliberately different outcomes:
403
441
 
404
442
  - A changed or untracked **top-level** dot path is **commit-required**.
@@ -30,6 +30,11 @@
30
30
  * forget the other, and the derived form satisfies this package's own
31
31
  * commit-msg grammar for any pair.
32
32
  *
33
+ * The target branch's gate is Tarun's approval alone (Tarun, 2026-09-30): the
34
+ * promotion PR gets NO build validation and CD runs after the merge into the
35
+ * target. The pre-flight proves the minimum-reviewer policy (>=1 approver,
36
+ * resetOnSourcePush=true) and does not require a build policy.
37
+ *
33
38
  * There is no default pair. Defaulting to integration -> main would put one
34
39
  * repository's branch names back into the package by the back door.
35
40
  */
@@ -192,6 +197,12 @@ export interface PromotionPolicyReceipts {
192
197
  * only counts when it is enabled, blocking, and scoped exactly to the target
193
198
  * branch; a project-wide or path-filtered lookalike cannot guard a promotion.
194
199
  *
200
+ * The target's gate is the minimum-reviewer policy ONLY (Tarun, 2026-09-30):
201
+ * the integration -> main promotion PR gets no build validation, Tarun's
202
+ * approval is the only gate, and CD runs after the merge into main. Promotion
203
+ * therefore never requires a build policy. `build` is still reported so a
204
+ * caller can show one that happens to be present, but nothing here demands it.
205
+ *
195
206
  * No named-reviewer policy is checked here, deliberately (Tarun,
196
207
  * 2026-09-27): the two-branch standard's `main` gate is "exactly one human
197
208
  * reviewer, author's vote excluded" via the minimum-reviewer policy below —
@@ -245,17 +256,11 @@ async function verifyPolicyGate(
245
256
  )
246
257
  const policies = response.value ?? []
247
258
  const receipts = promotionPolicyReceipts(policies, { repositoryId, targetRef })
248
- const { minimumReviewer, build } = receipts
249
- if (!minimumReviewer) {
259
+ if (!receipts.minimumReviewer) {
250
260
  throw new Error(
251
261
  `promotion requires an enabled blocking minimum-reviewer policy for repository ${repositoryId} ${targetRef} with minimumApproverCount>=1 and resetOnSourcePush=true`,
252
262
  )
253
263
  }
254
- if (!build) {
255
- throw new Error(
256
- `promotion requires an enabled blocking build policy for repository ${repositoryId} ${targetRef} with buildDefinitionId>0`,
257
- )
258
- }
259
264
  }
260
265
 
261
266
  export async function ensurePromotionPullRequest({
@@ -0,0 +1,113 @@
1
+ #!/usr/bin/env bash
2
+ set -euo pipefail
3
+
4
+ # Stage 2 of the CI pipeline for pull requests into `integration` (stage 1
5
+ # bumps the PR branch, stage 3 runs the tests). It forces the PR author,
6
+ # usually an agent, to reconcile with integration before anything merges:
7
+ #
8
+ # Check A the PR branch contains integration's tip
9
+ # Check B the PR branch's manifest version is integration's + 1 patch
10
+ #
11
+ # Read-only: this script only fetches and reads. It never pushes, and the only
12
+ # refs it writes are the two remote-tracking refs it fetches.
13
+ #
14
+ # Run from the package directory (like version-bump.sh); the manifest is
15
+ # resolved repo-relative through `git rev-parse --show-prefix`.
16
+
17
+ # CI never carries an agent identity. Never set one here, and never inherit
18
+ # one from the caller.
19
+ unset ADL_AGENT_ID_AGENT
20
+
21
+ die() {
22
+ echo "reconcile-guard: $*" >&2
23
+ exit 1
24
+ }
25
+
26
+ version_file=''
27
+ pr_source=''
28
+ base=''
29
+
30
+ while [[ $# -gt 0 ]]; do
31
+ case "$1" in
32
+ --file)
33
+ [[ $# -ge 2 ]] || die '--file requires a path'
34
+ version_file="$2"
35
+ shift 2
36
+ ;;
37
+ --pr-source)
38
+ [[ $# -ge 2 ]] || die '--pr-source requires a branch'
39
+ pr_source="$2"
40
+ shift 2
41
+ ;;
42
+ --base)
43
+ [[ $# -ge 2 ]] || die '--base requires a branch'
44
+ base="$2"
45
+ shift 2
46
+ ;;
47
+ *)
48
+ die "unknown argument: $1"
49
+ ;;
50
+ esac
51
+ done
52
+
53
+ [[ -n "$version_file" ]] || die '--file is required'
54
+ [[ "$version_file" == 'package.json' ]] || die 'only --file package.json is supported'
55
+ [[ -n "$pr_source" ]] || die '--pr-source is required'
56
+ [[ -n "$base" ]] || die '--base is required'
57
+
58
+ # Azure Pipelines hands over Build.SourceBranch-style refs (refs/heads/x).
59
+ branch="${pr_source#refs/heads/}"
60
+ base="${base#refs/heads/}"
61
+ [[ -n "$branch" ]] || die "--pr-source '$pr_source' names no branch"
62
+
63
+ case "$branch" in
64
+ main | integration | "$base")
65
+ die "refusing to guard '$branch': it is not a pull-request source branch"
66
+ ;;
67
+ esac
68
+
69
+ prefix="$(git rev-parse --show-prefix)" || die 'not inside a git work tree'
70
+ manifest_path="${prefix}${version_file}"
71
+
72
+ base_ref="refs/remotes/origin/$base"
73
+ branch_ref="refs/remotes/origin/$branch"
74
+
75
+ git fetch --quiet origin "+refs/heads/$base:$base_ref" || die "cannot fetch origin $base"
76
+ git fetch --quiet origin "+refs/heads/$branch:$branch_ref" || die "cannot fetch origin $branch"
77
+
78
+ base_short="$(git rev-parse --short "$base_ref")" || die "cannot resolve $base_ref"
79
+
80
+ if ! git merge-base --is-ancestor "$base_ref" "$branch_ref"; then
81
+ missing="$(git rev-list --count "$branch_ref..$base_ref")"
82
+ echo "reconcile-guard: FAIL — $branch does not contain integration's tip ($base_short). Reconcile: git pull origin integration && git push" >&2
83
+ echo "reconcile-guard: $missing integration commit(s) missing from $branch" >&2
84
+ exit 1
85
+ fi
86
+
87
+ read_version() {
88
+ local ref="$1" text
89
+ text="$(git show "$ref:$manifest_path")" || die "cannot read $manifest_path at $ref"
90
+ printf '%s' "$text" | node -e '
91
+ const version = JSON.parse(require("node:fs").readFileSync(0, "utf8")).version
92
+ if (typeof version !== "string") process.exit(1)
93
+ process.stdout.write(version)
94
+ ' || die "cannot read a string version from $manifest_path at $ref"
95
+ }
96
+
97
+ parse_version() {
98
+ [[ "$1" =~ ^([0-9]+)\.([0-9]+)\.([0-9]+)$ ]] || die "cannot parse '$1' as a release version x.y.z"
99
+ }
100
+
101
+ base_version="$(read_version "$base_ref")"
102
+ branch_version="$(read_version "$branch_ref")"
103
+
104
+ parse_version "$base_version"
105
+ target="${BASH_REMATCH[1]}.${BASH_REMATCH[2]}.$((BASH_REMATCH[3] + 1))"
106
+ parse_version "$branch_version"
107
+
108
+ if [[ "$branch_version" != "$target" ]]; then
109
+ echo "reconcile-guard: FAIL — $branch version $branch_version is not integration $base_version + 1 patch ($target). Stage 1 bumps it; re-run the PR build after reconciling." >&2
110
+ exit 1
111
+ fi
112
+
113
+ echo "reconcile-guard: PASS — $branch contains integration $base_short and is at $target"