mirror of
https://github.com/NeuralNomadsAI/CodeNomad.git
synced 2026-07-09 16:00:52 +00:00
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:
parent
b3594d29e5
commit
37a8621063
3 changed files with 326 additions and 0 deletions
85
.github/workflows/update-winget.yml
vendored
Normal file
85
.github/workflows/update-winget.yml
vendored
Normal 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 }}
|
||||
40
docs/guides/winget-release-automation.md
Normal file
40
docs/guides/winget-release-automation.md
Normal 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.
|
||||
201
scripts/winget/resolve-release-asset.cjs
Normal file
201
scripts/winget/resolve-release-asset.cjs
Normal 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
|
||||
})
|
||||
Loading…
Add table
Add a link
Reference in a new issue