<?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: Jonathan</title>
    <description>The latest articles on DEV Community by Jonathan (@pong1965).</description>
    <link>https://dev.to/pong1965</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%2F4015281%2F493b7797-432e-4e1c-b960-d582475ea1bb.png</url>
      <title>DEV Community: Jonathan</title>
      <link>https://dev.to/pong1965</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/pong1965"/>
    <language>en</language>
    <item>
      <title>Run-Scoped Mailboxes for GitHub Actions API Tests</title>
      <dc:creator>Jonathan</dc:creator>
      <pubDate>Fri, 02 Oct 2026 23:24:21 +0000</pubDate>
      <link>https://dev.to/pong1965/run-scoped-mailboxes-for-github-actions-api-tests-3bnf</link>
      <guid>https://dev.to/pong1965/run-scoped-mailboxes-for-github-actions-api-tests-3bnf</guid>
      <description>&lt;p&gt;Email-dependent API tests often look random when the real problem is shared state. Two GitHub Actions jobs create the same user, one job reads an old verification message, and a retry consumes the only useful link. The test reports a timeout, but the root cause is an inbox that belonged to another run.&lt;/p&gt;

&lt;p&gt;I use a run-scoped mailbox contract to make these failures easier to reason about. Each workflow run gets its own disposable address, a short lease, and a small evidence record. The approach is simple enough for a few API tests, but it also scales better than asking every test helper to invent its own cleanup rules.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why shared inboxes make API tests nondeterministic
&lt;/h2&gt;

&lt;p&gt;A shared test inbox creates three kinds of collisions:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Identity collisions:&lt;/strong&gt; two jobs register the same email address and one receives a conflict response.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Message collisions:&lt;/strong&gt; a test matches a verification email from a previous run.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Lifecycle collisions:&lt;/strong&gt; one cleanup step deletes data while another test is still polling it.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The usual reaction is to increase the polling timeout. That makes the pipeline slower and doesnt fix a stale message. A better boundary is to give every run an address derived from a run ID, then refuse to read messages outside that boundary.&lt;/p&gt;

&lt;p&gt;The address does not need to expose the branch name or a user email. A short random suffix, a workflow run identifier, and a provider-managed domain are enough. Store the full address in the test process, but put only a fixture label in CI logs.&lt;/p&gt;

&lt;p&gt;On a busy morning, this is usualy the first signal that saves a developer from chasing the wrong job.&lt;/p&gt;

&lt;h2&gt;
  
  
  Define a mailbox lease for each workflow run
&lt;/h2&gt;

&lt;p&gt;Think of the mailbox as a leased test resource. The lease has four values:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;code&gt;run_id&lt;/code&gt;: the workflow execution that owns the mailbox.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;address&lt;/code&gt;: the disposable address used by the API test.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;expires_at&lt;/code&gt;: the time after which the fixture is no longer valid.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;cleanup_state&lt;/code&gt;: whether the test released it, timed out, or failed.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The test should always query by &lt;code&gt;run_id&lt;/code&gt; or a unique message marker. Subject text alone is too broad. A useful marker can look like &lt;code&gt;ci-verification-${run_id}&lt;/code&gt; and should be passed as an API field or a controlled email subject, not hidden in an undocumented helper.&lt;/p&gt;

&lt;p&gt;This is also where teams should decide what a disposable address means. A &lt;a href="https://tempmailso.com" rel="noopener noreferrer"&gt;temp mail so&lt;/a&gt; service can be useful for manual checks or isolated test fixtures, but it is not a substitute for ownership and retention rules. The API test still needs to prove that the message belongs to the current run.&lt;/p&gt;

&lt;h2&gt;
  
  
  Implement the fixture contract
&lt;/h2&gt;

&lt;p&gt;Keep the fixture interface small. For example:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;type&lt;/span&gt; &lt;span class="nx"&gt;MailboxLease&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;runId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;address&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;marker&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;expiresAt&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;};&lt;/span&gt;

&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;waitForVerification&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="nx"&gt;lease&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;MailboxLease&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;timeoutMs&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;30&lt;/span&gt;&lt;span class="nx"&gt;_000&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="nb"&gt;Promise&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;deadline&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;Date&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;now&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nx"&gt;timeoutMs&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

  &lt;span class="k"&gt;while &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;Date&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;now&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="nx"&gt;deadline&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;message&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;inbox&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;find&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
      &lt;span class="na"&gt;to&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;lease&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;address&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;subjectContains&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;lease&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;marker&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;});&lt;/span&gt;

    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;message&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;extractVerificationUrl&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;message&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;delay&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="nx"&gt;_000&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`No message for run &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;lease&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;runId&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt; before timeout`&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The important detail is not the polling function. It is the contract around it: the mailbox address and marker are created together, and the failure names the run. If a test uses a fake value such as &lt;code&gt;temp gamil com&lt;/code&gt; or &lt;code&gt;temp org mail&lt;/code&gt; during a manual experiment, keep that value out of production fixtures and logs so it doesnt become a confusing search term later.&lt;/p&gt;

&lt;p&gt;For an overview of &lt;a href="https://dev.to/ryanlee91/react-auth-emails-without-state-drift-ec1"&gt;keeping auth email state aligned&lt;/a&gt;, the same principle applies on the client side: the address, request, and verification state should describe one attempt. The backend and UI should not each guess which email is current.&lt;/p&gt;

&lt;h2&gt;
  
  
  Wire it into GitHub Actions
&lt;/h2&gt;

&lt;p&gt;Create the lease before the test command and pass only the minimum values through the environment:&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;Create mailbox lease&lt;/span&gt;
  &lt;span class="na"&gt;id&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;mailbox&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;node scripts/create-mailbox-lease.mjs &amp;gt; mailbox.json&lt;/span&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;Run API tests&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;MAILBOX_LEASE_FILE&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;mailbox.json&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;npm test -- --runInBand&lt;/span&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;Upload test evidence&lt;/span&gt;
  &lt;span class="na"&gt;if&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;always()&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/upload-artifact@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;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;api-mailbox-evidence-${{ github.run_id }}&lt;/span&gt;
    &lt;span class="na"&gt;path&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;artifacts/api-mailbox&lt;/span&gt;
    &lt;span class="na"&gt;retention-days&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;7&lt;/span&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;Release mailbox lease&lt;/span&gt;
  &lt;span class="na"&gt;if&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;always()&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;node scripts/release-mailbox-lease.mjs mailbox.json&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The lease step should fail closed if it cannot create a unique fixture. Do not silently fall back to a team inbox. That fallback is convenient for one green run and painful when a parallel job starts.&lt;/p&gt;

&lt;p&gt;Keep the ownership rule seperate from the provider client. The test should know who owns the fixture, even if the mail adapter changes later.&lt;/p&gt;

&lt;p&gt;For teams comparing the &lt;a href="https://tempmailso.com" rel="noopener noreferrer"&gt;best throwaway email&lt;/a&gt; option for a test address, the practical decision is less about a catchy provider name and more about whether the fixture can be isolated, queried, and expired predictably.&lt;/p&gt;

&lt;h2&gt;
  
  
  Clean up without hiding failures
&lt;/h2&gt;

&lt;p&gt;Cleanup should run even when the test fails, but its result must not erase the original error. Save a small JSON receipt before release:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"run_id"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"1842"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"fixture"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"signup-verification"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"messages_seen"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"test_status"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"failed"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"cleanup_status"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"released"&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Keep message bodies out of the artifact unless a specific review requires them. A message ID, subject hash, timestamp, and redacted error usually gives enough context. This makes the cleanup is easier to audit and reduces the chance that a real address or token survives in CI storage.&lt;/p&gt;

&lt;p&gt;If the provider cannot release a mailbox immediately, mark the lease expired and let a scheduled janitor remove it later. Never reassign an unexpired lease to another run. Reuse is the shortcut that turns one stale message into a hard-to-reproduce incident.&lt;/p&gt;

&lt;p&gt;The result are easier to review when expiry and release appear as explicit states instead of hidden cleanup behavior.&lt;/p&gt;

&lt;h2&gt;
  
  
  Common questions
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Should every API test create a new mailbox?
&lt;/h3&gt;

&lt;p&gt;Not always. A test suite can share one lease when its cases are deliberately sequential and use distinct markers. Parallel tests should get separate leases, especially when they create users or consume one-time verification URLs.&lt;/p&gt;

&lt;h3&gt;
  
  
  Is a disposable address enough for privacy?
&lt;/h3&gt;

&lt;p&gt;No. It limits exposure of personal mail, but logs, artifacts, provider retention, and application databases still need rules. Record fixture IDs instead of full addresses wherever possible.&lt;/p&gt;

&lt;h3&gt;
  
  
  How long should a lease live?
&lt;/h3&gt;

&lt;p&gt;Long enough for the slowest supported CI path plus a small buffer. The exact duration depends on the provider and test environment. The key is to make expiration explicit and to report it as a fixture failure, not as an unexplained API timeout.&lt;/p&gt;

&lt;h2&gt;
  
  
  A small rollout checklist
&lt;/h2&gt;

&lt;p&gt;Start with one flaky email API test:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Generate a unique lease per workflow run.&lt;/li&gt;
&lt;li&gt;Add a run marker to every message lookup.&lt;/li&gt;
&lt;li&gt;Record status, message ID, and redacted timing evidence.&lt;/li&gt;
&lt;li&gt;Upload evidence with &lt;code&gt;if: always()&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Release or expire the lease in a final step.&lt;/li&gt;
&lt;li&gt;Verify two concurrent jobs cannot read each other's messages.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The before-and-after improvement is straightforward: a failed test now says which run owned the mailbox, which marker was expected, and whether cleanup completed. That is a much better starting point than “email timed out again.” It also makes &lt;a href="https://dev.to/jasonmills94/how-to-test-kubernetes-rollback-emails-without-inbox-guesswork-j8j"&gt;isolated inbox checks in CI&lt;/a&gt; a reusable engineering pattern instead of a one-off workaround.&lt;/p&gt;

</description>
      <category>githubactions</category>
      <category>api</category>
      <category>testing</category>
      <category>automation</category>
    </item>
    <item>
      <title>Git Worktrees for Isolated Email API Tests</title>
      <dc:creator>Jonathan</dc:creator>
      <pubDate>Fri, 02 Oct 2026 02:23:49 +0000</pubDate>
      <link>https://dev.to/pong1965/git-worktrees-for-isolated-email-api-tests-49bc</link>
      <guid>https://dev.to/pong1965/git-worktrees-for-isolated-email-api-tests-49bc</guid>
      <description>&lt;p&gt;When an email API test starts failing on one branch, the first instinct is often to rerun the same checkout and watch the inbox again. That gets messy when two fixes are being investigated at once. One branch changes retry timing, another changes the webhook parser, and both write into the same temporary mailbox. The result is a test report that is green-ish, confusing, and realy hard to trust.&lt;/p&gt;

&lt;p&gt;I have found a simple combination more useful: one Git worktree per investigation, one fixture namespace per worktree, and one small evidence bundle per run. It turns parallel debugging from a shared-state problem into a few independent folders. The workflow is especially handy for GitHub Actions and other automation where a failed check should explain itself after the job is gone.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why shared email fixtures make parallel branches noisy
&lt;/h2&gt;

&lt;p&gt;An email API test usually has more state than the test file suggests. There is a recipient address, a message ID, a polling window, a subject or token assertion, and often a cleanup step. If two branches reuse any of those values, a passing assertion can read the wrong message.&lt;/p&gt;

&lt;p&gt;That is why I avoid a generic address such as &lt;code&gt;test@example.com&lt;/code&gt; for concurrent checks. Even a disposable address should have a run-scoped name, for example &lt;code&gt;ci-${run_id}@inbox.test&lt;/code&gt;, or an equivalent fixture identifier supplied by the email test service. A search like &lt;code&gt;fake e mail com&lt;/code&gt; should not be the process for choosing test data; the test needs a documented contract instead.&lt;/p&gt;

&lt;p&gt;The same principle applies to signup flows that have several visible states. A small state machine for signup flows is a useful companion when the UI can show pending, expired, and verified states independently of the inbox lookup.&lt;/p&gt;

&lt;h2&gt;
  
  
  Create one worktree per investigation
&lt;/h2&gt;

&lt;p&gt;Git worktrees let me keep multiple branches checked out at once without copying the whole repository. A short setup looks like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;git fetch origin
git worktree add ../email-api-retry-fix &lt;span class="nt"&gt;-b&lt;/span&gt; debug/email-api-retry origin/main
git worktree add ../email-api-parser &lt;span class="nt"&gt;-b&lt;/span&gt; debug/email-api-parser origin/main
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Now each investigation has its own source tree and its own &lt;code&gt;.env.test&lt;/code&gt; or fixture configuration. I put the run identifier in the environment rather than in a global config file:&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;export &lt;/span&gt;&lt;span class="nv"&gt;EMAIL_TEST_RUN&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;git branch &lt;span class="nt"&gt;--show-current&lt;/span&gt; | &lt;span class="nb"&gt;tr&lt;/span&gt; &lt;span class="s1"&gt;'/'&lt;/span&gt; &lt;span class="s1"&gt;'-'&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="s2"&gt;-&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;date&lt;/span&gt; +%s&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
&lt;span class="nb"&gt;export &lt;/span&gt;&lt;span class="nv"&gt;EMAIL_FIXTURE_PREFIX&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;EMAIL_TEST_RUN&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;-"&lt;/span&gt;
./scripts/email-api-check.sh &lt;span class="nt"&gt;--fixture-prefix&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$EMAIL_FIXTURE_PREFIX&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The branch name is not a security boundary, so I still keep credentials in the CI secret store. It is only a convenient human-readable label. Also, remove a worktree when the investigation is finished; leaving many stale dependancies around makes the local setup harder to understand.&lt;/p&gt;

&lt;h2&gt;
  
  
  Give every test run its own inbox contract
&lt;/h2&gt;

&lt;p&gt;The check should produce a small contract before it sends anything. Mine normally records:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;the run ID and commit SHA&lt;/li&gt;
&lt;li&gt;the fixture or recipient identifier&lt;/li&gt;
&lt;li&gt;the expected event type&lt;/li&gt;
&lt;li&gt;the maximum polling duration&lt;/li&gt;
&lt;li&gt;the message marker that proves the right email was found&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The assertion should reject a message from another run, even if the subject looks correct. Matching both a run marker and an event marker is a small extra step with a big payoff. For high-concurrency suites, concurrency keys for email API checks can make this ownership explicit at the API boundary too.&lt;/p&gt;

&lt;p&gt;Here is the shape of a result I want the shell script to write:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"run_id"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"debug-email-api-retry-1710000000"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"commit"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"abc1234"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"event"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"verification.sent"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"matched"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"poll_seconds"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;8&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"message_id"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"msg_42"&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If the check needs a generated address, make the generation step visible in that result. Searching for terms such as &lt;code&gt;dummy e mail&lt;/code&gt; may describe the intent, but it does not tell a future reader which fixture was actually used.&lt;/p&gt;

&lt;h2&gt;
  
  
  Keep GitHub Actions evidence attached to the branch
&lt;/h2&gt;

&lt;p&gt;The workflow becomes much easier to triage when every job uploads the same small set of files, including failures:&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;Run email API check&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;EMAIL_TEST_RUN&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${{ github.head_ref || github.ref_name }}-${{ github.run_id }}&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;./scripts/email-api-check.sh --out artifacts/email-result.json&lt;/span&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;Upload email evidence&lt;/span&gt;
  &lt;span class="na"&gt;if&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;always()&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/upload-artifact@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;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;email-api-${{ github.run_id }}&lt;/span&gt;
    &lt;span class="na"&gt;path&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;artifacts/&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The before-and-after improvement is not fancy: before, a developer had to reconstruct which branch sent which message. After, the commit, fixture, assertion, and failure reason travel together. When a test fails, I can open the worktree, rerun the same command, and compare the artifact without guessing.&lt;/p&gt;

&lt;h2&gt;
  
  
  Q&amp;amp;A
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Should every branch get a new inbox?
&lt;/h3&gt;

&lt;p&gt;For parallel or long-running checks, yes. For a strictly sequential local test, a reusable fixture can be fine if cleanup is reliable. The important part is making that choice explicit.&lt;/p&gt;

&lt;h3&gt;
  
  
  Is a Git worktree required in CI?
&lt;/h3&gt;

&lt;p&gt;No. CI already gives each job a fresh checkout in most setups. Worktrees are most valuable locally, where several branches are open and developers are tempted to reuse one directory.&lt;/p&gt;

&lt;h3&gt;
  
  
  What is the smallest useful artifact?
&lt;/h3&gt;

&lt;p&gt;Commit SHA, run ID, fixture ID, expected event, matched message ID, and failure reason. Keep the payload small so people actualy read it.&lt;/p&gt;

&lt;p&gt;The shortcut is simple: isolate code with worktrees, isolate test data with run-scoped fixtures, and isolate diagnosis with predictable artifacts. That gives Git and automation a cleaner handoff, while leaving fewer mysteries in the inbox.&lt;/p&gt;

</description>
      <category>git</category>
      <category>api</category>
      <category>githubactions</category>
      <category>automation</category>
    </item>
    <item>
      <title>A Failure Bundle for GitHub Actions API Tests</title>
      <dc:creator>Jonathan</dc:creator>
      <pubDate>Thu, 01 Oct 2026 23:23:36 +0000</pubDate>
      <link>https://dev.to/pong1965/a-failure-bundle-for-github-actions-api-tests-5anc</link>
      <guid>https://dev.to/pong1965/a-failure-bundle-for-github-actions-api-tests-5anc</guid>
      <description>&lt;p&gt;API tests usually fail with a red check, a stack trace, and a request to “run it again.” That is not much evidence. By the time someone opens the workflow, the relevant response body may be gone, the test fixture may have expired, and nobody remembers which commit produced the request.&lt;/p&gt;

&lt;p&gt;When I build API integrations, I try to make every failed GitHub Actions job leave behind a small failure bundle. It is a boring improvement, but it turns a vague CI alert into something another developer can reproduce without a live debugging session.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why a red CI job is not enough
&lt;/h2&gt;

&lt;p&gt;A failed API test has at least three useful dimensions:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;The request:&lt;/strong&gt; method, route, safe headers, and a correlation ID.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The response:&lt;/strong&gt; status, selected headers, and a redacted body.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The environment:&lt;/strong&gt; commit, test command, dependency lockfile, and fixture state.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Most pipelines preserve only the test runner output. That output might say that an assertion expected &lt;code&gt;201&lt;/code&gt; and received &lt;code&gt;409&lt;/code&gt;, but it will not explain whether the duplicate came from a reused fake email address, a retry, or a backend job that finished late.&lt;/p&gt;

&lt;p&gt;The fix is not “log everything.” Logging everything creates privacy risks and makes the real signal hard to find. The fix is a predictable artifact with a narrow schema.&lt;/p&gt;

&lt;h2&gt;
  
  
  What belongs in a failure bundle
&lt;/h2&gt;

&lt;p&gt;I use a directory with one small JSON file per failed case and a workflow summary that links to the artifact. A useful record looks like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"test"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"creates a verification session"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"commit"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"${GITHUB_SHA}"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"request_id"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"req-8d2c"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"method"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"POST"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"path"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"/v1/verification-sessions"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"status"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;409&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"fixture"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"fresh-address"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"retry"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"body_redacted"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"verification session already exists"&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Do not store authorization headers, full cookies, or an entire inbound email in this file. A fixture label such as &lt;code&gt;fresh-address&lt;/code&gt; is enough to tell a teammate what was intended. If a test needs an inbox-like value, generate it inside the test run and record only a hash or short label.&lt;/p&gt;

&lt;p&gt;For teams that search for a tempail or a tem email while investigating, consistent field names matter more than the spelling in a query. Keep the canonical terms in documentation, then make the artifact easy to grep. This is a small detail, but it saves a realy annoying amount of time during an incident.&lt;/p&gt;

&lt;h2&gt;
  
  
  A GitHub Actions implementation
&lt;/h2&gt;

&lt;p&gt;The test command should always write its diagnostics to a known directory, even when the process exits non-zero. Then upload the directory in an &lt;code&gt;always()&lt;/code&gt; step:&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;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;API tests&lt;/span&gt;

&lt;span class="na"&gt;on&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;pull_request&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;push&lt;/span&gt;&lt;span class="pi"&gt;:&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;test&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;actions/setup-node@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;node-version&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;22&lt;/span&gt;
          &lt;span class="na"&gt;cache&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;npm&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;npm ci&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;npm test -- --reporter=dot&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;API_DIAGNOSTICS_DIR&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;artifacts/api-failures&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;if&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;always()&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/upload-artifact@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;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;api-failure-bundle-${{ github.run_id }}&lt;/span&gt;
          &lt;span class="na"&gt;path&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;artifacts/api-failures&lt;/span&gt;
          &lt;span class="na"&gt;if-no-files-found&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;ignore&lt;/span&gt;
          &lt;span class="na"&gt;retention-days&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;7&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The important part is the contract between the test runner and the workflow: &lt;code&gt;API_DIAGNOSTICS_DIR&lt;/code&gt; is stable, and the upload step runs whether the tests pass or fail. On a green run, the directory can be empty. On a red run, the bundle is attached to the exact workflow execution.&lt;/p&gt;

&lt;p&gt;For quick investigation, append a short summary as well. A summary can say which test failed, link the artifact, and show the request ID. Keep it short; large logs belong in the artifact, not in the pull request timeline.&lt;/p&gt;

&lt;h2&gt;
  
  
  Make retries produce comparable evidence
&lt;/h2&gt;

&lt;p&gt;Retries can hide a race condition. Every attempt should therefore have its own record, with an attempt number and timestamp. Do not overwrite &lt;code&gt;failure.json&lt;/code&gt; on retry. Use names such as &lt;code&gt;creates-session-attempt-1.json&lt;/code&gt; and &lt;code&gt;creates-session-attempt-2.json&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;This makes it possible to compare a &lt;code&gt;409&lt;/code&gt; followed by a &lt;code&gt;201&lt;/code&gt;, or a timeout followed by a successful response. It also tells you when the test is not flaky at all: a fixture may simply be reused between attempts. The cleanup rule should be visible in the bundle, not implied in a test helper that only one person remembers.&lt;/p&gt;

&lt;p&gt;I also keep the request ID from the API response, when one exists. That ID connects the CI artifact to server logs without copying sensitive payloads into GitHub. A redacted trace is usualy more useful than a perfect transcript that cannot be shared.&lt;/p&gt;

&lt;p&gt;If your project already has notes on &lt;a href="https://dev.to/pong1965/github-actions-notes-for-faster-email-api-triage"&gt;email API triage&lt;/a&gt;, this failure-bundle pattern fits naturally beside them. The same boundary applies when an email event is part of an API test: record the event ID and state, not private message contents.&lt;/p&gt;

&lt;h2&gt;
  
  
  Keep test data private and useful
&lt;/h2&gt;

&lt;p&gt;Fake email addresses and temporary fixtures are helpful for isolating signup and verification flows, but they are not a privacy policy. Avoid real customer addresses, production tokens, and complete message bodies in CI artifacts. Set a short retention period, restrict who can read artifacts, and delete local diagnostics before packaging anything else.&lt;/p&gt;

&lt;p&gt;An &lt;a href="https://dev.to/mrdapperx/email-as-a-deployment-contract"&gt;email deployment contract&lt;/a&gt; is a useful mental model here: define what the test promises to observe, then store only the evidence needed to check that promise. This keeps the bundle small and makes later cleanup much simpler.&lt;/p&gt;

&lt;h2&gt;
  
  
  A small adoption checklist
&lt;/h2&gt;

&lt;p&gt;Start with one unreliable API test and add:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;A stable diagnostics directory.&lt;/li&gt;
&lt;li&gt;A JSON record for request, response, fixture, and attempt metadata.&lt;/li&gt;
&lt;li&gt;Redaction before the record is written.&lt;/li&gt;
&lt;li&gt;An &lt;code&gt;always()&lt;/code&gt; artifact upload in GitHub Actions.&lt;/li&gt;
&lt;li&gt;A short workflow summary with the test name and request ID.&lt;/li&gt;
&lt;li&gt;A retention rule that matches the sensitivity of the data.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The result is not a fancy observability system. It is a repeatable handoff from a failing check to the next developer. That little bit of evidence often makes API failures faster to fix, easier to discuss, and less likely to be dismissed as “just CI being flaky.”&lt;/p&gt;

</description>
      <category>githubactions</category>
      <category>api</category>
      <category>testing</category>
      <category>automation</category>
    </item>
    <item>
      <title>GitHub Actions API Tests Need a Debuggable Receipt</title>
      <dc:creator>Jonathan</dc:creator>
      <pubDate>Thu, 01 Oct 2026 14:23:41 +0000</pubDate>
      <link>https://dev.to/pong1965/github-actions-api-tests-need-a-debuggable-receipt-aam</link>
      <guid>https://dev.to/pong1965/github-actions-api-tests-need-a-debuggable-receipt-aam</guid>
      <description>&lt;p&gt;An API test that says only &lt;code&gt;failed&lt;/code&gt; is not finished. It has detected a problem, but it has not helped the next developer decide whether the cause was a bad request, a slow dependency, a changed response, or a broken test fixture.&lt;/p&gt;

&lt;p&gt;I have found a small receipt contract makes GitHub Actions workflows much easier to operate. Each test run gets a correlation ID, records the request shape without secrets, saves the response summary, and leaves a short artifact even when the assertion fails. The change takes minutes, and the debugging loop becomes much shorter.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why a green API test can still be hard to debug
&lt;/h2&gt;

&lt;p&gt;Most CI API checks produce a status and a few lines of console output. That is fine when everything is green. It is not enough when a test runs beside several deployments or when a provider returns an unexpected response.&lt;/p&gt;

&lt;p&gt;The usual clues are scattered across logs:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Which commit and workflow run sent the request?&lt;/li&gt;
&lt;li&gt;Was this a fresh test fixture or a reused one?&lt;/li&gt;
&lt;li&gt;Did the API reject the payload, or did the response arrive too late?&lt;/li&gt;
&lt;li&gt;Did a retry see the same result?&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Without those facts, developers often rerun the job and hope the second result explains the first. That costs time and can hide a real race condition. A receipt gives the run an identity and puts the important evidence in one place.&lt;/p&gt;

&lt;h2&gt;
  
  
  Create a receipt contract before the request
&lt;/h2&gt;

&lt;p&gt;Start with a tiny JSON document. Keep it safe to upload as a CI artifact:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"run_id"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"local-abc123"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"endpoint"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"/v1/verification"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"method"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"POST"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"started_at"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"2026-10-01T14:00:00Z"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"status"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"pending"&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Do not put authorization headers, full email addresses, tokens, or response bodies in this file. A redacted request fingerprint is normally enough to compare retries. For test flows that use a &lt;a href="https://tempmailso.com" rel="noopener noreferrer"&gt;tempmailso&lt;/a&gt; fixture, store only an opaque fixture ID and its lifecycle state. Test email data should be temporary and non-production.&lt;/p&gt;

&lt;p&gt;The &lt;code&gt;run_id&lt;/code&gt; can combine the GitHub run number and commit:&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="nv"&gt;RUN_ID&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;GITHUB_RUN_ID&lt;/span&gt;&lt;span class="k"&gt;:-&lt;/span&gt;&lt;span class="nv"&gt;local&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;-&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;git rev-parse &lt;span class="nt"&gt;--short&lt;/span&gt; HEAD&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
&lt;span class="nv"&gt;RECEIPT&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"artifacts/api-receipt-&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;RUN_ID&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;.json"&lt;/span&gt;
&lt;span class="nb"&gt;mkdir&lt;/span&gt; &lt;span class="nt"&gt;-p&lt;/span&gt; artifacts
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Write the initial receipt before making the request. If the process crashes during setup, that first record still shows where the attempt stopped. This detail are easy to skip, but it has saved me from guessing which stage ran.&lt;/p&gt;

&lt;h2&gt;
  
  
  Capture evidence in GitHub Actions
&lt;/h2&gt;

&lt;p&gt;The workflow should always upload the receipt, including on failure. A simple shell shape looks like this:&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;set&lt;/span&gt; +e
node scripts/api-smoke-test.js &lt;span class="nt"&gt;--run-id&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$RUN_ID&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="nt"&gt;--receipt&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$RECEIPT&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
&lt;span class="nv"&gt;TEST_EXIT&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nv"&gt;$?&lt;/span&gt;
&lt;span class="nb"&gt;set&lt;/span&gt; &lt;span class="nt"&gt;-e&lt;/span&gt;

&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"test_exit=&lt;/span&gt;&lt;span class="nv"&gt;$TEST_EXIT&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&amp;gt;&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$GITHUB_OUTPUT&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
&lt;span class="nb"&gt;exit&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$TEST_EXIT&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then configure the artifact step with &lt;code&gt;if: always()&lt;/code&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="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;Upload API receipt&lt;/span&gt;
  &lt;span class="na"&gt;if&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;always()&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/upload-artifact@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;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;api-receipt-${{ github.run_id }}&lt;/span&gt;
    &lt;span class="na"&gt;path&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;artifacts/api-receipt-*.json&lt;/span&gt;
    &lt;span class="na"&gt;retention-days&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;7&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The receipt should finish with a small, stable set of fields: &lt;code&gt;status&lt;/code&gt;, &lt;code&gt;http_status&lt;/code&gt;, &lt;code&gt;duration_ms&lt;/code&gt;, &lt;code&gt;attempts&lt;/code&gt;, &lt;code&gt;error_code&lt;/code&gt;, and &lt;code&gt;finished_at&lt;/code&gt;. Save a hash or normalized summary instead of a secret-bearing body. The useful part are the timestamps and the error category, not a huge dump of data.&lt;/p&gt;

&lt;p&gt;For asynchronous verification, record transitions such as &lt;code&gt;fixture_created&lt;/code&gt;, &lt;code&gt;request_sent&lt;/code&gt;, &lt;code&gt;message_observed&lt;/code&gt;, and &lt;code&gt;assertion_passed&lt;/code&gt;. This follows the same &lt;a href="https://dev.to/kevindev27/state-machines-for-email-verification-apis-4jpp"&gt;state-machine thinking for verification APIs&lt;/a&gt; that makes event-heavy flows easier to reason about.&lt;/p&gt;

&lt;h2&gt;
  
  
  Keep email fixtures isolated
&lt;/h2&gt;

&lt;p&gt;Shared test inboxes are a frequent source of false positives. A message from an earlier run can satisfy a current assertion, while a legitimate delay looks like a missing message. Give each run a unique marker and match both the recipient fixture ID and that marker.&lt;/p&gt;

&lt;p&gt;The marker should be safe to log, for example &lt;code&gt;verify-${RUN_ID}&lt;/code&gt;. The test can poll with a deadline, then classify the result as &lt;code&gt;message_timeout&lt;/code&gt;, &lt;code&gt;wrong_fixture&lt;/code&gt;, or &lt;code&gt;duplicate_message&lt;/code&gt;. Those categories are more actionable than &lt;code&gt;assertion failed&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;If a workflow uses a fake email address or searches for the typo-like phrases &lt;code&gt;fake e mail com&lt;/code&gt; and &lt;code&gt;tepm mail com&lt;/code&gt; in test data, keep those values in fixtures only. They should not become primary application data or appear in production analytics. It is a small boundary, but teams forget it when they copy a quick test into a shared helper.&lt;/p&gt;

&lt;h2&gt;
  
  
  Turn failure output into a fast next step
&lt;/h2&gt;

&lt;p&gt;A receipt is useful only if someone can act on it. Print a one-line summary in the job log and keep detailed evidence in the artifact:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;API receipt: run=1234-abc123 status=failed error=message_timeout attempts=3 duration_ms=30042
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Use stable error codes. A reviewer can then search &lt;code&gt;message_timeout&lt;/code&gt; across runs, compare duration trends, and distinguish a provider issue from a changed contract. It also make reruns easier to compare because the shape stays the same.&lt;/p&gt;

&lt;p&gt;For request validation, pair the receipt with clear policy checks. The ideas in &lt;a href="https://dev.to/ryanlee91/react-signup-checks-need-policy-objects-4b4h"&gt;policy objects for signup checks&lt;/a&gt; apply outside React too: put rules in named, testable decisions instead of burying them in a long assertion.&lt;/p&gt;

&lt;h2&gt;
  
  
  A small receipt checklist
&lt;/h2&gt;

&lt;p&gt;Before merging an API workflow, check that it:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Creates a unique run ID before the first request.&lt;/li&gt;
&lt;li&gt;Writes a redacted receipt before setup can fail.&lt;/li&gt;
&lt;li&gt;Records request, response, timing, retry, and fixture state.&lt;/li&gt;
&lt;li&gt;Uploads the artifact with &lt;code&gt;if: always()&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Uses bounded retention and removes temporary fixtures.&lt;/li&gt;
&lt;li&gt;Exposes stable error codes without leaking secrets.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;When the check fail, the receipt should tell you what happened and what to try next. That is the real productivity win: fewer blind reruns, clearer API ownership, and CI evidence that remains useful after the green checkmark is gone.&lt;/p&gt;

</description>
      <category>api</category>
      <category>githubactions</category>
      <category>testing</category>
      <category>automation</category>
    </item>
    <item>
      <title>API Drift Checks Need a Reproducible CI Receipt</title>
      <dc:creator>Jonathan</dc:creator>
      <pubDate>Tue, 29 Sep 2026 11:23:28 +0000</pubDate>
      <link>https://dev.to/pong1965/api-drift-checks-need-a-reproducible-ci-receipt-1j9a</link>
      <guid>https://dev.to/pong1965/api-drift-checks-need-a-reproducible-ci-receipt-1j9a</guid>
      <description>&lt;p&gt;An API integration can be green on Monday and broken on Tuesday without any application code changing. A provider changes a response field, a backend deploy removes a status code, or a fixture quietly expires. The first clue is often a vague integration failure in a later job, and it doesnt tell the reviewer what actually changed.&lt;/p&gt;

&lt;p&gt;I prefer treating every contract check as a small evidence-producing tool. The check should answer three questions: which request ran, what response shape was expected, and what the runner observed. That receipt turns a red workflow from “try it again” into a useful starting point.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why API drift is hard to diagnose
&lt;/h2&gt;

&lt;p&gt;Most API smoke tests verify only the final status code. That is a useful first guard, but &lt;code&gt;200 OK&lt;/code&gt; can hide a breaking change. A field can change type, a nested object can disappear, or a successful response can contain an error payload.&lt;/p&gt;

&lt;p&gt;The opposite failure is also common: a test fails because a disposable address or a test account is no longer available, even though the API contract is healthy. Search terms such as “temp mail.so”, “disposable address”, or even “temp mailid” may appear in test data and logs. They are inputs to isolate, not proof that the endpoint is broken.&lt;/p&gt;

&lt;p&gt;The fastest way to separate these cases is to record a compact set of facts for every request:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;method and route, without secrets or full query values;&lt;/li&gt;
&lt;li&gt;response status and elapsed time;&lt;/li&gt;
&lt;li&gt;selected response keys and their types;&lt;/li&gt;
&lt;li&gt;a stable fixture or correlation ID;&lt;/li&gt;
&lt;li&gt;the commit SHA and API version under test.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;This is more valuable than uploading a huge response body. Large payloads slow reviews and can leak tokens or personal data. A small receipt keeps the signal visible.&lt;/p&gt;

&lt;h2&gt;
  
  
  Define the receipt before writing the workflow
&lt;/h2&gt;

&lt;p&gt;Start with a JSON shape that a person can read in a pull request comment or an Actions artifact:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"endpoint"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"GET /v1/projects/{id}"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"status"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;200&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"elapsed_ms"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;184&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"required_keys"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"id"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"name"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"status"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"observed_types"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nl"&gt;"id"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"string"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"name"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"string"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"status"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"string"&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"api_version"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"2026-09"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"commit"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"abc1234"&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Do not put authorization headers, raw email content, or unrestricted response data in this file. If a fixture contains an address such as “fake e mail com”, redact it before the receipt is written. The test should prove the contract without becoming a new data-retention problem.&lt;/p&gt;

&lt;p&gt;It also helps to define the failure fields up front. Include &lt;code&gt;missing_keys&lt;/code&gt;, &lt;code&gt;unexpected_types&lt;/code&gt;, and &lt;code&gt;request_id&lt;/code&gt; when a check fails. A reviewer should not need to reproduce the request locally just to learn that &lt;code&gt;status&lt;/code&gt; changed from a string to an object.&lt;/p&gt;

&lt;h2&gt;
  
  
  A small GitHub Actions contract check
&lt;/h2&gt;

&lt;p&gt;The workflow can stay boring. Install the test dependencies, run the contract command, and upload the receipt whether the step passes or fails:&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;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;API contract&lt;/span&gt;

&lt;span class="na"&gt;on&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;pull_request&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;push&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;branches&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;main&lt;/span&gt;&lt;span class="pi"&gt;]&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;contract&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;actions/setup-node@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;node-version&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;22&lt;/span&gt;
          &lt;span class="na"&gt;cache&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;npm&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;npm ci&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;npm run contract:test -- --receipt artifacts/api-receipt.json&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;API_BASE_URL&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${{ secrets.STAGING_API_BASE_URL }}&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;if&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;always()&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/upload-artifact@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;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;api-contract-receipt-${{ github.run_id }}&lt;/span&gt;
          &lt;span class="na"&gt;path&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;artifacts/api-receipt.json&lt;/span&gt;
          &lt;span class="na"&gt;if-no-files-found&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;ignore&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;if: always()&lt;/code&gt; line is the shortcut that pays off most. A failed check still leaves evidence for debugging. The resulting artifact is also easy to connect to &lt;a href="https://dev.to/mrdapperx/make-ci-automation-leave-a-useful-receipt-1bke"&gt;make CI automation leave a useful receipt&lt;/a&gt;, especially when several jobs produce different kinds of proof.&lt;/p&gt;

&lt;p&gt;Keep the command deterministic. Pin the Node version, install from the lockfile, use fixed fixtures, and set a bounded timeout. If the test needs an external service, report that dependency explicitly. A flaky network retry can make the check more noisy, not more reliable.&lt;/p&gt;

&lt;h2&gt;
  
  
  Make failures useful to the next developer
&lt;/h2&gt;

&lt;p&gt;A good failure message names the contract and the observed difference:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;API contract failed: GET /v1/projects/{id}
missing keys: status
unexpected types: owner.id expected string, got number
request_id: req_7f3a
receipt: artifacts/api-receipt.json
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The checks is easier to act on when the log points directly to the artifact. Add a short summary to &lt;code&gt;$GITHUB_STEP_SUMMARY&lt;/code&gt;, but keep secrets and response bodies out of it. The same principle applies to deploy alerts: &lt;a href="https://dev.to/jasonmills94/eks-rollouts-need-better-alert-context-485m"&gt;better alert context for rollouts&lt;/a&gt; makes an event actionable without dumping every internal detail.&lt;/p&gt;

&lt;p&gt;When a failure is caused by test infrastructure, label it differently from schema drift. For example, use &lt;code&gt;fixture_unavailable&lt;/code&gt;, &lt;code&gt;transport_timeout&lt;/code&gt;, and &lt;code&gt;contract_mismatch&lt;/code&gt; as machine-readable categories. This makes trends visible and prevents the team from weakening a real contract check just because one test account expired.&lt;/p&gt;

&lt;h2&gt;
  
  
  Checklist for a reproducible API check
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Assert status, required keys, and important value types.&lt;/li&gt;
&lt;li&gt;Record route, API version, commit, elapsed time, and request ID.&lt;/li&gt;
&lt;li&gt;Use stable fixtures and isolate any disposable address from production data.&lt;/li&gt;
&lt;li&gt;Upload a small receipt with &lt;code&gt;if: always()&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Redact tokens, message bodies, and personal identifiers.&lt;/li&gt;
&lt;li&gt;Classify fixture, transport, and contract failures separately.&lt;/li&gt;
&lt;li&gt;Review the receipt in the same pull request as the code change.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;This pattern is more simple than building a full observability platform, and it gives developers feedback where they already work. If the contract is intentionally changing, update the fixture and the expected receipt in the same change. If it is not intentional, the diff shows the drift before it reaches a downstream consumer.&lt;/p&gt;

&lt;h2&gt;
  
  
  Conclusion
&lt;/h2&gt;

&lt;p&gt;API tests become much more useful when they leave a reproducible receipt instead of only a pass or fail. GitHub Actions supplies the execution history, artifacts supply the evidence, and a small contract command supplies the boundary. Together they make API integrations easier to maintain, even when external services and test data move underneath them.&lt;/p&gt;

&lt;p&gt;The goal is not to capture every byte. It is to capture the few facts that let the next developer understand what ran, what changed, and what to do next. That is a small workflow improvement, but it realy shortens the path from a red build to a safe fix.&lt;/p&gt;

</description>
      <category>api</category>
      <category>githubactions</category>
      <category>testing</category>
      <category>automation</category>
    </item>
    <item>
      <title>API Contract Tests Need a GitHub Actions Receipt</title>
      <dc:creator>Jonathan</dc:creator>
      <pubDate>Mon, 28 Sep 2026 17:23:38 +0000</pubDate>
      <link>https://dev.to/pong1965/api-contract-tests-need-a-github-actions-receipt-5ebb</link>
      <guid>https://dev.to/pong1965/api-contract-tests-need-a-github-actions-receipt-5ebb</guid>
      <description>&lt;p&gt;An API test that ends with a green check is useful, but it is not always reviewable. When a contract test fails only in CI, the next question is usually not “did it fail?” It is “which request, fixture, and response caused the failure?”&lt;/p&gt;

&lt;p&gt;I have started treating every API check in GitHub Actions as a small delivery with a receipt. The receipt is a compact artifact containing the endpoint, contract version, response class, and links to the relevant logs. It turns a transient workflow result into evidence that another developer can inspect later.&lt;/p&gt;

&lt;h2&gt;
  
  
  The green check that explains nothing
&lt;/h2&gt;

&lt;p&gt;Many workflows report only a test command and its exit code:&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;Run API tests&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;npm test -- api&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This is fine while the suite is small. As the number of APIs grows, it becomes hard to tell whether the failure came from an expired fixture, an unexpected status code, a changed response field, or a dependency that was not ready yet. The check is technically correct, but the feedback loop is slow.&lt;/p&gt;

&lt;p&gt;The fix is not to print every response body into the job log. That can leak tokens, customer-shaped data, and alot of irrelevant noise. Instead, define what each contract test promises and save only the information needed to verify that promise.&lt;/p&gt;

&lt;h2&gt;
  
  
  Define a small contract for every API check
&lt;/h2&gt;

&lt;p&gt;Before adding more assertions, write down the observable contract. A useful contract might look like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;name: create_invoice
request: POST /v1/invoices
fixture: invoice-minimal.json
expected_status: 201
required_fields: id, status, created_at
max_duration_ms: 800
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This gives the test and the receipt the same vocabulary. It also makes failures easier to classify. A &lt;code&gt;401&lt;/code&gt; means authentication setup, a &lt;code&gt;422&lt;/code&gt; means request validation, and a missing &lt;code&gt;created_at&lt;/code&gt; field means the response shape changed. Those are different fixes, even when they all produce exit code 1.&lt;/p&gt;

&lt;p&gt;For email-related endpoints, fixture ownership matters just as much. This guide on &lt;a href="https://dev.to/mrdapperx/a-better-email-fixture-contract-for-ci-229i"&gt;a better fixture contract for CI&lt;/a&gt; is a good companion because it treats test data as an explicit interface instead of an invisible helper.&lt;/p&gt;

&lt;p&gt;I keep the contract close to the test, usually as JSON or TypeScript data. The file does not need to be clever. It needs to be easy to review when an endpoint changes.&lt;/p&gt;

&lt;h2&gt;
  
  
  Build a reviewable GitHub Actions receipt
&lt;/h2&gt;

&lt;p&gt;The workflow can collect a small JSON receipt after the test command completes, including when the command fails. That last part is important: a failure without its evidence is the moment when you need the artifact most.&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;Run API contract tests&lt;/span&gt;
  &lt;span class="na"&gt;id&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;contract_tests&lt;/span&gt;
  &lt;span class="na"&gt;continue-on-error&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&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;npm run test:contract -- --reporter=json --outputFile=artifacts/api-results.json&lt;/span&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;Build test receipt&lt;/span&gt;
  &lt;span class="na"&gt;if&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;always()&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;node scripts/build-api-receipt.mjs&lt;/span&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;Upload API receipt&lt;/span&gt;
  &lt;span class="na"&gt;if&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;always()&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/upload-artifact@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;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;api-receipt-${{ github.run_id }}&lt;/span&gt;
    &lt;span class="na"&gt;path&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;artifacts/api-receipt.json&lt;/span&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;Fail workflow when contract tests fail&lt;/span&gt;
  &lt;span class="na"&gt;if&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;steps.contract_tests.outcome == 'failure'&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;exit &lt;/span&gt;&lt;span class="m"&gt;1&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The receipt builder can summarize each check without copying sensitive payloads:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"run_id"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"1842"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"commit"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"abc1234"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"checks"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nl"&gt;"name"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"create_invoice"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"status"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"passed"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"http_status"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;201&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"duration_ms"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;214&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;continue-on-error&lt;/code&gt; step is deliberate. It lets the workflow create evidence before the final step restores the failing status. Without it, the job may stop before the receipt exists. A tiny bit of extra YAML saves alot of clicking through reruns.&lt;/p&gt;

&lt;h2&gt;
  
  
  Keep fixtures and secrets out of the log
&lt;/h2&gt;

&lt;p&gt;A receipt should help someone debug, not create a security incident. Redact authorization headers, cookies, full email addresses, and response fields that can contain personal data. Store a fixture name and a hash when you need to prove which input was used.&lt;/p&gt;

&lt;p&gt;Avoid putting search terms such as &lt;code&gt;tempmail so&lt;/code&gt;, &lt;code&gt;tp mail so&lt;/code&gt;, or &lt;code&gt;tempail mail&lt;/code&gt; into production-like fixture values unless the test is specifically checking search or text handling. They can make an artifact look like a real user record and confuse later analysis.&lt;/p&gt;

&lt;p&gt;For signup APIs, the failure path deserves the same attention as the happy path. When an automated check sees a blocked or delayed email, &lt;a href="https://dev.to/sophiax99/signup-email-blocks-need-appeal-paths-1g6a"&gt;designing an appeal path for signup email blocks&lt;/a&gt; helps separate product recovery from test retries. A retry is not a user-facing explanation.&lt;/p&gt;

&lt;h2&gt;
  
  
  Before and after workflow
&lt;/h2&gt;

&lt;p&gt;The old workflow answers: “Did the command exit successfully?” The receipt-based workflow answers several more useful questions:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Which contract failed?&lt;/li&gt;
&lt;li&gt;What status and response shape did it observe?&lt;/li&gt;
&lt;li&gt;Which commit and workflow run produced the result?&lt;/li&gt;
&lt;li&gt;Where is the sanitized artifact?&lt;/li&gt;
&lt;li&gt;Was the failure a request, dependency, fixture, or contract problem?&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That extra context changes code review. A pull request can show a stable contract receipt rather than a screenshot of a green job. It also makes flaky failures less mysterious, because duration and dependency state are visible in one place. The setup is a little more work, but the payoff is quick.&lt;/p&gt;

&lt;h2&gt;
  
  
  Quick questions
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Should every response body be uploaded?
&lt;/h3&gt;

&lt;p&gt;No. Save structured summaries by default. Keep full bodies only in a protected debugging run, with redaction and a short retention period.&lt;/p&gt;

&lt;h3&gt;
  
  
  Does the receipt replace test logs?
&lt;/h3&gt;

&lt;p&gt;No. Logs are detailed investigation material; the receipt is the index. Link the two with the workflow run ID and a stable check name.&lt;/p&gt;

&lt;h3&gt;
  
  
  What is the smallest useful first step?
&lt;/h3&gt;

&lt;p&gt;Add one artifact with status, HTTP code, duration, commit, and fixture hash. Once that is reliable, add richer classifications or dashboards.&lt;/p&gt;

&lt;p&gt;A CI result should be more than a traffic light. With a small contract and a durable GitHub Actions receipt, API failures become much faster to understand and much easier to fix.&lt;/p&gt;

</description>
      <category>api</category>
      <category>githubactions</category>
      <category>testing</category>
      <category>automation</category>
    </item>
    <item>
      <title>API Smoke Tests Need a Reviewable CI Receipt</title>
      <dc:creator>Jonathan</dc:creator>
      <pubDate>Fri, 25 Sep 2026 05:24:02 +0000</pubDate>
      <link>https://dev.to/pong1965/api-smoke-tests-need-a-reviewable-ci-receipt-go7</link>
      <guid>https://dev.to/pong1965/api-smoke-tests-need-a-reviewable-ci-receipt-go7</guid>
      <description>&lt;p&gt;API smoke tests are supposed to be the fast feedback loop: call a health endpoint, create a small resource, verify the response, and move on. In practice, a failed run often leaves a developer with 2,000 lines of logs and no clear answer about what happened.&lt;/p&gt;

&lt;p&gt;I have found a small change makes these checks much more useful. Treat every smoke-test run as a receipt. The receipt should say what was tested, which build made the request, what the API returned, and where the next investigation should start.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why smoke-test logs are not enough
&lt;/h2&gt;

&lt;p&gt;Logs are excellent for deep debugging, but poor as a review artifact. They are noisy, their important lines are mixed with setup output, and a later reader may not know which environment variables or commit produced them.&lt;/p&gt;

&lt;p&gt;A receipt is intentionally boring. It is one JSON file with stable fields. A failed request doesnt need to be reconstructed from a scrolling terminal; the useful facts are already attached to the workflow run.&lt;/p&gt;

&lt;p&gt;For example, a receipt can answer these questions in seconds:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Which commit and API base URL were tested?&lt;/li&gt;
&lt;li&gt;Which endpoint failed, with what status and latency?&lt;/li&gt;
&lt;li&gt;Was the response schema checked, or only the HTTP status?&lt;/li&gt;
&lt;li&gt;Can the same test be replayed locally with the recorded request ID?&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The result is usefull to both humans and automation. A pull request reviewer can inspect it, while another job can classify the failure without parsing prose.&lt;/p&gt;

&lt;h2&gt;
  
  
  The receipt contract
&lt;/h2&gt;

&lt;p&gt;Keep the first version small. Here is the shape I use for an API smoke check:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"run_id"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"smoke-1842"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"commit"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"abc1234"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"base_url"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"https://staging.example.test"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"checks"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"name"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"create-project"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"method"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"POST"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"path"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"/v1/projects"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"status"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;201&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"request_id"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"req_7f2"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"ok"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"failed_check"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Do not put tokens, cookies, email addresses, or full response bodies in this file. A receipt should be safe to download from CI. If a response needs investigation, save a redacted diagnostic separately and link it by an artifact name.&lt;/p&gt;

&lt;p&gt;It also helps to define what counts as a failure before writing the script. A 500 is obvious, but a 200 response with the wrong field type, an unexpected redirect, or a missing request ID are failures too. This are the details that prevent a green-but-broken smoke test.&lt;/p&gt;

&lt;p&gt;If the check creates data, give it a deterministic cleanup path. For verification flows, the same principle applies: an &lt;a href="https://dev.to/kevindev27/idempotent-email-verification-with-postgresql-3l42"&gt;idempotent email verification&lt;/a&gt; step is easier to retry and explain than a test that leaves an unknown user behind.&lt;/p&gt;

&lt;h2&gt;
  
  
  Build the receipt in GitHub Actions
&lt;/h2&gt;

&lt;p&gt;The workflow can run the test, preserve its exit code, and upload the receipt even when the test fails. The &lt;code&gt;if: always()&lt;/code&gt; is the important shortcut:&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;Run API smoke tests&lt;/span&gt;
  &lt;span class="na"&gt;id&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;smoke&lt;/span&gt;
  &lt;span class="na"&gt;shell&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;bash&lt;/span&gt;
  &lt;span class="na"&gt;run&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;|&lt;/span&gt;
    &lt;span class="s"&gt;set +e&lt;/span&gt;
    &lt;span class="s"&gt;python scripts/api_smoke.py \&lt;/span&gt;
      &lt;span class="s"&gt;--base-url "$API_BASE_URL" \&lt;/span&gt;
      &lt;span class="s"&gt;--receipt artifacts/api-receipt.json&lt;/span&gt;
    &lt;span class="s"&gt;status=$?&lt;/span&gt;
    &lt;span class="s"&gt;echo "exit_code=$status" &amp;gt;&amp;gt; "$GITHUB_OUTPUT"&lt;/span&gt;
    &lt;span class="s"&gt;exit "$status"&lt;/span&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;Upload API receipt&lt;/span&gt;
  &lt;span class="na"&gt;if&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;always()&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/upload-artifact@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;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;api-receipt-${{ github.run_id }}&lt;/span&gt;
    &lt;span class="na"&gt;path&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;artifacts/api-receipt.json&lt;/span&gt;
    &lt;span class="na"&gt;if-no-files-found&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;error&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The script should write the receipt in a &lt;code&gt;finally&lt;/code&gt;-style cleanup path, including when an assertion raises an exception. If the runner is killed before that happens, the workflow log still has value, but most normal failures get a compact artifact.&lt;/p&gt;

&lt;p&gt;For a larger suite, put the shared upload and naming behavior in a &lt;a href="https://dev.to/pong1965/reusable-workflows-for-email-api-checks-393n"&gt;reusable workflow for email API checks&lt;/a&gt;. The same pattern works for payment APIs, webhooks, and provisioning endpoints; only the check definitions change.&lt;/p&gt;

&lt;h2&gt;
  
  
  Keep email checks isolated
&lt;/h2&gt;

&lt;p&gt;Some API smoke tests must verify that a message was sent. I use a separate test inbox and a short retention policy for this. A temp mailbox linked from the run receipt can make the test observable without mixing staging traffic into somebody's personal inbox; for example, teams can create temporary mail with &lt;a href="https://tempmailso.com" rel="noopener noreferrer"&gt;tempmailso&lt;/a&gt; when that fits their test policy.&lt;/p&gt;

&lt;p&gt;Be explicit about the boundary. The receipt should store a message ID or a redacted subject, not the whole message. Also, dont make delivery timing the only assertion: check the API response, the message identity, and the verification link target independently.&lt;/p&gt;

&lt;p&gt;Search terms and provider names can get messy in test notes. I have seen &lt;code&gt;tempail&lt;/code&gt; and &lt;code&gt;temp org mail&lt;/code&gt; copied into issue descriptions. Keep those plain text and out of link anchors, otherwise a later search can mistake a typo for a supported integration.&lt;/p&gt;

&lt;h2&gt;
  
  
  A review checklist
&lt;/h2&gt;

&lt;p&gt;Before merging a new smoke test, check:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The title and check name describe the user-visible behavior.&lt;/li&gt;
&lt;li&gt;The receipt records commit, environment, endpoint, status, and request ID.&lt;/li&gt;
&lt;li&gt;Secrets and personal data are excluded or redacted.&lt;/li&gt;
&lt;li&gt;The artifact uploads even when the assertion fails.&lt;/li&gt;
&lt;li&gt;A failed run returns a non-zero exit code after writing its receipt.&lt;/li&gt;
&lt;li&gt;Created test data has a cleanup or expiry path.&lt;/li&gt;
&lt;li&gt;The test can be replayed with the same inputs, wether locally or in a debug job.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;This list is short on purpose. A receipt should reduce the time between failure and a good question, not become another reporting system that nobody reads.&lt;/p&gt;

&lt;h2&gt;
  
  
  Q&amp;amp;A
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Should every API test upload an artifact?
&lt;/h3&gt;

&lt;p&gt;No. Unit tests and fast contract checks may only need normal test output. Upload a receipt when the check crosses a network boundary, creates data, or runs in an environment that is hard to reproduce.&lt;/p&gt;

&lt;h3&gt;
  
  
  What if the API is unavailable?
&lt;/h3&gt;

&lt;p&gt;Record the connection phase, hostname, and a safe error category. Do not retry forever. One bounded retry can separate a transient network blip from an application failure, while the receipt keeps both attempts visible.&lt;/p&gt;

&lt;h3&gt;
  
  
  Is a receipt worth maintaining for a small project?
&lt;/h3&gt;

&lt;p&gt;Usually yes, when the smoke test runs on every pull request. The file costs little, and it gives future maintainers informations that would otherwise disappear with the workflow log.&lt;/p&gt;

</description>
      <category>api</category>
      <category>githubactions</category>
      <category>testing</category>
      <category>automation</category>
    </item>
    <item>
      <title>API Contract Drift: A Fast GitHub Actions Check</title>
      <dc:creator>Jonathan</dc:creator>
      <pubDate>Sat, 19 Sep 2026 11:23:21 +0000</pubDate>
      <link>https://dev.to/pong1965/api-contract-drift-a-fast-github-actions-check-1glo</link>
      <guid>https://dev.to/pong1965/api-contract-drift-a-fast-github-actions-check-1glo</guid>
      <description>&lt;p&gt;An API test can be green while the client is already living in the past. A field gets renamed, an error response loses its &lt;code&gt;code&lt;/code&gt;, or a list becomes an object. The endpoint still returns &lt;code&gt;200&lt;/code&gt;, so the pipeline reports success and the next integration exposes the real problem.&lt;/p&gt;

&lt;p&gt;I have found that the useful fix is not a bigger test suite. It is a small contract check that answers three questions quickly:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Did the response shape change?&lt;/li&gt;
&lt;li&gt;Which field or status code changed?&lt;/li&gt;
&lt;li&gt;Can the next developer reproduce it from the CI run?&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The workflow below is intentionally boring. It uses an OpenAPI document, &lt;code&gt;curl&lt;/code&gt;, &lt;code&gt;jq&lt;/code&gt;, and a GitHub Actions job summary. Boring checks are easier to trust and easier to repair.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why contract drift is expensive
&lt;/h2&gt;

&lt;p&gt;Unit tests usually protect the code that owns an endpoint. They do not always protect the agreement between that endpoint and a consumer. This gap gets larger when several teams deploy on different schedules.&lt;/p&gt;

&lt;p&gt;The cost is often hidden in a later job. A signup test may report an invalid email, while the actual regression is that the API stopped returning &lt;code&gt;verification_id&lt;/code&gt;. A search client may show an empty page when the server changed &lt;code&gt;items&lt;/code&gt; to &lt;code&gt;results&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;This is also why generated or temporary test data needs a clear boundary. A string such as &lt;code&gt;temp mail so&lt;/code&gt; can be valid fixture data, but it should never be allowed to decide whether a response matches the API contract. Keep data values and contract rules separate.&lt;/p&gt;

&lt;h2&gt;
  
  
  The smallest useful contract check
&lt;/h2&gt;

&lt;p&gt;Start with one stable endpoint and one expected response. Here is a compact shell check for a health-style API response:&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;set&lt;/span&gt; &lt;span class="nt"&gt;-euo&lt;/span&gt; pipefail

&lt;span class="nv"&gt;response&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;curl &lt;span class="nt"&gt;--fail-with-body&lt;/span&gt; &lt;span class="nt"&gt;--silent&lt;/span&gt; &lt;span class="nt"&gt;--show-error&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--header&lt;/span&gt; &lt;span class="s2"&gt;"Accept: application/json"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;API_BASE_URL&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;/v1/profile"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--output&lt;/span&gt; response.json &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--write-out&lt;/span&gt; &lt;span class="s1"&gt;'%{http_code}'&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;

&lt;span class="nb"&gt;test&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$response&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"200"&lt;/span&gt;
jq &lt;span class="nt"&gt;-e&lt;/span&gt; &lt;span class="s1"&gt;'(.id | type == "string") and
       (.email | type == "string") and
       (.roles | type == "array")'&lt;/span&gt; response.json &lt;span class="o"&gt;&amp;gt;&lt;/span&gt;/dev/null
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The status check catches protocol changes. The &lt;code&gt;jq&lt;/code&gt; expression catches shape changes. It is not a full JSON Schema validator, and that is the point: put the fastest, highest-value assertion at the edge first.&lt;/p&gt;

&lt;p&gt;For a larger API, validate selected responses against an OpenAPI schema with a dedicated tool. Do not copy a full generated client into the workflow just to check three fields; that makes the check heavy and confuseing when it fails.&lt;/p&gt;

&lt;h2&gt;
  
  
  Publish a failure receipt in GitHub Actions
&lt;/h2&gt;

&lt;p&gt;A failed command is not always a useful failure. Add a short summary so the run explains what was checked and where the evidence lives:&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;Check API contract&lt;/span&gt;
  &lt;span class="na"&gt;id&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;contract&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;API_BASE_URL&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${{ vars.API_BASE_URL }}&lt;/span&gt;
  &lt;span class="na"&gt;run&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;|&lt;/span&gt;
    &lt;span class="s"&gt;set -euo pipefail&lt;/span&gt;
    &lt;span class="s"&gt;status=$(curl --fail-with-body --silent --show-error \&lt;/span&gt;
      &lt;span class="s"&gt;"$API_BASE_URL/v1/profile" \&lt;/span&gt;
      &lt;span class="s"&gt;--output response.json \&lt;/span&gt;
      &lt;span class="s"&gt;--write-out '%{http_code}')&lt;/span&gt;
    &lt;span class="s"&gt;{&lt;/span&gt;
      &lt;span class="s"&gt;echo "## API contract check"&lt;/span&gt;
      &lt;span class="s"&gt;echo "- Endpoint: \\`GET /v1/profile\\`"&lt;/span&gt;
      &lt;span class="s"&gt;echo "- HTTP status: \\`$status\\`"&lt;/span&gt;
      &lt;span class="s"&gt;echo "- Fixture: deterministic profile response"&lt;/span&gt;
    &lt;span class="s"&gt;} &amp;gt;&amp;gt; "$GITHUB_STEP_SUMMARY"&lt;/span&gt;
    &lt;span class="s"&gt;test "$status" = "200"&lt;/span&gt;
    &lt;span class="s"&gt;jq -e '(.id | type == "string") and (.roles | type == "array")' response.json &amp;gt;/dev/null&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;On failure, upload the response as an artifact only when it is safe to do so. Redact tokens, personal data, and any mailbox content before uploading. A receipt should help debugging, not create a new security ticket.&lt;/p&gt;

&lt;p&gt;This complements &lt;a href="https://dev.to/kevindev27/request-ids-are-not-idempotency-keys-4pd4"&gt;the difference between request IDs and idempotency keys&lt;/a&gt;: the test receipt identifies a run, while the API contract defines what a retry or repeated request is allowed to mean. They solve different problems.&lt;/p&gt;

&lt;h2&gt;
  
  
  Keep fixtures deterministic
&lt;/h2&gt;

&lt;p&gt;Contract tests become noisy when their input changes for reasons unrelated to the contract. Give each fixture an explicit name, stable fields, and a cleanup rule. Avoid using the current time as a required value unless time is exactly what you are testing.&lt;/p&gt;

&lt;p&gt;For an email-related endpoint, a fixture can include a fixed message ID and a fake address. The test should assert the API response, not whether an external inbox happens to receive a message within a guessed number of seconds. That separation makes a failing test much more actionable.&lt;/p&gt;

&lt;p&gt;If your system uses release notifications, &lt;a href="https://dev.to/jasonmills94/kubernetes-release-emails-as-a-cicd-gate-4c76"&gt;using release emails as a CI/CD gate&lt;/a&gt; is a useful related pattern. Keep the gate's input contract explicit, so a notification format change does not silently turn the gate into a pass-through.&lt;/p&gt;

&lt;p&gt;One small naming detail matters more than it looks: call a fixture &lt;code&gt;temp mailid&lt;/code&gt; only if that is the literal value under test. Do not quietly normalize typo keywords in the assertion layer; that can hide the same class of data-contract bugs you are trying to catch.&lt;/p&gt;

&lt;h2&gt;
  
  
  A practical checklist
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Check the expected HTTP status before parsing the body.&lt;/li&gt;
&lt;li&gt;Assert only the fields that consumers truly depend on.&lt;/li&gt;
&lt;li&gt;Keep fixture values deterministic and clearly labeled.&lt;/li&gt;
&lt;li&gt;Write the endpoint, status, and fixture name to &lt;code&gt;GITHUB_STEP_SUMMARY&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Redact secrets before saving response artifacts.&lt;/li&gt;
&lt;li&gt;Give failures a direct reproduction command.&lt;/li&gt;
&lt;li&gt;Review contract checks when the API version changes.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The before-and-after improvement is simple. Before, a red integration test sent me hunting through a long log. After, the GitHub Actions run says which endpoint changed, which assertion failed, and which fixture was used. That is enough context to fix the contract or update the consumer with much less guesswork.&lt;/p&gt;

&lt;p&gt;The check is small, but the habit scales: treat API responses as agreements, make the agreements executable, and leave a useful receipt every time they break.&lt;/p&gt;

</description>
      <category>githubactions</category>
      <category>api</category>
      <category>testing</category>
      <category>devtools</category>
    </item>
    <item>
      <title>Turn API Tests Into Useful CI Receipts</title>
      <dc:creator>Jonathan</dc:creator>
      <pubDate>Fri, 18 Sep 2026 05:23:43 +0000</pubDate>
      <link>https://dev.to/pong1965/turn-api-tests-into-useful-ci-receipts-3731</link>
      <guid>https://dev.to/pong1965/turn-api-tests-into-useful-ci-receipts-3731</guid>
      <description>&lt;p&gt;An API test that only prints &lt;code&gt;Expected 200, received 500&lt;/code&gt; is technically useful, but it leaves the next question unanswered: what exactly happened in that run?&lt;/p&gt;

&lt;p&gt;I have been getting better results by treating every important API check as a small transaction with a receipt. The receipt records the request identity, the response evidence, the relevant fixture, and the cleanup result. It turns a noisy CI log into a compact debugging artifact that a teammate can replay without guessing.&lt;/p&gt;

&lt;p&gt;This pattern is especially handy for signup and verification flows that use a disposable temporary email address. The test can pass while checking the wrong message, the wrong tenant, or an old fixture. A receipt makes that ownership visible.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why a test receipt is different from a test log
&lt;/h2&gt;

&lt;p&gt;A log describes what the test printed. A receipt describes what the test proved.&lt;/p&gt;

&lt;p&gt;For an API smoke test, I want to answer these questions in a few seconds:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Which commit and workflow attempt created the request?&lt;/li&gt;
&lt;li&gt;Which endpoint, method, and safe input shape were used?&lt;/li&gt;
&lt;li&gt;What response status and request ID came back?&lt;/li&gt;
&lt;li&gt;Which test fixture or inbox did the assertion own?&lt;/li&gt;
&lt;li&gt;Was the observed event newer than the test start time?&lt;/li&gt;
&lt;li&gt;Did cleanup finish, or is something still hanging around?&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The difference matters when retries are enabled. A retry can produce a green build while the first request is still in progress. Without an ownership key, the assertion may read the response or email event from another attempt. The result are confusing failures that disappear when you run the test by hand.&lt;/p&gt;

&lt;p&gt;For email-backed API checks, include a run ID in the request metadata and in the fixture identity. Do not put secrets or full message bodies in the receipt. A short hash, message ID, or redacted subject is enough for most investigations. Guidance on &lt;a href="https://dev.to/bitheirstake/disposable-inboxes-need-deletion-budgets-191k"&gt;deletion budgets for disposable inboxes&lt;/a&gt; is also useful when deciding how long fixtures should survive.&lt;/p&gt;

&lt;h2&gt;
  
  
  Define the receipt contract
&lt;/h2&gt;

&lt;p&gt;Start with a small JSON contract. It should be stable enough that developers, scripts, and a future dashboard can consume it without parsing prose.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"run_id"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"gh-1842-7f3a"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"commit"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"a1b2c3d"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"endpoint"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"/v1/signup"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"method"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"POST"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"status"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;201&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"request_id"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"req_8f2c"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"fixture"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"sha256:8b7e"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"assertions"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"status_ok"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"owner_matches"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"created_after_start"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"cleanup"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"passed"&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The contract does not need every header or response field. Keep only evidence that helps answer “did this run own this result?” and “where should I look next?” A receipt should tell the truth even when the test fails, so write it from a &lt;code&gt;finally&lt;/code&gt; block or an equivalent cleanup path.&lt;/p&gt;

&lt;p&gt;Use a run ID that is unique for the workflow attempt but stable inside one test. In GitHub Actions, a practical value can combine the run number and attempt number:&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="nv"&gt;RUN_ID&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"gh-&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;GITHUB_RUN_NUMBER&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;-&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;GITHUB_RUN_ATTEMPT&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"run_id=&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;RUN_ID&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&amp;gt;&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$GITHUB_OUTPUT&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Pass that value into the test process. If the API supports a correlation header, send it with every request:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;--fail-with-body&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s2"&gt;"X-Test-Run: &lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;RUN_ID&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s2"&gt;"Content-Type: application/json"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="s1"&gt;'{"email":"ci-fixture@example.test"}'&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;API_BASE_URL&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;/v1/signup"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The command is intentionally boring. Boring commands are easier to rerun when a pull request is already on fire.&lt;/p&gt;

&lt;h2&gt;
  
  
  Build it into GitHub Actions
&lt;/h2&gt;

&lt;p&gt;The workflow should always upload the receipt, including on failure. That means the artifact step needs &lt;code&gt;if: always()&lt;/code&gt; and the test must create the output directory before it starts.&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;Run API checks&lt;/span&gt;
  &lt;span class="na"&gt;id&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;api_checks&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;API_BASE_URL&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${{ secrets.STAGING_API_URL }}&lt;/span&gt;
    &lt;span class="na"&gt;RUN_ID&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;gh-${{ github.run_number }}-${{ github.run_attempt }}&lt;/span&gt;
  &lt;span class="na"&gt;run&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;|&lt;/span&gt;
    &lt;span class="s"&gt;mkdir -p artifacts/api&lt;/span&gt;
    &lt;span class="s"&gt;./scripts/api-smoke-test \&lt;/span&gt;
      &lt;span class="s"&gt;--run-id "$RUN_ID" \&lt;/span&gt;
      &lt;span class="s"&gt;--receipt artifacts/api/receipt.json&lt;/span&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;Upload API receipt&lt;/span&gt;
  &lt;span class="na"&gt;if&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;always()&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/upload-artifact@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;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;api-receipt-${{ github.run_id }}&lt;/span&gt;
    &lt;span class="na"&gt;path&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;artifacts/api/receipt.json&lt;/span&gt;
    &lt;span class="na"&gt;if-no-files-found&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;warn&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The receipt filename stays constant inside the job, while the artifact name includes the GitHub run ID. This makes local scripts simple and keeps artifacts distinct in the Actions UI. It also make it easier to download the exact failed attempt from a link in a pull request comment.&lt;/p&gt;

&lt;p&gt;If your test runner already writes JUnit or JSON output, add the receipt beside it rather than replacing the existing report. Different files answer different questions: the test report explains assertions, while the receipt explains ownership and operational context.&lt;/p&gt;

&lt;h2&gt;
  
  
  Make failures replayable
&lt;/h2&gt;

&lt;p&gt;A useful receipt should point to a safe replay path. Record the fixture name, API version, environment label, and a sanitized input reference. Do not store access tokens, raw verification links, or personal data in a public artifact.&lt;/p&gt;

&lt;p&gt;For asynchronous endpoints, include the polling deadline and the last observed event ID. When the check fails, the next developer should know whether the service never emitted an event or whether the test stopped looking too soon. A fixed deadline is better than an infinite retry loop, and a new event must be newer than the current run start.&lt;/p&gt;

&lt;p&gt;This is the same ownership problem that appears in &lt;a href="https://dev.to/ryanlee91/react-feature-flags-for-safer-onboarding-emails-1g7j"&gt;safer onboarding email checks&lt;/a&gt;: the presence of an email or response does not prove that it belongs to the current action. Verify the correlation value before declaring success.&lt;/p&gt;

&lt;p&gt;Search terms from support tickets can be messy. Someone may write &lt;code&gt;tamp mail com&lt;/code&gt; while describing a temporary mailbox issue. Keep that phrase in a search mapping or test note if it helps triage, but do not let it become a production identifier or a backlink anchor.&lt;/p&gt;

&lt;h2&gt;
  
  
  Small workflow upgrades that pay off
&lt;/h2&gt;

&lt;p&gt;Once the basic receipt works, add a few low-cost improvements:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Validate the schema.&lt;/strong&gt; Fail the job if required receipt fields are missing, even when the API assertion passed.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Redact at the writer.&lt;/strong&gt; Scrubbing after upload is risky because the unredacted file may already be stored.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Print a one-line summary.&lt;/strong&gt; Put run ID, status, request ID, and artifact path in the job summary.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Keep fixtures short-lived.&lt;/strong&gt; Cleanup should run after both success and failure, with a visible cleanup status.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Compare retries.&lt;/strong&gt; If attempt two passes after attempt one fails, keep both receipts so the change in behavior is explainable.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The important bit are the stable identifiers. Fancy dashboards can come later. A developer with a precise run ID and a downloadable receipt is already much faster than a developer scrolling through 4,000 lines of logs.&lt;/p&gt;

&lt;h2&gt;
  
  
  Questions worth asking
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Should every API test produce a receipt?
&lt;/h3&gt;

&lt;p&gt;No. Start with boundary tests, asynchronous workflows, and checks that create external state. Tiny pure unit tests usually need ordinary test output only.&lt;/p&gt;

&lt;h3&gt;
  
  
  Should receipts be committed to the repository?
&lt;/h3&gt;

&lt;p&gt;Usually not. Upload them as CI artifacts with an appropriate retention policy. Generated files in the repository create noise and can accidentally preserve sensitive data.&lt;/p&gt;

&lt;h3&gt;
  
  
  What is the first field to add when debugging is painful?
&lt;/h3&gt;

&lt;p&gt;Add an ownership key that crosses the whole flow: CI run, request, database event, and external message. It gives every tool the same thread to follow.&lt;/p&gt;

&lt;p&gt;The payoff is simple: when an API test fails, the workflow leaves behind a small, honest record of what it attempted and what it observed. That is a much better developer tool than a red check with no clues.&lt;/p&gt;

</description>
      <category>githubactions</category>
      <category>testing</category>
      <category>automation</category>
      <category>devtools</category>
    </item>
    <item>
      <title>GitHub Actions Needs a Real API Test Receipt</title>
      <dc:creator>Jonathan</dc:creator>
      <pubDate>Thu, 17 Sep 2026 05:23:02 +0000</pubDate>
      <link>https://dev.to/pong1965/github-actions-needs-a-real-api-test-receipt-571l</link>
      <guid>https://dev.to/pong1965/github-actions-needs-a-real-api-test-receipt-571l</guid>
      <description>&lt;p&gt;An API test that fails in GitHub Actions usually leaves behind one line: &lt;code&gt;expected 200, got 500&lt;/code&gt;. That is enough to fail the build, but not enough to fix it quickly.&lt;/p&gt;

&lt;p&gt;I have had better results treating every important smoke test like a small transaction. The test should produce a receipt: what it sent, which temporary fixture it used, what it observed, and where the failure happened. This is especially useful when a signup or password-reset API sends an email as part of the flow.&lt;/p&gt;

&lt;p&gt;The trick is not to dump every response into the log. It is to capture a compact, safe record that survives retries and becomes a downloadable workflow artifact.&lt;/p&gt;

&lt;h2&gt;
  
  
  The failure is usually missing evidence
&lt;/h2&gt;

&lt;p&gt;A retry can make a flaky test green while hiding the original cause. Maybe the first request used a stale token. Maybe the API returned before the email provider accepted the message. Maybe the test read an old message from a shared temp mailbox. The final retry does not tell you which story was true.&lt;/p&gt;

&lt;p&gt;For each attempt, I want five fields:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;A run and attempt identifier.&lt;/li&gt;
&lt;li&gt;The request timestamp and endpoint name.&lt;/li&gt;
&lt;li&gt;A redacted request and response summary.&lt;/li&gt;
&lt;li&gt;The email fixture identifier, but never its secret contents.&lt;/li&gt;
&lt;li&gt;The final assertion and elapsed time.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;That list is small enough to review in a pull request and useful enough to debug from CI without reproducing the failure locally. It also pairs well with &lt;a href="https://dev.to/ryanlee91/type-safe-email-events-for-react-teams-196l"&gt;type-safe email events&lt;/a&gt; when the application exposes event payloads to a test client.&lt;/p&gt;

&lt;h2&gt;
  
  
  Build a small receipt in the workflow
&lt;/h2&gt;

&lt;p&gt;Make the test write JSON to a known directory. Keep the format boring so shell tools and CI systems can read it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"attempt"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"case"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"signup-verification"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"request_started_at"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"2026-09-17T05:20:00Z"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"status"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;202&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"email_fixture"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"ci-run-4812"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"message_seen"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"result"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"verification message timeout"&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then upload it whether the test passes or fails:&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;Run API smoke tests&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;pytest -q tests/smoke --receipt-dir artifacts/receipts&lt;/span&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;Upload test receipts&lt;/span&gt;
  &lt;span class="na"&gt;if&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;always()&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/upload-artifact@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;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;api-test-receipts&lt;/span&gt;
    &lt;span class="na"&gt;path&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;artifacts/receipts/&lt;/span&gt;
    &lt;span class="na"&gt;if-no-files-found&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;ignore&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;always()&lt;/code&gt; is the important bit. A receipt that only exists on success is a celebration photo, not debugging evidence. Make sure the test process flushes the file before exiting; this detail is easy to miss and causes some very confusing empty artifacts.&lt;/p&gt;

&lt;h2&gt;
  
  
  Keep email fixtures isolated
&lt;/h2&gt;

&lt;p&gt;When an API test needs to verify an email, create a fixture owned by the current run. Do not ask a shared inbox for the newest message and hope it belongs to your request. Include a unique run value in the address or subject, then check both the subject and the ownership token.&lt;/p&gt;

&lt;p&gt;A disposable temporary email service can be useful for manual checks and short-lived integration environments, but CI should still record only the fixture ID and relevant metadata. Never put inbox credentials, full message bodies, or reset links in a public workflow artifact. These are privacy checks for signup email logs, not just housekeeping.&lt;/p&gt;

&lt;p&gt;If someone writes &lt;code&gt;tempail mail&lt;/code&gt; or &lt;code&gt;tamp mail com&lt;/code&gt; in a test note, I treat it as a reminder to normalize the fixture vocabulary before it spreads into scripts and dashboards.&lt;/p&gt;

&lt;h2&gt;
  
  
  Make retries useful
&lt;/h2&gt;

&lt;p&gt;Retries should create a separate receipt, not overwrite &lt;code&gt;receipt.json&lt;/code&gt;. Add the attempt number to the filename:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;artifacts/receipts/
  signup-verification-attempt-1.json
  signup-verification-attempt-2.json
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This makes a before-and-after comparison possible. If attempt two passes, you can still see whether the first request timed out, used the wrong fixture, or received a server error. In practice, that saves more time than increasing the retry count.&lt;/p&gt;

&lt;p&gt;I also put a hard limit on polling. A test that waits forever is not more reliable; it is just harder to diagnose. Record the poll deadline and the number of messages observed, then fail with a narrow reason such as &lt;code&gt;message_not_owned&lt;/code&gt; or &lt;code&gt;provider_timeout&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  A practical checklist
&lt;/h2&gt;

&lt;p&gt;Before calling an API smoke test reliable, check that it:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;creates a unique fixture for every run&lt;/li&gt;
&lt;li&gt;records request timing and status without secrets&lt;/li&gt;
&lt;li&gt;writes one receipt per retry&lt;/li&gt;
&lt;li&gt;uploads receipts with &lt;code&gt;if: always()&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;distinguishes an API failure from an email observation failure&lt;/li&gt;
&lt;li&gt;deletes or expires short-lived fixtures after the run&lt;/li&gt;
&lt;li&gt;links implementation logs to a stable case name&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The payoff is a faster feedback loop. GitHub Actions still tells you that the build failed, but the artifact tells you what to do next. That is the difference between a retry button and an engineering workflow.&lt;/p&gt;

</description>
      <category>githubactions</category>
      <category>testing</category>
      <category>automation</category>
      <category>devtools</category>
    </item>
    <item>
      <title>GitHub Actions Email Tests Need a Fast Runbook</title>
      <dc:creator>Jonathan</dc:creator>
      <pubDate>Wed, 16 Sep 2026 17:23:07 +0000</pubDate>
      <link>https://dev.to/pong1965/github-actions-email-tests-need-a-fast-runbook-1h2b</link>
      <guid>https://dev.to/pong1965/github-actions-email-tests-need-a-fast-runbook-1h2b</guid>
      <description>&lt;p&gt;Email verification tests in GitHub Actions rarely fail for only one reason. The API may be slow, the inbox may contain an old message, or a retry may quietly use data from the first attempt. The test output usually says something less useful: timeout waiting for email.&lt;/p&gt;

&lt;p&gt;I have had better results by treating the email step like a small production integration. Give it a clear contract, record a little evidence, and clean up the test address when the run ends. This makes the workflow quicker to fix, and it avoids the classic CI mystery where a rerun passes but nobody knows why.&lt;/p&gt;

&lt;h2&gt;
  
  
  The hidden cost of email checks in CI
&lt;/h2&gt;

&lt;p&gt;An end-to-end signup test normally crosses several boundaries:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;The browser submits a form.&lt;/li&gt;
&lt;li&gt;The API accepts the signup and queues a message.&lt;/li&gt;
&lt;li&gt;A mail provider delivers the message.&lt;/li&gt;
&lt;li&gt;The test polls an inbox and opens a link.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Each boundary has a different failure mode. A single ten-minute timeout hides all of them. Shared inboxes make the story even worse because two jobs can see the same subject or consume each other's messages.&lt;/p&gt;

&lt;p&gt;For parallel jobs, start with &lt;a href="https://dev.to/kevindev27/testing-rest-api-verification-emails-without-polluting-shared-inboxes-5693"&gt;isolated verification inboxes&lt;/a&gt;. An address per run is a small change, but it removes a surprising amount of guessing.&lt;/p&gt;

&lt;h2&gt;
  
  
  A small runbook for every workflow
&lt;/h2&gt;

&lt;p&gt;My CI runbook has five steps:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;create a unique test address before the browser action&lt;/li&gt;
&lt;li&gt;record the address and a UTC trigger timestamp&lt;/li&gt;
&lt;li&gt;poll only for messages newer than that timestamp&lt;/li&gt;
&lt;li&gt;save a redacted message summary as an artifact on failure&lt;/li&gt;
&lt;li&gt;delete or expire the inbox in a final cleanup step&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The address does not need to be permanent. A create temporary mail helper is fine for a short-lived test, as long as the test owns the address and does not reuse it across jobs. A temporary inbox is test data, not a shared service account.&lt;/p&gt;

&lt;p&gt;If the helper has a typo in its documentation, someone may paste &lt;code&gt;tepm mail com&lt;/code&gt; into a debugging note later. That kind of small confusion is another reason to keep the actual address and provider details in the run receipt.&lt;/p&gt;

&lt;h2&gt;
  
  
  Capture evidence before polling
&lt;/h2&gt;

&lt;p&gt;Before waiting for a message, write a compact JSON record:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"run_id"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"${GITHUB_RUN_ID}"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"inbox"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"unique-address@example.test"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"triggered_at"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"2026-09-16T17:22:00Z"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"subject_expected"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Verify your account"&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Do not store message bodies or verification tokens in ordinary logs. A useful receipt can contain the message ID, subject, received timestamp, and a pass/fail reason. That is enough to tell whether delivery was late, matching was broad, or the application never queued the email.&lt;/p&gt;

&lt;p&gt;For the polling contract, match the recipient and a message ID created after &lt;code&gt;triggered_at&lt;/code&gt;. Subject-only matching is tempting, but it becomes fragile as soon as a retry or an older fixture is present. Teams building &lt;a href="https://dev.to/pong1965/contract-test-api-emails-in-github-actions-45f8"&gt;contract-tested email flows in CI&lt;/a&gt; can make these fields explicit and reviewable.&lt;/p&gt;

&lt;h2&gt;
  
  
  A GitHub Actions example
&lt;/h2&gt;

&lt;p&gt;Keep the workflow readable by putting setup and cleanup around the test command:&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;Run email flow&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;TEST_RUN_ID&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${{ github.run_id }}&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;npm run test:email -- --run-id "$TEST_RUN_ID"&lt;/span&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;Upload email receipt&lt;/span&gt;
  &lt;span class="na"&gt;if&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;failure()&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/upload-artifact@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;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;email-receipt-${{ github.run_id }}&lt;/span&gt;
    &lt;span class="na"&gt;path&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;artifacts/email/&lt;/span&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;Cleanup test inbox&lt;/span&gt;
  &lt;span class="na"&gt;if&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;always()&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;npm run test:email:cleanup&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The cleanup command should be safe to run after a partial setup. That detail sounds boring, but it prevents failed jobs from leaving dozens of disposable addresses around. If your provider calls this a tempmail disposable mailbox, document its expiry behavior beside the test code so nobody assumes it is durable.&lt;/p&gt;

&lt;h2&gt;
  
  
  The five-minute failure checklist
&lt;/h2&gt;

&lt;p&gt;When a run is red, check these in order:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Was the inbox created successfully and assigned only to this job?&lt;/li&gt;
&lt;li&gt;Was the API request accepted before polling started?&lt;/li&gt;
&lt;li&gt;Did a message arrive after the trigger timestamp?&lt;/li&gt;
&lt;li&gt;Did the matcher verify recipient, subject, and message ownership?&lt;/li&gt;
&lt;li&gt;Did cleanup run after the test, even on cancellation?&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;This checklist turns a vague timeout into a short investigation. It also makes automation easier to move between local development and CI because the same receipt fields exist in both places. I keep the runbook next to the test, becuase that is where the next maintainer will look first.&lt;/p&gt;

&lt;h2&gt;
  
  
  Q&amp;amp;A: common CI email questions
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Should every test create a new inbox?
&lt;/h3&gt;

&lt;p&gt;For parallel or retryable tests, yes. Reuse can be acceptable for a strictly serial smoke check, but a unique address is usually cheaper than debugging cross-run contamination.&lt;/p&gt;

&lt;h3&gt;
  
  
  How long should polling wait?
&lt;/h3&gt;

&lt;p&gt;Use a bounded timeout and log each poll outcome. Do not make the timeout enormous to hide delivery problems. A shorter, evidenced failure is more productive than a green-looking job that spent fifteen minutes guessing.&lt;/p&gt;

&lt;h3&gt;
  
  
  Is a disposable email service safe for production mail?
&lt;/h3&gt;

&lt;p&gt;No. Keep it scoped to test data and non-sensitive environments. Never put real customer information, credentials, or production verification links in a temporary inbox.&lt;/p&gt;

&lt;p&gt;The best email test is not the one that never fails. It is the one that tells you what failed quickly, then leaves the workspace clean for the next run.&lt;/p&gt;

</description>
      <category>githubactions</category>
      <category>automation</category>
      <category>devtools</category>
      <category>testing</category>
    </item>
    <item>
      <title>GitHub Actions API Tests Need Cheap Isolation</title>
      <dc:creator>Jonathan</dc:creator>
      <pubDate>Sat, 12 Sep 2026 23:23:05 +0000</pubDate>
      <link>https://dev.to/pong1965/github-actions-api-tests-need-cheap-isolation-ei9</link>
      <guid>https://dev.to/pong1965/github-actions-api-tests-need-cheap-isolation-ei9</guid>
      <description>&lt;p&gt;API smoke tests in GitHub Actions are easy to write and surprisingly hard to trust. A test can fail because the API is broken, because a verification email is late, or because yesterday's inbox state leaked into today's run. When all three look like the same red check, the workflow becomes expensive to maintain.&lt;/p&gt;

&lt;p&gt;I have found that the useful shortcut is cheap isolation plus a small run receipt. The goal is not a huge end-to-end framework. It is a workflow where each run gets its own inputs, inbox identity, and evidence.&lt;/p&gt;

&lt;h2&gt;
  
  
  The failure mode
&lt;/h2&gt;

&lt;p&gt;Consider a signup smoke test:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Create a user.&lt;/li&gt;
&lt;li&gt;Wait for a verification email.&lt;/li&gt;
&lt;li&gt;Open the link.&lt;/li&gt;
&lt;li&gt;Call an authenticated API endpoint.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;This crosses several boundaries. A reused address may already have an account. A shared mailbox may contain an old message. A retry may create a second user. Then the final assertion says only &lt;code&gt;expected 200, got 409&lt;/code&gt;, which is technically accurate but not very useful.&lt;/p&gt;

&lt;p&gt;Search terms such as &lt;strong&gt;tempmailso&lt;/strong&gt; or &lt;strong&gt;facebook temp email&lt;/strong&gt; often describe the kind of isolated address people want, but the engineering requirement is more specific: one test identity, a bounded lifetime, and a traceable message.&lt;/p&gt;

&lt;h2&gt;
  
  
  A small workflow contract
&lt;/h2&gt;

&lt;p&gt;Start by making the run inputs explicit. The test should know its run ID and derive a unique address from it. Keep the value in a job-local environment variable, and never print the full address if it contains credentials or tokens.&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;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;API smoke&lt;/span&gt;

&lt;span class="na"&gt;on&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;workflow_dispatch&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;schedule&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;cron&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;17&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;*/6&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;*&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;*&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;*"&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;smoke&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;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Run isolated smoke test&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;RUN_ID&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${{ github.run_id }}-${{ github.run_attempt }}&lt;/span&gt;
          &lt;span class="na"&gt;API_BASE_URL&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${{ secrets.SMOKE_API_BASE_URL }}&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;./scripts/smoke-api.sh&lt;/span&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;Upload evidence&lt;/span&gt;
        &lt;span class="na"&gt;if&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;always()&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/upload-artifact@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;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;smoke-evidence-${{ github.run_id }}&lt;/span&gt;
          &lt;span class="na"&gt;path&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;artifacts/smoke/&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;if: always()&lt;/code&gt; step matters. A failed test is exactly when its logs and response summaries are most valuable. Before adding retries, freeze this contract; &lt;a href="https://dev.to/mrdapperx/cron-writers-need-a-frozen-plan-5154"&gt;freeze the test plan before a scheduled run&lt;/a&gt; so a retry does not silently change what you are measuring.&lt;/p&gt;

&lt;h2&gt;
  
  
  Isolate the inbox
&lt;/h2&gt;

&lt;p&gt;Generate an address per run, then record only a safe identifier in the evidence file:&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;set&lt;/span&gt; &lt;span class="nt"&gt;-euo&lt;/span&gt; pipefail

&lt;span class="nv"&gt;run_id&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;RUN_ID&lt;/span&gt;:?missing&lt;span class="p"&gt; RUN_ID&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
&lt;span class="nb"&gt;mkdir&lt;/span&gt; &lt;span class="nt"&gt;-p&lt;/span&gt; artifacts/smoke
&lt;span class="nv"&gt;address&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"ci+&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;run_id&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;@example.test"&lt;/span&gt;
&lt;span class="nb"&gt;printf&lt;/span&gt; &lt;span class="s1"&gt;'run_id=%s\n'&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$run_id&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; artifacts/smoke/context.txt

python scripts/create_user.py &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--email&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$address&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--output&lt;/span&gt; artifacts/smoke/signup.json
python scripts/wait_for_verification.py &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--email&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$address&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--timeout-seconds&lt;/span&gt; 45 &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--output&lt;/span&gt; artifacts/smoke/inbox.json
python scripts/assert_api.py &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--input&lt;/span&gt; artifacts/smoke/inbox.json &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--output&lt;/span&gt; artifacts/smoke/api.json
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;In a real system, replace the example domain with a provider or test mailbox service that your team controls. A disposable email generator can be useful for temporary test identities, but it should not become a hidden dependency: document retention, rate limits, and whether messages are publicly readable.&lt;/p&gt;

&lt;p&gt;The slighty annoying part is cleanup. Delete the test user when the API supports it, and set a short retention policy for inbox data. Never store verification URLs in ordinary build logs.&lt;/p&gt;

&lt;h2&gt;
  
  
  Keep evidence as artifacts
&lt;/h2&gt;

&lt;p&gt;A run receipt should answer four questions: which identity was used, when the message arrived, what endpoint was called, and what the response status was. Redact tokens and message bodies unless the body is essential to diagnosis.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"run_id"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"12345-1"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"mail_wait_ms"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;1840&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"verification"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"received"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"endpoint"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"/api/me"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"status"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;200&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Add the message subject, provider request ID, and retry count when they are safe to retain. These small fields turn a flaky check into something you can replay. For scheduled publishers, &lt;a href="https://dev.to/mrdapperx/cron-publishers-need-inbox-probes-16p0"&gt;small inbox probes for publishing workflows&lt;/a&gt; use the same idea: test the boundary directly and leave evidence behind.&lt;/p&gt;

&lt;h2&gt;
  
  
  A practical checklist
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Create a fresh identity per workflow run.&lt;/li&gt;
&lt;li&gt;Pass &lt;code&gt;RUN_ID&lt;/code&gt; through every helper.&lt;/li&gt;
&lt;li&gt;Bound inbox polling with a timeout and a clear error.&lt;/li&gt;
&lt;li&gt;Separate API errors from delivery errors.&lt;/li&gt;
&lt;li&gt;Upload artifacts with &lt;code&gt;if: always()&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Redact tokens, cookies, and full verification URLs.&lt;/li&gt;
&lt;li&gt;Make retries idempotent with a stable run key.&lt;/li&gt;
&lt;li&gt;Remove test accounts and old inbox data.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If your team casually says &lt;strong&gt;temp org mail&lt;/strong&gt; or &lt;strong&gt;temp mailid&lt;/strong&gt;, translate that request into these explicit controls. The wording may vary; the safety properties should not.&lt;/p&gt;

&lt;h2&gt;
  
  
  Closing thought
&lt;/h2&gt;

&lt;p&gt;Reliable CI is less about adding another retry and more about reducing ambiguity. Give each API test a cheap isolated identity, a bounded inbox wait, and a compact receipt. The next red build then tells you whether the API, the email boundary, or the workflow itself needs attention—and that is a much better productivity win than a green check you cannot explain.&lt;/p&gt;

</description>
      <category>githubactions</category>
      <category>testing</category>
      <category>automation</category>
      <category>api</category>
    </item>
  </channel>
</rss>
