diff --git a/README.md b/README.md index ced2296..e1e7a55 100644 --- a/README.md +++ b/README.md @@ -41,6 +41,10 @@ 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 + +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. + ## Options | Option | Default | Description | diff --git a/src/plugin.ts b/src/plugin.ts index 9f122a4..e88068e 100644 --- a/src/plugin.ts +++ b/src/plugin.ts @@ -56,6 +56,10 @@ 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); diff --git a/src/plugin.unit.test.ts b/src/plugin.unit.test.ts index c34e71e..0eadc27 100644 --- a/src/plugin.unit.test.ts +++ b/src/plugin.unit.test.ts @@ -65,6 +65,13 @@ describe("prepare", () => { 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); + }); + it("stages the default assets, commits, pushes, and opens a pull request", async () => { const context = makeContext(); await prepare({}, context); @@ -104,8 +111,10 @@ describe("prepare", () => { it("does nothing further when there are no staged changes", async () => { vi.mocked(gitOperations.hasStagedChanges).mockResolvedValue(false); - await prepare({}, makeContext()); + const context = makeContext(); + await prepare({}, context); + expect(context.options.dryRun).toBe(true); expect(gitOperations.commit).not.toHaveBeenCalled(); expect(gitOperations.push).not.toHaveBeenCalled(); expect(vi.mocked(pullRequestOperations.openOrUpdate)).not.toHaveBeenCalled(); diff --git a/src/types.ts b/src/types.ts index 72ae694..95b9340 100644 --- a/src/types.ts +++ b/src/types.ts @@ -28,6 +28,9 @@ export interface PrepareContext { readonly env: Readonly>; readonly branch: { readonly name: string }; readonly nextRelease: { readonly version: string; readonly notes: string }; - readonly options: { readonly repositoryUrl: 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 logger: { readonly log: (message: string) => void }; }