diff --git a/build/cli/index.js b/build/cli/index.js index fcaa4fa0..5df903d2 100755 --- a/build/cli/index.js +++ b/build/cli/index.js @@ -52151,8 +52151,8 @@ class ProjectRepository { const { data: release } = await octokit.rest.repos.createRelease({ owner, repo, - tag_name: `v${version}`, - name: `v${version} - ${title}`, + tag_name: version, + name: `${version} - ${title}`, body: changelog, draft: false, prerelease: false, @@ -53833,6 +53833,25 @@ const project_repository_1 = __nccwpck_require__(7917); const constants_1 = __nccwpck_require__(8593); const logger_1 = __nccwpck_require__(8836); const task_emoji_1 = __nccwpck_require__(9785); +/** Semantic version pattern: x, x.y, or x.y.z (digits only, no leading 'v'). */ +const SEMVER_PATTERN = /^\d+(\.\d+){0,2}$/; +function normalizeAndValidateVersion(version) { + const trimmed = version.trim(); + const withoutV = trimmed.startsWith("v") ? trimmed.slice(1).trim() : trimmed; + if (withoutV.length === 0) { + return { + valid: false, + error: `${constants_1.INPUT_KEYS.SINGLE_ACTION_VERSION} must be a semantic version (e.g. 1.0.0).`, + }; + } + if (!SEMVER_PATTERN.test(withoutV)) { + return { + valid: false, + error: `${constants_1.INPUT_KEYS.SINGLE_ACTION_VERSION} must be a semantic version (e.g. 1.0.0). Got: ${version}`, + }; + } + return { valid: true, normalized: withoutV }; +} class CreateReleaseUseCase { constructor() { this.taskId = 'CreateReleaseUseCase'; @@ -53863,6 +53882,7 @@ class CreateReleaseUseCase { `${constants_1.INPUT_KEYS.SINGLE_ACTION_TITLE} is not set.` ], })); + return result; } else if (param.singleAction.changelog.length === 0) { (0, logger_1.logError)(`Changelog is not set.`); @@ -53874,9 +53894,22 @@ class CreateReleaseUseCase { `${constants_1.INPUT_KEYS.SINGLE_ACTION_CHANGELOG} is not set.` ], })); + return result; + } + const versionCheck = normalizeAndValidateVersion(param.singleAction.version); + if (!versionCheck.valid) { + (0, logger_1.logError)(versionCheck.error); + result.push(new result_1.Result({ + id: this.taskId, + success: false, + executed: true, + errors: [versionCheck.error], + })); + return result; } + const releaseVersion = `v${versionCheck.normalized}`; try { - const releaseUrl = await this.projectRepository.createRelease(param.owner, param.repo, param.singleAction.version, param.singleAction.title, param.singleAction.changelog, param.tokens.token); + const releaseUrl = await this.projectRepository.createRelease(param.owner, param.repo, releaseVersion, param.singleAction.title, param.singleAction.changelog, param.tokens.token); if (releaseUrl) { result.push(new result_1.Result({ id: this.taskId, @@ -53886,7 +53919,7 @@ class CreateReleaseUseCase { })); } else { - (0, logger_1.logWarn)(`CreateRelease: createRelease returned no URL for version ${param.singleAction.version}.`); + (0, logger_1.logWarn)(`CreateRelease: createRelease returned no URL for version ${releaseVersion}.`); result.push(new result_1.Result({ id: this.taskId, success: false, @@ -53961,24 +53994,25 @@ class CreateTagUseCase { })); return result; } + const tagName = `v${param.singleAction.version}`; try { - const sha1Tag = await this.projectRepository.createTag(param.owner, param.repo, param.currentConfiguration.releaseBranch, param.singleAction.version, param.tokens.token); + const sha1Tag = await this.projectRepository.createTag(param.owner, param.repo, param.currentConfiguration.releaseBranch, tagName, param.tokens.token); if (sha1Tag) { result.push(new result_1.Result({ id: this.taskId, success: true, executed: true, - steps: [`Tag ${param.singleAction.version} is ready: ${sha1Tag}`], + steps: [`Tag ${tagName} is ready: ${sha1Tag}`], })); } else { - (0, logger_1.logWarn)(`CreateTag: createTag returned no SHA for version ${param.singleAction.version}.`); + (0, logger_1.logWarn)(`CreateTag: createTag returned no SHA for version ${tagName}.`); result.push(new result_1.Result({ id: this.taskId, success: false, executed: true, errors: [ - `Failed to create tag ${param.singleAction.version}.` + `Failed to create tag ${tagName}.` ], })); } @@ -53989,7 +54023,7 @@ class CreateTagUseCase { id: this.taskId, success: false, executed: true, - steps: [`Failed to create tag ${param.singleAction.version}.`], + steps: [`Failed to create tag ${tagName}.`], errors: [ JSON.stringify(error) ], diff --git a/build/github_action/index.js b/build/github_action/index.js index f5cc571c..1b74a2e9 100644 --- a/build/github_action/index.js +++ b/build/github_action/index.js @@ -47232,8 +47232,8 @@ class ProjectRepository { const { data: release } = await octokit.rest.repos.createRelease({ owner, repo, - tag_name: `v${version}`, - name: `v${version} - ${title}`, + tag_name: version, + name: `${version} - ${title}`, body: changelog, draft: false, prerelease: false, @@ -48914,6 +48914,25 @@ const project_repository_1 = __nccwpck_require__(7917); const constants_1 = __nccwpck_require__(8593); const logger_1 = __nccwpck_require__(8836); const task_emoji_1 = __nccwpck_require__(9785); +/** Semantic version pattern: x, x.y, or x.y.z (digits only, no leading 'v'). */ +const SEMVER_PATTERN = /^\d+(\.\d+){0,2}$/; +function normalizeAndValidateVersion(version) { + const trimmed = version.trim(); + const withoutV = trimmed.startsWith("v") ? trimmed.slice(1).trim() : trimmed; + if (withoutV.length === 0) { + return { + valid: false, + error: `${constants_1.INPUT_KEYS.SINGLE_ACTION_VERSION} must be a semantic version (e.g. 1.0.0).`, + }; + } + if (!SEMVER_PATTERN.test(withoutV)) { + return { + valid: false, + error: `${constants_1.INPUT_KEYS.SINGLE_ACTION_VERSION} must be a semantic version (e.g. 1.0.0). Got: ${version}`, + }; + } + return { valid: true, normalized: withoutV }; +} class CreateReleaseUseCase { constructor() { this.taskId = 'CreateReleaseUseCase'; @@ -48944,6 +48963,7 @@ class CreateReleaseUseCase { `${constants_1.INPUT_KEYS.SINGLE_ACTION_TITLE} is not set.` ], })); + return result; } else if (param.singleAction.changelog.length === 0) { (0, logger_1.logError)(`Changelog is not set.`); @@ -48955,9 +48975,22 @@ class CreateReleaseUseCase { `${constants_1.INPUT_KEYS.SINGLE_ACTION_CHANGELOG} is not set.` ], })); + return result; + } + const versionCheck = normalizeAndValidateVersion(param.singleAction.version); + if (!versionCheck.valid) { + (0, logger_1.logError)(versionCheck.error); + result.push(new result_1.Result({ + id: this.taskId, + success: false, + executed: true, + errors: [versionCheck.error], + })); + return result; } + const releaseVersion = `v${versionCheck.normalized}`; try { - const releaseUrl = await this.projectRepository.createRelease(param.owner, param.repo, param.singleAction.version, param.singleAction.title, param.singleAction.changelog, param.tokens.token); + const releaseUrl = await this.projectRepository.createRelease(param.owner, param.repo, releaseVersion, param.singleAction.title, param.singleAction.changelog, param.tokens.token); if (releaseUrl) { result.push(new result_1.Result({ id: this.taskId, @@ -48967,7 +49000,7 @@ class CreateReleaseUseCase { })); } else { - (0, logger_1.logWarn)(`CreateRelease: createRelease returned no URL for version ${param.singleAction.version}.`); + (0, logger_1.logWarn)(`CreateRelease: createRelease returned no URL for version ${releaseVersion}.`); result.push(new result_1.Result({ id: this.taskId, success: false, @@ -49042,24 +49075,25 @@ class CreateTagUseCase { })); return result; } + const tagName = `v${param.singleAction.version}`; try { - const sha1Tag = await this.projectRepository.createTag(param.owner, param.repo, param.currentConfiguration.releaseBranch, param.singleAction.version, param.tokens.token); + const sha1Tag = await this.projectRepository.createTag(param.owner, param.repo, param.currentConfiguration.releaseBranch, tagName, param.tokens.token); if (sha1Tag) { result.push(new result_1.Result({ id: this.taskId, success: true, executed: true, - steps: [`Tag ${param.singleAction.version} is ready: ${sha1Tag}`], + steps: [`Tag ${tagName} is ready: ${sha1Tag}`], })); } else { - (0, logger_1.logWarn)(`CreateTag: createTag returned no SHA for version ${param.singleAction.version}.`); + (0, logger_1.logWarn)(`CreateTag: createTag returned no SHA for version ${tagName}.`); result.push(new result_1.Result({ id: this.taskId, success: false, executed: true, errors: [ - `Failed to create tag ${param.singleAction.version}.` + `Failed to create tag ${tagName}.` ], })); } @@ -49070,7 +49104,7 @@ class CreateTagUseCase { id: this.taskId, success: false, executed: true, - steps: [`Failed to create tag ${param.singleAction.version}.`], + steps: [`Failed to create tag ${tagName}.`], errors: [ JSON.stringify(error) ], diff --git a/docs/features.mdx b/docs/features.mdx index 0ecd28df..a6a15bb2 100644 --- a/docs/features.mdx +++ b/docs/features.mdx @@ -81,8 +81,8 @@ When you set `single-action` (and, when required, `single-action-issue`, `single | **`think_action`** | — | Uses OpenCode Plan for deep code analysis and change proposals (reasoning over the codebase). No issue required. | | **`initial_setup`** | — | Performs initial setup steps (e.g. for repo or project). No issue required. | | **`create_release`** | `single-action-version`, `single-action-title`, `single-action-changelog` | Creates a GitHub release with the given version, title, and changelog. | -| **`create_tag`** | `single-action-version` | Creates a Git tag for the given version. | -| **`publish_github_action`** | — | Publishes or updates the GitHub Action (e.g. versioning, release). | +| **`create_tag`** | `single-action-version` | Creates a Git tag with prefix `v` (e.g. `v1.2.0`) for the given version from the release branch. | +| **`publish_github_action`** | `single-action-version` | Publishes or updates the GitHub Action: creates/updates the major version tag (e.g. `v2` from `v2.0.4`). Requires `create_tag` to have been run first. | | **`deployed_action`** | `single-action-issue` | Marks the issue as deployed; updates labels and project state (e.g. "deployed"). | Single actions that **throw an error** if the last step fails: `publish_github_action`, `create_release`, `deployed_action`, `create_tag`. This lets the workflow fail the job when the action does not succeed. diff --git a/docs/single-actions/available-actions.mdx b/docs/single-actions/available-actions.mdx index 97cbef35..dc2ac493 100644 --- a/docs/single-actions/available-actions.mdx +++ b/docs/single-actions/available-actions.mdx @@ -25,8 +25,8 @@ These actions need **`single-action-issue`** set to the issue number. The workfl | **`think_action`** | — | **Deep code analysis** and change proposals (OpenCode Plan). You can pass a question (e.g. from CLI with `-q "..."`). No issue required. | One-off reasoning over the codebase; use from CLI or a workflow that provides context. | | **`initial_setup`** | — | Performs **initial setup** steps: creates labels, issue types (if supported), verifies access. No issue required. | First-time repo setup; run once or when you add new labels/types. | | **`create_release`** | `single-action-version`, `single-action-title`, `single-action-changelog` | Creates a **GitHub release** with the given version, title, and changelog (markdown body). | From a workflow after tests pass; use version and changelog from your build or inputs. | -| **`create_tag`** | `single-action-version` | Creates a **Git tag** for the given version. | When you only need a tag (e.g. for versioning) without a full release. | -| **`publish_github_action`** | — | **Publishes or updates** the GitHub Action (e.g. versioning, release to marketplace). No issue required. | In a CI job that builds and publishes the action. | +| **`create_tag`** | `single-action-version` | Creates a **Git tag** with prefix `v` (e.g. `v1.2.3`) for the given version from the release branch. | When you only need a tag (e.g. for versioning) without a full release. The tag is created from the `releaseBranch` stored in issue configuration. | +| **`publish_github_action`** | `single-action-version` | **Publishes or updates** the GitHub Action: creates/updates the major version tag (e.g. `v2` from `v2.0.4`) and the corresponding GitHub Release. Requires that `create_tag` has been run first to create the source tag `v{version}`. | In a CI job that builds and publishes the action, after `create_tag` and `create_release` have run. | ## Actions that fail the job on failure @@ -55,7 +55,7 @@ The **`copilot`** CLI command (e.g. `giik copilot -p "..."`) uses the OpenCode * | `initial_setup` | — | — | — | — | | `create_release` | — | ✅ | ✅ | ✅ | | `create_tag` | — | ✅ | — | — | -| `publish_github_action` | — | — | — | — | +| `publish_github_action` | — | ✅ | — | — | ## Next steps diff --git a/docs/single-actions/examples.mdx b/docs/single-actions/examples.mdx index 38860ff1..99ec9342 100644 --- a/docs/single-actions/examples.mdx +++ b/docs/single-actions/examples.mdx @@ -110,7 +110,7 @@ Changelog can be read from a file or generated in a previous step and passed as ## Workflow: create tag -Create only a tag (no release body): +Create a Git tag with prefix `v` (e.g. `v1.2.0`) from the release branch: ```yaml - uses: vypdev/copilot@v2 @@ -118,8 +118,11 @@ Create only a tag (no release body): token: ${{ secrets.PAT }} single-action: create_tag single-action-version: "1.2.0" + single-action-issue: "100" # Issue with releaseBranch in configuration ``` +**Note:** The tag is created from the `releaseBranch` stored in the issue configuration. The version input `1.2.0` will create tag `v1.2.0`. + ## Workflow: deployed Mark issue `100` as deployed (e.g. from your release workflow): diff --git a/src/data/repository/__tests__/project_repository.test.ts b/src/data/repository/__tests__/project_repository.test.ts index 761c4f6a..28736bdb 100644 --- a/src/data/repository/__tests__/project_repository.test.ts +++ b/src/data/repository/__tests__/project_repository.test.ts @@ -429,7 +429,7 @@ describe("ProjectRepository.createRelease", () => { const result = await repo.createRelease( "owner", "repo", - "1.0", + "v1.0", "First release", "Changelog", "token" diff --git a/src/data/repository/project_repository.ts b/src/data/repository/project_repository.ts index 1ab2d302..1fb6e745 100644 --- a/src/data/repository/project_repository.ts +++ b/src/data/repository/project_repository.ts @@ -758,8 +758,8 @@ export class ProjectRepository { const { data: release } = await octokit.rest.repos.createRelease({ owner, repo, - tag_name: `v${version}`, - name: `v${version} - ${title}`, + tag_name: version, + name: `${version} - ${title}`, body: changelog, draft: false, prerelease: false, diff --git a/src/usecase/actions/__tests__/create_release_use_case.test.ts b/src/usecase/actions/__tests__/create_release_use_case.test.ts index 19b1f881..11a730a1 100644 --- a/src/usecase/actions/__tests__/create_release_use_case.test.ts +++ b/src/usecase/actions/__tests__/create_release_use_case.test.ts @@ -65,6 +65,32 @@ describe('CreateReleaseUseCase', () => { expect(results.some((r) => r.errors?.some((e) => String(e).includes(`${INPUT_KEYS.SINGLE_ACTION_CHANGELOG} is not set.`)))).toBe(true); }); + it('returns failure when version format is invalid', async () => { + const param = baseParam({ + singleAction: { version: 'abc', title: 'Release', changelog: '- Fix' }, + }); + const results = await useCase.invoke(param); + expect(results).toHaveLength(1); + expect(results[0].success).toBe(false); + expect(results[0].errors?.some((e) => String(e).includes(INPUT_KEYS.SINGLE_ACTION_VERSION))).toBe(true); + expect(mockCreateRelease).not.toHaveBeenCalled(); + }); + + it('accepts version with leading v and produces tag v1.0.0 (no double v)', async () => { + mockCreateRelease.mockResolvedValue('https://github.com/owner/repo/releases/tag/v1.0.0'); + const param = baseParam({ singleAction: { version: 'v1.0.0', title: 'Release', changelog: '- Fix' } }); + const results = await useCase.invoke(param); + expect(results[0].success).toBe(true); + expect(mockCreateRelease).toHaveBeenCalledWith( + 'owner', + 'repo', + 'v1.0.0', + 'Release', + '- Fix', + 'token' + ); + }); + it('returns success with release URL when createRelease succeeds', async () => { mockCreateRelease.mockResolvedValue('https://github.com/owner/repo/releases/tag/v1.0.0'); const param = baseParam(); @@ -76,7 +102,7 @@ describe('CreateReleaseUseCase', () => { expect(mockCreateRelease).toHaveBeenCalledWith( 'owner', 'repo', - '1.0.0', + 'v1.0.0', 'Release title', '- Fix bug', 'token' diff --git a/src/usecase/actions/__tests__/create_tag_use_case.test.ts b/src/usecase/actions/__tests__/create_tag_use_case.test.ts index 9ae5f2ce..f941d719 100644 --- a/src/usecase/actions/__tests__/create_tag_use_case.test.ts +++ b/src/usecase/actions/__tests__/create_tag_use_case.test.ts @@ -62,12 +62,12 @@ describe('CreateTagUseCase', () => { expect(results).toHaveLength(1); expect(results[0]).toBeInstanceOf(Result); expect(results[0].success).toBe(true); - expect(results[0].steps?.some((s) => s.includes('1.0.0') && s.includes('abc123'))).toBe(true); + expect(results[0].steps?.some((s) => s.includes('v1.0.0') && s.includes('abc123'))).toBe(true); expect(mockCreateTag).toHaveBeenCalledWith( 'owner', 'repo', 'release/1.0.0', - '1.0.0', + 'v1.0.0', 'token' ); }); diff --git a/src/usecase/actions/create_release_use_case.ts b/src/usecase/actions/create_release_use_case.ts index abcd1d13..341159da 100644 --- a/src/usecase/actions/create_release_use_case.ts +++ b/src/usecase/actions/create_release_use_case.ts @@ -6,6 +6,28 @@ import { logError, logInfo, logWarn } from "../../utils/logger"; import { getTaskEmoji } from "../../utils/task_emoji"; import { ParamUseCase } from "../base/param_usecase"; +/** Semantic version pattern: x, x.y, or x.y.z (digits only, no leading 'v'). */ +const SEMVER_PATTERN = /^\d+(\.\d+){0,2}$/; + +function normalizeAndValidateVersion( + version: string +): { valid: true; normalized: string } | { valid: false; error: string } { + const trimmed = version.trim(); + const withoutV = trimmed.startsWith("v") ? trimmed.slice(1).trim() : trimmed; + if (withoutV.length === 0) { + return { + valid: false, + error: `${INPUT_KEYS.SINGLE_ACTION_VERSION} must be a semantic version (e.g. 1.0.0).`, + }; + } + if (!SEMVER_PATTERN.test(withoutV)) { + return { + valid: false, + error: `${INPUT_KEYS.SINGLE_ACTION_VERSION} must be a semantic version (e.g. 1.0.0). Got: ${version}`, + }; + } + return { valid: true, normalized: withoutV }; +} export class CreateReleaseUseCase implements ParamUseCase { taskId: string = 'CreateReleaseUseCase'; @@ -42,6 +64,7 @@ export class CreateReleaseUseCase implements ParamUseCase ], }) ); + return result; } else if (param.singleAction.changelog.length === 0) { logError(`Changelog is not set.`) result.push( @@ -54,13 +77,29 @@ export class CreateReleaseUseCase implements ParamUseCase ], }) ); + return result; + } + + const versionCheck = normalizeAndValidateVersion(param.singleAction.version); + if (!versionCheck.valid) { + logError(versionCheck.error); + result.push( + new Result({ + id: this.taskId, + success: false, + executed: true, + errors: [versionCheck.error], + }) + ); + return result; } + const releaseVersion = `v${versionCheck.normalized}`; try { const releaseUrl = await this.projectRepository.createRelease( param.owner, param.repo, - param.singleAction.version, + releaseVersion, param.singleAction.title, param.singleAction.changelog, param.tokens.token, @@ -75,7 +114,7 @@ export class CreateReleaseUseCase implements ParamUseCase }) ); } else { - logWarn(`CreateRelease: createRelease returned no URL for version ${param.singleAction.version}.`); + logWarn(`CreateRelease: createRelease returned no URL for version ${releaseVersion}.`); result.push( new Result({ id: this.taskId, diff --git a/src/usecase/actions/create_tag_use_case.ts b/src/usecase/actions/create_tag_use_case.ts index 096fab49..6ed3a271 100644 --- a/src/usecase/actions/create_tag_use_case.ts +++ b/src/usecase/actions/create_tag_use_case.ts @@ -45,12 +45,14 @@ export class CreateTagUseCase implements ParamUseCase { return result; } + const tagName = `v${param.singleAction.version}`; + try { const sha1Tag = await this.projectRepository.createTag( param.owner, param.repo, param.currentConfiguration.releaseBranch, - param.singleAction.version, + tagName, param.tokens.token, ); if (sha1Tag) { @@ -59,18 +61,18 @@ export class CreateTagUseCase implements ParamUseCase { id: this.taskId, success: true, executed: true, - steps: [`Tag ${param.singleAction.version} is ready: ${sha1Tag}`], + steps: [`Tag ${tagName} is ready: ${sha1Tag}`], }) ); } else { - logWarn(`CreateTag: createTag returned no SHA for version ${param.singleAction.version}.`); + logWarn(`CreateTag: createTag returned no SHA for version ${tagName}.`); result.push( new Result({ id: this.taskId, success: false, executed: true, errors: [ - `Failed to create tag ${param.singleAction.version}.` + `Failed to create tag ${tagName}.` ], }) ); @@ -82,7 +84,7 @@ export class CreateTagUseCase implements ParamUseCase { id: this.taskId, success: false, executed: true, - steps: [`Failed to create tag ${param.singleAction.version}.`], + steps: [`Failed to create tag ${tagName}.`], errors: [ JSON.stringify(error) ],