diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 68f9654..9a26cb5 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -75,3 +75,17 @@ jobs: else echo "No CHANGELOG.md section for $version; leaving notes as they are" fi + + # After the first listing exists on winget-pkgs (see docs/winget.md), later + # releases open an update PR automatically. Skip until the package id is set. + winget: + name: publish winget + needs: notes + if: ${{ vars.WINGET_PACKAGE_ID != '' }} + runs-on: windows-latest + steps: + - uses: vedantmgoyal9/winget-releaser@v2 + with: + identifier: ${{ vars.WINGET_PACKAGE_ID }} + installers-regex: '\.exe$' + token: ${{ secrets.WINGET_TOKEN }} diff --git a/CHANGELOG.md b/CHANGELOG.md index bb88815..167eb73 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -20,6 +20,10 @@ still pre-1.0, minor bumps carry new features and patch bumps carry fixes. Structured-output calls fall back from `json_schema` to `json_object` (and then a plain JSON completion) when the provider does not support OpenAI's strict schema mode. `OPENAI_BASE_URL` still overrides Settings when set. +- **winget publishing path.** A manifest generator (`scripts/print-winget-manifest.mjs`) + and a Release workflow job that updates + [microsoft/winget-pkgs](https://github.com/microsoft/winget-pkgs) once the + first listing exists. See [docs/winget.md](docs/winget.md). ### Removed diff --git a/README.md b/README.md index 2507c78..eb20c08 100644 --- a/README.md +++ b/README.md @@ -99,9 +99,11 @@ npm version patch # or minor / major — bumps package.json and creates t git push --follow-tags # pushes the commit and the vX.Y.Z tag ``` +Windows packages can also be listed on [winget](docs/winget.md) after a one-off first submit. + Once a user has installed any build, later releases install themselves automatically. The macOS app is **not code-signed yet**, so on first launch the user right-clicks the app and chooses **Open** to get past Gatekeeper (a one-time step). Signing + notarization removes that prompt and is what enables fully silent macOS auto-updates — add an Apple Developer ID certificate and wire the signing secrets into the workflow when you're ready. -You need **Node.js 20+** and an [OpenAI API key](https://platform.openai.com/api-keys). Enter it in the app and it gets stored encrypted with Electron `safeStorage`. FFmpeg is bundled, so there is nothing else to install. Prebuilt Linux AppImages are on the [releases page](https://github.com/JeremySNR/clip-forge/releases/latest). +You need **Node.js 20+** and an [OpenAI API key](https://platform.openai.com/api-keys). Enter it in the app and it gets stored encrypted with Electron `safeStorage`. FFmpeg is bundled, so there is nothing else to install. Prebuilt Linux AppImages are on the [releases page](https://github.com/JeremySNR/clip-forge/releases/latest). On Windows, `winget install JeremySNR.ClipForge` will work once the [winget package](docs/winget.md) is listed. Everything except transcription and analysis runs locally. Rendering, face tracking, editing, zoom and export never leave your machine. Only extracted audio, transcripts and a few sampled frames go to the OpenAI API. Never the full video. diff --git a/docs/winget.md b/docs/winget.md new file mode 100644 index 0000000..2ee501b --- /dev/null +++ b/docs/winget.md @@ -0,0 +1,45 @@ +# Publishing ClipForge to winget + +Windows is where most ClipForge installs happen. A +[winget](https://learn.microsoft.com/en-us/windows/package-manager/) package +lets people run: + +``` +winget install JeremySNR.ClipForge +``` + +The app is **not code-signed yet**, which winget allows. Users will still see a +SmartScreen prompt on first launch. + +## First publish (one-off) + +The first listing is a pull request to +[microsoft/winget-pkgs](https://github.com/microsoft/winget-pkgs). Generate the +three YAML files from the latest GitHub release: + +```bash +node scripts/print-winget-manifest.mjs --out .tmp/winget +``` + +Then either: + +- install [wingetcreate](https://github.com/microsoft/winget-create) and submit + (`wingetcreate submit .tmp/winget`), or +- open a PR against `microsoft/winget-pkgs` under + `manifests/j/JeremySNR/ClipForge//` with those three files. + +Package identifier: **JeremySNR.ClipForge**. + +## Later releases (automatic) + +Once the package exists, set two repository secrets/variables: + +| Name | Where | Value | +| --- | --- | --- | +| `WINGET_TOKEN` | Actions secret | A PAT with `public_repo` that can open PRs on a fork of `winget-pkgs` | +| `WINGET_PACKAGE_ID` | Actions variable | `JeremySNR.ClipForge` | + +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. diff --git a/eslint.config.mjs b/eslint.config.mjs index 2571b8d..fc8544c 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -22,7 +22,7 @@ export default tseslint.config( // `no-undef` off and leaves undefined-name checking to tsc. files: ['**/*.{js,mjs,cjs}'], languageOptions: { - globals: { process: 'readonly', console: 'readonly', URL: 'readonly' } + globals: { process: 'readonly', console: 'readonly', URL: 'readonly', fetch: 'readonly' } } }, { diff --git a/scripts/print-winget-manifest.mjs b/scripts/print-winget-manifest.mjs new file mode 100644 index 0000000..e5c8f41 --- /dev/null +++ b/scripts/print-winget-manifest.mjs @@ -0,0 +1,146 @@ +/** + * Prints a winget-pkgs manifest trio (version + installer + locale) for the + * latest (or a given) GitHub release. Used for the first submit to + * microsoft/winget-pkgs; later versions are published by the Release + * workflow once WINGET_TOKEN is set (see docs/winget.md). + * + * Usage: + * node scripts/print-winget-manifest.mjs # latest release + * node scripts/print-winget-manifest.mjs 0.7.0 # specific version + * + * Writes YAML to stdout as three files separated by banners, or to a + * directory if --out DIR is passed. + */ +import { mkdir, writeFile } from 'node:fs/promises' +import { join } from 'node:path' + +const PACKAGE_ID = 'JeremySNR.ClipForge' +const OWNER = 'JeremySNR' +const REPO = 'clip-forge' +const PUBLISHER = 'Jeremy Smith' +const PACKAGE_NAME = 'ClipForge' + +const args = process.argv.slice(2) +let versionArg = null +let outDir = null +for (let i = 0; i < args.length; i++) { + if (args[i] === '--out') { + outDir = args[++i] + continue + } + if (!args[i].startsWith('-')) versionArg = args[i] +} + +function sha256Of(asset) { + const digest = asset.digest + if (typeof digest === 'string' && digest.startsWith('sha256:')) return digest.slice(7) + throw new Error(`Release asset ${asset.name} has no sha256 digest`) +} + +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 +ManifestType: version +ManifestVersion: 1.6.0 +` + + const installerYaml = `PackageIdentifier: ${PACKAGE_ID} +PackageVersion: ${version} +InstallerLocale: en-US +InstallerType: nullsoft +Scope: user +UpgradeBehavior: install +ReleaseDate: ${date} +Installers: + - Architecture: x64 + InstallerUrl: ${installerUrl} + InstallerSha256: ${installerSha256.toUpperCase()} +ManifestType: installer +ManifestVersion: 1.6.0 +` + + const localeYaml = `PackageIdentifier: ${PACKAGE_ID} +PackageVersion: ${version} +PackageLocale: en-US +Publisher: ${PUBLISHER} +PublisherUrl: https://github.com/${OWNER} +PublisherSupportUrl: https://github.com/${OWNER}/${REPO}/issues +Author: ${PUBLISHER} +PackageName: ${PACKAGE_NAME} +PackageUrl: https://github.com/${OWNER}/${REPO} +License: MIT +LicenseUrl: https://github.com/${OWNER}/${REPO}/blob/main/LICENSE +Copyright: Copyright (c) ClipForge Contributors +ShortDescription: Open-source Opus Clip alternative. Turn long videos into vertical clips on your desktop. +Description: Turn podcasts, webinars, streams and interviews into ready-to-post vertical clips. AI-picked moments, virality scores, animated captions, auto zoom and speaker-aware reframing. Runs on your machine; you bring an OpenAI API key. +Moniker: clipforge +Tags: + - video + - captions + - clips + - electron + - openai +ReleaseNotesUrl: ${releaseUrl} +ManifestType: defaultLocale +ManifestVersion: 1.6.0 +` + + return { versionYaml, installerYaml, localeYaml } +} + +async function githubJson(path) { + const headers = { Accept: 'application/vnd.github+json', 'User-Agent': 'clipforge-winget' } + if (process.env.GH_TOKEN || process.env.GITHUB_TOKEN) { + headers.Authorization = `Bearer ${process.env.GH_TOKEN || process.env.GITHUB_TOKEN}` + } + const res = await fetch(`https://api.github.com/repos/${OWNER}/${REPO}/${path}`, { headers }) + if (!res.ok) throw new Error(`GitHub API ${path} failed: HTTP ${res.status}`) + return res.json() +} + +async function main() { + const release = versionArg + ? await githubJson(`releases/tags/v${versionArg.replace(/^v/, '')}`) + : await githubJson('releases/latest') + const version = String(release.tag_name ?? '').replace(/^v/, '') + const installer = (release.assets ?? []).find((a) => /^ClipForge-Setup-.*\.exe$/.test(a.name)) + if (!installer) { + throw new Error(`No ClipForge-Setup-*.exe on ${release.tag_name}`) + } + const manifests = buildManifests({ + version, + installerUrl: installer.browser_download_url, + installerSha256: sha256Of(installer), + releaseUrl: release.html_url, + publishedAt: release.published_at + }) + + const files = [ + [`${PACKAGE_ID}.yaml`, manifests.versionYaml], + [`${PACKAGE_ID}.installer.yaml`, manifests.installerYaml], + [`${PACKAGE_ID}.locale.en-US.yaml`, manifests.localeYaml] + ] + + if (outDir) { + await mkdir(outDir, { recursive: true }) + for (const [name, body] of files) { + await writeFile(join(outDir, name), body, 'utf8') + } + console.error(`Wrote ${files.length} files to ${outDir}`) + return + } + + for (const [name, body] of files) { + process.stdout.write(`# --- ${name} ---\n${body}\n`) + } +} + +const isMain = process.argv[1] && process.argv[1].endsWith('print-winget-manifest.mjs') +if (isMain) { + main().catch((err) => { + console.error(err instanceof Error ? err.message : err) + process.exit(1) + }) +} diff --git a/tests/wingetManifest.test.ts b/tests/wingetManifest.test.ts new file mode 100644 index 0000000..10039ac --- /dev/null +++ b/tests/wingetManifest.test.ts @@ -0,0 +1,34 @@ +import { describe, expect, it } from 'vitest' +// @ts-expect-error plain ESM helper, no declaration file +import { buildManifests } from '../scripts/print-winget-manifest.mjs' + +const SHA = 'c0e801a93b2c26dbc529e16f0c6fcf25a560728132a5a05a444406488a5433b5' + +describe('buildManifests', () => { + const manifests = buildManifests({ + version: '0.7.0', + installerUrl: + 'https://github.com/JeremySNR/clip-forge/releases/download/v0.7.0/ClipForge-Setup-0.7.0.exe', + installerSha256: SHA, + releaseUrl: 'https://github.com/JeremySNR/clip-forge/releases/tag/v0.7.0', + publishedAt: '2026-09-02T06:20:17Z' + }) + + it('emits the winget-pkgs identifier and NSIS installer', () => { + expect(manifests.versionYaml).toContain('PackageIdentifier: JeremySNR.ClipForge') + expect(manifests.versionYaml).toContain('PackageVersion: 0.7.0') + expect(manifests.installerYaml).toContain('InstallerType: nullsoft') + expect(manifests.installerYaml).toContain('ClipForge-Setup-0.7.0.exe') + expect(manifests.installerYaml).toContain(`InstallerSha256: ${SHA.toUpperCase()}`) + expect(manifests.installerYaml).toContain('ReleaseDate: 2026-09-02') + }) + + it('fills locale metadata winget-pkgs requires', () => { + expect(manifests.localeYaml).toContain('PackageName: ClipForge') + expect(manifests.localeYaml).toContain('License: MIT') + expect(manifests.localeYaml).toContain('Moniker: clipforge') + expect(manifests.localeYaml).toContain( + 'https://github.com/JeremySNR/clip-forge/releases/tag/v0.7.0' + ) + }) +})