The Problem
One of the challenges faced when developing scripts that will be used from other scripts is keeping the library script updated. Frequently the file is copied from the folder it was developed in to the folders containing the scripts that depend on it. This is a good way to make sure that the dependency follows the main script if it is moved, but creates multiple copies of the library script that are unlikely to be updated if a bug is fixed or an improvement made.
The Goal
Ideally, there would be a way to link the library script to the dependent scripts, without actually having to keep a copy of it in the folder. This would allow a single place to update all of the scripts and still maintain the dependencies with the main scripts.
The Solution - MSBuild
This solution will establish a workspace where all development of scripts occurs and then use MSBuild (included in the .NET framework) to deploy the scripts to a release folder. The build process will copy all of the dependencies of the scripts into their local folders so the folder can then be moved to another computer and all of the dependencies will be packaged together.
This also works well if there is a source control system maintaining the master copy of all the scripts. The workspace can be set as the base folder for the source control system and then only a single copy of each file is included in source control.
Environment and Directory Layout
The easiest way to run a build is to add msbuild.exe
to the PATH environment variable and call it from a folder containing a .proj
file. This will invoke the default target in the .proj
file and doesn't require any parameters to be passed to msbuild. Msbuild.exe
is included in the v2.0.50727 and v3.5 framework folders, usually located at C:\windows\Microsoft.NET\Framework
. Here is a batch file that can be run easily from explorer to run a build in its current folder:
@echo off</span><br />
set path=%path%;c:\windows\Microsoft.Net\Framework\v3.5;<br />
msbuild<br />
pause
Here is some powershell that will locate the path for .NET 3.5 for you:
The variable $ws
will be used to represent the workspace throughout this post. This is where the master copy of the scripts are kept (if using a source control system, this would be where files are checked out to). As an example, it could be set as follows:
The following folder structure is used within the workspace:
-
$ws\Release
This will be the default output folder and is excluded from source control. This folder is designed to be copied to c:\scripts and to have
c:\scripts\modules
added to your$env:PSModulePath
. It will be automatically created by the MSBuild target. -
$ws\ThirdPartyLibs
This is for 3rd party libraries, like binaries or .NET assemblies and is under source control. There will be a separate folder under here for each library that will be linked to from scripts and modules.
-
$ws\src
This is where working copies of scripts and modules are saved and is under source control.
-
$ws\src\Module
Each module gets a new folder under
$ws\src\Modules\<modulename>
-
$ws\src\Scripts
Single scripts can go in this root, or collections of scripts can be put in sub-folders here.
Configure the Main MSBuild Project File
The main build process is controlled by a MSBuild project file in the workspace root, $ws\msbuild.proj
. This file is configured to search the $ws\src\Modules\*
folders and $ws\src\Scripts\*
folders for any msbuild.proj
files (recursing only one folder deep) and run the default target for each of those files. It also overrides the OutputDirectory
property of each of these build files to force the build output to $ws\Release\Modules
for modules or $ws\
for scripts.
The contents of $ws\msbuild.proj
are:
<Project DefaultTargets="Build" xmlns="http://schemas.microsoft.com/developer/msbuild/2003">
<PropertyGroup>
<OutputDirectory>$(MSBuildProjectDirectory)\Release</OutputDirectory>
<ModuleOutputDirectory>$(OutputDirectory)\Modules</ModuleOutputDirectory>
<ScriptOutputDirectory>$(OutputDirectory)</ScriptOutputDirectory>
</PropertyGroup>
<ItemGroup>
<ModuleBuilds Include="src\Modules\*\msbuild.proj" />
<ScriptBuilds Include="src\Scripts\*\msbuild.proj" />
<ScriptFiles Include="src\Scripts\**\*.*" />
</ItemGroup>
<Target Name="ModuleBuild">
<MSBuild
Projects="@(ModuleBuilds)"
Properties="OutputDirectory=$(ModuleOutputDirectory)"
/>
</Target>
<Target Name="ScriptBuild">
<Copy
SourceFiles="@(ScriptFiles)"
DestinationFiles="@(ScriptFiles->'$(ScriptOutputDirectory)\%(RecursiveDir)%(Filename)%(Extension)')"
/>
<MSBuild
Projects="@(ScriptBuilds)"
Properties="OutputDirectory=$(ScriptOutputDirectory)\%(RecursiveDir)"
/>
</Target>
<Target Name="Build">
<CallTarget Targets="ModuleBuild;ScriptBuild" />
</Target>
<Target Name="Clean">
<RemoveDir
Directories="$(OutputDirectory)"
/>
</Target>
</Project>
This file can be invoked by calling msbuild.exe
with your current directory set to your workspace ($ws
), or by creating a file $ws\build.cmd
with the contents from above in it and double clicking on it.
This build file defines a base output directory as "a folder called 'Release' that is a sub-folder of the parent folder of the build file" and saves it in the OutputDirectory
parameter. This can be overridden on the command line using msbuild.exe /p:OutputDirectory=c:\scripts
. It also defines the parameters 'ModuleOutputDirectory' as a 'Modules' sub-folder of the main output directory and 'ScriptOutputDirectory' as the same folder as the main output directory.
This source folder structure:
$ws\src\Scripts\script1.ps1
$ws\src\Scripts\Nested\nestedScript1.ps1
$ws\src\Modules\Module1\Module1.psm1
Will be output as follows:
$ws\Release\script1.ps1
$ws\Release\Nested\nestedScript1.ps1
$ws\Release\Modules\Module1\Module1.psm1
As you can see, this allows the contents of $ws\Release
to be directly copied to c:\scripts
, or you can use the command line parameter above to set the output to c:\scripts
directly.
Including a Custom MSBuild Project for a Module or Script Folder
The whole point of this exercise is to allow a script or third party assembly/executable to be included in a module or script folder without having to create multiple copies of it in the source control system, but so far all we have is a way to recreate the directory structure of the $ws\src\scripts
folder. The magic of using MSBuild to accomplish this is in chaining project definitions contained in each module or script sub-folder to the main build definition.
To add pscp.exe
from $ws\ThirdPartyLibs\PuTTY
to the scripts contained in $ws\src\Scripts\Linux
, create the following msbuild.proj
in $ws\src\Scripts\Linux
:
<Project DefaultTargets="Build" xmlns="http://schemas.microsoft.com/developer/msbuild/2003">
<ItemGroup>
<tplibs Include="..\..\..\ThirdPartyLibs\PuTTY\pscp.exe" />
</ItemGroup>
<Target Name="Build">
<Copy
SourceFiles="@(tplibs)"
DestinationFolder="$(OutputDirectory)"
/>
</Target>
</Project>
Since the entire contents of $ws\src\Scripts
will be copied to the output folder by the main msbuild.proj
file ($ws\msbuild.proj
), all that is needed in the $ws\src\Scripts\Linux\msbuild.proj
file is any dependency that needs to be added from outside of this folder. This could even include scripts from other folders if you have some utility scripts that are used by other scripts. If there are no outside dependencies, a msbuild.proj
file does not need to be created.
Here is another example that copies $ws\src\Scripts\new-share.ps1
and $ws\src\Scripts\DNS\set-cname.ps1
to $ws\src\Scripts\IIS
so that a script located there (maybe new-iissite.ps1
) can use them to create a share and add a new CNAME record to DNS for the site:
<Project DefaultTargets="Build" xmlns="http://schemas.microsoft.com/developer/msbuild/2003">
<ItemGroup>
<tplibs Include="..\new-share.ps1;..\DNS\set-cname.ps1" />
</ItemGroup>
<Target Name="Build">
<Copy
SourceFiles="@(tplibs)"
DestinationFolder="$(OutputDirectory)"
/>
</Target>
</Project>
Now there is only one copy of new-share.ps1
and set-cname.ps1
in the source control system, but they are copied to the IIS
folder to fulfill dependencies for the new-iissite.ps1
file in that folder.
Modules will use a similar technique, but the msbuild.proj files have been crafted slightly differently to allow a module to be built independently. The script version of the msbuild.proj file could be modified to work like modules or vice-versa if so desired.
Here is the contents of $ws\src\Modules\Linux\msbuild.proj
that will be used to copy the module and add $ws\ThirdPartyLibs\PuTTY\pscp.exe
to this module:
<Project DefaultTargets="Build" xmlns="http://schemas.microsoft.com/developer/msbuild/2003">
<PropertyGroup>
<ModuleName>Linux</ModuleName>
<OutputDirectory>..\..\..\Release\Modules</OutputDirectory>
</PropertyGroup>
<ItemGroup>
<tplibs Include="..\..\..\ThirdPartyLibraries\PuTTY\pscp.exe" />
<ModuleFiles Include="**\*.*" Exclude="**\msbuild.proj" />
</ItemGroup>
<Target Name="Build">
<Copy
SourceFiles="@(tplibs)"
DestinationFolder="$(OutputDirectory)\$(ModuleName)"
/>
<Copy
SourceFiles="@(ModuleFiles)"
DestinationFolder="$(ModuleFiles->'$(OutputDirectory)\$(ModuleName)\%(RecursiveDir)%(Filename)%(Extension)')"
/>
</Target>
<Target Name="Clean">
<RemoveDir
Directories="$(OutputDirectory)\$(ModuleName)"
/>
</Target>
</Project>
Placing the recursive file copy task in the module's msbuild.proj
file and including a default OutputDirectory
property allows the module to be built alone. The msbuild.proj
file that was used in a script folder above only copied dependencies. The module version could be used in a script folder, allowing that script folder to be built on its own, but then the full build process would be doing double file copies.
Summary
This post shows two examples of how to configure a simple MSBuild project to enable a single master copy of scripts to be maintained and then meshed together to build folders with local copies of any dependencies for each script. MSBuild is a very robust build system and these examples can be extended to do much more.
Additional Resources
The following links were helpful to me in learning the necessary MSBuild syntax to write these build scripts.