Skip to content
Open
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
85 changes: 85 additions & 0 deletions .github/workflows/update-winget.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
name: Update Winget

on:
release:
types:
- published

permissions:
contents: read

jobs:
update-winget:
name: Submit Winget manifest update
if: ${{ !github.event.release.draft && !github.event.release.prerelease }}
runs-on: ubuntu-latest
env:
WINGET_PACKAGE_IDENTIFIER: ${{ vars.WINGET_PACKAGE_IDENTIFIER || 'NeuralNomadsAI.CodeNomad' }}
WINGET_FORK_OWNER: ${{ vars.WINGET_FORK_OWNER || 'pascalandr' }}
WINGET_WINDOWS_ASSET_NAME_TEMPLATE: ${{ vars.WINGET_WINDOWS_ASSET_NAME_TEMPLATE || 'CodeNomad-Tauri-windows-x64-{version}.zip' }}
WINGET_ASSET_WAIT_TIMEOUT_SECONDS: ${{ vars.WINGET_ASSET_WAIT_TIMEOUT_SECONDS || '900' }}
WINGET_ASSET_POLL_INTERVAL_SECONDS: ${{ vars.WINGET_ASSET_POLL_INTERVAL_SECONDS || '15' }}
steps:
- name: Checkout repository
uses: actions/checkout@v4

- name: Setup Node
uses: actions/setup-node@v4
with:
node-version: 20

- name: Wait for Windows Tauri release asset
id: release_asset
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
args=(
--repo "${{ github.repository }}"
--release-id "${{ github.event.release.id }}"
--tag "${{ github.event.release.tag_name }}"
--asset-name-template "$WINGET_WINDOWS_ASSET_NAME_TEMPLATE"
--timeout-seconds "$WINGET_ASSET_WAIT_TIMEOUT_SECONDS"
--poll-interval-seconds "$WINGET_ASSET_POLL_INTERVAL_SECONDS"
--github-output "$GITHUB_OUTPUT"
)

node scripts/winget/resolve-release-asset.cjs "${args[@]}"

- name: Log resolved Winget asset metadata
run: |
echo "Resolved asset: ${{ steps.release_asset.outputs.asset_name }}"
echo "Resolved version: ${{ steps.release_asset.outputs.version }}"
echo "Resolved SHA-256: ${{ steps.release_asset.outputs.asset_sha256 }}"

- name: Validate fork configuration
env:
GH_TOKEN: ${{ secrets.WINGET_GITHUB_TOKEN }}
EXPECTED_OWNER: ${{ env.WINGET_FORK_OWNER }}
run: |
set -euo pipefail
token_owner="$(gh api user --jq '.login')"
fork_name="$(gh api "repos/$EXPECTED_OWNER/winget-pkgs" --jq '.full_name')"
parent_name="$(gh api "repos/$EXPECTED_OWNER/winget-pkgs" --jq '.parent.full_name')"
is_fork="$(gh api "repos/$EXPECTED_OWNER/winget-pkgs" --jq '.fork')"

if [ "$token_owner" != "$EXPECTED_OWNER" ]; then
echo "WINGET_GITHUB_TOKEN belongs to '$token_owner' but WINGET_FORK_OWNER is '$EXPECTED_OWNER'" >&2
exit 1
fi

if [ "$is_fork" != "true" ] || [ "$parent_name" != "microsoft/winget-pkgs" ]; then
echo "Configured fork must be $EXPECTED_OWNER/winget-pkgs forked from microsoft/winget-pkgs" >&2
exit 1
fi

echo "Validated fork: $fork_name"

- name: Submit update to Winget
uses: vedantmgoyal9/winget-releaser@v2
with:
identifier: ${{ env.WINGET_PACKAGE_IDENTIFIER }}
version: ${{ steps.release_asset.outputs.version }}
release-tag: ${{ github.event.release.tag_name }}
installers-regex: ${{ steps.release_asset.outputs.asset_regex }}
fork-user: ${{ env.WINGET_FORK_OWNER }}
token: ${{ secrets.WINGET_GITHUB_TOKEN }}
40 changes: 40 additions & 0 deletions docs/guides/winget-release-automation.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
# Winget release automation

CodeNomad publishes Winget updates from GitHub Releases with `.github/workflows/update-winget.yml`.

## Trigger

- Runs on `release.published`.
- Exits early for draft or prerelease releases.
- Waits for the expected stable Windows Tauri asset because the release record can exist before all assets finish uploading.

## Required configuration

### Repository secret

- `WINGET_GITHUB_TOKEN`: Classic GitHub PAT with `public_repo` scope.
- The token owner must own the fork that submits to `microsoft/winget-pkgs`.
- Komac-based submission cannot open the PR with a fine-grained token today.

### Repository variables

- `WINGET_PACKAGE_IDENTIFIER` (default fallback in workflow: `NeuralNomadsAI.CodeNomad`)
- `WINGET_FORK_OWNER` (default fallback in workflow: `pascalandr`)
- `WINGET_WINDOWS_ASSET_NAME_TEMPLATE` (default fallback in workflow: `CodeNomad-Tauri-windows-x64-{version}.zip`)
- `WINGET_ASSET_WAIT_TIMEOUT_SECONDS` (optional, default fallback: `900`)
- `WINGET_ASSET_POLL_INTERVAL_SECONDS` (optional, default fallback: `15`)

`{version}` is replaced from the release tag after trimming a leading `v`.

## Runtime flow

1. Resolve the release version from `github.event.release.tag_name`.
2. Poll the release API until exactly one uploaded asset matches the configured Windows Tauri asset template.
3. Download the matched asset once and compute a SHA-256 for logging and verification.
4. Verify the PAT owner matches `WINGET_FORK_OWNER` and that `${WINGET_FORK_OWNER}/winget-pkgs` is a fork of `microsoft/winget-pkgs`.
5. Invoke `vedantmgoyal9/winget-releaser@v2`, which uses Komac under the hood to update the existing `NeuralNomadsAI.CodeNomad` manifest and open the PR.

## Notes

- The workflow does not depend on a persistent local `winget-pkgs` clone.
- The current package in `winget-pkgs` uses the release ZIP with a nested NSIS installer, so the workflow targets the stable `CodeNomad-Tauri-windows-x64-{version}.zip` asset.
201 changes: 201 additions & 0 deletions scripts/winget/resolve-release-asset.cjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,201 @@
#!/usr/bin/env node

const crypto = require("node:crypto")
const fs = require("node:fs")
const { Readable } = require("node:stream")

function printHelp() {
console.log(`Usage: node scripts/winget/resolve-release-asset.cjs [options]

Options:
--repo <owner/repo> GitHub repository that owns the release
--release-id <id> Numeric GitHub release id
--tag <tag> Release tag (used for version derivation)
--asset-name-template <template> Expected asset template, use {version} placeholder
--asset-regex <regex> Override exact-match template with a regex
--timeout-seconds <seconds> Total polling timeout (default: 900)
--poll-interval-seconds <seconds> Poll interval (default: 15)
--token <token> Optional GitHub token for release API calls
--github-output <path> Write GitHub Actions outputs to this file
--json Print JSON result to stdout
--help Show this message
`)
}

function parseArgs(argv) {
const args = {}
for (let index = 0; index < argv.length; index += 1) {
const arg = argv[index]
if (arg === "--help") {
args.help = true
continue
}
if (arg === "--json") {
args.json = true
continue
}
if (!arg.startsWith("--")) {
throw new Error(`Unexpected argument: ${arg}`)
}
const key = arg.slice(2)
const value = argv[index + 1]
if (!value || value.startsWith("--")) {
throw new Error(`Missing value for --${key}`)
}
args[key] = value
index += 1
}
return args
}

function sleep(ms) {
return new Promise((resolve) => setTimeout(resolve, ms))
}

function escapeRegex(value) {
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")
}

function normalizeVersionFromTag(tag) {
if (!tag) {
throw new Error("A release tag is required to derive the package version")
}
return tag.startsWith("v") ? tag.slice(1) : tag
}

function buildMatcher({ version, assetNameTemplate, assetRegex }) {
if (assetRegex) {
return {
description: assetRegex,
regex: new RegExp(assetRegex),
}
}

const template = assetNameTemplate || "CodeNomad-Tauri-windows-x64-{version}.zip"
if (!template.includes("{version}")) {
throw new Error("asset-name-template must include the {version} placeholder")
}

const expectedName = template.replaceAll("{version}", version)

return {
description: expectedName,
regex: new RegExp(`^${escapeRegex(expectedName)}$`),
}
}

async function githubJson(url, token) {
const response = await fetch(url, {
headers: {
Accept: "application/vnd.github+json",
"User-Agent": "CodeNomad-winget-release-automation",
...(token ? { Authorization: `Bearer ${token}` } : {}),
},
})

if (!response.ok) {
const body = await response.text()
throw new Error(`GitHub API request failed (${response.status}): ${body}`)
}

return response.json()
}

async function downloadAndHash(url) {
const response = await fetch(url)
if (!response.ok || !response.body) {
throw new Error(`Asset download failed (${response.status})`)
}

const hash = crypto.createHash("sha256")
const stream = Readable.fromWeb(response.body)

for await (const chunk of stream) {
hash.update(chunk)
}

return hash.digest("hex").toUpperCase()
}

async function main() {
const args = parseArgs(process.argv.slice(2))
if (args.help) {
printHelp()
return
}

const repo = args.repo
const releaseId = args["release-id"]
const tag = args.tag
const timeoutSeconds = Number(args["timeout-seconds"] || 900)
const pollIntervalSeconds = Number(args["poll-interval-seconds"] || 15)

if (!repo || !releaseId || !tag) {
throw new Error("--repo, --release-id, and --tag are required")
}

const version = normalizeVersionFromTag(tag)
const matcher = buildMatcher({
version,
assetNameTemplate: args["asset-name-template"],
assetRegex: args["asset-regex"],
})

const deadline = Date.now() + timeoutSeconds * 1000
const releaseUrl = `https://api.github.com/repos/${repo}/releases/${releaseId}`
const token = args.token || process.env.GITHUB_TOKEN || ""

let release
let asset

while (Date.now() <= deadline) {
release = await githubJson(releaseUrl, token)
const matches = (release.assets || []).filter(
(candidate) => matcher.regex.test(candidate.name) && candidate.state === "uploaded" && candidate.size > 0,
)

if (matches.length === 1) {
asset = matches[0]
break
}

if (matches.length > 1) {
throw new Error(`Matched multiple assets for ${matcher.description}: ${matches.map((item) => item.name).join(", ")}`)
}

const visibleAssets = (release.assets || []).map((item) => item.name).join(", ") || "no assets"
console.error(`Waiting for release asset ${matcher.description} on ${repo}@${tag}; currently saw ${visibleAssets}`)
await sleep(pollIntervalSeconds * 1000)
}

if (!asset || !release) {
throw new Error(`Timed out after ${timeoutSeconds}s waiting for release asset ${matcher.description}`)
}

const sha256 = await downloadAndHash(asset.browser_download_url)
const result = {
asset_name: asset.name,
asset_regex: matcher.regex.source,
asset_sha256: sha256,
asset_url: asset.browser_download_url,
release_url: release.html_url,
tag_name: release.tag_name,
version,
}

if (args["github-output"]) {
const lines = Object.entries(result).map(([key, value]) => `${key}=${value}`)
await fs.promises.appendFile(args["github-output"], `${lines.join("\n")}\n`, "utf8")
}

if (args.json) {
console.log(JSON.stringify(result, null, 2))
} else {
console.error(`Resolved ${result.asset_name} (${result.asset_sha256})`)
}
}

main().catch((error) => {
console.error(error instanceof Error ? error.message : String(error))
process.exitCode = 1
})
Loading