No Description
You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

github.js 16KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383
  1. "use strict";
  2. Object.defineProperty(exports, "__esModule", { value: true });
  3. exports.initGitHub = void 0;
  4. const colorette_1 = require("colorette");
  5. const fs = require("fs");
  6. const yaml = require("js-yaml");
  7. const js_yaml_1 = require("js-yaml");
  8. const ora = require("ora");
  9. const path = require("path");
  10. const libsodium = require("libsodium-wrappers");
  11. const auth_1 = require("../../../auth");
  12. const fsutils_1 = require("../../../fsutils");
  13. const iam_1 = require("../../../gcp/iam");
  14. const resourceManager_1 = require("../../../gcp/resourceManager");
  15. const logger_1 = require("../../../logger");
  16. const prompt_1 = require("../../../prompt");
  17. const utils_1 = require("../../../utils");
  18. const api_1 = require("../../../api");
  19. const apiv2_1 = require("../../../apiv2");
  20. let GIT_DIR;
  21. let GITHUB_DIR;
  22. let WORKFLOW_DIR;
  23. let YML_FULL_PATH_PULL_REQUEST;
  24. let YML_FULL_PATH_MERGE;
  25. const YML_PULL_REQUEST_FILENAME = "firebase-hosting-pull-request.yml";
  26. const YML_MERGE_FILENAME = "firebase-hosting-merge.yml";
  27. const CHECKOUT_GITHUB_ACTION_NAME = "actions/checkout@v2";
  28. const HOSTING_GITHUB_ACTION_NAME = "FirebaseExtended/action-hosting-deploy@v0";
  29. const githubApiClient = new apiv2_1.Client({ urlPrefix: api_1.githubApiOrigin, auth: false });
  30. async function initGitHub(setup) {
  31. if (!setup.projectId) {
  32. return (0, utils_1.reject)("Could not determine Project ID, can't set up GitHub workflow.", { exit: 1 });
  33. }
  34. if (!setup.config.hosting) {
  35. return (0, utils_1.reject)(`Didn't find a Hosting config in firebase.json. Run ${(0, colorette_1.bold)("firebase init hosting")} instead.`);
  36. }
  37. logger_1.logger.info();
  38. const gitRoot = getGitFolderPath();
  39. GIT_DIR = path.join(gitRoot, ".git");
  40. GITHUB_DIR = path.join(gitRoot, ".github");
  41. WORKFLOW_DIR = `${GITHUB_DIR}/workflows`;
  42. YML_FULL_PATH_PULL_REQUEST = `${WORKFLOW_DIR}/${YML_PULL_REQUEST_FILENAME}`;
  43. YML_FULL_PATH_MERGE = `${WORKFLOW_DIR}/${YML_MERGE_FILENAME}`;
  44. (0, utils_1.logBullet)("Authorizing with GitHub to upload your service account to a GitHub repository's secrets store.");
  45. const ghAccessToken = await signInWithGitHub();
  46. const userDetails = await getGitHubUserDetails(ghAccessToken);
  47. const ghUserName = userDetails.login;
  48. logger_1.logger.info();
  49. (0, utils_1.logSuccess)(`Success! Logged into GitHub as ${(0, colorette_1.bold)(ghUserName)}`);
  50. logger_1.logger.info();
  51. const { repo, key, keyId } = await promptForRepo(setup, ghAccessToken);
  52. const { default_branch: defaultBranch, id: repoId } = await getRepoDetails(repo, ghAccessToken);
  53. const githubSecretName = `FIREBASE_SERVICE_ACCOUNT_${setup.projectId
  54. .replace(/-/g, "_")
  55. .toUpperCase()}`;
  56. const serviceAccountName = `github-action-${repoId}`;
  57. const serviceAccountJSON = await createServiceAccountAndKeyWithRetry(setup, repo, serviceAccountName);
  58. logger_1.logger.info();
  59. (0, utils_1.logSuccess)(`Created service account ${(0, colorette_1.bold)(serviceAccountName)} with Firebase Hosting admin permissions.`);
  60. const spinnerSecrets = ora(`Uploading service account secrets to repository: ${repo}`);
  61. spinnerSecrets.start();
  62. const encryptedServiceAccountJSON = encryptServiceAccountJSON(serviceAccountJSON, key);
  63. await uploadSecretToGitHub(repo, ghAccessToken, await encryptedServiceAccountJSON, keyId, githubSecretName);
  64. spinnerSecrets.stop();
  65. (0, utils_1.logSuccess)(`Uploaded service account JSON to GitHub as secret ${(0, colorette_1.bold)(githubSecretName)}.`);
  66. (0, utils_1.logBullet)(`You can manage your secrets at https://github.com/${repo}/settings/secrets.`);
  67. logger_1.logger.info();
  68. if (setup.config.hosting.predeploy) {
  69. (0, utils_1.logBullet)(`You have a predeploy script configured in firebase.json.`);
  70. }
  71. const { script } = await promptForBuildScript();
  72. const ymlDeployDoc = loadYMLDeploy();
  73. let shouldWriteYMLHostingFile = true;
  74. let shouldWriteYMLDeployFile = false;
  75. if (fs.existsSync(YML_FULL_PATH_PULL_REQUEST)) {
  76. const { overwrite } = await promptForWriteYMLFile({
  77. message: `GitHub workflow file for PR previews exists. Overwrite? ${YML_PULL_REQUEST_FILENAME}`,
  78. });
  79. shouldWriteYMLHostingFile = overwrite;
  80. }
  81. if (shouldWriteYMLHostingFile) {
  82. writeChannelActionYMLFile(YML_FULL_PATH_PULL_REQUEST, githubSecretName, setup.projectId, script);
  83. logger_1.logger.info();
  84. (0, utils_1.logSuccess)(`Created workflow file ${(0, colorette_1.bold)(YML_FULL_PATH_PULL_REQUEST)}`);
  85. }
  86. const { setupDeploys, branch } = await promptToSetupDeploys(ymlDeployDoc.branch || defaultBranch);
  87. if (setupDeploys) {
  88. if (ymlDeployDoc.exists) {
  89. if (ymlDeployDoc.branch !== branch) {
  90. shouldWriteYMLDeployFile = true;
  91. }
  92. else {
  93. const { overwrite } = await promptForWriteYMLFile({
  94. message: `The GitHub workflow file for deploying to the live channel already exists. Overwrite? ${YML_MERGE_FILENAME}`,
  95. });
  96. shouldWriteYMLDeployFile = overwrite;
  97. }
  98. }
  99. else {
  100. shouldWriteYMLDeployFile = true;
  101. }
  102. if (shouldWriteYMLDeployFile) {
  103. writeDeployToProdActionYMLFile(YML_FULL_PATH_MERGE, branch, githubSecretName, setup.projectId, script);
  104. logger_1.logger.info();
  105. (0, utils_1.logSuccess)(`Created workflow file ${(0, colorette_1.bold)(YML_FULL_PATH_MERGE)}`);
  106. }
  107. }
  108. logger_1.logger.info();
  109. (0, utils_1.logLabeledBullet)("Action required", `Visit this URL to revoke authorization for the Firebase CLI GitHub OAuth App:`);
  110. logger_1.logger.info((0, colorette_1.bold)((0, colorette_1.underline)(`https://github.com/settings/connections/applications/${api_1.githubClientId}`)));
  111. (0, utils_1.logLabeledBullet)("Action required", `Push any new workflow file(s) to your repo`);
  112. }
  113. exports.initGitHub = initGitHub;
  114. function getGitFolderPath() {
  115. const commandDir = process.cwd();
  116. let projectRootDir = commandDir;
  117. while (!fs.existsSync(path.resolve(projectRootDir, ".git"))) {
  118. const parentDir = path.dirname(projectRootDir);
  119. if (parentDir === projectRootDir) {
  120. (0, utils_1.logBullet)(`Didn't detect a .git folder. Assuming ${commandDir} is the project root.`);
  121. return commandDir;
  122. }
  123. projectRootDir = parentDir;
  124. }
  125. (0, utils_1.logBullet)(`Detected a .git folder at ${projectRootDir}`);
  126. return projectRootDir;
  127. }
  128. function defaultGithubRepo() {
  129. const gitConfigPath = path.join(GIT_DIR, "config");
  130. if (fs.existsSync(gitConfigPath)) {
  131. const gitConfig = fs.readFileSync(gitConfigPath, "utf8");
  132. const match = /github\.com:(.+)\.git/.exec(gitConfig);
  133. if (match) {
  134. return match[1];
  135. }
  136. }
  137. return undefined;
  138. }
  139. function loadYMLDeploy() {
  140. if (fs.existsSync(YML_FULL_PATH_MERGE)) {
  141. const { on } = loadYML(YML_FULL_PATH_MERGE);
  142. const branch = on.push.branches[0];
  143. return { exists: true, branch };
  144. }
  145. else {
  146. return { exists: false };
  147. }
  148. }
  149. function loadYML(ymlPath) {
  150. return (0, js_yaml_1.safeLoad)(fs.readFileSync(ymlPath, "utf8"));
  151. }
  152. function mkdirNotExists(dir) {
  153. if (!(0, fsutils_1.dirExistsSync)(dir)) {
  154. fs.mkdirSync(dir);
  155. }
  156. }
  157. function writeChannelActionYMLFile(ymlPath, secretName, projectId, script) {
  158. const workflowConfig = {
  159. name: "Deploy to Firebase Hosting on PR",
  160. on: "pull_request",
  161. jobs: {
  162. ["build_and_preview"]: {
  163. if: "${{ github.event.pull_request.head.repo.full_name == github.repository }}",
  164. "runs-on": "ubuntu-latest",
  165. steps: [{ uses: CHECKOUT_GITHUB_ACTION_NAME }],
  166. },
  167. },
  168. };
  169. if (script) {
  170. workflowConfig.jobs.build_and_preview.steps.push({
  171. run: script,
  172. });
  173. }
  174. workflowConfig.jobs.build_and_preview.steps.push({
  175. uses: HOSTING_GITHUB_ACTION_NAME,
  176. with: {
  177. repoToken: "${{ secrets.GITHUB_TOKEN }}",
  178. firebaseServiceAccount: `\${{ secrets.${secretName} }}`,
  179. projectId: projectId,
  180. },
  181. });
  182. const ymlContents = `# This file was auto-generated by the Firebase CLI
  183. # https://github.com/firebase/firebase-tools
  184. ${yaml.safeDump(workflowConfig)}`;
  185. mkdirNotExists(GITHUB_DIR);
  186. mkdirNotExists(WORKFLOW_DIR);
  187. fs.writeFileSync(ymlPath, ymlContents, "utf8");
  188. }
  189. function writeDeployToProdActionYMLFile(ymlPath, branch, secretName, projectId, script) {
  190. const workflowConfig = {
  191. name: "Deploy to Firebase Hosting on merge",
  192. on: { push: { branches: [branch || "master"] } },
  193. jobs: {
  194. ["build_and_deploy"]: {
  195. "runs-on": "ubuntu-latest",
  196. steps: [{ uses: CHECKOUT_GITHUB_ACTION_NAME }],
  197. },
  198. },
  199. };
  200. if (script) {
  201. workflowConfig.jobs.build_and_deploy.steps.push({
  202. run: script,
  203. });
  204. }
  205. workflowConfig.jobs.build_and_deploy.steps.push({
  206. uses: HOSTING_GITHUB_ACTION_NAME,
  207. with: {
  208. repoToken: "${{ secrets.GITHUB_TOKEN }}",
  209. firebaseServiceAccount: `\${{ secrets.${secretName} }}`,
  210. channelId: "live",
  211. projectId: projectId,
  212. },
  213. });
  214. const ymlContents = `# This file was auto-generated by the Firebase CLI
  215. # https://github.com/firebase/firebase-tools
  216. ${yaml.safeDump(workflowConfig)}`;
  217. mkdirNotExists(GITHUB_DIR);
  218. mkdirNotExists(WORKFLOW_DIR);
  219. fs.writeFileSync(ymlPath, ymlContents, "utf8");
  220. }
  221. async function uploadSecretToGitHub(repo, ghAccessToken, encryptedServiceAccountJSON, keyId, secretName) {
  222. const data = {
  223. ["encrypted_value"]: encryptedServiceAccountJSON,
  224. ["key_id"]: keyId,
  225. };
  226. const headers = { Authorization: `token ${ghAccessToken}`, "User-Agent": "Firebase CLI" };
  227. return await githubApiClient.put(`/repos/${repo}/actions/secrets/${secretName}`, data, { headers });
  228. }
  229. async function promptForRepo(options, ghAccessToken) {
  230. let key = "";
  231. let keyId = "";
  232. const { repo } = await (0, prompt_1.prompt)(options, [
  233. {
  234. type: "input",
  235. name: "repo",
  236. default: defaultGithubRepo(),
  237. message: "For which GitHub repository would you like to set up a GitHub workflow? (format: user/repository)",
  238. validate: async (repo) => {
  239. try {
  240. const { body } = await githubApiClient.get(`/repos/${repo}/actions/secrets/public-key`, {
  241. headers: { Authorization: `token ${ghAccessToken}`, "User-Agent": "Firebase CLI" },
  242. queryParams: { type: "owner" },
  243. });
  244. key = body.key;
  245. keyId = body.key_id;
  246. }
  247. catch (e) {
  248. if (e.status === 403) {
  249. logger_1.logger.info();
  250. logger_1.logger.info();
  251. (0, utils_1.logWarning)("The provided authorization cannot be used with this repository. If this repository is in an organization, did you remember to grant access?", "error");
  252. logger_1.logger.info();
  253. (0, utils_1.logLabeledBullet)("Action required", `Visit this URL to ensure access has been granted to the appropriate organization(s) for the Firebase CLI GitHub OAuth App:`);
  254. logger_1.logger.info((0, colorette_1.bold)((0, colorette_1.underline)(`https://github.com/settings/connections/applications/${api_1.githubClientId}`)));
  255. logger_1.logger.info();
  256. }
  257. return false;
  258. }
  259. return true;
  260. },
  261. },
  262. ]);
  263. return { repo, key, keyId };
  264. }
  265. async function promptForBuildScript() {
  266. const { shouldSetupScript } = await (0, prompt_1.prompt)({}, [
  267. {
  268. type: "confirm",
  269. name: "shouldSetupScript",
  270. default: false,
  271. message: "Set up the workflow to run a build script before every deploy?",
  272. },
  273. ]);
  274. if (!shouldSetupScript) {
  275. return { script: undefined };
  276. }
  277. const { script } = await (0, prompt_1.prompt)({}, [
  278. {
  279. type: "input",
  280. name: "script",
  281. default: "npm ci && npm run build",
  282. message: "What script should be run before every deploy?",
  283. },
  284. ]);
  285. return { script };
  286. }
  287. async function promptToSetupDeploys(defaultBranch) {
  288. const { setupDeploys } = await (0, prompt_1.prompt)({}, [
  289. {
  290. type: "confirm",
  291. name: "setupDeploys",
  292. default: true,
  293. message: "Set up automatic deployment to your site's live channel when a PR is merged?",
  294. },
  295. ]);
  296. if (!setupDeploys) {
  297. return { setupDeploys };
  298. }
  299. const { branch } = await (0, prompt_1.prompt)({}, [
  300. {
  301. type: "input",
  302. name: "branch",
  303. default: defaultBranch,
  304. message: "What is the name of the GitHub branch associated with your site's live channel?",
  305. },
  306. ]);
  307. return { branch, setupDeploys };
  308. }
  309. async function promptForWriteYMLFile({ message }) {
  310. return await (0, prompt_1.prompt)({}, [
  311. {
  312. type: "confirm",
  313. name: "overwrite",
  314. default: false,
  315. message,
  316. },
  317. ]);
  318. }
  319. async function getGitHubUserDetails(ghAccessToken) {
  320. const { body: ghUserDetails } = await githubApiClient.get("/user", {
  321. headers: { Authorization: `token ${ghAccessToken}`, "User-Agent": "Firebase CLI" },
  322. });
  323. return ghUserDetails;
  324. }
  325. async function getRepoDetails(repo, ghAccessToken) {
  326. const { body } = await githubApiClient.get(`/repos/${repo}`, {
  327. headers: { Authorization: `token ${ghAccessToken}`, "User-Agent": "Firebase CLI" },
  328. });
  329. return body;
  330. }
  331. async function signInWithGitHub() {
  332. return await (0, auth_1.loginGithub)();
  333. }
  334. async function createServiceAccountAndKeyWithRetry(options, repo, accountId) {
  335. const spinnerServiceAccount = ora("Retrieving a service account.");
  336. spinnerServiceAccount.start();
  337. try {
  338. const serviceAccountJSON = await createServiceAccountAndKey(options, repo, accountId);
  339. spinnerServiceAccount.stop();
  340. return serviceAccountJSON;
  341. }
  342. catch (e) {
  343. spinnerServiceAccount.stop();
  344. if (!e.message.includes("429")) {
  345. throw e;
  346. }
  347. spinnerServiceAccount.start();
  348. await (0, iam_1.deleteServiceAccount)(options.projectId, `${accountId}@${options.projectId}.iam.gserviceaccount.com`);
  349. const serviceAccountJSON = await createServiceAccountAndKey(options, repo, accountId);
  350. spinnerServiceAccount.stop();
  351. return serviceAccountJSON;
  352. }
  353. }
  354. async function createServiceAccountAndKey(options, repo, accountId) {
  355. try {
  356. await (0, iam_1.createServiceAccount)(options.projectId, accountId, `A service account with permission to deploy to Firebase Hosting for the GitHub repository ${repo}`, `GitHub Actions (${repo})`);
  357. }
  358. catch (e) {
  359. if (!e.message.includes("409")) {
  360. throw e;
  361. }
  362. }
  363. const requiredRoles = [
  364. resourceManager_1.firebaseRoles.authAdmin,
  365. resourceManager_1.firebaseRoles.apiKeysViewer,
  366. resourceManager_1.firebaseRoles.hostingAdmin,
  367. resourceManager_1.firebaseRoles.runViewer,
  368. ];
  369. await (0, resourceManager_1.addServiceAccountToRoles)(options.projectId, accountId, requiredRoles);
  370. const serviceAccountKey = await (0, iam_1.createServiceAccountKey)(options.projectId, accountId);
  371. const buf = Buffer.from(serviceAccountKey.privateKeyData, "base64");
  372. const serviceAccountJSON = buf.toString();
  373. return serviceAccountJSON;
  374. }
  375. async function encryptServiceAccountJSON(serviceAccountJSON, key) {
  376. const messageBytes = Buffer.from(serviceAccountJSON);
  377. const keyBytes = Buffer.from(key, "base64");
  378. await libsodium.ready;
  379. const encryptedBytes = libsodium.crypto_box_seal(messageBytes, keyBytes);
  380. return Buffer.from(encryptedBytes).toString("base64");
  381. }