Skip to content
Snippets Groups Projects
release-offline.ts 11.4 KiB
Newer Older
  • Learn to ignore specific revisions
  • Ryan Laurie's avatar
    Ryan Laurie committed
    /**
     * Note: This is a backup and failsafe if you cannot use the release github actions in CI for some reason.
     * The environment consistency and observability of the release process is very important, so
     * you should only use this script if absolutely necessary, and you cannot use CI for some very
     * good reason.
     */
    
    import "dotenv/config";
    import { Octokit } from "@octokit/rest";
    import "zx/globals";
    import { $ } from "zx";
    $.verbose = false;
    
    import {
      isValidVersionString,
      hasBeenReleased,
      isValidCommitHash,
      isEnterpriseVersion,
      getMajorVersion,
      getVersionInfo,
      publishRelease,
      closeMilestone,
      openNextMilestones,
      versionRequirements,
    } from "./src";
    
    const {
      GITHUB_TOKEN,
      GITHUB_OWNER,
      GITHUB_REPO,
      AWS_S3_DOWNLOADS_BUCKET,
      AWS_S3_STATIC_BUCKET,
      AWS_CLOUDFRONT_DOWNLOADS_ID,
      AWS_CLOUDFRONT_STATIC_ID,
      AWS_SECRET_ACCESS_KEY,
      AWS_ACCESS_KEY_ID,
      DOCKERHUB_OWNER,
      DOCKERHUB_RELEASE_USERNAME,
      DOCKERHUB_RELEASE_TOKEN,
    } = process.env;
    
    const github = new Octokit({ auth: GITHUB_TOKEN });
    
    const JAR_PATH = "../target/uberjar";
    
    const version = process.argv?.[2]?.trim();
    const commitHash = process.argv?.[3]?.trim();
    const step = process.argv?.[4]?.trim().replace("--", "");
    
    const isWithoutGithub = process.argv?.[5]?.trim() === "--without-github";
    let latestFlag: string | boolean | null = process.argv?.[6]?.trim();
    
    Ryan Laurie's avatar
    Ryan Laurie committed
    
    const log = (message, color = "blue") =>
      // eslint-disable-next-line no-console
      console.log(chalk[color](`\n${message}\n`));
    
    function error(message) {
      log(`⚠️   ${message}`, "red");
      process.exit(1);
    }
    
    
    // need to force the user to set the latest tag from the command line if they are releasing without github
    if (isWithoutGithub) {
      if (!latestFlag) {
        error('If you are releasing without github you must pass --latest or --not-latest as the last argument');
      }
    
      latestFlag = (latestFlag === "--latest") ? true : false;
    }
    
    const edition = isEnterpriseVersion(version) ? "ee" : "oss";
    
    
    Ryan Laurie's avatar
    Ryan Laurie committed
    if (!isValidVersionString(version)) {
      error(
        "You must provide a valid version string as the first argument (e.g v0.45.6)",
      );
    }
    
    if (!isValidCommitHash(commitHash)) {
      error("You must provide a valid commit hash as the second argument");
    }
    
    if (!step) {
      error("You must provide a step argument like --build or --publish");
    }
    
    /**************************************************
              HELPERS
     **************************************************/
    
    // mostly for type checking
    function getGithubCredentials() {
    
      if (isWithoutGithub) {
        return { GITHUB_TOKEN: '', GITHUB_OWNER: '', GITHUB_REPO: '' };
      }
    
    
    Ryan Laurie's avatar
    Ryan Laurie committed
      if (!GITHUB_TOKEN || !GITHUB_OWNER || !GITHUB_REPO) {
        error("You must provide all github environment variables in .env-template");
        process.exit(1);
      }
    
      return { GITHUB_TOKEN, GITHUB_OWNER, GITHUB_REPO };
    }
    
    function getAWSCredentials() {
      if (
        !AWS_S3_DOWNLOADS_BUCKET ||
        !AWS_S3_STATIC_BUCKET ||
        !AWS_CLOUDFRONT_STATIC_ID ||
        !AWS_CLOUDFRONT_DOWNLOADS_ID ||
        !AWS_SECRET_ACCESS_KEY ||
        !AWS_ACCESS_KEY_ID
      ) {
        error("You must provide all AWS environment variables in .env-template");
        process.exit(1);
      }
    
      return {
        AWS_S3_DOWNLOADS_BUCKET,
        AWS_S3_STATIC_BUCKET,
        AWS_CLOUDFRONT_DOWNLOADS_ID,
        AWS_CLOUDFRONT_STATIC_ID,
      };
    }
    
    function getDockerCredentials() {
      if (
        !DOCKERHUB_RELEASE_USERNAME ||
        !DOCKERHUB_RELEASE_TOKEN ||
        !DOCKERHUB_OWNER
      ) {
        error("You must provide all docker environment variables in .env-template");
        process.exit(1);
      }
    
      return {
        DOCKERHUB_RELEASE_USERNAME,
        DOCKERHUB_RELEASE_TOKEN,
        DOCKERHUB_OWNER,
      };
    }
    
    async function checkReleased() {
      const { GITHUB_OWNER, GITHUB_REPO } = getGithubCredentials();
    
      const hasThisVersionBeenReleased = await hasBeenReleased({
        github,
        owner: GITHUB_OWNER,
        repo: GITHUB_REPO,
        version,
      });
    
      if (hasThisVersionBeenReleased) {
        error(`Version ${version} has already been released`);
      }
    }
    
    async function checkJar() {
      // check if built jar exists
      const requiredFiles = [
        `${JAR_PATH}/metabase.jar`,
        `${JAR_PATH}/COMMIT-ID`,
        `${JAR_PATH}/SHA256.sum`,
      ];
    
      requiredFiles.forEach(file => {
        if (!fs.existsSync(file)) {
          error(`You must build the jar first. ${file} does not exist`);
        }
      });
    
      const versionProperties = (
        await $`jar xf ${JAR_PATH}/metabase.jar version.properties && cat version.properties`
      ).toString();
    
      log(versionProperties, "green");
    
      if (!versionProperties.includes(`tag=${version}`)) {
        error(`This jar does not match the input version: ${version}`);
      }
    }
    
    /**************************************************
              BUILD STEP
     **************************************************/
    async function build() {
      log(`🚀 Building ${edition} jar for ${version} from commit ${commitHash}`);
    
      const unstagedChanges = (await $`git status --porcelain`).toString().trim();
    
      if (unstagedChanges) {
        error(
          `You have unstaged changes:\n\n ${unstagedChanges}\n\nPlease commit or stash them and try again`,
        );
      }
    
      // check build environment
      const majorVersion = Number(getMajorVersion(version));
      const nodeVersion = (await $`node --version`).toString();
      const javaVersion = (await $`java -version`).toString();
    
      if (!nodeVersion.includes(`v${versionRequirements[majorVersion].node}`)) {
        error(
          `Node version must be v${versionRequirements[majorVersion].node}, you are running ${nodeVersion}`,
        );
      }
    
      if (
        !javaVersion.includes(
          `openjdk version "${versionRequirements[majorVersion].java}`,
        )
      ) {
        error(
          `Java version must be ${versionRequirements[majorVersion].java}, you are running ${javaVersion}`,
        );
      }
    
      const currentBranch = (await $`git branch --show-current`).toString().trim();
    
      try {
        await $`git fetch --all`;
        await $`git stash && git checkout ${commitHash}`;
    
        // actually build jar
        await $`../bin/build.sh :edition :${edition} :version ${version}`.pipe(
          process.stdout,
        );
    
        await $`git checkout -`;
        await $`echo ${commitHash} > ${JAR_PATH}/COMMIT-ID`;
        await $`shasum -a 256 ${JAR_PATH}/metabase.jar > ${JAR_PATH}/SHA256.sum`;
    
        log(`✅ Built ${edition} jar for ${version} in ${JAR_PATH}`, "green");
      } catch (error) {
        console.error(error);
      } finally {
        await $`git checkout ${currentBranch}`;
      }
    }
    
    /**************************************************
              PUBLISH STEPS
    ***************************************************/
    
    async function s3() {
      log(`⏳ Publishing ${version} to s3`);
    
      const { GITHUB_OWNER, GITHUB_REPO } = getGithubCredentials();
      const { AWS_S3_DOWNLOADS_BUCKET } = getAWSCredentials();
    
      await checkJar();
    
      const versionPath = edition === "ee" ? `enterprise/${version}` : version;
    
      await $`aws s3 cp ${JAR_PATH}/metabase.jar s3://${AWS_S3_DOWNLOADS_BUCKET}/${versionPath}/metabase.jar`.pipe(
        process.stdout,
      );
    
      await $`aws cloudfront create-invalidation \
        --distribution-id ${AWS_CLOUDFRONT_DOWNLOADS_ID} \
        --paths /${versionPath}/metabase.jar`.pipe(process.stdout);
    
      log(`✅ Published ${version} to s3`);
    }
    
    async function docker() {
      log(`⏳ Building docker image for ${version}`);
    
      const { GITHUB_OWNER, GITHUB_REPO } = getGithubCredentials();
      const {
        DOCKERHUB_RELEASE_USERNAME,
        DOCKERHUB_RELEASE_TOKEN,
        DOCKERHUB_OWNER,
      } = getDockerCredentials();
    
      await checkJar();
    
      await $`cp -r ${JAR_PATH}/metabase.jar ../bin/docker/`;
      const dockerRepo = edition === "ee" ? "metabase-enterprise" : "metabase";
      const dockerTag = `${DOCKERHUB_OWNER}/${dockerRepo}:${version}`;
      await $`docker build --tag ${dockerTag} ../bin/docker/.`.pipe(process.stdout);
    
      log(`⏳ Pushing docker image to dockerhub for ${version}`);
    
      await $`docker login --username ${DOCKERHUB_RELEASE_USERNAME} -p ${DOCKERHUB_RELEASE_TOKEN}`;
      await $`docker push ${dockerTag}`.pipe(process.stdout);
    
      log(`✅ Published ${dockerTag} to DockerHub`);
    }
    
    async function versionInfo() {
      log(`⏳ Building version-info.json`);
    
      const { GITHUB_OWNER, GITHUB_REPO } = getGithubCredentials();
      const { AWS_S3_STATIC_BUCKET, AWS_CLOUDFRONT_STATIC_ID } =
        getAWSCredentials();
    
      const newVersionInfo = await getVersionInfo({
        github,
        owner: GITHUB_OWNER,
        repo: GITHUB_REPO,
        version,
      });
    
      const versionInfoName =
        edition === "ee" ? "version-info-ee.json" : "version-info.json";
    
      fs.writeFileSync(versionInfoName, JSON.stringify(newVersionInfo, null, 2));
    
      await $`aws s3 cp ${versionInfoName} s3://${AWS_S3_STATIC_BUCKET}/${versionInfoName}`.pipe(
        process.stdout,
      );
    
      await $`aws cloudfront create-invalidation \
        --distribution-id ${AWS_CLOUDFRONT_STATIC_ID} \
        --paths /${versionInfoName}`.pipe(process.stdout);
    
      log(`✅ Published ${versionInfoName} to s3`);
    }
    
    async function tag() {
      // tag commit
      await $`git tag ${version} ${commitHash}`;
      await $`git push origin ${version}`.pipe(process.stdout);
    
      log(`✅ Tagged ${version}`);
    }
    
    async function releaseNotes() {
      const { GITHUB_OWNER, GITHUB_REPO } = getGithubCredentials();
    
      log(`⏳ Building Release Notes for ${version}`);
    
      const checksum = (await $`shasum -a 256 ${JAR_PATH}/metabase.jar`)
        .toString()
        .split(" ")[0];
    
      await publishRelease({
        github,
        owner: GITHUB_OWNER,
        repo: GITHUB_REPO,
        version,
        checksum,
      });
    
      log(`✅ Published release notes for ${version} to github\n`);
    }
    
    async function updateMilestones() {
      const { GITHUB_OWNER, GITHUB_REPO } = getGithubCredentials();
    
      await closeMilestone({
        github,
        owner: GITHUB_OWNER,
        repo: GITHUB_REPO,
        version,
      });
    
      await openNextMilestones({
        github,
        owner: GITHUB_OWNER,
        repo: GITHUB_REPO,
        version,
      });
    }
    
    /**************************************************
              MAIN CONTROL FUNCTION
     **************************************************/
    
    (async () => {
      if (step === "build") {
        await checkReleased();
        await build();
      }
    
      if (step === "publish") {
        log(`🚀 Publishing ${edition} ${version} 🚀`);
    
    
        if ( isWithoutGithub ) {
          log(`⚠️   Skipping github steps because --without-github was passed   ⚠️`, "yellow");
    
          await s3();
          await docker();
    
          const remainingSteps = [
            "release notes were not published",
            "version-info.json was not updated",
            "no commit was tagged",
            "no milestones were updated",
          ].join("\n");
    
          log(`Because you released without github the following steps were not completed:\n ❌  ${remainingSteps}`);
    
          return;
        }
    
    
    Ryan Laurie's avatar
    Ryan Laurie committed
        await checkReleased();
        await s3();
        await docker();
        await versionInfo();
        await tag();
        await releaseNotes();
        await updateMilestones();
    
        log(`✅ Published ${edition} ${version}`);
    
        const remainingSteps = [
          "Publish the release notes",
          "Reorder commits to make sure OSS is first",
          "Submit a pull request to build the docker image for cloud",
          "Create an issue to update cloud instances",
          "Update documentation on the website to reflect the newly released versions",
        ].join("\n ✔️  ");
    
        log(`Don't forget, you still need to:\n ✔️  ${remainingSteps}`);
      }
    
      if (step === "check-jar") {
        await checkJar();
      }
    
      if (step === "s3") {
        await s3();
      }
    
      if (step === "docker") {
        await docker();
      }
    
      if (step === "version-info") {
        await versionInfo();
      }
    
      if (step === "tag") {
        await tag();
      }
    
      if (step === "release-notes") {
        await releaseNotes();
      }
    
      if (step === "update-milestones") {
        await updateMilestones();
      }
    })();