Skip to content
Snippets Groups Projects
  • Ryan Laurie's avatar
    f8a5a30d
    Separate Releasing from "Latest" release determination (#47868) · f8a5a30d
    Ryan Laurie authored
    * Support manual release channels
    
    s3 tag
    
    fix check
    
    generate matrix
    
    generate matrix
    
    add git tagging
    
    channel observability
    
    version info update
    
    handle some edge cases
    
    * remove testing code
    
    * update unit tests
    
    * support rollout percentage for  latest channel
    
    * update rollout onlyh
    
    * update unit tests
    
    * address review comments
    Separate Releasing from "Latest" release determination (#47868)
    Ryan Laurie authored
    * Support manual release channels
    
    s3 tag
    
    fix check
    
    generate matrix
    
    generate matrix
    
    add git tagging
    
    channel observability
    
    version info update
    
    handle some edge cases
    
    * remove testing code
    
    * update unit tests
    
    * support rollout percentage for  latest channel
    
    * update rollout onlyh
    
    * update unit tests
    
    * address review comments
Code owners
Assign users and groups as approvers for specific file changes. Learn more.
release-offline.ts 11.38 KiB
/**
 * 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();

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";

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: '' };
  }

  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;
    }

    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();
  }
})();