chore: TASK-075 automate Winget updates on release (#513)

## Summary
- add a release-published workflow that prepares and submits Winget
manifest updates automatically
- poll the GitHub Release API for the stable Windows Tauri asset and
compute its SHA-256 before submission
- document the maintainer secret and repository variables needed for the
Winget automation flow

## Validation
- `node --check "scripts/winget/resolve-release-asset.cjs"`
- `node "scripts/winget/resolve-release-asset.cjs" --help`
- dry-run resolver against the published `v0.16.0` release asset

## Notes
- skips draft and prerelease GitHub releases
- uses the maintainer fork submission flow for `microsoft/winget-pkgs`
- live PR submission still depends on configuring `WINGET_GITHUB_TOKEN`
- Fixes #462
This commit is contained in:
Pascal André 2026-06-03 09:03:46 +02:00 committed by GitHub
parent b3594d29e5
commit 37a8621063
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 326 additions and 0 deletions

85
.github/workflows/update-winget.yml vendored Normal file
View file

@ -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 }}

View file

@ -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.

View file

@ -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
})