From 3ae42f22015352d8fd0c874ac9223d45480d7d28 Mon Sep 17 00:00:00 2001 From: Adam Tagscherer Date: Thu, 3 Sep 2026 11:56:23 +0200 Subject: [PATCH] ci: render and validate WinGet manifests on release Signed-off-by: Adam Tagscherer --- .github/workflows/release-binaries.yaml | 13 + .github/workflows/winget-manifests.yaml | 232 +++++++++++++++++ scripts/render_winget_manifests.py | 314 ++++++++++++++++++++++++ 3 files changed, 559 insertions(+) create mode 100644 .github/workflows/winget-manifests.yaml create mode 100644 scripts/render_winget_manifests.py diff --git a/.github/workflows/release-binaries.yaml b/.github/workflows/release-binaries.yaml index e2bf273..e80ed1c 100644 --- a/.github/workflows/release-binaries.yaml +++ b/.github/workflows/release-binaries.yaml @@ -87,3 +87,16 @@ jobs: uses: ./.github/workflows/homebrew-formula.yaml with: tag: ${{ github.event.release.tag_name }} + + winget-manifests: + name: WinGet manifests + needs: build-binaries + permissions: + contents: read + uses: ./.github/workflows/winget-manifests.yaml + with: + release_tag: ${{ github.event.release.tag_name }} + publish: true + secrets: + PROJECT_APP_ID: ${{ secrets.PROJECT_APP_ID }} + PROJECT_APP_KEY: ${{ secrets.PROJECT_APP_KEY }} diff --git a/.github/workflows/winget-manifests.yaml b/.github/workflows/winget-manifests.yaml new file mode 100644 index 0000000..21efe0a --- /dev/null +++ b/.github/workflows/winget-manifests.yaml @@ -0,0 +1,232 @@ +# Copyright AI-Catalog Contributors (https://github.com/Agent-Card/ai-catalog-cli) +# Copyright AGNTCY Contributors (https://github.com/agntcy) +# SPDX-License-Identifier: Apache-2.0 + +--- +name: winget-manifests + +on: + workflow_call: + inputs: + release_tag: + description: Release tag to render WinGet manifests for + required: true + type: string + publish: + description: Push the manifests to the winget-pkgs fork + required: false + type: boolean + default: false + secrets: + PROJECT_APP_ID: + required: false + PROJECT_APP_KEY: + required: false + workflow_dispatch: + inputs: + release_tag: + description: Release tag to render WinGet manifests for + required: true + type: string + publish: + description: Push the manifests to the winget-pkgs fork + required: false + type: boolean + default: false + +concurrency: + group: ${{ github.workflow }}-${{ inputs.release_tag }} + cancel-in-progress: false + +jobs: + render: + name: Render manifests + runs-on: ubuntu-latest + permissions: + contents: read + outputs: + manifest_rel_dir: ${{ steps.render.outputs.manifest_rel_dir }} + package_identifier: ${{ steps.render.outputs.package_identifier }} + package_version: ${{ steps.render.outputs.package_version }} + release_url: ${{ steps.render.outputs.release_url }} + steps: + - name: Checkout repository + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + + - name: Setup Python + uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 + with: + python-version: "3.12" + + - name: Render WinGet manifests + id: render + env: + GITHUB_TOKEN: ${{ github.token }} + RELEASE_TAG: ${{ inputs.release_tag }} + run: | + python3 scripts/render_winget_manifests.py \ + --tag "$RELEASE_TAG" \ + --output-dir dist/winget \ + --github-output "$GITHUB_OUTPUT" + + - name: Upload manifests + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: winget-manifests-${{ steps.render.outputs.package_version }} + path: dist/winget/manifests + + validate: + name: Validate manifests + needs: render + runs-on: windows-2025 + permissions: + contents: read + steps: + - name: Download rendered manifests + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: winget-manifests-${{ needs.render.outputs.package_version }} + path: manifests + + - name: Validate and install from the manifest + shell: pwsh + env: + MANIFEST_REL_DIR: ${{ needs.render.outputs.manifest_rel_dir }} + run: | + function Assert-LastExit($what) { + if ($LASTEXITCODE -ne 0) { throw "$what failed with exit $LASTEXITCODE" } + } + + $dir = Join-Path "manifests" ($env:MANIFEST_REL_DIR -replace '^manifests/', '') + Get-ChildItem $dir | ForEach-Object { Write-Host $_.Name } + + winget --version + winget validate --manifest $dir --ignore-warnings + Assert-LastExit "winget validate" + + winget settings --enable LocalManifestFiles + Assert-LastExit "winget settings --enable LocalManifestFiles" + + winget install --manifest $dir ` + --accept-package-agreements --accept-source-agreements --disable-interactivity + Assert-LastExit "winget install" + + $env:PATH = "$env:LOCALAPPDATA\Microsoft\WinGet\Links;$env:SystemRoot\System32;$env:SystemRoot" + ai-catalog --help + Assert-LastExit "ai-catalog --help" + Write-Host "OK - installed via winget and the binary runs" + + publish: + name: Publish to winget-pkgs + needs: [render, validate] + if: inputs.publish == true || inputs.publish == 'true' + runs-on: ubuntu-latest + permissions: + contents: read + env: + APP_AUTH_CONFIGURED: ${{ secrets.PROJECT_APP_ID != '' && secrets.PROJECT_APP_KEY != '' }} + steps: + - name: Download rendered manifests + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: winget-manifests-${{ needs.render.outputs.package_version }} + path: dist/winget/manifests + + - name: Authenticate as the organization bot + if: env.APP_AUTH_CONFIGURED == 'true' + id: app-token + uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0 + with: + app-id: ${{ secrets.PROJECT_APP_ID }} + private-key: ${{ secrets.PROJECT_APP_KEY }} + owner: ${{ github.repository_owner }} + + - name: Resolve bot user id + if: ${{ steps.app-token.outputs.token != '' }} + id: app-user + env: + GH_TOKEN: ${{ steps.app-token.outputs.token }} + APP_SLUG: ${{ steps.app-token.outputs.app-slug }} + run: echo "user_id=$(gh api "/users/$APP_SLUG[bot]" --jq .id)" >> "$GITHUB_OUTPUT" + + - name: Push manifests to the winget-pkgs fork + env: + WINGET_PKGS_TOKEN: ${{ steps.app-token.outputs.token }} + FORK_OWNER: ${{ github.repository_owner }} + GIT_AUTHOR_NAME: ${{ steps.app-token.outputs.app-slug }}[bot] + GIT_AUTHOR_EMAIL: ${{ steps.app-user.outputs.user_id }}+${{ steps.app-token.outputs.app-slug }}[bot]@users.noreply.github.com + MANIFEST_DIR: ${{ github.workspace }}/dist/winget/${{ needs.render.outputs.manifest_rel_dir }} + MANIFEST_REL_DIR: ${{ needs.render.outputs.manifest_rel_dir }} + PACKAGE_IDENTIFIER: ${{ needs.render.outputs.package_identifier }} + PACKAGE_VERSION: ${{ needs.render.outputs.package_version }} + RELEASE_TAG: ${{ inputs.release_tag }} + RELEASE_URL: ${{ needs.render.outputs.release_url }} + run: | + if [ -z "$WINGET_PKGS_TOKEN" ]; then + echo "::notice::PROJECT_APP_ID / PROJECT_APP_KEY are not configured; the manifests are available as a workflow artifact only." + exit 0 + fi + + export GH_TOKEN="$WINGET_PKGS_TOKEN" + + if ! gh repo view "$FORK_OWNER/winget-pkgs" >/dev/null 2>&1; then + echo "::error::No fork at $FORK_OWNER/winget-pkgs. Fork microsoft/winget-pkgs into the organization before publishing." + exit 1 + fi + + gh auth setup-git >/dev/null + rm -rf /tmp/winget-pkgs + gh repo clone "$FORK_OWNER/winget-pkgs" /tmp/winget-pkgs -- --depth 1 + + cd /tmp/winget-pkgs + git remote add upstream https://github.com/microsoft/winget-pkgs.git 2>/dev/null || + git remote set-url upstream https://github.com/microsoft/winget-pkgs.git + git fetch --depth 1 upstream master + + branch="automation/winget-ai-catalog-$PACKAGE_VERSION" + git config user.name "$GIT_AUTHOR_NAME" + git config user.email "$GIT_AUTHOR_EMAIL" + git checkout -B "$branch" upstream/master + + if [ ! -d "$MANIFEST_DIR" ]; then + echo "::error::rendered manifests are not at $MANIFEST_DIR" + exit 1 + fi + + mkdir -p "$(dirname "$MANIFEST_REL_DIR")" + rm -rf "$MANIFEST_REL_DIR" + cp -R "$MANIFEST_DIR" "$(dirname "$MANIFEST_REL_DIR")/" + git add --all "$MANIFEST_REL_DIR" + + if git diff --cached --quiet -- "$MANIFEST_REL_DIR"; then + echo "WinGet manifests are already up to date for $RELEASE_TAG" + exit 0 + fi + + git commit -s -m "New version: $PACKAGE_IDENTIFIER version $PACKAGE_VERSION" + git push --force --set-upstream origin "$branch" + + pr_number=$(gh pr list --repo microsoft/winget-pkgs --head "$FORK_OWNER:$branch" \ + --base master --state open --json number --jq 'first(.[].number) // ""' 2>/dev/null || true) + + { + echo "### WinGet manifests for $PACKAGE_IDENTIFIER $PACKAGE_VERSION" + echo + if [ -n "$pr_number" ]; then + echo "Pushed \`$branch\`, updating the open upstream PR:" + echo + echo "https://github.com/microsoft/winget-pkgs/pull/$pr_number" + else + echo "Pushed \`$branch\`. Open the upstream PR to publish it:" + echo + echo "https://github.com/microsoft/winget-pkgs/compare/master...$FORK_OWNER:$branch?expand=1" + echo + echo "The App token cannot open that PR: an App only acts on" + echo "repositories it is installed on, and microsoft/winget-pkgs" + echo "is not one of them." + fi + echo + echo "Upstream release: $RELEASE_URL" + } >> "$GITHUB_STEP_SUMMARY" diff --git a/scripts/render_winget_manifests.py b/scripts/render_winget_manifests.py new file mode 100644 index 0000000..00806ec --- /dev/null +++ b/scripts/render_winget_manifests.py @@ -0,0 +1,314 @@ +#!/usr/bin/env python3 +# Copyright AI-Catalog Contributors (https://github.com/Agent-Card/ai-catalog-cli) +# Copyright AGNTCY Contributors (https://github.com/agntcy) +# SPDX-License-Identifier: Apache-2.0 + +from __future__ import annotations + +import argparse +import hashlib +import json +import os +import re +import shutil +import textwrap +import tomllib +import urllib.request +from dataclasses import dataclass +from pathlib import Path +from urllib.error import HTTPError, URLError + +ROOT = Path(__file__).resolve().parent.parent +MANIFEST = ROOT / "Cargo.toml" +DEFAULT_OUTPUT_DIR = ROOT / "dist" / "winget" + +MANIFEST_VERSION = "1.12.0" +PACKAGE_LOCALE = "en-US" +PACKAGE_IDENTIFIER = "AgentCard.AICatalog" +PACKAGE_NAME = "ai-catalog" +PUBLISHER = "Agent-Card" +PUBLISHER_URL = "https://github.com/Agent-Card" +AUTHOR = "AI-Catalog Contributors" +BINARY_NAME = "ai-catalog" +USER_AGENT = "ai-catalog-cli-release-automation" + +DEPENDENCIES = ["Microsoft.VCRedist.2015+.x64"] + +INSTALLERS = {"x64": "windows-amd64", "arm64": "windows-arm64"} + +TAG_PATTERN = re.compile(r"^v(?P[0-9A-Za-z.+-]+)$") +REPOSITORY_PATTERN = re.compile( + r"^https://github\.com/(?P[^/]+)/(?P[^/]+?)(?:\.git)?/?$" +) + + +@dataclass(frozen=True) +class Installer: + architecture: str + url: str + sha256: str + + +@dataclass(frozen=True) +class Release: + tag: str + version: str + date: str + url: str + installers: list[Installer] + + +def parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser( + description="Render WinGet manifests for a released CLI tag." + ) + parser.add_argument("--tag", required=True, help="Release tag, e.g. v0.2.2") + parser.add_argument( + "--output-dir", + type=Path, + default=DEFAULT_OUTPUT_DIR, + help=f"Output root for generated manifests (default: {DEFAULT_OUTPUT_DIR}).", + ) + parser.add_argument( + "--github-output", + type=Path, + help="Optional GitHub Actions output file to append manifest metadata to.", + ) + return parser.parse_args() + + +def github_headers() -> dict[str, str]: + headers = {"Accept": "application/vnd.github+json", "User-Agent": USER_AGENT} + for name in ("GITHUB_TOKEN", "GH_TOKEN"): + token = os.environ.get(name) + if token: + headers["Authorization"] = f"Bearer {token}" + break + return headers + + +def fetch(url: str) -> urllib.request.addinfourl: + request = urllib.request.Request(url, headers=github_headers()) + try: + return urllib.request.urlopen(request, timeout=60) + except URLError as error: + reason = f"HTTP {error.code}" if isinstance(error, HTTPError) else error.reason + raise SystemExit(f"failed to fetch {url}: {reason}") from error + + +def fetch_json(url: str) -> dict: + with fetch(url) as response: + return json.loads(response.read()) + + +def hash_asset(url: str) -> str: + digest = hashlib.sha256() + with fetch(url) as response: + while chunk := response.read(1024 * 1024): + digest.update(chunk) + return digest.hexdigest().upper() + + +def repository_slug(repository_url: str) -> str: + match = REPOSITORY_PATTERN.match(repository_url) + if not match: + raise SystemExit(f"expected GitHub repository URL, got: {repository_url}") + return f"{match.group('owner')}/{match.group('repo')}" + + +def resolve_release(tag: str, slug: str) -> Release: + match = TAG_PATTERN.match(tag) + if not match: + raise SystemExit(f"expected {TAG_PATTERN.pattern} tag, got: {tag}") + + release = fetch_json(f"https://api.github.com/repos/{slug}/releases/tags/{tag}") + assets = { + asset["name"]: asset["browser_download_url"] + for asset in release.get("assets", []) + } + + installers = [] + for architecture, archive in INSTALLERS.items(): + installer_url = assets.get(f"{BINARY_NAME}-{archive}.zip") + if installer_url is None: + raise SystemExit( + f"release {tag} does not contain the expected Windows asset " + f"{BINARY_NAME}-{archive}.zip" + ) + installers.append( + Installer( + architecture=architecture, + url=installer_url, + sha256=asset_sha256(assets, archive, installer_url), + ) + ) + + date = release.get("published_at") or release.get("created_at") + if date is None: + raise SystemExit(f"release {tag} does not expose a publication date") + + return Release( + tag=tag, + version=match.group("version"), + date=date[:10], + url=release["html_url"], + installers=installers, + ) + + +def asset_sha256(assets: dict[str, str], archive: str, installer_url: str) -> str: + """Prefer the digest the release published over re-hashing the archive.""" + checksum_url = assets.get(f"{BINARY_NAME}-{archive}.sha256") + if checksum_url is None: + return hash_asset(installer_url) + with fetch(checksum_url) as response: + fields = response.read().decode("utf-8").split() + if not fields: + raise SystemExit(f"checksum asset for {archive} is empty") + return fields[0].upper() + + +def render_version_manifest(release: Release) -> str: + return textwrap.dedent( + f"""\ + # yaml-language-server: $schema=https://aka.ms/winget-manifest.version.{MANIFEST_VERSION}.schema.json + + PackageIdentifier: {PACKAGE_IDENTIFIER} + PackageVersion: {release.version} + DefaultLocale: {PACKAGE_LOCALE} + ManifestType: version + ManifestVersion: {MANIFEST_VERSION} + """ + ) + + +def render_locale_manifest( + release: Release, + description: str, + repository_url: str, + license_id: str, + tags: list[str], +) -> str: + tag_block = "\n".join(["Tags:", *(f"- {tag}" for tag in tags)]) + template = textwrap.dedent( + f"""\ + # yaml-language-server: $schema=https://aka.ms/winget-manifest.defaultLocale.{MANIFEST_VERSION}.schema.json + + PackageIdentifier: {PACKAGE_IDENTIFIER} + PackageVersion: {release.version} + PackageLocale: {PACKAGE_LOCALE} + Publisher: {PUBLISHER} + PublisherUrl: {PUBLISHER_URL} + PublisherSupportUrl: {repository_url}/issues + Author: {AUTHOR} + PackageName: {PACKAGE_NAME} + PackageUrl: {repository_url} + License: {license_id} + LicenseUrl: {repository_url}/blob/{release.tag}/LICENSE + ShortDescription: {description} + Moniker: {BINARY_NAME} + __TAGS__ + Documentations: + - DocumentLabel: Documentation + DocumentUrl: {repository_url}#readme + ReleaseNotesUrl: {repository_url}/releases/tag/{release.tag} + ManifestType: defaultLocale + ManifestVersion: {MANIFEST_VERSION} + """ + ) + return template.replace("__TAGS__", tag_block) + + +def render_installer_manifest(release: Release) -> str: + dependencies = "".join( + f" - PackageIdentifier: {name}\n" for name in DEPENDENCIES + ) + installers = "".join( + f"- Architecture: {installer.architecture}\n" + f" InstallerUrl: {installer.url}\n" + f" InstallerSha256: {installer.sha256}\n" + f" NestedInstallerFiles:\n" + f" - RelativeFilePath: {BINARY_NAME}.exe\n" + f" PortableCommandAlias: {BINARY_NAME}\n" + for installer in release.installers + ) + template = textwrap.dedent( + f"""\ + # yaml-language-server: $schema=https://aka.ms/winget-manifest.installer.{MANIFEST_VERSION}.schema.json + + PackageIdentifier: {PACKAGE_IDENTIFIER} + PackageVersion: {release.version} + InstallerType: zip + NestedInstallerType: portable + Commands: + - {BINARY_NAME} + ReleaseDate: {release.date} + Dependencies: + PackageDependencies: + __DEPENDENCIES__Installers: + __INSTALLERS__ManifestType: installer + ManifestVersion: {MANIFEST_VERSION} + """ + ) + return template.replace("__DEPENDENCIES__", dependencies).replace( + "__INSTALLERS__", installers + ) + + +def manifest_directory(output_dir: Path, version: str) -> Path: + parts = PACKAGE_IDENTIFIER.split(".") + return output_dir / "manifests" / parts[0][0].lower() / Path(*parts) / version + + +def main() -> int: + args = parse_args() + + with MANIFEST.open("rb") as handle: + package = tomllib.load(handle)["package"] + + repository_url = package["repository"].rstrip("/") + release = resolve_release(args.tag, repository_slug(repository_url)) + + output_dir = args.output_dir.resolve() + manifests_dir = manifest_directory(output_dir, release.version) + if manifests_dir.exists(): + shutil.rmtree(manifests_dir) + manifests_dir.mkdir(parents=True, exist_ok=True) + + (manifests_dir / f"{PACKAGE_IDENTIFIER}.yaml").write_text( + render_version_manifest(release), encoding="utf-8" + ) + (manifests_dir / f"{PACKAGE_IDENTIFIER}.locale.{PACKAGE_LOCALE}.yaml").write_text( + render_locale_manifest( + release, + description=package["description"], + repository_url=repository_url, + license_id=package["license"], + tags=package["keywords"], + ), + encoding="utf-8", + ) + (manifests_dir / f"{PACKAGE_IDENTIFIER}.installer.yaml").write_text( + render_installer_manifest(release), encoding="utf-8" + ) + + outputs = { + "manifest_dir": str(manifests_dir), + "manifest_rel_dir": manifests_dir.relative_to(output_dir).as_posix(), + "package_identifier": PACKAGE_IDENTIFIER, + "package_version": release.version, + "release_url": release.url, + } + if args.github_output is not None: + with args.github_output.open("a", encoding="utf-8") as handle: + for key, value in outputs.items(): + handle.write(f"{key}={value}\n") + + for key, value in outputs.items(): + print(f"{key}={value}") + + return 0 + + +if __name__ == "__main__": + raise SystemExit(main())