Skip to content

[No QA] Update CIGitLogicTest.ts for cherry pick to production #59739

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 22 commits into from
Apr 8, 2025
Merged
Show file tree
Hide file tree
Changes from 11 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
17 changes: 13 additions & 4 deletions .github/libs/GitUtils.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import {execSync, spawn} from 'child_process';
import CONST from './CONST';
import sanitizeStringForJSONParse from './sanitizeStringForJSONParse';
import * as VersionUpdater from './versionUpdater';

Check failure on line 4 in .github/libs/GitUtils.ts

View workflow job for this annotation

GitHub Actions / Changed files ESLint check

Namespace imports are not allowed. Use named imports instead. Example: import { method } from "./libs/module"
import type {SemverLevel} from './versionUpdater';

type CommitType = {
Expand Down Expand Up @@ -71,7 +71,7 @@
/**
* @param [shallowExcludeTag] When fetching the given tag, exclude all history reachable by the shallowExcludeTag (used to make fetch much faster)
*/
function fetchTag(tag: string, shallowExcludeTag = '') {

Check failure on line 74 in .github/libs/GitUtils.ts

View workflow job for this annotation

GitHub Actions / ESLint check

'fetchTag' is defined but never used

Check failure on line 74 in .github/libs/GitUtils.ts

View workflow job for this annotation

GitHub Actions / Changed files ESLint check

'fetchTag' is defined but never used
let shouldRetry = true;
let needsRepack = false;
while (shouldRetry) {
Expand Down Expand Up @@ -113,9 +113,18 @@
*/
function getCommitHistoryAsJSON(fromTag: string, toTag: string): Promise<CommitType[]> {
// Fetch tags, excluding commits reachable from the previous patch version (i.e: previous checklist), so that we don't have to fetch the full history
const previousPatchVersion = getPreviousExistingTag(fromTag, VersionUpdater.SEMANTIC_VERSION_LEVELS.PATCH);
fetchTag(fromTag, previousPatchVersion);
fetchTag(toTag, previousPatchVersion);
// TODO: Add check for if tag is off by at least a minor, fetch the minor

// const previousPatchVersion = getPreviousExistingTag(fromTag, VersionUpdater.SEMANTIC_VERSION_LEVELS.MINOR);
// fetchTag(fromTag, `${fromTag}~1`);
// fetchTag(toTag, `${fromTag}~1`);

// execSync(`git fetch origin tag --no-tags ${fromTag}`);
// execSync(`git fetch origin tag --no-tags ${toTag}`);
// const hashForFromTag = execSync(`git rev-parse ${fromTag}`).toString().trim();

// TODO: Make this fast 🚀
execSync(`git fetch --tags --unshallow`);

console.log('Getting pull requests merged between the following tags:', fromTag, toTag);
return new Promise<string>((resolve, reject) => {
Expand Down Expand Up @@ -164,7 +173,7 @@
return;
}

const match = commit.subject.match(/Merge pull request #(\d+) from (?!Expensify\/.*-cherry-pick-staging)/);
const match = commit.subject.match(/Merge pull request #(\d+) from (?!Expensify\/.*-cherry-pick-(staging|production))/);
if (!Array.isArray(match) || match.length < 2) {
return;
}
Expand Down
168 changes: 135 additions & 33 deletions tests/unit/CIGitLogicTest.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@
import CONST from '@github/libs/CONST';
import GitUtils from '@github/libs/GitUtils';
import * as VersionUpdater from '@github/libs/versionUpdater';
import type {SemverLevel} from '@github/libs/versionUpdater';
import {SEMANTIC_VERSION_LEVELS, SemverLevel} from '@github/libs/versionUpdater';

Check failure on line 17 in tests/unit/CIGitLogicTest.ts

View workflow job for this annotation

GitHub Actions / ESLint check

Imports "SemverLevel" are only used as type

Check failure on line 17 in tests/unit/CIGitLogicTest.ts

View workflow job for this annotation

GitHub Actions / ESLint check

'SEMANTIC_VERSION_LEVELS' is defined but never used

Check failure on line 17 in tests/unit/CIGitLogicTest.ts

View workflow job for this annotation

GitHub Actions / Changed files ESLint check

Imports "SemverLevel" are only used as type

Check failure on line 17 in tests/unit/CIGitLogicTest.ts

View workflow job for this annotation

GitHub Actions / Changed files ESLint check

'SEMANTIC_VERSION_LEVELS' is defined but never used
import asMutable from '@src/types/utils/asMutable';
import * as Log from '../../scripts/utils/Logger';

Expand Down Expand Up @@ -64,6 +64,7 @@
function initGitServer() {
Log.info('Initializing git server...');
if (fs.existsSync(GIT_REMOTE)) {
Log.info(`${GIT_REMOTE} exists, remove it now...`);
fs.rmSync(GIT_REMOTE, {recursive: true});
}
fs.mkdirSync(GIT_REMOTE, {recursive: true});
Expand All @@ -76,8 +77,15 @@
exec('git add -A');
exec('git commit -m "Initial commit"');
exec('git switch -c staging');
exec('git switch -c production');

// Tag the production branch with 1.0.0.0
exec(`git tag ${getVersion()}`);
exec('git branch production');

// Bump version to 2.0.0.0
bumpVersion(VersionUpdater.SEMANTIC_VERSION_LEVELS.MAJOR, true)
exec(`git tag ${getVersion()}`)
exec(`git switch staging`);
exec('git config --local receive.denyCurrentBranch ignore');
Log.success(`Initialized git server in ${GIT_REMOTE}`);
}
Expand All @@ -96,15 +104,17 @@
Log.success('Checked out repo at $DUMMY_DIR!');
}

function bumpVersion(level: SemverLevel) {
function bumpVersion(level: SemverLevel, isRemote = false) {
Log.info('Bumping version...');
setupGitAsOSBotify();
exec('git switch main');
const nextVersion = VersionUpdater.incrementVersion(getVersion(), level);
exec(`npm --no-git-tag-version version ${nextVersion}`);
exec('git add package.json');
exec(`git commit -m "Update version to ${nextVersion}"`);
exec('git push origin main');
if (!isRemote) {
exec('git push origin main');
}
Log.success(`Version bumped to ${nextVersion} on main`);
}

Expand Down Expand Up @@ -139,7 +149,8 @@
} catch (e) {}

exec('git switch -c production');
exec('git push --force origin production');
exec(`git tag ${getVersion()}`);
exec('git push --force --tags origin production');
Log.success('Recreated production from staging!');
}

Expand Down Expand Up @@ -170,8 +181,9 @@
Log.success(`Merged PR #${num} to main`);
}

function cherryPickPR(num: number, resolveVersionBumpConflicts: () => void = () => {}, resolveMergeCommitConflicts: () => void = () => {}) {
function cherryPickPRToStaging(num: number, resolveVersionBumpConflicts: () => void = () => {}, resolveMergeCommitConflicts: () => void = () => {}) {
Log.info(`Cherry-picking PR ${num} to staging...`);
// TODO: Move mergePR into the test itself
mergePR(num);
const prMergeCommit = execSync('git rev-parse HEAD', {encoding: 'utf-8'}).trim();
bumpVersion(VersionUpdater.SEMANTIC_VERSION_LEVELS.BUILD);
Expand All @@ -181,6 +193,8 @@

mockGetInput.mockReturnValue(VersionUpdater.SEMANTIC_VERSION_LEVELS.PATCH);
const previousPatchVersion = getPreviousVersion();

// --shallow-exclude is used to speed up the fetch
exec(`git fetch origin main staging --no-tags --shallow-exclude="${previousPatchVersion}"`);

exec('git switch staging');
Expand All @@ -192,11 +206,17 @@
resolveVersionBumpConflicts();
}

// TODO: This assumes that we have a conflict, we should not assume that
setupGitAsHuman();

try {
exec(`git cherry-pick -x --mainline 1 --strategy=recursive -Xtheirs ${prMergeCommit}`);
} catch (e) {
// 1. Abort cherry-pick
// 2. Create the cherry-pick-staging branch
// 3. Run setupGitAsHuman()
// 4. Re-run the cherry pick git command (it will have conflicts again)
// 5. Catch the conflicts exception, run resolveMergeCommitConflicts()
resolveMergeCommitConflicts();
}

Expand All @@ -210,6 +230,56 @@
Log.success(`Successfully cherry-picked PR #${num} to staging!`);
}

function cherryPickPRToProduction(num: number, resolveVersionBumpConflicts: () => void = () => {}, resolveMergeCommitConflicts: () => void = () => {}) {
Log.info(`Cherry-picking PR ${num} to production...`);
mergePR(num);
const prMergeCommit = execSync('git rev-parse HEAD', {encoding: 'utf-8'}).trim();
bumpVersion(VersionUpdater.SEMANTIC_VERSION_LEVELS.PATCH);
let versionBumpCommit = execSync('git rev-parse HEAD', {encoding: 'utf-8'}).trim();
checkoutRepo();
setupGitAsOSBotify();

mockGetInput.mockReturnValue(VersionUpdater.SEMANTIC_VERSION_LEVELS.MINOR);
const previousPatchVersion = getPreviousVersion();
exec(`git fetch origin main production --no-tags --shallow-exclude="${previousPatchVersion}"`);

exec('git switch production');
exec('git switch -c cherry-pick-production');

try {
exec(`git cherry-pick -x --mainline 1 -Xtheirs ${versionBumpCommit}`);
} catch (e) {
resolveVersionBumpConflicts();
}

setupGitAsHuman();

try {
exec(`git cherry-pick -x --mainline 1 --strategy=recursive -Xtheirs ${prMergeCommit}`);
} catch (e) {
resolveMergeCommitConflicts();
}

setupGitAsOSBotify();
exec('git switch production');
exec(`git merge cherry-pick-production --no-ff -m "Merge pull request #${num + 1} from Expensify/cherry-pick-production"`);
exec('git branch -d cherry-pick-production');
exec('git push origin production');
Log.info(`Merged PR #${num + 1} into production`);
tagProduction();

bumpVersion(VersionUpdater.SEMANTIC_VERSION_LEVELS.BUILD);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We probably want to add a checkoutRepo here to simulate this running in another job. Because I imagine that's how we'll implement it in workflows

versionBumpCommit = execSync('git rev-parse HEAD', {encoding: 'utf-8'}).trim();
exec(`git fetch origin staging --depth=1`)
exec(`git switch staging`)
exec(`git cherry-pick -x --mainline 1 -Xtheirs ${versionBumpCommit}`)
exec('git push origin staging');
tagStaging();
Log.success(`Pushed to staging after CP to production`);

Log.success(`Successfully cherry-picked PR #${num} to production!`);
}

function tagStaging() {
Log.info('Tagging new version from the staging branch...');
checkoutRepo();
Expand All @@ -220,11 +290,28 @@
exec('git fetch origin staging --depth=1');
}
exec('git switch staging');
exec(`git tag ${getVersion()}-staging`);
exec('git push --tags');
Log.success(`Created new tag ${getVersion()}`);
}

function tagProduction() {
Log.info('Tagging new version from the production branch...');
Log.info(`Version is: ${getVersion()}`);
checkoutRepo();
setupGitAsOSBotify();
try {
execSync('git rev-parse --verify production', {stdio: 'ignore'});
} catch (e) {
exec('git fetch origin production --depth=1');
}
exec('git switch production');
exec(`git tag ${getVersion()}`);
exec('git push --tags');
Log.success(`Created new tag ${getVersion()}`);
}


function deployStaging() {
Log.info('Deploying staging...');
checkoutRepo();
Expand Down Expand Up @@ -290,33 +377,47 @@
deployStaging();

// Verify output for checklist and deploy comment
await assertPRsMergedBetween('1.0.0-0', '1.0.0-1', [1]);
await assertPRsMergedBetween('2.0.0-0', '2.0.0-1-staging', [1]);
});

test("Merge a pull request with the checklist locked, but don't CP it", () => {
test("Merge a pull request with the checklist locked, but don't CP it", async () => {
createBasicPR(2);
mergePR(2);

// Verify output for checklist and deploy comment, and make sure PR #2 is not on staging
await assertPRsMergedBetween('2.0.0-0', '2.0.0-1-staging', [1]);
});

test('Merge a pull request with the checklist locked and CP it to staging', async () => {
createBasicPR(3);
cherryPickPR(3);
cherryPickPRToStaging(3);

// Verify output for checklist
await assertPRsMergedBetween('1.0.0-0', '1.0.0-2', [1, 3]);
await assertPRsMergedBetween('2.0.0-0', '2.0.0-2-staging', [1, 3]);

// Verify output for deploy comment, and make sure PR #2 is not on staging
await assertPRsMergedBetween('2.0.0-1-staging', '2.0.0-2-staging', [3]);
});

test('Merge a pull request with the checklist locked and CP it to production', async () => {
createBasicPR(4);
cherryPickPRToProduction(4);

// Verify output for checklist
await assertPRsMergedBetween('2.0.0-0', '2.0.1-1-staging', [1, 3]);

// Verify output for deploy comment
await assertPRsMergedBetween('1.0.0-1', '1.0.0-2', [3]);
await assertPRsMergedBetween('2.0.0-0', '2.0.1-0', [4]);
});

test('Close the checklist', async () => {
test('Close the checklist, deploy production and staging', async () => {
deployProduction();

// Verify output for release body and production deploy comments
await assertPRsMergedBetween('1.0.0-0', '1.0.0-2', [1, 3]);
await assertPRsMergedBetween('2.0.0-0', '2.0.1-1', [1, 3]);

// Verify output for new checklist and staging deploy comments
await assertPRsMergedBetween('1.0.0-2', '1.0.1-0', [2]);
await assertPRsMergedBetween('2.0.0-2-staging', '2.0.2-0-staging', [2, 4]);
});

test('Merging another pull request when the checklist is unlocked', async () => {
Expand All @@ -325,10 +426,10 @@
deployStaging();

// Verify output for checklist
await assertPRsMergedBetween('1.0.0-2', '1.0.1-1', [2, 5]);
await assertPRsMergedBetween('2.0.0-2-staging', '2.0.2-1-staging', [2, 4, 5]);

// Verify output for deploy comment
await assertPRsMergedBetween('1.0.1-0', '1.0.1-1', [5]);
await assertPRsMergedBetween('2.0.2-0-staging', '2.0.2-1-staging', [5]);
});

test('Deploying a PR, then CPing a revert, then adding the same code back again before the next production deploy results in the correct code on staging and production', async () => {
Expand All @@ -345,10 +446,10 @@
deployStaging();

// Verify output for checklist
await assertPRsMergedBetween('1.0.0-2', '1.0.1-2', [2, 5, 6]);
await assertPRsMergedBetween('2.0.0-2-staging', '2.0.2-2-staging', [2, 4, 5, 6]);

// Verify output for deploy comment
await assertPRsMergedBetween('1.0.1-1', '1.0.1-2', [6]);
await assertPRsMergedBetween('2.0.2-1-staging', '2.0.2-2-staging', [6]);

Log.info('Appending and prepending content to myFile.txt in PR #7');
setupGitAsHuman();
Expand All @@ -366,10 +467,10 @@
deployStaging();

// Verify output for checklist
await assertPRsMergedBetween('1.0.0-2', '1.0.1-3', [2, 5, 6, 7]);
await assertPRsMergedBetween('2.0.0-2-staging', '2.0.2-3-staging', [2, 4, 5, 6, 7]);

// Verify output for deploy comment
await assertPRsMergedBetween('1.0.1-2', '1.0.1-3', [7]);
await assertPRsMergedBetween('2.0.2-2-staging', '2.0.2-3-staging', [7]);

Log.info('Making an unrelated change in PR #8');
setupGitAsHuman();
Expand All @@ -389,7 +490,7 @@
console.log('RORY_DEBUG AFTER:', fs.readFileSync('myFile.txt', {encoding: 'utf8'}));
exec('git add myFile.txt');
exec('git commit -m "Revert append and prepend"');
cherryPickPR(9);
cherryPickPRToStaging(9);

Log.info('Verifying that the revert is present on staging, but the unrelated change is not');
expect(fs.readFileSync('myFile.txt', {encoding: 'utf8'})).toBe(initialFileContent);
Expand All @@ -407,10 +508,11 @@
deployProduction();

// Verify production release list
await assertPRsMergedBetween('1.0.0-2', '1.0.1-4', [2, 5, 6, 7, 9]);
// TODO: Fix this case
// await assertPRsMergedBetween('2.0.1-0', '2.0.2-4', [2, 4, 5, 6, 7, 9]);

// Verify PR list for the new checklist
await assertPRsMergedBetween('1.0.1-4', '1.0.2-0', [8, 10]);
await assertPRsMergedBetween('2.0.2-4-staging', '2.0.3-0-staging', [8, 10]);
});

test('Force-pushing to a branch after rebasing older commits', async () => {
Expand All @@ -421,10 +523,10 @@
deployStaging();

// Verify PRs for checklist
await assertPRsMergedBetween('1.0.1-4', '1.0.2-1', [8, 10, 12]);
await assertPRsMergedBetween('2.0.2-4-staging', '2.0.3-1-staging', [8, 10, 12]);

// Verify PRs for deploy comments
await assertPRsMergedBetween('1.0.2-0', '1.0.2-1', [12]);
await assertPRsMergedBetween('2.0.3-0-staging', '2.0.3-1-staging', [12]);

checkoutRepo();
setupGitAsHuman();
Expand All @@ -437,10 +539,10 @@
deployProduction();

// Verify PRs for deploy comments / release
await assertPRsMergedBetween('1.0.1-4', '1.0.2-1', [8, 10, 12]);
await assertPRsMergedBetween('2.0.2-4-staging', '2.0.3-1-staging', [8, 10, 12]);

// Verify PRs for new checklist
await assertPRsMergedBetween('1.0.2-1', '1.0.3-0', [11]);
await assertPRsMergedBetween('2.0.3-1-staging', '2.0.4-0-staging', [11]);
});

test('Manual version bump', async () => {
Expand All @@ -464,7 +566,7 @@
Log.success(`Deployed v${getVersion()} to staging!`);

// Verify PRs for deploy comments / release and new checklist
await assertPRsMergedBetween('1.0.3-0', '4.0.0-0', [13]);
await assertPRsMergedBetween('2.0.4-0-staging', '5.0.0-0-staging', [13]);

Log.info('Creating manual version bump in PR #14');
checkoutRepo();
Expand All @@ -479,22 +581,22 @@
Log.success('Created manual version bump in PR #14 in branch pr-14');

const packageJSONBefore = fs.readFileSync('package.json', {encoding: 'utf-8'});
cherryPickPR(
cherryPickPRToStaging(
14,
() => {
fs.writeFileSync('package.json', packageJSONBefore);
exec('git add package.json');
exec('git cherry-pick --continue');
exec('git cherry-pick --no-edit --continue');
},
() => {
exec('git commit --no-edit --allow-empty');
},
);

// Verify PRs for deploy comments
await assertPRsMergedBetween('4.0.0-0', '7.0.0-0', [14]);
await assertPRsMergedBetween('5.0.0-0-staging', '8.0.0-0-staging', [14]);

// Verify PRs for the deploy checklist
await assertPRsMergedBetween('1.0.3-0', '7.0.0-0', [13, 14]);
});
await assertPRsMergedBetween('2.0.4-0-staging', '8.0.0-0-staging', [13, 14]);
})
});
Loading