Skip to content
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

feat: 'e clean' for removing artifacts #680

Open
wants to merge 13 commits into
base: main
Choose a base branch
from
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
.nyc_output/
artifacts
configs
coverage
local-storage.json
node_modules
package-lock.json
third_party
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -482,7 +482,7 @@ git cherry-pick --continue
git push

# create pull request
e pr --backport 1234
e pr open --backport 1234
```

## Common Usage
Expand Down
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@
"@marshallofsound/chrome-cookies-secure": "^2.1.1",
"@octokit/auth-oauth-device": "^3.1.1",
"@octokit/rest": "^18.5.2",
"adm-zip": "^0.5.16",
"ajv": "^8.11.0",
"ajv-formats": "^2.1.1",
"chalk": "^2.4.1",
Expand Down
5 changes: 3 additions & 2 deletions src/e
Original file line number Diff line number Diff line change
Expand Up @@ -169,7 +169,7 @@ program
.command('backport [pr]', 'Assists with manual backport processes')
.command('show <subcommand>', 'Show info about the current build config')
.command('test [specRunnerArgs...]', `Run Electron's spec runner`)
.command('pr [options]', 'Open a GitHub URL where you can PR your changes')
.command('pr [subcommand]', 'Work with PRs to electron/electron')
.command('patches <target>', 'Refresh the patches in $root/src/electron/patches/$target')
.command('open <sha1|PR#>', 'Open a GitHub URL for the given commit hash / pull # / issue #')
.command('auto-update', 'Check for build-tools updates or enable/disable automatic updates')
Expand All @@ -179,7 +179,8 @@ program
'Opens a PR to electron/electron that backport the given CL into our patches folder',
)
.alias('auto-cherry-pick')
.command('gh-auth', 'Generates a device oauth token');
.command('gh-auth', 'Generates a device oauth token')
.command('clean', 'Cleanup artifacts produced by build tools');

const ci = program.command('ci').executableDir(path.join(__dirname, 'ci'));
ci.command('status', 'Show information about CI job statuses');
Expand Down
29 changes: 29 additions & 0 deletions src/e-clean.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
#!/usr/bin/env node

const fs = require('fs');
const { ARTIFACTS_DIR, getStaleArtifacts } = require('./utils/artifacts');

const program = require('commander');
const { color, fatal } = require('./utils/logging');

function clean(options) {
try {
if (options.stale) {
const staleFiles = getStaleArtifacts();
staleFiles.forEach((file) => {
const filePath = path.join(ARTIFACTS_DIR, file);
fs.rmSync(filePath, { recursive: true, force: true });
});
console.log(color.success, `${staleFiles.length} stale artifact(s) removed.`);
} else {
fs.rmSync(ARTIFACTS_DIR, { recursive: true, force: true });
console.log(color.success, 'Artifacts directory cleaned successfully.');
}
} catch (error) {
fatal(error);
}
}

program.action(clean).option('--stale', 'Only clean stale artifacts');

program.parse(process.argv);
224 changes: 208 additions & 16 deletions src/e-pr.js
Original file line number Diff line number Diff line change
@@ -1,15 +1,23 @@
#!/usr/bin/env node

const childProcess = require('child_process');
const fs = require('fs');
const path = require('path');

const AdmZip = require('adm-zip');
const querystring = require('querystring');
const semver = require('semver');

const open = require('open');
const program = require('commander');
const { Octokit } = require('@octokit/rest');
const inquirer = require('inquirer');

const { getGitHubAuthToken } = require('./utils/github-auth');
const { current } = require('./evm-config');
const { color, fatal } = require('./utils/logging');
const { ARTIFACTS_DIR, maybeCheckStaleArtifacts } = require('./utils/artifacts');

const d = require('debug')('build-tools:pr');

// Adapted from https://github.com/electron/clerk
function findNoteInPRBody(body) {
Expand Down Expand Up @@ -134,27 +142,23 @@ function pullRequestSource(source) {
}

program
.command('open', null, { isDefault: true })
.description('Open a GitHub URL where you can PR your changes')
.option(
'-s, --source <source_branch>',
'Where the changes are coming from',
guessPRSource(current()),
)
.option(
'-t, --target <target_branch>',
'Where the changes are going to',
guessPRTarget(current()),
)
.option('-s, --source [source_branch]', 'Where the changes are coming from')
.option('-t, --target [target_branch]', 'Where the changes are going to')
.option('-b, --backport <pull_request>', 'Pull request being backported')
.action(async (options) => {
if (!options.source) {
const source = options.source || guessPRSource(current());
const target = options.target || guessPRTarget(current());

if (!source) {
fatal(`'source' is required to create a PR`);
} else if (!options.target) {
} else if (!target) {
fatal(`'target' is required to create a PR`);
}

const repoBaseUrl = 'https://github.com/electron/electron';
const comparePath = `${options.target}...${pullRequestSource(options.source)}`;
const comparePath = `${target}...${pullRequestSource(source)}`;
const queryParams = { expand: 1 };

if (!options.backport) {
Expand Down Expand Up @@ -188,5 +192,193 @@ program
}

return open(`${repoBaseUrl}/compare/${comparePath}?${querystring.stringify(queryParams)}`);
})
.parse(process.argv);
});

program
.command('download-dist <pull_request_number>')
.description('Download a pull request dist')
.option(
'--platform [platform]',
'Platform to download dist for. Defaults to current platform.',
process.platform,
)
.option(
'--arch [arch]',
'Architecture to download dist for. Defaults to current arch.',
process.arch,
)
.option(
'-o, --output <output_directory>',
'Specify the output directory for downloaded artifacts. ' +
'Defaults to ~/.electron_build_tools/artifacts/pr_{number}_{platform}_{arch}',
)
.option('-s, --skip-confirmation', 'Skip the confirmation prompt before downloading the dist.')
.action(async (pullRequestNumber, options) => {
if (!pullRequestNumber) {
fatal(`Pull request number is required to download a PR`);
}

maybeCheckStaleArtifacts();

d('checking auth...');
const octokit = new Octokit({
auth: await getGitHubAuthToken(['repo']),
});

d('fetching pr info...');
let pullRequest;
try {
const { data } = await octokit.pulls.get({
owner: 'electron',
repo: 'electron',
pull_number: pullRequestNumber,
});
pullRequest = data;
} catch (error) {
console.error(`Failed to get pull request: ${error}`);
return;
}

if (!options.skipConfirmation) {
const isElectronRepo = pullRequest.head.repo.full_name !== 'electron/electron';
const { proceed } = await inquirer.prompt([
{
type: 'confirm',
name: 'proceed',
message: `You are about to download artifacts from:

“${pullRequest.title} (#${pullRequest.number})” by ${pullRequest.user.login}
${pullRequest.head.repo.html_url}${isElectronRepo ? ' (fork)' : ''}

Proceed?`,
},
]);

if (!proceed) return;
}

d('fetching workflow runs...');
let workflowRuns;
try {
const { data } = await octokit.actions.listWorkflowRunsForRepo({
owner: 'electron',
repo: 'electron',
branch: pullRequest.head.ref,
name: 'Build',
event: 'pull_request',
status: 'completed',
per_page: 10,
sort: 'created',
direction: 'desc',
});
workflowRuns = data.workflow_runs;
} catch (error) {
console.error(`Failed to list workflow runs: ${error}`);
return;
}

const latestBuildWorkflowRun = workflowRuns.find((run) => run.name === 'Build');
if (!latestBuildWorkflowRun) {
fatal(`No 'Build' workflow runs found for pull request #${pullRequestNumber}`);
return;
}

d('fetching artifacts...');
let artifacts;
try {
const { data } = await octokit.actions.listWorkflowRunArtifacts({
owner: 'electron',
repo: 'electron',
run_id: latestBuildWorkflowRun.id,
});
artifacts = data.artifacts;
} catch (error) {
console.error(`Failed to list artifacts: ${error}`);
return;
}

const artifactName = `generated_artifacts_${options.platform}_${options.arch}`;
const artifact = artifacts.find((artifact) => artifact.name === artifactName);
if (!artifact) {
console.error(`Failed to find artifact: ${artifactName}`);
return;
}

let outputDir;

if (options.output) {
outputDir = path.resolve(options.output);

if (!(await fs.promises.stat(outputDir).catch(() => false))) {
fatal(`The output directory '${options.output}' does not exist`);
return;
}
} else {
const defaultDir = path.resolve(
ARTIFACTS_DIR,
`pr_${pullRequest.number}_${options.platform}_${options.arch}`,
);

// Clean up the directory if it exists
try {
await fs.promises.rm(defaultDir, { recursive: true, force: true });
} catch (error) {
if (error.code !== 'ENOENT') {
throw error;
}
}

// Create the directory
await fs.promises.mkdir(defaultDir, { recursive: true });

outputDir = defaultDir;
}

console.log(
`Downloading artifact '${artifactName}' from pull request #${pullRequestNumber}...`,
);

const response = await octokit.actions.downloadArtifact({
owner: 'electron',
repo: 'electron',
artifact_id: artifact.id,
archive_format: 'zip',
});

// Extract artifact zip in-memory
const artifactZip = new AdmZip(Buffer.from(response.data));

const distZipEntry = artifactZip.getEntry('dist.zip');
if (!distZipEntry) {
fatal('dist.zip not found in build artifact.');
return;
}

// Extract dist.zip in-memory
const distZipContents = artifactZip.readFile(distZipEntry);
const distZip = new AdmZip(distZipContents);

const platformExecutables = {
win32: 'electron.exe',
darwin: 'Electron.app/',
linux: 'electron',
};

const executableName = platformExecutables[options.platform];
if (!executableName) {
fatal(`Unable to find executable for platform '${options.platform}'`);
return;
}

if (!distZip.getEntry(executableName)) {
fatal(`${executableName} not found within dist.zip.`);
return;
}

// Extract dist.zip to the output directory
await distZip.extractAllToAsync(outputDir);

console.info(`Downloaded to ${outputDir}`);
});

program.parse(process.argv);
51 changes: 51 additions & 0 deletions src/utils/artifacts.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
const path = require('path');
const { localStorage } = require('./local-storage');
const fs = require('fs');

const ARTIFACTS_DIR = path.join(__dirname, '..', '..', 'artifacts');

/** How often to check for stale files. */
const STALE_CHECK_INTERVAL = 7 * 24 * 60 * 60 * 1000; // 1 week

/** How old a file is to be considered stale. */
const STALE_FILE_AGE = 30 * 24 * 60 * 60 * 1000; // 1 month

function getStaleArtifacts() {
const now = Date.now();
let files;
try {
files = fs.readdirSync(ARTIFACTS_DIR);
} catch (error) {
if (error.code !== 'ENOENT') {
throw error;
}
return [];
}
const staleFiles = files.filter((file) => {
const filePath = path.join(ARTIFACTS_DIR, file);
const stats = fs.statSync(filePath);
return stats.mtimeMs < now - STALE_FILE_AGE;
});
return staleFiles;
}

function maybeCheckStaleArtifacts() {
const lastChecked = parseInt(localStorage.getItem('lastArtifactsCheck'), 10);
const now = Date.now();

if (!lastChecked || lastChecked < now - STALE_CHECK_INTERVAL) {
const staleArtifacts = getStaleArtifacts();
if (staleArtifacts.length > 0) {
console.warn(
`Stale artifact(s) found:\n\t${staleArtifacts.join('\n\t')}\n\nRun 'e clean --stale' to cleanup artifacts.`,
);
}
localStorage.setItem('lastArtifactsCheck', now);
}
}

module.exports = {
ARTIFACTS_DIR,
getStaleArtifacts,
maybeCheckStaleArtifacts,
};
Loading