From e1e96ed99da5118c8b34e0d58b7a666632f7a1f2 Mon Sep 17 00:00:00 2001 From: Paul Schultz Date: Tue, 29 Oct 2024 11:20:41 -0500 Subject: [PATCH] chore: add workflow to automate backstage updates (#1832) Signed-off-by: Paul Schultz --- .github/workflows/update-backstage.yaml | 87 +++++ ...-build.yaml => versioned-build-image.yaml} | 6 +- scripts/update-backstage.mjs | 358 ++++++++++++------ 3 files changed, 335 insertions(+), 116 deletions(-) create mode 100644 .github/workflows/update-backstage.yaml rename .github/workflows/{versioned-docker-build.yaml => versioned-build-image.yaml} (96%) diff --git a/.github/workflows/update-backstage.yaml b/.github/workflows/update-backstage.yaml new file mode 100644 index 0000000000..eb142f278e --- /dev/null +++ b/.github/workflows/update-backstage.yaml @@ -0,0 +1,87 @@ +# Copyright 2024 The Janus IDP 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. + +name: Update Backstage + +# enforce only one release action per release branch at a time +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: false + +on: + workflow_dispatch: + inputs: + release: + description: 'Backstage release version (e.g., 1.2.3)' + required: false + deps: + # Use backstage,backstage-community,janus-idp,roadiehq,immobiliarelabs,pagerduty,parfuemerie-douglas to update everything + description: 'Comma-separated list of dependencies to update (e.g., backstage,backstage-community)' + required: false + skip-export-dynamic: + description: 'Skip updating dynamic plugins' + required: false + type: boolean + +jobs: + create-pr: + name: Create PR + runs-on: ubuntu-latest + + steps: + - name: Generate token + id: generate-token + uses: actions/create-github-app-token@31c86eb3b33c9b601a1f60f98dcbfd1d70f379b4 # v1.10.3 + with: + app-id: ${{ vars.JANUS_IDP_GITHUB_APP_ID }} + private-key: ${{ secrets.JANUS_IDP_GITHUB_APP_PRIVATE_KEY }} + + - name: Checkout + uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4 + with: + token: ${{ steps.generate-token.outputs.token }} + + - name: Setup Node.js + uses: actions/setup-node@1e60f620b9541d16bece96c5465dc8ee9832be0b # v4 + with: + node-version-file: '.nvmrc' + registry-url: 'https://registry.npmjs.org' + + - name: Install dependencies + run: yarn install + + - name: Run script + run: | + yarn versions:bump \ + ${{ inputs.release && format('--release {0}', inputs.release) }} \ + ${{ inputs.deps && format('--deps {0}', inputs.deps) }} \ + ${{ inputs.skip-export-dynamic && '--skip-export-dynamic' }} + + - name: Create Pull Request + uses: peter-evans/create-pull-request@v7 + with: + token: ${{ steps.generate-token.outputs.token }} + commit-message: | + ${{ inputs.release && format('feat: Update Backstage to {0}', inputs.release) }} + ${{ inputs.deps && 'feat: Update Backstage plugins dependencies' }} + ${{ !inputs.release && !inputs.deps && 'feat: Update Backstage to the latest version' }} + title: | + ${{ inputs.release && format('feat: Update Backstage to {0}', inputs.release) }} + ${{ inputs.deps && 'feat: Update Backstage plugins dependencies' }} + ${{ !inputs.release && !inputs.deps && 'feat: Update Backstage to the latest version' }} + branch: | + ${{ inputs.release && format('backstage/', inputs.release) }} + ${{ inputs.deps && 'backstage/plugin-dependencies' }} + ${{ !inputs.release && !inputs.deps && 'backstage/latest' }} + base: main \ No newline at end of file diff --git a/.github/workflows/versioned-docker-build.yaml b/.github/workflows/versioned-build-image.yaml similarity index 96% rename from .github/workflows/versioned-docker-build.yaml rename to .github/workflows/versioned-build-image.yaml index c459961d57..5045cd09db 100644 --- a/.github/workflows/versioned-docker-build.yaml +++ b/.github/workflows/versioned-build-image.yaml @@ -13,7 +13,7 @@ # limitations under the License. # on push of a tag, trigger a container build for that tag and push to http://quay.io/janus-idp/backstage-showcase -name: Versioned Docker Build +name: Versioned on: push: @@ -28,8 +28,8 @@ env: REGISTRY: quay.io jobs: - versioned-docker-build: - name: Versioned Docker Build + build-image: + name: Build Image runs-on: ubuntu-latest permissions: contents: read diff --git a/scripts/update-backstage.mjs b/scripts/update-backstage.mjs index 7b5281e6ee..be823cc286 100644 --- a/scripts/update-backstage.mjs +++ b/scripts/update-backstage.mjs @@ -1,154 +1,286 @@ -import glob from 'glob'; -import { execSync } from 'node:child_process'; -import { existsSync, readFileSync, readdirSync, writeFileSync } from 'node:fs'; -import { dirname, join } from 'node:path'; -import { fileURLToPath } from 'node:url'; -import semver from 'semver'; -import { updateBuildMetadata } from './update-metadata.mjs'; +import glob from "glob"; +import { execSync } from "node:child_process"; +import { existsSync, readFileSync, readdirSync, writeFileSync } from "node:fs"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; +import semver from "semver"; + +import { updateBuildMetadata } from "./update-metadata.mjs"; const __filename = fileURLToPath(import.meta.url); const __dirname = dirname(__filename); +const ROOT_DIR = join(__dirname, ".."); +const DYNAMIC_PLUGINS_DIR = join(ROOT_DIR, "dynamic-plugins/wrappers"); +const BACKSTAGE_JSON_PATH = join(ROOT_DIR, "backstage.json"); +const BUILD_METADATA_PATH = join( + ROOT_DIR, + "packages/app/src/build-metadata.json", +); +const PACKAGE_JSON_GLOB = "**/package.json"; +const IGNORE_GLOB = ["**/node_modules/**", "**/dist-dynamic/**"]; +const BACKSTAGE_RELEASES_API = + "https://api.github.com/repos/backstage/backstage/releases"; + // Change directory to the root of the project -process.chdir(join(__dirname, '..')); +process.chdir(ROOT_DIR); +/** + * Pins dependencies in package.json files by removing the caret (^) from version ranges. + */ function pinDependencies() { - // Find all package.json files in the project while ignoring node_modules and dist-dynamic - const packageJsonFiles = glob.sync('**/package.json', { - ignore: ['**/node_modules/**', '**/dist-dynamic/**'], + const packageJsonFiles = glob.sync(PACKAGE_JSON_GLOB, { + ignore: IGNORE_GLOB, }); for (const packageJsonFile of packageJsonFiles) { - const packageJsonPath = join(process.cwd(), packageJsonFile); - const packageJson = readFileSync(packageJsonPath, 'utf8'); - - // Replace all instances of "^" with "" in package.json - const modifiedContent = packageJson.replace(/"\^/g, '"'); - - writeFileSync(packageJsonPath, modifiedContent, 'utf8'); + try { + const packageJsonPath = join(process.cwd(), packageJsonFile); + const packageJson = JSON.parse(readFileSync(packageJsonPath, "utf8")); + // Replace all instances of "^" with "" in package.json dependencies + for (const depType of [ + "dependencies", + "devDependencies", + "peerDependencies", + ]) { + if (packageJson[depType]) { + for (const depName in packageJson[depType]) { + packageJson[depType][depName] = packageJson[depType][ + depName + ].replace(/^\^/, ""); + } + } + } + writeFileSync( + packageJsonPath, + JSON.stringify(packageJson, null, 2) + "\n", + "utf8", + ); + } catch (error) { + console.error(`Error processing ${packageJsonFile}:`, error); + } } } -function updateDynamicPluginVersions() { - // Change directory to the dynamic-plugins/wrappers folder - process.chdir('./dynamic-plugins/wrappers'); - - // Loop through all subdirectories and update the version in package.json - for (const dir of readdirSync('.', { withFileTypes: true })) { - if (!dir.isDirectory()) { - continue; - } - - process.chdir(dir.name); - - // Extract the value of the "name" key from package.json - const packageJson = JSON.parse(readFileSync('package.json', 'utf8')); - const name = packageJson.name; - - // Extract the list from the "dependencies" object - const deps = Object.entries(packageJson.dependencies); - - // Loop over each key in the "dependencies" object - for (const [depName, depVersion] of deps) { - // Replace "@" with "" and "/" with "-" - const modifiedDepName = depName.replace(/^@/, '').replace(/\//g, '-'); - - // Check if the modified dependency name matches the "name" value +/** + * Updates the version of a dynamic plugin in its package.json file. + * + * @param {string} pluginDir - The path to the dynamic plugin directory. + */ +function updateDynamicPluginVersion(pluginDir) { + try { + const packageJsonPath = join(pluginDir, "package.json"); + const packageJson = JSON.parse(readFileSync(packageJsonPath, "utf8")); + const { name, dependencies } = packageJson; + + for (const [depName, depVersion] of Object.entries(dependencies)) { + const modifiedDepName = depName.replace(/^@/, "").replace(/\//g, "-"); if (modifiedDepName === name) { console.log(`Updating ${name} to ${depVersion}...`); - - // Update the value of the "version" key in package.json packageJson.version = depVersion; } + } - // Update hoisted dependency version if incorrect - if (existsSync('./dist-dynamic/package.json')) { - const distDynamicPackageJson = JSON.parse( - readFileSync('./dist-dynamic/package.json', 'utf8'), - ); - - const distDynamicDeps = Object.entries( - distDynamicPackageJson.peerDependencies, - ); - - const newDeps = deps.reduce((prev, [depName, depVersion]) => { - const distDynamicDep = distDynamicDeps.find( - ([distDynamicDepName]) => distDynamicDepName === depName, - ); - - if (distDynamicDep) { - const [, distDynamicDepVersion] = distDynamicDep; - - prev[depName] = distDynamicDepVersion; - } else { - prev[depName] = depVersion; - } + // Update hoisted dependency version if incorrect (simplified) + const distDynamicPackageJsonPath = join( + pluginDir, + "dist-dynamic/package.json", + ); + if (existsSync(distDynamicPackageJsonPath)) { + const distDynamicPackageJson = JSON.parse( + readFileSync(distDynamicPackageJsonPath, "utf8"), + ); + packageJson.dependencies = Object.fromEntries( + Object.entries(dependencies).map(([depName, depVersion]) => [ + depName, + distDynamicPackageJson.peerDependencies[depName] || depVersion, + ]), + ); + } - return prev; - }, {}); + writeFileSync( + packageJsonPath, + JSON.stringify(packageJson, null, 2) + "\n", + "utf8", + ); + } catch (error) { + console.error(`Error updating plugin version in ${pluginDir}:`, error); + } +} - packageJson.dependencies = newDeps; +/** + * Updates the versions of all dynamic plugins in the dynamic-plugins directory. + */ +function updateDynamicPluginVersions() { + try { + process.chdir(DYNAMIC_PLUGINS_DIR); + for (const dir of readdirSync(".", { withFileTypes: true })) { + if (dir.isDirectory()) { + updateDynamicPluginVersion(join(DYNAMIC_PLUGINS_DIR, dir.name)); } } - - const modifiedContent = `${JSON.stringify(packageJson, null, 2)}\n`; - - writeFileSync('package.json', modifiedContent, 'utf8'); - - process.chdir('..'); + } catch (error) { + console.error("Error updating dynamic plugin versions:", error); + } finally { + process.chdir(ROOT_DIR); } - - // Change directory to the root of the project - process.chdir('../..'); } +/** + * Fetches the latest Backstage version from the GitHub API. + * + * @returns {Promise} The latest Backstage version. + */ async function getLatestBackstageVersion() { - const res = await fetch( - 'https://api.github.com/repos/backstage/backstage/releases', - ); - const data = await res.json(); - const versions = data.map(release => release.tag_name); - const filteredVersions = versions.filter( - version => semver.valid(version) && !semver.prerelease(version), - ); - return semver.maxSatisfying(filteredVersions, '*').substring(1); + try { + const res = await fetch(BACKSTAGE_RELEASES_API); + const data = await res.json(); + const versions = data + .map((release) => release.tag_name) + .filter( + (version) => semver.valid(version) && !semver.prerelease(version), + ); + return semver.maxSatisfying(versions, "*").substring(1); + } catch (error) { + console.error("Error fetching latest Backstage version:", error); + throw error; + } } +/** + * Updates the Backstage version in the backstage.json file. + * + * @param {string} version - The new Backstage version. + */ function updateBackstageVersionFile(version) { - const modifiedContent = `${JSON.stringify({ version }, null, 2)}\n`; + try { + const data = { version }; + writeFileSync( + BACKSTAGE_JSON_PATH, + JSON.stringify(data, null, 2) + "\n", + "utf8", + ); + } catch (error) { + console.error("Error updating Backstage version file:", error); + } +} + +/** + * Updates the `backstage.supported-versions` field in package.json files under the `DYNAMIC_PLUGINS_DIR`. + * + * @param {string} backstageVersion - The Backstage version to set. + */ +function updateSupportedBackstageVersions(backstageVersion) { + const packageJsonFiles = glob.sync(PACKAGE_JSON_GLOB, { + cwd: DYNAMIC_PLUGINS_DIR, // Search only within DYNAMIC_PLUGINS_DIR + ignore: IGNORE_GLOB, + }); - writeFileSync('backstage.json', modifiedContent, 'utf8'); + for (const packageJsonFile of packageJsonFiles) { + try { + const packageJsonPath = join(DYNAMIC_PLUGINS_DIR, packageJsonFile); + const packageJson = JSON.parse(readFileSync(packageJsonPath, "utf8")); + + // Update backstage.supported-versions + packageJson["backstage"] = { + ...packageJson["backstage"], + "supported-versions": backstageVersion, + }; + + writeFileSync( + packageJsonPath, + JSON.stringify(packageJson, null, 2) + "\n", + "utf8", + ); + } catch (error) { + console.error(`Error processing ${packageJsonFile}:`, error); + } + } } -console.log('Bumping version...'); -execSync('backstage-cli versions:bump --pattern @{backstage,backstage-community,janus-idp,roadiehq,immobiliarelabs,pagerduty,parfuemerie-douglas}/*', { stdio: 'inherit' }); +/** + * The main function that orchestrates the update process. + */ +async function main() { + try { + // Parse command line arguments + const args = process.argv.slice(2); + const releaseIndex = args.indexOf("--release"); + const depsIndex = args.indexOf("--deps"); + const skipExportDynamicIndex = args.indexOf("--skip-export-dynamic"); + const hasReleaseFlag = releaseIndex !== -1; + const hasDepsFlag = depsIndex !== -1; + const hasSkipExportDynamicFlag = skipExportDynamicIndex !== -1; + + // Ensure that --deps and --release are not used together + if (hasReleaseFlag && hasDepsFlag) { + console.error( + "Error: The --deps and --release flags cannot be used together.", + ); + process.exit(1); + } -console.log('Pinning all dependencies...'); -pinDependencies(); + // Construct the command for bumping versions + let bumpCommand = "backstage-cli versions:bump --skip-install"; + let releaseVersion = ""; + if (hasReleaseFlag) { + releaseVersion = args[releaseIndex + 1]; + if (!releaseVersion) { + console.error("Error: The --release flag requires a version argument."); + process.exit(1); + } + bumpCommand += ` --release ${releaseVersion}`; + } else if (hasDepsFlag) { + const deps = args[depsIndex + 1]?.split(","); + if (deps.length > 0) { + bumpCommand += ` --pattern @{${deps.join(",")}}/*`; + } + } -console.log('Updating dynamic plugin versions...'); -updateDynamicPluginVersions(); + console.log("Bumping version..."); + execSync(bumpCommand, { stdio: "inherit" }); -console.log('Updating lockfile...'); -execSync('yarn install', { stdio: 'inherit' }); + console.log("Pinning all dependencies..."); + pinDependencies(); -console.log('Deduping lockfile...'); -execSync('yarn dedupe', { stdio: 'inherit' }); + console.log("Updating wrapper versions..."); + updateDynamicPluginVersions(); -console.log('Updating dynamic-plugins folder...'); -execSync('yarn run export-dynamic:clean', { - stdio: 'inherit', -}); + if (!hasReleaseFlag) { + console.log("Fetching latest Backstage version..."); + releaseVersion = await getLatestBackstageVersion(); + } -console.log('Fetching latest Backstage version...'); -const backstageVersion = await getLatestBackstageVersion(); + console.log(`Updating wrappers supported versions to ${releaseVersion}...`); + updateSupportedBackstageVersions(releaseVersion); -console.log(`Updating backstage.json to ${backstageVersion}...`); -updateBackstageVersionFile(backstageVersion); + console.log("Updating lockfile..."); + execSync("yarn install", { stdio: "inherit" }); -console.log('Updating packages/app/src/build-metadata.json ...'); -updateBuildMetadata(backstageVersion); + console.log("Deduping lockfile..."); + execSync("yarn dedupe", { stdio: "inherit" }); -console.log( - `Successfully updated the Backstage Showcase to ${backstageVersion}!`, -); + if (hasSkipExportDynamicFlag) { + console.log( + `Skipping 'Updating dynamic-plugins folder...' step because '--skip-export-dynamic' is provided.`, + ); + } else { + console.log("Updating dynamic-plugins folder..."); + execSync("yarn run export-dynamic:clean", { stdio: "inherit" }); + } + + console.log(`Updating backstage.json to ${releaseVersion}...`); + updateBackstageVersionFile(releaseVersion); + + console.log(`Updating ${BUILD_METADATA_PATH}...`); + updateBuildMetadata(releaseVersion); + + console.log( + `Successfully updated the Backstage Showcase to ${releaseVersion}!`, + ); + } catch (error) { + console.error("An error occurred during the update process:", error); + } +} + +main();