diff --git a/.github/workflows/release-please.yml b/.github/workflows/release-please.yml index e8ebfae270d..f6549c64ed8 100644 --- a/.github/workflows/release-please.yml +++ b/.github/workflows/release-please.yml @@ -54,7 +54,15 @@ jobs: - name: Run E2E tests run: npm run test:e2e - - name: Publish package + - name: Publish netlify-cli package + run: npm publish --provenance + env: + NODE_AUTH_TOKEN: ${{secrets.NPM_TOKEN}} + + - name: Prepare netlify package + run: node scripts/netlifyPackage.js + + - name: Publish netlify package run: npm publish --provenance env: NODE_AUTH_TOKEN: ${{secrets.NPM_TOKEN}} diff --git a/e2e/install.e2e.ts b/e2e/install.e2e.ts index 67e3f4f6c90..502da96837b 100644 --- a/e2e/install.e2e.ts +++ b/e2e/install.e2e.ts @@ -70,6 +70,10 @@ const itWithMockNpmRegistry = it.extend<{ registry: { address: string; cwd: stri access: '$all', publish: '$all', }, + netlify: { + access: '$all', + publish: '$all', + }, '**': { access: '$all', publish: 'noone', @@ -116,6 +120,20 @@ const itWithMockNpmRegistry = it.extend<{ registry: { address: string; cwd: stri cwd: publishWorkspace, stdio: debug.enabled ? 'inherit' : 'ignore', }) + + // TODO: Figure out why calling this script is failing on Windows. + if (platform() !== 'win32') { + // Publishing `netlify` package + await execa.node(path.resolve(projectRoot, 'scripts/netlifyPackage.js'), { + cwd: publishWorkspace, + stdio: debug.enabled ? 'inherit' : 'ignore', + }) + await execa('npm', ['publish', `--registry=${registryURL.toString()}`, '--tag=testing'], { + cwd: publishWorkspace, + stdio: debug.enabled ? 'inherit' : 'ignore', + }) + } + await fs.rm(publishWorkspace, { force: true, recursive: true }) const testWorkspace = await fs.mkdtemp(path.join(os.tmpdir(), tempdirPrefix)) @@ -135,10 +153,15 @@ const itWithMockNpmRegistry = it.extend<{ registry: { address: string; cwd: stri }, }) -const tests: [packageManager: string, config: { install: [cmd: string, args: string[]]; lockfile: string }][] = [ +type Test = { packageName: string } +type InstallTest = Test & { install: [cmd: string, args: string[]]; lockfile: string } +type RunTest = Test & { run: [cmd: string, args: string[]] } + +const tests: [packageManager: string, config: InstallTest | RunTest][] = [ [ 'npm', { + packageName: 'netlify-cli', install: ['npm', ['install', 'netlify-cli@testing']], lockfile: 'package-lock.json', }, @@ -146,6 +169,7 @@ const tests: [packageManager: string, config: { install: [cmd: string, args: str [ 'pnpm', { + packageName: 'netlify-cli', install: ['pnpm', ['add', 'netlify-cli@testing']], lockfile: 'pnpm-lock.yaml', }, @@ -153,35 +177,67 @@ const tests: [packageManager: string, config: { install: [cmd: string, args: str [ 'yarn', { + packageName: 'netlify-cli', install: ['yarn', ['add', 'netlify-cli@testing']], lockfile: 'yarn.lock', }, ], + [ + 'npx', + { + packageName: 'netlify', + run: ['npx', ['-y', 'netlify@testing']], + }, + ], ] describe.each(tests)('%s → installs the cli and runs the help command without error', (_, config) => { - itWithMockNpmRegistry('installs the cli and runs the help command without error', async ({ registry }) => { - const cwd = registry.cwd - await execa(...config.install, { - cwd, - env: { npm_config_registry: registry.address }, - stdio: debug.enabled ? 'inherit' : 'ignore', - }) + // TODO: Figure out why this flow is failing on Windows. + const npxOnWindows = platform() === 'win32' && 'run' in config - expect( - existsSync(path.join(cwd, config.lockfile)), - `Generated lock file ${config.lockfile} does not exist in ${cwd}`, - ).toBe(true) + itWithMockNpmRegistry.skipIf(npxOnWindows)( + 'installs the cli and runs the help command without error', + async ({ registry }) => { + const cwd = registry.cwd - const binary = path.resolve(path.join(cwd, `./node_modules/.bin/netlify${platform() === 'win32' ? '.cmd' : ''}`)) - const { stdout } = await execa(binary, ['help'], { cwd }) + let stdout: string - expect(stdout.trim(), `Help command does not start with '⬥ Netlify CLI'\\n\\nVERSION: ${stdout}`).toMatch( - /^⬥ Netlify CLI\n\nVERSION/, - ) - expect(stdout, `Help command does not include 'netlify-cli/${pkg.version}':\n\n${stdout}`).toContain( - `netlify-cli/${pkg.version}`, - ) - expect(stdout, `Help command does not include '$ netlify [COMMAND]':\n\n${stdout}`).toMatch('$ netlify [COMMAND]') - }) + if ('install' in config) { + await execa(...config.install, { + cwd, + env: { npm_config_registry: registry.address }, + stdio: debug.enabled ? 'inherit' : 'ignore', + }) + + expect( + existsSync(path.join(cwd, config.lockfile)), + `Generated lock file ${config.lockfile} does not exist in ${cwd}`, + ).toBe(true) + + const binary = path.resolve( + path.join(cwd, `./node_modules/.bin/netlify${platform() === 'win32' ? '.cmd' : ''}`), + ) + const result = await execa(binary, ['help'], { cwd }) + + stdout = result.stdout + } else { + const [cmd, args] = config.run + const result = await execa(cmd, args, { + env: { + npm_config_registry: registry.address, + }, + }) + + stdout = result.stdout + } + + expect(stdout.trim(), `Help command does not start with '⬥ Netlify CLI'\\n\\nVERSION: ${stdout}`).toMatch( + /^⬥ Netlify CLI\n\nVERSION/, + ) + expect(stdout, `Help command does not include '${config.packageName}/${pkg.version}':\n\n${stdout}`).toContain( + `${config.packageName}/${pkg.version}`, + ) + expect(stdout, `Help command does not include '$ netlify [COMMAND]':\n\n${stdout}`).toMatch('$ netlify [COMMAND]') + }, + ) }) diff --git a/scripts/netlifyPackage.js b/scripts/netlifyPackage.js new file mode 100644 index 00000000000..b38cdc86aa4 --- /dev/null +++ b/scripts/netlifyPackage.js @@ -0,0 +1,68 @@ +// @ts-check +import assert from 'node:assert' +import { dirname, resolve } from 'node:path' +import { readFile, stat, writeFile } from 'node:fs/promises' + +import execa from 'execa' + +/** + * @import {Package} from "normalize-package-data" + */ + +const packageJSON = await getPackageJSON() + +async function getPackageJSON() { + const packageJSONPath = resolve('package.json') + + /** + * @type {Package} + */ + const contents = JSON.parse(await readFile(packageJSONPath, 'utf8')) + + return { + contents, + path: packageJSONPath, + } +} + +async function preparePackageJSON() { + const binPath = Object.values(packageJSON.contents.bin ?? {})[0] + if (!binPath) { + throw new Error('Did not find a non-empty binary entry in `package.json`, so the `npx` flow will not work.') + } + + const newPackageJSON = { + ...packageJSON.contents, + main: './dist/index.js', + name: 'netlify', + scripts: { + ...packageJSON.contents.scripts, + + // We don't need the pre-publish script because we expect the work in + // there to be done when publishing the `netlify-cli` package. We'll + // ensure this is the case by throwing if a shrinkwrap file isn't found. + prepublishOnly: undefined, + }, + bin: { + npxnetlify: binPath, + }, + } + + try { + const shrinkwrap = await stat(resolve(packageJSON.path, '../npm-shrinkwrap.json')) + + assert.ok(shrinkwrap.isFile()) + } catch { + throw new Error('Failed to find npm-shrinkwrap.json file. Did you run the pre-publish script?') + } + + console.log(`Writing updated package.json to ${packageJSON.path}...`) + await writeFile(packageJSON.path, `${JSON.stringify(newPackageJSON, null, 2)}\n`) + + console.log('Regenerating shrinkwrap file with updated package name...') + await execa('npm', ['shrinkwrap'], { + cwd: dirname(packageJSON.path), + }) +} + +await preparePackageJSON() diff --git a/scripts/prepublishOnly.js b/scripts/prepublishOnly.js index 03f396a7d01..a397a7de118 100644 --- a/scripts/prepublishOnly.js +++ b/scripts/prepublishOnly.js @@ -14,22 +14,16 @@ const main = async () => { const packageJSONPath = path.join(process.cwd(), 'package.json') const rawPackageJSON = await fs.readFile(packageJSONPath, 'utf8') - try { - // Remove dev dependencies from the package.json... - const packageJSON = JSON.parse(rawPackageJSON) - Reflect.deleteProperty(packageJSON, 'devDependencies') - await fs.writeFile(packageJSONPath, JSON.stringify(packageJSON, null, 2)) + // Remove dev dependencies from the package.json... + const packageJSON = JSON.parse(rawPackageJSON) + Reflect.deleteProperty(packageJSON, 'devDependencies') + await fs.writeFile(packageJSONPath, JSON.stringify(packageJSON, null, 2)) - // Prune out dev dependencies (this updates the `package-lock.json` lockfile) - cp.spawnSync('npm', ['prune'], { stdio: 'inherit' }) + // Prune out dev dependencies (this updates the `package-lock.json` lockfile) + cp.spawnSync('npm', ['prune'], { stdio: 'inherit' }) - // Convert `package-lock.json` lockfile to `npm-shrinkwrap.json` - cp.spawnSync('npm', ['shrinkwrap'], { stdio: 'inherit' }) - } finally { - // Restore the original `package.json`. (This makes no functional difference in a publishing - // environment, it's purely to minimize how destructive this script is.) - await fs.writeFile(packageJSONPath, rawPackageJSON) - } + // Convert `package-lock.json` lockfile to `npm-shrinkwrap.json` + cp.spawnSync('npm', ['shrinkwrap'], { stdio: 'inherit' }) } await main() diff --git a/src/index.ts b/src/index.ts new file mode 100644 index 00000000000..3c82e582d24 --- /dev/null +++ b/src/index.ts @@ -0,0 +1,4 @@ +// This is an entrypoint that mirrors the interface that the `netlify` package +// used to have, before it was renamed to `@netlify/api`. We keep it for +// backwards-compatibility. +export { NetlifyAPI, methods } from '@netlify/api' diff --git a/vitest.e2e.config.ts b/vitest.e2e.config.ts index c11f9428a12..d70d20380f0 100644 --- a/vitest.e2e.config.ts +++ b/vitest.e2e.config.ts @@ -3,7 +3,7 @@ import { defineConfig } from 'vitest/config' export default defineConfig({ test: { include: ['e2e/**/*.e2e.[jt]s'], - testTimeout: 600_000, + testTimeout: 1200_000, // Pin to vitest@1 behavior: https://vitest.dev/guide/migration.html#default-pool-is-forks. // TODO(serhalp) Remove this and fix flaky hanging e2e tests on Windows. pool: 'threads',