From 201ac6400ccc10910a12186d3cb28a3cc879f3f9 Mon Sep 17 00:00:00 2001 From: Jan Vilimek Date: Tue, 20 Sep 2022 20:22:30 +0200 Subject: [PATCH] cd pipeline (#35) Now we have also CD pipeline that can publish packages and create a release! Also - bump score-card version (updated url in package) - added DCO - badges for CI/CD pipeline in README - more scripts from backstage repo to lint&check sources --- .changeset/popular-queens-ring.md | 5 - .changeset/swift-moose-cheat.md | 5 - .github/workflows/cd.yml | 147 ++++++++ .npmrc | 2 +- DCO | 37 ++ README.md | 4 + package.json | 143 +++---- packages/app/CHANGELOG.md | 8 + packages/app/package.json | 5 +- packages/app/src/App.tsx | 2 +- .../app/src/components/catalog/EntityPage.tsx | 2 +- packages/backend/package.json | 2 +- plugins/score-card/CHANGELOG.md | 13 + plugins/score-card/package.json | 10 +- scripts/check-if-release.js | 128 +++++++ scripts/check-type-dependencies.js | 211 +++++++++++ scripts/create-github-release.js | 210 +++++++++++ scripts/create-release-tag.js | 84 +++++ scripts/prepare-release.js | 353 ++++++++++++++++++ 19 files changed, 1279 insertions(+), 92 deletions(-) delete mode 100644 .changeset/popular-queens-ring.md delete mode 100644 .changeset/swift-moose-cheat.md create mode 100644 .github/workflows/cd.yml create mode 100644 DCO create mode 100644 plugins/score-card/CHANGELOG.md create mode 100644 scripts/check-if-release.js create mode 100755 scripts/check-type-dependencies.js create mode 100644 scripts/create-github-release.js create mode 100644 scripts/create-release-tag.js create mode 100644 scripts/prepare-release.js diff --git a/.changeset/popular-queens-ring.md b/.changeset/popular-queens-ring.md deleted file mode 100644 index b623a52d17..0000000000 --- a/.changeset/popular-queens-ring.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'@ori/backstage-plugin-score-card': patch ---- - -added changeset tool diff --git a/.changeset/swift-moose-cheat.md b/.changeset/swift-moose-cheat.md deleted file mode 100644 index a2946b28e1..0000000000 --- a/.changeset/swift-moose-cheat.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'app': patch ---- - -added e2e tests for scorecard plugin diff --git a/.github/workflows/cd.yml b/.github/workflows/cd.yml new file mode 100644 index 0000000000..e3f0b7ab5e --- /dev/null +++ b/.github/workflows/cd.yml @@ -0,0 +1,147 @@ +name: 'CD pipeline' + +on: + workflow_dispatch: + inputs: + create_release: + description: 'Create release' + required: true + type: boolean + default: 'true' + publish_packages: + description: 'Publish npm packages' + required: true + type: boolean + default: 'true' + push: + branches: [main] + +jobs: + build: + runs-on: ubuntu-latest + + outputs: + needs_release: ${{ steps.release_check.outputs.needs_release }} + + strategy: + matrix: + node-version: [16.x] + + env: + CI: true + NODE_OPTIONS: --max-old-space-size=4096 + + steps: + - name: Check of github.event.before + if: ${{ !github.event.inputs.create_release && !github.event.inputs.publish_packages }} + run: if [ '${{ github.event.before }}' = '0000000000000000000000000000000000000000' ]; then echo "::warning title=Missing github.event.before::You are running this CD workflow on a newly created branch. Release won't be created..."; fi + + - name: Checkout repository + uses: actions/checkout@v3 + + - name: Set up Node.js + uses: actions/setup-node@v3 + with: + node-version: ${{ matrix.node-version }} + registry-url: https://registry.npmjs.org/ # Needed for auth + + - name: yarn install + uses: backstage/actions/yarn-install@v0.5.3 + with: + cache-prefix: ${{ runner.os }}-v${{ matrix.node-version }} + + - name: Fetch previous commit for release check + if: ${{ github.event.before != '0000000000000000000000000000000000000000' }} + run: git fetch origin '${{ github.event.before }}' + + - name: Check if release + id: release_check + if: ${{ github.event.before != '0000000000000000000000000000000000000000' }} + run: node scripts/check-if-release.js + env: + COMMIT_SHA_BEFORE: '${{ github.event.before }}' + + - name: validate config + run: yarn backstage-cli config:check --lax + + - name: lint + run: yarn backstage-cli repo lint + + - name: type checking and declarations + run: yarn tsc:full + + - name: build + run: yarn backstage-cli repo build --all + + - name: verify type dependencies + run: yarn lint:type-deps + + - name: test + run: | + yarn lerna -- run test -- --coverage --runInBand + + # A separate release build that is only run for commits that are the result of merging the "Version Packages" PR + # We can't re-use the output from the above step, but we'll have a guaranteed node_modules cache and + # only run the build steps that are necessary for publishing + release: + needs: build + + if: needs.build.outputs.needs_release == 'true' || github.event.inputs.create_release || github.event.inputs.publish_packages + + runs-on: ubuntu-latest + + strategy: + matrix: + node-version: [16.x] + + env: + CI: 'true' + NODE_OPTIONS: --max-old-space-size=4096 + + steps: + - name: Checkout repository + uses: actions/checkout@v3 + + - name: Set up Node.js + uses: actions/setup-node@v3 + with: + node-version: ${{ matrix.node-version }} + registry-url: https://registry.npmjs.org/ # Needed for auth + + - name: yarn install + uses: backstage/actions/yarn-install@v0.5.3 + with: + cache-prefix: ${{ runner.os }}-v${{ matrix.node-version }} + + - name: build type declarations + run: yarn tsc:full + + - name: build packages + run: yarn backstage-cli repo build + + # Publishes current version of packages that are not already present in the registry + - name: publish + if: needs.build.outputs.needs_release == 'true' || github.event.inputs.publish_packages + run: | + if [ -f ".changeset/pre.json" ]; then + yarn lerna -- publish from-package --yes --dist-tag next + else + yarn lerna -- publish from-package --yes + fi + env: + NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} + + # Grabs the version in the root package.json and creates a tag on GitHub + - name: Create a release tag + id: create_tag + if: needs.build.outputs.needs_release == 'true' || github.event.inputs.create_release + run: node scripts/create-release-tag.js + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + + # Convert the newly created tag into a release with changelog information + - name: Create release on GitHub + if: needs.build.outputs.needs_release == 'true' || github.event.inputs.create_release + run: node scripts/create-github-release.js ${{ steps.create_tag.outputs.tag_name }} 1 + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/.npmrc b/.npmrc index df9baac647..c3c66347fd 100644 --- a/.npmrc +++ b/.npmrc @@ -1,2 +1,2 @@ registry=https://registry.npmjs.org/ -always-auth=false \ No newline at end of file +engine-strict=true diff --git a/DCO b/DCO new file mode 100644 index 0000000000..0cdce0c397 --- /dev/null +++ b/DCO @@ -0,0 +1,37 @@ +Developer Certificate of Origin +Version 1.1 + +Copyright (C) 2004, 2006 The Linux Foundation and its contributors. +1 Letterman Drive +Suite D4700 +San Francisco, CA, 94129 + +Everyone is permitted to copy and distribute verbatim copies of this +license document, but changing it is not allowed. + + +Developer's Certificate of Origin 1.1 + +By making a contribution to this project, I certify that: + +(a) The contribution was created in whole or in part by me and I + have the right to submit it under the open source license + indicated in the file; or + +(b) The contribution is based upon previous work that, to the best + of my knowledge, is covered under an appropriate open source + license and I have the right under that license to submit that + work with modifications, whether created in whole or in part + by me, under the same open source license (unless I am + permitted to submit under a different license), as indicated + in the file; or + +(c) The contribution was provided directly to me by some other + person who certified (a), (b) or (c) and I have not modified + it. + +(d) I understand and agree that this project and the contribution + are public and that a record of the contribution (including all + personal information I submit with it, including my sign-off) is + maintained indefinitely and may be redistributed consistent with + this project or the open source license(s) involved. \ No newline at end of file diff --git a/README.md b/README.md index 6a60c44240..a756d4d9f5 100644 --- a/README.md +++ b/README.md @@ -2,6 +2,10 @@ Oriflame Backstage plugins. +[![CI pipeline](https://github.com/Oriflame/backstage-plugins/actions/workflows/ci.yml/badge.svg)](https://github.com/Oriflame/backstage-plugins/actions/workflows/ci.yml) + +[![CD pipeline](https://github.com/Oriflame/backstage-plugins/actions/workflows/cd.yml/badge.svg)](https://github.com/Oriflame/backstage-plugins/actions/workflows/cd.yml) + ## Getting started You may find our plugins in the `./plugins` folder. You may start each plugin in isolated mode (navigate to the plugin folder and run `yarn dev` or `yarn start:dev`, see respective README). You may start also the simple backstage host with the plugins integrated via `yarn dev` (in root folder). You may run `yarn test` to run jest tests. For more information see [CONTRIBUTING.md](./CONTRIBUTING.md). diff --git a/package.json b/package.json index d13546fd96..3ff63d3fc1 100644 --- a/package.json +++ b/package.json @@ -1,72 +1,75 @@ { - "name": "ori-backstage-plugins", - "description": "Oriflame plugins for Backstage.", - "maintainers": [ - "jvilimek@users.noreply.github.com", - "OSWDVLPPlatform@oriflame.com", - "GlobalITCOEdevelopmentTooling@Oriflame.com" + "name": "ori-backstage-plugins", + "description": "Oriflame plugins for Backstage.", + "maintainers": [ + "jvilimek@users.noreply.github.com", + "OSWDVLPPlatform@oriflame.com", + "GlobalITCOEdevelopmentTooling@Oriflame.com" + ], + "version": "0.1.5", + "private": true, + "engines": { + "node": "14 || 16" + }, + "scripts": { + "dev": "concurrently \"yarn start\" \"yarn start-backend\" \"http-server -p 8090 --cors 2>&1\"", + "start": "yarn workspace app start", + "start-backend": "yarn workspace backend start", + "build": "backstage-cli repo build --all", + "tsc": "tsc", + "tsc:full": "backstage-cli clean && tsc --skipLibCheck true --incremental false", + "clean": "backstage-cli clean && lerna run clean", + "diff": "lerna run diff --", + "test": "backstage-cli test", + "test:all": "lerna run test -- --coverage", + "test:e2e": "yarn workspace app cypress run", + "lint": "backstage-cli repo lint --since origin/main", + "lint:all": "backstage-cli repo lint", + "lint:type-deps": "node scripts/check-type-dependencies.js", + "create-plugin": "backstage-cli create-plugin --scope ori --no-private", + "remove-plugin": "backstage-cli remove-plugin", + "release": "node scripts/prepare-release.js && changeset version && yarn diff --yes && yarn prettier --write '{packages,plugins}/*/{package.json,CHANGELOG.md}' '.changeset/*.json' && yarn install", + "prettier:check": "prettier --check .", + "lerna": "lerna", + "lock:check": "yarn-lock-check" + }, + "workspaces": { + "packages": [ + "packages/*", + "plugins/*" + ] + }, + "resolutions": { + "@types/react": "^17", + "@types/react-dom": "^17" + }, + "dependencies": { + "@changesets/cli": "2.24.4" + }, + "devDependencies": { + "@backstage/cli": "0.18.1", + "@spotify/prettier-config": "14.1.0", + "@types/webpack": "5.28.0", + "concurrently": "7.4.0", + "eslint-plugin-notice": "0.9.10", + "lerna": "5.5.1", + "prettier": "2.7.1", + "typescript": "4.8.3", + "@types/react": "17.0.50", + "react": "17.0.2", + "react-dom": "17.0.2" + }, + "prettier": "@spotify/prettier-config", + "lint-staged": { + "*.{js,jsx,ts,tsx,mjs,cjs}": [ + "eslint --fix", + "prettier --write" ], - "version": "0.1.0", - "private": true, - "engines": { - "node": "14 || 16" - }, - "scripts": { - "dev": "concurrently \"yarn start\" \"yarn start-backend\" \"http-server -p 8090 --cors 2>&1\"", - "start": "yarn workspace app start", - "start-backend": "yarn workspace backend start", - "build": "lerna run build", - "build-image": "yarn workspace backend build-image", - "tsc": "tsc", - "tsc:full": "tsc --skipLibCheck false --incremental false", - "clean": "backstage-cli clean && lerna run clean", - "diff": "lerna run diff --", - "test": "lerna run test --since origin/main -- --coverage --runInBand", - "test:all": "lerna run test -- --coverage --runInBand", - "test:e2e": "yarn workspace app cypress run", - "lint": "lerna run lint --since origin/main --", - "lint:all": "lerna run lint --", - "create-plugin": "backstage-cli create-plugin --scope ori --no-private", - "remove-plugin": "backstage-cli remove-plugin", - "release": "changeset version && yarn prettier --write '{packages,plugins}/*/{package.json,CHANGELOG.md}' && yarn install" - }, - "workspaces": { - "packages": [ - "packages/*", - "plugins/*" - ] - }, - "resolutions": { - "@types/react": "^17", - "@types/react-dom": "^17" - }, - "dependencies": { - "@changesets/cli": "2.24.4" - }, - "devDependencies": { - "@backstage/cli": "0.18.1", - "@spotify/prettier-config": "14.1.0", - "@types/webpack": "5.28.0", - "concurrently": "7.4.0", - "eslint-plugin-notice": "0.9.10", - "lerna": "5.5.1", - "prettier": "2.7.1", - "typescript": "4.8.3", - "@types/react": "17.0.50", - "react": "17.0.2", - "react-dom": "17.0.2" - }, - "prettier": "@spotify/prettier-config", - "lint-staged": { - "*.{js,jsx,ts,tsx,mjs,cjs}": [ - "eslint --fix", - "prettier --write" - ], - "*.{json,md}": [ - "prettier --write" - ], - "*.md": [ - "node ./scripts/check-docs-quality" - ] - } -} \ No newline at end of file + "*.{json,md}": [ + "prettier --write" + ], + "*.md": [ + "node ./scripts/check-docs-quality" + ] + } +} diff --git a/packages/app/CHANGELOG.md b/packages/app/CHANGELOG.md index ee38582538..150d494a6f 100644 --- a/packages/app/CHANGELOG.md +++ b/packages/app/CHANGELOG.md @@ -1,5 +1,13 @@ # app +## 0.4.1 + +### Patch Changes + +- ac344f7: added e2e tests for scorecard plugin +- Updated dependencies [a4d022f] + - @oriflame/backstage-plugin-score-card@0.4.1 + ## 0.4.0 Initial version for plugins app host. diff --git a/packages/app/package.json b/packages/app/package.json index 1057ba89f3..cc33f74171 100644 --- a/packages/app/package.json +++ b/packages/app/package.json @@ -1,6 +1,6 @@ { "name": "app", - "version": "1.0.6", + "version": "0.4.1", "private": true, "backstage": { "role": "frontend" @@ -21,7 +21,6 @@ "@backstage/plugin-catalog-graph": "0.2.20", "@backstage/plugin-catalog-import": "0.8.11", "@backstage/plugin-catalog-react": "1.1.3", - "@backstage/plugin-home": "0.4.24", "@backstage/plugin-org": "0.5.8", "@backstage/plugin-scaffolder": "1.5.0", @@ -32,7 +31,7 @@ "@backstage/theme": "0.2.16", "@material-ui/core": "4.12.4", "@material-ui/icons": "4.11.3", - "@ori/backstage-plugin-score-card": "^0.4.0", + "@oriflame/backstage-plugin-score-card": "^0.4.1", "history": "5.3.0", "prop-types": "15.8.1", "react": "17.0.2", diff --git a/packages/app/src/App.tsx b/packages/app/src/App.tsx index 875b022112..394a152cce 100644 --- a/packages/app/src/App.tsx +++ b/packages/app/src/App.tsx @@ -31,7 +31,7 @@ import { Root } from './components/Root'; import { AlertDisplay, OAuthRequestDialog } from '@backstage/core-components'; import { FlatRoutes } from '@backstage/core-app-api'; import { createApp } from '@backstage/app-defaults'; -import { ScoreBoardPage } from '@ori/backstage-plugin-score-card'; +import { ScoreBoardPage } from '@oriflame/backstage-plugin-score-card'; const app = createApp({ apis, diff --git a/packages/app/src/components/catalog/EntityPage.tsx b/packages/app/src/components/catalog/EntityPage.tsx index f4bc8ba639..9f6ac0b1fc 100644 --- a/packages/app/src/components/catalog/EntityPage.tsx +++ b/packages/app/src/components/catalog/EntityPage.tsx @@ -36,7 +36,7 @@ import { EntityOwnershipCard, } from '@backstage/plugin-org'; -import { EntityScoreCardContent } from '@ori/backstage-plugin-score-card'; +import { EntityScoreCardContent } from '@oriflame/backstage-plugin-score-card'; const overviewContent = ( diff --git a/packages/backend/package.json b/packages/backend/package.json index 4d2f645800..c6b7166d31 100644 --- a/packages/backend/package.json +++ b/packages/backend/package.json @@ -38,7 +38,7 @@ "better-sqlite3": "7.6.2", "@types/luxon": "2.4.0", "luxon": "2.5.0", - "app": "^1.0.4", + "app": "^0.4.1", "dockerode": "3.3.4", "express": "4.18.1", "express-promise-router": "4.1.1", diff --git a/plugins/score-card/CHANGELOG.md b/plugins/score-card/CHANGELOG.md new file mode 100644 index 0000000000..54ff7627ff --- /dev/null +++ b/plugins/score-card/CHANGELOG.md @@ -0,0 +1,13 @@ +# @oriflame/backstage-plugin-score-card + +## 0.4.2 + +### Patch Changes + +- c9c49d1: Fixed repository url (package metadata) + +## 0.4.1 + +### Patch Changes + +- bumped dependencies diff --git a/plugins/score-card/package.json b/plugins/score-card/package.json index ee9bb82ccb..7379aefa34 100644 --- a/plugins/score-card/package.json +++ b/plugins/score-card/package.json @@ -1,6 +1,6 @@ { - "name": "@ori/backstage-plugin-score-card", - "version": "0.4.0", + "name": "@oriflame/backstage-plugin-score-card", + "version": "0.4.2", "main": "src/index.ts", "types": "src/index.ts", "license": "Apache-2.0", @@ -12,10 +12,10 @@ "backstage": { "role": "frontend-plugin" }, - "homepage": "https://backstage.io", + "homepage": "https://github.com/Oriflame/backstage-plugins/tree/main/plugins/score-card", "repository": { "type": "git", - "url": "https://github.com/backstage/backstage", + "url": "https://github.com/Oriflame/backstage-plugins", "directory": "plugins/score-card" }, "keywords": [ @@ -48,7 +48,6 @@ "react-use": "^17.2.4" }, "peerDependencies": { - "@types/react": "^16.13.1 || ^17.0.0", "react": "^16.13.1 || ^17.0.0", "react-dom": "^16.13.1 || ^17.0.0" }, @@ -64,6 +63,7 @@ "@testing-library/user-event": "14.4.3", "@types/jest": "*", "@types/node": "*", + "@types/react": "^16.13.1 || ^17.0.0", "msw": "0.47.3", "cross-fetch": "3.1.5", "http-server": "14.1.1" diff --git a/scripts/check-if-release.js b/scripts/check-if-release.js new file mode 100644 index 0000000000..69c073ff0a --- /dev/null +++ b/scripts/check-if-release.js @@ -0,0 +1,128 @@ +#!/usr/bin/env node +/* eslint-disable import/no-extraneous-dependencies */ +/* + * Copyright 2020 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +// This script is used to determine whether a particular commit has changes +// that should lead to a release. It is run as part of the main master build +// to determine whether the release flow should be run as well. +// +// It has the following output which can be used later in GitHub actions: +// +// needs_release = 'true' | 'false' + +const { execFile: execFileCb } = require('child_process'); +const { resolve: resolvePath } = require('path'); +const { promises: fs } = require('fs'); +const { promisify } = require('util'); + +const parentRef = + !process.env.COMMIT_SHA_BEFORE || + process.env.COMMIT_SHA_BEFORE === '0000000000000000000000000000000000000000' + ? 'HEAD^' + : process.env.COMMIT_SHA_BEFORE; + +const execFile = promisify(execFileCb); + +async function runPlain(cmd, ...args) { + try { + const { stdout } = await execFile(cmd, args, { shell: true }); + return stdout.trim(); + } catch (error) { + if (error.stderr) { + process.stderr.write(error.stderr); + } + if (!error.code) { + throw error; + } + throw new Error( + `Command '${[cmd, ...args].join(' ')}' failed with code ${error.code}`, + ); + } +} + +async function main() { + process.cwd(resolvePath(__dirname, '..')); + + const diff = await runPlain( + 'git', + 'diff', + '--name-only', + parentRef, + '--', + "'*/package.json'", // Git treats this as what would usually be **/package.json + ); + const packageList = diff + .split('\n') + .filter(path => path.match(/^(plugins)\/[^/]+\/package\.json$/)); + + const packageVersions = await Promise.all( + packageList.map(async path => { + let name; + let newVersion; + let oldVersion; + + try { + const data = JSON.parse( + await runPlain('git', 'show', `${parentRef}:${path}`), + ); + name = data.name; + oldVersion = data.version; + } catch { + oldVersion = ''; + } + + try { + const data = JSON.parse(await fs.readFile(path, 'utf8')); + name = data.name; + newVersion = data.version; + } catch (error) { + if (error.code === 'ENOENT') { + newVersion = ''; + } + } + + return { name, oldVersion, newVersion }; + }), + ); + + const newVersions = packageVersions.filter( + ({ oldVersion, newVersion }) => + oldVersion !== newVersion && + oldVersion !== '' && + newVersion !== '', + ); + + if (newVersions.length === 0) { + console.log('No package version bumps detected, no release needed'); + console.log(`::set-output name=needs_release::false`); + return; + } + + console.log('Package version bumps detected, a new release is needed'); + const maxLength = Math.max(...newVersions.map(_ => _.name.length)); + for (const { name, oldVersion, newVersion } of newVersions) { + console.log( + ` ${name.padEnd(maxLength, ' ')} ${oldVersion} to ${newVersion}`, + ); + } + console.log(`::set-output name=needs_release::true`); +} + +main().catch(error => { + console.error(error.stack); + process.exit(1); +}); diff --git a/scripts/check-type-dependencies.js b/scripts/check-type-dependencies.js new file mode 100755 index 0000000000..ba362bd5ff --- /dev/null +++ b/scripts/check-type-dependencies.js @@ -0,0 +1,211 @@ +#!/usr/bin/env node +/* + * Copyright 2020 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +const fs = require('fs'); +const { resolve: resolvePath } = require('path'); +// Cba polluting root package.json, we'll have this +// eslint-disable-next-line import/no-extraneous-dependencies +const chalk = require('chalk'); +const { getPackages } = require('@manypkg/get-packages'); + +async function main() { + const { packages } = await getPackages(resolvePath('.')); + + let hadErrors = false; + + for (const pkg of packages) { + if (!shouldCheckTypes(pkg)) { + continue; + } + const { errors } = await checkTypes(pkg); + if (errors.length) { + hadErrors = true; + console.error( + `Incorrect type dependencies in ${chalk.yellow(pkg.packageJson.name)}:`, + ); + for (const error of errors) { + if (error.name === 'WrongDepError') { + console.error( + ` Move from ${chalk.red(error.from)} to ${chalk.green( + error.to, + )}: ${chalk.cyan(error.dep)}`, + ); + } else if (error.name === 'MissingDepError') { + console.error( + ` Missing a type dependency: ${chalk.cyan(error.dep)}`, + ); + } else { + console.error(` Unknown error, ${chalk.red(error)}`); + } + } + } + } + + if (hadErrors) { + console.error(); + console.error( + chalk.red('At least one package had incorrect type dependencies'), + ); + + process.exit(2); + } +} + +function shouldCheckTypes(pkg) { + return ( + !pkg.private && + pkg.packageJson.types && + fs.existsSync(resolvePath(pkg.dir, 'dist/index.d.ts')) + ); +} + +function findAllDeps(declSrc) { + const importedDeps = (declSrc.match(/^import .* from '.*';$/gm) || []) + .map(match => match.match(/from '(.*)'/)[1]) + .filter(n => !n.startsWith('.')); + const referencedDeps = ( + declSrc.match(/^\/\/\/ $/gm) || [] + ) + .map(match => match.match(/types="(.*)"/)[1]) + .filter(n => !n.startsWith('.')) + // We allow references to these without an explicit dependency. + .filter(n => !['node', 'react'].includes(n)); + return Array.from(new Set([...importedDeps, ...referencedDeps])); +} + +/** + * Scan index.d.ts for imports and return errors for any dependency that's + * missing or incorrect in package.json + */ +function checkTypes(pkg) { + const typeDecl = fs.readFileSync( + resolvePath(pkg.dir, 'dist/index.d.ts'), + 'utf8', + ); + const allDeps = findAllDeps(typeDecl); + const deps = Array.from(new Set(allDeps)); + + const errors = []; + const typeDeps = []; + for (const dep of deps) { + try { + const typeDep = findTypesPackage(dep, pkg); + if (typeDep) { + typeDeps.push(typeDep); + } + } catch (error) { + errors.push(error); + } + } + + errors.push(...findTypeDepErrors(typeDeps, pkg)); + + return { errors }; +} + +/** + * Find the package used for types. This assumes that types are working is a package + * can be resolved, it doesn't do any checking of presence of types inside the dep. + */ +function findTypesPackage(dep, pkg) { + try { + require.resolve(`@types/${dep}/package.json`, { paths: [pkg.dir] }); + return `@types/${dep}`; + } catch { + try { + require.resolve(dep, { paths: [pkg.dir] }); + return undefined; + } catch { + try { + // Some type-only modules don't have a working main field, so try resolving package.json too + require.resolve(`${dep}/package.json`, { paths: [pkg.dir] }); + return undefined; + } catch { + try { + // Finally check if it's just a .d.ts file + require.resolve(`${dep}.d.ts`, { paths: [pkg.dir] }); + return undefined; + } catch { + throw mkErr('MissingDepError', `No types for ${dep}`, { dep }); + } + } + } + } +} + +/** + * Figures out what type dependencies are missing, or should be moved between dep types + */ +function findTypeDepErrors(typeDeps, pkg) { + const devDeps = mkTypeDepSet(pkg.packageJson.devDependencies); + const deps = mkTypeDepSet({ + ...pkg.packageJson.dependencies, + ...pkg.packageJson.peerDependencies, + }); + + const errors = []; + for (const typeDep of typeDeps) { + if (!deps.has(typeDep)) { + if (devDeps.has(typeDep)) { + errors.push( + mkErr('WrongDepError', `Should be dep ${typeDep}`, { + dep: typeDep, + from: 'devDependencies', + to: 'dependencies', + }), + ); + } else { + errors.push( + mkErr('MissingDepError', `No types for ${typeDep}`, { + dep: typeDep, + }), + ); + } + } else { + deps.delete(typeDep); + } + } + + for (const dep of deps) { + errors.push( + mkErr('WrongDepError', `Should be dev dep ${dep}`, { + dep, + from: 'dependencies', + to: 'devDependencies', + }), + ); + } + + return errors; +} + +function mkTypeDepSet(deps) { + const typeDeps = Object.keys(deps || {}).filter(n => n.startsWith('@types/')); + return new Set(typeDeps); +} + +function mkErr(name, msg, extra) { + const error = new Error(msg); + error.name = name; + Object.assign(error, extra); + return error; +} + +main().catch(error => { + console.error(error.stack || error); + process.exit(1); +}); diff --git a/scripts/create-github-release.js b/scripts/create-github-release.js new file mode 100644 index 0000000000..6062394725 --- /dev/null +++ b/scripts/create-github-release.js @@ -0,0 +1,210 @@ +#!/usr/bin/env node +/* eslint-disable import/no-extraneous-dependencies */ +/* + * Copyright 2020 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +/** + * This script creates a release on GitHub for the Backstage repository. + * Given a git tag, it identifies the PR created by changesets which is responsible for creating + * the git tag. It then uses the PR description consisting of changelogs for packages as the + * release description. + * + * Example: + * + * Set GITHUB_TOKEN environment variable. + * + * (Dry Run mode, will create a DRAFT release, but will not publish it.) + * (Draft releases are visible to maintainers and do not notify users.) + * $ node scripts/get-release-description v0.4.1 + * + * This will open the git tree at this tag https://github.com/backstage/backstage/tree/v0.4.1 + * It will identify https://github.com/backstage/backstage/pull/3668 as the responsible changeset PR. + * And will use everything in the PR description under "Releases" section. + * + * (Production or GitHub Actions Mode) + * $ node scripts/get-release-description v0.4.1 true + * + * This will do the same steps as above, and will publish the Release with the description. + */ + +const { Octokit } = require('@octokit/rest'); +const semver = require('semver'); + +// See Examples above to learn about these command line arguments. +const [TAG_NAME, BOOL_CREATE_RELEASE] = process.argv.slice(2); + +if (!BOOL_CREATE_RELEASE) { + console.log( + '\nRunning script in Dry Run mode. It will output details, will create a draft release but will NOT publish it.', + ); +} + +const GH_OWNER = 'Oriflame'; +const GH_REPO = 'backstage-plugins'; +const EXPECTED_COMMIT_MESSAGE = /^Merge pull request #(?[0-9]+) from/; +const CHANGESET_RELEASE_BRANCH = + 'backstage-plugins/changeset-release/main'; + +// Initialize a GitHub client +const { GITHUB_TOKEN } = process.env; +const octokit = new Octokit({ + auth: GITHUB_TOKEN, +}); + +// Get the message of the commit responsible for a tag +async function getCommitUsingTagName(tagName) { + // Get the tag SHA using the provided tag name + const refData = await octokit.git.getRef({ + owner: GH_OWNER, + repo: GH_REPO, + ref: `tags/${tagName}`, + }); + if (refData.status !== 200) { + console.error('refData:'); + console.error(refData); + throw new Error( + 'Something went wrong when getting the tag SHA using tag name', + ); + } + const tagSha = refData.data.object.sha; + console.log(`SHA for the tag ${TAG_NAME} is ${tagSha}`); + + // Get the commit SHA using the tag SHA + const tagData = await octokit.git.getTag({ + owner: GH_OWNER, + repo: GH_REPO, + tag_sha: tagSha, + }); + if (tagData.status !== 200) { + console.error('tagData:'); + console.error(tagData); + throw new Error( + 'Something went wrong when getting the commit SHA using tag SHA', + ); + } + const commitSha = tagData.data.object.sha; + console.log( + `The commit for the tag is https://github.com/backstage/backstage/commit/${commitSha}`, + ); + + // Get the commit message using the commit SHA + const commitData = await octokit.git.getCommit({ + owner: GH_OWNER, + repo: GH_REPO, + commit_sha: commitSha, + }); + if (commitData.status !== 200) { + console.error('commitData:'); + console.error(commitData); + throw new Error( + 'Something went wrong when getting the commit message using commit SHA', + ); + } + + // Example Commit Message + // Merge pull request #3555 from backstage/changeset-release/master Version Packages + return { sha: commitSha, message: commitData.data.message }; +} + +// There is a PR number in our expected commit message. Get the description of that PR. +async function getReleaseDescriptionFromCommit(commit) { + let pullRequestBody = undefined; + + const { data: pullRequests } = + await octokit.repos.listPullRequestsAssociatedWithCommit({ + owner: GH_OWNER, + repo: GH_REPO, + commit_sha: commit.sha, + }); + if (pullRequests.length === 1) { + pullRequestBody = pullRequests[0].body; + } else { + console.warn( + `Found ${pullRequests.length} pull requests for commit ${commit.sha}, falling back to parsing commit message`, + ); + + // It should exactly match the pattern of changeset commit message, or else will abort. + const expectedMessage = RegExp(EXPECTED_COMMIT_MESSAGE); + if (!expectedMessage.test(commit.message)) { + throw new Error( + `Expected regex did not match commit message: ${commit.message}`, + ); + } + + // Get the PR description from the commit message + const prNumber = commit.message.match(expectedMessage).groups.prNumber; + console.log( + `Identified the changeset Pull request - https://github.com/backstage/backstage/pull/${prNumber}`, + ); + + const { data } = await octokit.pulls.get({ + owner: GH_OWNER, + repo: GH_REPO, + pull_number: prNumber, + }); + + pullRequestBody = data.body; + } + + // Use the PR description to prepare for the release description + const isChangesetRelease = commit.message.includes(CHANGESET_RELEASE_BRANCH); + if (isChangesetRelease) { + const lines = pullRequestBody.split('\n'); + return lines.slice(lines.indexOf('# Releases') + 1).join('\n'); + } + + return pullRequestBody; +} + +// Create Release on GitHub. +async function createRelease(releaseDescription) { + // Create draft release if BOOL_CREATE_RELEASE is undefined + // Publish release if BOOL_CREATE_RELEASE is not undefined + const boolCreateDraft = !BOOL_CREATE_RELEASE; + + const releaseResponse = await octokit.repos.createRelease({ + owner: GH_OWNER, + repo: GH_REPO, + tag_name: TAG_NAME, + name: TAG_NAME, + body: releaseDescription, + draft: boolCreateDraft, + prerelease: Boolean(semver.prerelease(TAG_NAME)), + }); + + if (releaseResponse.status === 201) { + if (boolCreateDraft) { + console.log('Created draft release! Click Publish to notify users.'); + } else { + console.log('Published release!'); + } + console.log(releaseResponse.data.html_url); + } else { + console.error(releaseResponse); + throw new Error('Something went wrong when creating the release.'); + } +} + +async function main() { + const commit = await getCommitUsingTagName(TAG_NAME); + const releaseDescription = await getReleaseDescriptionFromCommit(commit); + await createRelease(releaseDescription); +} + +main().catch(error => { + console.error(error.stack); + process.exit(1); +}); diff --git a/scripts/create-release-tag.js b/scripts/create-release-tag.js new file mode 100644 index 0000000000..549c5736b9 --- /dev/null +++ b/scripts/create-release-tag.js @@ -0,0 +1,84 @@ +#!/usr/bin/env node +/* eslint-disable import/no-extraneous-dependencies */ +/* + * Copyright 2020 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +const { Octokit } = require('@octokit/rest'); +const path = require('path'); +const fs = require('fs-extra'); + +const baseOptions = { + owner: 'Oriflame', + repo: 'backstage-plugins', +}; + +async function getCurrentReleaseTag() { + const rootPath = path.resolve(__dirname, '../package.json'); + return fs.readJson(rootPath).then(_ => _.version); +} + +async function createGitTag(octokit, commitSha, tagName) { + const annotatedTag = await octokit.git.createTag({ + ...baseOptions, + tag: tagName, + message: tagName, + object: commitSha, + type: 'commit', + }); + + try { + await octokit.git.createRef({ + ...baseOptions, + ref: `refs/tags/${tagName}`, + sha: annotatedTag.data.sha, + }); + } catch (ex) { + if ( + ex.status === 422 && + ex.response.data.message === 'Reference already exists' + ) { + throw new Error(`Tag ${tagName} already exists in repository`); + } + console.error(`Tag creation for ${tagName} failed`); + throw ex; + } +} + +async function main() { + if (!process.env.GITHUB_SHA) { + throw new Error('GITHUB_SHA is not set'); + } + if (!process.env.GITHUB_TOKEN) { + throw new Error('GITHUB_TOKEN is not set'); + } + + const commitSha = process.env.GITHUB_SHA; + const octokit = new Octokit({ auth: process.env.GITHUB_TOKEN }); + + const releaseVersion = await getCurrentReleaseTag(); + const tagName = `v${releaseVersion}`; + + console.log(`Creating release tag ${tagName} at ${commitSha}`); + await createGitTag(octokit, commitSha, tagName); + + console.log(`::set-output name=tag_name::${tagName}`); + console.log(`::set-output name=version::${releaseVersion}`); +} + +main().catch(error => { + console.error(error.stack); + process.exit(1); +}); diff --git a/scripts/prepare-release.js b/scripts/prepare-release.js new file mode 100644 index 0000000000..093a198374 --- /dev/null +++ b/scripts/prepare-release.js @@ -0,0 +1,353 @@ +#!/usr/bin/env node +/* eslint-disable import/no-extraneous-dependencies */ +/* + * Copyright 2022 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +const fs = require('fs-extra'); +const semver = require('semver'); +const { getPackages } = require('@manypkg/get-packages'); +const path = require('path'); +const { execFile: execFileCb } = require('child_process'); +const { promisify } = require('util'); +const { default: parseChangeset } = require('@changesets/parse'); + +const execFile = promisify(execFileCb); + +// All of these are considered to be main-line release branches +const MAIN_BRANCHES = ['master', 'origin/master', 'changeset-release/master']; + +// This prefix is used for patch branches, followed by the release version +// For example, `patch/v1.2.0` +const PATCH_BRANCH_PREFIX = 'patch/v'; + +const DEPENDENCY_TYPES = [ + 'dependencies', + 'devDependencies', + 'optionalDependencies', + 'peerDependencies', +]; + +/** + * Finds the current stable release version of the repo, looking at + * the current commit and backwards, finding the first commit were a + * stable version is present. + */ +async function findCurrentReleaseVersion(repo) { + const rootPkgPath = path.resolve(repo.root.dir, 'package.json'); + const pkg = await fs.readJson(rootPkgPath); + + if (!semver.prerelease(pkg.version)) { + return pkg.version; + } + + const { stdout: revListStr } = await execFile('git', [ + 'rev-list', + 'HEAD', + '--', + 'package.json', + ]); + const revList = revListStr.trim().split(/\r?\n/); + + for (const rev of revList) { + const { stdout: pkgJsonStr } = await execFile('git', [ + 'show', + `${rev}:package.json`, + ]); + if (pkgJsonStr) { + const pkgJson = JSON.parse(pkgJsonStr); + if (!semver.prerelease(pkgJson.version)) { + return pkgJson.version; + } + } + } + + throw new Error('No stable release found'); +} + +/** + * Finds the tip of the patch branch of a given release version. + * Returns undefined if no patch branch exists. + */ +async function findTipOfPatchBranch(repo, release) { + try { + await execFile('git', ['fetch', 'origin', PATCH_BRANCH_PREFIX + release], { + shell: true, + cwd: repo.root.dir, + }); + } catch (error) { + if (error.stderr?.match(/fatal: couldn't find remote ref/i)) { + return undefined; + } + throw error; + } + const { stdout: refStr } = await execFile('git', ['rev-parse', 'FETCH_HEAD']); + return refStr.trim(); +} + +/** + * Returns a map of packages to their versions for any package version + * in that does not match the current version in the working directory. + */ +async function detectPatchVersionsBetweenRefs(repo, baseRef, ref) { + const patchVersions = new Map(); + + for (const pkg of repo.packages) { + const pkgJsonPath = path.join( + path.relative(repo.root.dir, pkg.dir), + 'package.json', + ); + try { + const { stdout: basePkgJsonStr } = await execFile('git', [ + 'show', + `${baseRef}:${pkgJsonPath}`, + ]); + + const { stdout: pkgJsonStr } = await execFile('git', [ + 'show', + `${ref}:${pkgJsonPath}`, + ]); + if (basePkgJsonStr && pkgJsonStr) { + const basePkgJson = JSON.parse(basePkgJsonStr); + const releasePkgJson = JSON.parse(pkgJsonStr); + + if (releasePkgJson.private) { + continue; + } + if (releasePkgJson.name !== basePkgJson.name) { + throw new Error( + `Mismatched package name at ${pkg.dir}, ${releasePkgJson.name} !== ${basePkgJson.name}`, + ); + } + if (releasePkgJson.version !== basePkgJson.version) { + patchVersions.set(basePkgJson.name, releasePkgJson.version); + } + } + } catch (error) { + if ( + error.stderr?.match(/^fatal: Path .* exists on disk, but not in .*$/im) + ) { + console.log(`Skipping new package ${pkg.packageJson.name}`); + continue; + } + throw error; + } + } + + return patchVersions; +} + +/** + * Bumps up the versions of packages to account for + * the base versions that are set in .changeset/patched.json. + * This may be needed when we have made emergency releases. + */ +async function applyPatchVersions(repo, patchVersions) { + const pendingVersionBumps = new Map(); + + for (const [name, patchVersion] of patchVersions) { + const pkg = repo.packages.find(p => p.packageJson.name === name); + if (!pkg) { + throw new Error(`Package ${name} not found`); + } + + if (!semver.valid(patchVersion)) { + throw new Error( + `Invalid base version ${patchVersion} for package ${name}`, + ); + } + + if (semver.gte(pkg.packageJson.version, patchVersion)) { + console.log( + `No need to bump ${name} ${pkg.packageJson.version} is already ahead of ${patchVersion}`, + ); + continue; + } + + let targetVersion = patchVersion; + + // If we're currently in a pre-release we need to manually execute the + // patch bump up to the next version. And we also need to make sure we + // resume the releases at the same pre-release tag. + const currentPrerelease = semver.prerelease(pkg.packageJson.version); + if (currentPrerelease) { + const parsed = semver.parse(targetVersion); + parsed.inc('patch'); + parsed.prerelease = currentPrerelease; + targetVersion = parsed.format(); + } + + pendingVersionBumps.set(name, { + targetVersion, + targetRange: `^${targetVersion}`, + }); + } + + for (const { dir, packageJson } of [repo.root, ...repo.packages]) { + let hasChanges = false; + + if (pendingVersionBumps.has(packageJson.name)) { + packageJson.version = pendingVersionBumps.get( + packageJson.name, + ).targetVersion; + hasChanges = true; + } + + for (const depType of DEPENDENCY_TYPES) { + const deps = packageJson[depType]; + for (const depName of Object.keys(deps ?? {})) { + const currentRange = deps[depName]; + if (currentRange === '*' || currentRange === '') { + continue; + } + + if (pendingVersionBumps.has(depName)) { + const pendingBump = pendingVersionBumps.get(depName); + console.log( + `Replacing ${depName} ${currentRange} with ${pendingBump.targetRange} in ${depType} of ${packageJson.name}`, + ); + deps[depName] = pendingBump.targetRange; + hasChanges = true; + } + } + } + + if (hasChanges) { + await fs.writeJson(path.resolve(dir, 'package.json'), packageJson, { + spaces: 2, + encoding: 'utf8', + }); + } + } +} + +/** + * Detects any patched packages version since the most recent release on + * the main branch, and then bumps all packages in the repo accordingly. + */ +async function updatePackageVersions(repo) { + const currentRelease = await findCurrentReleaseVersion(repo); + console.log(`Current release version: ${currentRelease}`); + + const patchRef = await findTipOfPatchBranch(repo, currentRelease); + if (patchRef) { + console.log(`Tip of the patch branch: ${patchRef}`); + + const patchVersions = await detectPatchVersionsBetweenRefs( + repo, + `v${currentRelease}`, + patchRef, + ); + if (patchVersions.size > 0) { + console.log( + `Found ${patchVersions.size} packages that were patched since the last release`, + ); + for (const [name, version] of patchVersions) { + console.log(` ${name}: ${version}`); + } + + await applyPatchVersions(repo, patchVersions); + } else { + console.log('No packages were patched since the last release'); + } + } else { + console.log('No patch branch found'); + } +} + +/** + * Returns the mode and tag that is currently set + * in the .changeset/pre.json file + */ +async function getPreInfo(repo) { + const pre = path.join(repo.root.dir, '.changeset', 'pre.json'); + if (!(await fs.pathExists(pre))) { + return { mode: undefined, tag: undefined }; + } + + const { mode, tag } = await fs.readJson(pre); + return { mode, tag }; +} + +/** + * Returns the name of the current git branch + */ +async function getCurrentBranch(repo) { + const { stdout } = await execFile( + 'git', + ['rev-parse', '--abbrev-ref', 'HEAD'], + { cwd: repo.root.dir, shell: true }, + ); + return stdout.trim(); +} + +/** + * Bumps the release version in the root package.json. + * + * This takes into account whether we're in pre-release mode or on a patch branch. + */ +async function updateBackstageReleaseVersion(repo, type) { + const { mode: preMode, tag: preTag } = await getPreInfo(repo); + + const { version: currentVersion } = repo.root.packageJson; + + let nextVersion; + if (type === 'minor') { + if (preMode === 'pre') { + if (semver.prerelease(currentVersion)) { + nextVersion = semver.inc(currentVersion, 'pre', preTag); + } else { + nextVersion = semver.inc(currentVersion, 'preminor', preTag); + } + } else if (preMode === 'exit') { + nextVersion = semver.inc(currentVersion, 'patch'); + } else { + nextVersion = semver.inc(currentVersion, 'minor'); + } + } else if (type === 'patch') { + if (preMode) { + throw new Error(`Unexpected pre mode ${preMode} on current branch`); + } + nextVersion = semver.inc(currentVersion, 'patch'); + } + + await fs.writeJson( + path.join(repo.root.dir, 'package.json'), + { + ...repo.root.packageJson, + version: nextVersion, + }, + { spaces: 2, encoding: 'utf8' }, + ); +} + +async function main() { + const repo = await getPackages(__dirname); + const branchName = await getCurrentBranch(repo); + const isMainBranch = MAIN_BRANCHES.includes(branchName); + + console.log(`Current branch: ${branchName}`); + if (isMainBranch) { + console.log('Main release, updating package versions'); + await updatePackageVersions(repo); + } + + await updateBackstageReleaseVersion(repo, isMainBranch ? 'minor' : 'patch'); +} + +main().catch(error => { + console.error(error.stack); + process.exit(1); +});