diff --git a/README.md b/README.md index 52c34296..880ddb27 100644 --- a/README.md +++ b/README.md @@ -47,6 +47,28 @@ The plugin can be configured in the [**semantic-release** configuration file](ht With this example [GitLab releases](https://docs.gitlab.com/ee/user/project/releases/) will be published to the `https://custom.gitlab.com` instance. +### GitLab CI Component Example + +For projects that are [GitLab CI components](https://docs.gitlab.com/ee/ci/components/) and need to be published to the GitLab catalog: + +```json +{ + "branches": ["main"], + "plugins": [ + "@semantic-release/commit-analyzer", + "@semantic-release/release-notes-generator", + [ + "@semantic-release/gitlab", + { + "publishToCatalog": true + } + ] + ] +} +``` + +This will create a GitLab release and then publish the component to the GitLab catalog using the `/projects/:id/catalog/publish` API endpoint. + ## Configuration ### GitLab authentication @@ -95,6 +117,7 @@ If you need to bypass the proxy for some hosts, configure the `NO_PROXY` environ | `failCommentCondition` | Use this as condition, when to comment on or create an issues in case of failures. See [failCommentCondition](#failCommentCondition). | - | | `labels` | The [labels](https://docs.gitlab.com/ee/user/project/labels.html#labels) to add to the issue created when a release fails. Set to `false` to not add any label. Labels should be comma-separated as described in the [official docs](https://docs.gitlab.com/ee/api/issues.html#new-issue), e.g. `"semantic-release,bot"`. | `semantic-release` | | `assignee` | The [assignee](https://docs.gitlab.com/ee/user/project/issues/managing_issues.html#assignee) to add to the issue created when a release fails. | - | +| `publishToCatalog` | Whether to publish GitLab CI components to the [GitLab catalog](https://docs.gitlab.com/ee/ci/components/) after creating a release. When enabled, an additional API call to `/projects/:id/catalog/publish` is made after the release is created. This is only needed for projects that are GitLab CI components. | `false` | | `retryLimit` | The maximum number of retries for failing HTTP requests. | `3` | #### assets diff --git a/lib/definitions/errors.js b/lib/definitions/errors.js index 51508538..e8808673 100644 --- a/lib/definitions/errors.js +++ b/lib/definitions/errors.js @@ -39,6 +39,12 @@ Your configuration for the \`labels\` option is \`${stringify(labels)}\`.`, Your configuration for the \`assignee\` option is \`${stringify(assignee)}\`.`, }), + EINVALIDPUBLISHTOCATALOG: ({publishToCatalog}) => ({ + message: 'Invalid `publishToCatalog` option.', + details: `The [publishToCatalog option](${linkify('README.md#publishtocatalog')}) if defined, must be a \`Boolean\`. + +Your configuration for the \`publishToCatalog\` option is \`${stringify(publishToCatalog)}\`.`, + }), EINVALIDGITLABURL: () => ({ message: 'The git repository URL is not a valid GitLab URL.', details: `The **semantic-release** \`repositoryUrl\` option must a valid GitLab URL with the format \`/.git\`. diff --git a/lib/publish.js b/lib/publish.js index 4a8f9511..c86dd1b0 100644 --- a/lib/publish.js +++ b/lib/publish.js @@ -22,8 +22,17 @@ export default async (pluginConfig, context) => { nextRelease: { gitTag, gitHead, notes, version }, logger, } = context; - const { gitlabToken, gitlabUrl, gitlabApiUrl, assets, milestones, proxy, retryLimit, retryStatusCodes } = - resolveConfig(pluginConfig, context); + const { + gitlabToken, + gitlabUrl, + gitlabApiUrl, + assets, + milestones, + proxy, + retryLimit, + retryStatusCodes, + publishToCatalog, + } = resolveConfig(pluginConfig, context); const assetsList = []; const { projectPath, projectApiUrl } = getProjectContext(context, gitlabUrl, gitlabApiUrl, repositoryUrl); @@ -197,6 +206,24 @@ export default async (pluginConfig, context) => { logger.log("Published GitLab release: %s", gitTag); + // Publish to catalog if enabled + if (publishToCatalog) { + debug("Publishing to GitLab catalog for project: %s", projectPath); + + const catalogPublishEndpoint = urlJoin(projectApiUrl, "catalog/publish"); + + try { + await got.post(catalogPublishEndpoint, { + ...apiOptions, + ...proxy, + }); + logger.log("Published GitLab CI component to catalog: %s", gitTag); + } catch (error) { + logger.error("An error occurred while publishing to the GitLab catalog API:\n%O", error); + throw error; + } + } + const releaseUrl = urlJoin(gitlabUrl, projectPath, `/-/releases/${encodedGitTag}`); return { name: RELEASE_NAME, url: releaseUrl }; diff --git a/lib/resolve-config.js b/lib/resolve-config.js index d26dbf37..3ae87c79 100644 --- a/lib/resolve-config.js +++ b/lib/resolve-config.js @@ -16,6 +16,7 @@ export default ( labels, assignee, retryLimit, + publishToCatalog, }, { envCi: { service } = {}, @@ -71,6 +72,7 @@ export default ( assignee, retryLimit: retryLimit ?? DEFAULT_RETRY_LIMIT, retryStatusCodes: DEFAULT_RETRY_STATUS_CODES, + publishToCatalog: publishToCatalog || false, }; }; diff --git a/lib/verify.js b/lib/verify.js index fdfc3b7f..fcb746bf 100644 --- a/lib/verify.js +++ b/lib/verify.js @@ -23,6 +23,7 @@ const VALIDATORS = { failComment: canBeDisabled(isNonEmptyString), labels: canBeDisabled(isNonEmptyString), assignee: isNonEmptyString, + publishToCatalog: (value) => typeof value === "boolean", }; export default async (pluginConfig, context) => { diff --git a/test/publish.test.js b/test/publish.test.js index 249e8bcf..226b86b1 100644 --- a/test/publish.test.js +++ b/test/publish.test.js @@ -666,3 +666,59 @@ test.serial("Publish a release with error response", async (t) => { t.is(error.message, `Response code 499 (Something went wrong)`); t.true(gitlab.isDone()); }); + +test.serial("Publish a release with catalog publishing enabled", async (t) => { + const owner = "test_user"; + const repo = "test_repo"; + const env = { GITLAB_TOKEN: "gitlab_token" }; + const pluginConfig = { publishToCatalog: true }; + const nextRelease = { gitHead: "123", gitTag: "v1.0.0", notes: "Test release note body" }; + const options = { repositoryUrl: `https://gitlab.com/${owner}/${repo}.git` }; + const encodedProjectPath = encodeURIComponent(`${owner}/${repo}`); + const encodedGitTag = encodeURIComponent(nextRelease.gitTag); + + const gitlab = authenticate(env) + .post(`/projects/${encodedProjectPath}/releases`, { + tag_name: nextRelease.gitTag, + description: nextRelease.notes, + assets: { + links: [], + }, + }) + .reply(200) + .post(`/projects/${encodedProjectPath}/catalog/publish`) + .reply(200); + + const result = await publish(pluginConfig, { env, options, nextRelease, logger: t.context.logger }); + + t.is(result.url, `https://gitlab.com/${owner}/${repo}/-/releases/${encodedGitTag}`); + t.deepEqual(t.context.log.args[0], ["Published GitLab release: %s", nextRelease.gitTag]); + t.deepEqual(t.context.log.args[1], ["Published GitLab CI component to catalog: %s", nextRelease.gitTag]); + t.true(gitlab.isDone()); +}); + +test.serial("Publish a release with catalog publishing error", async (t) => { + const owner = "test_user"; + const repo = "test_repo"; + const env = { GITLAB_TOKEN: "gitlab_token" }; + const pluginConfig = { publishToCatalog: true }; + const nextRelease = { gitHead: "123", gitTag: "v1.0.0", notes: "Test release note body" }; + const options = { repositoryUrl: `https://gitlab.com/${owner}/${repo}.git` }; + const encodedProjectPath = encodeURIComponent(`${owner}/${repo}`); + + const gitlab = authenticate(env) + .post(`/projects/${encodedProjectPath}/releases`, { + tag_name: nextRelease.gitTag, + description: nextRelease.notes, + assets: { + links: [], + }, + }) + .reply(200) + .post(`/projects/${encodedProjectPath}/catalog/publish`) + .reply(400, { message: "Catalog publishing failed" }); + + const error = await t.throwsAsync(publish(pluginConfig, { env, options, nextRelease, logger: t.context.logger })); + t.is(error.message, `Response code 400 (Catalog publishing failed)`); + t.true(gitlab.isDone()); +}); diff --git a/test/resolve-config.test.js b/test/resolve-config.test.js index 5f800e78..97e892b9 100644 --- a/test/resolve-config.test.js +++ b/test/resolve-config.test.js @@ -19,6 +19,7 @@ const defaultOptions = { proxy: {}, retryLimit: 3, retryStatusCodes: [408, 413, 422, 429, 500, 502, 503, 504, 521, 522, 524], + publishToCatalog: false, }; test("Returns user config", (t) => { diff --git a/test/verify.test.js b/test/verify.test.js index 48f3004f..3dc75b3a 100644 --- a/test/verify.test.js +++ b/test/verify.test.js @@ -988,3 +988,43 @@ test.serial( t.true(gitlab.isDone()); } ); + +test.serial('Does not throw SemanticReleaseError if "publishToCatalog" option is valid boolean', async (t) => { + const owner = "test_user"; + const repo = "test_repo"; + const env = { GITLAB_TOKEN: "gitlab_token" }; + const publishToCatalog = true; + const gitlab = authenticate(env) + .get(`/projects/${owner}%2F${repo}`) + .reply(200, { permissions: { project_access: { access_level: 40 } } }); + + await t.notThrowsAsync( + verify( + { publishToCatalog }, + { env, options: { repositoryUrl: `https://gitlab.com/${owner}/${repo}.git` }, logger: t.context.logger } + ) + ); + t.true(gitlab.isDone()); +}); + +test.serial('Throw SemanticReleaseError if "publishToCatalog" option is not a boolean', async (t) => { + const owner = "test_user"; + const repo = "test_repo"; + const env = { GITLAB_TOKEN: "gitlab_token" }; + const publishToCatalog = "invalid"; + const gitlab = authenticate(env) + .get(`/projects/${owner}%2F${repo}`) + .reply(200, { permissions: { project_access: { access_level: 40 } } }); + + const { + errors: [error], + } = await t.throwsAsync( + verify( + { publishToCatalog }, + { env, options: { repositoryUrl: `https://gitlab.com/${owner}/${repo}.git` }, logger: t.context.logger } + ) + ); + t.is(error.name, "SemanticReleaseError"); + t.is(error.code, "EINVALIDPUBLISHTOCATALOG"); + t.true(gitlab.isDone()); +});