<?xml version="1.0" encoding="utf-8"?>
<feed xmlns="http://www.w3.org/2005/Atom">
<title>michaelheap.com</title>
<subtitle>Thoughts on leadership, code and how to fix odd edge cases in tools (not necessarily in that order)</subtitle>
<link href="https://michaelheap.com/rss" rel="self"/>
<link href="https://michaelheap.com"/>
<updated>2025-11-16T19:49:03Z</updated>
<id>https://michaelheap.com</id>
<author>
<name>Michael Heap</name>
<email>[email protected]</email>
</author>
<entry>
<title>Automated NPM secret rotation in GitHub Actions</title>
<link href="https://michaelheap.com/rotate-all-npm-tokens-github-actions/"/>
<updated>2025-11-16T19:49:03Z</updated>
<id>https://michaelheap.com/rotate-all-npm-tokens-github-actions/</id>
<content type="html"><p>NPM recently announced that all long-lived tokens are being revoked, and that going forwards any new tokens may be valid for a maximum of 90 days.</p>
<p>This presents a challenge for me, as I've built a system where when I tag a release on GitHub in my JavaScript projects, it builds and publishes to NPM using an access token. Rotating those tokens regularly would be a lot of toil.</p>
<h2 id="upgrading-to-oidc" tabindex="-1">Upgrading to OIDC</h2>
<p>The correct way to solve this is to adopt <a href="https://docs.npmjs.com/trusted-publishers#for-github-actions">trusted publishing (OIDC)</a>. However, I don't have time to update all of my projects right now. I'd like to keep using an access token until I have time to update each project.</p>
<h2 id="rotating-github-actions-user-secrets-at-scale" tabindex="-1">Rotating GitHub Actions user secrets at scale</h2>
<p>Fortunately, back in 2020 I was feeling the pain of rotating GitHub secrets for a user account as I couldn't use organization level secrets and built <a href="https://github.com/mheap/github-update-secret">github-update-secret</a>.</p>
<p><code>github-update-secret</code> iterates over all of your repositories and checks if the provided secret name is set. If so, it updates the value to the new value provided.</p>
<h3 id="how-it-works" tabindex="-1">How it works</h3>
<ul>
<li>Provide GitHub authentication by populating the <code>GITHUB_TOKEN</code> environment variable, or passing the <code>--pat</code> flag to the CLI</li>
<li>Fetch a list of all repos to which the authenticated user has admin access for the provided user/org</li>
<li>Fetch the list of secrets on each repository</li>
<li>For each repository that has a secret named the same as the provided <code>SECRET_NAME</code>, update the value of that secret</li>
<li>Check if the provided target is an organisation.</li>
<li>If so:
<ul>
<li>Check if there is an org secret named <code>SECRET_NAME</code></li>
<li>If there is, update the value</li>
</ul>
</li>
</ul>
<h3 id="example" tabindex="-1">Example</h3>
<p>I just rotated all of my <code>NPM_TOKEN</code> secrets with a secret that's valid for another 90 days using the following command:</p>
<pre class="shiki nord" style="background-color: #2e3440ff; color: #d8dee9ff"><div class="language-id">bash</div><div class="code-container"><code><div class="line"><span style="color: #D8DEE9FF">GITHUB_TOKEN=ghp_... DEBUG=github-update-secret npx github-update-secret </span><span style="color: #81A1C1">&lt;</span><span style="color: #D8DEE9FF">user/org</span><span style="color: #81A1C1">&gt;</span><span style="color: #D8DEE9FF"> </span><span style="color: #81A1C1">&lt;</span><span style="color: #D8DEE9FF">SECRET_NAME</span><span style="color: #81A1C1">&gt;</span><span style="color: #D8DEE9FF"> </span><span style="color: #81A1C1">&lt;</span><span style="color: #D8DEE9FF">new_value</span><span style="color: #81A1C1">&gt;</span></div></code></div></pre>
<p>Running the tool with <code>DEBUG=github-update-secret</code> allows you to see all of the repositories that are being updated:</p>
<pre class="shiki nord" style="background-color: #2e3440ff; color: #d8dee9ff"><div class="language-id">bash</div><div class="code-container"><code><div class="line"><span style="color: #D8DEE9FF">❯ DEBUG=github-update-secret npx github-update-secret mheap NPM_TOKEN npm_...</span></div><div class="line"><span style="color: #D8DEE9FF"> github-update-secret Fetching repo list +0ms</span></div><div class="line"><span style="color: #D8DEE9FF"> github-update-secret Processed repo list +2s</span></div><div class="line"><span style="color: #D8DEE9FF"> github-update-secret Building list of repos using </span><span style="color: #ECEFF4">[</span><span style="color: #D8DEE9FF">npm_token</span><span style="color: #ECEFF4">]</span><span style="color: #D8DEE9FF"> +0ms</span></div><div class="line"><span style="color: #D8DEE9FF"> github-update-secret Found </span><span style="color: #ECEFF4">[</span><span style="color: #D8DEE9FF">27</span><span style="color: #ECEFF4">]</span><span style="color: #D8DEE9FF"> repos with the secret </span><span style="color: #ECEFF4">[</span><span style="color: #D8DEE9FF">npm_token</span><span style="color: #ECEFF4">]</span><span style="color: #D8DEE9FF"> +2s</span></div><div class="line"><span style="color: #D8DEE9FF"> github-update-secret Updating action-guard +1ms</span></div><div class="line"><span style="color: #D8DEE9FF"> github-update-secret Updated action-guard +347ms</span></div><div class="line"><span style="color: #D8DEE9FF"> github-update-secret Updating action-router +1ms</span></div><div class="line"><span style="color: #D8DEE9FF"> github-update-secret Updated action-router +381ms</span></div><div class="line"><span style="color: #D8DEE9FF"> github-update-secret Updating action-run +1ms</span></div><div class="line"><span style="color: #D8DEE9FF"> github-update-secret Updated action-run +338ms</span></div><div class="line"><span style="color: #D8DEE9FF"> github-update-secret Updating actions-output-wrapper +0ms</span></div><div class="line"><span style="color: #D8DEE9FF">...snip...</span></div><div class="line"><span style="color: #D8DEE9FF"> github-update-secret Check </span><span style="color: #81A1C1">if</span><span style="color: #D8DEE9FF"> provided user is an org +0ms</span></div><div class="line"><span style="color: #D8DEE9FF"> github-update-secret User is not an org. Skipping org secret update +142ms</span></div></code></div></pre>
<p>NPM’s move to 90-day tokens is good for security but rough on existing workflows. OIDC is the long-term fix, but until I can update each project, <code>github-update-secret</code> gives me a quick way to rotate tokens across all my repos and keep releases moving. It’s not perfect, but it provides enough breathing room until I can do the real migration.</p>
</content>
</entry>
<entry>
<title>JSON Semantic Diff</title>
<link href="https://michaelheap.com/json-semantic-diff/"/>
<updated>2025-11-16T18:19:42Z</updated>
<id>https://michaelheap.com/json-semantic-diff/</id>
<content type="html"><p><a href="https://github.com/geofffranks/spruce">Spruce</a> is a general purpose YAML &amp; JSON merging tool, and one of it's subcommands is a semantic JSON diff. It captures added and removed fields, and highlights any type changes.</p>
<h2 id="installing-spruce" tabindex="-1">Installing Spruce</h2>
<p>Spruce is written in Go, and can be installed in many different ways:</p>
<ol>
<li>
<p>With <code>mise</code> (which I highly recommend):</p>
<pre class="shiki nord" style="background-color: #2e3440ff; color: #d8dee9ff"><div class="language-id">bash</div><div class="code-container"><code><div class="line"><span style="color: #D8DEE9FF">mise use -g github:geofffranks/spruce</span></div></code></div></pre>
</li>
<li>
<p>With <code>brew</code> if you're on MacOS</p>
<pre class="shiki nord" style="background-color: #2e3440ff; color: #d8dee9ff"><div class="language-id">bash</div><div class="code-container"><code><div class="line"><span style="color: #D8DEE9FF">brew install starkandwayne/cf/spruce</span></div></code></div></pre>
</li>
<li>
<p>With <code>go get</code>:</p>
<pre class="shiki nord" style="background-color: #2e3440ff; color: #d8dee9ff"><div class="language-id">bash</div><div class="code-container"><code><div class="line"><span style="color: #D8DEE9FF">go get github.com/geofffranks/spruce</span></div></code></div></pre>
</li>
</ol>
<h2 id="example-usage" tabindex="-1">Example usage</h2>
<p>Here are two sample JSON files. Between the first and the second, I've changed a value, changed two data types, and removed two fields:</p>
<pre class="shiki nord" style="background-color: #2e3440ff; color: #d8dee9ff"><div class="language-id">json</div><div class="code-container"><code><div class="line"><span style="color: #ECEFF4">{</span></div><div class="line"><span style="color: #D8DEE9FF"> </span><span style="color: #ECEFF4">"</span><span style="color: #8FBCBB">name</span><span style="color: #ECEFF4">"</span><span style="color: #ECEFF4">:</span><span style="color: #D8DEE9FF"> </span><span style="color: #ECEFF4">"</span><span style="color: #A3BE8C">Alice</span><span style="color: #ECEFF4">"</span><span style="color: #ECEFF4">,</span></div><div class="line"><span style="color: #D8DEE9FF"> </span><span style="color: #ECEFF4">"</span><span style="color: #8FBCBB">age</span><span style="color: #ECEFF4">"</span><span style="color: #ECEFF4">:</span><span style="color: #D8DEE9FF"> </span><span style="color: #ECEFF4">"</span><span style="color: #A3BE8C">30</span><span style="color: #ECEFF4">"</span><span style="color: #ECEFF4">,</span></div><div class="line"><span style="color: #D8DEE9FF"> </span><span style="color: #ECEFF4">"</span><span style="color: #8FBCBB">pets</span><span style="color: #ECEFF4">"</span><span style="color: #ECEFF4">:</span><span style="color: #D8DEE9FF"> </span><span style="color: #ECEFF4">"</span><span style="color: #A3BE8C">Fido</span><span style="color: #ECEFF4">"</span><span style="color: #ECEFF4">,</span></div><div class="line"><span style="color: #D8DEE9FF"> </span><span style="color: #ECEFF4">"</span><span style="color: #8FBCBB">favourite_cake</span><span style="color: #ECEFF4">"</span><span style="color: #ECEFF4">:</span><span style="color: #D8DEE9FF"> </span><span style="color: #ECEFF4">"</span><span style="color: #A3BE8C">Carrot cake</span><span style="color: #ECEFF4">"</span><span style="color: #ECEFF4">,</span></div><div class="line"><span style="color: #D8DEE9FF"> </span><span style="color: #ECEFF4">"</span><span style="color: #8FBCBB">another</span><span style="color: #ECEFF4">"</span><span style="color: #ECEFF4">:</span><span style="color: #D8DEE9FF"> </span><span style="color: #ECEFF4">{</span></div><div class="line"><span style="color: #D8DEE9FF"> </span><span style="color: #ECEFF4">"</span><span style="color: #8FBCBB">value</span><span style="color: #ECEFF4">"</span><span style="color: #ECEFF4">:</span><span style="color: #D8DEE9FF"> </span><span style="color: #ECEFF4">"</span><span style="color: #A3BE8C">here</span><span style="color: #ECEFF4">"</span></div><div class="line"><span style="color: #D8DEE9FF"> </span><span style="color: #ECEFF4">}</span></div><div class="line"><span style="color: #ECEFF4">}</span></div></code></div></pre>
<pre class="shiki nord" style="background-color: #2e3440ff; color: #d8dee9ff"><div class="language-id">json</div><div class="code-container"><code><div class="line"><span style="color: #ECEFF4">{</span></div><div class="line"><span style="color: #D8DEE9FF"> </span><span style="color: #ECEFF4">"</span><span style="color: #8FBCBB">name</span><span style="color: #ECEFF4">"</span><span style="color: #ECEFF4">:</span><span style="color: #D8DEE9FF"> </span><span style="color: #ECEFF4">"</span><span style="color: #A3BE8C">Alice Smith</span><span style="color: #ECEFF4">"</span><span style="color: #ECEFF4">,</span></div><div class="line"><span style="color: #D8DEE9FF"> </span><span style="color: #ECEFF4">"</span><span style="color: #8FBCBB">age</span><span style="color: #ECEFF4">"</span><span style="color: #ECEFF4">:</span><span style="color: #D8DEE9FF"> </span><span style="color: #B48EAD">30</span><span style="color: #ECEFF4">,</span></div><div class="line"><span style="color: #D8DEE9FF"> </span><span style="color: #ECEFF4">"</span><span style="color: #8FBCBB">pets</span><span style="color: #ECEFF4">"</span><span style="color: #ECEFF4">:</span><span style="color: #D8DEE9FF"> </span><span style="color: #ECEFF4">[</span><span style="color: #ECEFF4">"</span><span style="color: #A3BE8C">Fido</span><span style="color: #ECEFF4">"</span><span style="color: #ECEFF4">],</span></div><div class="line"><span style="color: #D8DEE9FF"> </span><span style="color: #ECEFF4">"</span><span style="color: #8FBCBB">another</span><span style="color: #ECEFF4">"</span><span style="color: #ECEFF4">:</span><span style="color: #D8DEE9FF"> </span><span style="color: #ECEFF4">{}</span></div><div class="line"><span style="color: #ECEFF4">}</span></div></code></div></pre>
<p>If we run <code>spruce diff</code>, this information is returned to us in a format that makes is <em>very</em> easy to see the diffs:</p>
<pre class="shiki nord" style="background-color: #2e3440ff; color: #d8dee9ff"><div class="language-id">diff</div><div class="code-container"><code><div class="line"><span style="color: #D8DEE9FF">$ spruce diff first.json second.json</span></div><div class="line"></div><div class="line"><span style="color: #D8DEE9FF">(root level)</span></div><div class="line"><span style="color: #ECEFF4">-</span><span style="color: #BF616A"> one map entry removed:</span></div><div class="line"><span style="color: #D8DEE9FF">favourite_cake: "Carrot cake"</span></div><div class="line"></div><div class="line"><span style="color: #D8DEE9FF">age</span></div><div class="line"><span style="color: #D8DEE9FF">± type change from string to int</span></div><div class="line"><span style="color: #ECEFF4">-</span><span style="color: #BF616A"> 30</span></div><div class="line"><span style="color: #ECEFF4">+</span><span style="color: #A3BE8C"> 30</span></div><div class="line"></div><div class="line"><span style="color: #D8DEE9FF">another</span></div><div class="line"><span style="color: #ECEFF4">-</span><span style="color: #BF616A"> one map entry removed:</span></div><div class="line"><span style="color: #D8DEE9FF">value: here</span></div><div class="line"></div><div class="line"><span style="color: #D8DEE9FF">name</span></div><div class="line"><span style="color: #D8DEE9FF">± value change</span></div><div class="line"><span style="color: #ECEFF4">-</span><span style="color: #BF616A"> Alice</span></div><div class="line"><span style="color: #ECEFF4">+</span><span style="color: #A3BE8C"> Alice Smith</span></div><div class="line"></div><div class="line"><span style="color: #D8DEE9FF">pets</span></div><div class="line"><span style="color: #D8DEE9FF">± type change from string to list</span></div><div class="line"><span style="color: #ECEFF4">-</span><span style="color: #BF616A"> Fido</span></div><div class="line"><span style="color: #ECEFF4">+</span><span style="color: #A3BE8C"> - Fido</span></div></code></div></pre>
</content>
</entry>
<entry>
<title>Say Three Things</title>
<link href="https://michaelheap.com/say-three-things/"/>
<updated>2025-11-06T21:03:56Z</updated>
<id>https://michaelheap.com/say-three-things/</id>
<content type="html"><p>We’ve all been in <em>that</em> meeting, with a dozen people sat on Zoom, not quite sure why they’re there. The more jaded of the group might have their camera off, trying to do some work while they listen in. Others might keep their camera on, trying to stay focused on what’s being said.</p>
<p>No matter if your camera is on or off, if you’re not participating in the meeting why are you there at all? <a href="https://knowyourmeme.com/photos/1753854-shiba-inu-barking-in-an-office-meeting">This meeting could have been an email</a></p>
<p>Except, it couldn’t. People <em>are</em> interacting on the call. There’s a lot of nuance in the discussion that would be missed in a summary. You need to be there to hear it. So how do you make the most of your time?</p>
<p>Remind people that you exist. Say at least three things in the meeting. It doesn’t matter if you’re meeting with your CEO or your newest, most junior hire. Speaking up reminds people that you’re there.</p>
<p>If you stay silent, they’re likely to forget you were even there. Or worse, assume that you don’t understand what’s going on around you. By speaking up, you can paint a picture of how you want to be perceived.</p>
<h2 id="what-three-things-should-i-say%3F" tabindex="-1">What Three Things Should I Say?</h2>
<p>“But I don’t have anything to add!” I hear you say. Wonderful! Neither do a lot of people that can’t stop talking in meetings (myself included). Here are a couple of my favourite ways to inject myself into the conversation.</p>
<h3 id="1.-clarify" tabindex="-1">1. Clarify</h3>
<p>“<em>Can I try to summarise to make sure that I’m following? I heard…</em>”</p>
<p>This is a great way to illustrate that you understand what’s going on. There’s always someone on the call that was distracted by a Slack message, a knock at the door, or a butterfly and they're very appreciative that you just summarised the last 5 minutes of conversation.</p>
<h3 id="2.-capture" tabindex="-1">2. Capture</h3>
<p>“<em>So the next steps are for Alice and Bob to submit a proposal for how to proceed. I’ve captured that in the meeting notes. Is there a specific date we need that by?</em>”</p>
<p>Being the note taker is a thankless task, but it also gives you the opportunity to speak. It also helps build a reputation as someone that helps drive progress.</p>
<h3 id="3.-connect" tabindex="-1">3. Connect</h3>
<p>“<em>Has anyone spoken to Charlie about this? I can see some parallels with Project Flubjam</em>”</p>
<p>This one takes some organisational context, but being the person to connect the dots between orgs is a huge differentiator</p>
<h3 id="4.-chat" tabindex="-1">4. Chat</h3>
<p>And if all else fails, join the meeting early. The few unscripted minutes before a call are often the most human ones. Ask about someone’s weekend or joke about the weather - people remember small talk more than polished summaries.</p>
<h2 id="why-it-matters" tabindex="-1">Why It Matters</h2>
<p>Saying three things in every meeting isn’t about filling airtime or proving your worth. It’s about showing up with intention.</p>
<p>Your aim isn’t to be the person that speaks for 25 minutes of a 30 minute meeting. Prioritise clarity and brevity - 15-30 seconds of talking per point is enough.</p>
<p>You’re signalling that you’re engaged, thoughtful, and invested in the outcome. Whether you’re clarifying, summarising, or connecting dots, those small contributions compound over time into credibility and visibility.</p>
<p>Silence might feel safe, but it also makes you invisible. Speaking up, even briefly, ensures that when decisions are made or opportunities arise, people remember that you were part of the conversation rather than being just another name on the invite list.</p>
</content>
</entry>
<entry>
<title>Run a container on a schedule with ECS</title>
<link href="https://michaelheap.com/ecs-scheduled-container/"/>
<updated>2025-06-02T08:00:13Z</updated>
<id>https://michaelheap.com/ecs-scheduled-container/</id>
<content type="html"><p>I've got a Docker container that I want to run periodically to fetch data and store it in a database. As this is something that needs to run persistently, I'm using Terraform to manage the infrastructure. It took me a while to figure out all the required resources and permissions, so I thought I'd share my solution here.</p>
<p>To securely run a container on a schedule in ECS, I needed to:</p>
<ul>
<li>Initialise Terraform</li>
<li>Configure any secrets that the container needs</li>
<li>Create an ECS Fargate cluster, and a VPC for the container to run in</li>
<li>Create an IAM role for the container to execute as</li>
<li>Create an IAM role for EventBridge to trigger the ECS task</li>
<li>Define an EventBridge schedule trigger</li>
<li>Define a task</li>
</ul>
<h2 id="initialise-terraform" tabindex="-1">Initialise Terraform</h2>
<p>Create <code>providers.tf</code> containing the AWS profile and region you want to use:</p>
<pre class="shiki nord" style="background-color: #2e3440ff; color: #d8dee9ff"><div class="language-id">hcl</div><div class="code-container"><code><div class="line"><span style="color: #81A1C1">provider</span><span style="color: #A3BE8C"> "aws"</span><span style="color: #D8DEE9FF"> {</span></div><div class="line"><span style="color: #D8DEE9FF"> </span><span style="color: #D8DEE9">region</span><span style="color: #D8DEE9FF"> </span><span style="color: #81A1C1">=</span><span style="color: #D8DEE9FF"> </span><span style="color: #A3BE8C">"us-east-2"</span></div><div class="line"><span style="color: #D8DEE9FF"> </span><span style="color: #D8DEE9">profile</span><span style="color: #D8DEE9FF"> </span><span style="color: #81A1C1">=</span><span style="color: #D8DEE9FF"> </span><span style="color: #A3BE8C">"default"</span></div><div class="line"><span style="color: #D8DEE9FF">}</span></div></code></div></pre>
<p>Then run <code>terraform init</code>.</p>
<h2 id="secrets" tabindex="-1">Secrets</h2>
<p>The container that I'm running needs access to some secret values as environment variables. I read the two most sensitive items from <code>tfvars</code>, and hard code the role as the Terraform repo is private anyway. The <code>test/area/role</code> secret is really just configuration rather than being a secret:</p>
<pre class="shiki nord" style="background-color: #2e3440ff; color: #d8dee9ff"><div class="language-id">hcl</div><div class="code-container"><code><div class="line"><span style="color: #81A1C1">variable</span><span style="color: #A3BE8C"> "AREA_USERNAME"</span><span style="color: #D8DEE9FF"> {</span></div><div class="line"><span style="color: #D8DEE9FF"> </span><span style="color: #D8DEE9">description</span><span style="color: #D8DEE9FF"> </span><span style="color: #81A1C1">=</span><span style="color: #D8DEE9FF"> </span><span style="color: #A3BE8C">"Username for AREA access"</span></div><div class="line"><span style="color: #D8DEE9FF"> </span><span style="color: #D8DEE9">type</span><span style="color: #D8DEE9FF"> </span><span style="color: #81A1C1">=</span><span style="color: #D8DEE9FF"> string</span></div><div class="line"><span style="color: #D8DEE9FF"> </span><span style="color: #D8DEE9">sensitive</span><span style="color: #D8DEE9FF"> </span><span style="color: #81A1C1">=</span><span style="color: #D8DEE9FF"> </span><span style="color: #B48EAD">true</span></div><div class="line"><span style="color: #D8DEE9FF">}</span></div><div class="line"></div><div class="line"><span style="color: #81A1C1">variable</span><span style="color: #A3BE8C"> "AREA_PASSWORD"</span><span style="color: #D8DEE9FF"> {</span></div><div class="line"><span style="color: #D8DEE9FF"> </span><span style="color: #D8DEE9">description</span><span style="color: #D8DEE9FF"> </span><span style="color: #81A1C1">=</span><span style="color: #D8DEE9FF"> </span><span style="color: #A3BE8C">"Password for AREA access"</span></div><div class="line"><span style="color: #D8DEE9FF"> </span><span style="color: #D8DEE9">type</span><span style="color: #D8DEE9FF"> </span><span style="color: #81A1C1">=</span><span style="color: #D8DEE9FF"> string</span></div><div class="line"><span style="color: #D8DEE9FF"> </span><span style="color: #D8DEE9">sensitive</span><span style="color: #D8DEE9FF"> </span><span style="color: #81A1C1">=</span><span style="color: #D8DEE9FF"> </span><span style="color: #B48EAD">true</span></div><div class="line"><span style="color: #D8DEE9FF">}</span></div><div class="line"></div><div class="line"><span style="color: #81A1C1">locals</span><span style="color: #D8DEE9FF"> {</span></div><div class="line"><span style="color: #D8DEE9FF"> </span><span style="color: #D8DEE9">my_secrets</span><span style="color: #D8DEE9FF"> </span><span style="color: #81A1C1">=</span><span style="color: #D8DEE9FF"> {</span></div><div class="line"><span style="color: #D8DEE9FF"> "test/area/username" = var.AREA_USERNAME</span></div><div class="line"><span style="color: #D8DEE9FF"> "test/area/password" = var.AREA_PASSWORD</span></div><div class="line"><span style="color: #D8DEE9FF"> "test/area/role" = "some_role"</span></div><div class="line"><span style="color: #D8DEE9FF"> }</span></div><div class="line"><span style="color: #D8DEE9FF">}</span></div><div class="line"></div><div class="line"><span style="color: #81A1C1">resource</span><span style="color: #A3BE8C"> "aws_secretsmanager_secret" "my_secrets"</span><span style="color: #D8DEE9FF"> {</span></div><div class="line"><span style="color: #D8DEE9FF"> </span><span style="color: #D8DEE9">for_each</span><span style="color: #D8DEE9FF"> </span><span style="color: #81A1C1">=</span><span style="color: #D8DEE9FF"> local</span><span style="color: #B48EAD">.</span><span style="color: #D8DEE9FF">my_secrets</span></div><div class="line"><span style="color: #D8DEE9FF"> </span><span style="color: #D8DEE9">name</span><span style="color: #D8DEE9FF"> </span><span style="color: #81A1C1">=</span><span style="color: #D8DEE9FF"> each</span><span style="color: #B48EAD">.</span><span style="color: #D8DEE9FF">key</span></div><div class="line"><span style="color: #D8DEE9FF">}</span></div><div class="line"></div><div class="line"><span style="color: #81A1C1">resource</span><span style="color: #A3BE8C"> "aws_secretsmanager_secret_version" "my_secret_values"</span><span style="color: #D8DEE9FF"> {</span></div><div class="line"><span style="color: #D8DEE9FF"> </span><span style="color: #D8DEE9">for_each</span><span style="color: #D8DEE9FF"> </span><span style="color: #81A1C1">=</span><span style="color: #D8DEE9FF"> local</span><span style="color: #B48EAD">.</span><span style="color: #D8DEE9FF">my_secrets</span></div><div class="line"><span style="color: #D8DEE9FF"> </span><span style="color: #D8DEE9">secret_id</span><span style="color: #D8DEE9FF"> </span><span style="color: #81A1C1">=</span><span style="color: #D8DEE9FF"> aws_secretsmanager_secret</span><span style="color: #B48EAD">.</span><span style="color: #D8DEE9FF">my_secrets[each</span><span style="color: #B48EAD">.</span><span style="color: #D8DEE9FF">key].id</span></div><div class="line"><span style="color: #D8DEE9FF"> </span><span style="color: #D8DEE9">secret_string</span><span style="color: #D8DEE9FF"> </span><span style="color: #81A1C1">=</span><span style="color: #D8DEE9FF"> each</span><span style="color: #B48EAD">.</span><span style="color: #D8DEE9FF">value</span></div><div class="line"><span style="color: #D8DEE9FF">}</span></div></code></div></pre>
<h2 id="ecs-cluster-and-vpc" tabindex="-1">ECS Cluster and VPC</h2>
<p>ECS allows you to scale to zero, but you still need a VPC to spin up containers in. My containers need internet access, so here's how I create an ECS cluster and VPC with internet access:</p>
<pre class="shiki nord" style="background-color: #2e3440ff; color: #d8dee9ff"><div class="language-id">hcl</div><div class="code-container"><code><div class="line"><span style="color: #616E88"># Create an ECS cluster</span></div><div class="line"><span style="color: #81A1C1">resource</span><span style="color: #A3BE8C"> "aws_ecs_cluster" "my_cluster"</span><span style="color: #D8DEE9FF"> {</span></div><div class="line"><span style="color: #D8DEE9FF"> </span><span style="color: #D8DEE9">name</span><span style="color: #D8DEE9FF"> </span><span style="color: #81A1C1">=</span><span style="color: #D8DEE9FF"> </span><span style="color: #A3BE8C">"demo-fargate-cluster"</span></div><div class="line"><span style="color: #D8DEE9FF">}</span></div><div class="line"></div><div class="line"><span style="color: #616E88"># Create a VPC and network, allowing all egress traffic</span></div><div class="line"><span style="color: #81A1C1">resource</span><span style="color: #A3BE8C"> "aws_vpc" "main"</span><span style="color: #D8DEE9FF"> {</span></div><div class="line"><span style="color: #D8DEE9FF"> </span><span style="color: #D8DEE9">cidr_block</span><span style="color: #D8DEE9FF"> </span><span style="color: #81A1C1">=</span><span style="color: #D8DEE9FF"> </span><span style="color: #A3BE8C">"10.0.0.0/16"</span></div><div class="line"><span style="color: #D8DEE9FF"> </span><span style="color: #D8DEE9">enable_dns_hostnames</span><span style="color: #D8DEE9FF"> </span><span style="color: #81A1C1">=</span><span style="color: #D8DEE9FF"> </span><span style="color: #B48EAD">true</span></div><div class="line"><span style="color: #D8DEE9FF">}</span></div><div class="line"></div><div class="line"><span style="color: #81A1C1">resource</span><span style="color: #A3BE8C"> "aws_subnet" "public"</span><span style="color: #D8DEE9FF"> {</span></div><div class="line"><span style="color: #D8DEE9FF"> </span><span style="color: #D8DEE9">vpc_id</span><span style="color: #D8DEE9FF"> </span><span style="color: #81A1C1">=</span><span style="color: #D8DEE9FF"> aws_vpc</span><span style="color: #B48EAD">.</span><span style="color: #D8DEE9FF">main</span><span style="color: #B48EAD">.</span><span style="color: #D8DEE9FF">id</span></div><div class="line"><span style="color: #D8DEE9FF"> </span><span style="color: #D8DEE9">cidr_block</span><span style="color: #D8DEE9FF"> </span><span style="color: #81A1C1">=</span><span style="color: #D8DEE9FF"> </span><span style="color: #A3BE8C">"10.0.1.0/24"</span></div><div class="line"><span style="color: #D8DEE9FF"> </span><span style="color: #D8DEE9">availability_zone</span><span style="color: #D8DEE9FF"> </span><span style="color: #81A1C1">=</span><span style="color: #D8DEE9FF"> </span><span style="color: #A3BE8C">"us-east-2a"</span></div><div class="line"><span style="color: #D8DEE9FF"> </span><span style="color: #D8DEE9">map_public_ip_on_launch</span><span style="color: #D8DEE9FF"> </span><span style="color: #81A1C1">=</span><span style="color: #D8DEE9FF"> </span><span style="color: #B48EAD">true</span></div><div class="line"><span style="color: #D8DEE9FF">}</span></div><div class="line"></div><div class="line"><span style="color: #616E88"># You could also use a NAT gateway instead. We use an internet gateway</span></div><div class="line"><span style="color: #616E88"># due to speed / cost reasons in this example</span></div><div class="line"><span style="color: #81A1C1">resource</span><span style="color: #A3BE8C"> "aws_internet_gateway" "gw"</span><span style="color: #D8DEE9FF"> {</span></div><div class="line"><span style="color: #D8DEE9FF"> </span><span style="color: #D8DEE9">vpc_id</span><span style="color: #D8DEE9FF"> </span><span style="color: #81A1C1">=</span><span style="color: #D8DEE9FF"> aws_vpc</span><span style="color: #B48EAD">.</span><span style="color: #D8DEE9FF">main</span><span style="color: #B48EAD">.</span><span style="color: #D8DEE9FF">id</span></div><div class="line"><span style="color: #D8DEE9FF">}</span></div><div class="line"></div><div class="line"><span style="color: #81A1C1">resource</span><span style="color: #A3BE8C"> "aws_route_table" "public"</span><span style="color: #D8DEE9FF"> {</span></div><div class="line"><span style="color: #D8DEE9FF"> </span><span style="color: #D8DEE9">vpc_id</span><span style="color: #D8DEE9FF"> </span><span style="color: #81A1C1">=</span><span style="color: #D8DEE9FF"> aws_vpc</span><span style="color: #B48EAD">.</span><span style="color: #D8DEE9FF">main</span><span style="color: #B48EAD">.</span><span style="color: #D8DEE9FF">id</span></div><div class="line"></div><div class="line"><span style="color: #D8DEE9FF"> </span><span style="color: #81A1C1">route</span><span style="color: #D8DEE9FF"> {</span></div><div class="line"><span style="color: #D8DEE9FF"> </span><span style="color: #D8DEE9">cidr_block</span><span style="color: #D8DEE9FF"> </span><span style="color: #81A1C1">=</span><span style="color: #D8DEE9FF"> </span><span style="color: #A3BE8C">"0.0.0.0/0"</span></div><div class="line"><span style="color: #D8DEE9FF"> </span><span style="color: #D8DEE9">gateway_id</span><span style="color: #D8DEE9FF"> </span><span style="color: #81A1C1">=</span><span style="color: #D8DEE9FF"> aws_internet_gateway</span><span style="color: #B48EAD">.</span><span style="color: #D8DEE9FF">gw</span><span style="color: #B48EAD">.</span><span style="color: #D8DEE9FF">id</span></div><div class="line"><span style="color: #D8DEE9FF"> }</span></div><div class="line"><span style="color: #D8DEE9FF">}</span></div><div class="line"></div><div class="line"><span style="color: #81A1C1">resource</span><span style="color: #A3BE8C"> "aws_route_table_association" "public_assoc"</span><span style="color: #D8DEE9FF"> {</span></div><div class="line"><span style="color: #D8DEE9FF"> </span><span style="color: #D8DEE9">subnet_id</span><span style="color: #D8DEE9FF"> </span><span style="color: #81A1C1">=</span><span style="color: #D8DEE9FF"> aws_subnet</span><span style="color: #B48EAD">.</span><span style="color: #D8DEE9FF">public</span><span style="color: #B48EAD">.</span><span style="color: #D8DEE9FF">id</span></div><div class="line"><span style="color: #D8DEE9FF"> </span><span style="color: #D8DEE9">route_table_id</span><span style="color: #D8DEE9FF"> </span><span style="color: #81A1C1">=</span><span style="color: #D8DEE9FF"> aws_route_table</span><span style="color: #B48EAD">.</span><span style="color: #D8DEE9FF">public</span><span style="color: #B48EAD">.</span><span style="color: #D8DEE9FF">id</span></div><div class="line"><span style="color: #D8DEE9FF">}</span></div><div class="line"></div><div class="line"><span style="color: #81A1C1">resource</span><span style="color: #A3BE8C"> "aws_security_group" "ecs_tasks"</span><span style="color: #D8DEE9FF"> {</span></div><div class="line"><span style="color: #D8DEE9FF"> </span><span style="color: #D8DEE9">name</span><span style="color: #D8DEE9FF"> </span><span style="color: #81A1C1">=</span><span style="color: #D8DEE9FF"> </span><span style="color: #A3BE8C">"ecs-scheduled-tasks-sg"</span></div><div class="line"><span style="color: #D8DEE9FF"> </span><span style="color: #D8DEE9">description</span><span style="color: #D8DEE9FF"> </span><span style="color: #81A1C1">=</span><span style="color: #D8DEE9FF"> </span><span style="color: #A3BE8C">"Allow outbound traffic"</span></div><div class="line"><span style="color: #D8DEE9FF"> </span><span style="color: #D8DEE9">vpc_id</span><span style="color: #D8DEE9FF"> </span><span style="color: #81A1C1">=</span><span style="color: #D8DEE9FF"> aws_vpc</span><span style="color: #B48EAD">.</span><span style="color: #D8DEE9FF">main</span><span style="color: #B48EAD">.</span><span style="color: #D8DEE9FF">id</span></div><div class="line"></div><div class="line"><span style="color: #D8DEE9FF"> </span><span style="color: #81A1C1">egress</span><span style="color: #D8DEE9FF"> {</span></div><div class="line"><span style="color: #D8DEE9FF"> </span><span style="color: #D8DEE9">from_port</span><span style="color: #D8DEE9FF"> </span><span style="color: #81A1C1">=</span><span style="color: #D8DEE9FF"> </span><span style="color: #B48EAD">0</span></div><div class="line"><span style="color: #D8DEE9FF"> </span><span style="color: #D8DEE9">to_port</span><span style="color: #D8DEE9FF"> </span><span style="color: #81A1C1">=</span><span style="color: #D8DEE9FF"> </span><span style="color: #B48EAD">0</span></div><div class="line"><span style="color: #D8DEE9FF"> </span><span style="color: #D8DEE9">protocol</span><span style="color: #D8DEE9FF"> </span><span style="color: #81A1C1">=</span><span style="color: #D8DEE9FF"> </span><span style="color: #A3BE8C">"-1"</span></div><div class="line"><span style="color: #D8DEE9FF"> </span><span style="color: #D8DEE9">cidr_blocks</span><span style="color: #D8DEE9FF"> </span><span style="color: #81A1C1">=</span><span style="color: #D8DEE9FF"> [</span><span style="color: #A3BE8C">"0.0.0.0/0"</span><span style="color: #D8DEE9FF">]</span></div><div class="line"><span style="color: #D8DEE9FF"> }</span></div><div class="line"><span style="color: #D8DEE9FF">}</span></div></code></div></pre>
<h2 id="execution-iam-role" tabindex="-1">Execution IAM role</h2>
<p>The container that runs does not have access to AWS Secrets Manager by default. To allow access, I create a new IAM role that the container will assume using <code>sts:AssumeRole</code> before running. This new role has a policy attached that allows access to specific secrets in AWS Secrets Manager.</p>
<pre class="shiki nord" style="background-color: #2e3440ff; color: #d8dee9ff"><div class="language-id">hcl</div><div class="code-container"><code><div class="line"><span style="color: #616E88"># Allow ECS to assume this role</span></div><div class="line"><span style="color: #81A1C1">resource</span><span style="color: #A3BE8C"> "aws_iam_role" "ecs_task_execution"</span><span style="color: #D8DEE9FF"> {</span></div><div class="line"><span style="color: #D8DEE9FF"> </span><span style="color: #D8DEE9">name</span><span style="color: #D8DEE9FF"> </span><span style="color: #81A1C1">=</span><span style="color: #D8DEE9FF"> </span><span style="color: #A3BE8C">"ecs-task-execution-role"</span></div><div class="line"></div><div class="line"><span style="color: #D8DEE9FF"> </span><span style="color: #D8DEE9">assume_role_policy</span><span style="color: #D8DEE9FF"> </span><span style="color: #81A1C1">=</span><span style="color: #D8DEE9FF"> jsonencode({</span></div><div class="line"><span style="color: #D8DEE9FF"> Version = "2012-10-17",</span></div><div class="line"><span style="color: #D8DEE9FF"> Statement = [{</span></div><div class="line"><span style="color: #D8DEE9FF"> Effect = "Allow",</span></div><div class="line"><span style="color: #D8DEE9FF"> Principal = {</span></div><div class="line"><span style="color: #D8DEE9FF"> Service = "ecs-tasks.amazonaws.com"</span></div><div class="line"><span style="color: #D8DEE9FF"> },</span></div><div class="line"><span style="color: #D8DEE9FF"> </span><span style="color: #D8DEE9">Action</span><span style="color: #D8DEE9FF"> </span><span style="color: #81A1C1">=</span><span style="color: #D8DEE9FF"> </span><span style="color: #A3BE8C">"sts:AssumeRole"</span></div><div class="line"><span style="color: #D8DEE9FF"> }]</span></div><div class="line"><span style="color: #D8DEE9FF"> })</span></div><div class="line"><span style="color: #D8DEE9FF">}</span></div><div class="line"></div><div class="line"><span style="color: #616E88"># Create a policy that allows access to the secrets we defined earlier</span></div><div class="line"><span style="color: #81A1C1">resource</span><span style="color: #A3BE8C"> "aws_iam_policy" "ecs_execution_secrets_access"</span><span style="color: #D8DEE9FF"> {</span></div><div class="line"><span style="color: #D8DEE9FF"> </span><span style="color: #D8DEE9">name</span><span style="color: #D8DEE9FF"> </span><span style="color: #81A1C1">=</span><span style="color: #D8DEE9FF"> </span><span style="color: #A3BE8C">"ecs-exec-secrets-access"</span></div><div class="line"></div><div class="line"><span style="color: #D8DEE9FF"> </span><span style="color: #D8DEE9">policy</span><span style="color: #D8DEE9FF"> </span><span style="color: #81A1C1">=</span><span style="color: #D8DEE9FF"> jsonencode({</span></div><div class="line"><span style="color: #D8DEE9FF"> Version = "2012-10-17",</span></div><div class="line"><span style="color: #D8DEE9FF"> Statement = [{</span></div><div class="line"><span style="color: #D8DEE9FF"> Effect = "Allow",</span></div><div class="line"><span style="color: #D8DEE9FF"> Action = [</span></div><div class="line"><span style="color: #D8DEE9FF"> "secretsmanager:GetSecretValue",</span></div><div class="line"><span style="color: #D8DEE9FF"> "secretsmanager:DescribeSecret"</span></div><div class="line"><span style="color: #D8DEE9FF"> ],</span></div><div class="line"><span style="color: #D8DEE9FF"> Resource = [</span></div><div class="line"><span style="color: #D8DEE9FF"> aws_secretsmanager_secret.my_secrets["test/area/username"].arn,</span></div><div class="line"><span style="color: #D8DEE9FF"> aws_secretsmanager_secret.my_secrets["test/area/password"].arn,</span></div><div class="line"><span style="color: #D8DEE9FF"> aws_secretsmanager_secret.my_secrets["test/area/role"].arn,</span></div><div class="line"><span style="color: #D8DEE9FF"> ]</span></div><div class="line"><span style="color: #D8DEE9FF"> }]</span></div><div class="line"><span style="color: #D8DEE9FF"> })</span></div><div class="line"><span style="color: #D8DEE9FF">}</span></div><div class="line"></div><div class="line"><span style="color: #616E88"># Attach the above policy to the IAM role</span></div><div class="line"><span style="color: #81A1C1">resource</span><span style="color: #A3BE8C"> "aws_iam_role_policy_attachment" "ecs_exec_secrets_access_attach"</span><span style="color: #D8DEE9FF"> {</span></div><div class="line"><span style="color: #D8DEE9FF"> </span><span style="color: #D8DEE9">role</span><span style="color: #D8DEE9FF"> </span><span style="color: #81A1C1">=</span><span style="color: #D8DEE9FF"> aws_iam_role</span><span style="color: #B48EAD">.</span><span style="color: #D8DEE9FF">ecs_task_execution</span><span style="color: #B48EAD">.</span><span style="color: #D8DEE9FF">name</span></div><div class="line"><span style="color: #D8DEE9FF"> </span><span style="color: #D8DEE9">policy_arn</span><span style="color: #D8DEE9FF"> </span><span style="color: #81A1C1">=</span><span style="color: #D8DEE9FF"> aws_iam_policy</span><span style="color: #B48EAD">.</span><span style="color: #D8DEE9FF">ecs_execution_secrets_access</span><span style="color: #B48EAD">.</span><span style="color: #D8DEE9FF">arn</span></div><div class="line"><span style="color: #D8DEE9FF">}</span></div><div class="line"></div><div class="line"><span style="color: #616E88"># Attach the ECS Task execution policy</span></div><div class="line"><span style="color: #81A1C1">resource</span><span style="color: #A3BE8C"> "aws_iam_role_policy_attachment" "ecs_task_execution_attach"</span><span style="color: #D8DEE9FF"> {</span></div><div class="line"><span style="color: #D8DEE9FF"> </span><span style="color: #D8DEE9">role</span><span style="color: #D8DEE9FF"> </span><span style="color: #81A1C1">=</span><span style="color: #D8DEE9FF"> aws_iam_role</span><span style="color: #B48EAD">.</span><span style="color: #D8DEE9FF">ecs_task_execution</span><span style="color: #B48EAD">.</span><span style="color: #D8DEE9FF">name</span></div><div class="line"><span style="color: #D8DEE9FF"> </span><span style="color: #D8DEE9">policy_arn</span><span style="color: #D8DEE9FF"> </span><span style="color: #81A1C1">=</span><span style="color: #D8DEE9FF"> </span><span style="color: #A3BE8C">"arn:aws:iam::aws:policy/service-role/AmazonECSTaskExecutionRolePolicy"</span></div><div class="line"><span style="color: #D8DEE9FF">}</span></div><div class="line"></div></code></div></pre>
<h2 id="trigger-iam-role" tabindex="-1">Trigger IAM role</h2>
<p>Amazon EventBridge also needs an IAM role in order to trigger the ECS task. Here's an IAM role that has permission to run the defined tasks explicitly.</p>
<p>It took me a <em>long</em> time to realise that not only did I need to provide a <code>runTask</code> permission to the tasks, I also needed to allow <code>runTask</code> to the ECS cluster that was being used.</p>
<pre class="shiki nord" style="background-color: #2e3440ff; color: #d8dee9ff"><div class="language-id">hcl</div><div class="code-container"><code><div class="line"><span style="color: #616E88"># Allow EventBridge to assume this role</span></div><div class="line"><span style="color: #81A1C1">resource</span><span style="color: #A3BE8C"> "aws_iam_role" "eventbridge_invoke_ecs"</span><span style="color: #D8DEE9FF"> {</span></div><div class="line"><span style="color: #D8DEE9FF"> </span><span style="color: #D8DEE9">name</span><span style="color: #D8DEE9FF"> </span><span style="color: #81A1C1">=</span><span style="color: #D8DEE9FF"> </span><span style="color: #A3BE8C">"eventbridge-ecs-invoke-role"</span></div><div class="line"></div><div class="line"><span style="color: #D8DEE9FF"> </span><span style="color: #D8DEE9">assume_role_policy</span><span style="color: #D8DEE9FF"> </span><span style="color: #81A1C1">=</span><span style="color: #D8DEE9FF"> jsonencode({</span></div><div class="line"><span style="color: #D8DEE9FF"> Version = "2012-10-17",</span></div><div class="line"><span style="color: #D8DEE9FF"> Statement = [{</span></div><div class="line"><span style="color: #D8DEE9FF"> Effect = "Allow",</span></div><div class="line"><span style="color: #D8DEE9FF"> Principal = {</span></div><div class="line"><span style="color: #D8DEE9FF"> Service = "events.amazonaws.com"</span></div><div class="line"><span style="color: #D8DEE9FF"> },</span></div><div class="line"><span style="color: #D8DEE9FF"> </span><span style="color: #D8DEE9">Action</span><span style="color: #D8DEE9FF"> </span><span style="color: #81A1C1">=</span><span style="color: #D8DEE9FF"> </span><span style="color: #A3BE8C">"sts:AssumeRole"</span></div><div class="line"><span style="color: #D8DEE9FF"> }</span></div><div class="line"><span style="color: #D8DEE9FF"> ]</span></div><div class="line"><span style="color: #D8DEE9FF"> })</span></div><div class="line"><span style="color: #D8DEE9FF">}</span></div><div class="line"></div><div class="line"><span style="color: #616E88"># Create a new IAM policy that allows us to run all of the defined tasks</span></div><div class="line"><span style="color: #616E88"># We need to explicitly allow ecs:RunTask for the ecs:cluster too</span></div><div class="line"><span style="color: #81A1C1">resource</span><span style="color: #A3BE8C"> "aws_iam_role_policy" "eventbridge_ecs_policy"</span><span style="color: #D8DEE9FF"> {</span></div><div class="line"><span style="color: #D8DEE9FF"> </span><span style="color: #D8DEE9">name</span><span style="color: #D8DEE9FF"> </span><span style="color: #81A1C1">=</span><span style="color: #D8DEE9FF"> </span><span style="color: #A3BE8C">"invoke-ecs"</span></div><div class="line"><span style="color: #D8DEE9FF"> </span><span style="color: #D8DEE9">role</span><span style="color: #D8DEE9FF"> </span><span style="color: #81A1C1">=</span><span style="color: #D8DEE9FF"> aws_iam_role</span><span style="color: #B48EAD">.</span><span style="color: #D8DEE9FF">eventbridge_invoke_ecs</span><span style="color: #B48EAD">.</span><span style="color: #D8DEE9FF">id</span></div><div class="line"></div><div class="line"><span style="color: #D8DEE9FF"> </span><span style="color: #D8DEE9">policy</span><span style="color: #D8DEE9FF"> </span><span style="color: #81A1C1">=</span><span style="color: #D8DEE9FF"> jsonencode({</span></div><div class="line"><span style="color: #D8DEE9FF"> Version = "2012-10-17",</span></div><div class="line"><span style="color: #D8DEE9FF"> Statement = [</span></div><div class="line"><span style="color: #D8DEE9FF"> {</span></div><div class="line"><span style="color: #D8DEE9FF"> Effect = "Allow",</span></div><div class="line"><span style="color: #D8DEE9FF"> Action = "ecs:RunTask",</span></div><div class="line"><span style="color: #D8DEE9FF"> Resource = aws_ecs_task_definition.scheduled_my_command.arn</span></div><div class="line"><span style="color: #D8DEE9FF"> },</span></div><div class="line"><span style="color: #D8DEE9FF"> {</span></div><div class="line"><span style="color: #D8DEE9FF"> </span><span style="color: #D8DEE9">Effect</span><span style="color: #D8DEE9FF"> </span><span style="color: #81A1C1">=</span><span style="color: #D8DEE9FF"> </span><span style="color: #A3BE8C">"Allow"</span><span style="color: #D8DEE9FF">,</span></div><div class="line"><span style="color: #D8DEE9FF"> </span><span style="color: #D8DEE9">Action</span><span style="color: #D8DEE9FF"> </span><span style="color: #81A1C1">=</span><span style="color: #D8DEE9FF"> </span><span style="color: #A3BE8C">"iam:PassRole"</span><span style="color: #D8DEE9FF">,</span></div><div class="line"><span style="color: #D8DEE9FF"> </span><span style="color: #D8DEE9">Resource</span><span style="color: #D8DEE9FF"> </span><span style="color: #81A1C1">=</span><span style="color: #D8DEE9FF"> aws_iam_role</span><span style="color: #B48EAD">.</span><span style="color: #D8DEE9FF">ecs_task_execution</span><span style="color: #B48EAD">.</span><span style="color: #D8DEE9FF">arn</span></div><div class="line"><span style="color: #D8DEE9FF"> },</span></div><div class="line"><span style="color: #D8DEE9FF"> {</span></div><div class="line"><span style="color: #D8DEE9FF"> </span><span style="color: #D8DEE9">Effect</span><span style="color: #D8DEE9FF"> </span><span style="color: #81A1C1">=</span><span style="color: #D8DEE9FF"> </span><span style="color: #A3BE8C">"Allow"</span><span style="color: #D8DEE9FF">,</span></div><div class="line"><span style="color: #D8DEE9FF"> </span><span style="color: #D8DEE9">Action</span><span style="color: #D8DEE9FF"> </span><span style="color: #81A1C1">=</span><span style="color: #D8DEE9FF"> </span><span style="color: #A3BE8C">"ecs:RunTask"</span><span style="color: #D8DEE9FF">,</span></div><div class="line"><span style="color: #D8DEE9FF"> </span><span style="color: #D8DEE9">Resource</span><span style="color: #D8DEE9FF"> </span><span style="color: #81A1C1">=</span><span style="color: #D8DEE9FF"> </span><span style="color: #A3BE8C">"*"</span><span style="color: #D8DEE9FF">,</span></div><div class="line"><span style="color: #D8DEE9FF"> </span><span style="color: #D8DEE9">Condition</span><span style="color: #D8DEE9FF"> </span><span style="color: #81A1C1">=</span><span style="color: #D8DEE9FF"> {</span></div><div class="line"><span style="color: #D8DEE9FF"> </span><span style="color: #A3BE8C">"ArnEquals"</span><span style="color: #D8DEE9FF"> </span><span style="color: #81A1C1">:</span><span style="color: #D8DEE9FF"> {</span></div><div class="line"><span style="color: #D8DEE9FF"> "ecs:cluster" : aws_ecs_cluster.my_cluster.arn</span></div><div class="line"><span style="color: #D8DEE9FF"> }</span></div><div class="line"><span style="color: #D8DEE9FF"> }</span></div><div class="line"><span style="color: #D8DEE9FF"> }</span></div><div class="line"><span style="color: #D8DEE9FF"> ]</span></div><div class="line"><span style="color: #D8DEE9FF"> })</span></div><div class="line"><span style="color: #D8DEE9FF">}</span></div><div class="line"></div></code></div></pre>
<h2 id="eventbridge-schedule-trigger" tabindex="-1">EventBridge schedule trigger</h2>
<p>We need to define an event rule that runs the container on a schedule. I also create a Cloudwatch log group to capture task logs.</p>
<pre class="shiki nord" style="background-color: #2e3440ff; color: #d8dee9ff"><div class="language-id">hcl</div><div class="code-container"><code><div class="line"><span style="color: #616E88"># Run the task at 23:59 every night</span></div><div class="line"><span style="color: #81A1C1">resource</span><span style="color: #A3BE8C"> "aws_cloudwatch_event_rule" "run_at_23_59"</span><span style="color: #D8DEE9FF"> {</span></div><div class="line"><span style="color: #D8DEE9FF"> </span><span style="color: #D8DEE9">name</span><span style="color: #D8DEE9FF"> </span><span style="color: #81A1C1">=</span><span style="color: #D8DEE9FF"> </span><span style="color: #A3BE8C">"run-ecs-task-schedule"</span></div><div class="line"><span style="color: #D8DEE9FF"> </span><span style="color: #D8DEE9">schedule_expression</span><span style="color: #D8DEE9FF"> </span><span style="color: #81A1C1">=</span><span style="color: #D8DEE9FF"> </span><span style="color: #A3BE8C">"cron(59 23 * * ? *)"</span></div><div class="line"><span style="color: #D8DEE9FF">}</span></div><div class="line"></div><div class="line"><span style="color: #616E88"># And create a Cloudwatch log group to send logs to</span></div><div class="line"><span style="color: #81A1C1">resource</span><span style="color: #A3BE8C"> "aws_cloudwatch_log_group" "my_scheduled_task"</span><span style="color: #D8DEE9FF"> {</span></div><div class="line"><span style="color: #D8DEE9FF"> </span><span style="color: #D8DEE9">name</span><span style="color: #D8DEE9FF"> </span><span style="color: #81A1C1">=</span><span style="color: #D8DEE9FF"> </span><span style="color: #A3BE8C">"/ecs/my-scheduled-task-logs"</span></div><div class="line"><span style="color: #D8DEE9FF"> </span><span style="color: #D8DEE9">retention_in_days</span><span style="color: #D8DEE9FF"> </span><span style="color: #81A1C1">=</span><span style="color: #D8DEE9FF"> </span><span style="color: #B48EAD">1</span></div><div class="line"><span style="color: #D8DEE9FF">}</span></div></code></div></pre>
<h2 id="task-definition" tabindex="-1">Task Definition</h2>
<p>Finally, we need to define the task to run. You'll need to upload the docker image to ECR before defining a task. You'll also need to specify all of the secrets that the container needs access to.</p>
<pre class="shiki nord" style="background-color: #2e3440ff; color: #d8dee9ff"><div class="language-id">hcl</div><div class="code-container"><code><div class="line"><span style="color: #616E88"># Define the container and command to run, plus CPU/Memory usage</span></div><div class="line"><span style="color: #81A1C1">resource</span><span style="color: #A3BE8C"> "aws_ecs_task_definition" "scheduled_my_command"</span><span style="color: #D8DEE9FF"> {</span></div><div class="line"><span style="color: #D8DEE9FF"> </span><span style="color: #D8DEE9">family</span><span style="color: #D8DEE9FF"> </span><span style="color: #81A1C1">=</span><span style="color: #D8DEE9FF"> </span><span style="color: #A3BE8C">"scheduled-my-command"</span></div><div class="line"><span style="color: #D8DEE9FF"> </span><span style="color: #D8DEE9">requires_compatibilities</span><span style="color: #D8DEE9FF"> </span><span style="color: #81A1C1">=</span><span style="color: #D8DEE9FF"> [</span><span style="color: #A3BE8C">"FARGATE"</span><span style="color: #D8DEE9FF">]</span></div><div class="line"><span style="color: #D8DEE9FF"> </span><span style="color: #D8DEE9">network_mode</span><span style="color: #D8DEE9FF"> </span><span style="color: #81A1C1">=</span><span style="color: #D8DEE9FF"> </span><span style="color: #A3BE8C">"awsvpc"</span></div><div class="line"><span style="color: #D8DEE9FF"> </span><span style="color: #D8DEE9">cpu</span><span style="color: #D8DEE9FF"> </span><span style="color: #81A1C1">=</span><span style="color: #D8DEE9FF"> </span><span style="color: #A3BE8C">"256"</span></div><div class="line"><span style="color: #D8DEE9FF"> </span><span style="color: #D8DEE9">memory</span><span style="color: #D8DEE9FF"> </span><span style="color: #81A1C1">=</span><span style="color: #D8DEE9FF"> </span><span style="color: #A3BE8C">"512"</span></div><div class="line"></div><div class="line"><span style="color: #D8DEE9FF"> </span><span style="color: #D8DEE9">execution_role_arn</span><span style="color: #D8DEE9FF"> </span><span style="color: #81A1C1">=</span><span style="color: #D8DEE9FF"> aws_iam_role</span><span style="color: #B48EAD">.</span><span style="color: #D8DEE9FF">ecs_task_execution</span><span style="color: #B48EAD">.</span><span style="color: #D8DEE9FF">arn</span></div><div class="line"></div><div class="line"><span style="color: #D8DEE9FF"> </span><span style="color: #D8DEE9">container_definitions</span><span style="color: #D8DEE9FF"> </span><span style="color: #81A1C1">=</span><span style="color: #D8DEE9FF"> jsonencode([</span></div><div class="line"><span style="color: #D8DEE9FF"> {</span></div><div class="line"><span style="color: #D8DEE9FF"> name = </span><span style="color: #A3BE8C">"demo"</span><span style="color: #D8DEE9FF">,</span></div><div class="line"><span style="color: #D8DEE9FF"> image = </span><span style="color: #A3BE8C">"hello-world"</span><span style="color: #D8DEE9FF">,</span></div><div class="line"><span style="color: #D8DEE9FF"> essential = </span><span style="color: #B48EAD">true</span><span style="color: #D8DEE9FF">,</span></div><div class="line"><span style="color: #D8DEE9FF"> secrets = [</span></div><div class="line"><span style="color: #D8DEE9FF"> {</span></div><div class="line"><span style="color: #D8DEE9FF"> name = </span><span style="color: #A3BE8C">"AREA_USER"</span><span style="color: #D8DEE9FF">,</span></div><div class="line"><span style="color: #D8DEE9FF"> valueFrom = aws_secretsmanager_secret</span><span style="color: #B48EAD">.</span><span style="color: #D8DEE9FF">my_secrets[</span><span style="color: #A3BE8C">"test/area/username"</span><span style="color: #D8DEE9FF">].arn</span></div><div class="line"><span style="color: #D8DEE9FF"> },</span></div><div class="line"><span style="color: #D8DEE9FF"> {</span></div><div class="line"><span style="color: #D8DEE9FF"> </span><span style="color: #D8DEE9">name</span><span style="color: #D8DEE9FF"> </span><span style="color: #81A1C1">=</span><span style="color: #D8DEE9FF"> </span><span style="color: #A3BE8C">"AREA_PASSWORD"</span><span style="color: #D8DEE9FF">,</span></div><div class="line"><span style="color: #D8DEE9FF"> </span><span style="color: #D8DEE9">valueFrom</span><span style="color: #D8DEE9FF"> </span><span style="color: #81A1C1">=</span><span style="color: #D8DEE9FF"> aws_secretsmanager_secret</span><span style="color: #B48EAD">.</span><span style="color: #D8DEE9FF">my_secrets[</span><span style="color: #A3BE8C">"test/area/password"</span><span style="color: #D8DEE9FF">].arn</span></div><div class="line"><span style="color: #D8DEE9FF"> },</span></div><div class="line"><span style="color: #D8DEE9FF"> {</span></div><div class="line"><span style="color: #D8DEE9FF"> </span><span style="color: #D8DEE9">name</span><span style="color: #D8DEE9FF"> </span><span style="color: #81A1C1">=</span><span style="color: #D8DEE9FF"> </span><span style="color: #A3BE8C">"AREA_ROLE"</span><span style="color: #D8DEE9FF">,</span></div><div class="line"><span style="color: #D8DEE9FF"> </span><span style="color: #D8DEE9">valueFrom</span><span style="color: #D8DEE9FF"> </span><span style="color: #81A1C1">=</span><span style="color: #D8DEE9FF"> aws_secretsmanager_secret</span><span style="color: #B48EAD">.</span><span style="color: #D8DEE9FF">my_secrets[</span><span style="color: #A3BE8C">"test/area/role"</span><span style="color: #D8DEE9FF">].arn</span></div><div class="line"><span style="color: #D8DEE9FF"> }</span></div><div class="line"><span style="color: #D8DEE9FF"> ],</span></div><div class="line"><span style="color: #D8DEE9FF"> </span><span style="color: #D8DEE9">logConfiguration</span><span style="color: #D8DEE9FF"> </span><span style="color: #81A1C1">=</span><span style="color: #D8DEE9FF"> {</span></div><div class="line"><span style="color: #D8DEE9FF"> logDriver = "awslogs",</span></div><div class="line"><span style="color: #D8DEE9FF"> options = {</span></div><div class="line"><span style="color: #D8DEE9FF"> awslogs-group = "/ecs/my-scheduled-task-logs"</span></div><div class="line"><span style="color: #D8DEE9FF"> awslogs-region = "us-east-2"</span></div><div class="line"><span style="color: #D8DEE9FF"> awslogs-stream-prefix = "demo"</span></div><div class="line"><span style="color: #D8DEE9FF"> }</span></div><div class="line"><span style="color: #D8DEE9FF"> }</span></div><div class="line"><span style="color: #D8DEE9FF"> }</span></div><div class="line"><span style="color: #D8DEE9FF"> ])</span></div><div class="line"><span style="color: #D8DEE9FF">}</span></div><div class="line"></div><div class="line"><span style="color: #616E88"># Trigger this task using the scheduled event</span></div><div class="line"><span style="color: #81A1C1">resource</span><span style="color: #A3BE8C"> "aws_cloudwatch_event_target" "ecs_my_command_target"</span><span style="color: #D8DEE9FF"> {</span></div><div class="line"><span style="color: #D8DEE9FF"> </span><span style="color: #D8DEE9">rule</span><span style="color: #D8DEE9FF"> </span><span style="color: #81A1C1">=</span><span style="color: #D8DEE9FF"> aws_cloudwatch_event_rule</span><span style="color: #B48EAD">.</span><span style="color: #D8DEE9FF">run_at_23_59</span><span style="color: #B48EAD">.</span><span style="color: #D8DEE9FF">name</span></div><div class="line"><span style="color: #D8DEE9FF"> </span><span style="color: #D8DEE9">role_arn</span><span style="color: #D8DEE9FF"> </span><span style="color: #81A1C1">=</span><span style="color: #D8DEE9FF"> aws_iam_role</span><span style="color: #B48EAD">.</span><span style="color: #D8DEE9FF">eventbridge_invoke_ecs</span><span style="color: #B48EAD">.</span><span style="color: #D8DEE9FF">arn</span></div><div class="line"><span style="color: #D8DEE9FF"> </span><span style="color: #D8DEE9">target_id</span><span style="color: #D8DEE9FF"> </span><span style="color: #81A1C1">=</span><span style="color: #D8DEE9FF"> </span><span style="color: #A3BE8C">"ecs-task-my-command"</span></div><div class="line"></div><div class="line"><span style="color: #D8DEE9FF"> </span><span style="color: #D8DEE9">arn</span><span style="color: #D8DEE9FF"> </span><span style="color: #81A1C1">=</span><span style="color: #D8DEE9FF"> aws_ecs_cluster</span><span style="color: #B48EAD">.</span><span style="color: #D8DEE9FF">my_cluster</span><span style="color: #B48EAD">.</span><span style="color: #D8DEE9FF">arn</span></div><div class="line"><span style="color: #D8DEE9FF"> </span><span style="color: #81A1C1">ecs_target</span><span style="color: #D8DEE9FF"> {</span></div><div class="line"><span style="color: #D8DEE9FF"> </span><span style="color: #D8DEE9">task_definition_arn</span><span style="color: #D8DEE9FF"> </span><span style="color: #81A1C1">=</span><span style="color: #D8DEE9FF"> aws_ecs_task_definition</span><span style="color: #B48EAD">.</span><span style="color: #D8DEE9FF">scheduled_my_command</span><span style="color: #B48EAD">.</span><span style="color: #D8DEE9FF">arn</span></div><div class="line"><span style="color: #D8DEE9FF"> </span><span style="color: #D8DEE9">launch_type</span><span style="color: #D8DEE9FF"> </span><span style="color: #81A1C1">=</span><span style="color: #D8DEE9FF"> </span><span style="color: #A3BE8C">"FARGATE"</span></div><div class="line"><span style="color: #D8DEE9FF"> </span><span style="color: #81A1C1">network_configuration</span><span style="color: #D8DEE9FF"> {</span></div><div class="line"><span style="color: #D8DEE9FF"> </span><span style="color: #D8DEE9">subnets</span><span style="color: #D8DEE9FF"> </span><span style="color: #81A1C1">=</span><span style="color: #D8DEE9FF"> [aws_subnet</span><span style="color: #B48EAD">.</span><span style="color: #D8DEE9FF">public</span><span style="color: #B48EAD">.</span><span style="color: #D8DEE9FF">id]</span></div><div class="line"><span style="color: #D8DEE9FF"> </span><span style="color: #D8DEE9">security_groups</span><span style="color: #D8DEE9FF"> </span><span style="color: #81A1C1">=</span><span style="color: #D8DEE9FF"> [aws_security_group</span><span style="color: #B48EAD">.</span><span style="color: #D8DEE9FF">ecs_tasks</span><span style="color: #B48EAD">.</span><span style="color: #D8DEE9FF">id]</span></div><div class="line"><span style="color: #D8DEE9FF"> </span><span style="color: #D8DEE9">assign_public_ip</span><span style="color: #D8DEE9FF"> </span><span style="color: #81A1C1">=</span><span style="color: #D8DEE9FF"> </span><span style="color: #B48EAD">true</span></div><div class="line"><span style="color: #D8DEE9FF"> }</span></div><div class="line"><span style="color: #D8DEE9FF"> }</span></div><div class="line"></div><div class="line"><span style="color: #D8DEE9FF"> </span><span style="color: #616E88"># This is only needed for debugging. See the final section</span></div><div class="line"><span style="color: #D8DEE9FF"> </span><span style="color: #616E88"># dead_letter_config {</span></div><div class="line"><span style="color: #D8DEE9FF"> </span><span style="color: #616E88"># arn = aws_sqs_queue.failed_invocations.arn</span></div><div class="line"><span style="color: #D8DEE9FF"> </span><span style="color: #616E88"># }</span></div><div class="line"><span style="color: #D8DEE9FF">}</span></div></code></div></pre>
<h2 id="help%2C-it's-not-working!" tabindex="-1">Help, it's not working!</h2>
<p>Last, but not least, debugging! If your task isn't triggering as expected, you can configure a DeadLetterQueue (DLQ) to receieve the error messages from AWS. If you choose to do this, uncomment the <code>dead_letter_config</code> section of the task definition above.</p>
<pre class="shiki nord" style="background-color: #2e3440ff; color: #d8dee9ff"><div class="language-id">hcl</div><div class="code-container"><code><div class="line"><span style="color: #81A1C1">resource</span><span style="color: #A3BE8C"> "aws_sqs_queue" "failed_invocations"</span><span style="color: #D8DEE9FF"> {</span></div><div class="line"><span style="color: #D8DEE9FF"> </span><span style="color: #D8DEE9">name</span><span style="color: #D8DEE9FF"> </span><span style="color: #81A1C1">=</span><span style="color: #D8DEE9FF"> </span><span style="color: #A3BE8C">"eventbridge-ecs-dlq"</span></div><div class="line"><span style="color: #D8DEE9FF">}</span></div><div class="line"></div><div class="line"><span style="color: #81A1C1">resource</span><span style="color: #A3BE8C"> "aws_sqs_queue_policy" "allow_eventbridge"</span><span style="color: #D8DEE9FF"> {</span></div><div class="line"><span style="color: #D8DEE9FF"> </span><span style="color: #D8DEE9">queue_url</span><span style="color: #D8DEE9FF"> </span><span style="color: #81A1C1">=</span><span style="color: #D8DEE9FF"> aws_sqs_queue</span><span style="color: #B48EAD">.</span><span style="color: #D8DEE9FF">failed_invocations</span><span style="color: #B48EAD">.</span><span style="color: #D8DEE9FF">id</span></div><div class="line"></div><div class="line"><span style="color: #D8DEE9FF"> </span><span style="color: #D8DEE9">policy</span><span style="color: #D8DEE9FF"> </span><span style="color: #81A1C1">=</span><span style="color: #D8DEE9FF"> jsonencode({</span></div><div class="line"><span style="color: #D8DEE9FF"> Version = "2012-10-17",</span></div><div class="line"><span style="color: #D8DEE9FF"> Statement = [</span></div><div class="line"><span style="color: #D8DEE9FF"> {</span></div><div class="line"><span style="color: #D8DEE9FF"> Sid = "AllowEventBridgeToSendMessages",</span></div><div class="line"><span style="color: #D8DEE9FF"> Effect = "Allow",</span></div><div class="line"><span style="color: #D8DEE9FF"> Principal = {</span></div><div class="line"><span style="color: #D8DEE9FF"> Service = "events.amazonaws.com"</span></div><div class="line"><span style="color: #D8DEE9FF"> },</span></div><div class="line"><span style="color: #D8DEE9FF"> </span><span style="color: #D8DEE9">Action</span><span style="color: #D8DEE9FF"> </span><span style="color: #81A1C1">=</span><span style="color: #D8DEE9FF"> </span><span style="color: #A3BE8C">"sqs:SendMessage"</span><span style="color: #D8DEE9FF">,</span></div><div class="line"><span style="color: #D8DEE9FF"> </span><span style="color: #D8DEE9">Resource</span><span style="color: #D8DEE9FF"> </span><span style="color: #81A1C1">=</span><span style="color: #D8DEE9FF"> aws_sqs_queue</span><span style="color: #B48EAD">.</span><span style="color: #D8DEE9FF">failed_invocations</span><span style="color: #B48EAD">.</span><span style="color: #D8DEE9FF">arn,</span></div><div class="line"><span style="color: #D8DEE9FF"> </span><span style="color: #D8DEE9">Condition</span><span style="color: #D8DEE9FF"> </span><span style="color: #81A1C1">=</span><span style="color: #D8DEE9FF"> {</span></div><div class="line"><span style="color: #D8DEE9FF"> ArnEquals = {</span></div><div class="line"><span style="color: #D8DEE9FF"> "aws:SourceArn" = aws_cloudwatch_event_rule.run_at_23_59.arn</span></div><div class="line"><span style="color: #D8DEE9FF"> }</span></div><div class="line"><span style="color: #D8DEE9FF"> }</span></div><div class="line"><span style="color: #D8DEE9FF"> }</span></div><div class="line"><span style="color: #D8DEE9FF"> ]</span></div><div class="line"><span style="color: #D8DEE9FF"> })</span></div><div class="line"><span style="color: #D8DEE9FF">}</span></div></code></div></pre>
<p>To read messages from the dead letter queue, use the <code>aws</code> CLI tool:</p>
<pre class="shiki nord" style="background-color: #2e3440ff; color: #d8dee9ff"><div class="language-id">bash</div><div class="code-container"><code><div class="line"><span style="color: #616E88"># Fetch the queue URL</span></div><div class="line"><span style="color: #D8DEE9FF">aws sqs get-queue-url --queue-name eventbridge-ecs-dlq</span></div><div class="line"></div><div class="line"><span style="color: #616E88"># Read messages - change the URL for your ID</span></div><div class="line"><span style="color: #D8DEE9FF">aws sqs receive-message \</span></div><div class="line"><span style="color: #D8DEE9FF"> --queue-url https://sqs.us-east-2.amazonaws.com/111111111111/eventbridge-ecs-dlq \</span></div><div class="line"><span style="color: #D8DEE9FF"> --max-number-of-messages 10 \</span></div><div class="line"><span style="color: #D8DEE9FF"> --wait-time-seconds 5 \</span></div><div class="line"><span style="color: #D8DEE9FF"> --message-attribute-names All \</span></div><div class="line"><span style="color: #D8DEE9FF"> --attribute-names All</span></div></code></div></pre>
<h2 id="conclusion" tabindex="-1">Conclusion</h2>
<p>The parts of this that gave me the most trouble were figuring out how to debug using <code>dead_letter_queue</code> and that the IAM policy for <code>runTask</code> needed access to the cluster too.</p>
<p>Hopefully this has helped you (or will help me again in the future) to deploy scheduled tasks on ECS.</p>
</content>
</entry>
<entry>
<title>Pin your GitHub Actions</title>
<link href="https://michaelheap.com/pin-your-github-actions/"/>
<updated>2025-03-15T20:19:27Z</updated>
<id>https://michaelheap.com/pin-your-github-actions/</id>
<content type="html"><p>Way back in 2019, Julien Renaux published <a href="https://julienrenaux.fr/2019/12/20/github-actions-security-risk/">Use GitHub Actions at your own risk</a>. While the title is a little sensational, it correctly pointed out that any maintainer can update a branch or tag to point at new code without you knowing. This means that if any action is compromised, you'll start leaking secrets without knowing it.</p>
<p>Today, <code>tj-actions/changed-files</code>, a widely used GitHub Action <a href="https://www.stepsecurity.io/blog/harden-runner-detection-tj-actions-changed-files-action-is-compromised">was compromised</a> and started leaking secrets.</p>
<p>Hopefully this was the wakeup call the industry needed to start paying attention to supply chain security.</p>
<h2 id="solving-the-problem" tabindex="-1">Solving the problem</h2>
<p>Security is always a trade-off. You can solve the supply chain problem by specifying a full length commit SHA, but fetching that SHA for every action is a painful process. Then, whenever you want to upgrade your action you have to do it all again.</p>
<p>Thankfully, there are tools and automations available to solve this problem. You can be secure <em>and</em> feel minimal pain thanks to these projects:</p>
<ol>
<li><a href="https://github.com/mheap/pin-github-action">pin-github-action</a> - This is one of my projects. It takes a directory of workflows and uses the GitHub API to convert tag and branch references to a full length commit SHA.</li>
<li><a href="https://github.com/zgosalvez/github-actions-ensure-sha-pinned-actions">github-actions-ensure-sha-pinned-actions</a> - A community action that will fail the build if it detects any unpinned actions being used in the current repository.</li>
<li><a href="https://docs.github.com/en/code-security/dependabot/working-with-dependabot/keeping-your-actions-up-to-date-with-dependabot">Dependabot</a> / <a href="https://docs.renovatebot.com/modules/manager/github-actions/">Renovate</a> - Dependency management systems that submits PRs to upgrade GitHub Actions. They both natively understand the <code># &lt;ref&gt;</code> comments in workflow files.</li>
</ol>
<h3 id="pin-to-a-sha" tabindex="-1">Pin to a SHA</h3>
<p>The first thing to do is update all of your existing workflows to use the long commit SHA using <code>pin-github-action</code>.</p>
<p>You can run it with <code>npx</code> or <code>docker</code>. If you're not sure which to use, use <code>docker</code> through the following alias:</p>
<pre class="shiki nord" style="background-color: #2e3440ff; color: #d8dee9ff"><div class="language-id">bash</div><div class="code-container"><code><div class="line"><span style="color: #88C0D0">alias</span><span style="color: #D8DEE9FF"> pin-github-action=</span><span style="color: #ECEFF4">"</span><span style="color: #A3BE8C">docker run --rm -v </span><span style="color: #ECEFF4">$(</span><span style="color: #A3BE8C">pwd</span><span style="color: #ECEFF4">)</span><span style="color: #A3BE8C">:/workflows -e GITHUB_TOKEN mheap/pin-github-action</span><span style="color: #ECEFF4">"</span></div></code></div></pre>
<p>If you're working with a large number of workflows, or any private actions you'll need to set the <code>GITHUB_TOKEN</code> environment variable to prevent rate limiting or provide valid access credentials to a repository:</p>
<pre class="shiki nord" style="background-color: #2e3440ff; color: #d8dee9ff"><div class="language-id">bash</div><div class="code-container"><code><div class="line"><span style="color: #81A1C1">export</span><span style="color: #D8DEE9FF"> GITHUB_TOKEN=</span><span style="color: #ECEFF4">"</span><span style="color: #A3BE8C">ghp_YOUR_TOKEN_HERE</span><span style="color: #ECEFF4">"</span></div></code></div></pre>
<p>Finally, change directory to your repository and run <code>pin-github-action</code>:</p>
<pre class="shiki nord" style="background-color: #2e3440ff; color: #d8dee9ff"><div class="language-id">bash</div><div class="code-container"><code><div class="line"><span style="color: #88C0D0">cd</span><span style="color: #D8DEE9FF"> my-repo</span></div><div class="line"><span style="color: #D8DEE9FF">pin-github-action .github/workflows</span></div></code></div></pre>
<p>If you run <code>git diff .github/workflows</code> you'll see that all of your actions have been updated to the long commit SHA:</p>
<pre class="shiki nord" style="background-color: #2e3440ff; color: #d8dee9ff"><div class="language-id">diff</div><div class="code-container"><code><div class="line"><span style="color: #D8DEE9FF"> steps:</span></div><div class="line"><span style="color: #ECEFF4">-</span><span style="color: #BF616A"> - uses: actions/checkout@v4</span></div><div class="line"><span style="color: #ECEFF4">+</span><span style="color: #A3BE8C"> - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4</span></div><div class="line"><span style="color: #ECEFF4">-</span><span style="color: #BF616A"> - uses: MyOrg/some-action@v1</span></div><div class="line"><span style="color: #ECEFF4">+</span><span style="color: #A3BE8C"> - uses: MyOrg/some-action@25ed13d0628a1601b4b44048e63cc4328ed03633 # v1</span></div></code></div></pre>
<p>I recommend pinning all actions to a SHA, but this may not be feasible for some companies that use internal actions. If you want to trust internal actions, you can pass the <code>--allow</code> flag to <code>pin-github-action</code> to add a specific prefix to an allowlist:</p>
<pre class="shiki nord" style="background-color: #2e3440ff; color: #d8dee9ff"><div class="language-id">bash</div><div class="code-container"><code><div class="line"><span style="color: #D8DEE9FF">pin-github-action --allow </span><span style="color: #ECEFF4">"</span><span style="color: #A3BE8C">MyOrg/*</span><span style="color: #ECEFF4">"</span><span style="color: #D8DEE9FF"> .github/workflows</span></div></code></div></pre>
<p>This will ignore any actions with the prefix <code>MyOrg</code>:</p>
<pre class="shiki nord" style="background-color: #2e3440ff; color: #d8dee9ff"><div class="language-id">diff</div><div class="code-container"><code><div class="line"><span style="color: #D8DEE9FF"> steps:</span></div><div class="line"><span style="color: #ECEFF4">-</span><span style="color: #BF616A"> - uses: actions/checkout@v4</span></div><div class="line"><span style="color: #ECEFF4">+</span><span style="color: #A3BE8C"> - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4</span></div><div class="line"><span style="color: #D8DEE9FF"> - uses: MyOrg/some-action@v1</span></div></code></div></pre>
<h3 id="prevent-regressions" tabindex="-1">Prevent regressions</h3>
<p>Pinning all actions to a specific SHA solves the problem today, but it doesn't guarantee that a new action won't be added in the future without using a SHA. To prevent that happening, <a href="https://github.com/zgosalvez/github-actions-ensure-sha-pinned-actions">github-actions-ensure-sha-pinned-actions</a> can be used to fail the build when any unpinned actions are detected.</p>
<p>The README for the action contains examples, but if you're using <code>pin-github-action</code> you can automatically add a new workflow using the <code>--enforce</code> flag. The <code>--enforce</code> flag writes a workflow containing <code>github-actions-ensure-sha-pinned-actions</code> to the path provided, including adding any actions passed in <code>--allow</code> to the <code>allowlist</code> input for the action.</p>
<p>The following command will create a workflow at <code>.github/workflows/security.yaml</code> that ensures all actions are using the long commit SHA, <em>unless</em> they are actions from <code>MyOrg</code>:</p>
<pre class="shiki nord" style="background-color: #2e3440ff; color: #d8dee9ff"><div class="language-id">bash</div><div class="code-container"><code><div class="line"><span style="color: #D8DEE9FF">pin-github-action --allow </span><span style="color: #ECEFF4">"</span><span style="color: #A3BE8C">MyOrg/*</span><span style="color: #ECEFF4">"</span><span style="color: #D8DEE9FF"> --enforce .github/workflows/security.yaml .github/workflows </span></div></code></div></pre>
<p>The created workflow looks like this:</p>
<pre class="shiki nord" style="background-color: #2e3440ff; color: #d8dee9ff"><div class="language-id">yaml</div><div class="code-container"><code><div class="line"><span style="color: #81A1C1">on</span><span style="color: #ECEFF4">:</span><span style="color: #D8DEE9FF"> </span><span style="color: #A3BE8C">push</span></div><div class="line"></div><div class="line"><span style="color: #8FBCBB">name</span><span style="color: #ECEFF4">:</span><span style="color: #D8DEE9FF"> </span><span style="color: #A3BE8C">Security</span></div><div class="line"></div><div class="line"><span style="color: #8FBCBB">jobs</span><span style="color: #ECEFF4">:</span></div><div class="line"><span style="color: #D8DEE9FF"> </span><span style="color: #8FBCBB">ensure-pinned-actions</span><span style="color: #ECEFF4">:</span></div><div class="line"><span style="color: #D8DEE9FF"> </span><span style="color: #8FBCBB">runs-on</span><span style="color: #ECEFF4">:</span><span style="color: #D8DEE9FF"> </span><span style="color: #A3BE8C">ubuntu-latest</span></div><div class="line"><span style="color: #D8DEE9FF"> </span><span style="color: #8FBCBB">steps</span><span style="color: #ECEFF4">:</span></div><div class="line"><span style="color: #D8DEE9FF"> </span><span style="color: #ECEFF4">-</span><span style="color: #D8DEE9FF"> </span><span style="color: #8FBCBB">name</span><span style="color: #ECEFF4">:</span><span style="color: #D8DEE9FF"> </span><span style="color: #A3BE8C">Checkout code</span></div><div class="line"><span style="color: #D8DEE9FF"> </span><span style="color: #8FBCBB">uses</span><span style="color: #ECEFF4">:</span><span style="color: #D8DEE9FF"> </span><span style="color: #A3BE8C">actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683</span><span style="color: #D8DEE9FF"> </span><span style="color: #616E88"># v4</span></div><div class="line"><span style="color: #D8DEE9FF"> </span><span style="color: #ECEFF4">-</span><span style="color: #D8DEE9FF"> </span><span style="color: #8FBCBB">name</span><span style="color: #ECEFF4">:</span><span style="color: #D8DEE9FF"> </span><span style="color: #A3BE8C">Ensure SHA pinned actions</span></div><div class="line"><span style="color: #D8DEE9FF"> </span><span style="color: #8FBCBB">uses</span><span style="color: #ECEFF4">:</span><span style="color: #D8DEE9FF"> </span><span style="color: #A3BE8C">zgosalvez/github-actions-ensure-sha-pinned-actions@25ed13d0628a1601b4b44048e63cc4328ed03633</span><span style="color: #D8DEE9FF"> </span><span style="color: #616E88"># v3</span></div><div class="line"><span style="color: #D8DEE9FF"> </span><span style="color: #8FBCBB">with</span><span style="color: #ECEFF4">:</span></div><div class="line"><span style="color: #D8DEE9FF"> </span><span style="color: #8FBCBB">allowlist</span><span style="color: #ECEFF4">:</span><span style="color: #D8DEE9FF"> </span><span style="color: #81A1C1">|</span></div><div class="line"><span style="color: #A3BE8C"> MyOrg/</span></div></code></div></pre>
<h3 id="automating-updates" tabindex="-1">Automating updates</h3>
<p>Finally, we need to keep our dependencies up to date. You have two options here:</p>
<ol>
<li>Run <code>pin-github-action</code> again</li>
<li>Use Dependabot or Renovate</li>
</ol>
<p><strong>I recommend using Dependabot or Renovate.</strong></p>
<p>Running <code>pin-github-action</code> on a repository with pinned SHAs and will extract the target version from the comment and update the SHA like so:</p>
<pre class="shiki nord" style="background-color: #2e3440ff; color: #d8dee9ff"><div class="language-id">diff</div><div class="code-container"><code><div class="line"><span style="color: #D8DEE9FF"> steps:</span></div><div class="line"><span style="color: #ECEFF4">-</span><span style="color: #BF616A"> - uses: actions/checkout@eef61447b9ff4aafe5dcd4e0bbf5d482be7e7871 # v4</span></div><div class="line"><span style="color: #ECEFF4">+</span><span style="color: #A3BE8C"> - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4</span></div></code></div></pre>
<p>This is great for updating actions in bulk, but it doesn't improve your security posture that much. You're still blindly upgrading to the latest version.</p>
<p>I recommend using Dependabot or Renovate to update your actions. Both tools create Pull Requests with updated SHAs and provide a diff for review. Each pull request contains a link to the <code>compare</code> view on GitHub that you can use to audit the changes since your last update and ensure that the action has not been compromised.</p>
<h3 id="stay-safe%2C-pin-your-dependencies" tabindex="-1">Stay safe, pin your dependencies</h3>
<p>The compromise of <code>tj-actions/changed-files</code> serves as a crucial reminder of the security risks in GitHub Actions. While pinning actions to commit SHAs adds an extra step, it significantly reduces the risk of supply chain attacks.</p>
<p>By leveraging tools like <code>pin-github-action</code>, enforcing SHA pinning with <code>github-actions-ensure-sha-pinned-actions</code>, and automating updates with Dependabot or Renovate, teams can secure their workflows without unnecessary overhead.</p>
<p>Supply chain security is an ongoing effort, but with these practices in place, you can proactively protect your repositories and secrets from potential threats.</p>
</content>
</entry>
<entry>
<title>Output a JSON file with Jekyll</title>
<link href="https://michaelheap.com/jekyll-output-json/"/>
<updated>2025-03-14T21:14:14Z</updated>
<id>https://michaelheap.com/jekyll-output-json/</id>
<content type="html"><p>I needed to expose some of the data we had in <code>app/_data</code> in our Jekyll deployment for consumption in another project. Instead of copy/pasting the data, I chose to expose it as an API.</p>
<p>To do the same, create a file named <code>app/_plugins/your_name_here_hook.rb</code> with the following contents:</p>
<pre class="shiki nord" style="background-color: #2e3440ff; color: #d8dee9ff"><div class="language-id">ruby</div><div class="code-container"><code><div class="line"><span style="color: #81A1C1">module</span><span style="color: #D8DEE9FF"> MyApi</span></div><div class="line"><span style="color: #D8DEE9FF"> </span><span style="color: #81A1C1">def</span><span style="color: #D8DEE9FF"> </span><span style="color: #88C0D0">self.process</span><span style="color: #ECEFF4">(</span><span style="color: #D8DEE9">site</span><span style="color: #ECEFF4">)</span><span style="color: #D8DEE9FF"> </span></div><div class="line"><span style="color: #D8DEE9FF"> api_prefix </span><span style="color: #81A1C1">=</span><span style="color: #D8DEE9FF"> </span><span style="color: #ECEFF4">'</span><span style="color: #A3BE8C">api</span><span style="color: #ECEFF4">'</span></div><div class="line"><span style="color: #D8DEE9FF"> the_data </span><span style="color: #81A1C1">=</span><span style="color: #D8DEE9FF"> site</span><span style="color: #ECEFF4">.</span><span style="color: #D8DEE9FF">data</span><span style="color: #ECEFF4">[</span><span style="color: #ECEFF4">'</span><span style="color: #A3BE8C">path_to</span><span style="color: #ECEFF4">'</span><span style="color: #ECEFF4">][</span><span style="color: #ECEFF4">'</span><span style="color: #A3BE8C">your</span><span style="color: #ECEFF4">'</span><span style="color: #ECEFF4">][</span><span style="color: #ECEFF4">'</span><span style="color: #A3BE8C">data</span><span style="color: #ECEFF4">'</span><span style="color: #ECEFF4">]</span></div><div class="line"></div><div class="line"><span style="color: #D8DEE9FF"> </span><span style="color: #8FBCBB">FileUtils</span><span style="color: #ECEFF4">.</span><span style="color: #D8DEE9FF">mkdir_p</span><span style="color: #ECEFF4">(</span><span style="color: #ECEFF4">"</span><span style="color: #81A1C1">#{</span><span style="color: #A3BE8C">site</span><span style="color: #ECEFF4">.</span><span style="color: #A3BE8C">dest</span><span style="color: #81A1C1">}</span><span style="color: #A3BE8C">/</span><span style="color: #81A1C1">#{</span><span style="color: #A3BE8C">api_prefix</span><span style="color: #81A1C1">}</span><span style="color: #ECEFF4">"</span><span style="color: #ECEFF4">)</span></div><div class="line"><span style="color: #D8DEE9FF"> </span><span style="color: #8FBCBB">File</span><span style="color: #ECEFF4">.</span><span style="color: #D8DEE9FF">write</span><span style="color: #ECEFF4">(</span><span style="color: #ECEFF4">"</span><span style="color: #81A1C1">#{</span><span style="color: #A3BE8C">site</span><span style="color: #ECEFF4">.</span><span style="color: #A3BE8C">dest</span><span style="color: #81A1C1">}</span><span style="color: #A3BE8C">/</span><span style="color: #81A1C1">#{</span><span style="color: #A3BE8C">api_prefix</span><span style="color: #81A1C1">}</span><span style="color: #A3BE8C">/your-data.json</span><span style="color: #ECEFF4">"</span><span style="color: #ECEFF4">,</span><span style="color: #D8DEE9FF"> the_data</span><span style="color: #ECEFF4">.</span><span style="color: #D8DEE9FF">to_json</span><span style="color: #ECEFF4">)</span></div><div class="line"><span style="color: #D8DEE9FF"> </span><span style="color: #81A1C1">end</span></div><div class="line"><span style="color: #81A1C1">end</span></div><div class="line"></div><div class="line"><span style="color: #8FBCBB">Jekyll</span><span style="color: #ECEFF4">::</span><span style="color: #8FBCBB">Hooks</span><span style="color: #ECEFF4">.</span><span style="color: #D8DEE9FF">register </span><span style="color: #ECEFF4">:</span><span style="color: #D8DEE9FF">site</span><span style="color: #ECEFF4">,</span><span style="color: #D8DEE9FF"> </span><span style="color: #ECEFF4">:</span><span style="color: #D8DEE9FF">post_write </span><span style="color: #81A1C1">do</span><span style="color: #D8DEE9FF"> </span><span style="color: #ECEFF4">|</span><span style="color: #D8DEE9">site</span><span style="color: #ECEFF4">,</span><span style="color: #D8DEE9FF"> </span><span style="color: #D8DEE9">_</span><span style="color: #ECEFF4">|</span></div><div class="line"><span style="color: #D8DEE9FF"> </span><span style="color: #8FBCBB">MyApi</span><span style="color: #ECEFF4">.</span><span style="color: #D8DEE9FF">process</span><span style="color: #ECEFF4">(</span><span style="color: #D8DEE9FF">site</span><span style="color: #ECEFF4">)</span></div><div class="line"><span style="color: #81A1C1">end</span></div></code></div></pre>
<p>Then you can access <code>/api/your-data.json</code> on your Jekyll deployment to see the data.</p>
</content>
</entry>
<entry>
<title>Running Scrumdog with Docker</title>
<link href="https://michaelheap.com/scrumdog-docker/"/>
<updated>2025-02-25T19:25:33Z</updated>
<id>https://michaelheap.com/scrumdog-docker/</id>
<content type="html"><p>I was intrigued by the idea of <a href="https://github.com/whoek/scrumdog">Scrumdog</a>, which allows you to export Jira tickets to a sqlite database.</p>
<p>Unfortunately the website is now offline, and the provided binaries don't work on non-intel macs. Thankfully, Scrumdog is open source so I can just build it myself... except that it's written in OCaml and I don't have any of the toolchain installed and don't really want to install it just for one project.</p>
<p>So, I reached for Docker.</p>
<p>It seems as though the <code>opam</code> dependency file isn't configured correctly for scrumdog, so here's a <code>Dockerfile</code> that explicitly installs the correct libraries:</p>
<pre class="shiki nord" style="background-color: #2e3440ff; color: #d8dee9ff"><div class="language-id">bash</div><div class="code-container"><code><div class="line"><span style="color: #616E88"># Build a base image with updated dependencies</span></div><div class="line"><span style="color: #D8DEE9FF">FROM ocaml/opam:alpine AS init-opam</span></div><div class="line"></div><div class="line"><span style="color: #D8DEE9FF">RUN </span><span style="color: #88C0D0">set</span><span style="color: #D8DEE9FF"> -x </span><span style="color: #81A1C1">&&</span><span style="color: #D8DEE9FF"> \</span></div><div class="line"><span style="color: #D8DEE9FF"> </span><span style="color: #88C0D0">:</span><span style="color: #D8DEE9FF"> </span><span style="color: #ECEFF4">"</span><span style="color: #A3BE8C">Update and upgrade default packages</span><span style="color: #ECEFF4">"</span><span style="color: #D8DEE9FF"> </span><span style="color: #81A1C1">&&</span><span style="color: #D8DEE9FF"> \</span></div><div class="line"><span style="color: #D8DEE9FF"> sudo apk update </span><span style="color: #81A1C1">&&</span><span style="color: #D8DEE9FF"> sudo apk upgrade </span><span style="color: #81A1C1">&&</span><span style="color: #D8DEE9FF"> \</span></div><div class="line"><span style="color: #D8DEE9FF"> sudo apk add gmp-dev sqlite-dev</span></div><div class="line"></div><div class="line"></div><div class="line"><span style="color: #616E88"># Install opam dependencies explicitly then build scrumdog</span></div><div class="line"><span style="color: #D8DEE9FF">FROM init-opam AS ocaml-app-base</span></div><div class="line"><span style="color: #D8DEE9FF">COPY </span><span style="color: #88C0D0">.</span><span style="color: #D8DEE9FF"> </span><span style="color: #88C0D0">.</span></div><div class="line"><span style="color: #D8DEE9FF">RUN </span><span style="color: #88C0D0">set</span><span style="color: #D8DEE9FF"> -x </span><span style="color: #81A1C1">&&</span><span style="color: #D8DEE9FF"> \</span></div><div class="line"><span style="color: #D8DEE9FF"> </span><span style="color: #88C0D0">:</span><span style="color: #D8DEE9FF"> </span><span style="color: #ECEFF4">"</span><span style="color: #A3BE8C">Install related packages</span><span style="color: #ECEFF4">"</span><span style="color: #D8DEE9FF"> </span><span style="color: #81A1C1">&&</span><span style="color: #D8DEE9FF"> \</span></div><div class="line"><span style="color: #D8DEE9FF"> opam install dune yojson sqlite3 cohttp-lwt-unix tls-lwt </span><span style="color: #81A1C1">&&</span><span style="color: #D8DEE9FF"> \</span></div><div class="line"><span style="color: #D8DEE9FF"> </span><span style="color: #88C0D0">eval</span><span style="color: #D8DEE9FF"> </span><span style="color: #ECEFF4">$(</span><span style="color: #A3BE8C">opam env</span><span style="color: #ECEFF4">)</span><span style="color: #D8DEE9FF"> </span><span style="color: #81A1C1">&&</span><span style="color: #D8DEE9FF"> \</span></div><div class="line"><span style="color: #D8DEE9FF"> </span><span style="color: #88C0D0">:</span><span style="color: #D8DEE9FF"> </span><span style="color: #ECEFF4">"</span><span style="color: #A3BE8C">Build applications</span><span style="color: #ECEFF4">"</span></div><div class="line"><span style="color: #D8DEE9FF">RUN </span><span style="color: #88C0D0">eval</span><span style="color: #D8DEE9FF"> </span><span style="color: #ECEFF4">$(</span><span style="color: #A3BE8C">opam env</span><span style="color: #ECEFF4">)</span><span style="color: #D8DEE9FF"> </span><span style="color: #81A1C1">&&</span><span style="color: #D8DEE9FF"> dune build</span></div><div class="line"></div><div class="line"></div><div class="line"><span style="color: #616E88"># Install dependencies + copy the built executable</span></div><div class="line"><span style="color: #D8DEE9FF">FROM alpine AS ocaml-app</span></div><div class="line"></div><div class="line"><span style="color: #D8DEE9FF">COPY --from=ocaml-app-base /home/opam/_build/default/bin/scrumdog.exe /home/bin/main.exe</span></div><div class="line"><span style="color: #D8DEE9FF">RUN </span><span style="color: #88C0D0">set</span><span style="color: #D8DEE9FF"> -x </span><span style="color: #81A1C1">&&</span><span style="color: #D8DEE9FF"> \</span></div><div class="line"><span style="color: #D8DEE9FF"> </span><span style="color: #88C0D0">:</span><span style="color: #D8DEE9FF"> </span><span style="color: #ECEFF4">"</span><span style="color: #A3BE8C">Update and upgrade default packages</span><span style="color: #ECEFF4">"</span><span style="color: #D8DEE9FF"> </span><span style="color: #81A1C1">&&</span><span style="color: #D8DEE9FF"> \</span></div><div class="line"><span style="color: #D8DEE9FF"> apk update </span><span style="color: #81A1C1">&&</span><span style="color: #D8DEE9FF"> apk upgrade </span><span style="color: #81A1C1">&&</span><span style="color: #D8DEE9FF"> \</span></div><div class="line"><span style="color: #D8DEE9FF"> apk add gmp-dev sqlite-dev </span><span style="color: #81A1C1">&&</span><span style="color: #D8DEE9FF"> \</span></div><div class="line"><span style="color: #D8DEE9FF"> </span><span style="color: #88C0D0">:</span><span style="color: #D8DEE9FF"> </span><span style="color: #ECEFF4">"</span><span style="color: #A3BE8C">Create a user to execute application</span><span style="color: #ECEFF4">"</span><span style="color: #D8DEE9FF"> </span><span style="color: #81A1C1">&&</span><span style="color: #D8DEE9FF"> \</span></div><div class="line"><span style="color: #D8DEE9FF"> adduser -D app </span><span style="color: #81A1C1">&&</span><span style="color: #D8DEE9FF"> \</span></div><div class="line"><span style="color: #D8DEE9FF"> </span><span style="color: #88C0D0">:</span><span style="color: #D8DEE9FF"> </span><span style="color: #ECEFF4">"</span><span style="color: #A3BE8C">Change owner to app</span><span style="color: #ECEFF4">"</span><span style="color: #D8DEE9FF"> </span><span style="color: #81A1C1">&&</span><span style="color: #D8DEE9FF"> \</span></div><div class="line"><span style="color: #D8DEE9FF"> chown app:app /home/bin/main.exe</span></div><div class="line"></div><div class="line"><span style="color: #D8DEE9FF">WORKDIR /home/app</span></div><div class="line"><span style="color: #D8DEE9FF">USER app</span></div><div class="line"><span style="color: #D8DEE9FF">ENTRYPOINT </span><span style="color: #ECEFF4">[</span><span style="color: #ECEFF4">"</span><span style="color: #A3BE8C">/home/bin/main.exe</span><span style="color: #ECEFF4">"</span><span style="color: #ECEFF4">]</span></div></code></div></pre>
<p>Finally, build the image and run it.</p>
<pre class="shiki nord" style="background-color: #2e3440ff; color: #d8dee9ff"><div class="language-id">bash</div><div class="code-container"><code><div class="line"><span style="color: #D8DEE9FF">docker build -t scrumdog-local </span><span style="color: #88C0D0">.</span></div><div class="line"><span style="color: #D8DEE9FF">docker run --rm -v </span><span style="color: #81A1C1">$</span><span style="color: #D8DEE9">PWD</span><span style="color: #D8DEE9FF">:/home/app scrumdog-local -j</span></div></code></div></pre>
<p>This generates a <code>sample.jql</code> file in the current directory that you can edit and use as a configuration file:</p>
<pre class="shiki nord" style="background-color: #2e3440ff; color: #d8dee9ff"><div class="language-id">bash</div><div class="code-container"><code><div class="line"><span style="color: #D8DEE9FF">docker run --rm -v </span><span style="color: #81A1C1">$</span><span style="color: #D8DEE9">PWD</span><span style="color: #D8DEE9FF">:/home/app scrumdog-local sample.jql</span></div></code></div></pre>
</content>
</entry>
<entry>
<title>Quality > Speed > Scope</title>
<link href="https://michaelheap.com/quality-speed-scope/"/>
<updated>2025-02-23T13:56:47Z</updated>
<id>https://michaelheap.com/quality-speed-scope/</id>
<content type="html"><p>In the world of product development, prioritization is everything. Should we optimize for quality, ship as fast as possible, or build as many features as we can?</p>
<ol>
<li>
<p><strong>Never compromise on quality.</strong><br />
A product that doesn’t work well, isn’t reliable, or doesn’t meet user expectations will ultimately fail. It doesn't matter how quickly you ship it or how many features it has. Users won’t stick around for a bad experience. A great product that solves a real problem with elegance and consistency will always win in the long run.</p>
</li>
<li>
<p><strong>Never compromise on speed.</strong><br />
Perfect is the enemy of good. If you’re not shipping, all the work you've done so far is delivering <em>zero</em> value. It’s easy to get caught up in refining every detail, but a product that never sees the light of day doesn’t help anyone. Shipping fast ensures that you’re constantly learning, iterating, and improving based on real-world feedback.</p>
</li>
<li>
<p><strong>Compromise on scope.</strong><br />
Instead of trying to build everything at once, focus on solving a narrow use case extremely well. Identify the core problem your users face, build the simplest and most effective solution for it, and get it in their hands as soon as possible. A great MVP isn’t about having the most features; it’s about having the right ones.</p>
</li>
</ol>
<p>Then, <strong>iterate relentlessly</strong>. Ship, learn, refine, and ship again. Small, incremental progress compounds over time.</p>
<p>If you follow this approach consistently, you’ll be amazed at what kind of product you can build in just six months. The best products don’t emerge from grand, ambitious plans. They evolve through <em>disciplined execution</em> and <em>continuous improvement</em>.</p>
</content>
</entry>
<entry>
<title>Using AWS credential_process and 1Password</title>
<link href="https://michaelheap.com/aws-credential-helper-1password/"/>
<updated>2025-02-18T17:15:37Z</updated>
<id>https://michaelheap.com/aws-credential-helper-1password/</id>
<content type="html"><p>A while back I read an excellent post from Paul Galow on <a href="https://paulgalow.com/securing-aws-credentials-macos-lastpass">securing AWS credentials with LastPass</a>. I wanted exactly this, but with 1Password instead. Here's how to do it.</p>
<h2 id="configure-1password" tabindex="-1">Configure 1Password</h2>
<p>If you don’t already have <code>op</code> installed, you’ll need to install the <a href="https://developer.1password.com/docs/cli/">op CLI</a> from the 1Password website.</p>
<p>Once that’s done, create a new vault for storing secrets that are accessible from the CLI:</p>
<pre class="shiki nord" style="background-color: #2e3440ff; color: #d8dee9ff"><div class="language-id">bash</div><div class="code-container"><code><div class="line"><span style="color: #D8DEE9FF">op vault create CLI</span></div></code></div></pre>
<p>Then create a new item in that vault, making sure to replace <code>XXX</code> with your actual access key and secret:</p>
<pre class="shiki nord" style="background-color: #2e3440ff; color: #d8dee9ff"><div class="language-id">bash</div><div class="code-container"><code><div class="line"><span style="color: #D8DEE9FF"> op item create --category Password --vault CLI --title AWSCredentials ACCESS_KEY=XXX SECRET_KEY=XXX</span></div></code></div></pre>
<blockquote>
<p>The above command is prefixed with a space so that it is not written to your shell history if <code>HIST_IGNORE_SPACE</code> is enabled</p>
</blockquote>
<h2 id="create-the-credential_process" tabindex="-1">Create the credential_process</h2>
<p>The AWS CLI has the ability to read credentials from a process rather than a static configuration file. It expects that the credential process will return a JSON document containing <code>AccessKeyid</code> and <code>SecretAccessKey</code>.</p>
<p>Copy and paste the following in to a terminal to create the credentials script:</p>
<pre class="shiki nord" style="background-color: #2e3440ff; color: #d8dee9ff"><div class="language-id">bash</div><div class="code-container"><code><div class="line"><span style="color: #81A1C1">export</span><span style="color: #D8DEE9FF"> AWS_HELPER=</span><span style="color: #ECEFF4">"</span><span style="color: #81A1C1">$</span><span style="color: #D8DEE9">HOME</span><span style="color: #A3BE8C">/bin/aws-1password</span><span style="color: #ECEFF4">"</span></div><div class="line"><span style="color: #D8DEE9FF">mkdir -p </span><span style="color: #81A1C1">$</span><span style="color: #D8DEE9">HOME</span><span style="color: #D8DEE9FF">/bin</span></div><div class="line"></div><div class="line"><span style="color: #88C0D0">echo</span><span style="color: #D8DEE9FF"> </span><span style="color: #ECEFF4">'</span><span style="color: #A3BE8C">#!/usr/bin/env bash</span></div><div class="line"><span style="color: #A3BE8C">readonly opVault="CLI"</span></div><div class="line"><span style="color: #A3BE8C">readonly opEntry="AWSCredentials"</span></div><div class="line"><span style="color: #A3BE8C">readonly accessKeyId=$(op read "op://$opVault/$opEntry/ACCESS_KEY")</span></div><div class="line"><span style="color: #A3BE8C">readonly secretAccessKey=$(op read "op://$opVault/$opEntry/SECRET_KEY")</span></div><div class="line"></div><div class="line"><span style="color: #A3BE8C"># Create JSON object that AWS CLI expects</span></div><div class="line"><span style="color: #A3BE8C">jq -n \</span></div><div class="line"><span style="color: #A3BE8C"> --arg accessKeyId "$accessKeyId" \</span></div><div class="line"><span style="color: #A3BE8C"> --arg secretAccessKey "$secretAccessKey" \</span></div><div class="line"><span style="color: #A3BE8C"> ".Version = 1</span></div><div class="line"><span style="color: #A3BE8C"> | .AccessKeyId = \$accessKeyId</span></div><div class="line"><span style="color: #A3BE8C"> | .SecretAccessKey = \$secretAccessKey"</span><span style="color: #ECEFF4">'</span><span style="color: #D8DEE9FF"> </span><span style="color: #81A1C1">&gt;</span><span style="color: #D8DEE9FF"> </span><span style="color: #81A1C1">$</span><span style="color: #D8DEE9">AWS_HELPER</span><span style="color: #81A1C1">;</span></div><div class="line"></div><div class="line"><span style="color: #D8DEE9FF">chmod +x </span><span style="color: #81A1C1">$</span><span style="color: #D8DEE9">AWS_HELPER</span></div><div class="line"></div></code></div></pre>
<h2 id="configure-aws" tabindex="-1">Configure AWS</h2>
<p>The final thing to do is to create an AWS config file that uses that script to fetch authentication credentials:</p>
<pre class="shiki nord" style="background-color: #2e3440ff; color: #d8dee9ff"><div class="language-id">bash</div><div class="code-container"><code><div class="line"><span style="color: #D8DEE9FF">mkdir -p </span><span style="color: #81A1C1">$</span><span style="color: #D8DEE9">HOME</span><span style="color: #D8DEE9FF">/.aws</span></div><div class="line"><span style="color: #88C0D0">echo</span><span style="color: #D8DEE9FF"> </span><span style="color: #ECEFF4">"</span><span style="color: #A3BE8C">[default]</span></div><div class="line"><span style="color: #A3BE8C">credential_process = </span><span style="color: #81A1C1">$</span><span style="color: #D8DEE9">AWS_HELPER</span><span style="color: #ECEFF4">"</span><span style="color: #D8DEE9FF"> </span><span style="color: #81A1C1">&gt;</span><span style="color: #D8DEE9FF"> </span><span style="color: #81A1C1">$</span><span style="color: #D8DEE9">HOME</span><span style="color: #D8DEE9FF">/.aws/config</span></div></code></div></pre>
<p>To check if it worked, run <code>aws configure list</code>. You should be prompted for your 1Password credentials to unlock the vault.</p>
<pre class="shiki nord" style="background-color: #2e3440ff; color: #d8dee9ff"><div class="language-id">bash</div><div class="code-container"><code><div class="line"><span style="color: #D8DEE9FF">$ aws configure list</span></div><div class="line"><span style="color: #D8DEE9FF"> Name Value Type Location</span></div><div class="line"><span style="color: #D8DEE9FF"> ---- ----- ---- --------</span></div><div class="line"><span style="color: #D8DEE9FF"> profile </span><span style="color: #81A1C1">&lt;</span><span style="color: #D8DEE9FF">not set</span><span style="color: #81A1C1">&gt;</span><span style="color: #D8DEE9FF"> None None</span></div><div class="line"><span style="color: #D8DEE9FF">access_key </span><span style="color: #81A1C1">****************</span><span style="color: #D8DEE9FF">XYZ custom-process</span></div><div class="line"><span style="color: #D8DEE9FF">secret_key </span><span style="color: #81A1C1">****************</span><span style="color: #D8DEE9FF">XYZ custom-process</span></div><div class="line"><span style="color: #D8DEE9FF"> region </span><span style="color: #81A1C1">&lt;</span><span style="color: #D8DEE9FF">not set</span><span style="color: #81A1C1">&gt;</span><span style="color: #D8DEE9FF"> None None</span></div></code></div></pre>
</content>
</entry>
<entry>
<title>Slack channels are free</title>
<link href="https://michaelheap.com/slack-channels-are-free/"/>
<updated>2025-02-12T20:51:45Z</updated>
<id>https://michaelheap.com/slack-channels-are-free/</id>
<content type="html"><p>“Should we create a new Slack channel for this?”</p>
<p>I’ve not seen a topic as divisive as this since the days of tabs vs spaces (tl;dr: <a href="https://adamtuttle.codes/blog/2021/tabs-vs-spaces-its-an-accessibility-issue/">use tabs</a>). Whenever there’s a suggestion to create a new channel, there are fervent discussions about why it’s a good/bad idea, with strong feelings on both sides.</p>
<p>I’m firmly in camp “as many channels as needed, sometimes more channels than employees”. Once you hit a certain size, having a few firehose channels for everything means that 80%+ of the messages in a channel are irrelevant to the majority of the audience.</p>
<p>Instead, spin up a new channel for each area of focus. This allows you to keep your working groups small (12 rather than 1200 people) and lets people focus on the channels that are important day to day.</p>
<p><strong>Extra credit:</strong> If you really want to help people focus, ensure that you use threads to prevent unread notifications. If you see a topic that you want to follow, click the “get notified about new replies” option in the context menu for the top level message.</p>
<h2 id="the-power-of-focused-channels" tabindex="-1">The Power of Focused Channels</h2>
<p>Using specific channels for each topic has immediate benefits. Scoping conversations down to a topic rather than it being a free for all provides consumers with:</p>
<ol>
<li>Notification control</li>
<li>Easier searching</li>
<li>Single purpose channels (e.g. discussion, alerts etc)</li>
<li>The correct audience</li>
</ol>
<p>Even Slack recommends using more, topic specific channels:</p>
<blockquote>
<p>When you set up multiple topic- and project-specific channels, groups can focus their discussions among smaller numbers of people, helping them to align and move faster. And having lots of specific channels means that each person can participate in fewer channels, because only a handful of them will be necessary for their daily work.<br />
<br />
via <a href="https://slack.com/intl/en-gb/resources/using-slack/how-to-organize-your-slack-channels">https://slack.com/intl/en-gb/resources/using-slack/how-to-organize-your-slack-channels</a></p>
</blockquote>
<h3 id="fewer-unnecessary-notifications" tabindex="-1">Fewer Unnecessary Notifications</h3>
<p>Being able to manage notifications is my favourite reason to use more Slack channels. Not all messages are created equally, but when they land in the same channel I have to manually read and filter the messages in my head.</p>
<p>For concrete example, imagine a company that has a single <code>#general</code> channel. It’s a place where company updates are shared, but also a place where people chat about the weekend. As a consumer I have to follow the channel in case I miss anything but the signal to noise ratio is low.</p>
<p>Now imagine that this channel is split in to <code>#announcements</code> and <code>#watercooler</code>. I can safely mute or leave the water cooler channel while paying close attention to <code>#announcements</code>.</p>
<h3 id="find-what-you-need%E2%80%94fast" tabindex="-1">Find What You Need—Fast</h3>
<p>Once you have separate channels, searching becomes much easier. All you need is a keyword or two and the channel that you recall seeing the conversation in.</p>
<p>When working on some new documentation, I searched in the <code>#support</code> channel for “flubjam”, the name of the product I was working on (no, thats not the real product name). This surfaced all the issues customers were having with the product that I can now weave in to the updated documentation.</p>
<h3 id="dedicated-channels%2C-better-workflows" tabindex="-1">Dedicated Channels, Better Workflows</h3>
<p>If we take the notification control benefit and supercharge it, we get to “single use channels”. Slack can do so much more than be a chat room for your team.</p>
<p>A <em>large</em> portion of my Slack usage is monitoring events:</p>
<ul>
<li>A new post on Stack Overflow with a specific tag goes to <code>#stack-overflow</code></li>
<li>New GitHub issues and releases for the flubjam project are piped in to <code>#notify-flubjam</code></li>
<li>All GitHub activity for the flubjam project is piped in to <code>#firehose-flubjam</code></li>
<li>Posts elsewhere on Slack that get a floppy disk icon are cross posted to <code>#zmeta-saved</code></li>
</ul>
<p>Creating single use channels gives people the information that they need, when they need it.</p>
<h3 id="reach-the-right-people%2C-every-time" tabindex="-1">Reach the Right People, Every Time</h3>
<p>Finally, using single purpose channels means that your audience is probably the one you’re looking for. Instead of blasting your question to 1200 people in a general channel, you can ask a targeted group your specific question.</p>
<p>I find this much easier than trying to figure out who’s involved in a project to set up a five way DM (which will inevitably miss out a key person who then feels slighted that I forgot them).</p>
<h2 id="finding-the-right-channels" tabindex="-1">Finding the Right Channels</h2>
<p>So if focused channels are so great, why don’t we all do it? There’s an easy answer:</p>
<p><strong>Finding new channels is hard</strong>.</p>
<p>There are a couple of ways to solve this problem. The most effective ones I’ve seen are:</p>
<ul>
<li>Have a channel that announces new channels (very meta!)</li>
<li>Set a reminder to check for new channels weekly using the Channels-&gt;Browse Channels option. Usually there haven’t been that many created in the last 7 days.</li>
<li>You can rely on people to invite you. Usually they won’t invite you directly. Instead, they’ll mention you by your Slack username, realise that you’re not there and then click “invite”. This means you only join channels when there is something for you to actively participate in</li>
<li>Finally, update your onboarding documentation with links to relevant channels (you do have onboarding documentation, right? 😁)</li>
</ul>
<h2 id="where-should-you-post%3F" tabindex="-1">Where Should You Post?</h2>
<p>Ok, so you have lots of tightly scoped channels. You’ve joined the channels relevant to your day to day work, and now you have a question about the “flubjam” project.</p>
<p>Do you post in <code>#team-product</code>, <code>#flubjam-is-cool</code> or <code>#general</code>?</p>
<p>Channel naming schemes can help with this. One of the patterns I’ve seen at a few different places now is to have an “<code>#ask-team-name</code>” channel that is public. Have a question for finance? <code>#ask-finance</code>! Curious about the product roadmap? <code>#ask-product</code>!</p>
<p>If you’re small enough that you have 1-3 products, keeping a single <code>#ask-product</code> channel makes sense.</p>
<p>As you expand your product offering, you may want to expand to “<code>#ask-product-name</code>” style channels, such as “<code>#ask-dev-portal</code>”. This allows the <a href="https://medium.com/design-bootcamp/the-power-of-the-product-triad-0e76801a384d">product triad</a> to answer the questions as a team rather than the questions going to a specific department all the time.</p>
<p>Finally, each product is made up of various initiatives and deliverables. You likely have a “<code>#private-team-a</code>” channel that you use to discuss ongoing projects, but I encourage you to bring that conversation in to “<code>#project-name</code>” channels. Not only does it give you a single place to read for any given project, it invites collaboration with other teams who may have suggestions based on their experience. These channels are short lived and should be archived as soon as the project is completed.</p>
<p>To recap:</p>
<ul>
<li><code>#ask-team-name</code> to get started</li>
<li><code>#ask-product-name</code> as the team grows</li>
<li><code>#project-name</code> to stop conversation sprawl and build a culture of collaboration</li>
</ul>
<p>With concrete examples:</p>
<ul>
<li><code>#ask-product</code> for general product questions</li>
<li><code>#ask-flubjam</code> for all flubjam related questions</li>
<li><code>#project-flubjam-capacitors</code> is a short term channel to talk about implementing capacitors in flubjam</li>
</ul>
<h2 id="create-that-slack-channel" tabindex="-1">Create That Slack Channel</h2>
<p>Focused channels improve notification control, make searching easier, ensure the right audience sees your messages, and unlock powerful workflows beyond just chat. While discoverability can be a challenge, a few simple strategies—like clear naming conventions—can make it easy for people to find the right spaces.</p>
<p>At the end of the day, good Slack hygiene isn’t about having fewer channels—it’s about having the <strong>right</strong> channels.</p>
<h2 id="update%3A-super-bonus-content" tabindex="-1">Update: Super Bonus Content</h2>
<p>I shared a preview of this post with some colleagues and <a href="https://www.linkedin.com/in/jasonhnaustin">Jason</a> had some great feedback on how to cope with channel sprawl:</p>
<ul>
<li><strong>Housecleaning built-in:</strong> If you have a topic that is not meant to be long-lived, a convention that flags the channel (usually <code>#temp-</code>) signifies that when the topic is resolved, the channel dies. This helps address the channel sprawl problem, and intentionally redirects folks back to long-lived comms channels, vs tactical problem solving.</li>
<li><strong>Join many, star few:</strong> the likelihood that you keep up with the conversation in every channel you join, in a &quot;Slack channels are free&quot; environment, is pretty much zero. Missing critical notifications on people you are working with regularly is a big risk to productivity, and job security in some cases. Star key channels, and mark your closest collaborators and reporting chain as &quot;VIP&quot;, so they are on a short-list to focus your attention.</li>
<li><strong>Ruthless prioritization on &quot;Leave Channel&quot;:</strong> if you're not contributing or engaging in a channel, leave it immediately. If you're mentioned, you'll be notified, and you can rejoin.</li>
</ul>
<p>As someone that left <em>175</em> Slack channels in his January cleanup, that last point on ruthless priorization is key. I'm already back in some of them, but I know it's because I'm actively engaged rather than through inertia.</p>
</content>
</entry>
</feed>