jules-orchestrator-kit 0.74.0 → 0.75.1

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.
@@ -149,51 +149,214 @@ ${extraSteps ? extraSteps + "\n" : ""}
149
149
  }
150
150
 
151
151
  /**
152
- * Write the generated workflow, refusing to clobber an existing file unless
152
+ * Generate a workflow_run auto-merge workflow for GitHub Actions that safely
153
+ * merges qualifying green PRs opened by authorized automation bots.
154
+ *
155
+ * Implements a strict trust boundary:
156
+ * - Executes exclusively on the base branch (default `main`)
157
+ * - Evaluates PR commits strictly as unexecuted data (fetch + git diff)
158
+ * - Verifies the bot actor against authorized identities (default `google-labs-jules[bot]`)
159
+ * - Merges cleanly via `gh pr merge --squash --delete-branch`
160
+ *
161
+ * @param {object} opts
162
+ * @param {object} [opts.config]
163
+ * @param {string} [opts.version]
164
+ * @param {string[]} [opts.allowedActors]
165
+ * @returns {{ file: string, content: string }}
166
+ */
167
+ export function buildAutoMergeWorkflow(opts = {}) {
168
+ const base = opts.config?.baseBranch || opts.baseBranch || "main";
169
+ const version = opts.version || "0";
170
+ const defaultActors = ["google-labs-jules[bot]", "github-actions[bot]"];
171
+ const allowedActors = opts.allowedActors || defaultActors;
172
+ const actorCases = allowedActors.map((a) => `"${a}"`).join("|");
173
+
174
+ const content = `# Generated by jules-orchestrator-kit v${version}.
175
+ #
176
+ # Auto-merge verified pull requests opened by authorized automation bots.
177
+ #
178
+ # TRUST BOUNDARY ARCHITECTURE:
179
+ # For pull requests from repository branches, GitHub Actions runs
180
+ # 'on: pull_request' workflows from the PR branch head. A pull request can
181
+ # therefore supply its own modified workflow code.
182
+ #
183
+ # This workflow runs on 'workflow_run' after 'Agent Safety Gate' completes.
184
+ # It executes STRICTLY from the default branch (${base}), ensuring that:
185
+ # 1. It only runs code, scripts, and policies committed to ${base}.
186
+ # 2. The pull request commits are fetched strictly as unexecuted data.
187
+ # 3. Privileged write tokens are never exposed to the untrusted PR branch.
188
+ #
189
+ # Regenerate with: agentctl ci init --with-automerge --force
190
+
191
+ name: Agent Auto-Merge
192
+
193
+ on:
194
+ workflow_run:
195
+ workflows: ["Agent Safety Gate"]
196
+ types: [completed]
197
+
198
+ permissions:
199
+ contents: read
200
+
201
+ concurrency:
202
+ group: agent-automerge-\${{ github.event.workflow_run.head_sha }}
203
+ cancel-in-progress: false
204
+
205
+ jobs:
206
+ automerge:
207
+ if: >-
208
+ github.event.workflow_run.conclusion == 'success' &&
209
+ github.event.workflow_run.event == 'pull_request' &&
210
+ github.event.workflow_run.head_repository.full_name == github.repository
211
+ runs-on: ubuntu-latest
212
+ permissions:
213
+ contents: write
214
+ pull-requests: write
215
+ env:
216
+ GH_TOKEN: \${{ secrets.GITHUB_TOKEN }}
217
+ REPO: \${{ github.repository }}
218
+ HEAD_SHA: \${{ github.event.workflow_run.head_sha }}
219
+ ACTOR: \${{ github.event.workflow_run.actor.login }}
220
+ steps:
221
+ - name: Checkout base revision (${base})
222
+ uses: actions/checkout@v4
223
+ with:
224
+ ref: ${base}
225
+ fetch-depth: 0
226
+ persist-credentials: false
227
+
228
+ - name: Verify authorized automation actor
229
+ run: |
230
+ set -euo pipefail
231
+ case "$ACTOR" in
232
+ ${actorCases})
233
+ echo "Authorized automation actor: $ACTOR"
234
+ ;;
235
+ *)
236
+ echo "Actor '$ACTOR' is not an authorized automation bot; leaving PR for human review."
237
+ exit 0
238
+ ;;
239
+ esac
240
+
241
+ - id: pr
242
+ name: Resolve Pull Request
243
+ run: |
244
+ set -euo pipefail
245
+ num=$(gh pr list --repo "$REPO" --state open --base "${base}" \\
246
+ --json number,headRefOid \\
247
+ --jq "[.[] | select(.headRefOid == \\"$HEAD_SHA\\")] | .[0].number // empty")
248
+ if [ -z "$num" ]; then
249
+ echo "No open PR found for head SHA $HEAD_SHA; nothing to merge."
250
+ echo "found=false" >> "$GITHUB_OUTPUT"
251
+ exit 0
252
+ fi
253
+ echo "Resolved PR #$num for $HEAD_SHA"
254
+ echo "number=$num" >> "$GITHUB_OUTPUT"
255
+ echo "found=true" >> "$GITHUB_OUTPUT"
256
+
257
+ - name: Fetch PR commits as untrusted data
258
+ if: steps.pr.outputs.found == 'true'
259
+ run: |
260
+ set -euo pipefail
261
+ git fetch --no-tags origin "+$HEAD_SHA:refs/pr-head"
262
+
263
+ - name: Set up Node.js
264
+ if: steps.pr.outputs.found == 'true'
265
+ uses: actions/setup-node@v4
266
+ with:
267
+ node-version: "20"
268
+
269
+ - name: Verify safety gate from base
270
+ if: steps.pr.outputs.found == 'true'
271
+ run: |
272
+ npx --yes jules-orchestrator-kit check --mode committed --base "${base}"
273
+
274
+ - name: Squash and merge pull request
275
+ if: steps.pr.outputs.found == 'true'
276
+ run: |
277
+ gh pr merge --squash --delete-branch "\${{ steps.pr.outputs.number }}" --repo "$REPO"
278
+ `;
279
+
280
+ return { file: ".github/workflows/agent-automerge.yml", content };
281
+ }
282
+
283
+ /**
284
+ * Write the generated workflow(s), refusing to clobber existing files unless
153
285
  * asked.
154
286
  *
155
287
  * @param {string} root
156
- * @param {object} [opts] - see {@link buildCiWorkflow}, plus `force` and `dryRun`
157
- * @returns {{ ok: boolean, file?: string, written?: boolean, stack?: string, setupSummary?: string, content?: string, error?: string }}
288
+ * @param {object} [opts] - see {@link buildCiWorkflow}, plus `force`, `dryRun`, and `withAutomerge`
289
+ * @returns {{ ok: boolean, file?: string, files?: string[], written?: boolean, stack?: string, setupSummary?: string, content?: string, error?: string }}
158
290
  */
159
291
  export function writeCiWorkflow(root, opts = {}) {
292
+ const target = CI_TARGETS.includes(opts.target) ? opts.target : (opts.target ? opts.target : "github");
160
293
  if (opts.target && !CI_TARGETS.includes(opts.target)) {
161
294
  return { ok: false, error: `Unknown CI target '${opts.target}'. Choose one of: ${CI_TARGETS.join(", ")}` };
162
295
  }
163
296
 
297
+ const withAutomerge = Boolean(opts.withAutomerge || opts["with-automerge"]);
298
+ if (withAutomerge && target !== "github") {
299
+ return { ok: false, error: "Auto-merge workflow is currently only supported for GitHub Actions." };
300
+ }
301
+
164
302
  const built = buildCiWorkflow(opts);
165
303
  const stackId = opts.stack?.stack || "unknown";
166
- const abs = join(root, built.file);
304
+ const itemsToWrite = [built];
167
305
 
168
- if (opts.dryRun) {
169
- return { ok: true, file: built.file, written: false, stack: stackId, setupSummary: built.setupSummary, content: built.content };
306
+ if (withAutomerge) {
307
+ itemsToWrite.push(buildAutoMergeWorkflow(opts));
170
308
  }
171
309
 
172
- if (existsSync(abs) && !opts.force) {
173
- let identical = false;
174
- try {
175
- identical = readFileSync(abs, "utf-8") === built.content;
176
- } catch (_) {
177
- // Unreadable target: treat as different so the caller is told to --force
178
- // rather than being told nothing changed.
179
- }
310
+ const files = itemsToWrite.map((item) => item.file);
311
+
312
+ if (opts.dryRun) {
180
313
  return {
181
314
  ok: true,
182
315
  file: built.file,
316
+ files,
183
317
  written: false,
184
- identical,
185
318
  stack: stackId,
186
319
  setupSummary: built.setupSummary,
187
320
  content: built.content,
321
+ workflows: itemsToWrite,
188
322
  };
189
323
  }
190
324
 
191
- try {
192
- mkdirSync(dirname(abs), { recursive: true });
193
- writeFileSync(abs, built.content, "utf-8");
194
- } catch (err) {
195
- return { ok: false, error: `Could not write ${built.file}: ${err.message}` };
325
+ let allWritten = true;
326
+ let allIdentical = true;
327
+ for (const item of itemsToWrite) {
328
+ const abs = join(root, item.file);
329
+ if (existsSync(abs) && !opts.force) {
330
+ let identical = false;
331
+ try {
332
+ identical = readFileSync(abs, "utf-8") === item.content;
333
+ } catch (_) {
334
+ // Unreadable target: treat as different so the caller is told to --force
335
+ // rather than being told nothing changed.
336
+ }
337
+ if (!identical) {
338
+ allIdentical = false;
339
+ }
340
+ allWritten = false;
341
+ continue;
342
+ }
343
+
344
+ try {
345
+ mkdirSync(dirname(abs), { recursive: true });
346
+ writeFileSync(abs, item.content, "utf-8");
347
+ } catch (err) {
348
+ return { ok: false, error: `Could not write ${item.file}: ${err.message}` };
349
+ }
196
350
  }
197
351
 
198
- return { ok: true, file: built.file, written: true, stack: stackId, setupSummary: built.setupSummary, content: built.content };
352
+ return {
353
+ ok: true,
354
+ file: built.file,
355
+ files,
356
+ written: allWritten,
357
+ identical: allWritten ? false : allIdentical,
358
+ stack: stackId,
359
+ setupSummary: built.setupSummary,
360
+ content: built.content,
361
+ };
199
362
  }
package/src/config.mjs CHANGED
@@ -34,7 +34,7 @@ const DEFAULTS = {
34
34
  // use: an identical exfiltration job placed in `.gitlab-ci.yml` was approved
35
35
  // where `.github/workflows/x.yml` was rejected. A safety gate that is only
36
36
  // safe on GitHub is not a safety gate.
37
- const CI_DEFINITIONS = [
37
+ export const CI_DEFINITIONS = [
38
38
  ".github/**",
39
39
  ".gitlab-ci.yml",
40
40
  "**/.gitlab-ci.yml",
@@ -1019,9 +1019,11 @@ export function resolveTrustedPolicy(root = process.cwd(), baseRef = null, mode
1019
1019
 
1020
1020
  let trustedConfigRaw = null;
1021
1021
  let trustedJulesRaw = null;
1022
+ let trustedProtectedRaw = null;
1022
1023
  try {
1023
1024
  trustedConfigRaw = showFromOrigin(root, base, ".agent/config.yml");
1024
1025
  trustedJulesRaw = showFromOrigin(root, base, ".agent/jules.yml");
1026
+ trustedProtectedRaw = showFromOrigin(root, base, ".agent/protected-paths.json");
1025
1027
  } catch (_) {
1026
1028
  // If base branch cannot be resolved or show fails, let changedFiles report code 1
1027
1029
  }
@@ -1035,7 +1037,21 @@ export function resolveTrustedPolicy(root = process.cwd(), baseRef = null, mode
1035
1037
  if (raw) parsed = parseYaml(raw) || {};
1036
1038
  } catch (_) {}
1037
1039
 
1040
+ let trustedProtectedPaths = [];
1041
+ if (trustedProtectedRaw) {
1042
+ try {
1043
+ const parsedProtected = JSON.parse(trustedProtectedRaw);
1044
+ if (Array.isArray(parsedProtected.protected)) {
1045
+ trustedProtectedPaths = parsedProtected.protected.filter((p) => typeof p === "string" && p);
1046
+ }
1047
+ } catch (_) {}
1048
+ }
1049
+
1038
1050
  const trustedScope = normalizeScope(parsed);
1051
+ if (trustedProtectedPaths.length > 0) {
1052
+ trustedScope.protect = dedupe([...trustedScope.protect, ...trustedProtectedPaths]);
1053
+ trustedScope.protectedPatterns = trustedProtectedPaths;
1054
+ }
1039
1055
  const trustedDiffKb = Number(parsed.limits?.diff_kb || parsed.limits?.diffKb) || 75;
1040
1056
 
1041
1057
  const rawSetup = parsed.setup_cmd ?? parsed.verify?.setup;
@@ -1100,6 +1116,7 @@ export function resolveTrustedPolicy(root = process.cwd(), baseRef = null, mode
1100
1116
  // Inspect any proposed scaffold in this change (F06)
1101
1117
  let proposedConfig = null;
1102
1118
  let proposedJules = null;
1119
+ let proposedProtectedPaths = [];
1103
1120
 
1104
1121
  try {
1105
1122
  if (mode === "committed") {
@@ -1107,11 +1124,29 @@ export function resolveTrustedPolicy(root = process.cwd(), baseRef = null, mode
1107
1124
  if (c) proposedConfig = parseYaml(c);
1108
1125
  const j = showFromOrigin(root, "HEAD", ".agent/jules.yml");
1109
1126
  if (j) proposedJules = parseYaml(j);
1127
+ const p = showFromOrigin(root, "HEAD", ".agent/protected-paths.json");
1128
+ if (p) {
1129
+ try {
1130
+ const parsedP = JSON.parse(p);
1131
+ if (Array.isArray(parsedP.protected)) {
1132
+ proposedProtectedPaths = parsedP.protected.filter((x) => typeof x === "string" && x);
1133
+ }
1134
+ } catch (_) {}
1135
+ }
1110
1136
  } else {
1111
1137
  const configPath = join(root, ".agent/config.yml");
1112
1138
  if (existsSync(configPath)) proposedConfig = parseYaml(readFileSync(configPath, "utf-8"));
1113
1139
  const julesPath = join(root, ".agent/jules.yml");
1114
1140
  if (existsSync(julesPath)) proposedJules = parseYaml(readFileSync(julesPath, "utf-8"));
1141
+ const protectedPath = join(root, ".agent/protected-paths.json");
1142
+ if (existsSync(protectedPath)) {
1143
+ try {
1144
+ const parsedP = JSON.parse(readFileSync(protectedPath, "utf-8"));
1145
+ if (Array.isArray(parsedP.protected)) {
1146
+ proposedProtectedPaths = parsedP.protected.filter((x) => typeof x === "string" && x);
1147
+ }
1148
+ } catch (_) {}
1149
+ }
1115
1150
  }
1116
1151
  } catch (_) {}
1117
1152
 
@@ -1182,6 +1217,10 @@ export function resolveTrustedPolicy(root = process.cwd(), baseRef = null, mode
1182
1217
 
1183
1218
  const stages = explicitStages ?? profilePlan?.stages ?? null;
1184
1219
  const trustedScope = normalizeScope(pConfig);
1220
+ if (proposedProtectedPaths.length > 0) {
1221
+ trustedScope.protect = dedupe([...trustedScope.protect, ...proposedProtectedPaths]);
1222
+ trustedScope.protectedPatterns = proposedProtectedPaths;
1223
+ }
1185
1224
  const trustedLimits = {
1186
1225
  diffKb: Number(pConfig.limits?.diff_kb || pConfig.limits?.diffKb) || 75,
1187
1226
  };