diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md new file mode 100644 index 0000000..34de063 --- /dev/null +++ b/.github/copilot-instructions.md @@ -0,0 +1,9 @@ +# Scout for WinGet review context + +Scout is a .NET 10 terminal app. `WinGetScout.csproj` builds a cross-platform mock/CLI target and a Windows COM target; shipping binaries are Native AOT. The portable ZIP must retain the WinGet in-process COM DLLs beside `wingetscout.exe`. The standalone EXE uses the `winget.exe` CLI backend. + +For any Terminal.Gui update, inspect changed APIs used by `src/App.cs` and `Program.cs`, especially UI thread dispatch, callbacks, timers, shutdown, keyboard input, layout, and rendering. Suggest a focused regression test or manual TUI check for each plausible incompatibility. A successful data-only test run does not establish interactive rendering parity. + +For any WinGet update, keep `Microsoft.WindowsPackageManager.ComInterop` and `Microsoft.WindowsPackageManager.InProcCom` on the same version. Review `src/ComBackend.cs` for AOT and COM behavior and `src/CliBackend.cs` for output parsing. Never replace indexed WinRT collection access with `foreach`: AOT cannot generate the required projected `IIterable` wrapper here. Do not set `AcceptSourceAgreements` on a composite catalog reference. Distinguish read-only `--comdiag` and `--comsmoke` checks from install or upgrade validation. + +Run `dotnet test --project tests/WinGetScout.Tests.csproj`. For package updates, also publish the Windows x64 and ARM64 AOT targets and confirm the COM companion files are present. Report tests and limits accurately. Suggest concrete code or tests rather than a generic dependency summary. Do not auto-merge critical dependency PRs. diff --git a/.github/dependabot.yml b/.github/dependabot.yml index b40adea..109f5b5 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -1,15 +1,25 @@ version: 2 updates: - # NuGet — track Terminal.Gui develop prereleases and xUnit stable. + # Include both project manifests and indirect packages. Keep the WinGet COM + # projection and engine together; they must use the same package version. - package-ecosystem: nuget - directory: / + directories: + - / + - /tests schedule: interval: weekly day: monday - open-pull-requests-limit: 5 + time: '09:00' + timezone: America/Chicago + allow: + - dependency-type: all + assignees: [harder] + open-pull-requests-limit: 10 groups: terminal-gui: - patterns: ['Terminal.Gui*'] + patterns: ['Terminal.Gui'] + winget-com: + patterns: ['Microsoft.WindowsPackageManager.*'] xunit: patterns: ['xunit*', 'Microsoft.NET.Test.Sdk'] @@ -18,4 +28,13 @@ updates: directory: / schedule: interval: monthly - open-pull-requests-limit: 3 + assignees: [harder] + open-pull-requests-limit: 10 + + # Keep the SDK pin visible. SDK PRs also need regenerated NuGet lock files. + - package-ecosystem: dotnet-sdk + directory: / + schedule: + interval: monthly + assignees: [harder] + open-pull-requests-limit: 5 diff --git a/.github/scripts/critical-dependencies.js b/.github/scripts/critical-dependencies.js new file mode 100644 index 0000000..ccfbcfe --- /dev/null +++ b/.github/scripts/critical-dependencies.js @@ -0,0 +1,132 @@ +const fs = require('node:fs'); + +const LABEL = 'critical-dependency'; + +function developVersion(version) { + const match = /^(\d+)\.(\d+)\.(\d+)-develop\.(\d+)$/i.exec(version); + return match ? match.slice(1).map(Number) : null; +} + +function compareParts(left, right) { + for (let index = 0; index < left.length; index++) { + if (left[index] !== right[index]) return left[index] - right[index]; + } + return 0; +} + +function suggestedChecks(notes) { + const text = (notes || '').toLowerCase(); + const checks = []; + if (/\b(pin|pinned|pinning)\b/.test(text)) checks.push('pin list parsing, pin/unpin behavior, and pinned upgrades'); + if (/\b(source|catalog|repository)\b/.test(text)) checks.push('source enumeration, agreements, and source-aware package selection'); + if (/\b(com|winrt|projection|activation)\b/.test(text)) checks.push('Native AOT COM activation and read-only `--comdiag`/`--comsmoke`'); + if (/\b(progress|cancel|async)\b/.test(text)) checks.push('progress callbacks, cancellation, and UI thread dispatch'); + if (/\b(installer|install|upgrade|uninstall)\b/.test(text)) checks.push('installer preview and install/upgrade/uninstall plans'); + if (/\b(table|output|format|json)\b/.test(text)) checks.push('captured CLI output fixtures and table parsing'); + return checks.length ? checks.map(check => `- ${check}`).join('\n') : '- Review upstream API and output changes; add a focused test for any behavior Scout uses.'; +} + +async function ensureLabel(github, owner, repo) { + try { + await github.rest.issues.getLabel({ owner, repo, name: LABEL }); + } catch (error) { + if (error.status !== 404) throw error; + await github.rest.issues.createLabel({ + owner, repo, name: LABEL, color: 'B60205', + description: 'Upstream change requiring WinGet Scout compatibility review', + }); + } +} + +async function createOnce(github, core, owner, repo, title, body) { + const existing = await github.paginate(github.rest.issues.listForRepo, { + owner, repo, labels: LABEL, state: 'all', per_page: 100, + }); + if (existing.some(issue => !issue.pull_request && issue.title === title)) { + core.info(`Already tracked: ${title}`); + return; + } + const { data: issue } = await github.rest.issues.create({ + owner, repo, title, body, labels: [LABEL], assignees: [owner], + }); + core.info(`Created ${issue.html_url}`); +} + +async function checkTerminalGui(github, core, owner, repo, project) { + const match = / ({ version, parts: developVersion(version) })) + .filter(candidate => candidate.parts) + .sort((a, b) => compareParts(b.parts, a.parts)); + if (candidates.length === 0) throw new Error('No Terminal.Gui develop versions found'); + const latest = candidates[0]; + if (compareParts(latest.parts, current) <= 0) { + core.info(`Terminal.Gui develop is current: ${match[1]}`); + return; + } + const { data: releases } = await github.rest.repos.listReleases({ + owner: 'gui-cs', repo: 'Terminal.Gui', per_page: 30, + }); + const upstream = releases.find(release => release.tag_name.toLowerCase().includes(latest.version.toLowerCase())); + await createOnce(github, core, owner, repo, `Terminal.Gui develop update: ${latest.version}`, ` +Scout currently references **${match[1]}**; NuGet has **${latest.version}** on the develop channel. + +- [NuGet package and version history](https://www.nuget.org/packages/Terminal.Gui/${latest.version}) +- [Upstream changes](${upstream ? upstream.html_url : 'https://github.com/gui-cs/Terminal.Gui/releases'}) +- Check whether Dependabot opened an update PR. Its version PR may target a stable release instead of the develop channel. +- Compare APIs used by Scout, especially UI thread dispatch, shutdown, layout, input, and progress callbacks. +- Run unit tests, Windows x64 and ARM64 AOT publish, and mock smoke; record any live COM/TUI checks that require a Windows host. + +Suggested checks from the upstream release notes: +${suggestedChecks(upstream?.body)} + +Ask Copilot to review the update PR using the repository instructions and suggest concrete compatibility fixes. Keep the dependency bump under human review. +`.trim()); +} + +async function checkWinget(github, core, owner, repo) { + const { data: releases } = await github.rest.repos.listReleases({ + owner: 'microsoft', repo: 'winget-cli', per_page: 30, + }); + const newest = [ + releases.find(release => !release.draft && !release.prerelease), + releases.find(release => !release.draft && release.prerelease), + ].filter(Boolean); + if (newest.length === 0) throw new Error('No WinGet CLI releases found'); + for (const release of newest) { + const channel = release.prerelease ? 'preview' : 'stable'; + await createOnce(github, core, owner, repo, + `WinGet ${channel} release: ${release.tag_name}`, ` +Microsoft published [WinGet ${release.tag_name}](${release.html_url}) (${channel}). + +- Compare the release notes with Scout's src/CliBackend.cs parser, subprocess flags, and src/ComBackend.cs API usage. +- Check whether both Microsoft.WindowsPackageManager.ComInterop and Microsoft.WindowsPackageManager.InProcCom have matching NuGet updates; Dependabot should update them in one PR. +- Refresh CLI output fixtures if tables, pins, progress, or source output changed. +- Run tests and Windows x64/ARM64 AOT smoke. On a real Windows host, run read-only --comdiag and --comsmoke; record the installed WinGet version and any fallback. +- Ask Copilot to propose focused tests or compatibility fixes from the release notes. Review changes before merging. + +Suggested checks from the upstream release notes: +${suggestedChecks(release.body)} +`.trim()); + } +} + +module.exports = async ({ github, context, core, projectText = fs.readFileSync('WinGetScout.csproj', 'utf8') }) => { + const { owner, repo } = context.repo; + await ensureLabel(github, owner, repo); + await checkTerminalGui(github, core, owner, repo, projectText); + await checkWinget(github, core, owner, repo); +}; + +module.exports.developVersion = developVersion; +module.exports.compareParts = compareParts; +module.exports.suggestedChecks = suggestedChecks; diff --git a/.github/scripts/critical-dependencies.test.js b/.github/scripts/critical-dependencies.test.js new file mode 100644 index 0000000..32fb688 --- /dev/null +++ b/.github/scripts/critical-dependencies.test.js @@ -0,0 +1,55 @@ +const test = require('node:test'); +const assert = require('node:assert/strict'); +const monitor = require('./critical-dependencies.js'); + +test('critical releases create one assigned issue each and stay quiet on later runs', async () => { + const originalFetch = global.fetch; + const issues = []; + let labelExists = false; + global.fetch = async () => ({ + ok: true, + json: async () => ({ versions: ['2.5.1-develop.44', '2.5.1-develop.45'] }), + }); + const github = { + paginate: async () => issues, + rest: { + issues: { + getLabel: async () => { + if (!labelExists) throw Object.assign(new Error('missing label'), { status: 404 }); + }, + createLabel: async () => { labelExists = true; }, + listForRepo: async () => ({ data: issues }), + create: async ({ title, body, assignees, labels }) => { + const issue = { title, body, assignees, labels, html_url: `https://example.invalid/${issues.length + 1}` }; + issues.push(issue); + return { data: issue }; + }, + }, + repos: { + listReleases: async ({ repo }) => ({ data: repo === 'Terminal.Gui' + ? [{ tag_name: 'v2.5.1-develop.45', html_url: 'https://example.invalid/terminal', body: 'Fix progress callbacks' }] + : [ + { tag_name: 'v1.29.0', html_url: 'https://example.invalid/stable', body: 'Source changes', draft: false, prerelease: false }, + { tag_name: 'v1.30.0-preview', html_url: 'https://example.invalid/preview', body: 'COM updates', draft: false, prerelease: true }, + ] }), + }, + }, + }; + try { + const args = { + github, + context: { repo: { owner: 'harder', repo: 'wingetscout' } }, + core: { info: () => {} }, + projectText: '', + }; + await monitor(args); + assert.equal(issues.length, 3); + assert.ok(issues.every(issue => issue.assignees[0] === 'harder' && issue.labels[0] === 'critical-dependency')); + assert.match(issues[0].body, /progress callbacks/); + assert.match(issues[2].body, /COM activation/); + await monitor(args); + assert.equal(issues.length, 3); + } finally { + global.fetch = originalFetch; + } +}); diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 3263b92..1b11daa 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -20,10 +20,20 @@ jobs: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v7 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 - name: Lint GitHub Actions workflows - uses: rhysd/actionlint@v1.7.12 + uses: rhysd/actionlint@914e7df21a07ef503a81201c76d2b11c789d3fca # v1.7.12 + + - name: Test critical dependency monitor + run: node --test .github/scripts/critical-dependencies.test.js + + dependency-review: + if: github.event_name == 'pull_request' + runs-on: ubuntu-latest + steps: + - name: Reject newly vulnerable dependencies + uses: actions/dependency-review-action@2031cfc080254a8a887f58cffee85186f0e49e48 # v4.9.0 linux-runtime-tests: name: Linux runtime tests @@ -31,15 +41,15 @@ jobs: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v7 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 - name: Setup .NET 10 - uses: actions/setup-dotnet@v6 + uses: actions/setup-dotnet@a98b56852c35b8e3190ac28c8c2271da59106c68 # v6 with: - dotnet-version: '10.0.x' + global-json-file: global.json - name: Restore - run: dotnet restore tests/WinGetScout.Tests.csproj + run: dotnet restore tests/WinGetScout.Tests.csproj --locked-mode - name: Build cross-platform application run: dotnet build WinGetScout.csproj --no-restore --configuration Release --framework net10.0 @@ -67,17 +77,17 @@ jobs: runs-on: windows-latest steps: - - uses: actions/checkout@v7 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 - name: Setup .NET 10 - uses: actions/setup-dotnet@v6 + uses: actions/setup-dotnet@a98b56852c35b8e3190ac28c8c2271da59106c68 # v6 with: - dotnet-version: '10.0.x' - dotnet-quality: 'preview' + global-json-file: global.json cache: true cache-dependency-path: | - WinGetScout.csproj - tests/WinGetScout.Tests.csproj + packages.lock.json + packages.identity.lock.json + tests/packages.lock.json - name: Compute CI build version id: version @@ -88,7 +98,7 @@ jobs: echo "ci_version=$CI_VERSION" >> "$GITHUB_OUTPUT" - name: Restore - run: dotnet restore tests/WinGetScout.Tests.csproj + run: dotnet restore tests/WinGetScout.Tests.csproj --locked-mode - name: Build cross-platform application run: > @@ -146,10 +156,56 @@ jobs: } - name: Upload CI build outputs - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 with: name: wingetscout-${{ steps.version.outputs.ci_version }}-win-build path: | bin/Release/net10.0/** tests/bin/Release/net10.0/** retention-days: 7 + + native-aot-smoke: + name: Native AOT ${{ matrix.rid }} + if: github.event_name == 'pull_request' + runs-on: ${{ matrix.runner }} + strategy: + fail-fast: false + matrix: + include: + - rid: win-x64 + runner: windows-latest + - rid: win-arm64 + runner: windows-11-arm + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + - uses: actions/setup-dotnet@a98b56852c35b8e3190ac28c8c2271da59106c68 # v6 + with: + global-json-file: global.json + cache: true + cache-dependency-path: | + packages.lock.json + packages.identity.lock.json + tests/packages.lock.json + - name: Publish native Windows build + run: dotnet publish WinGetScout.csproj -c Release -f net10.0-windows10.0.26100.0 -r ${{ matrix.rid }} -p:RestoreLockedMode=true -o publish/${{ matrix.rid }} + - name: Check packaged COM files and mock startup + shell: pwsh + run: | + $folder = 'publish/${{ matrix.rid }}' + foreach ($name in @('wingetscout.exe', 'WindowsPackageManager.dll', 'Microsoft.Management.Deployment.InProc.dll')) { + if (-not (Test-Path (Join-Path $folder $name))) { throw "Missing $name from native publish" } + } + & (Join-Path $folder 'wingetscout.exe') --smoke + if ($LASTEXITCODE -ne 0) { throw "Native mock smoke failed: $LASTEXITCODE" } + + - name: Publish MSIX Identity build with locked dependencies + run: dotnet publish WinGetScout.csproj -c Release -f net10.0-windows10.0.26100.0 -r ${{ matrix.rid }} -p:WingetComMode=Identity -p:RestoreLockedMode=true -o publish/identity-${{ matrix.rid }} + - name: Check Identity build metadata and mock startup + shell: pwsh + run: | + $folder = 'publish/identity-${{ matrix.rid }}' + foreach ($name in @('wingetscout.exe', 'Microsoft.Management.Deployment.winmd')) { + if (-not (Test-Path (Join-Path $folder $name))) { throw "Missing $name from Identity publish" } + } + & (Join-Path $folder 'wingetscout.exe') --smoke + if ($LASTEXITCODE -ne 0) { throw "Identity mock smoke failed: $LASTEXITCODE" } diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml new file mode 100644 index 0000000..c931587 --- /dev/null +++ b/.github/workflows/codeql.yml @@ -0,0 +1,36 @@ +name: CodeQL + +on: + push: + branches: [main] + pull_request: + branches: [main] + schedule: + - cron: '41 13 * * 1' + workflow_dispatch: + +permissions: + contents: read + security-events: write + +jobs: + analyze: + # Code scanning accepts SARIF from pull_request runs with a read-only token. + # A Dependabot-authored push to main may not have upload permission. + if: github.event_name == 'pull_request' || github.actor != 'dependabot[bot]' + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + language: [csharp, actions] + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + - uses: actions/setup-dotnet@a98b56852c35b8e3190ac28c8c2271da59106c68 # v6 + if: matrix.language == 'csharp' + with: + global-json-file: global.json + - uses: github/codeql-action/init@2892aa5e19bbd11bc0cff5427e3b750a04d9e3c2 # v4 + with: + languages: ${{ matrix.language }} + build-mode: none + - uses: github/codeql-action/analyze@2892aa5e19bbd11bc0cff5427e3b750a04d9e3c2 # v4 diff --git a/.github/workflows/critical-dependencies.yml b/.github/workflows/critical-dependencies.yml new file mode 100644 index 0000000..0405de3 --- /dev/null +++ b/.github/workflows/critical-dependencies.yml @@ -0,0 +1,25 @@ +name: Critical dependencies + +on: + schedule: + - cron: '23 14 * * *' + workflow_dispatch: + +permissions: + contents: read + issues: write + +concurrency: + group: critical-dependencies + cancel-in-progress: false + +jobs: + monitor: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + - name: Check Terminal.Gui prereleases and WinGet releases + uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 + with: + script: | + await require('./.github/scripts/critical-dependencies.js')({github, context, core}); diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index abbb807..587b251 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -9,76 +9,120 @@ name: Release # LICENSE into zips, computes SHA-256 checksums, and publishes a GitHub Release with # all artifacts attached. # -# No code signing — that requires an Azure Trusted Signing account. Users will see SmartScreen warnings until -# the binaries build enough reputation. The SHA256SUMS files let users verify integrity. +# Portable downloads are currently unsigned. MSIX is published only when signing is +# configured and the completed package passes signature verification. on: push: tags: ['v*'] - workflow_dispatch: - inputs: - version: - description: 'Version to release (without leading v, e.g. 0.1.0). Required for manual runs.' - required: true permissions: - contents: write + contents: read concurrency: group: release cancel-in-progress: false jobs: + validate-tag: + runs-on: ubuntu-latest + outputs: + version: ${{ steps.version.outputs.version }} + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + with: + fetch-depth: 0 + - name: Check tag, project version, and main ancestry + id: version + shell: bash + run: | + set -euo pipefail + version="${GITHUB_REF_NAME#v}" + if [[ ! "$GITHUB_REF_NAME" =~ ^v[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z.-]+)?$ ]]; then + echo "::error::Use a vMAJOR.MINOR.PATCH tag, optionally with a prerelease suffix" + exit 1 + fi + project_version="$(sed -n 's/.*\([^<]*\)<\/Version>.*/\1/p' WinGetScout.csproj | head -1)" + if [ "$version" != "$project_version" ]; then + echo "::error::Tag $version does not match WinGetScout.csproj $project_version" + exit 1 + fi + git merge-base --is-ancestor "$GITHUB_SHA" origin/main || { + echo "::error::Release tag must point to a commit on main" + exit 1 + } + echo "version=$version" >> "$GITHUB_OUTPUT" + + - name: Reject an already published tag + uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 + with: + script: | + try { + const release = await github.rest.repos.getReleaseByTag({ + ...context.repo, tag: context.ref.replace('refs/tags/', ''), + }); + core.setFailed(`Release already exists: ${release.data.html_url}. Publish a new version instead of rebuilding this tag.`); + } catch (error) { + if (error.status !== 404) throw error; + } + workflow-lint: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v7 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 - name: Lint GitHub Actions workflows - uses: rhysd/actionlint@v1.7.12 + uses: rhysd/actionlint@914e7df21a07ef503a81201c76d2b11c789d3fca # v1.7.12 + + - name: Test critical dependency monitor + run: node --test .github/scripts/critical-dependencies.test.js + + tests: + needs: [validate-tag, workflow-lint] + runs-on: windows-latest + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + - uses: actions/setup-dotnet@a98b56852c35b8e3190ac28c8c2271da59106c68 # v6 + with: + global-json-file: global.json + cache: true + cache-dependency-path: | + packages.lock.json + packages.identity.lock.json + tests/packages.lock.json + - name: Restore locked dependencies + run: dotnet restore tests/WinGetScout.Tests.csproj --locked-mode + - name: Run tests + run: dotnet test --project tests/WinGetScout.Tests.csproj -c Release --no-restore --minimum-expected-tests 245 + - name: Smoke test + run: dotnet run --project WinGetScout.csproj -c Release -f net10.0 --no-restore -- --smoke build: - needs: workflow-lint + needs: [validate-tag, tests] name: Build ${{ matrix.rid }} runs-on: windows-latest + permissions: + contents: read + id-token: write + attestations: write + artifact-metadata: write strategy: fail-fast: false matrix: rid: [win-x64, win-arm64] - outputs: - version: ${{ steps.version.outputs.version }} - steps: - - uses: actions/checkout@v7 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 - name: Setup .NET 10 - uses: actions/setup-dotnet@v6 + uses: actions/setup-dotnet@a98b56852c35b8e3190ac28c8c2271da59106c68 # v6 with: - dotnet-version: '10.0.x' - dotnet-quality: 'preview' + global-json-file: global.json cache: true cache-dependency-path: | - WinGetScout.csproj - tests/WinGetScout.Tests.csproj - - - name: Resolve version - id: version - shell: bash - run: | - if [ -n "${{ inputs.version }}" ]; then - VERSION="${{ inputs.version }}" - else - REF="${GITHUB_REF#refs/tags/}" - VERSION="${REF#v}" - fi - # Validate: digits-and-dots with optional prerelease suffix - if ! echo "$VERSION" | grep -Eq '^[0-9A-Za-z.+-]+$'; then - echo "::error::Invalid version: $VERSION" - exit 2 - fi - echo "Version: $VERSION" - echo "version=$VERSION" >> "$GITHUB_OUTPUT" + packages.lock.json + packages.identity.lock.json + tests/packages.lock.json # -f selects the Windows TFM, which is what compiles in the COM backend (WINGET_COM) # and pulls the in-process WinGet engine into the publish folder. Without it, the @@ -89,41 +133,48 @@ jobs: -f net10.0-windows10.0.26100.0 -r ${{ matrix.rid }} -o publish/${{ matrix.rid }} - /p:Version=${{ steps.version.outputs.version }} + -p:RestoreLockedMode=true + /p:Version=${{ needs.validate-tag.outputs.version }} - # Download B: a signed MSIX whose package identity lets the AOT exe reach the in-box - # out-of-process WinGet server, shipping only the ~61 KB projection winmd (not the 7 MB - # in-process engine). See com-activation.md. Built pack-only here; signed by the next step. - - name: Build MSIX (download B) - continue-on-error: true # experimental; never block the exe/zip downloads + - name: Check signing configuration + id: signing + shell: pwsh + env: + AZURE_TENANT_ID: ${{ secrets.AZURE_TENANT_ID }} + AZURE_CLIENT_ID: ${{ secrets.AZURE_CLIENT_ID }} + AZURE_CLIENT_SECRET: ${{ secrets.AZURE_CLIENT_SECRET }} + AZURE_SIGNING_ENDPOINT: ${{ vars.AZURE_SIGNING_ENDPOINT }} + AZURE_SIGNING_ACCOUNT: ${{ vars.AZURE_SIGNING_ACCOUNT }} + AZURE_SIGNING_PROFILE: ${{ vars.AZURE_SIGNING_PROFILE }} + MSIX_PUBLISHER: ${{ vars.MSIX_PUBLISHER }} + run: | + $names = @('AZURE_TENANT_ID', 'AZURE_CLIENT_ID', 'AZURE_CLIENT_SECRET', + 'AZURE_SIGNING_ENDPOINT', 'AZURE_SIGNING_ACCOUNT', + 'AZURE_SIGNING_PROFILE', 'MSIX_PUBLISHER') + $present = @($names | Where-Object { -not [string]::IsNullOrWhiteSpace([Environment]::GetEnvironmentVariable($_)) }) + if ($present.Count -ne 0 -and $present.Count -ne $names.Count) { + throw "MSIX signing is partly configured. Configure all seven secrets/variables or none." + } + "enabled=$($present.Count -eq $names.Count)".ToLowerInvariant() >> $env:GITHUB_OUTPUT + + # The portable ZIP is the initial WinGet package. MSIX is optional until + # signing is configured, but a configured signing failure blocks release. + - name: Build MSIX + if: steps.signing.outputs.enabled == 'true' shell: pwsh run: | $arch = "${{ matrix.rid }}" -replace '^win-', '' # Appx Version must be 4-part numeric with no prerelease suffix. - $v = "${{ steps.version.outputs.version }}" -replace '[-+].*$', '' + $v = "${{ needs.validate-tag.outputs.version }}" -replace '[-+].*$', '' $p = @($v.Split('.')); while ($p.Count -lt 3) { $p += '0' } $msixVersion = ($p[0..2] -join '.') + '.0' $publisher = "${{ vars.MSIX_PUBLISHER }}" - if (-not $publisher) { $publisher = 'CN=wingetscout (Dev)' } Write-Host "MSIX version=$msixVersion publisher=$publisher arch=$arch" ./packaging/build-msix.ps1 -Arch $arch -Version $msixVersion -Publisher $publisher - # Sign the MSIX with Azure Trusted Signing when configured (see code-signing.md). vars.MSIX_PUBLISHER - # must equal the certificate subject. Skipped when secrets are absent — the resulting MSIX is then - # unsigned and installs only under Developer Mode, so configure signing before shipping B to users. - - name: Detect Azure Trusted Signing configuration - id: trusted-signing - shell: pwsh - env: - AZURE_CLIENT_ID: ${{ secrets.AZURE_CLIENT_ID }} - run: | - $configured = -not [string]::IsNullOrWhiteSpace($env:AZURE_CLIENT_ID) - "configured=$($configured.ToString().ToLowerInvariant())" >> $env:GITHUB_OUTPUT - - - name: Sign MSIX (Azure Trusted Signing) - if: steps.trusted-signing.outputs.configured == 'true' - continue-on-error: true - uses: azure/trusted-signing-action@v2.0.0 + - name: Sign MSIX (Azure Artifact Signing) + if: steps.signing.outputs.enabled == 'true' + uses: azure/trusted-signing-action@c7ab2a863ab5f9a846ddb8265964877ef296ee82 # v2.0.0 with: azure-tenant-id: ${{ secrets.AZURE_TENANT_ID }} azure-client-id: ${{ secrets.AZURE_CLIENT_ID }} @@ -137,10 +188,24 @@ jobs: timestamp-rfc3161: http://timestamp.acs.microsoft.com timestamp-digest: SHA256 + - name: Verify signed MSIX + if: steps.signing.outputs.enabled == 'true' + shell: pwsh + run: | + $msix = @(Get-ChildItem dist -Filter '*.msix') + if ($msix.Count -ne 1) { throw "Expected one MSIX for ${{ matrix.rid }}" } + $kit = "${env:ProgramFiles(x86)}\Windows Kits\10\bin" + $tool = Get-ChildItem $kit -Recurse -Filter signtool.exe | + Where-Object { $_.FullName -match '\\x64\\signtool\.exe$' } | + Sort-Object FullName -Descending | Select-Object -First 1 + if (-not $tool) { throw 'signtool.exe not found' } + & $tool.FullName verify /pa /v $msix[0].FullName + if ($LASTEXITCODE -ne 0) { throw 'MSIX signature verification failed' } + - name: Package artifacts shell: pwsh run: | - $version = "${{ steps.version.outputs.version }}" + $version = "${{ needs.validate-tag.outputs.version }}" $rid = "${{ matrix.rid }}" $bundleName = "wingetscout-$version-$rid" $exeSuffix = switch ($rid) { @@ -157,15 +222,22 @@ jobs: # only the zip below bundles. Copy-Item "publish/$rid/wingetscout.exe" "dist/wingetscout-$exeSuffix.exe" + foreach ($name in @('wingetscout.exe', 'WindowsPackageManager.dll', 'Microsoft.Management.Deployment.InProc.dll')) { + if (-not (Test-Path "publish/$rid/$name")) { throw "Native publish is missing $name" } + } + # Portable bundle: the full publish output (exe + the in-process WinGet COM # engine — WindowsPackageManager.dll + Microsoft.Management.Deployment.InProc.dll # — so the COM backend activates) + docs, in a zip. Exclude the .pdb (symbols only). - New-Item -ItemType Directory -Path "stage/$bundleName" -Force | Out-Null - Copy-Item "publish/$rid/*" "stage/$bundleName/" -Recurse -Exclude *.pdb - Copy-Item "README.md" "stage/$bundleName/" - Copy-Item "LICENSE" "stage/$bundleName/" - Copy-Item "feature-gaps.md" "stage/$bundleName/" - Compress-Archive -Path "stage/$bundleName" -DestinationPath "dist/$bundleName.zip" -Force + # Keep the directory inside the archive stable across versions. Scheduled + # update checks record the executable's absolute path and must survive a + # WinGet portable upgrade that replaces the extracted files. + New-Item -ItemType Directory -Path "stage/wingetscout" -Force | Out-Null + Copy-Item "publish/$rid/*" "stage/wingetscout/" -Recurse -Exclude *.pdb + Copy-Item "README.md" "stage/wingetscout/" + Copy-Item "LICENSE" "stage/wingetscout/" + Copy-Item "feature-gaps.md" "stage/wingetscout/" + Compress-Archive -Path "stage/wingetscout" -DestinationPath "dist/$bundleName.zip" -Force # Checksum manifest, alphabetized so diff is stable. $hashes = Get-ChildItem dist/* | @@ -179,8 +251,13 @@ jobs: Write-Host "Produced:" Get-ChildItem dist | ForEach-Object { Write-Host " $($_.Name) ($([math]::Round($_.Length / 1MB, 2)) MB)" } + - name: Attest release artifacts + uses: actions/attest@1e69f48acb82d1966a394da916b4c1698aa569d6 # v4 + with: + subject-path: dist/* + - name: Upload artifacts - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 with: name: ${{ matrix.rid }} path: dist/ @@ -188,69 +265,144 @@ jobs: release: name: Publish GitHub Release - needs: build + needs: [validate-tag, build] runs-on: ubuntu-latest + permissions: + contents: write steps: - - uses: actions/checkout@v7 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 - name: Download all artifacts - uses: actions/download-artifact@v8 + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8 with: path: artifacts/ merge-multiple: true - - name: List downloaded artifacts - run: ls -lh artifacts/ + - name: Verify required release assets + env: + VERSION: ${{ needs.validate-tag.outputs.version }} + shell: bash + run: | + set -euo pipefail + for arch in x64 arm64; do + test -s "artifacts/wingetscout-$arch.exe" + test -s "artifacts/wingetscout-$VERSION-win-$arch.zip" + test -s "artifacts/SHA256SUMS-win-$arch.txt" + done + ls -lh artifacts/ + + - name: Reject any release for this tag, including drafts + uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 + with: + script: | + const tag = context.ref.replace('refs/tags/', ''); + const releases = await github.paginate(github.rest.repos.listReleases, { + ...context.repo, per_page: 100, + }); + const existing = releases.find(release => release.tag_name === tag); + if (existing) { + core.setFailed(`Release already exists for ${tag} (draft=${existing.draft}): ${existing.html_url}. Refusing to mix release assets.`); + } - name: Compose release notes - env: - VERSION: ${{ needs.build.outputs.version }} run: | - cat > release-notes.md < release-notes.md <<'EOF' # Scout for WinGet Scout for WinGet manages real Windows packages. Install, upgrade, uninstall, and repair actions change your package state. See the [README](https://github.com/${{ github.repository }}#readme) for setup and usage. ## Downloads - Three ways to run it; pick by how much you want the COM features (Verify, Repair, install preview, richer detail, live progress) vs. zero-install simplicity. See [com-activation.md](https://github.com/${{ github.repository }}/blob/main/com-activation.md). - - | File | Backend | Install | Notes | - |------|---------|---------|-------| - | \`wingetscout-${VERSION}-{x64,arm64}.msix\` | **COM** | yes (signed) | **Download B** — package identity → COM, smallest footprint. Requires the package to be signed (see below). | - | \`wingetscout-${VERSION}-win-{x64,arm64}.zip\` | **COM** | no (portable) | Exe + in-process engine + docs. COM with no install, no signing — the no-fuss COM option. | - | \`wingetscout-{x64,arm64}.exe\` | CLI | no (portable) | **Download A** — single exe, \`winget\` CLI backend, no COM features. | - | \`SHA256SUMS-*.txt\` | — | — | Per-architecture checksums | - - ## Quick start + - Portable ZIP (`wingetscout--win-.zip`): the complete COM build. Extract the folder and run `wingetscout.exe`. + - Standalone EXE (`wingetscout-.exe`): uses the WinGet CLI backend because its COM companion files are not included. + - `SHA256SUMS-*.txt`: checksums for each architecture's files. - - **Just run it (CLI):** download the \`.exe\` for your architecture and run it. - - **COM, no install:** download the \`.zip\`, extract, run \`wingetscout.exe\` from the folder (the in-process engine sits beside it). - - **COM, installed (MSIX):** download the \`.msix\` and double-click. *Unsigned MSIX builds install only with Developer Mode enabled; a properly signed release installs normally.* - - \`\`\`powershell - # Verify a download (PowerShell) - Get-FileHash .\wingetscout-x64.exe -Algorithm SHA256 - # Compare against SHA256SUMS-win-x64.txt - \`\`\` + Portable downloads are currently unsigned. Compare the SHA-256 checksum before running a downloaded file. Build provenance attestations can be checked with `gh attestation verify -R harder/wingetscout`. + EOF + if compgen -G 'artifacts/*.msix' > /dev/null; then + cat >> release-notes.md <<'EOF' - The portable downloads may be unsigned. Verify the release and its SHA-256 checksum before approving a SmartScreen prompt or unblocking a downloaded file. + Signed MSIX packages are also available for installed, package-identity COM operation. EOF + fi - - name: Publish release - uses: softprops/action-gh-release@v3 + - name: Upload assets to a draft release + id: staged-release + uses: softprops/action-gh-release@efb35369e0ad2afab669f228072c1b0d510eae64 # v3 with: + draft: true + prerelease: ${{ contains(needs.validate-tag.outputs.version, '-') }} files: | - artifacts/*.exe - artifacts/*.zip - artifacts/*.msix - artifacts/SHA256SUMS-*.txt + artifacts/* body_path: release-notes.md generate_release_notes: true append_body: true - # false so an experimental MSIX that didn't build doesn't fail the release; the exe/zip/ - # sums always exist because this job only runs after `build` succeeds. - fail_on_unmatched_files: false + fail_on_unmatched_files: true + overwrite_files: false env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + + - name: Publish the complete draft + uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 + with: + script: | + const fs = require('fs'); + const path = require('path'); + const crypto = require('crypto'); + const release_id = Number('${{ steps.staged-release.outputs.id }}'); + if (!Number.isSafeInteger(release_id)) throw new Error('Draft release ID was not returned'); + const files = fs.readdirSync('artifacts', { withFileTypes: true }) + .filter(entry => entry.isFile()) + .map(entry => entry.name); + const assets = await github.paginate(github.rest.repos.listReleaseAssets, { + ...context.repo, release_id, per_page: 100, + }); + if (assets.length !== files.length) { + throw new Error(`Draft has ${assets.length} assets; expected ${files.length}`); + } + for (const name of files) { + const asset = assets.find(item => item.name === name); + if (!asset || asset.state !== 'uploaded') { + throw new Error(`Draft asset missing or incomplete: ${name}`); + } + const file = path.join('artifacts', name); + const digest = `sha256:${crypto.createHash('sha256').update(fs.readFileSync(file)).digest('hex')}`; + if (asset.size !== fs.statSync(file).size || asset.digest !== digest) { + throw new Error(`Draft asset differs from build artifact: ${name}`); + } + } + await github.rest.repos.updateRelease({ + ...context.repo, + release_id, + draft: false, + prerelease: '${{ contains(needs.validate-tag.outputs.version, '-') }}' === 'true', + }); + + - name: Download the published ZIPs + if: ${{ !contains(needs.validate-tag.outputs.version, '-') }} + env: + GH_TOKEN: ${{ github.token }} + VERSION: ${{ needs.validate-tag.outputs.version }} + shell: bash + run: | + set -euo pipefail + gh release download "v$VERSION" --pattern "wingetscout-$VERSION-win-*.zip" --dir published-assets + for arch in x64 arm64; do + cmp "artifacts/wingetscout-$VERSION-win-$arch.zip" "published-assets/wingetscout-$VERSION-win-$arch.zip" + done + + - name: Prepare WinGet manifests from the published ZIPs + if: ${{ !contains(needs.validate-tag.outputs.version, '-') }} + shell: pwsh + env: + VERSION: ${{ needs.validate-tag.outputs.version }} + run: ./packaging/new-winget-manifest.ps1 -Version $env:VERSION -AssetDirectory published-assets -OutputDirectory winget-manifests + + - name: Save WinGet submission files + if: ${{ !contains(needs.validate-tag.outputs.version, '-') }} + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 + with: + name: winget-manifests-${{ needs.validate-tag.outputs.version }} + path: winget-manifests/ + retention-days: 90 diff --git a/.gitignore b/.gitignore index 3ebdfcd..65ea102 100644 --- a/.gitignore +++ b/.gitignore @@ -3,6 +3,8 @@ bin/ obj/ publish/ dist/ +/packaging/release-assets/ +/packaging/winget-manifests/ *.nupkg *.snupkg diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 0a08bde..554cc7f 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -24,6 +24,18 @@ The repository selects Microsoft.Testing.Platform in `global.json`. Pass the pro `--project` as shown above; the older positional `dotnet test tests/...csproj` form is not accepted by this runner. IDE test discovery likewise requires Microsoft.Testing.Platform support. +The same file pins the .NET SDK for reproducible locked restores. When updating that pin +or the WinGet COM packages, install the new SDK and regenerate both application modes: + +```powershell +dotnet restore tests/WinGetScout.Tests.csproj --use-lock-file +dotnet restore WinGetScout.csproj -p:WingetComMode=Identity --use-lock-file +``` + +Commit `packages.lock.json`, `packages.identity.lock.json`, and +`tests/packages.lock.json` if they changed. CI reads the SDK version from `global.json` +and restores both portable and MSIX Identity graphs in locked mode. + ## Filing issues - **Bugs**: include the failing scenario, OS + architecture (x64 vs arm64), and where possible a `--dump` trace (e.g. `wingetscout --dump search vscode > dump.txt`). diff --git a/README.md b/README.md index dc8cc06..974f3a7 100644 --- a/README.md +++ b/README.md @@ -10,6 +10,10 @@ **Recommended: download the portable ZIP** from [GitHub Releases](https://github.com/harder/wingetscout/releases). It includes `wingetscout.exe` and the companion files needed for the full WinGet COM experience. No .NET runtime or installer is required. +A WinGet community package is being prepared as `Harder.WinGetScout`. Use the release ZIP until the [community submission](packaging/WINGET-SUBMISSION.md) is accepted. + +After that package is accepted, `winget install -e --id Harder.WinGetScout` will install the ZIP into WinGet's managed portable directory and add `wingetscout` to your user `PATH`. Open a **new terminal** to use the command from any folder. The WinGet manifest already requests this behavior; it does not require a separate installer or manual PATH editing. WinGet verifies the downloaded ZIP hash, but it does not sign the executable. See [signing and distribution](code-signing.md). + > **Release availability:** The Scout filenames below will appear with the first Scout release. If they are not on the Releases page yet, Scout has not been published; see [Build from source](#build-from-source) to try the current code. 1. Choose the ZIP for your Windows PC: @@ -31,9 +35,9 @@ Scout requires Windows 10 or 11 and [WinGet](https://learn.microsoft.com/windows ### Other downloads - **Single executable:** `wingetscout-x64.exe` or `wingetscout-arm64.exe` is the smallest portable option. It runs through the `winget` CLI because the COM companion files are not included. Run the downloaded filename directly, for example `.\wingetscout-x64.exe`. -- **MSIX:** If a **signed** `wingetscout--.msix` is available, double-click it to install Scout as a Windows app. Unsigned MSIX packages require Developer Mode; use the portable ZIP for a straightforward setup. +- **MSIX later:** A signed `wingetscout--.msix` may be offered after public-trust signing is configured and tested. It is a separate package-identity installation option; the first WinGet submission uses ZIP. -Portable downloads may be unsigned. If Windows shows a SmartScreen warning, verify that the file came from this repository's release page and compare its SHA-256 hash with the release's `SHA256SUMS` file before choosing **More info → Run anyway**. You can also right-click the downloaded file, open **Properties**, and select **Unblock** when Windows offers it. See [code signing](code-signing.md) for the current signing status. +Portable downloads are currently unsigned. If Windows shows a SmartScreen warning, verify that the file came from this repository's release page and compare its SHA-256 hash with the release's `SHA256SUMS` file before choosing **More info → Run anyway**. You can also right-click the downloaded file, open **Properties**, and select **Unblock** when Windows offers it. See [code signing](code-signing.md) for the current signing status. ## What you can do diff --git a/WinGetScout.csproj b/WinGetScout.csproj index 7389ca5..d20415d 100644 --- a/WinGetScout.csproj +++ b/WinGetScout.csproj @@ -43,6 +43,8 @@ out-of-process server. This is what the signed MSIX (download B) ships. --> InProc + + $(MSBuildProjectDirectory)/packages.identity.lock.json