main.ts (8045B)
1 import {addPath, debug, getInput, setFailed, summary} from '@actions/core'; 2 import {ExecOptions, exec} from '@actions/exec'; 3 import {getOctokit, context} from '@actions/github'; 4 import {which} from '@actions/io'; 5 import {cacheDir, downloadTool, extractTar, find} from '@actions/tool-cache'; 6 import {readFile} from 'fs/promises'; 7 import path from 'path'; 8 import grammar from './grammar.js'; 9 import got from 'got'; 10 // @ts-ignore 11 import nearly from 'nearley/lib/nearley.js'; 12 const {Grammar, Parser} = nearly; 13 14 interface WaybackResponse { 15 archived_snapshots: { 16 closest?: { 17 available: boolean; 18 url: string; 19 timestamp: string; 20 status: string; 21 }; 22 }; 23 } 24 25 // Purely used for tests 26 function areWeTestingWithJest() { 27 return process.env.JEST_WORKER_ID !== undefined; 28 } 29 30 async function downloadRelease(version: string): Promise<string> { 31 // Download 32 const downloadUrl = `https://github.com/getzola/zola/releases/download/v${version}/zola-v${version}-x86_64-unknown-linux-gnu.tar.gz`; 33 let downloadPath: string | null = null; 34 try { 35 downloadPath = await downloadTool(downloadUrl); 36 } catch (error) { 37 debug(error as string); 38 throw new Error( 39 `Failed to download version v${version}: ${error as string}` 40 ); 41 } 42 43 // Extract 44 const extPath = await extractTar(downloadPath); 45 46 // Install into the local tool cache - node extracts with a root folder that matches the fileName downloaded 47 return await cacheDir(extPath, 'zola', version); 48 } 49 50 async function getZolaCli(version: string): Promise<void> { 51 // look if the binary is cached 52 let toolPath: string; 53 toolPath = find('zola', version); 54 55 // if not: download, extract and cache 56 if (!toolPath) { 57 toolPath = await downloadRelease(version); 58 debug(`Zola cached under ${toolPath}`); 59 } 60 61 addPath(toolPath); 62 } 63 64 export async function run(): Promise<void> { 65 // __dirname does not exist in esm world so we fake it the esm way. Nodejs approves. 66 const __dirname = process.env['GITHUB_WORKSPACE'] || '.'; 67 68 const working_directory = getInput('working_directory'); 69 70 let dataString = ''; 71 let infoString = ''; 72 const parser: typeof Parser = new Parser(Grammar.fromCompiled(grammar)); 73 const options: ExecOptions = { 74 cwd: path.join(__dirname, working_directory), 75 ignoreReturnCode: true, 76 listeners: { 77 stderr: (data: Buffer) => { 78 dataString += data.toString(); 79 }, 80 stdout: (data: Buffer) => { 81 infoString += data.toString(); 82 } 83 } 84 }; 85 86 // Download zola 87 await getZolaCli('0.17.2'); 88 89 const zolaPath: string = await which('zola', true); 90 const startTime = new Date(); 91 92 await exec(`${zolaPath}`, ['check'], options); 93 94 try { 95 parser.feed(dataString); 96 } catch (parseError: unknown) { 97 setFailed(`Error at character ${(parseError as {offset: string}).offset}`); 98 } 99 100 const annotations: { 101 path: string; 102 start_line: number; 103 end_line: number; 104 start_column: number; 105 end_column: number; 106 annotation_level: string; 107 message: string; 108 }[] = []; 109 110 for (const rawResult of parser.results[0]) { 111 const result = rawResult as { 112 error_message: string; 113 file?: string; 114 url?: string; 115 }; 116 if (!result.hasOwnProperty('file')) { 117 continue; 118 } 119 120 // eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- We ensured this exists previously 121 const data: string = await readFile(result.file!, 'utf8'); 122 const lines: string[] = data.split(/\r?\n/); 123 124 for (const [index, line] of lines.entries()) { 125 if (line.trim() === '') { 126 continue; 127 } 128 129 const startingPositionOfUrl = line.indexOf(result.url ?? ''); 130 131 if (startingPositionOfUrl === -1) { 132 continue; 133 } 134 135 let message = `Zola Error Message: ${result.error_message}`; 136 137 // Check if we have a webarchive link 138 const waybackResponse = await got 139 .get(`http://archive.org/wayback/available?url=${result.url ?? ''}`) 140 .json<WaybackResponse>(); 141 if (waybackResponse.archived_snapshots !== null) { 142 if ( 143 waybackResponse.archived_snapshots.closest?.available && 144 waybackResponse.archived_snapshots.closest.status === '200' 145 ) { 146 message = `${message}\nWayback Machine Link is available: ${waybackResponse.archived_snapshots.closest.url}`; 147 } 148 } 149 150 annotations.push({ 151 // This is a little awkward but does the job 152 path: `/${path.relative(__dirname, result.file ?? '')}`, 153 start_line: index, 154 end_line: index, 155 start_column: startingPositionOfUrl, 156 end_column: startingPositionOfUrl + (result.url ?? '').length, 157 annotation_level: getInput('annotation_level'), 158 message 159 }); 160 } 161 } 162 163 // Only create result if there is anything to report 164 if (annotations.length > 0) { 165 const token = getInput('repo-token'); 166 const octokit = getOctokit(token); 167 168 // call octokit to create a check with annotation and details 169 await octokit.rest.checks.create({ 170 owner: context.repo.owner, 171 repo: context.repo.repo, 172 name: 'Zola Check', 173 head_sha: context.sha, 174 started_at: areWeTestingWithJest() ? undefined : startTime.toISOString(), 175 completed_at: areWeTestingWithJest() 176 ? undefined 177 : new Date().toISOString(), 178 status: 'completed', 179 conclusion: getInput('conclusion_level'), 180 output: { 181 title: 'Link is not reachable', 182 summary: 183 'Zola check found links which are not reachable. Make sure to either ignore these due to being false positives or fixing them', 184 annotations 185 } 186 }); 187 } 188 189 // Write summary 190 const stdoutParser: typeof Parser = new Parser(Grammar.fromCompiled(grammar)); 191 stdoutParser.feed(infoString); 192 193 if ( 194 // eslint-disable-next-line @typescript-eslint/no-explicit-any 195 stdoutParser.results[0].filter((result: any) => 196 result.hasOwnProperty('successReport') 197 ).length > 0 198 ) { 199 // eslint-disable-next-line @typescript-eslint/no-explicit-any 200 const totalExternal = stdoutParser.results[0].filter((result: any) => 201 result.hasOwnProperty('external_links_planed_checking') 202 )[0]['external_links_planed_checking']['total']; 203 summary 204 .addHeading('Zola check results') 205 .addTable([ 206 [ 207 {data: 'Link Type', header: true}, 208 {data: 'Total', header: true}, 209 {data: 'Result', header: true} 210 ], 211 ['Internal', '', 'Pass ✅'], 212 ['External', totalExternal, `Pass ✅`] 213 ]) 214 .write(); 215 } else { 216 // eslint-disable-next-line @typescript-eslint/no-explicit-any 217 const totalInternal = stdoutParser.results[0].filter((result: any) => 218 result.hasOwnProperty('internal_links') 219 )[0]['internal_links']['total']; 220 // eslint-disable-next-line @typescript-eslint/no-explicit-any 221 const totalExternal = stdoutParser.results[0].filter((result: any) => 222 result.hasOwnProperty('external_links_planed_checking') 223 )[0]['external_links_planed_checking']['total']; 224 const skippedExternal = 225 // eslint-disable-next-line @typescript-eslint/no-explicit-any 226 stdoutParser.results[0].filter((result: any) => 227 result.hasOwnProperty('external_links_planed_checking') 228 )[0]['external_links_planed_checking']['skipped'] || '0'; 229 // eslint-disable-next-line @typescript-eslint/no-explicit-any 230 const errorCount = stdoutParser.results[0].filter((result: any) => 231 result.hasOwnProperty('external_links_checked') 232 )[0]['external_links_checked']['errors']; 233 summary 234 .addHeading('Zola check results') 235 .addTable([ 236 [ 237 {data: 'Link Type', header: true}, 238 {data: 'Total', header: true}, 239 {data: 'Result', header: true} 240 ], 241 ['Internal', totalInternal, 'Pass ✅'], 242 [ 243 'External', 244 `${totalExternal} (Skipped ${skippedExternal})`, 245 `Fail (${errorCount} error(s) found) ❌` 246 ] 247 ]) 248 .write(); 249 } 250 } 251 252 run();