# Submit a WinGet manifest PR to microsoft/winget-pkgs on every release. # # Fires on `release: published` (the moment you click Publish on a GitHub # release). Uses Microsoft's own `wingetcreate` CLI so it handles both the # first-time submission and subsequent updates, but the two paths use # different commands: # # - First-time submission: hand-author the three YAML manifests # (PackageIdentifier metadata, locale strings, installer) and submit # them via `wingetcreate submit`. `wingetcreate new` is not usable # non-interactively for first-time submissions because the package # identifier is an interactive-only prompt. # # - Subsequent submissions: `wingetcreate update NamelessSaint.EntraChecks # --urls --version --submit`. The # package identifier is already known, so wingetcreate can pull the # previous manifest from microsoft/winget-pkgs and just bump it. # # Setup needed once: create a repo secret WINGET_TOKEN containing a # classic GitHub PAT with the `public_repo` scope (on a user account # that's authorised to fork microsoft/winget-pkgs - any GitHub user is). # Without that secret the job is a no-op (logs a friendly explanation # and exits). See README "Packaging" for the wider context. # # The very first submission gets human review by the WinGet maintainers # (typically 1-3 days). After that's merged, future tag pushes auto-submit # updates against the established NamelessSaint.EntraChecks identifier. name: WinGet submission on: release: types: [published] workflow_dispatch: inputs: tag: description: "Release tag to submit (e.g. v1.7.0)" required: true validate_only: description: "Check WINGET_TOKEN and stop, without submitting anything" type: boolean default: false jobs: publish: name: Submit ${{ github.event.release.tag_name || inputs.tag }} to winget-pkgs runs-on: windows-latest steps: - name: Check WINGET_TOKEN id: gate shell: pwsh env: TOKEN: ${{ secrets.WINGET_TOKEN }} run: | if ([string]::IsNullOrWhiteSpace($env:TOKEN)) { Write-Host "::warning::WINGET_TOKEN secret is not set; skipping submission." Write-Host "To enable: create a classic GitHub PAT with the 'public_repo'" Write-Host "scope and add it as a repo secret named WINGET_TOKEN." "submit=false" | Out-File -FilePath $env:GITHUB_OUTPUT -Append exit 0 } # A present-but-expired PAT used to surface as wingetcreate's # "Token was invalid" after the installer had been downloaded and # the manifests authored - a long way from the cause. Ask GitHub # up front instead, and read the scopes off the response so a # token that is live but under-scoped also fails here. try { $resp = Invoke-WebRequest -Uri "https://api.github.com/user" -Headers @{ Authorization = "Bearer $env:TOKEN" 'User-Agent' = 'winget-submit-action' } -ErrorAction Stop } catch { $code = $_.Exception.Response.StatusCode.value__ Write-Host "::error::WINGET_TOKEN was rejected by GitHub (HTTP $code)." Write-Host "Classic PATs expire. Generate a new one with the 'public_repo'" Write-Host "scope and update the secret: gh secret set WINGET_TOKEN" exit 1 } $scopes = $resp.Headers['X-OAuth-Scopes'] if ($scopes -is [array]) { $scopes = $scopes -join ', ' } Write-Host "Token accepted for $(($resp.Content | ConvertFrom-Json).login); scopes: $scopes" # Fine-grained PATs send no X-OAuth-Scopes header at all, so an # empty value is inconclusive rather than wrong - warn, don't fail. if ([string]::IsNullOrWhiteSpace($scopes)) { Write-Host "::warning::No X-OAuth-Scopes header; cannot confirm 'public_repo'. Continuing." } elseif ($scopes -notmatch '(^|,\s*)(public_repo|repo)(,|$)') { Write-Host "::error::Token lacks the 'public_repo' scope (has: $scopes)." exit 1 } if ("${{ inputs.validate_only }}" -eq "true") { Write-Host "validate_only requested - stopping before submission." "submit=false" | Out-File -FilePath $env:GITHUB_OUTPUT -Append exit 0 } "submit=true" | Out-File -FilePath $env:GITHUB_OUTPUT -Append - name: Resolve target tag + version + installer URL if: steps.gate.outputs.submit == 'true' id: target shell: pwsh run: | $tag = if ("${{ inputs.tag }}") { "${{ inputs.tag }}" } else { "${{ github.event.release.tag_name }}" } $version = $tag -replace '^v','' $url = "https://github.com/${{ github.repository }}/releases/download/$tag/EntraChecks_${version}_x64-setup.exe" "tag=$tag" | Out-File -FilePath $env:GITHUB_OUTPUT -Append "version=$version" | Out-File -FilePath $env:GITHUB_OUTPUT -Append "url=$url" | Out-File -FilePath $env:GITHUB_OUTPUT -Append Write-Host "Target: $tag (version $version)" Write-Host "Installer URL: $url" - name: Install wingetcreate if: steps.gate.outputs.submit == 'true' shell: pwsh run: | # wingetcreate's own banner exits non-zero when invoked without # a real subcommand (even `version` does this on 1.12.8.0), so # we just download the binary and let downstream steps fail or # succeed on their own merits. Confirms the download landed. Invoke-WebRequest -Uri https://aka.ms/wingetcreate/latest -OutFile wingetcreate.exe if (-not (Test-Path .\wingetcreate.exe)) { Write-Host "::error::wingetcreate.exe did not download" exit 1 } Write-Host "wingetcreate.exe size: $((Get-Item .\wingetcreate.exe).Length) bytes" - name: Detect whether package already exists in winget-pkgs if: steps.gate.outputs.submit == 'true' id: probe shell: pwsh run: | # The path is deterministic from the identifier; a 200 means at # least one version of NamelessSaint.EntraChecks is on file, a 404 # means this is the first-ever submission and we need to # hand-author the manifest set. $probeUrl = "https://api.github.com/repos/microsoft/winget-pkgs/contents/manifests/n/NamelessSaint/EntraChecks" $exists = $false try { $resp = Invoke-WebRequest -Uri $probeUrl -Method Get -Headers @{ 'User-Agent' = 'winget-submit-action' } -ErrorAction Stop if ($resp.StatusCode -eq 200) { $exists = $true } } catch { if ($_.Exception.Response.StatusCode.value__ -ne 404) { Write-Host "::warning::Unexpected response probing winget-pkgs: $($_.Exception.Message). Treating as first-time submission." } } Write-Host "Package already exists in winget-pkgs: $exists" "exists=$exists" | Out-File -FilePath $env:GITHUB_OUTPUT -Append - name: Update existing manifest (release N+1) if: steps.gate.outputs.submit == 'true' && steps.probe.outputs.exists == 'True' shell: pwsh env: TOKEN: ${{ secrets.WINGET_TOKEN }} run: | .\wingetcreate.exe update NamelessSaint.EntraChecks ` --version "${{ steps.target.outputs.version }}" ` --urls "${{ steps.target.outputs.url }}" ` --token "$env:TOKEN" ` --submit if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } - name: Author + submit first-time manifest set if: steps.gate.outputs.submit == 'true' && steps.probe.outputs.exists == 'False' shell: pwsh env: TOKEN: ${{ secrets.WINGET_TOKEN }} run: | $version = "${{ steps.target.outputs.version }}" $url = "${{ steps.target.outputs.url }}" # SHA256 has to be uppercase per WinGet schema and has to match # the installer we're pointing at. Hash it from the live URL so # the workflow is self-consistent (no risk of a stale hash from # a prior copy/paste). Write-Host "Downloading installer to hash..." $tmp = New-TemporaryFile Invoke-WebRequest -Uri $url -OutFile $tmp.FullName $sha = (Get-FileHash -Path $tmp.FullName -Algorithm SHA256).Hash.ToUpper() Remove-Item $tmp.FullName -Force Write-Host "SHA256: $sha" $manifestDir = "manifests-out/n/NamelessSaint/EntraChecks/$version" New-Item -ItemType Directory -Force -Path $manifestDir | Out-Null # Build each manifest as an array of lines so the embedded YAML # doesn't fight the GitHub Actions YAML parser (here-strings need # flush-left content, which breaks the run-block-scalar). $LF = [char]0x0A $versionManifest = @( "# yaml-language-server: `$schema=https://aka.ms/winget-manifest.version.1.12.0.schema.json", "PackageIdentifier: NamelessSaint.EntraChecks", "PackageVersion: $version", "DefaultLocale: en-US", "ManifestType: version", "ManifestVersion: 1.12.0" ) -join $LF Set-Content -Path "$manifestDir/NamelessSaint.EntraChecks.yaml" -Value $versionManifest -Encoding utf8 $description = "EntraChecks runs read-only security and compliance checks across Microsoft 365 and Azure environments, then produces analyst-friendly HTML, CSV, JSON, and Excel reports. Modules cover Conditional Access, MFA, Identity Protection, Intune, Microsoft Secure Score, Defender for Cloud regulatory compliance, Azure Policy, Microsoft Purview Compliance Manager, on-premises Active Directory, hybrid identity correlation, and SOC 2 readiness. The desktop app is a thin Tauri shell over the same PowerShell engine the CLI uses; all checks are read-only and never modify your tenant." $localeManifest = @( "# yaml-language-server: `$schema=https://aka.ms/winget-manifest.defaultLocale.1.12.0.schema.json", "PackageIdentifier: NamelessSaint.EntraChecks", "PackageVersion: $version", "PackageLocale: en-US", "Publisher: NamelessSaint", "PublisherUrl: https://github.com/NamelessSaint8", "PublisherSupportUrl: https://github.com/NamelessSaint8/AzurePAM/issues", "Author: NamelessSaint", "PackageName: EntraChecks", "PackageUrl: https://github.com/NamelessSaint8/AzurePAM", "License: MIT", "LicenseUrl: https://github.com/NamelessSaint8/AzurePAM/blob/main/LICENSE", "Copyright: Copyright (c) NamelessSaint", "ShortDescription: Read-only Microsoft Cloud (Entra/Azure/M365) compliance assessment toolkit.", "Description: $description", "Moniker: entrachecks", "Tags:", "- security", "- compliance", "- azure", "- entra", "- m365", "- microsoft365", "- soc2", "- audit", "- powershell", "ReleaseNotesUrl: https://github.com/NamelessSaint8/AzurePAM/releases/tag/v$version", "ManifestType: defaultLocale", "ManifestVersion: 1.12.0" ) -join $LF Set-Content -Path "$manifestDir/NamelessSaint.EntraChecks.locale.en-US.yaml" -Value $localeManifest -Encoding utf8 $installerManifest = @( "# yaml-language-server: `$schema=https://aka.ms/winget-manifest.installer.1.12.0.schema.json", "PackageIdentifier: NamelessSaint.EntraChecks", "PackageVersion: $version", "InstallerLocale: en-US", "InstallerType: nullsoft", # Must match tauri.conf.json bundle.windows.nsis.installMode # ("currentUser"). Declaring machine scope here makes WinGet # elevate and then fail to correlate the per-user ARP entry # the installer actually writes. "Scope: user", "InstallerSwitches:", " Silent: /S", " SilentWithProgress: /S", "Installers:", "- Architecture: x64", " InstallerUrl: $url", " InstallerSha256: $sha", "ManifestType: installer", "ManifestVersion: 1.12.0" ) -join $LF Set-Content -Path "$manifestDir/NamelessSaint.EntraChecks.installer.yaml" -Value $installerManifest -Encoding utf8 Write-Host "--- manifests ---" Get-ChildItem $manifestDir | ForEach-Object { Write-Host "## $($_.Name) ##" Get-Content $_.FullName | Write-Host } Write-Host "Submitting via wingetcreate..." .\wingetcreate.exe submit $manifestDir --token "$env:TOKEN" if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }