The `deploy-to-firebase.js` tests rely on git info retrieved from the `angular/angular` repository (via `git ls-remote ...`). Previously, different calls to `git ls-remote ...` could return different values if a new commit was pushed or a new branch was created during test execution, resulting in errors ([example CI failure][1]). This commit makes the tests more stable by memoizing the result of `git ls-remote ...` and returning the same result for subsequent calls with the same arguments (even if meanwhile the remote has been updated). [1]: https://circleci.com/gh/angular/angular/877626 PR Close #40088
		
			
				
	
	
		
			376 lines
		
	
	
		
			14 KiB
		
	
	
	
		
			JavaScript
		
	
	
	
	
	
			
		
		
	
	
			376 lines
		
	
	
		
			14 KiB
		
	
	
	
		
			JavaScript
		
	
	
	
	
	
| #!/bin/env node
 | |
| //
 | |
| // WARNING: `CI_SECRET_AIO_DEPLOY_FIREBASE_TOKEN` should NOT be printed.
 | |
| //
 | |
| 'use strict';
 | |
| 
 | |
| const {cd, cp, exec, mv, sed, set} = require('shelljs');
 | |
| 
 | |
| set('-e');
 | |
| 
 | |
| 
 | |
| // Constants
 | |
| const REPO_SLUG = 'angular/angular';
 | |
| const NG_REMOTE_URL = `https://github.com/${REPO_SLUG}.git`;
 | |
| const GIT_REMOTE_REFS_CACHE = new Map();
 | |
| 
 | |
| // Exports
 | |
| module.exports = {
 | |
|   computeDeploymentsInfo,
 | |
|   computeInputVars,
 | |
|   computeMajorVersion,
 | |
|   getLatestCommit,
 | |
|   getMostRecentMinorBranch,
 | |
| };
 | |
| 
 | |
| // Run
 | |
| if (require.main === module) {
 | |
|   const isDryRun = process.argv[2] === '--dry-run';
 | |
|   const inputVars = computeInputVars(process.env);
 | |
|   const deploymentsInfo = computeDeploymentsInfo(inputVars);
 | |
|   const totalDeployments = deploymentsInfo.length;
 | |
| 
 | |
|   console.log(`Total deployments: ${totalDeployments}`);
 | |
| 
 | |
|   deploymentsInfo.forEach((deploymentInfo, idx) => {
 | |
|     console.log(
 | |
|         `\n\n\nDeployment ${idx + 1} of ${totalDeployments}\n` +
 | |
|         '-----------------');
 | |
| 
 | |
|     if (deploymentInfo.skipped) {
 | |
|       console.log(deploymentInfo.reason);
 | |
|     } else {
 | |
|       console.log(
 | |
|           `Git branch          : ${inputVars.currentBranch}\n` +
 | |
|           `Git commit          : ${inputVars.currentCommit}\n` +
 | |
|           `Build/deploy mode   : ${deploymentInfo.deployEnv}\n` +
 | |
|           `Firebase project    : ${deploymentInfo.projectId}\n` +
 | |
|           `Firebase site       : ${deploymentInfo.siteId}\n` +
 | |
|           `Pre-deploy actions  : ${serializeActions(deploymentInfo.preDeployActions)}\n` +
 | |
|           `Post-deploy actions : ${serializeActions(deploymentInfo.postDeployActions)}\n` +
 | |
|           `Deployment URLs     : ${deploymentInfo.deployedUrl}\n` +
 | |
|           `                      https://${deploymentInfo.siteId}.web.app/`);
 | |
| 
 | |
|       if (!isDryRun) {
 | |
|         deploy({...inputVars, ...deploymentInfo});
 | |
|       }
 | |
|     }
 | |
|   });
 | |
| }
 | |
| 
 | |
| // Helpers
 | |
| function build({deployedUrl, deployEnv}) {
 | |
|   console.log('\n\n\n==== Build the AIO app. ====\n');
 | |
|   yarn(`build --configuration=${deployEnv} --progress=false`);
 | |
| 
 | |
|   console.log('\n\n\n==== Add any mode-specific files into the AIO distribution. ====\n');
 | |
|   cp('-rf', `src/extra-files/${deployEnv}/.`, 'dist/');
 | |
| 
 | |
|   console.log('\n\n\n==== Update opensearch descriptor for AIO with `deployedUrl`. ====\n');
 | |
|   yarn(`set-opensearch-url ${deployedUrl.replace(/[^/]$/, '$&/')}`);  // The URL must end with `/`.
 | |
| }
 | |
| 
 | |
| function checkPayloadSize() {
 | |
|   console.log('\n\n\n==== Check payload size and upload the numbers to Firebase DB. ====\n');
 | |
|   yarn('payload-size');
 | |
| }
 | |
| 
 | |
| function computeDeploymentsInfo(
 | |
|     {currentBranch, currentCommit, isPullRequest, repoName, repoOwner, stableBranch}) {
 | |
|   // Do not deploy if we are running in a fork.
 | |
|   if (`${repoOwner}/${repoName}` !== REPO_SLUG) {
 | |
|     return [skipDeployment(`Skipping deploy because this is not ${REPO_SLUG}.`)];
 | |
|   }
 | |
| 
 | |
|   // Do not deploy if this is a PR. PRs are deployed in the `aio_preview` CircleCI job.
 | |
|   if (isPullRequest) {
 | |
|     return [skipDeployment('Skipping deploy because this is a PR build.')];
 | |
|   }
 | |
| 
 | |
|   // Do not deploy if the current commit is not the latest on its branch.
 | |
|   const latestCommit = getLatestCommit(currentBranch);
 | |
|   if (currentCommit !== latestCommit) {
 | |
|     return [
 | |
|       skipDeployment(
 | |
|           `Skipping deploy because ${currentCommit} is not the latest commit (${latestCommit}).`),
 | |
|     ];
 | |
|   }
 | |
| 
 | |
|   // The deployment mode is computed based on the branch we are building.
 | |
|   const currentBranchMajorVersion = computeMajorVersion(currentBranch);
 | |
|   const deploymentInfoPerTarget = {
 | |
|     next: {
 | |
|       deployEnv: 'next',
 | |
|       projectId: 'angular-io',
 | |
|       siteId: 'next-angular-io-site',
 | |
|       deployedUrl: 'https://next.angular.io/',
 | |
|       preDeployActions: [build, checkPayloadSize],
 | |
|       postDeployActions: [testPwaScore],
 | |
|     },
 | |
|     rc: {
 | |
|       deployEnv: 'rc',
 | |
|       projectId: 'angular-io',
 | |
|       siteId: 'rc-angular-io-site',
 | |
|       deployedUrl: 'https://rc.angular.io/',
 | |
|       preDeployActions: [build, checkPayloadSize],
 | |
|       postDeployActions: [testPwaScore],
 | |
|     },
 | |
|     stable: {
 | |
|       deployEnv: 'stable',
 | |
|       projectId: 'angular-io',
 | |
|       siteId: `v${currentBranchMajorVersion}-angular-io-site`,
 | |
|       deployedUrl: 'https://angular.io/',
 | |
|       preDeployActions: [build, checkPayloadSize],
 | |
|       postDeployActions: [testPwaScore],
 | |
|     },
 | |
|     // Config for deploying the stable build to the RC Firebase site when there is no active RC.
 | |
|     // See https://github.com/angular/angular/issues/39760 for more info on the purpose of this
 | |
|     // special deployment.
 | |
|     noActiveRc: {
 | |
|       deployEnv: 'stable',
 | |
|       projectId: 'angular-io',
 | |
|       siteId: 'rc-angular-io-site',
 | |
|       deployedUrl: 'https://rc.angular.io/',
 | |
|       preDeployActions: [removeServiceWorker, redirectToAngularIo],
 | |
|       postDeployActions: [testNoActiveRcDeployment],
 | |
|     },
 | |
|     archive: {
 | |
|       deployEnv: 'archive',
 | |
|       projectId: 'angular-io',
 | |
|       siteId: `v${currentBranchMajorVersion}-angular-io-site`,
 | |
|       deployedUrl: `https://v${currentBranchMajorVersion}.angular.io/`,
 | |
|       preDeployActions: [build, checkPayloadSize],
 | |
|       postDeployActions: [testPwaScore],
 | |
|     },
 | |
|   };
 | |
| 
 | |
|   // If the current branch is `master`, deploy as `next`.
 | |
|   if (currentBranch === 'master') {
 | |
|     return [deploymentInfoPerTarget.next];
 | |
|   }
 | |
| 
 | |
|   // Determine if there is an active RC version by checking whether the most recent minor branch is
 | |
|   // the stable branch or not.
 | |
|   const mostRecentMinorBranch = getMostRecentMinorBranch();
 | |
|   const rcBranch = (mostRecentMinorBranch !== stableBranch) ? mostRecentMinorBranch : null;
 | |
| 
 | |
|   // If the current branch is the RC branch, deploy as `rc`.
 | |
|   if (currentBranch === rcBranch) {
 | |
|     return [deploymentInfoPerTarget.rc];
 | |
|   }
 | |
| 
 | |
|   // If the current branch is the stable branch, deploy as `stable`.
 | |
|   if (currentBranch === stableBranch) {
 | |
|     return (rcBranch !== null) ?
 | |
|       // There is an active RC version. Only deploy to the `stable` project/site.
 | |
|       [deploymentInfoPerTarget.stable] :
 | |
|       // There is no active RC version. In addition to deploying to the `stable` project/site,
 | |
|       // deploy to `rc` to ensure it redirects to `stable`.
 | |
|       // See https://github.com/angular/angular/issues/39760 for more info on the purpose of this
 | |
|       // special deployment.
 | |
|       [
 | |
|         deploymentInfoPerTarget.stable,
 | |
|         deploymentInfoPerTarget.noActiveRc,
 | |
|       ];
 | |
|   }
 | |
| 
 | |
|   // If we get here, it means that the current branch is neither `master`, nor the RC or stable
 | |
|   // branches. At this point, we may only deploy as `archive` and only if the following criteria are
 | |
|   // met:
 | |
|   //   1. The current branch must have the highest minor version among all branches with the same
 | |
|   //      major version.
 | |
|   //   2. The current branch must have a major version that is lower than the stable major version.
 | |
| 
 | |
|   // Do not deploy if it is not the branch with the highest minor for the given major version.
 | |
|   const mostRecentMinorBranchForMajor = getMostRecentMinorBranch(currentBranchMajorVersion);
 | |
|   if (currentBranch !== mostRecentMinorBranchForMajor) {
 | |
|     return [
 | |
|       skipDeployment(
 | |
|           `Skipping deploy of branch "${currentBranch}" to Firebase.\n` +
 | |
|           'There is a more recent branch with the same major version: ' +
 | |
|           `"${mostRecentMinorBranchForMajor}"`),
 | |
|     ];
 | |
|   }
 | |
| 
 | |
|   // Do not deploy if it does not have a lower major version than stable.
 | |
|   const stableBranchMajorVersion = computeMajorVersion(stableBranch);
 | |
|   if (currentBranchMajorVersion >= stableBranchMajorVersion) {
 | |
|     return [
 | |
|       skipDeployment(
 | |
|           `Skipping deploy of branch "${currentBranch}" to Firebase.\n` +
 | |
|           'This branch has an equal or higher major version than the stable branch ' +
 | |
|           `("${stableBranch}") and is not the most recent minor branch.`),
 | |
|     ];
 | |
|   }
 | |
| 
 | |
|   // This is the highest minor version for a major that is lower than the stable major version:
 | |
|   // Deploy as `archive`.
 | |
|   return [deploymentInfoPerTarget.archive];
 | |
| }
 | |
| 
 | |
| function computeInputVars({
 | |
|   CI_AIO_MIN_PWA_SCORE: minPwaScore,
 | |
|   CI_BRANCH: currentBranch,
 | |
|   CI_COMMIT: currentCommit,
 | |
|   CI_PULL_REQUEST,
 | |
|   CI_REPO_NAME: repoName,
 | |
|   CI_REPO_OWNER: repoOwner,
 | |
|   CI_SECRET_AIO_DEPLOY_FIREBASE_TOKEN: firebaseToken,
 | |
|   CI_STABLE_BRANCH: stableBranch,
 | |
| }) {
 | |
|   return {
 | |
|     currentBranch,
 | |
|     currentCommit,
 | |
|     firebaseToken,
 | |
|     isPullRequest: CI_PULL_REQUEST !== 'false',
 | |
|     minPwaScore,
 | |
|     repoName,
 | |
|     repoOwner,
 | |
|     stableBranch,
 | |
|   };
 | |
| }
 | |
| 
 | |
| function computeMajorVersion(branchName) {
 | |
|   return +branchName.split('.', 1)[0];
 | |
| }
 | |
| 
 | |
| function deploy(data) {
 | |
|   const {
 | |
|     currentCommit,
 | |
|     firebaseToken,
 | |
|     postDeployActions,
 | |
|     preDeployActions,
 | |
|     projectId,
 | |
|     siteId,
 | |
|   } = data;
 | |
| 
 | |
|   cd(`${__dirname}/..`);
 | |
| 
 | |
|   console.log('\n\n\n==== Run pre-deploy actions. ====\n');
 | |
|   preDeployActions.forEach(fn => fn(data));
 | |
| 
 | |
|   console.log('\n\n\n==== Deploy AIO to Firebase hosting. ====\n');
 | |
|   const firebase = cmd => yarn(`firebase ${cmd} --token "${firebaseToken}"`);
 | |
|   firebase(`use "${projectId}"`);
 | |
|   firebase('target:clear hosting aio');
 | |
|   firebase(`target:apply hosting aio "${siteId}"`);
 | |
|   firebase(`deploy --only hosting:aio --message "Commit: ${currentCommit}" --non-interactive`);
 | |
| 
 | |
|   console.log('\n\n\n==== Run post-deploy actions. ====\n');
 | |
|   postDeployActions.forEach(fn => fn(data));
 | |
| }
 | |
| 
 | |
| function getRemoteRefs(refOrPattern, {remote = NG_REMOTE_URL, retrieveFromCache = true} = {}) {
 | |
|   // If remote refs for the same `refOrPattern` and `remote` have been requested before, return the
 | |
|   // cached results. This improves the performance and ensures a more stable behavior.
 | |
|   //
 | |
|   // NOTE:
 | |
|   // This shouldn't make any difference during normal execution (since there are no duplicate
 | |
|   // requests atm), but makes the tests more stable (for example, avoiding errors caused by pushing
 | |
|   // a new commit on a branch while the tests execute, which would cause `getLatestCommit()` to
 | |
|   // return a different value).
 | |
|   const cmd = `git ls-remote ${remote} ${refOrPattern}`;
 | |
|   const result = (retrieveFromCache && GIT_REMOTE_REFS_CACHE.has(cmd))
 | |
|       ? GIT_REMOTE_REFS_CACHE.get(cmd)
 | |
|       : exec(cmd, {silent: true}).trim().split('\n');
 | |
| 
 | |
|   // Cache the result for future use (regardless of the value of `retrieveFromCache`).
 | |
|   GIT_REMOTE_REFS_CACHE.set(cmd, result);
 | |
| 
 | |
|   return result;
 | |
| }
 | |
| 
 | |
| function getMostRecentMinorBranch(major = '*', options = undefined) {
 | |
|   // List the branches that start with the given major version (or any major if none given).
 | |
|   return getRemoteRefs(`refs/heads/${major}.*.x`, options)
 | |
|       // Extract the branch name.
 | |
|       .map(line => line.split('/')[2])
 | |
|       // Filter out branches that are not of the format `<number>.<number>.x`.
 | |
|       .filter(name => /^\d+\.\d+\.x$/.test(name))
 | |
|       // Sort by version.
 | |
|       .sort((a, b) => {
 | |
|         const [majorA, minorA] = a.split('.');
 | |
|         const [majorB, minorB] = b.split('.');
 | |
|         return (majorA - majorB) || (minorA - minorB);
 | |
|       })
 | |
|       // Get the branch corresponding to the highest version.
 | |
|       .pop();
 | |
| }
 | |
| 
 | |
| function getLatestCommit(branchName, options = undefined) {
 | |
|   return getRemoteRefs(branchName, options)[0].slice(0, 40);
 | |
| }
 | |
| 
 | |
| function redirectToAngularIo() {
 | |
|   // Update the Firebase hosting configuration redirect all non-file requests (i.e. requests that do
 | |
|   // not contain a dot in their last path segment) to `angular.io`.
 | |
|   // See https://firebase.google.com/docs/hosting/full-config#redirects.
 | |
|   const redirectRule =
 | |
|       '{"type": 302, "regex": "^(.*/[^./]*)$", "destination": "https://angular.io:1"}';
 | |
|   sed('-i', /(\s*)"redirects": \[/, `$&\n$1  ${redirectRule},\n`, 'firebase.json');
 | |
| }
 | |
| 
 | |
| function removeServiceWorker() {
 | |
|   // Rename the SW manifest (`ngsw.json`). This will cause the ServiceWorker to unregister itself.
 | |
|   // See https://angular.io/guide/service-worker-devops#fail-safe.
 | |
|   mv('dist/ngsw.json', 'dist/ngsw.json.bak');
 | |
| }
 | |
| 
 | |
| function serializeActions(actions) {
 | |
|   return actions.map(fn => fn.name).join(', ');
 | |
| }
 | |
| 
 | |
| function skipDeployment(reason) {
 | |
|   return {reason, skipped: true};
 | |
| }
 | |
| 
 | |
| function testNoActiveRcDeployment({deployedUrl}) {
 | |
|   const deployedOrigin = deployedUrl.replace(/\/$/, '');
 | |
| 
 | |
|   // Ensure a request for `ngsw.json` returns 404.
 | |
|   const ngswJsonUrl = `${deployedOrigin}/ngsw.json`;
 | |
|   const ngswJsonScript = `https.get('${ngswJsonUrl}', res => console.log(res.statusCode))`;
 | |
|   const ngswJsonActualStatusCode = exec(`node --eval "${ngswJsonScript}"`, {silent: true}).trim();
 | |
|   const ngswJsonExpectedStatusCode = '404';
 | |
| 
 | |
|   if (ngswJsonActualStatusCode !== ngswJsonExpectedStatusCode) {
 | |
|     throw new Error(
 | |
|         `Expected '${ngswJsonUrl}' to return a status code of '${ngswJsonExpectedStatusCode}', ` +
 | |
|         `but it returned '${ngswJsonActualStatusCode}'.`);
 | |
|   }
 | |
| 
 | |
|   // Ensure a request for `foo/bar` is redirected to `https://angular.io/foo/bar`.
 | |
|   const fooBarUrl = `${deployedOrigin}/foo/bar?baz=qux`;
 | |
|   const fooBarScript =
 | |
|       `https.get('${fooBarUrl}', res => console.log(res.statusCode, res.headers.location))`;
 | |
|   const [fooBarActualStatusCode, fooBarActualRedirectUrl] =
 | |
|       exec(`node --eval "${fooBarScript}"`, {silent: true}).trim().split(' ');
 | |
|   const fooBarExpectedStatusCode = '302';
 | |
|   const fooBarExpectedRedirectUrl = 'https://angular.io/foo/bar?baz=qux';
 | |
| 
 | |
|   if (fooBarActualStatusCode !== fooBarExpectedStatusCode) {
 | |
|     throw new Error(
 | |
|         `Expected '${fooBarUrl}' to return a status code of '${fooBarExpectedStatusCode}', but ` +
 | |
|         `it returned '${fooBarActualStatusCode}'.`);
 | |
|   } else if (fooBarActualRedirectUrl !== fooBarExpectedRedirectUrl) {
 | |
|     throw new Error(
 | |
|         `Expected '${fooBarUrl}' to be redirected to '${fooBarExpectedRedirectUrl}', but it was ` +
 | |
|         `but it was redirected to '${fooBarActualRedirectUrl}'.`);
 | |
|   }
 | |
| }
 | |
| 
 | |
| function testPwaScore({deployedUrl, minPwaScore}) {
 | |
|   console.log('\n\n\n==== Run PWA-score tests. ====\n');
 | |
|   yarn(`test-pwa-score "${deployedUrl}" "${minPwaScore}"`);
 | |
| }
 | |
| 
 | |
| function yarn(cmd) {
 | |
|   // Using `--silent` to ensure no secret env variables are printed.
 | |
|   //
 | |
|   // NOTE:
 | |
|   // This is not strictly necessary, since CircleCI will mask secret environment variables in the
 | |
|   // output (see https://circleci.com/docs/2.0/env-vars/#secrets-masking), but is an extra
 | |
|   // precaution.
 | |
|   return exec(`yarn --silent ${cmd}`);
 | |
| }
 |