Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions .github/copilot-instructions.md
Original file line number Diff line number Diff line change
@@ -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<T>` 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.
29 changes: 24 additions & 5 deletions .github/dependabot.yml
Original file line number Diff line number Diff line change
@@ -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']

Expand All @@ -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
132 changes: 132 additions & 0 deletions .github/scripts/critical-dependencies.js
Original file line number Diff line number Diff line change
@@ -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 = /<PackageReference Include="Terminal\.Gui" Version="([^"]+)"/.exec(project);
if (!match) throw new Error('Terminal.Gui PackageReference was not found');
const current = developVersion(match[1]);
if (!current) throw new Error(`Expected a Terminal.Gui develop prerelease, got ${match[1]}`);

const response = await fetch('https://api.nuget.org/v3-flatcontainer/terminal.gui/index.json', {
signal: AbortSignal.timeout(15000),
});
if (!response.ok) throw new Error(`NuGet returned HTTP ${response.status}`);
const { versions } = await response.json();
const candidates = versions
.map(version => ({ 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;
55 changes: 55 additions & 0 deletions .github/scripts/critical-dependencies.test.js
Original file line number Diff line number Diff line change
@@ -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: '<PackageReference Include="Terminal.Gui" Version="2.5.1-develop.44" />',
};
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;
}
});
84 changes: 70 additions & 14 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -20,26 +20,36 @@ 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
needs: workflow-lint
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
Expand Down Expand Up @@ -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
Expand All @@ -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: >
Expand Down Expand Up @@ -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" }
Loading
Loading