From e444b43035711de4128ce8de79ee3ecb6b5d3294 Mon Sep 17 00:00:00 2001 From: Sam Verschueren Date: Thu, 27 Jun 2024 12:10:31 +0200 Subject: [PATCH] ci: add automated releases --- .github/workflows/publish.yaml | 36 ++++ .gitignore | 2 +- integration/README.md | 3 + .../create-tutorial.test.ts.snap | 169 ++++++++++++++++++ integration/cli/create-tutorial.test.ts | 129 +++++++++++++ integration/package.json | 13 ++ packages/cli/package.json | 5 +- packages/cli/scripts/pre-pack.js | 60 +++++-- .../cli/src/commands/create/dependencies.ts | 127 +++++++++++++ packages/cli/src/commands/create/index.ts | 28 ++- pnpm-workspace.yaml | 1 + 11 files changed, 534 insertions(+), 39 deletions(-) create mode 100644 .github/workflows/publish.yaml create mode 100644 integration/README.md create mode 100644 integration/cli/__snapshots__/create-tutorial.test.ts.snap create mode 100644 integration/cli/create-tutorial.test.ts create mode 100644 integration/package.json create mode 100644 packages/cli/src/commands/create/dependencies.ts diff --git a/.github/workflows/publish.yaml b/.github/workflows/publish.yaml new file mode 100644 index 000000000..9ace8dfbd --- /dev/null +++ b/.github/workflows/publish.yaml @@ -0,0 +1,36 @@ +name: Publish +on: + workflow_dispatch: + inputs: + version: + description: 'Version to publish' + required: true + type: string + tag: + description: 'Tag to publish' + required: false + type: string + default: 'latest' + +jobs: + publish_packages: + name: Publish packages + runs-on: ubuntu-latest + steps: + - name: Setup + uses: pnpm/action-setup@v4 + with: + version: 8 + - name: Checkout + uses: actions/checkout@v4 + - name: Bump version + run: pnpm --recursive --filter "@tutorialkit/*" --filter tutorialkit --filter create-tutorial exec npm version --allow-same-version ${{ inputs.version }} + # Generate changelogs + - name: Create Pull Request + uses: peter-evans/create-pull-request@v6 + with: + commit-message: "chore: bump version ${{ inputs.version }}" + title: "chore: bump version ${{ inputs.version }}" + body: "Bump packages to version ${{ inputs.version }}" + reviewers: SamVerschueren,d3lm,Nemikolh + branch: chore/bump-version diff --git a/.gitignore b/.gitignore index feaa4f038..e3eec48b9 100644 --- a/.gitignore +++ b/.gitignore @@ -18,7 +18,7 @@ dist-ssr *.sln *.sw? .pnpm-store -/tutorialkit/template +/packages/cli/template tsconfig.tsbuildinfo tsconfig.build.tsbuildinfo .tmp diff --git a/integration/README.md b/integration/README.md new file mode 100644 index 000000000..b0ba778b9 --- /dev/null +++ b/integration/README.md @@ -0,0 +1,3 @@ +# Integration + +TODO add comment diff --git a/integration/cli/__snapshots__/create-tutorial.test.ts.snap b/integration/cli/__snapshots__/create-tutorial.test.ts.snap new file mode 100644 index 000000000..677ea2bb3 --- /dev/null +++ b/integration/cli/__snapshots__/create-tutorial.test.ts.snap @@ -0,0 +1,169 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`npm > should create a project 1`] = ` +[ + ".gitignore", + ".vscode", + ".vscode/extensions.json", + ".vscode/launch.json", + ".vscode/settings.json", + "README.md", + "astro.config.ts", + "icons", + "icons/languages", + "icons/languages/css.svg", + "icons/languages/html.svg", + "icons/languages/js.svg", + "icons/languages/json.svg", + "icons/languages/markdown.svg", + "icons/languages/sass.svg", + "icons/languages/ts.svg", + "package-lock.json", + "package.json", + "public", + "public/favicon.svg", + "public/logo.svg", + "src", + "src/content", + "src/content/config.ts", + "src/content/tutorial", + "src/content/tutorial/1-basics", + "src/content/tutorial/1-basics/1-introduction", + "src/content/tutorial/1-basics/1-introduction/1-welcome", + "src/content/tutorial/1-basics/1-introduction/1-welcome/_files", + "src/content/tutorial/1-basics/1-introduction/1-welcome/_files/counter.js", + "src/content/tutorial/1-basics/1-introduction/1-welcome/_solution", + "src/content/tutorial/1-basics/1-introduction/1-welcome/_solution/counter.js", + "src/content/tutorial/1-basics/1-introduction/1-welcome/content.md", + "src/content/tutorial/1-basics/1-introduction/meta.md", + "src/content/tutorial/1-basics/meta.md", + "src/content/tutorial/meta.md", + "src/env.d.ts", + "src/templates", + "src/templates/default", + "src/templates/default/.gitignore", + "src/templates/default/counter.js", + "src/templates/default/index.html", + "src/templates/default/javascript.svg", + "src/templates/default/main.js", + "src/templates/default/package-lock.json", + "src/templates/default/package.json", + "src/templates/default/public", + "src/templates/default/public/vite.svg", + "src/templates/default/style.css", + "tsconfig.json", + "uno.config.ts", +] +`; + +exports[`pnpm > should create a project 1`] = ` +[ + ".gitignore", + ".vscode", + ".vscode/extensions.json", + ".vscode/launch.json", + ".vscode/settings.json", + "README.md", + "astro.config.ts", + "icons", + "icons/languages", + "icons/languages/css.svg", + "icons/languages/html.svg", + "icons/languages/js.svg", + "icons/languages/json.svg", + "icons/languages/markdown.svg", + "icons/languages/sass.svg", + "icons/languages/ts.svg", + "package.json", + "pnpm-lock.yaml", + "public", + "public/favicon.svg", + "public/logo.svg", + "src", + "src/content", + "src/content/config.ts", + "src/content/tutorial", + "src/content/tutorial/1-basics", + "src/content/tutorial/1-basics/1-introduction", + "src/content/tutorial/1-basics/1-introduction/1-welcome", + "src/content/tutorial/1-basics/1-introduction/1-welcome/_files", + "src/content/tutorial/1-basics/1-introduction/1-welcome/_files/counter.js", + "src/content/tutorial/1-basics/1-introduction/1-welcome/_solution", + "src/content/tutorial/1-basics/1-introduction/1-welcome/_solution/counter.js", + "src/content/tutorial/1-basics/1-introduction/1-welcome/content.md", + "src/content/tutorial/1-basics/1-introduction/meta.md", + "src/content/tutorial/1-basics/meta.md", + "src/content/tutorial/meta.md", + "src/env.d.ts", + "src/templates", + "src/templates/default", + "src/templates/default/.gitignore", + "src/templates/default/counter.js", + "src/templates/default/index.html", + "src/templates/default/javascript.svg", + "src/templates/default/main.js", + "src/templates/default/package-lock.json", + "src/templates/default/package.json", + "src/templates/default/public", + "src/templates/default/public/vite.svg", + "src/templates/default/style.css", + "tsconfig.json", + "uno.config.ts", +] +`; + +exports[`yarn > should create a project 1`] = ` +[ + ".gitignore", + ".vscode", + ".vscode/extensions.json", + ".vscode/launch.json", + ".vscode/settings.json", + "README.md", + "astro.config.ts", + "icons", + "icons/languages", + "icons/languages/css.svg", + "icons/languages/html.svg", + "icons/languages/js.svg", + "icons/languages/json.svg", + "icons/languages/markdown.svg", + "icons/languages/sass.svg", + "icons/languages/ts.svg", + "package.json", + "public", + "public/favicon.svg", + "public/logo.svg", + "src", + "src/content", + "src/content/config.ts", + "src/content/tutorial", + "src/content/tutorial/1-basics", + "src/content/tutorial/1-basics/1-introduction", + "src/content/tutorial/1-basics/1-introduction/1-welcome", + "src/content/tutorial/1-basics/1-introduction/1-welcome/_files", + "src/content/tutorial/1-basics/1-introduction/1-welcome/_files/counter.js", + "src/content/tutorial/1-basics/1-introduction/1-welcome/_solution", + "src/content/tutorial/1-basics/1-introduction/1-welcome/_solution/counter.js", + "src/content/tutorial/1-basics/1-introduction/1-welcome/content.md", + "src/content/tutorial/1-basics/1-introduction/meta.md", + "src/content/tutorial/1-basics/meta.md", + "src/content/tutorial/meta.md", + "src/env.d.ts", + "src/templates", + "src/templates/default", + "src/templates/default/.gitignore", + "src/templates/default/counter.js", + "src/templates/default/index.html", + "src/templates/default/javascript.svg", + "src/templates/default/main.js", + "src/templates/default/package-lock.json", + "src/templates/default/package.json", + "src/templates/default/public", + "src/templates/default/public/vite.svg", + "src/templates/default/style.css", + "tsconfig.json", + "uno.config.ts", + "yarn.lock", +] +`; diff --git a/integration/cli/create-tutorial.test.ts b/integration/cli/create-tutorial.test.ts new file mode 100644 index 000000000..0ac87131a --- /dev/null +++ b/integration/cli/create-tutorial.test.ts @@ -0,0 +1,129 @@ +import path from 'node:path'; +import fs from 'node:fs/promises'; +import { describe, beforeEach, afterAll, expect, it } from 'vitest'; +import { execa } from 'execa'; +import { temporaryDirectory } from 'tempy'; + +const baseDir = path.resolve(__dirname, '../..'); + +const cli = path.join(baseDir, 'packages/cli/dist/index.js'); + +interface TestContext { + projectName: string; + dest: string; +} + +const tmp = temporaryDirectory(); + +beforeEach(async (context) => { + context.projectName = Math.random().toString(36).substring(7); + context.dest = path.join(tmp, context.projectName); +}); + +afterAll(async () => { + await fs.rm(tmp, { force: true, recursive: true }); +}); + +describe('npm', () => { + const packageManager = 'npm'; + + it('should create a project', async ({ projectName, dest }) => { + await createProject(projectName, packageManager, { cwd: tmp }); + + const projectFiles = await fs.readdir(dest, { recursive: true }); + + expect(projectFiles.map(normaliseSlash).sort()).toMatchSnapshot(); + }); + + it('should create and build a project', async ({ projectName, dest }) => { + await createProject(projectName, packageManager, { cwd: tmp, install: true }); + + await execa(packageManager, ['run', 'build'], { + cwd: dest, + }); + + // remove `_astro` before taking the snapshot + await fs.rm(path.join(dest, 'dist/_astro'), { force: true, recursive: true }); + + const distFiles = await fs.readdir(path.join(dest, 'dist'), { recursive: true }); + + expect(distFiles.map(normaliseSlash).sort()).toMatchSnapshot(); + }); +}); + +describe('pnpm', () => { + const packageManager = 'pnpm'; + + it('should create a project', async ({ projectName, dest }) => { + await createProject(projectName, packageManager, { cwd: tmp }); + + const projectFiles = await fs.readdir(dest, { recursive: true }); + + expect(projectFiles.map(normaliseSlash).sort()).toMatchSnapshot(); + }); + + it('should create and build a project', async ({ projectName, dest }) => { + await createProject(projectName, packageManager, { cwd: tmp, install: true }); + + await execa(packageManager, ['run', 'build'], { + cwd: dest, + }); + + // remove `_astro` before taking the snapshot + await fs.rm(path.join(dest, 'dist/_astro'), { force: true, recursive: true }); + + const distFiles = await fs.readdir(path.join(dest, 'dist'), { recursive: true }); + + expect(distFiles.map(normaliseSlash).sort()).toMatchSnapshot(); + }); +}); + +describe('yarn', () => { + const packageManager = 'yarn'; + + it('should create a project', async ({ projectName, dest }) => { + await createProject(projectName, packageManager, { cwd: tmp }); + + const projectFiles = await fs.readdir(dest, { recursive: true }); + + expect(projectFiles.map(normaliseSlash).sort()).toMatchSnapshot(); + }); + + it('should create and build a project', async ({ projectName, dest }) => { + await createProject(projectName, packageManager, { cwd: tmp, install: true }); + + await execa(packageManager, ['run', 'build'], { + cwd: dest, + }); + + // remove `_astro` before taking the snapshot + await fs.rm(path.join(dest, 'dist/_astro'), { force: true, recursive: true }); + + const distFiles = await fs.readdir(path.join(dest, 'dist'), { recursive: true }); + + expect(distFiles.map(normaliseSlash).sort()).toMatchSnapshot(); + }); +}); + +async function createProject(name: string, packageManager: string, options: { cwd: string; install?: boolean }) { + await execa( + 'node', + [ + cli, + 'create', + name, + `--${options.install ? '' : 'no-'}install`, + '--no-git', + '--package-manager', + packageManager, + '--defaults', + ], + { + cwd: options.cwd, + }, + ); +} + +function normaliseSlash(filePath: string) { + return filePath.replace(/\\/g, '/'); +} diff --git a/integration/package.json b/integration/package.json new file mode 100644 index 000000000..16409ebde --- /dev/null +++ b/integration/package.json @@ -0,0 +1,13 @@ +{ + "name": "tutorialkit-integration", + "private": true, + "type": "module", + "scripts": { + "test": "vitest --testTimeout=300000" + }, + "dependencies": { + "execa": "^9.2.0", + "tempy": "^3.1.0", + "vitest": "^1.6.0" + } +} diff --git a/packages/cli/package.json b/packages/cli/package.json index b9efeac6d..7eb1080f5 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -16,8 +16,7 @@ "scripts": { "build": "node scripts/build.js", "prepack": "node scripts/pre-pack.js", - "postpack": "node scripts/cleanup.js", - "test": "vitest --testTimeout=300000" + "postpack": "node scripts/cleanup.js" }, "files": [ "dist", @@ -45,7 +44,7 @@ "esbuild": "^0.20.2", "esbuild-node-externals": "^1.13.1", "fs-extra": "^11.2.0", - "vitest": "^1.6.0" + "tempy": "^3.1.0" }, "engines": { "node": ">=18.18.0" diff --git a/packages/cli/scripts/pre-pack.js b/packages/cli/scripts/pre-pack.js index 0b42d0ddc..0b9c1b305 100644 --- a/packages/cli/scripts/pre-pack.js +++ b/packages/cli/scripts/pre-pack.js @@ -1,15 +1,23 @@ import fsExtra from 'fs-extra'; import ignore from 'ignore'; -import { spawn } from 'node:child_process'; import fs from 'node:fs'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; +import { execa } from 'execa'; +import { temporaryDirectoryTask } from 'tempy'; import { distFolder, overwritesFolder, templateDest, templatePath } from './_constants.js'; import { success } from './logger.js'; const __dirname = path.dirname(fileURLToPath(import.meta.url)); -await runBuild(); +const version = JSON.parse(fs.readFileSync(path.join(__dirname, '../package.json'), 'utf8')).version; + +await execa('node', [path.join(__dirname, './build.js')], { + stdio: 'inherit', + env: { + TUTORIALKIT_TEMPLATE_PATH: path.relative(distFolder, templateDest), + }, +}); const gitignore = ignore().add(await fs.readFileSync(path.join(templatePath, '.gitignore'), 'utf8')); @@ -43,26 +51,42 @@ fs.cpSync(path.join(overwritesFolder), path.join(templateDest), { // remove project references from tsconfig.json const tsconfigPath = path.join(templateDest, 'tsconfig.json'); -const tsconfig = JSON.parse(fs.readFileSync(tsconfigPath, { encoding: 'utf-8' })); +const tsconfig = JSON.parse(fs.readFileSync(tsconfigPath, 'utf8')); delete tsconfig.references; fs.writeFileSync(tsconfigPath, JSON.stringify(tsconfig, undefined, 2)); -async function runBuild() { - const exitCode = await new Promise((resolve) => { - const child = spawn('node', [path.join(__dirname, './build.js')], { - stdio: 'inherit', - env: { - ...process.env, - TUTORIALKIT_TEMPLATE_PATH: path.relative(distFolder, templateDest), - }, - }); - - child.on('close', () => resolve(child.exitCode)); - }); - - if (exitCode !== 0) { - process.exit(1); +// update dependencies +const packageJsonPath = path.join(templateDest, 'package.json'); +const packageJson = JSON.parse(fs.readFileSync(packageJsonPath, 'utf8')); + +updateWorkspaceVersions(packageJson.dependencies, version); +updateWorkspaceVersions(packageJson.devDependencies, version); + +fs.writeFileSync(packageJsonPath, JSON.stringify(packageJson, undefined, 2)); + +// generate lockfiles +await temporaryDirectoryTask(async (tmp) => { + fs.cpSync(path.join(templateDest, 'package.json'), path.join(tmp, 'package.json')); + + await execa('npm', ['install', '--package-lock-only'], { cwd: tmp }); + await execa('pnpm', ['install', '--lockfile-only'], { cwd: tmp }); + await execa('yarn', ['install'], { cwd: tmp }); + + fs.cpSync(path.join(tmp, 'package-lock.json'), path.join(templateDest, 'package-lock.json')); + fs.cpSync(path.join(tmp, 'pnpm-lock.yaml'), path.join(templateDest, 'pnpm-lock.yaml')); + fs.cpSync(path.join(tmp, 'yarn.lock'), path.join(templateDest, 'yarn.lock')); +}); + +success('Lockfiles generated'); + +function updateWorkspaceVersions(dependencies, version) { + for (const dependency in dependencies) { + const depVersion = dependencies[dependency]; + + if (depVersion === 'workspace:*') { + dependencies[dependency] = version; + } } } diff --git a/packages/cli/src/commands/create/dependencies.ts b/packages/cli/src/commands/create/dependencies.ts new file mode 100644 index 000000000..8fb31e272 --- /dev/null +++ b/packages/cli/src/commands/create/dependencies.ts @@ -0,0 +1,127 @@ +import fs from 'node:fs'; +import path from 'node:path'; +import * as prompts from '@clack/prompts'; +import chalk from 'chalk'; +import { lookpath } from 'lookpath'; +import { warnLabel } from '../../utils/messages.js'; +import { runShellCommand } from '../../utils/shell.js'; +import { assertNotCanceled, runTask } from '../../utils/tasks.js'; +import { DEFAULT_VALUES, type CreateOptions } from './options.js'; + +const LOCK_FILES = new Map([ + ['npm', 'package-lock.json'], + ['pnpm', 'pnpm-lock.yaml'], + ['yarn', 'yarn.lock'], +]); + +export type PackageManager = 'npm' | 'yarn' | 'pnpm' | 'bun'; + +export async function installDependencies(cwd: string, flags: CreateOptions) { + let packageManager: PackageManager; + + if (flags.packageManager) { + if (await lookpath(String(flags.packageManager))) { + packageManager = flags.packageManager as PackageManager; + } else { + prompts.log.warn( + `The specified package manager '${chalk.yellow(flags.packageManager)}' doesn't seem to be installed!`, + ); + } + } + + if (!packageManager) { + if (flags.defaults) { + packageManager = DEFAULT_VALUES.packageManager as PackageManager; + } else { + packageManager = await getPackageManager(); + } + } + + // remove lock files for other package managers + for (const [pkgManager, lockFile] of LOCK_FILES) { + if (pkgManager !== packageManager) { + fs.rmSync(path.join(cwd, lockFile), { force: true }); + } + } + + let installDeps = flags.install ?? DEFAULT_VALUES.install; + + if (!flags.defaults && flags.install === undefined) { + const answer = await prompts.confirm({ + message: 'Install dependencies?', + initialValue: DEFAULT_VALUES.install, + }); + + assertNotCanceled(answer); + + installDeps = answer; + } + + let dependenciesInstalled = false; + + if (installDeps) { + await runTask({ + title: `Installing dependencies with ${packageManager}`, + dryRun: flags.dryRun, + dryRunMessage: `${warnLabel('DRY RUN')} Skipped dependency installation`, + task: async () => { + try { + await runShellCommand(packageManager, ['install'], { cwd, stdio: 'ignore' }); + + dependenciesInstalled = true; + + return 'Dependencies installed'; + } catch { + const installCommand = chalk.yellow(`${packageManager} install`); + + throw new Error(`Failed to install dependencies. Please run ${installCommand} manually after the setup.`); + } + }, + }); + } else { + prompts.log.message(`${chalk.blue('dependencies [skip]')} Remember to install the dependencies after the setup.`); + } + + return { selectedPackageManager: packageManager, dependenciesInstalled }; +} + +async function getPackageManager() { + const installedPackageManagers = await getInstalledPackageManagers(); + + let initialValue = process.env.npm_config_user_agent?.split('/')[0] as PackageManager | undefined; + + if (!installedPackageManagers.includes(initialValue)) { + initialValue = 'npm'; + } + + const answer = await prompts.select({ + message: 'What package manager should we use?', + initialValue, + options: [ + { label: 'npm', value: 'npm' }, + ...installedPackageManagers.map((pkgManager) => { + return { label: pkgManager, value: pkgManager }; + }), + ], + }); + + assertNotCanceled(answer); + + return answer as PackageManager; +} + +async function getInstalledPackageManagers(): Promise { + const packageManagers: PackageManager[] = []; + + for (const pkgManager of ['yarn', 'pnpm', 'bun']) { + try { + if (await lookpath(pkgManager)) { + packageManagers.push(pkgManager as PackageManager); + } + } catch (error) { + // package manager not found, do nothing + } + } + + return packageManagers; +} diff --git a/packages/cli/src/commands/create/index.ts b/packages/cli/src/commands/create/index.ts index 4e5489a86..e2ddbf454 100644 --- a/packages/cli/src/commands/create/index.ts +++ b/packages/cli/src/commands/create/index.ts @@ -15,8 +15,6 @@ import { DEFAULT_VALUES, type CreateOptions } from './options.js'; import { selectPackageManager, type PackageManager } from './package-manager.js'; import { copyTemplate } from './template.js'; -const TUTORIALKIT_VERSION = pkg.version; - export async function createTutorial(flags: yargs.Arguments) { if (flags._[1] === 'help' || flags.help || flags.h) { printHelp({ @@ -253,30 +251,26 @@ function updatePackageJson(dest: string, projectName: string, flags: CreateOptio } const pkgPath = path.resolve(dest, 'package.json'); - const pkgJson = JSON.parse(fs.readFileSync(pkgPath, 'utf8')); pkgJson.name = projectName; - updateWorkspaceVersions(pkgJson.dependencies, TUTORIALKIT_VERSION); - updateWorkspaceVersions(pkgJson.devDependencies, TUTORIALKIT_VERSION); - fs.writeFileSync(pkgPath, JSON.stringify(pkgJson, undefined, 2)); -} -function updateWorkspaceVersions(dependencies: Record, version: string) { - for (const dependency in dependencies) { - const depVersion = dependencies[dependency]; + try { + const pkgLockPath = path.resolve(dest, 'package-lock.json'); + const pkgLockJson = JSON.parse(fs.readFileSync(pkgLockPath, 'utf8')); + const defaultPackage = pkgLockJson.packages['']; - if (depVersion === 'workspace:*') { - if (process.env.TK_DIRECTORY) { - const name = dependency.split('/')[1]; + pkgLockJson.name = projectName; - dependencies[dependency] = `file:${process.env.TK_DIRECTORY}/packages/${name.replace('-', '/')}`; - } else { - dependencies[dependency] = version; - } + if (defaultPackage) { + defaultPackage.name = projectName; } + + fs.writeFileSync(pkgLockPath, JSON.stringify(pkgLockJson, undefined, 2)); + } catch { + // ignore any errors } } diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index e3910015a..409823ccb 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -2,3 +2,4 @@ packages: - 'packages/*' - 'packages/components/*' - 'docs/*' + - 'integration'