Skip to content
Closed
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
23 changes: 14 additions & 9 deletions .github/workflows/release-patch-1-create-pr.yml
Original file line number Diff line number Diff line change
Expand Up @@ -75,15 +75,20 @@ jobs:
GH_TOKEN: '${{ steps.generate_token.outputs.token }}'
continue-on-error: true
run: |
# Capture output and display it in logs using tee
{
node scripts/releasing/create-patch-pr.js --commit=${{ github.event.inputs.commit }} --channel=${{ github.event.inputs.channel }} --dry-run=${{ github.event.inputs.dry_run }}
echo "EXIT_CODE=$?" >> "$GITHUB_OUTPUT"
} 2>&1 | tee >(
echo "LOG_CONTENT<<EOF" >> "$GITHUB_ENV"
cat >> "$GITHUB_ENV"
echo "EOF" >> "$GITHUB_ENV"
)
# Use a temporary file to store the log
LOG_FILE=$(mktemp)

# Run the script and tee the output to the log file
node scripts/releasing/create-patch-pr.js --commit=${{ github.event.inputs.commit }} --channel=${{ github.event.inputs.channel }} --dry-run=${{ github.event.inputs.dry_run }} 2>&1 | tee "${LOG_FILE}"

# Capture the exit code of the node script (the first command in the pipe)
EXIT_CODE=${PIPESTATUS[0]}
echo "EXIT_CODE=${EXIT_CODE}" >> "$GITHUB_OUTPUT"

# Store the log content in an environment variable for the comment step
echo "LOG_CONTENT<<EOF" >> "$GITHUB_ENV"
cat "${LOG_FILE}" >> "$GITHUB_ENV"
echo "EOF" >> "$GITHUB_ENV"

- name: 'Comment on Original PR'
if: 'always() && inputs.original_pr'
Expand Down
37 changes: 29 additions & 8 deletions scripts/releasing/create-patch-pr.js
Original file line number Diff line number Diff line change
Expand Up @@ -7,10 +7,11 @@
*/

import { execSync } from 'node:child_process';
import { fileURLToPath } from 'node:url';
import yargs from 'yargs';
import { hideBin } from 'yargs/helpers';

async function main() {
export async function main() {
const argv = await yargs(hideBin(process.argv))
.option('commit', {
alias: 'c',
Expand Down Expand Up @@ -134,7 +135,23 @@ async function main() {
execSync(`git cherry-pick ${commit}`, { stdio: 'pipe' });
console.log(`βœ… Cherry-pick successful - no conflicts detected`);
} catch (error) {
// Check if this is a cherry-pick conflict
const output = (error.stdout || '') + (error.stderr || '');

// Case 1: Commit is empty, which is not an error.
if (output.includes('The previous cherry-pick is now empty')) {
console.log(`βœ… Commit ${commit} is empty and has been skipped.`);
process.exit(0);
}

// Case 2: Commit has already been applied.
if (output.match(/nothing to commit/)) {
console.log(
`βœ… Commit ${commit} has already been applied to ${releaseBranch}. No patch needed.`,
);
process.exit(0);
}

// Case 3: It's a real error, check for merge conflicts.
try {
const status = execSync('git status --porcelain', { encoding: 'utf8' });
const conflictFiles = status
Expand Down Expand Up @@ -164,11 +181,11 @@ async function main() {
execSync(`git commit --no-edit`);
console.log(`βœ… Committed cherry-pick with conflict markers`);
} else {
// Re-throw if it's not a conflict error
// It's not a conflict, so re-throw the original error.
throw error;
}
} catch (_statusError) {
// Re-throw original error if we can't determine the status
// If `git status` fails, we can't determine the cause, so re-throw.
throw error;
}
}
Expand Down Expand Up @@ -278,7 +295,11 @@ function getLatestReleaseInfo(channel) {
}
}

main().catch((err) => {
console.error(err);
process.exit(1);
});
const isMain = process.argv[1] === fileURLToPath(import.meta.url);

if (isMain) {
main().catch((err) => {
console.error(err);
process.exit(1);
});
}
216 changes: 216 additions & 0 deletions scripts/tests/create-patch-pr.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,216 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/

import { vi, describe, it, expect, beforeEach, afterEach } from 'vitest';

// Mock the child_process module at the top level
vi.mock('node:child_process', async (importOriginal) => {
const actual = await importOriginal();
return {
...actual,
execSync: vi.fn(),
};
});

describe('create-patch-pr', () => {
const scriptPath = '../../scripts/releasing/create-patch-pr.js';
let originalArgv;
let execSyncMock;
let consoleLogSpy;
let consoleErrorSpy;
// eslint-disable-next-line @typescript-eslint/no-unused-vars
let processExitSpy;

// Helper to set up mocks with default "happy path" behavior
const setupMocks = (overrides = {}) => {
const defaultMocks = {
'get-release-version.js': () =>
JSON.stringify({
previousReleaseTag: 'v1.0.0',
releaseVersion: 'v1.0.1',
}),
'git ls-remote': () => {
throw new Error('exit code 1');
}, // Default: branches don't exist
'gh pr list': () => '', // Default: no existing PR
'git cherry-pick': () => '', // Default: success
'git push': () => '',
'git status': () => '',
};

const mocks = { ...defaultMocks, ...overrides };

execSyncMock.mockImplementation((command) => {
for (const key in mocks) {
if (command.includes(key)) {
return mocks[key]();
}
}
return ''; // Default success for other commands (checkout, config, etc.)
});
};

beforeEach(async () => {
vi.resetModules();
originalArgv = [...process.argv];
const cp = await import('node:child_process');
execSyncMock = cp.execSync;
vi.resetAllMocks();

consoleLogSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
processExitSpy = vi.spyOn(process, 'exit').mockImplementation((code) => {
throw new Error(`Process exited with code ${code}`);
});
});

afterEach(() => {
process.argv = originalArgv;
vi.restoreAllMocks();
});

async function runScript(args = []) {
process.argv = ['node', 'create-patch-pr.js', ...args];
const { main } = await import(scriptPath);
await main();
}

// --- TEST CASES ---

it('should complete successfully on the happy path', async () => {
setupMocks();
await runScript(['--commit', 'abcdef1', '--channel', 'stable']);

const prCommand = execSyncMock.mock.calls.find((call) =>
call[0].startsWith('gh pr create'),
);
expect(prCommand).toBeDefined();
expect(prCommand[0]).toContain('fix(patch): cherry-pick abcdef1');
expect(prCommand[0]).not.toContain('[CONFLICTS]');
expect(consoleLogSpy).toHaveBeenCalledWith(
'βœ… Patch process completed successfully!',
);
});

it('should handle cherry-pick with merge conflicts', async () => {
setupMocks({
'git cherry-pick': () => {
throw new Error('Cherry-pick failed');
},
'git status': () => 'UU file1.js\nUU file2.js',
});

await runScript(['--commit', 'abcdef1', '--channel', 'stable']);

const prCommand = execSyncMock.mock.calls.find((call) =>
call[0].startsWith('gh pr create'),
);
expect(prCommand).toBeDefined();
expect(prCommand[0]).toContain('[CONFLICTS]');
expect(consoleLogSpy).toHaveBeenCalledWith(
expect.stringContaining('Cherry-pick has conflicts'),
);
expect(consoleLogSpy).toHaveBeenCalledWith(
'⚠️ Patch process completed with conflicts - manual resolution required!',
);
});

it('should exit gracefully if commit is already applied', async () => {
setupMocks({
'git cherry-pick': () => {
const error = new Error('Command failed');
error.stderr = 'nothing to commit, working tree clean';
throw error;
},
});

await expect(
runScript(['--commit', 'abcdef1', '--channel', 'stable']),
).rejects.toThrow('Process exited with code 0');
expect(consoleLogSpy).toHaveBeenCalledWith(
expect.stringContaining('already been applied'),
);
});

it('should exit gracefully if commit is empty', async () => {
setupMocks({
'git cherry-pick': () => {
const error = new Error('Command failed');
error.stdout = 'The previous cherry-pick is now empty';
throw error;
},
});

await expect(
runScript(['--commit', 'abcdef1', '--channel', 'stable']),
).rejects.toThrow('Process exited with code 0');
expect(consoleLogSpy).toHaveBeenCalledWith(
expect.stringContaining('is empty and has been skipped'),
);
});

it('should detect and report an existing PR', async () => {
setupMocks({
'git ls-remote': () => '', // Branches exist
'gh pr list': () =>
JSON.stringify({
number: 123,
url: 'https://github.com/owner/repo/pull/123',
}),
});

await runScript(['--commit', 'abcdef1', '--channel', 'stable']);

expect(consoleLogSpy).toHaveBeenCalledWith(
'Found existing PR #123: https://github.com/owner/repo/pull/123',
);
const prCreateCall = execSyncMock.mock.calls.find((call) =>
call[0].startsWith('gh pr create'),
);
expect(prCreateCall).toBeUndefined();
});

it('should log commands but not execute them in dry-run mode', async () => {
setupMocks();
await runScript([
'--commit',
'abcdef1',
'--channel',
'stable',
'--dry-run',
]);

expect(consoleLogSpy).toHaveBeenCalledWith('Running in dry-run mode.');
expect(consoleLogSpy).toHaveBeenCalledWith(
expect.stringContaining('[DRY RUN] Would cherry-pick'),
);
const pushCall = execSyncMock.mock.calls.find((call) =>
call[0].startsWith('git push'),
);
expect(pushCall).toBeUndefined();
});

it('should provide manual instructions on permission failure', async () => {
setupMocks({
'git push': () => {
const error = new Error('Command failed');
error.message =
'GH013: refusing to allow a GitHub App to create or update workflow without the workflows permission.';
throw error;
},
});

await expect(
runScript(['--commit', 'abcdef1', '--channel', 'stable']),
).rejects.toThrow('Process exited with code 1');
expect(consoleErrorSpy).toHaveBeenCalledWith(
expect.stringContaining('insufficient GitHub App permissions'),
);
expect(consoleLogSpy).toHaveBeenCalledWith(
expect.stringContaining('Please run these commands manually'),
);
});
});
Loading