From 5d4420da57ade8af8f6517c94ea68acd18b7466a Mon Sep 17 00:00:00 2001 From: Carlos Garcia Ortiz karliatto Date: Tue, 4 Jun 2024 14:02:53 +0200 Subject: [PATCH] ci(github): connect npm release with more checks --- .github/workflows/release-connect-npm.yml | 118 ++++++++++++--- .../check-packages-same-deployment-type.ts | 26 ++++ ci/scripts/connect-release-npm-init.ts | 134 ------------------ ci/scripts/determine-deployment-type.ts | 14 ++ .../get-connect-dependencies-to-release.ts | 77 ++++++++++ ci/scripts/helpers.ts | 25 ++++ 6 files changed, 244 insertions(+), 150 deletions(-) create mode 100644 ci/scripts/check-packages-same-deployment-type.ts delete mode 100644 ci/scripts/connect-release-npm-init.ts create mode 100644 ci/scripts/determine-deployment-type.ts create mode 100644 ci/scripts/get-connect-dependencies-to-release.ts diff --git a/.github/workflows/release-connect-npm.yml b/.github/workflows/release-connect-npm.yml index 036f0caed76..dd9db26d5a9 100644 --- a/.github/workflows/release-connect-npm.yml +++ b/.github/workflows/release-connect-npm.yml @@ -1,27 +1,84 @@ name: "[Release] Connect NPM" on: workflow_dispatch: - inputs: - packages: - description: 'Array string with names of the packages to deploy. (example: ["blockchain-link-utils","blockchain-link-types","analytics"])' - required: true - type: string - deploymentType: - description: "Specifies the deployment type for the npm package. (example: canary, stable)" - required: true - type: choice - options: - - canary - - stable jobs: - deploy-npm: - name: Deploy NPM ${{ inputs.deploymentType }} + extract-version: + runs-on: ubuntu-latest + outputs: + version: ${{ steps.set-version.outputs.version }} + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + # Number of commits to fetch. 0 indicates all history for all branches and tags. + fetch-depth: 0 + + - name: Setup node + uses: actions/setup-node@v4 + with: + node-version-file: ".nvmrc" + + - name: Extract connect version + id: set-version + run: echo "version=$(node ./ci/scripts/get-connect-version.js)" >> $GITHUB_OUTPUT + + sanity-check-version-match: + runs-on: ubuntu-latest + needs: [extract-version] + steps: + - uses: actions/checkout@v4 + + - name: Check connect version match + uses: ./.github/actions/check-connect-version-match + with: + branch_ref: "${{ github.ref }}" + extracted_version: "${{ needs.extract-version.outputs.version }}" + + identify-release-packages: + runs-on: ubuntu-latest + needs: [extract-version, sanity-check-version-match] + outputs: + packagesNeedRelease: ${{ steps.set-packages-need-release.outputs.packagesNeedRelease }} + deploymentType: ${{ steps.determine-deployment-type.outputs.deploymentType }} + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup node + uses: actions/setup-node@v4 + with: + node-version-file: ".nvmrc" + + - name: Install dependencies + run: yarn install + + - name: Get packages that need release + id: set-packages-need-release + run: echo "packagesNeedRelease=$(yarn tsx ./ci/scripts/get-connect-dependencies-to-release.ts)" >> $GITHUB_OUTPUT + + - name: Determine Deployment Type from version in branch + id: determine-deployment-type + outputs: + deploymentType: ${{ steps.determine-deployment-type.outputs.deploymentType }} + run: echo "deploymentType=$(yarn tsx ./ci/scripts/determine-deployment-type.ts ${{ needs.extract-version.outputs.version }})" >> $GITHUB_OUTPUT + + - name: Sanity Check - All Packages Same Deployment Type + env: + PACKAGES: ${{ steps.set-packages-need-release.outputs.packagesNeedRelease }} + DEPLOYMENT_TYPE: ${{ steps.determine-deployment-type.outputs.deploymentType }} + run: | + yarn tsx ./ci/scripts/check-packages-same-deployment-type.ts '${{ env.PACKAGES }}' "${{ env.DEPLOYMENT_TYPE }}" + + deploy-npm-connect-dependencies: + name: Deploy NPM ${{ needs.identify-release-packages.outputs.deploymentType }} ${{ matrix.package }} + needs: [extract-version, sanity-check-version-match, identify-release-packages] environment: production-connect runs-on: ubuntu-latest strategy: + fail-fast: false matrix: - package: ${{ fromJson(github.event.inputs.packages) }} + package: ${{ fromJson(needs.identify-release-packages.outputs.packagesNeedRelease) }} steps: - uses: actions/checkout@v4 with: @@ -30,12 +87,41 @@ jobs: - name: Set deployment type id: set_deployment_type run: | - if [ "${{ github.event.inputs.deploymentType }}" == "canary" ]; then + if [ "${{ needs.identify-release-packages.outputs.deploymentType }}" == "canary" ]; then echo "DEPLOYMENT_TYPE=beta" >> $GITHUB_ENV else echo "DEPLOYMENT_TYPE=latest" >> $GITHUB_ENV fi + - name: Deploy to NPM ${{ matrix.package }} + env: + NPM_TOKEN: ${{ secrets.NPM_TOKEN }} + uses: ./.github/actions/release-connect-npm + with: + deploymentType: ${{ env.DEPLOYMENT_TYPE }} + packageName: ${{ matrix.package }} + deploy-npm-connect: + name: Deploy NPM ${{ needs.identify-release-packages.outputs.deploymentType }} ${{ matrix.package }} + # We only deploy connect NPM once dependencies have been deployed successfully. + needs: [deploy-npm-connect-dependencies] + environment: production-connect + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + package: ["connect", "connect-web", "connect-webextension"] + steps: + - uses: actions/checkout@v4 + with: + ref: develop + - name: Set deployment type + id: set_deployment_type + run: | + if [ "${{ needs.identify-release-packages.outputs.deploymentType }}" == "canary" ]; then + echo "DEPLOYMENT_TYPE=beta" >> $GITHUB_ENV + else + echo "DEPLOYMENT_TYPE=latest" >> $GITHUB_ENV + fi - name: Deploy to NPM ${{ matrix.package }} env: NPM_TOKEN: ${{ secrets.NPM_TOKEN }} diff --git a/ci/scripts/check-packages-same-deployment-type.ts b/ci/scripts/check-packages-same-deployment-type.ts new file mode 100644 index 00000000000..b2e2c5845de --- /dev/null +++ b/ci/scripts/check-packages-same-deployment-type.ts @@ -0,0 +1,26 @@ +import semver from 'semver'; + +import { getLocalVersion } from './helpers'; + +const checkVersions = (packages: string[], deploymentType: string): void => { + const versions = packages.map(packageName => getLocalVersion(packageName)); + + const isCorrectType = versions.every(version => { + const isBeta = semver.prerelease(version); + return (deploymentType === 'canary' && isBeta) || (deploymentType === 'stable' && !isBeta); + }); + + if (!isCorrectType) { + console.error( + `Mixed deployment types detected. All versions should be either "stable" or "canary".`, + ); + process.exit(1); + } else { + console.log(`All versions are of the ${deploymentType} deployment type.`); + } +}; + +const packages = JSON.parse(process.argv[2]); +const deploymentType = process.argv[3]; + +checkVersions(packages, deploymentType); diff --git a/ci/scripts/connect-release-npm-init.ts b/ci/scripts/connect-release-npm-init.ts deleted file mode 100644 index 414bd525da9..00000000000 --- a/ci/scripts/connect-release-npm-init.ts +++ /dev/null @@ -1,134 +0,0 @@ -// This script should check what packages are from the repository have different most recent version in NPM -// as the on e in the package.json and trigger the workflow to release to NPM those packages. - -import { exec, gettingNpmDistributionTags } from './helpers'; -import fs from 'fs'; -import util from 'util'; -import path from 'path'; -import semver from 'semver'; - -const args = process.argv.slice(2); - -if (args.length < 2) { - throw new Error('Check npm dependencies requires 2 parameters: deploymentType branchName'); -} -const [deploymentType, branchName] = args; - -const allowedDeploymentType = ['canary', 'stable']; -if (!allowedDeploymentType.includes(deploymentType)) { - throw new Error( - `provided semver: ${deploymentType} must be one of ${allowedDeploymentType.join(', ')}`, - ); -} - -const readFile = util.promisify(fs.readFile); - -const ROOT = path.join(__dirname, '..', '..'); - -const triggerReleaseNpmWorkflow = (branch: string, packages: string, type: 'stable' | 'canary') => - exec('gh', [ - 'workflow', - 'run', - '.github/workflows/release-connect-npm.yml', - '--ref', - branch, - '--field', - `packages=${packages}`, - '--field', - `deploymentType=${type}`, - ]); - -const getNpmRemoteGreatestVersion = async (moduleName: string) => { - try { - const distributionTags = await gettingNpmDistributionTags(moduleName); - - const versionArray: string[] = Object.values(distributionTags); - const greatestVersion = versionArray.reduce((max, current) => { - return semver.gt(current, max) ? current : max; - }); - - return greatestVersion; - } catch (error) { - console.error('error:', error); - throw new Error('Not possible to get remote greatest version'); - } -}; - -const nonReleaseDependencies: string[] = []; - -const checkNonReleasedDependencies = async (packageName: string) => { - const rawPackageJSON = await readFile( - path.join(ROOT, 'packages', packageName, 'package.json'), - 'utf-8', - ); - - const packageJSON = JSON.parse(rawPackageJSON); - const { - version, - dependencies, - // devDependencies // We should ignore devDependencies. - } = packageJSON; - - const remoteGreatestVersion = await getNpmRemoteGreatestVersion(`@trezor/${packageName}`); - - // If local version is greatest than the greatest one in NPM we add it to the release. - if (semver.gt(version, remoteGreatestVersion as string)) { - const index = nonReleaseDependencies.indexOf(packageName); - if (index > -1) { - nonReleaseDependencies.splice(index, 1); - } - nonReleaseDependencies.push(packageName); - } - - if (!dependencies || !Object.keys(dependencies)) { - return; - } - - // eslint-disable-next-line no-restricted-syntax - for await (const [dependency] of Object.entries(dependencies)) { - // is not a dependency released from monorepo. we don't care - if (!dependency.startsWith('@trezor')) { - // eslint-disable-next-line no-continue - continue; - } - const [_prefix, name] = dependency.split('/'); - - console.log('name', name); - - await checkNonReleasedDependencies(name); - } - console.log('nonReleaseDependencies', nonReleaseDependencies); -}; - -const initConnectNpmRelease = async () => { - // We check what dependencies need to be released because they have version bumped locally - // and remote greatest version is lower than the local one. - await checkNonReleasedDependencies('connect'); - await checkNonReleasedDependencies('connect-web'); - await checkNonReleasedDependencies('connect-webextension'); - console.log('Final nonReleaseDependencies', nonReleaseDependencies); - - // We do not want to include `connect`, `connect-web` and `connect-webextension` since we want - // to release those separately and we always want to release them. - const onlyDependenciesToRelease = nonReleaseDependencies.filter(item => { - return !['connect', 'connect-web', 'connect-webextension'].includes(item); - }); - - // We use `onlyDependenciesToRelease` to trigger NPM releases - const dependenciesToRelease = JSON.stringify(onlyDependenciesToRelease); - console.log('dependenciesToRelease:', dependenciesToRelease); - console.log('deploymentType:', deploymentType); - console.log('branchName:', branchName); - - // Now we want to trigger the action that will trigger the actual release, - // after approval form authorized member. - const releaseDependencyActionOutput = triggerReleaseNpmWorkflow( - branchName, - dependenciesToRelease, - deploymentType, - ); - - console.log('releaseDependencyActionOutput output:', releaseDependencyActionOutput.stdout); -}; - -initConnectNpmRelease(); diff --git a/ci/scripts/determine-deployment-type.ts b/ci/scripts/determine-deployment-type.ts new file mode 100644 index 00000000000..cadbdd85874 --- /dev/null +++ b/ci/scripts/determine-deployment-type.ts @@ -0,0 +1,14 @@ +const semver = require('semver'); + +const version = process.argv[2]; + +let deploymentType; +if (semver.prerelease(version)) { + deploymentType = 'canary'; +} else if (semver.minor(version) || semver.major(version)) { + deploymentType = 'stable'; +} else { + throw new Error(`Invalid version: ${version}`); +} + +process.stdout.write(deploymentType); diff --git a/ci/scripts/get-connect-dependencies-to-release.ts b/ci/scripts/get-connect-dependencies-to-release.ts new file mode 100644 index 00000000000..8111bf56157 --- /dev/null +++ b/ci/scripts/get-connect-dependencies-to-release.ts @@ -0,0 +1,77 @@ +// This script should check what packages from the repository have a higher version than in NPM +// and stdout out those to be used by GitHub workflow. + +import fs from 'fs'; +import util from 'util'; +import path from 'path'; +import semver from 'semver'; + +import { getNpmRemoteGreatestVersion } from './helpers'; + +const readFile = util.promisify(fs.readFile); + +const ROOT = path.join(__dirname, '..', '..'); + +const nonReleaseDependencies: string[] = []; + +const checkNonReleasedDependencies = async (packageName: string) => { + const rawPackageJSON = await readFile( + path.join(ROOT, 'packages', packageName, 'package.json'), + 'utf-8', + ); + + const packageJSON = JSON.parse(rawPackageJSON); + const { + version: localVersion, + dependencies, + // devDependencies // We should ignore devDependencies. + } = packageJSON; + + const remoteGreatestVersion = await getNpmRemoteGreatestVersion(`@trezor/${packageName}`); + + // If local version is greatest than the greatest one in NPM we add it to the release. + if (semver.gt(localVersion, remoteGreatestVersion as string)) { + const index = nonReleaseDependencies.indexOf(packageName); + if (index > -1) { + nonReleaseDependencies.splice(index, 1); + } + nonReleaseDependencies.push(packageName); + } + + if (!dependencies || !Object.keys(dependencies)) { + return; + } + + // eslint-disable-next-line no-restricted-syntax + for await (const [dependency] of Object.entries(dependencies)) { + // is not a dependency released from monorepo. we don't care + if (!dependency.startsWith('@trezor')) { + // eslint-disable-next-line no-continue + continue; + } + const [_prefix, name] = dependency.split('/'); + + await checkNonReleasedDependencies(name); + } +}; + +const getConnectDependenciesToRelease = async () => { + // We check what dependencies need to be released because they have version bumped locally + // and remote greatest version is lower than the local one. + await checkNonReleasedDependencies('connect'); + await checkNonReleasedDependencies('connect-web'); + await checkNonReleasedDependencies('connect-webextension'); + + // We do not want to include `connect`, `connect-web` and `connect-webextension` since we want + // to release those separately and we always want to release them. + const onlyDependenciesToRelease = nonReleaseDependencies.filter(item => { + return !['connect', 'connect-web', 'connect-webextension'].includes(item); + }); + + // We use `onlyDependenciesToRelease` to trigger NPM releases + const dependenciesToRelease = JSON.stringify(onlyDependenciesToRelease); + + process.stdout.write(dependenciesToRelease); +}; + +getConnectDependenciesToRelease(); diff --git a/ci/scripts/helpers.ts b/ci/scripts/helpers.ts index 6103f9b8bf7..0e348e752f5 100644 --- a/ci/scripts/helpers.ts +++ b/ci/scripts/helpers.ts @@ -28,6 +28,22 @@ export const gettingNpmDistributionTags = async (packageName: string) => { return data['dist-tags']; }; +export const getNpmRemoteGreatestVersion = async (moduleName: string) => { + try { + const distributionTags = await gettingNpmDistributionTags(moduleName); + + const versionArray: string[] = Object.values(distributionTags); + const greatestVersion = versionArray.reduce((max, current) => { + return semver.gt(current, max) ? current : max; + }); + + return greatestVersion; + } catch (error) { + console.error('error:', error); + throw new Error('Not possible to get remote greatest version'); + } +}; + export const checkPackageDependencies = async ( packageName: string, deploymentType: 'stable' | 'canary', @@ -157,3 +173,12 @@ export const commit = async ({ path, message }: { path: string; message: string export const comment = async ({ prNumber, body }: { prNumber: string; body: string }) => { await exec('gh', ['pr', 'comment', `${prNumber}`, '--body', body]); }; + +export const getLocalVersion = (packageName: string) => { + const packageJsonPath = path.join(ROOT, 'packages', packageName, 'package.json'); + if (!fs.existsSync(packageJsonPath)) { + throw new Error(`package.json not found for package: ${packageName}`); + } + const packageJson = JSON.parse(fs.readFileSync(packageJsonPath, 'utf8')); + return packageJson.version; +};