From c62e4336fde0a804327302badacb642e8e9faf9f Mon Sep 17 00:00:00 2001 From: Chirag Jangir <184008125+chiragjangiir@users.noreply.github.com> Date: Sat, 10 Oct 2026 22:07:15 +0530 Subject: [PATCH 1/2] Pin the winget publisher action to a commit MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit winget-releaser is handed WINGET_TOKEN, a PAT that can open PRs on a fork of winget-pkgs, and @v2 is a moving tag: it currently resolves to a commit pushed 2026-10-07 while the v2 release dates from 2025-01-27. Pinning to the commit @v2 resolves to today keeps the published behaviour identical while stopping a later tag move from running unreviewed code with that token. 🤖 Generated with Codebuff Co-Authored-By: Codebuff --- .github/workflows/release.yml | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index b3fa14f..1216d28 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -129,7 +129,13 @@ jobs: - id: version shell: bash run: echo "version=$(node -p "require('./package.json').version")" >> $GITHUB_OUTPUT - - uses: vedantmgoyal9/winget-releaser@v2 + # Pinned to a commit rather than @v2: this action is handed + # WINGET_TOKEN (a PAT that can open PRs on a fork of winget-pkgs), and + # @v2 is a moving tag — it currently resolves to a commit pushed + # 2026-10-07 while the v2 release dates from 2025-01-27, so a tag move + # would run unreviewed code with that token. Bump deliberately: + # gh api repos/vedantmgoyal9/winget-releaser/commits/v2 -q .sha + - uses: vedantmgoyal9/winget-releaser@f29ed1ffd34f91223a2aebaa71beaab0901ff9fd # v2 with: identifier: ${{ vars.WINGET_PACKAGE_ID }} installers-regex: '\.exe$' From fe2bffa1c495f07938fa4deb0fc3e551e79c50e5 Mon Sep 17 00:00:00 2001 From: Chirag Jangir <184008125+chiragjangiir@users.noreply.github.com> Date: Sat, 10 Oct 2026 22:07:57 +0530 Subject: [PATCH 2/2] Check the winget manifest before it is submitted MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The first submission to winget-pkgs is still outstanding (#47), and the three YAML files it needs are produced by print-winget-manifest.mjs. Nothing checked them, so a manifest winget-pkgs would reject only surfaced during review. The generator now parses its own output and checks it against the winget 1.6.0 manifest schemas: required fields, identifier and version agreement across the trio, the installer URL naming the versioned asset the release uploads, hash format, the architecture and installer type enums, and the locale length limits. It also checks the one invariant only this repo knows: Scope has to match electron-builder's NSIS config in package.json, because that is what decides where winget installs the app. Nothing is written when a check fails. The emitted manifest is unchanged byte for byte for the current release, so this only adds the checks. 🤖 Generated with Codebuff Co-Authored-By: Codebuff --- docs/winget.md | 24 ++ scripts/print-winget-manifest.mjs | 353 +++++++++++++++++++++++++++++- tests/wingetManifest.test.ts | 129 ++++++++++- 3 files changed, 489 insertions(+), 17 deletions(-) diff --git a/docs/winget.md b/docs/winget.md index a6de8cb..3984771 100644 --- a/docs/winget.md +++ b/docs/winget.md @@ -21,6 +21,21 @@ three YAML files from the latest GitHub release: node scripts/print-winget-manifest.mjs --out .tmp/winget ``` +The generator checks its own output before writing anything: the fields each +file has to carry under the 1.6.0 manifest schemas, the identifier and version +agreeing across the three files, the installer URL naming the versioned asset +the release uploads, the hash being 64 hex characters, the architecture and +installer type being ones winget accepts, and the locale strings staying inside +the schema's length limits. It also refuses a manifest whose `Scope` disagrees +with electron-builder's NSIS config in `package.json` (per-user today), since +that is what decides where winget installs the app. Nothing is written if a +check fails, and the reasons are printed. + +That covers the shape winget-pkgs validates on a pull request. It cannot cover +the parts of their pipeline that need Windows — the installer actually running +and uninstalling, and the manual review — so watch the checks on the PR itself +after submitting. + Then either: - install [wingetcreate](https://github.com/microsoft/winget-create) and submit @@ -43,3 +58,12 @@ The [Release workflow](../.github/workflows/release.yml) then runs `winget-releaser` after each GitHub Release so the manifest stays current. Leave `WINGET_PACKAGE_ID` empty until the first package is accepted, otherwise the job would try to update a package that does not exist yet. + +The job pins `vedantmgoyal9/winget-releaser` to a commit instead of `@v2`. +That action is handed `WINGET_TOKEN`, and `v2` is a moving tag — it currently +resolves to a commit pushed after the v2 release, so a tag move would run +unreviewed code with the token. Bump the pin on purpose: + +```bash +gh api repos/vedantmgoyal9/winget-releaser/commits/v2 -q .sha +``` diff --git a/scripts/print-winget-manifest.mjs b/scripts/print-winget-manifest.mjs index 7f28b43..026d029 100644 --- a/scripts/print-winget-manifest.mjs +++ b/scripts/print-winget-manifest.mjs @@ -10,15 +10,119 @@ * * Writes YAML to stdout as three files separated by banners, or to a * directory if --out DIR is passed. + * + * The trio is checked against the winget 1.6.0 manifest schemas before it is + * written, so a submission winget-pkgs would reject fails here instead. */ -import { mkdir, writeFile } from 'node:fs/promises' +import { mkdir, readFile, writeFile } from 'node:fs/promises' +import { createRequire } from 'node:module' import { join } from 'node:path' +const require = createRequire(import.meta.url) +// Same loader as scripts/verify-release.mjs: js-yaml is already a dependency, +// and parsing the emitted text checks the bytes we actually submit rather than +// the values we intended to write. +const yaml = require('js-yaml') + const PACKAGE_ID = 'JeremySNR.Cutawan' const OWNER = 'JeremySNR' const REPO = 'cutawan' const PUBLISHER = 'Jeremy Smith' const PACKAGE_NAME = 'Cutawan' +const DEFAULT_LOCALE = 'en-US' +const MANIFEST_VERSION = '1.6.0' + +// Values below come from the schemas the trio declares +// (ManifestVersion 1.6.0), so winget-pkgs validates submissions against them: +// https://aka.ms/winget-manifest.version.1.6.0.schema.json +// https://aka.ms/winget-manifest.installer.1.6.0.schema.json +// https://aka.ms/winget-manifest.defaultlocale.1.6.0.schema.json +const ARCHITECTURES = ['x86', 'x64', 'arm', 'arm64', 'neutral'] +const INSTALLER_TYPES = [ + 'msix', + 'msi', + 'appx', + 'exe', + 'zip', + 'inno', + 'nullsoft', + 'wix', + 'burn', + 'pwa', + 'portable' +] +const SCOPES = ['user', 'machine'] +const UPGRADE_BEHAVIORS = ['install', 'uninstallPrevious', 'deny'] +// The identifier and version patterns are the only schema rules that exclude +// ASCII control characters, which eslint's no-control-regex rejects inside a +// regex literal. They are predicates instead, with the same rules: at least two +// dot-separated parts, 1-32 characters each, and none of the characters Windows +// forbids (the identifier also rejects whitespace, the version does not). +const WINDOWS_RESERVED = ['\\', '/', ':', '*', '?', '"', '<', '>', '|'] + +const hasExcludedChar = (text, excluded, { whitespace = false } = {}) => + [...text].some((char) => { + const code = char.codePointAt(0) + return ( + excluded.has(char) || + (code >= 0x01 && code <= 0x1f) || + (whitespace && /\s/.test(char)) + ) + }) + +const isSchemaPackageIdentifier = (value) => { + const parts = value.split('.') + if (parts.length < 2 || parts.length > 8) return false + const excluded = new Set([...WINDOWS_RESERVED, '.']) + return parts.every( + (part) => + part.length >= 1 && part.length <= 32 && !hasExcludedChar(part, excluded, { whitespace: true }) + ) +} + +const isSchemaPackageVersion = (value) => + value.length >= 1 && + value.length <= 128 && + !hasExcludedChar(value, new Set(WINDOWS_RESERVED)) + +const SHA256_RE = /^[A-Fa-f0-9]{64}$/ +const URL_RE = /^https?:\/\/.+/i +const LOCALE_RE = /^([a-zA-Z]{2,3}|[iI]-[a-zA-Z]+|[xX]-[a-zA-Z]{1,8})(-[a-zA-Z]{1,8})*$/ + +const REQUIRED = { + version: [ + 'PackageIdentifier', + 'PackageVersion', + 'DefaultLocale', + 'ManifestType', + 'ManifestVersion' + ], + installer: ['PackageIdentifier', 'PackageVersion', 'Installers', 'ManifestType', 'ManifestVersion'], + locale: [ + 'PackageIdentifier', + 'PackageVersion', + 'PackageLocale', + 'Publisher', + 'PackageName', + 'License', + 'ShortDescription', + 'ManifestType', + 'ManifestVersion' + ] +} + +const MANIFEST_TYPES = { version: 'version', installer: 'installer', locale: 'defaultLocale' } + +// Bounds from the defaultLocale schema. The minima matter as much as the +// maxima: a two-character licence fails the schema's minLength. +const BOUNDED_LOCALE_FIELDS = { + Publisher: [2, 256], + PackageName: [2, 256], + License: [3, 512], + ShortDescription: [3, 256], + Description: [3, 10000], + Copyright: [3, 512] +} const args = process.argv.slice(2) let versionArg = null @@ -37,18 +141,243 @@ function sha256Of(asset) { throw new Error(`Release asset ${asset.name} has no sha256 digest`) } +/** + * The scope the installer manifest must declare for the current packaging + * config. electron-builder's NSIS target installs per machine only when + * `perMachine` is true; `oneClick` does not change that. package.json leaves it + * unset, so Cutawan's installer is per user, and `Scope: user` below has to + * move with it if that ever changes. + */ +export function expectedInstallerScope(nsis = {}) { + return nsis && nsis.perMachine === true ? 'machine' : 'user' +} + +/** + * Checks an emitted trio against the winget 1.6.0 schemas and the invariants + * only this repo can know (the installer scope must match package.json, and + * the installer URL must name the versioned asset the release builds). + * + * Returns a list of problems; an empty list means the trio is safe to submit. + */ +export function validateManifests(manifests, { expectedScope } = {}) { + const problems = [] + const parsed = {} + + const kinds = Object.keys(REQUIRED) + const keys = { version: 'versionYaml', installer: 'installerYaml', locale: 'localeYaml' } + for (const kind of kinds) { + try { + parsed[kind] = yaml.load(manifests[keys[kind]]) + } catch (err) { + problems.push(`${kind} manifest is not valid YAML: ${err.message}`) + } + } + + const objectOf = (kind) => { + const data = parsed[kind] + if (data === undefined || data === null) return null + if (typeof data !== 'object' || Array.isArray(data)) { + problems.push(`${kind} manifest is not a mapping`) + return null + } + return data + } + + const checkString = (kind, data, field, { matches, invalid, min, max, values } = {}) => { + const value = data[field] + if (value === undefined || value === null) return + if (typeof value !== 'string') { + problems.push(`${kind} manifest ${field} must be a string`) + return + } + if (matches && !matches(value)) { + problems.push( + `${kind} manifest ${field} ${invalid ?? 'does not match the schema pattern'}: ${value}` + ) + } + if (min !== undefined && value.length < min) { + problems.push(`${kind} manifest ${field} must be at least ${min} characters`) + } + if (max !== undefined && value.length > max) { + problems.push(`${kind} manifest ${field} must be at most ${max} characters`) + } + if (values && !values.includes(value)) { + problems.push(`${kind} manifest ${field} must be one of ${values.join(', ')}: ${value}`) + } + } + + for (const kind of kinds) { + const data = objectOf(kind) + if (!data) continue + + for (const field of REQUIRED[kind]) { + if (data[field] === undefined || data[field] === '') { + problems.push(`${kind} manifest is missing ${field}`) + } + } + if (data.ManifestType !== undefined && data.ManifestType !== MANIFEST_TYPES[kind]) { + problems.push(`${kind} manifest ManifestType must be ${MANIFEST_TYPES[kind]}`) + } + if (data.ManifestVersion !== undefined && data.ManifestVersion !== MANIFEST_VERSION) { + problems.push( + `${kind} manifest ManifestVersion must be ${MANIFEST_VERSION}: ${data.ManifestVersion}` + ) + } + checkString(kind, data, 'PackageIdentifier', { + matches: isSchemaPackageIdentifier, + invalid: 'is not a valid package identifier', + max: 128 + }) + checkString(kind, data, 'PackageVersion', { + matches: isSchemaPackageVersion, + invalid: 'is not a valid package version', + max: 128 + }) + } + + const version = parsed.version + const installer = parsed.installer + const locale = parsed.locale + + // Every file has to describe the same package at the same version, or + // winget-pkgs rejects the trio even when each file is individually valid. + const identifiers = [version, installer, locale] + .map((data) => data?.PackageIdentifier) + .filter((id) => id !== undefined) + if (identifiers.length && new Set(identifiers).size !== 1) { + problems.push(`PackageIdentifier must match across the trio: ${identifiers.join(', ')}`) + } + const versions = [version, installer, locale] + .map((data) => data?.PackageVersion) + .filter((v) => v !== undefined) + if (versions.length && new Set(versions).size !== 1) { + problems.push(`PackageVersion must match across the trio: ${versions.join(', ')}`) + } + + if (version && locale && locale.PackageLocale !== undefined) { + checkString('locale', locale, 'PackageLocale', { + matches: (value) => LOCALE_RE.test(value), + max: 20 + }) + if (version.DefaultLocale !== undefined && locale.PackageLocale !== version.DefaultLocale) { + problems.push( + `PackageLocale (${locale.PackageLocale}) must match DefaultLocale (${version.DefaultLocale})` + ) + } + } + + if (installer) { + checkString('installer', installer, 'InstallerType', { values: INSTALLER_TYPES }) + checkString('installer', installer, 'Scope', { values: SCOPES }) + checkString('installer', installer, 'UpgradeBehavior', { values: UPGRADE_BEHAVIORS }) + if (installer.InstallerLocale !== undefined) { + checkString('installer', installer, 'InstallerLocale', { + matches: (value) => LOCALE_RE.test(value), + max: 20 + }) + if (version?.DefaultLocale !== undefined && installer.InstallerLocale !== version.DefaultLocale) { + problems.push( + `InstallerLocale (${installer.InstallerLocale}) must match DefaultLocale (${version.DefaultLocale})` + ) + } + } + // The declared scope decides where winget installs, so a mismatch would + // hand users a per-machine install described as per-user (or the reverse). + if (expectedScope && installer.Scope !== undefined && installer.Scope !== expectedScope) { + problems.push( + `installer manifest Scope is ${installer.Scope} but package.json's NSIS config implies ` + + `${expectedScope}; change both together` + ) + } + + if (!Array.isArray(installer.Installers)) { + if (installer.Installers !== undefined) { + problems.push('installer manifest Installers must be a list') + } + } else if (installer.Installers.length === 0) { + problems.push('installer manifest needs at least one entry in Installers') + } else if (installer.Installers.length > 1024) { + problems.push('installer manifest has more than 1024 entries in Installers') + } else { + const seen = new Set() + for (const entry of installer.Installers) { + if (!entry || typeof entry !== 'object') { + problems.push('every installers entry must be a mapping') + continue + } + const label = entry.Architecture ?? 'unknown architecture' + for (const field of ['Architecture', 'InstallerUrl', 'InstallerSha256']) { + if (entry[field] === undefined || entry[field] === '') { + problems.push(`installers entry (${label}) is missing ${field}`) + } + } + if (entry.Architecture !== undefined && !ARCHITECTURES.includes(entry.Architecture)) { + problems.push(`unsupported installer architecture: ${entry.Architecture}`) + } + if (typeof entry.InstallerSha256 === 'string' && !SHA256_RE.test(entry.InstallerSha256)) { + problems.push(`InstallerSha256 must be 64 hex characters: ${entry.InstallerSha256}`) + } + if (typeof entry.InstallerUrl === 'string') { + if (!URL_RE.test(entry.InstallerUrl)) { + problems.push(`InstallerUrl must be http(s): ${entry.InstallerUrl}`) + } else if (entry.InstallerUrl.length > 2048) { + problems.push('InstallerUrl must be at most 2048 characters') + } + const asset = entry.InstallerUrl.split('/').pop() + // A renamed artifact is the likeliest way this breaks silently: the + // manifest would point at a file the release never uploads. + if (installer.PackageVersion !== undefined && !asset.includes(installer.PackageVersion)) { + problems.push(`InstallerUrl must name the ${installer.PackageVersion} asset: ${asset}`) + } + if (seen.has(entry.InstallerUrl)) { + problems.push(`duplicate InstallerUrl: ${entry.InstallerUrl}`) + } + seen.add(entry.InstallerUrl) + } + } + } + } + + if (locale) { + for (const [field, [min, max]] of Object.entries(BOUNDED_LOCALE_FIELDS)) { + checkString('locale', locale, field, { min, max }) + } + checkString('locale', locale, 'Moniker', { min: 1, max: 40 }) + for (const field of Object.keys(locale)) { + if (/Url$/.test(field)) { + checkString('locale', locale, field, { matches: (value) => URL_RE.test(value), max: 2048 }) + } + } + if (locale.Tags !== undefined) { + if (!Array.isArray(locale.Tags)) { + problems.push('locale manifest Tags must be a list') + } else { + if (locale.Tags.length > 16) problems.push('locale manifest allows at most 16 Tags') + if (new Set(locale.Tags).size !== locale.Tags.length) { + problems.push('locale manifest Tags must be unique') + } + if (locale.Tags.some((tag) => typeof tag !== 'string' || tag.length < 1 || tag.length > 40)) { + problems.push('every locale manifest tag must be 1-40 characters') + } + } + } + } + + return problems +} + export function buildManifests({ version, installerUrl, installerSha256, releaseUrl, publishedAt }) { const date = (publishedAt ?? new Date().toISOString()).slice(0, 10) const versionYaml = `PackageIdentifier: ${PACKAGE_ID} PackageVersion: ${version} -DefaultLocale: en-US +DefaultLocale: ${DEFAULT_LOCALE} ManifestType: version -ManifestVersion: 1.6.0 +ManifestVersion: ${MANIFEST_VERSION} ` const installerYaml = `PackageIdentifier: ${PACKAGE_ID} PackageVersion: ${version} -InstallerLocale: en-US +InstallerLocale: ${DEFAULT_LOCALE} InstallerType: nullsoft Scope: user UpgradeBehavior: install @@ -58,12 +387,12 @@ Installers: InstallerUrl: ${installerUrl} InstallerSha256: ${installerSha256.toUpperCase()} ManifestType: installer -ManifestVersion: 1.6.0 +ManifestVersion: ${MANIFEST_VERSION} ` const localeYaml = `PackageIdentifier: ${PACKAGE_ID} PackageVersion: ${version} -PackageLocale: en-US +PackageLocale: ${DEFAULT_LOCALE} Publisher: ${PUBLISHER} PublisherUrl: https://github.com/${OWNER} PublisherSupportUrl: https://github.com/${OWNER}/${REPO}/issues @@ -84,7 +413,7 @@ Tags: - openai ReleaseNotesUrl: ${releaseUrl} ManifestType: defaultLocale -ManifestVersion: 1.6.0 +ManifestVersion: ${MANIFEST_VERSION} ` return { versionYaml, installerYaml, localeYaml } @@ -117,6 +446,16 @@ async function main() { publishedAt: release.published_at }) + const pkg = JSON.parse(await readFile(new URL('../package.json', import.meta.url), 'utf8')) + const problems = validateManifests(manifests, { + expectedScope: expectedInstallerScope(pkg.build?.nsis) + }) + if (problems.length) { + throw new Error( + `This manifest would be rejected by winget-pkgs:\n - ${problems.join('\n - ')}` + ) + } + const files = [ [`${PACKAGE_ID}.yaml`, manifests.versionYaml], [`${PACKAGE_ID}.installer.yaml`, manifests.installerYaml], diff --git a/tests/wingetManifest.test.ts b/tests/wingetManifest.test.ts index 9ec6f95..537060f 100644 --- a/tests/wingetManifest.test.ts +++ b/tests/wingetManifest.test.ts @@ -1,19 +1,24 @@ import { describe, expect, it } from 'vitest' +import pkg from '../package.json' // @ts-expect-error plain ESM helper, no declaration file -import { buildManifests } from '../scripts/print-winget-manifest.mjs' +import { buildManifests, expectedInstallerScope, validateManifests } from '../scripts/print-winget-manifest.mjs' const SHA = 'c0e801a93b2c26dbc529e16f0c6fcf25a560728132a5a05a444406488a5433b5' -describe('buildManifests', () => { - const manifests = buildManifests({ - version: '0.7.0', - installerUrl: - 'https://github.com/JeremySNR/cutawan/releases/download/v0.7.0/Cutawan-Setup-0.7.0.exe', - installerSha256: SHA, - releaseUrl: 'https://github.com/JeremySNR/cutawan/releases/tag/v0.7.0', - publishedAt: '2026-09-02T06:20:17Z' - }) +const manifests = buildManifests({ + version: '0.7.0', + installerUrl: + 'https://github.com/JeremySNR/cutawan/releases/download/v0.7.0/Cutawan-Setup-0.7.0.exe', + installerSha256: SHA, + releaseUrl: 'https://github.com/JeremySNR/cutawan/releases/tag/v0.7.0', + publishedAt: '2026-09-02T06:20:17Z' +}) +// package.json is the source of truth for where the installer puts the app, so +// the manifest's Scope has to be derived from it rather than assumed. +const expectedScope = expectedInstallerScope(pkg.build.nsis) + +describe('buildManifests', () => { it('emits the winget-pkgs identifier and NSIS installer', () => { expect(manifests.versionYaml).toContain('PackageIdentifier: JeremySNR.Cutawan') expect(manifests.versionYaml).toContain('PackageVersion: 0.7.0') @@ -32,3 +37,107 @@ describe('buildManifests', () => { ) }) }) + +describe('validateManifests', () => { + const validate = (overrides = {}, scope = expectedScope) => + validateManifests({ ...manifests, ...overrides }, { expectedScope: scope }) + + it('accepts the trio it generates', () => { + expect(validate()).toEqual([]) + }) + + it('keeps the declared scope in step with the packaging config', () => { + // Scope: user is only right while electron-builder's NSIS target stays + // per-user. Turning on perMachine in package.json without moving the + // manifest fails here instead of at winget-pkgs. + expect(manifests.installerYaml).toContain(`Scope: ${expectedScope}`) + }) + + it('rejects a scope that disagrees with the packaging config', () => { + expect(validate({}, 'machine')).toEqual([ + expect.stringContaining("package.json's NSIS config implies machine") + ]) + }) + + it('rejects a sha256 that is not 64 hex characters', () => { + expect( + validate({ installerYaml: manifests.installerYaml.replace(SHA.toUpperCase(), 'abc123') }) + ).toEqual([expect.stringContaining('InstallerSha256 must be 64 hex characters')]) + }) + + it('rejects a version that disagrees across the trio', () => { + expect( + validate({ + localeYaml: manifests.localeYaml.replace('PackageVersion: 0.7.0', 'PackageVersion: 0.7.1') + }) + ).toEqual([expect.stringContaining('PackageVersion must match across the trio')]) + }) + + it('rejects a locale field winget requires', () => { + expect( + validate({ localeYaml: manifests.localeYaml.replace(/^ShortDescription:.*$/m, '') }) + ).toEqual([expect.stringContaining('locale manifest is missing ShortDescription')]) + }) + + it('rejects a locale field outside the schema length limits', () => { + expect( + validate({ localeYaml: manifests.localeYaml.replace('License: MIT', 'License: M') }) + ).toEqual([expect.stringContaining('License must be at least 3 characters')]) + }) + + it('rejects an architecture winget does not accept', () => { + expect( + validate({ installerYaml: manifests.installerYaml.replace('Architecture: x64', 'Architecture: arm64e') }) + ).toEqual([expect.stringContaining('unsupported installer architecture: arm64e')]) + }) + + it('rejects an installer URL that does not name the versioned asset', () => { + expect( + validate({ + installerYaml: manifests.installerYaml.replace('Cutawan-Setup-0.7.0.exe', 'Cutawan-Setup.exe') + }) + ).toEqual([expect.stringContaining('InstallerUrl must name the 0.7.0 asset')]) + }) + + it('rejects duplicated tags', () => { + expect( + validate({ localeYaml: manifests.localeYaml.replace(' - openai', ' - video') }) + ).toEqual([expect.stringContaining('Tags must be unique')]) + }) + + it('rejects a package identifier with fewer than two parts', () => { + expect( + validate({ + versionYaml: manifests.versionYaml.replace( + 'PackageIdentifier: JeremySNR.Cutawan', + 'PackageIdentifier: Cutawan' + ) + }) + ).toEqual([ + expect.stringContaining('PackageIdentifier is not a valid package identifier'), + expect.stringContaining('PackageIdentifier must match across the trio') + ]) + }) + + it('rejects a package version with a character winget forbids', () => { + expect( + validate({ + versionYaml: manifests.versionYaml.replace('PackageVersion: 0.7.0', 'PackageVersion: 0.7/0') + }) + ).toEqual([ + expect.stringContaining('PackageVersion is not a valid package version'), + expect.stringContaining('PackageVersion must match across the trio') + ]) + }) + + it('rejects an installer URL that is not http(s)', () => { + expect( + validate({ + installerYaml: manifests.installerYaml.replace( + ' InstallerUrl: https://github.com', + ' InstallerUrl: ftp://github.com' + ) + }) + ).toEqual([expect.stringContaining('InstallerUrl must be http(s)')]) + }) +})