Files
semantic-release/test/git.test.js
T
Olabode Lawal-Shittabey 3e2a07107d feat: implement tags push in verifyAuth dry-run operation and isolate branch status check (#3447)
This PR refactors the `verifyAuth` function replacing the normal push
dry-run (which could result in a branch outdated error/notice) with a
tags dry-run push. It does this by separating the push permission check
operation in the `verifyAuth` logic from the `isBranchUpdated` logic
that lived in thesame execution block via a `try-catch` into standalone
operations; this branch status check only occured when the push
permission check fails.

### Changes Made

- Modify `verifyAuth` logic to do a tags dry run instead of a branch
dry-run push
- Separate `verifyAuth` to a standalone block to throw a
`"EGITNOPERMISSION"` if no push permission is found
- Improved the `isBranchUpdated` helper to ensure it checks for cases
where local branch is either behind, ahead or diverged of head remote
branch and return appropriate boolean response which in this case is...
    - `false` - when local branch is behind remote branch
    - `true` - when local branch is ahead of remote branch
- `false` - when local branch and remote branch have commits that the
other doesn't
- Make the branch up to date check a standalone process in the release
verification process returning `false` when branch is considered not
up-to-date - with a message
    ```js
if (!(await isBranchUpToDate(options.repositoryUrl, context.branch.name,
{ cwd, env }))) {
        logger.log(
`The local branch ${context.branch.name} is behind the remote one,
therefore a new version won't be published.`
        );
        return false;
      }
    ```

### Related Issue

Fixes #3437 

BREAKING CHANGE: `semantic-release` now checks if your local branch is
up-to-date with your remote repo branch after of push permission
verification.
2026-10-22 03:27:48 +01:00

499 lines
18 KiB
JavaScript

import { existsSync } from "node:fs";
import path from "node:path";
import test from "ava";
import { temporaryDirectory } from "tempy";
import {
addNote,
fetch,
fetchNotes,
getBranches,
getGitHead,
getTagHead,
getTags,
getTagsNotes,
isBranchUpToDate,
isGitRepo,
isRefExists,
push,
repoUrl,
tag,
verifyAuth,
verifyTagName,
} from "../lib/git.js";
import {
gitAddConfig,
gitAddNote,
gitCheckout,
gitCommits,
gitCommitTag,
gitDetachedHead,
gitDetachedHeadFromBranch,
gitFetch,
gitGetCommits,
gitGetNote,
gitPush,
gitRemoteTagHead,
gitRepo,
gitShallowClone,
gitTagVersion,
initGit,
} from "./helpers/git-utils.js";
test("Get the last commit sha", async (t) => {
// Create a git repository, set the current working directory at the root of the repo
const { cwd } = await gitRepo();
// Add commits to the master branch
const commits = await gitCommits(["First"], { cwd });
const result = await getGitHead({ cwd });
t.is(result, commits[0].hash);
});
test("Throw error if the last commit sha cannot be found", async (t) => {
// Create a git repository, set the current working directory at the root of the repo
const { cwd } = await gitRepo();
await t.throwsAsync(getGitHead({ cwd }));
});
test("Unshallow and fetch repository", async (t) => {
// Create a git repository, set the current working directory at the root of the repo
let { cwd, repositoryUrl } = await gitRepo();
// Add commits to the master branch
await gitCommits(["First", "Second"], { cwd });
// Create a shallow clone with only 1 commit
cwd = await gitShallowClone(repositoryUrl);
// Verify the shallow clone contains only one commit
t.is((await gitGetCommits(undefined, { cwd })).length, 1);
await fetch(repositoryUrl, "master", "master", { cwd });
// Verify the shallow clone contains all the commits
t.is((await gitGetCommits(undefined, { cwd })).length, 2);
});
test("Do not throw error when unshallow a complete repository", async (t) => {
// Create a git repository, set the current working directory at the root of the repo
const { cwd, repositoryUrl } = await gitRepo(true);
await gitCommits(["First"], { cwd });
await gitPush(repositoryUrl, "master", { cwd });
await gitCheckout("second-branch", true, { cwd });
await gitCommits(["Second"], { cwd });
await gitPush(repositoryUrl, "second-branch", { cwd });
await t.notThrowsAsync(fetch(repositoryUrl, "master", "master", { cwd }));
await t.notThrowsAsync(fetch(repositoryUrl, "second-branch", "master", { cwd }));
});
test("Fetch all tags on a detached head repository", async (t) => {
let { cwd, repositoryUrl } = await gitRepo();
await gitCommits(["First"], { cwd });
await gitTagVersion("v1.0.0", undefined, { cwd });
await gitCommits(["Second"], { cwd });
await gitTagVersion("v1.0.1", undefined, { cwd });
const [commit] = await gitCommits(["Third"], { cwd });
await gitTagVersion("v1.1.0", undefined, { cwd });
await gitPush(repositoryUrl, "master", { cwd });
cwd = await gitDetachedHead(repositoryUrl, commit.hash);
await fetch(repositoryUrl, "master", "master", { cwd });
t.deepEqual((await getTags("master", { cwd })).sort(), ["v1.0.0", "v1.0.1", "v1.1.0"].sort());
});
test("Fetch all tags on a repository with a detached head from branch (CircleCI)", async (t) => {
let { cwd, repositoryUrl } = await gitRepo();
await gitCommits(["First"], { cwd });
await gitTagVersion("v1.0.0", undefined, { cwd });
await gitCommits(["Second"], { cwd });
await gitTagVersion("v1.0.1", undefined, { cwd });
const [commit] = await gitCommits(["Third"], { cwd });
await gitTagVersion("v1.1.0", undefined, { cwd });
await gitPush(repositoryUrl, "master", { cwd });
await gitCheckout("other-branch", true, { cwd });
await gitPush(repositoryUrl, "other-branch", { cwd });
await gitCheckout("master", false, { cwd });
await gitCommits(["Fourth"], { cwd });
await gitTagVersion("v2.0.0", undefined, { cwd });
await gitPush(repositoryUrl, "master", { cwd });
cwd = await gitDetachedHeadFromBranch(repositoryUrl, "other-branch", commit.hash);
await fetch(repositoryUrl, "master", "other-branch", { cwd });
await fetch(repositoryUrl, "other-branch", "other-branch", { cwd });
t.deepEqual((await getTags("other-branch", { cwd })).sort(), ["v1.0.0", "v1.0.1", "v1.1.0"].sort());
t.deepEqual((await getTags("master", { cwd })).sort(), ["v1.0.0", "v1.0.1", "v1.1.0", "v2.0.0"].sort());
});
test("Fetch all tags on a detached head repository with outdated cached repo (GitLab CI)", async (t) => {
const { cwd, repositoryUrl } = await gitRepo();
await gitCommits(["First"], { cwd });
await gitTagVersion("v1.0.0", undefined, { cwd });
await gitCommits(["Second"], { cwd });
await gitTagVersion("v1.0.1", undefined, { cwd });
let [commit] = await gitCommits(["Third"], { cwd });
await gitTagVersion("v1.1.0", undefined, { cwd });
await gitPush(repositoryUrl, "master", { cwd });
// Create a clone (as first CI run would)
const cloneCwd = await gitShallowClone(repositoryUrl);
await gitFetch(repositoryUrl, { cwd: cloneCwd });
await gitCheckout(commit.hash, false, { cwd: cloneCwd });
// Push tag to remote
[commit] = await gitCommits(["Fourth"], { cwd });
await gitTagVersion("v1.2.0", undefined, { cwd });
await gitPush(repositoryUrl, "master", { cwd });
// Fetch on the cached repo and make detached head, leaving master outdated
await fetch(repositoryUrl, "master", "master", { cwd: cloneCwd });
await gitCheckout(commit.hash, false, { cwd: cloneCwd });
t.deepEqual((await getTags("master", { cwd: cloneCwd })).sort(), ["v1.0.0", "v1.0.1", "v1.1.0", "v1.2.0"].sort());
});
test("Verify if a branch exists", async (t) => {
// Create a git repository, set the current working directory at the root of the repo
const { cwd } = await gitRepo();
// Add commits to the master branch
await gitCommits(["First"], { cwd });
// Create the new branch 'other-branch' from master
await gitCheckout("other-branch", true, { cwd });
// Add commits to the 'other-branch' branch
await gitCommits(["Second"], { cwd });
t.true(await isRefExists("master", { cwd }));
t.true(await isRefExists("other-branch", { cwd }));
t.falsy(await isRefExists("next", { cwd }));
});
test("Get all branches", async (t) => {
const { cwd, repositoryUrl } = await gitRepo(true);
await gitCommits(["First"], { cwd });
await gitPush(repositoryUrl, "master", { cwd });
await gitCheckout("second-branch", true, { cwd });
await gitCommits(["Second"], { cwd });
await gitPush(repositoryUrl, "second-branch", { cwd });
await gitCheckout("third-branch", true, { cwd });
await gitCommits(["Third"], { cwd });
await gitPush(repositoryUrl, "third-branch", { cwd });
t.deepEqual((await getBranches(repositoryUrl, { cwd })).sort(), ["master", "second-branch", "third-branch"].sort());
});
test("Return empty array if there are no branches", async (t) => {
const { cwd, repositoryUrl } = await initGit(true);
t.deepEqual(await getBranches(repositoryUrl, { cwd }), []);
});
test("Get the commit sha for a given tag", async (t) => {
// Create a git repository, set the current working directory at the root of the repo
const { cwd } = await gitRepo();
// Add commits to the master branch
const commits = await gitCommits(["First"], { cwd });
// Create the tag corresponding to version 1.0.0
await gitTagVersion("v1.0.0", undefined, { cwd });
t.is(await getTagHead("v1.0.0", { cwd }), commits[0].hash);
});
test("Verify auth succeeds when the remote accepts the dry-run push and tag push", async (t) => {
const { cwd, repositoryUrl } = await gitRepo(true);
await gitCommits(["First"], { cwd });
await gitTagVersion("v1.0.0", undefined, { cwd });
await gitPush(repositoryUrl, "master", { cwd });
await t.notThrowsAsync(verifyAuth(repositoryUrl, { cwd }));
});
test("Verify auth ignores non-fast-forward and rejected push errors", async (t) => {
const { cwd, repositoryUrl } = await gitRepo(true);
await gitCommits(["First"], { cwd });
await gitTagVersion("v1.0.0", undefined, { cwd });
await gitPush(repositoryUrl, "master", { cwd });
const staleRepo = await gitShallowClone(repositoryUrl);
await gitCommits(["Second"], { cwd });
await gitPush(repositoryUrl, "master", { cwd });
await t.notThrowsAsync(verifyAuth(repositoryUrl, { cwd: staleRepo }));
});
test("Return git remote repository url from config", async (t) => {
// Create a git repository, set the current working directory at the root of the repo
const { cwd } = await gitRepo();
// Add remote.origin.url config
await gitAddConfig("remote.origin.url", "git@hostname.com:owner/package.git", { cwd });
t.is(await repoUrl({ cwd }), "git@hostname.com:owner/package.git");
});
test("Return git remote repository url set while cloning", async (t) => {
// Create a git repository, set the current working directory at the root of the repo
let { cwd, repositoryUrl } = await gitRepo();
await gitCommits(["First"], { cwd });
// Create a clone
cwd = await gitShallowClone(repositoryUrl);
t.is(await repoUrl({ cwd }), repositoryUrl);
});
test("Return falsy if git repository url is not set", async (t) => {
// Create a git repository, set the current working directory at the root of the repo
const { cwd } = await gitRepo();
t.falsy(await repoUrl({ cwd }));
});
test("Add tag on head commit", async (t) => {
// Create a git repository, set the current working directory at the root of the repo
const { cwd } = await gitRepo();
const commits = await gitCommits(["Test commit"], { cwd });
await tag("tag_name", "HEAD", { cwd });
await t.is(await gitCommitTag(commits[0].hash, { cwd }), "tag_name");
});
test("Push tag to remote repository", async (t) => {
// Create a git repository with a remote, set the current working directory at the root of the repo
const { cwd, repositoryUrl } = await gitRepo(true);
const commits = await gitCommits(["Test commit"], { cwd });
await tag("tag_name", "HEAD", { cwd });
await push(repositoryUrl, { cwd });
t.is(await gitRemoteTagHead(repositoryUrl, "tag_name", { cwd }), commits[0].hash);
});
test("Push tag to remote repository with remote branch ahead", async (t) => {
const { cwd, repositoryUrl } = await gitRepo(true);
const commits = await gitCommits(["First"], { cwd });
await gitPush(repositoryUrl, "master", { cwd });
const temporaryRepo = await gitShallowClone(repositoryUrl);
await gitCommits(["Second"], { cwd: temporaryRepo });
await gitPush("origin", "master", { cwd: temporaryRepo });
await tag("tag_name", "HEAD", { cwd });
await push(repositoryUrl, { cwd });
t.is(await gitRemoteTagHead(repositoryUrl, "tag_name", { cwd }), commits[0].hash);
});
test('Return "true" if in a Git repository', async (t) => {
// Create a git repository with a remote, set the current working directory at the root of the repo
const { cwd } = await gitRepo(true);
t.true(await isGitRepo({ cwd }));
});
test("Return falsy if not in a Git repository", async (t) => {
const cwd = temporaryDirectory();
t.falsy(await isGitRepo({ cwd }));
});
test('Return "true" for valid tag names', async (t) => {
t.true(await verifyTagName("1.0.0"));
t.true(await verifyTagName("v1.0.0"));
t.true(await verifyTagName("tag_name"));
t.true(await verifyTagName("tag/name"));
});
test("Return falsy for invalid tag names", async (t) => {
t.falsy(await verifyTagName("?1.0.0"));
t.falsy(await verifyTagName("*1.0.0"));
t.falsy(await verifyTagName("[1.0.0]"));
t.falsy(await verifyTagName("1.0.0.."));
});
test("Throws error if obtaining the tags fails", async (t) => {
const cwd = temporaryDirectory();
await t.throwsAsync(getTags("master", { cwd }));
});
test('Return "true" if repository is up to date', async (t) => {
const { cwd, repositoryUrl } = await gitRepo(true);
await gitCommits(["First"], { cwd });
await gitPush(repositoryUrl, "master", { cwd });
t.true(await isBranchUpToDate(repositoryUrl, "master", { cwd }));
});
test("Return true if local repository is ahead of remote", async (t) => {
const { cwd, repositoryUrl } = await gitRepo(true);
await gitCommits(["First"], { cwd });
await gitCommits(["Second"], { cwd });
await gitPush(repositoryUrl, "master", { cwd });
const localCwd = await gitShallowClone(repositoryUrl);
await gitCommits(["Third"], { cwd: localCwd });
t.true(await isBranchUpToDate(repositoryUrl, "master", { cwd: localCwd }));
});
test("Return falsy if local repository is behind remote", async (t) => {
const { cwd, repositoryUrl } = await gitRepo(true);
await gitCommits(["First"], { cwd });
await gitPush(repositoryUrl, "master", { cwd });
const localCwd = await gitShallowClone(repositoryUrl);
await gitCommits(["Second", "Third"], { cwd });
await gitPush(repositoryUrl, "master", { cwd });
t.falsy(await isBranchUpToDate(repositoryUrl, "master", { cwd: localCwd }));
});
test("Return falsy if local and remote repository are diverged i.e. both have commits the other doesn't", async (t) => {
const { cwd, repositoryUrl } = await gitRepo(true);
await gitCommits(["First"], { cwd });
await gitPush(repositoryUrl, "master", { cwd });
const localCwd = await gitShallowClone(repositoryUrl);
await gitCommits(["Second", "Third"], { cwd });
await gitPush(repositoryUrl, "master", { cwd });
await gitCommits(["Fourth"], { cwd: localCwd });
t.falsy(await isBranchUpToDate(repositoryUrl, "master", { cwd: localCwd }));
});
test("Return falsy if detached head repository is not up to date", async (t) => {
let { cwd, repositoryUrl } = await gitRepo();
const [commit] = await gitCommits(["First"], { cwd });
await gitCommits(["Second"], { cwd });
await gitPush(repositoryUrl, "master", { cwd });
cwd = await gitDetachedHead(repositoryUrl, commit.hash);
await fetch(repositoryUrl, "master", "master", { cwd });
t.falsy(await isBranchUpToDate(repositoryUrl, "master", { cwd }));
});
test("Get a commit note", async (t) => {
// Create a git repository, set the current working directory at the root of the repo
const { cwd } = await gitRepo();
// Add commits to the master branch
await gitCommits(["First"], { cwd });
await gitTagVersion("v1.0.0", undefined, { cwd });
await gitAddNote(JSON.stringify({ note: "note" }), "v1.0.0", { cwd });
const tagsNotes = await getTagsNotes({ cwd });
t.deepEqual(tagsNotes.get("v1.0.0"), { note: "note" });
});
test("Return undefined if there is no commit note", async (t) => {
// Create a git repository, set the current working directory at the root of the repo
const { cwd } = await gitRepo();
// Add commits to the master branch
await gitCommits(["First"], { cwd });
await gitTagVersion("v1.0.0", undefined, { cwd });
const tagsNotes = await getTagsNotes({ cwd });
t.deepEqual(tagsNotes.get("v1.0.0"), undefined);
});
test("Return undefined if a commit note is invalid", async (t) => {
// Create a git repository, set the current working directory at the root of the repo
const { cwd } = await gitRepo();
// Add commits to the master branch
await gitCommits(["First"], { cwd });
await gitTagVersion("v1.0.0", undefined, { cwd });
await gitAddNote("non-json note", "v1.0.0", { cwd });
const tagsNotes = await getTagsNotes({ cwd });
t.deepEqual(tagsNotes.get("v1.0.0"), undefined);
});
test("Add a commit note", async (t) => {
// Create a git repository, set the current working directory at the root of the repo
const { cwd } = await gitRepo();
// Add commits to the master branch
const commits = await gitCommits(["First"], { cwd });
await addNote({ note: "note" }, commits[0].hash, { cwd });
t.is(await gitGetNote(commits[0].hash, { cwd }), '{"note":"note"}');
});
test("Overwrite a commit note", async (t) => {
// Create a git repository, set the current working directory at the root of the repo
const { cwd } = await gitRepo();
// Add commits to the master branch
const commits = await gitCommits(["First"], { cwd });
await addNote({ note: "note" }, commits[0].hash, { cwd });
await addNote({ note: "note2" }, commits[0].hash, { cwd });
t.is(await gitGetNote(commits[0].hash, { cwd }), '{"note":"note2"}');
});
test("Unshallow and fetch repository with notes", async (t) => {
// Create a git repository, set the current working directory at the root of the repo
let { cwd, repositoryUrl } = await gitRepo();
// Add commits to the master branch
const commits = await gitCommits(["First", "Second"], { cwd });
await gitAddNote(JSON.stringify({ note: "note" }), commits[0].hash, { cwd });
// Create a shallow clone with only 1 commit
cwd = await gitShallowClone(repositoryUrl);
// Verify the shallow clone doesn't contains the note
await t.throwsAsync(gitGetNote(commits[0].hash, { cwd }));
await fetch(repositoryUrl, "master", "master", { cwd });
await fetchNotes(repositoryUrl, { cwd });
// Verify the shallow clone contains the note
t.is(await gitGetNote(commits[0].hash, { cwd }), '{"note":"note"}');
});
test("Fetch all notes on a detached head repository", async (t) => {
let { cwd, repositoryUrl } = await gitRepo();
await gitCommits(["First"], { cwd });
const [commit] = await gitCommits(["Second"], { cwd });
await gitPush(repositoryUrl, "master", { cwd });
await gitAddNote(JSON.stringify({ note: "note" }), commit.hash, { cwd });
cwd = await gitDetachedHead(repositoryUrl, commit.hash);
await fetch(repositoryUrl, "master", "master", { cwd });
await fetchNotes(repositoryUrl, { cwd });
t.is(await gitGetNote(commit.hash, { cwd }), '{"note":"note"}');
});
test("Does not execute a `repositoryUrl` injected as an `--upload-pack` git option", async (t) => {
const { cwd } = await gitRepo();
await gitCommits(["First"], { cwd });
const marker = path.join(temporaryDirectory(), "upload-pack-rce");
// A `repositoryUrl` beginning with `-` would be parsed by git as a
// command-line option rather than a positional argument. `--upload-pack`
// lets the injected value run an arbitrary binary when fetching refs.
const repositoryUrl = `--upload-pack=touch ${marker}`;
await t.throwsAsync(isBranchUpToDate(repositoryUrl, "master", { cwd }));
t.false(existsSync(marker));
});
test("Does not execute a `repositoryUrl` injected as a `--receive-pack` git option", async (t) => {
const { cwd } = await gitRepo(true);
await gitCommits(["First"], { cwd });
const marker = path.join(temporaryDirectory(), "receive-pack-rce");
// `--receive-pack` is the push-side equivalent: it lets the injected value
// run an arbitrary binary when pushing to the (configured) remote.
const repositoryUrl = `--receive-pack=touch ${marker}`;
await t.throwsAsync(push(repositoryUrl, { cwd }));
t.false(existsSync(marker));
});