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$' 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)')]) + }) +})