=> {
let myOutput = '';
const options = {
listeners: {
stdout: (stdoutData: Buffer) => {
myOutput += stdoutData.toString();
},
},
};
await exec(`npx`, command, options);
if (myOutput && !myOutput.includes('Success')) {
throw new Error(myOutput);
}
};
export const formatImage = ({
buildingLogUrl,
imageUrl,
}: {
buildingLogUrl: string;
imageUrl: string;
}) => {
return `
`;
};
export const getCommentFooter = () => {
return '🤖 By [surge-preview](https://github.com/afc163/surge-preview)';
};
================================================
FILE: src/main.ts
================================================
import * as core from '@actions/core';
import { exec } from '@actions/exec';
import * as github from '@actions/github';
import { comment } from './commentToPullRequest';
import { execSurgeCommand, formatImage, getCommentFooter } from './helpers';
let failOnErrorGlobal = false;
let fail: (err: Error) => void;
async function main() {
const surgeToken =
core.getInput('surge_token') || '6973bdb764f0d5fd07c910de27e2d7d0';
const token = core.getInput('github_token', { required: true });
const dist = core.getInput('dist');
const teardown =
core.getInput('teardown')?.toString().toLowerCase() === 'true';
const failOnError = !!(
core.getInput('failOnError') || process.env.FAIL_ON__ERROR
);
failOnErrorGlobal = failOnError;
core.debug(
`failOnErrorGlobal: ${typeof failOnErrorGlobal} + ${failOnErrorGlobal.toString()}`,
);
const octokit = github.getOctokit(token);
let prNumber: number | undefined;
let prState: string | undefined;
core.debug('github.context');
core.debug(JSON.stringify(github.context, null, 2));
const { job, payload } = github.context;
core.debug(`payload.after: ${payload.after}`);
core.debug(`payload.pull_request: ${payload.pull_request}`);
const gitCommitSha =
payload.after ||
payload?.pull_request?.head?.sha ||
payload?.workflow_run?.head_sha;
core.debug(JSON.stringify(github.context.repo, null, 2));
core.debug(`payload.pull_request?.head: ${payload.pull_request?.head}`);
const fromForkedRepo = payload.pull_request?.head.repo.fork;
if (payload.number && payload.pull_request) {
core.debug('prNumber retrieved from pull_request');
prNumber = payload.number;
prState = payload.action;
} else {
core.debug('Not a pull_request, so doing a API search');
// Inspired by https://github.com/orgs/community/discussions/25220#discussioncomment-8697399
const query = {
q: `repo:${github.context.repo.owner}/${github.context.repo.repo} is:pr sha:${gitCommitSha}`,
per_page: 1,
};
try {
const result = await octokit.rest.search.issuesAndPullRequests(query);
const pr = result.data.items.length > 0 && result.data.items[0];
core.debug(`Found related pull_request: ${JSON.stringify(pr, null, 2)}`);
prNumber = pr ? pr.number : undefined;
prState = pr ? pr.state : undefined;
} catch (e) {
// As mentioned in https://github.com/orgs/community/discussions/25220#discussioncomment-8971083
// from time to time, you may get rate limit errors given search API seems to use many calls internally.
core.warning(`Unable to get the PR number with API search: ${e}`);
}
}
if (!prNumber) {
core.info(`😢 No related PR found, skip it.`);
return;
}
core.info(`Found PR number: ${prNumber}, PR status: ${prState}`);
const commentIfNotForkedRepo = (message: string) => {
// if it is forked repo, don't comment
if (fromForkedRepo) {
core.info('PR created from a forked repository, so skip PR comment');
return;
}
comment({
repo: github.context.repo,
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
number: prNumber,
message,
octokit,
header: job,
});
};
fail = (err: Error) => {
core.info('error message:');
core.info(JSON.stringify(err, null, 2));
commentIfNotForkedRepo(`
😭 Deploy PR Preview ${gitCommitSha} failed. [Build logs](https://github.com/${
github.context.repo.owner
}/${github.context.repo.repo}/actions/runs/${github.context.runId})
${formatImage({
buildingLogUrl,
imageUrl:
'https://user-images.githubusercontent.com/507615/90250824-4e066700-de6f-11ea-8230-600ecc3d6a6b.png',
})}
${getCommentFooter()}
`);
if (failOnError) {
core.setFailed(err.message);
}
};
const repoOwner = github.context.repo.owner.replace(/\./g, '-');
const repoName = github.context.repo.repo.replace(/\./g, '-');
const url = `${repoOwner}-${repoName}-${job}-pr-${prNumber}.surge.sh`;
core.setOutput('preview_url', url);
let data;
try {
const result = await octokit.rest.checks.listForRef({
owner: github.context.repo.owner,
repo: github.context.repo.repo,
ref: gitCommitSha,
});
data = result.data;
} catch (err) {
if (err instanceof Error) {
fail(err);
}
return;
}
core.debug(JSON.stringify(data?.check_runs, null, 2));
// 尝试获取 check_run_id,逻辑不是很严谨
let checkRunId;
if (data?.check_runs?.length >= 0) {
const checkRun = data?.check_runs?.find((item) => item.name === job);
checkRunId = checkRun?.id;
}
const buildingLogUrl = checkRunId
? `https://github.com/${github.context.repo.owner}/${github.context.repo.repo}/runs/${checkRunId}`
: `https://github.com/${github.context.repo.owner}/${github.context.repo.repo}/actions/runs/${github.context.runId}`;
core.debug(`teardown enabled?: ${teardown}`);
core.debug(`event action?: ${payload.action}`);
if (teardown && prState === 'closed') {
try {
core.info(`Teardown: ${url}`);
core.setSecret(surgeToken);
await execSurgeCommand({
command: ['surge', 'teardown', url, `--token`, surgeToken],
});
return commentIfNotForkedRepo(`
:recycle: [PR Preview](https://${url}) ${gitCommitSha} has been successfully destroyed since this PR has been closed.
${formatImage({
buildingLogUrl,
imageUrl:
'https://user-images.githubusercontent.com/507615/98094112-d838f700-1ec3-11eb-8530-381c2276b80e.png',
})}
${getCommentFooter()}
`);
} catch (err) {
if (err instanceof Error) {
return fail?.(err);
}
}
}
commentIfNotForkedRepo(`
⚡️ Deploying PR Preview ${gitCommitSha} to [surge.sh](https://${url}) ... [Build logs](${buildingLogUrl})
${formatImage({
buildingLogUrl,
imageUrl:
'https://user-images.githubusercontent.com/507615/90240294-8d2abd00-de5b-11ea-8140-4840a0b2d571.gif',
})}
${getCommentFooter()}
`);
const startTime = Date.now();
try {
if (!core.getInput('build')) {
await exec(`npm install`);
await exec(`npm run build`);
} else {
const buildCommands = core.getInput('build').split('\n');
for (const command of buildCommands) {
core.info(`RUN: ${command}`);
await exec(command);
}
}
const duration = (Date.now() - startTime) / 1000;
core.info(`Build time: ${duration} seconds`);
core.info(`Deploy to ${url}`);
core.setSecret(surgeToken);
await execSurgeCommand({
command: ['surge', `./${dist}`, url, `--token`, surgeToken],
});
commentIfNotForkedRepo(`
🎊 PR Preview ${gitCommitSha} has been successfully built and deployed to https://${url}
🕐 Build time: **${duration}s**
${formatImage({
buildingLogUrl,
imageUrl:
'https://user-images.githubusercontent.com/507615/90250366-88233900-de6e-11ea-95a5-84f0762ffd39.png',
})}
${getCommentFooter()}
`);
} catch (err) {
if (err instanceof Error) {
fail?.(err);
}
}
}
// eslint-disable-next-line github/no-then
main().catch((err) => {
fail?.(err);
});
================================================
FILE: tsconfig.json
================================================
{
"compilerOptions": {
"target": "es6" /* Specify ECMAScript target version: 'ES3' (default), 'ES5', 'ES2015', 'ES2016', 'ES2017', 'ES2018', 'ES2019' or 'ESNEXT'. */,
"module": "commonjs" /* Specify module code generation: 'none', 'commonjs', 'amd', 'system', 'umd', 'es2015', or 'ESNext'. */,
"outDir": "./lib" /* Redirect output structure to the directory. */,
"rootDir": "./src" /* Specify the root directory of input files. Use to control the output directory structure with --outDir. */,
"strict": true /* Enable all strict type-checking options. */,
"lib": ["dom"],
"noImplicitAny": true /* Raise error on expressions and declarations with an implied 'any' type. */,
"esModuleInterop": true /* Enables emit interoperability between CommonJS and ES Modules via creation of namespace objects for all imports. Implies 'allowSyntheticDefaultImports'. */
},
"exclude": ["node_modules", "**/*.test.ts"]
}
================================================
FILE: utils/gen-preview.js
================================================
const fs = require('fs');
const path = require('path');
const github = require('@actions/github');
const writeFile = (filePath, html) => {
const fd = path.resolve(__dirname, '..', filePath, 'index.html');
const contentBuf = Buffer.from(html || 'test content', {
encoding: 'utf-8',
});
try {
fs.unlinkSync(fd);
} catch (e) {
console.error('Could not delete file => ', e.message);
}
try {
fs.writeFileSync(fd, contentBuf);
console.log('Wrote content to file: ', fd);
} catch (e) {
console.error('Could not write file => ', e.message);
}
};
const allowedVars = [
'GITHUB_ACTOR',
'GITHUB_RUN_NUMBER',
'GITHUB_ACTION',
'GITHUB_REF',
'GITHUB_SHA',
];
(() => {
const filePath = process.argv.slice(2)[0];
const valueMap = process.env;
const content = Object.entries(valueMap)
.filter(([x]) => allowedVars.includes(x))
.map(([key, val]) => {
return `${key}: ${val}
`;
});
if (!content.length) {
console.log(`no content received`);
return writeFile(filePath, 'test content');
}
content.push(
`github.context.payload.pull_request.head.ref: ${github.context.payload.pull_request.head.ref}
`,
);
content.push(
`github.context.payload.pull_request.head.sha: ${github.context.payload.pull_request.head.sha}
`,
);
console.log(`received: ${content}`);
const contentHtml = content.join('\n ');
const html = `
${contentHtml}
`;
return writeFile(filePath, html);
})();