diff --git a/action.yml b/action.yml index 9598fc662..6553fbcfb 100644 --- a/action.yml +++ b/action.yml @@ -52,6 +52,9 @@ inputs: alert-comment-cc-users: description: 'Comma separated GitHub user names which start with @ (e.g. "@foo,@bar"). They will be mentioned in commit comment for alert.' required: false + external-data-json-path: + description: 'JSON data file for storing benchmark results. When this input is set, github-action-benchmark no longer uses Git branch to store data. Instead, it reads and appends benchmark data from/to the file. User must store the file anywhere' + required: false runs: using: 'node12' diff --git a/config.js b/config.js index 22b871de5..037543645 100644 --- a/config.js +++ b/config.js @@ -28,10 +28,14 @@ function resolvePath(p) { } return path.resolve(p); } -async function statPath(p) { +async function resolveFilePath(p) { p = resolvePath(p); try { - return [await fs_1.promises.stat(p), p]; + const s = await fs_1.promises.stat(p); + if (!s.isFile()) { + throw new Error(`Specified path '${p}' is not a file`); + } + return p; } catch (e) { throw new Error(`Cannot stat '${p}': ${e}`); @@ -39,11 +43,7 @@ async function statPath(p) { } async function validateOutputFilePath(filePath) { try { - const [stat, resolved] = await statPath(filePath); - if (!stat.isFile()) { - throw new Error(`Specified path '${filePath}' is not a file`); - } - return resolved; + return await resolveFilePath(filePath); } catch (err) { throw new Error(`Invalid value for 'output-file-path' input: ${err}`); @@ -109,6 +109,33 @@ function validateAlertCommentCcUsers(users) { } } } +async function isDir(path) { + try { + const s = await fs_1.promises.stat(path); + return s.isDirectory(); + } + catch (_) { + return false; + } +} +async function validateExternalDataJsonPath(path, autoPush) { + if (!path) { + return Promise.resolve(undefined); + } + if (autoPush) { + throw new Error('auto-push must be false when external-data-json-path is set since this action reads/writes the given JSON file and never pushes to remote'); + } + try { + const p = resolvePath(path); + if (await isDir(p)) { + throw new Error(`Specified path '${p}' must be file but it is actually directory`); + } + return p; + } + catch (err) { + throw new Error(`Invalid value for 'external-data-json-path' input: ${err}`); + } +} async function configFromJobInput() { const tool = core.getInput('tool'); let outputFilePath = core.getInput('output-file-path'); @@ -122,6 +149,7 @@ async function configFromJobInput() { const alertThreshold = getPercentageInput('alert-threshold'); const failOnAlert = getBoolInput('fail-on-alert'); const alertCommentCcUsers = getCommaSeparatedInput('alert-comment-cc-users'); + let externalDataJsonPath = core.getInput('external-data-json-path'); validateToolType(tool); outputFilePath = await validateOutputFilePath(outputFilePath); validateGhPagesBranch(ghPagesBranch); @@ -134,6 +162,7 @@ async function configFromJobInput() { validateGitHubToken('comment-on-alert', githubToken, 'to send commit comment on alert'); } validateAlertCommentCcUsers(alertCommentCcUsers); + externalDataJsonPath = await validateExternalDataJsonPath(externalDataJsonPath, autoPush); return { name, tool, @@ -147,6 +176,7 @@ async function configFromJobInput() { alertThreshold, failOnAlert, alertCommentCcUsers, + externalDataJsonPath, }; } exports.configFromJobInput = configFromJobInput; diff --git a/write.js b/write.js index 918a2fb21..16a981567 100644 --- a/write.js +++ b/write.js @@ -16,7 +16,12 @@ const Octokit = require("@octokit/rest"); const git = __importStar(require("./git")); const default_index_html_1 = require("./default_index_html"); exports.SCRIPT_PREFIX = 'window.BENCHMARK_DATA = '; -async function loadDataJson(dataPath) { +const DEFAULT_DATA_JSON = { + lastUpdate: 0, + repoUrl: '', + entries: {}, +}; +async function loadDataJs(dataPath) { try { const script = await fs_1.promises.readFile(dataPath, 'utf8'); const json = script.slice(exports.SCRIPT_PREFIX.length); @@ -26,14 +31,10 @@ async function loadDataJson(dataPath) { } catch (err) { console.log(`Could not find data.js at ${dataPath}. Using empty default: ${err}`); - return { - lastUpdate: 0, - repoUrl: '', - entries: {}, - }; + return { ...DEFAULT_DATA_JSON }; } } -async function storeDataJson(dataPath, data) { +async function storeDataJs(dataPath, data) { const script = exports.SCRIPT_PREFIX + JSON.stringify(data, null, 2); await fs_1.promises.writeFile(dataPath, script, 'utf8'); core.debug(`Overwrote ${dataPath} for adding new data`); @@ -161,41 +162,64 @@ async function leaveComment(commitId, body, token) { console.log(`Alert comment was sent to ${commitUrl}. Response:`, res.status, res.data); return res; } -async function alert(benchName, curEntry, prevEntry, threshold, token, shouldComment, shouldFail, ccUsers) { - if (!shouldComment && !shouldFail) { +async function handleAlert(benchName, curEntry, prevEntry, config) { + const { alertThreshold, githubToken, commentOnAlert, failOnAlert, alertCommentCcUsers } = config; + if (!commentOnAlert && !failOnAlert) { core.debug('Alert check was skipped because both comment-on-alert and fail-on-alert were disabled'); return; } - const alerts = findAlerts(curEntry, prevEntry, threshold); + const alerts = findAlerts(curEntry, prevEntry, alertThreshold); if (alerts.length === 0) { core.debug('No performance alert found happily'); return; } core.debug(`Found ${alerts.length} alerts`); - const body = buildAlertComment(alerts, benchName, curEntry, prevEntry, threshold, ccUsers); + const body = buildAlertComment(alerts, benchName, curEntry, prevEntry, alertThreshold, alertCommentCcUsers); let message = body; - if (shouldComment) { - if (!token) { + if (commentOnAlert) { + if (!githubToken) { throw new Error("'comment-on-alert' is set but github-token is not set"); } - const res = await leaveComment(curEntry.commit.id, body, token); + const res = await leaveComment(curEntry.commit.id, body, githubToken); // eslint-disable-next-line @typescript-eslint/camelcase const url = res.data.html_url; message = body + `\nComment was generated at ${url}`; } - if (shouldFail) { + if (failOnAlert) { core.debug('Mark this workflow as fail since one or more alerts found'); throw new Error(message); } } -async function writeBenchmark(bench, config) { - var _a, _b, _c, _d; - const { name, tool, ghPagesBranch, benchmarkDataDirPath, githubToken, autoPush, skipFetchGhPages, commentOnAlert, alertThreshold, failOnAlert, alertCommentCcUsers, } = config; - const dataPath = path.join(benchmarkDataDirPath, 'data.js'); - /* eslint-disable @typescript-eslint/camelcase */ +function addBenchmarkToDataJson(benchName, bench, data) { + var _a, _b; + // eslint-disable-next-line @typescript-eslint/camelcase const htmlUrl = (_b = (_a = github.context.payload.repository) === null || _a === void 0 ? void 0 : _a.html_url, (_b !== null && _b !== void 0 ? _b : '')); - const isPrivateRepo = (_d = (_c = github.context.payload.repository) === null || _c === void 0 ? void 0 : _c.private, (_d !== null && _d !== void 0 ? _d : false)); - /* eslint-enable @typescript-eslint/camelcase */ + let prevBench = null; + data.lastUpdate = Date.now(); + data.repoUrl = htmlUrl; + // Add benchmark result + if (data.entries[benchName] === undefined) { + data.entries[benchName] = [bench]; + core.debug(`No entry was found for benchmark '${benchName}' in existing data. Created`); + } + else { + const entries = data.entries[benchName]; + // Get last entry which has different commit ID for alert comment + for (const e of entries.slice().reverse()) { + if (e.commit.id !== bench.commit.id) { + prevBench = e; + break; + } + } + entries.push(bench); + } + return prevBench; +} +async function writeBenchmarkToGitHubPages(bench, config) { + var _a, _b; + const { name, tool, ghPagesBranch, benchmarkDataDirPath, githubToken, autoPush, skipFetchGhPages } = config; + const dataPath = path.join(benchmarkDataDirPath, 'data.js'); + const isPrivateRepo = (_b = (_a = github.context.payload.repository) === null || _a === void 0 ? void 0 : _a.private, (_b !== null && _b !== void 0 ? _b : false)); await git.cmd('switch', ghPagesBranch); try { if (!skipFetchGhPages && (!isPrivateRepo || githubToken)) { @@ -206,27 +230,9 @@ async function writeBenchmark(bench, config) { "before generating a commit, please set 'github-token' input to pull GitHub pages branch"); } await io.mkdirP(benchmarkDataDirPath); - let prevBench = null; - const data = await loadDataJson(dataPath); - data.lastUpdate = Date.now(); - data.repoUrl = htmlUrl; - // Add benchmark result - if (data.entries[name] === undefined) { - data.entries[name] = [bench]; - core.debug(`No entry found for benchmark '${name}'. Created.`); - } - else { - const entries = data.entries[name]; - // Get last entry which has different commit ID for alert comment - for (const e of entries.slice().reverse()) { - if (e.commit.id !== bench.commit.id) { - prevBench = e; - break; - } - } - entries.push(bench); - } - await storeDataJson(dataPath, data); + const data = await loadDataJs(dataPath); + const prevBench = addBenchmarkToDataJson(name, bench, data); + await storeDataJs(dataPath, data); await git.cmd('add', dataPath); await addIndexHtmlIfNeeded(benchmarkDataDirPath); await git.cmd('commit', '-m', `add ${name} (${tool}) benchmark result for ${bench.commit.id}`); @@ -237,19 +243,52 @@ async function writeBenchmark(bench, config) { else { core.debug(`Auto-push to ${ghPagesBranch} is skipped because it requires both github-token and auto-push`); } - // Put this after `git push` for reducing possibility to get conflict on push. Since sending - // comment take time due to API call, do it after updating remote branch. - if (prevBench === null) { - core.debug('Alert check was skipped because previous benchmark result was not found'); - } - else { - await alert(name, bench, prevBench, alertThreshold, githubToken, commentOnAlert, failOnAlert, alertCommentCcUsers); - } + return prevBench; } finally { // `git switch` does not work for backing to detached head await git.cmd('checkout', '-'); } } +async function loadDataJson(jsonPath) { + try { + const content = await fs_1.promises.readFile(jsonPath, 'utf8'); + const json = JSON.parse(content); + core.debug(`Loaded external JSON file at ${jsonPath}`); + return json; + } + catch (err) { + core.warning(`Could not find external JSON file for benchmark data at ${jsonPath}. Using empty default: ${err}`); + return { ...DEFAULT_DATA_JSON }; + } +} +async function writeBenchmarkToExternalJson(bench, jsonFilePath, config) { + const { name } = config; + const data = await loadDataJson(jsonFilePath); + const prevBench = addBenchmarkToDataJson(name, bench, data); + try { + const jsonDirPath = path.dirname(jsonFilePath); + await io.mkdirP(jsonDirPath); + await fs_1.promises.writeFile(jsonFilePath, JSON.stringify(data, null, 2), 'utf8'); + } + catch (err) { + throw new Error(`Could not store benchmark data as JSON at ${jsonFilePath}: ${err}`); + } + return prevBench; +} +async function writeBenchmark(bench, config) { + const { name, externalDataJsonPath } = config; + const prevBench = externalDataJsonPath + ? await writeBenchmarkToExternalJson(bench, externalDataJsonPath, config) + : await writeBenchmarkToGitHubPages(bench, config); + // Put this after `git push` for reducing possibility to get conflict on push. Since sending + // comment take time due to API call, do it after updating remote branch. + if (prevBench === null) { + core.debug('Alert check was skipped because previous benchmark result was not found'); + } + else { + await handleAlert(name, bench, prevBench, config); + } +} exports.writeBenchmark = writeBenchmark; //# sourceMappingURL=write.js.map \ No newline at end of file