chore: initial-commit #1
26
.gitea/workflows/cd.yaml
Normal file
26
.gitea/workflows/cd.yaml
Normal file
@@ -0,0 +1,26 @@
|
||||
name: CD
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
|
||||
jobs:
|
||||
release:
|
||||
name: Release
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version-file: .nvmrc
|
||||
- run: npm ci
|
||||
- name: Increment tag
|
||||
id: increment-tag
|
||||
uses: ./
|
||||
with:
|
||||
token: ${{ secrets.GITEA_TOKEN }}
|
||||
- name: Push tag
|
||||
run: |
|
||||
git tag ${{ steps.increment-tag.outputs.new-tag }}
|
||||
git push origin ${{ steps.increment-tag.outputs.new-tag }}
|
||||
63
.gitea/workflows/ci.yaml
Normal file
63
.gitea/workflows/ci.yaml
Normal file
@@ -0,0 +1,63 @@
|
||||
name: CI
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
|
||||
jobs:
|
||||
test:
|
||||
name: Test
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version-file: .nvmrc
|
||||
- run: npm ci
|
||||
- name: Format code
|
||||
run: npm run format
|
||||
- name: Typecheck
|
||||
run: npm run typecheck
|
||||
- name: Run test
|
||||
run: npm run test
|
||||
|
||||
check-dist:
|
||||
name: Check Dist
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version-file: .nvmrc
|
||||
- run: npm ci
|
||||
- run: mv dist dist_orig
|
||||
- run: npm run build
|
||||
- run: |
|
||||
original_hash=$(sha256sum dist_orig/index.js | cut -d' ' -f1)
|
||||
new_hash=$(sha256sum dist/index.js | cut -d' ' -f1)
|
||||
if [ "$original_hash" != "$new_hash" ]; then
|
||||
echo "Build is not up to date. Original hash: $original_hash, new hash: $new_hash"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
dry-run:
|
||||
name: Dry-Run
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version-file: .nvmrc
|
||||
- run: npm ci
|
||||
- name: Increment tag
|
||||
id: increment-tag
|
||||
uses: ./
|
||||
with:
|
||||
token: ${{ secrets.GITEA_TOKEN }}
|
||||
- name: Print new tag
|
||||
run: |
|
||||
if [ -z "${{ steps.increment-tag.outputs.new-tag }}" ]; then
|
||||
echo "No new tag found"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "New tag: ${{ steps.increment-tag.outputs.new-tag }}"
|
||||
54
.gitignore
vendored
Normal file
54
.gitignore
vendored
Normal file
@@ -0,0 +1,54 @@
|
||||
# Dependencies
|
||||
node_modules/
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
|
||||
# Build outputs
|
||||
build/
|
||||
*.tsbuildinfo
|
||||
|
||||
# Coverage reports
|
||||
coverage/
|
||||
*.lcov
|
||||
|
||||
# Environment variables
|
||||
.env
|
||||
.env.local
|
||||
.env.*.local
|
||||
|
||||
# IDE files
|
||||
.vscode/
|
||||
.idea/
|
||||
*.swp
|
||||
*.swo
|
||||
*~
|
||||
|
||||
# OS files
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
# Logs
|
||||
logs/
|
||||
*.log
|
||||
|
||||
# Runtime data
|
||||
pids/
|
||||
*.pid
|
||||
*.seed
|
||||
*.pid.lock
|
||||
|
||||
# Optional npm cache directory
|
||||
.npm
|
||||
|
||||
# Optional eslint cache
|
||||
.eslintcache
|
||||
|
||||
# Optional REPL history
|
||||
.node_repl_history
|
||||
|
||||
# Output of 'npm pack'
|
||||
*.tgz
|
||||
|
||||
# Yarn Integrity file
|
||||
.yarn-integrity
|
||||
8
.prettierrc
Normal file
8
.prettierrc
Normal file
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"semi": true,
|
||||
"trailingComma": "es5",
|
||||
"singleQuote": false,
|
||||
"printWidth": 100,
|
||||
"tabWidth": 2,
|
||||
"useTabs": false
|
||||
}
|
||||
62
README.md
62
README.md
@@ -1,2 +1,62 @@
|
||||
# conventional-semantic-git-tag-increment
|
||||
# Conventional Semantic Git Tag Increment
|
||||
|
||||
A GitHub Action that automatically increments semantic version tags based on conventional commit messages.
|
||||
|
||||
Doesn't assume that you are using any special packaging software, etc. It just relies on Git having tags and commit messages.
|
||||
|
||||
## Usage
|
||||
|
||||
### Basic workflow
|
||||
|
||||
```yaml
|
||||
name: Auto-tag
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
|
||||
jobs:
|
||||
tag:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- uses: tbehrendt/conventional-semantic-git-tag-increment@v1
|
||||
id: tag
|
||||
with:
|
||||
token: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Create and push tag
|
||||
run: |
|
||||
git tag ${{ steps.tag.outputs.new-tag }}
|
||||
git push origin ${{ steps.tag.outputs.new-tag }}
|
||||
```
|
||||
|
||||
### With custom last tag
|
||||
|
||||
```yaml
|
||||
- uses: tbehrendt/conventional-semantic-git-tag-increment@v1
|
||||
with:
|
||||
last-tag: "v2.1.0"
|
||||
token: ${{ secrets.GITHUB_TOKEN }}
|
||||
```
|
||||
|
||||
## Examples
|
||||
|
||||
| Commit Message | Current Tag | New Tag | Reason |
|
||||
| ----------------------------------- | ----------- | -------- | -------------------- |
|
||||
| `feat: add user authentication` | `v1.0.0` | `v1.1.0` | New feature |
|
||||
| `fix: resolve login bug` | `v1.1.0` | `v1.1.1` | Bug fix |
|
||||
| `feat!: change API response format` | `v1.1.1` | `v2.0.0` | Breaking change |
|
||||
| `docs: update README` | `v2.0.0` | `v2.0.1` | Documentation update |
|
||||
|
||||
## Inputs
|
||||
|
||||
- `last-tag` (optional): Starting tag to increment from. If not provided, uses the latest tag in the repository.
|
||||
- `token` (required): GitHub token for repository access. Use `${{ github.token }}` for public repos or a PAT for private repos.
|
||||
|
||||
## Outputs
|
||||
|
||||
- `new-tag`: The incremented semantic version tag (e.g., `1.2.3`)
|
||||
|
||||
21
action.yml
Normal file
21
action.yml
Normal file
@@ -0,0 +1,21 @@
|
||||
name: "Conventional Semantic Git Tag Increment"
|
||||
description: "Increments git tags based on conventional commit messages"
|
||||
author: "Timo Behrendt <t.behrendt@t00n.de>"
|
||||
|
||||
inputs:
|
||||
last-tag:
|
||||
description: "Last git tag to increment from (optional, will auto-detect if not provided)"
|
||||
required: false
|
||||
default: ""
|
||||
token:
|
||||
description: "Token for repository access"
|
||||
required: true
|
||||
default: "${{ github.token }}"
|
||||
|
||||
outputs:
|
||||
new-tag:
|
||||
description: "The new incremented git tag"
|
||||
|
||||
runs:
|
||||
using: "node20"
|
||||
main: "dist/index.js"
|
||||
104
dist/index.js
vendored
Normal file
104
dist/index.js
vendored
Normal file
File diff suppressed because one or more lines are too long
12
esbuild.config.mjs
Normal file
12
esbuild.config.mjs
Normal file
@@ -0,0 +1,12 @@
|
||||
import { build } from "esbuild";
|
||||
|
||||
await build({
|
||||
entryPoints: ["src/index.ts"],
|
||||
bundle: true,
|
||||
platform: "node",
|
||||
target: "node20",
|
||||
outfile: "dist/index.js",
|
||||
sourcemap: false,
|
||||
minify: true,
|
||||
logLevel: "info",
|
||||
});
|
||||
10
jest.config.js
Normal file
10
jest.config.js
Normal file
@@ -0,0 +1,10 @@
|
||||
module.exports = {
|
||||
preset: "ts-jest",
|
||||
testEnvironment: "node",
|
||||
roots: ["<rootDir>/src"],
|
||||
testMatch: ["**/*.spec.ts"],
|
||||
transform: {
|
||||
"^.+\\.ts$": "ts-jest",
|
||||
},
|
||||
moduleFileExtensions: ["ts", "js", "json"],
|
||||
};
|
||||
5706
package-lock.json
generated
Normal file
5706
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
27
package.json
Normal file
27
package.json
Normal file
@@ -0,0 +1,27 @@
|
||||
{
|
||||
"name": "conventional-semantic-git-tag-increment",
|
||||
"description": "GitHub Action to increment semantic version tag based on conventional commits",
|
||||
"main": "src/index.ts",
|
||||
"scripts": {
|
||||
"typecheck": "tsc --noEmit",
|
||||
"build": "esbuild src/index.ts --bundle --outfile=dist/index.js --platform=node --target=node20 --minify",
|
||||
"test": "jest",
|
||||
"format": "prettier --write src/**/*.ts"
|
||||
},
|
||||
"author": "Timo Behrendt <t.behrendt@t00n.de>",
|
||||
"license": "MIT",
|
||||
"devDependencies": {
|
||||
"@types/conventional-commits-parser": "^5.0.1",
|
||||
"@types/jest": "^30.0.0",
|
||||
"@types/node": "^24.3.0",
|
||||
"esbuild": "^0.25.9",
|
||||
"jest": "^30.0.5",
|
||||
"prettier": "^3.6.2",
|
||||
"ts-jest": "^29.4.1",
|
||||
"typescript": "^5.9.2"
|
||||
},
|
||||
"dependencies": {
|
||||
"@actions/core": "^1.11.1",
|
||||
"conventional-commits-parser": "^3.2.4"
|
||||
}
|
||||
}
|
||||
3
src/index.ts
Normal file
3
src/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
import { run } from "./main";
|
||||
|
||||
run();
|
||||
404
src/main.spec.ts
Normal file
404
src/main.spec.ts
Normal file
@@ -0,0 +1,404 @@
|
||||
import {
|
||||
IncrementType,
|
||||
Tag,
|
||||
GitService,
|
||||
CoreService,
|
||||
ConventionalCommitAnalyzer,
|
||||
TagService,
|
||||
TagIncrementer,
|
||||
LocalGitService,
|
||||
LocalCoreService,
|
||||
} from "./main";
|
||||
|
||||
// Mock child_process.execSync
|
||||
jest.mock("child_process", () => ({
|
||||
execSync: jest.fn(),
|
||||
}));
|
||||
|
||||
import { execSync } from "child_process";
|
||||
const mockExecSync = execSync as jest.MockedFunction<typeof execSync>;
|
||||
|
||||
// Mock interfaces for testing
|
||||
class MockGitService implements GitService {
|
||||
constructor(
|
||||
private mockTags: { name: string }[] = [],
|
||||
private mockCommit: { commit: { message: string } } = { commit: { message: "test commit" } }
|
||||
) {}
|
||||
|
||||
async listTags(perPage: number): Promise<{ name: string }[]> {
|
||||
return this.mockTags.slice(0, perPage);
|
||||
}
|
||||
|
||||
async getCommit(ref: string): Promise<{ commit: { message: string } }> {
|
||||
// Use ref parameter to avoid unused parameter warning
|
||||
if (!ref) {
|
||||
throw new Error("Ref is required");
|
||||
}
|
||||
return this.mockCommit;
|
||||
}
|
||||
|
||||
async testConnection(): Promise<void> {
|
||||
// Mock successful connection
|
||||
}
|
||||
|
||||
setMockTags(tags: { name: string }[]) {
|
||||
this.mockTags = tags;
|
||||
}
|
||||
|
||||
setMockCommit(commit: { commit: { message: string } }) {
|
||||
this.mockCommit = commit;
|
||||
}
|
||||
}
|
||||
|
||||
class MockCoreService implements CoreService {
|
||||
private inputs: Record<string, string> = {};
|
||||
private outputs: Record<string, string> = {};
|
||||
private logs: string[] = [];
|
||||
private warnings: string[] = [];
|
||||
private failures: string[] = [];
|
||||
|
||||
setInput(name: string, value: string) {
|
||||
this.inputs[name] = value;
|
||||
}
|
||||
|
||||
getInput(name: string): string {
|
||||
return this.inputs[name] || "";
|
||||
}
|
||||
|
||||
info(message: string): void {
|
||||
this.logs.push(`INFO: ${message}`);
|
||||
}
|
||||
|
||||
warning(message: string): void {
|
||||
this.warnings.push(`WARNING: ${message}`);
|
||||
}
|
||||
|
||||
setOutput(name: string, value: string): void {
|
||||
this.outputs[name] = value;
|
||||
}
|
||||
|
||||
setFailed(message: string): void {
|
||||
this.failures.push(message);
|
||||
}
|
||||
|
||||
getOutputs(): Record<string, string> {
|
||||
return { ...this.outputs };
|
||||
}
|
||||
|
||||
getLogs(): string[] {
|
||||
return [...this.logs];
|
||||
}
|
||||
|
||||
getWarnings(): string[] {
|
||||
return [...this.warnings];
|
||||
}
|
||||
|
||||
getFailures(): string[] {
|
||||
return [...this.failures];
|
||||
}
|
||||
|
||||
clear() {
|
||||
this.outputs = {};
|
||||
this.logs = [];
|
||||
this.warnings = [];
|
||||
this.failures = [];
|
||||
}
|
||||
}
|
||||
|
||||
describe("TagService", () => {
|
||||
let tagService: TagService;
|
||||
|
||||
beforeEach(() => {
|
||||
tagService = new TagService();
|
||||
});
|
||||
|
||||
describe("bumpTag", () => {
|
||||
const baseTag: Tag = { major: 1, minor: 2, patch: 3 };
|
||||
|
||||
it("should increment major version", () => {
|
||||
const result = tagService.bumpTag(baseTag, IncrementType.MAJOR);
|
||||
expect(result).toEqual({ major: 2, minor: 0, patch: 0 });
|
||||
});
|
||||
|
||||
it("should increment minor version", () => {
|
||||
const result = tagService.bumpTag(baseTag, IncrementType.MINOR);
|
||||
expect(result).toEqual({ major: 1, minor: 3, patch: 0 });
|
||||
});
|
||||
|
||||
it("should increment patch version", () => {
|
||||
const result = tagService.bumpTag(baseTag, IncrementType.PATCH);
|
||||
expect(result).toEqual({ major: 1, minor: 2, patch: 4 });
|
||||
});
|
||||
|
||||
it("should handle zero versions", () => {
|
||||
const zeroTag: Tag = { major: 0, minor: 0, patch: 0 };
|
||||
const result = tagService.bumpTag(zeroTag, IncrementType.MINOR);
|
||||
expect(result).toEqual({ major: 0, minor: 1, patch: 0 });
|
||||
});
|
||||
});
|
||||
|
||||
describe("renderTag", () => {
|
||||
const tag: Tag = { major: 1, minor: 2, patch: 3 };
|
||||
|
||||
it("should render tag without prefix", () => {
|
||||
const result = tagService.renderTag(tag);
|
||||
expect(result).toBe("1.2.3");
|
||||
});
|
||||
|
||||
it("should render tag with prefix", () => {
|
||||
const result = tagService.renderTag(tag, "v");
|
||||
expect(result).toBe("v1.2.3");
|
||||
});
|
||||
});
|
||||
|
||||
describe("parseTag", () => {
|
||||
it("should parse valid tag without prefix", () => {
|
||||
const result = tagService.parseTag("1.2.3");
|
||||
expect(result).toEqual({ major: 1, minor: 2, patch: 3 });
|
||||
});
|
||||
|
||||
it("should parse valid tag with prefix", () => {
|
||||
const result = tagService.parseTag("v1.2.3");
|
||||
expect(result).toEqual({ major: 1, minor: 2, patch: 3 });
|
||||
});
|
||||
|
||||
it("should throw error for invalid format", () => {
|
||||
expect(() => tagService.parseTag("invalid")).toThrow(
|
||||
"Invalid tag format: invalid. Expected semantic version (e.g., 1.2.3)"
|
||||
);
|
||||
});
|
||||
|
||||
it("should throw error for non-numeric versions", () => {
|
||||
expect(() => tagService.parseTag("1.a.3")).toThrow(
|
||||
"Invalid tag format: 1.a.3. Expected semantic version (e.g., 1.2.3)"
|
||||
);
|
||||
});
|
||||
|
||||
it("should throw error for incomplete version", () => {
|
||||
expect(() => tagService.parseTag("1.2")).toThrow(
|
||||
"Invalid tag format: 1.2. Expected semantic version (e.g., 1.2.3)"
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("TagIncrementer", () => {
|
||||
let tagIncrementer: TagIncrementer;
|
||||
let mockGitService: MockGitService;
|
||||
let mockCoreService: MockCoreService;
|
||||
let mockCommitAnalyzer: ConventionalCommitAnalyzer;
|
||||
let mockTagService: TagService;
|
||||
|
||||
beforeEach(() => {
|
||||
mockGitService = new MockGitService();
|
||||
mockCoreService = new MockCoreService();
|
||||
mockCommitAnalyzer = new ConventionalCommitAnalyzer();
|
||||
mockTagService = new TagService();
|
||||
|
||||
tagIncrementer = new TagIncrementer(
|
||||
mockGitService,
|
||||
mockCoreService,
|
||||
mockCommitAnalyzer,
|
||||
mockTagService
|
||||
);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
mockCoreService.clear();
|
||||
});
|
||||
|
||||
describe("getBaseTag", () => {
|
||||
it("should return provided tag when available", async () => {
|
||||
const result = await tagIncrementer.getBaseTag("v1.2.3");
|
||||
|
||||
expect(result).toBe("v1.2.3");
|
||||
expect(mockCoreService.getLogs()).toContain("INFO: Using provided tag: v1.2.3");
|
||||
});
|
||||
|
||||
it("should fetch latest tag when no tag provided", async () => {
|
||||
mockGitService.setMockTags([{ name: "v2.0.0" }]);
|
||||
|
||||
const result = await tagIncrementer.getBaseTag(undefined);
|
||||
|
||||
expect(result).toBe("v2.0.0");
|
||||
expect(mockCoreService.getLogs()).toContain("INFO: Using latest tag: v2.0.0");
|
||||
});
|
||||
|
||||
it("should default to 0.0.0 when no tags found", async () => {
|
||||
mockGitService.setMockTags([]);
|
||||
|
||||
const result = await tagIncrementer.getBaseTag(undefined);
|
||||
|
||||
expect(result).toBe("0.0.0");
|
||||
expect(mockCoreService.getWarnings()).toContain(
|
||||
"WARNING: No tags found in repository, defaulting to 0.0.0"
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("determineIncrementType", () => {
|
||||
it("should analyze commit and determine increment type", async () => {
|
||||
mockGitService.setMockCommit({
|
||||
commit: { message: "feat: new feature" },
|
||||
});
|
||||
|
||||
const result = await tagIncrementer.determineIncrementType("ref");
|
||||
|
||||
expect(result).toBe(IncrementType.MINOR);
|
||||
});
|
||||
});
|
||||
|
||||
describe("incrementTag", () => {
|
||||
it("should increment tag correctly for patch increment", async () => {
|
||||
mockGitService.setMockTags([{ name: "v1.2.3" }]);
|
||||
mockGitService.setMockCommit({
|
||||
commit: { message: "fix: bug fix" },
|
||||
});
|
||||
|
||||
const result = await tagIncrementer.incrementTag(undefined, "ref");
|
||||
|
||||
expect(result).toBe("1.2.4");
|
||||
expect(mockCoreService.getOutputs()).toEqual({});
|
||||
});
|
||||
|
||||
it("should increment tag correctly for minor increment", async () => {
|
||||
mockGitService.setMockTags([{ name: "v1.2.3" }]);
|
||||
mockGitService.setMockCommit({
|
||||
commit: { message: "feat: new feature" },
|
||||
});
|
||||
|
||||
const result = await tagIncrementer.incrementTag(undefined, "ref");
|
||||
|
||||
expect(result).toBe("1.3.0");
|
||||
});
|
||||
|
||||
it("should increment tag correctly for major increment", async () => {
|
||||
mockGitService.setMockTags([{ name: "v1.2.3" }]);
|
||||
mockGitService.setMockCommit({
|
||||
commit: { message: "feat!: breaking change" },
|
||||
});
|
||||
|
||||
const result = await tagIncrementer.incrementTag(undefined, "ref");
|
||||
|
||||
expect(result).toBe("2.0.0");
|
||||
});
|
||||
|
||||
it("should use provided tag when available", async () => {
|
||||
mockGitService.setMockCommit({
|
||||
commit: { message: "fix: bug fix" },
|
||||
});
|
||||
|
||||
const result = await tagIncrementer.incrementTag("v2.1.0", "ref");
|
||||
|
||||
expect(result).toBe("2.1.1");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("LocalGitService", () => {
|
||||
let localGitService: LocalGitService;
|
||||
|
||||
beforeEach(() => {
|
||||
localGitService = new LocalGitService();
|
||||
mockExecSync.mockClear();
|
||||
});
|
||||
|
||||
describe("listTags", () => {
|
||||
it("should call git command to list tags", async () => {
|
||||
mockExecSync.mockReturnValue("v1.0.0\nv1.1.0\n" as any);
|
||||
|
||||
const result = await localGitService.listTags(5);
|
||||
|
||||
expect(mockExecSync).toHaveBeenCalledWith("git tag --sort=-version:refname", {
|
||||
encoding: "utf8",
|
||||
});
|
||||
expect(result).toEqual([{ name: "v1.0.0" }, { name: "v1.1.0" }]);
|
||||
});
|
||||
|
||||
it("should return empty array when no tags exist", async () => {
|
||||
mockExecSync.mockReturnValue("" as any);
|
||||
|
||||
const result = await localGitService.listTags(5);
|
||||
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getCommit", () => {
|
||||
it("should call git command to get commit", async () => {
|
||||
mockExecSync.mockReturnValue("test commit message" as any);
|
||||
|
||||
const result = await localGitService.getCommit("abc123");
|
||||
|
||||
expect(mockExecSync).toHaveBeenCalledWith("git log -1 --pretty=%B abc123", {
|
||||
encoding: "utf8",
|
||||
});
|
||||
expect(result).toEqual({ commit: { message: "test commit message" } });
|
||||
});
|
||||
});
|
||||
|
||||
describe("testConnection", () => {
|
||||
it("should call git command to test connection", async () => {
|
||||
mockExecSync.mockReturnValue(".git" as any);
|
||||
|
||||
await localGitService.testConnection();
|
||||
|
||||
expect(mockExecSync).toHaveBeenCalledWith("git rev-parse --git-dir", { stdio: "pipe" });
|
||||
});
|
||||
|
||||
it("should throw error when not in git repository", async () => {
|
||||
mockExecSync.mockImplementation(() => {
|
||||
throw new Error("fatal: not a git repository");
|
||||
});
|
||||
|
||||
await expect(localGitService.testConnection()).rejects.toThrow(
|
||||
"Not in a git repository. Please ensure this action runs after checkout."
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("LocalCoreService", () => {
|
||||
let localCoreService: LocalCoreService;
|
||||
|
||||
beforeEach(() => {
|
||||
localCoreService = new LocalCoreService();
|
||||
});
|
||||
|
||||
// Note: These tests would require mocking the @actions/core module
|
||||
// In a real implementation, you might want to use jest.mock() to mock the entire module
|
||||
// For now, we'll just test that the methods exist and can be called
|
||||
|
||||
it("should have all required methods", () => {
|
||||
expect(typeof localCoreService.getInput).toBe("function");
|
||||
expect(typeof localCoreService.info).toBe("function");
|
||||
expect(typeof localCoreService.warning).toBe("function");
|
||||
expect(typeof localCoreService.setOutput).toBe("function");
|
||||
expect(typeof localCoreService.setFailed).toBe("function");
|
||||
});
|
||||
});
|
||||
|
||||
// Integration test example
|
||||
describe("TagIncrementer Integration", () => {
|
||||
it("should handle complete tag increment flow", async () => {
|
||||
const mockGitService = new MockGitService([{ name: "v1.2.3" }], {
|
||||
commit: { message: "feat: new feature" },
|
||||
});
|
||||
const mockCoreService = new MockCoreService();
|
||||
const commitAnalyzer = new ConventionalCommitAnalyzer();
|
||||
const tagService = new TagService();
|
||||
|
||||
const tagIncrementer = new TagIncrementer(
|
||||
mockGitService,
|
||||
mockCoreService,
|
||||
commitAnalyzer,
|
||||
tagService
|
||||
);
|
||||
|
||||
const result = await tagIncrementer.incrementTag(undefined, "ref");
|
||||
|
||||
expect(result).toBe("1.3.0");
|
||||
expect(mockCoreService.getLogs()).toContain("INFO: Determined increment type: minor");
|
||||
expect(mockCoreService.getLogs()).toContain("INFO: New tag: 1.3.0");
|
||||
});
|
||||
});
|
||||
281
src/main.ts
Normal file
281
src/main.ts
Normal file
@@ -0,0 +1,281 @@
|
||||
import * as core from "@actions/core";
|
||||
import * as ccp from "conventional-commits-parser";
|
||||
import type { Commit } from "conventional-commits-parser";
|
||||
import { execSync } from "child_process";
|
||||
|
||||
export enum IncrementType {
|
||||
MAJOR = "major",
|
||||
MINOR = "minor",
|
||||
PATCH = "patch",
|
||||
}
|
||||
|
||||
export type Tag = {
|
||||
major: number;
|
||||
minor: number;
|
||||
patch: number;
|
||||
};
|
||||
|
||||
export interface GitService {
|
||||
listTags(perPage: number): Promise<{ name: string }[]>;
|
||||
getCommit(ref: string): Promise<{ commit: { message: string } }>;
|
||||
testConnection(): Promise<void>;
|
||||
}
|
||||
|
||||
export interface CoreService {
|
||||
getInput(name: string): string;
|
||||
info(message: string): void;
|
||||
warning(message: string): void;
|
||||
setOutput(name: string, value: string): void;
|
||||
setFailed(message: string): void;
|
||||
}
|
||||
|
||||
export class ConventionalCommitAnalyzer {
|
||||
analyzeCommit(message: string): Commit {
|
||||
return ccp.sync(message);
|
||||
}
|
||||
|
||||
determineIncrementType(analyzedCommit: Commit): IncrementType {
|
||||
if (
|
||||
analyzedCommit.breaking ||
|
||||
analyzedCommit.header?.includes("!") ||
|
||||
analyzedCommit.notes.some((note: any) => note.title === "BREAKING CHANGE")
|
||||
) {
|
||||
return IncrementType.MAJOR;
|
||||
} else if (analyzedCommit.type === "feat") {
|
||||
return IncrementType.MINOR;
|
||||
} else {
|
||||
return IncrementType.PATCH;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export class TagService {
|
||||
bumpTag(tag: Tag, incrementType: IncrementType): Tag {
|
||||
switch (incrementType) {
|
||||
case IncrementType.MAJOR:
|
||||
return {
|
||||
major: tag.major + 1,
|
||||
minor: 0,
|
||||
patch: 0,
|
||||
};
|
||||
case IncrementType.MINOR:
|
||||
return {
|
||||
major: tag.major,
|
||||
minor: tag.minor + 1,
|
||||
patch: 0,
|
||||
};
|
||||
case IncrementType.PATCH:
|
||||
return {
|
||||
major: tag.major,
|
||||
minor: tag.minor,
|
||||
patch: tag.patch + 1,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
renderTag(tag: Tag, prefix: string = ""): string {
|
||||
return `${prefix}${tag.major}.${tag.minor}.${tag.patch}`;
|
||||
}
|
||||
|
||||
parseTag(tagString: string): Tag {
|
||||
if (!/^v?\d+\.\d+\.\d+$/.test(tagString)) {
|
||||
throw new Error(`Invalid tag format: ${tagString}. Expected semantic version (e.g., 1.2.3)`);
|
||||
}
|
||||
|
||||
const versionMatch = tagString.match(/^v?(\d+)\.(\d+)\.(\d+)$/);
|
||||
if (!versionMatch) {
|
||||
throw new Error(`Could not parse version from tag: ${tagString}`);
|
||||
}
|
||||
|
||||
return {
|
||||
major: parseInt(versionMatch[1]),
|
||||
minor: parseInt(versionMatch[2]),
|
||||
patch: parseInt(versionMatch[3]),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export class TagIncrementer {
|
||||
constructor(
|
||||
private gitService: GitService,
|
||||
private coreService: CoreService,
|
||||
private commitAnalyzer: ConventionalCommitAnalyzer,
|
||||
private tagService: TagService
|
||||
) {}
|
||||
|
||||
async getBaseTag(lastTag: string | undefined): Promise<string> {
|
||||
if (lastTag) {
|
||||
this.coreService.info(`Using provided tag: ${lastTag}`);
|
||||
return lastTag;
|
||||
}
|
||||
|
||||
this.coreService.info("Fetching tags from local repository");
|
||||
const tags = await this.gitService.listTags(1);
|
||||
|
||||
if (tags.length === 0) {
|
||||
this.coreService.warning("No tags found in repository, defaulting to 0.0.0");
|
||||
return "0.0.0";
|
||||
}
|
||||
|
||||
const baseTag = tags[0].name;
|
||||
this.coreService.info(`Using latest tag: ${baseTag}`);
|
||||
return baseTag;
|
||||
}
|
||||
|
||||
async determineIncrementType(ref: string): Promise<IncrementType> {
|
||||
this.coreService.info(`Fetching commit ${ref}`);
|
||||
|
||||
try {
|
||||
const { commit } = await this.gitService.getCommit(ref);
|
||||
this.coreService.info(`Successfully fetched commit: ${commit.message.substring(0, 100)}...`);
|
||||
|
||||
const analyzedCommit = this.commitAnalyzer.analyzeCommit(commit.message);
|
||||
this.coreService.info(
|
||||
`Analyzed commit - type: ${analyzedCommit.type}, breaking: ${analyzedCommit.breaking}`
|
||||
);
|
||||
|
||||
const incrementType = this.commitAnalyzer.determineIncrementType(analyzedCommit);
|
||||
this.coreService.info(`Determined increment type: ${incrementType}`);
|
||||
|
||||
return incrementType;
|
||||
} catch (error) {
|
||||
this.coreService.info(
|
||||
`Error fetching commit ${ref}: ${error instanceof Error ? error.message : String(error)}`
|
||||
);
|
||||
|
||||
// Additional debugging for commit-related errors
|
||||
if (error instanceof Error && error.message.includes("404")) {
|
||||
this.coreService.info(`404 error suggests commit ${ref} was not found in repository`);
|
||||
this.coreService.info(
|
||||
`This could indicate: 1) SHA doesn't exist, 2) Repository access issue, 3) API endpoint mismatch`
|
||||
);
|
||||
}
|
||||
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async incrementTag(lastTag: string | undefined, ref: string): Promise<string> {
|
||||
this.coreService.info(`Starting tag increment process at ref ${ref}`);
|
||||
|
||||
const baseTag = await this.getBaseTag(lastTag);
|
||||
const parsedTag = this.tagService.parseTag(baseTag);
|
||||
|
||||
this.coreService.info(
|
||||
`Parsed version: ${parsedTag.major}.${parsedTag.minor}.${parsedTag.patch}`
|
||||
);
|
||||
|
||||
const incrementType = await this.determineIncrementType(ref);
|
||||
|
||||
const newTag = this.tagService.bumpTag(parsedTag, incrementType);
|
||||
const renderedTag = this.tagService.renderTag(newTag);
|
||||
|
||||
this.coreService.info(`New tag: ${renderedTag}`);
|
||||
this.coreService.info(
|
||||
`Successfully determined new tag: ${renderedTag} (${incrementType} increment)`
|
||||
);
|
||||
|
||||
return renderedTag;
|
||||
}
|
||||
}
|
||||
|
||||
export class LocalGitService implements GitService {
|
||||
async listTags(perPage: number): Promise<{ name: string }[]> {
|
||||
try {
|
||||
const tags = execSync("git tag --sort=-version:refname", { encoding: "utf8" })
|
||||
.split("\n")
|
||||
.filter(Boolean)
|
||||
.slice(0, perPage);
|
||||
|
||||
return tags.map((name) => ({ name }));
|
||||
} catch (error) {
|
||||
// If no tags exist, return empty array
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
async getCommit(ref: string): Promise<{ commit: { message: string } }> {
|
||||
try {
|
||||
const message = execSync(`git log -1 --pretty=%B ${ref}`, { encoding: "utf8" }).trim();
|
||||
return { commit: { message } };
|
||||
} catch (error) {
|
||||
throw new Error(
|
||||
`Failed to get commit ${ref}: ${error instanceof Error ? error.message : String(error)}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async testConnection(): Promise<void> {
|
||||
try {
|
||||
// Test if we're in a git repository
|
||||
execSync("git rev-parse --git-dir", { stdio: "pipe" });
|
||||
} catch (error) {
|
||||
throw new Error("Not in a git repository. Please ensure this action runs after checkout.");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export class LocalCoreService implements CoreService {
|
||||
getInput(name: string): string {
|
||||
return core.getInput(name);
|
||||
}
|
||||
|
||||
info(message: string): void {
|
||||
core.info(message);
|
||||
}
|
||||
|
||||
warning(message: string): void {
|
||||
core.warning(message);
|
||||
}
|
||||
|
||||
setOutput(name: string, value: string): void {
|
||||
core.setOutput(name, value);
|
||||
}
|
||||
|
||||
setFailed(message: string): void {
|
||||
core.setFailed(message);
|
||||
}
|
||||
}
|
||||
|
||||
export async function run(): Promise<void> {
|
||||
try {
|
||||
const coreService = new LocalCoreService();
|
||||
|
||||
const lastTag = coreService.getInput("last-tag");
|
||||
const ref = "HEAD";
|
||||
|
||||
// Log context information for debugging
|
||||
coreService.info(`Working with local git repository`);
|
||||
coreService.info(`Commit ref: ${ref}`);
|
||||
coreService.info(`Last tag input: ${lastTag || "undefined"}`);
|
||||
|
||||
const gitService = new LocalGitService();
|
||||
const commitAnalyzer = new ConventionalCommitAnalyzer();
|
||||
const tagService = new TagService();
|
||||
const tagIncrementer = new TagIncrementer(gitService, coreService, commitAnalyzer, tagService);
|
||||
|
||||
coreService.info("Starting tag increment process...");
|
||||
|
||||
// Test the connection first
|
||||
coreService.info("Testing git repository connection...");
|
||||
await gitService.testConnection();
|
||||
coreService.info("Git repository connection successful");
|
||||
|
||||
const newTag = await tagIncrementer.incrementTag(lastTag || undefined, ref);
|
||||
|
||||
coreService.info(`Process completed successfully. New tag: ${newTag}`);
|
||||
coreService.setOutput("new-tag", newTag);
|
||||
} catch (error) {
|
||||
const coreService = new LocalCoreService();
|
||||
|
||||
// Enhanced error logging
|
||||
if (error instanceof Error) {
|
||||
coreService.info(`Error details: ${error.message}`);
|
||||
coreService.info(`Error stack: ${error.stack}`);
|
||||
coreService.setFailed(error.message);
|
||||
} else {
|
||||
coreService.info(`Unknown error: ${String(error)}`);
|
||||
coreService.setFailed("An unknown error occurred");
|
||||
}
|
||||
}
|
||||
}
|
||||
23
tsconfig.json
Normal file
23
tsconfig.json
Normal file
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "commonjs",
|
||||
"lib": ["ES2022"],
|
||||
"outDir": "./dist",
|
||||
"rootDir": "./src",
|
||||
"strict": true,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"removeComments": false,
|
||||
"noImplicitAny": true,
|
||||
"noImplicitReturns": true,
|
||||
"noImplicitThis": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"exactOptionalPropertyTypes": true,
|
||||
"noImplicitOverride": true
|
||||
},
|
||||
"include": ["src/**/*"],
|
||||
"exclude": ["node_modules", "dist", "**/*.test.ts", "**/*.spec.ts"]
|
||||
}
|
||||
Reference in New Issue
Block a user