Skip to content

feat: publish netlify package #7293

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 21 commits into from
May 19, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 9 additions & 1 deletion .github/workflows/release-please.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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}}
100 changes: 78 additions & 22 deletions e2e/install.e2e.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down Expand Up @@ -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))
Expand All @@ -135,53 +153,91 @@ 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',
},
],
[
'pnpm',
{
packageName: 'netlify-cli',
install: ['pnpm', ['add', 'netlify-cli@testing']],
lockfile: 'pnpm-lock.yaml',
},
],
[
'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]')
},
)
})
68 changes: 68 additions & 0 deletions scripts/netlifyPackage.js
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: I was going to comment that this codebase does not follow this naming convention, but I see that scripts/prepublishOnly is the one exception, which you probably saw right next to this one!

🤷🏼

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I did name scripts/prepublishOnly as it is (despite not matching other file naming conventions) specifically so it would match up exactly with the npm script name.

Original file line number Diff line number Diff line change
@@ -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'))
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: We already have a dep on normalize-package-data you can use here to avoid contents being any

There's even a util src/utils/get-cli-package-json.ts you should be able to use

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This use case is a bit different. We're reading package.json and then writing it back, so I don't think we should be normalising it.

I have used the Package type now.


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()
22 changes: 8 additions & 14 deletions scripts/prepublishOnly.js
Original file line number Diff line number Diff line change
Expand Up @@ -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()
4 changes: 4 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
// This is an entrypoint that mirrors the interface that the `netlify` package
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

thought: Should we log a warning here that the package has been renamed?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That feels a bit extreme? We might be logging something that is not actionable to the user, since this might be a transitive dependency.

// used to have, before it was renamed to `@netlify/api`. We keep it for
// backwards-compatibility.
export { NetlifyAPI, methods } from '@netlify/api'
2 changes: 1 addition & 1 deletion vitest.e2e.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
Loading