Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 28 additions & 0 deletions packages/commons/github/src/ClonedRepository.ts
Original file line number Diff line number Diff line change
Expand Up @@ -360,6 +360,34 @@ export class ClonedRepository {
return result.trim();
}

public async getCommitMessage(commitSha: string): Promise<string> {
await this.git.cwd(this.clonePath);
const result = await this.git.raw(["log", "-1", "--format=%B", commitSha]);
// git log appends a trailing newline; strip exactly one to preserve any intentional blank lines.
return result.replace(/\n$/, "");
}

public async getCommitParents(commitSha: string): Promise<string[]> {
await this.git.cwd(this.clonePath);
// `git rev-list --parents -n 1 <sha>` prints "<commit> <parent1> <parent2> ..." (empty parents for root commit).
const result = await this.git.raw(["rev-list", "--parents", "-n", "1", commitSha]);
const parts = result.trim().split(/\s+/);
return parts.slice(1);
}

/**
* Returns the SHAs of commits reachable from HEAD but not from any remote-tracking ref,
* ordered oldest-first. These are the commits created locally that have not been pushed.
*/
public async getLocalOnlyCommits(): Promise<string[]> {
await this.git.cwd(this.clonePath);
const result = await this.git.raw(["rev-list", "--reverse", "HEAD", "--not", "--remotes=origin"]);
return result
.trim()
.split("\n")
.filter((line) => line.length > 0);
}

/**
* Returns true if the working tree has uncommitted changes (staged or unstaged).
*/
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
# yaml-language-server: $schema=../../../../fern-changes-yml.schema.json

- summary: |
Sign every local-only commit when pushing to GitHub, not just HEAD. Replay branches
contain multiple commits (e.g. `[fern-generated]` + `[fern-replay]`); previously only
the HEAD commit was recreated via the GitHub API and signed, leaving earlier commits
on the PR unverified.
type: fix
98 changes: 88 additions & 10 deletions packages/generator-cli/src/__test__/pushSignedCommit.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,10 @@ import { isNonFastForwardError, pushSignedCommit } from "../pipeline/github/push

interface MockedRepository {
getHeadSha: ReturnType<typeof vi.fn>;
getHeadTreeHash: ReturnType<typeof vi.fn>;
getHeadCommitMessage: ReturnType<typeof vi.fn>;
getHeadParents: ReturnType<typeof vi.fn>;
getLocalOnlyCommits: ReturnType<typeof vi.fn>;
getCommitTreeHash: ReturnType<typeof vi.fn>;
getCommitMessage: ReturnType<typeof vi.fn>;
getCommitParents: ReturnType<typeof vi.fn>;
pushObjectToRef: ReturnType<typeof vi.fn>;
pullWithRebase: ReturnType<typeof vi.fn>;
resetHardToSha: ReturnType<typeof vi.fn>;
Expand All @@ -16,9 +17,10 @@ interface MockedRepository {
function buildRepository(overrides: Partial<MockedRepository> = {}): MockedRepository {
return {
getHeadSha: vi.fn().mockResolvedValue("local-sha"),
getHeadTreeHash: vi.fn().mockResolvedValue("tree-sha"),
getHeadCommitMessage: vi.fn().mockResolvedValue("SDK Generation"),
getHeadParents: vi.fn().mockResolvedValue(["parent-sha"]),
getLocalOnlyCommits: vi.fn().mockResolvedValue(["local-sha"]),
getCommitTreeHash: vi.fn().mockResolvedValue("tree-sha"),
getCommitMessage: vi.fn().mockResolvedValue("SDK Generation"),
getCommitParents: vi.fn().mockResolvedValue(["parent-sha"]),
pushObjectToRef: vi.fn().mockResolvedValue(undefined),
pullWithRebase: vi.fn().mockResolvedValue(undefined),
resetHardToSha: vi.fn().mockResolvedValue(undefined),
Expand Down Expand Up @@ -201,8 +203,9 @@ describe("pushSignedCommit", () => {
const nonFF = Object.assign(new Error("Update is not a fast forward"), { status: 422 });
const repository = buildRepository({
getHeadSha: vi.fn().mockResolvedValueOnce("local-sha-1").mockResolvedValue("local-sha-2"),
getHeadTreeHash: vi.fn().mockResolvedValueOnce("tree-sha-1").mockResolvedValue("tree-sha-2"),
getHeadParents: vi.fn().mockResolvedValueOnce(["parent-sha-1"]).mockResolvedValue(["parent-sha-2"])
getLocalOnlyCommits: vi.fn().mockResolvedValueOnce(["local-sha-1"]).mockResolvedValue(["local-sha-2"]),
getCommitTreeHash: vi.fn().mockResolvedValueOnce("tree-sha-1").mockResolvedValue("tree-sha-2"),
getCommitParents: vi.fn().mockResolvedValueOnce(["parent-sha-1"]).mockResolvedValue(["parent-sha-2"])
});
const octokit = buildOctokit({
createCommit: vi
Expand Down Expand Up @@ -251,6 +254,80 @@ describe("pushSignedCommit", () => {
);
});

it("signs every local-only commit and re-parents each onto its signed predecessor", async () => {
// Replay-style branch: [fern-generated] (gen-sha) followed by [fern-replay] (replay-sha).
const repository = buildRepository({
getHeadSha: vi.fn().mockResolvedValue("replay-sha"),
getLocalOnlyCommits: vi.fn().mockResolvedValue(["gen-sha", "replay-sha"]),
getCommitTreeHash: vi.fn().mockImplementation((sha: string) => Promise.resolve(`tree-${sha}`)),
getCommitMessage: vi
.fn()
.mockImplementation((sha: string) =>
Promise.resolve(
sha === "gen-sha" ? "[fern-generated] Update SDK" : "[fern-replay] Applied customizations"
)
),
getCommitParents: vi
.fn()
.mockImplementation((sha: string) => Promise.resolve(sha === "gen-sha" ? ["base-sha"] : ["gen-sha"]))
});
const octokit = buildOctokit({
createCommit: vi
.fn()
.mockResolvedValueOnce({ data: { sha: "signed-gen-sha" } })
.mockResolvedValueOnce({ data: { sha: "signed-replay-sha" } })
});

const result = await makeCaller(
repository,
octokit
)({
...baseArgs,
repository: repository as never,
octokit: octokit as never
});

expect(result).toBe("signed-replay-sha");
expect(octokit.git.createCommit).toHaveBeenNthCalledWith(1, {
owner: "acme",
repo: "acme-sdk",
message: "[fern-generated] Update SDK",
tree: "tree-gen-sha",
parents: ["base-sha"]
});
expect(octokit.git.createCommit).toHaveBeenNthCalledWith(2, {
owner: "acme",
repo: "acme-sdk",
message: "[fern-replay] Applied customizations",
tree: "tree-replay-sha",
parents: ["signed-gen-sha"]
});
expect(octokit.git.updateRef).toHaveBeenCalledWith(expect.objectContaining({ sha: "signed-replay-sha" }));
expect(repository.resetHardToSha).toHaveBeenCalledWith("signed-replay-sha");
});

it("falls back to signing HEAD only when the local-only chain is empty", async () => {
const repository = buildRepository({
getLocalOnlyCommits: vi.fn().mockResolvedValue([])
});
const octokit = buildOctokit();

const result = await makeCaller(
repository,
octokit
)({
...baseArgs,
repository: repository as never,
octokit: octokit as never
});

expect(result).toBe("signed-sha-1");
expect(octokit.git.createCommit).toHaveBeenCalledTimes(1);
expect(octokit.git.createCommit).toHaveBeenCalledWith(
expect.objectContaining({ message: "SDK Generation", tree: "tree-sha", parents: ["parent-sha"] })
);
});

it("forwards explicit author as both author and committer when the caller provides one", async () => {
const repository = buildRepository();
const octokit = buildOctokit();
Expand Down Expand Up @@ -282,8 +359,9 @@ describe("pushSignedCommit", () => {
const customAuthor = { name: "auth0-bot", email: "auth0-bot@example.com" };
const repository = buildRepository({
getHeadSha: vi.fn().mockResolvedValueOnce("local-sha-1").mockResolvedValue("local-sha-2"),
getHeadTreeHash: vi.fn().mockResolvedValueOnce("tree-sha-1").mockResolvedValue("tree-sha-2"),
getHeadParents: vi.fn().mockResolvedValueOnce(["parent-sha-1"]).mockResolvedValue(["parent-sha-2"])
getLocalOnlyCommits: vi.fn().mockResolvedValueOnce(["local-sha-1"]).mockResolvedValue(["local-sha-2"]),
getCommitTreeHash: vi.fn().mockResolvedValueOnce("tree-sha-1").mockResolvedValue("tree-sha-2"),
getCommitParents: vi.fn().mockResolvedValueOnce(["parent-sha-1"]).mockResolvedValue(["parent-sha-2"])
});
const octokit = buildOctokit({
createCommit: vi
Expand Down
122 changes: 91 additions & 31 deletions packages/generator-cli/src/pipeline/github/pushSignedCommit.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,13 @@ import type { PipelineLogger } from "../PipelineLogger";

const MAX_CONCURRENT_PUSH_RETRIES = 3;

/**
* Upper bound on how many local-only commits are individually recreated as signed API
* commits. Chains longer than this (which should never happen for pipeline-created
* branches) fall back to signing HEAD only, matching the previous behavior.
*/
const MAX_SIGNED_CHAIN_LENGTH = 20;

export interface CommitAuthor {
name: string;
email: string;
Expand Down Expand Up @@ -46,15 +53,18 @@ export interface PushSignedCommitOptions {
}

/**
* Pushes the current branch HEAD to GitHub as a signed commit by:
* Pushes the current branch HEAD to GitHub as signed commits by:
* 1. Pushing the local commit to a temporary ref (uploads tree + blob objects).
* 2. Creating a new commit via the GitHub REST API (which GitHub signs with the App's key
* when the octokit instance is authenticated with a GitHub App installation token).
* 3. Updating the branch ref to point to the signed commit.
* 2. Recreating every local-only commit (oldest first) via the GitHub REST API (which
* GitHub signs with the App's key when the octokit instance is authenticated with a
* GitHub App installation token), re-parenting each onto its signed predecessor. This
* covers multi-commit branches (e.g. `[fern-generated]` + `[fern-replay]`) so every
* commit on the PR shows as Verified, not just HEAD.
* 3. Updating the branch ref to point to the signed head commit.
* 4. Deleting the temporary ref.
* 5. Fast-forwarding the local branch to the signed commit SHA.
*
* Returns the SHA of the signed commit on the remote (which differs from the local HEAD SHA).
* Returns the SHA of the signed head commit on the remote (which differs from the local HEAD SHA).
*/
export async function pushSignedCommit({
repository,
Expand All @@ -71,34 +81,21 @@ export async function pushSignedCommit({
let tempRefPushed = false;

try {
let [localHeadSha, treeSha, message, parents] = await Promise.all([
repository.getHeadSha(),
repository.getHeadTreeHash(),
repository.getHeadCommitMessage(),
repository.getHeadParents()
]);
let localHeadSha = await repository.getHeadSha();

await repository.pushObjectToRef(localHeadSha, tempRef);
tempRefPushed = true;

for (let attempt = 0; attempt < MAX_CONCURRENT_PUSH_RETRIES; attempt++) {
// GitHub's web-flow signer keys off `committer`. The Git Data API defaults
// `committer` to `author` when `author` is provided, so sending `author` alone
// suppresses auto-signing. Omitting both lets GitHub fill them in from the
// authenticated signing-capable principal (App installation, OAuth — never a PAT)
// and stamp the "Verified" signature; the App's bot identity keeps the Fern bot
// noreply email, so attribution-based tooling (replay commit detection,
// findExistingUpdatablePR) still matches.
const identityFields = author != null ? { author, committer: author } : {};
const { data: signedCommit } = await octokit.git.createCommit({
const signedSha = await signLocalCommitChain({
repository,
octokit,
owner,
repo,
message,
tree: treeSha,
parents,
...identityFields
localHeadSha,
author,
logger
});
const signedSha = signedCommit.sha;

try {
await upsertBranchRef({ octokit, owner, repo, branch, sha: signedSha, force });
Expand All @@ -115,12 +112,7 @@ export async function pushSignedCommit({
`Non-fast-forward on refs/heads/${branch} (attempt ${attempt + 1}/${MAX_CONCURRENT_PUSH_RETRIES}); rebasing locally and retrying.`
);
await repository.pullWithRebase(branch);
[localHeadSha, treeSha, message, parents] = await Promise.all([
repository.getHeadSha(),
repository.getHeadTreeHash(),
repository.getHeadCommitMessage(),
repository.getHeadParents()
]);
localHeadSha = await repository.getHeadSha();
// The rebased commit is not a descendant of the original tempRef tip,
// so force-push is required to overwrite it.
await repository.pushObjectToRef(localHeadSha, tempRef, { force: true });
Expand All @@ -144,6 +136,74 @@ export async function pushSignedCommit({
}
}

/**
* Recreates every local-only commit (oldest first) as a signed commit via the GitHub API,
* re-parenting each onto the signed replacement of its predecessor. Returns the signed SHA
* corresponding to the local HEAD.
*/
async function signLocalCommitChain({
repository,
octokit,
owner,
repo,
localHeadSha,
author,
logger
}: {
repository: ClonedRepository;
octokit: Octokit;
owner: string;
repo: string;
localHeadSha: string;
author?: CommitAuthor;
logger: PipelineLogger;
}): Promise<string> {
let chain = await repository.getLocalOnlyCommits();
if (chain.length === 0 || chain[chain.length - 1] !== localHeadSha) {
// No remote-tracking refs to compare against (or HEAD moved unexpectedly) —
// sign HEAD only, matching the previous single-commit behavior.
chain = [localHeadSha];
} else if (chain.length > MAX_SIGNED_CHAIN_LENGTH) {
logger.warn(
`Local commit chain has ${chain.length} commits (limit ${MAX_SIGNED_CHAIN_LENGTH}); signing HEAD only.`
);
chain = [localHeadSha];
}

// GitHub's web-flow signer keys off `committer`. The Git Data API defaults
// `committer` to `author` when `author` is provided, so sending `author` alone
// suppresses auto-signing. Omitting both lets GitHub fill them in from the
// authenticated signing-capable principal (App installation, OAuth — never a PAT)
// and stamp the "Verified" signature; the App's bot identity keeps the Fern bot
// noreply email, so attribution-based tooling (replay commit detection,
// findExistingUpdatablePR) still matches.
const identityFields = author != null ? { author, committer: author } : {};

const signedShaByLocalSha = new Map<string, string>();
let signedSha = localHeadSha;
for (const sha of chain) {
const [treeSha, message, parents] = await Promise.all([
repository.getCommitTreeHash(sha),
repository.getCommitMessage(sha),
repository.getCommitParents(sha)
]);
const { data: signedCommit } = await octokit.git.createCommit({
owner,
repo,
message,
tree: treeSha,
parents: parents.map((parent) => signedShaByLocalSha.get(parent) ?? parent),
...identityFields
});
signedShaByLocalSha.set(sha, signedCommit.sha);
signedSha = signedCommit.sha;
}
if (chain.length > 1) {
logger.debug(`Signed ${chain.length} local commits via the GitHub API`);
}
return signedSha;
}

async function upsertBranchRef({
octokit,
owner,
Expand Down