@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.
- package/bin/adl +2 -2
- package/bin/git +1 -1
- package/bin/git-ado +2 -2
- package/bin/lib/bun-compile-build.sh +1 -1
- package/bin/lib/fleet-host.sh +59 -0
- package/bin/lib/lean-ctx-observe-hooks.sh +9 -9
- package/bin/reconcile +222 -249
- package/bin/reconcile-host +111 -0
- package/bin/setup +216 -0
- package/bin/sync-and-reconcile +52 -39
- package/bin/validate-manifest +15 -44
- package/package.json +1 -3
- package/packages/agent-hooks/package.json +1 -2
- 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/fs-trash.ts +2 -2
- 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/agent-settings/assets/GLOBAL-AGENTS.md +4 -2
- package/packages/agent-settings/src/global-surface-sweep.ts +5 -15
- package/packages/agent-skills/package.json +1 -1
- package/packages/azdo/package.json +1 -2
- package/packages/azdo/src/azdo-repo-doctor.ts +70 -75
- package/packages/fleet/manifest.json +5 -24
- package/packages/fleet/manifest.schema.json +8 -58
- package/packages/fleet/src/install-embedding-model.ts +1 -1
- package/packages/fleet/src/repo-maintain.ts +403 -0
- package/packages/fleet/src/scratch-reap.ts +1 -1
- 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/package.json +0 -1
- 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/packages/fleet/jobs/git-maintain/src/main.ts +0 -848
- package/packages/fleet/systemd/adl-azure-agent-update.service +0 -13
- 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
|
|
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
|
|
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"
|