123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383 |
- "use strict";
- Object.defineProperty(exports, "__esModule", { value: true });
- exports.initGitHub = void 0;
- const colorette_1 = require("colorette");
- const fs = require("fs");
- const yaml = require("js-yaml");
- const js_yaml_1 = require("js-yaml");
- const ora = require("ora");
- const path = require("path");
- const libsodium = require("libsodium-wrappers");
- const auth_1 = require("../../../auth");
- const fsutils_1 = require("../../../fsutils");
- const iam_1 = require("../../../gcp/iam");
- const resourceManager_1 = require("../../../gcp/resourceManager");
- const logger_1 = require("../../../logger");
- const prompt_1 = require("../../../prompt");
- const utils_1 = require("../../../utils");
- const api_1 = require("../../../api");
- const apiv2_1 = require("../../../apiv2");
- let GIT_DIR;
- let GITHUB_DIR;
- let WORKFLOW_DIR;
- let YML_FULL_PATH_PULL_REQUEST;
- let YML_FULL_PATH_MERGE;
- const YML_PULL_REQUEST_FILENAME = "firebase-hosting-pull-request.yml";
- const YML_MERGE_FILENAME = "firebase-hosting-merge.yml";
- const CHECKOUT_GITHUB_ACTION_NAME = "actions/checkout@v2";
- const HOSTING_GITHUB_ACTION_NAME = "FirebaseExtended/action-hosting-deploy@v0";
- const githubApiClient = new apiv2_1.Client({ urlPrefix: api_1.githubApiOrigin, auth: false });
- async function initGitHub(setup) {
- if (!setup.projectId) {
- return (0, utils_1.reject)("Could not determine Project ID, can't set up GitHub workflow.", { exit: 1 });
- }
- if (!setup.config.hosting) {
- return (0, utils_1.reject)(`Didn't find a Hosting config in firebase.json. Run ${(0, colorette_1.bold)("firebase init hosting")} instead.`);
- }
- logger_1.logger.info();
- const gitRoot = getGitFolderPath();
- GIT_DIR = path.join(gitRoot, ".git");
- GITHUB_DIR = path.join(gitRoot, ".github");
- WORKFLOW_DIR = `${GITHUB_DIR}/workflows`;
- YML_FULL_PATH_PULL_REQUEST = `${WORKFLOW_DIR}/${YML_PULL_REQUEST_FILENAME}`;
- YML_FULL_PATH_MERGE = `${WORKFLOW_DIR}/${YML_MERGE_FILENAME}`;
- (0, utils_1.logBullet)("Authorizing with GitHub to upload your service account to a GitHub repository's secrets store.");
- const ghAccessToken = await signInWithGitHub();
- const userDetails = await getGitHubUserDetails(ghAccessToken);
- const ghUserName = userDetails.login;
- logger_1.logger.info();
- (0, utils_1.logSuccess)(`Success! Logged into GitHub as ${(0, colorette_1.bold)(ghUserName)}`);
- logger_1.logger.info();
- const { repo, key, keyId } = await promptForRepo(setup, ghAccessToken);
- const { default_branch: defaultBranch, id: repoId } = await getRepoDetails(repo, ghAccessToken);
- const githubSecretName = `FIREBASE_SERVICE_ACCOUNT_${setup.projectId
- .replace(/-/g, "_")
- .toUpperCase()}`;
- const serviceAccountName = `github-action-${repoId}`;
- const serviceAccountJSON = await createServiceAccountAndKeyWithRetry(setup, repo, serviceAccountName);
- logger_1.logger.info();
- (0, utils_1.logSuccess)(`Created service account ${(0, colorette_1.bold)(serviceAccountName)} with Firebase Hosting admin permissions.`);
- const spinnerSecrets = ora(`Uploading service account secrets to repository: ${repo}`);
- spinnerSecrets.start();
- const encryptedServiceAccountJSON = encryptServiceAccountJSON(serviceAccountJSON, key);
- await uploadSecretToGitHub(repo, ghAccessToken, await encryptedServiceAccountJSON, keyId, githubSecretName);
- spinnerSecrets.stop();
- (0, utils_1.logSuccess)(`Uploaded service account JSON to GitHub as secret ${(0, colorette_1.bold)(githubSecretName)}.`);
- (0, utils_1.logBullet)(`You can manage your secrets at https://github.com/${repo}/settings/secrets.`);
- logger_1.logger.info();
- if (setup.config.hosting.predeploy) {
- (0, utils_1.logBullet)(`You have a predeploy script configured in firebase.json.`);
- }
- const { script } = await promptForBuildScript();
- const ymlDeployDoc = loadYMLDeploy();
- let shouldWriteYMLHostingFile = true;
- let shouldWriteYMLDeployFile = false;
- if (fs.existsSync(YML_FULL_PATH_PULL_REQUEST)) {
- const { overwrite } = await promptForWriteYMLFile({
- message: `GitHub workflow file for PR previews exists. Overwrite? ${YML_PULL_REQUEST_FILENAME}`,
- });
- shouldWriteYMLHostingFile = overwrite;
- }
- if (shouldWriteYMLHostingFile) {
- writeChannelActionYMLFile(YML_FULL_PATH_PULL_REQUEST, githubSecretName, setup.projectId, script);
- logger_1.logger.info();
- (0, utils_1.logSuccess)(`Created workflow file ${(0, colorette_1.bold)(YML_FULL_PATH_PULL_REQUEST)}`);
- }
- const { setupDeploys, branch } = await promptToSetupDeploys(ymlDeployDoc.branch || defaultBranch);
- if (setupDeploys) {
- if (ymlDeployDoc.exists) {
- if (ymlDeployDoc.branch !== branch) {
- shouldWriteYMLDeployFile = true;
- }
- else {
- const { overwrite } = await promptForWriteYMLFile({
- message: `The GitHub workflow file for deploying to the live channel already exists. Overwrite? ${YML_MERGE_FILENAME}`,
- });
- shouldWriteYMLDeployFile = overwrite;
- }
- }
- else {
- shouldWriteYMLDeployFile = true;
- }
- if (shouldWriteYMLDeployFile) {
- writeDeployToProdActionYMLFile(YML_FULL_PATH_MERGE, branch, githubSecretName, setup.projectId, script);
- logger_1.logger.info();
- (0, utils_1.logSuccess)(`Created workflow file ${(0, colorette_1.bold)(YML_FULL_PATH_MERGE)}`);
- }
- }
- logger_1.logger.info();
- (0, utils_1.logLabeledBullet)("Action required", `Visit this URL to revoke authorization for the Firebase CLI GitHub OAuth App:`);
- logger_1.logger.info((0, colorette_1.bold)((0, colorette_1.underline)(`https://github.com/settings/connections/applications/${api_1.githubClientId}`)));
- (0, utils_1.logLabeledBullet)("Action required", `Push any new workflow file(s) to your repo`);
- }
- exports.initGitHub = initGitHub;
- function getGitFolderPath() {
- const commandDir = process.cwd();
- let projectRootDir = commandDir;
- while (!fs.existsSync(path.resolve(projectRootDir, ".git"))) {
- const parentDir = path.dirname(projectRootDir);
- if (parentDir === projectRootDir) {
- (0, utils_1.logBullet)(`Didn't detect a .git folder. Assuming ${commandDir} is the project root.`);
- return commandDir;
- }
- projectRootDir = parentDir;
- }
- (0, utils_1.logBullet)(`Detected a .git folder at ${projectRootDir}`);
- return projectRootDir;
- }
- function defaultGithubRepo() {
- const gitConfigPath = path.join(GIT_DIR, "config");
- if (fs.existsSync(gitConfigPath)) {
- const gitConfig = fs.readFileSync(gitConfigPath, "utf8");
- const match = /github\.com:(.+)\.git/.exec(gitConfig);
- if (match) {
- return match[1];
- }
- }
- return undefined;
- }
- function loadYMLDeploy() {
- if (fs.existsSync(YML_FULL_PATH_MERGE)) {
- const { on } = loadYML(YML_FULL_PATH_MERGE);
- const branch = on.push.branches[0];
- return { exists: true, branch };
- }
- else {
- return { exists: false };
- }
- }
- function loadYML(ymlPath) {
- return (0, js_yaml_1.safeLoad)(fs.readFileSync(ymlPath, "utf8"));
- }
- function mkdirNotExists(dir) {
- if (!(0, fsutils_1.dirExistsSync)(dir)) {
- fs.mkdirSync(dir);
- }
- }
- function writeChannelActionYMLFile(ymlPath, secretName, projectId, script) {
- const workflowConfig = {
- name: "Deploy to Firebase Hosting on PR",
- on: "pull_request",
- jobs: {
- ["build_and_preview"]: {
- if: "${{ github.event.pull_request.head.repo.full_name == github.repository }}",
- "runs-on": "ubuntu-latest",
- steps: [{ uses: CHECKOUT_GITHUB_ACTION_NAME }],
- },
- },
- };
- if (script) {
- workflowConfig.jobs.build_and_preview.steps.push({
- run: script,
- });
- }
- workflowConfig.jobs.build_and_preview.steps.push({
- uses: HOSTING_GITHUB_ACTION_NAME,
- with: {
- repoToken: "${{ secrets.GITHUB_TOKEN }}",
- firebaseServiceAccount: `\${{ secrets.${secretName} }}`,
- projectId: projectId,
- },
- });
- const ymlContents = `# This file was auto-generated by the Firebase CLI
- # https://github.com/firebase/firebase-tools
-
- ${yaml.safeDump(workflowConfig)}`;
- mkdirNotExists(GITHUB_DIR);
- mkdirNotExists(WORKFLOW_DIR);
- fs.writeFileSync(ymlPath, ymlContents, "utf8");
- }
- function writeDeployToProdActionYMLFile(ymlPath, branch, secretName, projectId, script) {
- const workflowConfig = {
- name: "Deploy to Firebase Hosting on merge",
- on: { push: { branches: [branch || "master"] } },
- jobs: {
- ["build_and_deploy"]: {
- "runs-on": "ubuntu-latest",
- steps: [{ uses: CHECKOUT_GITHUB_ACTION_NAME }],
- },
- },
- };
- if (script) {
- workflowConfig.jobs.build_and_deploy.steps.push({
- run: script,
- });
- }
- workflowConfig.jobs.build_and_deploy.steps.push({
- uses: HOSTING_GITHUB_ACTION_NAME,
- with: {
- repoToken: "${{ secrets.GITHUB_TOKEN }}",
- firebaseServiceAccount: `\${{ secrets.${secretName} }}`,
- channelId: "live",
- projectId: projectId,
- },
- });
- const ymlContents = `# This file was auto-generated by the Firebase CLI
- # https://github.com/firebase/firebase-tools
-
- ${yaml.safeDump(workflowConfig)}`;
- mkdirNotExists(GITHUB_DIR);
- mkdirNotExists(WORKFLOW_DIR);
- fs.writeFileSync(ymlPath, ymlContents, "utf8");
- }
- async function uploadSecretToGitHub(repo, ghAccessToken, encryptedServiceAccountJSON, keyId, secretName) {
- const data = {
- ["encrypted_value"]: encryptedServiceAccountJSON,
- ["key_id"]: keyId,
- };
- const headers = { Authorization: `token ${ghAccessToken}`, "User-Agent": "Firebase CLI" };
- return await githubApiClient.put(`/repos/${repo}/actions/secrets/${secretName}`, data, { headers });
- }
- async function promptForRepo(options, ghAccessToken) {
- let key = "";
- let keyId = "";
- const { repo } = await (0, prompt_1.prompt)(options, [
- {
- type: "input",
- name: "repo",
- default: defaultGithubRepo(),
- message: "For which GitHub repository would you like to set up a GitHub workflow? (format: user/repository)",
- validate: async (repo) => {
- try {
- const { body } = await githubApiClient.get(`/repos/${repo}/actions/secrets/public-key`, {
- headers: { Authorization: `token ${ghAccessToken}`, "User-Agent": "Firebase CLI" },
- queryParams: { type: "owner" },
- });
- key = body.key;
- keyId = body.key_id;
- }
- catch (e) {
- if (e.status === 403) {
- logger_1.logger.info();
- logger_1.logger.info();
- (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");
- logger_1.logger.info();
- (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:`);
- logger_1.logger.info((0, colorette_1.bold)((0, colorette_1.underline)(`https://github.com/settings/connections/applications/${api_1.githubClientId}`)));
- logger_1.logger.info();
- }
- return false;
- }
- return true;
- },
- },
- ]);
- return { repo, key, keyId };
- }
- async function promptForBuildScript() {
- const { shouldSetupScript } = await (0, prompt_1.prompt)({}, [
- {
- type: "confirm",
- name: "shouldSetupScript",
- default: false,
- message: "Set up the workflow to run a build script before every deploy?",
- },
- ]);
- if (!shouldSetupScript) {
- return { script: undefined };
- }
- const { script } = await (0, prompt_1.prompt)({}, [
- {
- type: "input",
- name: "script",
- default: "npm ci && npm run build",
- message: "What script should be run before every deploy?",
- },
- ]);
- return { script };
- }
- async function promptToSetupDeploys(defaultBranch) {
- const { setupDeploys } = await (0, prompt_1.prompt)({}, [
- {
- type: "confirm",
- name: "setupDeploys",
- default: true,
- message: "Set up automatic deployment to your site's live channel when a PR is merged?",
- },
- ]);
- if (!setupDeploys) {
- return { setupDeploys };
- }
- const { branch } = await (0, prompt_1.prompt)({}, [
- {
- type: "input",
- name: "branch",
- default: defaultBranch,
- message: "What is the name of the GitHub branch associated with your site's live channel?",
- },
- ]);
- return { branch, setupDeploys };
- }
- async function promptForWriteYMLFile({ message }) {
- return await (0, prompt_1.prompt)({}, [
- {
- type: "confirm",
- name: "overwrite",
- default: false,
- message,
- },
- ]);
- }
- async function getGitHubUserDetails(ghAccessToken) {
- const { body: ghUserDetails } = await githubApiClient.get("/user", {
- headers: { Authorization: `token ${ghAccessToken}`, "User-Agent": "Firebase CLI" },
- });
- return ghUserDetails;
- }
- async function getRepoDetails(repo, ghAccessToken) {
- const { body } = await githubApiClient.get(`/repos/${repo}`, {
- headers: { Authorization: `token ${ghAccessToken}`, "User-Agent": "Firebase CLI" },
- });
- return body;
- }
- async function signInWithGitHub() {
- return await (0, auth_1.loginGithub)();
- }
- async function createServiceAccountAndKeyWithRetry(options, repo, accountId) {
- const spinnerServiceAccount = ora("Retrieving a service account.");
- spinnerServiceAccount.start();
- try {
- const serviceAccountJSON = await createServiceAccountAndKey(options, repo, accountId);
- spinnerServiceAccount.stop();
- return serviceAccountJSON;
- }
- catch (e) {
- spinnerServiceAccount.stop();
- if (!e.message.includes("429")) {
- throw e;
- }
- spinnerServiceAccount.start();
- await (0, iam_1.deleteServiceAccount)(options.projectId, `${accountId}@${options.projectId}.iam.gserviceaccount.com`);
- const serviceAccountJSON = await createServiceAccountAndKey(options, repo, accountId);
- spinnerServiceAccount.stop();
- return serviceAccountJSON;
- }
- }
- async function createServiceAccountAndKey(options, repo, accountId) {
- try {
- 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})`);
- }
- catch (e) {
- if (!e.message.includes("409")) {
- throw e;
- }
- }
- const requiredRoles = [
- resourceManager_1.firebaseRoles.authAdmin,
- resourceManager_1.firebaseRoles.apiKeysViewer,
- resourceManager_1.firebaseRoles.hostingAdmin,
- resourceManager_1.firebaseRoles.runViewer,
- ];
- await (0, resourceManager_1.addServiceAccountToRoles)(options.projectId, accountId, requiredRoles);
- const serviceAccountKey = await (0, iam_1.createServiceAccountKey)(options.projectId, accountId);
- const buf = Buffer.from(serviceAccountKey.privateKeyData, "base64");
- const serviceAccountJSON = buf.toString();
- return serviceAccountJSON;
- }
- async function encryptServiceAccountJSON(serviceAccountJSON, key) {
- const messageBytes = Buffer.from(serviceAccountJSON);
- const keyBytes = Buffer.from(key, "base64");
- await libsodium.ready;
- const encryptedBytes = libsodium.crypto_box_seal(messageBytes, keyBytes);
- return Buffer.from(encryptedBytes).toString("base64");
- }
|