feat(release): add WinGet publishing

This commit is contained in:
Darkatse 2026-08-07 00:55:20 -04:00
parent 008ab07af3
commit 79b5112411
8 changed files with 365 additions and 1 deletions

View file

@ -469,6 +469,16 @@ jobs:
--repo "$GITHUB_REPOSITORY" \
--clobber
publish-winget:
needs: [prepare, publish-release]
permissions:
contents: read
uses: ./.github/workflows/winget-release.yml
with:
release_tag: ${{ needs.prepare.outputs.tag }}
secrets:
WINGET_CREATE_GITHUB_TOKEN: ${{ secrets.WINGET_CREATE_GITHUB_TOKEN }}
publish-package-repositories:
needs: [prepare, publish-release]
continue-on-error: true

222
.github/workflows/winget-release.yml vendored Normal file
View file

@ -0,0 +1,222 @@
name: WinGet Release
on:
workflow_call:
inputs:
release_tag:
description: Existing stable release tag, for example v2.2.0
required: true
type: string
secrets:
WINGET_CREATE_GITHUB_TOKEN:
required: true
workflow_dispatch:
inputs:
release_tag:
description: Existing stable release tag, for example v2.2.0
required: true
type: string
concurrency:
group: winget-release-${{ inputs.release_tag }}
cancel-in-progress: false
permissions:
contents: read
env:
PACKAGE_ID: TauriTavern.TauriTavern
WINGETCREATE_URL: https://github.com/microsoft/winget-create/releases/download/v1.12.13.0/wingetcreate.exe
WINGETCREATE_SHA256: 24042bd37915805615e6cf969ac57c6439124c3fe85823327f5f3fb24bd9ffea
jobs:
publish:
runs-on: windows-latest
timeout-minutes: 20
steps:
- uses: actions/checkout@v4
with:
ref: ${{ inputs.release_tag }}
persist-credentials: false
- uses: actions/setup-node@v4
with:
node-version: 22
- name: Resolve the completed stable release
id: release
shell: pwsh
env:
GH_TOKEN: ${{ github.token }}
RELEASE_TAG: ${{ inputs.release_tag }}
run: |
$version = (& node scripts/ci/verify-release-version.mjs $env:RELEASE_TAG).Trim()
if ($LASTEXITCODE -ne 0) {
throw "Release version validation failed for $env:RELEASE_TAG"
}
$releaseJson = gh api "repos/$env:GITHUB_REPOSITORY/releases/tags/$env:RELEASE_TAG"
if ($LASTEXITCODE -ne 0) {
throw "Unable to read release $env:RELEASE_TAG"
}
$release = $releaseJson | ConvertFrom-Json
if ($release.tag_name -cne $env:RELEASE_TAG -or $release.draft -or $release.prerelease) {
throw "$env:RELEASE_TAG is not a published stable release"
}
$assetName = "TauriTavern-$version-windows-x64-setup.exe"
$assets = @($release.assets | Where-Object { $_.name -ceq $assetName })
if ($assets.Count -ne 1 -or $assets[0].state -ne "uploaded") {
throw "Expected exactly one completed release asset named $assetName"
}
$releaseDate = [DateTimeOffset]::Parse($release.published_at).ToString("yyyy-MM-dd")
"version=$version" >> $env:GITHUB_OUTPUT
"installer_url=$($assets[0].browser_download_url)" >> $env:GITHUB_OUTPUT
"release_date=$releaseDate" >> $env:GITHUB_OUTPUT
"release_url=$($release.html_url)" >> $env:GITHUB_OUTPUT
- name: Check whether publication is already in progress or complete
id: preflight
shell: pwsh
env:
GH_TOKEN: ${{ github.token }}
VERSION: ${{ steps.release.outputs.version }}
run: |
function Test-GitHubPath([string] $Path) {
$response = gh api $Path 2>&1
if ($LASTEXITCODE -eq 0) {
return $true
}
$message = $response -join "`n"
if ($message -match "HTTP 404") {
return $false
}
throw "GitHub API request failed for ${Path}: $message"
}
$packagePath = "manifests/t/TauriTavern/TauriTavern"
$versionPath = "repos/microsoft/winget-pkgs/contents/$packagePath/$env:VERSION"
if (Test-GitHubPath $versionPath) {
"skip=true" >> $env:GITHUB_OUTPUT
"reason=$env:PACKAGE_ID $env:VERSION is already published" >> $env:GITHUB_OUTPUT
exit 0
}
$query = "repo:microsoft/winget-pkgs is:pr is:open `"$env:PACKAGE_ID`" `"$env:VERSION`""
$searchJson = gh api --method GET search/issues --raw-field "q=$query"
if ($LASTEXITCODE -ne 0) {
throw "Unable to search for an existing WinGet pull request"
}
$search = $searchJson | ConvertFrom-Json
if ($search.total_count -gt 0) {
"skip=true" >> $env:GITHUB_OUTPUT
"reason=An open WinGet pull request already exists: $($search.items[0].html_url)" >> $env:GITHUB_OUTPUT
exit 0
}
$packageRoot = "repos/microsoft/winget-pkgs/contents/$packagePath"
if (-not (Test-GitHubPath $packageRoot)) {
throw "$env:PACKAGE_ID has no merged seed manifest. Complete the one-time bootstrap documented in docs/CurrentState/WindowsDistribution.md"
}
"skip=false" >> $env:GITHUB_OUTPUT
- name: Download verified WingetCreate
if: steps.preflight.outputs.skip != 'true'
id: wingetcreate
shell: pwsh
run: |
$toolPath = Join-Path $env:RUNNER_TEMP "wingetcreate.exe"
Invoke-WebRequest -Uri $env:WINGETCREATE_URL -OutFile $toolPath
$actualHash = (Get-FileHash -LiteralPath $toolPath -Algorithm SHA256).Hash.ToLowerInvariant()
if ($actualHash -cne $env:WINGETCREATE_SHA256) {
throw "WingetCreate SHA256 mismatch: expected $env:WINGETCREATE_SHA256, received $actualHash"
}
"path=$toolPath" >> $env:GITHUB_OUTPUT
- name: Generate manifest update
if: steps.preflight.outputs.skip != 'true'
id: manifest
shell: pwsh
env:
INSTALLER_URL: ${{ steps.release.outputs.installer_url }}
RELEASE_DATE: ${{ steps.release.outputs.release_date }}
RELEASE_URL: ${{ steps.release.outputs.release_url }}
VERSION: ${{ steps.release.outputs.version }}
WINGETCREATE_PATH: ${{ steps.wingetcreate.outputs.path }}
run: |
$arguments = @(
"update",
$env:PACKAGE_ID,
"--version", $env:VERSION,
"--urls", "$env:INSTALLER_URL|x64|user",
"--release-date", $env:RELEASE_DATE,
"--release-notes-url", $env:RELEASE_URL,
"--out", "manifests"
)
& $env:WINGETCREATE_PATH @arguments
if ($LASTEXITCODE -ne 0) {
throw "WingetCreate failed to generate the manifest update"
}
$installerManifests = @(
Get-ChildItem -LiteralPath "manifests" -Recurse -File -Filter "$env:PACKAGE_ID.installer.yaml"
)
if ($installerManifests.Count -ne 1) {
throw "Expected exactly one generated installer manifest"
}
"directory=$($installerManifests[0].Directory.FullName)" >> $env:GITHUB_OUTPUT
- name: Archive generated manifests
if: steps.preflight.outputs.skip != 'true'
uses: actions/upload-artifact@v4
with:
name: winget-manifest-${{ steps.release.outputs.version }}
path: ${{ steps.manifest.outputs.directory }}
if-no-files-found: error
retention-days: 30
- name: Submit manifest update
if: steps.preflight.outputs.skip != 'true'
shell: pwsh
env:
MANIFEST_DIRECTORY: ${{ steps.manifest.outputs.directory }}
WINGETCREATE_PATH: ${{ steps.wingetcreate.outputs.path }}
WINGET_CREATE_GITHUB_TOKEN: ${{ secrets.WINGET_CREATE_GITHUB_TOKEN }}
run: |
if ([string]::IsNullOrWhiteSpace($env:WINGET_CREATE_GITHUB_TOKEN)) {
throw "WINGET_CREATE_GITHUB_TOKEN is not configured"
}
& $env:WINGETCREATE_PATH submit $env:MANIFEST_DIRECTORY --no-open
if ($LASTEXITCODE -ne 0) {
throw "WingetCreate failed to submit the manifest update"
}
- name: Summarize WinGet publication
if: always()
shell: pwsh
env:
REASON: ${{ steps.preflight.outputs.reason }}
SKIPPED: ${{ steps.preflight.outputs.skip }}
VERSION: ${{ steps.release.outputs.version }}
run: |
if ($env:SKIPPED -eq "true") {
$status = $env:REASON
} elseif ("${{ job.status }}" -eq "success") {
$status = "Manifest generated, archived, and submitted"
} else {
$status = "Publication failed before completion"
}
@"
## WinGet release
- Package: ``$env:PACKAGE_ID``
- Version: ``$env:VERSION``
- Result: $status
"@ >> $env:GITHUB_STEP_SUMMARY

View file

@ -11,5 +11,9 @@
Flatpak。
- `nix-cache/publish.sh`:校验 Nix cache key,签署 store closure 并发布缓存对象。
WinGet 的 canonical manifest 位于外部 `microsoft/winget-pkgs` 仓库,因此本目录不
保存它的副本;Stable Release 通过 `.github/workflows/winget-release.yml` 生成并
提交版本更新。详细契约见 `docs/CurrentState/WindowsDistribution.md`。
这些工具由 CI 调用,并在执行前校验所需环境变量和外部命令。用户安装入口仍为
`scripts/install-linux.sh`。

View file

@ -90,3 +90,7 @@
19. `docs/CurrentState/LinuxRepository.md`
- APT、RPM 与 Nix 的分发现状
- 包含支持范围、签名身份、安装入口、缓存配置和维护边界
20. `docs/CurrentState/WindowsDistribution.md`
- WinGet Stable 发布链路与 Windows installer 契约
- 包含 Package ID、幂等更新、一次性 seed manifest 与凭据维护边界

View file

@ -0,0 +1,52 @@
# Windows 分发现状
TauriTavern 的 Windows Stable Release 以 GitHub Release 为唯一安装包来源。WinGet
只发布指向该 Release 的元数据,不重新构建或托管二份安装包。
## WinGet 契约
- Package ID:`TauriTavern.TauriTavern`
- 安装包:`TauriTavern-<version>-windows-x64-setup.exe`
- 类型:NSIS(WinGet manifest 中为 `nullsoft`)
- 架构与范围:`x64`、`user`
- 静默参数:`/S`;带进度的无人值守参数:`/P`
- 升级行为:`install`
- 仅发布 Stable;Canary、MSI 和 portable zip 不进入这个 Package ID
用户目录安装与当前 NSIS 默认行为一致。Portable 版本依赖 Scoop 的 `persist`
语义,不能把它等价映射为 WinGet portable package。
## 发布链路
`.github/workflows/stable-release.yml` 在全部 Release 资产上传成功后调用
`.github/workflows/winget-release.yml`。后者:
1. 校验 tag、仓库版本与已发布的非 prerelease Release 一致;
2. 精确选择一个 x64 NSIS setup 资产;
3. 若目标版本已合入或已有开放 PR,则幂等成功;
4. 下载固定版本的 WingetCreate 并校验 SHA256;
5. 从官方仓库中的上一版 manifest 生成更新,先上传为 Actions artifact,再提交 PR。
工作流可以按 `release_tag` 手动运行,因此 WinGet 失败时只重试 manifest 发布,
不需要重新构建 Stable Release。Stable 的资产上传仍保留 `--clobber`;发布契约是
每个 Stable tag 只构建、上传一次,WinGet 始终读取上传完成后的最终资产。
## 一次性启用
自动更新依赖官方 `microsoft/winget-pkgs` 仓库中已有 Package ID。首次启用需要:
1. 以一个已经发布的 Stable 版本提交并合入
`TauriTavern.TauriTavern` multi-file seed manifest;
2. 确认 seed manifest 满足上面的 installer 契约,并在真实 Windows 用户环境验证
install、silent install、upgrade 与 uninstall;
3. 使用完成 Microsoft CLA 的专用 GitHub 账号创建 classic PAT,仅授予
`public_repo`,保存为仓库 secret `WINGET_CREATE_GITHUB_TOKEN`;
4. 手动运行 `WinGet Release` 验证更新 PR,再让后续 Stable 自动调用。
seed manifest 合入并同步到 WinGet source 后,安装命令为:
```powershell
winget install --id TauriTavern.TauriTavern --exact
```
在官方 source 尚未可查询前,不应把该命令加入面向用户的 README。

View file

@ -84,7 +84,9 @@ test('Linux installer test paths use the shell filesystem namespace', () => {
assert.equal(shellPath('/tmp/os-release', 'linux'), '/tmp/os-release');
});
test('Linux installer is syntactically valid in available common shells', () => {
test('Linux installer is syntactically valid in available common shells', {
skip: process.platform === 'win32' ? 'requires POSIX filesystem paths' : false,
}, () => {
const shells = ['sh', 'dash', 'bash', 'zsh'];
const availableShells = shells.filter(commandExists);

View file

@ -18,6 +18,7 @@ test('stable release workflow starts from a published release or an explicit tag
test('stable release workflow preserves manually written release notes', () => {
assert.doesNotMatch(JSON.stringify(workflow.jobs['publish-release']), /codex|release edit|notes-file/i);
assert.match(workflowSource, /Upload assets without changing release notes/);
assert.match(JSON.stringify(workflow.jobs['publish-release']), /--clobber/);
});
test('stable release builds Windows and macOS debug installers in parallel', () => {
@ -54,6 +55,18 @@ test('stable release workflow publishes release assets before optional repositor
assert.equal(flatpak['continue-on-error'], true);
});
test('stable release publishes WinGet only after the release assets are complete', () => {
const winget = workflow.jobs['publish-winget'];
assert.deepEqual(winget.needs, ['prepare', 'publish-release']);
assert.equal(winget.uses, './.github/workflows/winget-release.yml');
assert.equal(winget.with.release_tag, '${{ needs.prepare.outputs.tag }}');
assert.equal(
winget.secrets.WINGET_CREATE_GITHUB_TOKEN,
'${{ secrets.WINGET_CREATE_GITHUB_TOKEN }}',
);
});
test('stable release workflow keeps repository credentials in GitHub secrets', () => {
assert.match(workflowSource, /secrets\.R2_ACCESS_KEY_ID/);
assert.match(workflowSource, /secrets\.R2_SECRET_ACCESS_KEY/);
@ -61,6 +74,7 @@ test('stable release workflow keeps repository credentials in GitHub secrets', (
assert.match(workflowSource, /secrets\.NIX_CACHE_PRIVATE_KEY_BASE64/);
assert.match(workflowSource, /secrets\.FLATPAK_R2_ACCESS_KEY_ID/);
assert.match(workflowSource, /secrets\.FLATPAK_R2_SECRET_ACCESS_KEY/);
assert.match(workflowSource, /secrets\.WINGET_CREATE_GITHUB_TOKEN/);
assert.doesNotMatch(workflowSource, /BEGIN (?:PGP|OPENSSH|PRIVATE) PRIVATE KEY/);
});

View file

@ -0,0 +1,56 @@
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import test from 'node:test';
import YAML from 'yaml';
const workflowPath = '.github/workflows/winget-release.yml';
const workflowSource = readFileSync(workflowPath, 'utf8');
const workflow = YAML.parse(workflowSource);
const publish = workflow.jobs.publish;
test('WinGet publication is reusable and manually retryable by stable tag', () => {
for (const trigger of ['workflow_call', 'workflow_dispatch']) {
assert.equal(workflow.on[trigger].inputs.release_tag.required, true);
assert.equal(workflow.on[trigger].inputs.release_tag.type, 'string');
}
assert.equal(workflow.on.workflow_call.secrets.WINGET_CREATE_GITHUB_TOKEN.required, true);
assert.equal(workflow.concurrency['cancel-in-progress'], false);
});
test('WinGet publication is limited to the x64 user-scope NSIS release asset', () => {
assert.equal(workflow.env.PACKAGE_ID, 'TauriTavern.TauriTavern');
const resolveRelease = publish.steps.find((step) => step.id === 'release');
const generate = publish.steps.find((step) => step.id === 'manifest');
assert.match(resolveRelease.run, /windows-x64-setup\.exe/);
assert.match(generate.run, /\$env:INSTALLER_URL\|x64\|user/);
assert.doesNotMatch(workflowSource, /windows-x64-portable|\.msi|canary/i);
});
test('WinGetCreate is pinned, verified, and never receives the token as an argument', () => {
assert.equal(
workflow.env.WINGETCREATE_URL,
'https://github.com/microsoft/winget-create/releases/download/v1.12.13.0/wingetcreate.exe',
);
assert.equal(
workflow.env.WINGETCREATE_SHA256,
'24042bd37915805615e6cf969ac57c6439124c3fe85823327f5f3fb24bd9ffea',
);
assert.match(workflowSource, /Get-FileHash/);
assert.match(workflowSource, /WINGET_CREATE_GITHUB_TOKEN/);
assert.doesNotMatch(workflowSource, /--token/);
});
test('WinGet publication is idempotent and preserves an inspectable manifest artifact', () => {
const preflight = publish.steps.find((step) => step.id === 'preflight');
assert.match(preflight.run, /microsoft\/winget-pkgs/);
assert.match(preflight.run, /is:pr is:open/);
assert.match(preflight.run, /seed manifest/);
const generateIndex = publish.steps.findIndex((step) => step.id === 'manifest');
const archiveIndex = publish.steps.findIndex((step) => step.uses === 'actions/upload-artifact@v4');
const submitIndex = publish.steps.findIndex((step) => step.name === 'Submit manifest update');
assert.ok(generateIndex < archiveIndex);
assert.ok(archiveIndex < submitIndex);
});