<?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>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>
    <item>
      <title>Replayable Email Checks with GitHub Actions</title>
      <dc:creator>Jonathan</dc:creator>
      <pubDate>Sat, 12 Sep 2026 08:22:53 +0000</pubDate>
      <link>https://dev.to/pong1965/replayable-email-checks-with-github-actions-3lf0</link>
      <guid>https://dev.to/pong1965/replayable-email-checks-with-github-actions-3lf0</guid>
      <description>&lt;p&gt;Email verification tests often fail at the worst possible moment: inside CI, with only “timeout waiting for inbox” in the log. The API may be fine. The inbox may be slow. A token may have expired. Or the workflow may have retried with a different test identity.&lt;/p&gt;

&lt;p&gt;I like treating this as an evidence problem. A GitHub Actions job should leave enough information to replay the check and explain the failure, without dumping email contents or secrets into logs. This small pattern has made disposable email address checks much less mysterious in API projects.&lt;/p&gt;

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

&lt;p&gt;An end-to-end email test usually has four steps:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Create a test address.&lt;/li&gt;
&lt;li&gt;Ask the signup API to send a verification message.&lt;/li&gt;
&lt;li&gt;Poll an inbox API for the message.&lt;/li&gt;
&lt;li&gt;Extract the link and call the verification endpoint.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The basic flow is simple, but the failure surface is not. CI runners have variable network latency, providers can delay delivery, and a broad retry can hide the first useful error. The old approach was to increase the timeout and hope. It worked sometimes, but diagnosis got slower.&lt;/p&gt;

&lt;p&gt;The better approach is to give each run an ID, record safe state transitions, and upload a compact receipt when the test ends.&lt;/p&gt;

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

&lt;p&gt;Here is a deliberately plain workflow shape. The helper script owns the polling details, while the workflow owns isolation and evidence:&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;email-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;pull_request&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;jobs&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;verify-email&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 verification 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;TEST_INBOX_TOKEN&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${{ secrets.TEST_INBOX_TOKEN }}&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/email-check&lt;/span&gt;
          &lt;span class="s"&gt;python scripts/email_smoke.py \&lt;/span&gt;
            &lt;span class="s"&gt;--run-id "${{ github.run_id }}-${{ github.run_attempt }}" \&lt;/span&gt;
            &lt;span class="s"&gt;--receipt artifacts/email-check/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 safe 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;email-check-${{ 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-check/receipt.json&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The receipt should contain timestamps, step names, HTTP status codes, correlation IDs, and a final reason. It should not contain the inbox token, verification URL, full message body, or personal data. A useful record might look 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;"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-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;"states"&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;"address_created"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"send_requested"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"message_found"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"send_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;"poll_attempts"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;4&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;3180&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;"verified"&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;One small detail matters: use a unique address or namespace for every run. Reusing an inbox can make an old message look like a fresh success. Also, make the message query specific to the run ID when your mail-testing API supports it. The check then becomes replayable instead of merely repeatable.&lt;/p&gt;

&lt;h2&gt;
  
  
  Capture evidence, not noise
&lt;/h2&gt;

&lt;p&gt;Keep the console output short and structured. For failures, print the state where the check stopped and the correlation ID. Do not print the email address if it can be tied to a real person, and never print the token.&lt;/p&gt;

&lt;p&gt;I also separate delivery failures from assertion failures:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Delivery:&lt;/strong&gt; no matching message arrived before the deadline.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Transport:&lt;/strong&gt; the inbox or application API returned an error.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Contract:&lt;/strong&gt; the message arrived, but the expected link or subject was missing.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Verification:&lt;/strong&gt; the link arrived, but the API rejected it.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That taxonomy makes retries safer. A transient transport error might deserve one retry. A contract failure should fail fast and invite investigation. For context on keeping recovery messages auditable, see &lt;a href="https://dev.to/sophiax99/oauth-recovery-emails-need-provenance-mc8"&gt;email provenance in recovery flows&lt;/a&gt;, and review &lt;a href="https://dev.to/bitheirstake/signup-privacy-logs-need-expiration-rules-27m7"&gt;expiration rules for signup logs&lt;/a&gt; before retaining receipts for a long time.&lt;/p&gt;

&lt;p&gt;Sometimes a test note says “tempail mail” because that is what a user searched for. Keep that typo as plain text in search-oriented documentation, but dont turn it into a link or a keyword that defines the test contract. Small wording details can matter more than expected for support.&lt;/p&gt;

&lt;h2&gt;
  
  
  Practical checklist
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Generate a unique run ID and inbox identity.&lt;/li&gt;
&lt;li&gt;Set a bounded polling deadline, not an endless retry.&lt;/li&gt;
&lt;li&gt;Log state transitions and safe response metadata.&lt;/li&gt;
&lt;li&gt;Redact tokens, message bodies, and verification URLs.&lt;/li&gt;
&lt;li&gt;Upload the receipt with &lt;code&gt;if: always()&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Distinguish delivery, transport, contract, and verification failures.&lt;/li&gt;
&lt;li&gt;Add a manual dispatch so a maintainer can replay the exact check.&lt;/li&gt;
&lt;li&gt;Expire artifacts according to your privacy policy.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The before/after improvement is straightforward: instead of “email test failed,” the pull request shows “message found after four polls; verification returned 200,” or “delivery deadline exceeded after six polls.” That is a real productivity win, and it usually lets the next fix start immediately.&lt;/p&gt;

&lt;h2&gt;
  
  
  Final thoughts
&lt;/h2&gt;

&lt;p&gt;Email verification is an external boundary, so flaky behavior is normal. The goal is not to pretend the boundary is deterministic. Build the GitHub Actions job so every run is isolated, bounded, and explainable. With a small receipt and a focused API helper, disposable email address testing becomes a useful smoke check rather than a source of midnight guesswork.&lt;/p&gt;

</description>
      <category>githubactions</category>
      <category>testing</category>
      <category>automation</category>
      <category>devtools</category>
    </item>
    <item>
      <title>Replayable API Smoke Tests in GitHub Actions</title>
      <dc:creator>Jonathan</dc:creator>
      <pubDate>Fri, 11 Sep 2026 20:23:04 +0000</pubDate>
      <link>https://dev.to/pong1965/replayable-api-smoke-tests-in-github-actions-4lde</link>
      <guid>https://dev.to/pong1965/replayable-api-smoke-tests-in-github-actions-4lde</guid>
      <description>&lt;h1&gt;
  
  
  Replayable API Smoke Tests in GitHub Actions
&lt;/h1&gt;

&lt;p&gt;An API smoke test should answer one quick question: does the most important path still work? In practice, teams often get a green check without knowing which environment ran, which test data it used, or what to do when an email arrives late. The check passes once, then becomes hard to replay.&lt;/p&gt;

&lt;p&gt;I have found a small run contract makes these workflows much more useful. Give each run an explicit input, a bounded wait, and an evidence bundle. That turns GitHub Actions from a button that says “probably okay” into a developer tool you can inspect and re-run.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why smoke tests become hard to replay
&lt;/h2&gt;

&lt;p&gt;The first version is usualy simple: install dependencies, call an endpoint, and exit non-zero on failure. The trouble starts when the test depends on a temporary account, an email link, or a shared staging record. A retry may use stale data. Two jobs may read the same inbox. A timeout gets reported as a generic assertion failure.&lt;/p&gt;

&lt;p&gt;Before changing the test code, write down four values:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Run ID:&lt;/strong&gt; unique for the workflow attempt.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Environment:&lt;/strong&gt; the API base URL and deployment revision.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Test subject:&lt;/strong&gt; a disposable user or isolated fixture.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Deadline:&lt;/strong&gt; the maximum time allowed for asynchronous work.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;These values are small, but they make a log searchable. They also stop a developer from guessing which account a failed check touched. For OAuth-heavy systems, &lt;a href="https://dev.to/sophiax99/safer-oauth-emails-start-with-link-boundaries-3ele"&gt;safer boundaries for OAuth email flows&lt;/a&gt; are a useful companion to this approach.&lt;/p&gt;

&lt;h2&gt;
  
  
  Give every check a run contract
&lt;/h2&gt;

&lt;p&gt;Pass the contract through environment variables or a checked-in config file. Keep secrets in GitHub Actions secrets, while ordinary run metadata can be visible in the job summary.&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;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;https://staging.example.com&lt;/span&gt;
  &lt;span class="na"&gt;SMOKE_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;POLL_DEADLINE_SECONDS&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;45"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The test should create unique data with &lt;code&gt;SMOKE_RUN_ID&lt;/code&gt;, then clean it up when the API allows that. If cleanup is unsafe, tag the fixture for a scheduled janitor job. Avoid using the current timestamp alone; parallel jobs can still collide, and timestamps are not very nice to search.&lt;/p&gt;

&lt;p&gt;For a verification flow, a &lt;code&gt;temp mail&lt;/code&gt; inbox can be appropriate for an isolated smoke test. Treat it as test infrastructure, though, not as a shortcut around production trust controls. Also document the exact service and retention behavior. A note like “tamp mail com” in an old runbook is not enough to identify a dependency, and “tepm mail com” can send an investigator searching in the wrong place.&lt;/p&gt;

&lt;h2&gt;
  
  
  Build a useful GitHub Actions workflow
&lt;/h2&gt;

&lt;p&gt;Make the workflow runnable on both pushes and manual dispatch. Manual dispatch is the fastest way to reproduce a staging failure without editing 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="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;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;workflow_dispatch&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;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;timeout-minutes&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;5&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 smoke -- --run-id "$SMOKE_RUN_ID"&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;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;always()&lt;/code&gt; condition matters. Without it, the most valuable files disappear exactly when the check fails. Keep the artifact small: request and response metadata, step timings, correlation IDs, and a sanitized error. Never upload access tokens or full email contents by accident.&lt;/p&gt;

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

&lt;p&gt;Asynchronous email checks need a poll loop with a deadline, not an unbounded sleep. Poll at a modest interval, record each attempt, and distinguish “message not received yet” from “provider rejected the request.” The test can then report whether the failure is in the API, delivery path, or test dependency.&lt;/p&gt;

&lt;p&gt;If the UI also exercises this path, &lt;a href="https://dev.to/ryanlee91/react-forms-need-async-boundaries-5fd6"&gt;async boundaries in signup forms&lt;/a&gt; explains why the client should expose a pending state rather than pretending the request completed. The same idea applies to CI: pending is a state with a deadline, not a reason to hide progress.&lt;/p&gt;

&lt;h2&gt;
  
  
  Store evidence for the next failure
&lt;/h2&gt;

&lt;p&gt;Write one machine-readable receipt per run:&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-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;"environment"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"staging"&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;"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;"stage"&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_email"&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;47120&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"retryable"&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A stable receipt lets a later script summarize failures across runs. It also supports a practical retry policy: retry delivery timeouts, but do not retry a 401, schema mismatch, or deterministic assertion. This saves minutes during incident triage and avoids creating duplicate test accounts.&lt;/p&gt;

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

&lt;ul&gt;
&lt;li&gt;Can a developer start the test with &lt;code&gt;workflow_dispatch&lt;/code&gt;?&lt;/li&gt;
&lt;li&gt;Is every fixture unique to the run?&lt;/li&gt;
&lt;li&gt;Is the asynchronous wait bounded and observable?&lt;/li&gt;
&lt;li&gt;Does failure upload sanitized evidence?&lt;/li&gt;
&lt;li&gt;Are retryable and permanent errors distinct?&lt;/li&gt;
&lt;li&gt;Can the job identify its API revision and environment?&lt;/li&gt;
&lt;li&gt;Are external test dependencies documented and isolated?&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Final thoughts
&lt;/h2&gt;

&lt;p&gt;The biggest productivity win is not a faster smoke test. It is a smoke test that explains itself when it fails. A run ID, a deadline, and a small evidence receipt are enough to make many API checks replayable. Add those pieces first, then tune parallelism or polling only after the workflow tells you where the time goes.&lt;/p&gt;

</description>
      <category>githubactions</category>
      <category>testing</category>
      <category>automation</category>
      <category>devtools</category>
    </item>
    <item>
      <title>GitHub Actions Email Tests Need Evidence</title>
      <dc:creator>Jonathan</dc:creator>
      <pubDate>Wed, 09 Sep 2026 17:22:52 +0000</pubDate>
      <link>https://dev.to/pong1965/github-actions-email-tests-need-evidence-3m7e</link>
      <guid>https://dev.to/pong1965/github-actions-email-tests-need-evidence-3m7e</guid>
      <description>&lt;p&gt;Email verification tests often fail in CI with a useless message: “expected email, received none.” A retry may turn green, but it does not tell the team whether the message was late, the inbox was shared, or the API rejected the request.&lt;/p&gt;

&lt;p&gt;In my CI workflows, the biggest productivity win was treating every email test as an evidence-producing job. The test still asserts the user-visible result, but it also saves enough context to explain a failure. That makes a flaky test a short investigation instead of a rerun lottery.&lt;/p&gt;

&lt;h2&gt;
  
  
  The failure signal is too small
&lt;/h2&gt;

&lt;p&gt;An email flow crosses several boundaries:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;The application accepts a signup request.&lt;/li&gt;
&lt;li&gt;A worker queues and sends a message.&lt;/li&gt;
&lt;li&gt;The inbox provider exposes the message.&lt;/li&gt;
&lt;li&gt;The test finds the right message and follows its link.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;One failed assertion covers all four stages. Add a throwaway email address or a shared test inbox and the ambiguity gets worse: another job might consume the same message.&lt;/p&gt;

&lt;p&gt;Give each run a unique correlation ID, and include it in the recipient or subject when your test system allows it. Also log state transitions, not the full message body. This keeps logs useful without leaking tokens.&lt;/p&gt;

&lt;h2&gt;
  
  
  Build an evidence bundle
&lt;/h2&gt;

&lt;p&gt;For each attempt, I save five small artifacts:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;request.json&lt;/code&gt;: safe request metadata and correlation ID&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;events.jsonl&lt;/code&gt;: queue, delivery, and polling events with timestamps&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;inbox.json&lt;/code&gt;: message IDs, subjects, and received times&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;failure.txt&lt;/code&gt;: the final reason and elapsed time&lt;/li&gt;
&lt;li&gt;a screenshot or trace when the browser is involved&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The key is to write these files even when a retry succeeds. Otherwise, the first failure disappears and the green result hides a slow or broken dependency. A little extra disk use is worth it.&lt;/p&gt;

&lt;p&gt;If your flow uses React, it is also useful to separate UI state from delivery state. This guide on &lt;a href="https://dev.to/ryanlee91/how-to-test-react-invite-emails-in-preview-environments-without-inbox-collisions-3mnp"&gt;preview email tests without inbox collisions&lt;/a&gt; is a good reminder that inbox ownership is part of test design.&lt;/p&gt;

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

&lt;p&gt;Here is the small part of a workflow that makes artifacts available after any test outcome:&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 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;email_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:email -- --reporter=line&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-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="pi"&gt;|&lt;/span&gt;
      &lt;span class="s"&gt;.artifacts/email/&lt;/span&gt;
      &lt;span class="s"&gt;test-results/&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="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 after evidence is uploaded&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.email_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;&lt;code&gt;if: always()&lt;/code&gt; is the important line. The upload must run after a failed test, and the final step preserves the correct job status. I prefer one artifact per workflow run because it is easy to find and compare.&lt;/p&gt;

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

&lt;p&gt;Retries should answer a question. Retry only the polling operation when delivery may be delayed; do not silently repeat the entire signup if that creates duplicate accounts. Record the attempt number, polling interval, and last observed event.&lt;/p&gt;

&lt;p&gt;When a retry passes, compare its evidence with the failed attempt. A five-second difference suggests eventual consistency. Two different recipients suggest a fixture bug. No queue event suggests an application or worker failure. These distinctions save more time than increasing the retry count.&lt;/p&gt;

&lt;p&gt;For invite flows, keep authentication and tenant context explicit too. The discussion of &lt;a href="https://dev.to/sophiax99/tenant-bound-magic-links-for-saas-invites-5g84"&gt;tenant-bound magic links for SaaS invites&lt;/a&gt; shows why a valid link is not enough if it is accepted in the wrong context.&lt;/p&gt;

&lt;h2&gt;
  
  
  Checklist
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Generate a unique correlation ID per test.&lt;/li&gt;
&lt;li&gt;Isolate each inbox or recipient.&lt;/li&gt;
&lt;li&gt;Log safe event metadata with timestamps.&lt;/li&gt;
&lt;li&gt;Upload artifacts with &lt;code&gt;always()&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Retry polling, not side effects.&lt;/li&gt;
&lt;li&gt;Compare failed and successful attempts.&lt;/li&gt;
&lt;li&gt;Keep tokens and message bodies out of CI logs.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;I still see teams searching for “temp mailid” or “temp org mail” while diagnosing a test. The label matters less than the contract: the test needs a known recipient, a bounded wait, and evidence when that contract breaks.&lt;/p&gt;

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

&lt;h3&gt;
  
  
  Should every email test upload a screenshot?
&lt;/h3&gt;

&lt;p&gt;Only browser-facing tests need one. API-only tests usually get more value from structured events and inbox metadata.&lt;/p&gt;

&lt;h3&gt;
  
  
  Are retries a bad idea?
&lt;/h3&gt;

&lt;p&gt;No. A bounded retry is useful for asynchronous delivery. It becomes harmful when it hides failures or repeats non-idempotent actions.&lt;/p&gt;

&lt;h3&gt;
  
  
  What is the first improvement to make?
&lt;/h3&gt;

&lt;p&gt;Upload the artifacts from the first failed run. Once failures are inspectable, you can tune timeouts and retry policy based on evidence.&lt;/p&gt;

</description>
      <category>githubactions</category>
      <category>testing</category>
      <category>automation</category>
      <category>devtools</category>
    </item>
    <item>
      <title>Replay Email Evidence in GitHub Actions</title>
      <dc:creator>Jonathan</dc:creator>
      <pubDate>Mon, 07 Sep 2026 14:23:06 +0000</pubDate>
      <link>https://dev.to/pong1965/replay-email-evidence-in-github-actions-20f0</link>
      <guid>https://dev.to/pong1965/replay-email-evidence-in-github-actions-20f0</guid>
      <description>&lt;p&gt;Email-dependent CI checks are often treated like a single assertion: send a message, poll an inbox, click a link, pass or fail. That model is fast when everything works and nearly useless when it does not.&lt;/p&gt;

&lt;p&gt;The useful question is not “did the email test fail?” It is “what did this run observe, and can I replay that observation?” A small evidence bundle in GitHub Actions turns a noisy API failure into a short debugging session.&lt;/p&gt;

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

&lt;p&gt;Suppose a signup job creates a user, calls an email API, and waits for verification. A retry might find an old message. A shared inbox might contain another branch’s mail. Or the provider may accept the request while delivery is still pending. The final assertion hides all three cases.&lt;/p&gt;

&lt;p&gt;This gets worse when teams use a create temporary mail flow or a throwaway email generator for test identities but do not record which address belonged to which run. The inbox is isolated in theory, yet the CI logs can’t prove it.&lt;/p&gt;

&lt;p&gt;Start with a run identifier and carry it through every layer:&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="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="nv"&gt;TEST_EMAIL&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;

curl &lt;span class="nt"&gt;-fsS&lt;/span&gt; &lt;span class="nt"&gt;-X&lt;/span&gt; POST &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$EMAIL_API&lt;/span&gt;&lt;span class="s2"&gt;/messages"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s1"&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="s2"&gt;"{&lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="s2"&gt;to&lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="s2"&gt;:&lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="nv"&gt;$TEST_EMAIL&lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="s2"&gt;,&lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="s2"&gt;run_id&lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="s2"&gt;:&lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="nv"&gt;$RUN_ID&lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="s2"&gt;}"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; email-submit.json
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Never put a verification token in a normal log line. Save the response body as a restricted artifact, or redact the token before printing. This is a small habit, but it saves time when a failed run needs to be shared with someone outside the feature team.&lt;/p&gt;

&lt;h2&gt;
  
  
  Store evidence in the job
&lt;/h2&gt;

&lt;p&gt;Capture four small files, even when the test passes:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;code&gt;request.json&lt;/code&gt; — the safe request inputs and run ID.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;submit.json&lt;/code&gt; — the email API receipt, including provider status.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;poll.jsonl&lt;/code&gt; — timestamped polling results and message IDs.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;assertion.txt&lt;/code&gt; — the final reason for pass or failure.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;A polling record should say what was checked, not just dump a response:&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="nl"&gt;"at"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="s2"&gt;"2026-09-07T14:20:10Z"&lt;/span&gt;&lt;span class="p"&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="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="nl"&gt;"state"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="s2"&gt;"pending"&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;"at"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="s2"&gt;"2026-09-07T14:20:16Z"&lt;/span&gt;&lt;span class="p"&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="s2"&gt;"m_4821"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="nl"&gt;"state"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="s2"&gt;"matched"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="nl"&gt;"subject_ok"&lt;/span&gt;&lt;span class="p"&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Keep the retention period short and the artifact name predictable. Developers should be able to find evidence in seconds, and old test mail should not become a permanent data store. One common mistake is calling it a temp org mail address in a note while the actual fixture uses a different naming rule; consistent labels matter more than clever labels.&lt;/p&gt;

&lt;p&gt;For inbox matching, record the predicate too: recipient, subject prefix, run ID, and minimum message timestamp. These fields make a false positive visible.&lt;/p&gt;

&lt;h2&gt;
  
  
  Replay the check locally
&lt;/h2&gt;

&lt;p&gt;The fastest fix is usually a replay against saved evidence, not a full rerun of the entire pipeline. Make the parser accept a fixture directory:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;python scripts/check_email.py &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--submit&lt;/span&gt; .artifacts/submit.json &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--poll&lt;/span&gt; .artifacts/poll.jsonl &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--expected-run&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The command should return a distinct exit code for “no matching message,” “wrong message,” and “malformed provider response.” That distinction is more actionable than a generic timeout. It also lets you add regression fixtures for provider quirks without sending another message.&lt;/p&gt;

&lt;p&gt;If your test uses Playwright, pair the browser trace with the inbox record. The guide on &lt;a href="https://dev.to/silviutech/playwright-inbox-filters-for-flaky-signup-tests-24io"&gt;inbox filters for flaky signup tests&lt;/a&gt; is a useful companion for making the message selection explicit. Before an agent or parallel job starts, &lt;a href="https://dev.to/mrdapperx/freeze-email-test-plans-before-agent-runs-319j"&gt;freeze email test plans&lt;/a&gt; so the expected contract stays stable.&lt;/p&gt;

&lt;h2&gt;
  
  
  A compact workflow
&lt;/h2&gt;

&lt;p&gt;In GitHub Actions, the shape can stay simple:&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;run&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;./scripts/run-email-check.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 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-evidence-${{ github.run_id }}-${{ github.run_attempt }}&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="na"&gt;retention-days&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;3&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 important shortcut. Evidence that only exists on success is not evidence; it is decoration. In practice, this workflow makes failures less guessy and avoids rerunning unrelated build steps just to inspect one missing message.&lt;/p&gt;

&lt;h2&gt;
  
  
  Checklist
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Give every test run a unique email correlation ID.&lt;/li&gt;
&lt;li&gt;Isolate the inbox or use a strict recipient and timestamp filter.&lt;/li&gt;
&lt;li&gt;Save the API receipt, poll history, and assertion reason.&lt;/li&gt;
&lt;li&gt;Redact tokens and keep artifacts for a short period.&lt;/li&gt;
&lt;li&gt;Make the checker replayable from local fixtures.&lt;/li&gt;
&lt;li&gt;Upload evidence when the job fails as well as when it passes.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The payoff is modest but real: a flaky email check becomes a bounded investigation. GitHub Actions still tells you that the build failed, but the artifact tells you why.&lt;/p&gt;

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