@exadev/release-gate

中文文档
1.0.4发布于 2026/10/29MITTypeScriptESM + CJS构建来源已验证近 30 天下载 2546

Tag and push a semantic-release release, then finish publishing it later -- from a separate process, after an external gate -- without semantic-release core supporting this natively

原始使用文档
文档目录10 节
  1. Why
  2. Getting started
  3. detachRelease(cliOptions?, execEnv?)
  4. resumeRelease(state, execEnv?)
  5. ReleaseGateState
  6. What this package does not do
  7. Pairing with @exadev/build-identity
  8. Why an exact semantic-release version pin
  9. Conventions
  10. Publishing

@exadev/release-gate

GitHub npm Release CI

Tag and push a semantic-release release, then finish publishing it later -- from a separate process, after an external gate -- without semantic-release core supporting this natively.

npm downloads chart, log scale

Why

semantic-release's own run() tags and pushes a release, then immediately calls every configured publish plugin (npm, GitHub Releases, and so on) in the same process, in the same CI job. There is no supported way to stop after the tag exists and resume publishing later, once something outside semantic-release itself -- a staging deploy, a smoke test, a manual sign-off -- has confirmed the release should actually go out. semantic-release/semantic-release#2411 asks for exactly this, and has been raised (and declined as out of scope for core) more than once. This package is the standalone reference implementation promised in the design comment on that issue: a small, additive pair of functions -- detachRelease and resumeRelease -- built entirely on semantic-release's own internals, demonstrating the split as a real, usable thing rather than an abstract proposal.

It works by reimplementing the body of semantic-release's internal run() function up to (and including) the tag-and-push step, then stopping -- there is no exported seam inside semantic-release to hook into this halfway, since its only public export is the single function that runs a release start to finish. The state needed to finish the release later (which branch, which commits, which version, which notes) is captured into a plain, JSON-serializable ReleaseGateState, which resumeRelease picks up -- in the same process or a completely different one -- to rebuild and run the real publish/success plugin pipeline.

Getting started

pnpm add @exadev/release-gate
// Job 1: tag and push, but stop short of publishing.
import { detachRelease } from '@exadev/release-gate';
import { writeFile } from 'node:fs/promises';

const state = await detachRelease();
if (state) {
  await writeFile('release-gate-state.json', JSON.stringify(state));
}
// Job 2, later -- once whatever external gate needs to pass has passed.
import { resumeRelease } from '@exadev/release-gate';
import { readFile } from 'node:fs/promises';

const state: unknown = JSON.parse(await readFile('release-gate-state.json', 'utf8'));
const releases = await resumeRelease(state);

detachRelease(cliOptions?, execEnv?)

function detachRelease(cliOptions?: Options, execEnv?: DetachReleaseEnv): Promise<ReleaseGateState | null>;

interface DetachReleaseEnv {
  cwd?: string;
  env?: NodeJS.ProcessEnv;
  stdout?: NodeJS.WritableStream;
  stderr?: NodeJS.WritableStream;
}

The signature deliberately mirrors semantic-release's own default export -- cliOptions is the real semantic-release Options type, and a caller can swap semanticRelease(cliOptions, execEnv) for detachRelease(cliOptions, execEnv) with no other change. Runs verify config, branch resolution, push-auth verification, verifyConditions, commit analysis, verifyRelease, generateNotes, and prepare, then creates and pushes the release tag -- exactly what semantic-release itself does at this point in a real run.

Returns null in every case semantic-release's own run() would have stopped before publishing anyway: no CI environment detected, a pull-request-triggered run, a branch semantic-release isn't configured to release from, a local branch that's behind its remote, no releasable commits since the last release, or dry-run mode (a dry run never creates a tag, so there is nothing to resume). Otherwise returns the ReleaseGateState a later resumeRelease call needs.

resumeRelease(state, execEnv?)

function resumeRelease(state: unknown, execEnv?: ResumeReleaseEnv): Promise<readonly Release[]>;

interface ResumeReleaseEnv {
  cwd?: string;
  env?: NodeJS.ProcessEnv;
  stdout?: NodeJS.WritableStream;
  stderr?: NodeJS.WritableStream;
}

state is accepted as unknown and validated at runtime (via isReleaseGateState), not typed as ReleaseGateState directly -- it typically arrives from outside the current process (a file, a CI artifact), so it carries no compile-time guarantee by the time it reaches this function. A state this package didn't produce, or one from an incompatible schemaVersion, throws ReleaseGateStateError.

cwd/env/stdout/stderr are always built fresh for this call, never read out of state: a resume can run in an entirely different process, checkout, or CI job than the one that ran detachRelease, with different credentials and a different logger destination. In particular, the authenticated push URL is re-derived from this call's own env, not persisted -- see ReleaseGateState below.

Rebuilds the real publish/success plugin pipeline from state.options/state.pluginsPath and runs it against the persisted branch/lastRelease/commits/nextRelease, mirroring semantic-release's own real call sites exactly (publish then success, with fail invoked and the error rethrown if publish or success throws). Returns whatever the configured publish plugins returned.

ReleaseGateState

FieldWhat it is
schemaVersionAlways 1 in this version. resumeRelease rejects a state with a different value rather than misinterpreting it.
optionsThe resolved semantic-release options detachRelease ran with.
pluginsPathThe shareable-config plugin-resolution map. Always {} -- see "What this package does not do" below.
branchesEvery branch semantic-release resolved for the repository.
branchThe one branch this release ran on.
lastReleaseThe release found on the branch before this one, or {} for a first release.
commitsEvery commit since lastRelease that was analysed.
nextReleaseThe release that was tagged and pushed -- resumeRelease publishes exactly this.

Deliberately not persisted: cwd, env, stdout, stderr, and the logger are all process-bound and rebuilt fresh by resumeRelease (see above); releases is always empty at the point detachRelease returns, since the only thing that could have populated it -- the maintenance-branch backport path -- is rejected outright, not silently handled.

options.repositoryUrl is always the credential-free original URL, never the credential-embedded one semantic-release builds for pushing. semantic-release's own run() overwrites options.repositoryUrl with a URL carrying an embedded CI token (e.g. https://x-access-token:[email protected]/...) so git push can authenticate non-interactively. ReleaseGateState is designed to be written to a file or CI artifact and read back somewhere else, so it must never carry that credential -- detachRelease swaps it back to the original before returning. resumeRelease re-derives its own authenticated push URL from whatever env it's given.

What this package does not do

  • No support for the maintenance-branch backport/addChannel path. semantic-release can add an already-published release to an additional distribution channel when a maintenance branch merges up from a lower one -- a structurally distinct flow from tagging a new release, with its own tag-and-push step that happens before publish even runs, not after it. detachRelease throws ReleaseGateConfigurationError if this path would apply, rather than silently mishandling it.
  • No support for extends (shareable configuration). semantic-release's own getConfig resolves extends into a pluginsPath map used to load plugins relative to the shareable config's own location, but never exposes that map in its return value -- there is no way to reconstruct it later without re-running extends resolution from scratch, which resumeRelease has no config file to do. detachRelease throws ReleaseGateConfigurationError if cliOptions.extends is set.
  • No stdout/stderr secret redaction. semantic-release's own default export wraps process stdout/stderr to scrub sensitive values (tokens, credentials) out of anything a plugin logs, via hook-std. detachRelease/resumeRelease don't do this -- it's a secondary console-logging safety net, not part of the tag-before-publish mechanic this package exists to demonstrate, and adding a real dependency to reproduce it wasn't judged worth it for a reference implementation. A caller with plugins that might log secrets should scrub CI logs through whatever mechanism their CI provider already offers for that.

Pairing with @exadev/build-identity

@exadev/build-identity answers "is this commit a real, tagged release" from live git state. Combined with this package, a build can report a genuine three-state identity instead of just two:

import { resolveBuildIdentity } from '@exadev/build-identity';
import { readFile } from 'node:fs/promises';

const identity = resolveBuildIdentity(process.cwd(), 'exadev/example');

if (identity.kind === 'commit') {
  // Not yet a release at all.
} else {
  // A tag exists -- but was it actually published, or is it still sitting behind the gate?
  const gateState: unknown = await readFile('release-gate-state.json', 'utf8')
    .then(JSON.parse)
    .catch(() => null);
  const stillGated = gateState !== null;
}

There's no code dependency between the two packages -- this is a documentation-only pairing, since build-identity's tag-confirmed fact and release-gate's publish-confirmed fact are two independently useful, independently checkable things.

Why an exact semantic-release version pin

This package deep-imports semantic-release's own internal lib/*.js modules (getConfig, verify, getBranches, getCommits, the plugin pipeline builder, and more) to reimplement run()'s body. semantic-release's own package.json has no exports field, so nothing blocks these deep imports from resolving -- but only its single default export carries a semver guarantee. A minor or patch bump could change any of these internals' signatures or behaviour with no warning. package.json pins semantic-release to an exact version, not a caret range, and a real end-to-end integration test (a disposable git repository, a real tag, a real push) exists specifically to catch a future version silently breaking one of these deep imports. Upgrading is a deliberate, manually-reviewed step, not an automatic dependency bump.

Conventions

British English throughout. Conventional commits, enforced by commitlint (commitlint.config.ts) and released automatically by semantic-release (release.config.ts) on every push to main. Strict TypeScript, no any, no unjustified type assertions -- see eslint.config.ts, which lints this package with @exadev/eslint-config.

Publishing

Releases are handled entirely by .github/workflows/ci.yml's release job: semantic-release analyses commits since the last tag, bumps the version, publishes to npmjs.org over OIDC trusted publishing (no stored NPM_TOKEN), and creates the tag and GitHub Release. See that workflow for the exact steps, and CONTRIBUTING.md for what a brand-new trusted-publish package needed once, by hand, before that automation could run unattended.

版本与发布记录

@exadev/release-gate 共发布 6 个版本,首次发布于 2026/09/08;过去 12 个月发布了 6 个版本。最新版本附带 npm provenance 构建来源证明,可追溯到公开的 CI 构建记录。

版本发布日期距今
1.0.4latest2026/10/2915 天前
1.0.32026/10/061 个月前
1.0.22026/10/031 个月前
1.0.12026/09/141 个月前
1.0.02026/09/082 个月前
0.0.02026/09/082 个月前

关于 @exadev/release-gate 的常见问题

@exadev/release-gate 是什么?

Tag and push a semantic-release release, then finish publishing it later -- from a separate process, after an external gate -- without semantic-release core supporting this natively

如何安装 @exadev/release-gate?

在项目目录运行 npm install @exadev/release-gate,或使用 pnpm add / yarn add / bun add。

@exadev/release-gate 支持 TypeScript 吗?

支持。包内自带 TypeScript 类型声明(package.json 声明了 types 入口),安装后即可获得类型提示,无需再安装 @types 包。

@exadev/release-gate 支持 ESM 还是 CommonJS?

同时提供 ES Module 与 CommonJS 入口,import 和 require() 都可以使用,打包工具会自动选择合适的版本。

@exadev/release-gate 需要什么版本的 Node.js?

package.json 的 engines 字段要求 Node.js >=20。版本不符时 npm 默认只给出警告,开启 engine-strict 后会拒绝安装。

@exadev/release-gate 可以商用吗?

@exadev/release-gate 使用 MIT 许可。宽松许可:允许商业使用、修改和再分发,通常只需在分发时保留原版权与许可声明。以上为概括,不构成法律意见,请以许可原文为准。

@exadev/release-gate 的下载量有多少?

根据 npm 官方下载统计,@exadev/release-gate 在 2026/10/14 – 2026/11/12 这 30 天内被下载 2,546 次,日均约 85 次。下载量会把 CI 构建、镜像同步和作为其他包依赖的安装一并计入,适合横向比较热度,不能直接等同于使用人数。

@exadev/release-gate 还在维护吗?

最新版本 1.0.4 发布于 2026/10/29(15 天前);至今共发布 6 个版本,其中过去 12 个月发布了 6 个;首次发布于 2026/09/08。发布频率只是参考信号:功能稳定的工具可能很少需要更新,建议结合源码仓库的 issue 和提交记录判断。