Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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 }}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Winget job uses branch as tag

Medium Severity

The winget job never passes release-tag, so winget-releaser falls back to github.ref_name. Tag pushes resolve correctly, but a Release v commit or workflow_dispatch from main looks up a release named main and publishing fails. The notes job already reads the version from package.json for this same workflow.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 3344f06. Configure here.

4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
4 changes: 3 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
45 changes: 45 additions & 0 deletions docs/winget.md
Original file line number Diff line number Diff line change
@@ -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/<version>/` 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.
2 changes: 1 addition & 1 deletion eslint.config.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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' }
}
},
{
Expand Down
146 changes: 146 additions & 0 deletions scripts/print-winget-manifest.mjs
Original file line number Diff line number Diff line change
@@ -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)
})
}
34 changes: 34 additions & 0 deletions tests/wingetManifest.test.ts
Original file line number Diff line number Diff line change
@@ -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'
)
})
})
Loading