An ASP.NET MVC 5 application on .NET Framework 4.8 needs a repeatable build long before it needs automated production deployment. A good first workflow restores packages, compiles the solution, runs the existing tests and produces a web deployment package someone can inspect.

This is a setup guide built from official documentation that I re-read on November 2, 2026, and the workflow below is not just a sketch: I ran it unchanged against a minimal public sample, legacy-mvc48-ci-sample, an MVC 5 app on .NET Framework 4.8 with two MSTest tests. That run on windows-2022 restored, built, passed 2 of 2 tests, produced the package and uploaded both artifacts. A real application will have more projects and dependencies, so the paths and names are still yours to adapt.

The Windows runner

GitHub's .NET build and test guide is written around the dotnet CLI, which fits SDK-style projects. A classic MVC 5 web project with packages.config is usually built with Visual Studio's MSBuild instead, so the runner matters.

The Windows Server 2022 runner inventory, image version 20261026.347.1 when I checked, lists Visual Studio Enterprise 2022, the MSBuild component, vswhere and .NET Framework 4.7.2, 4.8 and 4.8.1. That inventory changes over time, so record the image version shown in each workflow run.

Write down the repository contract first

Before touching YAML, list the solution path, the web project, the test projects, the build configuration and what the package should be called. Also list everything that only exists on a developer's machine: private NuGet feeds, DLLs referenced from a local folder, generated files, custom build tasks. Each one either gets an authorized way into CI or becomes an explicit blocker. That list is usually where legacy builds fail, not in the YAML.

Restore packages.config dependencies

Microsoft's nuget restore reference accepts a solution file, so nuget restore LegacyMvc.sln restores every packages.config in it. Keep it as its own step so a restore failure is not confused with a compile error.

NuGet/setup-nuget puts nuget.exe on the runner; its latest release on November 2 was v4.0. nuget-version: latest is convenient for a first run, and the README also accepts version ranges such as 5.x, so pin one once the build works. If you use private feeds, supply credentials through secrets and the feed's documented method, never by committing a developer's NuGet.Config.

Build, test, package

microsoft/setup-msbuild finds MSBuild with vswhere and adds it to PATH. Its README is explicit that it "does NOT add other Visual Studio tools (like VSTest…)" to PATH, so the test step below locates vstest.console.exe itself. The MSBuild switches used here (/m, /t, /p) are in the MSBuild command-line reference.

For packaging, Microsoft's guide to building and packaging web application projects shows DeployOnBuild=true with DeployTarget=Package: after the build, the Web Publishing Pipeline runs its Package target and produces [project name].zip, plus a .deploy.cmd and a SetParameters.xml next to it. The template searches for that zip instead of assuming a folder.

Tested on the sample repository above, on November 2, 2026. Save an adapted copy as .github/workflows/legacy-mvc.yml. The action versions are the current major tags from each project's README or releases page; for stricter supply-chain control, pin reviewed commit SHAs.

name: Legacy MVC CI on: pull_request: push: branches: [main] workflow_dispatch: permissions: contents: read jobs: build: runs-on: windows-2022 defaults: run: shell: pwsh steps: - uses: actions/checkout@v7 - uses: microsoft/setup-msbuild@v3 - uses: NuGet/setup-nuget@v4 with: nuget-version: latest - name: Restore packages.config dependencies run: | nuget restore LegacyMvc.sln -NonInteractive if ($LASTEXITCODE -ne 0) { throw 'Restore failed' } - name: Build solution run: | msbuild LegacyMvc.sln /m /t:Build /p:Configuration=Release '/p:Platform=Any CPU' if ($LASTEXITCODE -ne 0) { throw 'Build failed' } - name: Run existing tests run: | $vswhere = Join-Path ${env:ProgramFiles(x86)} 'Microsoft Visual Studio\Installer\vswhere.exe' $vs = & $vswhere -latest -products '*' -property installationPath $vstest = Join-Path $vs 'Common7\IDE\Extensions\TestPlatform\vstest.console.exe' $tests = 'tests\LegacyMvc.Tests\bin\Release\LegacyMvc.Tests.dll' if (!(Test-Path $vstest)) { throw 'vstest.console.exe not found' } if (!(Test-Path $tests)) { throw 'Test assembly not found' } & $vstest $tests '/Logger:trx;LogFileName=tests.trx' '/ResultsDirectory:TestResults' if ($LASTEXITCODE -ne 0) { throw 'Tests failed' } [xml]$trx = Get-Content 'TestResults\tests.trx' -Raw if ([int]$trx.TestRun.ResultSummary.Counters.total -lt 1) { throw 'No tests were discovered' } - name: Create web deployment package run: | msbuild src\LegacyMvc\LegacyMvc.csproj /p:Configuration=Release /p:DeployOnBuild=true /p:DeployTarget=Package if ($LASTEXITCODE -ne 0) { throw 'Packaging failed' } $zip = Get-ChildItem src\LegacyMvc -Recurse -Filter 'LegacyMvc.zip' | Select-Object -First 1 if (!$zip) { throw 'LegacyMvc.zip not found' } New-Item -ItemType Directory -Force artifacts | Out-Null Get-ChildItem $zip.DirectoryName | Copy-Item -Destination artifacts - name: Upload package uses: actions/upload-artifact@v7 with: name: legacy-mvc-package path: artifacts/ if-no-files-found: error - name: Upload test results if: always() uses: actions/upload-artifact@v7 with: name: legacy-mvc-test-results path: TestResults/ if-no-files-found: warn 

Make "tests ran" an actual check

The VSTest.Console options cover the test assembly arguments, /Logger and /TestAdapterPath. MSTest, NUnit and xUnit each need their adapter available to the runner; if discovery fails on CI but works locally, /TestAdapterPath is the first thing to look at. In the sample, MSTest 4.5.2 installed through packages.config was discovered without it.

The template reads the TRX file and fails when zero tests were reported, because a test step that exits cleanly after discovering nothing looks green and proves nothing. If the repository has no tests, say so in the handover instead of advertising coverage. And remember what unit tests don't cover: IIS configuration, the real database and browser behavior need their own checks.

Inspect the package before trusting it

Download the artifact and open it. The same Microsoft guide explains that SetParameters.xml is generated from the project and can include connection strings found in web.config, so this is exactly where a production connection string can leak into an artifact. A zip that exists is a good sign, not proof that it deploys.

If the project already has reviewed publish settings, you can keep them in a profile under Properties/PublishProfiles/ and select it with PublishProfile, as Microsoft's command-line deployment tutorial does. That tutorial lists three command-line publish methods, MSDeploy, Package and FileSystem; for CI you want the profile to use Package, not publish straight to a server. It was written for Visual Studio 2012 and notes that building a single web project then also needed /p:VisualStudioVersion, so if MSBuild cannot find the web application targets, that property is worth checking.

Keep production behind a separate gate

No passwords, tokens or production connection strings in the YAML or in committed publish profiles. When you add a deployment job later, point it at a protected environment with required reviewers, which GitHub documents in its deployments and environments reference. Naming an environment in YAML does not create the approval rule by itself. Check your plan, too: the same page says that on GitHub Free, Pro and Team, required reviewers "are only available for public repositories", and most legacy business apps live in private ones.

The first milestone is modest and useful: a package you inspected and test results you can read. Production deployment comes after staging works, access is approved and there is a rollback plan.

If you have an older ASP.NET MVC or .NET Framework app that needs a build like this, or a focused fix, I offer a fixed-scope service on Upwork: https://www.upwork.com/services/product/2117249840584640174