<?xml-stylesheet type="text/xsl" href="http://feeds.feedblitz.com/feedblitz_rss.xslt"?>
<rss xmlns:content="http://purl.org/rss/1.0/modules/content/"  xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xmlns:xsd="http://www.w3.org/2001/XMLSchema" xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:trackback="http://madskills.com/public/xml/rss/module/trackback/" xmlns:pingback="http://madskills.com/public/xml/rss/module/pingback/" xmlns:webfeeds="http://webfeeds.org/rss/1.0" xmlns:wfw="http://wellformedweb.org/CommentAPI/" xmlns:slash="http://purl.org/rss/1.0/modules/slash/" version="2.0" xmlns:feedburner="http://rssnamespace.org/feedburner/ext/1.0"><channel><webfeeds:logo>https://www.hanselman.com/blog/images/zenicon.jpg</webfeeds:logo><webfeeds:analytics id="UA-130207-1" engine="GoogleAnalytics" /><title>Scott Hanselman's Blog</title><link>https://www.hanselman.com/blog/</link><description>Scott Hanselman on Programming, User Experience, The Zen of Computers and Life in General</description><image>
	<url>https://www.hanselman.com/blog/images/tinyheadshot2.jpg</url>
	<title>Scott Hanselman's Blog</title>
	<link>https://www.hanselman.com/blog/</link>
</image><copyright>Scott Hanselman</copyright><lastBuildDate>Thu, 27 Aug 2026 15:25:48 GMT</lastBuildDate><managingEditor>scott@hanselman.com</managingEditor><webMaster>scott@hanselman.com</webMaster>
<meta xmlns="http://www.w3.org/1999/xhtml" name="robots" content="noindex" />
<item xml:lang="en-us">
<feedburner:origLink>https://www.hanselman.com/blog/debugging-my-new-network-when-10-gigabit-ethernet-runs-at-300-megabits</feedburner:origLink><trackback:ping>https://www.hanselman.com/blog/feed/trackback/a2a687b7-45ce-46fb-aa23-bd2a8cf810b5</trackback:ping><pingback:server>https://www.hanselman.com/blog/feed/pingback</pingback:server><pingback:target>https://www.hanselman.com/blog/post/a2a687b7-45ce-46fb-aa23-bd2a8cf810b5</pingback:target><dc:creator /><wfw:comment>https://feeds.feedblitz.com/~/968351264/0/scotthanselman~Debugging-my-new-network-when-Gigabit-Ethernet-Runs-at-Megabits/comments#comments-start</wfw:comment><wfw:commentRss>https://www.hanselman.com/blog/feed/rss/comments/a2a687b7-45ce-46fb-aa23-bd2a8cf810b5</wfw:commentRss><title>Debugging my new network, when 10 Gigabit Ethernet Runs at 300 Megabits</title><guid isPermaLink="false">https://www.hanselman.com/blog/post/a2a687b7-45ce-46fb-aa23-bd2a8cf810b5</guid><link>https://feeds.feedblitz.com/~/968351264/0/scotthanselman~Debugging-my-new-network-when-Gigabit-Ethernet-Runs-at-Megabits</link><pubDate>Thu, 27 Aug 2026 15:25:48 GMT</pubDate><description><![CDATA[<div><p>I've been moving my home storage over to a UniFi UNAS Pro 8 as <a href="/blog/migrating-a-synology-nas-to-a-unifi-unas-pro-8-with-robocopy-smb-multichannel-and-surprising-performance-traps">part of a larger homelab moderization</a>. My main IRONHEART (the ultimate PC from a few years back) desktop now has an Intel E610-XT2 10GbE card, the NAS is on 10GbE, and there's a Minisforum MS-01 miniPC on the same network with a 10GbE SFP+ connection running Immich and Portainer and a few other things.</p>
<p>Everything says 10 gigabit. Windows says 10 gigabit. UniFi says 10 gigabit. SMB copies are using the correct NIC but my file copies are running at around <strong>100 to 200 megabits per second which is sad making.</strong></p>
<p>Naturally, I blamed the NAS, and the spinning rust within. The UNAS has six 16 TB spinning disks in RAID 6 and a pair of NVMe SSDs being used as cache. I'm also running Immich on the MS-01, with its photo library living on the UNAS, so there are lots of thumbnails, metadata reads, and little background writes happening. All seem like reasonable suspects.</p>
<p>I switched the UNAS SSD cache from read-write to read-only. No meaningful difference. I stopped Immich completely. No difference. I looked at <code>iostat</code>; the disks weren't saturated. We looked at SMB signing and Windows Defender network scanning. Still slow.</p>
<p>Then I stopped testing the NAS and ran <code>iperf3</code> directly between the Windows desktop and the MS-01:</p>
<pre><code>iperf3 -c 192.168.1.222 -P 4</code></pre>
<p><strong>133 Mbit/sec</strong></p>
<p>Oops. The reverse test was better, but still wrong:</p>
<pre><code>iperf3 -c 192.168.1.222 -P 4 -R</code></pre>
<p><strong>1.33 Gbit/sec</strong></p>
<p>That's weird. Now the disks, SMB, Immich, RAID, and the NAS itself were completely out of the equation. This was a Windows/NIC problem and it's weirdly asymmetrical.</p>
<p>Looking at the Intel adapter statistics shows me...</p>
<pre><code>Get-NetAdapterStatistics -Name "Ethernet - 10 Gig Intel"</code></pre>
<p>There were nearly a million <code>ReceivedDiscardedPackets</code>. During one ten-second <code>iperf3</code> test, the counter increased by another 268. Why?</p>
<p>The E610 driver had its receive buffers at the default <strong>512</strong>, although it supported up to 4096. I increased them. I love an increased buffer.</p>
<pre><code>Set-NetAdapterAdvancedProperty `
  -Name "Ethernet - 10 Gig Intel" `
  -DisplayName "Receive Buffers" `
  -DisplayValue "4096"</code></pre>
<p>The discarded-packet count during the next test went from <strong>268 to zero</strong>, and receive throughput jumped from <strong>1.33 Gbit/sec to 5.15 Gbit/sec</strong>. The transmit direction was still terrible, basically <strong>313 Mbit/sec</strong>. The next experiment was disabling Large Send Offload (LSO) V2 for IPv4:</p>
<pre><code>Set-NetAdapterAdvancedProperty `
  -Name "Ethernet - 10 Gig Intel" `
  -DisplayName "Large Send Offload V2 (IPv4)" `
  -DisplayValue "Disabled"</code></pre>
<p>Then I ran the same <code>iperf3</code> test again.</p>
<p><strong>7.03 Gbit/sec</strong></p>
<p>That's not a typo. <strong>313 Mbit/sec to 7.03 Gbit/sec by changing one NIC setting.</strong> Sweet sassy molassey.</p>
<p>LSO exists for a good reason: Windows can hand large TCP buffers to the NIC and let the adapter/driver segment them into network-sized packets, reducing CPU work. Microsoft does, however, explicitly point out that segmentation offload can reduce maximum sustainable throughput with some network adapters/configurations. LSO is usually useful, but not in this case.</p>
<p>In my particular combination of Windows and the Intel E610-XT2, something in the IPv4 LSO path was very, very sad. I don't yet know whether this is an Intel driver bug, firmware issue, Windows interaction, or something particular to this machine, so I wouldn't turn this into random tech blogger advice that everyone should disable LSO. <strong>Measure first, cut once. Er, twice. Just stay woke.</strong></p>
<p>Finally I went back to the test that started all this and copied the same large file to the UNAS and Robocopy reported:</p>
<pre><code>Speed : 350,201,354 Bytes/sec.
Speed : 20,038.682 MegaBytes/min.</code></pre>
<p>About <strong>350 MB/sec</strong>, or <strong>2.8 Gbit/sec of sustained real-world SMB writes</strong> to a six-disk RAID 6 NAS.</p>
<p>That's much more like it. The useful lesson isn't "disable LSO." It was that when storage is mysteriously slow, eventually you have to stop testing storage. <code>iperf3</code> removed the NAS, filesystem, RAID, cache, SMB and disks from the experiment in one move. Once the raw network was also slow, the problem became dramatically smaller. And, sometimes the little checkbox labeled <strong>Large Send Offload</strong> is capable of making your 10 gigabit card run like it's 2004.</p>
<p>TL;DR - with LSO V2 for IPv4 enabled, Windows-to-Linux <code>iperf3</code> managed about <strong>313 Mbit/sec</strong>. Turning off that single offload took the exact same test to <strong>7.03 Gbit/sec</strong>. I&rsquo;m deliberately saying <em>on this machine</em> because LSO is normally useful and this isn&rsquo;t blanket advice to disable it everywhere.&nbsp;</p><br/><hr/>© 2025 Scott Hanselman. All rights reserved. <br/></div><div style="clear:both;padding-top:0.2em;"><a title="Like on Facebook" href="https://feeds.feedblitz.com/_/28/968351264/scotthanselman"><img height="20" src="https://assets.feedblitz.com/i/fblike20.png" style="border:0;margin:0;padding:0;"></a>&#160;<a title="Post to X.com" href="https://feeds.feedblitz.com/_/24/968351264/scotthanselman"><img height="20" src="https://assets.feedblitz.com/i/x.png" style="border:0;margin:0;padding:0;"></a>&#160;<a title="Subscribe by email" href="https://feeds.feedblitz.com/_/19/968351264/scotthanselman"><img height="20" src="https://assets.feedblitz.com/i/email20.png" style="border:0;margin:0;padding:0;"></a>&#160;<a title="Subscribe by RSS" href="https://feeds.feedblitz.com/_/20/968351264/scotthanselman"><img height="20" src="https://assets.feedblitz.com/i/rss20.png" style="border:0;margin:0;padding:0;"></a>&#160;</div>]]>
</description><comments>https://feeds.feedblitz.com/~/968351264/0/scotthanselman~Debugging-my-new-network-when-Gigabit-Ethernet-Runs-at-Megabits/comments#comments-start</comments><category>Musings</category><content:encoded><![CDATA[<div><p>I've been moving my home storage over to a UniFi UNAS Pro 8 as <a href="http://feeds.feedblitz.com/~/t/0/0/scotthanselman/~https://www.hanselman.com/blog/migrating-a-synology-nas-to-a-unifi-unas-pro-8-with-robocopy-smb-multichannel-and-surprising-performance-traps">part of a larger homelab moderization</a>. My main IRONHEART (the ultimate PC from a few years back) desktop now has an Intel E610-XT2 10GbE card, the NAS is on 10GbE, and there's a Minisforum MS-01 miniPC on the same network with a 10GbE SFP+ connection running Immich and Portainer and a few other things.</p>
<p>Everything says 10 gigabit. Windows says 10 gigabit. UniFi says 10 gigabit. SMB copies are using the correct NIC but my file copies are running at around <strong>100 to 200 megabits per second which is sad making.</strong></p>
<p>Naturally, I blamed the NAS, and the spinning rust within. The UNAS has six 16 TB spinning disks in RAID 6 and a pair of NVMe SSDs being used as cache. I'm also running Immich on the MS-01, with its photo library living on the UNAS, so there are lots of thumbnails, metadata reads, and little background writes happening. All seem like reasonable suspects.</p>
<p>I switched the UNAS SSD cache from read-write to read-only. No meaningful difference. I stopped Immich completely. No difference. I looked at <code>iostat</code>; the disks weren't saturated. We looked at SMB signing and Windows Defender network scanning. Still slow.</p>
<p>Then I stopped testing the NAS and ran <code>iperf3</code> directly between the Windows desktop and the MS-01:</p>
<pre><code>iperf3 -c 192.168.1.222 -P 4</code></pre>
<p><strong>133 Mbit/sec</strong></p>
<p>Oops. The reverse test was better, but still wrong:</p>
<pre><code>iperf3 -c 192.168.1.222 -P 4 -R</code></pre>
<p><strong>1.33 Gbit/sec</strong></p>
<p>That's weird. Now the disks, SMB, Immich, RAID, and the NAS itself were completely out of the equation. This was a Windows/NIC problem and it's weirdly asymmetrical.</p>
<p>Looking at the Intel adapter statistics shows me...</p>
<pre><code>Get-NetAdapterStatistics -Name "Ethernet - 10 Gig Intel"</code></pre>
<p>There were nearly a million <code>ReceivedDiscardedPackets</code>. During one ten-second <code>iperf3</code> test, the counter increased by another 268. Why?</p>
<p>The E610 driver had its receive buffers at the default <strong>512</strong>, although it supported up to 4096. I increased them. I love an increased buffer.</p>
<pre><code>Set-NetAdapterAdvancedProperty `
  -Name "Ethernet - 10 Gig Intel" `
  -DisplayName "Receive Buffers" `
  -DisplayValue "4096"</code></pre>
<p>The discarded-packet count during the next test went from <strong>268 to zero</strong>, and receive throughput jumped from <strong>1.33 Gbit/sec to 5.15 Gbit/sec</strong>. The transmit direction was still terrible, basically <strong>313 Mbit/sec</strong>. The next experiment was disabling Large Send Offload (LSO) V2 for IPv4:</p>
<pre><code>Set-NetAdapterAdvancedProperty `
  -Name "Ethernet - 10 Gig Intel" `
  -DisplayName "Large Send Offload V2 (IPv4)" `
  -DisplayValue "Disabled"</code></pre>
<p>Then I ran the same <code>iperf3</code> test again.</p>
<p><strong>7.03 Gbit/sec</strong></p>
<p>That's not a typo. <strong>313 Mbit/sec to 7.03 Gbit/sec by changing one NIC setting.</strong> Sweet sassy molassey.</p>
<p>LSO exists for a good reason: Windows can hand large TCP buffers to the NIC and let the adapter/driver segment them into network-sized packets, reducing CPU work. Microsoft does, however, explicitly point out that segmentation offload can reduce maximum sustainable throughput with some network adapters/configurations. LSO is usually useful, but not in this case.</p>
<p>In my particular combination of Windows and the Intel E610-XT2, something in the IPv4 LSO path was very, very sad. I don't yet know whether this is an Intel driver bug, firmware issue, Windows interaction, or something particular to this machine, so I wouldn't turn this into random tech blogger advice that everyone should disable LSO. <strong>Measure first, cut once. Er, twice. Just stay woke.</strong></p>
<p>Finally I went back to the test that started all this and copied the same large file to the UNAS and Robocopy reported:</p>
<pre><code>Speed : 350,201,354 Bytes/sec.
Speed : 20,038.682 MegaBytes/min.</code></pre>
<p>About <strong>350 MB/sec</strong>, or <strong>2.8 Gbit/sec of sustained real-world SMB writes</strong> to a six-disk RAID 6 NAS.</p>
<p>That's much more like it. The useful lesson isn't "disable LSO." It was that when storage is mysteriously slow, eventually you have to stop testing storage. <code>iperf3</code> removed the NAS, filesystem, RAID, cache, SMB and disks from the experiment in one move. Once the raw network was also slow, the problem became dramatically smaller. And, sometimes the little checkbox labeled <strong>Large Send Offload</strong> is capable of making your 10 gigabit card run like it's 2004.</p>
<p>TL;DR - with LSO V2 for IPv4 enabled, Windows-to-Linux <code>iperf3</code> managed about <strong>313 Mbit/sec</strong>. Turning off that single offload took the exact same test to <strong>7.03 Gbit/sec</strong>. I&rsquo;m deliberately saying <em>on this machine</em> because LSO is normally useful and this isn&rsquo;t blanket advice to disable it everywhere.&nbsp;</p>
<br/><hr/>© 2025 Scott Hanselman. All rights reserved. 
<br/></div><Img align="left" border="0" height="1" width="1" alt="" style="border:0;float:left;margin:0;padding:0;width:1px!important;height:1px!important;" hspace="0" src="https://feeds.feedblitz.com/~/i/968351264/0/scotthanselman">
<div style="clear:both;padding-top:0.2em;"><a title="Like on Facebook" href="https://feeds.feedblitz.com/_/28/968351264/scotthanselman"><img height="20" src="https://assets.feedblitz.com/i/fblike20.png" style="border:0;margin:0;padding:0;"></a>&#160;<a title="Post to X.com" href="https://feeds.feedblitz.com/_/24/968351264/scotthanselman"><img height="20" src="https://assets.feedblitz.com/i/x.png" style="border:0;margin:0;padding:0;"></a>&#160;<a title="Subscribe by email" href="https://feeds.feedblitz.com/_/19/968351264/scotthanselman"><img height="20" src="https://assets.feedblitz.com/i/email20.png" style="border:0;margin:0;padding:0;"></a>&#160;<a title="Subscribe by RSS" href="https://feeds.feedblitz.com/_/20/968351264/scotthanselman"><img height="20" src="https://assets.feedblitz.com/i/rss20.png" style="border:0;margin:0;padding:0;"></a>&#160;</div>]]>
</content:encoded></item>
<item>
<feedburner:origLink>https://www.hanselman.com/blog/migrating-a-synology-nas-to-a-unifi-unas-pro-8-with-robocopy-smb-multichannel-and-surprising-performance-traps</feedburner:origLink><trackback:ping>https://www.hanselman.com/blog/feed/trackback/bfae81c0-7b82-4188-b51a-fc41faeaf414</trackback:ping><pingback:server>https://www.hanselman.com/blog/feed/pingback</pingback:server><pingback:target>https://www.hanselman.com/blog/post/bfae81c0-7b82-4188-b51a-fc41faeaf414</pingback:target><dc:creator /><wfw:comment>https://feeds.feedblitz.com/~/968151545/0/scotthanselman~Migrating-a-Synology-NAS-to-a-UniFi-UNAS-Pro-with-Robocopy-SMB-Multichannel-and-Surprising-Performance-Traps/comments#comments-start</wfw:comment><wfw:commentRss>https://www.hanselman.com/blog/feed/rss/comments/bfae81c0-7b82-4188-b51a-fc41faeaf414</wfw:commentRss><slash:comments>3</slash:comments><title>Migrating a Synology NAS to a UniFi UNAS Pro 8 with Robocopy, SMB Multichannel, and Surprising Performance Traps</title><guid isPermaLink="false">https://www.hanselman.com/blog/post/bfae81c0-7b82-4188-b51a-fc41faeaf414</guid><link>https://feeds.feedblitz.com/~/968151545/0/scotthanselman~Migrating-a-Synology-NAS-to-a-UniFi-UNAS-Pro-with-Robocopy-SMB-Multichannel-and-Surprising-Performance-Traps</link><pubDate>Sun, 23 Aug 2026 19:23:42 GMT</pubDate><description><![CDATA[<div><p>I&rsquo;ve had a <a href="/blog/synology-ds1520-is-the-sweet-spot-for-a-home-nas-and-a-private-cloud">Synology NAS</a> for a <a href="/blog/a-basic-noncloudbased-personal-backup-strategy">very long time</a>, and recently I started moving its contents to a new <a href="https://store.ui.com/us/en/category/network-storage">Ubiquiti UniFi UNAS Pro 8</a>. This seemed like it ought to be a fairly boring operation. Both devices speak SMB, I have a fast network (recently upgraded to 10 gigabit internally), and Windows has had tools for copying files reliably between machines for decades. Naturally, it turned into a whole evening of learning things I thought I already knew, which is why I started a blog lol.</p>
<p>There was a nice bit of history here for me because back in 2007 (good lord!) I wrote a post called <strong>&ldquo;<a href="/blog/xcopy-considered-harmful-robocopy-or-xxcopy-or-syncback">XCopy considered harmful - Robocopy or XXCopy or SyncBack</a>.&rdquo;</strong> My argument at the time was basically that once you are moving enough files, Explorer stops being the move and Robocopy starts looking pretty good. I even used <code>/Z</code>, Robocopy&rsquo;s restartable mode, because being able to resume a partially transferred file was useful on unreliable connections.</p>
<p>Almost twenty years later, <code>/Z</code> turned out to be one of the most important things I needed to remove because it made everything hella slow.</p>
<h4>The migration</h4>
<p>The basic job was straightforward. I had shares on the Synology such as:</p>
<pre><code>\\server\music
</code></pre>
<p>and matching shares on the UNAS:</p>
<pre><code>\\UNAS-Pro-8\music
</code></pre>
<p>I initially used Explorer, mostly because it was there and because sometimes the easy thing really is the easy thing. That lasted until Explorer started producing errors on individual files:</p>
<pre><code>The requested operation could not be completed due to a file system limitation
</code></pre>
<p>My first thought was filenames. NAS migrations are full of opportunities to discover that one filesystem is more permissive than another, and there were filenames with parentheses and other punctuation in them.</p>
<p>Then this failed:</p>
<pre><code>\\server\music\Athlete\Tourist\05 Wires.m4p
</code></pre>
<p>There is nothing especially exotic about <code>05 Wires.m4p</code>, so I moved over to Robocopy to get a little more information. It consistently got to 92% and returned Windows error 665:</p>
<pre><code>92%        New File               4.3 m        05 Wires.m4p
ERROR 665 (0x00000299) Copying File
The requested operation could not be completed due to a file system limitation
</code></pre>
<p>At this point the useful question was no longer &ldquo;what is wrong with that filename?&rdquo; but &ldquo;which part of the path is refusing this file?&rdquo;</p>
<p>I copied the file from the Synology to my local Windows desktop. That worked. I then copied the local file from Windows to the UNAS, and that failed with the same filesystem limitation.</p>
<p>That isolated the problem so the Synology could read the file, Windows could store it, and something about writing this particular file to the UNAS was causing trouble.</p>
<h4>Alternate Data Streams, again</h4>
<p>NTFS files can contain named Alternate Data Streams in addition to the ordinary unnamed stream that we usually think of as the contents of a file. This is an old Windows filesystem feature, and <a href="/blog/removing-security-from-downloaded-powershell-scripts-with-alternative-data-streams">it happens to be one I wrote about in 2007</a> when discussing <code>Zone.Identifier</code>, which Windows can use to record where a downloaded file came from. I even <a href="/blog/emancipation">blogged about Alternate Data Streams in 2003</a>!!! Windows can expose these streams with <code>DIR /R</code>.</p>
<p>So I ran:</p>
<pre><code>dir /r "%USERPROFILE%\Desktop\05 Wires.m4p"
</code></pre>
<p>and got:</p>
<pre><code>11/30/2011  02:17 PM         4,576,368 05 Wires.m4p
                               360,456 05 Wires.m4p:01APIC_03.jpg:$DATA
</code></pre>
<p>There it is. Alongside the normal 4.5 MB music file was a roughly 360 KB named data stream called <code>01APIC_03.jpg</code>.</p>
<p>That also explained the strange 92% failure. Robocopy was successfully getting through the main contents of the file and then encountering the additional stream. What had looked like a failure somewhere in the middle of an ordinary <code>.m4p</code> file was actually occurring when Windows attempted to deal with the additional filesystem data.</p>
<p>Robocopy has support for exactly this situation. Microsoft documents <code>X</code> as one of the <code>/COPY</code> flags, meaning &ldquo;skip alternate data streams.&rdquo; So:</p>
<pre><code>/COPY:DATX
</code></pre>
<p>means copy the file&rsquo;s data, attributes, and timestamps, but do not copy the alternate streams. <code>/DCOPY:DATX</code> applies the corresponding behavior to directories. I retried the same file:</p>
<pre><code>robocopy "\\server\music\Athlete\Tourist" "\\UNAS-Pro-8\music\Athlete\Tourist" "05 Wires.m4p" /R:0 /W:0 /COPY:DATX /DCOPY:DATX /V
</code></pre>
<p>and it completed successfully. The important distinction here is that <code>DATX</code> does not remove metadata stored <em>inside</em> an MP3, M4A, M4P, JPEG, or other file format. It tells Robocopy not to reproduce separate filesystem streams associated with the file. In my case those extra streams were not something I needed to preserve on the new NAS.</p>
<h4>The copy worked, but it was slow</h4>
<p>Once the ADS issue was understood, I started the larger migration with a fairly conventional-looking Robocopy command:</p>
<pre><code>robocopy "\\server\music" "\\UNAS-Pro-8\music" /E /Z /MT:16 /R:2 /W:2 /COPY:DATX /DCOPY:DATX /XJ /TEE /LOG:"%USERPROFILE%\Desktop\synology-to-unas.log"
</code></pre>
<p>It ran, but performance was all over the place. Sometimes I would see a few hundred megabits per second, then it would drop dramatically. A small file could appear to sit there for a long time. I started wondering whether I was looking at buffering, slow disks, parity calculations, SMB behavior on the UNAS, or maybe my Synology had finally reached its limits.</p>
<p>So now it's "just try random stuff (bisect)" time. I reduced the number of threads. I tried single threaded. None of that helped. Then I removed <code>/Z</code>.</p>
<p>Microsoft&rsquo;s Robocopy documentation describes <code>/Z</code> as restartable mode, which lets an interrupted file resume rather than starting again from byte zero. What I had forgotten is that Microsoft&rsquo;s current migration guidance specifically warns that <code>/Z</code> should be used cautiously because the extra logging required for restartability can significantly reduce copy performance.</p>
<p>My successful music run ended up using:</p>
<pre><code>robocopy "\\server\music" "\\UNAS-Pro-8\music" /E /MT:4 /R:2 /W:2 /COPY:DATX /DCOPY:DATX /XJ /TEE /LOG:"%USERPROFILE%\Desktop\synology-to-unas-DATX.log"
</code></pre>
<p>The summary from that run was:</p>
<pre><code>               Total    Copied   Skipped  Mismatch    FAILED
    Files :     15940      6991      8949         0         0
    Bytes :  70.907 g  47.917 g  22.989 g         0         0
   Speed :           187,185,171 Bytes/sec.
</code></pre>
<p>So the run that copied almost 48 GB of remaining data averaged about 187 MB/sec, with no failed files.</p>
<p>This was not a controlled benchmark where I changed exactly one variable while everything else remained identical, so I&rsquo;m not going to pretend the number proves that <code>/Z</code> accounted for every bit of the earlier slowdown. The practical difference was large enough, however, that <code>/Z</code> is no longer something I will automatically put in a LAN migration command just because restartability sounds desirable. On a stable local network I would start without it and add it only when I actually need its semantics.</p>
<h4><code>/MT</code> is useful, but it helps a particular kind of problem</h4>
<p>Robocopy&rsquo;s <code>/MT:n</code> option runs copies using multiple threads. It supports values from 1 through 128, with eight threads as the default if <code>/MT</code> is supplied without a number. Microsoft&rsquo;s own migration guidance also points out that more threads do not automatically translate into a faster migration and recommends measuring thread counts against the actual workload.</p>
<p>This made more sense once I stopped thinking of <code>/MT:4</code> as &ldquo;make one file four times faster.&rdquo;</p>
<p>Imagine a music collection with thousands of files of questionable provenance (I ripped them, just kidding). There is work associated with opening a file, creating the destination file, reading and writing its contents, dealing with metadata, and closing it again. A single-threaded copy has periods where the network or storage can be waiting while one of those operations completes. Having several files in progress at once gives Robocopy opportunities to overlap that work.</p>
<p>For this particular collection, four threads turned out to be a good fit. Sixteen wasn&rsquo;t obviously helping more, and one thread wasn&rsquo;t an improvement. I would resist turning <code>/MT</code> into a magic value that belongs in every command line, because a directory containing 50,000 photographs presents a different workload from four 900 GB disk images.</p>
<p>There is also a logging cost worth remembering. Microsoft recommends redirecting Robocopy output to a log when using multithreaded copies, and its migration guidance uses switches such as <code>/NP</code>, <code>/NFL</code>, and <code>/NDL</code> when the objective is throughput rather than watching every filename scroll by.</p>
<p>For a migration I am not actively watching, I would probably use something like:</p>
<pre><code>robocopy "\\server\share" "\\UNAS-Pro-8\share" /E /MT:4 /R:2 /W:2 /COPY:DATX /DCOPY:DATX /XJ /NP /NFL /NDL /LOG:"%USERPROFILE%\Desktop\nas-migration.log"
</code></pre>
<h4>Then came the large files</h4>
<p>Later I started copying some files that were hundreds of gigabytes each. Microsoft describes <code>/J</code> as unbuffered I/O and recommends it for large files, so it seemed like the obvious option to try.</p>
<p>With <code>/J</code> enabled, however, NAS-to-NAS transfer slowed dramatically. The useful thing about having a Windows machine in the middle is that I could test each half of the trip separately. I took one of the exact same large files and copied it directly from the Synology to my local machine. That ran at roughly 250 MB/sec, so the Synology was perfectly capable of reading the file at high speed.</p>
<p>I then removed <code>/J</code> from the direct Synology-to-UNAS Robocopy command:</p>
<pre><code>robocopy "\\server\share" "\\UNAS-Pro-8\share" "huge-file.ext" /R:0 /W:0 /COPY:DATX /NP
</code></pre>
<p>and the speed came back.</p>
<p>I don&rsquo;t think the useful conclusion is that <code>/J</code> is bad. Microsoft recommends it for large-file copies for a reason, and it is entirely possible that it is exactly what you want when copying from local disk to local disk or in another network configuration. What mattered here was that Windows was simultaneously reading from one SMB server and writing to another SMB server, and on this particular path buffered I/O performed much better.</p>
<p>That is a good reminder that command-line switches describe behavior, not guaranteed performance improvements. <code>/J</code> changes the I/O model. <code>/MT</code> changes concurrency. <code>/Z</code> adds restartability. Whether those changes improve a migration depends on the rest of the system.</p>
<h4>The Synology was faster than I gave it credit for</h4>
<p>At several points I blamed the aging Synology. It is an old machine with spinning disks, so it was easy to assume that a few hundred megabits per second was simply all it had left. Then I remembered that the Synology has four 1 GbE interfaces and that SMB 3 supports Multichannel. I am still surprised this worked so well.</p>
<p>SMB Multichannel allows an SMB session to use multiple network paths simultaneously. Microsoft documents this specifically as a way to aggregate available network bandwidth, and Synology supports SMB3 Multichannel for the same reason.</p>
<p>Windows makes the active channels easy to inspect:</p>
<pre><code>Get-SmbMultichannelConnection -ServerName server |
    Format-Table ServerName,Selected,ClientIpAddress,ServerIpAddress,ClientLinkSpeed,ServerLinkSpeed,CurrentChannels
</code></pre>
<p>My machine reported:</p>
<pre><code>ServerName Selected ClientIpAddress ServerIpAddress ClientLinkSpeed ServerLinkSpeed
---------- -------- --------------- --------------- --------------- ---------------
server         True 192.168.1.45    192.168.1.210        1000000000      1000000000
server         True 192.168.1.45    192.168.1.198        1000000000      1000000000
server         True 192.168.1.45    192.168.1.197        1000000000      1000000000
server         True 192.168.1.45    192.168.1.26         1000000000      1000000000
</code></pre>
<p>All four 1 GbE interfaces on the Synology were participating in the SMB connection.</p>
<p>My Windows machine currently has a 2.5 GbE adapter (10 gig coming soon), and during the fast copy I was seeing approximately 250 MB/sec arriving from the Synology. That suddenly made the behavior of the system much less mysterious. The Synology was not limited to the throughput of one gigabit Ethernet connection because SMB Multichannel was allowing Windows to use the four available server-side paths, while the 2.5 GbE link on the PC was becoming the smaller network pipe.</p>
<p>Synology&rsquo;s documentation makes an important distinction here between SMB Multichannel and ordinary link aggregation. Multichannel can increase SMB performance for one client by using multiple network connections, while conventional link aggregation is generally about aggregate throughput across multiple clients and services.</p>
<p>Like I said, I have a 10 GbE adapter on the way for the Windows machine, so there is another experiment available after the migration. The Synology still only has four 1 GbE interfaces, which gives it 4 Gb/sec of network links in aggregate, but removing the current 2.5 GbE client bottleneck should show how much farther the disks and the Synology itself can go. For an older NAS that I had already mentally demoted to &ldquo;the slow backup NAS,&rdquo; it performed surprisingly well.</p>
<h4>I also tried rsync</h4>
<p>Yes, I know, what about rsync? You are saying Windows as a middleman is unnecessary. The Synology can provide rsync, and UniFi Drive can pull from an rsync server using daemon mode. Ubiquiti documents the rsync path under Drive&rsquo;s Backup Tasks and requires daemon mode for this type of source.</p>
<p>I tried a separate movie share with rsync while the other experiments were going on. It worked, and I saw about 67 MB/sec. It also gave me a destination layout with some additional directory nesting that I would need to clean up afterward.</p>
<p>That is not an argument that rsync is slow in general, nor that its directory behavior cannot be configured correctly. It is just what happened in this particular Synology-to-UNAS test. Once the Robocopy path was reaching roughly 187 MB/sec and preserving exactly the UNC share layout I wanted, there wasn&rsquo;t much incentive for me to make rsync the primary migration mechanism.</p>
<p>The slightly amusing result was that the apparently indirect path:</p>
<pre><code>Synology -&gt; SMB -&gt; Windows -&gt; SMB -&gt; UNAS
</code></pre>
<p>was considerably faster in my environment than asking the two NAS devices to transfer the test share directly with the rsync implementation exposed by UniFi Drive. My guess is because rsync wasn't using the 4 1gig connections linked. Let me know what you think in the comments.</p>
<h4>The Robocopy command I ended up with</h4>
<p>For the normal shares containing lots of files, this is the version I would start with now:</p>
<pre><code>robocopy "\\server\share" "\\UNAS-Pro-8\share" /E /MT:4 /R:2 /W:2 /COPY:DATX /DCOPY:DATX /XJ /NP /NFL /NDL /LOG:"%USERPROFILE%\Desktop\nas-migration.log"
</code></pre>
<p>The switches have fairly specific jobs:</p>
<pre><code>/E           Copy subdirectories, including empty ones
/MT:4        Allow four file-copy threads
/R:2         Retry a failed copy twice
/W:2         Wait two seconds between retries
/COPY:DATX   Copy data, attributes and timestamps, but skip ADS
/DCOPY:DATX  Apply the corresponding directory copy flags
/XJ          Exclude junction points
/NP          Don't print percentage progress
/NFL         Don't log every filename
/NDL         Don't log every directory
/LOG         Write the useful output to a file
</code></pre>
<p>I deliberately do not have <code>/Z</code> in there. I also would not automatically add <code>/J</code>; for a workload dominated by very large files I would test the same transfer both with and without it before committing to a multi-terabyte run.</p>
<p>The <code>/MT</code> value is similarly empirical. Four worked extremely well for this Synology and this mix of files, but I would try 1, 4, 8, or another sensible number against a representative slice of the real data rather than assuming that the largest available thread count is best.</p>
<h4>Checking the result</h4>
<p>For the files where I care enough to prove that the destination contains exactly the same bytes as the source, PowerShell&rsquo;s <code>Get-FileHash</code> is a convenient final check:</p>
<pre><code>Get-FileHash "\\server\share\huge-file.ext" -Algorithm SHA256
Get-FileHash "\\UNAS-Pro-8\share\huge-file.ext" -Algorithm SHA256
</code></pre>
<p><code>Get-FileHash</code> uses SHA-256 by default, and if the SHA-256 values match then the two inputs produced the same digest. For enormous files this requires reading the entire file again at both ends, so I am unlikely to hash every song in a music collection, but it is an easy way to verify the particularly valuable multi-hundred-gigabyte files after a migration.</p>
<h4>A few things I&rsquo;d check before blaming the NAS</h4>
<p>What made this migration interesting was that the symptoms could have supported several plausible explanations. The UNAS has a new RAID array, the Synology is old, Windows is acting as an SMB client in both directions, the files came from years of different applications, and Robocopy has enough switches to make almost any command line look authoritative.</p>
<p>When a file failed, I copied it Synology-to-local and then local-to-UNAS, which exposed the destination-side ADS problem. When large files were slow, I copied the same file Synology-to-local, which proved the old NAS could still deliver about 250 MB/sec. When the network suddenly became much faster, <code>Get-SmbMultichannelConnection</code> showed that all four Synology Ethernet interfaces were participating. When <code>/J</code> looked slow, removing just that behavior restored the throughput I was expecting.</p>
<p>The final performance was not the result of finding a secret &ldquo;fast Robocopy&rdquo; command from a forum. It came from thinking about which features I actually wanted for this migration and removing a few that were useful in other circumstances but expensive in mine.</p>
<p>That is probably the part I will want to remember the next time I do this. <code>/Z</code>, <code>/J</code>, and <code>/MT</code> are not levels on a performance slider. They change restartability, buffering, and concurrency. Alternate Data Streams are real data even when Explorer normally hides them. SMB Multichannel can make an old NAS with several gigabit interfaces much more capable than one might assume from looking at any single Ethernet port.</p>
<p>Robocopy ended up being the fastest thing I tried. As with all advice, this worked for me. Ideally you'll gind more value in the comments as Hacker News folks and Windows experts will drop in with better tools and strategies. Just remember, there's more than one way to saturate a network and I completely saturated this one, so I'm pretty happy with the result of my migration.</p><br/><hr/>© 2025 Scott Hanselman. All rights reserved. <br/></div><div style="clear:both;padding-top:0.2em;"><a title="Like on Facebook" href="https://feeds.feedblitz.com/_/28/968151545/scotthanselman"><img height="20" src="https://assets.feedblitz.com/i/fblike20.png" style="border:0;margin:0;padding:0;"></a>&#160;<a title="Post to X.com" href="https://feeds.feedblitz.com/_/24/968151545/scotthanselman"><img height="20" src="https://assets.feedblitz.com/i/x.png" style="border:0;margin:0;padding:0;"></a>&#160;<a title="Subscribe by email" href="https://feeds.feedblitz.com/_/19/968151545/scotthanselman"><img height="20" src="https://assets.feedblitz.com/i/email20.png" style="border:0;margin:0;padding:0;"></a>&#160;<a title="Subscribe by RSS" href="https://feeds.feedblitz.com/_/20/968151545/scotthanselman"><img height="20" src="https://assets.feedblitz.com/i/rss20.png" style="border:0;margin:0;padding:0;"></a>&#160;</div>]]>
</description><comments>https://feeds.feedblitz.com/~/968151545/0/scotthanselman~Migrating-a-Synology-NAS-to-a-UniFi-UNAS-Pro-with-Robocopy-SMB-Multichannel-and-Surprising-Performance-Traps/comments#comments-start</comments><category>Musings</category><content:encoded><![CDATA[<div><p>I&rsquo;ve had a <a href="http://feeds.feedblitz.com/~/t/0/0/scotthanselman/~https://www.hanselman.com/blog/synology-ds1520-is-the-sweet-spot-for-a-home-nas-and-a-private-cloud">Synology NAS</a> for a <a href="http://feeds.feedblitz.com/~/t/0/0/scotthanselman/~https://www.hanselman.com/blog/a-basic-noncloudbased-personal-backup-strategy">very long time</a>, and recently I started moving its contents to a new <a href="http://feeds.feedblitz.com/~/t/0/0/scotthanselman/~https://store.ui.com/us/en/category/network-storage">Ubiquiti UniFi UNAS Pro 8</a>. This seemed like it ought to be a fairly boring operation. Both devices speak SMB, I have a fast network (recently upgraded to 10 gigabit internally), and Windows has had tools for copying files reliably between machines for decades. Naturally, it turned into a whole evening of learning things I thought I already knew, which is why I started a blog lol.</p>
<p>There was a nice bit of history here for me because back in 2007 (good lord!) I wrote a post called <strong>&ldquo;<a href="http://feeds.feedblitz.com/~/t/0/0/scotthanselman/~https://www.hanselman.com/blog/xcopy-considered-harmful-robocopy-or-xxcopy-or-syncback">XCopy considered harmful - Robocopy or XXCopy or SyncBack</a>.&rdquo;</strong> My argument at the time was basically that once you are moving enough files, Explorer stops being the move and Robocopy starts looking pretty good. I even used <code>/Z</code>, Robocopy&rsquo;s restartable mode, because being able to resume a partially transferred file was useful on unreliable connections.</p>
<p>Almost twenty years later, <code>/Z</code> turned out to be one of the most important things I needed to remove because it made everything hella slow.</p>
<h4>The migration</h4>
<p>The basic job was straightforward. I had shares on the Synology such as:</p>
<pre><code>\\server\music
</code></pre>
<p>and matching shares on the UNAS:</p>
<pre><code>\\UNAS-Pro-8\music
</code></pre>
<p>I initially used Explorer, mostly because it was there and because sometimes the easy thing really is the easy thing. That lasted until Explorer started producing errors on individual files:</p>
<pre><code>The requested operation could not be completed due to a file system limitation
</code></pre>
<p>My first thought was filenames. NAS migrations are full of opportunities to discover that one filesystem is more permissive than another, and there were filenames with parentheses and other punctuation in them.</p>
<p>Then this failed:</p>
<pre><code>\\server\music\Athlete\Tourist\05 Wires.m4p
</code></pre>
<p>There is nothing especially exotic about <code>05 Wires.m4p</code>, so I moved over to Robocopy to get a little more information. It consistently got to 92% and returned Windows error 665:</p>
<pre><code>92%        New File               4.3 m        05 Wires.m4p
ERROR 665 (0x00000299) Copying File
The requested operation could not be completed due to a file system limitation
</code></pre>
<p>At this point the useful question was no longer &ldquo;what is wrong with that filename?&rdquo; but &ldquo;which part of the path is refusing this file?&rdquo;</p>
<p>I copied the file from the Synology to my local Windows desktop. That worked. I then copied the local file from Windows to the UNAS, and that failed with the same filesystem limitation.</p>
<p>That isolated the problem so the Synology could read the file, Windows could store it, and something about writing this particular file to the UNAS was causing trouble.</p>
<h4>Alternate Data Streams, again</h4>
<p>NTFS files can contain named Alternate Data Streams in addition to the ordinary unnamed stream that we usually think of as the contents of a file. This is an old Windows filesystem feature, and <a href="http://feeds.feedblitz.com/~/t/0/0/scotthanselman/~https://www.hanselman.com/blog/removing-security-from-downloaded-powershell-scripts-with-alternative-data-streams">it happens to be one I wrote about in 2007</a> when discussing <code>Zone.Identifier</code>, which Windows can use to record where a downloaded file came from. I even <a href="http://feeds.feedblitz.com/~/t/0/0/scotthanselman/~https://www.hanselman.com/blog/emancipation">blogged about Alternate Data Streams in 2003</a>!!! Windows can expose these streams with <code>DIR /R</code>.</p>
<p>So I ran:</p>
<pre><code>dir /r "%USERPROFILE%\Desktop\05 Wires.m4p"
</code></pre>
<p>and got:</p>
<pre><code>11/30/2011  02:17 PM         4,576,368 05 Wires.m4p
                               360,456 05 Wires.m4p:01APIC_03.jpg:$DATA
</code></pre>
<p>There it is. Alongside the normal 4.5 MB music file was a roughly 360 KB named data stream called <code>01APIC_03.jpg</code>.</p>
<p>That also explained the strange 92% failure. Robocopy was successfully getting through the main contents of the file and then encountering the additional stream. What had looked like a failure somewhere in the middle of an ordinary <code>.m4p</code> file was actually occurring when Windows attempted to deal with the additional filesystem data.</p>
<p>Robocopy has support for exactly this situation. Microsoft documents <code>X</code> as one of the <code>/COPY</code> flags, meaning &ldquo;skip alternate data streams.&rdquo; So:</p>
<pre><code>/COPY:DATX
</code></pre>
<p>means copy the file&rsquo;s data, attributes, and timestamps, but do not copy the alternate streams. <code>/DCOPY:DATX</code> applies the corresponding behavior to directories. I retried the same file:</p>
<pre><code>robocopy "\\server\music\Athlete\Tourist" "\\UNAS-Pro-8\music\Athlete\Tourist" "05 Wires.m4p" /R:0 /W:0 /COPY:DATX /DCOPY:DATX /V
</code></pre>
<p>and it completed successfully. The important distinction here is that <code>DATX</code> does not remove metadata stored <em>inside</em> an MP3, M4A, M4P, JPEG, or other file format. It tells Robocopy not to reproduce separate filesystem streams associated with the file. In my case those extra streams were not something I needed to preserve on the new NAS.</p>
<h4>The copy worked, but it was slow</h4>
<p>Once the ADS issue was understood, I started the larger migration with a fairly conventional-looking Robocopy command:</p>
<pre><code>robocopy "\\server\music" "\\UNAS-Pro-8\music" /E /Z /MT:16 /R:2 /W:2 /COPY:DATX /DCOPY:DATX /XJ /TEE /LOG:"%USERPROFILE%\Desktop\synology-to-unas.log"
</code></pre>
<p>It ran, but performance was all over the place. Sometimes I would see a few hundred megabits per second, then it would drop dramatically. A small file could appear to sit there for a long time. I started wondering whether I was looking at buffering, slow disks, parity calculations, SMB behavior on the UNAS, or maybe my Synology had finally reached its limits.</p>
<p>So now it's "just try random stuff (bisect)" time. I reduced the number of threads. I tried single threaded. None of that helped. Then I removed <code>/Z</code>.</p>
<p>Microsoft&rsquo;s Robocopy documentation describes <code>/Z</code> as restartable mode, which lets an interrupted file resume rather than starting again from byte zero. What I had forgotten is that Microsoft&rsquo;s current migration guidance specifically warns that <code>/Z</code> should be used cautiously because the extra logging required for restartability can significantly reduce copy performance.</p>
<p>My successful music run ended up using:</p>
<pre><code>robocopy "\\server\music" "\\UNAS-Pro-8\music" /E /MT:4 /R:2 /W:2 /COPY:DATX /DCOPY:DATX /XJ /TEE /LOG:"%USERPROFILE%\Desktop\synology-to-unas-DATX.log"
</code></pre>
<p>The summary from that run was:</p>
<pre><code>               Total    Copied   Skipped  Mismatch    FAILED
    Files :     15940      6991      8949         0         0
    Bytes :  70.907 g  47.917 g  22.989 g         0         0
   Speed :           187,185,171 Bytes/sec.
</code></pre>
<p>So the run that copied almost 48 GB of remaining data averaged about 187 MB/sec, with no failed files.</p>
<p>This was not a controlled benchmark where I changed exactly one variable while everything else remained identical, so I&rsquo;m not going to pretend the number proves that <code>/Z</code> accounted for every bit of the earlier slowdown. The practical difference was large enough, however, that <code>/Z</code> is no longer something I will automatically put in a LAN migration command just because restartability sounds desirable. On a stable local network I would start without it and add it only when I actually need its semantics.</p>
<h4><code>/MT</code> is useful, but it helps a particular kind of problem</h4>
<p>Robocopy&rsquo;s <code>/MT:n</code> option runs copies using multiple threads. It supports values from 1 through 128, with eight threads as the default if <code>/MT</code> is supplied without a number. Microsoft&rsquo;s own migration guidance also points out that more threads do not automatically translate into a faster migration and recommends measuring thread counts against the actual workload.</p>
<p>This made more sense once I stopped thinking of <code>/MT:4</code> as &ldquo;make one file four times faster.&rdquo;</p>
<p>Imagine a music collection with thousands of files of questionable provenance (I ripped them, just kidding). There is work associated with opening a file, creating the destination file, reading and writing its contents, dealing with metadata, and closing it again. A single-threaded copy has periods where the network or storage can be waiting while one of those operations completes. Having several files in progress at once gives Robocopy opportunities to overlap that work.</p>
<p>For this particular collection, four threads turned out to be a good fit. Sixteen wasn&rsquo;t obviously helping more, and one thread wasn&rsquo;t an improvement. I would resist turning <code>/MT</code> into a magic value that belongs in every command line, because a directory containing 50,000 photographs presents a different workload from four 900 GB disk images.</p>
<p>There is also a logging cost worth remembering. Microsoft recommends redirecting Robocopy output to a log when using multithreaded copies, and its migration guidance uses switches such as <code>/NP</code>, <code>/NFL</code>, and <code>/NDL</code> when the objective is throughput rather than watching every filename scroll by.</p>
<p>For a migration I am not actively watching, I would probably use something like:</p>
<pre><code>robocopy "\\server\share" "\\UNAS-Pro-8\share" /E /MT:4 /R:2 /W:2 /COPY:DATX /DCOPY:DATX /XJ /NP /NFL /NDL /LOG:"%USERPROFILE%\Desktop\nas-migration.log"
</code></pre>
<h4>Then came the large files</h4>
<p>Later I started copying some files that were hundreds of gigabytes each. Microsoft describes <code>/J</code> as unbuffered I/O and recommends it for large files, so it seemed like the obvious option to try.</p>
<p>With <code>/J</code> enabled, however, NAS-to-NAS transfer slowed dramatically. The useful thing about having a Windows machine in the middle is that I could test each half of the trip separately. I took one of the exact same large files and copied it directly from the Synology to my local machine. That ran at roughly 250 MB/sec, so the Synology was perfectly capable of reading the file at high speed.</p>
<p>I then removed <code>/J</code> from the direct Synology-to-UNAS Robocopy command:</p>
<pre><code>robocopy "\\server\share" "\\UNAS-Pro-8\share" "huge-file.ext" /R:0 /W:0 /COPY:DATX /NP
</code></pre>
<p>and the speed came back.</p>
<p>I don&rsquo;t think the useful conclusion is that <code>/J</code> is bad. Microsoft recommends it for large-file copies for a reason, and it is entirely possible that it is exactly what you want when copying from local disk to local disk or in another network configuration. What mattered here was that Windows was simultaneously reading from one SMB server and writing to another SMB server, and on this particular path buffered I/O performed much better.</p>
<p>That is a good reminder that command-line switches describe behavior, not guaranteed performance improvements. <code>/J</code> changes the I/O model. <code>/MT</code> changes concurrency. <code>/Z</code> adds restartability. Whether those changes improve a migration depends on the rest of the system.</p>
<h4>The Synology was faster than I gave it credit for</h4>
<p>At several points I blamed the aging Synology. It is an old machine with spinning disks, so it was easy to assume that a few hundred megabits per second was simply all it had left. Then I remembered that the Synology has four 1 GbE interfaces and that SMB 3 supports Multichannel. I am still surprised this worked so well.</p>
<p>SMB Multichannel allows an SMB session to use multiple network paths simultaneously. Microsoft documents this specifically as a way to aggregate available network bandwidth, and Synology supports SMB3 Multichannel for the same reason.</p>
<p>Windows makes the active channels easy to inspect:</p>
<pre><code>Get-SmbMultichannelConnection -ServerName server |
    Format-Table ServerName,Selected,ClientIpAddress,ServerIpAddress,ClientLinkSpeed,ServerLinkSpeed,CurrentChannels
</code></pre>
<p>My machine reported:</p>
<pre><code>ServerName Selected ClientIpAddress ServerIpAddress ClientLinkSpeed ServerLinkSpeed
---------- -------- --------------- --------------- --------------- ---------------
server         True 192.168.1.45    192.168.1.210        1000000000      1000000000
server         True 192.168.1.45    192.168.1.198        1000000000      1000000000
server         True 192.168.1.45    192.168.1.197        1000000000      1000000000
server         True 192.168.1.45    192.168.1.26         1000000000      1000000000
</code></pre>
<p>All four 1 GbE interfaces on the Synology were participating in the SMB connection.</p>
<p>My Windows machine currently has a 2.5 GbE adapter (10 gig coming soon), and during the fast copy I was seeing approximately 250 MB/sec arriving from the Synology. That suddenly made the behavior of the system much less mysterious. The Synology was not limited to the throughput of one gigabit Ethernet connection because SMB Multichannel was allowing Windows to use the four available server-side paths, while the 2.5 GbE link on the PC was becoming the smaller network pipe.</p>
<p>Synology&rsquo;s documentation makes an important distinction here between SMB Multichannel and ordinary link aggregation. Multichannel can increase SMB performance for one client by using multiple network connections, while conventional link aggregation is generally about aggregate throughput across multiple clients and services.</p>
<p>Like I said, I have a 10 GbE adapter on the way for the Windows machine, so there is another experiment available after the migration. The Synology still only has four 1 GbE interfaces, which gives it 4 Gb/sec of network links in aggregate, but removing the current 2.5 GbE client bottleneck should show how much farther the disks and the Synology itself can go. For an older NAS that I had already mentally demoted to &ldquo;the slow backup NAS,&rdquo; it performed surprisingly well.</p>
<h4>I also tried rsync</h4>
<p>Yes, I know, what about rsync? You are saying Windows as a middleman is unnecessary. The Synology can provide rsync, and UniFi Drive can pull from an rsync server using daemon mode. Ubiquiti documents the rsync path under Drive&rsquo;s Backup Tasks and requires daemon mode for this type of source.</p>
<p>I tried a separate movie share with rsync while the other experiments were going on. It worked, and I saw about 67 MB/sec. It also gave me a destination layout with some additional directory nesting that I would need to clean up afterward.</p>
<p>That is not an argument that rsync is slow in general, nor that its directory behavior cannot be configured correctly. It is just what happened in this particular Synology-to-UNAS test. Once the Robocopy path was reaching roughly 187 MB/sec and preserving exactly the UNC share layout I wanted, there wasn&rsquo;t much incentive for me to make rsync the primary migration mechanism.</p>
<p>The slightly amusing result was that the apparently indirect path:</p>
<pre><code>Synology -&gt; SMB -&gt; Windows -&gt; SMB -&gt; UNAS
</code></pre>
<p>was considerably faster in my environment than asking the two NAS devices to transfer the test share directly with the rsync implementation exposed by UniFi Drive. My guess is because rsync wasn't using the 4 1gig connections linked. Let me know what you think in the comments.</p>
<h4>The Robocopy command I ended up with</h4>
<p>For the normal shares containing lots of files, this is the version I would start with now:</p>
<pre><code>robocopy "\\server\share" "\\UNAS-Pro-8\share" /E /MT:4 /R:2 /W:2 /COPY:DATX /DCOPY:DATX /XJ /NP /NFL /NDL /LOG:"%USERPROFILE%\Desktop\nas-migration.log"
</code></pre>
<p>The switches have fairly specific jobs:</p>
<pre><code>/E           Copy subdirectories, including empty ones
/MT:4        Allow four file-copy threads
/R:2         Retry a failed copy twice
/W:2         Wait two seconds between retries
/COPY:DATX   Copy data, attributes and timestamps, but skip ADS
/DCOPY:DATX  Apply the corresponding directory copy flags
/XJ          Exclude junction points
/NP          Don't print percentage progress
/NFL         Don't log every filename
/NDL         Don't log every directory
/LOG         Write the useful output to a file
</code></pre>
<p>I deliberately do not have <code>/Z</code> in there. I also would not automatically add <code>/J</code>; for a workload dominated by very large files I would test the same transfer both with and without it before committing to a multi-terabyte run.</p>
<p>The <code>/MT</code> value is similarly empirical. Four worked extremely well for this Synology and this mix of files, but I would try 1, 4, 8, or another sensible number against a representative slice of the real data rather than assuming that the largest available thread count is best.</p>
<h4>Checking the result</h4>
<p>For the files where I care enough to prove that the destination contains exactly the same bytes as the source, PowerShell&rsquo;s <code>Get-FileHash</code> is a convenient final check:</p>
<pre><code>Get-FileHash "\\server\share\huge-file.ext" -Algorithm SHA256
Get-FileHash "\\UNAS-Pro-8\share\huge-file.ext" -Algorithm SHA256
</code></pre>
<p><code>Get-FileHash</code> uses SHA-256 by default, and if the SHA-256 values match then the two inputs produced the same digest. For enormous files this requires reading the entire file again at both ends, so I am unlikely to hash every song in a music collection, but it is an easy way to verify the particularly valuable multi-hundred-gigabyte files after a migration.</p>
<h4>A few things I&rsquo;d check before blaming the NAS</h4>
<p>What made this migration interesting was that the symptoms could have supported several plausible explanations. The UNAS has a new RAID array, the Synology is old, Windows is acting as an SMB client in both directions, the files came from years of different applications, and Robocopy has enough switches to make almost any command line look authoritative.</p>
<p>When a file failed, I copied it Synology-to-local and then local-to-UNAS, which exposed the destination-side ADS problem. When large files were slow, I copied the same file Synology-to-local, which proved the old NAS could still deliver about 250 MB/sec. When the network suddenly became much faster, <code>Get-SmbMultichannelConnection</code> showed that all four Synology Ethernet interfaces were participating. When <code>/J</code> looked slow, removing just that behavior restored the throughput I was expecting.</p>
<p>The final performance was not the result of finding a secret &ldquo;fast Robocopy&rdquo; command from a forum. It came from thinking about which features I actually wanted for this migration and removing a few that were useful in other circumstances but expensive in mine.</p>
<p>That is probably the part I will want to remember the next time I do this. <code>/Z</code>, <code>/J</code>, and <code>/MT</code> are not levels on a performance slider. They change restartability, buffering, and concurrency. Alternate Data Streams are real data even when Explorer normally hides them. SMB Multichannel can make an old NAS with several gigabit interfaces much more capable than one might assume from looking at any single Ethernet port.</p>
<p>Robocopy ended up being the fastest thing I tried. As with all advice, this worked for me. Ideally you'll gind more value in the comments as Hacker News folks and Windows experts will drop in with better tools and strategies. Just remember, there's more than one way to saturate a network and I completely saturated this one, so I'm pretty happy with the result of my migration.</p>
<br/><hr/>© 2025 Scott Hanselman. All rights reserved. 
<br/></div><Img align="left" border="0" height="1" width="1" alt="" style="border:0;float:left;margin:0;padding:0;width:1px!important;height:1px!important;" hspace="0" src="https://feeds.feedblitz.com/~/i/968151545/0/scotthanselman">
<div style="clear:both;padding-top:0.2em;"><a title="Like on Facebook" href="https://feeds.feedblitz.com/_/28/968151545/scotthanselman"><img height="20" src="https://assets.feedblitz.com/i/fblike20.png" style="border:0;margin:0;padding:0;"></a>&#160;<a title="Post to X.com" href="https://feeds.feedblitz.com/_/24/968151545/scotthanselman"><img height="20" src="https://assets.feedblitz.com/i/x.png" style="border:0;margin:0;padding:0;"></a>&#160;<a title="Subscribe by email" href="https://feeds.feedblitz.com/_/19/968151545/scotthanselman"><img height="20" src="https://assets.feedblitz.com/i/email20.png" style="border:0;margin:0;padding:0;"></a>&#160;<a title="Subscribe by RSS" href="https://feeds.feedblitz.com/_/20/968151545/scotthanselman"><img height="20" src="https://assets.feedblitz.com/i/rss20.png" style="border:0;margin:0;padding:0;"></a>&#160;</div>]]>
</content:encoded></item>
<item>
<feedburner:origLink>https://www.hanselman.com/blog/is-the-craft-dead</feedburner:origLink><trackback:ping>https://www.hanselman.com/blog/feed/trackback/449e1a01-a1f1-49c3-96eb-4695a08ecef1</trackback:ping><pingback:server>https://www.hanselman.com/blog/feed/pingback</pingback:server><pingback:target>https://www.hanselman.com/blog/post/449e1a01-a1f1-49c3-96eb-4695a08ecef1</pingback:target><dc:creator>Scott Hanselman</dc:creator><wfw:comment>https://feeds.feedblitz.com/~/945480746/0/scotthanselman~Is-the-craft-dead/comments#comments-start</wfw:comment><wfw:commentRss>https://www.hanselman.com/blog/feed/rss/comments/449e1a01-a1f1-49c3-96eb-4695a08ecef1</wfw:commentRss><slash:comments>8</slash:comments><title>Is the craft dead?</title><guid isPermaLink="false">https://www.hanselman.com/blog/post/449e1a01-a1f1-49c3-96eb-4695a08ecef1</guid><link>https://feeds.feedblitz.com/~/945480746/0/scotthanselman~Is-the-craft-dead</link><pubDate>Mon, 09 Feb 2026 05:50:59 GMT</pubDate><description><![CDATA[<div><p>The Japanese are really good at woodworking. And I love watching the Yankee workshop, my dad makes Native American bows and arrows completely from scratch in his workshop with trees that he finds.&nbsp;<p>This is all different from the stuff you get at IKEA, but I’ve been coding now for money for 35 years and systems are still complicated, computers still do dumb stuff, humans still do dumb stuff, this is just like the move from assembler to C, like the introduction of syntax highlighting, the introduction of intellisense, and the copy paste directly into production shift when stack overflow happened.</p>
<p>There is value in good taste, there is value in craftsmanship, and there is value in human judgment. The furniture might be differently designed, but we’re still interior designers and putting together a cohesive system is non-trivial.&nbsp;</p>
<p>Don’t let them gaslight you with one shot Minecraft clones and one shot C compilers. Software is still hard, it’s just that you’re no longer I/O bound with the speed of your fingertips.</p>
<p>I think that there will be lots of work for us cleaning up after the slop, but if you know what you’re doing AI augmented development is going to get you some amazing results and I am enjoying learning a ton during this momentous era shift - but the craft still exists.</p><br/><hr/>© 2025 Scott Hanselman. All rights reserved. <br/></div><div style="clear:both;padding-top:0.2em;"><a title="Like on Facebook" href="https://feeds.feedblitz.com/_/28/945480746/scotthanselman"><img height="20" src="https://assets.feedblitz.com/i/fblike20.png" style="border:0;margin:0;padding:0;"></a>&#160;<a title="Post to X.com" href="https://feeds.feedblitz.com/_/24/945480746/scotthanselman"><img height="20" src="https://assets.feedblitz.com/i/x.png" style="border:0;margin:0;padding:0;"></a>&#160;<a title="Subscribe by email" href="https://feeds.feedblitz.com/_/19/945480746/scotthanselman"><img height="20" src="https://assets.feedblitz.com/i/email20.png" style="border:0;margin:0;padding:0;"></a>&#160;<a title="Subscribe by RSS" href="https://feeds.feedblitz.com/_/20/945480746/scotthanselman"><img height="20" src="https://assets.feedblitz.com/i/rss20.png" style="border:0;margin:0;padding:0;"></a>&#160;</div>]]>
</description><comments>https://feeds.feedblitz.com/~/945480746/0/scotthanselman~Is-the-craft-dead/comments#comments-start</comments><category>Musings</category><content:encoded><![CDATA[<div><p>The Japanese are really good at woodworking. And I love watching the Yankee workshop, my dad makes Native American bows and arrows completely from scratch in his workshop with trees that he finds.&nbsp;<p>This is all different from the stuff you get at IKEA, but I’ve been coding now for money for 35 years and systems are still complicated, computers still do dumb stuff, humans still do dumb stuff, this is just like the move from assembler to C, like the introduction of syntax highlighting, the introduction of intellisense, and the copy paste directly into production shift when stack overflow happened.</p>
<p>There is value in good taste, there is value in craftsmanship, and there is value in human judgment. The furniture might be differently designed, but we’re still interior designers and putting together a cohesive system is non-trivial.&nbsp;</p>
<p>Don’t let them gaslight you with one shot Minecraft clones and one shot C compilers. Software is still hard, it’s just that you’re no longer I/O bound with the speed of your fingertips.</p>
<p>I think that there will be lots of work for us cleaning up after the slop, but if you know what you’re doing AI augmented development is going to get you some amazing results and I am enjoying learning a ton during this momentous era shift - but the craft still exists.</p>
<br/><hr/>© 2025 Scott Hanselman. All rights reserved. 
<br/></div><Img align="left" border="0" height="1" width="1" alt="" style="border:0;float:left;margin:0;padding:0;width:1px!important;height:1px!important;" hspace="0" src="https://feeds.feedblitz.com/~/i/945480746/0/scotthanselman">
<div style="clear:both;padding-top:0.2em;"><a title="Like on Facebook" href="https://feeds.feedblitz.com/_/28/945480746/scotthanselman"><img height="20" src="https://assets.feedblitz.com/i/fblike20.png" style="border:0;margin:0;padding:0;"></a>&#160;<a title="Post to X.com" href="https://feeds.feedblitz.com/_/24/945480746/scotthanselman"><img height="20" src="https://assets.feedblitz.com/i/x.png" style="border:0;margin:0;padding:0;"></a>&#160;<a title="Subscribe by email" href="https://feeds.feedblitz.com/_/19/945480746/scotthanselman"><img height="20" src="https://assets.feedblitz.com/i/email20.png" style="border:0;margin:0;padding:0;"></a>&#160;<a title="Subscribe by RSS" href="https://feeds.feedblitz.com/_/20/945480746/scotthanselman"><img height="20" src="https://assets.feedblitz.com/i/rss20.png" style="border:0;margin:0;padding:0;"></a>&#160;</div>]]>
</content:encoded></item>
<item>
<feedburner:origLink>https://www.hanselman.com/blog/the-danger-of-glamourizing-one-shots</feedburner:origLink><trackback:ping>https://www.hanselman.com/blog/feed/trackback/18a2f0d8-2090-4b71-ac9c-c0d592583e22</trackback:ping><pingback:server>https://www.hanselman.com/blog/feed/pingback</pingback:server><pingback:target>https://www.hanselman.com/blog/post/18a2f0d8-2090-4b71-ac9c-c0d592583e22</pingback:target><dc:creator>Scott Hanselman</dc:creator><wfw:comment>https://feeds.feedblitz.com/~/945480803/0/scotthanselman~The-danger-of-glamourizing-one-shots/comments#comments-start</wfw:comment><wfw:commentRss>https://www.hanselman.com/blog/feed/rss/comments/18a2f0d8-2090-4b71-ac9c-c0d592583e22</wfw:commentRss><slash:comments>1</slash:comments><title>The danger of glamourizing one shots</title><guid isPermaLink="false">https://www.hanselman.com/blog/post/18a2f0d8-2090-4b71-ac9c-c0d592583e22</guid><link>https://feeds.feedblitz.com/~/945480803/0/scotthanselman~The-danger-of-glamourizing-one-shots</link><pubDate>Wed, 04 Feb 2026 05:53:00 GMT</pubDate><description><![CDATA[<div><p>People should not be judging AI-augmented coding by “1 shots.”&nbsp;</p><p>If someone told you that their model did a “one shot of Minecraft” and they’re impressed by that, you need to consider how much semantic heavy lifting the word “Minecraft” is doing in that prompt.&nbsp;</p><p>Ask them to one shot Minecraft without using the word Minecraft.&nbsp;</p><p>It’s not trivial to one shot something unique, because programming is the art of making the ambiguous incredibly specific through sculpting. AI sculpting is less about vibes and more about finding the specificity you want and keeping the system stable through changes. Good SDLC practices still matter, historical context still matters, and knowing how things work matters, shout out to Grady Booch.</p><p>It’s a cool party trick to one shot Mario Brothers or space invaders, but then you’ll end up with the most mid version of both. Literally mid. You’ll get the statistical fat part of the Bell curve version of these mythical games. You’re telling the model to close its eyes and draw the face of these games from memory.&nbsp;</p><p>As high-level programming cedes way to the prose compiler, making your goals and specs well understood to the ambiguity loop and showing good judgment is going to matter more than ever. Consider all of your words and make sure that certain words aren’t carrying all the semantic load, hidden or otherwise.</p><br/><hr/>© 2025 Scott Hanselman. All rights reserved. <br/></div><div style="clear:both;padding-top:0.2em;"><a title="Like on Facebook" href="https://feeds.feedblitz.com/_/28/945480803/scotthanselman"><img height="20" src="https://assets.feedblitz.com/i/fblike20.png" style="border:0;margin:0;padding:0;"></a>&#160;<a title="Post to X.com" href="https://feeds.feedblitz.com/_/24/945480803/scotthanselman"><img height="20" src="https://assets.feedblitz.com/i/x.png" style="border:0;margin:0;padding:0;"></a>&#160;<a title="Subscribe by email" href="https://feeds.feedblitz.com/_/19/945480803/scotthanselman"><img height="20" src="https://assets.feedblitz.com/i/email20.png" style="border:0;margin:0;padding:0;"></a>&#160;<a title="Subscribe by RSS" href="https://feeds.feedblitz.com/_/20/945480803/scotthanselman"><img height="20" src="https://assets.feedblitz.com/i/rss20.png" style="border:0;margin:0;padding:0;"></a>&#160;</div>]]>
</description><comments>https://feeds.feedblitz.com/~/945480803/0/scotthanselman~The-danger-of-glamourizing-one-shots/comments#comments-start</comments><category>Musings</category><content:encoded><![CDATA[<div><p>People should not be judging AI-augmented coding by “1 shots.”&nbsp;</p><p>If someone told you that their model did a “one shot of Minecraft” and they’re impressed by that, you need to consider how much semantic heavy lifting the word “Minecraft” is doing in that prompt.&nbsp;</p><p>Ask them to one shot Minecraft without using the word Minecraft.&nbsp;</p><p>It’s not trivial to one shot something unique, because programming is the art of making the ambiguous incredibly specific through sculpting. AI sculpting is less about vibes and more about finding the specificity you want and keeping the system stable through changes. Good SDLC practices still matter, historical context still matters, and knowing how things work matters, shout out to Grady Booch.</p><p>It’s a cool party trick to one shot Mario Brothers or space invaders, but then you’ll end up with the most mid version of both. Literally mid. You’ll get the statistical fat part of the Bell curve version of these mythical games. You’re telling the model to close its eyes and draw the face of these games from memory.&nbsp;</p><p>As high-level programming cedes way to the prose compiler, making your goals and specs well understood to the ambiguity loop and showing good judgment is going to matter more than ever. Consider all of your words and make sure that certain words aren’t carrying all the semantic load, hidden or otherwise.</p>
<br/><hr/>© 2025 Scott Hanselman. All rights reserved. 
<br/></div><Img align="left" border="0" height="1" width="1" alt="" style="border:0;float:left;margin:0;padding:0;width:1px!important;height:1px!important;" hspace="0" src="https://feeds.feedblitz.com/~/i/945480803/0/scotthanselman">
<div style="clear:both;padding-top:0.2em;"><a title="Like on Facebook" href="https://feeds.feedblitz.com/_/28/945480803/scotthanselman"><img height="20" src="https://assets.feedblitz.com/i/fblike20.png" style="border:0;margin:0;padding:0;"></a>&#160;<a title="Post to X.com" href="https://feeds.feedblitz.com/_/24/945480803/scotthanselman"><img height="20" src="https://assets.feedblitz.com/i/x.png" style="border:0;margin:0;padding:0;"></a>&#160;<a title="Subscribe by email" href="https://feeds.feedblitz.com/_/19/945480803/scotthanselman"><img height="20" src="https://assets.feedblitz.com/i/email20.png" style="border:0;margin:0;padding:0;"></a>&#160;<a title="Subscribe by RSS" href="https://feeds.feedblitz.com/_/20/945480803/scotthanselman"><img height="20" src="https://assets.feedblitz.com/i/rss20.png" style="border:0;margin:0;padding:0;"></a>&#160;</div>]]>
</content:encoded></item>
<item>
<feedburner:origLink>https://www.hanselman.com/blog/automatically-signing-a-windows-exe-with-azure-trusted-signing-dotnet-sign-and-github-actions</feedburner:origLink><trackback:ping>https://www.hanselman.com/blog/feed/trackback/e6ac5a6a-1d2a-4ad2-b116-71ff0508b5c3</trackback:ping><pingback:server>https://www.hanselman.com/blog/feed/pingback</pingback:server><pingback:target>https://www.hanselman.com/blog/post/e6ac5a6a-1d2a-4ad2-b116-71ff0508b5c3</pingback:target><dc:creator>Scott Hanselman</dc:creator><wfw:comment>https://feeds.feedblitz.com/~/930373259/0/scotthanselman~Automatically-Signing-a-Windows-EXE-with-Azure-Trusted-Signing-dotnet-sign-and-GitHub-Actions/comments#comments-start</wfw:comment><wfw:commentRss>https://www.hanselman.com/blog/feed/rss/comments/e6ac5a6a-1d2a-4ad2-b116-71ff0508b5c3</wfw:commentRss><slash:comments>12</slash:comments><title>Automatically Signing a Windows EXE with Azure Trusted Signing, dotnet sign, and GitHub Actions</title><guid isPermaLink="false">https://www.hanselman.com/blog/post/e6ac5a6a-1d2a-4ad2-b116-71ff0508b5c3</guid><link>https://feeds.feedblitz.com/~/930373259/0/scotthanselman~Automatically-Signing-a-Windows-EXE-with-Azure-Trusted-Signing-dotnet-sign-and-GitHub-Actions</link><pubDate>Fri, 28 Nov 2025 19:31:25 GMT</pubDate><description><![CDATA[<div><p><a href="https://www.hanselman.com/blog/content/binary/Windows-Live-Writer/Automatically-Signing-a-Windows-EXE-with_D0BE/image_2.png"><img title="WindowsEdgeLight on a Surface" style="float: right; padding-top: 0px; padding-left: 0px; margin: 0px 0px 0px 5px; display: inline; padding-right: 0px" alt="WindowsEdgeLight on a Surface" src="https://www.hanselman.com/blog/content/binary/Windows-Live-Writer/Automatically-Signing-a-Windows-EXE-with_D0BE/image_thumb%5B1%5D.png" width="400" align="right" height="344"></a>Mac Tahoe (in Beta as of the time of this writing) has this new feature called Edge Light that basically puts a bright picture of an Edge Light around your screen and basically uses the power of OLED to give you a virtual ring light. So I was like, why can't we also have nice things? I wrote (<a href="https://www.youtube.com/watch?v=WMbHVu4lAGA">vibed, with GitHub Copilot and Claude Sonnet 4.5</a>) a Windows Edge Light App (source code at <a title="https://github.com/shanselman/WindowsEdgeLight" href="https://github.com/shanselman/WindowsEdgeLight">https://github.com/shanselman/WindowsEdgeLight</a> and you can get the latest release here <a title="https://github.com/shanselman/WindowsEdgeLight/releases" href="https://github.com/shanselman/WindowsEdgeLight/releases">https://github.com/shanselman/WindowsEdgeLight/releases</a> or the app will check for new releases and autoupdate with Updatum).</p> <p>However, as is with all suss loose executables on the internet, when you run random stuff you'll often get the Window Defender 'new phone, who dis' warning which is scary. After several downloads and no viruses or complaints, my executable will eventually gain reputation with the Windows Defender Smart Screen service, but having a Code Signing Certificate is said to help with that. However, code signing certs are expensive and a hassle to manage and renew.</p> <p>Someone told me that <a href="https://azure.microsoft.com/en-us/products/trusted-signing">Azure Trusted Signing</a> was somewhat less of a hassle - it's less, but it's still non-trivial. I read <a href="https://weblog.west-wind.com/posts/2025/Jul/20/Fighting-through-Setting-up-Microsoft-Trusted-Signing">this post from Rick (his blog is gold and has been for years) earlier in the year</a> and some of it was super useful and other stuff has been made simpler over time.</p> <p>I wrote 80% of this blog post, but since I just spent an hour getting code signing to work and GitHub Copilot was going through and logging everything I did, I did use Claude 4.5 to help organize some of this. I have reviewed it all and re-written parts I didn't like, so any mistakes are mine.</p> <p>Azure Trusted Signing is Microsoft's cloud-based code signing service that:</p> <ul> <li><strong>No hardware tokens</strong> - Everything happens in the cloud  <li><strong>Automatic certificate management</strong> - Certificates are issued and renewed automatically  <li><strong>GitHub Actions integration</strong> - Sign during your CI/CD pipeline. I used GH Actions.  <li><strong>Kinda Affortable</strong> - About $10/month for small projects. I would like it if this were $10 a year. This is cheaper than a yearly cert, but it'll add up after a while so I'm always looking for cheaper/easier options.  <li><strong>Trusted by Windows</strong> - Uses the same certificate authority as Microsoft's own apps, so you should get your EXE trusted faster</li></ul> <h2>Prerequisites</h2> <p>Before starting, you'll need:  <ol> <li><strong>Azure subscription</strong>  <li><strong>Azure CLI</strong> - <a href="https://aka.ms/installazurecliwindows">Install from here</a>  <li><strong>Identity validation documents</strong> - Driver's license or passport for individual developers. Note that I'm in the US, so your mileage may vary but I basically set up the account, scanned a QR code, took a picture of my license, then did a selfie, then waited.  <li><strong>Windows PC</strong> - For local signing (optional) but I ended up using the dotnet sign tool. There are  <li><strong>GitHub repository</strong> - For automated signing (optional)</li></ol> <h3>Part 1: Setting Up Azure Trusted Signing</h3> <h4>Step 1: Register the Resource Provider</h4> <p>First, I need to enable the Azure Trusted Signing service in my subscription. This can be done in the Portal, or at the CLI. <pre><code># Login to Azure
az login
# Register the Microsoft.CodeSigning resource provider
az provider register --namespace Microsoft.CodeSigning
# Wait for registration to complete (takes 2-3 minutes)
az provider show --namespace Microsoft.CodeSigning --query "registrationState"
</code></pre>
<p>Wait until the output shows <code>"Registered"</code>. 
<h3>Step 2: Create a Trusted Signing Account</h3>
<p>Now create the actual signing account. You can do this via Azure Portal or CLI. 
<p><strong>Option A: Azure Portal (Easier for first-timers)</strong> 
<ol>
<li>Go to <a href="https://portal.azure.com/">Azure Portal</a> 
<li>Search for "Trusted Signing Accounts" 
<li>Click <strong>Create</strong> 
<li>Fill in: 
<ul>
<li><strong>Subscription</strong>: Your subscription 
<li><strong>Resource Group</strong>: Create new or use existing (e.g., "MyAppSigning") 
<li><strong>Account Name</strong>: A unique name (e.g., "myapp-signing") 
<li><strong>Region</strong>: Choose closest to you (e.g., "West US 2") 
<li><strong>SKU</strong>: Basic (sufficient for most apps)</li></ul>
<li>Click <strong>Review + Create</strong>, then <strong>Create</strong></li></ol>
<p><strong>Option B: Azure CLI (Faster if you are a CLI person or like to drive stick shift)</strong><pre><code># Create a resource group
az group create --name MyAppSigning --location westus2
# Create the Trusted Signing account
az trustedsigning create \
  --resource-group MyAppSigning \
  --account-name myapp-signing \
  --location westus2 \
  --sku-name Basic
</code></pre>
<p><strong>Important</strong>: Note your region endpoint. Common ones are: 
<ul>
<li>East US: <code>https://eus.codesigning.azure.net/</code> 
<li>West US 2: <code>https://wus2.codesigning.azure.net/</code> 
<li>Your specific region: Check in Azure Portal under your account's Overview page</li></ul>
<p>I totally flaked on this and messed around for 10 min before I realized that this URL matters and is specific to your account. Remember this endpoint.</p>
<h3>Step 3: Complete Identity Validation</h3>
<p>This is the most important step. Microsoft needs to verify you're a real person/organization. 
<ol>
<li>In Azure Portal, go to your Trusted Signing Account 
<li>Click <strong>Identity validation</strong> in the left menu 
<li>Click <strong>Add identity validation</strong> 
<li>Choose validation type: 
<ul>
<li><strong>Individual</strong>: For solo developers (uses driver's license/passport) 
<li><strong>Organization</strong>: For companies (uses business registration documents)</li></ul>
<li>For <strong>Individual validation</strong>: 
<ul>
<li>Upload a clear photo of your government-issued ID 
<li>Provide your full legal name (must match ID exactly) 
<li>Provide your email address</li></ul>
<li>Submit and wait for approval</li></ol>
<p><strong>Approval Time</strong>: 
<ul>
<li>Individual: Usually 1-3 business days 
<li>Organization: 3-5 business days 
<li>Me: This took about 4 hours, so again, YMMV. I used my personal account and my personal Azure (don't trust MSFT folks with unlimited Azure credits, I pay for my own) so they didn't know it was me. I went through the regular line, not the Pre-check line LOL.</li></ul>
<p>You'll receive an email when approved. <strong>You cannot sign any code until this is approved.</strong> 
<h3>Step 4: Create a Certificate Profile</h3>
<p>Once your identity is validated, create a certificate profile. This is what actually issues the signing certificates. 
<ol>
<li>In your Trusted Signing Account, click <strong>Certificate profiles</strong> 
<li>Click <strong>Add certificate profile</strong> 
<li>Fill in: 
<ul>
<li><strong>Profile name</strong>: Descriptive name (e.g., "MyAppProfile") 
<li><strong>Profile type</strong>: Choose <strong>Public Trust</strong> (required to prevent SmartScreen) 
<li><strong>Identity validation</strong>: Select your approved identity 
<li><strong>Certificate type</strong>: Code Signing</li></ul>
<li>Click <strong>Add</strong></li></ol>
<p><strong>Important</strong>: Only "Public Trust" profiles prevent SmartScreen warnings. "Private Trust" is for internal apps only. This took me a second to realize also as it's not an intuitive name. 
<h3>Step 5: Verify Your Setup</h3><pre><code># List your Trusted Signing accounts
az trustedsigning show \
  --resource-group MyAppSigning \
  --account-name myapp-signing
# Should show status: "Succeeded"
</code></pre>
<p><strong>Write down these values</strong> - you'll need them later: 
<ul>
<li><strong>Account Name</strong>: <code>myapp-signing</code> 
<li><strong>Certificate Profile Name</strong>: <code>MyAppProfile</code> 
<li><strong>Endpoint URL</strong>: <code>https://wus2.codesigning.azure.net/</code> (or your region) 
<li><strong>Subscription ID</strong>: Found in Azure Portal 
<li><strong>Resource Group</strong>: <code>MyAppSigning</code></li></ul>
<h2>Part 2: Local Code Signing</h2>
<p>Now let's sign an executable on your my machine. You don't NEED to do this, but I wanted to try it locally to avoid a bunch of CI/CD runs, and I wanted to right-click the EXE and see the cert in Properties before I took it all to the cloud. The nice part about this was that I didn't need to mess with any certificates. 
<h3>Step 1: Assign Yourself the Signing Role</h3>
<p>You need permission to actually use the signing service. 
<p><strong>Option A: Azure Portal</strong> 
<ol>
<li>Go to your Trusted Signing Account 
<li>Click <strong>Access control (IAM)</strong> 
<li>Click <strong>Add</strong> → <strong>Add role assignment</strong> 
<li>Search for and select <strong>Trusted Signing Certificate Profile Signer. </strong>This is important. I searched for "code" and found nothing. Search for "Trusted" 
<li>Click <strong>Next</strong> 
<li>Click <strong>Select members</strong> and find your user account 
<li>Click <strong>Select</strong>, then <strong>Review + assign</strong></li></ol>
<p><strong>Option B: Azure CLI</strong><pre><code># Get your user object ID
$userId = az ad signed-in-user show --query id -o tsv
# Assign the role
az role assignment create \
  --role "Trusted Signing Certificate Profile Signer" \
  --assignee-object-id $userId \
  --scope /subscriptions/YOUR_SUBSCRIPTION_ID/resourceGroups/MyAppSigning/providers/Microsoft.CodeSigning/codeSigningAccounts/myapp-signing
</code></pre>
<p>Replace <code>YOUR_SUBSCRIPTION_ID</code> with your actual subscription ID. 
<h3>Step 2: Login with the Correct Scope</h3>
<p>This is crucial - you need to login with the specific codesigning scope.<pre><code># Logout first to clear old tokens
az logout
# Login with codesigning scope
az login --use-device-code --scope "https://codesigning.azure.net/.default"
</code></pre>
<p>This will give you a code to enter at <a href="https://microsoft.com/devicelogin">https://microsoft.com/devicelogin</a>. Follow the prompts. 
<p><strong>Why device code flow?</strong> Because Azure CLI's default authentication can conflict with Visual Studio credentials in my experience. Device code flow is more reliable for code signing. 
<h3>Step 3: Download the Sign Tool</h3>
<p><strong>Option A: Install Globally (Recommended for regular use)</strong><pre><code># Install as a global tool (available everywhere)
dotnet tool install --global --prerelease sign
# Verify installation
sign --version
</code></pre>
<p><strong>Option B: Install Locally (Project-specific)</strong><pre><code># Install to current directory
dotnet tool install --tool-path . --prerelease sign
# Use with .\sign.exe
</code></pre>
<p><strong>Which should I use?</strong> 
<ul>
<li><strong>Global</strong>: If you'll sign multiple projects or sign frequently 
<li><strong>Local</strong>: If you want to keep the tool with a specific project or don't want it in your PATH</li></ul>
<h3>Step 4: Sign Your Executable</h3>
<p>Note again that code signing URL is specific to you. The tscp is your Trusted Signing Certificate Profile name and the tsa is your Trusted Signing Account name. I set *.exe to sign all the EXEs in the folder and note that the -b base directory is an absolute path, not a relative one. For me it was d:\github\WindowsEdgeLight\publish, and your mileage will vary.</p><pre><code># Navigate to your project folder
cd C:\MyProject
# Sign the executable
.\sign.exe code trusted-signing `
  -b "C:\MyProject\publish" `
  -tse "https://wus2.codesigning.azure.net" `
  -tscp "MyAppProfile" `
  -tsa "myapp-signing" `
  *.exe `
  -v Information
</code></pre>
<p><strong>Parameters explained:</strong> 
<ul>
<li><code>-b</code>: Base directory containing files to sign 
<li><code>-tse</code>: Trusted Signing endpoint (your region) 
<li><code>-tscp</code>: Certificate profile name 
<li><code>-tsa</code>: Trusted Signing account name 
<li><code>*.exe</code>: Pattern to match files to sign 
<li><code>-v</code>: Verbosity level (Trace, Information, Warning, Error)</li></ul>
<p><strong>Expected output:</strong><pre><code>info: Signing WindowsEdgeLight.exe succeeded.
Completed in 2743 ms.
</code></pre>
<h3>Step 5: Verify the Signature</h3>
<p>You can do this in PowerShell:</p><pre><code># Check the signature
Get-AuthenticodeSignature ".\publish\MyApp.exe" | Format-List
# Look for:
# Status: Valid
# SignerCertificate: CN=Your Name, O=Your Name, ...
# TimeStamperCertificate: Should be present
</code></pre>
<p><strong>Right-click the EXE</strong> → <strong>Properties</strong> → <strong>Digital Signatures</strong> tab: 
<ul>
<li>You should see your signature 
<li>"This digital signature is OK"</li></ul>
<h3>Common Local Signing Issues</h3>
<p>I hit all of these lol</p>
<p><strong>Issue: "Please run 'az login' to set up account"</strong> 
<ul>
<li><strong>Cause</strong>: Not logged in with the right scope 
<li><strong>Fix</strong>: Run <code>az logout</code> then <code>az login --use-device-code --scope "https://codesigning.azure.net/.default"</code></li></ul>
<p><strong>Issue: "403 Forbidden"</strong> 
<ul>
<li><strong>Cause</strong>: Wrong endpoint, account name, or missing permissions 
<li><strong>Fix</strong>: 
<ul>
<li>Verify endpoint matches your region (wus2, eus, etc.) 
<li>Verify account name is exact (case-sensitive) 
<li>Verify you have "Trusted Signing Certificate Profile Signer" role</li></ul></li></ul>
<p><strong>Issue: "User account does not exist in tenant"</strong> 
<ul>
<li><strong>Cause</strong>: Azure CLI trying to use Visual Studio credentials 
<li><strong>Fix</strong>: Use device code flow (see Step 2)</li></ul>
<h2>Part 3: Automated Signing with GitHub Actions</h2>
<p>This is where the magic happens. I want to automatically sign every release. I'm using GitVersion so I just need to tag a commit and GitHub Actions will kick off a run. You can go look at a real run in detail at <a title="https://github.com/shanselman/WindowsEdgeLight/actions/runs/19775054123" href="https://github.com/shanselman/WindowsEdgeLight/actions/runs/19775054123">https://github.com/shanselman/WindowsEdgeLight/actions/runs/19775054123</a>
<h3>Step 1: Create a Service Principal</h3>
<p>GitHub Actions needs its own identity to sign code. We'll create a service principal (like a robot account). This is VERY different than your local signing setup.
<p><strong>Important</strong>: You need <strong>Owner</strong> or <strong>User Access Administrator</strong> role on your subscription to do this. If you don't have it, ask your Azure admin or a friend.<pre><code># Create service principal with signing permissions
az ad sp create-for-rbac \
  --name "MyAppGitHubActions" \
  --role "Trusted Signing Certificate Profile Signer" \
  --scopes /subscriptions/YOUR_SUBSCRIPTION_ID/resourceGroups/MyAppSigning/providers/Microsoft.CodeSigning/codeSigningAccounts/myapp-signing \
  --json-auth
</code></pre>
<p>This outputs JSON like this:<pre><code>{
  "clientId": "12345678-1234-1234-1234-123456789abc",
  "clientSecret": "super-secret-value-abc123",
  "tenantId": "87654321-4321-4321-4321-cba987654321",
  "subscriptionId": "abcdef12-3456-7890-abcd-ef1234567890"
}
</code></pre>
<p><strong>SAVE THESE VALUES IMMEDIATELY!</strong> You can't retrieve the <code>clientSecret</code> again. This is super important.
<p><strong>Alternative: Azure Portal Method</strong> 
<p>If CLI doesn't work: 
<ol>
<li><strong>Azure Portal</strong> → <strong>App registrations</strong> → <strong>New registration</strong> 
<li>Name: "MyAppGitHubActions" 
<li>Click <strong>Register</strong> 
<li><strong>Copy the Application (client) ID</strong> - this is <code>AZURE_CLIENT_ID</code> 
<li><strong>Copy the Directory (tenant) ID</strong> - this is <code>AZURE_TENANT_ID</code> 
<li>Go to <strong>Certificates &amp; secrets</strong> → <strong>New client secret</strong> 
<li>Description: "GitHub Actions" 
<li>Expiration: 24 months (max) 
<li>Click <strong>Add</strong> and <strong>immediately copy the Value</strong> - this is <code>AZURE_CLIENT_SECRET</code> 
<li>Go to your Trusted Signing Account → <strong>Access control (IAM)</strong> 
<li><strong>Add role assignment</strong> → <strong>Trusted Signing Certificate Profile Signer</strong> 
<li><strong>Select members</strong> → Search for "MyAppGitHubActions" 
<li><strong>Review + assign</strong></li></ol>
<h3>Step 2: Add GitHub Secrets</h3>
<p>Go to your GitHub repository: 
<ol>
<li><strong>Settings</strong> → <strong>Secrets and variables</strong> → <strong>Actions</strong> 
<li>Click <strong>New repository secret</strong> for each:</li></ol>
<ul>
<li><code>AZURE_CLIENT_ID </code>- From service principal output or App registration </li>
<li><code>AZURE_CLIENT_SECRET <font face="Calibri">- </font></code>From service principal output or Certificates &amp; secrets </li>
<li><code>AZURE_TENANT_ID </code>- From service principal output or App registration </li>
<li><code>AZURE_SUBSCRIPTION_ID </code>- Azure Portal → Subscriptions </li></ul>
<p><strong>Security Note</strong>: These secrets are encrypted and never visible in logs. Only your workflow can access them. You'll never see them again.
<h3>Step 3: Update Your GitHub Workflow</h3>
<p>This is a little confusing as it's YAML, which is Satan's markup, but it's what we have sunk to as a society. 
<p>Note the dotnet-version below. Yours might be 8 or 9, etc. Also, I am building both x64 and ARM versions and I am using GitVersion so if you want a more complete build.yml, you can go here <a title="https://github.com/shanselman/WindowsEdgeLight/blob/master/.github/workflows/build.yml" href="https://github.com/shanselman/WindowsEdgeLight/blob/master/.github/workflows/build.yml">https://github.com/shanselman/WindowsEdgeLight/blob/master/.github/workflows/build.yml</a> I am also zipping mine up and prepping my releases so my loose EXE lives in a ZIP file.
<p>Add signing steps to your <code>.github/workflows/build.yml</code>:<pre><code>name: Build and Sign
on:
  push:
    tags:
      - 'v*'
  workflow_dispatch:
permissions:
  contents: write
jobs:
  build:
    runs-on: windows-latest
    
    steps:
    - name: Checkout code
      uses: actions/checkout@v4
      with:
        fetch-depth: 0
      
    - name: Setup .NET
      uses: actions/setup-dotnet@v4
      with:
        dotnet-version: '10.0.x'
        
    - name: Restore dependencies
      run: dotnet restore MyApp/MyApp.csproj
    - name: Build
      run: |
        dotnet publish MyApp/MyApp.csproj `
          -c Release `
          -r win-x64 `
          --self-contained
    # === SIGNING STEPS START HERE ===
    
    - name: Azure Login
      uses: azure/login@v2
      with:
        creds: '{"clientId":"${{ secrets.AZURE_CLIENT_ID }}","clientSecret":"${{ secrets.AZURE_CLIENT_SECRET }}","subscriptionId":"${{ secrets.AZURE_SUBSCRIPTION_ID }}","tenantId":"${{ secrets.AZURE_TENANT_ID }}"}'
    - name: Sign executables with Trusted Signing
      uses: azure/trusted-signing-action@v0
      with:
        azure-tenant-id: ${{ secrets.AZURE_TENANT_ID }}
        azure-client-id: ${{ secrets.AZURE_CLIENT_ID }}
        azure-client-secret: ${{ secrets.AZURE_CLIENT_SECRET }}
        endpoint: https://wus2.codesigning.azure.net/
        trusted-signing-account-name: myapp-signing
        certificate-profile-name: MyAppProfile
        files-folder: ${{ github.workspace }}\MyApp\bin\Release\net10.0-windows\win-x64\publish
        files-folder-filter: exe
        files-folder-recurse: true
        file-digest: SHA256
        timestamp-rfc3161: http://timestamp.acs.microsoft.com
        timestamp-digest: SHA256
    
    # === SIGNING STEPS END HERE ===
        
    - name: Create Release
      if: startsWith(github.ref, 'refs/tags/')
      uses: softprops/action-gh-release@v2
      with:
        files: MyApp/bin/Release/net10.0-windows/win-x64/publish/MyApp.exe
      env:
        GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
</code></pre>
<p><strong>Key points:</strong> 
<ul>
<li><code>endpoint</code>: Use YOUR region's endpoint (wus2, eus, etc.) 
<li><code>trusted-signing-account-name</code>: Your account name (exact, case-sensitive) 
<li><code>certificate-profile-name</code>: Your certificate profile name (exact, case-sensitive) 
<li><code>files-folder</code>: Path to your compiled executables 
<li><code>files-folder-filter</code>: File types to sign (exe, dll, etc.) 
<li><code>files-folder-recurse</code>: Sign files in subfolders</li></ul>
<h3>Step 4: Test the Workflow</h3>
<p>Now trigger the workflow. You have two options:</p>
<p><strong>Option A: Manual Trigger (Safest for testing)</strong>
<p>Since the workflow includes <code>workflow_dispatch:</code>, you can trigger it manually without creating a tag:<pre><code># Trigger manually via GitHub CLI
gh workflow run build.yml
# Or go to GitHub web UI:
# Actions tab → "Build and Sign" workflow → "Run workflow" button
</code></pre>
<p>This is ideal for testing because:
<ul>
<li>No tag required 
<li>Won't create a release 
<li>Can test multiple times 
<li>Easy to debug issues</li></ul>
<p><strong>Option B: Create a Tag (For actual releases)</strong><pre><code># Make sure you're on your main branch with no uncommitted changes
git status
# Create and push a tag
git tag v1.0.0
git push origin v1.0.0
</code></pre>
<p>Use this when you're ready to create an actual release with signed binaries. This is what I am doing on my side.
<h3>Step 5: Monitor the Build</h3>
<p>Watch the progress with GitHub CLI:<pre><code># See latest runs
gh run list --limit 5
# Watch a specific run
gh run watch
# View detailed status
gh run view --log
</code></pre>
<p>Or visit: <code>https://github.com/YOUR_USERNAME/YOUR_REPO/actions</code> 
<p><strong>Look for:</strong> 
<ul>
<li>Azure Login - Should complete in ~5 seconds 
<li>Sign executables with Trusted Signing - Should complete in ~10-30 seconds 
<li>Create Release - Your signed executable is now available in /releases in your GitHib project</li></ul>
<h3>Common GitHub Actions Issues</h3>
<p>I hit a few of these, natch.</p>
<p><strong>Issue: "403 Forbidden" during signing</strong> 
<ul>
<li><strong>Cause</strong>: Service principal doesn't have permissions 
<li><strong>Fix</strong>: 
<ol>
<li>Go to Azure Portal → Trusted Signing Account → Access control (IAM) 
<li>Verify "MyAppGitHubActions" has "Trusted Signing Certificate Profile Signer" role 
<li>If not, add it manually</li></ol></li></ul>
<p><strong>Issue: "No files matched the pattern"</strong> 
<ul>
<li><strong>Cause</strong>: Wrong <code>files-folder</code> path or build artifacts in wrong location 
<li><strong>Fix</strong>: 
<ol>
<li>Add a debug step before signing: <code>- run: Get-ChildItem -Recurse</code> 
<li>Find where your EXE is actually located 
<li>Update <code>files-folder</code> to match</li></ol></li></ul>
<p><strong>Issue: Secrets not working</strong> 
<ul>
<li><strong>Cause</strong>: Typo in secret name or value not saved 
<li><strong>Fix</strong>: 
<ol>
<li>Verify secret names EXACTLY match (case-sensitive) 
<li>Re-create secrets if unsure 
<li>Make sure no extra spaces in values</li></ol></li></ul>
<p><strong>Issue: "DefaultAzureCredential authentication failed"</strong> 
<ul>
<li><strong>Cause</strong>: Usually wrong tenant ID or client ID 
<li><strong>Fix</strong>: Verify all 4 secrets are correct from service principal output</li></ul>
<h2>Part 4: Understanding the Certificate</h2>
<h3>Certificate Lifecycle</h3>
<p>Azure Trusted Signing uses <strong>short-lived certificates</strong> (typically 3 days). This freaked me out but they say this is actually a security feature: </p>
<ul>
<li>If a certificate is compromised, it expires quickly 
<li>You never manage certificate files or passwords 
<li>Automatic renewal - you don't have to do anything</li></ul>
<p><strong>But won't my signature break after 3 days?</strong> 
<p>No, it seems that's what <strong>timestamping</strong> is for. When you sign a file: 
<ol>
<li>Azure issues a 3-day certificate 
<li>The file is signed with that certificate 
<li>A timestamp server records "this file was signed on DATE" 
<li>Even after the certificate expires, the signature remains valid because the timestamp proves it was signed when the certificate was valid</li></ol>
<p>That's why both local and GitHub Actions signing include:<pre><code>timestamp-rfc3161: http://timestamp.acs.microsoft.com
</code></pre>
<h3>What the Certificate Contains</h3>
<p>Your signed executable has a certificate with: 
<ul>
<li><strong>Subject</strong>: Your name (e.g., "CN=John Doe, O=John Doe, L=Seattle, S=Washington, C=US") 
<li><strong>Issuer</strong>: Microsoft ID Verified CS EOC CA 01 
<li><strong>Valid Dates</strong>: 3-day window 
<li><strong>Key Size</strong>: 3072-bit RSA (very secure) 
<li><strong>Enhanced Key Usage</strong>: Code Signing</li></ul>
<h3>Verify Certificate on Any Machine</h3><pre><code># Using PowerShell
Get-AuthenticodeSignature "MyApp.exe" | Select-Object -ExpandProperty SignerCertificate | Format-List
# Using Windows UI
# Right-click EXE → Properties → Digital Signatures tab → Details → View Certificate
</code></pre>
<p>This whole thing took me about an hour to 75 minutes. It was detailed, but not deeply difficult. Misspellings, case-sensitivity, and a few account issues with Role-Based Access Control did slow me down. Hope this helps!</p>
<h3>Used Resources</h3>
<ul>
<li><a href="https://learn.microsoft.com/en-us/azure/trusted-signing/">Azure Trusted Signing Documentation</a> 
<li><a href="https://github.com/dotnet/sign">dotnet/sign Tool</a> 
<li><a href="https://github.com/Azure/trusted-signing-action">azure/trusted-signing-action</a> 
<li><a href="https://learn.microsoft.com/en-us/windows/win32/seccrypto/cryptography-tools">Windows Code Signing Best Practices</a> 
<li><a href="https://learn.microsoft.com/en-us/windows/security/threat-protection/microsoft-defender-smartscreen/microsoft-defender-smartscreen-overview">SmartScreen Reputation System</a></li></ul>
<p><em>Written in November 2025 based on real-world implementation for WindowsEdgeLight. Your setup might vary slightly depending on Azure region and account type. Things change, be stoic.</em></p><br/><hr/>© 2025 Scott Hanselman. All rights reserved. <br/></div><div style="clear:both;padding-top:0.2em;"><a title="Like on Facebook" href="https://feeds.feedblitz.com/_/28/930373259/scotthanselman"><img height="20" src="https://assets.feedblitz.com/i/fblike20.png" style="border:0;margin:0;padding:0;"></a>&#160;<a title="Post to X.com" href="https://feeds.feedblitz.com/_/24/930373259/scotthanselman"><img height="20" src="https://assets.feedblitz.com/i/x.png" style="border:0;margin:0;padding:0;"></a>&#160;<a title="Subscribe by email" href="https://feeds.feedblitz.com/_/19/930373259/scotthanselman"><img height="20" src="https://assets.feedblitz.com/i/email20.png" style="border:0;margin:0;padding:0;"></a>&#160;<a title="Subscribe by RSS" href="https://feeds.feedblitz.com/_/20/930373259/scotthanselman"><img height="20" src="https://assets.feedblitz.com/i/rss20.png" style="border:0;margin:0;padding:0;"></a>&#160;</div>]]>
</description><comments>https://feeds.feedblitz.com/~/930373259/0/scotthanselman~Automatically-Signing-a-Windows-EXE-with-Azure-Trusted-Signing-dotnet-sign-and-GitHub-Actions/comments#comments-start</comments><category>Azure</category><category>DotNetCore</category><content:encoded><![CDATA[<div><p><a href="http://feeds.feedblitz.com/~/t/0/0/scotthanselman/~https://www.hanselman.com/blog/content/binary/Windows-Live-Writer/Automatically-Signing-a-Windows-EXE-with_D0BE/image_2.png"><img title="WindowsEdgeLight on a Surface" style="float: right; padding-top: 0px; padding-left: 0px; margin: 0px 0px 0px 5px; display: inline; padding-right: 0px" alt="WindowsEdgeLight on a Surface" src="https://www.hanselman.com/blog/content/binary/Windows-Live-Writer/Automatically-Signing-a-Windows-EXE-with_D0BE/image_thumb%5B1%5D.png" width="400" align="right" height="344"></a>Mac Tahoe (in Beta as of the time of this writing) has this new feature called Edge Light that basically puts a bright picture of an Edge Light around your screen and basically uses the power of OLED to give you a virtual ring light. So I was like, why can't we also have nice things? I wrote (<a href="http://feeds.feedblitz.com/~/t/0/0/scotthanselman/~https://www.youtube.com/watch?v=WMbHVu4lAGA">vibed, with GitHub Copilot and Claude Sonnet 4.5</a>) a Windows Edge Light App (source code at <a title="https://github.com/shanselman/WindowsEdgeLight" href="http://feeds.feedblitz.com/~/t/0/0/scotthanselman/~https://github.com/shanselman/WindowsEdgeLight">https://github.com/shanselman/WindowsEdgeLight</a> and you can get the latest release here <a title="https://github.com/shanselman/WindowsEdgeLight/releases" href="http://feeds.feedblitz.com/~/t/0/0/scotthanselman/~https://github.com/shanselman/WindowsEdgeLight/releases">https://github.com/shanselman/WindowsEdgeLight/releases</a> or the app will check for new releases and autoupdate with Updatum).</p> <p>However, as is with all suss loose executables on the internet, when you run random stuff you'll often get the Window Defender 'new phone, who dis' warning which is scary. After several downloads and no viruses or complaints, my executable will eventually gain reputation with the Windows Defender Smart Screen service, but having a Code Signing Certificate is said to help with that. However, code signing certs are expensive and a hassle to manage and renew.</p> <p>Someone told me that <a href="http://feeds.feedblitz.com/~/t/0/0/scotthanselman/~https://azure.microsoft.com/en-us/products/trusted-signing">Azure Trusted Signing</a> was somewhat less of a hassle - it's less, but it's still non-trivial. I read <a href="http://feeds.feedblitz.com/~/t/0/0/scotthanselman/~https://weblog.west-wind.com/posts/2025/Jul/20/Fighting-through-Setting-up-Microsoft-Trusted-Signing">this post from Rick (his blog is gold and has been for years) earlier in the year</a> and some of it was super useful and other stuff has been made simpler over time.</p> <p>I wrote 80% of this blog post, but since I just spent an hour getting code signing to work and GitHub Copilot was going through and logging everything I did, I did use Claude 4.5 to help organize some of this. I have reviewed it all and re-written parts I didn't like, so any mistakes are mine.</p> <p>Azure Trusted Signing is Microsoft's cloud-based code signing service that:</p> <ul> <li><strong>No hardware tokens</strong> - Everything happens in the cloud  <li><strong>Automatic certificate management</strong> - Certificates are issued and renewed automatically  <li><strong>GitHub Actions integration</strong> - Sign during your CI/CD pipeline. I used GH Actions.  <li><strong>Kinda Affortable</strong> - About $10/month for small projects. I would like it if this were $10 a year. This is cheaper than a yearly cert, but it'll add up after a while so I'm always looking for cheaper/easier options.  <li><strong>Trusted by Windows</strong> - Uses the same certificate authority as Microsoft's own apps, so you should get your EXE trusted faster</li></ul> <h2>Prerequisites</h2> <p>Before starting, you'll need:  <ol> <li><strong>Azure subscription</strong>  <li><strong>Azure CLI</strong> - <a href="http://feeds.feedblitz.com/~/t/0/0/scotthanselman/~https://aka.ms/installazurecliwindows">Install from here</a>  <li><strong>Identity validation documents</strong> - Driver's license or passport for individual developers. Note that I'm in the US, so your mileage may vary but I basically set up the account, scanned a QR code, took a picture of my license, then did a selfie, then waited.  <li><strong>Windows PC</strong> - For local signing (optional) but I ended up using the dotnet sign tool. There are  <li><strong>GitHub repository</strong> - For automated signing (optional)</li></ol> <h3>Part 1: Setting Up Azure Trusted Signing</h3> <h4>Step 1: Register the Resource Provider</h4> <p>First, I need to enable the Azure Trusted Signing service in my subscription. This can be done in the Portal, or at the CLI. <pre><code># Login to Azure
az login
# Register the Microsoft.CodeSigning resource provider
az provider register --namespace Microsoft.CodeSigning
# Wait for registration to complete (takes 2-3 minutes)
az provider show --namespace Microsoft.CodeSigning --query "registrationState"
</code></pre>
<p>Wait until the output shows <code>"Registered"</code>. 
<h3>Step 2: Create a Trusted Signing Account</h3>
<p>Now create the actual signing account. You can do this via Azure Portal or CLI. 
<p><strong>Option A: Azure Portal (Easier for first-timers)</strong> 
<ol>
<li>Go to <a href="http://feeds.feedblitz.com/~/t/0/0/scotthanselman/~https://portal.azure.com/">Azure Portal</a> 
<li>Search for "Trusted Signing Accounts" 
<li>Click <strong>Create</strong> 
<li>Fill in: 
<ul>
<li><strong>Subscription</strong>: Your subscription 
<li><strong>Resource Group</strong>: Create new or use existing (e.g., "MyAppSigning") 
<li><strong>Account Name</strong>: A unique name (e.g., "myapp-signing") 
<li><strong>Region</strong>: Choose closest to you (e.g., "West US 2") 
<li><strong>SKU</strong>: Basic (sufficient for most apps)</li></ul>
<li>Click <strong>Review + Create</strong>, then <strong>Create</strong></li></ol>
<p><strong>Option B: Azure CLI (Faster if you are a CLI person or like to drive stick shift)</strong><pre><code># Create a resource group
az group create --name MyAppSigning --location westus2
# Create the Trusted Signing account
az trustedsigning create \
  --resource-group MyAppSigning \
  --account-name myapp-signing \
  --location westus2 \
  --sku-name Basic
</code></pre>
<p><strong>Important</strong>: Note your region endpoint. Common ones are: 
<ul>
<li>East US: <code>https://eus.codesigning.azure.net/</code> 
<li>West US 2: <code>https://wus2.codesigning.azure.net/</code> 
<li>Your specific region: Check in Azure Portal under your account's Overview page</li></ul>
<p>I totally flaked on this and messed around for 10 min before I realized that this URL matters and is specific to your account. Remember this endpoint.</p>
<h3>Step 3: Complete Identity Validation</h3>
<p>This is the most important step. Microsoft needs to verify you're a real person/organization. 
<ol>
<li>In Azure Portal, go to your Trusted Signing Account 
<li>Click <strong>Identity validation</strong> in the left menu 
<li>Click <strong>Add identity validation</strong> 
<li>Choose validation type: 
<ul>
<li><strong>Individual</strong>: For solo developers (uses driver's license/passport) 
<li><strong>Organization</strong>: For companies (uses business registration documents)</li></ul>
<li>For <strong>Individual validation</strong>: 
<ul>
<li>Upload a clear photo of your government-issued ID 
<li>Provide your full legal name (must match ID exactly) 
<li>Provide your email address</li></ul>
<li>Submit and wait for approval</li></ol>
<p><strong>Approval Time</strong>: 
<ul>
<li>Individual: Usually 1-3 business days 
<li>Organization: 3-5 business days 
<li>Me: This took about 4 hours, so again, YMMV. I used my personal account and my personal Azure (don't trust MSFT folks with unlimited Azure credits, I pay for my own) so they didn't know it was me. I went through the regular line, not the Pre-check line LOL.</li></ul>
<p>You'll receive an email when approved. <strong>You cannot sign any code until this is approved.</strong> 
<h3>Step 4: Create a Certificate Profile</h3>
<p>Once your identity is validated, create a certificate profile. This is what actually issues the signing certificates. 
<ol>
<li>In your Trusted Signing Account, click <strong>Certificate profiles</strong> 
<li>Click <strong>Add certificate profile</strong> 
<li>Fill in: 
<ul>
<li><strong>Profile name</strong>: Descriptive name (e.g., "MyAppProfile") 
<li><strong>Profile type</strong>: Choose <strong>Public Trust</strong> (required to prevent SmartScreen) 
<li><strong>Identity validation</strong>: Select your approved identity 
<li><strong>Certificate type</strong>: Code Signing</li></ul>
<li>Click <strong>Add</strong></li></ol>
<p><strong>Important</strong>: Only "Public Trust" profiles prevent SmartScreen warnings. "Private Trust" is for internal apps only. This took me a second to realize also as it's not an intuitive name. 
<h3>Step 5: Verify Your Setup</h3><pre><code># List your Trusted Signing accounts
az trustedsigning show \
  --resource-group MyAppSigning \
  --account-name myapp-signing
# Should show status: "Succeeded"
</code></pre>
<p><strong>Write down these values</strong> - you'll need them later: 
<ul>
<li><strong>Account Name</strong>: <code>myapp-signing</code> 
<li><strong>Certificate Profile Name</strong>: <code>MyAppProfile</code> 
<li><strong>Endpoint URL</strong>: <code>https://wus2.codesigning.azure.net/</code> (or your region) 
<li><strong>Subscription ID</strong>: Found in Azure Portal 
<li><strong>Resource Group</strong>: <code>MyAppSigning</code></li></ul>
<h2>Part 2: Local Code Signing</h2>
<p>Now let's sign an executable on your my machine. You don't NEED to do this, but I wanted to try it locally to avoid a bunch of CI/CD runs, and I wanted to right-click the EXE and see the cert in Properties before I took it all to the cloud. The nice part about this was that I didn't need to mess with any certificates. 
<h3>Step 1: Assign Yourself the Signing Role</h3>
<p>You need permission to actually use the signing service. 
<p><strong>Option A: Azure Portal</strong> 
<ol>
<li>Go to your Trusted Signing Account 
<li>Click <strong>Access control (IAM)</strong> 
<li>Click <strong>Add</strong> → <strong>Add role assignment</strong> 
<li>Search for and select <strong>Trusted Signing Certificate Profile Signer. </strong>This is important. I searched for "code" and found nothing. Search for "Trusted" 
<li>Click <strong>Next</strong> 
<li>Click <strong>Select members</strong> and find your user account 
<li>Click <strong>Select</strong>, then <strong>Review + assign</strong></li></ol>
<p><strong>Option B: Azure CLI</strong><pre><code># Get your user object ID
$userId = az ad signed-in-user show --query id -o tsv
# Assign the role
az role assignment create \
  --role "Trusted Signing Certificate Profile Signer" \
  --assignee-object-id $userId \
  --scope /subscriptions/YOUR_SUBSCRIPTION_ID/resourceGroups/MyAppSigning/providers/Microsoft.CodeSigning/codeSigningAccounts/myapp-signing
</code></pre>
<p>Replace <code>YOUR_SUBSCRIPTION_ID</code> with your actual subscription ID. 
<h3>Step 2: Login with the Correct Scope</h3>
<p>This is crucial - you need to login with the specific codesigning scope.<pre><code># Logout first to clear old tokens
az logout
# Login with codesigning scope
az login --use-device-code --scope "https://codesigning.azure.net/.default"
</code></pre>
<p>This will give you a code to enter at <a href="http://feeds.feedblitz.com/~/t/0/0/scotthanselman/~https://microsoft.com/devicelogin">https://microsoft.com/devicelogin</a>. Follow the prompts. 
<p><strong>Why device code flow?</strong> Because Azure CLI's default authentication can conflict with Visual Studio credentials in my experience. Device code flow is more reliable for code signing. 
<h3>Step 3: Download the Sign Tool</h3>
<p><strong>Option A: Install Globally (Recommended for regular use)</strong><pre><code># Install as a global tool (available everywhere)
dotnet tool install --global --prerelease sign
# Verify installation
sign --version
</code></pre>
<p><strong>Option B: Install Locally (Project-specific)</strong><pre><code># Install to current directory
dotnet tool install --tool-path . --prerelease sign
# Use with .\sign.exe
</code></pre>
<p><strong>Which should I use?</strong> 
<ul>
<li><strong>Global</strong>: If you'll sign multiple projects or sign frequently 
<li><strong>Local</strong>: If you want to keep the tool with a specific project or don't want it in your PATH</li></ul>
<h3>Step 4: Sign Your Executable</h3>
<p>Note again that code signing URL is specific to you. The tscp is your Trusted Signing Certificate Profile name and the tsa is your Trusted Signing Account name. I set *.exe to sign all the EXEs in the folder and note that the -b base directory is an absolute path, not a relative one. For me it was d:\github\WindowsEdgeLight\publish, and your mileage will vary.</p><pre><code># Navigate to your project folder
cd C:\MyProject
# Sign the executable
.\sign.exe code trusted-signing `
  -b "C:\MyProject\publish" `
  -tse "https://wus2.codesigning.azure.net" `
  -tscp "MyAppProfile" `
  -tsa "myapp-signing" `
  *.exe `
  -v Information
</code></pre>
<p><strong>Parameters explained:</strong> 
<ul>
<li><code>-b</code>: Base directory containing files to sign 
<li><code>-tse</code>: Trusted Signing endpoint (your region) 
<li><code>-tscp</code>: Certificate profile name 
<li><code>-tsa</code>: Trusted Signing account name 
<li><code>*.exe</code>: Pattern to match files to sign 
<li><code>-v</code>: Verbosity level (Trace, Information, Warning, Error)</li></ul>
<p><strong>Expected output:</strong><pre><code>info: Signing WindowsEdgeLight.exe succeeded.
Completed in 2743 ms.
</code></pre>
<h3>Step 5: Verify the Signature</h3>
<p>You can do this in PowerShell:</p><pre><code># Check the signature
Get-AuthenticodeSignature ".\publish\MyApp.exe" | Format-List
# Look for:
# Status: Valid
# SignerCertificate: CN=Your Name, O=Your Name, ...
# TimeStamperCertificate: Should be present
</code></pre>
<p><strong>Right-click the EXE</strong> → <strong>Properties</strong> → <strong>Digital Signatures</strong> tab: 
<ul>
<li>You should see your signature 
<li>"This digital signature is OK"</li></ul>
<h3>Common Local Signing Issues</h3>
<p>I hit all of these lol</p>
<p><strong>Issue: "Please run 'az login' to set up account"</strong> 
<ul>
<li><strong>Cause</strong>: Not logged in with the right scope 
<li><strong>Fix</strong>: Run <code>az logout</code> then <code>az login --use-device-code --scope "https://codesigning.azure.net/.default"</code></li></ul>
<p><strong>Issue: "403 Forbidden"</strong> 
<ul>
<li><strong>Cause</strong>: Wrong endpoint, account name, or missing permissions 
<li><strong>Fix</strong>: 
<ul>
<li>Verify endpoint matches your region (wus2, eus, etc.) 
<li>Verify account name is exact (case-sensitive) 
<li>Verify you have "Trusted Signing Certificate Profile Signer" role</li></ul></li></ul>
<p><strong>Issue: "User account does not exist in tenant"</strong> 
<ul>
<li><strong>Cause</strong>: Azure CLI trying to use Visual Studio credentials 
<li><strong>Fix</strong>: Use device code flow (see Step 2)</li></ul>
<h2>Part 3: Automated Signing with GitHub Actions</h2>
<p>This is where the magic happens. I want to automatically sign every release. I'm using GitVersion so I just need to tag a commit and GitHub Actions will kick off a run. You can go look at a real run in detail at <a title="https://github.com/shanselman/WindowsEdgeLight/actions/runs/19775054123" href="http://feeds.feedblitz.com/~/t/0/0/scotthanselman/~https://github.com/shanselman/WindowsEdgeLight/actions/runs/19775054123">https://github.com/shanselman/WindowsEdgeLight/actions/runs/19775054123</a>
<h3>Step 1: Create a Service Principal</h3>
<p>GitHub Actions needs its own identity to sign code. We'll create a service principal (like a robot account). This is VERY different than your local signing setup.
<p><strong>Important</strong>: You need <strong>Owner</strong> or <strong>User Access Administrator</strong> role on your subscription to do this. If you don't have it, ask your Azure admin or a friend.<pre><code># Create service principal with signing permissions
az ad sp create-for-rbac \
  --name "MyAppGitHubActions" \
  --role "Trusted Signing Certificate Profile Signer" \
  --scopes /subscriptions/YOUR_SUBSCRIPTION_ID/resourceGroups/MyAppSigning/providers/Microsoft.CodeSigning/codeSigningAccounts/myapp-signing \
  --json-auth
</code></pre>
<p>This outputs JSON like this:<pre><code>{
  "clientId": "12345678-1234-1234-1234-123456789abc",
  "clientSecret": "super-secret-value-abc123",
  "tenantId": "87654321-4321-4321-4321-cba987654321",
  "subscriptionId": "abcdef12-3456-7890-abcd-ef1234567890"
}
</code></pre>
<p><strong>SAVE THESE VALUES IMMEDIATELY!</strong> You can't retrieve the <code>clientSecret</code> again. This is super important.
<p><strong>Alternative: Azure Portal Method</strong> 
<p>If CLI doesn't work: 
<ol>
<li><strong>Azure Portal</strong> → <strong>App registrations</strong> → <strong>New registration</strong> 
<li>Name: "MyAppGitHubActions" 
<li>Click <strong>Register</strong> 
<li><strong>Copy the Application (client) ID</strong> - this is <code>AZURE_CLIENT_ID</code> 
<li><strong>Copy the Directory (tenant) ID</strong> - this is <code>AZURE_TENANT_ID</code> 
<li>Go to <strong>Certificates &amp; secrets</strong> → <strong>New client secret</strong> 
<li>Description: "GitHub Actions" 
<li>Expiration: 24 months (max) 
<li>Click <strong>Add</strong> and <strong>immediately copy the Value</strong> - this is <code>AZURE_CLIENT_SECRET</code> 
<li>Go to your Trusted Signing Account → <strong>Access control (IAM)</strong> 
<li><strong>Add role assignment</strong> → <strong>Trusted Signing Certificate Profile Signer</strong> 
<li><strong>Select members</strong> → Search for "MyAppGitHubActions" 
<li><strong>Review + assign</strong></li></ol>
<h3>Step 2: Add GitHub Secrets</h3>
<p>Go to your GitHub repository: 
<ol>
<li><strong>Settings</strong> → <strong>Secrets and variables</strong> → <strong>Actions</strong> 
<li>Click <strong>New repository secret</strong> for each:</li></ol>
<ul>
<li><code>AZURE_CLIENT_ID </code>- From service principal output or App registration </li>
<li><code>AZURE_CLIENT_SECRET <font face="Calibri">- </font></code>From service principal output or Certificates &amp; secrets </li>
<li><code>AZURE_TENANT_ID </code>- From service principal output or App registration </li>
<li><code>AZURE_SUBSCRIPTION_ID </code>- Azure Portal → Subscriptions </li></ul>
<p><strong>Security Note</strong>: These secrets are encrypted and never visible in logs. Only your workflow can access them. You'll never see them again.
<h3>Step 3: Update Your GitHub Workflow</h3>
<p>This is a little confusing as it's YAML, which is Satan's markup, but it's what we have sunk to as a society. 
<p>Note the dotnet-version below. Yours might be 8 or 9, etc. Also, I am building both x64 and ARM versions and I am using GitVersion so if you want a more complete build.yml, you can go here <a title="https://github.com/shanselman/WindowsEdgeLight/blob/master/.github/workflows/build.yml" href="http://feeds.feedblitz.com/~/t/0/0/scotthanselman/~https://github.com/shanselman/WindowsEdgeLight/blob/master/.github/workflows/build.yml">https://github.com/shanselman/WindowsEdgeLight/blob/master/.github/workflows/build.yml</a> I am also zipping mine up and prepping my releases so my loose EXE lives in a ZIP file.
<p>Add signing steps to your <code>.github/workflows/build.yml</code>:<pre><code>name: Build and Sign
on:
  push:
    tags:
      - 'v*'
  workflow_dispatch:
permissions:
  contents: write
jobs:
  build:
    runs-on: windows-latest
    
    steps:
    - name: Checkout code
      uses: actions/checkout@v4
      with:
        fetch-depth: 0
      
    - name: Setup .NET
      uses: actions/setup-dotnet@v4
      with:
        dotnet-version: '10.0.x'
        
    - name: Restore dependencies
      run: dotnet restore MyApp/MyApp.csproj
    - name: Build
      run: |
        dotnet publish MyApp/MyApp.csproj `
          -c Release `
          -r win-x64 `
          --self-contained
    # === SIGNING STEPS START HERE ===
    
    - name: Azure Login
      uses: azure/login@v2
      with:
        creds: '{"clientId":"${{ secrets.AZURE_CLIENT_ID }}","clientSecret":"${{ secrets.AZURE_CLIENT_SECRET }}","subscriptionId":"${{ secrets.AZURE_SUBSCRIPTION_ID }}","tenantId":"${{ secrets.AZURE_TENANT_ID }}"}'
    - name: Sign executables with Trusted Signing
      uses: azure/trusted-signing-action@v0
      with:
        azure-tenant-id: ${{ secrets.AZURE_TENANT_ID }}
        azure-client-id: ${{ secrets.AZURE_CLIENT_ID }}
        azure-client-secret: ${{ secrets.AZURE_CLIENT_SECRET }}
        endpoint: https://wus2.codesigning.azure.net/
        trusted-signing-account-name: myapp-signing
        certificate-profile-name: MyAppProfile
        files-folder: ${{ github.workspace }}\MyApp\bin\Release\net10.0-windows\win-x64\publish
        files-folder-filter: exe
        files-folder-recurse: true
        file-digest: SHA256
        timestamp-rfc3161: http://timestamp.acs.microsoft.com
        timestamp-digest: SHA256
    
    # === SIGNING STEPS END HERE ===
        
    - name: Create Release
      if: startsWith(github.ref, 'refs/tags/')
      uses: softprops/action-gh-release@v2
      with:
        files: MyApp/bin/Release/net10.0-windows/win-x64/publish/MyApp.exe
      env:
        GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
</code></pre>
<p><strong>Key points:</strong> 
<ul>
<li><code>endpoint</code>: Use YOUR region's endpoint (wus2, eus, etc.) 
<li><code>trusted-signing-account-name</code>: Your account name (exact, case-sensitive) 
<li><code>certificate-profile-name</code>: Your certificate profile name (exact, case-sensitive) 
<li><code>files-folder</code>: Path to your compiled executables 
<li><code>files-folder-filter</code>: File types to sign (exe, dll, etc.) 
<li><code>files-folder-recurse</code>: Sign files in subfolders</li></ul>
<h3>Step 4: Test the Workflow</h3>
<p>Now trigger the workflow. You have two options:</p>
<p><strong>Option A: Manual Trigger (Safest for testing)</strong>
<p>Since the workflow includes <code>workflow_dispatch:</code>, you can trigger it manually without creating a tag:<pre><code># Trigger manually via GitHub CLI
gh workflow run build.yml
# Or go to GitHub web UI:
# Actions tab → "Build and Sign" workflow → "Run workflow" button
</code></pre>
<p>This is ideal for testing because:
<ul>
<li>No tag required 
<li>Won't create a release 
<li>Can test multiple times 
<li>Easy to debug issues</li></ul>
<p><strong>Option B: Create a Tag (For actual releases)</strong><pre><code># Make sure you're on your main branch with no uncommitted changes
git status
# Create and push a tag
git tag v1.0.0
git push origin v1.0.0
</code></pre>
<p>Use this when you're ready to create an actual release with signed binaries. This is what I am doing on my side.
<h3>Step 5: Monitor the Build</h3>
<p>Watch the progress with GitHub CLI:<pre><code># See latest runs
gh run list --limit 5
# Watch a specific run
gh run watch
# View detailed status
gh run view --log
</code></pre>
<p>Or visit: <code>https://github.com/YOUR_USERNAME/YOUR_REPO/actions</code> 
<p><strong>Look for:</strong> 
<ul>
<li>Azure Login - Should complete in ~5 seconds 
<li>Sign executables with Trusted Signing - Should complete in ~10-30 seconds 
<li>Create Release - Your signed executable is now available in /releases in your GitHib project</li></ul>
<h3>Common GitHub Actions Issues</h3>
<p>I hit a few of these, natch.</p>
<p><strong>Issue: "403 Forbidden" during signing</strong> 
<ul>
<li><strong>Cause</strong>: Service principal doesn't have permissions 
<li><strong>Fix</strong>: 
<ol>
<li>Go to Azure Portal → Trusted Signing Account → Access control (IAM) 
<li>Verify "MyAppGitHubActions" has "Trusted Signing Certificate Profile Signer" role 
<li>If not, add it manually</li></ol></li></ul>
<p><strong>Issue: "No files matched the pattern"</strong> 
<ul>
<li><strong>Cause</strong>: Wrong <code>files-folder</code> path or build artifacts in wrong location 
<li><strong>Fix</strong>: 
<ol>
<li>Add a debug step before signing: <code>- run: Get-ChildItem -Recurse</code> 
<li>Find where your EXE is actually located 
<li>Update <code>files-folder</code> to match</li></ol></li></ul>
<p><strong>Issue: Secrets not working</strong> 
<ul>
<li><strong>Cause</strong>: Typo in secret name or value not saved 
<li><strong>Fix</strong>: 
<ol>
<li>Verify secret names EXACTLY match (case-sensitive) 
<li>Re-create secrets if unsure 
<li>Make sure no extra spaces in values</li></ol></li></ul>
<p><strong>Issue: "DefaultAzureCredential authentication failed"</strong> 
<ul>
<li><strong>Cause</strong>: Usually wrong tenant ID or client ID 
<li><strong>Fix</strong>: Verify all 4 secrets are correct from service principal output</li></ul>
<h2>Part 4: Understanding the Certificate</h2>
<h3>Certificate Lifecycle</h3>
<p>Azure Trusted Signing uses <strong>short-lived certificates</strong> (typically 3 days). This freaked me out but they say this is actually a security feature: </p>
<ul>
<li>If a certificate is compromised, it expires quickly 
<li>You never manage certificate files or passwords 
<li>Automatic renewal - you don't have to do anything</li></ul>
<p><strong>But won't my signature break after 3 days?</strong> 
<p>No, it seems that's what <strong>timestamping</strong> is for. When you sign a file: 
<ol>
<li>Azure issues a 3-day certificate 
<li>The file is signed with that certificate 
<li>A timestamp server records "this file was signed on DATE" 
<li>Even after the certificate expires, the signature remains valid because the timestamp proves it was signed when the certificate was valid</li></ol>
<p>That's why both local and GitHub Actions signing include:<pre><code>timestamp-rfc3161: http://timestamp.acs.microsoft.com
</code></pre>
<h3>What the Certificate Contains</h3>
<p>Your signed executable has a certificate with: 
<ul>
<li><strong>Subject</strong>: Your name (e.g., "CN=John Doe, O=John Doe, L=Seattle, S=Washington, C=US") 
<li><strong>Issuer</strong>: Microsoft ID Verified CS EOC CA 01 
<li><strong>Valid Dates</strong>: 3-day window 
<li><strong>Key Size</strong>: 3072-bit RSA (very secure) 
<li><strong>Enhanced Key Usage</strong>: Code Signing</li></ul>
<h3>Verify Certificate on Any Machine</h3><pre><code># Using PowerShell
Get-AuthenticodeSignature "MyApp.exe" | Select-Object -ExpandProperty SignerCertificate | Format-List
# Using Windows UI
# Right-click EXE → Properties → Digital Signatures tab → Details → View Certificate
</code></pre>
<p>This whole thing took me about an hour to 75 minutes. It was detailed, but not deeply difficult. Misspellings, case-sensitivity, and a few account issues with Role-Based Access Control did slow me down. Hope this helps!</p>
<h3>Used Resources</h3>
<ul>
<li><a href="http://feeds.feedblitz.com/~/t/0/0/scotthanselman/~https://learn.microsoft.com/en-us/azure/trusted-signing/">Azure Trusted Signing Documentation</a> 
<li><a href="http://feeds.feedblitz.com/~/t/0/0/scotthanselman/~https://github.com/dotnet/sign">dotnet/sign Tool</a> 
<li><a href="http://feeds.feedblitz.com/~/t/0/0/scotthanselman/~https://github.com/Azure/trusted-signing-action">azure/trusted-signing-action</a> 
<li><a href="http://feeds.feedblitz.com/~/t/0/0/scotthanselman/~https://learn.microsoft.com/en-us/windows/win32/seccrypto/cryptography-tools">Windows Code Signing Best Practices</a> 
<li><a href="http://feeds.feedblitz.com/~/t/0/0/scotthanselman/~https://learn.microsoft.com/en-us/windows/security/threat-protection/microsoft-defender-smartscreen/microsoft-defender-smartscreen-overview">SmartScreen Reputation System</a></li></ul>
<p><em>Written in November 2025 based on real-world implementation for WindowsEdgeLight. Your setup might vary slightly depending on Azure region and account type. Things change, be stoic.</em></p>
<br/><hr/>© 2025 Scott Hanselman. All rights reserved. 
<br/></div><Img align="left" border="0" height="1" width="1" alt="" style="border:0;float:left;margin:0;padding:0;width:1px!important;height:1px!important;" hspace="0" src="https://feeds.feedblitz.com/~/i/930373259/0/scotthanselman">
<div style="clear:both;padding-top:0.2em;"><a title="Like on Facebook" href="https://feeds.feedblitz.com/_/28/930373259/scotthanselman"><img height="20" src="https://assets.feedblitz.com/i/fblike20.png" style="border:0;margin:0;padding:0;"></a>&#160;<a title="Post to X.com" href="https://feeds.feedblitz.com/_/24/930373259/scotthanselman"><img height="20" src="https://assets.feedblitz.com/i/x.png" style="border:0;margin:0;padding:0;"></a>&#160;<a title="Subscribe by email" href="https://feeds.feedblitz.com/_/19/930373259/scotthanselman"><img height="20" src="https://assets.feedblitz.com/i/email20.png" style="border:0;margin:0;padding:0;"></a>&#160;<a title="Subscribe by RSS" href="https://feeds.feedblitz.com/_/20/930373259/scotthanselman"><img height="20" src="https://assets.feedblitz.com/i/rss20.png" style="border:0;margin:0;padding:0;"></a>&#160;</div>]]>
</content:encoded></item>
<item>
<feedburner:origLink>https://www.hanselman.com/blog/webcam-randomly-pausing-in-obs-discord-and-websites-lsvcam-and-tiktok-studio</feedburner:origLink><trackback:ping>https://www.hanselman.com/blog/feed/trackback/03c0598d-681d-4fad-914b-83c801a54801</trackback:ping><pingback:server>https://www.hanselman.com/blog/feed/pingback</pingback:server><pingback:target>https://www.hanselman.com/blog/post/03c0598d-681d-4fad-914b-83c801a54801</pingback:target><dc:creator>Scott Hanselman</dc:creator><wfw:comment>https://feeds.feedblitz.com/~/905963465/0/scotthanselman~Webcam-randomly-pausing-in-OBS-Discord-and-websites-LSVCam-and-TikTok-Studio/comments#comments-start</wfw:comment><wfw:commentRss>https://www.hanselman.com/blog/feed/rss/comments/03c0598d-681d-4fad-914b-83c801a54801</wfw:commentRss><slash:comments>5</slash:comments><title>Webcam randomly pausing in OBS, Discord, and websites - LSVCam and TikTok Studio</title><guid isPermaLink="false">https://www.hanselman.com/blog/post/03c0598d-681d-4fad-914b-83c801a54801</guid><link>https://feeds.feedblitz.com/~/905963465/0/scotthanselman~Webcam-randomly-pausing-in-OBS-Discord-and-websites-LSVCam-and-TikTok-Studio</link><pubDate>Wed, 09 Oct 2024 19:32:28 GMT</pubDate><description><![CDATA[<div><p>I use my webcam constantly for streaming and I'm pretty familiar with all the internals and how the camera model on Windows works. I also use OBS extensively, so I regularly use the OBS virtual camera and flow everything through Open Broadcasting Studio. </p> <p>For my podcast, I use Zencastr which is a web-based app that talks to the webcam via the browser APIs. For YouTubes, I'll use Riverside or StreamYard, also webapps.</p> <p>I've done this reliably for the last several years without any trouble. Yesterday, I started seeing the most weird thing and it was absolutely perplexing and almost destroyed the day. I started seeing regular pauses in my webcam stream but only in two instances. </p> <ul> <li>The webcam would pause for 10-15 seconds every 90 or so seconds when access the Webcam in a browser</li> <li>I would see a long pause/hang in OBS when double clicking on my Video Source (Webcam) to view its properties</li></ul> <p>Micah initially said USB but my usb bus and hubs have worked reliably for years. Thought something might have changed in my El Gato capture device, but that has also been rock solid for 1/2 a decade. Then I started exploring virtual cameras and looked in the windows camera dialog under settings for a list of all virtual cameras. </p> <p>Interestingly, <em>virtual </em>cameras don't get listed under Cameras in Settings in Windows:</p> <p><a href="https://www.hanselman.com/blog/content/binary/Windows-Live-Writer/730f6664b802_E062/image_2.png"><img title="List of Cameras in Windows" style="padding-top: 0px; padding-left: 0px; display: inline; padding-right: 0px" alt="List of Cameras in Windows" src="https://www.hanselman.com/blog/content/binary/Windows-Live-Writer/730f6664b802_E062/image_thumb.png" width="640" height="364"></a></p> <p>From what I can tell, there's no user interface to list out all of your cameras - virtual or otherwise - in windows. </p> <p>Here's a quick PowerShell script you can run to list out anything 'connected' that also includes the string "cam" in your local devices</p><pre class="brush: ps; gutter: false; toolbar: false; auto-links: false; smart-tabs: false;">Get-CimInstance -Namespace root\cimv2 -ClassName Win32_PnPEntity |<br>     Where-Object { $_.Name -match 'Cam' } |<br>     Select-Object Name, Manufacturer, PNPDeviceID
</pre>
<p>and my output</p><pre>Name                                     Manufacturer        PNPDeviceID<br>----                                     ------------        -----------<br>Cam Link 4K                              Microsoft           USB\VID_0FD9&amp;PID_0066&amp;MI_00\7&amp;3768531A&amp;0&amp;0000<br>Digital Audio Interface (2- Cam Link 4K) Microsoft           SWD\MMDEVAPI\{0.0.1.00000000}.{AF1690B6-CA2A-4AD3-AAFD-8DDEBB83DD4A}<br>Logitech StreamCam WinUSB                Logitech            USB\VID_046D&amp;PID_0893&amp;MI_04\7&amp;E36D0CF&amp;0&amp;0004<br>Logitech StreamCam                       (Generic USB Audio) USB\VID_046D&amp;PID_0893&amp;MI_02\7&amp;E36D0CF&amp;0&amp;0002<br>Logitech StreamCam                       Logitech            USB\VID_046D&amp;PID_0893&amp;MI_00\7&amp;E36D0CF&amp;0&amp;0000<br>Remote Desktop Camera Bus                Microsoft           UMB\UMB\1&amp;841921D&amp;0&amp;RDCAMERA_BUS<br>Cam Link 4K                              (Generic USB Audio) USB\VID_0FD9&amp;PID_0066&amp;MI_03\7&amp;3768531A&amp;0&amp;0003<br>Windows Virtual Camera Device            Microsoft           SWD\VCAMDEVAPI\B486E21F1D4BC97087EA831093E840AD2177E046699EFBF62B27304F5CCAEF57</pre>
<p>However, when I list out my cameras using JavaScript enumerateDevices() like this<br><pre class="brush: js; gutter: false; toolbar: false; auto-links: false; smart-tabs: false;">// Put variables in global scope to make them available to the browser console.<br>async function listWebcams() {<br>  try {<br>    const devices = await navigator.mediaDevices.enumerateDevices();<br>    const webcams = devices.filter(device =&gt; device.kind === 'videoinput');<br><br>    if (webcams.length &gt; 0) {<br>      console.log("Connected webcams:");<br>      webcams.forEach((webcam, index) =&gt; {<br>        console.log(`${index + 1}. ${webcam.label || `Camera ${index + 1}`}`);<br>      });<br>    } else {<br>      console.log("No webcams found.");<br>    }<br>  } catch (error) {<br>    console.error("Error accessing media devices:", error);<br>  }<br>}<br>listWebcams();</pre></p>
<p>I would get:</p><pre>Connected webcams:
test.html:11 1. Logitech StreamCam (046d:0893)
test.html:11 2. OBS Virtual Camera (Windows Virtual Camera)
test.html:11 3. Cam Link 4K (0fd9:0066)
test.html:11 4. LSVCam
test.html:11 5. OBS Virtual Camera</pre>
<p>So, what, what's LSVCam? And depending on how I'd call it I'd get the pause and </p><pre>getUserMedia error: NotReadableError NotReadableError: Could not start video source</pre>
<p>Some apps could see this <strong>LSVCam</strong> and others couldn't. OBS really dislikes it, browsers really dislike it and it seemed to HANG on enumeration of cameras. Why can parts of Windows see this camera and others can't?</p>
<p>I don't know. Do you?</p>
<p>Regardless, it turns that it appears once in my registry, here (this is a dump of the key, you just care about the Registry PATH)</p><pre>Windows Registry Editor Version 5.00<br><br>[HKEY_CLASSES_ROOT\CLSID\{860BB310-5D01-11d0-BD3B-00A0C911CE86}\Instance\LSVCam]<br>"FriendlyName"="LSVCam"<br>"CLSID"="{BA80C4AD-8AED-4A61-B434-481D46216E45}"<br>"FilterData"=hex:02,00,00,00,00,00,20,00,01,00,00,00,00,00,00,00,30,70,69,33,\<br>  08,00,00,00,00,00,00,00,01,00,00,00,00,00,00,00,00,00,00,00,30,74,79,33,00,\<br>  00,00,00,38,00,00,00,48,00,00,00,76,69,64,73,00,00,10,00,80,00,00,aa,00,38,\<br>  9b,71,00,00,00,00,00,00,00,00,00,00,00,00,00,00,00,00</pre>
<p>If you want to get rid of it, delete HKEY_CLASSES_ROOT\CLSID\{860BB310-5D01-11d0-BD3B-00A0C911CE86}\Instance\LSVCam</p>
<p><strong>WARNING: </strong>DO NOT delete the \Instance, just the LSVCam and below. I am a random person on the internet and you got here by googling, so if you mess up your machine by going into RegEdit.exe, I'm sorry to this man, but it's above me now.</p>
<p>Where did LSVCam.dll come from, you may ask? TikTok Live Studio, baby. Live Studio Video/Virtual Cam, I am guessing.</p><pre>Directory of C:\Program Files\TikTok LIVE Studio\0.67.2\resources\app\electron\sdk\lib\MediaSDK_V1<br><br>09/18/2024  09:20 PM           218,984 LSVCam.dll<br>               1 File(s)        218,984 bytes</pre>
<p>This is a regression that started recently for me, so it's my opinion that they are installing a virtual camera for their game streaming feature but they are doing it poorly. It's either not completely installed, or hangs on enumeration, but the result is you'll see hangs on camera enumeration in your apps, especually browser apps that poll for cameras changes or check on a timer.</p>
<p>Nothing bad will happen if you delete the registry key BUT it'll show back up when you run TikTok Studio again. I still stream to TikTok, I just delete this key each time until someone on the TikTok Studio development team sees this blog post.</p>
<p>Hope this helps!</p><br/><hr/>© 2025 Scott Hanselman. All rights reserved. <br/></div><div style="clear:both;padding-top:0.2em;"><a title="Like on Facebook" href="https://feeds.feedblitz.com/_/28/905963465/scotthanselman"><img height="20" src="https://assets.feedblitz.com/i/fblike20.png" style="border:0;margin:0;padding:0;"></a>&#160;<a title="Post to X.com" href="https://feeds.feedblitz.com/_/24/905963465/scotthanselman"><img height="20" src="https://assets.feedblitz.com/i/x.png" style="border:0;margin:0;padding:0;"></a>&#160;<a title="Subscribe by email" href="https://feeds.feedblitz.com/_/19/905963465/scotthanselman"><img height="20" src="https://assets.feedblitz.com/i/email20.png" style="border:0;margin:0;padding:0;"></a>&#160;<a title="Subscribe by RSS" href="https://feeds.feedblitz.com/_/20/905963465/scotthanselman"><img height="20" src="https://assets.feedblitz.com/i/rss20.png" style="border:0;margin:0;padding:0;"></a>&#160;</div>]]>
</description><comments>https://feeds.feedblitz.com/~/905963465/0/scotthanselman~Webcam-randomly-pausing-in-OBS-Discord-and-websites-LSVCam-and-TikTok-Studio/comments#comments-start</comments><category>Bugs</category><content:encoded><![CDATA[<div><p>I use my webcam constantly for streaming and I'm pretty familiar with all the internals and how the camera model on Windows works. I also use OBS extensively, so I regularly use the OBS virtual camera and flow everything through Open Broadcasting Studio. </p> <p>For my podcast, I use Zencastr which is a web-based app that talks to the webcam via the browser APIs. For YouTubes, I'll use Riverside or StreamYard, also webapps.</p> <p>I've done this reliably for the last several years without any trouble. Yesterday, I started seeing the most weird thing and it was absolutely perplexing and almost destroyed the day. I started seeing regular pauses in my webcam stream but only in two instances. </p> <ul> <li>The webcam would pause for 10-15 seconds every 90 or so seconds when access the Webcam in a browser</li> <li>I would see a long pause/hang in OBS when double clicking on my Video Source (Webcam) to view its properties</li></ul> <p>Micah initially said USB but my usb bus and hubs have worked reliably for years. Thought something might have changed in my El Gato capture device, but that has also been rock solid for 1/2 a decade. Then I started exploring virtual cameras and looked in the windows camera dialog under settings for a list of all virtual cameras. </p> <p>Interestingly, <em>virtual </em>cameras don't get listed under Cameras in Settings in Windows:</p> <p><a href="http://feeds.feedblitz.com/~/t/0/0/scotthanselman/~https://www.hanselman.com/blog/content/binary/Windows-Live-Writer/730f6664b802_E062/image_2.png"><img title="List of Cameras in Windows" style="padding-top: 0px; padding-left: 0px; display: inline; padding-right: 0px" alt="List of Cameras in Windows" src="https://www.hanselman.com/blog/content/binary/Windows-Live-Writer/730f6664b802_E062/image_thumb.png" width="640" height="364"></a></p> <p>From what I can tell, there's no user interface to list out all of your cameras - virtual or otherwise - in windows. </p> <p>Here's a quick PowerShell script you can run to list out anything 'connected' that also includes the string "cam" in your local devices</p><pre class="brush: ps; gutter: false; toolbar: false; auto-links: false; smart-tabs: false;">Get-CimInstance -Namespace root\cimv2 -ClassName Win32_PnPEntity |
<br>     Where-Object { $_.Name -match 'Cam' } |
<br>     Select-Object Name, Manufacturer, PNPDeviceID
</pre>
<p>and my output</p><pre>Name                                     Manufacturer        PNPDeviceID
<br>----                                     ------------        -----------
<br>Cam Link 4K                              Microsoft           USB\VID_0FD9&amp;PID_0066&amp;MI_00\7&amp;3768531A&amp;0&amp;0000
<br>Digital Audio Interface (2- Cam Link 4K) Microsoft           SWD\MMDEVAPI\{0.0.1.00000000}.{AF1690B6-CA2A-4AD3-AAFD-8DDEBB83DD4A}
<br>Logitech StreamCam WinUSB                Logitech            USB\VID_046D&amp;PID_0893&amp;MI_04\7&amp;E36D0CF&amp;0&amp;0004
<br>Logitech StreamCam                       (Generic USB Audio) USB\VID_046D&amp;PID_0893&amp;MI_02\7&amp;E36D0CF&amp;0&amp;0002
<br>Logitech StreamCam                       Logitech            USB\VID_046D&amp;PID_0893&amp;MI_00\7&amp;E36D0CF&amp;0&amp;0000
<br>Remote Desktop Camera Bus                Microsoft           UMB\UMB\1&amp;841921D&amp;0&amp;RDCAMERA_BUS
<br>Cam Link 4K                              (Generic USB Audio) USB\VID_0FD9&amp;PID_0066&amp;MI_03\7&amp;3768531A&amp;0&amp;0003
<br>Windows Virtual Camera Device            Microsoft           SWD\VCAMDEVAPI\B486E21F1D4BC97087EA831093E840AD2177E046699EFBF62B27304F5CCAEF57</pre>
<p>However, when I list out my cameras using JavaScript enumerateDevices() like this
<br><pre class="brush: js; gutter: false; toolbar: false; auto-links: false; smart-tabs: false;">// Put variables in global scope to make them available to the browser console.
<br>async function listWebcams() {
<br>  try {
<br>    const devices = await navigator.mediaDevices.enumerateDevices();
<br>    const webcams = devices.filter(device =&gt; device.kind === 'videoinput');
<br>
<br>    if (webcams.length &gt; 0) {
<br>      console.log("Connected webcams:");
<br>      webcams.forEach((webcam, index) =&gt; {
<br>        console.log(`${index + 1}. ${webcam.label || `Camera ${index + 1}`}`);
<br>      });
<br>    } else {
<br>      console.log("No webcams found.");
<br>    }
<br>  } catch (error) {
<br>    console.error("Error accessing media devices:", error);
<br>  }
<br>}
<br>listWebcams();</pre></p>
<p>I would get:</p><pre>Connected webcams:
test.html:11 1. Logitech StreamCam (046d:0893)
test.html:11 2. OBS Virtual Camera (Windows Virtual Camera)
test.html:11 3. Cam Link 4K (0fd9:0066)
test.html:11 4. LSVCam
test.html:11 5. OBS Virtual Camera</pre>
<p>So, what, what's LSVCam? And depending on how I'd call it I'd get the pause and </p><pre>getUserMedia error: NotReadableError NotReadableError: Could not start video source</pre>
<p>Some apps could see this <strong>LSVCam</strong> and others couldn't. OBS really dislikes it, browsers really dislike it and it seemed to HANG on enumeration of cameras. Why can parts of Windows see this camera and others can't?</p>
<p>I don't know. Do you?</p>
<p>Regardless, it turns that it appears once in my registry, here (this is a dump of the key, you just care about the Registry PATH)</p><pre>Windows Registry Editor Version 5.00
<br>
<br>[HKEY_CLASSES_ROOT\CLSID\{860BB310-5D01-11d0-BD3B-00A0C911CE86}\Instance\LSVCam]
<br>"FriendlyName"="LSVCam"
<br>"CLSID"="{BA80C4AD-8AED-4A61-B434-481D46216E45}"
<br>"FilterData"=hex:02,00,00,00,00,00,20,00,01,00,00,00,00,00,00,00,30,70,69,33,\
<br>  08,00,00,00,00,00,00,00,01,00,00,00,00,00,00,00,00,00,00,00,30,74,79,33,00,\
<br>  00,00,00,38,00,00,00,48,00,00,00,76,69,64,73,00,00,10,00,80,00,00,aa,00,38,\
<br>  9b,71,00,00,00,00,00,00,00,00,00,00,00,00,00,00,00,00</pre>
<p>If you want to get rid of it, delete HKEY_CLASSES_ROOT\CLSID\{860BB310-5D01-11d0-BD3B-00A0C911CE86}\Instance\LSVCam</p>
<p><strong>WARNING: </strong>DO NOT delete the \Instance, just the LSVCam and below. I am a random person on the internet and you got here by googling, so if you mess up your machine by going into RegEdit.exe, I'm sorry to this man, but it's above me now.</p>
<p>Where did LSVCam.dll come from, you may ask? TikTok Live Studio, baby. Live Studio Video/Virtual Cam, I am guessing.</p><pre>Directory of C:\Program Files\TikTok LIVE Studio\0.67.2\resources\app\electron\sdk\lib\MediaSDK_V1
<br>
<br>09/18/2024  09:20 PM           218,984 LSVCam.dll
<br>               1 File(s)        218,984 bytes</pre>
<p>This is a regression that started recently for me, so it's my opinion that they are installing a virtual camera for their game streaming feature but they are doing it poorly. It's either not completely installed, or hangs on enumeration, but the result is you'll see hangs on camera enumeration in your apps, especually browser apps that poll for cameras changes or check on a timer.</p>
<p>Nothing bad will happen if you delete the registry key BUT it'll show back up when you run TikTok Studio again. I still stream to TikTok, I just delete this key each time until someone on the TikTok Studio development team sees this blog post.</p>
<p>Hope this helps!</p>
<br/><hr/>© 2025 Scott Hanselman. All rights reserved. 
<br/></div><Img align="left" border="0" height="1" width="1" alt="" style="border:0;float:left;margin:0;padding:0;width:1px!important;height:1px!important;" hspace="0" src="https://feeds.feedblitz.com/~/i/905963465/0/scotthanselman">
<div style="clear:both;padding-top:0.2em;"><a title="Like on Facebook" href="https://feeds.feedblitz.com/_/28/905963465/scotthanselman"><img height="20" src="https://assets.feedblitz.com/i/fblike20.png" style="border:0;margin:0;padding:0;"></a>&#160;<a title="Post to X.com" href="https://feeds.feedblitz.com/_/24/905963465/scotthanselman"><img height="20" src="https://assets.feedblitz.com/i/x.png" style="border:0;margin:0;padding:0;"></a>&#160;<a title="Subscribe by email" href="https://feeds.feedblitz.com/_/19/905963465/scotthanselman"><img height="20" src="https://assets.feedblitz.com/i/email20.png" style="border:0;margin:0;padding:0;"></a>&#160;<a title="Subscribe by RSS" href="https://feeds.feedblitz.com/_/20/905963465/scotthanselman"><img height="20" src="https://assets.feedblitz.com/i/rss20.png" style="border:0;margin:0;padding:0;"></a>&#160;</div>]]>
</content:encoded></item>
<item>
<feedburner:origLink>https://www.hanselman.com/blog/open-sourcing-dos-4</feedburner:origLink><trackback:ping>https://www.hanselman.com/blog/feed/trackback/ed4f5c94-07d3-465c-96f1-776ba41b0099</trackback:ping><pingback:server>https://www.hanselman.com/blog/feed/pingback</pingback:server><pingback:target>https://www.hanselman.com/blog/post/ed4f5c94-07d3-465c-96f1-776ba41b0099</pingback:target><dc:creator>Scott Hanselman</dc:creator><wfw:comment>https://feeds.feedblitz.com/~/882544025/0/scotthanselman~Open-Sourcing-DOS/comments#comments-start</wfw:comment><wfw:commentRss>https://www.hanselman.com/blog/feed/rss/comments/ed4f5c94-07d3-465c-96f1-776ba41b0099</wfw:commentRss><slash:comments>19</slash:comments><title>Open Sourcing DOS 4</title><guid isPermaLink="false">https://www.hanselman.com/blog/post/ed4f5c94-07d3-465c-96f1-776ba41b0099</guid><link>https://feeds.feedblitz.com/~/882544025/0/scotthanselman~Open-Sourcing-DOS</link><pubDate>Thu, 25 Apr 2024 16:46:13 GMT</pubDate><description><![CDATA[<div><p><img title="Beta DOS Disks" style="float: right; margin: 0px 0px 0px 4px; display: inline" alt="Beta DOS Disks" src="https://www.hanselman.com/blog/content/binary/Windows-Live-Writer/Open-Sourcing-DOS-4_E712/clip_image002_5b6e1c02-95d8-4ee1-87af-ca53a8b0bd56.png" width="500" align="right" height="342"><em>See <a href="https://cloudblogs.microsoft.com/opensource/2024/04/25/open-sourcing-ms-dos-4-0/">the canonical version of this blog post at the Microsoft Open Source Blog</a>!</em>  <p>Ten years ago, <a href="https://devblogs.microsoft.com/commandline/re-open-sourcing-ms-dos-1-25-and-2-0/">Microsoft released the source for MS-DOS 1.25 and 2.0</a> to the Computer History Museum, and then <a href="https://github.com/microsoft/MS-DOS">later republished them</a> for reference purposes. This code holds an important place in history and is a fascinating read of an operating system that was written entirely in 8086 assembly code nearly 45 years ago. </p> <p>Today, in partnership with IBM and in the spirit of open innovation, we're releasing the source code to MS-DOS 4.00 under the MIT license. There's a somewhat complex and fascinating history behind the 4.0 versions of DOS, as Microsoft partnered with IBM for portions of the code but also created a branch of DOS called Multitasking DOS that did not see a wide release. </p> <p><a title="https://github.com/microsoft/MS-DOS?WT.mc_id=-blog-scottha" href="https://github.com/microsoft/MS-DOS?WT.mc_id=-blog-scottha"><strong>https://github.com/microsoft/MS-DOS</strong></a></p> <p>A young English researcher named <a href="https://starfrost.net/blog/001-mdos4-part-1/">Connor "Starfrost" Hyde</a> recently corresponded with former Microsoft Chief Technical Officer Ray Ozzie about some of the software in his collection. Amongst the floppies, Ray found unreleased beta binaries of DOS 4.0 that he was sent while he was at Lotus. Starfrost reached out to the Microsoft Open Source Programs Office (OSPO) to explore releasing DOS 4 source, as he is working on documenting the relationship between DOS 4, MT-DOS, and what would eventually become OS/2. Some later versions of these Multitasking DOS binaries can be found around the internet, but these new Ozzie beta binaries appear to be much earlier, unreleased, and also include the ibmbio.com source.&nbsp; </p> <p>Scott Hanselman, with the help of internet archivist and enthusiast Jeff Sponaugle, has imaged these original disks and carefully scanned the original printed documents from this "Ozzie Drop". Microsoft, along with our friends at IBM, think this is a fascinating piece of operating system history worth sharing.&nbsp; </p> <p>Jeff Wilcox and OSPO went to the Microsoft Archives, and while they were unable to find the full source code for MT-DOS, they did find MS DOS 4.00, which we're releasing today, alongside these additional beta binaries, PDFs of the documentation, and disk images. We will continue to explore the archives and may update this release if more is discovered.&nbsp; </p> <p>Thank you to Ray Ozzie, Starfrost, Jeff Sponaugle, Larry Osterman, our friends at the IBM OSPO, as well as the makers of such digital archeology software including, but not limited to Greaseweazle, Fluxengine, Aaru Data Preservation Suite, and the HxC Floppy Emulator. Above all, thank you to the original authors of this code, some of whom still work at Microsoft and IBM today! </p> <p>If you'd like to run this software yourself and explore, we have successfully run it directly on an original IBM PC XT, a newer Pentium, and within the open source PCem and 86box emulators.&nbsp; </p><br/><hr/>© 2025 Scott Hanselman. All rights reserved. <br/></div><div style="clear:both;padding-top:0.2em;"><a title="Like on Facebook" href="https://feeds.feedblitz.com/_/28/882544025/scotthanselman"><img height="20" src="https://assets.feedblitz.com/i/fblike20.png" style="border:0;margin:0;padding:0;"></a>&#160;<a title="Post to X.com" href="https://feeds.feedblitz.com/_/24/882544025/scotthanselman"><img height="20" src="https://assets.feedblitz.com/i/x.png" style="border:0;margin:0;padding:0;"></a>&#160;<a title="Subscribe by email" href="https://feeds.feedblitz.com/_/19/882544025/scotthanselman"><img height="20" src="https://assets.feedblitz.com/i/email20.png" style="border:0;margin:0;padding:0;"></a>&#160;<a title="Subscribe by RSS" href="https://feeds.feedblitz.com/_/20/882544025/scotthanselman"><img height="20" src="https://assets.feedblitz.com/i/rss20.png" style="border:0;margin:0;padding:0;"></a>&#160;</div>]]>
</description><comments>https://feeds.feedblitz.com/~/882544025/0/scotthanselman~Open-Sourcing-DOS/comments#comments-start</comments><category>Open Source</category><content:encoded><![CDATA[<div><p><img title="Beta DOS Disks" style="float: right; margin: 0px 0px 0px 4px; display: inline" alt="Beta DOS Disks" src="https://www.hanselman.com/blog/content/binary/Windows-Live-Writer/Open-Sourcing-DOS-4_E712/clip_image002_5b6e1c02-95d8-4ee1-87af-ca53a8b0bd56.png" width="500" align="right" height="342"><em>See <a href="http://feeds.feedblitz.com/~/t/0/0/scotthanselman/~https://cloudblogs.microsoft.com/opensource/2024/04/25/open-sourcing-ms-dos-4-0/">the canonical version of this blog post at the Microsoft Open Source Blog</a>!</em>  <p>Ten years ago, <a href="http://feeds.feedblitz.com/~/t/0/0/scotthanselman/~https://devblogs.microsoft.com/commandline/re-open-sourcing-ms-dos-1-25-and-2-0/">Microsoft released the source for MS-DOS 1.25 and 2.0</a> to the Computer History Museum, and then <a href="http://feeds.feedblitz.com/~/t/0/0/scotthanselman/~https://github.com/microsoft/MS-DOS">later republished them</a> for reference purposes. This code holds an important place in history and is a fascinating read of an operating system that was written entirely in 8086 assembly code nearly 45 years ago. </p> <p>Today, in partnership with IBM and in the spirit of open innovation, we're releasing the source code to MS-DOS 4.00 under the MIT license. There's a somewhat complex and fascinating history behind the 4.0 versions of DOS, as Microsoft partnered with IBM for portions of the code but also created a branch of DOS called Multitasking DOS that did not see a wide release. </p> <p><a title="https://github.com/microsoft/MS-DOS?WT.mc_id=-blog-scottha" href="http://feeds.feedblitz.com/~/t/0/0/scotthanselman/~https://github.com/microsoft/MS-DOS?WT.mc_id=-blog-scottha"><strong>https://github.com/microsoft/MS-DOS</strong></a></p> <p>A young English researcher named <a href="http://feeds.feedblitz.com/~/t/0/0/scotthanselman/~https://starfrost.net/blog/001-mdos4-part-1/">Connor "Starfrost" Hyde</a> recently corresponded with former Microsoft Chief Technical Officer Ray Ozzie about some of the software in his collection. Amongst the floppies, Ray found unreleased beta binaries of DOS 4.0 that he was sent while he was at Lotus. Starfrost reached out to the Microsoft Open Source Programs Office (OSPO) to explore releasing DOS 4 source, as he is working on documenting the relationship between DOS 4, MT-DOS, and what would eventually become OS/2. Some later versions of these Multitasking DOS binaries can be found around the internet, but these new Ozzie beta binaries appear to be much earlier, unreleased, and also include the ibmbio.com source.&nbsp; </p> <p>Scott Hanselman, with the help of internet archivist and enthusiast Jeff Sponaugle, has imaged these original disks and carefully scanned the original printed documents from this "Ozzie Drop". Microsoft, along with our friends at IBM, think this is a fascinating piece of operating system history worth sharing.&nbsp; </p> <p>Jeff Wilcox and OSPO went to the Microsoft Archives, and while they were unable to find the full source code for MT-DOS, they did find MS DOS 4.00, which we're releasing today, alongside these additional beta binaries, PDFs of the documentation, and disk images. We will continue to explore the archives and may update this release if more is discovered.&nbsp; </p> <p>Thank you to Ray Ozzie, Starfrost, Jeff Sponaugle, Larry Osterman, our friends at the IBM OSPO, as well as the makers of such digital archeology software including, but not limited to Greaseweazle, Fluxengine, Aaru Data Preservation Suite, and the HxC Floppy Emulator. Above all, thank you to the original authors of this code, some of whom still work at Microsoft and IBM today! </p> <p>If you'd like to run this software yourself and explore, we have successfully run it directly on an original IBM PC XT, a newer Pentium, and within the open source PCem and 86box emulators.&nbsp; </p>
<br/><hr/>© 2025 Scott Hanselman. All rights reserved. 
<br/></div><Img align="left" border="0" height="1" width="1" alt="" style="border:0;float:left;margin:0;padding:0;width:1px!important;height:1px!important;" hspace="0" src="https://feeds.feedblitz.com/~/i/882544025/0/scotthanselman">
<div style="clear:both;padding-top:0.2em;"><a title="Like on Facebook" href="https://feeds.feedblitz.com/_/28/882544025/scotthanselman"><img height="20" src="https://assets.feedblitz.com/i/fblike20.png" style="border:0;margin:0;padding:0;"></a>&#160;<a title="Post to X.com" href="https://feeds.feedblitz.com/_/24/882544025/scotthanselman"><img height="20" src="https://assets.feedblitz.com/i/x.png" style="border:0;margin:0;padding:0;"></a>&#160;<a title="Subscribe by email" href="https://feeds.feedblitz.com/_/19/882544025/scotthanselman"><img height="20" src="https://assets.feedblitz.com/i/email20.png" style="border:0;margin:0;padding:0;"></a>&#160;<a title="Subscribe by RSS" href="https://feeds.feedblitz.com/_/20/882544025/scotthanselman"><img height="20" src="https://assets.feedblitz.com/i/rss20.png" style="border:0;margin:0;padding:0;"></a>&#160;</div>]]>
</content:encoded></item>
<item>
<feedburner:origLink>https://www.hanselman.com/blog/updating-to-net-8-updating-to-ihostbuilder-and-running-playwright-tests-within-nunit-headless-or-headed-on-any-os</feedburner:origLink><trackback:ping>https://www.hanselman.com/blog/feed/trackback/815e0b55-f583-49a5-b01c-bd38197343f9</trackback:ping><pingback:server>https://www.hanselman.com/blog/feed/pingback</pingback:server><pingback:target>https://www.hanselman.com/blog/post/815e0b55-f583-49a5-b01c-bd38197343f9</pingback:target><dc:creator>Scott Hanselman</dc:creator><wfw:comment>https://feeds.feedblitz.com/~/873234002/0/scotthanselman~Updating-to-NET-updating-to-IHostBuilder-and-running-Playwright-Tests-within-NUnit-headless-or-headed-on-any-OS/comments#comments-start</wfw:comment><wfw:commentRss>https://www.hanselman.com/blog/feed/rss/comments/815e0b55-f583-49a5-b01c-bd38197343f9</wfw:commentRss><slash:comments>54</slash:comments><title>Updating to .NET 8, updating to IHostBuilder, and running Playwright Tests within NUnit headless or headed on any OS</title><guid isPermaLink="false">https://www.hanselman.com/blog/post/815e0b55-f583-49a5-b01c-bd38197343f9</guid><link>https://feeds.feedblitz.com/~/873234002/0/scotthanselman~Updating-to-NET-updating-to-IHostBuilder-and-running-Playwright-Tests-within-NUnit-headless-or-headed-on-any-OS</link><pubDate>Thu, 07 Mar 2024 01:12:13 GMT</pubDate><description><![CDATA[<div><p><img title="All the Unit Tests pass" style="float: right; margin: 0px 0px 0px 5px; display: inline" alt="All the Unit Tests pass" src="https://www.hanselman.com/blog/content/binary/Windows-Live-Writer/78fe85887e7e_1244B/image_8b82f0d7-a3bc-4403-96c3-9dd36fc46d1f.png" width="475" align="right" height="437">I've been doing not just Unit Testing for my sites but full on Integration Testing and Browser Automation Testing as early as 2007 with Selenium. Lately, however, I've been using the faster and generally more compatible <a href="https://playwright.dev/">Playwright</a>. It has one API and can test on Windows, Linux, Mac, locally, in a container (headless), in my CI/CD pipeline, on Azure DevOps, or in GitHub Actions. </p> <p>For me, it's that last moment of truth to make sure that the site runs completely from end to end.</p> <p>I can write those Playwright tests in something like TypeScript, and I could launch them with node, but I like running end unit tests and using that test runner and test harness as my jumping off point for my .NET applications. I'm used to right clicking and "run unit tests" or even better, right click and "debug unit tests" in Visual Studio or VS Code. This gets me the benefit of all of the assertions of a full unit testing framework, and all the benefits of using something like Playwright to automate my browser. </p> <p><a href="https://www.hanselman.com/blog/real-browser-integration-testing-with-selenium-standalone-chrome-and-aspnet-core-21">In 2018 I was using WebApplicationFactory</a> and some tricky hacks to basically spin up ASP.NET within .NET (at the time) Core 2.1 within the unit tests and then launching Selenium. This was kind of janky and would require to manually start a separate process and manage its life cycle. However, I kept on with this hack for a number of years basically trying to get the Kestrel Web Server to spin up inside of my unit tests.</p> <p>I've recently upgraded my main site and podcast site to .NET 8. Keep in mind that I've been moving my websites forward from early early versions of .NET to the most recent versions. The blog is happily running on Linux in a container on .NET 8, but its original code started in 2002 on .NET 1.1.</p> <p>Now that I'm on .NET 8, I scandalously discovered (as my unit tests stopped working) <a href="https://learn.microsoft.com/en-us/aspnet/core/migration/22-to-30?view=aspnetcore-3.1&amp;tabs=visual-studio#hostbuilder-replaces-webhostbuilder">that the rest of the world had moved from IWebHostBuilder to IHostBuilder five version of .NET ago</a>. Gulp. Say what you will, but the backward compatibility is impressive. </p> <p>As such my code for Program.cs changed from this</p><pre class="brush: csharp; gutter: false; toolbar: false; auto-links: false; smart-tabs: false;">public static void Main(string[] args)<br>{<br>    CreateWebHostBuilder(args).Build().Run();<br>}<br><br>public static IWebHostBuilder CreateWebHostBuilder(string[] args) =&gt;<br>    WebHost.CreateDefaultBuilder(args)<br>        .UseStartup&lt;Startup&gt;();<br></pre>
<p>to this:</p><pre class="brush: csharp; gutter: false; toolbar: false; auto-links: false; smart-tabs: false;">public static void Main(string[] args)<br>{<br>  CreateHostBuilder(args).Build().Run();<br>}<br><br>public static IHostBuilder CreateHostBuilder(string[] args) =&gt;<br>  Host.CreateDefaultBuilder(args).<br>      ConfigureWebHostDefaults(WebHostBuilder =&gt; WebHostBuilder.UseStartup&lt;Startup&gt;());</pre>
<p>Not a major change on the outside but tidies things up on the inside and sets me up with <a href="https://learn.microsoft.com/en-us/aspnet/core/fundamentals/host/generic-host?view=aspnetcore-3.1">a more flexible generic host for my web app</a>.</p>
<p>My unit tests stopped working because my Kestral Web Server hack was no longer firing up my server. </p>
<p>Here is an example of my goal from a Playwright perspective within a .NET NUnit test. </p><pre class="brush: csharp; gutter: false; toolbar: false; auto-links: false; smart-tabs: false;">[Test]<br>public async Task DoesSearchWork()<br>{<br>    await Page.GotoAsync(Url);<br><br>    await Page.Locator("#topbar").GetByRole(AriaRole.Link, new() { Name = "episodes" }).ClickAsync();<br><br>    await Page.GetByPlaceholder("search and filter").ClickAsync();<br><br>    await Page.GetByPlaceholder("search and filter").TypeAsync("wife");<br><br>    const string visibleCards = ".showCard:visible";<br><br>    var waiting = await Page.WaitForSelectorAsync(visibleCards, new PageWaitForSelectorOptions() { Timeout = 500 });<br><br>    await Expect(Page.Locator(visibleCards).First).ToBeVisibleAsync();<br><br>    await Expect(Page.Locator(visibleCards)).ToHaveCountAsync(5);<br>}
</pre>
<p>I love this. Nice and clean. Certainly here we are assuming that we have a URL in that first line, which will be localhost something, and then we assume that our web application has started up on its own. </p>
<p>Here is the setup code that starts my new "web application test builder factory," yeah, the name is stupid but it's descriptive. Note the OneTimeSetUp and the OneTimeTearDown. This starts my web app within the context of my TestHost. Note the :0 makes the app find a port which I then, sadly, have to dig out and put into the Url private for use within my Unit Tests. Note that the &lt;Startup&gt; is in fact my Startup class within Startup.cs which hosts my app's pipeline and Configure and ConfigureServices get setup here so routing all works.</p><pre class="brush: csharp; gutter: false; toolbar: false; auto-links: false; smart-tabs: false;">private string Url;<br>private WebApplication? _app = null;<br><br>[OneTimeSetUp]<br>public void Setup()<br>{<br>    var builder = WebApplicationTestBuilderFactory.CreateBuilder&lt;Startup&gt;();<br><br>    var startup = new Startup(builder.Environment);<br>    builder.WebHost.ConfigureKestrel(o =&gt; o.Listen(IPAddress.Loopback, 0));<br>    startup.ConfigureServices(builder.Services);<br>    _app = builder.Build();<br><br>    // listen on any local port (hence the 0)<br>    startup.Configure(_app, _app.Configuration);<br>    _app.Start();<br><br>    //you are kidding me<br>    Url = _app.Services.GetRequiredService&lt;IServer&gt;().Features.GetRequiredFeature&lt;IServerAddressesFeature&gt;().Addresses.Last();<br>}<br><br>[OneTimeTearDown]<br>public async Task TearDown()<br>{<br>    await _app.DisposeAsync();<br>}</pre>
<p>So what horrors are buried in WebApplicationTestBuilderFactory? The first bit is bad and we should fix it for .NET 9. The rest is actually every nice, with a hat tip to David Fowler for his help and guidance! This is the magic and the ick in one small helper class.</p><pre class="brush: csharp; gutter: false; toolbar: false; auto-links: false; smart-tabs: false;">public class WebApplicationTestBuilderFactory <br>{<br>    public static WebApplicationBuilder CreateBuilder&lt;T&gt;() where T : class <br>    {<br>        //This ungodly code requires an unused reference to the MvcTesting package that hooks up<br>        //  MSBuild to create the manifest file that is read here.<br>        var testLocation = Path.Combine(AppContext.BaseDirectory, "MvcTestingAppManifest.json");<br>        var json = JsonObject.Parse(File.ReadAllText(testLocation));<br>        var asmFullName = typeof(T).Assembly.FullName ?? throw new InvalidOperationException("Assembly Full Name is null");<br>        var contentRootPath = json?[asmFullName]?.GetValue&lt;string&gt;();<br><br>        //spin up a real live web application inside TestHost.exe<br>        var builder = WebApplication.CreateBuilder(<br>            new WebApplicationOptions()<br>            {<br>                ContentRootPath = contentRootPath,<br>                ApplicationName = asmFullName<br>            });<br>        return builder;<br>    }<br>}</pre>
<p>The first 4 lines are nasty. Because the test runs in the context of a different directory and my website needs to run within the context of its own content root path, I have to force the content root path to be correct and the only way to do that is by getting the apps base directory from a file generated within MSBuild from the (aging) MvcTesting package. The package is not used, but by referencing it it gets into the build and makes that file that I then use to pull out the directory. </p>
<p>If we can get rid of that "hack" and pull the directory from context elsewhere, then this helper function turns into a single line and .NET 9 gets WAY WAY more testable!</p>
<p>Now I can run my Unit Tests AND Playwright Browser Integration Tests across all OS's, headed or headless, in docker or on the metal. The site is updated to .NET 8 and all is right with my code. Well, it runs at least. ;)</p><br/><hr/>© 2025 Scott Hanselman. All rights reserved. <br/></div><div style="clear:both;padding-top:0.2em;"><a title="Like on Facebook" href="https://feeds.feedblitz.com/_/28/873234002/scotthanselman"><img height="20" src="https://assets.feedblitz.com/i/fblike20.png" style="border:0;margin:0;padding:0;"></a>&#160;<a title="Post to X.com" href="https://feeds.feedblitz.com/_/24/873234002/scotthanselman"><img height="20" src="https://assets.feedblitz.com/i/x.png" style="border:0;margin:0;padding:0;"></a>&#160;<a title="Subscribe by email" href="https://feeds.feedblitz.com/_/19/873234002/scotthanselman"><img height="20" src="https://assets.feedblitz.com/i/email20.png" style="border:0;margin:0;padding:0;"></a>&#160;<a title="Subscribe by RSS" href="https://feeds.feedblitz.com/_/20/873234002/scotthanselman"><img height="20" src="https://assets.feedblitz.com/i/rss20.png" style="border:0;margin:0;padding:0;"></a>&#160;</div>]]>
</description><comments>https://feeds.feedblitz.com/~/873234002/0/scotthanselman~Updating-to-NET-updating-to-IHostBuilder-and-running-Playwright-Tests-within-NUnit-headless-or-headed-on-any-OS/comments#comments-start</comments><category>ASP.NET</category><category>DotNetCore</category><content:encoded><![CDATA[<div><p><img title="All the Unit Tests pass" style="float: right; margin: 0px 0px 0px 5px; display: inline" alt="All the Unit Tests pass" src="https://www.hanselman.com/blog/content/binary/Windows-Live-Writer/78fe85887e7e_1244B/image_8b82f0d7-a3bc-4403-96c3-9dd36fc46d1f.png" width="475" align="right" height="437">I've been doing not just Unit Testing for my sites but full on Integration Testing and Browser Automation Testing as early as 2007 with Selenium. Lately, however, I've been using the faster and generally more compatible <a href="http://feeds.feedblitz.com/~/t/0/0/scotthanselman/~https://playwright.dev/">Playwright</a>. It has one API and can test on Windows, Linux, Mac, locally, in a container (headless), in my CI/CD pipeline, on Azure DevOps, or in GitHub Actions. </p> <p>For me, it's that last moment of truth to make sure that the site runs completely from end to end.</p> <p>I can write those Playwright tests in something like TypeScript, and I could launch them with node, but I like running end unit tests and using that test runner and test harness as my jumping off point for my .NET applications. I'm used to right clicking and "run unit tests" or even better, right click and "debug unit tests" in Visual Studio or VS Code. This gets me the benefit of all of the assertions of a full unit testing framework, and all the benefits of using something like Playwright to automate my browser. </p> <p><a href="http://feeds.feedblitz.com/~/t/0/0/scotthanselman/~https://www.hanselman.com/blog/real-browser-integration-testing-with-selenium-standalone-chrome-and-aspnet-core-21">In 2018 I was using WebApplicationFactory</a> and some tricky hacks to basically spin up ASP.NET within .NET (at the time) Core 2.1 within the unit tests and then launching Selenium. This was kind of janky and would require to manually start a separate process and manage its life cycle. However, I kept on with this hack for a number of years basically trying to get the Kestrel Web Server to spin up inside of my unit tests.</p> <p>I've recently upgraded my main site and podcast site to .NET 8. Keep in mind that I've been moving my websites forward from early early versions of .NET to the most recent versions. The blog is happily running on Linux in a container on .NET 8, but its original code started in 2002 on .NET 1.1.</p> <p>Now that I'm on .NET 8, I scandalously discovered (as my unit tests stopped working) <a href="http://feeds.feedblitz.com/~/t/0/0/scotthanselman/~https://learn.microsoft.com/en-us/aspnet/core/migration/22-to-30?view=aspnetcore-3.1&amp;tabs=visual-studio#hostbuilder-replaces-webhostbuilder">that the rest of the world had moved from IWebHostBuilder to IHostBuilder five version of .NET ago</a>. Gulp. Say what you will, but the backward compatibility is impressive. </p> <p>As such my code for Program.cs changed from this</p><pre class="brush: csharp; gutter: false; toolbar: false; auto-links: false; smart-tabs: false;">public static void Main(string[] args)
<br>{
<br>    CreateWebHostBuilder(args).Build().Run();
<br>}
<br>
<br>public static IWebHostBuilder CreateWebHostBuilder(string[] args) =&gt;
<br>    WebHost.CreateDefaultBuilder(args)
<br>        .UseStartup&lt;Startup&gt;();
<br></pre>
<p>to this:</p><pre class="brush: csharp; gutter: false; toolbar: false; auto-links: false; smart-tabs: false;">public static void Main(string[] args)
<br>{
<br>  CreateHostBuilder(args).Build().Run();
<br>}
<br>
<br>public static IHostBuilder CreateHostBuilder(string[] args) =&gt;
<br>  Host.CreateDefaultBuilder(args).
<br>      ConfigureWebHostDefaults(WebHostBuilder =&gt; WebHostBuilder.UseStartup&lt;Startup&gt;());</pre>
<p>Not a major change on the outside but tidies things up on the inside and sets me up with <a href="http://feeds.feedblitz.com/~/t/0/0/scotthanselman/~https://learn.microsoft.com/en-us/aspnet/core/fundamentals/host/generic-host?view=aspnetcore-3.1">a more flexible generic host for my web app</a>.</p>
<p>My unit tests stopped working because my Kestral Web Server hack was no longer firing up my server. </p>
<p>Here is an example of my goal from a Playwright perspective within a .NET NUnit test. </p><pre class="brush: csharp; gutter: false; toolbar: false; auto-links: false; smart-tabs: false;">[Test]
<br>public async Task DoesSearchWork()
<br>{
<br>    await Page.GotoAsync(Url);
<br>
<br>    await Page.Locator("#topbar").GetByRole(AriaRole.Link, new() { Name = "episodes" }).ClickAsync();
<br>
<br>    await Page.GetByPlaceholder("search and filter").ClickAsync();
<br>
<br>    await Page.GetByPlaceholder("search and filter").TypeAsync("wife");
<br>
<br>    const string visibleCards = ".showCard:visible";
<br>
<br>    var waiting = await Page.WaitForSelectorAsync(visibleCards, new PageWaitForSelectorOptions() { Timeout = 500 });
<br>
<br>    await Expect(Page.Locator(visibleCards).First).ToBeVisibleAsync();
<br>
<br>    await Expect(Page.Locator(visibleCards)).ToHaveCountAsync(5);
<br>}
</pre>
<p>I love this. Nice and clean. Certainly here we are assuming that we have a URL in that first line, which will be localhost something, and then we assume that our web application has started up on its own. </p>
<p>Here is the setup code that starts my new "web application test builder factory," yeah, the name is stupid but it's descriptive. Note the OneTimeSetUp and the OneTimeTearDown. This starts my web app within the context of my TestHost. Note the :0 makes the app find a port which I then, sadly, have to dig out and put into the Url private for use within my Unit Tests. Note that the &lt;Startup&gt; is in fact my Startup class within Startup.cs which hosts my app's pipeline and Configure and ConfigureServices get setup here so routing all works.</p><pre class="brush: csharp; gutter: false; toolbar: false; auto-links: false; smart-tabs: false;">private string Url;
<br>private WebApplication? _app = null;
<br>
<br>[OneTimeSetUp]
<br>public void Setup()
<br>{
<br>    var builder = WebApplicationTestBuilderFactory.CreateBuilder&lt;Startup&gt;();
<br>
<br>    var startup = new Startup(builder.Environment);
<br>    builder.WebHost.ConfigureKestrel(o =&gt; o.Listen(IPAddress.Loopback, 0));
<br>    startup.ConfigureServices(builder.Services);
<br>    _app = builder.Build();
<br>
<br>    // listen on any local port (hence the 0)
<br>    startup.Configure(_app, _app.Configuration);
<br>    _app.Start();
<br>
<br>    //you are kidding me
<br>    Url = _app.Services.GetRequiredService&lt;IServer&gt;().Features.GetRequiredFeature&lt;IServerAddressesFeature&gt;().Addresses.Last();
<br>}
<br>
<br>[OneTimeTearDown]
<br>public async Task TearDown()
<br>{
<br>    await _app.DisposeAsync();
<br>}</pre>
<p>So what horrors are buried in WebApplicationTestBuilderFactory? The first bit is bad and we should fix it for .NET 9. The rest is actually every nice, with a hat tip to David Fowler for his help and guidance! This is the magic and the ick in one small helper class.</p><pre class="brush: csharp; gutter: false; toolbar: false; auto-links: false; smart-tabs: false;">public class WebApplicationTestBuilderFactory 
<br>{
<br>    public static WebApplicationBuilder CreateBuilder&lt;T&gt;() where T : class 
<br>    {
<br>        //This ungodly code requires an unused reference to the MvcTesting package that hooks up
<br>        //  MSBuild to create the manifest file that is read here.
<br>        var testLocation = Path.Combine(AppContext.BaseDirectory, "MvcTestingAppManifest.json");
<br>        var json = JsonObject.Parse(File.ReadAllText(testLocation));
<br>        var asmFullName = typeof(T).Assembly.FullName ?? throw new InvalidOperationException("Assembly Full Name is null");
<br>        var contentRootPath = json?[asmFullName]?.GetValue&lt;string&gt;();
<br>
<br>        //spin up a real live web application inside TestHost.exe
<br>        var builder = WebApplication.CreateBuilder(
<br>            new WebApplicationOptions()
<br>            {
<br>                ContentRootPath = contentRootPath,
<br>                ApplicationName = asmFullName
<br>            });
<br>        return builder;
<br>    }
<br>}</pre>
<p>The first 4 lines are nasty. Because the test runs in the context of a different directory and my website needs to run within the context of its own content root path, I have to force the content root path to be correct and the only way to do that is by getting the apps base directory from a file generated within MSBuild from the (aging) MvcTesting package. The package is not used, but by referencing it it gets into the build and makes that file that I then use to pull out the directory. </p>
<p>If we can get rid of that "hack" and pull the directory from context elsewhere, then this helper function turns into a single line and .NET 9 gets WAY WAY more testable!</p>
<p>Now I can run my Unit Tests AND Playwright Browser Integration Tests across all OS's, headed or headless, in docker or on the metal. The site is updated to .NET 8 and all is right with my code. Well, it runs at least. ;)</p>
<br/><hr/>© 2025 Scott Hanselman. All rights reserved. 
<br/></div><Img align="left" border="0" height="1" width="1" alt="" style="border:0;float:left;margin:0;padding:0;width:1px!important;height:1px!important;" hspace="0" src="https://feeds.feedblitz.com/~/i/873234002/0/scotthanselman">
<div style="clear:both;padding-top:0.2em;"><a title="Like on Facebook" href="https://feeds.feedblitz.com/_/28/873234002/scotthanselman"><img height="20" src="https://assets.feedblitz.com/i/fblike20.png" style="border:0;margin:0;padding:0;"></a>&#160;<a title="Post to X.com" href="https://feeds.feedblitz.com/_/24/873234002/scotthanselman"><img height="20" src="https://assets.feedblitz.com/i/x.png" style="border:0;margin:0;padding:0;"></a>&#160;<a title="Subscribe by email" href="https://feeds.feedblitz.com/_/19/873234002/scotthanselman"><img height="20" src="https://assets.feedblitz.com/i/email20.png" style="border:0;margin:0;padding:0;"></a>&#160;<a title="Subscribe by RSS" href="https://feeds.feedblitz.com/_/20/873234002/scotthanselman"><img height="20" src="https://assets.feedblitz.com/i/rss20.png" style="border:0;margin:0;padding:0;"></a>&#160;</div>]]>
</content:encoded></item>
<item>
<feedburner:origLink>https://www.hanselman.com/blog/using-wsl-and-lets-encrypt-to-create-azure-app-service-ssl-wildcard-certificates</feedburner:origLink><trackback:ping>https://www.hanselman.com/blog/feed/trackback/7fbeba21-edbe-4af4-b909-26b6ba644546</trackback:ping><pingback:server>https://www.hanselman.com/blog/feed/pingback</pingback:server><pingback:target>https://www.hanselman.com/blog/post/7fbeba21-edbe-4af4-b909-26b6ba644546</pingback:target><dc:creator>Scott Hanselman</dc:creator><wfw:comment>https://feeds.feedblitz.com/~/749206136/0/scotthanselman~Using-WSL-and-Lets-Encrypt-to-create-Azure-App-Service-SSL-Wildcard-Certificates/comments#comments-start</wfw:comment><wfw:commentRss>https://www.hanselman.com/blog/feed/rss/comments/7fbeba21-edbe-4af4-b909-26b6ba644546</wfw:commentRss><slash:comments>3</slash:comments><title>Using WSL and Let's Encrypt to create Azure App Service SSL Wildcard Certificates</title><guid isPermaLink="false">https://www.hanselman.com/blog/post/7fbeba21-edbe-4af4-b909-26b6ba644546</guid><link>https://feeds.feedblitz.com/~/749206136/0/scotthanselman~Using-WSL-and-Lets-Encrypt-to-create-Azure-App-Service-SSL-Wildcard-Certificates</link><pubDate>Tue, 27 Jun 2023 17:17:25 GMT</pubDate><description><![CDATA[<div><p>There are many let's encrypt automatic tools for azure but I also wanted to see if I could use certbot in wsl to generate a wildcard certificate for the azure Friday website and then upload the resulting certificates to azure app service. </p> <p>Azure app service ultimately needs a specific format called dot PFX that includes the full certificate path and all intermediates.</p> <p>Per the docs, App Service private certificates must meet <a href="https://learn.microsoft.com/en-us/azure/app-service/configure-ssl-certificate?tabs=apex%2Cportal#private-certificate-requirements">the following requirements</a>:  <ul> <li>Exported as a password-protected PFX file, encrypted using triple DES.  <li>Contains private key at least 2048 bits long  <li>Contains all intermediate certificates and the root certificate in the certificate chain.</li></ul> <p>If you have a PFX that doesn't meet all these requirements you can have Windows reencrypt the file.</p> <p>I use WSL and certbot to create the cert, then I import/export in Windows and upload the resulting PFX.</p> <p>Within WSL, install certbot:</p><pre class="gutter: false; toolbar: false; smart-tabs: false;">sudo apt update<br>sudo apt install python3 python3-venv libaugeas0<br>sudo python3 -m venv /opt/certbot/<br>sudo /opt/certbot/bin/pip install --upgrade pip<br>sudo /opt/certbot/bin/pip install certbot</pre>
<p>Then I generate the cert. You'll get a nice text UI from certbot and update your DNS as a verification challenge. Change this to make sure it's <strong>two</strong> lines, and your domains and subdomains are correct and your paths are correct.</p><pre class="gutter: false; toolbar: false; smart-tabs: false;">sudo certbot certonly --manual --preferred-challenges=dns --email YOUR@EMAIL.COM   <br>    --server https://acme-v02.api.letsencrypt.org/directory   <br>    --agree-tos   --manual-public-ip-logging-ok   -d "azurefriday.com"   -d "*.azurefriday.com"<br>sudo openssl pkcs12 -export -out AzureFriday2023.pfx <br>    -inkey /etc/letsencrypt/live/azurefriday.com/privkey.pem <br>    -in /etc/letsencrypt/live/azurefriday.com/fullchain.pem</pre>
<p>I then copy the resulting file to my desktop (check your desktop path) so it's now in the Windows world.</p><pre class="gutter: false; toolbar: false; smart-tabs: false;">sudo cp AzureFriday2023.pfx /mnt/c/Users/Scott/OneDrive/Desktop
</pre>
<p>Now from Windows, import the PFX, note the thumbprint and export that cert.</p><pre class="brush: ps; gutter: false; toolbar: false; smart-tabs: false;">Import-PfxCertificate -FilePath "AzureFriday2023.pfx" -CertStoreLocation Cert:\LocalMachine\My <br>    -Password (ConvertTo-SecureString -String 'PASSWORDHERE' -AsPlainText -Force) -Exportable<br><br>Export-PfxCertificate -Cert Microsoft.PowerShell.Security\Certificate::LocalMachine\My\597THISISTHETHUMBNAILCF1157B8CEBB7CA1 <br>    -FilePath 'AzureFriday2023-fixed.pfx' -Password (ConvertTo-SecureString -String 'PASSWORDHERE' -AsPlainText -Force) </pre>
<p>Then upload the cert to the Certificates section of your App Service, under Bring Your Own Cert. </p><figure><img title="Custom Domains in Azure App Service" style="display: inline" alt="Custom Domains in Azure App Service" src="https://www.hanselman.com/blog/content/binary/Windows-Live-Writer/Using-WSL-and-Lets-Encrypt-to-create-Azu_C384/image_3849c466-fcdb-4abd-96ad-8d52a5e93730.png" width="858" height="437"></figure> 
<p>Then under Custom Domains, click Update Binding and select the new cert (with the latest expiration date).</p>
<p><img title="image" style="margin: 0px; display: inline" alt="image" src="https://www.hanselman.com/blog/content/binary/Windows-Live-Writer/Using-WSL-and-Lets-Encrypt-to-create-Azu_C384/image_3d6c1eb8-4a3e-4004-985a-75e8f8f56118.png" width="522" height="437"></p>
<p>Next step is to make this even more automatic or select a more automated solution but for now, I'll worry about this in September and it solved my expensive Wildcard Domain issue.</p><br/><hr/>© 2025 Scott Hanselman. All rights reserved. <br/></div><div style="clear:both;padding-top:0.2em;"><a title="Like on Facebook" href="https://feeds.feedblitz.com/_/28/749206136/scotthanselman"><img height="20" src="https://assets.feedblitz.com/i/fblike20.png" style="border:0;margin:0;padding:0;"></a>&#160;<a title="Post to X.com" href="https://feeds.feedblitz.com/_/24/749206136/scotthanselman"><img height="20" src="https://assets.feedblitz.com/i/x.png" style="border:0;margin:0;padding:0;"></a>&#160;<a title="Subscribe by email" href="https://feeds.feedblitz.com/_/19/749206136/scotthanselman"><img height="20" src="https://assets.feedblitz.com/i/email20.png" style="border:0;margin:0;padding:0;"></a>&#160;<a title="Subscribe by RSS" href="https://feeds.feedblitz.com/_/20/749206136/scotthanselman"><img height="20" src="https://assets.feedblitz.com/i/rss20.png" style="border:0;margin:0;padding:0;"></a>&#160;</div>]]>
</description><comments>https://feeds.feedblitz.com/~/749206136/0/scotthanselman~Using-WSL-and-Lets-Encrypt-to-create-Azure-App-Service-SSL-Wildcard-Certificates/comments#comments-start</comments><category>Azure</category><content:encoded><![CDATA[<div><p>There are many let's encrypt automatic tools for azure but I also wanted to see if I could use certbot in wsl to generate a wildcard certificate for the azure Friday website and then upload the resulting certificates to azure app service. </p> <p>Azure app service ultimately needs a specific format called dot PFX that includes the full certificate path and all intermediates.</p> <p>Per the docs, App Service private certificates must meet <a href="http://feeds.feedblitz.com/~/t/0/0/scotthanselman/~https://learn.microsoft.com/en-us/azure/app-service/configure-ssl-certificate?tabs=apex%2Cportal#private-certificate-requirements">the following requirements</a>:  <ul> <li>Exported as a password-protected PFX file, encrypted using triple DES.  <li>Contains private key at least 2048 bits long  <li>Contains all intermediate certificates and the root certificate in the certificate chain.</li></ul> <p>If you have a PFX that doesn't meet all these requirements you can have Windows reencrypt the file.</p> <p>I use WSL and certbot to create the cert, then I import/export in Windows and upload the resulting PFX.</p> <p>Within WSL, install certbot:</p><pre class="gutter: false; toolbar: false; smart-tabs: false;">sudo apt update
<br>sudo apt install python3 python3-venv libaugeas0
<br>sudo python3 -m venv /opt/certbot/
<br>sudo /opt/certbot/bin/pip install --upgrade pip
<br>sudo /opt/certbot/bin/pip install certbot</pre>
<p>Then I generate the cert. You'll get a nice text UI from certbot and update your DNS as a verification challenge. Change this to make sure it's <strong>two</strong> lines, and your domains and subdomains are correct and your paths are correct.</p><pre class="gutter: false; toolbar: false; smart-tabs: false;">sudo certbot certonly --manual --preferred-challenges=dns --email YOUR@EMAIL.COM   
<br>    --server https://acme-v02.api.letsencrypt.org/directory   
<br>    --agree-tos   --manual-public-ip-logging-ok   -d "azurefriday.com"   -d "*.azurefriday.com"
<br>sudo openssl pkcs12 -export -out AzureFriday2023.pfx 
<br>    -inkey /etc/letsencrypt/live/azurefriday.com/privkey.pem 
<br>    -in /etc/letsencrypt/live/azurefriday.com/fullchain.pem</pre>
<p>I then copy the resulting file to my desktop (check your desktop path) so it's now in the Windows world.</p><pre class="gutter: false; toolbar: false; smart-tabs: false;">sudo cp AzureFriday2023.pfx /mnt/c/Users/Scott/OneDrive/Desktop
</pre>
<p>Now from Windows, import the PFX, note the thumbprint and export that cert.</p><pre class="brush: ps; gutter: false; toolbar: false; smart-tabs: false;">Import-PfxCertificate -FilePath "AzureFriday2023.pfx" -CertStoreLocation Cert:\LocalMachine\My 
<br>    -Password (ConvertTo-SecureString -String 'PASSWORDHERE' -AsPlainText -Force) -Exportable
<br>
<br>Export-PfxCertificate -Cert Microsoft.PowerShell.Security\Certificate::LocalMachine\My\597THISISTHETHUMBNAILCF1157B8CEBB7CA1 
<br>    -FilePath 'AzureFriday2023-fixed.pfx' -Password (ConvertTo-SecureString -String 'PASSWORDHERE' -AsPlainText -Force) </pre>
<p>Then upload the cert to the Certificates section of your App Service, under Bring Your Own Cert. </p><figure><img title="Custom Domains in Azure App Service" style="display: inline" alt="Custom Domains in Azure App Service" src="https://www.hanselman.com/blog/content/binary/Windows-Live-Writer/Using-WSL-and-Lets-Encrypt-to-create-Azu_C384/image_3849c466-fcdb-4abd-96ad-8d52a5e93730.png" width="858" height="437"></figure> 
<p>Then under Custom Domains, click Update Binding and select the new cert (with the latest expiration date).</p>
<p><img title="image" style="margin: 0px; display: inline" alt="image" src="https://www.hanselman.com/blog/content/binary/Windows-Live-Writer/Using-WSL-and-Lets-Encrypt-to-create-Azu_C384/image_3d6c1eb8-4a3e-4004-985a-75e8f8f56118.png" width="522" height="437"></p>
<p>Next step is to make this even more automatic or select a more automated solution but for now, I'll worry about this in September and it solved my expensive Wildcard Domain issue.</p>
<br/><hr/>© 2025 Scott Hanselman. All rights reserved. 
<br/></div><Img align="left" border="0" height="1" width="1" alt="" style="border:0;float:left;margin:0;padding:0;width:1px!important;height:1px!important;" hspace="0" src="https://feeds.feedblitz.com/~/i/749206136/0/scotthanselman">
<div style="clear:both;padding-top:0.2em;"><a title="Like on Facebook" href="https://feeds.feedblitz.com/_/28/749206136/scotthanselman"><img height="20" src="https://assets.feedblitz.com/i/fblike20.png" style="border:0;margin:0;padding:0;"></a>&#160;<a title="Post to X.com" href="https://feeds.feedblitz.com/_/24/749206136/scotthanselman"><img height="20" src="https://assets.feedblitz.com/i/x.png" style="border:0;margin:0;padding:0;"></a>&#160;<a title="Subscribe by email" href="https://feeds.feedblitz.com/_/19/749206136/scotthanselman"><img height="20" src="https://assets.feedblitz.com/i/email20.png" style="border:0;margin:0;padding:0;"></a>&#160;<a title="Subscribe by RSS" href="https://feeds.feedblitz.com/_/20/749206136/scotthanselman"><img height="20" src="https://assets.feedblitz.com/i/rss20.png" style="border:0;margin:0;padding:0;"></a>&#160;</div>]]>
</content:encoded></item>
<item>
<feedburner:origLink>https://www.hanselman.com/blog/github-copilot-for-cli-for-powershell</feedburner:origLink><trackback:ping>https://www.hanselman.com/blog/feed/trackback/aa1cc05f-3910-471d-8686-68c749ec90ff</trackback:ping><pingback:server>https://www.hanselman.com/blog/feed/pingback</pingback:server><pingback:target>https://www.hanselman.com/blog/post/aa1cc05f-3910-471d-8686-68c749ec90ff</pingback:target><dc:creator>Scott Hanselman</dc:creator><wfw:comment>https://feeds.feedblitz.com/~/737271731/0/scotthanselman~GitHub-Copilot-for-CLI-for-PowerShell/comments#comments-start</wfw:comment><wfw:commentRss>https://www.hanselman.com/blog/feed/rss/comments/aa1cc05f-3910-471d-8686-68c749ec90ff</wfw:commentRss><slash:comments>6</slash:comments><title>GitHub Copilot for CLI for PowerShell</title><guid isPermaLink="false">https://www.hanselman.com/blog/post/aa1cc05f-3910-471d-8686-68c749ec90ff</guid><link>https://feeds.feedblitz.com/~/737271731/0/scotthanselman~GitHub-Copilot-for-CLI-for-PowerShell</link><pubDate>Tue, 25 Apr 2023 15:31:49 GMT</pubDate><description><![CDATA[<div><p>GitHub Next has this cool project that is basically Copilot for the CLI (command line interface). You can sign up for their waitlist at the <a href="https://githubnext.com/projects/copilot-cli/">Copilot for CLI site</a>.</p> <blockquote> <p>Copilot for CLI provides three shell commands: <code>??</code>, <code>git?</code> and <code>gh?</code></p></blockquote> <p>This is cool and all, but I use PowerShell. Turns out these ?? commands are just router commands to a larger EXE called github-copilot-cli. So if you go "?? something" you're really going "github-copilot-cli what-the-shell something."</p> <p>So this means I should be able to to do the same/similar aliases for my PowerShell prompt AND change the injected prompt (look at me I'm a prompt engineer) to add 'use powershell to.' </p> <p>Now it's not perfect, but hopefully it will make the point to the Copilot CLI team that PowerShell needs love also.</p> <p>Here are my aliases. Feel free to suggest if these suck. Note the addition of "user powershell to" for the ?? one. I may make a ?? and a p? where one does bash and one does PowerShell. I could also have it use wsl.exe and shell out to bash. Lots of possibilities.</p><pre class="brush: ps; gutter: false; toolbar: false; collapse: false; smart-tabs: false;">function ?? { <br>    $TmpFile = New-TemporaryFile <br>    github-copilot-cli what-the-shell ('use powershell to ' + $args) --shellout $TmpFile <br>    if ([System.IO.File]::Exists($TmpFile)) { <br>        $TmpFileContents = Get-Content $TmpFile <br>            if ($TmpFileContents -ne $nill) {<br>            Invoke-Expression $TmpFileContents <br>            Remove-Item $TmpFile <br>        }<br>    }<br>}<br><br>function git? {<br>    $TmpFile = New-TemporaryFile<br>    github-copilot-cli git-assist $args --shellout $TmpFile<br>    if ([System.IO.File]::Exists($TmpFile)) {<br>        $TmpFileContents = Get-Content $TmpFile <br>            if ($TmpFileContents -ne $nill) {<br>            Invoke-Expression $TmpFileContents <br>            Remove-Item $TmpFile <br>        }<br>    }<br>}<br>function gh? {<br>    $TmpFile = New-TemporaryFile<br>    github-copilot-cli gh-assist $args --shellout $TmpFile<br>    if ([System.IO.File]::Exists($TmpFile)) {<br>        $TmpFileContents = Get-Content $TmpFile <br>            if ($TmpFileContents -ne $nill) {<br>            Invoke-Expression $TmpFileContents <br>            Remove-Item $TmpFile <br>        }<br>    }<br>} </pre>
<p>It also then offers to run the command. Very smooth.</p><figure><img title="image" style="margin: 0px; display: inline" alt="image" src="https://www.hanselman.com/blog/content/binary/Windows-Live-Writer/GitHub-Copilot-for-CLI-for-PowerShell_B0E3/image_f39afdbf-04bf-4c95-a913-2404f46dc308.png" width="999" height="437"></figure> 
<p>Hope you like it. Lots of fun stuff happening in this space.</p><br/><hr/>© 2025 Scott Hanselman. All rights reserved. <br/></div><div style="clear:both;padding-top:0.2em;"><a title="Like on Facebook" href="https://feeds.feedblitz.com/_/28/737271731/scotthanselman"><img height="20" src="https://assets.feedblitz.com/i/fblike20.png" style="border:0;margin:0;padding:0;"></a>&#160;<a title="Post to X.com" href="https://feeds.feedblitz.com/_/24/737271731/scotthanselman"><img height="20" src="https://assets.feedblitz.com/i/x.png" style="border:0;margin:0;padding:0;"></a>&#160;<a title="Subscribe by email" href="https://feeds.feedblitz.com/_/19/737271731/scotthanselman"><img height="20" src="https://assets.feedblitz.com/i/email20.png" style="border:0;margin:0;padding:0;"></a>&#160;<a title="Subscribe by RSS" href="https://feeds.feedblitz.com/_/20/737271731/scotthanselman"><img height="20" src="https://assets.feedblitz.com/i/rss20.png" style="border:0;margin:0;padding:0;"></a>&#160;</div>]]>
</description><comments>https://feeds.feedblitz.com/~/737271731/0/scotthanselman~GitHub-Copilot-for-CLI-for-PowerShell/comments#comments-start</comments><category>AI</category><category>PowerShell</category><content:encoded><![CDATA[<div><p>GitHub Next has this cool project that is basically Copilot for the CLI (command line interface). You can sign up for their waitlist at the <a href="http://feeds.feedblitz.com/~/t/0/0/scotthanselman/~https://githubnext.com/projects/copilot-cli/">Copilot for CLI site</a>.</p> <blockquote> <p>Copilot for CLI provides three shell commands: <code>??</code>, <code>git?</code> and <code>gh?</code></p></blockquote> <p>This is cool and all, but I use PowerShell. Turns out these ?? commands are just router commands to a larger EXE called github-copilot-cli. So if you go "?? something" you're really going "github-copilot-cli what-the-shell something."</p> <p>So this means I should be able to to do the same/similar aliases for my PowerShell prompt AND change the injected prompt (look at me I'm a prompt engineer) to add 'use powershell to.' </p> <p>Now it's not perfect, but hopefully it will make the point to the Copilot CLI team that PowerShell needs love also.</p> <p>Here are my aliases. Feel free to suggest if these suck. Note the addition of "user powershell to" for the ?? one. I may make a ?? and a p? where one does bash and one does PowerShell. I could also have it use wsl.exe and shell out to bash. Lots of possibilities.</p><pre class="brush: ps; gutter: false; toolbar: false; collapse: false; smart-tabs: false;">function ?? { 
<br>    $TmpFile = New-TemporaryFile 
<br>    github-copilot-cli what-the-shell ('use powershell to ' + $args) --shellout $TmpFile 
<br>    if ([System.IO.File]::Exists($TmpFile)) { 
<br>        $TmpFileContents = Get-Content $TmpFile 
<br>            if ($TmpFileContents -ne $nill) {
<br>            Invoke-Expression $TmpFileContents 
<br>            Remove-Item $TmpFile 
<br>        }
<br>    }
<br>}
<br>
<br>function git? {
<br>    $TmpFile = New-TemporaryFile
<br>    github-copilot-cli git-assist $args --shellout $TmpFile
<br>    if ([System.IO.File]::Exists($TmpFile)) {
<br>        $TmpFileContents = Get-Content $TmpFile 
<br>            if ($TmpFileContents -ne $nill) {
<br>            Invoke-Expression $TmpFileContents 
<br>            Remove-Item $TmpFile 
<br>        }
<br>    }
<br>}
<br>function gh? {
<br>    $TmpFile = New-TemporaryFile
<br>    github-copilot-cli gh-assist $args --shellout $TmpFile
<br>    if ([System.IO.File]::Exists($TmpFile)) {
<br>        $TmpFileContents = Get-Content $TmpFile 
<br>            if ($TmpFileContents -ne $nill) {
<br>            Invoke-Expression $TmpFileContents 
<br>            Remove-Item $TmpFile 
<br>        }
<br>    }
<br>} </pre>
<p>It also then offers to run the command. Very smooth.</p><figure><img title="image" style="margin: 0px; display: inline" alt="image" src="https://www.hanselman.com/blog/content/binary/Windows-Live-Writer/GitHub-Copilot-for-CLI-for-PowerShell_B0E3/image_f39afdbf-04bf-4c95-a913-2404f46dc308.png" width="999" height="437"></figure> 
<p>Hope you like it. Lots of fun stuff happening in this space.</p>
<br/><hr/>© 2025 Scott Hanselman. All rights reserved. 
<br/></div><Img align="left" border="0" height="1" width="1" alt="" style="border:0;float:left;margin:0;padding:0;width:1px!important;height:1px!important;" hspace="0" src="https://feeds.feedblitz.com/~/i/737271731/0/scotthanselman">
<div style="clear:both;padding-top:0.2em;"><a title="Like on Facebook" href="https://feeds.feedblitz.com/_/28/737271731/scotthanselman"><img height="20" src="https://assets.feedblitz.com/i/fblike20.png" style="border:0;margin:0;padding:0;"></a>&#160;<a title="Post to X.com" href="https://feeds.feedblitz.com/_/24/737271731/scotthanselman"><img height="20" src="https://assets.feedblitz.com/i/x.png" style="border:0;margin:0;padding:0;"></a>&#160;<a title="Subscribe by email" href="https://feeds.feedblitz.com/_/19/737271731/scotthanselman"><img height="20" src="https://assets.feedblitz.com/i/email20.png" style="border:0;margin:0;padding:0;"></a>&#160;<a title="Subscribe by RSS" href="https://feeds.feedblitz.com/_/20/737271731/scotthanselman"><img height="20" src="https://assets.feedblitz.com/i/rss20.png" style="border:0;margin:0;padding:0;"></a>&#160;</div>]]>
</content:encoded></item>
</channel></rss>

