<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: kobima6813</title>
    <description>The latest articles on DEV Community by kobima6813 (@kobima6813).</description>
    <link>https://dev.to/kobima6813</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F4196589%2F40972d6e-a4ab-4b64-9d3b-8a0539bb6c09.png</url>
      <title>DEV Community: kobima6813</title>
      <link>https://dev.to/kobima6813</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/kobima6813"/>
    <language>en</language>
    <item>
      <title>15,000 e-invoices, 12 generators: 4 setups pass every validator and still bill the wrong amount</title>
      <dc:creator>kobima6813</dc:creator>
      <pubDate>Sun, 25 Oct 2026 01:45:58 +0000</pubDate>
      <link>https://dev.to/kobima6813/15000-e-invoices-12-generators-4-setups-pass-every-validator-and-still-bill-the-wrong-amount-3cd8</link>
      <guid>https://dev.to/kobima6813/15000-e-invoices-12-generators-4-setups-pass-every-validator-and-still-bill-the-wrong-amount-3cd8</guid>
      <description>&lt;p&gt;&lt;strong&gt;Finding first:&lt;/strong&gt; in our October run, 4 generator configurations pass KoSIT (XRechnung 3.0.2), the CEN EN 16931 rules and Peppol BIS 3.0.20, yet on &lt;strong&gt;472 of 600&lt;/strong&gt; invoices each the amount due (BT-115) differs from what the shop order actually charged.&lt;/p&gt;

&lt;p&gt;No validator catches this, because no validator knows the original order total. It only checks that the invoice is consistent with itself.&lt;/p&gt;

&lt;h2&gt;
  
  
  What we ran
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;15,000 synthetic invoices&lt;/strong&gt; from &lt;strong&gt;12 open-source generators&lt;/strong&gt; (25 configurations × 600 orders: 300 B2B + 300 B2C).&lt;/li&gt;
&lt;li&gt;Validators are pinned by SHA-256: KoSIT 1.6.3, CEN EN 16931 and Peppol BIS 3.0.20.&lt;/li&gt;
&lt;li&gt;The orders are deterministic, tax-inclusive shop carts with % discounts and shipping, i.e. what a WooCommerce or Odoo shop actually produces.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Full report: &lt;a href="https://kobima6813.github.io/einvoice-conformance-matrix/reports/2026-10/" rel="noopener noreferrer"&gt;https://kobima6813.github.io/einvoice-conformance-matrix/reports/2026-10/&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  The "passes but wrong" cases
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Configuration&lt;/th&gt;
&lt;th&gt;Validators&lt;/th&gt;
&lt;th&gt;Amount due ≠ order total&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Mustang 2.26.0 (library-computed totals)&lt;/td&gt;
&lt;td&gt;all pass&lt;/td&gt;
&lt;td&gt;472 / 600&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;speedata/einvoice 0.0.23, CII&lt;/td&gt;
&lt;td&gt;all pass&lt;/td&gt;
&lt;td&gt;472 / 600&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;speedata/einvoice 0.0.23, UBL&lt;/td&gt;
&lt;td&gt;all pass&lt;/td&gt;
&lt;td&gt;472 / 600&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;josemmo/einvoicing 0.3.1, 2-decimal rounding&lt;/td&gt;
&lt;td&gt;all pass&lt;/td&gt;
&lt;td&gt;472 / 600&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The cause is the same in all four. A gross (tax-inclusive) cart is converted into EN 16931 net arithmetic, and rounding lands in a different place than the shop rounded. The amounts are usually cents, but the invoice no longer matches what the customer paid, and that breaks automatic payment matching. Mustang already merged a fix (&lt;a href="https://github.com/ZUGFeRD/mustangproject/pull/1318" rel="noopener noreferrer"&gt;PR #1318&lt;/a&gt;). It isn't released yet.&lt;/p&gt;

&lt;h2&gt;
  
  
  Validator failures we reported upstream
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Odoo 18/19 Factur-X:&lt;/strong&gt; PEPPOL-EN16931-R120 on 242/300 B2B invoices and BR-CO-10 / BR-S-08 with &lt;code&gt;round_globally&lt;/code&gt;. We tested two fix PRs (&lt;a href="https://github.com/odoo/odoo/pull/297750" rel="noopener noreferrer"&gt;#297750&lt;/a&gt;, &lt;a href="https://github.com/odoo/odoo/pull/297752" rel="noopener noreferrer"&gt;#297752&lt;/a&gt;) against the corpus, and the errors go to 0. The maintainer confirmed the results.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;WooCommerce PDF Invoices &amp;amp; Packing Slips 5.16.4:&lt;/strong&gt; &lt;code&gt;ram:RoundingAmount&lt;/code&gt; is out of XSD order, so 238/600 Factur-X invoices are XSD-invalid (&lt;a href="https://github.com/wpovernight/woocommerce-pdf-invoices-packing-slips/issues/1662" rel="noopener noreferrer"&gt;#1662&lt;/a&gt;). Another user reproduced it independently.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;horstoeko/zugferd:&lt;/strong&gt; the VAT breakdown for exempt categories was wrong. It's fixed in v1.0.133.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;speedata/einvoice:&lt;/strong&gt; UBL element order and a BT-27/BT-28 swap. Both are fixed in v0.0.23.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;josemmo/einvoicing 0.3.1:&lt;/strong&gt; the default config gives 590/600 CEN failures (&lt;a href="https://github.com/josemmo/einvoicing/issues/93" rel="noopener noreferrer"&gt;#93&lt;/a&gt;). It passes once a rounding preset is set.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;ZUGFeRD-csharp 18.0.1:&lt;/strong&gt; CII without a delivery date is XSD-invalid. This was already known upstream (&lt;a href="https://github.com/stephanstapel/ZUGFeRD-csharp/pull/981" rel="noopener noreferrer"&gt;PR #981&lt;/a&gt;), so it's not our report.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  France: 30 of 30 pass
&lt;/h2&gt;

&lt;p&gt;France's receiving obligation started on 1 Sep 2026. We added 5 French scenarios: standard 20 % + 5.5 %, the VAT franchise (art. 293 B), domestic reverse charge, an intra-EU supply and all four French rates. Each one ran in 2 Factur-X profiles (EN16931, EXTENDED) on 3 generator versions (drafthorse + factur-x, horstoeko 1.0.132 and 1.0.133). &lt;strong&gt;All 30 pass.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Honest caveats:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;In our first run, horstoeko failed BR-IC-12. That was &lt;em&gt;our&lt;/em&gt; driver's mistake: the ship-to address is dropped unless &lt;code&gt;setDocumentShipTo()&lt;/code&gt; is called before &lt;code&gt;setDocumentShipToAddress()&lt;/code&gt;. It was not a library bug.&lt;/li&gt;
&lt;li&gt;We have &lt;strong&gt;not&lt;/strong&gt; tested the French platform-specific (PA/PPF) rule sets, because they aren't available as free, pinned validators.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Free self-check
&lt;/h2&gt;

&lt;p&gt;You can check one of your own invoices here: &lt;a href="https://kobima6813.github.io/einvoice-conformance-matrix/check.html" rel="noopener noreferrer"&gt;https://kobima6813.github.io/einvoice-conformance-matrix/check.html&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;It runs in your browser, and the file is not uploaded anywhere. It covers a subset of EN 16931 and tells you how many problems it found. It is not a replacement for KoSIT or a Peppol validator.&lt;/p&gt;

&lt;h2&gt;
  
  
  Limits
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;The orders are synthetic. Real catalogs have edge cases we don't generate yet.&lt;/li&gt;
&lt;li&gt;Each configuration is one library version with one setting. Your integration code can add or hide bugs.&lt;/li&gt;
&lt;li&gt;Corrections are welcome. Every number links to a file in the repo, and you can reproduce it with &lt;code&gt;scripts/run_matrix.sh&lt;/code&gt;.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Repo and home page: &lt;a href="https://kobima6813.github.io/einvoice-conformance-matrix/" rel="noopener noreferrer"&gt;https://kobima6813.github.io/einvoice-conformance-matrix/&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;If you maintain one of these generators and think a result is wrong, open an issue and I'll re-run it.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>einvoicing</category>
      <category>xrechnung</category>
      <category>peppol</category>
      <category>opensource</category>
    </item>
    <item>
      <title>We ran 12,000 e-invoices through KoSIT, EN 16931 and Peppol. Passing the validator isn't enough</title>
      <dc:creator>kobima6813</dc:creator>
      <pubDate>Sat, 24 Oct 2026 17:00:54 +0000</pubDate>
      <link>https://dev.to/kobima6813/we-ran-12000-e-invoices-through-kosit-en-16931-and-peppol-passing-the-validator-isnt-enough-4h9</link>
      <guid>https://dev.to/kobima6813/we-ran-12000-e-invoices-through-kosit-en-16931-and-peppol-passing-the-validator-isnt-enough-4h9</guid>
      <description>&lt;p&gt;Germany's B2B e-invoice transition ends on 1 Jan 2027 (1 Jan 2028 for issuers with turnover up to EUR 800k). France starts requiring large and mid-size companies to issue on 1 Sep 2026, and Belgium has been live since 1 Jan 2026. A lot of shops will be relying on an open-source library or plugin to produce XRechnung, ZUGFeRD/Factur-X or Peppol UBL.&lt;/p&gt;

&lt;p&gt;So we built an open, reproducible matrix. It takes the same 600 synthetic tax-inclusive shop orders (19 %/7 % VAT, percentage coupons, shipping), runs them through each generator, and checks every invoice with pinned official rule sets: KoSIT 1.6.3 + XRechnung 3.0.2, CEN EN 16931 and Peppol BIS 3.0.20. On top of that we check whether the amount due matches what the customer was actually charged.&lt;/p&gt;

&lt;p&gt;What we found (full table: &lt;a href="https://kobima6813.github.io/einvoice-conformance-matrix/):" rel="noopener noreferrer"&gt;https://kobima6813.github.io/einvoice-conformance-matrix/):&lt;/a&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;WooCommerce PDF Invoices 5.16.4, Factur-X:&lt;/strong&gt; 39.7 % schema-invalid. &lt;code&gt;RoundingAmount&lt;/code&gt; is written in the wrong XSD position (reported upstream).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Odoo 19, Factur-X:&lt;/strong&gt; with &lt;code&gt;round_globally&lt;/code&gt;, 32.7 % (B2B) and 42.7 % (B2C) fail CEN calculation rules, and 80.7 % of B2B invoices fail Peppol R120. A follow-up fix is in progress upstream.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Mustang 2.26.0 and josemmo/einvoicing 0.3.1:&lt;/strong&gt; both pass KoSIT 100 %, but when the library computes totals from net prices, the amount due differs from the charged amount in 90.7 % (B2B) and 66.7 % (B2C) of orders. Usually that is a few cents. It's the gap between tax-inclusive shop arithmetic and EN 16931 net arithmetic, and no validator flags it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;josemmo/einvoicing with no preset or rounding matrix:&lt;/strong&gt; amounts are written with up to 8 decimals, so 98–99 % fail BR-DEC rules. Fix: &lt;code&gt;setRoundingMatrix(['' =&amp;gt; 2])&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;speedata/einvoice 0.0.23&lt;/strong&gt; (Go) computes the VAT breakdown and totals, and every CII/UBL invoice passes. The amount-due gap is the same as with Mustang.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;ZUGFeRD-csharp 18.0.1&lt;/strong&gt; (.NET): UBL passes. CII without a delivery date is 100 % schema-invalid because the required empty &lt;code&gt;ApplicableHeaderTradeDelivery&lt;/code&gt; is dropped. That's already known upstream (PR #981). Workaround: set &lt;code&gt;ActualDeliveryDate&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;horstoeko/zugferd, drafthorse and ZUGFeRD-csharp:&lt;/strong&gt; given correct totals, everything passes. The arithmetic is your job.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Several bugs found this way are already fixed in releases (speedata/einvoice v0.0.23, horstoeko/zugferd 1.0.133, Mustang master).&lt;/p&gt;

&lt;p&gt;Run the same checks on your own invoices in CI:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;uses&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;kobima6813/einvoice-conformance-matrix@main&lt;/span&gt;
  &lt;span class="na"&gt;with&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;invoices&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;out/invoices&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Maintain a generator? Listing is free: open a &lt;code&gt;Listing request&lt;/code&gt; issue or a PR (see CONTRIBUTING). Corrections are welcome.&lt;/p&gt;

&lt;p&gt;Repo: &lt;a href="https://github.com/kobima6813/einvoice-conformance-matrix" rel="noopener noreferrer"&gt;https://github.com/kobima6813/einvoice-conformance-matrix&lt;/a&gt;&lt;/p&gt;

</description>
      <category>einvoicing</category>
      <category>opensource</category>
      <category>php</category>
      <category>python</category>
    </item>
    <item>
      <title>How to stop secrets leaking into GitHub Actions logs (and move to OIDC)</title>
      <dc:creator>kobima6813</dc:creator>
      <pubDate>Fri, 23 Oct 2026 11:58:27 +0000</pubDate>
      <link>https://dev.to/kobima6813/how-to-stop-secrets-leaking-into-github-actions-logs-and-move-to-oidc-4ci7</link>
      <guid>https://dev.to/kobima6813/how-to-stop-secrets-leaking-into-github-actions-logs-and-move-to-oidc-4ci7</guid>
      <description>&lt;p&gt;After the Railway incident I wrote a &lt;a href="https://dev.to/kobima6813/railway-was-breached-a-15-minute-key-rotation-checklist-3lob"&gt;15-minute key rotation checklist&lt;/a&gt;. Rotating is step one. Step two is making sure the &lt;em&gt;new&lt;/em&gt; keys don't leak again, and CI is one of the easiest places to leak them.&lt;/p&gt;

&lt;p&gt;Here are the three patterns I see most often, and how to fix each one.&lt;/p&gt;

&lt;h2&gt;
  
  
  1. Printing secrets in &lt;code&gt;run:&lt;/code&gt; steps
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;run&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;echo "key is ${{ secrets.DEPLOY_KEY }}"&lt;/span&gt;
&lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;run&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;printf '%s\n' "${{ secrets.DB_PASSWORD }}"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;GitHub masks known secret values in logs, but masking is best-effort. If the value is base64-encoded, split, URL-encoded or otherwise transformed, the masked form no longer matches and the real value can show up in the log.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Fix:&lt;/strong&gt; never echo secrets. Pass them through &lt;code&gt;env:&lt;/code&gt; on the step that needs them and let the tool read the variable:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Deploy&lt;/span&gt;
  &lt;span class="na"&gt;env&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;DEPLOY_KEY&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${{ secrets.DEPLOY_KEY }}&lt;/span&gt;
  &lt;span class="na"&gt;run&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;./deploy.sh&lt;/span&gt;   &lt;span class="c1"&gt;# reads $DEPLOY_KEY, never prints it&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If you create a derived secret at runtime, mask it explicitly before anything can print it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"::add-mask::&lt;/span&gt;&lt;span class="nv"&gt;$DERIVED_TOKEN&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  2. Secrets in workflow-level &lt;code&gt;env:&lt;/code&gt;
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;env&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;OPENAI_API_KEY&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${{ secrets.OPENAI_API_KEY }}&lt;/span&gt;
&lt;span class="na"&gt;jobs&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;build&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;steps&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;uses&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;some/third-party-action@v1&lt;/span&gt;   &lt;span class="c1"&gt;# can read OPENAI_API_KEY too&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A secret in top-level &lt;code&gt;env:&lt;/code&gt; is available to every step of every job, including third-party actions that have nothing to do with it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Fix:&lt;/strong&gt; scope the secret to the single step that needs it (as in the example above).&lt;/p&gt;

&lt;h2&gt;
  
  
  3. Long-lived cloud keys instead of OIDC
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;uses&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;aws-actions/configure-aws-credentials@v4&lt;/span&gt;
  &lt;span class="na"&gt;with&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;aws-access-key-id&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${{ secrets.AWS_ACCESS_KEY_ID }}&lt;/span&gt;
    &lt;span class="na"&gt;aws-secret-access-key&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${{ secrets.AWS_SECRET_ACCESS_KEY }}&lt;/span&gt;
    &lt;span class="na"&gt;aws-region&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;eu-west-1&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A static key stored as a secret is exactly the thing you have to rotate after a breach. With OIDC, GitHub issues a short-lived token per job and your cloud exchanges it for temporary credentials. There is no long-lived key to steal.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;AWS:&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;permissions&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;id-token&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;write&lt;/span&gt;
  &lt;span class="na"&gt;contents&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;read&lt;/span&gt;
&lt;span class="na"&gt;steps&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;uses&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;aws-actions/configure-aws-credentials@v4&lt;/span&gt;
    &lt;span class="na"&gt;with&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;role-to-assume&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;arn:aws:iam::123456789012:role/gha-deploy&lt;/span&gt;
      &lt;span class="na"&gt;aws-region&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;eu-west-1&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;You create an IAM OIDC identity provider for &lt;code&gt;token.actions.githubusercontent.com&lt;/code&gt; and a role whose trust policy limits which repo/branch can assume it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;GCP (Workload Identity Federation):&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;permissions&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;id-token&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;write&lt;/span&gt;
  &lt;span class="na"&gt;contents&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;read&lt;/span&gt;
&lt;span class="na"&gt;steps&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;uses&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;google-github-actions/auth@v2&lt;/span&gt;
    &lt;span class="na"&gt;with&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;workload_identity_provider&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;projects/123/locations/global/workloadIdentityPools/gh/providers/github&lt;/span&gt;
      &lt;span class="na"&gt;service_account&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;deployer@my-project.iam.gserviceaccount.com&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Azure:&lt;/strong&gt; use &lt;code&gt;azure/login&lt;/code&gt; with &lt;code&gt;client-id&lt;/code&gt;, &lt;code&gt;tenant-id&lt;/code&gt; and &lt;code&gt;subscription-id&lt;/code&gt; plus a federated credential on the app registration, instead of a &lt;code&gt;creds&lt;/code&gt; JSON secret.&lt;/p&gt;

&lt;p&gt;Always restrict the trust policy to your repo (and ideally branch or environment). An OIDC role that any repo can assume is worse than no OIDC.&lt;/p&gt;

&lt;h2&gt;
  
  
  Catch these automatically
&lt;/h2&gt;

&lt;p&gt;I built &lt;a href="https://github.com/kobima6813/actions-harden" rel="noopener noreferrer"&gt;actions-harden&lt;/a&gt;, a zero-dependency CLI and GitHub Action that flags these patterns (plus unpinned actions, missing permissions and script injection). Real output on a deliberately bad workflow:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;secrets_bad.yml:7: [MEDIUM] workflow-env-secret: secrets.OPENAI_API_KEY is set in workflow-level env:, exposing it to every step of every job (including third-party actions). Scope it to the step that needs it.
secrets_bad.yml:15: [MEDIUM] long-lived-cloud-key: Long-lived AWS key from secrets. Prefer short-lived OIDC credentials: aws-actions/configure-aws-credentials with 'role-to-assume' + 'permissions: id-token: write'.
secrets_bad.yml:20: [MEDIUM] long-lived-cloud-key: Long-lived GCP key from secrets. Prefer short-lived OIDC credentials: google-github-actions/auth with 'workload_identity_provider' (Workload Identity Federation).
secrets_bad.yml:21: [HIGH] secret-in-log: secrets.DEPLOY_KEY is printed in a run: step. Masking is best-effort (encoded/split values leak); never echo secrets.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Add it to CI:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;permissions&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;contents&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;read&lt;/span&gt;
&lt;span class="na"&gt;jobs&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;scan&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;runs-on&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;ubuntu-latest&lt;/span&gt;
    &lt;span class="na"&gt;steps&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;uses&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;actions/checkout@v4&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;uses&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;kobima6813/actions-harden@main&lt;/span&gt;
        &lt;span class="na"&gt;with&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
          &lt;span class="na"&gt;fail-on&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;high&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Quick checklist
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;[ ] No &lt;code&gt;echo&lt;/code&gt;/&lt;code&gt;printf&lt;/code&gt; of &lt;code&gt;${{ secrets.* }}&lt;/code&gt; in &lt;code&gt;run:&lt;/code&gt; steps&lt;/li&gt;
&lt;li&gt;[ ] Secrets scoped to the step, not workflow-level &lt;code&gt;env:&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;[ ] &lt;code&gt;::add-mask::&lt;/code&gt; for any secret derived at runtime&lt;/li&gt;
&lt;li&gt;[ ] AWS/GCP/Azure via OIDC, static keys deleted&lt;/li&gt;
&lt;li&gt;[ ] OIDC trust limited to your repo/branch&lt;/li&gt;
&lt;li&gt;[ ] &lt;code&gt;permissions:&lt;/code&gt; set explicitly, &lt;code&gt;id-token: write&lt;/code&gt; only where needed&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If you're still rotating after the Railway breach, start with the &lt;a href="https://dev.to/kobima6813/railway-was-breached-a-15-minute-key-rotation-checklist-3lob"&gt;checklist&lt;/a&gt;, then do this so you don't have to do it twice.&lt;/p&gt;

</description>
      <category>githubactions</category>
      <category>security</category>
      <category>devops</category>
    </item>
    <item>
      <title>Railway was breached: a 15-minute key rotation checklist</title>
      <dc:creator>kobima6813</dc:creator>
      <pubDate>Thu, 22 Oct 2026 17:34:32 +0000</pubDate>
      <link>https://dev.to/kobima6813/railway-was-breached-a-15-minute-key-rotation-checklist-3lob</link>
      <guid>https://dev.to/kobima6813/railway-was-breached-a-15-minute-key-rotation-checklist-3lob</guid>
      <description>&lt;p&gt;On &lt;strong&gt;October 21, 2026&lt;/strong&gt; Railway published a &lt;a href="https://blog.railway.com/p/security-disclosure-wednesday-october-21st" rel="noopener noreferrer"&gt;security disclosure&lt;/a&gt;: it detected anomalous activity involving the systems that store customer environment variables, and says an unauthorized third party may have accessed encrypted environment variables and, for some of them, obtained the decrypted contents.&lt;/p&gt;

&lt;p&gt;Railway's recommendation is to &lt;strong&gt;roll all environment variables stored in Railway, including API keys and private registry credentials, and review your service logs for unusual activity&lt;/strong&gt;. Railway has also invalidated all Railway API tokens and Project Tokens created before the incident, and rotated storage bucket credentials. Live updates are on the &lt;a href="https://status.railway.com/incident/QH8L8B3E" rel="noopener noreferrer"&gt;status page&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;The advice is "rotate everything". This post turns that into a checklist you can actually work through. It's written for a small team with a handful of services.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Note: at the time of writing, builds for some plans are still being re-enabled in stages, so you may not be able to redeploy right away. &lt;strong&gt;You can still revoke and create keys now.&lt;/strong&gt; Do the provider-side work first and redeploy when you can.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  0. Inventory (3 minutes)
&lt;/h2&gt;

&lt;p&gt;For each Railway project and service, list the variable &lt;strong&gt;names&lt;/strong&gt; (not values) and where each one comes from:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# Railway CLI: create a NEW token first, old ones were invalidated&lt;/span&gt;
railway variables &lt;span class="nt"&gt;--kv&lt;/span&gt; | &lt;span class="nb"&gt;cut&lt;/span&gt; &lt;span class="nt"&gt;-d&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nt"&gt;-f1&lt;/span&gt; | &lt;span class="nb"&gt;sort&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Group them by provider: LLM APIs, payments, databases, cloud, GitHub, messaging, everything else. That list is your checklist. Don't paste values into chats, tickets or terminals that are being logged.&lt;/p&gt;

&lt;h2&gt;
  
  
  1. Rotate the high-value keys first
&lt;/h2&gt;

&lt;p&gt;Do the order by blast radius. Keys that spend money or touch customer data come first.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;AI inference keys (OpenAI, Anthropic, OpenRouter, xAI, etc.).&lt;/strong&gt; Railway says it believes the attack targeted AI inference providers, and it has notified those providers to revoke keys proactively. So:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Create a new key, then &lt;strong&gt;delete&lt;/strong&gt; the old one (not just "stop using it").&lt;/li&gt;
&lt;li&gt;Check usage and billing dashboards for spend you don't recognise.&lt;/li&gt;
&lt;li&gt;Set or lower spending limits while you're there.&lt;/li&gt;
&lt;li&gt;Railway's guides: &lt;a href="https://blog.railway.com/p/security-disclosure-recommendation-check-for-unexpected-openai-usage" rel="noopener noreferrer"&gt;OpenAI&lt;/a&gt;, &lt;a href="https://blog.railway.com/p/security-disclosure-recommendation-check-for-unexpected-anthropic-usage" rel="noopener noreferrer"&gt;Anthropic&lt;/a&gt;, &lt;a href="https://blog.railway.com/p/security-disclosure-recommendation-check-for-unexpected-openrouter-usage" rel="noopener noreferrer"&gt;OpenRouter&lt;/a&gt;, &lt;a href="https://blog.railway.com/p/security-disclosure-recommendation-check-for-unexpected-grok-usage" rel="noopener noreferrer"&gt;Grok&lt;/a&gt;.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Stripe and other payment keys.&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Roll the secret key and any restricted keys. Re-create webhook signing secrets.&lt;/li&gt;
&lt;li&gt;Review recent API request logs and events for activity you didn't trigger. See the &lt;a href="https://blog.railway.com/p/security-disclosure-recommendation-check-for-unexpected-usage-in-stripe" rel="noopener noreferrer"&gt;Stripe guide&lt;/a&gt;.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Databases.&lt;/strong&gt; Change the password or create a new user, update &lt;code&gt;DATABASE_URL&lt;/code&gt;, then drop the old credentials. Railway has guides for &lt;a href="https://blog.railway.com/p/security-disclosure-recommendation-rotate-postgresql-database-credentials" rel="noopener noreferrer"&gt;PostgreSQL&lt;/a&gt;, &lt;a href="https://blog.railway.com/p/security-disclosure-recommendation-rotate-mysql-database-credentials" rel="noopener noreferrer"&gt;MySQL&lt;/a&gt;, &lt;a href="https://blog.railway.com/p/security-disclosure-recommendation-rotate-mongodb-database-credentials" rel="noopener noreferrer"&gt;MongoDB&lt;/a&gt; and &lt;a href="https://blog.railway.com/p/security-disclosure-recommendation-rotate-redis-database-credentials" rel="noopener noreferrer"&gt;Redis&lt;/a&gt;. If the database is reachable from the internet, check its connection logs.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Cloud (AWS / GCP / Azure).&lt;/strong&gt; Deactivate the IAM access key or service-account key and create a new one. Then review CloudTrail or the audit logs for calls you didn't make. Guides: &lt;a href="https://blog.railway.com/p/security-disclosure-recommendation-check-for-anomalous-aws-access" rel="noopener noreferrer"&gt;AWS&lt;/a&gt;, &lt;a href="https://blog.railway.com/p/security-disclosure-recommendation-check-for-anomalous-gcp-access" rel="noopener noreferrer"&gt;GCP&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;GitHub tokens.&lt;/strong&gt; Revoke any PAT or GitHub App private key that was stored as a variable, and check your org audit log and security log. Railway also asks you to reconnect GitHub and redeploy buckets after its credential reset. See the &lt;a href="https://blog.railway.com/p/security-disclosure-recommendation-redeploy-buckets-and-reconnect-github-after-the-credential-reset" rel="noopener noreferrer"&gt;guide&lt;/a&gt; and the &lt;a href="https://blog.railway.com/p/security-disclosure-recommendation-check-for-anomalous-github-access" rel="noopener noreferrer"&gt;GitHub access guide&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  2. Then the long tail
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Bot tokens&lt;/strong&gt; (Telegram: &lt;code&gt;/revoke&lt;/code&gt; via BotFather; Slack and Discord: regenerate the token).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Email/SMS providers&lt;/strong&gt; (SendGrid, Postmark, Twilio, etc.).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Private registry credentials&lt;/strong&gt; (npm, Docker Hub, GHCR). Railway calls these out explicitly.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Your own app secrets&lt;/strong&gt;: session/JWT signing keys, encryption salts, internal service-to-service tokens. Rotating these will log users out, so plan for it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Third-party APIs&lt;/strong&gt; (maps, search, scraping, etc.).&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;For each one: &lt;strong&gt;create new → update the Railway variable → revoke old → note the time&lt;/strong&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  3. Recreate Railway tokens
&lt;/h2&gt;

&lt;p&gt;Railway's own API tokens and Project Tokens created before the incident no longer work. Anything that deploys to Railway (CI, scripts, the CLI) needs a new token. Make it &lt;strong&gt;project-scoped&lt;/strong&gt;, not account-wide, where you can.&lt;/p&gt;

&lt;h2&gt;
  
  
  4. Redeploy and verify
&lt;/h2&gt;

&lt;p&gt;When builds are available for your plan, redeploy so services pick up the new values. Then:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Hit your health checks.&lt;/li&gt;
&lt;li&gt;Confirm the old keys are actually dead (the provider dashboard should show them as revoked).&lt;/li&gt;
&lt;li&gt;Review the service logs from the incident window for unusual activity, as Railway recommends.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  5. Make the next one less painful
&lt;/h2&gt;

&lt;p&gt;Most of the 15 minutes goes on long-lived keys that sit in too many places. Some fixes:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Use OIDC in CI instead of static cloud keys.&lt;/strong&gt; GitHub Actions can get short-lived AWS, GCP or Azure credentials (&lt;code&gt;role-to-assume&lt;/code&gt;, &lt;code&gt;workload_identity_provider&lt;/code&gt;, federated &lt;code&gt;client-id&lt;/code&gt;), so there's no key to steal.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Never print secrets in logs.&lt;/strong&gt; &lt;code&gt;echo ${{ secrets.X }}&lt;/code&gt; relies on log masking, and masking misses encoded or transformed values.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Scope secrets to the step that needs them&lt;/strong&gt;, not workflow-level &lt;code&gt;env:&lt;/code&gt; where every step and third-party action can read them.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Pin third-party actions to commit SHAs&lt;/strong&gt;, so a moved tag can't read your secrets.&lt;/li&gt;
&lt;li&gt;Keep the inventory from step 0 in your repo (names only). Next time it's a checklist, not an archaeology project.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;I added checks for exactly these patterns to &lt;a href="https://github.com/kobima6813/actions-harden" rel="noopener noreferrer"&gt;actions-harden&lt;/a&gt;, a free, zero-dependency scanner for GitHub Actions workflows. The new rules are &lt;code&gt;secret-in-log&lt;/code&gt;, &lt;code&gt;long-lived-cloud-key&lt;/code&gt; and &lt;code&gt;workflow-env-secret&lt;/code&gt;, next to SHA pinning, permissions and script-injection checks:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;uses&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;kobima6813/actions-harden@main&lt;/span&gt;
  &lt;span class="na"&gt;with&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;fail-on&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;medium&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If you'd rather hear about CI breakages before they hit your builds, there's also &lt;a href="https://kobima6813.github.io/ci-earlywarn/" rel="noopener noreferrer"&gt;CI Early-Warn&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;Stay safe, and rotate before you redeploy.&lt;/p&gt;

</description>
      <category>security</category>
      <category>devops</category>
      <category>githubactions</category>
      <category>railway</category>
    </item>
    <item>
      <title>How to tell if the ubuntu-latest 26.04 migration broke your build</title>
      <dc:creator>kobima6813</dc:creator>
      <pubDate>Tue, 20 Oct 2026 16:46:30 +0000</pubDate>
      <link>https://dev.to/kobima6813/how-to-tell-if-the-ubuntu-latest-2604-migration-broke-your-build-29fn</link>
      <guid>https://dev.to/kobima6813/how-to-tell-if-the-ubuntu-latest-2604-migration-broke-your-build-29fn</guid>
      <description>&lt;p&gt;GitHub is moving the &lt;code&gt;ubuntu-latest&lt;/code&gt; label from Ubuntu 24.04 to &lt;strong&gt;Ubuntu 26.04&lt;/strong&gt;. The rollout is gradual and runs &lt;strong&gt;between October 19 and November 19, 2026&lt;/strong&gt; (&lt;a href="https://github.blog/changelog/2026-09-17-ubuntu-26-generally-available-and-latest-migration/" rel="noopener noreferrer"&gt;GitHub changelog&lt;/a&gt;, &lt;a href="https://github.com/actions/runner-images/issues/14748" rel="noopener noreferrer"&gt;runner-images announcement #14748&lt;/a&gt;).&lt;/p&gt;

&lt;p&gt;Because it is gradual, one run can land on 24.04 and the next on 26.04, with no change in your repo. If a build that was green yesterday is red today, this is how to check whether the image is the cause.&lt;/p&gt;

&lt;h2&gt;
  
  
  1. Check which image the job actually ran on
&lt;/h2&gt;

&lt;p&gt;Open the failed run and expand the &lt;strong&gt;Set up job&lt;/strong&gt; step. You will see something like:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="s"&gt;Runner Image&lt;/span&gt;
  &lt;span class="s"&gt;Image&lt;/span&gt;&lt;span class="err"&gt;:&lt;/span&gt; &lt;span class="s"&gt;ubuntu-26.04&lt;/span&gt;
  &lt;span class="s"&gt;Version&lt;/span&gt;&lt;span class="err"&gt;:&lt;/span&gt; &lt;span class="s"&gt;20261012.163.1&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Compare it with the last green run. If the green run says &lt;code&gt;ubuntu-24.04&lt;/code&gt; and the red one says &lt;code&gt;ubuntu-26.04&lt;/code&gt;, you have your prime suspect.&lt;/p&gt;

&lt;p&gt;The same values are available as environment variables in every job, so you can print them explicitly:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;run&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;echo "ImageOS=$ImageOS ImageVersion=$ImageVersion" &amp;amp;&amp;amp; lsb_release -ds&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;ImageOS&lt;/code&gt; is &lt;code&gt;ubuntu24&lt;/code&gt; or &lt;code&gt;ubuntu26&lt;/code&gt;. Logging this in every job makes later debugging much faster.&lt;/p&gt;

&lt;h2&gt;
  
  
  2. Confirm by pinning (the fastest test)
&lt;/h2&gt;

&lt;p&gt;Re-run the same commit with the old image:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;runs-on&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;ubuntu-24.04&lt;/span&gt;   &lt;span class="c1"&gt;# instead of ubuntu-latest&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;ul&gt;
&lt;li&gt;Green on 24.04 and red on 26.04 means the image change is the cause.&lt;/li&gt;
&lt;li&gt;Red on both means the cause is something else (a dependency release, a flaky test, your own change).&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Pinning to &lt;code&gt;ubuntu-24.04&lt;/code&gt; is also the officially suggested escape hatch if you are not ready to move. Treat it as a temporary pin, not a permanent fix.&lt;/p&gt;

&lt;p&gt;To test ahead of time, add a matrix leg:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;strategy&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;matrix&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;os&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;ubuntu-24.04&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;ubuntu-26.04&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
&lt;span class="na"&gt;runs-on&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${{ matrix.os }}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  3. Diff the installed software
&lt;/h2&gt;

&lt;p&gt;Each image has a software list in &lt;a href="https://github.com/actions/runner-images/tree/main/images/ubuntu" rel="noopener noreferrer"&gt;actions/runner-images&lt;/a&gt;:&lt;br&gt;
&lt;a href="https://github.com/actions/runner-images/blob/main/images/ubuntu/Ubuntu2404-Readme.md" rel="noopener noreferrer"&gt;Ubuntu2404-Readme.md&lt;/a&gt; and &lt;a href="https://github.com/actions/runner-images/blob/main/images/ubuntu/Ubuntu2604-Readme.md" rel="noopener noreferrer"&gt;Ubuntu2604-Readme.md&lt;/a&gt;. Diff them for the tools your build uses, and check the &lt;a href="https://github.com/actions/runner-images/releases" rel="noopener noreferrer"&gt;releases page&lt;/a&gt; for the exact &lt;code&gt;ImageVersion&lt;/code&gt; you got.&lt;/p&gt;

&lt;p&gt;Differences we saw when comparing the two READMEs on 2026-10-20 (always re-check against the current files):&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Area&lt;/th&gt;
&lt;th&gt;Ubuntu 24.04&lt;/th&gt;
&lt;th&gt;Ubuntu 26.04&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Default Python&lt;/td&gt;
&lt;td&gt;3.12&lt;/td&gt;
&lt;td&gt;3.14&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Default Node.js&lt;/td&gt;
&lt;td&gt;22&lt;/td&gt;
&lt;td&gt;24&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Ruby / PHP / Perl&lt;/td&gt;
&lt;td&gt;3.2 / 8.3 / 5.38&lt;/td&gt;
&lt;td&gt;3.3 / 8.5 / 5.40&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Clang&lt;/td&gt;
&lt;td&gt;16–18&lt;/td&gt;
&lt;td&gt;20–22&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;OpenSSL&lt;/td&gt;
&lt;td&gt;3.0&lt;/td&gt;
&lt;td&gt;3.5&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Docker client&lt;/td&gt;
&lt;td&gt;28&lt;/td&gt;
&lt;td&gt;29&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Kernel&lt;/td&gt;
&lt;td&gt;6.x azure&lt;/td&gt;
&lt;td&gt;7.0 azure&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Java 8 (&lt;code&gt;JAVA_HOME_8_X64&lt;/code&gt;)&lt;/td&gt;
&lt;td&gt;present&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;not listed&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Not listed on 26.04&lt;/td&gt;
&lt;td&gt;&lt;/td&gt;
&lt;td&gt;Fastlane, Julia, Lerna, Mercurial, Miniconda, Newman, Parcel, Pulumi, MediaInfo, Haveged, Sphinx search server&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h2&gt;
  
  
  4. Common breakages and quick fixes
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;"command not found"&lt;/strong&gt; for a tool that used to be preinstalled: install it explicitly (&lt;code&gt;setup-*&lt;/code&gt; action, &lt;code&gt;apt-get install&lt;/code&gt;, &lt;code&gt;pip&lt;/code&gt;/&lt;code&gt;npm&lt;/code&gt; install) instead of relying on the image.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Python 3.14 / Node 24 default changes&lt;/strong&gt;: pin the version with &lt;code&gt;actions/setup-python&lt;/code&gt; / &lt;code&gt;actions/setup-node&lt;/code&gt; rather than using whatever the image ships. Watch for removed stdlib/deprecated APIs and native modules that have no wheels/prebuilds yet.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Compiler upgrades&lt;/strong&gt; (newer GCC/Clang): new warnings become errors under &lt;code&gt;-Werror&lt;/code&gt;; old code may need flag changes.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;OpenSSL 3.5&lt;/strong&gt;: legacy algorithms or old TLS settings in tests can fail.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Java 8 missing&lt;/strong&gt;: use &lt;code&gt;actions/setup-java&lt;/code&gt; with &lt;code&gt;java-version: 8&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;apt package names changed or removed&lt;/strong&gt;: &lt;code&gt;apt-get install&lt;/code&gt; fails with "Unable to locate package". Check the package name for 26.04 (resolute).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Kernel / system changes&lt;/strong&gt;: low-level things (eBPF, ACLs, FUSE, container runtimes) can behave differently. One example reported on day 2 of the rollout: &lt;a href="https://github.com/actions/runner-images/issues/15004" rel="noopener noreferrer"&gt;setfacl fails on Ubuntu 26.04 (#15004)&lt;/a&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Cache keys&lt;/strong&gt;: if your cache key does not include the OS version, a 24.04 cache restored on 26.04 can break native builds. Include &lt;code&gt;${{ runner.os }}-${{ env.ImageOS }}&lt;/code&gt; in the key.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If you find a real regression in the image, search and report it in &lt;a href="https://github.com/actions/runner-images/issues" rel="noopener noreferrer"&gt;actions/runner-images issues&lt;/a&gt; with the &lt;code&gt;ImageVersion&lt;/code&gt; from step 1.&lt;/p&gt;

&lt;h2&gt;
  
  
  5. Tell "it's the image" from "it's just me" across repos
&lt;/h2&gt;

&lt;p&gt;The hard part in a gradual rollout is knowing whether a red build is your problem or everybody's. Disclosure: I built a small free tool for this, &lt;strong&gt;&lt;a href="https://github.com/kobima6813/ci-earlywarn" rel="noopener noreferrer"&gt;CI Early-Warn&lt;/a&gt;&lt;/strong&gt;. A &lt;a href="https://github.com/marketplace/actions/ci-early-warn-beacon" rel="noopener noreferrer"&gt;GitHub Action&lt;/a&gt; reports minimal job metadata (OS, image version, pass/fail, failing-step category; no code, logs or repo names). When failures across many repos jump on a new image version, it raises an alert, and says "ambiguous" when it can't tell the cause. It is early and only useful with enough participants, so steps 1–4 above are the reliable method.&lt;/p&gt;

&lt;h2&gt;
  
  
  TL;DR
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;Look at &lt;strong&gt;Set up job → Runner Image&lt;/strong&gt; (or &lt;code&gt;$ImageOS&lt;/code&gt; / &lt;code&gt;$ImageVersion&lt;/code&gt;).&lt;/li&gt;
&lt;li&gt;Re-run with &lt;code&gt;runs-on: ubuntu-24.04&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Diff the runner-images READMEs for the tools you use.&lt;/li&gt;
&lt;li&gt;Install/pin what you depend on explicitly, then move to 26.04 on your schedule.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;&lt;strong&gt;Free CI cost audit:&lt;/strong&gt; email your GitHub Actions workflow file(s) to &lt;a href="mailto:conformance@mail.grokbot.com"&gt;conformance@mail.grokbot.com&lt;/a&gt; (subject "CI audit") and I'll reply within 2 business days with a short report on wasted runner minutes and concrete fixes. Workflow YAML only, no secrets or code; files deleted after 30 days.&lt;/p&gt;

</description>
      <category>githubactions</category>
      <category>ubuntu</category>
      <category>devops</category>
      <category>ci</category>
    </item>
  </channel>
</rss>
