<?xml version="1.0" encoding="UTF-8"?>
<feed xmlns="http://www.w3.org/2005/Atom" xml:lang="en-AU" xmlns:media="http://search.yahoo.com/mrss/">
  <id>https://david.gardiner.net.au/feed.xml</id>
  <title type="html">David Gardiner</title>
  <updated>2026-06-06T00:37:10.013Z</updated>
  <subtitle>A blog of software development, .NET and other interesting things</subtitle>
  <rights>Copyright 2026 David Gardiner</rights>
  <icon>https://www.gravatar.com/avatar/37edf2567185071646d62ba28b868fab?s=64</icon>
  <logo>https://www.gravatar.com/avatar/37edf2567185071646d62ba28b868fab?s=256</logo>
  <generator uri="https://github.com/flcdrg/astrojs-atom" version="3">astrojs-atom</generator>
  <author>
    <name>David Gardiner</name>
  </author>
  <link href="https://david.gardiner.net.au/feed.xml" rel="self" type="application/atom+xml"/>
  <link href="https://david.gardiner.net.au/" rel="alternate" type="text/html" hreflang="en-AU"/>
  <category term=".NET"/>
  <category term="Software Development"/>
  <category term="Azure"/>
  <category term="DevOps"/>
  <entry>
    <id>https://david.gardiner.net.au/2026/05/which-laptop</id>
    <updated>2026-05-29T09:00:00.000+09:30</updated>
    <title>New laptop options in 2026</title>
    <link href="https://david.gardiner.net.au/2026/05/which-laptop" rel="alternate" type="text/html" title="New laptop options in 2026"/>
    <category term="Hardware"/>
    <published>2026-05-29T09:00:00.000+09:30</published>
    <summary type="html">It&apos;s hardware refresh time. Which laptops are in the running for my next main development machine?</summary>
    <content type="html">&lt;p&gt;It&apos;s been 3 years since my l&lt;a href=&quot;/2023/04/new-laptop&quot;&gt;ast laptop purchase&lt;/a&gt;, and it&apos;s technically a year overdue (as &lt;a href=&quot;https://www.sixpivot.com.au&quot;&gt;SixPivot&lt;/a&gt; allows for a hardware refresh every two years). My Dell has been chugging along pretty well after a few stability issues in the first 12 months.&lt;/p&gt;
&lt;p&gt;Usually I&apos;d like to bump things up incrementally with each new device. A bit more RAM, faster CPU or larger storage. But then AI happened, and component prices for those things in particular have skyrocketed! I quickly realised that while I may end up getting a newer generation CPU, and probably an onboard NPU (neural processing unit), unless I was willing to pay much more then the other specs would likely stay the same.&lt;/p&gt;
&lt;p&gt;We do get a &lt;a href=&quot;https://handbook.sixpivot.com.au/perks-and-benefits/laptop-allowance&quot;&gt;generous laptop allowance at SixPivot&lt;/a&gt;, but this time around it will likely need quite a bit of supplementing thanks to those price rises. That is a bit disappointing, but there&apos;s nothing I can do about it - it is what it is.&lt;/p&gt;
&lt;p&gt;And so I decided to look at machines with 64GB RAM and 2TB storage. So what should I get for a replacement?&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Model&lt;/th&gt;
&lt;th&gt;Width (mm)&lt;/th&gt;
&lt;th&gt;Depth (mm)&lt;/th&gt;
&lt;th&gt;Height (mm)&lt;/th&gt;
&lt;th&gt;Display size&lt;/th&gt;
&lt;th&gt;Resolution&lt;/th&gt;
&lt;th&gt;CPU&lt;/th&gt;
&lt;th&gt;RAM (GB)&lt;/th&gt;
&lt;th&gt;Storage (TB)&lt;/th&gt;
&lt;th&gt;Cost (AUD)&lt;/th&gt;
&lt;th&gt;Date&lt;/th&gt;
&lt;th&gt;&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;&lt;tr&gt;
&lt;td&gt;Existing Dell&lt;/td&gt;
&lt;td&gt;344&lt;/td&gt;
&lt;td&gt;230&lt;/td&gt;
&lt;td&gt;18&lt;/td&gt;
&lt;td&gt;15.5&quot;&lt;/td&gt;
&lt;td&gt;3456x2160&lt;/td&gt;
&lt;td&gt;i9&lt;/td&gt;
&lt;td&gt;64&lt;/td&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;td&gt;$5,468&lt;/td&gt;
&lt;td&gt;Apr 2023&lt;/td&gt;
&lt;td&gt;&lt;a href=&quot;/2023/04/new-laptop&quot;&gt;XPS 9530&lt;/a&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Framework 16&lt;/td&gt;
&lt;td&gt;356&lt;/td&gt;
&lt;td&gt;270&lt;/td&gt;
&lt;td&gt;17.9&lt;/td&gt;
&lt;td&gt;16&quot;&lt;/td&gt;
&lt;td&gt;2560x1600&lt;/td&gt;
&lt;td&gt;AMD Ryzen AI 7 350&lt;/td&gt;
&lt;td&gt;64&lt;/td&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;td&gt;$5,613&lt;/td&gt;
&lt;td&gt;6-Apr&lt;/td&gt;
&lt;td&gt;&lt;a href=&quot;https://frame.work/au/en/products/laptop16-diy-amd-ai300/configuration/new&quot;&gt;https://frame.work/au/en/products/laptop16-diy-amd-ai300/configuration/new&lt;/a&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Framework 13&lt;/td&gt;
&lt;td&gt;296&lt;/td&gt;
&lt;td&gt;228&lt;/td&gt;
&lt;td&gt;15.8&lt;/td&gt;
&lt;td&gt;13.5&quot;&lt;/td&gt;
&lt;td&gt;2256x1504&lt;/td&gt;
&lt;td&gt;AMD Ryzen AI 7 350&lt;/td&gt;
&lt;td&gt;64&lt;/td&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;td&gt;$4,472&lt;/td&gt;
&lt;td&gt;6-Apr&lt;/td&gt;
&lt;td&gt;&lt;a href=&quot;https://frame.work/au/en/products/laptop13-diy-amd-ai300/configuration/new&quot;&gt;https://frame.work/au/en/products/laptop13-diy-amd-ai300/configuration/new&lt;/a&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;MBP 14&lt;/td&gt;
&lt;td&gt;312&lt;/td&gt;
&lt;td&gt;221&lt;/td&gt;
&lt;td&gt;15.5&lt;/td&gt;
&lt;td&gt;14.2&quot;&lt;/td&gt;
&lt;td&gt;3024x1964&lt;/td&gt;
&lt;td&gt;M5 Pro (18 Core)&lt;/td&gt;
&lt;td&gt;64&lt;/td&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;td&gt;$5,399&lt;/td&gt;
&lt;td&gt;26-Apr&lt;/td&gt;
&lt;td&gt;&lt;a href=&quot;https://www.apple.com/au/shop/buy-mac/macbook-pro/14-inch-space-black-standard-display-apple-m5-pro-chip-18-core-cpu-20-core-gpu-64gb-memory-2tb-storage&quot;&gt;https://www.apple.com/au/shop/buy-mac/macbook-pro/14-inch-space-black-standard-display-apple-m5-pro-chip-18-core-cpu-20-core-gpu-64gb-memory-2tb-storage&lt;/a&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;MPB 16&lt;/td&gt;
&lt;td&gt;355&lt;/td&gt;
&lt;td&gt;248&lt;/td&gt;
&lt;td&gt;16.8&lt;/td&gt;
&lt;td&gt;16.2&quot;&lt;/td&gt;
&lt;td&gt;3456x2234&lt;/td&gt;
&lt;td&gt;M5 Max&lt;/td&gt;
&lt;td&gt;48&lt;/td&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;td&gt;$6,999&lt;/td&gt;
&lt;td&gt;28-May&lt;/td&gt;
&lt;td&gt;&lt;a href=&quot;https://www.jbhifi.com.au/products/apple-macbook-pro-16-inch-with-m5-max-chip-2tb-48gb-space-black&quot;&gt;https://www.jbhifi.com.au/products/apple-macbook-pro-16-inch-with-m5-max-chip-2tb-48gb-space-black&lt;/a&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;MBP 16&lt;/td&gt;
&lt;td&gt;355&lt;/td&gt;
&lt;td&gt;248&lt;/td&gt;
&lt;td&gt;16.8&lt;/td&gt;
&lt;td&gt;16.2&quot;&lt;/td&gt;
&lt;td&gt;3456x2234&lt;/td&gt;
&lt;td&gt;M5 Pro (18 Core)&lt;/td&gt;
&lt;td&gt;64&lt;/td&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;td&gt;$5,799&lt;/td&gt;
&lt;td&gt;25-Apr&lt;/td&gt;
&lt;td&gt;&lt;a href=&quot;https://www.apple.com/au/shop/buy-mac/macbook-pro/16-inch-silver-standard-display-apple-m5-pro-chip-18-core-cpu-20-core-gpu-64gb-memory-2tb-storage&quot;&gt;https://www.apple.com/au/shop/buy-mac/macbook-pro/16-inch-silver-standard-display-apple-m5-pro-chip-18-core-cpu-20-core-gpu-64gb-memory-2tb-storage&lt;/a&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Dell XPS 16&lt;/td&gt;
&lt;td&gt;352&lt;/td&gt;
&lt;td&gt;237&lt;/td&gt;
&lt;td&gt;14&lt;/td&gt;
&lt;td&gt;16&quot;&lt;/td&gt;
&lt;td&gt;3.2K&lt;/td&gt;
&lt;td&gt;Series 3 Intel® Core™ Ultra X7 358H (16 cores, up to 4.8 GHz)&lt;/td&gt;
&lt;td&gt;64&lt;/td&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;td&gt;$6,603&lt;/td&gt;
&lt;td&gt;7-May&lt;/td&gt;
&lt;td&gt;&lt;a href=&quot;https://www.dell.com/en-au/shop/dell-laptops/xps-16-laptop-2026/spd/xps-da16260-laptop/cda16260cto02mau#customization-anchor&quot;&gt;https://www.dell.com/en-au/shop/dell-laptops/xps-16-laptop-2026/spd/xps-da16260-laptop/cda16260cto02mau#customization-anchor&lt;/a&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;&lt;/table&gt;
&lt;p&gt;I&apos;ve given Dell a good run, but I was really frustrated with the build quality of the most recent laptop. While it eventually stabilised, it required more than one service technician to come and replace a faulty component. So I was really open to other options.&lt;/p&gt;
&lt;p&gt;Framework laptops had really caught my eye. I really liked how &quot;composable&quot; they are, and being able to swap in and out different modules. That would probably have been my first choice except for one minor issue - they only offer a 1 year warranty in Australia, and as this will be a work laptop it needs to be fully supported for at least 2 years. So sadly, as good as they looked, they were out of the race.&lt;/p&gt;
&lt;p&gt;On that note, I should mention that the prices above don&apos;t include extended support. Depending on the brand and model, 3 years support can range from $450-650. Not something to overlook when crunching the numbers. This laptop will be a work tool, and if there&apos;s a problem it needs to be fixed ASAP to minimise any downtime.&lt;/p&gt;
&lt;p&gt;I&apos;m not a complete stranger to Apple hardware. I&apos;ve been a happy iPhone user since giving up on Windows Phone, and even before that I owned an Apple Newton MessagePad. But I&apos;ve never had an Apple desktop or laptop. I&apos;ve noticed some of my SixPivot colleagues have been using MacBooks, so I was intrigued. Could that be an option?&lt;/p&gt;
&lt;p&gt;And can I really write off Dell? To be fair I did take a look at their latest XPS laptop (now that they&apos;ve decided to switch back the &apos;XPS&apos; brand again!)&lt;/p&gt;
&lt;p&gt;Time to weigh up the pros and cons:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Stick with a Windows laptop&lt;/li&gt;
&lt;li&gt;Brave stepping out into the Apple macOS ecosystem&lt;/li&gt;
&lt;li&gt;Maybe I should look at another laptop vendor (not Lenovo, their keyboard layout is really irritating!)&lt;/li&gt;
&lt;/ul&gt;
</content>
  </entry>
  <entry>
    <id>https://david.gardiner.net.au/2026/05/mastodon-apps</id>
    <updated>2026-05-22T00:10:00.000+09:30</updated>
    <title>16 Mastodon client apps for iOS</title>
    <link href="https://david.gardiner.net.au/2026/05/mastodon-apps" rel="alternate" type="text/html" title="16 Mastodon client apps for iOS"/>
    <category term="iOS"/>
    <published>2026-05-22T00:10:00.000+09:30</published>
    <summary type="html">A review of 16 Mastodon iOS apps, with 6 making the cut for further consideration</summary>
    <content type="html">&lt;p&gt;I&apos;ve been part of the &lt;a href=&quot;/2022/11/hello-mastodon&quot;&gt;Mastodon Fediverse since 2022&lt;/a&gt;, and I&apos;m always on the lookout for a decent iOS app to use on my phone. I originally started with the stock Mastodon app, and then tried running &lt;a href=&quot;https://github.com/elk-zone/elk&quot;&gt;Elk&lt;/a&gt; as a progressive web app on the phone. Both were unreliable, and so more recently I&apos;ve been using Manfred (see below).&lt;/p&gt;
&lt;p&gt;But I wondered what else was out there and decided to try installing as many Mastodon apps as I could to find ones I might stick with.&lt;/p&gt;
&lt;p&gt;To even be considered for evaluation, an app must have been updated in the last 6 months.&lt;/p&gt;
&lt;p&gt;Other features I&apos;m interested in&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Free (or at least some free trial period)&lt;/li&gt;
&lt;li&gt;Can have in-app purchases for extra features, but basic functionality (like posting) should be included.&lt;/li&gt;
&lt;li&gt;I lean more towards the apps that have a clean UI and focus on the content/feed.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;I ended up trying out 16 apps, and found 6 that are worth spending a bit more time with.&lt;/p&gt;
&lt;p&gt;As much as possible, all screenshots were taken at the same moment in time for the feed to allow comparison of how they display identical content.&lt;/p&gt;
&lt;p&gt;Screenshots are from my iPhone 16 Pro running iOS 26.5&lt;/p&gt;
&lt;h2&gt;&lt;a href=&quot;https://apps.apple.com/us/app/ice-cubes-for-mastodon/id6444915884&quot;&gt;Ice Cubes for Mastodon&lt;/a&gt;&lt;/h2&gt;
&lt;p&gt;Seems pretty good. Looks nice and works well. I like how it makes full use of the display.&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://david.gardiner.net.au/_astro/02-ice-cubes.CBJsEqLD_EIVEF.webp&quot; alt=&quot;screenshot of Ice Cubes&quot; /&gt;&lt;/p&gt;
&lt;h2&gt;&lt;a href=&quot;https://apps.apple.com/us/app/metatext/id1523996615&quot;&gt;MetaText&lt;/a&gt;&lt;/h2&gt;
&lt;p&gt;Also a decent app. Also in consideration.&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://david.gardiner.net.au/_astro/03-metatext.DQ-wpd-G_2viUBY.webp&quot; alt=&quot;screenshot of MetaText&quot; /&gt;&lt;/p&gt;
&lt;h2&gt;&lt;a href=&quot;https://apps.apple.com/us/app/crowfi-for-mastodon/id6756513753&quot;&gt;Crowfi for Mastodon&lt;/a&gt;&lt;/h2&gt;
&lt;p&gt;Another reasonable one.&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://david.gardiner.net.au/_astro/05-crowfi.BTTKhVKv_6TEdn.webp&quot; alt=&quot;screenshot of Crowfi&quot; /&gt;&lt;/p&gt;
&lt;h2&gt;&lt;a href=&quot;https://apps.apple.com/us/app/iphanpy-for-mastodon/id6755365082&quot;&gt;iPhanpy for Mastodon&lt;/a&gt;&lt;/h2&gt;
&lt;p&gt;And another one&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://david.gardiner.net.au/_astro/06-iphanpy.Bb7mkStn_Z1ygcmK.webp&quot; alt=&quot;screenshot of iPhanpy&quot; /&gt;&lt;/p&gt;
&lt;h2&gt;&lt;a href=&quot;https://apps.apple.com/us/app/mona-7-for-mastodon/id6755672518&quot;&gt;Mona 7 for Mastodon&lt;/a&gt;&lt;/h2&gt;
&lt;p&gt;And another one!&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://david.gardiner.net.au/_astro/07-mona.idcPVOyO_ZKinSS.webp&quot; alt=&quot;screenshot of &quot; /&gt;&lt;/p&gt;
&lt;h2&gt;&lt;a href=&quot;https://apps.apple.com/us/app/trunks-for-mastodon/id6444749479&quot;&gt;Trunks for Mastodon&lt;/a&gt;&lt;/h2&gt;
&lt;p&gt;And even another one. Nice preview feature in the post editor.&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://david.gardiner.net.au/_astro/08-trunks.CDoGoEdy_Z2aFSPN.webp&quot; alt=&quot;screenshot of Trunks&quot; /&gt;&lt;/p&gt;
&lt;h2&gt;&lt;a href=&quot;https://apps.apple.com/us/app/ivory-for-mastodon-by-tapbots/id6444602274&quot;&gt;Ivory&lt;/a&gt;&lt;/h2&gt;
&lt;p&gt;A nice app, but while it is free in the app store, you need to pay after a 7 day trial. $AU25/year seems a bit much for just a social media client.&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://david.gardiner.net.au/_astro/01-ivory.B-_ctYP-_Z1H8y2D.webp&quot; alt=&quot;screenshot of Ivory&quot; /&gt;&lt;/p&gt;
&lt;h2&gt;&lt;a href=&quot;https://apps.apple.com/us/app/feather-for-mastodon/id6446263061&quot;&gt;feather for Mastodon&lt;/a&gt;&lt;/h2&gt;
&lt;p&gt;Most apps are dark mode but this one has a bright white background by default.&lt;/p&gt;
&lt;p&gt;I tried posting with a image, but it had a problem with photo permissions.&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://david.gardiner.net.au/_astro/09-feather.D4er538F_Z2hjfX9.webp&quot; alt=&quot;screenshot of feather&quot; /&gt;&lt;/p&gt;
&lt;h2&gt;&lt;a href=&quot;https://apps.apple.com/us/app/manfred-for-mastodon/id1667817813&quot;&gt;Manfred for Mastodon&lt;/a&gt;&lt;/h2&gt;
&lt;p&gt;I&apos;ve been using this app for a few months now. It looked promising, but continues to have a few glitches with scrolling and a few other rough edges like the post editor. Time to leave this one for now. Might come back in the future to see how it is going.&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://david.gardiner.net.au/_astro/16-manfred.D_Hhxtdz_Zuhq4e.webp&quot; alt=&quot;screenshot of Manfred&quot; /&gt;&lt;/p&gt;
&lt;h2&gt;&lt;a href=&quot;https://apps.apple.com/us/app/openvibe-bluesky-mastodon/id1666230916&quot;&gt;Openvibe&lt;/a&gt;&lt;/h2&gt;
&lt;p&gt;I&apos;ve actually had this app installed for a long time, and used it to cross post to both Mastodon and Bluesky (as it supports as well as Tumblr and Threads). Sometimes it is good, other times it will fail to post to one or both services.&lt;/p&gt;
&lt;p&gt;I gather they&apos;ve just updated their authentication for Bluesky, so maybe that will help.&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://david.gardiner.net.au/_astro/15-openvibe.Py-0JXV2_Acqd1.webp&quot; alt=&quot;screenshot of Openvibe&quot; /&gt;&lt;/p&gt;
&lt;h2&gt;&lt;a href=&quot;https://apps.apple.com/us/app/mastodon/id1571998974&quot;&gt;Mastodon&lt;/a&gt;&lt;/h2&gt;
&lt;p&gt;The original app. Most of the time it was fine, but then earlier this year it got buggy and wouldn&apos;t load new posts, so I switched to Manfred.&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://david.gardiner.net.au/_astro/10-mastodon.DTad6uNC_2vfgNg.webp&quot; alt=&quot;screenshot of Mastodon&quot; /&gt;&lt;/p&gt;
&lt;h2&gt;&lt;a href=&quot;https://apps.apple.com/us/app/tootle-for-mastodon/id1236013466&quot;&gt;Tootle for Mastodon&lt;/a&gt;&lt;/h2&gt;
&lt;p&gt;No idea what this app is doing. I can&apos;t get it to sign in. Tried entering my server &lt;code&gt;mastodon.online&lt;/code&gt; but it doesn&apos;t like it.&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://david.gardiner.net.au/_astro/11-tootle.kResXTJR_Z6LhtL.webp&quot; alt=&quot;screenshot of Tootle&quot; /&gt;&lt;/p&gt;
&lt;h2&gt;&lt;a href=&quot;https://apps.apple.com/us/app/pinkleap-bluesky-mastodon/id6740792786&quot;&gt;Pinkleap&lt;/a&gt;&lt;/h2&gt;
&lt;p&gt;Also does Bluesky. I found the app a bit buggy. Sometimes it got stuck on the splash screen. Doesn&apos;t seem to want to refresh the feed properly.&lt;/p&gt;
&lt;p&gt;Not real keen on the large list of profile photos across the top.&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://david.gardiner.net.au/_astro/12-pinkleap.BNlYh9Ba_Z2qU3Yr.webp&quot; alt=&quot;screenshot of &quot; /&gt;&lt;/p&gt;
&lt;h2&gt;&lt;a href=&quot;https://apps.apple.com/us/app/dawn-for-mastodon-by-nightfox/id1668645019&quot;&gt;DAWN for Mastodon by Nightfox&lt;/a&gt;&lt;/h2&gt;
&lt;p&gt;Reminds me of the old Twitter app. I&apos;ve moved on.&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://david.gardiner.net.au/_astro/13-dawn.DzDeZ9CH_Z2vEfbQ.webp&quot; alt=&quot;screenshot of DAWN&quot; /&gt;&lt;/p&gt;
&lt;h2&gt;&lt;a href=&quot;https://apps.apple.com/us/app/pheme-for-mastodon/id6751176171&quot;&gt;Pheme for Mastodon&lt;/a&gt;&lt;/h2&gt;
&lt;p&gt;I&apos;m confused. That is not my feed. Not sure what this app is doing.&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://david.gardiner.net.au/_astro/14-pheme.BSViOzts_Z2w5OKT.webp&quot; alt=&quot;screenshot of &quot; /&gt;&lt;/p&gt;
&lt;h2&gt;&lt;a href=&quot;https://apps.apple.com/us/app/indigo-for-bluesky-mastodon/id6763755310&quot;&gt;Indigo&lt;/a&gt;&lt;/h2&gt;
&lt;p&gt;Looks nice, Indigo also supports Bluesky, bu you can&apos;t post for free. Nope.&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://david.gardiner.net.au/_astro/04-indigo.G-eAbYfg_ZoBLf3.webp&quot; alt=&quot;screenshot of Indigo&quot; /&gt;&lt;/p&gt;
&lt;h2&gt;Conclusion&lt;/h2&gt;
&lt;p&gt;So that&apos;s narrowed down the field to 6 contenders. I&apos;ll keep using those for a little longer and see where I land.&lt;/p&gt;
</content>
    <media:thumbnail url="https://david.gardiner.net.au/_astro/mastodon-logo.CGj-59jp.png" width="313" height="81"/>
    <media:content medium="image" url="https://david.gardiner.net.au/_astro/mastodon-logo.CGj-59jp.png" width="313" height="81"/>
  </entry>
  <entry>
    <id>https://david.gardiner.net.au/2026/05/google-search-indexing</id>
    <updated>2026-05-10T17:00:00.000+09:30</updated>
    <title>Google, what happened!!?</title>
    <link href="https://david.gardiner.net.au/2026/05/google-search-indexing" rel="alternate" type="text/html" title="Google, what happened!!?"/>
    <category term="Blogging"/>
    <category term="WWW"/>
    <published>2026-05-10T17:00:00.000+09:30</published>
    <summary type="html">When I discovered that Google has stopped indexing pages on my blog.</summary>
    <content type="html">&lt;p&gt;I was working again with Aspire this week and hit another issue launching the solution under Visual Studio. It felt familiar, but I wasn&apos;t sure. A quick search online threw up some results, but nothing great.&lt;/p&gt;
&lt;p&gt;Did I make a note of the error in OneNote? No.&lt;/p&gt;
&lt;p&gt;What about my blog? Umm.. yes! And super embarrassing, it was &lt;a href=&quot;/2026/2026-04-10-vs-debugging-fatal-error.md&quot;&gt;my most recent post&lt;/a&gt;!&lt;/p&gt;
&lt;p&gt;So yay! I applied the workaround and was unblocked. But that got me thinking.. surely my post should have been somewhere in the search results by now?&lt;/p&gt;
&lt;p&gt;And that&apos;s how I discovered that starting in mid-April Google suddenly decided to stop indexing pages on my site!&lt;/p&gt;
&lt;p&gt;Here&apos;s the summary from &lt;a href=&quot;https://search.google.com/search-console&quot;&gt;Google Search Console&lt;/a&gt;:&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://david.gardiner.net.au/_astro/search-console-pages-indexed.CY_yX2PA_Z2nGuPB.webp&quot; alt=&quot;Screenshot from Google Search Console showing just 6 pages indexed&quot; /&gt;&lt;/p&gt;
&lt;p&gt;Only 6 pages currently being indexed? What on earth?&lt;/p&gt;
&lt;p&gt;Scrolling further down that page and it breaks down why pages aren&apos;t being indexed:&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://david.gardiner.net.au/_astro/search-console-why-not-indexed.Dprfn7nc_ZAO59c.webp&quot; alt=&quot;Screenshot from Google Search Console with &apos;Why pages aren’t indexed&apos; table&quot; /&gt;&lt;/p&gt;
&lt;p&gt;Pretty much all of my actual blog posts are under the &apos;Crawled - currently not indexed&apos; category.&lt;/p&gt;
&lt;p&gt;So for some reason (and they don&apos;t really say why) Google knows about the pages, but has not included them in the index, so that&apos;s why my search didn&apos;t show up my blog post.&lt;/p&gt;
&lt;p&gt;So what changed in April?&lt;/p&gt;
&lt;p&gt;I looked back at the Git history of my blog repo. There were some changes I merged late March. The only thing that was slightly interesting was I did upgrade from Astro 5 to 6 during that time.&lt;/p&gt;
&lt;p&gt;Looking at the source of the homepage, I noticed something curious:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;&amp;lt;!DOCTYPE html&amp;gt;
&amp;lt;html lang=&quot;en&quot;&amp;gt;
  &amp;lt;head&amp;gt;
    &amp;lt;meta charset=&quot;utf-8&quot;&amp;gt;
    &amp;lt;link rel=&quot;icon&quot; href=&quot;/favicon.ico&quot;&amp;gt;
    &amp;lt;meta name=&quot;viewport&quot; content=&quot;width=device-width, initial-scale=1&quot;&amp;gt;
    &amp;lt;meta name=&quot;generator&quot; content=&quot;Astro v6.3.1&quot;&amp;gt;
    &amp;lt;link rel=&quot;canonical&quot; href=&quot;https://david.gardiner.net.au/index&quot;&amp;gt;
    &amp;lt;link rel=&quot;sitemap&quot; href=&quot;/sitemap-index.xml&quot;&amp;gt;
    &amp;lt;title&amp;gt;David Gardiner&amp;lt;/title&amp;gt; 
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;That &apos;canonical&apos; line looks wrong! The &lt;code&gt;href&lt;/code&gt; should be set to &lt;code&gt;https://david.gardiner.net.au/&lt;/code&gt;, but it has a &lt;code&gt;/index&lt;/code&gt; tacked on the end. I checked the canonical values for other pages, and they were all fine. So just the home page is wrong.&lt;/p&gt;
&lt;p&gt;Out of interest, I switched back to the revision of the repo before that change and rebuilt the website at that point in time. Sure enough it was correct back then:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;&amp;lt;!DOCTYPE html&amp;gt;
&amp;lt;html lang=&quot;en&quot;&amp;gt;
  &amp;lt;head&amp;gt;
    &amp;lt;meta charset=&quot;utf-8&quot;&amp;gt;
    &amp;lt;link rel=&quot;icon&quot; href=&quot;/favicon.ico&quot;&amp;gt;
    &amp;lt;meta name=&quot;viewport&quot; content=&quot;width=device-width, initial-scale=1&quot;&amp;gt;
    &amp;lt;meta name=&quot;generator&quot; content=&quot;Astro v5.18.1&quot;&amp;gt;
    &amp;lt;link rel=&quot;canonical&quot; href=&quot;https://david.gardiner.net.au/&quot;&amp;gt;
    &amp;lt;link rel=&quot;sitemap&quot; href=&quot;/sitemap-index.xml&quot;&amp;gt;
    &amp;lt;title&amp;gt;David Gardiner&amp;lt;/title&amp;gt;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The code that created the canonical URL looked like this:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;    &amp;lt;link
      rel=&quot;canonical&quot;
      href={new URL(Astro.url.pathname.replace(&quot;.html&quot;, &quot;&quot;), Astro.site)}
    /&amp;gt;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;So it appears the functionality has changed in Astro 6 slightly. I created a new function that returns the correct value, including for the home page:&lt;/p&gt;

&lt;pre&gt;&lt;code&gt;export function getCanonicalUrl(astroUrl: URL, astroSite: URL | undefined): string | URL | null | undefined {
  const pathname = astroUrl.pathname.replace(&quot;.html&quot;, &quot;&quot;);
  if (pathname === &quot;/index&quot;) {
    return new URL(&quot;/&quot;, astroSite);
  }
  return new URL(pathname, astroSite);
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;My blog site is rendered statically, and as a way of ensuring that no surprising changes to the generated HTML happen from component updates, I previously created a couple of snapshot tests using Verify CLI. In particular these check the contents of a specific blog post and also the RSS feed.&lt;/p&gt;
&lt;p&gt;But I realised I didn&apos;t have one in place for the home page. While that wouldn&apos;t work well for my main blog repository (which is private), as the home page content would change every time I published a new blog post, that isn&apos;t true for the public repo where I maintain the blog engine logic. There I have just a few old blog posts that i use for testing.&lt;/p&gt;
&lt;p&gt;I&apos;ve &lt;a href=&quot;https://github.com/flcdrg/astro-blog-engine/blob/main/.github/workflows/main.yml#L136-L139&quot;&gt;added that into the pipeline now&lt;/a&gt;, so that in the future if there are any unexpected changes from upgrades, the snapshot test will allow me to review them and decide if they are acceptable or not.&lt;/p&gt;
&lt;p&gt;Now whether this is the actual cause of Google taking a dislike to me, I have no idea. But it&apos;s the most obvious thing I can see so far. Time will tell if I get back in Google&apos;s good books or not.&lt;/p&gt;
&lt;p&gt;On the plus side, I did check with Bing&apos;s &lt;a href=&quot;https://www.bing.com/webmasters&quot;&gt;Webmaster tools&lt;/a&gt;, and that all looks fine (and Bing is returning results for my site), so at least it isn&apos;t everyone who doesn&apos;t like me.&lt;/p&gt;
</content>
  </entry>
  <entry>
    <id>https://david.gardiner.net.au/2026/04/vs-debugging-fatal-error</id>
    <updated>2026-04-10T12:00:00.000+09:30</updated>
    <title>Fatal error 0x8013132d debugging .NET 10 applications in Visual Studio</title>
    <link href="https://david.gardiner.net.au/2026/04/vs-debugging-fatal-error" rel="alternate" type="text/html" title="Fatal error 0x8013132d debugging .NET 10 applications in Visual Studio"/>
    <category term=".NET"/>
    <category term="Aspire"/>
    <category term="Visual Studio"/>
    <published>2026-04-10T12:00:00.000+09:30</published>
    <summary type="html">A workaround for a fatal error while trying to debug a .NET 10 application in Visual Studio</summary>
    <content type="html">&lt;p&gt;Trying to debug an &lt;a href=&quot;https://aspire.dev&quot;&gt;Aspire&lt;/a&gt; .NET 10 application in Visual Studio today and hitting this error:&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://david.gardiner.net.au/_astro/visual-studio-fatal-error.B1dWqQi6_Zzh0OH.webp&quot; alt=&quot;Visual Studio error dialog&quot; /&gt;&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;A fatal error has occurred and debugging needs to be terminated. For more details, please see the Microsoft Help and Support web site. HRESULT=0x8013132d. ErrorCode=0x0.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;A bit of searching online turned up &lt;a href=&quot;https://developercommunity.microsoft.com/t/11022347&quot;&gt;this bug report&lt;/a&gt; on the Microsoft Developer Community site.&lt;/p&gt;
&lt;p&gt;That was eventually forwarded to the &lt;a href=&quot;https://github.com/dotnet/runtime/issues/124913&quot;&gt;.NET runtime&lt;/a&gt; repo on GitHub where it was identified as relating to a &lt;a href=&quot;https://learn.microsoft.com/dotnet/core/compatibility/interop/9.0/cet-support?WT.mc_id=DOP-MVP-5001655&quot;&gt;breaking change introduced in .NET 9&lt;/a&gt;&lt;/p&gt;
&lt;p&gt;This change improves the security of .NET applications, but in this case is stopping me from being able to debug.&lt;/p&gt;
&lt;p&gt;You can opt out of this new behaviour by adding the following property to your csproj:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;&amp;lt;CETCompat&amp;gt;false&amp;lt;/CETCompat&amp;gt;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Given in my case the problem only happens when I&apos;m debugging, then it would be preferable to leave this new feature on by default, so I&apos;ve added a condition to the property like this:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;&amp;lt;CETCompat Condition=&quot;&apos;$(Configuration)&apos; == &apos;Debug&apos;&quot;&amp;gt;false&amp;lt;/CETCompat&amp;gt;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;In my Aspire AppHost csproj file, the &lt;code&gt;PropertyGroup&lt;/code&gt; element looks like this:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;  &amp;lt;PropertyGroup&amp;gt;
    &amp;lt;OutputType&amp;gt;Exe&amp;lt;/OutputType&amp;gt;
    &amp;lt;TargetFramework&amp;gt;net10.0&amp;lt;/TargetFramework&amp;gt;
    &amp;lt;ImplicitUsings&amp;gt;enable&amp;lt;/ImplicitUsings&amp;gt;
    &amp;lt;Nullable&amp;gt;enable&amp;lt;/Nullable&amp;gt;
    &amp;lt;UserSecretsId&amp;gt;00000000-0000-0000-0000-000000000000&amp;lt;/UserSecretsId&amp;gt;
    &amp;lt;CETCompat Condition=&quot;&apos;$(Configuration)&apos; == &apos;Debug&apos;&quot;&amp;gt;false&amp;lt;/CETCompat&amp;gt;
  &amp;lt;/PropertyGroup&amp;gt;
&lt;/code&gt;&lt;/pre&gt;
</content>
    <media:thumbnail url="https://david.gardiner.net.au/_astro/visual-studio-2026.VSY9MLYp.png" width="256" height="256"/>
    <media:content medium="image" url="https://david.gardiner.net.au/_astro/visual-studio-2026.VSY9MLYp.png" width="256" height="256"/>
  </entry>
  <entry>
    <id>https://david.gardiner.net.au/2026/04/exceptional-unit-tests</id>
    <updated>2026-04-01T09:30:00.000+10:30</updated>
    <title>Exceptional unit tests</title>
    <link href="https://david.gardiner.net.au/2026/04/exceptional-unit-tests" rel="alternate" type="text/html" title="Exceptional unit tests"/>
    <category term=".NET"/>
    <category term="Testing"/>
    <published>2026-04-01T09:30:00.000+10:30</published>
    <summary type="html">Unexpected exceptions being thrown and caught inside application
code, that weren&apos;t obvious until the unit test was run under a debugger.</summary>
    <content type="html">&lt;p&gt;I was working on a .NET application that had a nice suite of unit tests, and pleasingly the tests were all passing.&lt;/p&gt;
&lt;p&gt;While making a change to the code, one of the tests failed (which is the whole point of having tests!). To better understand the reason for the failure I re-ran the test in the Visual Studio debugger.&lt;/p&gt;
&lt;p&gt;I noticed something strange - the system under test was throwing a &lt;code&gt;NullReferenceException&lt;/code&gt;, which was then being caught (and effectively swallowed) by an outer &lt;code&gt;try&lt;/code&gt;/&lt;code&gt;catch&lt;/code&gt; block.&lt;/p&gt;
&lt;p&gt;The surprising thing was that this test should not have been doing that - a pretty straightforward test of some business logic. It was just a coincidence that in this case the exception wasn&apos;t changing the observable behaviour of the code, which is why the test had previously been passing.&lt;/p&gt;
&lt;p&gt;In this case, the underlying cause of the &lt;code&gt;NullReferenceException&lt;/code&gt; turned out to be a missing mocked method on a dependency.&lt;/p&gt;
&lt;p&gt;It did make me wonder though, are there other similar issues hidden elsewhere in the unit tests?&lt;/p&gt;
&lt;p&gt;To find out, I opened up Visual Studio&apos;s &lt;a href=&quot;https://learn.microsoft.com/visualstudio/debugger/managing-exceptions-with-the-debugger?view=visualstudio&amp;amp;WT.mc_id=DOP-MVP-5001655&quot;&gt;Exception Settings window&lt;/a&gt; (&lt;strong&gt;Debug&lt;/strong&gt; | &lt;strong&gt;Windows&lt;/strong&gt; | &lt;strong&gt;Exception Settings&lt;/strong&gt;), searched for &apos;NullReferenceException&apos; and ensured that it was set to &apos;Break when thrown&apos;&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://david.gardiner.net.au/_astro/exception-settings.BskstRPv_Z1zU8Gz.webp&quot; alt=&quot;Screenshot of Exception Settings window in Visual Studio&quot; /&gt;&lt;/p&gt;
&lt;p&gt;I then ran the entire test suite under the debugger (&lt;strong&gt;Test&lt;/strong&gt; | &lt;strong&gt;Debug All Tests&lt;/strong&gt;) and took note of each time the debugger stopped with a thrown exception. Some of these were other exception types that were expected (and I could turn off the &apos;Break when thrown&apos; on those if they were too noisy).&lt;/p&gt;
&lt;p&gt;I ended up finding a few other tests that had similar issues. There were also some paths in the application code where null handling could be made more robust.&lt;/p&gt;
&lt;p&gt;The tests still pass, but they should now be a bit more reliable in the future for the next developer who is relying on them when making application code changes (which could be me!)&lt;/p&gt;
</content>
    <media:thumbnail url="https://david.gardiner.net.au/_astro/exception-settings.BskstRPv.png" width="942" height="498"/>
    <media:content medium="image" url="https://david.gardiner.net.au/_astro/exception-settings.BskstRPv.png" width="942" height="498"/>
  </entry>
  <entry>
    <id>https://david.gardiner.net.au/2026/03/delete-github-action-artifacts</id>
    <updated>2026-03-21T16:00:00.000+10:30</updated>
    <title>Delete old GitHub Actions artifacts with PowerShell</title>
    <link href="https://david.gardiner.net.au/2026/03/delete-github-action-artifacts" rel="alternate" type="text/html" title="Delete old GitHub Actions artifacts with PowerShell"/>
    <category term="GitHub"/>
    <category term="PowerShell"/>
    <published>2026-03-21T16:00:00.000+10:30</published>
    <summary type="html">My GitHub Actions artifact usage was nearing the maximum quota for the month, so I needed a script to delete old artifacts</summary>
    <content type="html">&lt;p&gt;I received an email from GitHub overnight saying:&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;You have used 90% of the Actions storage included for the flcdrg account&lt;/p&gt;
&lt;p&gt;Your plan includes 2 GB of Actions storage per month at no extra cost. You have used 90% so far this billing cycle. 1.8 GB used / 2 GB included&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;Oh dear, that&apos;s not good. Time for some Spring (or Autumn as it is in Australia) cleaning!&lt;/p&gt;
&lt;p&gt;I found a useful post by &lt;a href=&quot;https://www.eliostruyf.com&quot;&gt;Elio Struyf&lt;/a&gt; - &lt;a href=&quot;https://www.eliostruyf.com/clean-github-actions-artifacts-script/&quot;&gt;Clean up old GitHub Actions artifacts with a script&lt;/a&gt;, which contains a Bash script to delete old artifacts. PowerShell is my preferred scripting language so I first asked Copilot to convert the Bash script to PowerShell.&lt;/p&gt;
&lt;p&gt;I then ran it on some repositories that I new had lots of artifacts, but noticed that the paging was not working quite right, and that it was skipping artifacts if it couldn&apos;t parse the date field.&lt;/p&gt;
&lt;p&gt;I made two changes:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;Read all the data in one go using the &lt;code&gt;--paginate --slurp&lt;/code&gt; parameters. This solves the problem that I think was happening when you read a page of results, then deleted them, and then asked the API for the next page, but the counts would now be out due to the deleted items.&lt;/li&gt;
&lt;li&gt;Ensure the date string is parsed using US date format (as it was defaulting to Australian format and then getting confused with dates that didn&apos;t make sense)&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;Here&apos;s the final script:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;param(
    [Parameter(Position = 0)]
    [string]$Repo,

    [Parameter(Position = 1)]
    [int]$DaysOld = 5
)

if (-not (Get-Command gh -ErrorAction SilentlyContinue)) {
    Write-Error &quot;GitHub CLI (gh) is not installed or not available in PATH.&quot;
    exit 1
}

$null = gh auth status *&amp;gt; $null
if ($LASTEXITCODE -ne 0) {
    Write-Host &quot;Please authenticate with the GitHub CLI using &apos;gh auth login&apos;.&quot;
    exit 1
}

if ([string]::IsNullOrWhiteSpace($Repo)) {
    Write-Host &quot;Usage: .\delete-github-action-artifacts.ps1 &amp;lt;owner/repo&amp;gt; [days-old]&quot;
    Write-Host &quot;Example: .\delete-github-action-artifacts.ps1 owner/repo 5&quot;
    exit 1
}

Write-Host &quot;Cleaning up artifacts older than $DaysOld days for repository: $Repo&quot;

$pagesResponse = gh api --paginate --slurp -H &quot;Accept: application/vnd.github+json&quot; &quot;/repos/$Repo/actions/artifacts?per_page=100&quot; | ConvertFrom-Json
$allArtifacts = @()

foreach ($pageResponse in @($pagesResponse)) {
    if ($null -ne $pageResponse.artifacts) {
        $allArtifacts += @($pageResponse.artifacts)
    }
}

if (-not $allArtifacts -or $allArtifacts.Count -eq 0) {
    Write-Host &quot;No artifacts found.&quot;
}
else {
    foreach ($artifact in $allArtifacts) {
        $id = $artifact.id
        $name = $artifact.name
        $createdAt = $artifact.created_at

        if ($null -eq $id -or [string]::IsNullOrWhiteSpace($name) -or [string]::IsNullOrWhiteSpace($createdAt)) {
            $artifactJson = $artifact | ConvertTo-Json -Compress
            Write-Host &quot;Skipping invalid artifact data: $artifactJson&quot;
            continue
        }

        try {
            $createdAtUtc = [DateTimeOffset]::Parse($createdAt, [System.Globalization.CultureInfo]::InvariantCulture).UtcDateTime

            $ageDays = [int][Math]::Floor(([DateTime]::UtcNow - $createdAtUtc).TotalDays)

            if ($ageDays -gt $DaysOld) {
                Write-Host &quot;Deleting artifact: $name (ID: $id, Age: $ageDays days)&quot;
                $null = gh api -X DELETE &quot;/repos/$Repo/actions/artifacts/$id&quot; 2&amp;gt;$null

                if ($LASTEXITCODE -ne 0) {
                    Write-Host &quot;Failed to delete artifact: $name (ID: $id)&quot;
                }
            }
            else {
                Write-Host &quot;Keeping artifact: $name (ID: $id, Age: $ageDays days, Created At: $createdAt)&quot;
            }

        }
        catch {
            Write-Host &quot;Deleting artifact: $name (ID: $id, Created At: $createdAt)&quot;
            $null = gh api -X DELETE &quot;/repos/$Repo/actions/artifacts/$id&quot; 2&amp;gt;$null

            if ($LASTEXITCODE -ne 0) {
                Write-Host &quot;Failed to delete artifact: $name (ID: $id)&quot;
            }

        }

    }
}

Write-Host &quot;Cleanup completed.&quot;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;I&apos;ve also published it as a GitHub Gist at &lt;a href=&quot;https://gist.github.com/flcdrg/f204fc3f84247fe6247d654c0a673b73&quot;&gt;https://gist.github.com/flcdrg/f204fc3f84247fe6247d654c0a673b73&lt;/a&gt;&lt;/p&gt;
&lt;h2&gt;Prevention&lt;/h2&gt;
&lt;p&gt;The main cause of accumulating artifacts is by using the &lt;a href=&quot;https://github.com/marketplace/actions/upload-a-build-artifact&quot;&gt;&lt;code&gt;actions/upload-artifact&lt;/code&gt;&lt;/a&gt; action in GitHub Actions workflows. I&apos;ve now updated those actions to include the &lt;a href=&quot;https://github.com/marketplace/actions/upload-a-build-artifact#retention-period&quot;&gt;&lt;code&gt;retention-days&lt;/code&gt; property&lt;/a&gt; so that artifacts are automatically deleted after a few days.&lt;/p&gt;
&lt;p&gt;eg.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;      - name: Upload wrangler.jsonc
        uses: actions/upload-artifact@v7
        with:
          name: wrangler.jsonc
          path: wrangler.jsonc
          retention-days: 2
&lt;/code&gt;&lt;/pre&gt;
</content>
  </entry>
  <entry>
    <id>https://david.gardiner.net.au/2026/03/csharp-extension-members</id>
    <updated>2026-03-17T08:00:00.000+10:30</updated>
    <title>Best of C# 14 - extension members</title>
    <link href="https://david.gardiner.net.au/2026/03/csharp-extension-members" rel="alternate" type="text/html" title="Best of C# 14 - extension members"/>
    <category term=".NET"/>
    <published>2026-03-17T08:00:00.000+10:30</published>
    <summary type="html">We&apos;ve had extension methods in C# for a long time, but what are Extension Members?</summary>
    <content type="html">&lt;p&gt;Extension methods were introduced way back in 2007 with &lt;a href=&quot;https://learn.microsoft.com/dotnet/csharp/whats-new/csharp-version-history?WT.mc_id=DOP-MVP-5001655#c-version-30&quot;&gt;C# 3&lt;/a&gt;. But they only allowed you to add methods to an instance type. Extension Members, a new feature of C# 14, finally &apos;extend&apos; this concept to properties and static members too.&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://david.gardiner.net.au/_astro/logo_csharp.CqWbN3Rf_Z2mqclD.webp&quot; alt=&quot;C# logo&quot; /&gt;&lt;/p&gt;
&lt;p&gt;The classic extension method definition looks like this:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;public static class MyExtensions
{
    public static int WordCount(this string str) =&amp;gt;
        str.Split([&apos; &apos;, &apos;.&apos;, &apos;?&apos;], StringSplitOptions.RemoveEmptyEntries).Length;
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The class needed to be static, and the static method&apos;s first parameter needs the &lt;code&gt;this&lt;/code&gt; modifier.&lt;/p&gt;
&lt;p&gt;That style of extension method hasn&apos;t gone away, and you&apos;re free to keep doing it that way if you like.&lt;/p&gt;
&lt;p&gt;But now we can &lt;a href=&quot;https://learn.microsoft.com/dotnet/csharp/whats-new/csharp-14?WT.mc_id=DOP-MVP-5001655#extension-members&quot;&gt;create extension properties or extension methods on types&lt;/a&gt;. To do that there&apos;s a new  &lt;code&gt;extension&lt;/code&gt; keyword that is used to create extension blocks. You can use these for extension properties, and extension methods, and also for both of these for types in addition to instances.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;public static class Enumerable
{
    // Extension block
    extension(string str) // extension members for string instances
    {
        // Extension method:
        public int WordCount() =&amp;gt;
            str.Split([&apos; &apos;, &apos;.&apos;, &apos;?&apos;], StringSplitOptions.RemoveEmptyEntries).Length;

        // Extension property:
        public bool Is80CharsLong =&amp;gt;
            str.Length == 80;
    }    

    extension(string)
    {
        // extension method
        public static string ToTitleCase(string str) =&amp;gt;
            System.Globalization.CultureInfo.CurrentCulture.TextInfo.ToTitleCase(str);

        // extension property
        public static string TwoSpaces =&amp;gt; &quot;  &quot;;
    }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;You use these extension members like this:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;var sentence = &quot;This is a sentence of words.&quot;;

int wordCount = sentence.WordCount();

var is80CharsLong = sentence.Is80CharsLong;

var title = string.ToTitleCase(&quot;hello world&quot;);

var indent = string.TwoSpaces;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;It&apos;s another useful language tool. Used appropriately it could make your codebase easier to understand and allow you to separate concerns.&lt;/p&gt;
&lt;h2&gt;Possible reasons for not using an &lt;code&gt;extension&lt;/code&gt; block&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;I&apos;ve read that some library authors encountered issues when they migrated their old extension methods to extension blocks and the libraries were targeting older frameworks besides .NET 10. If you need to target older frameworks then test this thoroughly first.&lt;/li&gt;
&lt;li&gt;If all you&apos;re doing is creating extension methods (no properties or type methods), then it&apos;s fine to stick with the old syntax.&lt;/li&gt;
&lt;/ul&gt;
</content>
    <media:thumbnail url="https://david.gardiner.net.au/_astro/logo_csharp.CqWbN3Rf.png" width="72" height="72"/>
    <media:content medium="image" url="https://david.gardiner.net.au/_astro/logo_csharp.CqWbN3Rf.png" width="72" height="72"/>
  </entry>
  <entry>
    <id>https://david.gardiner.net.au/2026/03/mystery-files-app</id>
    <updated>2026-03-16T11:00:00.000+10:30</updated>
    <title>Where did this new Files app come from?</title>
    <link href="https://david.gardiner.net.au/2026/03/mystery-files-app" rel="alternate" type="text/html" title="Where did this new Files app come from?"/>
    <category term="Windows 11"/>
    <published>2026-03-16T11:00:00.000+10:30</published>
    <summary type="html">What is this new &apos;Files&apos; app that suddenly appeared on my Windows taskbar, and how
do you make it go away?</summary>
    <content type="html">&lt;p&gt;Recently when starting Windows 11 I noticed that a new icon had appeared on the taskbar. It wasn&apos;t too long after I&apos;d upgraded to 25H2 so my first assumption was it was part of that. The annoying thing was there wasn&apos;t an obvious way to make it go away.&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://david.gardiner.net.au/_astro/microsoft-365-companion-files-app.B5t6HI9k_Z1CqVRG.webp&quot; alt=&quot;Screenshot of Windows 11 taskbar with Files application running&quot; /&gt;&lt;/p&gt;
&lt;p&gt;Right-clicking on the icon just gave an option to close the application, but it wasn&apos;t permanent. The next time I signed into Windows then there it was again. Kind of annoying.&lt;/p&gt;
&lt;p&gt;On my computer, I searched in &lt;strong&gt;Windows Settings&lt;/strong&gt; | &lt;strong&gt;Apps&lt;/strong&gt;, but no &apos;Files&apos; application was listed - very odd.&lt;/p&gt;
&lt;p&gt;Searching online for &quot;Windows 11 Files app&quot; brings up heaps of results, but none matched the icon that I was seeing. So I decided to to a bit of detective work to see if I could figure out what this thing was and where it came from. I looked in Task Manager, and sure enough there&apos;s a &lt;code&gt;Files.exe&lt;/code&gt; process running:&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://david.gardiner.net.au/_astro/microsoft-365-companion-task-manager.SCkv-gVC_ZAdpUx.webp&quot; alt=&quot;Screenshot of Windows Task Manager showing &apos;Files.exe&apos; process running&quot; /&gt;&lt;/p&gt;
&lt;p&gt;Ah interesting - there&apos;s &quot;M365Companions&quot; in the path. Maybe adding that to my online search keywords might help? Yes, that made all the difference!&lt;/p&gt;
&lt;p&gt;I turned up this &lt;a href=&quot;https://learn.microsoft.com/en-us/microsoft-365-apps/companions/overview?WT.mc_id=DOP-MVP-5001655&quot;&gt;Overview of Microsoft 365 companion apps documentation&lt;/a&gt;. That&apos;s more of an IT admin page, but it links to a more end-user friendly &lt;a href=&quot;https://support.microsoft.com/en-au/office/get-started-with-microsoft-365-companions-a27df74a-cc41-4e74-8216-51091dc30194&quot;&gt;Getting started with Microsoft 365 companions page&lt;/a&gt;.&lt;/p&gt;
&lt;p&gt;That second page does say &quot;You can unpin or remove the apps from your taskbar at any time.&quot; which is obviously not correct. Not sure if that&apos;a bug in the software or the documentation.&lt;/p&gt;
&lt;p&gt;So it turns out there&apos;s 3 of these &apos;companion&apos; apps:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;People companion&lt;/strong&gt;: Find people in the organization (and others they have communicated with), pin close collaborators to accelerate workflows, and quickly learn about people in current meetings without disrupting workflow.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Files companion&lt;/strong&gt;: Find your Microsoft 365 files, preview file contents, share files with colleagues, and access recently used documents.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Calendar companion&lt;/strong&gt;: Access Microsoft 365 calendar, view upcoming events, join meetings, and search appointments directly from the taskbar.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;I&apos;m sure someone will find them useful, but for now I just wanted them to go away.&lt;/p&gt;
&lt;p&gt;Armed with that extra information I went back to &lt;strong&gt;Windows Settings&lt;/strong&gt; | &lt;strong&gt;Apps&lt;/strong&gt; and this time searched for &apos;companion&apos;. Sure enough there is a &quot;Microsoft 365 companion apps&quot; app listed.&lt;/p&gt;
&lt;p&gt;Clicking on the &quot;...&quot; menu gives an option to &lt;strong&gt;Uninstall&lt;/strong&gt;, or if you click on &lt;strong&gt;Advanced options&lt;/strong&gt; you see something similar to this:&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://david.gardiner.net.au/_astro/microsoft-365-companion-advanced.DQfOLick_1aDgez.webp&quot; alt=&quot;Screenshot of Advanced options page for &apos;Microsoft 365 companion apps&apos; app showing &apos;Files&apos; enabled for starting when user logs in&quot; /&gt;&lt;/p&gt;
&lt;p&gt;For now I toggled &lt;strong&gt;Files&lt;/strong&gt; off, so it won&apos;t start automatically the next time I sign in.&lt;/p&gt;
&lt;p&gt;Maybe in the future I might want to make use of these apps, but for now that will do.&lt;/p&gt;
</content>
    <media:thumbnail url="https://david.gardiner.net.au/_astro/microsoft-365-companion-files-app.B5t6HI9k.png" width="215" height="350"/>
    <media:content medium="image" url="https://david.gardiner.net.au/_astro/microsoft-365-companion-files-app.B5t6HI9k.png" width="215" height="350"/>
  </entry>
  <entry>
    <id>https://david.gardiner.net.au/2026/03/csharp-field-keyword</id>
    <updated>2026-03-10T22:30:00.000+10:30</updated>
    <title>Best of C# 14 - field keyword</title>
    <link href="https://david.gardiner.net.au/2026/03/csharp-field-keyword" rel="alternate" type="text/html" title="Best of C# 14 - field keyword"/>
    <category term=".NET"/>
    <published>2026-03-10T22:30:00.000+10:30</published>
    <summary type="html">The new &apos;field&apos; keyword is in C# 14. When can you use it and when might you not.</summary>
    <content type="html">&lt;p&gt;&lt;a href=&quot;https://learn.microsoft.com/dotnet/csharp/whats-new/csharp-14?WT.mc_id=DOP-MVP-5001655&quot;&gt;C# 14&lt;/a&gt; shipped back in November last year along with .NET 10. I&apos;ve already given a presentation a number of times on my highlights of .NET 10, which included three features of C# 14 that I think are particularly useful.&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://david.gardiner.net.au/_astro/logo_csharp.CqWbN3Rf_Z2mqclD.webp&quot; alt=&quot;C# logo&quot; /&gt;&lt;/p&gt;
&lt;p&gt;The first I&apos;m going to focus on is the new &lt;code&gt;field&lt;/code&gt; keyword.&lt;/p&gt;
&lt;p&gt;The problem this solves is where you&apos;d really like to avoid having to declare a backing field, but prior to C# 14 as soon as you wanted to include any kind of logic in a getter or setter, then you couldn&apos;t use &lt;a href=&quot;https://learn.microsoft.com/dotnet/csharp/programming-guide/classes-and-structs/auto-implemented-properties?WT.mc_id=DOP-MVP-5001655&quot;&gt;auto properties&lt;/a&gt;, but instead you needed to provide a backing field and implement the getter and setter. eg.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;private string _msg;
public string Message
{
    get =&amp;gt; _msg;
    set =&amp;gt; _msg = value ?? throw new ArgumentNullException(nameof(value));
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Because we&apos;re adding validation to the setter, that forced us to introduce the &lt;code&gt;_msg&lt;/code&gt; field. And if you can be disciplined that&apos;s fine, but the trap is there&apos;s nothing to stop other code in the same class from also referencing that, even if it shouldn&apos;t. The compiler won&apos;t stop you.&lt;/p&gt;
&lt;p&gt;To help with scenarios like this, and to reduce the amount of code you need to write, C# 14 introduces the &lt;code&gt;field&lt;/code&gt; keyword. It&apos;s a way to reference the compiler-generated backing field, but only within the property&apos;s getter and setter. Our code now becomes:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;public string Message
{
    get;
    set =&amp;gt; field = value ?? throw new ArgumentNullException(nameof(value));
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Our line count is one less, plus we&apos;re now not exposing the backing field outside of the property.&lt;/p&gt;
&lt;h2&gt;Under the hood&lt;/h2&gt;
&lt;p&gt;When the C# compiler turns the original code into IL (Intermediate Language), it creates something like this:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;.field private string _msg
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;That&apos;s how IL represents a regular field.&lt;/p&gt;
&lt;p&gt;And if you search the IL generated by code that uses the &lt;code&gt;field&lt;/code&gt; keyword, you&apos;ll still see a &lt;code&gt;.field&lt;/code&gt; entry, but the name is a bit different:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;.field private string &apos;&amp;lt;Message2&amp;gt;k__BackingField&apos;
    .custom instance void [System.Runtime]System.Runtime.CompilerServices.CompilerGeneratedAttribute::.ctor()
      = (01 00 00 00 )
    .custom instance void [System.Runtime]System.Diagnostics.DebuggerBrowsableAttribute::.ctor(valuetype [System.Runtime]System.Diagnostics.DebuggerBrowsableState)
      = (01 00 00 00 00 00 00 00 )
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;&amp;lt;Message2&amp;gt;k__BackingField&lt;/code&gt; is not a valid C# identifier, so there&apos;s no chance of being able to reference this field by name elsewhere in your C# code. That &lt;code&gt;DebuggerBrowsableAttribute&lt;/code&gt; also means that your debugger will choose to not show the field.&lt;/p&gt;
&lt;p&gt;But because the compiler knows the name it has assigned to the field, it generates the correct IL for you when you use the &lt;code&gt;field&lt;/code&gt; keyword.&lt;/p&gt;
&lt;p&gt;Here&apos;s the IL generated for the property setter:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;IL_0000: ldarg.0      // this
IL_0001: ldarg.1      // &apos;value&apos;
IL_0002: dup

IL_0003: brtrue.s     IL_0011
IL_0005: pop
IL_0006: ldstr        &quot;value&quot;
IL_000b: newobj       instance void [System.Runtime]System.ArgumentNullException::.ctor(string)
IL_0010: throw
IL_0011: stfld        string Stuff::&apos;&amp;lt;Message&amp;gt;k__BackingField&apos;
IL_0016: ret
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The &lt;a href=&quot;https://learn.microsoft.com/dotnet/api/system.reflection.emit.opcodes.stfld?view=net-10.0&amp;amp;WT.mc_id=DOP-MVP-5001655&quot;&gt;&lt;code&gt;stfld&lt;/code&gt;&lt;/a&gt; instruction is saving the current value from the stack into the the compiler-generated backing field.&lt;/p&gt;
&lt;h2&gt;Reasons for not using &lt;code&gt;field&lt;/code&gt;&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;If you have legitimate reasons for accessing the backing field elsewhere in the class (such as multiple properties that reference or share the same backing field).&lt;/li&gt;
&lt;li&gt;Thread safety using &lt;code&gt;volatile&lt;/code&gt;, &lt;code&gt;Interlocked&lt;/code&gt; or &lt;code&gt;lock&lt;/code&gt; patterns.&lt;/li&gt;
&lt;li&gt;Lazy initialisation/caching patterns&lt;/li&gt;
&lt;li&gt;Serialization or reflection that makes assumptions about field names&lt;/li&gt;
&lt;/ul&gt;
</content>
    <media:thumbnail url="https://david.gardiner.net.au/_astro/logo_csharp.CqWbN3Rf.png" width="72" height="72"/>
    <media:content medium="image" url="https://david.gardiner.net.au/_astro/logo_csharp.CqWbN3Rf.png" width="72" height="72"/>
  </entry>
  <entry>
    <id>https://david.gardiner.net.au/2026/02/azure-postgresql-upgrade</id>
    <updated>2026-02-28T13:00:00.000+10:30</updated>
    <title>Upgrading Azure Database for PostgreSQL flexible server</title>
    <link href="https://david.gardiner.net.au/2026/02/azure-postgresql-upgrade" rel="alternate" type="text/html" title="Upgrading Azure Database for PostgreSQL flexible server"/>
    <category term="Azure"/>
    <category term="Azure Pipelines"/>
    <category term="Terraform"/>
    <published>2026-02-28T13:00:00.000+10:30</published>
    <summary type="html">How to upgrade the PostgreSQL server in Azure, with examples using Terraform, and some workarounds
for known issues you may encounter during the upgrade process.</summary>
    <content type="html">&lt;p&gt;I was working on a project recently that made use of &lt;a href=&quot;https://learn.microsoft.com/azure/postgresql/overview?WT.mc_id=DOP-MVP-5001655&quot;&gt;Azure Database for PostgreSQL flexible server&lt;/a&gt;. The system had been set up a while ago, and so when I was reviewing the resources in the Azure Portal, I noticed a warning banner for the PostreSQL server:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;Your server version will lose standard Azure support on March 31, 2026. Upgrade now to avoid extended support charges starting April 1, 2026.
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;img src=&quot;https://david.gardiner.net.au/_astro/postgresql-upgrade-old-version.BsaaRaF4_2tq5ef.webp&quot; alt=&quot;Screenshot of Azure Portal showing PostgreSQL server with warning about standard support ending 31st March 2026&quot; /&gt;&lt;/p&gt;
&lt;p&gt;Terraform was being used for Infrastructure as Code, and it looked similar to this:&lt;/p&gt;

&lt;pre&gt;&lt;code&gt;resource &quot;azurerm_postgresql_flexible_server&quot; &quot;server&quot; {
  name                              = &quot;psql-postgresql-apps-australiaeast&quot;
  resource_group_name               = data.azurerm_resource_group.rg.name
  location                          = data.azurerm_resource_group.rg.location
  version                           = &quot;11&quot;
  delegated_subnet_id               = azurerm_subnet.example.id
  private_dns_zone_id               = azurerm_private_dns_zone.example.id
  public_network_access_enabled     = false
  administrator_login               = &quot;psqladmin&quot;
  administrator_password_wo         = ephemeral.random_password.postgresql_password.result
  administrator_password_wo_version = 1
  zone                              = &quot;1&quot;

  storage_mb   = 32768
  storage_tier = &quot;P4&quot;

  sku_name   = &quot;B_Standard_B1ms&quot;
  depends_on = [azurerm_private_dns_zone_virtual_network_link.example]
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;As you can see from the code and screenshot above, the PostgreSQL version in use was 11. Doing a bit of research, I found version 11 was &lt;a href=&quot;https://www.postgresql.org/support/versioning/&quot;&gt;first released back in 2018&lt;/a&gt;, and the the final minor update 11.22 was released in 2023.&lt;/p&gt;
&lt;p&gt;Azure provides standard support for PostgreSQL versions (documented at &lt;a href=&quot;https://learn.microsoft.com/azure/postgresql/configure-maintain/concepts-version-policy?WT.mc_id=DOP-MVP-5001655&quot;&gt;Azure Database for PostgreSQL version policy&lt;/a&gt;). There is also the option of paying for &lt;a href=&quot;https://learn.microsoft.com/azure/postgresql/configure-maintain/extended-support?WT.mc_id=DOP-MVP-5001655&quot;&gt;extended support&lt;/a&gt;, though in the case of v11 that only gets you to November this year, so just a few extra months.&lt;/p&gt;
&lt;p&gt;In my case, I wanted to do a test of the upgrade process first, so I restored a backup of the existing server to a new resource. This essentially creates an exact copy of the server at the same version.&lt;/p&gt;
&lt;p&gt;While we are using Infrastructure as Code, I decided to use the Azure Portal to test the upgrade, as I figured if there were any problems, they might be easier to understand, rather than try and interpret weird Terraform/AzureRM errors.&lt;/p&gt;
&lt;p&gt;Following the &lt;a href=&quot;https://learn.microsoft.com/azure/postgresql/configure-maintain/how-to-perform-major-version-upgrade?WT.mc_id=DOP-MVP-5001655&quot;&gt;upgrade documentation&lt;/a&gt;, I clicked on the &lt;strong&gt;Upgrade&lt;/strong&gt; in the Portal.&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://david.gardiner.net.au/_astro/postgresql-upgrade-portal1.WLqDAiM5_ZscVNA.webp&quot; alt=&quot;Screenshot of Azure Portal upgrade screen&quot; /&gt;&lt;/p&gt;
&lt;p&gt;This initiates a deployment, which depending on how much data you have and the particular SKU you&apos;re running on (eg. how fast the VM you&apos;re using is), this may take quite a while. One time it took over an hour (which was important as that may be longer than the default Terraform lifecycle, and also the pipeline job timeouts).&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://david.gardiner.net.au/_astro/postgresql-upgrade-progress.CC7oJ8mA_Z1fvKmn.webp&quot; alt=&quot;Screenshot of Azure Portal showing PostgreSQL resource with upgrade in progress&quot; /&gt;&lt;/p&gt;
&lt;p&gt;If that succeeds, then you should be good to try the real thing with IaC.&lt;/p&gt;
&lt;h2&gt;Upgrading with Terraform&lt;/h2&gt;
&lt;p&gt;To upgrade a major version with Terraform, you need to make a couple of changes to your &lt;a href=&quot;https://registry.terraform.io/providers/hashicorp/azurerm/latest/docs/resources/postgresql_flexible_server&quot;&gt;&lt;code&gt;azurerm_postgresql_flexible_server&lt;/code&gt;&lt;/a&gt; resource:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;The &lt;code&gt;version&lt;/code&gt; property should be updated to the desired version&lt;/li&gt;
&lt;li&gt;The &lt;code&gt;create_mode&lt;/code&gt; property should be set to &lt;code&gt;Update&lt;/code&gt; (if it wasn&apos;t specified then the default is &apos;Default&apos;)&lt;/li&gt;
&lt;/ol&gt;
&lt;pre&gt;&lt;code&gt;resource &quot;azurerm_postgresql_flexible_server&quot; &quot;server&quot; {
  name                              = &quot;psql-postgresql-apps-australiaeast&quot;
  resource_group_name               = data.azurerm_resource_group.rg.name
  location                          = data.azurerm_resource_group.rg.location
  version                           = &quot;17&quot;
  delegated_subnet_id               = azurerm_subnet.example.id
  private_dns_zone_id               = azurerm_private_dns_zone.example.id
  public_network_access_enabled     = false
  administrator_login               = &quot;psqladmin&quot;
  administrator_password_wo         = ephemeral.random_password.postgresql_password.result
  administrator_password_wo_version = 1
  zone                              = &quot;1&quot;
  create_mode                       = &quot;Update&quot;

  storage_mb   = 32768
  storage_tier = &quot;P4&quot;

  sku_name   = &quot;B_Standard_B1ms&quot;
  depends_on = [azurerm_private_dns_zone_virtual_network_link.example]
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The weird thing (which I assume is a side-effect of Terraform state) is that even after you&apos;ve completed the upgrade, you can&apos;t change &lt;code&gt;create_mode&lt;/code&gt; back to &lt;code&gt;Default&lt;/code&gt; - Terraform will throw an error if you try that. Instead you just need to leave it set to &lt;code&gt;Update&lt;/code&gt;, but as long as the &lt;code&gt;version&lt;/code&gt; property doesn&apos;t change then Terraform will leave it at the same version.&lt;/p&gt;
&lt;h3&gt;Adjust your timeouts&lt;/h3&gt;
&lt;p&gt;I was using Azure Pipelines, so I added a &lt;code&gt;timeoutInMinutes&lt;/code&gt; property to the job and set it to 90 minutes. Be aware that there are &lt;a href=&quot;https://learn.microsoft.com/azure/devops/pipelines/process/phases?view=azure-devops&amp;amp;tabs=yaml&amp;amp;WT.mc_id=DOP-MVP-5001655#timeouts&quot;&gt;different default and maximum timeouts&lt;/a&gt; depending on what kind of build agent you use.&lt;/p&gt;
&lt;p&gt;Likewise the Terraform &lt;code&gt;azurerm_postgresql_flexible_server&lt;/code&gt; resource has &lt;a href=&quot;https://registry.terraform.io/providers/hashicorp/azurerm/latest/docs/resources/postgresql_flexible_server#timeouts&quot;&gt;default timeouts&lt;/a&gt;. You may want to specify a &lt;code&gt;timeout&lt;/code&gt; block to extend those values if necessary.&lt;/p&gt;
&lt;h2&gt;Gotchas&lt;/h2&gt;
&lt;p&gt;I hit some compatibility issues with the PostgreSQL instance I was attempting to upgrade. The Portal displayed the following error(s):&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;The major version upgrade failed precheck. Upgrading shared_preload_libraries library pg_failover_slots from source version 11 to target version 17 is not supported.;
Upgrading shared_preload_libraries library pg_failover_slots from source version 11 to target version 17 is not supported.;
Upgrading shared_preload_libraries library pg_failover_slots from source version 11 to target version 17 is not supported.;
Upgrading shared_preload_libraries library pg_failover_slots from source version 11 to target version 17 is not supported.;
Upgrading shared_preload_libraries library pg_failover_slots from source version 11 to target version 17 is not supported.;
Upgrading with password authentication mode enabled is not allowed from source version MajorVersion11. Please enable SCRAM and reset the passwords prior to retrying the upgrade.
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;There&apos;s two issues here:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;The &lt;code&gt;pg_failover_slots&lt;/code&gt; shared preloaded library is &lt;a href=&quot;https://learn.microsoft.com/answers/questions/5730837/attempt-to-upgrade-azure-database-for-postgresql-f&quot;&gt;not supported for upgrading&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;Legacy MD5 passwords are deprecated in newer versions, &lt;a href=&quot;https://techcommunity.microsoft.com/blog/azuredbsupport/azure-postgresql-lesson-learned-6-major-upgrade-blocked-by-password-auth-the-one/4469545&quot;&gt;and &quot;SCRAM&quot; needs to be enabled&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;How do we resolve these with Infrastructure as Code? In this case as we&apos;re using Terraform, we need to map/import those settings and then we can modify them. We make use of the &lt;a href=&quot;https://registry.terraform.io/providers/hashicorp/azurerm/latest/docs/resources/postgresql_flexible_server_configuration&quot;&gt;&lt;code&gt;azurerm_postgresql_flexible_server_configuration&lt;/code&gt;&lt;/a&gt; resource for this.&lt;/p&gt;
&lt;p&gt;The &lt;code&gt;value&lt;/code&gt; properties should initially match the existing values (eg. Make sure that Terraform thinks they are unchanged). A trick to get the existing values is to run the Terraform in &apos;plan&apos; mode and take note of what values it can see from and then copy those into your code.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;import {
  to = azurerm_postgresql_flexible_server_configuration.accepted_pasword_auth_method
  id = &quot;${azurerm_resource_group.group.id}/providers/Microsoft.DBforPostgreSQL/flexibleServers/psql-postgresql-apps-australiaeast/configurations/azure.accepted_password_auth_method&quot;
}

resource &quot;azurerm_postgresql_flexible_server_configuration&quot; &quot;accepted_pasword_auth_method&quot; {
  name      = &quot;azure.accepted_password_auth_method&quot;
  server_id = azurerm_postgresql_flexible_server.server.id
  value     = &quot;md5&quot;
}

import {
  to = azurerm_postgresql_flexible_server_configuration.password_encryption
  id = &quot;${azurerm_resource_group.group.id}/providers/Microsoft.DBforPostgreSQL/flexibleServers/psql-postgresql-apps-australiaeast/configurations/password_encryption&quot;
}

resource &quot;azurerm_postgresql_flexible_server_configuration&quot; &quot;password_encryption&quot; {
  name      = &quot;password_encryption&quot;
  server_id = azurerm_postgresql_flexible_server.server.id
  value     = &quot;md5&quot;
}

import {
  to = azurerm_postgresql_flexible_server_configuration.shared_preload_libraries
  id = &quot;${azurerm_resource_group.group.id}/providers/Microsoft.DBforPostgreSQL/flexibleServers/psql-postgresql-apps-australiaeast/configurations/shared_preload_libraries&quot;
}

resource &quot;azurerm_postgresql_flexible_server_configuration&quot; &quot;shared_preload_libraries&quot; {
  name      = &quot;shared_preload_libraries&quot;
  server_id = azurerm_postgresql_flexible_server.server.id
  value     = &quot;anon,auto_explain,pg_cron,pg_failover_slots,pg_hint_plan,pg_partman_bgw,pg_prewarm,pg_stat_statements,pgaudit,pglogical,timescaledb,wal2json&quot;
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Once you&apos;ve got those in place then you can make the changes to remove the upgrade block:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;resource &quot;azurerm_postgresql_flexible_server_configuration&quot; &quot;accepted_pasword_auth_method&quot; {
  name      = &quot;azure.accepted_password_auth_method&quot;
  server_id = azurerm_postgresql_flexible_server.server.id
  value     = &quot;md5,SCRAM-SHA-256&quot;
}

resource &quot;azurerm_postgresql_flexible_server_configuration&quot; &quot;password_encryption&quot; {
  name      = &quot;password_encryption&quot;
  server_id = azurerm_postgresql_flexible_server.server.id
  value     = &quot;SCRAM-SHA-256&quot;
}

resource &quot;azurerm_postgresql_flexible_server_configuration&quot; &quot;shared_preload_libraries&quot; {
  name      = &quot;shared_preload_libraries&quot;
  server_id = azurerm_postgresql_flexible_server.server.id
  value     = &quot;anon,auto_explain,pg_cron,pg_hint_plan,pg_partman_bgw,pg_prewarm,pg_stat_statements,pgaudit,pglogical,timescaledb,wal2json&quot;
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;This will allow any existing MD5 passwords to continue to work, but any new passwords will use the more modern SCRAM-SHA-256.&lt;/p&gt;
&lt;p&gt;For the &lt;code&gt;shared_preload_libraries&lt;/code&gt;, we&apos;ve removed the offending &lt;code&gt;pg_failover_slots&lt;/code&gt; from the list.&lt;/p&gt;
&lt;h2&gt;Tips&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;Temporarily upgrade your server SKU to beefier hardware so the upgrade goes faster. If you&apos;re using IaC then make sure you use that to make the change.&lt;/li&gt;
&lt;li&gt;Note that if you change the separate storage performance tier (IOPS), &lt;a href=&quot;https://learn.microsoft.com/azure/virtual-machines/disks-performance-tiers?tabs=azure-cli#restrictions&quot;&gt;you will need to wait 12 hours before downgrading again&lt;/a&gt;.&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;Completion&lt;/h2&gt;
&lt;p&gt;If everything goes to plan, you should end up with your PostgreSQL resource upgraded to the version that you specified. Here&apos;s my resource upgraded to 17.7. &lt;a href=&quot;https://techcommunity.microsoft.com/blog/adforpostgresql/postgresql-18-now-ga-on-azure-postgres-flexible-server/4469802?WT.mc_id=DOP-MVP-5001655&quot;&gt;v18 is actually available&lt;/a&gt; but I wasn&apos;t offered it due to &apos;regional capacity constraints&apos;, which explains why the &apos;Upgrade&apos; button is now disabled.&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://david.gardiner.net.au/_astro/postgresql-upgrade-complete.BDSml29o_Z3kvTd.webp&quot; alt=&quot;Screenshot of Azure Portal showing PostgreSQL upgrade complete&quot; /&gt;&lt;/p&gt;
&lt;p&gt;I&apos;ve published source code for a working example of Azure Database for PostgreSQL flexible server with an Azure Container app and using a VNet at &lt;a href=&quot;https://github.com/flcdrg/terraform-azure-postgresql-containerapps&quot;&gt;https://github.com/flcdrg/terraform-azure-postgresql-containerapps&lt;/a&gt;&lt;/p&gt;
</content>
    <media:thumbnail url="https://david.gardiner.net.au/_astro/postgresql-logo.BZ7GfDHR.png" width="540" height="557"/>
    <media:content medium="image" url="https://david.gardiner.net.au/_astro/postgresql-logo.BZ7GfDHR.png" width="540" height="557"/>
  </entry>
</feed>
