Skip to content
Open
Show file tree
Hide file tree
Changes from 17 commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
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
3 changes: 2 additions & 1 deletion .github/actions/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,8 @@
"@actions/artifact": "^2.3.2",
"@actions/core": "^1.11.1",
"@actions/exec": "^1.1.1",
"lodash": "^4.17.21"
"lodash": "^4.17.21",
"snyk-nodejs-lockfile-parser": "^2.4.2"
},
"scripts": {
"build": "node ./build.js",
Expand Down
6 changes: 3 additions & 3 deletions .github/actions/src/__tests__/commons.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ vi.mock(import('lodash/memoize.js'), () => ({

const mockedExecOutput = vi.spyOn(exec, 'getExecOutput');

describe(commons.checkForChanges, () => {
describe(commons.checkDirForChanges, () => {
function mockChanges(value: boolean) {
mockedExecOutput.mockResolvedValueOnce({
exitCode: value ? 1 : 0, stdout: '', stderr: ''
Expand All @@ -17,14 +17,14 @@ describe(commons.checkForChanges, () => {

it('should return true if git diff exits with non zero code', async () => {
mockChanges(true);
await expect(commons.checkForChanges('/')).resolves.toEqual(true);
await expect(commons.checkDirForChanges('/')).resolves.toEqual(true);
expect(mockedExecOutput).toHaveBeenCalledOnce();
});

it('should return false if git diff exits with 0', async () => {
mockChanges(false);

await expect(commons.checkForChanges('/')).resolves.toEqual(false);
await expect(commons.checkDirForChanges('/')).resolves.toEqual(false);
expect(mockedExecOutput).toHaveBeenCalledOnce();
});
});
Expand Down
18 changes: 18 additions & 0 deletions .github/actions/src/__tests__/lockfiles.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import { describe, expect, it, vi } from 'vitest';
import * as utils from '../lockfiles.js';

vi.mock(import('../gitRoot.js'), () => ({
gitRoot: 'root'
}));

describe(utils.extractPackageName, () => {
it('works with packages that start with @', () => {
expect(utils.extractPackageName('@sourceacademy/tab-Rune@workspace:^'))
.toEqual('@sourceacademy/tab-Rune');
});

it('works with regular package names', () => {
expect(utils.extractPackageName('lodash@npm:^4.17.20'))
.toEqual('lodash');
});
});
9 changes: 1 addition & 8 deletions .github/actions/src/commons.ts
Original file line number Diff line number Diff line change
Expand Up @@ -60,19 +60,12 @@ export function isPackageRecord(obj: unknown): obj is PackageRecord {
return true;
}

// Not using the repotools version since this uses @action/exec instead of
// calling execFile from child_process
export async function getGitRoot() {
const { stdout } = await getExecOutput('git rev-parse --show-toplevel');
return stdout.trim();
}

/**
* Returns `true` if there are changes present in the given directory relative to
* the master branch\
* Used to determine, particularly for libraries, if running tests and tsc are necessary
*/
export const checkForChanges = memoize(async (directory: string) => {
export const checkDirForChanges = memoize(async (directory: string) => {
const { exitCode } = await getExecOutput(
'git',
['--no-pager', 'diff', '--quiet', 'origin/master', '--', directory],
Expand Down
13 changes: 13 additions & 0 deletions .github/actions/src/gitRoot.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import { getExecOutput } from '@actions/exec';

// Not using the repotools version since this uses @action/exec instead of
// calling execFile from child_process
export async function getGitRoot() {
const { stdout } = await getExecOutput('git rev-parse --show-toplevel');
Copy link

Choose a reason for hiding this comment

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

Bug: getExecOutput is called with the full command string as the executable, instead of separating the command and its arguments.
Severity: CRITICAL | Confidence: 1.00

🔍 Detailed Analysis

The getExecOutput function is called with the entire command git rev-parse --show-toplevel as a single string for the command parameter. The @actions/exec API expects the executable and its arguments to be separate parameters. This will cause the shell to attempt to execute a non-existent command named "git rev-parse --show-toplevel", leading to a failure in the GitHub Actions workflow.

💡 Suggested Fix

Pass 'git' as the first argument and ['rev-parse', '--show-toplevel'] as the second argument to getExecOutput.

🤖 Prompt for AI Agent
Review the code at the location below. A potential bug has been identified by an AI
agent.
Verify if this is a real issue. If it is, propose a fix; if not, explain why it's not
valid.

Location: .github/actions/src/gitRoot.ts#L6

Potential issue: The `getExecOutput` function is called with the entire command `git
rev-parse --show-toplevel` as a single string for the `command` parameter. The
`@actions/exec` API expects the executable and its arguments to be separate parameters.
This will cause the shell to attempt to execute a non-existent command named `"git
rev-parse --show-toplevel"`, leading to a failure in the GitHub Actions workflow.

Did we get this right? 👍 / 👎 to inform future reviews.

return stdout.trim();
}

/**
* Path to the root of the git repository
*/
export const gitRoot = await getGitRoot();
48 changes: 43 additions & 5 deletions .github/actions/src/info/__tests__/index.test.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,13 @@
import type { Dirent } from 'fs';
import fs from 'fs/promises';
import pathlib from 'path';
import * as core from '@actions/core';
import { describe, expect, test, vi } from 'vitest';
import * as git from '../../commons.js';
import { getAllPackages, getRawPackages } from '../index.js';
import * as lockfiles from '../../lockfiles.js';
import { getAllPackages, getRawPackages, main } from '../index.js';

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

vi.mock(import('path'), async importOriginal => {
const { posix } = await importOriginal();
Expand All @@ -16,6 +18,10 @@ vi.mock(import('path'), async importOriginal => {
};
});

vi.mock(import('../../gitRoot.js'), () => ({
gitRoot: 'root'
}));

class NodeError extends Error {
constructor(public readonly code: string) {
super();
Expand All @@ -39,7 +45,7 @@ const mockDirectory: Record<string, string | Record<string, unknown>> = {
'package.json': JSON.stringify({
name: '@sourceacademy/bundle-bundle0',
devDependencies: {
'@sourceacademy/modules-lib': 'workspace:^'
'@sourceacademy/modules-lib': 'workspace:^',
}
})
},
Expand All @@ -49,7 +55,8 @@ const mockDirectory: Record<string, string | Record<string, unknown>> = {
'package.json': JSON.stringify({
name: '@sourceacademy/tab-Tab0',
dependencies: {
'@sourceacademy/bundle-bundle0': 'workspace:^'
lodash: '^4.1.1',
'@sourceacademy/bundle-bundle0': 'workspace:^',
},
devDependencies: {
playwright: '^1.54.0'
Expand Down Expand Up @@ -114,6 +121,7 @@ function mockReadFile(path: string) {

vi.spyOn(fs, 'readdir').mockImplementation(mockReaddir as any);
vi.spyOn(fs, 'readFile').mockImplementation(mockReadFile as any);
vi.spyOn(lockfiles, 'hasLockFileChanged').mockResolvedValue(false);

describe(getRawPackages, () => {
test('maxDepth = 1', async () => {
Expand All @@ -125,7 +133,7 @@ describe(getRawPackages, () => {
const [[name, packageData]] = results;
expect(name).toEqual('@sourceacademy/modules');
expect(packageData.hasChanges).toEqual(true);
expect(git.checkForChanges).toHaveBeenCalledOnce();
expect(git.checkDirForChanges).toHaveBeenCalledOnce();
});

test('maxDepth = 3', async () => {
Expand Down Expand Up @@ -211,3 +219,33 @@ describe(getAllPackages, () => {
expect(results['@sourceacademy/modules-lib'].changes).toEqual(true);
});
});

describe(main, () => {
const mockedSetOutput = vi.spyOn(core, 'setOutput');

vi.spyOn(core.summary, 'addHeading').mockImplementation(() => core.summary);
vi.spyOn(core.summary, 'addTable').mockImplementation(() => core.summary);
vi.spyOn(core.summary, 'write').mockImplementation(() => Promise.resolve(core.summary));

test('Does not write packages with no changes to the output', async () => {
mockedCheckChanges.mockImplementation(path => {
return Promise.resolve(path === 'root/src/tabs/tab0');
});

await main();
const { mock: { calls } } = mockedSetOutput;

expect(mockedSetOutput).toHaveBeenCalledTimes(6);

expect(calls[0]).toEqual(['bundles', []]);
expect(calls[1]).toEqual(['tabs', [expect.objectContaining({ changes: true })]]);
expect(calls[2]).toEqual(['libs', []]);

// These next two are undefined because the mock implementations
// don't return any info about them
expect(calls[3]).toEqual(['devserver', undefined]);
expect(calls[4]).toEqual(['docserver', undefined]);

expect(calls[5]).toEqual(['workflows', false]);
});
});
6 changes: 3 additions & 3 deletions .github/actions/src/info/action.yml
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,11 @@ description: Gets which packages are currently present in the repository

outputs:
libs:
description: The list of library packages present
description: The list of library packages that have changes
bundles:
description: The list of bundles packages present
description: The list of bundles packages that have changes
tabs:
description: The list of tabs packages present
description: The list of tabs packages that have changes
devserver:
description: Information for the devserver
docserver:
Expand Down
38 changes: 26 additions & 12 deletions .github/actions/src/info/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,9 @@ import utils from 'util';
import * as core from '@actions/core';
import type { SummaryTableRow } from '@actions/core/lib/summary.js';
import packageJson from '../../../../package.json' with { type: 'json' };
import { checkForChanges, getGitRoot, type PackageRecord, type RawPackageRecord } from '../commons.js';
import { checkDirForChanges, type PackageRecord, type RawPackageRecord } from '../commons.js';
import { gitRoot } from '../gitRoot.js';
import { getPackagesWithResolutionChanges, hasLockFileChanged } from '../lockfiles.js';
import { topoSortPackages } from './sorter.js';

const packageNameRE = /^@sourceacademy\/(.+?)-(.+)$/u;
Expand All @@ -14,23 +16,34 @@ const packageNameRE = /^@sourceacademy\/(.+?)-(.+)$/u;
* an unprocessed format
*/
export async function getRawPackages(gitRoot: string, maxDepth?: number) {
let packagesWithResolutionChanges: string[] = [];

// If there are lock file changes we need to set hasChanges to true for
// that package even if that package's directory has no changes
if (await hasLockFileChanged()) {
packagesWithResolutionChanges = await getPackagesWithResolutionChanges();
}

const output: Record<string, RawPackageRecord> = {};

/**
* Search the given directory for package.json files
*/
async function recurser(currentDir: string, currentDepth: number) {
const items = await fs.readdir(currentDir, { withFileTypes: true });
await Promise.all(items.map(async item => {
if (item.isFile()) {
if (item.name === 'package.json') {
try {
const [hasChanges, packageJson] = await Promise.all([
checkForChanges(currentDir),
checkDirForChanges(currentDir),
fs.readFile(pathlib.join(currentDir, 'package.json'), 'utf-8')
.then(JSON.parse)
]);

output[packageJson.name] = {
directory: currentDir,
hasChanges,
hasChanges: hasChanges || packagesWithResolutionChanges.includes(packageJson.name),
package: packageJson
};
} catch (error) {
Expand Down Expand Up @@ -93,16 +106,18 @@ export function processRawPackages(topoOrder: string[], packages: Record<string,
if (!packageInfo.hasChanges) {
if (packageInfo.package.dependencies) {
for (const name of Object.keys(packageInfo.package.dependencies)) {
if (packages[name].hasChanges) {
if (packages[name]?.hasChanges) {
packageInfo.hasChanges = true;
break;
}
}
}

// If hasChanges still hasn't been set yet, we can proceed to iterate
// through devDependencies as well
if (!packageInfo.hasChanges && packageInfo.package.devDependencies) {
for (const name of Object.keys(packageInfo.package.devDependencies)) {
if (packages[name].hasChanges) {
if (packages[name]?.hasChanges) {
packageInfo.hasChanges = true;
break;
}
Expand Down Expand Up @@ -203,15 +218,14 @@ function setOutputs(
devserver: PackageRecord,
docserver: PackageRecord
) {
core.setOutput('bundles', bundles);
core.setOutput('tabs', tabs);
core.setOutput('libs', libs);
core.setOutput('bundles', bundles.filter(x => x.changes));
core.setOutput('tabs', tabs.filter(x => x.changes));
core.setOutput('libs', libs.filter(x => x.changes));
core.setOutput('devserver', devserver);
core.setOutput('docserver', docserver);
}

async function main() {
const gitRoot = await getGitRoot();
export async function main() {
const { packages, bundles, tabs, libs } = await getAllPackages(gitRoot);

const { repository } = packageJson;
Expand Down Expand Up @@ -260,7 +274,7 @@ async function main() {
packages['@sourceacademy/modules-docserver']
);

const workflows = await checkForChanges(pathlib.join(gitRoot, '.github/workflows'));
const workflows = await checkDirForChanges(pathlib.join(gitRoot, '.github/workflows'));
core.setOutput('workflows', workflows);
}

Expand All @@ -269,6 +283,6 @@ if (process.env.GITHUB_ACTIONS) {
try {
await main();
} catch (error: any) {
core.setFailed(error.message);
core.setFailed(error);
}
}
25 changes: 16 additions & 9 deletions .github/actions/src/load-artifacts/__tests__/artifact.test.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { ArtifactNotFoundError } from '@actions/artifact';
import * as core from '@actions/core';
import * as exec from '@actions/exec';
import * as manifest from '@sourceacademy/modules-repotools/manifest';
Expand All @@ -10,10 +11,10 @@ vi.mock(import('@actions/core'), async importOriginal => {
...original,
// Mock these functions to remove stdout output
error: vi.fn(),
info: () => {},
startGroup: () => {},
info: () => { },
startGroup: () => { },
setFailed: vi.fn(),
endGroup: () => {}
endGroup: () => { }
};
});
const mockedResolveAllTabs = vi.spyOn(manifest, 'resolveAllTabs');
Expand Down Expand Up @@ -41,6 +42,7 @@ test('tab resolution errors cause setFailed to be called', async () => {

await main();

expect(mockedResolveAllTabs).toHaveBeenCalledOnce();
expect(core.error).toHaveBeenCalledExactlyOnceWith('error1');
expect(core.setFailed).toHaveBeenCalledExactlyOnceWith('Tab resolution failed with errors');
});
Expand Down Expand Up @@ -68,22 +70,26 @@ test('tabs that can\'t be found are built', async () => {
if (name === 'Tab0-tab') {
return { artifact: { id: 0 } };
}
throw new Error();
throw new ArtifactNotFoundError();
});

await main();

expect(mockedGetArtifact).toHaveBeenCalledTimes(2);
expect(mockedResolveAllTabs).toHaveBeenCalledOnce();

const [[artifactCall0], [artifactCall1]] = mockedGetArtifact.mock.calls;
expect(artifactCall0).toEqual('Tab0-tab');
expect(artifactCall1).toEqual('Tab1-tab');

expect(exec.exec).toHaveBeenCalledTimes(2);
const [[,execCall0], [,execCall1]] = vi.mocked(exec.exec).mock.calls;
console.log('args are', execCall0);
const [[execCmd0, execCall0], [execCmd1, execCall1]] = vi.mocked(exec.exec).mock.calls;

expect(execCmd0).toEqual('yarn workspaces focus');
expect(execCall0).toContain('@sourceacademy/tab-Tab1');
expect(execCall0).not.toContain('@sourceacademy/tab-Tab0');

expect(execCmd1).toEqual('yarn workspaces foreach -pA');
expect(execCall1).toContain('@sourceacademy/tab-Tab1');
expect(execCall1).not.toContain('@sourceacademy/tab-Tab0');
});
Expand All @@ -101,11 +107,12 @@ test('install failure means build doesn\'t happen', async () => {
}
});

mockedGetArtifact.mockRejectedValueOnce(new Error());

mockedGetArtifact.mockRejectedValueOnce(new ArtifactNotFoundError());
mockedExec.mockResolvedValueOnce(1);

await main();

expect(mockedGetArtifact).toHaveBeenCalledOnce();
expect(exec.exec).toHaveBeenCalledOnce();
expect(core.setFailed).toHaveBeenCalledExactlyOnceWith('yarn workspace focus failed for Tab0');
expect(core.setFailed).toHaveBeenCalledExactlyOnceWith('yarn workspace focus failed');
});
Loading