Skip to content

Commit 7dd9f43

Browse files
authored
Handle deleted PR branches in push-to-pull-request-branch with opt-in skip mode (#27208)
1 parent a99036c commit 7dd9f43

8 files changed

Lines changed: 144 additions & 18 deletions

.github/workflows/design-decision-gate.lock.yml

Lines changed: 17 additions & 17 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

.github/workflows/design-decision-gate.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ safe-outputs:
2222
allowed-files:
2323
- docs/adr/**
2424
patch-format: bundle
25+
ignore-missing-branch-failure: true
2526
commit-title-suffix: " [design-decision-gate]"
2627
noop:
2728
messages:

actions/setup/js/push_to_pull_request_branch.cjs

Lines changed: 42 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,25 @@ const { getGitAuthEnv } = require("./git_helpers.cjs");
2424

2525
/** @type {string} Safe output type handled by this module */
2626
const HANDLER_TYPE = "push_to_pull_request_branch";
27+
const MISSING_BRANCH_ERROR_TEMPLATE = branchName => `Branch ${branchName} no longer exists on origin (it may have been deleted), can't push to it.`;
28+
const MISSING_REMOTE_REF_PATTERNS = [
29+
"couldn't find remote ref",
30+
"could not find remote ref",
31+
"remote ref does not exist",
32+
"did not match any file(s) known to git",
33+
"unknown revision or path not in the working tree",
34+
"fatal: couldn't find remote ref",
35+
"exit code 128",
36+
];
37+
38+
/**
39+
* @param {unknown} value
40+
* @returns {boolean}
41+
*/
42+
function looksLikeMissingRemoteBranchError(value) {
43+
const text = String(value ?? "").toLowerCase();
44+
return MISSING_REMOTE_REF_PATTERNS.some(pattern => text.includes(pattern));
45+
}
2746

2847
/**
2948
* Main handler factory for push_to_pull_request_branch
@@ -36,6 +55,7 @@ async function main(config = {}) {
3655
const titlePrefix = config.title_prefix || "";
3756
const envLabels = config.labels ? (Array.isArray(config.labels) ? config.labels : config.labels.split(",")).map(label => String(label).trim()).filter(label => label) : [];
3857
const ifNoChanges = config.if_no_changes || "warn";
58+
const ignoreMissingBranchFailure = config.ignore_missing_branch_failure === true;
3959
const commitTitleSuffix = config.commit_title_suffix || "";
4060
const maxSizeKb = config.max_patch_size ? parseInt(String(config.max_patch_size), 10) : 1024;
4161
const maxCount = config.max || 0; // 0 means no limit
@@ -70,6 +90,7 @@ async function main(config = {}) {
7090
core.info(`Required labels: ${envLabels.join(", ")}`);
7191
}
7292
core.info(`If no changes: ${ifNoChanges}`);
93+
core.info(`Ignore missing branch failure: ${ignoreMissingBranchFailure}`);
7394
if (commitTitleSuffix) {
7495
core.info(`Commit title suffix: ${commitTitleSuffix}`);
7596
}
@@ -464,9 +485,18 @@ async function main(config = {}) {
464485
});
465486

466487
if (lsRemoteResult.exitCode === 2) {
488+
const missingBranchError = MISSING_BRANCH_ERROR_TEMPLATE(branchName);
489+
if (ignoreMissingBranchFailure) {
490+
core.warning(`${missingBranchError} Skipping as configured by ignore-missing-branch-failure.`);
491+
return {
492+
success: false,
493+
error: missingBranchError,
494+
skipped: true,
495+
};
496+
}
467497
return {
468498
success: false,
469-
error: `Branch ${branchName} no longer exists on origin (it may have been deleted), can't push to it.`,
499+
error: missingBranchError,
470500
};
471501
}
472502

@@ -488,13 +518,24 @@ async function main(config = {}) {
488518
env: { ...process.env, ...gitAuthEnv },
489519
});
490520
} catch (fetchError) {
521+
const fetchErrorMessage = fetchError instanceof Error ? fetchError.message : String(fetchError);
522+
if (ignoreMissingBranchFailure && looksLikeMissingRemoteBranchError(fetchErrorMessage)) {
523+
const missingBranchError = MISSING_BRANCH_ERROR_TEMPLATE(branchName);
524+
core.warning(`${missingBranchError} Skipping as configured by ignore-missing-branch-failure.`);
525+
return { success: false, error: missingBranchError, skipped: true };
526+
}
491527
return { success: false, error: `Failed to fetch branch ${branchName}: ${fetchError instanceof Error ? fetchError.message : String(fetchError)}` };
492528
}
493529

494530
// Check if branch exists on origin
495531
try {
496532
await exec.exec(`git rev-parse --verify origin/${branchName}`);
497533
} catch (verifyError) {
534+
const missingBranchError = MISSING_BRANCH_ERROR_TEMPLATE(branchName);
535+
if (ignoreMissingBranchFailure) {
536+
core.warning(`${missingBranchError} Skipping as configured by ignore-missing-branch-failure.`);
537+
return { success: false, error: missingBranchError, skipped: true };
538+
}
498539
return { success: false, error: `Branch ${branchName} does not exist on origin, can't push to it: ${verifyError instanceof Error ? verifyError.message : String(verifyError)}` };
499540
}
500541

actions/setup/js/push_to_pull_request_branch.test.cjs

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -645,6 +645,22 @@ index 0000000..abc1234
645645
expect(mockExec.exec).not.toHaveBeenCalled();
646646
});
647647

648+
it("should skip deleted branch failure when ignore_missing_branch_failure is enabled", async () => {
649+
const patchPath = createPatchFile();
650+
651+
mockExec.getExecOutput.mockResolvedValueOnce({ exitCode: 2, stdout: "", stderr: "fatal: couldn't find remote ref feature-branch" });
652+
653+
const module = await loadModule();
654+
const handler = await module.main({ ignore_missing_branch_failure: true });
655+
const result = await handler({ patch_path: patchPath }, {});
656+
657+
expect(result.success).toBe(false);
658+
expect(result.skipped).toBe(true);
659+
expect(result.error).toContain("no longer exists on origin");
660+
expect(mockCore.warning).toHaveBeenCalledWith(expect.stringContaining("ignore-missing-branch-failure"));
661+
expect(mockExec.exec).not.toHaveBeenCalled();
662+
});
663+
648664
it("should fail with diagnostic error when branch existence check fails for other reasons", async () => {
649665
const patchPath = createPatchFile();
650666

@@ -689,6 +705,23 @@ index 0000000..abc1234
689705
expect(result.error).toContain("does not exist on origin");
690706
});
691707

708+
it("should skip rev-parse missing branch failure when ignore_missing_branch_failure is enabled", async () => {
709+
const patchPath = createPatchFile();
710+
711+
// git fetch succeeds, but git rev-parse fails
712+
mockExec.exec.mockResolvedValueOnce(0); // fetch
713+
mockExec.exec.mockRejectedValueOnce(new Error("fatal: Needed a single revision"));
714+
715+
const module = await loadModule();
716+
const handler = await module.main({ ignore_missing_branch_failure: true });
717+
const result = await handler({ patch_path: patchPath }, {});
718+
719+
expect(result.success).toBe(false);
720+
expect(result.skipped).toBe(true);
721+
expect(result.error).toContain("no longer exists on origin");
722+
expect(mockCore.warning).toHaveBeenCalledWith(expect.stringContaining("ignore-missing-branch-failure"));
723+
});
724+
692725
it("should handle git checkout failure", async () => {
693726
const patchPath = createPatchFile();
694727

pkg/parser/schemas/main_workflow_schema.json

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7074,6 +7074,11 @@
70747074
"enum": ["warn", "error", "ignore"],
70757075
"description": "Behavior when no changes to push: 'warn' (default - log warning but succeed), 'error' (fail the action), or 'ignore' (silent success)"
70767076
},
7077+
"ignore-missing-branch-failure": {
7078+
"type": "boolean",
7079+
"description": "When true, treat deleted/missing pull request branch errors as a skipped push instead of a hard failure. Useful when the PR branch may be deleted before safe outputs run.",
7080+
"default": false
7081+
},
70777082
"commit-title-suffix": {
70787083
"type": "string",
70797084
"description": "Optional suffix to append to generated commit titles (e.g., ' [skip ci]' to prevent triggering CI on the commit)"

pkg/workflow/compiler_safe_outputs_handlers.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -411,6 +411,7 @@ var handlerRegistry = map[string]handlerBuilder{
411411
AddIfNotEmpty("title_prefix", c.TitlePrefix).
412412
AddStringSlice("labels", c.Labels).
413413
AddIfNotEmpty("if_no_changes", c.IfNoChanges).
414+
AddIfTrue("ignore_missing_branch_failure", c.IgnoreMissingBranchFailure).
414415
AddIfNotEmpty("commit_title_suffix", c.CommitTitleSuffix).
415416
AddDefault("max_patch_size", maxPatchSize).
416417
AddIfNotEmpty("target-repo", c.TargetRepoSlug).

0 commit comments

Comments
 (0)