<?xml version="1.0" encoding="utf-8" standalone="yes"?><rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom"><channel><title>Github on PHP Boy Scout</title><link>https://phpboyscout.uk/tags/github/</link><description>Recent content in Github on PHP Boy Scout</description><generator>Hugo -- gohugo.io</generator><language>en-gb</language><copyright>Matt Cockayne</copyright><lastBuildDate>Fri, 19 Jun 2026 00:00:00 +0000</lastBuildDate><atom:link href="https://phpboyscout.uk/tags/github/index.xml" rel="self" type="application/rss+xml"/><item><title>Sign your own binaries with go-tool-base, part 3: keyless CI signing with OIDC</title><link>https://phpboyscout.uk/sign-your-own-binaries-with-go-tool-base-part-3/</link><pubDate>Fri, 19 Jun 2026 00:00:00 +0000</pubDate><guid>https://phpboyscout.uk/sign-your-own-binaries-with-go-tool-base-part-3/</guid><description>&lt;img src="https://phpboyscout.uk/sign-your-own-binaries-with-go-tool-base-part-3/cover-sign-your-own-binaries-with-go-tool-base-part-3.png" alt="Featured image of post Sign your own binaries with go-tool-base, part 3: keyless CI signing with OIDC" /&gt;&lt;p&gt;&lt;a class="link" href="https://phpboyscout.uk/sign-your-own-binaries-with-go-tool-base-part-2/" &gt;Part 2&lt;/a&gt;
left you with a KMS key your release pipeline can sign through and a role
(&lt;code&gt;&amp;lt;name&amp;gt;-signer&lt;/code&gt;) that&amp;rsquo;s allowed to call &lt;code&gt;kms:Sign&lt;/code&gt; and nothing else. There&amp;rsquo;s
one obvious question left hanging: how does a CI job &lt;em&gt;become&lt;/em&gt; that role without
an AWS access key stashed in a CI variable? Because a long-lived key sitting in
your settings is exactly the kind of credential that ends up in a breach
write-up. This part wires CI in with no stored credentials at all, on GitLab
&lt;em&gt;and&lt;/em&gt; GitHub.&lt;/p&gt;
&lt;p&gt;The mechanism is OIDC federation, and the one-sentence version is: your CI
platform already proves who it is to AWS, so let it. When a pipeline runs,
GitLab or GitHub can hand the job a short-lived signed token (a JWT) that says
&amp;ldquo;this is a tag pipeline on &lt;code&gt;acme/acme-cli&lt;/code&gt;&amp;rdquo;. AWS trusts that token the same way
a bouncer trusts a passport: it checks who issued it and what it says, and if
the claims match a role&amp;rsquo;s trust policy, it swaps the token for temporary AWS
credentials that live only for the job&amp;rsquo;s run. No key is stored anywhere; the
credentials are minted on the spot and evaporate when the runner stops. The
&lt;a class="link" href="https://phpboyscout.uk/no-access-keys-in-ci/" &gt;deep-dive on keyless CI&lt;/a&gt;
covers the why and the threat model; here we just do the wiring.&lt;/p&gt;
&lt;p&gt;Two things have to line up for this to work: the IAM OIDC &lt;em&gt;identity provider&lt;/em&gt;
(the thing in your AWS account that says &amp;ldquo;I trust tokens from this issuer&amp;rdquo;),
and the signer role&amp;rsquo;s &lt;em&gt;trust policy&lt;/em&gt; (which says &amp;ldquo;and only from these
pipelines&amp;rdquo;). The &lt;code&gt;terraform-aws-signing-kms&lt;/code&gt; module owns the second. The first
comes from a sibling module.&lt;/p&gt;
&lt;h2 id="where-the-identity-provider-comes-from"&gt;Where the identity provider comes from
&lt;/h2&gt;&lt;p&gt;You don&amp;rsquo;t register the OIDC provider in the signing module. That&amp;rsquo;s deliberate:
the same provider is shared across every role in the account that federates
from CI, so it lives one level up, in
&lt;a class="link" href="https://gitlab.com/phpboyscout/terraform-aws-bootstrap" target="_blank" rel="noopener"
 &gt;&lt;code&gt;terraform-aws-bootstrap&lt;/code&gt;&lt;/a&gt;.
It takes a &lt;code&gt;ci_provider&lt;/code&gt; input (&lt;code&gt;github&lt;/code&gt; by default, or &lt;code&gt;gitlab&lt;/code&gt;), registers the
right IAM OIDC identity provider for that forge, and emits its ARN. You feed
that ARN into the signing module. If you ran the bootstrap in Part 2 you already
have it; if not, stand it up first and grab the &lt;code&gt;oidc_provider_arn&lt;/code&gt; output.&lt;/p&gt;
&lt;p&gt;That ARN is the only thing the two modules need to agree on. Everything else
about &lt;em&gt;which&lt;/em&gt; pipelines may sign lives in the signing module&amp;rsquo;s trust policy,
which is what the rest of this part configures.&lt;/p&gt;
&lt;h2 id="the-gitlab-path"&gt;The GitLab path
&lt;/h2&gt;&lt;p&gt;Look the provider up by URL so you never hardcode the ARN, then pass it in along
with the subject filter that scopes who can assume the role:&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-hcl" data-lang="hcl"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;data&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;aws_iam_openid_connect_provider&amp;#34; &amp;#34;gitlab&amp;#34;&lt;/span&gt; {
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="n"&gt; url&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;https://gitlab.com&amp;#34;&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;}
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;module&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;signing_kms&amp;#34;&lt;/span&gt; {
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="n"&gt; source&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;gitlab.com/phpboyscout/signing-kms/aws&amp;#34;&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="n"&gt; version&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;0.1.2&amp;#34;&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="n"&gt; name&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;acme-release-signing-v1&amp;#34;&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="n"&gt; oidc_provider_arn&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;aws_iam_openid_connect_provider&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;gitlab&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;arn&lt;/span&gt;&lt;span class="c1"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="c1"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="c1"&gt; # oidc_issuer_host defaults to &amp;#34;gitlab.com&amp;#34;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="c1"&gt; # oidc_audience defaults to &amp;#34;sts.amazonaws.com&amp;#34;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="n"&gt; ci_subject_filters&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="s2"&gt;&amp;#34;project_path:acme/acme-cli:ref_type:tag:ref:v*&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="p"&gt;]&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="n"&gt; key_administrator_arns&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="cm"&gt;/* ... from Part 2 ... */&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="n"&gt; automation_role_arn&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;aws_iam_role&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;automation&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;arn&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;}
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;The interesting line is &lt;code&gt;ci_subject_filters&lt;/code&gt;. GitLab stamps each CI token&amp;rsquo;s
&lt;code&gt;sub&lt;/code&gt; claim with the project path, the ref type and the ref. The pattern above
reads as &amp;ldquo;tag pipelines on &lt;code&gt;acme/acme-cli&lt;/code&gt;, for any ref starting &lt;code&gt;v&lt;/code&gt;&amp;rdquo;. A branch
pipeline or a merge-request pipeline carries &lt;code&gt;ref_type:branch&lt;/code&gt; instead, so it
simply doesn&amp;rsquo;t match, and the role refuses to be assumed. Your signer can only
be driven from a release tag, which is the whole point: a dependency author
opening an MR can&amp;rsquo;t trick CI into minting a signature.&lt;/p&gt;
&lt;p&gt;On the pipeline side, the release job declares an &lt;code&gt;id_tokens&lt;/code&gt; block so GitLab
issues a token with the right audience, writes it to a file, and the AWS SDK
picks it up:&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-yaml" data-lang="yaml"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="nt"&gt;goreleaser&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;rules&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;- &lt;span class="nt"&gt;if&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s1"&gt;&amp;#39;$CI_COMMIT_TAG =~ /^v\d+\.\d+\.\d+$/&amp;#39;&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;id_tokens&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;AWS_WEB_IDENTITY_TOKEN&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;aud&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="l"&gt;sts.amazonaws.com&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;variables&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;AWS_REGION&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="l"&gt;eu-west-2&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;AWS_ROLE_ARN&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="l"&gt;${SIGNER_ROLE_ARN}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;AWS_WEB_IDENTITY_TOKEN_FILE&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="l"&gt;/tmp/aws-token&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;before_script&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;- &lt;span class="l"&gt;echo &amp;#34;$AWS_WEB_IDENTITY_TOKEN&amp;#34; &amp;gt; /tmp/aws-token&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;script&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;- &lt;span class="l"&gt;aws sts get-caller-identity&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;- &lt;span class="l"&gt;goreleaser release --clean&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;&lt;code&gt;AWS_ROLE_ARN&lt;/code&gt; plus &lt;code&gt;AWS_WEB_IDENTITY_TOKEN_FILE&lt;/code&gt; is the convention the SDK
recognises: it sees the two together and calls &lt;code&gt;assume-role-with-web-identity&lt;/code&gt;
for you, so by the time &lt;code&gt;goreleaser&lt;/code&gt; runs it&amp;rsquo;s already the signer role. The
actual signing job is Part 6; the &lt;code&gt;aws sts get-caller-identity&lt;/code&gt; line is just a
sanity check that federation worked. It should print the signer role&amp;rsquo;s ARN.&lt;/p&gt;
&lt;!-- VHS: clip of a GitLab tag pipeline running `aws sts get-caller-identity` and printing the federated signer-role ARN, against a real install --&gt;
&lt;h2 id="the-github-path"&gt;The GitHub path
&lt;/h2&gt;&lt;p&gt;Same shape, different issuer and a different &lt;code&gt;sub&lt;/code&gt; format. GitHub&amp;rsquo;s &lt;code&gt;sub&lt;/code&gt;
support landed in module &lt;strong&gt;v0.1.2&lt;/strong&gt;: earlier versions validated
&lt;code&gt;ci_subject_filters&lt;/code&gt; against GitLab&amp;rsquo;s format only and would &lt;em&gt;reject&lt;/em&gt; a GitHub
subject outright, so pin the version.&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-hcl" data-lang="hcl"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;data&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;aws_iam_openid_connect_provider&amp;#34; &amp;#34;github&amp;#34;&lt;/span&gt; {
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="n"&gt; url&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;https://token.actions.githubusercontent.com&amp;#34;&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;}
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;module&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;signing_kms&amp;#34;&lt;/span&gt; {
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="n"&gt; source&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;gitlab.com/phpboyscout/signing-kms/aws&amp;#34;&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="n"&gt; version&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;0.1.2&amp;#34;&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="n"&gt; name&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;acme-release-signing-v1&amp;#34;&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="n"&gt; oidc_provider_arn&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;aws_iam_openid_connect_provider&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;github&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;arn&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="n"&gt; oidc_issuer_host&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;token.actions.githubusercontent.com&amp;#34;&lt;/span&gt;&lt;span class="c1"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="c1"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="c1"&gt; # oidc_audience still defaults to &amp;#34;sts.amazonaws.com&amp;#34;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="n"&gt; ci_subject_filters&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="s2"&gt;&amp;#34;repo:acme/acme-cli:ref:refs/tags/v*&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="p"&gt;]&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="n"&gt; key_administrator_arns&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="cm"&gt;/* ... */&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="n"&gt; automation_role_arn&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;aws_iam_role&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;automation&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;arn&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;}
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;Two differences from GitLab. The &lt;code&gt;oidc_issuer_host&lt;/code&gt; has to change, because it&amp;rsquo;s
the prefix on the trust-policy condition keys (&lt;code&gt;token.actions.githubusercontent.com:sub&lt;/code&gt;
rather than &lt;code&gt;gitlab.com:sub&lt;/code&gt;). And the subject format is GitHub&amp;rsquo;s own:
&lt;code&gt;repo:&amp;lt;owner&amp;gt;/&amp;lt;repo&amp;gt;:ref:refs/tags/v*&lt;/code&gt; scopes the same way the GitLab pattern
did, to tag refs only. The audience stays &lt;code&gt;sts.amazonaws.com&lt;/code&gt;, because that&amp;rsquo;s
the default &lt;code&gt;aws-actions/configure-aws-credentials&lt;/code&gt; requests, so there&amp;rsquo;s nothing
to override.&lt;/p&gt;
&lt;p&gt;The workflow side is the official AWS action. It needs &lt;code&gt;id-token: write&lt;/code&gt;
permission to ask GitHub for the token in the first place, and &lt;code&gt;contents: write&lt;/code&gt;
so GoReleaser can create the release:&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-yaml" data-lang="yaml"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="nt"&gt;permissions&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;id-token&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="l"&gt;write&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;contents&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="l"&gt;write&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="nt"&gt;jobs&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;release&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;runs-on&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="l"&gt;ubuntu-latest&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;steps&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;- &lt;span class="nt"&gt;uses&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="l"&gt;actions/checkout@v4&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;- &lt;span class="nt"&gt;uses&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="l"&gt;aws-actions/configure-aws-credentials@v4&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;with&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;role-to-assume&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="l"&gt;${{ secrets.SIGNER_ROLE_ARN }}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;aws-region&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="l"&gt;eu-west-2&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;audience&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="l"&gt;sts.amazonaws.com&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;- &lt;span class="nt"&gt;run&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="l"&gt;aws sts get-caller-identity&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;- &lt;span class="nt"&gt;run&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="l"&gt;goreleaser release --clean&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;If you forget &lt;code&gt;id-token: write&lt;/code&gt;, GitHub never issues a token and the action
fails before it reaches AWS. It&amp;rsquo;s the most common GitHub-side trip-up.&lt;/p&gt;
&lt;h2 id="the-gotcha-that-wastes-an-afternoon-token-audience"&gt;The gotcha that wastes an afternoon: token audience
&lt;/h2&gt;&lt;p&gt;Here&amp;rsquo;s the one worth burning into memory, because the error message points you
at the wrong thing. An IAM OIDC provider carries a &lt;code&gt;client_id_list&lt;/code&gt;, and it
rejects any token whose &lt;code&gt;aud&lt;/code&gt; claim isn&amp;rsquo;t on that list &lt;em&gt;before AWS even looks at
the role&amp;rsquo;s trust policy&lt;/em&gt;. So if your CI token&amp;rsquo;s audience and the provider&amp;rsquo;s
client ID don&amp;rsquo;t match, you get:&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-text" data-lang="text"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;InvalidIdentityToken: Incorrect token audience
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;That reads like a role-trust problem, and you&amp;rsquo;ll go round in circles editing
&lt;code&gt;ci_subject_filters&lt;/code&gt;, but the trust policy was never consulted. The fix is to
keep the audience aligned to &lt;code&gt;sts.amazonaws.com&lt;/code&gt; everywhere: it&amp;rsquo;s the module&amp;rsquo;s
&lt;code&gt;oidc_audience&lt;/code&gt; default, it&amp;rsquo;s what &lt;code&gt;terraform-aws-bootstrap&lt;/code&gt; puts on the
provider&amp;rsquo;s &lt;code&gt;client_id_list&lt;/code&gt;, it&amp;rsquo;s the &lt;code&gt;aud&lt;/code&gt; in the GitLab &lt;code&gt;id_tokens&lt;/code&gt; block, and
it&amp;rsquo;s the default &lt;code&gt;aws-actions/configure-aws-credentials&lt;/code&gt; requests. Leave all
four alone and they agree by default. The moment you override one, override all
of them, or you&amp;rsquo;ll meet that error.&lt;/p&gt;
&lt;h2 id="where-this-leaves-you"&gt;Where this leaves you
&lt;/h2&gt;&lt;p&gt;There&amp;rsquo;s now no AWS access key anywhere in either forge. The pipeline proves it&amp;rsquo;s
a release tag on your project, AWS hands it the signer role for the length of
the run, and the credentials are gone the moment the runner stops. The signing
module&amp;rsquo;s trust policy is the gate; the bootstrap module&amp;rsquo;s OIDC provider is the
lock it hangs on. Both are verifiable in the public modules:
&lt;a class="link" href="https://gitlab.com/phpboyscout/terraform-aws-signing-kms" target="_blank" rel="noopener"
 &gt;&lt;code&gt;terraform-aws-signing-kms&lt;/code&gt;&lt;/a&gt;
for the role and
&lt;a class="link" href="https://gitlab.com/phpboyscout/terraform-aws-bootstrap" target="_blank" rel="noopener"
 &gt;&lt;code&gt;terraform-aws-bootstrap&lt;/code&gt;&lt;/a&gt;
for the provider.&lt;/p&gt;
&lt;p&gt;The role can sign, but you still can&amp;rsquo;t &lt;em&gt;verify&lt;/em&gt; anything yet, because nobody has
the public half of that KMS key. &lt;a class="link" href="https://phpboyscout.uk/sign-your-own-binaries-with-go-tool-base-part-4/" &gt;Part 4&lt;/a&gt;
fixes that: &lt;code&gt;gtb keys mint&lt;/code&gt; pulls the public key straight out of KMS, and &lt;code&gt;gtb keys wkd&lt;/code&gt; publishes it somewhere the release platform can&amp;rsquo;t touch.&lt;/p&gt;</description></item><item><title>Why go-tool-base left GitHub for GitLab</title><link>https://phpboyscout.uk/why-we-left-github-for-gitlab/</link><pubDate>Mon, 11 May 2026 00:00:00 +0000</pubDate><guid>https://phpboyscout.uk/why-we-left-github-for-gitlab/</guid><description>&lt;img src="https://phpboyscout.uk/why-we-left-github-for-gitlab/cover-why-we-left-github-for-gitlab.png" alt="Featured image of post Why go-tool-base left GitHub for GitLab" /&gt;&lt;p&gt;A botched version bump made me stop and actually look at where go-tool-base lived, and I didn&amp;rsquo;t much like what I saw. GitHub had spent months quietly falling over, and when Mitchell Hashimoto (GitHub user #1299, no less) publicly walked Ghostty off the platform, it stopped feeling like just my problem. I&amp;rsquo;ve been a GitLab fan for years, so the move was less a leap and more an overdue nudge. This is the &lt;em&gt;why&lt;/em&gt;, not the &lt;em&gt;how&lt;/em&gt;.&lt;/p&gt;
&lt;h2 id="it-started-with-a-wrong-number"&gt;It started with a wrong number
&lt;/h2&gt;&lt;p&gt;Every migration has a trigger, and mine was embarrassingly small. A commit landed on &lt;code&gt;main&lt;/code&gt; carrying a &lt;code&gt;BREAKING CHANGE:&lt;/code&gt; footer it didn&amp;rsquo;t really deserve. Semantic-release did exactly what it&amp;rsquo;s told to do with that footer: it cut a major version. go-tool-base lurched from the v1 line straight to v2.0.0, and a chain of things that keyed off the version went sideways with it.&lt;/p&gt;
&lt;p&gt;It was fixable. It wasn&amp;rsquo;t a disaster. But it was the kind of small, stupid breakage that makes you stop and actually &lt;em&gt;look&lt;/em&gt; at your setup instead of just patching it and moving on. And when I looked, the version bump wasn&amp;rsquo;t the thing that bothered me. It was everything around it.&lt;/p&gt;
&lt;h2 id="the-platform-had-been-quietly-failing"&gt;The platform had been quietly failing
&lt;/h2&gt;&lt;p&gt;I&amp;rsquo;d been losing time to GitHub for months. Not dramatically. No single outage you&amp;rsquo;d write home about, just a steady drip of Actions queues that wouldn&amp;rsquo;t drain, pull requests that wouldn&amp;rsquo;t merge, the occasional morning where the thing simply wasn&amp;rsquo;t there. You absorb it. You re-run the job. You make a coffee and try again. You tell yourself it&amp;rsquo;s a blip.&lt;/p&gt;
&lt;p&gt;The trouble with a steady drip is that you stop counting it. It becomes weather.&lt;/p&gt;
&lt;h2 id="the-canary-left-the-mine"&gt;The canary left the mine
&lt;/h2&gt;&lt;p&gt;Then, in late April, &lt;a class="link" href="https://mitchellh.com/" target="_blank" rel="noopener"
 &gt;Mitchell Hashimoto&lt;/a&gt; (co-founder of HashiCorp, creator of Vagrant, Terraform and the Ghostty terminal) published &lt;a class="link" href="https://mitchellh.com/writing/ghostty-leaving-github" target="_blank" rel="noopener"
 &gt;&lt;em&gt;Ghostty Is Leaving GitHub&lt;/em&gt;&lt;/a&gt;, and &lt;em&gt;The Register&lt;/em&gt; &lt;a class="link" href="https://www.theregister.com/software/2026/04/29/mitchell-hashimoto-says-github-no-longer-for-serious-work/5227505" target="_blank" rel="noopener"
 &gt;picked it up&lt;/a&gt; a day later under the headline &amp;ldquo;GitHub &amp;rsquo;no longer a place for serious work&amp;rsquo;&amp;rdquo;.&lt;/p&gt;
&lt;p&gt;This is not a man with a casual relationship to GitHub. He&amp;rsquo;s, by his own account, user #1299, joined February 2008. He called it &amp;ldquo;the place that has made me the most happy&amp;rdquo;. And he still wrote this:&lt;/p&gt;

 &lt;blockquote&gt;
 &lt;p&gt;This is no longer a place for serious work if it just blocks you out for hours per day, every day.&lt;/p&gt;

 &lt;/blockquote&gt;
&lt;p&gt;The detail that landed hardest for me wasn&amp;rsquo;t a quote, it was a habit. He&amp;rsquo;d kept a journal for a month, marking an &amp;ldquo;X&amp;rdquo; on every day a GitHub outage had cost him working time. &lt;em&gt;Almost every day had an X.&lt;/em&gt; Reading that, I realised I&amp;rsquo;d been having the same month. I&amp;rsquo;d just never been disciplined enough to write it down. He&amp;rsquo;d turned my vague &amp;ldquo;it&amp;rsquo;s been flaky lately&amp;rdquo; into a row of crosses on a calendar.&lt;/p&gt;

 &lt;blockquote&gt;
 &lt;p&gt;I want to ship software and it doesn&amp;rsquo;t want me to ship software.&lt;/p&gt;

 &lt;/blockquote&gt;
&lt;p&gt;When the person who&amp;rsquo;s been on the platform for eighteen years and &lt;em&gt;loves&lt;/em&gt; it says that out loud, it stops being your private grumble. It&amp;rsquo;s the canary, and the canary has stopped singing.&lt;/p&gt;
&lt;h2 id="why-gitlab-and-not-just-somewhere-else"&gt;Why GitLab, and not just &amp;ldquo;somewhere else&amp;rdquo;
&lt;/h2&gt;&lt;p&gt;Being annoyed at GitHub is a reason to leave. It is not, on its own, a reason to pick a destination. The destination has to be a positive choice.&lt;/p&gt;
&lt;p&gt;For me GitLab was an easy one, because I&amp;rsquo;ve been a fan for years. Long enough, in fact, to have &lt;em&gt;also&lt;/em&gt; been a reliable grumbler about their pricing tiers, which is how you know it&amp;rsquo;s a real relationship and not a honeymoon. What I&amp;rsquo;ve always rated is the model: GitLab treats source hosting, CI/CD, the package registry, releases and Pages as &lt;em&gt;one integrated product&lt;/em&gt;, not a marketplace of bolted-on parts you assemble yourself.&lt;/p&gt;
&lt;p&gt;That integration is the actual prize. On the old setup, &amp;ldquo;CI&amp;rdquo; meant a folder of separate GitHub Actions workflow files, each pinned, each its own little world. On GitLab it&amp;rsquo;s a single &lt;a class="link" href="https://gitlab.com/phpboyscout/go-tool-base/-/blob/5c78fc9/.gitlab-ci.yml#L7" target="_blank" rel="noopener"
 &gt;&lt;code&gt;.gitlab-ci.yml&lt;/code&gt; pipeline&lt;/a&gt; with proper stages (lint, test, security, docs, release) and the release stage talks to the built-in package registry and Pages without me wiring up a single external credential. The CI job that builds the project can authenticate to the things the project needs &lt;em&gt;because they&amp;rsquo;re the same platform&lt;/em&gt;.&lt;/p&gt;
&lt;p&gt;There&amp;rsquo;s a second-order benefit too. A migration is a rare licence to fix things you&amp;rsquo;d never otherwise touch. Moving gave me the cover to reset go-tool-base&amp;rsquo;s versioning cleanly (back to a sensible &lt;code&gt;v0.x&lt;/code&gt; line, the accidental &lt;code&gt;v2.0.0&lt;/code&gt; left behind as a cautionary tale) and to move the module path to its new home in one deliberate change rather than a thousand apologetic ones.&lt;/p&gt;
&lt;h2 id="what-im-not-going-to-claim"&gt;What I&amp;rsquo;m not going to claim
&lt;/h2&gt;&lt;p&gt;I&amp;rsquo;m not going to tell you GitHub is finished, or that GitLab never has a bad day, because it does, everyone does. This isn&amp;rsquo;t a teardown. GitHub gave go-tool-base a perfectly good home for its first year, and the archived mirror is still sitting there, read-only, pointing anyone who finds it at the new place.&lt;/p&gt;
&lt;p&gt;What changed is simpler than a grand verdict. The friction crossed a line, someone I respect said the quiet part loudly enough that I couldn&amp;rsquo;t keep filing it under &amp;ldquo;weather&amp;rdquo;, and the place I&amp;rsquo;d have moved to anyway was sitting right there with a better model. Sometimes the prudent move and the move you secretly wanted turn out to be the same move, and you just need a wrong version number to give you permission.&lt;/p&gt;
&lt;h2 id="boiling-it-down"&gt;Boiling it down
&lt;/h2&gt;&lt;p&gt;go-tool-base moved from GitHub to GitLab in May 2026. The proximate cause was a self-inflicted version-bump mess; the real cause was months of GitHub unreliability that I&amp;rsquo;d stopped consciously noticing until Mitchell Hashimoto&amp;rsquo;s very public departure named it for me. GitLab was a positive pick, not just an escape hatch: its integrated CI/CD, registry, releases and Pages are one product rather than a kit, and that integration is genuinely worth having. The migration also bought a clean versioning restart as a bonus.&lt;/p&gt;
&lt;p&gt;If you&amp;rsquo;ve been absorbing a steady drip of friction and telling yourself it&amp;rsquo;s normal: try the calendar trick. Mark the X&amp;rsquo;s for a month. The page will tell you something you already half-know.&lt;/p&gt;</description></item><item><title>OpenSSF Scorecard graded my supply chain</title><link>https://phpboyscout.uk/openssf-scorecard-graded-my-supply-chain/</link><pubDate>Tue, 14 Apr 2026 00:00:00 +0000</pubDate><guid>https://phpboyscout.uk/openssf-scorecard-graded-my-supply-chain/</guid><description>&lt;img src="https://phpboyscout.uk/openssf-scorecard-graded-my-supply-chain/cover-openssf-scorecard-graded-my-supply-chain.png" alt="Featured image of post OpenSSF Scorecard graded my supply chain" /&gt;&lt;p&gt;I turned OpenSSF Scorecard on expecting a pat on the head. go-tool-base is a security-minded project, I&amp;rsquo;m careful, surely the robot would agree. The robot did not agree. It handed back a report card with a fair bit of red ink, and the most pointed finding on it wasn&amp;rsquo;t about my code at all. It was about me.&lt;/p&gt;
&lt;h2 id="a-linter-for-the-things-you-dont-call-code"&gt;A linter for the things you don&amp;rsquo;t call code
&lt;/h2&gt;&lt;p&gt;Scorecard is an automated set of checks that grades a repository&amp;rsquo;s supply-chain hygiene: are your CI dependencies pinned, are your workflow tokens least-privilege, is your branch protected, do commits get reviewed. It&amp;rsquo;s a linter, but pointed at the part of the project you don&amp;rsquo;t usually think of as code, the build and release machinery and the practices around them. And like any good linter, its value is mostly in catching the things you&amp;rsquo;d swear you&amp;rsquo;d already got right.&lt;/p&gt;
&lt;p&gt;Three of its findings were worth the price of admission on their own.&lt;/p&gt;
&lt;h2 id="pin-the-actions-you-dont-control"&gt;Pin the actions you don&amp;rsquo;t control
&lt;/h2&gt;&lt;p&gt;The first was about how go-tool-base&amp;rsquo;s GitHub Actions referenced other actions. Like nearly everyone, I&amp;rsquo;d written &lt;code&gt;uses: actions/checkout@v6&lt;/code&gt;. Scorecard doesn&amp;rsquo;t like that, and it&amp;rsquo;s right not to.&lt;/p&gt;
&lt;p&gt;&lt;code&gt;@v6&lt;/code&gt; is a &lt;em&gt;tag&lt;/em&gt;, and a tag is mutable. Whoever controls that action can move &lt;code&gt;v6&lt;/code&gt; to point at different code tomorrow, and your CI will pick it up silently on the next run. For an action that runs in a job holding your repository token, that&amp;rsquo;s a supply-chain hole the width of a barn door: compromise the tag, compromise every pipeline that trusts it. The fix is to pin to an immutable commit SHA, with the human-readable version left as a comment, &lt;a class="link" href="https://gitlab.com/phpboyscout/go-tool-base/-/commit/5500b92" target="_blank" rel="noopener"
 &gt;which is exactly what I changed&lt;/a&gt;:&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-yaml" data-lang="yaml"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;- &lt;span class="w"&gt; &lt;/span&gt;- &lt;span class="nt"&gt;uses&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="l"&gt;actions/checkout@v6&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;- &lt;span class="w"&gt; &lt;/span&gt;- &lt;span class="nt"&gt;uses&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="l"&gt;actions/setup-go@v6&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="nt"&gt;+ - uses&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="l"&gt;actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="c"&gt;# v6&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="nt"&gt;+ - uses&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="l"&gt;actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="c"&gt;# v6&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;Now the action is frozen at bytes I reviewed. Dependabot still bumps the SHA when a real new version lands, so I get updates as reviewable pull requests rather than as silent tag movements. The pin doesn&amp;rsquo;t stop me updating. It stops me updating &lt;em&gt;without noticing&lt;/em&gt;.&lt;/p&gt;
&lt;h2 id="give-the-workflow-token-the-least-it-can-do"&gt;Give the workflow token the least it can do
&lt;/h2&gt;&lt;p&gt;The second finding was about permissions. My workflow declared its token permissions at the top, once, for the whole file:&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-yaml" data-lang="yaml"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="nt"&gt;permissions&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;contents&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="l"&gt;read&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;security-events&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="l"&gt;write&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;id-token&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="l"&gt;write&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;That reads as careful, and it&amp;rsquo;s still too broad, because top-level permissions apply to &lt;em&gt;every job in the workflow&lt;/em&gt;. A job that only needs to read the repo is now also holding &lt;code&gt;id-token: write&lt;/code&gt; and &lt;code&gt;security-events: write&lt;/code&gt;, for no reason other than that some &lt;em&gt;other&lt;/em&gt; job in the same file needed them. Scorecard rejects exactly this, and the fix is to default the whole workflow to read-only and grant write narrowly, &lt;a class="link" href="https://gitlab.com/phpboyscout/go-tool-base/-/commit/c41e557" target="_blank" rel="noopener"
 &gt;in the job that actually needs it&lt;/a&gt;:&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-yaml" data-lang="yaml"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="nt"&gt;permissions&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="l"&gt;read-all&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;Write permissions moved down into the single job that uses them. It&amp;rsquo;s the same least-privilege instinct that runs through everything else in these projects, just applied to a CI token instead of an IAM role: a credential should be able to do the one thing it&amp;rsquo;s for, and nothing else, no matter how convenient the broad grant looked.&lt;/p&gt;
&lt;h2 id="the-finding-that-was-about-me"&gt;The finding that was about me
&lt;/h2&gt;&lt;p&gt;The third one stung, because there was no YAML to fix. Scorecard&amp;rsquo;s Code-Review check scores how consistently changes are reviewed before they land, and mine scored badly for the most embarrassing possible reason: I&amp;rsquo;d set up branch protection on &lt;code&gt;main&lt;/code&gt;, and then, being the solo maintainer in a hurry, I&amp;rsquo;d been merrily bypassing it to push straight to &lt;code&gt;main&lt;/code&gt; whenever it suited me.&lt;/p&gt;
&lt;p&gt;So I had a rule, written down and enforced by the platform, that I was personally and routinely ignoring. Scorecard noticed, totted up the unreviewed commits, and graded me on it. There&amp;rsquo;s something properly humbling about a robot reading your git history and pointing out that the person breaking your security policy most often is &lt;em&gt;you&lt;/em&gt;. The fix wasn&amp;rsquo;t code. It was going through a pull request like everyone else, even when &amp;ldquo;everyone else&amp;rdquo; is just me on a different day.&lt;/p&gt;
&lt;h2 id="the-bottom-line"&gt;The bottom line
&lt;/h2&gt;&lt;p&gt;OpenSSF Scorecard is a linter for your supply chain, and like any linter it&amp;rsquo;s most useful when it tells you something you were sure you&amp;rsquo;d already handled. It dinged go-tool-base for referencing actions by mutable tag instead of pinned SHA, for granting workflow-token write permissions at the top level where every job inherited them, and for a Code-Review score I&amp;rsquo;d earned fair and square by bypassing my own branch protection.&lt;/p&gt;
&lt;p&gt;The first two were quick, satisfying changes with a clear security story. The third was the one that stuck, because the tool I&amp;rsquo;d added to grade the project ended up grading the maintainer, and was entirely right to. Turn it on. Brace yourself a little.&lt;/p&gt;</description></item></channel></rss>