Background

I came across an interesting question recently. How does one execute a PowerShell script in a Visual Studio project's pre- or post-build event, and get precise error message feedback when errors occur.

Part of the problem is that errors seem to get "swallowed" in the PowerShell script. Output can be directed to the console as the post-build event occurs, but nothing gets sent to the error window, and the build is not interrupted on error.

My initial inclination was to simply use PowerShell error trapping and exit() codes in the PowerShell script to classify errors, but that hardly pleases the imagination. At least it provides build termination if you call exit() with any non-zero value, but it lacks precision.

When compilation errors occur in C# code, Visual Studio provides line numbers and column positions of the error, with the handly double-click jump-to-error goodness that makes tracking down the error at least a little less tedious. Using exit() codes does not provide the same quality of service for PowerShell scripts.

So, in an attempt to find the grail, I developed a small MSBuild task for executing PowerShell scripts and logging their errors for display in Visual studio. Here is how it was done.

Sample Project: Noc.PsBuild.zip (9.35 KB)

Creating The MSBuild Task

(The sample project uses Visual Studio 2008 and targets the .NET Framework 3.5. I believe the solution can be adapted forward to VS 2010 and backward to VS 2005. The MSBuild task also requires PowerShell 2.0.)

The project references the following assemblies for MSBuild and for PowerShell automation:

  • Microsoft.Build.Framework (GAC)
  • Microsoft.Build.Utilities.v3.5 (GAC)
  • System.Management.Automation (from C:\Program Files\Reference Assemblies\Microsoft\WindowsPowerShell\v1.0)

Executing a PowerShell script from an MSBuild task turned out to be fairly easy. I started with a task skeleton that includes a property to specify the path to the PowerShell script.

using System.IO;
using System.Management.Automation;
using System.Management.Automation.Runspaces;
using Microsoft.Build.Framework;
using Microsoft.Build.Utilities;

public class PsBuildTask : Task
{
   
[Required]
   
public string ScriptPath { get; set; }

   
public override bool Execute()
   
{
       
// ...
   
}
}

The class inherits from Task, which provides all the infrastructure for generating compiler errors and warnings via the Log property. The Execute() method should return false if the task is considered to fail. In this case, it should return false if a script contains errors. All that remains is to fire up the PowerShell runtime and execute the specified script.

The following code snippet starts the PowerShell runtime, adds a dot-sourcing command to the pipeline, executes the pipeline, and retrieves the errors.

// create Powershell runspace
Runspace runspace = RunspaceFactory.CreateRunspace();
runspace
.Open();

// create a pipeline and feed it the script text
Pipeline pipeline = runspace.CreatePipeline();
pipeline
.Commands.AddScript(". " + ScriptPath);

// execute the script and extract errors
pipeline
.Invoke();
var errors = pipeline.Error;

The errors object can read errors from the pipeline, if they exist, using one of the Read() overloads. The next snippet iterates through the errors, if any, then retrieves the error record and logs to the Log utility. This particular LogError() overload allows the specification of which file, line, and column is responsible for the error. That is key to getting the double-click send-me-to-the-error behavior in Visual Studio.

// log an MSBuild error for each error.
foreach (PSObject error in errors.Read(errors.Count))
{
   
var invocationInfo = ((ErrorRecord)(error.BaseObject)).InvocationInfo;
   
Log.LogError(
       
"Script",
       
string.Empty,
       
string.Empty,
       
new FileInfo(ScriptPath).FullName,
        invocationInfo
.ScriptLineNumber,
        invocationInfo
.OffsetInLine,
       
0,
       
0,
        error
.ToString());
}

Finally, close the runtime and return true if there were no errors logged.

// close the runspace
runspace
.Close();

return !Log.HasLoggedErrors;

That's it for the MSBuild task. Now, to use the task, it must either be installed to the GAC, or placed in a known location. Once that is established, other projects can be configured to consume this new task.

Consuming the MSBuild Task

Consider, for example, a C#-based class library project (.csproj). Integrating the task in a post build event requires just a few things.

First, register the task just inside the <Project> node of the .csproj file like so:

<UsingTask TaskName="PsBuildTask"
           
AssemblyFile="..\Noc.PsBuild\bin\Debug\Noc.PsBuild.dll" />

TaskName should be the name of the task, though it appears namespace is not required. Assembly file is an absolute path to the custom MSBuild task assembly, or relative path with respect to the .csproj file. For assemblies in the GAC, you can use the AssemblyName attribute instead.

Once registered, the task can be used within pre- and post-build events. Configure a build event within the <Project> element of the .csproj file like so:

<Target Name="AfterBuild">
   
<PsBuildTask ScriptPath=".\script.ps1" />
</Target>

And that's it. When Visual Studio compiles the project, it loads the custom assembly and task object and executes the task. Errors raised by the pipeline are retrieved and reported.

Now, a final note about the sample project. Visual Studio has a nasty habit of locking the Noc.PsBuild assembly each time Noc.PsBuild.Demo builds. That means that to rebuild this solution more than once requires restarting Visual Studio. This is all well and good for a demo, but of course this project structure, where the build task is in the same solution as a dependent assembly, is not ideal for real development. Also, the solution has only the "Works on My Machine" seal of approval. There could be gotchas, and it certainly could be extended to be even more flexible. Also, the script.ps1 file may be blocked if your execution policy is RemoteSigned. You'll need to unblock it or relax the policy to get successful execution. Good luck with it.

Happy coding!


 
Tuesday, September 14, 2010 4:53:28 PM (Mountain Standard Time, UTC-07:00)
Just wanted to thank you for taking the time to go this in-depth for my question on SO. This is an excellent example. :)
Aren
Comments are closed.