编码技巧收集五 ~ 如何规范的发布npm包版本

106 阅读4分钟

 引言

版本格式

一般为:x.y.z-state
x – 主版本号,y – 次版本号,z – 修订号
state – 版本状态,可选字段,可选值包括以下几种

  • alpha,内部测试版本,bug较多
  • beta,公测版本,给外部进行测试的版本,有缺陷
  • rc,release candidate,前面三种测试版本的进一步版本,实现了全部功能,解决了大部分bug,接近发布,正式版本的候选版本

版本号递增规则

  • 主版本号:做了不兼容旧版本的API修改,大版本修改,主版本号递增时,次版本号和修订号必须归零

  • 次版本号:向下兼容的功能性新增或弃用,feature 版本,每次次版本号递增时,修订号必须归零

  • 修订号:向下的版本问题修复,bug fix 版本

  • 数字型的标识符为非负整数,且不能在前面补零

  • 0.y.z 表示开发阶段,一切可能随时改变,非稳定版

  • 1.0.0 界定此版本为初始稳定版,后面的一切更新都基于此版本修改

  • 在发布重要版本时,可以先发布alpha,beta,rc等先行版本,先行版本版本状态是以 . 分隔的标识符,由数字字母组成,alpha、beta、rc后需要带上次数信息,比如1.0.0-alpha.1

  • 某个软件版本发行后,后续修改都必须以新版本发行

changelog

my-release项目

package.json配置

{
    "name": "my-release",
    "version": "1.0.0",
    "main": "index.js",
    "bin": {
        "my-release": "bin/release.js"
    },
    "scripts": {
        "changelog": "conventional-changelog -p angular -i CHANGELOG.md -s",
        "release:alpha": "./bin/release.js --preid=alpha --skipBuild",
        "release:beta": "./bin/release.js --preid=beta --skipBuild",
        "release": "./bin/release.js --skipBuild"
    },
    "dependencies": {
        "chalk": "^4.1.1",
        "enquirer": "^2.3.6",
        "execa": "^5.1.1",
        "minimist": "^1.2.5",
        "semver": "^7.3.5"
    },
    "devDependencies": {
        "conventional-changelog-cli": "^2.1.1",
        "eslint": "^7.29.0",
        "eslint-config-prettier": "^8.3.0",
        "eslint-plugin-prettier": "^3.4.0",
        "prettier": "^2.3.2"
    }
}

/bin/relaese.js

#!/usr/bin/env node

const execa = require('execa')
const path = require('path')
const fs = require('fs')
const args = require('minimist')(process.argv.slice(2))
const semver = require('semver')
const chalk = require('chalk')
const {
    prompt
} = require('enquirer')

const packageDir = process.cwd()
const packagePath = path.resolve(packageDir, 'package.json')

const pkg = require(packagePath)
const pkgName = pkg.name
const currentVersion = pkg.version


const isDryRun = args.dry
const preid = args.preid
const message = args.m

const skipBuild = args.skipBuild
const skipChangelog = args.skipChangelog

const formalVersionList = ['patch', 'minor', 'major']

const prereleaseVersionList = [
   'prerelease',
   'prepatch',
   'preminor',
   'premajor',
 ]

const VERSION_DESC_MAP = {
    patch: '【BUG FIX】',
    minor: '【NEW FEATURE】',
    major: '【BREAK CHANGE】',
    prerelease: '【DEV @ TEST PHASE】',
    prepatch: '【BUG FIX @ TEST PHASE】',
    preminor: '【NEW FEATURE @ TEST PHASE】',
    premajor: '【BREAK CHANGE @ TEST PHASE】',
}

/**
 * @param {import('semver').ReleaseType} i
 */
const inc = (i) => semver.inc(currentVersion, i, preid)

/**
 * @param {string} bin
 * @param {string[]} args
 * @param {object} opts
 */
const run = (bin, args, opts = {}) =>
    execa(bin, args, {
        stdio: 'inherit',
        ...opts
    })

/**
 * @param {string} bin
 * @param {string[]} args
 * @param {object} opts
 */
const dryRun = (bin, args, opts = {}) =>
    console.log(
        chalk.blue(`[dryrun] ${bin} ${args.join(' ')}`),
        opts
    )

const runIfNotDry = isDryRun ? dryRun : run

/**
 * @param {string} msg
 */
const step = (msg) => console.log(chalk.cyan(msg))

async function main() {
    let targetVersion = args._[0]

    if (!targetVersion) {
        const versionIncrements = preid ?
            prereleaseVersionList :
            formalVersionList

        const {
            release
        } = await prompt({
            type: 'select',
            name: 'release',
            message: 'Select release type',
            // @ts-ignore
            choices: versionIncrements
                .map(
                    (i) => `${i}${VERSION_DESC_MAP[i]}: (${inc(i)})`
                )
                .concat(['custom']),
        })

        if (release === 'custom') {
            /**
             * @type {{ version: string }}
             */
            const res = await prompt({
                type: 'input',
                name: 'version',
                message: 'Input custom version',
                initial: currentVersion,
            })
            targetVersion = res.version
        } else {
            targetVersion = release.match(/\((.*)\)/)[1]
        }
    }

    if (!semver.valid(targetVersion)) {
        throw new Error(
            `invalid target version: ${targetVersion}`
        )
    }

    const tag = `${targetVersion}`

    /**
     * @type {{ yes: boolean }}
     */
    const {
        yes
    } = await prompt({
        type: 'confirm',
        name: 'yes',
        message: `Releasing ${pkgName}: ${currentVersion} => ${targetVersion}. Confirm?`,
    })

    if (!yes) {
        return
    }

    step('\nUpdating package version...')
    updateVersion(targetVersion)

    step('\nBuilding package...')
    if (!skipBuild && !isDryRun) {
        await run('yarn', ['build'])
    } else {
        console.log(`(skipped)`)
    }

    step('\nGenerating changelog...')
    if (!skipChangelog) {
        await run('yarn', ['changelog'])
    } else {
        console.log(`(skipped)`)
    }

    const {
        stdout
    } = await run('git', ['diff'], {
        stdio: 'pipe',
    })
    if (stdout) {
        step('\nCommitting changes...')
        await runIfNotDry('git', ['add', '-A'])
        const commitMessage = message ?
            message :
            `chore(release): publish v${tag}`
        await runIfNotDry('git', [
       'commit',
       '-m',
       commitMessage,
     ])
    } else {
        console.log('No changes to commit.')
    }

    step('\nPublishing package...')
    await publishPackage(targetVersion, runIfNotDry)

    step('\nPushing to GitLab...')
    await runIfNotDry('git', ['tag', `v${targetVersion}`])
    await runIfNotDry('git', [
     'push',
     'origin',
     `refs/tags/v${targetVersion}`,
   ])
    await runIfNotDry('git', ['push'])

    if (isDryRun) {
        console.log(
            `\nDry run finished - run git diff to see package changes.`
        )
    }
}

/**
 * @param {string} version
 */
function updateVersion(version) {
    const pkg = JSON.parse(fs.readFileSync(packagePath, 'utf-8'))
    pkg.version = version
    fs.writeFileSync(
        packagePath,
        JSON.stringify(pkg, null, 2) + '\n'
    )
}

/**
 * @param {string} version
 * @param {Function} runIfNotDry
 */
async function publishPackage(version, runIfNotDry) {
    try {
        // my-npm-libary 需要替换的要发布的npm包仓库地址
        await runIfNotDry('npm', [
       'config',
       'set',
       'registry',
       'my-npm-libary',
     ])

        const publicArgs = ['publish', '--access', 'public']
        if (preid) {
            publicArgs.push(`--tag`, preid)
        }
        await runIfNotDry('npm', publicArgs, {
            stdio: 'pipe',
        })
        console.log(
            chalk.green(
                `Successfully published ${pkgName}@${version}`
            )
        )
    } catch (e) {
        if (e.stderr.match(/previously published/)) {
            console.log(
                chalk.red(`Skipping already published: ${pkgName}`)
            )
        } else {
            throw e
        }
    }
}

main().catch((err) => {
    console.error(err)
})

用法

在项目中package.json里增加scripts命令

"scripts": {
  "build": "yarn typecheck && rollup -c",
  "changelog": "conventional-changelog -p angular -i CHANGELOG.md -s",
  "release:alpha": "my-release --preid=alpha",
  "release:beta": "my-release --preid=beta",
  "release": "my-release"
}
  • build: 构建打包命令,用户需要自己实现,如需跳过增加--skipBuild参数

  • changelog: 根据commit message自动生成changelog,需要先安装conventional-changelog-cli,并配置以下命令,如需跳过增加--skipChangelog参数

  • release:alpha: 发布alpha版本,开发、自测时使用

  • release:beta: 发布beta版本,已经过单测,可在正式项目中使用

  •  release: 发布正式版本,已有1个以上项目在线上环境中使用

npm撤销发布包

这里要说一点,取消发布包可能并不像你想象得那么容易,这种操作是受到诸多限制的,撤销发布的包被认为是一种不好的行为。

试想一下你撤销了发布的包[假设它已经在社区内有了一定程度的影响],
这对那些已经深度使用并依赖你发布的包的团队是件多么崩溃的事情!

npm unpublish 包名

【注意】如果报权限方面的错,加上--force

  • 1.根据规范,只有在发包的24小时内才允许撤销发布的包( unpublish is only allowed with versions published in the last 24 hours)
  • 2.即使你撤销了发布的包,发包的时候也不能再和被撤销的包的名称和版本重复了(即不能名称相同,版本相同,因为这两者构成的唯一标识已经被“占用”了)

npm unpublish的推荐替代命令:

使用这个命令,并不会在社区里撤销你已有的包,但会在任何人尝试安装这个包的时候得到警告

由于npm unpublish不能成功删除超过时间的包,从网上找到这行命令,亲测可用!

npx force-unpublish package-name '原因描述'