Skip to content
15 changes: 15 additions & 0 deletions .changeset/fix-signed-push-genuine-rebase-conflict-fallback.md

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

151 changes: 125 additions & 26 deletions actions/setup/js/push_signed_commits.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,21 @@ class PushSigned提交PolicyViolation extends Error {
}
}

/**
* Sentinel error class marking a terminal failure of the unsigned git push fallback that is
* attempted after a genuine (non-recoverable) rebase conflict. This must propagate as-is
* without triggering the generic "GraphQL failed, retry with git push" fallback further up —
* that push was already attempted (and rejected), so retrying it again would just repeat the
* same failure (e.g. against a branch-protection rule requiring signed commits).
*/
class PushSigned提交UnsignedFallbackFailed extends Error {
/** @param {string} message */
constructor(message, options) {
super(message, options);
this.name = "PushSigned提交UnsignedFallbackFailed";
}
}

/**
* Unescape a C-quoted path returned by `git diff-tree --raw`.
*
Expand Down Expand Up @@ -467,6 +482,14 @@ async function pushSigned提交({
}
let shas = revListEntries.map(entry => entry.sha);

// When a genuine (non-recoverable) rebase conflict is hit below, the original un-rebased
// commit range is pushed unsigned instead of being replayed through GraphQL. Rather than
// returning immediately, that path sets this flag and falls through into the same
// preflight validation (merge commit / unsupported file mode / file-protection policy
// checks) that every other unsigned-push fallback goes through, so genuinely unsupported
// or policy-blocked content still refuses the fallback instead of being pushed as-is.
let unsignedPushFallbackReason;

if (shas.length === 0) {
core.info("pushSigned提交: no new commits to push via GraphQL");
return undefined;
Expand Down Expand Up @@ -564,16 +587,37 @@ async function pushSigned提交({
if (recovered) {
rebaseResult = await runRebase();
if (rebaseResult.exitCode !== 0) {
const retryOutput = `${rebaseResult.stdout || ""}\n${rebaseResult.stderr || ""}`;
try {
await exec.exec("git", ["rebase", "--abort"], { cwd });
} catch {
// Ignore cleanup failures.
} catch (abortError) {
throw new Error(
`${ERR_SYSTEM}: pushSigned提交: failed to rebase commit range onto current GraphQL parent (${firstGraphqlParentOid}) even after backfilling the required commit objects, ` +
`and 'git rebase --abort' also failed to restore branch '${branch}' to its original state. ` +
`Root cause: ${retryOutput.trim()}. Abort failure: ${getErrorMessage(abortError)}`,
{ cause: abortError }
);
}
const retryOutput = `${rebaseResult.stdout || ""}\n${rebaseResult.stderr || ""}`;
throw new Error(
`${ERR_SYSTEM}: pushSigned提交: failed to rebase commit range onto current GraphQL parent (${firstGraphqlParentOid}) even after backfilling the required commit objects. ` +
`Resolve conflicts by rebasing/cherry-picking locally and retry. Root cause: ${retryOutput.trim()}`
);
if (isPartialCloneObjectFailure(retryOutput)) {
// Still a missing-object failure even after the targeted backfill — the shallow/
// partial clone genuinely cannot supply what this rebase needs, so there is no
// valid replay range to fall back to unsigned; this remains fatal.
throw new Error(
`${ERR_SYSTEM}: pushSigned提交: failed to rebase commit range onto current GraphQL parent (${firstGraphqlParentOid}) even after backfilling the required commit objects. ` +
`Resolve conflicts by rebasing/cherry-picking locally and retry. Root cause: ${retryOutput.trim()}`
);
}
// The backfill succeeded (the objects needed to attempt the rebase are now present),
// but retrying the rebase still failed — this is a genuine content conflict that was
// only revealed once the missing objects were available, not a partial-clone object
// issue. Route it through the same unsigned-push fallback as any other genuine
// conflict below, instead of unconditionally throwing.
const diagnosticMessage =
`${ERR_SYSTEM}: pushSigned提交: failed to rebase commit range onto current GraphQL parent (${firstGraphqlParentOid}) even after backfilling the required commit objects. ` + `Root cause: ${retryOutput.trim()}`;
if (allowGitPushFallback === false) {
throw new Error(`${ERR_SYSTEM}: ${diagnosticMessage} Resolve conflicts by rebasing/cherry-picking locally and retry.`);
}
unsignedPushFallbackReason = diagnosticMessage;
}
} else {
throw new Error(
Expand All @@ -585,27 +629,52 @@ async function pushSigned提交({
} else {
try {
await exec.exec("git", ["rebase", "--abort"], { cwd });
} catch {
// Ignore cleanup failures.
} catch (abortError) {
// If the abort itself fails, HEAD can be left in a detached, partially-rebased or
// still-conflicted state. Continuing from there (falling through to the unsigned push
// fallback, or even the strict throw below) would risk operating on / pushing that
// broken worktree state, so this must be fatal rather than silently ignored.
throw new Error(
`${ERR_SYSTEM}: pushSigned提交: failed to rebase commit range onto current GraphQL parent (${firstGraphqlParentOid}), and 'git rebase --abort' also failed to restore branch '${branch}' to its original state. ` +
`Root cause: ${combinedOutput.trim()}. Abort failure: ${getErrorMessage(abortError)}`,
{ cause: abortError }
);
}
const diagnosticMessage = `${ERR_SYSTEM}: pushSigned提交: failed to rebase commit range onto current GraphQL parent (${firstGraphqlParentOid}). ` + `Root cause: ${combinedOutput.trim()}`;
if (allowGitPushFallback === false) {
throw new Error(`${ERR_SYSTEM}: ${diagnosticMessage} Resolve conflicts by rebasing/cherry-picking locally and retry.`);
}
throw new Error(
`${ERR_SYSTEM}: pushSigned提交: failed to rebase commit range onto current GraphQL parent (${firstGraphqlParentOid}). ` + `Resolve conflicts by rebasing/cherry-picking locally and retry. Root cause: ${combinedOutput.trim()}`
);
// Genuine merge conflict (not a shallow/partial-clone object-fetch issue, and no custom
// resolver handled it): rebasing the commit range onto the current base cannot be done
// automatically, and replaying the stale-base commits through GraphQL would silently
Comment on lines +647 to +649
// synthesize file content against the wrong parent. Rather than failing the whole
// operation (which previously forced callers to fall back to opening an issue instead
// of a pull request), fall through to push the ORIGINAL un-rebased commits directly via
// unsigned `git push` once they pass the same preflight validation (merge commit /
// unsupported file mode / file-protection policy) as any other unsigned push fallback.
// GitHub will still create the pull request; it will simply report the branch as having
// conflicts that need to be resolved, the same as any normal PR.
// `rebase --abort` (above) already restored HEAD and the branch to their original,
// un-rebased state, so revListEntries/shas (computed before the rebase attempt) still
// describe exactly what will be pushed — no need to recompute them.
unsignedPushFallbackReason = diagnosticMessage;
}
}
const { stdout: rebasedRevListOut } = await exec.getExecOutput("git", ["rev-list", "--parents", "--topo-order", "--reverse", `${firstGraphqlParentOid}..HEAD`], { cwd });
revListEntries = rebasedRevListOut
.trim()
.split("\n")
.filter(Boolean)
.map(line => {
const fields = line.split(" ");
return { line, fields, sha: fields[0] };
});
shas = revListEntries.map(entry => entry.sha);
if (shas.length === 0) {
core.info("pushSigned提交: no new commits to replay after rebase");
return undefined;
if (!unsignedPushFallbackReason) {
const { stdout: rebasedRevListOut } = await exec.getExecOutput("git", ["rev-list", "--parents", "--topo-order", "--reverse", `${firstGraphqlParentOid}..HEAD`], { cwd });
revListEntries = rebasedRevListOut
.trim()
.split("\n")
.filter(Boolean)
.map(line => {
const fields = line.split(" ");
return { line, fields, sha: fields[0] };
});
shas = revListEntries.map(entry => entry.sha);
if (shas.length === 0) {
core.info("pushSigned提交: no new commits to replay after rebase");
return undefined;
}
}
}

Expand Down Expand Up @@ -739,6 +808,32 @@ async function pushSigned提交({
deletionsMap.set(sha, deletions);
}

// Enforce file-protection/size policy for every commit up front — before choosing between
// an unsigned push fallback and the GraphQL replay below — so a validationConfig-blocked
// file always refuses the unsigned push fallback, not just the GraphQL path.
for (const sha of shas) {
validateSynthesizedFileChanges(additionsMap.get(sha) || [], deletionsMap.get(sha) || [], validationConfig);
}

if (unsignedPushFallbackReason) {
// All commits in the original, un-rebased range passed the same merge-commit,
// unsupported-file-mode, and file-protection-policy checks as the GraphQL replay path
// above; push them directly via unsigned `git push` instead of replaying through GraphQL
// (which would synthesize file content against the wrong parent).
core.warning(`${unsignedPushFallbackReason} Falling back to an unsigned git push of the un-rebased commit(s) so the pull request can still be created (it will show as having merge conflicts with the base branch).`);
try {
const fallbackSha = await pushBranchAndResolveHead({ branch, cwd, gitAuthEnv, pushRemoteUrl, pushToken });
core.info(`pushSigned提交: unsigned git push fallback (unresolved rebase conflict) completed, using pushed SHA ${fallbackSha}`);
return fallbackSha;
} catch (pushError) {
// The unsigned push itself can be rejected (e.g. by branch protection rules requiring
// signed commits). Surface a clear, combined error instead of letting the raw git-push
// failure — or a misleading "success" — propagate; this preserves the original
// throw-on-conflict behavior for repos where an unsigned push is not a viable fallback.
throw new PushSigned提交UnsignedFallbackFailed(`${ERR_SYSTEM}: ${unsignedPushFallbackReason} Unsigned git push fallback was also rejected: ${getErrorMessage(pushError)}`, { cause: pushError });
}
}

// All commits passed the mode checks. Replay via GraphQL.
/** @type {string | undefined} */
let lastOid;
Expand Down Expand Up @@ -814,7 +909,6 @@ async function pushSigned提交({

const additions = additionsMap.get(sha) || [];
const deletions = deletionsMap.get(sha) || [];
validateSynthesizedFileChanges(additions, deletions, validationConfig);
core.info(`pushSigned提交: file changes: ${additions.length} addition(s), ${deletions.length} deletion(s)`);

/** @type {any} */
Expand Down Expand Up @@ -842,6 +936,11 @@ async function pushSigned提交({
core.info(`pushSigned提交: all ${shas.length} commit(s) pushed as signed commits`);
return lastOid ?? shas[shas.length - 1];
} catch (err) {
if (err instanceof PushSigned提交UnsignedFallbackFailed) {
// The unsigned push fallback for a genuine rebase conflict was already attempted (and
// rejected) above; re-throw as-is instead of retrying the same push again below.
throw err;
}
if (err instanceof PushSigned提交UnsupportedShape) {
throw new Error(
`${ERR_VALIDATION}: pushSigned提交: refusing unsigned push for branch '${branch}': ${stripLeadingErrorCode(getErrorMessage(err))}. ` +
Expand Down
Loading
Loading