forked from jfrog/setup-jfrog-cli
-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathjob-summary.ts
More file actions
321 lines (291 loc) · 14.6 KB
/
job-summary.ts
File metadata and controls
321 lines (291 loc) · 14.6 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
import * as core from '@actions/core';
import { gte } from 'semver';
import { Octokit } from '@octokit/core';
import { OctokitResponse } from '@octokit/types/dist-types/OctokitResponse';
import * as github from '@actions/github';
import { promisify } from 'util';
import { gzip } from 'zlib';
import path from 'path';
import { existsSync, promises as fs } from 'fs';
import { HttpClient, HttpClientResponse } from '@actions/http-client';
import { OutgoingHttpHeaders } from 'http';
import { tmpdir } from 'os';
import { Utils } from './utils';
export class JobSummary {
// Directory name which holds markdown files for the Workflow summary
private static readonly JOB_SUMMARY_DIR_NAME: string = 'jfrog-command-summary';
// Directory name which holds security command summary files
private static readonly SECURITY_DIR_NAME: string = 'security';
// Directory name which holds sarifs files for the code scanning tab
private static readonly SARIF_REPORTS_DIR_NAME: string = 'sarif-reports';
// JFrog CLI command summary output directory environment variable
public static readonly JFROG_CLI_COMMAND_SUMMARY_OUTPUT_DIR_ENV: string = 'JFROG_CLI_COMMAND_SUMMARY_OUTPUT_DIR';
// Minimum JFrog CLI version supported for job summary command
private static readonly MIN_CLI_VERSION_JOB_SUMMARY: string = '2.66.0';
// Code scanning sarif expected file extension.
private static readonly CODE_SCANNING_FINAL_SARIF_FILE: string = 'final.sarif';
// URL for the markdown header image
// This is hosted statically because its usage is outside the context of the JFrog setup action.
// It cannot be linked to the repository, as GitHub serves the image from a CDN,
// which gets blocked by the browser, resulting in an empty image.
private static MARKDOWN_HEADER_PNG_URL: string = 'https://media.jfrog.com/wp-content/uploads/2024/09/02161430/jfrog-job-summary.svg';
// Flag to indicate if the summary header is accessible, can be undefined if not checked yet.
private static isSummaryHeaderAccessible: boolean | undefined = undefined;
// Job ID query parameter key
private static readonly JOB_ID_PARAM_KEY: string = 'job_id';
// Run ID query parameter key
private static readonly RUN_ID_PARAM_KEY: string = 'run_id';
// Git repository query parameter key
private static readonly GIT_REPO_PARAM_KEY: string = 'git_repo';
// Source query parameter indicating the source of the request
private static readonly SOURCE_PARAM_KEY: string = 's';
private static readonly SOURCE_PARAM_VALUE: string = '1';
// Metric query parameter indicating the metric type
private static readonly METRIC_PARAM_KEY: string = 'm';
private static readonly METRIC_PARAM_VALUE: string = '1';
/**
* Enabling job summary is done by setting the output dir for the summaries.
* If the output dir is not set, the CLI won't generate the summary Markdown files.
*/
public static enableJobSummaries() {
let tempDir: string = this.getTempDirectory();
Utils.exportVariableIfNotSet(this.JFROG_CLI_COMMAND_SUMMARY_OUTPUT_DIR_ENV, tempDir);
}
public static isJobSummarySupported(): boolean {
const version: string = core.getInput(Utils.CLI_VERSION_ARG);
return version === Utils.LATEST_CLI_VERSION || gte(version, this.MIN_CLI_VERSION_JOB_SUMMARY);
}
/**
* Generates GitHub workflow unified Summary report.
* This function runs as part of post-workflow cleanup function,
* collects existing section markdown files generated by the CLI,
* and constructs a single Markdown file, to be displayed in the GitHub UI.
*/
public static async setMarkdownAsJobSummary() {
try {
// Read all sections and construct the final Markdown file
const markdownContent: string = await this.readCommandSummaryMarkdown();
if (markdownContent.length == 0) {
core.debug('No job summary file found. Workflow summary will not be generated.');
return;
}
// Write to GitHub's job summary
core.summary.addRaw(markdownContent, true);
await core.summary.write({ overwrite: true });
} catch (error) {
core.warning(`Failed to generate Workflow summary: ${error}`);
}
}
/**
* Populates the code scanning SARIF (if generated by scan commands) to the code scanning tab in GitHub.
*/
public static async populateCodeScanningTab() {
try {
const encodedSarif: string = await this.getCodeScanningEncodedSarif();
if (!encodedSarif) {
return;
}
const token: string | undefined = process.env.JF_GIT_TOKEN;
if (!token) {
console.info('No token provided for uploading code scanning sarif files.');
return;
}
await this.uploadCodeScanningSarif(encodedSarif, token);
} catch (error) {
core.warning(`Failed populating code scanning sarif: ${error}`);
}
}
/**
* Uploads a SARIF (Static Analysis Results Interchange Format) file to GitHub's code scanning API.
* This method handles the communication with GitHub's REST API to populate the code scanning tab with security analysis results.
* Supports both GitHub.com and GitHub Enterprise Server installations.
* @param encodedSarif - The SARIF content that has been compressed using gzip and encoded to base64 format.
* This encoding is required by GitHub's code-scanning/sarifs API endpoint.
* @param token - GitHub authentication token with appropriate permissions to upload SARIF files.
* Must have 'security_events: write' and 'contents: read' permissions.
* @throws Will throw an error if the HTTP response status is not in the 2xx range or if authentication fails.
*/
private static async uploadCodeScanningSarif(encodedSarif: string, token: string) {
const inputBaseUrl = Utils.getGheBaseUrl();
const octokit = inputBaseUrl ? github.getOctokit(token, { baseUrl: inputBaseUrl }) : github.getOctokit(token);
const response = await octokit.request('POST /repos/{owner}/{repo}/code-scanning/sarifs', {
owner: github.context.repo.owner,
repo: github.context.repo.repo,
commit_sha: github.context.sha,
ref: github.context.ref,
sarif: encodedSarif,
});
if (response.status < 200 || response.status >= 300) {
const usedBaseUrl = (octokit as any).request?.endpoint?.DEFAULTS?.baseUrl || 'unknown';
throw new Error(`Failed to upload SARIF file (status ${response.status}). baseUrl=${usedBaseUrl}; response=` + JSON.stringify(response));
}
core.info('SARIF file uploaded successfully');
}
/**
* Compresses the input sarif content using gzip and encodes it to base64. This is required by the code-scanning/sarif API.
* @param input - The sarif content to compress and encode.
* @returns The compressed and encoded string.
* @private
*/
private static async compressAndEncodeSarif(input: string): Promise<string> {
try {
const compressed: Buffer = await promisify(gzip)(input);
return compressed.toString('base64');
} catch (error) {
throw new Error('Compression of sarif file failed: ' + error);
}
}
/**
* Each section should prepare a file called markdown.md.
* This function reads each section file and wraps it with a markdown header
* @returns <string> the content of the markdown file as string, warped in a collapsable section.
*/
private static async readCommandSummaryMarkdown(): Promise<string> {
let markdownContent: string = await this.readMarkdownContent();
if (markdownContent === '') {
return '';
}
// Check if the header can be accessed via the internet to decide if to use the image or the text header
this.isSummaryHeaderAccessible = await this.isHeaderPngAccessible();
core.debug('Header image is accessible: ' + this.isSummaryHeaderAccessible);
return this.wrapContent(markdownContent);
}
/**
* Reads the combined SARIF file, compresses and encodes it to match the code-scanning/sarif API requirements.
* @returns <string[]> the paths of the code scanning sarif files.
*/
private static async getCodeScanningEncodedSarif(): Promise<string> {
const finalSarifFile: string = path.join(
this.getJobOutputDirectoryPath(),
this.SECURITY_DIR_NAME,
this.SARIF_REPORTS_DIR_NAME,
this.CODE_SCANNING_FINAL_SARIF_FILE,
);
if (!existsSync(finalSarifFile)) {
console.debug('No code scanning sarif file was found.');
return '';
}
// Read the SARIF file, compress and encode it to match the code-scanning/sarif API requirements.
const sarif: string = await fs.readFile(finalSarifFile, 'utf-8');
return await this.compressAndEncodeSarif(sarif);
}
private static async readMarkdownContent() {
const markdownFilePath: string = path.join(this.getJobOutputDirectoryPath(), 'markdown.md');
if (existsSync(markdownFilePath)) {
return await fs.readFile(markdownFilePath, 'utf-8');
}
core.debug(`No job summary file found. at ${markdownFilePath}.`);
return '';
}
private static getMarkdownHeader(): string {
let mainTitle: string;
if (this.isSummaryHeaderAccessible) {
let platformUrl: string = this.getPlatformUrl();
mainTitle = `[](${platformUrl})` + '\n\n';
} else {
mainTitle = `# 🐸 JFrog Job Summary` + '\n\n';
}
return mainTitle + this.getProjectPackagesLink();
}
/**
* Gets the project packages link to be displayed in the summary
* If the project is undefined, it will resolve to 'all' section in the UI.
* @return <string> https://platformUrl/ui/packages?projectKey=projectKey
*/
private static getProjectPackagesLink(): string {
let platformUrl: string = this.getPlatformUrl();
if (!platformUrl) {
return '';
}
let projectKey: string = process.env.JF_PROJECT ? process.env.JF_PROJECT : '';
let projectPackagesUrl: string = platformUrl + 'ui/packages';
if (projectKey) {
projectPackagesUrl += '?projectKey=' + projectKey;
}
return `<a href="${projectPackagesUrl}"> 🐸 View package details on the JFrog platform </a>` + '\n\n';
}
private static getPlatformUrl(): string {
let platformUrl: string | undefined = process.env.JF_URL;
if (!platformUrl) {
return '';
}
if (!platformUrl.endsWith('/')) {
platformUrl = platformUrl + '/';
}
return platformUrl;
}
private static getJobOutputDirectoryPath(): string {
const outputDir: string | undefined = process.env[this.JFROG_CLI_COMMAND_SUMMARY_OUTPUT_DIR_ENV];
if (!outputDir) {
throw new Error('Jobs home directory is undefined, ' + this.JFROG_CLI_COMMAND_SUMMARY_OUTPUT_DIR_ENV + ' is not set.');
}
return path.join(outputDir, this.JOB_SUMMARY_DIR_NAME);
}
public static async clearCommandSummaryDir() {
const outputDir: string = this.getJobOutputDirectoryPath();
core.debug('Removing command summary directory: ' + outputDir);
await fs.rm(outputDir, { recursive: true });
}
private static wrapContent(fileContent: string) {
return this.getMarkdownHeader() + fileContent + this.getMarkdownFooter();
}
private static getMarkdownFooter() {
return `${this.getUsageBadge()} \n\n # \n\n The above Job Summary was generated by the <a href="https://github.com/marketplace/actions/setup-jfrog-cli"> Setup JFrog CLI GitHub Action </a>`;
}
static getUsageBadge(): string {
const platformUrl: string = this.getPlatformUrl();
const githubJobId: string = this.getGithubJobId();
const gitRepo: string = process.env.GITHUB_REPOSITORY || '';
const runId: string = process.env.GITHUB_RUN_ID || '';
const url: URL = new URL(`${platformUrl}ui/api/v1/u`);
url.searchParams.set(this.SOURCE_PARAM_KEY, this.SOURCE_PARAM_VALUE);
url.searchParams.set(this.METRIC_PARAM_KEY, this.METRIC_PARAM_VALUE);
url.searchParams.set(this.JOB_ID_PARAM_KEY, githubJobId);
url.searchParams.set(this.RUN_ID_PARAM_KEY, runId);
url.searchParams.set(this.GIT_REPO_PARAM_KEY, gitRepo);
return `})`;
}
/**
* Checks if the header image is accessible via the internet.
* Saves the result in a static variable to avoid multiple checks.
* @private
*/
private static async isHeaderPngAccessible(): Promise<boolean> {
if (this.isSummaryHeaderAccessible != undefined) {
return this.isSummaryHeaderAccessible;
}
const url: string = this.MARKDOWN_HEADER_PNG_URL;
const httpClient: HttpClient = new HttpClient();
try {
// Set timeout to 5 seconds
const requestOptions: OutgoingHttpHeaders = {
socketTimeout: 5000,
};
const response: HttpClientResponse = await httpClient.head(url, requestOptions);
this.isSummaryHeaderAccessible = response.message.statusCode === 200;
} catch (error) {
core.warning('No internet access to the header image, using the text header instead.');
this.isSummaryHeaderAccessible = false;
} finally {
httpClient.dispose();
}
return this.isSummaryHeaderAccessible;
}
private static getTempDirectory(): string {
// Determine the temporary directory path, prioritizing RUNNER_TEMP
// Runner_Temp is set on GitHub machines, but on self-hosted it could be unset.
const tempDir: string = process.env.RUNNER_TEMP || tmpdir();
if (!tempDir) {
throw new Error('Failed to determine the temporary directory');
}
return tempDir;
}
/**
* Retrieves the GitHub job ID, which in this context refers to the GitHub workflow name.
* Note: We use "job" instead of "workflow" to align with our terminology, where "GitHub job summary"
* refers to the entire workflow summary. Here, "job ID" means the workflow name, not individual jobs within the workflow.
*/
static getGithubJobId(): string {
return process.env.GITHUB_WORKFLOW || '';
}
}