Skip to content

Commit 72f3f0a

Browse files
committed
Use Richard's implementation of lockfile change detection
1 parent e8fde17 commit 72f3f0a

File tree

6 files changed

+158
-162
lines changed

6 files changed

+158
-162
lines changed

.github/actions/src/lockfiles/__tests__/lockfiles.test.ts renamed to .github/actions/src/__tests__/lockfiles.test.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import { describe, expect, it, vi } from 'vitest';
2-
import * as utils from '../utils.js';
2+
import * as utils from '../lockfiles.js';
33

4-
vi.mock(import('../../gitRoot.js'), () => ({
4+
vi.mock(import('../gitRoot.js'), () => ({
55
gitRoot: 'root'
66
}));
77

.github/actions/src/info/__tests__/index.test.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ import pathlib from 'path';
44
import * as core from '@actions/core';
55
import { describe, expect, test, vi } from 'vitest';
66
import * as git from '../../commons.js';
7-
import * as lockfiles from '../../lockfiles/index.js';
7+
import * as lockfiles from '../../lockfiles.js';
88
import { getAllPackages, getRawPackages, main } from '../index.js';
99

1010
const mockedCheckChanges = vi.spyOn(git, 'checkDirForChanges');

.github/actions/src/info/index.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ import type { SummaryTableRow } from '@actions/core/lib/summary.js';
66
import packageJson from '../../../../package.json' with { type: 'json' };
77
import { checkDirForChanges, type PackageRecord, type RawPackageRecord } from '../commons.js';
88
import { gitRoot } from '../gitRoot.js';
9-
import { getPackagesWithResolutionChanges, hasLockFileChanged } from '../lockfiles/index.js';
9+
import { getPackagesWithResolutionChanges, hasLockFileChanged } from '../lockfiles.js';
1010
import { topoSortPackages } from './sorter.js';
1111

1212
const packageNameRE = /^@sourceacademy\/(.+?)-(.+)$/u;

.github/actions/src/lockfiles.ts

Lines changed: 154 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,154 @@
1+
import fs from 'fs/promises';
2+
import pathlib from 'path';
3+
import * as core from '@actions/core';
4+
import { getExecOutput } from '@actions/exec';
5+
import memoize from 'lodash/memoize.js';
6+
import { extractPkgsFromYarnLockV2 } from 'snyk-nodejs-lockfile-parser';
7+
import { gitRoot } from './gitRoot.js';
8+
9+
const packageNameRE = /^(.+)@.+$/;
10+
11+
/**
12+
* Lockfile specifications come in the form of package_name@resolution, but
13+
* we only want the package name. This function extracts that package name,
14+
* accounting for the fact that package names might start with '@'
15+
*/
16+
export function extractPackageName(raw: string) {
17+
const match = packageNameRE.exec(raw);
18+
if (!match) {
19+
throw new Error(`Invalid package name: ${raw}`);
20+
}
21+
22+
return match[1];
23+
}
24+
25+
/**
26+
* Parses and lockfile's contents and extracts all the different dependencies and
27+
* versions
28+
*/
29+
function processLockFileText(contents: string) {
30+
const lockFile = extractPkgsFromYarnLockV2(contents);
31+
const mappings = new Set<string>();
32+
for (const [pkgSpecifier, { resolution }] of Object.entries(lockFile)) {
33+
mappings.add(`${pkgSpecifier} -> ${resolution}`);
34+
}
35+
return mappings;
36+
}
37+
38+
/**
39+
* Retrieves the contents of the lockfile in the repo
40+
*/
41+
async function getCurrentLockFile() {
42+
const lockFilePath = pathlib.join(gitRoot, 'yarn.lock');
43+
const contents = await fs.readFile(lockFilePath, 'utf-8');
44+
return processLockFileText(contents);
45+
}
46+
47+
/**
48+
* Retrieves the contents of the lockfile on the master branch
49+
*/
50+
async function getMasterLockFile() {
51+
const { stdout, stderr, exitCode } = await getExecOutput(
52+
'git',
53+
[
54+
'--no-pager',
55+
'show',
56+
'origin/master:yarn.lock'
57+
],
58+
{ silent: true }
59+
);
60+
61+
if (exitCode !== 0) {
62+
core.error(stderr);
63+
throw new Error('git show exited with non-zero error-code');
64+
}
65+
66+
return processLockFileText(stdout);
67+
}
68+
69+
interface ResolutionSpec { pkgSpecifier: string, pkgName: string }
70+
71+
interface YarnWhyOutput {
72+
value: string;
73+
children: {
74+
[locator: string]: {
75+
locator: string;
76+
descriptor: string;
77+
};
78+
};
79+
}
80+
81+
/**
82+
* Determines the names of the packages that have changed versions
83+
*/
84+
export async function getPackagesWithResolutionChanges() {
85+
const [currentLockFileMappings, masterLockFileMappings] = await Promise.all([
86+
getCurrentLockFile(),
87+
getMasterLockFile()
88+
]);
89+
90+
const changes = new Set(masterLockFileMappings);
91+
for (const edge of currentLockFileMappings) {
92+
changes.delete(edge);
93+
}
94+
95+
const changedDeps: ResolutionSpec[] = [];
96+
for (const edge of changes) {
97+
const [pkgSpecifier] = edge.split(' -> ');
98+
changedDeps.push({ pkgSpecifier, pkgName: extractPackageName(pkgSpecifier) });
99+
}
100+
101+
const frontier = [...changedDeps];
102+
while (frontier.length > 0) {
103+
const { pkgName, pkgSpecifier } = frontier.shift()!;
104+
105+
// Run `yarn why <package_name>` to see why a package is included
106+
// Don't use recursive (-R) since we want to build the graph ourselves
107+
const { stdout: output, exitCode, stderr } = await getExecOutput('yarn', ['why', pkgName, '--json'], { silent: true });
108+
if (exitCode !== 0) {
109+
core.error(stderr);
110+
throw new Error(`yarn why for ${pkgName} failed!`);
111+
}
112+
113+
const toAdd = output.split('\n').reduce<ResolutionSpec[]>((res, each) => {
114+
each = each.trim();
115+
if (each === '') return res;
116+
117+
const pkg = JSON.parse(each) as YarnWhyOutput;
118+
const childrenSpecifiers = Object.values(pkg.children).map(({ descriptor }) => descriptor);
119+
if (!childrenSpecifiers.includes(pkgSpecifier)) return res;
120+
return [
121+
...res,
122+
{ pkgSpecifier: pkg.value, pkgName: extractPackageName(pkg.value) }
123+
];
124+
}, []);
125+
126+
frontier.push(...toAdd);
127+
changedDeps.push(...toAdd);
128+
}
129+
130+
core.info('=== Summary of dirty monorepo packages ===\n');
131+
const pkgsToRebuild = changedDeps.filter(({ pkgSpecifier }) => pkgSpecifier.includes('@workspace:'));
132+
for (const { pkgName } of pkgsToRebuild) {
133+
core.info(`- ${pkgName}`);
134+
}
135+
136+
return pkgsToRebuild.map(({ pkgName }) => pkgName);
137+
}
138+
139+
/**
140+
* Returns `true` if there are changes present in the given directory relative to
141+
* the master branch\
142+
* Used to determine, particularly for libraries, if running tests and tsc are necessary
143+
*/
144+
export const hasLockFileChanged = memoize(async () => {
145+
const { exitCode } = await getExecOutput(
146+
'git --no-pager diff --quiet origin/master -- yarn.lock',
147+
[],
148+
{
149+
failOnStdErr: false,
150+
ignoreReturnCode: true
151+
}
152+
);
153+
return exitCode !== 0;
154+
});

.github/actions/src/lockfiles/index.ts

Lines changed: 0 additions & 31 deletions
This file was deleted.

.github/actions/src/lockfiles/utils.ts

Lines changed: 0 additions & 127 deletions
This file was deleted.

0 commit comments

Comments
 (0)