From b8f91e11143613f9136801f18220237cf47405a4 Mon Sep 17 00:00:00 2001 From: German Suarez Alonso Date: Thu, 13 Nov 2025 15:57:18 +0100 Subject: [PATCH 1/5] feat: update existing branch with the new SHA --- README.md | 10 ++++++---- __tests__/create-branch.test.ts | 30 +++++++++++++++++++++++------- action.yml | 6 +++--- src/create-branch.ts | 18 ++++++++++++++++-- 4 files changed, 48 insertions(+), 16 deletions(-) diff --git a/README.md b/README.md index 0cffbf6..4e4186c 100644 --- a/README.md +++ b/README.md @@ -1,22 +1,24 @@ # Create Branch GitHub Action -This action creates a new branch with the same commit reference as the branch it is being ran on, or your chosen reference when specified. +This action creates a new branch with the same commit reference as the branch it is being run on, or your chosen reference when specified. ## Inputs ### `branch` -**Optional** The name of the branch to create. Default `"release-candidate"`. If your branch conains forward slashes (`/`) use the full branch reference. Instead of `/long/branch/name` use `refs/heads/long/branch/name`. It's an issue with the GitHub API https://gist.github.com/jasonrudolph/10727108 +**Optional** The name of the branch to create. Default `"release-candidate"`. If your branch contains forward slashes (`/`) use the full branch reference. Instead of `/long/branch/name` use `refs/heads/long/branch/name`. It's an issue with the GitHub API https://gist.github.com/jasonrudolph/10727108 + +If the branch already exists, the action will attempt to update the branch reference to point at the provided `sha` (or the current event `sha`) via a fast‑forward update. The action does not force-push or rewrite history; if the update is not a fast‑forward the API will reject the update. ### `sha` -**Optional** The SHA1 value for the branch reference. +**Optional** The SHA-1 value for the branch reference. ## Outputs ### `created` -Boolean value representing whether or not a new branch was created. +Boolean value representing whether the action successfully created or updated the branch reference. `true` means the branch reference was created or updated to point at the requested SHA; `false` indicates the operation failed. ## Example usage diff --git a/__tests__/create-branch.test.ts b/__tests__/create-branch.test.ts index eb5b252..a51be93 100644 --- a/__tests__/create-branch.test.ts +++ b/__tests__/create-branch.test.ts @@ -12,7 +12,8 @@ describe('Create a branch based on the input', () => { let octokitMock = { rest: { git: { - createRef: jest.fn() + createRef: jest.fn(), + updateRef: jest.fn() }, repos: { getBranch: jest.fn() @@ -40,28 +41,43 @@ describe('Create a branch based on the input', () => { it('Creates a new branch if not already there', async () => { octokitMock.rest.repos.getBranch.mockRejectedValue(new HttpError()) await createBranch(githubMock, contextMock, branch) - expect(octokitMock.rest.git.createRef).toHaveBeenCalledWith({ + expect(octokitMock.rest.git.createRef).toHaveBeenCalledWith(expect.objectContaining({ ref: 'refs/heads/release-v1', sha: 'ebb4992dc72451c1c6c99e1cce9d741ec0b5b7d7' - }) + })) }); it('Creates a new branch from a given commit SHA', async () => { octokitMock.rest.repos.getBranch.mockRejectedValue(new HttpError()) await createBranch(githubMock, contextMock, branch, sha) - expect(octokitMock.rest.git.createRef).toHaveBeenCalledWith({ + expect(octokitMock.rest.git.createRef).toHaveBeenCalledWith(expect.objectContaining({ ref: 'refs/heads/release-v1', sha: 'ffac537e6cbbf934b08745a378932722df287a53' - }) + })) }) it('Replaces refs/heads in branch name', async () => { octokitMock.rest.repos.getBranch.mockRejectedValue(new HttpError()) await createBranch(githubMock, contextMock, `refs/heads/${branch}`) - expect(octokitMock.rest.git.createRef).toHaveBeenCalledWith({ + expect(octokitMock.rest.git.createRef).toHaveBeenCalledWith(expect.objectContaining({ ref: 'refs/heads/release-v1', sha: 'ebb4992dc72451c1c6c99e1cce9d741ec0b5b7d7' - }) + })) + }); + + it('Updates existing branch via fast-forward', async () => { + // getBranch succeeds (branch exists) + octokitMock.rest.repos.getBranch.mockResolvedValue({}); + octokitMock.rest.git.updateRef.mockResolvedValue({ data: { ref: 'refs/heads/release-v1' } }); + + const result = await createBranch(githubMock, contextMock, branch, undefined); + + expect(octokitMock.rest.git.updateRef).toHaveBeenCalledWith(expect.objectContaining({ + ref: 'refs/heads/release-v1', + sha: contextMock.sha + })); + + expect(result).toBe(true); }); it('Fails if github token isn\'t defined', async () => { diff --git a/action.yml b/action.yml index cf378a6..73114ff 100644 --- a/action.yml +++ b/action.yml @@ -6,13 +6,13 @@ branding: color: 'green' inputs: branch: - description: 'The branch to create' + description: 'The branch to create (or update if it already exists via a fast-forward)' default: 'release-candidate' sha: - description: 'The SHA1 value for the branch reference' + description: 'The SHA1 value for the branch reference. If omitted the action uses the event SHA.' outputs: created: - description: 'Boolean value representing whether or not a new branch was created.' + description: 'Boolean value representing whether the branch reference was successfully created or updated to the requested SHA.' runs: using: 'node20' main: 'dist/index.js' diff --git a/src/create-branch.ts b/src/create-branch.ts index 118c0fd..882831c 100644 --- a/src/create-branch.ts +++ b/src/create-branch.ts @@ -6,27 +6,41 @@ export async function createBranch(getOctokit: any, context: Context, branch: st branch = branch.replace('refs/heads/', ''); const ref = `refs/heads/${branch}`; - // throws HttpError if branch already exists. + // Check if branch already exists try { await toolkit.rest.repos.getBranch({ ...context.repo, branch, }); + + // Branch exists, update it with the new SHA + const resp = await toolkit.rest.git.updateRef({ + ref, + sha: sha || context.sha, + ...context.repo, + }); + + return isValidRefResponse(resp, ref); } catch (error: any) { if (error.name === 'HttpError' && error.status === 404) { + // Branch doesn't exist, create it const resp = await toolkit.rest.git.createRef({ ref, sha: sha || context.sha, ...context.repo, }); - return resp?.data?.ref === ref; + return isValidRefResponse(resp, ref); } else { throw Error(error); } } } +function isValidRefResponse(resp: any, expectedRef: string): boolean { + return resp?.data?.ref === expectedRef; +} + function githubToken(): string { const token = process.env.GITHUB_TOKEN; if (!token) throw ReferenceError('No token defined in the environment variables'); From e4b9e8cd48cd7352aa4cf76fe4b273bb9cbdbe38 Mon Sep 17 00:00:00 2001 From: German Suarez Alonso Date: Fri, 14 Nov 2025 12:18:09 +0100 Subject: [PATCH 2/5] debug: add detailed logging for branch creation/update API responses --- dist/index.js | 51 ++++++++++++++++++++++++++++++++++++-------- src/create-branch.ts | 32 +++++++++++++++++---------- 2 files changed, 63 insertions(+), 20 deletions(-) diff --git a/dist/index.js b/dist/index.js index aff86c1..b966c2a 100644 --- a/dist/index.js +++ b/dist/index.js @@ -10117,10 +10117,29 @@ function wrappy (fn, cb) { /***/ }), /***/ 6719: -/***/ (function(__unused_webpack_module, exports) { +/***/ (function(__unused_webpack_module, exports, __nccwpck_require__) { "use strict"; +var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) { + if (k2 === undefined) k2 = k; + Object.defineProperty(o, k2, { enumerable: true, get: function() { return m[k]; } }); +}) : (function(o, m, k, k2) { + if (k2 === undefined) k2 = k; + o[k2] = m[k]; +})); +var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) { + Object.defineProperty(o, "default", { enumerable: true, value: v }); +}) : function(o, v) { + o["default"] = v; +}); +var __importStar = (this && this.__importStar) || function (mod) { + if (mod && mod.__esModule) return mod; + var result = {}; + if (mod != null) for (var k in mod) if (k !== "default" && Object.prototype.hasOwnProperty.call(mod, k)) __createBinding(result, mod, k); + __setModuleDefault(result, mod); + return result; +}; var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, generator) { function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); } return new (P || (P = Promise))(function (resolve, reject) { @@ -10132,29 +10151,43 @@ var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, ge }; Object.defineProperty(exports, "__esModule", ({ value: true })); exports.createBranch = void 0; +const core = __importStar(__nccwpck_require__(2186)); function createBranch(getOctokit, context, branch, sha) { - var _a; return __awaiter(this, void 0, void 0, function* () { const toolkit = getOctokit(githubToken()); // Sometimes branch might come in with refs/heads already branch = branch.replace('refs/heads/', ''); const ref = `refs/heads/${branch}`; - // throws HttpError if branch already exists. + const refPath = `heads/${branch}`; + const targetSha = sha || context.sha; + core.debug(`Target ref: ${ref} (createRef), refPath: ${refPath} (getRef/updateRef), target SHA: ${targetSha}`); + // Check if branch already exists using git refs API (heads/) try { - yield toolkit.rest.repos.getBranch(Object.assign(Object.assign({}, context.repo), { branch })); + const refData = yield toolkit.rest.git.getRef(Object.assign({ ref: refPath }, context.repo)); + core.debug(`Found ref via getRef: ${JSON.stringify(refData.data)}`); + // If ref exists, update it to target SHA + const resp = yield toolkit.rest.git.updateRef(Object.assign({ ref: refPath, sha: targetSha }, context.repo)); + core.debug(`updateRef response: ${JSON.stringify(resp.data)}`); + return isValidRefResponse(resp, ref); } catch (error) { + // If the ref was not found, create it. Other errors bubble up. if (error.name === 'HttpError' && error.status === 404) { - const resp = yield toolkit.rest.git.createRef(Object.assign({ ref, sha: sha || context.sha }, context.repo)); - return ((_a = resp === null || resp === void 0 ? void 0 : resp.data) === null || _a === void 0 ? void 0 : _a.ref) === ref; - } - else { - throw Error(error); + core.debug(`Ref not found via getRef, creating new branch`); + const resp = yield toolkit.rest.git.createRef(Object.assign({ ref, sha: targetSha }, context.repo)); + core.debug(`createRef response: ${JSON.stringify(resp.data)}`); + return isValidRefResponse(resp, ref); } + core.debug(`Unexpected error while checking/creating ref: ${error.name} ${error.status} ${error.message}`); + throw Error(error); } }); } exports.createBranch = createBranch; +function isValidRefResponse(resp, expectedRef) { + var _a; + return ((_a = resp === null || resp === void 0 ? void 0 : resp.data) === null || _a === void 0 ? void 0 : _a.ref) === expectedRef; +} function githubToken() { const token = process.env.GITHUB_TOKEN; if (!token) diff --git a/src/create-branch.ts b/src/create-branch.ts index 882831c..2f5a74e 100644 --- a/src/create-branch.ts +++ b/src/create-branch.ts @@ -1,39 +1,49 @@ import { Context } from '@actions/github/lib/context'; +import * as core from '@actions/core'; export async function createBranch(getOctokit: any, context: Context, branch: string, sha?: string) { const toolkit = getOctokit(githubToken()); // Sometimes branch might come in with refs/heads already branch = branch.replace('refs/heads/', ''); const ref = `refs/heads/${branch}`; - - // Check if branch already exists + const refPath = `heads/${branch}`; + const targetSha = sha || context.sha; + + core.debug(`Target ref: ${ref} (createRef), refPath: ${refPath} (getRef/updateRef), target SHA: ${targetSha}`); + // Check if branch already exists using git refs API (heads/) try { - await toolkit.rest.repos.getBranch({ + const refData = await toolkit.rest.git.getRef({ + ref: refPath, ...context.repo, - branch, }); - // Branch exists, update it with the new SHA + core.debug(`Found ref via getRef: ${JSON.stringify(refData.data)}`); + + // If ref exists, update it to target SHA const resp = await toolkit.rest.git.updateRef({ - ref, - sha: sha || context.sha, + ref: refPath, + sha: targetSha, ...context.repo, }); + core.debug(`updateRef response: ${JSON.stringify(resp.data)}`); return isValidRefResponse(resp, ref); } catch (error: any) { + // If the ref was not found, create it. Other errors bubble up. if (error.name === 'HttpError' && error.status === 404) { - // Branch doesn't exist, create it + core.debug(`Ref not found via getRef, creating new branch`); const resp = await toolkit.rest.git.createRef({ ref, - sha: sha || context.sha, + sha: targetSha, ...context.repo, }); + core.debug(`createRef response: ${JSON.stringify(resp.data)}`); return isValidRefResponse(resp, ref); - } else { - throw Error(error); } + + core.debug(`Unexpected error while checking/creating ref: ${error.name} ${error.status} ${error.message}`); + throw Error(error); } } From 70be8548bd13121b9841cbbacf6efb1919b0ef32 Mon Sep 17 00:00:00 2001 From: German Suarez Alonso Date: Fri, 14 Nov 2025 12:56:11 +0100 Subject: [PATCH 3/5] fix: tests getref --- __tests__/create-branch.test.ts | 27 ++++++++++++++++----------- 1 file changed, 16 insertions(+), 11 deletions(-) diff --git a/__tests__/create-branch.test.ts b/__tests__/create-branch.test.ts index a51be93..e8e47fb 100644 --- a/__tests__/create-branch.test.ts +++ b/__tests__/create-branch.test.ts @@ -13,7 +13,8 @@ describe('Create a branch based on the input', () => { rest: { git: { createRef: jest.fn(), - updateRef: jest.fn() + updateRef: jest.fn(), + getRef: jest.fn() }, repos: { getBranch: jest.fn() @@ -28,18 +29,20 @@ describe('Create a branch based on the input', () => { }); it('gets a branch', async () => { - octokitMock.rest.repos.getBranch.mockRejectedValue(new HttpError()) + octokitMock.rest.git.getRef.mockRejectedValue(new HttpError()) + octokitMock.rest.git.createRef.mockResolvedValue({ data: { ref: `refs/heads/${branch}` } }); process.env.GITHUB_REPOSITORY = 'peterjgrainger/test-action-changelog-reminder' await createBranch(githubMock, context, branch) - expect(octokitMock.rest.repos.getBranch).toHaveBeenCalledWith({ + expect(octokitMock.rest.git.getRef).toHaveBeenCalledWith({ + ref: `heads/${branch}`, repo: 'test-action-changelog-reminder', - owner: 'peterjgrainger', - branch + owner: 'peterjgrainger' }) }); it('Creates a new branch if not already there', async () => { - octokitMock.rest.repos.getBranch.mockRejectedValue(new HttpError()) + octokitMock.rest.git.getRef.mockRejectedValue(new HttpError()) + octokitMock.rest.git.createRef.mockResolvedValue({ data: { ref: 'refs/heads/release-v1' } }); await createBranch(githubMock, contextMock, branch) expect(octokitMock.rest.git.createRef).toHaveBeenCalledWith(expect.objectContaining({ ref: 'refs/heads/release-v1', @@ -48,7 +51,8 @@ describe('Create a branch based on the input', () => { }); it('Creates a new branch from a given commit SHA', async () => { - octokitMock.rest.repos.getBranch.mockRejectedValue(new HttpError()) + octokitMock.rest.git.getRef.mockRejectedValue(new HttpError()) + octokitMock.rest.git.createRef.mockResolvedValue({ data: { ref: 'refs/heads/release-v1' } }); await createBranch(githubMock, contextMock, branch, sha) expect(octokitMock.rest.git.createRef).toHaveBeenCalledWith(expect.objectContaining({ ref: 'refs/heads/release-v1', @@ -57,7 +61,8 @@ describe('Create a branch based on the input', () => { }) it('Replaces refs/heads in branch name', async () => { - octokitMock.rest.repos.getBranch.mockRejectedValue(new HttpError()) + octokitMock.rest.git.getRef.mockRejectedValue(new HttpError()) + octokitMock.rest.git.createRef.mockResolvedValue({ data: { ref: 'refs/heads/release-v1' } }); await createBranch(githubMock, contextMock, `refs/heads/${branch}`) expect(octokitMock.rest.git.createRef).toHaveBeenCalledWith(expect.objectContaining({ ref: 'refs/heads/release-v1', @@ -66,14 +71,14 @@ describe('Create a branch based on the input', () => { }); it('Updates existing branch via fast-forward', async () => { - // getBranch succeeds (branch exists) - octokitMock.rest.repos.getBranch.mockResolvedValue({}); + // getRef succeeds (ref exists) + octokitMock.rest.git.getRef.mockResolvedValue({ data: { ref: 'refs/heads/release-v1', object: { sha: contextMock.sha } } }); octokitMock.rest.git.updateRef.mockResolvedValue({ data: { ref: 'refs/heads/release-v1' } }); const result = await createBranch(githubMock, contextMock, branch, undefined); expect(octokitMock.rest.git.updateRef).toHaveBeenCalledWith(expect.objectContaining({ - ref: 'refs/heads/release-v1', + ref: 'heads/release-v1', sha: contextMock.sha })); From 7372a6a039dae02deb096589a2fc73ac2f33d72b Mon Sep 17 00:00:00 2001 From: German Suarez Alonso Date: Mon, 17 Nov 2025 11:24:13 +0100 Subject: [PATCH 4/5] copilot suggestion: preserve original error information Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- src/create-branch.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/create-branch.ts b/src/create-branch.ts index 2f5a74e..4c906b3 100644 --- a/src/create-branch.ts +++ b/src/create-branch.ts @@ -43,7 +43,7 @@ export async function createBranch(getOctokit: any, context: Context, branch: st } core.debug(`Unexpected error while checking/creating ref: ${error.name} ${error.status} ${error.message}`); - throw Error(error); + throw error; } } From f5be298258bb3f10a3a02515fea079270690f149 Mon Sep 17 00:00:00 2001 From: German Suarez Alonso Date: Mon, 17 Nov 2025 11:24:40 +0100 Subject: [PATCH 5/5] copilot suggestion: regular hyphen Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 4e4186c..c7f36f4 100644 --- a/README.md +++ b/README.md @@ -8,7 +8,7 @@ This action creates a new branch with the same commit reference as the branch it **Optional** The name of the branch to create. Default `"release-candidate"`. If your branch contains forward slashes (`/`) use the full branch reference. Instead of `/long/branch/name` use `refs/heads/long/branch/name`. It's an issue with the GitHub API https://gist.github.com/jasonrudolph/10727108 -If the branch already exists, the action will attempt to update the branch reference to point at the provided `sha` (or the current event `sha`) via a fast‑forward update. The action does not force-push or rewrite history; if the update is not a fast‑forward the API will reject the update. +If the branch already exists, the action will attempt to update the branch reference to point at the provided `sha` (or the current event `sha`) via a fast-forward update. The action does not force-push or rewrite history; if the update is not a fast-forward the API will reject the update. ### `sha`