diff --git a/lib/definitions/errors.js b/lib/definitions/errors.js index 51508538..a611b28c 100644 --- a/lib/definitions/errors.js +++ b/lib/definitions/errors.js @@ -39,6 +39,19 @@ Your configuration for the \`labels\` option is \`${stringify(labels)}\`.`, Your configuration for the \`assignee\` option is \`${stringify(assignee)}\`.`, }), + EINVALIDGENERICPACKAGELABEL: ({label, asset}) => ({ + message: 'Invalid label for generic package asset.', + details: `The label \`${label}\` for a generic package asset is invalid. GitLab generic package filenames must follow these rules: + +- Can contain: Letters (A-Z, a-z), Numbers (0-9), and special characters: . _ - + ~ @ / +- Cannot start with: ~ or @ +- Cannot end with: ~ or @ +- Cannot contain spaces + +Your asset configuration: \`${stringify(asset)}\` + +Please update the label to comply with [GitLab's package naming restrictions](https://docs.gitlab.com/ee/user/packages/generic_packages/#publish-a-package-file).`, + }), 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/verify.js b/lib/verify.js index fdfc3b7f..1cd6a1a8 100644 --- a/lib/verify.js +++ b/lib/verify.js @@ -13,6 +13,22 @@ const isStringOrStringArray = (value) => const isArrayOf = (validator) => (array) => isArray(array) && array.every((value) => validator(value)); const canBeDisabled = (validator) => (value) => value === false || validator(value); +const isValidGenericPackageLabel = (label) => { + // GitLab generic package filename restrictions + // Can contain: A-Z, a-z, 0-9, . _ - + ~ @ / + // Cannot start with: ~ or @ + // Cannot end with: ~ or @ + // Cannot contain spaces + if (!label) return true; // label is optional + if (typeof label !== "string") return false; + if (/\s/.test(label)) return false; // no spaces + if (/^[~@]/.test(label)) return false; // cannot start with ~ or @ + if (/[~@]$/.test(label)) return false; // cannot end with ~ or @ + // Check if it only contains allowed characters + if (!/^[A-Za-z0-9._\-+~@/]+$/.test(label)) return false; + return true; +}; + const VALIDATORS = { assets: isArrayOf( (asset) => @@ -45,6 +61,17 @@ export default async (pluginConfig, context) => { .filter(([option, value]) => !isValid(option, value)) .map(([option, value]) => getError(`EINVALID${option.toUpperCase()}`, { [option]: value })); + // Validate generic package labels with specific error codes + if (options.assets && Array.isArray(options.assets)) { + options.assets.forEach((asset) => { + if (isPlainObject(asset) && asset.target === "generic_package" && asset.label) { + if (!isValidGenericPackageLabel(asset.label)) { + errors.push(getError("EINVALIDGENERICPACKAGELABEL", { label: asset.label, asset })); + } + } + }); + } + if (!projectPath) { errors.push(getError("EINVALIDGITLABURL")); } diff --git a/package-lock.json b/package-lock.json index 7a17ecf2..67190c3f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -270,7 +270,6 @@ "integrity": "sha512-jOT8V1Ba5BdC79sKrRWDdMT5l1R+XNHTPR6CPWzUP2EcfAcvIHZWF0eAbmRcpOOP5gVIwnqNg0C4nvh6Abc3OA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@octokit/auth-token": "^6.0.0", "@octokit/graphql": "^9.0.1", @@ -1016,7 +1015,6 @@ "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "dev": true, "license": "MIT", - "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -3742,7 +3740,6 @@ "integrity": "sha512-8dD6FusOQSrpv9Z1rdNMdlSgQOIP880DHqnohobOmYLElGEqAL/JvxvuxZO16r4HtjTlfPRDC1hbvxC9dPN2nA==", "dev": true, "license": "MIT", - "peer": true, "bin": { "marked": "bin/marked.js" }, @@ -6611,7 +6608,6 @@ "dev": true, "inBundle": true, "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, @@ -7726,7 +7722,6 @@ "integrity": "sha512-phCkJ6pjDi9ANdhuF5ElS10GGdAKY6R1Pvt9lT3SFhOwM4T7QZE7MLpBDbNruUx/Q3gFD92/UOFringGipRqZA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@semantic-release/commit-analyzer": "^13.0.0-beta.1", "@semantic-release/error": "^4.0.0", diff --git a/test/publish.test.js b/test/publish.test.js index d12443a9..f7c1268f 100644 --- a/test/publish.test.js +++ b/test/publish.test.js @@ -141,7 +141,7 @@ test.serial("Publish a release with generics", async (t) => { const encodedGitTag = encodeURIComponent(nextRelease.gitTag); const encodedVersion = encodeURIComponent(nextRelease.version); const uploaded = { file: { url: "/uploads/file.css" } }; - const generic = { path: "file.css", label: "Style package", target: "generic_package", status: "hidden" }; + const generic = { path: "file.css", label: "style-package", target: "generic_package", status: "hidden" }; const assets = [generic]; const encodedLabel = encodeURIComponent(generic.label); const expectedUrl = `https://gitlab.com/api/v4/projects/${encodedProjectPath}/packages/generic/release/${encodedVersion}/${encodedLabel}`; @@ -152,7 +152,7 @@ test.serial("Publish a release with generics", async (t) => { assets: { links: [ { - name: "Style package", + name: "style-package", url: expectedUrl, link_type: "package", }, @@ -189,7 +189,7 @@ test.serial("Publish a release with generics: with asset.packageName (fixed text const uploaded = { file: { url: "/uploads/file.css" } }; const generic = { path: "file.css", - label: "Style package", + label: "style-package", target: "generic_package", status: "hidden", packageName: "microk8s", @@ -205,7 +205,7 @@ test.serial("Publish a release with generics: with asset.packageName (fixed text assets: { links: [ { - name: "Style package", + name: "style-package", url: expectedUrl, link_type: "package", }, @@ -248,7 +248,7 @@ test.serial("Publish a release with generics: with asset.packageName (template)" const uploaded = { file: { url: "/uploads/file.css" } }; const generic = { path: "file.css", - label: "Style package", + label: "style-package", target: "generic_package", status: "hidden", packageName: "${nextRelease.channel}", @@ -263,7 +263,7 @@ test.serial("Publish a release with generics: with asset.packageName (template)" assets: { links: [ { - name: "Style package", + name: "style-package", url: expectedUrl, link_type: "package", }, @@ -344,7 +344,7 @@ test.serial("Publish a release with generics and external storage provider (http const encodedGitTag = encodeURIComponent(nextRelease.gitTag); const encodedVersion = encodeURIComponent(nextRelease.version); const uploaded = { file: { url: "http://aws.example.com/bucket/gitlab/file.css" } }; - const generic = { path: "file.css", label: "Style package", target: "generic_package", status: "hidden" }; + const generic = { path: "file.css", label: "style-package", target: "generic_package", status: "hidden" }; const assets = [generic]; const encodedLabel = encodeURIComponent(generic.label); const expectedUrl = `https://gitlab.com/api/v4/projects/${encodedProjectPath}/packages/generic/release/${encodedVersion}/${encodedLabel}`; @@ -355,7 +355,7 @@ test.serial("Publish a release with generics and external storage provider (http assets: { links: [ { - name: "Style package", + name: "style-package", url: expectedUrl, link_type: "package", }, @@ -390,7 +390,7 @@ test.serial("Publish a release with generics and external storage provider (http const encodedGitTag = encodeURIComponent(nextRelease.gitTag); const encodedVersion = encodeURIComponent(nextRelease.version); const uploaded = { file: { url: "https://aws.example.com/bucket/gitlab/file.css" } }; - const generic = { path: "file.css", label: "Style package", target: "generic_package", status: "hidden" }; + const generic = { path: "file.css", label: "style-package", target: "generic_package", status: "hidden" }; const assets = [generic]; const encodedLabel = encodeURIComponent(generic.label); const expectedUrl = `https://gitlab.com/api/v4/projects/${encodedProjectPath}/packages/generic/release/${encodedVersion}/${encodedLabel}`; @@ -401,7 +401,7 @@ test.serial("Publish a release with generics and external storage provider (http assets: { links: [ { - name: "Style package", + name: "style-package", url: expectedUrl, link_type: "package", }, @@ -436,7 +436,7 @@ test.serial("Publish a release with generics and external storage provider (ftp) const encodedGitTag = encodeURIComponent(nextRelease.gitTag); const encodedVersion = encodeURIComponent(nextRelease.version); const uploaded = { file: { url: "ftp://drive.example.com/gitlab/file.css" } }; - const generic = { path: "file.css", label: "Style package", target: "generic_package", status: "hidden" }; + const generic = { path: "file.css", label: "style-package", target: "generic_package", status: "hidden" }; const assets = [generic]; const encodedLabel = encodeURIComponent(generic.label); const expectedUrl = `https://gitlab.com/api/v4/projects/${encodedProjectPath}/packages/generic/release/${encodedVersion}/${encodedLabel}`; @@ -447,7 +447,7 @@ test.serial("Publish a release with generics and external storage provider (ftp) assets: { links: [ { - name: "Style package", + name: "style-package", url: expectedUrl, link_type: "package", }, diff --git a/test/verify.test.js b/test/verify.test.js index 48f3004f..220f2394 100644 --- a/test/verify.test.js +++ b/test/verify.test.js @@ -988,3 +988,91 @@ test.serial( t.true(gitlab.isDone()); } ); + +test.serial("Throw SemanticReleaseError if generic package asset labels are invalid", async (t) => { + const owner = "test_user"; + const repo = "test_repo"; + const env = { GITLAB_TOKEN: "gitlab_token" }; + const assets = [ + { path: "file1.css", label: "Style package", target: "generic_package" }, // contains spaces + { path: "file2.css", label: "~invalid", target: "generic_package" }, // starts with ~ + { path: "file3.css", label: "@invalid", target: "generic_package" }, // starts with @ + { path: "file4.css", label: "invalid~", target: "generic_package" }, // ends with ~ + { path: "file5.css", label: "invalid@", target: "generic_package" }, // ends with @ + { path: "file6.css", label: "invalid$char", target: "generic_package" }, // contains invalid character + ]; + const gitlab = authenticate(env) + .get(`/projects/${owner}%2F${repo}`) + .reply(200, { permissions: { project_access: { access_level: 40 } } }); + + const { errors } = await t.throwsAsync( + verify( + { assets }, + { env, options: { repositoryUrl: `https://gitlab.com/${owner}/${repo}.git` }, logger: t.context.logger } + ) + ); + t.is(errors.length, 6); + errors.forEach((error) => { + t.is(error.name, "SemanticReleaseError"); + t.is(error.code, "EINVALIDGENERICPACKAGELABEL"); + }); + t.true(gitlab.isDone()); +}); + +test.serial("Does not throw for valid generic package asset labels", async (t) => { + const owner = "test_user"; + const repo = "test_repo"; + const env = { GITLAB_TOKEN: "gitlab_token" }; + const assets = [ + { path: "file.css", label: "valid-label_123.tar.gz", target: "generic_package" }, + { path: "file2.css", label: "another.valid+label~0", target: "generic_package" }, + { path: "file3.css", label: "path/to/file", target: "generic_package" }, + ]; + const gitlab = authenticate(env) + .get(`/projects/${owner}%2F${repo}`) + .reply(200, { permissions: { project_access: { access_level: 40 } } }); + + await t.notThrowsAsync( + verify( + { assets }, + { env, options: { repositoryUrl: `https://gitlab.com/${owner}/${repo}.git` }, logger: t.context.logger } + ) + ); + t.true(gitlab.isDone()); +}); + +test.serial("Does not throw for generic package assets without labels", async (t) => { + const owner = "test_user"; + const repo = "test_repo"; + const env = { GITLAB_TOKEN: "gitlab_token" }; + const assets = [{ path: "file.css", target: "generic_package" }]; + const gitlab = authenticate(env) + .get(`/projects/${owner}%2F${repo}`) + .reply(200, { permissions: { project_access: { access_level: 40 } } }); + + await t.notThrowsAsync( + verify( + { assets }, + { env, options: { repositoryUrl: `https://gitlab.com/${owner}/${repo}.git` }, logger: t.context.logger } + ) + ); + t.true(gitlab.isDone()); +}); + +test.serial("Does not throw for non-generic package assets with spaces in labels", async (t) => { + const owner = "test_user"; + const repo = "test_repo"; + const env = { GITLAB_TOKEN: "gitlab_token" }; + const assets = [{ path: "file.css", label: "Valid Label With Spaces" }]; + const gitlab = authenticate(env) + .get(`/projects/${owner}%2F${repo}`) + .reply(200, { permissions: { project_access: { access_level: 40 } } }); + + await t.notThrowsAsync( + verify( + { assets }, + { env, options: { repositoryUrl: `https://gitlab.com/${owner}/${repo}.git` }, logger: t.context.logger } + ) + ); + t.true(gitlab.isDone()); +});