-
Notifications
You must be signed in to change notification settings - Fork 457
Expand file tree
/
Copy pathpush_to_pull_request_branch.cjs
More file actions
1681 lines (1546 loc) · 83 KB
/
Copy pathpush_to_pull_request_branch.cjs
File metadata and controls
1681 lines (1546 loc) · 83 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
// @ts-check
/// <reference types="@actions/github-script" />
/** @type {typeof import("fs")} */
const fs = require("fs");
const { generateStagedPreview } = require("./staged_preview.cjs");
const { isStagedMode } = require("./safe_output_helpers.cjs");
const { pushSignedCommits } = require("./push_signed_commits.cjs");
const { updateActivationCommentWithCommit, updateActivationComment } = require("./update_activation_comment.cjs");
const { getErrorMessage } = require("./error_helpers.cjs");
const { withRetry, RATE_LIMIT_RETRY_CONFIG } = require("./error_recovery.cjs");
const { normalizeBranchName } = require("./normalize_branch_name.cjs");
const { pushExtraEmptyCommit } = require("./extra_empty_commit.cjs");
const { detectForkPR, checkBranchPushable } = require("./pr_helpers.cjs");
const { resolveTargetRepoConfig, resolveAndValidateRepo } = require("./repo_helpers.cjs");
const { createAuthenticatedGitHubClient } = require("./handler_auth.cjs");
const { checkFileProtection, checkFileProtectionPostApply } = require("./manifest_file_helpers.cjs");
const { buildWorkflowRunUrl } = require("./workflow_metadata_helpers.cjs");
const { renderTemplateFromFile, buildProtectedFileList, getPromptPath } = require("./messages_core.cjs");
const { overridePersistedExtraheader, restorePersistedExtraheader } = require("./git_auth_helpers.cjs");
const { ensureFullHistoryForBundle, extractBundlePrerequisiteCommits, isShallowOrSparseCheckout, linearizeRangeAsCommit, ensureSafeDirectoryTrust } = require("./git_helpers.cjs");
const { normalizeCommitSHA } = require("./commit_sha_helpers.cjs");
const { findRepoCheckout } = require("./find_repo_checkout.cjs");
const { getThreatDetectedMarker } = require("./threat_detection_warning.cjs");
const { attachExecutionState } = require("./safe_output_execution_metadata.cjs");
const { resolveTransportPaths } = require("./resolve_transport_paths.cjs");
/**
* @typedef {import('./types/handler-factory').HandlerFactoryFunction} HandlerFactoryFunction
*/
/** @type {string} Safe output type handled by this module */
const HANDLER_TYPE = "push_to_pull_request_branch";
const MISSING_BRANCH_ERROR_TEMPLATE = branchName => `Branch ${branchName} no longer exists on origin (it may have been deleted), can't push to it.`;
const MISSING_REMOTE_REF_PATTERNS = [
"couldn't find remote ref",
"could not find remote ref",
"remote ref does not exist",
"did not match any file(s) known to git",
"unknown revision or path not in the working tree",
"fatal: couldn't find remote ref",
"exit code 128",
];
/**
* @param {unknown} value
* @returns {boolean}
*/
function looksLikeMissingRemoteBranchError(value) {
const text = String(value ?? "").toLowerCase();
return MISSING_REMOTE_REF_PATTERNS.some(pattern => text.includes(pattern));
}
/**
* @param {unknown} rawAwContext
* @returns {{ item_type: string, item_number: number | null } | null}
*/
function parseAwContext(rawAwContext) {
/**
* @param {unknown} parsed
* @returns {{ item_type: string, item_number: number | null } | null}
*/
function validateAndNormalizeParsedContext(parsed) {
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
return null;
}
const parsedObj = /** @type {Record<string, unknown>} */ parsed;
const itemTypeValue = parsedObj["item_type"];
const itemNumberValue = parsedObj["item_number"];
const itemType = typeof itemTypeValue === "string" ? itemTypeValue : "";
const itemNumber = parsePositiveInteger(itemNumberValue);
return { item_type: itemType, item_number: itemNumber };
}
if (rawAwContext == null) {
return null;
}
if (typeof rawAwContext === "string") {
const trimmed = rawAwContext.trim();
if (!trimmed) {
return null;
}
try {
const parsed = JSON.parse(trimmed);
return validateAndNormalizeParsedContext(parsed);
} catch {
return null;
}
}
return validateAndNormalizeParsedContext(rawAwContext);
}
/**
* Parses a value into a positive integer.
*
* @param {unknown} value
* @returns {number | null}
*/
function parsePositiveInteger(value) {
if (typeof value !== "string" && typeof value !== "number") {
return null;
}
const parsed = Number.parseInt(String(value), 10);
return Number.isInteger(parsed) && parsed > 0 ? parsed : null;
}
/**
* Temporarily override the persisted GitHub extraheader for remote git operations.
*
* @template T
* @param {string} token
* @param {() => Promise<T>} callback
* @param {string} [cwd] - Optional working directory; scopes the git config override to the correct checkout
* @returns {Promise<T>}
*/
async function withGitHubHostToken(token, callback, cwd) {
if (!token) {
return callback();
}
const githubServerUrl = (process.env.GITHUB_SERVER_URL || "https://github.com").replace(/\/+$/, "");
let previousExtraheaders = [];
let overrideApplied = false;
try {
previousExtraheaders = await overridePersistedExtraheader(githubServerUrl, token, cwd);
overrideApplied = true;
return await callback();
} finally {
if (overrideApplied) {
await restorePersistedExtraheader(githubServerUrl, previousExtraheaders, cwd);
}
}
}
/**
* Uses git as the source of truth for the files modified by a fetched bundle ref.
*
* @param {{ getExecOutput: (command: string, args?: string[], options?: any) => Promise<{ stdout: string }> }} exec
* @param {Record<string, unknown>} gitOptions
* @param {string} rangeBaseRef
* @param {string} bundleRef
* @returns {Promise<string[]>}
*/
async function getBundlePreApplyFiles(exec, gitOptions, rangeBaseRef, bundleRef) {
const bundleDiffResult = await exec.getExecOutput("git", ["diff", "--name-only", "--no-renames", `${rangeBaseRef}..${bundleRef}`], gitOptions);
return bundleDiffResult.stdout
.split("\n")
.map(f => f.trim())
.filter(Boolean);
}
/**
* Checks if a git push stderr output indicates that the 'workflows' scope is required.
* GitHub rejects branch pushes that contain .github/workflows/** changes when the token
* lacks the 'workflows' scope, producing one of two known error message variants.
*
* @param {string} stderr - The captured stderr from a failed git push
* @returns {boolean} true when the rejection is due to missing 'workflows' scope
*/
function isWorkflowsScopeRejection(stderr) {
if (!stderr) return false;
const lower = stderr.toLowerCase();
return lower.includes("`workflows` scope") || lower.includes("workflow can be created or updated due to timeout");
}
/**
* Returns the list of unique workflow file paths (.github/workflows/**) present in the
* local branch history beyond the PR's base branch. This is used as a pre-flight check
* before pushing a new branch ref: GitHub rejects such pushes when the token lacks the
* 'workflows' scope, even if the current changeset itself does not touch workflow files
* (the rejection is based on ALL commits reachable from the pushed ref).
*
* Uses `origin/${baseBranch}` as the exclusion baseline so that commits already on the
* PR's target branch (which GitHub has already accepted) are excluded. Falls back to
* `origin/HEAD` when `baseBranch` is not available. In shallow PR checkouts where the
* named remote ref is not fetched, falls back to `GITHUB_BASE_SHA` (always present as a
* commit object in GitHub Actions `pull_request` events). Returns an empty array only
* when all baselines are exhausted — in that case the push is still attempted and any
* real 'workflows' scope rejection will be caught and surfaced as the typed error
* downstream.
*
* Note: `origin/${baseBranch}` and `origin/HEAD` are intentionally different baselines
* for their respective layers. `origin/${baseBranch}` limits detection to commits the
* agent actually introduced (correct for the PR delta). Using `origin/HEAD` here would
* traverse commits on the target branch itself for PRs targeting non-default branches,
* producing false-positive `workflows_scope_required` errors.
*
* @param {{ getExecOutput: Function }} exec - @actions/exec module (or compatible mock)
* @param {Record<string, any>} gitOptions - Base git exec options (cwd, env, etc.)
* @param {string | undefined} baseBranch - PR base branch name (e.g. "main"); falls back to origin/HEAD when not provided
* @param {typeof core} coreLogger - Actions core logger used for debug output
* @returns {Promise<string[]>} Unique workflow file paths found in the branch history
*/
async function detectWorkflowFileChanges(exec, gitOptions, baseBranch, coreLogger) {
const primary = baseBranch && baseBranch.trim() ? `origin/${baseBranch}` : "origin/HEAD";
const baselines = [primary];
const githubBaseSha = process.env.GITHUB_BASE_SHA;
if (githubBaseSha && githubBaseSha.trim()) {
baselines.push(githubBaseSha.trim());
}
for (const baseline of baselines) {
try {
const result = await exec.getExecOutput("git", ["log", "--name-only", "--pretty=format:", "HEAD", "--not", baseline, "--", ".github/workflows/"], { ...gitOptions, ignoreReturnCode: true });
if (result.exitCode !== 0) {
// Non-zero exit means the baseline ref was not resolvable (e.g. shallow clone
// without the named remote ref fetched); try the next fallback.
coreLogger.debug(`detectWorkflowFileChanges: git log exited ${result.exitCode} (baseline '${baseline}' may be unavailable); trying next fallback`);
continue;
}
const files = [
...new Set(
result.stdout
.split("\n")
.map(f => f.trim())
.filter(Boolean)
),
];
if (baseline !== primary) {
coreLogger.debug(`detectWorkflowFileChanges: used fallback baseline '${baseline}'; found ${files.length} workflow file(s)`);
}
return files;
} catch (err) {
coreLogger.debug(`detectWorkflowFileChanges: git log threw (baseline '${baseline}'): ${getErrorMessage(err)}; trying next fallback`);
}
}
coreLogger.debug(`detectWorkflowFileChanges: all baselines exhausted; skipping pre-flight`);
return [];
}
/**
* Performs a pre-flight workflow-scope check before pushing a new branch ref.
* Returns a non-fatal skip result when the branch history contains workflow file changes
* but the agent's own changeset has none (scope requirement originates from pre-existing
* commits). Returns a hard typed error only when the agent itself staged workflow files.
* Returns null when the push may proceed.
*
* Extracts the duplicated guard that appears in both the review-branch and
* fallback-branch push paths so future changes only need to be made in one place.
*
* @param {{ getExecOutput: Function }} exec - @actions/exec module (or compatible mock)
* @param {Record<string, any>} gitOptions - Base git exec options (cwd, env, etc.)
* @param {boolean} allowWorkflows - Whether the push token has the 'workflows' scope
* @param {string | undefined} baseBranch - PR base branch name passed through to detectWorkflowFileChanges
* @param {string} context - Short label for the push path (e.g. "Review branch", "Fallback branch")
* @param {typeof core} coreLogger - Actions core logger
* @param {string[] | undefined} agentChangedFiles - Files from the agent's post-apply diff; used to distinguish agent vs pre-existing workflow changes.
* Pass `undefined` when the distinction cannot be made — the function will fall back to the original hard-error behavior.
* @returns {Promise<{ success: false, error_type: string, error: string } | { success: false, skipped: true, error: string } | null>}
*/
async function runWorkflowScopePreflightCheck(exec, gitOptions, allowWorkflows, baseBranch, context, coreLogger, agentChangedFiles) {
if (allowWorkflows) return null;
const workflowFiles = await detectWorkflowFileChanges(exec, gitOptions, baseBranch, coreLogger);
if (workflowFiles.length > 0) {
coreLogger.info(`Pre-flight check: branch history contains workflow file changes (${workflowFiles.join(", ")}). Failing before push attempt.`);
if (agentChangedFiles !== undefined) {
const agentWorkflowFiles = agentChangedFiles.filter(f => f.startsWith(".github/workflows/"));
if (agentWorkflowFiles.length === 0) {
return buildWorkflowsScopeSkip(context, coreLogger);
}
}
return buildWorkflowsScopeError(`${context} pre-flight`, coreLogger);
}
return null;
}
/**
* Builds the typed result and logs actionable guidance when a branch push fails
* because the token lacks the 'workflows' scope.
*
* @param {string} context - Short label identifying the push path (e.g. "Review branch", "Fallback branch")
* @param {typeof core} coreLogger - Actions core logger
* @returns {{ success: false, error_type: "workflows_scope_required", error: string }}
*/
function buildWorkflowsScopeError(context, coreLogger) {
coreLogger.error(`${context} push rejected: the branch includes changes to workflow files (.github/workflows/**) that require the 'workflows' scope on the push token.`);
coreLogger.error("To allow this workflow to push workflow file changes, configure 'push-to-pull-request-branch.allow-workflows: true' together with a GitHub App in 'safe-outputs.github-app'.");
return {
success: false,
error_type: "workflows_scope_required",
error: `${context} push rejected: the branch includes changes to workflow files (.github/workflows/**) requiring the 'workflows' scope. The token used for the safe-outputs checkout does not have this scope. Fix: configure 'push-to-pull-request-branch.allow-workflows: true' with a GitHub App in 'safe-outputs.github-app', or exclude workflow files from the changeset.`,
};
}
/**
* Builds a non-fatal skip result and emits a warning when the branch history requires
* the 'workflows' scope but the scope requirement originates from pre-existing commits
* rather than the agent's own changeset.
*
* @param {string} context - Short label identifying the push path (e.g. "Review branch", "Fallback branch")
* @param {typeof core} coreLogger - Actions core logger
* @returns {{ success: false, skipped: true, error: string }}
*/
function buildWorkflowsScopeSkip(context, coreLogger) {
const message =
`${context}: branch history contains workflow file changes (.github/workflows/**) requiring the 'workflows' scope, ` +
`but the agent's own changeset does not include workflow files — the scope requirement originates from pre-existing commits. ` +
`Skipping push to avoid a scope rejection. ` +
`To allow pushing workflow file changes, configure 'push-to-pull-request-branch.allow-workflows: true' with a GitHub App in 'safe-outputs.github-app'.`;
coreLogger.warning(message);
return { success: false, skipped: true, error: message };
}
/**
* Main handler factory for push_to_pull_request_branch
* Returns a message handler function that processes individual push_to_pull_request_branch messages
* @type {HandlerFactoryFunction}
*/
async function main(config = {}) {
// Extract configuration from config parameter
const target = config.target || "triggering";
const titlePrefix = config.title_prefix || "";
const rawRequiredLabels = config.required_labels ?? config.labels;
const envLabels = rawRequiredLabels ? (Array.isArray(rawRequiredLabels) ? rawRequiredLabels : rawRequiredLabels.split(",")).map(label => String(label).trim()).filter(label => label) : [];
const ifNoChanges = config.if_no_changes || "warn";
const ignoreMissingBranchFailure = config.ignore_missing_branch_failure === true;
const fallbackAsPullRequest = config.fallback_as_pull_request !== false;
const checkBranchProtection = config.check_branch_protection !== false;
const signedCommits = config.signed_commits !== false;
const commitTitleSuffix = config.commit_title_suffix || "";
const maxSizeKb = parsePositiveInteger(config.max_patch_size) ?? 4096;
const maxCount = config.max || 0; // 0 means no limit
const allowWorkflows = config.allow_workflows === true;
// Cross-repo support: resolve target repository from config
// This allows pushing to PRs in a different repository than the workflow
const { defaultTargetRepo, allowedRepos } = resolveTargetRepoConfig(config);
const githubClient = await createAuthenticatedGitHubClient(config);
// Ensure the workspace is trusted in the bridge process (Process Safe Outputs step).
// The bridge runs outside the Docker container as a potentially different user/HOME,
// so the in-container `git config --global safe.directory` may not be visible here.
// Using GIT_CONFIG_* env vars avoids relying on ~/.gitconfig and covers cases where
// the conditional "Configure Git credentials" step was skipped or HOME differs.
ensureSafeDirectoryTrust(process.env.GITHUB_WORKSPACE || process.cwd());
// Git network operations authenticate using the credentials actions/checkout
// persisted into .git/config for the safe_outputs job (persist-credentials: true,
// using the resolved push token). We intentionally do NOT inject an additional
// http.extraheader via GIT_CONFIG_* here: doing so duplicates the Authorization
// header already present in .git/config and causes the server to reject the request
// with "Duplicate header: Authorization" (HTTP 400) on git fetch/push. GitHub API
// ("gh") operations authenticate separately via the authenticated Octokit client above.
const gitAuthEnv = {};
// Base branch from config (if set) - used only for logging at factory level
// Dynamic base branch resolution happens per-message after resolving the actual target repo
const configBaseBranch = config.base_branch || null;
const configuredHeadRepo = typeof config["head-repo"] === "string" ? config["head-repo"].trim() : "";
const headGitHubToken = typeof config["head-github-token"] === "string" ? config["head-github-token"].trim() : "";
// Check if we're in staged mode (either globally or per-handler config)
const isStaged = isStagedMode(config);
core.info(`Target: ${target}`);
if (configBaseBranch) {
core.info(`Base branch (from config): ${configBaseBranch}`);
}
if (titlePrefix) {
core.info(`Title prefix: ${titlePrefix}`);
}
if (envLabels.length > 0) {
core.info(`Required labels: ${envLabels.join(", ")}`);
}
core.info(`If no changes: ${ifNoChanges}`);
core.info(`Ignore missing branch failure: ${ignoreMissingBranchFailure}`);
core.info(`Fallback as pull request: ${fallbackAsPullRequest}`);
core.info(`Check branch protection: ${checkBranchProtection}`);
core.info(`Push signed commits: ${signedCommits}`);
if (commitTitleSuffix) {
core.info(`Commit title suffix: ${commitTitleSuffix}`);
}
core.info(`Max patch size: ${maxSizeKb} KB`);
core.info(`Max count: ${maxCount || "unlimited"}`);
core.info(`Default target repo: ${defaultTargetRepo}`);
if (configuredHeadRepo) {
core.info(`Configured head repo: ${configuredHeadRepo}`);
}
if (allowedRepos.size > 0) {
core.info(`Allowed repos: ${[...allowedRepos].join(", ")}`);
}
// Track how many items we've processed for max limit
let processedCount = 0;
/**
* Message handler function - processes individual push_to_pull_request_branch messages
* @param {any} message - The push_to_pull_request_branch message to process
* @param {import('./types/handler-factory').ResolvedTemporaryIds} resolvedTemporaryIds - Map of temporary IDs to resolved IDs
* @returns {Promise<import('./types/handler-factory').HandlerResult>}
*/
return async function handlePushToPullRequestBranch(message, resolvedTemporaryIds) {
// Check max count
if (maxCount > 0 && processedCount >= maxCount) {
core.info(`Skipping message - max count (${maxCount}) reached`);
return { success: false, error: `Max count (${maxCount}) reached`, skipped: true };
}
processedCount++;
// Determine the patch and bundle file paths. The MCP server sets these on
// the entry it writes, but the validation step strips them as a defense
// against agent-forged values. Recover them by re-deriving from `branch`.
const transportPaths = resolveTransportPaths(message, defaultTargetRepo);
const patchFilePath = transportPaths.patchPath;
core.info(`Patch file path: ${patchFilePath || "(not set)"}`);
// Determine the bundle file path from the message (set when patch-format: bundle is configured)
const bundleFilePath = transportPaths.bundlePath;
if (bundleFilePath) {
core.info(`Bundle file path: ${bundleFilePath}`);
}
// Check if bundle or patch file exists
const hasBundleFile = !!(bundleFilePath && fs.existsSync(bundleFilePath));
const hasPatchFile = !!(patchFilePath && fs.existsSync(patchFilePath));
const applyTransport = hasBundleFile ? "bundle" : "patch";
core.info(`Apply transport mode: ${applyTransport} (patch file present: ${hasPatchFile}, bundle file present: ${hasBundleFile})`);
if (bundleFilePath && !hasBundleFile) {
core.warning(`Bundle file path was provided but file is not present on disk: ${bundleFilePath}; falling back to patch transport`);
}
// Always require a patch file. The patch remains the preview/debug artifact and
// the first-pass validation input; bundle transport adds an authoritative
// pre-apply git diff check later after the bundle ref has been fetched.
if (!hasPatchFile) {
const msg = "No patch file found - cannot push without changes";
switch (ifNoChanges) {
case "error":
return { success: false, error: msg };
case "ignore":
return { success: false, error: msg, skipped: true };
case "warn":
default:
core.info(msg);
return { success: false, error: msg, skipped: true };
}
}
let patchContent = fs.readFileSync(patchFilePath, "utf8");
// Check for actual error conditions
if (patchContent.includes("Failed to generate patch")) {
const msg = "Patch file contains error message - cannot push without changes";
core.error("Patch file generation failed");
core.error(`Patch file location: ${patchFilePath}`);
core.error(`Patch file size: ${Buffer.byteLength(patchContent, "utf8")} bytes`);
const previewLength = Math.min(500, patchContent.length);
core.error(`Patch file preview (first ${previewLength} characters):`);
core.error(patchContent.substring(0, previewLength));
return { success: false, error: msg };
}
const isEmpty = !patchContent || !patchContent.trim();
// Validate patch/bundle size against `max_patch_size`.
//
// Size-check source of truth, in order of preference:
// 1. `message.diff_size` — the incremental net diff size recorded at
// patch/bundle generation time (this is the correct quantity to cap:
// how much the PR branch will actually change as a result of the push).
// 2. For bundle transport: the on-disk bundle file size.
// 3. For patch transport: the format-patch file size.
//
// Using `diff_size` when present fixes the long-running branch case where
// the transport file accumulates per-commit metadata + per-commit diffs and
// can be many MB even when each iteration only changes a few KB.
if (!isEmpty) {
const patchSizeBytes = Buffer.byteLength(patchContent, "utf8");
const patchSizeKb = Math.ceil(patchSizeBytes / 1024);
let bundleSizeBytes = 0;
if (hasBundleFile) {
try {
bundleSizeBytes = fs.statSync(bundleFilePath).size;
} catch (statErr) {
core.warning(`Failed to stat bundle file for size check: ${getErrorMessage(statErr)}`);
}
}
const bundleSizeKb = Math.ceil(bundleSizeBytes / 1024);
const diffSizeBytesRaw = message.diff_size;
const haveDiffSize = typeof diffSizeBytesRaw === "number" && diffSizeBytesRaw >= 0;
let sizeForCheckBytes;
let sizeLabel;
if (haveDiffSize) {
sizeForCheckBytes = diffSizeBytesRaw;
sizeLabel = "Incremental diff size";
} else if (hasBundleFile) {
sizeForCheckBytes = bundleSizeBytes;
sizeLabel = "Bundle size";
} else {
sizeForCheckBytes = patchSizeBytes;
sizeLabel = "Patch size";
}
const sizeForCheckKb = Math.ceil(sizeForCheckBytes / 1024);
if (hasBundleFile) {
core.info(`Bundle file size: ${bundleSizeKb} KB`);
} else {
core.info(`Patch file size: ${patchSizeKb} KB`);
}
core.info(`${sizeLabel}: ${sizeForCheckKb} KB (maximum allowed: ${maxSizeKb} KB)`);
if (sizeForCheckKb > maxSizeKb) {
let msg;
if (haveDiffSize) {
const transportLabel = hasBundleFile ? `Bundle size: ${bundleSizeKb} KB` : `Patch file size: ${patchSizeKb} KB`;
msg = `Incremental diff size (${sizeForCheckKb} KB) exceeds maximum allowed size (${maxSizeKb} KB). ${transportLabel}.`;
} else if (hasBundleFile) {
msg = `Bundle size (${sizeForCheckKb} KB) exceeds maximum allowed size (${maxSizeKb} KB)`;
} else {
msg = `Patch size (${sizeForCheckKb} KB) exceeds maximum allowed size (${maxSizeKb} KB)`;
}
return { success: false, error: msg };
}
core.info("Patch size validation passed");
}
// Check file protection: allowlist (strict) or protected-files policy.
// Fallback-to-issue detection is deferred until after PR metadata is resolved below.
/** @type {string[] | null} Protected files found in the patch (manifest basenames + path-prefix matches) */
let protectedFilesForFallback = null;
if (!isEmpty) {
const protection = checkFileProtection(patchContent, config);
if (protection.action === "deny") {
const filesStr = protection.files.join(", ");
const msg =
protection.source === "allowlist"
? `Cannot push to pull request branch: patch modifies files outside the allowed-files list (${filesStr}). Add the files to the allowed-files configuration field or remove them from the patch.`
: `Cannot push to pull request branch: patch modifies protected files (${filesStr}). Add them to the allowed-files configuration field or set protected-files: fallback-to-issue to create a review issue instead.`;
core.error(msg);
return { success: false, error: msg };
}
if (protection.action === "fallback") {
protectedFilesForFallback = protection.files;
core.warning(`Protected file protection triggered (fallback-to-issue): ${protection.files.join(", ")}. Will create review issue instead of pushing.`);
}
}
if (isEmpty) {
const msg = "Patch file is empty - no changes to apply (noop operation)";
switch (ifNoChanges) {
case "error":
return { success: false, error: "No changes to push - failing as configured by if-no-changes: error" };
case "ignore":
return { success: false, error: msg, skipped: true };
case "warn":
default:
core.info(msg);
return { success: false, error: msg, skipped: true };
}
}
core.info("Patch content validation passed");
core.info(`Target configuration: ${target}`);
// If in staged mode, emit 🎭 Staged Mode Preview via generateStagedPreview
if (isStaged) {
await generateStagedPreview({
title: "Push to PR Branch",
description: "The following changes would be pushed if staged mode was disabled:",
items: [{ target, commit_message: message.commit_message }],
renderItem: item => {
let content = `**Target:** ${item.target}\n\n`;
if (item.commit_message) {
content += `**Commit Message:** ${item.commit_message}\n\n`;
}
if (patchFilePath && fs.existsSync(patchFilePath)) {
const patchStats = fs.readFileSync(patchFilePath, "utf8");
if (patchStats.trim()) {
content += `**Changes:** Patch file exists with ${patchStats.split("\n").length} lines\n\n`;
content += `<details><summary>Show patch preview</summary>\n\n\`\`\`diff\n${patchStats.slice(0, 2000)}${patchStats.length > 2000 ? "\n... (truncated)" : ""}\n\`\`\`\n\n</details>\n\n`;
} else {
content += `**Changes:** No changes (empty patch)\n\n`;
}
}
return content;
},
});
return { success: true, staged: true };
}
// Validate target configuration
if (target !== "*" && target !== "triggering") {
const pullNumber = parseInt(target, 10);
if (isNaN(pullNumber)) {
return { success: false, error: 'Invalid target configuration: must be "triggering", "*", or a valid pull request number' };
}
}
// Compute the target branch name based on target configuration
let pullNumber;
if (target === "triggering") {
pullNumber = typeof context !== "undefined" ? context.payload?.pull_request?.number || context.payload?.issue?.number : undefined;
if (!pullNumber) {
const awContext = typeof context !== "undefined" ? parseAwContext(context.payload?.inputs?.aw_context) : null;
const awItemType = awContext?.item_type.trim() ?? "";
const awItemNumber = awContext?.item_number ?? null;
if (awItemType === "pull_request" && awItemNumber !== null) {
pullNumber = awItemNumber;
core.info(`Resolved triggering pull request number '${pullNumber}' from aw_context.`);
}
}
if (!pullNumber) {
return { success: false, error: 'push-to-pull-request-branch with target "triggering" requires pull request context' };
}
} else if (target === "*") {
if (message.pull_request_number) {
pullNumber = parseInt(message.pull_request_number, 10);
}
} else {
pullNumber = parseInt(target, 10);
}
let branchName;
let prTitle = "";
let prLabels = [];
/** @type {any} */
let branchStateBefore = null;
if (!pullNumber) {
return { success: false, error: "Pull request number is required but not found" };
}
// Resolve and validate target repository
// For cross-repo scenarios, the PR may be in a different repository than the workflow
const repoResult = resolveAndValidateRepo(message, defaultTargetRepo, allowedRepos, "push to PR branch");
if (!repoResult.success) {
return { success: false, error: repoResult.error };
}
const itemRepo = repoResult.repo;
const repoParts = repoResult.repoParts;
core.info(`Target repository: ${itemRepo}`);
// Resolve the checkout directory for the target repo.
// When the target repo differs from the workflow repo, it may be checked out
// into a subdirectory of GITHUB_WORKSPACE (e.g. via actions/checkout path:).
// All git operations must run from that directory, not from GITHUB_WORKSPACE.
/** @type {any} */
let repoCwd = undefined;
const workflowRepo = process.env.GITHUB_REPOSITORY || "";
if (itemRepo.toLowerCase() !== workflowRepo.toLowerCase()) {
core.info(`Cross-repo push: looking for checkout of ${itemRepo}`);
// First try the checkout mapping (faster than scanning the workspace)
const checkoutMappingConfig = config.checkout_mapping || null;
if (checkoutMappingConfig) {
const targetLower = itemRepo.toLowerCase();
const mappedPath = checkoutMappingConfig[targetLower];
if (mappedPath) {
const path = require("path");
repoCwd = path.resolve(process.env.GITHUB_WORKSPACE || process.cwd(), mappedPath);
core.info(`Using checkout mapping: ${itemRepo} -> ${mappedPath}`);
}
}
// Fall back to workspace scan if not found in mapping
if (!repoCwd) {
const checkoutResult = findRepoCheckout(itemRepo, process.env.GITHUB_WORKSPACE, { allowedRepos: [...allowedRepos] });
if (!checkoutResult.success) {
return {
success: false,
error: `Repository '${itemRepo}' not found in workspace. Check out the target repo with actions/checkout and set its 'path' input so the checkout can be located. If checking out multiple repositories, ensure each actions/checkout step uses the appropriate 'path' input.`,
};
}
repoCwd = checkoutResult.path;
}
core.info(`Found checkout for ${itemRepo} at: ${repoCwd}`);
}
// Base options for all git exec calls - includes cwd when running in a subdirectory checkout
const baseGitOpts = repoCwd ? { cwd: repoCwd } : {};
// For cross-repo checkouts, also trust the specific subdirectory. The factory-level call
// covers GITHUB_WORKSPACE; this per-message call covers subdirectory checkout paths.
if (repoCwd) {
ensureSafeDirectoryTrust(repoCwd);
}
let pullRequest;
try {
const response = await githubClient.rest.pulls.get({
owner: repoParts.owner,
repo: repoParts.repo,
pull_number: pullNumber,
});
pullRequest = response.data;
branchName = pullRequest.head.ref;
prTitle = pullRequest.title || "";
prLabels = pullRequest.labels.map(label => label.name);
if (typeof pullRequest.head?.sha === "string" && pullRequest.head.sha) {
branchStateBefore = { head_sha: pullRequest.head.sha };
}
} catch (error) {
core.info(`Warning: Could not fetch PR ${pullNumber} from ${itemRepo}: ${getErrorMessage(error)}`);
return { success: false, error: `Failed to determine branch name for PR ${pullNumber} in ${itemRepo}` };
}
let pushRepo = itemRepo;
let pushRepoParts = repoParts;
let pushGithubClient = githubClient;
const actualHeadRepo = typeof pullRequest.head?.repo?.full_name === "string" ? pullRequest.head.repo.full_name : "";
// SECURITY: When head.repo is null (likely a deleted fork) we cannot verify which
// repository the PR head came from. Always reject to prevent writes to an unverifiable PR.
if (pullRequest.head?.repo == null) {
const nullHeadErr = "Cannot push to PR: head repository is null (likely a deleted fork)";
core.error(nullHeadErr);
return { success: false, error: nullHeadErr };
}
// SECURITY: Validate the PR head repository against the configured or default expected value
// before any fork-status branching. This prevents writes to a same-repo PR when head-repo
// names an automation fork, and prevents writes to a fork PR when head-repo is not set.
const expectedHeadRepo = configuredHeadRepo || itemRepo;
if (actualHeadRepo && actualHeadRepo.toLowerCase() !== expectedHeadRepo.toLowerCase()) {
const { isFork: actualIsFork } = detectForkPR(pullRequest);
if (actualIsFork && !configuredHeadRepo) {
core.error(`Cannot push to fork PR branch: head is '${actualHeadRepo}', not '${itemRepo}'`);
core.error("Fork PRs remain blocked unless safe-outputs.push-to-pull-request-branch.head-repo is configured.");
return {
success: false,
error: `Cannot push to fork PR: head repository '${actualHeadRepo}' does not match target '${itemRepo}'. Configure safe-outputs.push-to-pull-request-branch.head-repo and matching credentials to allow an automation-owned fork.`,
};
}
return {
success: false,
error: `Cannot push to PR: head repository '${actualHeadRepo}' does not match expected '${expectedHeadRepo}'. Writes to repositories other than the configured head-repo remain blocked.`,
};
}
// SECURITY: Check if this is a fork PR - only explicitly configured automation-owned
// forks are eligible for updates.
const { isFork, reason: forkReason } = detectForkPR(pullRequest);
if (isFork) {
if (!configuredHeadRepo) {
core.error(`Cannot push to fork PR branch: ${forkReason}`);
core.error("Fork PRs remain blocked unless safe-outputs.push-to-pull-request-branch.head-repo is configured.");
return {
success: false,
error: `Cannot push to fork PR: ${forkReason}. Configure safe-outputs.push-to-pull-request-branch.head-repo and matching credentials to allow an automation-owned fork.`,
};
}
const headRepoResult = resolveAndValidateRepo({ repo: configuredHeadRepo }, itemRepo, allowedRepos, "pull request head repository");
if (!headRepoResult.success) {
return { success: false, error: headRepoResult.error };
}
pushRepo = headRepoResult.repo;
pushRepoParts = headRepoResult.repoParts;
if (headGitHubToken && typeof global.getOctokit === "function") {
pushGithubClient = global.getOctokit(headGitHubToken);
}
core.info(`Fork PR update allowed via configured head repo: ${pushRepo}`);
} else {
core.info(`Fork PR check: not a fork (${forkReason})`);
}
const pushRemoteUrl = pushRepo.toLowerCase() === itemRepo.toLowerCase() ? "" : `${(process.env.GITHUB_SERVER_URL || "https://github.com").replace(/\/+$/, "")}/${pushRepo}.git`;
const branchRemoteName = pushRemoteUrl || "origin";
// SECURITY: Sanitize branch name to prevent shell injection (CWE-78)
// Branch names from GitHub API must be normalized before use in git commands
if (branchName) {
const originalBranchName = branchName;
branchName = normalizeBranchName(branchName);
// Validate it's not empty after normalization
if (!branchName) {
return { success: false, error: `Invalid branch name: sanitization resulted in empty string (original: "${originalBranchName}")` };
}
if (originalBranchName !== branchName) {
core.info(`Branch name sanitized: "${originalBranchName}" -> "${branchName}"`);
}
}
const branchRemoteRef = pushRemoteUrl ? `refs/remotes/gh-aw-head/${branchName}` : `refs/remotes/origin/${branchName}`;
core.info(`Target branch: ${branchName}`);
core.info(`PR title: ${prTitle}`);
core.info(`PR labels: ${prLabels.join(", ")}`);
// SECURITY: Block pushing to the repository's default branch or any branch with
// protection rules. PR head branches must never be default or protected branches.
// This prevents agents from pushing directly to branches that should only receive
// changes through reviewed pull requests.
{
const blockReason = await checkBranchPushable(pushGithubClient, pushRepoParts.owner, pushRepoParts.repo, branchName, checkBranchProtection);
if (blockReason) {
core.error(blockReason);
return { success: false, error: blockReason };
}
}
// Validate title prefix if specified
if (titlePrefix && !prTitle.startsWith(titlePrefix)) {
return { success: false, error: `Pull request title "${prTitle}" does not start with required prefix "${titlePrefix}"` };
}
// Validate labels if specified
if (envLabels.length > 0) {
const missingLabels = envLabels.filter(label => !prLabels.includes(label));
if (missingLabels.length > 0) {
return { success: false, error: `Pull request is missing required labels: ${missingLabels.join(", ")}. Current labels: ${prLabels.join(", ")}` };
}
}
if (titlePrefix) {
core.info(`✓ Title prefix validation passed: "${titlePrefix}"`);
}
if (envLabels.length > 0) {
core.info(`✓ Labels validation passed: ${envLabels.join(", ")}`);
}
const createProtectedFilesFallbackIssue = async files => {
const runUrl = buildWorkflowRunUrl(context, context.repo);
const runId = context.runId;
const patchFileName = patchFilePath ? patchFilePath.replace("/tmp/gh-aw/", "") : "aw-unknown.patch";
const githubServer = process.env.GITHUB_SERVER_URL || "https://github.com";
const prUrl = `${githubServer}/${repoParts.owner}/${repoParts.repo}/pull/${pullNumber}`;
const issueTitle = `[gh-aw] Protected Files: ${prTitle || `PR #${pullNumber}`}`;
const fileList = buildProtectedFileList(files, githubServer, repoParts.owner, repoParts.repo, branchName);
const templatePath = getPromptPath("manifest_protection_push_to_pr_fallback.md");
const issueBody = renderTemplateFromFile(templatePath, {
files: fileList,
pull_number: pullNumber,
pr_url: prUrl,
run_url: runUrl,
run_id: runId,
branch_name: branchName,
patch_file_name: patchFileName,
});
try {
const { data: issue } = await withRetry(
() =>
githubClient.rest.issues.create({
owner: repoParts.owner,
repo: repoParts.repo,
title: issueTitle,
body: issueBody,
labels: ["agentic-workflows"],
}),
RATE_LIMIT_RETRY_CONFIG,
`create manifest-protection review issue in ${repoParts.owner}/${repoParts.repo}`
);
core.info(`Created manifest-protection review issue #${issue.number}: ${issue.html_url}`);
await updateActivationComment(github, context, core, issue.html_url, issue.number, "issue");
return attachExecutionState(
{
success: true,
fallback_used: true,
issue_number: issue.number,
issue_url: issue.html_url,
},
branchStateBefore,
branchStateBefore
);
} catch (issueError) {
const error = `Manifest file protection: failed to create review issue. Error: ${getErrorMessage(issueError)}`;
core.error(error);
return { success: false, error };
}
};
// Deferred protected file protection – fallback-to-issue path.
// Create a review issue now that we have repoParts, pullNumber, and prTitle available.
if (protectedFilesForFallback && protectedFilesForFallback.length > 0) {
return await createProtectedFilesFallbackIssue(protectedFilesForFallback);
}
const hasChanges = !isEmpty;
// Switch to or create the target branch
core.info(`Switching to branch: ${branchName}`);
// Detect missing/deleted branches early and return a clear error.
// This avoids an opaque git fetch exit code when the PR branch was deleted.
{
const lsRemoteResult = await withGitHubHostToken(
headGitHubToken,
async () =>
exec.getExecOutput("git", ["ls-remote", "--exit-code", "--heads", branchRemoteName, branchName], {
env: { ...process.env, ...gitAuthEnv },
...baseGitOpts,
ignoreReturnCode: true,
}),
baseGitOpts.cwd
);
if (lsRemoteResult.exitCode === 2) {
const missingBranchError = MISSING_BRANCH_ERROR_TEMPLATE(branchName);
if (ignoreMissingBranchFailure) {
core.warning(`${missingBranchError} Skipping as configured by ignore-missing-branch-failure.`);
return {
success: false,
error: missingBranchError,
skipped: true,
};
}
return {
success: false,
error: missingBranchError,
};
}
if (lsRemoteResult.exitCode !== 0) {
const stderr = (lsRemoteResult.stderr || "").trim();
return {
success: false,
error: `Failed to verify branch ${branchName} exists on ${pushRepo}: ${stderr || `git ls-remote exited with code ${lsRemoteResult.exitCode}`}`,
};
}
}
// Fetch the specific target branch from origin
// Authenticate using the credentials actions/checkout persisted into .git/config
// for the safe_outputs job; no GIT_CONFIG_* extraheader is injected (see gitAuthEnv above).
try {
core.info(`Fetching branch: ${branchName}`);
await withGitHubHostToken(
headGitHubToken,
async () =>
exec.exec("git", ["fetch", branchRemoteName, `${branchName}:${branchRemoteRef}`], {
env: { ...process.env, ...gitAuthEnv },
...baseGitOpts,
}),
baseGitOpts.cwd
);
} catch (fetchError) {
const fetchErrorMessage = getErrorMessage(fetchError);
if (ignoreMissingBranchFailure && looksLikeMissingRemoteBranchError(fetchErrorMessage)) {
const missingBranchError = MISSING_BRANCH_ERROR_TEMPLATE(branchName);
core.warning(`${missingBranchError} Skipping as configured by ignore-missing-branch-failure.`);
return { success: false, error: missingBranchError, skipped: true };
}
return { success: false, error: `Failed to fetch branch ${branchName}: ${getErrorMessage(fetchError)}` };
}
// Check if branch exists on origin
try {
await exec.exec(`git rev-parse --verify ${branchRemoteRef}`, [], baseGitOpts);
} catch (verifyError) {
const missingBranchError = MISSING_BRANCH_ERROR_TEMPLATE(branchName);
if (ignoreMissingBranchFailure) {
core.warning(`${missingBranchError} Skipping as configured by ignore-missing-branch-failure.`);
return { success: false, error: missingBranchError, skipped: true };
}
return { success: false, error: `Branch ${branchName} does not exist on origin, can't push to it: ${getErrorMessage(verifyError)}` };
}
// Checkout the branch from origin
try {
await exec.exec(`git checkout -B ${branchName} ${branchRemoteRef}`, [], baseGitOpts);
core.info(`Checked out existing branch from ${pushRepo}: ${branchName}`);
} catch (checkoutError) {
return { success: false, error: `Failed to checkout branch ${branchName}: ${getErrorMessage(checkoutError)}` };
}
// Apply the patch/bundle using git CLI (skip if empty)
// Track number of new commits added so we can restrict the extra empty commit
// to branches with exactly one new commit (security: prevents use of CI trigger
// token on multi-commit branches where workflow files may have been modified).
let newCommitCount = 0;
let remoteHeadBeforePatch = "";
let pushedCommitSha = "";
let rangeBaseRef = branchRemoteRef;
if (hasChanges) {
// Capture HEAD before applying changes to compute new-commit count later
try {
const { stdout } = await exec.getExecOutput("git", ["rev-parse", "HEAD"], baseGitOpts);
remoteHeadBeforePatch = stdout.trim();
if (remoteHeadBeforePatch) {
rangeBaseRef = remoteHeadBeforePatch;
core.info(`Remote branch HEAD before apply: ${remoteHeadBeforePatch}`);
}
} catch {
// Non-fatal - extra empty commit will be skipped
}
// Pin patch application to the recorded base commit captured at patch-generation time.
// This avoids applying a patch generated from an older branch tip onto a newer remote tip.
// If the commit is unavailable (e.g. cross-repo/missing object), continue with current HEAD.
if (!hasBundleFile && message.base_commit) {
const recordedBaseCommit = normalizeCommitSHA(message.base_commit);
if (recordedBaseCommit) {
core.info(`Patch route base_commit resolved: ${recordedBaseCommit}`);
try {
try {
await exec.exec("git", ["fetch", "origin", recordedBaseCommit, "--depth=1"], {
env: { ...process.env, ...gitAuthEnv },
...baseGitOpts,
});
} catch (fetchError) {
core.info(`Note: could not fetch base_commit ${recordedBaseCommit} explicitly (${getErrorMessage(fetchError)}); will verify local availability next`);
}
await exec.exec("git", ["cat-file", "-e", recordedBaseCommit], baseGitOpts);
const ancestryCheck = await exec.getExecOutput("git", ["merge-base", "--is-ancestor", recordedBaseCommit, branchRemoteRef], { ...baseGitOpts, ignoreReturnCode: true });
if (ancestryCheck.exitCode !== 0) {