Skip to content
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
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -41,9 +41,9 @@ export default config;

Requires a `GITHUB_TOKEN` or `GH_TOKEN` environment variable with permission to push a branch and open pull requests on the repository (the default `GITHUB_TOKEN` in GitHub Actions is sufficient to push and open the pull request, since the pushed branch is never the protected one). GitHub does not start workflows for pushes made with that token, so a repository that requires status checks on the release pull request pushes the branch with a deploy key instead (`ssh-key` on `actions/checkout`) and runs CI on `release/**` pushes.

## Tagging and publishing
## Tagging

The plugin ends the run in semantic-release's dry-run mode once it has handled a release, so semantic-release neither tags nor runs publish plugins from the staging run. A staged release has no tag until the pull request merges, and whatever publishes (a separate job triggered by the merge, for instance) creates the tag on the merged commit.
semantic-release creates and pushes a release's tag straight after `prepare`, before any publish step, so a staged release would otherwise carry a tag that is not on the base branch until its pull request merges, and a restaged release would fail with "tag already exists". The plugin therefore removes that tag again in its `publish` step and clears one left by an earlier run in `verifyRelease`, never touching a tag the base branch contains. List the plugin after every other publish plugin; whatever publishes after the pull request merges creates the tag on the merged commit.

## Options

Expand Down
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
"@commitlint/config-conventional": "^21.2.0",
"@exadev/eslint-config": "3.0.0",
"@semantic-release/changelog": "^7.0.0",
"@semantic-release/npm": "13.2.0",
"@types/node": "^24.9.2",
"@vitest/coverage-v8": "^4.1.10",
"eslint": "^10.8.0",
Expand Down
11 changes: 7 additions & 4 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 2 additions & 0 deletions release.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,8 @@ const config: Options = {
},
],
"@semantic-release/changelog",
// Bumps package.json to the release version without publishing, so the staged release commit carries it and the publish job reads it from the merged tree.
["@semantic-release/npm", { npmPublish: false }],
["./dist/index.js", { labels: ["release"] }],
],
};
Expand Down
35 changes: 35 additions & 0 deletions src/git.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,9 @@ export interface GitOperations {
commit: (message: string) => Promise<void>;
push: (branch: string) => Promise<void>;
hasStagedChanges: () => Promise<boolean>;
tagExists: (tag: string) => Promise<boolean>;
isTagReachableFrom: (tag: string, ref: string) => Promise<boolean>;
deleteTag: (tag: string) => Promise<void>;
}

export function createGitOperations(cwd: string): GitOperations {
Expand All @@ -32,5 +35,37 @@ export function createGitOperations(cwd: string): GitOperations {

return result.exitCode !== 0;
},
async tagExists(tag) {
const result = await execa("git", ["rev-parse", "--quiet", "--verify", `refs/tags/${tag}`], {
cwd,
reject: false,
});

return result.exitCode === 0;
},
async isTagReachableFrom(tag, ref) {
const result = await execa("git", ["merge-base", "--is-ancestor", `refs/tags/${tag}`, ref], {
cwd,
reject: false,
});

return result.exitCode === 0;
},
async deleteTag(tag) {
const local = await execa("git", ["rev-parse", "--quiet", "--verify", `refs/tags/${tag}`], {
cwd,
reject: false,
});
if (local.exitCode === 0) {
await execa("git", ["tag", "--delete", tag], { cwd });
}
const remote = await execa("git", ["ls-remote", "--exit-code", "--tags", "origin", `refs/tags/${tag}`], {
cwd,
reject: false,
});
if (remote.exitCode === 0) {
await execa("git", ["push", "origin", `:refs/tags/${tag}`], { cwd });
}
},
};
}
2 changes: 1 addition & 1 deletion src/index.ts
Original file line number Diff line number Diff line change
@@ -1,2 +1,2 @@
export { prepare, verifyConditions } from "./plugin.js";
export { prepare, publish, verifyConditions, verifyRelease } from "./plugin.js";
export type { PluginConfig } from "./types.js";
43 changes: 35 additions & 8 deletions src/plugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,13 @@ import { createGitOperations } from "./git.js";
import { parseRepositoryUrl } from "./parse-repository-url.js";
import { createPullRequestOperations } from "./pull-request.js";
import { renderTemplate } from "./template.js";
import type { PluginConfig, PrepareContext } from "./types.js";
import type { PluginConfig, ReleaseContext } from "./types.js";

const DEFAULT_ASSETS = ["CHANGELOG.md", "package.json"];
const DEFAULT_BRANCH = "release/${version}";
const DEFAULT_COMMIT_MESSAGE = "chore(release): ${version}";

function readGithubToken(env: PrepareContext["env"]): string | undefined {
function readGithubToken(env: ReleaseContext["env"]): string | undefined {
const token = env["GITHUB_TOKEN"] ?? env["GH_TOKEN"];

return token === undefined || token.length === 0 ? undefined : token;
Expand All @@ -17,7 +17,7 @@ function readGithubToken(env: PrepareContext["env"]): string | undefined {
/**
* semantic-release's `verifyConditions` hook: fails fast if a GitHub token isn't available, rather than letting `prepare` fail later with a less obvious authentication error.
*/
export function verifyConditions(_pluginConfig: PluginConfig, context: PrepareContext): void {
export function verifyConditions(_pluginConfig: PluginConfig, context: ReleaseContext): void {
if (readGithubToken(context.env) === undefined) {
throw new Error(
"semantic-release-request requires a GITHUB_TOKEN or GH_TOKEN environment variable to open pull requests.",
Expand All @@ -33,7 +33,7 @@ export function verifyConditions(_pluginConfig: PluginConfig, context: PrepareCo
/**
* semantic-release's `prepare` hook: stages the release commit onto a dedicated branch and opens (or updates) a pull request from it, instead of `@semantic-release/git`'s own behaviour of committing directly onto the release branch. Assumes an earlier plugin (typically `@semantic-release/changelog`) has already written whichever of `assets` need updating on disk; this plugin only stages, commits, pushes, and opens the pull request.
*/
export async function prepare(pluginConfig: PluginConfig, context: PrepareContext): Promise<void> {
export async function prepare(pluginConfig: PluginConfig, context: ReleaseContext): Promise<void> {
const assets = pluginConfig.assets ?? DEFAULT_ASSETS;
const branch = renderTemplate(pluginConfig.branch ?? DEFAULT_BRANCH, context.nextRelease);
const commitMessage = renderTemplate(
Expand All @@ -56,10 +56,6 @@ export async function prepare(pluginConfig: PluginConfig, context: PrepareContex
);
}

// semantic-release tags and pushes the release right after `prepare`, but a release handled here only exists as a pull request, staged or already merged: the tag would name a commit that is not on the base branch and make every later staging run fail with "tag already exists". Dry-run mode is the one switch the core honours for skipping the tag, the push and every publish plugin, and it reads it after `prepare` returns.
const { options } = context;
options.dryRun = true;

const git = createGitOperations(context.cwd);
await git.checkoutNewBranch(branch);
await git.stage(assets);
Expand Down Expand Up @@ -88,3 +84,34 @@ export async function prepare(pluginConfig: PluginConfig, context: PrepareContex
`Staged release ${context.nextRelease.version} as pull request #${String(pullRequest.number)} (${pullRequest.htmlUrl}).`,
);
}

/**
* semantic-release creates and pushes a release's tag straight after `prepare`, before any publish step. A staged release has no tag until its pull request merges (whatever publishes afterwards tags the merged commit), and a leftover tag names a commit that is not on the base branch and makes every later run fail with "tag already exists". This step removes a tag left by an earlier run so a restaged release can be tagged by the core again; it never touches a tag the base branch already contains.
* @throws if the tag exists on the remote and removing it fails.
*/
export async function verifyRelease(_pluginConfig: PluginConfig, context: ReleaseContext): Promise<void> {
const git = createGitOperations(context.cwd);
const tag = context.nextRelease.gitTag;

if (!(await git.tagExists(tag))) {
return;
}
if (await git.isTagReachableFrom(tag, `origin/${context.branch.name}`)) {
return;
}

context.logger.log(`Removing ${tag}, left by an earlier run, which is not on ${context.branch.name}.`);
await git.deleteTag(tag);
}

/**
* Runs after the core has created and pushed the tag for the release staged by `prepare`, and removes it again: the release is only a pull request at this point, so the tag belongs to whatever publishes once it merges. Order this plugin after every other publish plugin, since none of them can rely on the tag existing once it has run.
* @throws if removing the tag from the remote fails.
*/
export async function publish(_pluginConfig: PluginConfig, context: ReleaseContext): Promise<void> {
const git = createGitOperations(context.cwd);
const tag = context.nextRelease.gitTag;

context.logger.log(`Removing ${tag}: the release is staged as a pull request, not published.`);
await git.deleteTag(tag);
}
62 changes: 48 additions & 14 deletions src/plugin.unit.test.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,17 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { GitOperations } from "./git.js";
import type { PullRequestOperations } from "./pull-request.js";
import type { PrepareContext } from "./types.js";
import type { ReleaseContext } from "./types.js";

const gitOperations: GitOperations = {
checkoutNewBranch: vi.fn(async () => Promise.resolve(undefined)),
stage: vi.fn(async () => Promise.resolve(undefined)),
commit: vi.fn(async () => Promise.resolve(undefined)),
push: vi.fn(async () => Promise.resolve(undefined)),
hasStagedChanges: vi.fn(async () => Promise.resolve(true)),
tagExists: vi.fn(async () => Promise.resolve(false)),
isTagReachableFrom: vi.fn(async () => Promise.resolve(false)),
deleteTag: vi.fn(async () => Promise.resolve(undefined)),
};

const pullRequestOperations: PullRequestOperations = {
Expand All @@ -25,14 +28,14 @@ vi.mock("./pull-request.js", () => ({
createPullRequestOperations: vi.fn(() => pullRequestOperations),
}));

const { prepare, verifyConditions } = await import("./plugin.js");
const { prepare, publish, verifyConditions, verifyRelease } = await import("./plugin.js");

function makeContext(overrides: Partial<PrepareContext> = {}): PrepareContext {
function makeContext(overrides: Partial<ReleaseContext> = {}): ReleaseContext {
return {
cwd: "/repo",
env: { GITHUB_TOKEN: "token" },
branch: { name: "main" },
nextRelease: { version: "1.2.3", notes: "## 1.2.3\n\n- a change" },
nextRelease: { version: "1.2.3", notes: "## 1.2.3\n\n- a change", gitTag: "v1.2.3" },
options: { repositoryUrl: "https://github.com/ExaDev/eslint-config.git" },
logger: { log: vi.fn(() => undefined) },
...overrides,
Expand Down Expand Up @@ -63,13 +66,8 @@ describe("prepare", () => {
beforeEach(() => {
vi.clearAllMocks();
vi.mocked(gitOperations.hasStagedChanges).mockResolvedValue(true);
});

it("ends the run in dry-run mode once a release is staged so no tag or publish follows", async () => {
const context = makeContext();
await prepare({}, context);

expect(context.options.dryRun).toBe(true);
vi.mocked(gitOperations.tagExists).mockResolvedValue(false);
vi.mocked(gitOperations.isTagReachableFrom).mockResolvedValue(false);
});

it("stages the default assets, commits, pushes, and opens a pull request", async () => {
Expand Down Expand Up @@ -111,10 +109,8 @@ describe("prepare", () => {
it("does nothing further when there are no staged changes", async () => {
vi.mocked(gitOperations.hasStagedChanges).mockResolvedValue(false);

const context = makeContext();
await prepare({}, context);
await prepare({}, makeContext());

expect(context.options.dryRun).toBe(true);
expect(gitOperations.commit).not.toHaveBeenCalled();
expect(gitOperations.push).not.toHaveBeenCalled();
expect(vi.mocked(pullRequestOperations.openOrUpdate)).not.toHaveBeenCalled();
Expand All @@ -129,4 +125,42 @@ describe("prepare", () => {
prepare({}, makeContext({ options: { repositoryUrl: "https://gitlab.com/o/r" } })),
).rejects.toThrow(/only supports GitHub/);
});

describe("verifyRelease", () => {
it("removes a tag left by an earlier run that the base branch does not contain", async () => {
vi.mocked(gitOperations.tagExists).mockResolvedValue(true);
vi.mocked(gitOperations.isTagReachableFrom).mockResolvedValue(false);

await verifyRelease({}, makeContext());

expect(gitOperations.isTagReachableFrom).toHaveBeenCalledWith("v1.2.3", "origin/main");
expect(gitOperations.deleteTag).toHaveBeenCalledWith("v1.2.3");
});

it("keeps a tag the base branch already contains", async () => {
vi.mocked(gitOperations.tagExists).mockResolvedValue(true);
vi.mocked(gitOperations.isTagReachableFrom).mockResolvedValue(true);

await verifyRelease({}, makeContext());

expect(gitOperations.deleteTag).not.toHaveBeenCalled();
});

it("does nothing when the tag does not exist", async () => {
vi.mocked(gitOperations.tagExists).mockResolvedValue(false);

await verifyRelease({}, makeContext());

expect(gitOperations.isTagReachableFrom).not.toHaveBeenCalled();
expect(gitOperations.deleteTag).not.toHaveBeenCalled();
});
});

describe("publish", () => {
it("removes the tag the core created for the staged release", async () => {
await publish({}, makeContext());

expect(gitOperations.deleteTag).toHaveBeenCalledWith("v1.2.3");
});
});
});
9 changes: 3 additions & 6 deletions src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,14 +23,11 @@ export interface PluginConfig {
/**
* The subset of a semantic-release plugin's own `pluginConfig`/`context` parameters this plugin reads. Narrower than semantic-release's own (untyped) `Context`, since this plugin only ever uses these fields -- see semantic-release's own plugin documentation for the full shape a real invocation provides.
*/
export interface PrepareContext {
export interface ReleaseContext {
readonly cwd: string;
readonly env: Readonly<Record<string, string | undefined>>;
readonly branch: { readonly name: string };
readonly nextRelease: { readonly version: string; readonly notes: string };
/**
* semantic-release's own options object. `dryRun` is written back by `prepare` once a release is staged, so the run ends without tagging or publishing.
*/
readonly options: { readonly repositoryUrl: string; dryRun?: boolean };
readonly nextRelease: { readonly version: string; readonly notes: string; readonly gitTag: string };
readonly options: { readonly repositoryUrl: string };
readonly logger: { readonly log: (message: string) => void };
}