Repository navigation
ci: harden WinGet manifest generation and PR validation #16
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
edb4d89
6af4089
0b551cf
065d601
5b47e93
4ff7623
c3731cf
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -37,7 +37,9 @@ jobs: | |
| runs-on: windows-latest | ||
| if: > | ||
| github.event_name == 'workflow_dispatch' || | ||
| github.event.workflow_run.event == 'release' | ||
| (github.event_name == 'workflow_run' && | ||
| github.event.workflow_run.event == 'release' && | ||
| github.event.workflow_run.conclusion == 'success') | ||
|
|
||
| steps: | ||
| - name: Checkout code | ||
|
|
@@ -48,49 +50,102 @@ jobs: | |
| shell: bash | ||
| env: | ||
| GH_TOKEN: ${{ github.token }} | ||
| MANUAL_RELEASE_TAG: ${{ github.event.inputs.release_tag }} | ||
| run: | | ||
| if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then | ||
| TAG="${{ github.event.inputs.release_tag }}" | ||
| TAG="$MANUAL_RELEASE_TAG" | ||
| else | ||
| TAG=$(gh api repos/${{ github.repository }}/releases/latest --jq '.tag_name') | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
When an older release workflow is rerun after a newer release exists, or two release runs overlap, this branch ignores the triggering Useful? React with 👍 / 👎. |
||
| fi | ||
|
|
||
| if [[ ! "$TAG" =~ ^v?[0-9]+\.[0-9]+\.[0-9]+(\.[0-9]+)?$ ]]; then | ||
| echo "::error::Invalid release tag: $TAG" | ||
| exit 1 | ||
| fi | ||
|
|
||
| TAG_VERSION="${TAG#v}" | ||
| echo "TAG=$TAG" >> $GITHUB_OUTPUT | ||
| echo "TAG_VERSION=$TAG_VERSION" >> $GITHUB_OUTPUT | ||
| echo "TAG=$TAG" >> "$GITHUB_OUTPUT" | ||
| echo "TAG_VERSION=$TAG_VERSION" >> "$GITHUB_OUTPUT" | ||
| echo "Resolved tag: $TAG (version $TAG_VERSION)" | ||
|
|
||
| - name: Verify release asset exists | ||
| shell: bash | ||
| - name: Download release bundle | ||
| shell: pwsh | ||
| env: | ||
| RELEASE_TAG: ${{ steps.get_release.outputs.TAG }} | ||
| run: | | ||
| TAG="${{ steps.get_release.outputs.TAG }}" | ||
| URL="https://github.com/${{ github.repository }}/releases/download/${TAG}/DefinitionForCommandPalette.msixbundle" | ||
| STATUS=$(curl -o /dev/null -s -w "%{http_code}" -L "$URL") | ||
| if [ "$STATUS" != "200" ] && [ "$STATUS" != "302" ]; then | ||
| echo "::error::Release asset not found (HTTP $STATUS): $URL" | ||
| exit 1 | ||
| fi | ||
| echo "✓ MSIX bundle asset verified for $TAG" | ||
| $tag = $env:RELEASE_TAG | ||
| $url = "https://github.com/${{ github.repository }}/releases/download/$tag/DefinitionForCommandPalette.msixbundle" | ||
| Invoke-WebRequest -Uri $url -OutFile DefinitionForCommandPalette.msixbundle | ||
|
ruslanlap marked this conversation as resolved.
|
||
| if (-not (Test-Path DefinitionForCommandPalette.msixbundle)) { | ||
| throw "Release asset download failed: $url" | ||
| } | ||
|
|
||
| - name: Package winget manifests | ||
| - name: Generate current winget manifests | ||
| shell: pwsh | ||
| env: | ||
| RELEASE_TAG: ${{ steps.get_release.outputs.TAG }} | ||
| RELEASE_TAG_VERSION: ${{ steps.get_release.outputs.TAG_VERSION }} | ||
| run: | | ||
| $tag = "${{ steps.get_release.outputs.TAG }}" | ||
| $tagVersion = "${{ steps.get_release.outputs.TAG_VERSION }}" | ||
| $tag = $env:RELEASE_TAG | ||
| $tagVersion = $env:RELEASE_TAG_VERSION | ||
| $parts = @($tagVersion.Split('.')) | ||
| while ($parts.Count -lt 4) { $parts += '0' } | ||
| $packageVersion = ($parts[0..3] -join '.') | ||
| $url = "https://github.com/${{ github.repository }}/releases/download/$tag/DefinitionForCommandPalette.msixbundle" | ||
| $installerHash = (Get-FileHash DefinitionForCommandPalette.msixbundle -Algorithm SHA256).Hash | ||
|
|
||
| if (-not (Test-Path winget)) { | ||
| Write-Host "::error::No winget/ directory at repo root — cannot package manifests." | ||
| exit 1 | ||
| $extractDir = Join-Path $env:RUNNER_TEMP "bundle-signature" | ||
| if (Test-Path $extractDir) { Remove-Item $extractDir -Recurse -Force } | ||
| [System.IO.Compression.ZipFile]::ExtractToDirectory( | ||
| (Resolve-Path DefinitionForCommandPalette.msixbundle), | ||
| $extractDir | ||
| ) | ||
| $sigFile = Join-Path $extractDir "AppxSignature.p7x" | ||
| if (-not (Test-Path $sigFile)) { | ||
| throw "AppxSignature.p7x not found. Refusing to package an unsigned MSIX bundle for WinGet." | ||
| } | ||
| $signatureHash = (Get-FileHash $sigFile -Algorithm SHA256).Hash | ||
|
|
||
| $manifestFiles = Get-ChildItem -Path winget -Recurse -File | ||
| if ($manifestFiles.Count -eq 0) { | ||
| Write-Host "::error::No manifest files under winget/." | ||
| exit 1 | ||
| $stage = "winget-current" | ||
| New-Item -ItemType Directory -Force -Path $stage | Out-Null | ||
|
|
||
| $files = @( | ||
| "ruslanlap.DefinitionForCommandPalette.yaml", | ||
| "ruslanlap.DefinitionForCommandPalette.installer.yaml", | ||
| "ruslanlap.DefinitionForCommandPalette.locale.en-US.yaml", | ||
| "ruslanlap.DefinitionForCommandPalette.locale.fr-FR.yaml" | ||
| ) | ||
|
|
||
| foreach ($name in $files) { | ||
| $src = Join-Path "winget" $name | ||
| if (-not (Test-Path $src)) { throw "Missing template manifest: $src" } | ||
| $text = Get-Content $src -Raw | ||
| $text = $text -replace '(?m)^PackageVersion:\s*.*$', "PackageVersion: $packageVersion" | ||
| $text = $text -replace 'winget-manifest\.([^.]+)\.[0-9.]+\.schema\.json', 'winget-manifest.$1.1.12.0.schema.json' | ||
| $text = $text -replace '(?m)^ManifestVersion:\s*.*$', 'ManifestVersion: 1.12.0' | ||
|
|
||
| if ($name -like '*.installer.yaml') { | ||
| $text = $text -replace '(?m)^\s*InstallerUrl:\s*.*$', " InstallerUrl: $url" | ||
| $text = $text -replace '(?m)^\s*InstallerSha256:\s*.*$', " InstallerSha256: $installerHash" | ||
| $text = [regex]::Replace($text, '(?m)^\s*SignatureSha256:\s*.*\r?\n?', '') | ||
| $needle = " InstallerSha256: $installerHash" | ||
| $replacement = " InstallerSha256: $installerHash`r`n SignatureSha256: $signatureHash" | ||
| $text = $text.Replace($needle, $replacement) | ||
| if ($text -notmatch '(?m)^MinimumOSVersion:') { | ||
| $text = $text -replace '(?m)^InstallerType:\s*msix\s*$', "MinimumOSVersion: 10.0.19041.0`r`nInstallerType: msix" | ||
| } | ||
| } | ||
|
|
||
| Set-Content -Path (Join-Path $stage $name) -Value $text -NoNewline | ||
| } | ||
|
|
||
| Write-Host "PackageVersion: $packageVersion" | ||
| Write-Host "InstallerSha256: $installerHash" | ||
| Write-Host "SignatureSha256: $signatureHash" | ||
|
|
||
| $zip = "winget-manifests-$tag.zip" | ||
| Compress-Archive -Path winget/* -DestinationPath $zip -Force | ||
| Write-Host "Packaged $($manifestFiles.Count) file(s) into $zip" | ||
| Compress-Archive -Path "$stage/*" -DestinationPath $zip -Force | ||
| Write-Host "Packaged exactly 4 current-version manifests into $zip" | ||
|
|
||
| - name: Upload winget manifests artifact | ||
| uses: actions/upload-artifact@v4 | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,64 +1,55 @@ | ||
| # Submitting to winget-pkgs | ||
|
|
||
| This repository ships the winget manifests under `winget/`. The CI workflow | ||
| [`.github/workflows/winget-publish.yml`](../.github/workflows/winget-publish.yml) | ||
| packages them into an artifact on every Microsoft Store release, but it does | ||
| **not** open the upstream PR. Submitting the manifests to | ||
| [`microsoft/winget-pkgs`](https://github.com/microsoft/winget-pkgs) is a | ||
| manual step that takes about five minutes. | ||
|
|
||
| ## Why the workflow doesn't submit | ||
|
|
||
| - `wingetcreate new` requires Sharprompt interactive input. It cannot run | ||
| in CI without a TTY and fails with | ||
| `Sharprompt requires an interactive environment`. | ||
| - `wingetcreate update` requires the package to already exist in | ||
| `microsoft/winget-pkgs`. This package does not. | ||
| - The winget-pkgs GitHub App is the recommended path, but configuring it is | ||
| a separate project. | ||
|
|
||
| ## When you need to do this | ||
|
|
||
| Every time you bump `PackageVersion` in `winget/*.yaml`. The CI artifact is | ||
| the source of truth — pull it after the Store publish completes. | ||
|
|
||
| ## Procedure | ||
|
|
||
| 1. Wait for the `Publish CmdPal Extension to Microsoft Store` workflow run | ||
| to complete successfully. | ||
| 2. Download the `winget-manifests-<tag>` artifact from the matching | ||
| `Publish to WinGet` run. | ||
| 3. Fork [`microsoft/winget-pkgs`](https://github.com/microsoft/winget-pkgs) | ||
| if you have not already. | ||
| 4. Create a branch named after the package and version: | ||
| The repository keeps four manifest templates under `winget/`. The | ||
| `.github/workflows/winget-publish.yml` workflow turns those templates into a | ||
| clean, current-version manifest set after a signed GitHub release is available. | ||
|
|
||
| ## Signing requirement | ||
|
|
||
| The distributed `.msixbundle` must be signed by a certificate chain trusted by | ||
| Windows. The current release workflow uses SignPath Foundation via `SIGNPATH_API_TOKEN` | ||
| plus the SignPath repository variables. | ||
|
|
||
| A self-signed certificate is not sufficient for winget validation and produces | ||
| `0x800B0109 / CERT_E_UNTRUSTEDROOT`. For MSIX, the manifest `Publisher` | ||
| must also match the subject of the code-signing certificate, so do not add a | ||
| generic PFX fallback without handling package identity explicitly. | ||
|
|
||
| ## What CI now generates | ||
|
|
||
| For the requested release tag, `Publish to WinGet`: | ||
|
|
||
| 1. Downloads `DefinitionForCommandPalette.msixbundle` from the GitHub Release. | ||
| 2. Computes the bundle `InstallerSha256`. | ||
| 3. Extracts `AppxSignature.p7x` and computes `SignatureSha256`. | ||
| 4. Normalizes the release tag to a four-part `PackageVersion`. | ||
| 5. Updates the manifest schema/version to 1.12.0. | ||
| 6. Packages exactly these four current-version files: | ||
| - `ruslanlap.DefinitionForCommandPalette.yaml` | ||
| - `ruslanlap.DefinitionForCommandPalette.installer.yaml` | ||
| - `ruslanlap.DefinitionForCommandPalette.locale.en-US.yaml` | ||
| - `ruslanlap.DefinitionForCommandPalette.locale.fr-FR.yaml` | ||
|
|
||
| Historical folders under `winget/` are intentionally excluded from the | ||
| artifact so a submission cannot accidentally contain multiple package versions. | ||
|
|
||
| ## Submission procedure | ||
|
|
||
| 1. Ensure the release workflow completed and the GitHub Release contains the | ||
| trusted-signed `DefinitionForCommandPalette.msixbundle`. | ||
| 2. Run `Publish to WinGet` for that release tag. | ||
| 3. Download the `winget-manifests-<tag>` artifact. | ||
| 4. Copy the four files into: | ||
| `manifests/r/ruslanlap/DefinitionForCommandPalette/<PackageVersion>/` | ||
| in the `microsoft/winget-pkgs` fork. | ||
| 5. Validate with: | ||
| ```powershell | ||
| winget validate --manifest <manifest-directory> | ||
| ``` | ||
| git checkout -b ruslanlap/DefinitionForCommandPalette-1.0.4 | ||
| ``` | ||
| 5. Copy the manifests into the canonical path: | ||
| ``` | ||
| cp winget-manifests-1.0.4.zip /tmp/ | ||
| cd /tmp && unzip winget-manifests-1.0.4.zip -d manifests | ||
| mkdir -p upstream/manifests/r/ruslanlap/DefinitionForCommandPalette/1.0.4.0 | ||
| cp -r manifests/* upstream/manifests/r/ruslanlap/DefinitionForCommandPalette/1.0.4.0/ | ||
| ``` | ||
| 6. Update `PackageVersion`, `InstallerUrl`, and `InstallerSha256` in the | ||
| manifests to match the new release. The current values point at v1.0.0 | ||
| and need to be bumped before each submission. | ||
| 7. Validate locally with `winget validate --manifest <path>`. | ||
| 8. Commit, push, open a PR upstream. | ||
| 9. Watch the validation checks. Common expected labels for this package | ||
| type (MSIX bundle with no primary executable): | ||
| - `Validation-Executable-Error` | ||
| - `Validation-No-Executables` | ||
|
|
||
| These are routine for MSIX-only packages. Comment on the PR explaining | ||
| that the install path is `Plugins/DefinitionForCommandPalette` under the | ||
| PowerToys Run installation directory, and link the relevant section of | ||
| `deploy.yml` if the moderator asks. | ||
| 10. Wait for a moderator to merge. Do not ping more than twice. | ||
|
|
||
| ## Why hand-write instead of using wingetcreate | ||
| 6. Commit, push, and open/update the upstream PR. | ||
| 7. Wait for the winget validation pipeline and reviewer approval. | ||
|
|
||
| Hand-written manifests are easier to review, easier to bump across | ||
| versions, and easier to keep in sync with the rest of this repo's release | ||
| process. The win is small for new packages but compounds across versions. | ||
| For the current v1.0.4 submission, upstream PR #431811 has already confirmed | ||
| that the only remaining validation blocker is the untrusted certificate chain. | ||
| Once a trusted-signed bundle replaces the release asset, regenerate the | ||
| manifests and update that PR with the new hashes. |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
When a PR changes only
Directory.Packages.props,global.json, ornuget.config, none of these path filters match, so the new MSIX validation workflow does not run. Because this repository centrally declares every NuGet version inDirectory.Packages.props, dependency-only PRs— including the updates configured in.github/dependabot.yml—receive no compile or packaging validation. Add the root build inputs to thispathslist.Useful? React with 👍 / 👎.