<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:content="http://purl.org/rss/1.0/modules/content/">
  <channel>
    <title>ByteTech247</title>
    <link>https://bytetech247.com</link>
    <description>ByteTech247 delivers expert Dev Tools, automation, and AI productivity insights. Get production-ready guides and fixes to optimize your workflows.</description>
    <language>en-us</language>
    <atom:link href="https://bytetech247.com/rss.xml" rel="self" type="application/rss+xml" />
    <item>
      <title>Annual Copilot Plans: Model Multipliers Just Spiked</title>
      <link>https://bytetech247.com/ai-productivity/annual-copilot-plans-model-multipliers-spike/</link>
      <guid isPermaLink="true">https://bytetech247.com/ai-productivity/annual-copilot-plans-model-multipliers-spike/</guid>
      <description>GitHub revised Copilot&apos;s legacy model-multiplier table for annual Pro/Pro+ plans on June 1, 2026, some models jumped as high as 57x.</description>
      <content:encoded><![CDATA[<aside class="quick-answer" aria-label="Quick answer"><h2 id="quick-answer">Quick Answer</h2>
<p>Annual Copilot Pro and Pro+ subscribers stayed on the legacy Premium Request Unit system when monthly plans moved to AI Credits on June 1, 2026. GitHub revised that multiplier table the same day: Claude Opus 4.6 through 4.8 now cost 27 premium requests per call, and GPT-5.5 costs 57. Check the live table before assuming your old math still holds.</p>
</aside><h2 id="two-june-1-changes-and-only-one-hit-annual-plans">Two June 1 changes, and only one hit annual plans</h2>
<p>GitHub shipped two separate billing changes on the same date, and the timing makes them easy to conflate. Every monthly-billed Copilot plan retired Premium Request Units for AI Credits on June 1, 2026, a real-per-token pricing system covered in <a href="/ai-productivity/github-copilot-pru-to-ai-credits-migration/">GitHub Copilot’s PRU to AI Credits Migration, Explained</a>. Annual-billed Pro and Pro+ subscribers didn’t move at all. They stayed on the old multiplier system, unchanged in mechanism.</p>
<p>What did change for that group, on the exact same date, was the multiplier table itself. GitHub’s own documentation draws a firm line between the two systems: multipliers belong to the old request-based model and have no equivalent under Credits at all, a distinction that only makes sense once you know the table underneath it kept moving after the surrounding system was declared legacy. This post is about that table: what it charges today, why the most-cited number about it undersells the real cost, and what an annual subscriber can actually do before the next renewal.</p>
<h2 id="what-githubs-live-multiplier-table-shows-today">What GitHub’s live multiplier table shows today</h2>
<p>GitHub still publishes a full multiplier table for this legacy system, at a URL that says “legacy” in the path. The page itself narrows its own audience: Copilot Pro and Pro+ buyers, annual billing only, and only those who never left the old request-based system once June 1, 2026, arrived. Fetched directly from that page on 2026-08-19, here is every model it lists and the exact multiplier attached to it:</p>









































































































<table tabindex="0"><thead><tr><th scope="col" style="text-align:left">Model</th><th scope="col" style="text-align:right">Multiplier</th></tr></thead><tbody><tr><td style="text-align:left">GPT-5.5</td><td style="text-align:right">57</td></tr><tr><td style="text-align:left">Claude Opus 4.6</td><td style="text-align:right">27</td></tr><tr><td style="text-align:left">Claude Opus 4.7</td><td style="text-align:right">27</td></tr><tr><td style="text-align:left">Claude Opus 4.8</td><td style="text-align:right">27</td></tr><tr><td style="text-align:left">Claude Opus 4.5</td><td style="text-align:right">15</td></tr><tr><td style="text-align:left">Gemini 3.5 Flash</td><td style="text-align:right">14</td></tr><tr><td style="text-align:left">Claude Sonnet 4.6</td><td style="text-align:right">9</td></tr><tr><td style="text-align:left">Claude Sonnet 4.5</td><td style="text-align:right">6</td></tr><tr><td style="text-align:left">Gemini 3 Pro</td><td style="text-align:right">6</td></tr><tr><td style="text-align:left">Gemini 3.1 Pro</td><td style="text-align:right">6</td></tr><tr><td style="text-align:left">GPT-5.3-Codex</td><td style="text-align:right">6</td></tr><tr><td style="text-align:left">GPT-5.4</td><td style="text-align:right">6</td></tr><tr><td style="text-align:left">GPT-5.4 mini</td><td style="text-align:right">6</td></tr><tr><td style="text-align:left">GPT-5.1</td><td style="text-align:right">3</td></tr><tr><td style="text-align:left">GPT-5.1-Codex</td><td style="text-align:right">3</td></tr><tr><td style="text-align:left">GPT-5.1-Codex-Max</td><td style="text-align:right">3</td></tr><tr><td style="text-align:left">Claude Haiku 4.5</td><td style="text-align:right">0.33</td></tr><tr><td style="text-align:left">GPT-4o</td><td style="text-align:right">0.33</td></tr><tr><td style="text-align:left">GPT-4o mini</td><td style="text-align:right">0.33</td></tr><tr><td style="text-align:left">GPT-5.1-Codex-Mini</td><td style="text-align:right">0.33</td></tr><tr><td style="text-align:left">GPT-5 mini</td><td style="text-align:right">0.33</td></tr><tr><td style="text-align:left">Raptor mini</td><td style="text-align:right">0.33</td></tr><tr><td style="text-align:left">MAI-Code-1-Flash</td><td style="text-align:right">0.33</td></tr><tr><td style="text-align:left">MAI-Code-1.1-Flash</td><td style="text-align:right">0.25</td></tr></tbody></table>
<p>That’s sorted highest to lowest for readability; GitHub’s own table runs alphabetically. Every value above is copied directly from <a href="https://docs.github.com/en/copilot/reference/copilot-billing/request-based-billing-legacy/model-multipliers-for-annual-plans">GitHub’s model-multipliers-for-annual-plans documentation</a>, a page GitHub itself warns can move without notice. A separate table on the same page sets Copilot code review at a flat 13x multiplier for every pull request or IDE review it runs, regardless of which chat model is configured elsewhere. Turning on auto model selection anywhere it’s offered, chat, the CLI, the mobile app, or a cloud agent run, knocks 10% off whatever multiplier applies, so a 1x model bills at 0.9x instead.</p>
<div class="callout" role="note" data-astro-cid-q2ml7llr><p class="callout__title" data-astro-cid-q2ml7llr><span aria-hidden="true" data-astro-cid-q2ml7llr>ℹ</span>No historical baseline exists to compare against</p><div class="callout__body" data-astro-cid-q2ml7llr><p>GitHub’s documentation doesn’t publish what this table looked like before June
1, 2026, and no earlier snapshot of this exact page exists in the Wayback
Machine either. The table above is verified as current, not as a change from a
specific prior value GitHub itself confirms.</p></div></div>
<h2 id="structural-comparison-matrix">Structural Comparison Matrix</h2>



































<table tabindex="0"><thead><tr><th scope="col" style="text-align:left">Operational Aspect</th><th scope="col" style="text-align:left">Annual Pro/Pro+ (legacy, today)</th><th scope="col" style="text-align:left">Monthly Pro/Pro+ (AI Credits, today)</th></tr></thead><tbody><tr><td style="text-align:left"><strong>Highest-cost model</strong></td><td style="text-align:left">GPT-5.5 at a flat 57x multiplier, confirmed on GitHub’s current table</td><td style="text-align:left">GPT-5.5 priced per token; cost scales with actual prompt and response length</td></tr><tr><td style="text-align:left"><strong>Base monthly pool</strong></td><td style="text-align:left">Pro: 300 premium requests/month. Pro+: 1,500 premium requests/month</td><td style="text-align:left">Pro: 1,500 credits/month. Pro+: 7,000 credits/month</td></tr><tr><td style="text-align:left"><strong>New model or feature access</strong></td><td style="text-align:left">Frozen. GitHub’s own docs say this group is cut off from future model and feature additions (quoted in full below)</td><td style="text-align:left">Full access as GitHub ships new models</td></tr><tr><td style="text-align:left"><strong>Running out mid-cycle</strong></td><td style="text-align:left">Falls back to included models, possibly rate-limited, or buy more at $0.04/request</td><td style="text-align:left">Premium features halt, or metered billing continues if an admin enabled it</td></tr><tr><td style="text-align:left"><strong>Path forward</strong></td><td style="text-align:left">Stay to term end (then auto-downgrade to Free), cancel for a prorated refund, or upgrade for prorated credit</td><td style="text-align:left">Already on the destination system; nothing left to migrate to</td></tr></tbody></table>
<p>Every figure in that table traces to GitHub’s own current documentation, cited by section below.</p>
<h2 id="the-27x-figure-most-people-cite-isnt-the-tables-ceiling">The 27x figure most people cite isn’t the table’s ceiling</h2>
<p>Third-party coverage of this multiplier table, published as the June 1 changes were rolling out, described some models’ multipliers as rising “as much as 27x.” That figure isn’t wrong. Claude Opus 4.6, 4.7, and 4.8 really do sit at 27x on GitHub’s live table today, and that’s a real, heavy number for anyone whose workflow leans on Opus-class reasoning.</p>
<p>It’s also not the whole picture. GPT-5.5 carries a 57x multiplier on that same live table, more than double the number that’s been repeated across recap posts and community threads. Whether GPT-5.5 already had that multiplier when the 27x figure first circulated, or was added to the catalog afterward, isn’t something GitHub’s documentation records and isn’t something this post can verify against a primary source. What’s verifiable, directly, right now, is that 27x understates the current ceiling. A subscriber budgeting against “worst case, 27x” is actually budgeting against a number more than half of what the table can charge.</p>
<p>The specific claim that Claude Opus rose from a lower baseline to 27x traces to third-party technical coverage, not a GitHub announcement or changelog entry. <a href="https://docs.github.com/en/copilot/reference/copilot-billing/request-based-billing-legacy/what-changed-with-billing">GitHub’s own “What changed with Copilot billing” page</a> describes the June 1 shift from PRU to token-based billing in general terms and doesn’t publish a before/after multiplier comparison anywhere. Read the specific “before” number as reported, not confirmed, and treat the current 57x and 27x figures above as the only parts of this story verified straight from GitHub’s own page.</p>
<h2 id="what-a-27x-multiplier-costs-against-a-1500-request-pool">What a 27x multiplier costs against a 1,500-request pool</h2>
<p>The multiplier only means something once it’s run against an actual monthly allowance. <a href="https://docs.github.com/en/copilot/reference/copilot-billing/request-based-billing-legacy/copilot-requests">GitHub’s own plan documentation</a> sets that allowance at 300 premium requests a month for an annual Copilot Pro plan, and 1,500 for annual Pro+.</p>
<p>Run the current table’s numbers against those pools directly:</p>
<ol>
<li>Pro+ (1,500 requests) against Claude Opus 4.7 at 27x: 1,500 / 27 = 55 calls before the pool empties.</li>
<li>Pro+ (1,500 requests) against GPT-5.5 at 57x: 1,500 / 57 = 26 calls, roughly half as many.</li>
<li>Pro (300 requests) against Claude Opus 4.7 at 27x: 300 / 27 = 11 calls for the entire month.</li>
<li>Pro (300 requests) against GPT-5.5 at 57x: 300 / 57 = 5 calls for the entire month.</li>
</ol>
<p>Compare that against a lightweight model on the same plan. Claude Haiku 4.5 sits at 0.33x, so a Pro+ subscriber gets roughly 4,545 Haiku calls out of the same 1,500-request pool, a gap of two orders of magnitude between the cheapest and most expensive model on one table. Model choice was already a real lever under PRU. At today’s revised multipliers, it’s a much sharper one: reaching for Opus or GPT-5.5 by default on an annual plan burns through a month’s allowance in single digits to low double digits of calls, not the hundreds a lighter model affords.</p>
<h2 id="legacy-also-means-frozen-out-of-new-models">Legacy also means frozen out of new models</h2>
<p>The multiplier increase isn’t the only structural cost of staying on an annual plan past June 1. GitHub’s own documentation says so directly, on the same page as the multiplier table, not buried in a separate FAQ:</p>
<blockquote>
<p>Users on legacy annual Copilot plans will not receive access to new models and features.</p>
</blockquote>
<p>That’s a second, quieter tax on top of the pricing one. A monthly subscriber gets each new model as GitHub ships it, priced under Credits from day one. An annual subscriber on the legacy track is working from whatever model catalog existed when they last checked, with no guarantee a future model gets added to their table at all. Whether GPT-5.5 shows up as row 57x or doesn’t show up for a given annual account isn’t something this post can verify beyond what GitHub’s own table lists today.</p>
<h2 id="what-annual-subscribers-can-actually-do-about-it">What annual subscribers can actually do about it</h2>
<p>GitHub lays out three explicit options for a Copilot Pro or Pro+ subscriber on an existing annual plan, described directly on its own “What changed with Copilot billing” page:</p>
<ul>
<li><strong>Stay</strong> on the current annual plan under the legacy multiplier system. When the plan ends, the account is automatically downgraded to Copilot Free, not moved to AI Credits.</li>
<li><strong>Cancel</strong> the plan outright for a prorated refund, with the option to re-subscribe to the equivalent monthly plan afterward.</li>
<li><strong>Upgrade</strong> to a monthly paid plan now and receive prorated credit for the annual plan’s remaining value, landing on AI Credits immediately instead of waiting for the term to end.</li>
</ul>
<p>None of those is obviously correct for every subscriber. Someone with months left on an annual term and a workflow that leans on Haiku-tier models at 0.33x isn’t facing the same math as someone burning Opus or GPT-5.5 calls regularly against a 300-request Pro pool. The multiplier table above is what makes that comparison concrete instead of hypothetical: run your own actual model mix against it before deciding whether to ride out the renewal or upgrade early.</p>
<div class="callout" role="note" data-astro-cid-q2ml7llr><p class="callout__title" data-astro-cid-q2ml7llr><span aria-hidden="true" data-astro-cid-q2ml7llr>⚠</span>Doing nothing has a default outcome too</p><div class="callout__body" data-astro-cid-q2ml7llr><p>Staying on an annual plan isn’t a neutral holding pattern. If it lapses
without action, GitHub downgrades the account to Copilot Free, not to AI
Credits. Reaching the renewal date and deciding then is a real choice with a
real default, not a delay with no consequence.</p></div></div>
<h2 id="before-your-annual-plan-renews">Before your annual plan renews</h2>
<p>Check which billing system an account actually runs on before assuming the September 1 credits reversion or the June 1 Credits migration applies to it. Neither one touches an annual Pro or Pro+ subscriber still inside their term; this multiplier table is the only mechanism actively affecting that group right now, and it’s the one most likely to go unnoticed because nothing about it required an email or a dashboard banner to take effect.</p>
<p>If an annual plan still has months left and the workflow on it leans on GPT-5.5 or Claude Opus regularly, run the math from this post against the actual monthly call count before the next renewal decision. Upgrading early trades the annual plan’s remaining value for prorated credit toward a monthly plan, landing on per-token Credits pricing right away instead of waiting for the term to end. Riding out the term only makes sense if the real usage pattern fits comfortably inside 300 or 1,500 premium requests at the multipliers that model mix actually costs today, not the multipliers it cost before this table was last revised.</p>
<p>Browse more coverage like this in the <a href="/ai-productivity">AI Productivity</a> archive, or start from <a href="/ai-productivity/github-copilot-ai-credits-cliff-2026/">GitHub Copilot’s AI Credits Cliff</a> hub for the full reversion story.</p>]]></content:encoded>
      <pubDate>Wed, 19 Aug 2026 00:00:00 GMT</pubDate>
    </item>
    <item>
      <title>Check Your Copilot AI Credits Usage Before Sept 1</title>
      <link>https://bytetech247.com/ai-productivity/check-copilot-ai-credits-usage-before-sept-1/</link>
      <guid isPermaLink="true">https://bytetech247.com/ai-productivity/check-copilot-ai-credits-usage-before-sept-1/</guid>
      <description>See GitHub Copilot AI Credits usage per cycle, per user via API, and org-wide before the Sept 1, 2026 allowance cut hits your bill.</description>
      <content:encoded><![CDATA[<aside class="quick-answer" aria-label="Quick answer"><h2 id="quick-answer">Quick Answer</h2>
<p>GitHub added three ways to see AI Credits consumption in mid-2026: a per-user <code>ai_credits_used</code> field in the usage metrics API (2026-06-19), an individual per-cycle view in Copilot settings (2026-07-20), and an org- and enterprise-level usage dashboard (2026-07-22). Check all three before September 1, 2026, when promotional Business and Enterprise credit allowances revert to standard levels.</p>
</aside><h2 id="three-blind-spots-github-just-closed">Three blind spots GitHub just closed</h2>
<p>Before mid-2026, nobody using GitHub Copilot could see their real AI Credits consumption anywhere in the product. Not the seat holder burning through them, not the org owner managing the budget, not the enterprise admin rolling it up across teams. The number existed somewhere on GitHub’s billing backend, but the product gave you no live view of it.</p>
<p>That changed fast, in three separate shipments over about five weeks. GitHub added a per-user <code>ai_credits_used</code> field to the Copilot usage metrics API on 2026-06-19, an individual per-cycle usage view in Copilot settings on 2026-07-20, and an org- and enterprise-level usage dashboard on 2026-07-22, according to <a href="https://github.blog/changelog/2026-07-20-copilot-users-can-now-see-ai-credits-used-per-billing-cycle/">GitHub’s own changelog</a>. None of these are budget controls. They are visibility, the thing you need before a budget control is even worth configuring.</p>
<p>That distinction matters for this specific series. GitHub’s promotional AI Credits allowance (3,000 credits per Business seat, 7,000 per Enterprise seat) reverts to the standard allowance (1,900 and 3,900) on September 1, 2026, per <a href="https://docs.github.com/en/copilot/concepts/billing/usage-based-billing-for-organizations-and-enterprises">GitHub’s own billing docs</a>. The <a href="/ai-productivity/github-copilot-ai-credits-cliff-2026/">pillar hub on that cliff</a> covers the reversion itself. This post covers the narrower, more immediate question: how do you actually check where you stand right now, at each of the three levels GitHub just made visible.</p>
<h2 id="see-your-own-number-copilot-settings-usage">See your own number: Copilot settings, Usage</h2>
<p>If you hold a Copilot Business or Copilot Enterprise seat, your own consumption is two clicks away. Click your profile picture in the upper-right corner of GitHub, select <strong>Copilot settings</strong>, and look under <strong>Usage</strong>. The section is labeled <strong>“Usage this cycle”</strong>, confirmed directly on <a href="https://docs.github.com/en/enterprise-cloud@latest/copilot/how-tos/manage-and-track-spending/monitor-ai-usage">GitHub’s own AI Credits monitoring docs</a>, and it shows how many AI credits you have used in the current billing period.</p>
<p>This view needs no admin role. It is your own consumption, gated only by holding a Business or Enterprise seat, not by being an org owner or billing manager. If you have never opened it, that is the two-minute check to run before reading the rest of this post.</p>
<div class="callout" role="note" data-astro-cid-q2ml7llr><p class="callout__title" data-astro-cid-q2ml7llr><span aria-hidden="true" data-astro-cid-q2ml7llr>ℹ</span>Copilot Individual uses a different page entirely</p><div class="callout__body" data-astro-cid-q2ml7llr><p>This “Usage this cycle” heading is specific to Business and Enterprise seats.
Copilot Individual subscribers see a separate <strong>AI usage</strong> page under
<code>github.com/settings/billing</code> instead, tied to that plan’s own pay-as-you-go
credits rather than the seat allowance this series covers.</p></div></div>
<h2 id="pull-ai_credits_used-straight-from-the-api">Pull ai_credits_used straight from the API</h2>
<p>The individual view answers “what did I use.” It does not answer “who on my team is closest to the cliff.” That is what the API field is for.</p>
<p>GitHub’s Copilot usage metrics API added an <code>ai_credits_used</code> field to its per-user reports on 2026-06-19. The per-user report family (<code>*-users-1-day</code> and <code>*-users-28-day</code> in GitHub’s own reference docs) returns one record per user, and <code>ai_credits_used</code> is defined there as the total AI credits a user consumed, one combined number across all of that user’s Copilot activity, without a separate breakdown by which model, feature, or surface generated it. The same reference page is explicit that this is a metrics signal for analyzing consumption, not a billed total, worth remembering before you treat it as an invoice line item.</p>
<p>Each per-user record carries more than the credit total. It also includes <code>user_id</code>, <code>user_login</code>, eight boolean <code>used_*</code> flags covering surfaces like chat, CLI, agent mode, and code review, and an <code>ai_adoption_phase</code> field. That last one is worth a closer look, since it is what powers the org-level dashboard covered next.</p>
<div class="code-block code-block--has-filename"><div class="code-block__header"><span class="code-block__filename">Per-user report record (illustrative, matches the documented schema)</span><button type="button" class="code-block__copy" data-copy-button>Copy</button></div><pre class="astro-code github-dark" style="background-color:#24292e;color:#e1e4e8;overflow-x:auto" tabindex="0" data-language="json" data-filename="Per-user report record (illustrative, matches the documented schema)"><code><span class="line"><span style="color:#E1E4E8">{</span></span>
<span class="line"><span style="color:#79B8FF">  &quot;user_id&quot;</span><span style="color:#E1E4E8">: </span><span style="color:#79B8FF">4181923</span><span style="color:#E1E4E8">,</span></span>
<span class="line"><span style="color:#79B8FF">  &quot;user_login&quot;</span><span style="color:#E1E4E8">: </span><span style="color:#9ECBFF">&quot;octocat&quot;</span><span style="color:#E1E4E8">,</span></span>
<span class="line"><span style="color:#79B8FF">  &quot;ai_credits_used&quot;</span><span style="color:#E1E4E8">: </span><span style="color:#79B8FF">742</span><span style="color:#E1E4E8">,</span></span>
<span class="line"><span style="color:#79B8FF">  &quot;used_chat&quot;</span><span style="color:#E1E4E8">: </span><span style="color:#79B8FF">true</span><span style="color:#E1E4E8">,</span></span>
<span class="line"><span style="color:#79B8FF">  &quot;used_cli&quot;</span><span style="color:#E1E4E8">: </span><span style="color:#79B8FF">false</span><span style="color:#E1E4E8">,</span></span>
<span class="line"><span style="color:#79B8FF">  &quot;used_agent&quot;</span><span style="color:#E1E4E8">: </span><span style="color:#79B8FF">true</span><span style="color:#E1E4E8">,</span></span>
<span class="line"><span style="color:#79B8FF">  &quot;used_copilot_coding_agent&quot;</span><span style="color:#E1E4E8">: </span><span style="color:#79B8FF">false</span><span style="color:#E1E4E8">,</span></span>
<span class="line"><span style="color:#79B8FF">  &quot;used_copilot_code_review_active&quot;</span><span style="color:#E1E4E8">: </span><span style="color:#79B8FF">false</span><span style="color:#E1E4E8">,</span></span>
<span class="line"><span style="color:#79B8FF">  &quot;ai_adoption_phase&quot;</span><span style="color:#E1E4E8">: </span><span style="color:#79B8FF">1</span></span>
<span class="line"><span style="color:#E1E4E8">}</span></span></code></pre></div>
<p>The field names and their descriptions above are verified against <a href="https://docs.github.com/en/copilot/reference/copilot-usage-metrics/copilot-usage-metrics">GitHub’s own Copilot usage metrics reference</a>. The specific values are illustrative, not a captured live response, since pulling a real one requires an org’s own API token. Per GitHub’s own docs, this endpoint is available to enterprise administrators and organization owners with access to Copilot usage metrics, not to an individual seat holder pulling their own record.</p>
<h2 id="structural-comparison-matrix">Structural Comparison Matrix</h2>








































<table tabindex="0"><thead><tr><th scope="col" style="text-align:left">Visibility level</th><th scope="col" style="text-align:left">Where to find it</th><th scope="col" style="text-align:left">What it shows</th><th scope="col" style="text-align:left">Access required</th><th scope="col" style="text-align:left">Shipped</th></tr></thead><tbody><tr><td style="text-align:left"><strong>Individual (self)</strong></td><td style="text-align:left">Copilot settings, Usage, “Usage this cycle”</td><td style="text-align:left">Your own AI Credits used in the current billing cycle</td><td style="text-align:left">Any Copilot Business or Enterprise seat holder, own account only</td><td style="text-align:left">2026-07-20</td></tr><tr><td style="text-align:left"><strong>Per-user (API)</strong></td><td style="text-align:left">Copilot usage metrics API, per-user reports</td><td style="text-align:left"><code>ai_credits_used</code> total per user, plus <code>used_*</code> surface flags and <code>ai_adoption_phase</code></td><td style="text-align:left">Org owners and enterprise admins with API access</td><td style="text-align:left">2026-06-19</td></tr><tr><td style="text-align:left"><strong>Organization</strong></td><td style="text-align:left">Org’s Insights tab, Copilot usage</td><td style="text-align:left">Adoption-phase cohorts and output metrics tied to credit spend, org-wide</td><td style="text-align:left">Organization owner or billing manager</td><td style="text-align:left">2026-07-22</td></tr><tr><td style="text-align:left"><strong>Enterprise</strong></td><td style="text-align:left">Enterprise account’s Insights tab, Copilot usage</td><td style="text-align:left">The same dashboard, rolled up across every organization in the enterprise</td><td style="text-align:left">Enterprise owner, billing manager, or a custom role with View Enterprise Copilot Metrics</td><td style="text-align:left">2026-07-22</td></tr></tbody></table>
<p>The dates are not decorative. All three views shipped inside a five-week window that ends five weeks before the September 1 allowance cut, which is a narrow amount of runway to notice a problem and act on it if nobody checks until the bill changes.</p>
<h2 id="the-dashboard-ties-credits-to-outcomes-not-just-spend">The dashboard ties credits to outcomes, not just spend</h2>
<p>The 2026-07-22 dashboard is not a bigger version of the per-cycle number. It groups engaged users into four adoption phases, built from the same <code>ai_adoption_phase</code> field the API returns: Phase 0, no cohort, for users who did not meet the engagement bar for any phase; Phase 1, code first, for users who engaged with code completion or IDE agent mode; Phase 2, agent first, for users who touched exactly one of Copilot’s agent surfaces, its cloud agent, code review, or CLI; and Phase 3, multi-agent, for users who touched two or more of those same surfaces, or used the GitHub Copilot app, according to <a href="https://github.blog/changelog/2026-05-29-copilot-usage-metrics-api-adds-cohorts-for-ai-adoption/">GitHub’s own changelog on the adoption-phase cohorts</a>.</p>
<p>For each cohort, the dashboard surfaces average pull requests merged per month, median merge velocity, user counts, and lines of code produced, alongside a six-month trend and an adoption multiplier comparing passive users against engaged ones. That framing changes the conversation an org owner can have about credits. A team burning through its allowance in Phase 3 is a different problem than one burning through it in Phase 0, even if the raw <code>ai_credits_used</code> totals look identical.</p>
<div class="callout" role="note" data-astro-cid-q2ml7llr><p class="callout__title" data-astro-cid-q2ml7llr><span aria-hidden="true" data-astro-cid-q2ml7llr>✓</span>Pull the NDJSON export if you need to slice the data further</p><div class="callout__body" data-astro-cid-q2ml7llr><p>The dashboard supports exporting the underlying data as NDJSON, which GitHub’s
own docs suggest feeding back into Copilot Chat to answer questions like which
users have high interaction counts but low code-acceptance rates. That is a
faster way to find outliers than scrolling the dashboard’s own charts.</p></div></div>
<h2 id="check-this-before-september-1-not-after">Check this before September 1, not after</h2>
<p>Three checks, three different owners. If you hold a seat, open Copilot settings and look at “Usage this cycle” today, not the week the allowance drops. Org owners should pull the dashboard from the Insights tab and see which adoption phase their heaviest users sit in before deciding where to draw a budget line. Enterprise admins need the same check at the enterprise level, since a spike hiding in one org’s numbers can stay invisible until everything gets rolled up.</p>
<p>None of the three views in this post set a limit or send an alert. They only show you the number. Once you know where you actually stand, <a href="/ai-productivity/github-copilot-budget-controls-setup/">setting Copilot budget controls before the cliff</a> is the next step, the one that turns this visibility into an actual guardrail instead of a number you have to remember to keep checking.</p>
<p>Browse more coverage like this in the <a href="/ai-productivity">AI Productivity</a> archive, or start from <a href="/ai-productivity/github-copilot-ai-credits-cliff-2026/">GitHub Copilot’s AI Credits Promo Cliff</a> hub for the reversion itself.</p>]]></content:encoded>
      <pubDate>Wed, 19 Aug 2026 00:00:00 GMT</pubDate>
    </item>
    <item>
      <title>Why Copilot Agent Mode Burns Through Credits So Fast</title>
      <link>https://bytetech247.com/ai-productivity/copilot-agent-mode-credits-consumption/</link>
      <guid isPermaLink="true">https://bytetech247.com/ai-productivity/copilot-agent-mode-credits-consumption/</guid>
      <description>GitHub Copilot&apos;s Agent Mode spends AI credits per tool-calling step, not per message. Here&apos;s the actual mechanism, with real token math and a sourced case.</description>
      <content:encoded><![CDATA[<aside class="quick-answer" aria-label="Quick answer"><h2 id="quick-answer">Quick Answer</h2>
<p>Agent Mode spends AI credits once per step in its read-edit-test-reread loop, not once per chat message. Each step resends the growing conversation history and every enabled tool’s full JSON schema, so a modest six-step bug fix already costs roughly 12 times a single chat question on the same model. Route routine steps to a cheaper model to control it.</p>
</aside><h2 id="why-a-chat-message-and-an-agent-step-arent-the-same-unit-of-spend">Why a chat message and an agent step aren’t the same unit of spend</h2>
<p>Copilot Chat’s per-token pricing is straightforward once you’ve seen it: the sibling piece in this series on <a href="/ai-productivity/github-copilot-ai-credits-pricing-explained/">how AI Credits are priced</a> walks through the exact math, one prompt in, one response out, each priced per token at that model’s published rate. A single chat question against a lightweight model rarely reaches even a full credit.</p>
<p>Agent Mode reuses that same per-token pricing. The multiplier isn’t a different rate. It’s a different number of billed round trips. GitHub’s own <a href="https://github.blog/ai-and-ml/github-copilot/agent-mode-101-all-about-github-copilots-powerful-mode/">Agent Mode 101</a> post describes it as an agentic loop that plans, edits, runs commands, checks the result, and course-corrects, naming three of its underlying tools by their function names: <code>run_in_terminal</code> executes a command, <code>edit_file</code> applies a change, <code>read_file</code> pulls a file’s contents into context. Each of those tool calls, and each result that comes back from one, is its own request to the model. A per-message mental model of cost, the one that made sense for Chat, quietly stops describing what you’re actually being billed for the moment Agent Mode starts looping.</p>
<h2 id="what-actually-happens-inside-one-agent-mode-step">What actually happens inside one Agent Mode step</h2>
<p>Each step costs more than a chat message not only because there are more of them, but because of what gets resent on every single one. The VS Code team, who ship Copilot’s agent mode inside the editor, explained part of the mechanism directly in a June 2026 post on <a href="https://code.visualstudio.com/blogs/2026/06/17/improving-token-efficiency-in-github-copilot">token efficiency</a>:</p>
<blockquote>
<p>Each tool is sent to the model with a full definition (a name, a description, and a complete JSON parameter schema), and historically every one was loaded into context on every request.</p>
</blockquote>
<p>The same post describes the loop itself just as plainly:</p>
<blockquote>
<p>An agentic coding turn can involve many sequential requests to the inference provider; one for each step the model takes as it calls tools and works towards a solution.</p>
</blockquote>
<p>Put those two observations together and the mechanism is straightforward. Enable a handful of tools, and their full schemas ride along on every step, whether that step needs them or not. Run a test and get back a stack trace, and that stack trace becomes part of the context every step after it has to resend. A six-step bug fix doesn’t send six independent, similarly-sized requests. It sends six requests that each carry more than the one before it.</p>
<h2 id="structural-comparison-matrix">Structural Comparison Matrix</h2>



































<table tabindex="0"><thead><tr><th scope="col" style="text-align:left">Operational Aspect</th><th scope="col" style="text-align:left">Single-turn Chat</th><th scope="col" style="text-align:left">Agent Mode session</th></tr></thead><tbody><tr><td style="text-align:left"><strong>Model calls per request</strong></td><td style="text-align:left">One</td><td style="text-align:left">One per loop step (read, edit, test, re-check), until the task finishes</td></tr><tr><td style="text-align:left"><strong>What’s resent each call</strong></td><td style="text-align:left">This turn’s prompt and immediate context only</td><td style="text-align:left">Full conversation history plus every enabled tool’s complete JSON schema</td></tr><tr><td style="text-align:left"><strong>What grows the context</strong></td><td style="text-align:left">Nothing, each turn starts fresh</td><td style="text-align:left">Tool outputs (file contents, test logs, terminal output) get appended back in for the next step</td></tr><tr><td style="text-align:left"><strong>Cost driver</strong></td><td style="text-align:left">Prompt length times one response, at the model’s per-token rate</td><td style="text-align:left">Step count times context growth per step times the model’s per-token rate</td></tr><tr><td style="text-align:left"><strong>Real mitigation available</strong></td><td style="text-align:left">Rarely needed at this scale</td><td style="text-align:left">Cached input, priced about a tenth of fresh input on models that support it, and tool search, which cut total session tokens for the median user</td></tr></tbody></table>
<div class="callout" role="note" data-astro-cid-q2ml7llr><p class="callout__title" data-astro-cid-q2ml7llr><span aria-hidden="true" data-astro-cid-q2ml7llr>ℹ</span>The cache and tool-search numbers are Microsoft&#39;s own measurement</p><div class="callout__body" data-astro-cid-q2ml7llr><p>The VS Code team’s token-efficiency post found that for most OpenAI models
with cached-input pricing, a token served from cache runs about a tenth the
cost of that same token sent fresh. GitHub’s own Claude Opus 4.8 pricing shows
the same roughly-10x gap, independently confirmed by the sibling piece on
<a href="/ai-productivity/github-copilot-ai-credits-pricing-explained/">credits
pricing</a> in
this series. On tool search specifically, that same post reports total session
tokens for the median Copilot user fell 8.97% on GPT-5.4 and 10.92% on
GPT-5.5, and roughly 18% on Anthropic models, by not loading every enabled
tool’s schema into every single step.</p></div></div>
<h2 id="a-real-case-a-months-credits-gone-in-a-day">A real case: a month’s credits gone in a day</h2>
<p>The clearest evidence for this mechanism isn’t a marketing multiplier. It’s <a href="https://github.com/orgs/community/discussions/197557">GitHub Community discussion #197557</a>, opened June 1, 2026, the same day GitHub’s AI Credits system went live. A Copilot Student-plan user reported their entire monthly allowance, 200 credits ($2.00), gone after what they described as around 10 to 20 Agent Mode requests, on the first day of a fresh billing cycle. That 200-credit figure comes from the reporting user’s own dashboard, not from a GitHub-published Student-plan table, so treat the allowance size as one user’s reported number rather than an official spec.</p>
<p>The poster’s own usage breakdown names three models against a credit total for each: 145.01 credits against GPT-5.4 mini, 35.98 against GPT-5 mini, and 18.93 against Claude Haiku 4.5, adding up to the full 200. Other users replied inside days of the same cutover with the same pattern: one reported 180 of 200 monthly credits gone within two to three days, another reported an entire allowance spent on roughly 10 to 15 agent requests against 4 to 5 simple chat messages that barely moved the number by comparison. That’s not one isolated complaint. It’s the same read-edit-test-reread loop, run by different people on different tasks, converging on the same outcome: Agent Mode requests cost meaningfully more than chat messages, consistently, not occasionally.</p>
<h2 id="why-the-multiplier-compounds-the-arithmetic-on-one-bug-fix">Why the multiplier compounds: the arithmetic on one bug fix</h2>
<p>Real sessions vary too much to reduce to one universal number, so here’s a deliberately simple version of the mechanism, built from GitHub’s own published per-token rates rather than a captured trace. A single chat question, roughly 3,000 tokens of prompt and file context in, 400 tokens of answer out, against GPT-5 mini’s Lightweight-tier rate of $0.25 input and $2.00 output per million tokens, costs $0.00075 plus $0.0008, about $0.00155, or roughly 0.16 credits.</p>
<p>Now walk the same model through a six-step Agent Mode loop fixing one bug: read the failing file, propose an edit, run the tests, read back a failing result, revise, run the tests again. Because each step resends the conversation so far, plausible input token counts climb from around 4,000 on step one to around 14,500 by step six, while each step’s own output stays modest, a few hundred tokens for a tool call or a short summary. Total that across all six steps, 57,000 input tokens and 2,200 output tokens, at GPT-5 mini’s same rate, and the session costs about $0.0187, or roughly 1.87 credits, about 12 times the single chat question on the identical model.</p>
<p>Run the same six-step shape against a frontier model instead, Claude Opus 4.8’s Powerful-tier rate of $5.00 input and $25.00 output per million tokens (the same rate the pricing sibling in this series verifies directly), and the session costs 34 credits against a 2.5-credit chat question on that model, a comparable 12 to 14 times. Step count and growing context drive the multiplier. Model choice multiplies the dollar amount on top of that, but it isn’t what causes the multiplier in the first place.</p>
<div class="callout" role="note" data-astro-cid-q2ml7llr><p class="callout__title" data-astro-cid-q2ml7llr><span aria-hidden="true" data-astro-cid-q2ml7llr>⚠</span>This example is arithmetic, not a captured session</p><div class="callout__body" data-astro-cid-q2ml7llr><p>The token counts above are a labeled illustration built from GitHub’s real
published per-token rates, not a logged trace from an actual Copilot session.
GitHub doesn’t expose a per-step token breakdown for a specific session
anywhere in the product, only cycle and per-user totals, covered in this
series’ piece on <a href="/ai-productivity/check-copilot-ai-credits-usage-before-sept-1/">checking your own
usage</a>. Treat
the mechanism here as verified and the exact numbers as illustrative.</p></div></div>
<h2 id="where-the-roughly-1000x-figure-actually-comes-from">Where the “roughly 1,000x” figure actually comes from</h2>
<p>A number bigger than 12x shows up everywhere this topic gets covered: “GitHub’s own research found agentic tasks use roughly 1,000x more tokens than single-turn queries.” It’s a real sentence, written by a secondary aggregator, fireup.pro, syndicated through daily.dev, published July 14, 2026, and it attributes the figure to GitHub without linking to a GitHub blog post, changelog entry, or dataset that actually states it.</p>
<p>Checking that claim directly against GitHub’s own writing on the subject doesn’t confirm it. Neither GitHub’s Agent Mode 101 post nor the VS Code team’s token-efficiency piece, the two most relevant GitHub-authored sources on exactly this question, states a 1,000x figure or any single comparable multiplier. That doesn’t make the underlying pattern false: the arithmetic above and the real 197557 thread both point the same direction, agent sessions cost meaningfully more than chat messages. It does mean this post won’t repeat “1,000x” as a confirmed GitHub number, because the trail to confirm it runs out at a secondary source repeating an unlinked claim, not at GitHub itself.</p>
<p>The same secondary coverage states real bills jumped from $29 to $750 and from $50 to $3,000 a month, again without naming the developer, the thread, or the invoice behind either figure. This post treats those two numbers the same way: unconfirmed against a named source, worth noting as a widely repeated claim, not worth repeating as fact. The 197557 thread above is the concrete, checkable version of the same pattern, a real account, a real dashboard, and a credit total that adds up.</p>
<h2 id="what-actually-reduces-agent-modes-credit-burn">What actually reduces Agent Mode’s credit burn</h2>
<p>None of this is an argument against Agent Mode. It’s an argument for spending its steps deliberately, and three levers are real, not hypothetical.</p>
<p>Route routine steps to a lighter model. The step count and context growth happen regardless of which model answers each step, but a Lightweight-tier model’s per-token rate is a fraction of a Powerful-tier one, and the arithmetic above shows that gap compounds across every step in the loop, not just one.</p>
<p>Work in checkpoints instead of one open-ended prompt. This site’s own <a href="/ai-productivity/prompting-guide-ai-coding-assistants/">prompting guide for AI coding assistants</a> covers asking for a short plan before a multi-file change starts. The same habit caps how many exploratory read-edit-test cycles Agent Mode runs before you’ve confirmed it’s headed the right way, which caps the step count the arithmetic above multiplies against.</p>
<p>Let tool search and prompt caching do the rest. Neither requires a different prompt from you. They’re GitHub and the VS Code team optimizing what gets sent under the hood, cutting total session tokens for the median user by the percentages cited above, not a setting you configure by hand.</p>
<p>Agent Mode’s credit cost was never really about which model answers your question. It’s about how many times that model has to check its own work before the task is done. Watch the step count on a task before you watch the model name, cap exploratory loops with a plan first, and let the cheaper-model, cache-friendly defaults handle the rest. For the full reversion story this cluster sits inside, see the <a href="/ai-productivity/github-copilot-ai-credits-cliff-2026/">AI Credits cliff hub</a>, and browse more coverage in the <a href="/ai-productivity">AI Productivity</a> archive.</p>]]></content:encoded>
      <pubDate>Wed, 19 Aug 2026 00:00:00 GMT</pubDate>
    </item>
    <item>
      <title>gh agent-task: Run Copilot Coding Sessions From gh</title>
      <link>https://bytetech247.com/dev-tools/gh-agent-task-copilot-coding-sessions/</link>
      <guid isPermaLink="true">https://bytetech247.com/dev-tools/gh-agent-task-copilot-coding-sessions/</guid>
      <description>gh agent-task creates, follows, and lists GitHub Copilot coding-agent sessions from the terminal. Still preview, requires GitHub CLI v2.80.0+.</description>
      <content:encoded><![CDATA[<aside class="quick-answer" aria-label="Quick answer"><h2 id="quick-answer">Quick Answer</h2>
<p>Use <code>gh agent-task create &quot;&lt;description&gt;&quot;</code> to start a GitHub Copilot coding-agent session from your terminal instead of opening a browser tab; add <code>--follow</code> to watch it work live. Use <code>gh agent-task view &lt;id&gt;</code> to check progress later. It requires GitHub CLI v2.80.0+, an OAuth-authenticated login (not a PAT), and stays labeled preview.</p>
</aside><h2 id="what-gh-agent-task-actually-does">What gh agent-task actually does</h2>
<p><code>gh agent-task</code> is a command group, not a single command: <code>create</code>, <code>view</code>, and <code>list</code>, plus the aliases <code>gh agent</code>, <code>gh agents</code>, and <code>gh agent-tasks</code>. All three subcommands operate on the same underlying object. GitHub’s own manual page defines it plainly:</p>
<blockquote>
<p>“a task is a GitHub issue that triggers automated code changes from natural language instructions”</p>
</blockquote>
<p>Run <code>gh agent-task create</code> with a description, and <code>gh</code> opens a GitHub issue behind the scenes, hands it to GitHub’s Copilot coding agent, an asynchronous background process that runs on GitHub’s own infrastructure, not your machine, and that agent makes the actual code changes, then opens a draft pull request for you to review. Nothing runs locally except the CLI call that kicks the session off.</p>
<p>It’s worth being precise about what’s shipping here, the same way the <a href="/dev-tools/gh-skill-manage-ai-agent-skills/"><code>gh skill</code> command</a> needed the same clarification. <code>gh agent-task</code> is part of the standard GitHub CLI, <code>cli/cli</code>, the same binary that runs <code>gh pr create</code> and <code>gh issue list</code>. It is a different product from GitHub Copilot CLI (<code>github/copilot-cli</code>), which is an interactive terminal coding assistant with its own separate release history. <code>gh agent-task</code> doesn’t run Copilot’s reasoning in your terminal at all; it delegates the work to GitHub’s servers and lets you check in on it.</p>
<h2 id="where-gh-agent-task-actually-came-from">Where gh agent-task actually came from</h2>
<p>It’s a fair question given how <code>gh</code> handles other optional functionality: did <code>gh agent-task</code> start life as a separate <code>gh extension install</code>-able package before becoming a built-in command? It didn’t. <a href="https://github.blog/changelog/2025-09-25-kick-off-and-track-copilot-coding-agent-sessions-from-the-github-cli/">GitHub’s changelog entry</a>, published 2025-09-25, introduced the <code>agent-task</code> command set as part of GitHub CLI v2.80.0 directly, no separate <code>gh extension install</code> step involved. The implementation itself confirms it: <a href="https://github.com/cli/cli/pull/11600">cli/cli pull request #11600</a>, “Introduce <code>gh agent-task</code>,” added the command under <code>pkg/cmd/agent</code> inside the main <code>cli/cli</code> repository, the same tree every other built-in <code>gh</code> command lives in. No <code>github/gh-agent-task</code> or similar standalone extension repository exists; checking for one returns a 404. GitHub’s own release notes for v2.80.0 state the status without ambiguity: “The <code>agent-task</code> commandset is in preview and is subject to change without notice.” Nearly a year later, that’s still the current state: the live manual page at <code>cli.github.com/manual/gh_agent-task</code> still labels it preview today.</p>
<h2 id="structural-comparison-matrix">Structural Comparison Matrix</h2>



































<table tabindex="0"><thead><tr><th scope="col" style="text-align:left">Operational Aspect</th><th scope="col" style="text-align:left">Before gh agent-task (GitHub.com only)</th><th scope="col" style="text-align:left">gh agent-task (v2.80.0+, preview)</th></tr></thead><tbody><tr><td style="text-align:left"><strong>Starting a task</strong></td><td style="text-align:left">Open the repo on github.com, file an issue, assign it to Copilot from the web UI</td><td style="text-align:left"><code>gh agent-task create &quot;&lt;description&gt;&quot;</code> from any terminal, no browser tab required</td></tr><tr><td style="text-align:left"><strong>Watching progress</strong></td><td style="text-align:left">Refresh the issue or PR page in a browser</td><td style="text-align:left"><code>gh agent-task view &lt;id&gt; --follow</code> streams session logs live in the terminal</td></tr><tr><td style="text-align:left"><strong>Listing recent tasks</strong></td><td style="text-align:left">Search issues and PRs assigned to Copilot by hand</td><td style="text-align:left"><code>gh agent-task list</code>, up to 30 by default, filterable with <code>--json</code>/<code>--jq</code></td></tr><tr><td style="text-align:left"><strong>Authenticating</strong></td><td style="text-align:left">Whatever session your browser already has open</td><td style="text-align:left">Requires OAuth device-flow login through <code>gh auth login</code>; a Personal Access Token or <code>GITHUB_TOKEN</code> is rejected outright</td></tr><tr><td style="text-align:left"><strong>Unattended CI use</strong></td><td style="text-align:left">Not applicable, a human drives the web UI</td><td style="text-align:left">Blocked in practice: the OAuth-only requirement means a fresh CI job has no way to authenticate without a one-time interactive device-code prompt</td></tr></tbody></table>
<h2 id="before-you-run-anything-version-and-auth">Before you run anything: version and auth</h2>
<p>Check what you’re running first:</p>
<div class="code-block"><div class="code-block__header"><span class="code-block__filename"></span><button type="button" class="code-block__copy" data-copy-button>Copy</button></div><pre class="astro-code github-dark" style="background-color:#24292e;color:#e1e4e8;overflow-x:auto" tabindex="0" data-language="bash"><code><span class="line"><span style="color:#B392F0">gh</span><span style="color:#79B8FF"> --version</span></span></code></pre></div>
<p><code>gh agent-task</code> needs GitHub CLI v2.80.0 or later. Update through whatever channel installed <code>gh</code> in the first place if you’re behind: <code>brew upgrade gh</code> on macOS, <code>apt update &amp;&amp; apt install gh</code> on Debian/Ubuntu, <code>scoop update gh</code> on Windows, or a fresh binary from <a href="https://cli.github.com/">cli.github.com</a> if you installed manually.</p>
<div class="callout" role="note" data-astro-cid-q2ml7llr><p class="callout__title" data-astro-cid-q2ml7llr><span aria-hidden="true" data-astro-cid-q2ml7llr>⚠</span>OAuth login only, PATs are rejected</p><div class="callout__body" data-astro-cid-q2ml7llr><p><code>gh agent-task create</code> will not accept a Personal Access Token or a
<code>GITHUB_TOKEN</code> env var, even though those work for most other <code>gh</code> commands.
Run <code>gh auth login</code> interactively at least once first. This is a real,
currently open limitation, tracked in <a href="https://github.com/cli/cli/issues/11845">cli/cli issue
#11845</a>, and it’s the main reason <code>gh   agent-task</code> isn’t yet a clean fit for a fresh, non-interactive CI job.</p></div></div>
<p>Try <code>gh agent-task create</code> with a PAT instead of an OAuth login and this is exactly what comes back:</p>
<blockquote>
<p>“this command requires an OAuth token. Re-authenticate with: gh auth login”</p>
</blockquote>
<h2 id="create-a-task">Create a task</h2>
<p>This is the <a href="https://cli.github.com/manual/gh_agent-task_create">documented example command</a> from GitHub’s own manual:</p>
<div class="code-block"><div class="code-block__header"><span class="code-block__filename"></span><button type="button" class="code-block__copy" data-copy-button>Copy</button></div><pre class="astro-code github-dark" style="background-color:#24292e;color:#e1e4e8;overflow-x:auto" tabindex="0" data-language="bash"><code><span class="line"><span style="color:#B392F0">gh</span><span style="color:#9ECBFF"> agent-task</span><span style="color:#9ECBFF"> create</span><span style="color:#9ECBFF"> &quot;build me a new app&quot;</span></span></code></pre></div>
<p>That alone opens a new agent task on the current repository and returns immediately, the session keeps running on GitHub’s servers after your terminal moves on. A few flags change that behavior:</p>
<div class="code-block"><div class="code-block__header"><span class="code-block__filename"></span><button type="button" class="code-block__copy" data-copy-button>Copy</button></div><pre class="astro-code github-dark" style="background-color:#24292e;color:#e1e4e8;overflow-x:auto" tabindex="0" data-language="bash"><code><span class="line"><span style="color:#9ca6b0"># stream the session&#39;s logs in your terminal instead of returning immediately</span></span>
<span class="line"><span style="color:#B392F0">gh</span><span style="color:#9ECBFF"> agent-task</span><span style="color:#9ECBFF"> create</span><span style="color:#9ECBFF"> &quot;build me a new app&quot;</span><span style="color:#79B8FF"> --follow</span></span>
<span class="line"></span>
<span class="line"><span style="color:#9ca6b0"># read a longer task description from a file instead of a shell string</span></span>
<span class="line"><span style="color:#B392F0">gh</span><span style="color:#9ECBFF"> agent-task</span><span style="color:#9ECBFF"> create</span><span style="color:#79B8FF"> -F</span><span style="color:#9ECBFF"> task-desc.md</span></span>
<span class="line"></span>
<span class="line"><span style="color:#9ca6b0"># pipe a description in from stdin</span></span>
<span class="line"><span style="color:#79B8FF">echo</span><span style="color:#9ECBFF"> &quot;build me a new app&quot;</span><span style="color:#F97583"> |</span><span style="color:#B392F0"> gh</span><span style="color:#9ECBFF"> agent-task</span><span style="color:#9ECBFF"> create</span><span style="color:#79B8FF"> -F</span><span style="color:#9ECBFF"> -</span></span>
<span class="line"></span>
<span class="line"><span style="color:#9ca6b0"># target a specific base branch instead of the repo&#39;s default</span></span>
<span class="line"><span style="color:#B392F0">gh</span><span style="color:#9ECBFF"> agent-task</span><span style="color:#9ECBFF"> create</span><span style="color:#9ECBFF"> &quot;fix errors&quot;</span><span style="color:#79B8FF"> --base</span><span style="color:#9ECBFF"> branch-name</span></span>
<span class="line"></span>
<span class="line"><span style="color:#9ca6b0"># use a specific custom agent, defined in a &lt;name&gt;.md agent file, instead of the default</span></span>
<span class="line"><span style="color:#B392F0">gh</span><span style="color:#9ECBFF"> agent-task</span><span style="color:#9ECBFF"> create</span><span style="color:#9ECBFF"> &quot;build me a new app&quot;</span><span style="color:#79B8FF"> --custom-agent</span><span style="color:#9ECBFF"> my-agent</span></span></code></pre></div>
<ol>
<li><code>-F</code>/<code>--from-file</code> reads the task description from a file, or from standard input when the value is <code>-</code>, useful once a description is longer than fits comfortably in a shell argument.</li>
<li><code>--follow</code> keeps the command running and prints session logs as they happen, instead of handing control back to your shell right away.</li>
<li><code>-b</code>/<code>--base</code> sets which branch the resulting pull request targets, instead of the repository’s default branch.</li>
<li><code>-a</code>/<code>--custom-agent</code> points at a named custom agent instead of the default Copilot coding agent.</li>
<li>Omit the description entirely and <code>gh agent-task create</code> opens your configured editor for one instead.</li>
</ol>
<h2 id="check-progress-and-list-your-tasks">Check progress and list your tasks</h2>
<p><code>gh agent-task view</code> accepts either a session ID or a pull request number, since a task’s identity and its resulting PR are two ways of pointing at the same underlying run:</p>
<div class="code-block"><div class="code-block__header"><span class="code-block__filename"></span><button type="button" class="code-block__copy" data-copy-button>Copy</button></div><pre class="astro-code github-dark" style="background-color:#24292e;color:#e1e4e8;overflow-x:auto" tabindex="0" data-language="bash"><code><span class="line"><span style="color:#9ca6b0"># by session ID</span></span>
<span class="line"><span style="color:#B392F0">gh</span><span style="color:#9ECBFF"> agent-task</span><span style="color:#9ECBFF"> view</span><span style="color:#9ECBFF"> e2fa49d2-f164-4a56-ab99-498090b8fcdf</span></span>
<span class="line"></span>
<span class="line"><span style="color:#9ca6b0"># by pull request number in the current repo</span></span>
<span class="line"><span style="color:#B392F0">gh</span><span style="color:#9ECBFF"> agent-task</span><span style="color:#9ECBFF"> view</span><span style="color:#79B8FF"> 12345</span></span>
<span class="line"></span>
<span class="line"><span style="color:#9ca6b0"># by PR number in a different repo</span></span>
<span class="line"><span style="color:#B392F0">gh</span><span style="color:#9ECBFF"> agent-task</span><span style="color:#9ECBFF"> view</span><span style="color:#79B8FF"> --repo</span><span style="color:#9ECBFF"> OWNER/REPO</span><span style="color:#79B8FF"> 12345</span></span>
<span class="line"></span>
<span class="line"><span style="color:#9ca6b0"># open it in the browser instead of the terminal</span></span>
<span class="line"><span style="color:#B392F0">gh</span><span style="color:#9ECBFF"> agent-task</span><span style="color:#9ECBFF"> view</span><span style="color:#79B8FF"> 12345</span><span style="color:#79B8FF"> --web</span></span></code></pre></div>
<p>Add <code>--follow</code> to an already-running task the same way <code>create --follow</code> does, or <code>--log</code> to print what’s happened so far without following live. <code>gh agent-task list</code> shows your most recent tasks, 30 by default, controllable with <code>-L</code>/<code>--limit</code>:</p>
<div class="code-block"><div class="code-block__header"><span class="code-block__filename"></span><button type="button" class="code-block__copy" data-copy-button>Copy</button></div><pre class="astro-code github-dark" style="background-color:#24292e;color:#e1e4e8;overflow-x:auto" tabindex="0" data-language="bash"><code><span class="line"><span style="color:#B392F0">gh</span><span style="color:#9ECBFF"> agent-task</span><span style="color:#9ECBFF"> list</span></span></code></pre></div>
<p>Both <code>view</code> and <code>list</code> support <code>--json</code> with fields including <code>id</code>, <code>state</code>, <code>pullRequestNumber</code>, <code>pullRequestUrl</code>, <code>createdAt</code>, and <code>completedAt</code>, so a script can poll a task’s state without scraping terminal output.</p>
<h2 id="its-also-one-of-the-commands-the-july-security-fix-touched">It’s also one of the commands the July security fix touched</h2>
<p><code>gh agent-task view</code> and <code>gh agent-task create</code> were two of the seven commands patched in v2.97.0’s terminal-injection fix, GHSA-3m3g-3wcr-px46, because task output streamed from GitHub’s servers reached your terminal unsanitized before that release. This post only covers what <code>gh agent-task</code> does; for the vulnerability itself and the other six affected commands, see <a href="/dev-tools/gh-cli-2-97-0-terminal-injection-fixes/">gh CLI 2.97.0 Fixes Terminal Injection in 7 Commands</a>.</p>
<p><code>gh agent-task</code> is also part of <code>gh</code>’s broader shift into an agent-tooling hub through 2026, alongside <code>gh skill</code> and <code>gh discussion</code>. See <a href="/dev-tools/github-cli-ai-agent-control-surface/">How GitHub CLI Became an AI-Agent Control Surface</a> for that wider pattern across all ten changes in this series.</p>
<p>Reach for <code>gh agent-task create</code> the next time you want to delegate a well-scoped, described-in-words change, a refactor, a dependency bump, a small feature, without switching to a browser to file the issue and assign it. Skip it for CI automation for now; the OAuth-only requirement means it still needs a human to authenticate at least once, and issue #11845 shows GitHub hasn’t shipped a PAT-friendly path yet.</p>
<p>Browse more coverage like this in the <a href="/dev-tools">Dev Tools</a> archive, or start from <a href="/dev-tools/github-cli-2026-agent-era-expansion">GitHub CLI’s 2026 agent-era expansion</a> for the full ten-part series.</p>]]></content:encoded>
      <pubDate>Wed, 19 Aug 2026 00:00:00 GMT</pubDate>
    </item>
    <item>
      <title>gh CLI 2.97.0 Fixes Terminal Injection in 7 Commands</title>
      <link>https://bytetech247.com/dev-tools/gh-cli-2-97-0-terminal-injection-fixes/</link>
      <guid isPermaLink="true">https://bytetech247.com/dev-tools/gh-cli-2-97-0-terminal-injection-fixes/</guid>
      <description>GitHub CLI (gh) 2.97.0 fixes a terminal escape-sequence injection bug (GHSA-3m3g-3wcr-px46) across gh api, gh pr diff, and 5 more commands. Update now.</description>
      <content:encoded><![CDATA[<aside class="quick-answer" aria-label="Quick answer"><h2 id="quick-answer">Quick Answer</h2>
<p>Update <code>gh</code> to v2.97.0 or later if you run <code>gh api</code>, <code>gh pr diff</code>, <code>gh gist view</code>, <code>gh codespace logs</code>, <code>gh release download --output -</code>, <code>gh agent-task</code>, or <code>gh skills preview</code> against repos, gists, or codespaces you don’t fully trust. Versions through v2.96.0 printed that content raw, letting embedded terminal escape sequences hijack what’s on your screen, tracked as GHSA-3m3g-3wcr-px46.</p>
</aside><h2 id="what-ghsa-3m3g-3wcr-px46-actually-breaks">What GHSA-3m3g-3wcr-px46 actually breaks</h2>
<p>A terminal escape sequence is a short byte sequence, usually starting with <code>ESC</code> (<code>\x1b</code>), that a terminal emulator interprets as a command instead of literal text: move the cursor, clear the screen, change colors, set the window title, or, on some emulators, quite a bit more than that. Normal output leans on these constantly, that’s how <code>gh</code>’s own colored diffs and progress spinners work. The bug is what happens when those bytes come from someone else’s file instead of from <code>gh</code> itself.</p>
<p>GitHub’s advisory states the flaw plainly:</p>
<blockquote>
<p>“Several GitHub CLI commands print externally controlled content to the terminal without neutralizing terminal escape sequences. An attacker who can influence that content (for example a gist file, a pull request diff, a release asset, a codespace log) can embed escape sequences that are interpreted by a user’s terminal when they run one of the affected commands.”</p>
</blockquote>
<p>Versions through v2.96.0 printed that untrusted content straight through, unfiltered. A gist author, a PR contributor, or anyone with push access to a Codespace could plant escape sequences that fire the moment a victim ran the ordinary, read-only command that displays their content, no click required beyond running <code>gh</code> itself. GitHub tracks this as <a href="https://github.com/cli/cli/security/advisories/GHSA-3m3g-3wcr-px46">GHSA-3m3g-3wcr-px46</a> and CVE-2026-64654, scored medium severity at a CVSS v4 base score of 5.1.</p>
<p>The advisory is direct about what a successful hit can do:</p>
<blockquote>
<p>“An attacker who can influence the content shown by an affected command can inject terminal escape sequences into the terminal of a user who views that content. The practical impact depends on the user’s terminal emulator and ranges from cosmetic (title or on screen content manipulation) to, on some emulators, command execution.”</p>
</blockquote>
<p>That range matters more than it first looks. Most terminal emulators only let an injected sequence rewrite what’s on-screen or rename the window title, which is annoying but not dangerous on its own. A smaller set of emulators support escape sequences that reach further, and “further” is the part that stops this from being merely cosmetic.</p>
<h2 id="the-7-commands-v2970-sanitizes">The 7 commands v2.97.0 sanitizes</h2>
<p>Every affected command reads content someone else wrote and prints it straight to your terminal. Not every invocation is exposed, though, the advisory lists a specific triggering condition for each command:</p>





















































<table tabindex="0"><thead><tr><th scope="col" style="text-align:left">Command</th><th scope="col" style="text-align:left">What Reaches Your Terminal</th><th scope="col" style="text-align:left">You’re Exposed When</th><th scope="col" style="text-align:left">You’re Not Exposed When</th></tr></thead><tbody><tr><td style="text-align:left"><code>gh gist view</code></td><td style="text-align:left">A gist file’s contents</td><td style="text-align:left">The file is too big for an inline JSON response, so the API truncates it and <code>gh</code> goes back to fetch the full body from its raw URL</td><td style="text-align:left">A smaller file comes back inline as sanitized JSON in one request</td></tr><tr><td style="text-align:left"><code>gh api</code></td><td style="text-align:left">A raw HTTP response body</td><td style="text-align:left">The response isn’t JSON, for example raw file content or a diff pulled through the <code>Accept</code> header</td><td style="text-align:left">The response is JSON, which was already sanitized before printing</td></tr><tr><td style="text-align:left"><code>gh pr diff</code></td><td style="text-align:left">A pull request’s diff or patch</td><td style="text-align:left">Always, the diff itself carries the changed files’ raw text</td><td style="text-align:left">-</td></tr><tr><td style="text-align:left"><code>gh release download --output -</code></td><td style="text-align:left">A release asset’s bytes</td><td style="text-align:left">The asset is written to standard output</td><td style="text-align:left">The same asset is written to a file on disk instead</td></tr><tr><td style="text-align:left"><code>gh codespace logs</code></td><td style="text-align:left">A running codespace’s logs</td><td style="text-align:left">Always, the logs stream straight from the codespace</td><td style="text-align:left">-</td></tr><tr><td style="text-align:left"><code>gh skills preview</code></td><td style="text-align:left">A skill file’s contents</td><td style="text-align:left">Always, when the previewed file contains escape sequences</td><td style="text-align:left">-</td></tr><tr><td style="text-align:left"><code>gh agent-task view</code> / <code>gh agent-task create</code></td><td style="text-align:left">Task output streamed from GitHub’s servers</td><td style="text-align:left">Always, while that output is displayed</td><td style="text-align:left">-</td></tr></tbody></table>
<p>Four of the seven, <code>gh agent-task view</code>/<code>create</code>, <code>gh skills preview</code>, <code>gh pr diff</code>, and <code>gh codespace logs</code>, have no unaffected case listed. Printing exactly that kind of content is what those commands are for, so v2.97.0’s fix has to run every time, not just under a specific condition.</p>
<h2 id="how-gh-fixes-it-sanitize-by-default-opt-out-only-with-allow-escape-sequences">How gh fixes it: sanitize by default, opt out only with —allow-escape-sequences</h2>
<p>v2.97.0 (2026-07-31) closes all seven paths the same way: neutralize escape sequences before the bytes reach your terminal, on by default, no configuration required. Check what you’re running first:</p>
<div class="code-block"><div class="code-block__header"><span class="code-block__filename"></span><button type="button" class="code-block__copy" data-copy-button>Copy</button></div><pre class="astro-code github-dark" style="background-color:#24292e;color:#e1e4e8;overflow-x:auto" tabindex="0" data-language="bash"><code><span class="line"><span style="color:#B392F0">gh</span><span style="color:#79B8FF"> --version</span></span></code></pre></div>
<p>Anything before 2.97.0 needs an update, through whatever installed <code>gh</code> in the first place: <code>brew upgrade gh</code> on macOS, <code>apt update &amp;&amp; apt install gh</code> on Debian/Ubuntu, <code>scoop update gh</code> on Windows, or a fresh binary from <a href="https://cli.github.com/">cli.github.com</a> if you installed manually.</p>
<p><code>gh</code>’s own source shows what the fix actually does. This is the relevant check inside <code>gh gist view</code>, one of the seven affected commands, in <code>cli/cli</code>’s current <code>pkg/cmd/gist/view/view.go</code>:</p>
<div class="code-block code-block--has-filename"><div class="code-block__header"><span class="code-block__filename">pkg/cmd/gist/view/view.go (cli/cli)</span><button type="button" class="code-block__copy" data-copy-button>Copy</button></div><pre class="astro-code github-dark" style="background-color:#24292e;color:#e1e4e8;overflow-x:auto" tabindex="0" data-language="go" data-filename="pkg/cmd/gist/view/view.go (cli/cli)"><code><span class="line"><span style="color:#F97583">if</span><span style="color:#F97583"> !</span><span style="color:#E1E4E8">opts.AllowEscapeSequences </span><span style="color:#F97583">&amp;&amp;</span><span style="color:#F97583"> !</span><span style="color:#E1E4E8">opts.IO.</span><span style="color:#B392F0">IsStdoutTTY</span><span style="color:#E1E4E8">() {</span></span>
<span class="line"><span style="color:#F97583">    if</span><span style="color:#E1E4E8"> iostreams.</span><span style="color:#B392F0">ContainsEscapeSequence</span><span style="color:#E1E4E8">(content.</span><span style="color:#B392F0">RawBytes</span><span style="color:#E1E4E8">()) {</span></span>
<span class="line"><span style="color:#F97583">        return</span><span style="color:#E1E4E8"> errors.</span><span style="color:#B392F0">New</span><span style="color:#E1E4E8">(</span><span style="color:#9ECBFF">&quot;gist file contains terminal escape sequences; pass --allow-escape-sequences to view it anyway&quot;</span><span style="color:#E1E4E8">)</span></span>
<span class="line"><span style="color:#E1E4E8">    }</span></span>
<span class="line"><span style="color:#E1E4E8">    opts.IO.</span><span style="color:#B392F0">SetContentSanitization</span><span style="color:#E1E4E8">(</span><span style="color:#79B8FF">false</span><span style="color:#E1E4E8">)</span></span>
<span class="line"><span style="color:#E1E4E8">}</span></span></code></pre></div>
<p>On a real terminal, <code>gh</code> renders escape sequences inert automatically, no error, no flag needed, you just see plain text where a hidden sequence would have been. When the output is piped somewhere else instead, <code>gh gist view &lt;id&gt; | less</code>, or into a script, it takes the stricter path: it refuses the content outright rather than silently rewriting the bytes, unless you pass the new flag deliberately:</p>
<div class="code-block"><div class="code-block__header"><span class="code-block__filename"></span><button type="button" class="code-block__copy" data-copy-button>Copy</button></div><pre class="astro-code github-dark" style="background-color:#24292e;color:#e1e4e8;overflow-x:auto" tabindex="0" data-language="bash"><code><span class="line"><span style="color:#B392F0">gh</span><span style="color:#9ECBFF"> gist</span><span style="color:#9ECBFF"> view</span><span style="color:#F97583"> &lt;</span><span style="color:#9ECBFF">i</span><span style="color:#E1E4E8">d</span><span style="color:#F97583">&gt;</span><span style="color:#79B8FF"> --allow-escape-sequences</span></span></code></pre></div>
<div class="callout" role="note" data-astro-cid-q2ml7llr><p class="callout__title" data-astro-cid-q2ml7llr><span aria-hidden="true" data-astro-cid-q2ml7llr>⚠</span>--allow-escape-sequences turns the fix back off</p><div class="callout__body" data-astro-cid-q2ml7llr><p>Passing this flag restores the exact pre-2.97.0 behavior for that one command:
raw bytes, no neutralization. It exists for the case where you already trust
the source and specifically want the sequences to render, a legitimately
colorized dump, for instance. Reaching for it against a gist, PR, or codespace
you don’t control puts you back where GHSA-3m3g-3wcr-px46 started.</p></div></div>
<h2 id="not-ghs-first-escape-sequence-bug">Not gh’s first escape-sequence bug</h2>
<p>This isn’t a new bug class for <code>gh</code>, and the advisory says so directly:</p>
<blockquote>
<p>“This extends the same class of issue addressed by CVE-2026-45803, which covered only <code>gh run view --log</code>, to the other command paths that reach the terminal without sanitization.”</p>
</blockquote>
<p>CVE-2026-45803 fixed exactly one command. GHSA-3m3g-3wcr-px46 found six more paths doing the identical thing and fixed all of them in one release. If you patched for the earlier CVE and assumed the underlying bug class was closed, v2.97.0 is the release that says it wasn’t.</p>
<h2 id="where-this-fits-in-ghs-two-2026-security-releases">Where this fits in gh’s two 2026 security releases</h2>
<p>v2.97.0 wasn’t a single-issue release. <a href="https://github.com/cli/cli/releases/tag/v2.97.0">GitHub’s own release notes</a> for that version open with four separate security advisories fixed the same day, not four terminal-injection bugs specifically: GHSA-3m3g-3wcr-px46 (this one), a URL-path-escaping bug in outbound request building (GHSA-4fjg-2h4q-fwg3), a partial-token leak in <code>gh auth status</code> for token types like <code>github_pat_*</code> and <code>ghs_*</code> (GHSA-cg6r-mpgc-h9mm), and a regex-escaping bypass in <code>gh attestation verify</code>’s <code>--signer-repo</code>/<code>--signer-workflow</code> matching (GHSA-mm27-mwq9-fr5g). This post covers only the terminal-injection one; the other three are a different bug class in different commands entirely.</p>
<p>v2.97.0 also wasn’t <code>gh</code>’s first security-driven release of the year. The month before, v2.96.0 (2026-07-02), the release right before this one, closed a real remote-code-execution path in <code>gh codespace jupyter</code>. See <a href="/dev-tools/gh-cli-codespace-jupyter-rce-fixed/">Update gh CLI Now: Codespace Jupyter RCE Fixed</a> for that fix in full.</p>
<p>Two of the seven patched commands, <code>gh agent-task</code>’s <code>view</code> and <code>create</code> subcommands, also belong to the newest command surface <code>gh</code> shipped in 2026, still labeled preview. This post only covers the security fix; for what <code>gh agent-task</code> actually does and how to drive a Copilot coding session from it, see <a href="/dev-tools/gh-agent-task-copilot-coding-sessions/">gh agent-task: Run Copilot Coding Sessions From gh</a>.</p>
<p>Both releases are part of the same wider wave covered in <a href="/dev-tools/github-cli-2026-agent-era-expansion">GitHub CLI’s 2026 agent-era expansion</a>.</p>
<p>Update to v2.97.0 today if you haven’t already, that’s the entire fix, no configuration required on your end. If you’re stuck on an older version for now, treat <code>gh skills preview</code>, <code>gh codespace logs</code>, <code>gh gist view</code>, <code>gh agent-task</code>, <code>gh api</code>, <code>gh pr diff</code>, and <code>gh release download --output -</code> the same way you’d treat opening an attachment from someone you don’t know: fine against your own repos and gists, risky against anything else. Skip <code>--allow-escape-sequences</code> entirely unless you already trust exactly what you’re about to print.</p>
<p>Browse more coverage like this in the <a href="/dev-tools">Dev Tools</a> archive.</p>]]></content:encoded>
      <pubDate>Wed, 19 Aug 2026 00:00:00 GMT</pubDate>
    </item>
    <item>
      <title>Update gh CLI Now: Codespace Jupyter RCE Fixed</title>
      <link>https://bytetech247.com/dev-tools/gh-cli-codespace-jupyter-rce-fixed/</link>
      <guid isPermaLink="true">https://bytetech247.com/dev-tools/gh-cli-codespace-jupyter-rce-fixed/</guid>
      <description>A malicious Codespace could use gh codespace jupyter to hand VS Code a vscode:// URL and run commands on your machine. Fixed in gh CLI v2.96.0.</description>
      <content:encoded><![CDATA[<aside class="quick-answer" aria-label="Quick answer"><h2 id="quick-answer">Quick Answer</h2>
<p><code>gh codespace jupyter</code> opened a Codespace-supplied URL without checking it was a loopback address, letting a malicious Codespace hand your local VS Code a <code>vscode://</code> link and run commands on your machine. It affected v2.10.0 through v2.95.0. Update to gh CLI v2.96.0 or later now; the fix validates the URL before opening it.</p>
</aside><h2 id="how-gh-codespace-jupyter-opened-the-door">How gh codespace jupyter opened the door</h2>
<p><code>gh codespace jupyter -c &lt;name&gt;</code> is supposed to do one thing: start a remote JupyterLab server inside a Codespace and open it in your browser. The URL for that server doesn’t come from <code>gh</code> itself. A process running inside the Codespace generates it and hands it back to your local CLI, which then opens it without checking what it actually points to.</p>
<p>That’s the entire bug. GitHub’s advisory, tracked as <a href="https://github.com/cli/cli/security/advisories/GHSA-8cg3-r6g9-fpg2">GHSA-8cg3-r6g9-fpg2</a> and assigned CVE-2026-59831, states the root cause plainly:</p>
<blockquote>
<p>“The CLI trusts the URL provided by the Codespace and opens it as-is, without confirming that it is a loopback JupyterLab web address.”</p>
</blockquote>
<p>The part that turns a missing validation check into remote code execution is what the browser launcher itself accepts. It doesn’t just open <code>http://</code> and <code>https://</code> links. It also honors <code>vscode://</code> and <code>vscode-insiders://</code> URLs, handing them straight to VS Code. A compromised or malicious Codespace can return one of those instead of a normal JupyterLab address. Once the OS forwards that link, VS Code treats it like any other link it’s been handed and follows it.</p>
<blockquote>
<p>“Successful exploitation requires the victim to accept one VS Code ‘open URL’ or Workspace Trust prompt.”</p>
</blockquote>
<div class="callout" role="note" data-astro-cid-q2ml7llr><p class="callout__title" data-astro-cid-q2ml7llr><span aria-hidden="true" data-astro-cid-q2ml7llr>ℹ</span>Not a zero-click exploit</p><div class="callout__body" data-astro-cid-q2ml7llr><p>You still have to click through one dialog for the chain to complete. That
narrows the real-world blast radius but doesn’t remove the risk, since a
single accidental click on an unfamiliar Codespace is all it takes.</p></div></div>
<p>The advisory also places this bug in context rather than treating it as new territory:</p>
<blockquote>
<p>“This is a variant of CVE-2024-52308. That remediation validated the SSH connection details for <code>gh codespace ssh</code> and <code>gh codespace logs</code>, but did not cover the equivalent Jupyter code path.”</p>
</blockquote>
<p>In other words, <code>gh</code> fixed this exact class of bug for two other Codespace commands back in 2024 and missed the third. GitHub CLI v2.96.0, shipped 2026-07-02, closes that gap by checking the Jupyter server URL first: anything other than a loopback <code>http</code> or <code>https</code> address now gets rejected before the CLI ever opens it.</p>
<p>GitHub rates the bug Medium severity, CVSS 3.1 base score 4.4:</p>
<blockquote>
<p>CVSS:3.1/AV:N/AC:H/PR:L/UI:R/S:C/C:L/I:L/A:N</p>
</blockquote>
<p>Network attack vector, high attack complexity, low privileges required, user interaction required: that last part is the same single trust-prompt click the quote above describes, not a marketing gloss on the score.</p>
<h2 id="structural-comparison-matrix">Structural Comparison Matrix</h2>



































<table tabindex="0"><thead><tr><th scope="col" style="text-align:left">Operational Aspect</th><th scope="col" style="text-align:left">Vulnerable (v2.10.0-v2.95.0)</th><th scope="col" style="text-align:left">Patched (v2.96.0+)</th></tr></thead><tbody><tr><td style="text-align:left"><strong>Jupyter server URL validation</strong></td><td style="text-align:left">None; <code>gh</code> opens whatever URL the in-Codespace process returns</td><td style="text-align:left">Validated; only loopback <code>http</code>/<code>https</code> addresses are accepted</td></tr><tr><td style="text-align:left"><strong>URL schemes the launcher honors</strong></td><td style="text-align:left"><code>vscode://</code> and <code>vscode-insiders://</code> pass straight through to VS Code</td><td style="text-align:left">Non-loopback URLs are rejected before they ever reach the OS launcher</td></tr><tr><td style="text-align:left"><strong>Exploit chain</strong></td><td style="text-align:left">Malicious Codespace returns a <code>vscode://</code> link, OS hands it to VS Code, one trust-prompt accept results in code execution</td><td style="text-align:left">Malformed or non-loopback URL is rejected before opening, chain never starts</td></tr><tr><td style="text-align:left"><strong>Coverage vs. the 2024 fix</strong></td><td style="text-align:left"><code>gh codespace ssh</code>/<code>gh codespace logs</code> validated since CVE-2024-52308; <code>gh codespace jupyter</code> still open</td><td style="text-align:left">All three Codespace commands now validate the URL/connection details they’re handed</td></tr><tr><td style="text-align:left"><strong>Severity</strong></td><td style="text-align:left">CVSS 3.1 base score 4.4, rated medium</td><td style="text-align:left">N/A, patched</td></tr></tbody></table>
<h2 id="check-your-gh-version-and-update">Check your gh version and update</h2>
<p>Run this first, before anything else in this post matters:</p>
<div class="code-block"><div class="code-block__header"><span class="code-block__filename"></span><button type="button" class="code-block__copy" data-copy-button>Copy</button></div><pre class="astro-code github-dark" style="background-color:#24292e;color:#e1e4e8;overflow-x:auto" tabindex="0" data-language="bash"><code><span class="line"><span style="color:#B392F0">gh</span><span style="color:#79B8FF"> --version</span></span></code></pre></div>
<p>If the output reports anything from v2.10.0 up to and including v2.95.0, you’re running the vulnerable code path. <code>gh</code> has no built-in self-update command, so update through whatever installed it in the first place:</p>
<div class="code-block"><div class="code-block__header"><span class="code-block__filename"></span><button type="button" class="code-block__copy" data-copy-button>Copy</button></div><pre class="astro-code github-dark" style="background-color:#24292e;color:#e1e4e8;overflow-x:auto" tabindex="0" data-language="bash"><code><span class="line"><span style="color:#9ca6b0"># macOS (Homebrew)</span></span>
<span class="line"><span style="color:#B392F0">brew</span><span style="color:#9ECBFF"> upgrade</span><span style="color:#9ECBFF"> gh</span></span>
<span class="line"></span>
<span class="line"><span style="color:#9ca6b0"># Debian/Ubuntu</span></span>
<span class="line"><span style="color:#B392F0">sudo</span><span style="color:#9ECBFF"> apt</span><span style="color:#9ECBFF"> update</span><span style="color:#E1E4E8"> &amp;&amp; </span><span style="color:#B392F0">sudo</span><span style="color:#9ECBFF"> apt</span><span style="color:#9ECBFF"> install</span><span style="color:#9ECBFF"> gh</span></span>
<span class="line"></span>
<span class="line"><span style="color:#9ca6b0"># Windows (winget)</span></span>
<span class="line"><span style="color:#B392F0">winget</span><span style="color:#9ECBFF"> upgrade</span><span style="color:#79B8FF"> --id</span><span style="color:#9ECBFF"> GitHub.cli</span><span style="color:#79B8FF"> --source</span><span style="color:#9ECBFF"> winget</span></span>
<span class="line"></span>
<span class="line"><span style="color:#9ca6b0"># Windows (Scoop)</span></span>
<span class="line"><span style="color:#B392F0">scoop</span><span style="color:#9ECBFF"> update</span><span style="color:#9ECBFF"> gh</span></span></code></pre></div>
<p>If none of those match your setup, grab a fresh binary from <a href="https://cli.github.com/">cli.github.com</a> directly. Run <code>gh --version</code> again afterward and confirm it reports v2.96.0 or later before you run <code>gh codespace jupyter</code> against anything again.</p>
<p>Until you’ve confirmed the update, GitHub’s own remediation guidance is worth following for real: only run <code>gh codespace jupyter</code> against Codespaces you actually trust, and prefer Codespaces built from default or pre-built devcontainers rather than custom setup scripts you haven’t reviewed.</p>
<h2 id="what-shipped-alongside-this-fix">What shipped alongside this fix</h2>
<p><a href="https://github.com/cli/cli/releases/tag/v2.96.0">v2.96.0</a> wasn’t a single-issue release. The same version also dropped the authentication requirement for <code>gh release download</code> against public repositories, an unrelated behavior change, not a security fix, covered on its own in <a href="/dev-tools/gh-release-download-no-auth-public-repos/">gh release download No Longer Needs Auth (Public Repos)</a>.</p>
<p>One month later, v2.97.0 fixed a different class of problem entirely: four terminal escape-sequence injection bugs across seven commands, including <code>gh api</code> and <code>gh pr diff</code>, tracked as a separate advisory. That release doesn’t touch the Jupyter URL path this post covers. See <a href="/dev-tools/gh-cli-2-97-0-terminal-injection-fixes/">gh CLI 2.97.0 Fixes 4 Terminal Injection Bugs</a> for what it changed and whether it affects commands you actually run.</p>
<p>Both fixes are part of the same wave covered in <a href="/dev-tools/github-cli-2026-agent-era-expansion">GitHub CLI’s 2026 agent-era expansion</a>, which walks through everything <code>gh</code> shipped between April and July 2026, new commands and security fixes together.</p>
<p>Update now if you haven’t. This isn’t a theoretical hardening measure; it’s a patched remote-code-execution path with a CVE and a working exploit chain, and the fix costs you one package-manager command. Skip <code>gh codespace jupyter</code> against any Codespace you don’t fully trust until <code>gh --version</code> reports v2.96.0 or later.</p>
<p>Browse more coverage like this in the <a href="/dev-tools">Dev Tools</a> archive.</p>]]></content:encoded>
      <pubDate>Wed, 19 Aug 2026 00:00:00 GMT</pubDate>
    </item>
    <item>
      <title>gh CLI&apos;s New PGP Signing Key for Linux Packages</title>
      <link>https://bytetech247.com/dev-tools/gh-cli-new-pgp-signing-key-linux/</link>
      <guid isPermaLink="true">https://bytetech247.com/dev-tools/gh-cli-new-pgp-signing-key-linux/</guid>
      <description>GitHub rotated the PGP key signing gh CLI&apos;s Linux apt/dnf/yum packages on 2026-04-08. Update your keyring before the old key expires Sept 5, 2026.</description>
      <content:encoded><![CDATA[<aside class="quick-answer" aria-label="Quick answer"><h2 id="quick-answer">Quick Answer</h2>
<p>GitHub rotated the PGP key that signs <code>gh</code> CLI’s apt and RPM Linux packages on 2026-04-08, publishing a keyring with both the current and new key so installs don’t break mid-rotation. The old key expires September 5, 2026. If you installed <code>gh</code> via apt, dnf, or yum before then and haven’t updated since, re-fetch the keyring now.</p>
</aside><h2 id="whats-actually-rotating-and-why-now">What’s actually rotating, and why now</h2>
<p><a href="https://github.blog/changelog/2026-04-08-new-pgp-signing-key-for-github-cli-linux-packages/">GitHub’s own changelog entry</a> states the change plainly:</p>
<blockquote>
<p>“We’ve published an updated PGP keyring for GitHub CLI’s Linux package repositories. The keyring now includes both the current signing key and a new replacement key.”</p>
</blockquote>
<p>That’s the whole announcement in two sentences, dated 2026-04-08. The detail that makes it worth a dedicated post is what it’s protecting against. The key currently signing every <code>gh</code> package built for <code>apt</code>, <code>dnf</code>, <code>yum</code>, and <code>zypper</code> repositories expires on <strong>September 5, 2026</strong>. GitHub generated a replacement ahead of that date and published a combined keyring file containing both fingerprints, so anyone who re-runs their distro’s install steps between April 8 and September 5 already has the new key in place before the old one stops working.</p>
<p>This isn’t GitHub’s first time handling a <code>gh</code> key expiration. Back in September 2024, the previous signing key expired without a replacement ready, breaking Linux installs and updates until GitHub shipped an emergency extension, tracked in <a href="https://github.com/cli/cli/issues/9569">cli/cli issue #9569</a>. This time, the rotation shipped nearly five months ahead of the expiry date instead of after it, specifically to avoid a repeat.</p>
<div class="callout" role="note" data-astro-cid-q2ml7llr><p class="callout__title" data-astro-cid-q2ml7llr><span aria-hidden="true" data-astro-cid-q2ml7llr>ℹ</span>Windows, macOS, and non-package-manager installs aren&#39;t touched</p><div class="callout__body" data-astro-cid-q2ml7llr><p>This PGP key only signs the <code>apt</code> and RPM repositories <code>cli.github.com</code> hosts.
Homebrew, winget, Scoop, Conda, a precompiled binary, or a source build never
check it, on any operating system. If you didn’t install <code>gh</code> through <code>apt</code>,
<code>dnf</code>, <code>yum</code>, or <code>zypper</code> on Linux, none of the commands below apply to you.</p></div></div>
<h2 id="structural-comparison-matrix">Structural Comparison Matrix</h2>



































<table tabindex="0"><thead><tr><th scope="col" style="text-align:left">Operational Aspect</th><th scope="col" style="text-align:left">Old keyring only (installed/updated before 2026-04-08)</th><th scope="col" style="text-align:left">Updated keyring (current + new key)</th></tr></thead><tbody><tr><td style="text-align:left"><strong><code>apt</code>/<code>dnf</code>/<code>yum</code>/<code>zypper</code> installs or updates after Sept 5, 2026</strong></td><td style="text-align:left">Fail with a signature-verification error once the old key expires</td><td style="text-align:left">Succeed; the new key is already valid</td></tr><tr><td style="text-align:left"><strong><code>gpg --show-keys</code> on the apt keyring file</strong></td><td style="text-align:left">Shows one <code>pub</code> entry, fingerprint ending <code>...75716059</code></td><td style="text-align:left">Shows two <code>pub</code> entries, the old fingerprint plus the new one ending <code>...62313325</code></td></tr><tr><td style="text-align:left"><strong><code>rpm -qa gpg-pubkey</code> GitHub CLI entries</strong></td><td style="text-align:left">One entry matching <code>opensource+cli@github.com</code></td><td style="text-align:left">Two entries matching that address</td></tr><tr><td style="text-align:left"><strong>CI base images / Docker layers that provision <code>gh</code> fresh each run</strong></td><td style="text-align:left">Break the first time a build runs after Sept 5, 2026, unless the layer was rebuilt since April 8</td><td style="text-align:left">Keep working through and past the expiry date</td></tr><tr><td style="text-align:left"><strong>Action required before Sept 5, 2026</strong></td><td style="text-align:left">Re-fetch the keyring or repo config for your distro</td><td style="text-align:left">None</td></tr></tbody></table>
<p>Every row above is confirmed against GitHub’s own remediation guide in <a href="https://github.com/cli/cli/issues/13118">cli/cli issue #13118</a>, not summarized from memory.</p>
<h2 id="confirm-which-keyring-youre-actually-running">Confirm which keyring you’re actually running</h2>
<p>Before changing anything, check what your system already has. On Debian or Ubuntu:</p>
<div class="code-block code-block--has-filename"><div class="code-block__header"><span class="code-block__filename">check the apt keyring</span><button type="button" class="code-block__copy" data-copy-button>Copy</button></div><pre class="astro-code github-dark" style="background-color:#24292e;color:#e1e4e8;overflow-x:auto" tabindex="0" data-language="bash" data-filename="check the apt keyring"><code><span class="line"><span style="color:#B392F0">gpg</span><span style="color:#79B8FF"> --show-keys</span><span style="color:#9ECBFF"> /etc/apt/keyrings/githubcli-archive-keyring.gpg</span></span></code></pre></div>
<p>Two <code>pub</code> entries in the output mean you’re already covered:</p>
<div class="code-block"><div class="code-block__header"><span class="code-block__filename"></span><button type="button" class="code-block__copy" data-copy-button>Copy</button></div><pre class="astro-code github-dark" style="background-color:#24292e;color:#e1e4e8;overflow-x:auto" tabindex="0" data-language="plaintext"><code><span class="line"><span>pub   rsa4096 2022-09-06 [SC] [expires: 2026-09-05]</span></span>
<span class="line"><span>      2C6106201985B60E6C7AC87323F3D4EA75716059</span></span>
<span class="line"><span>uid                      GitHub CLI &lt;opensource+cli@github.com&gt;</span></span>
<span class="line"><span>sub   rsa4096 2022-09-06 [E] [expires: 2026-09-05]</span></span>
<span class="line"><span></span></span>
<span class="line"><span>pub   rsa4096 2026-04-07 [SC]</span></span>
<span class="line"><span>      7F38BBB59D064DBCB3D84D725612B36462313325</span></span>
<span class="line"><span>uid                      GitHub CLI &lt;opensource+cli@github.com&gt;</span></span>
<span class="line"><span>sub   rsa4096 2026-04-07 [E]</span></span></code></pre></div>
<p>Only the first block means you still need to update. On Fedora, RHEL, CentOS, Amazon Linux 2, or openSUSE, check the RPM keyring instead:</p>
<div class="code-block code-block--has-filename"><div class="code-block__header"><span class="code-block__filename">check the RPM keyring</span><button type="button" class="code-block__copy" data-copy-button>Copy</button></div><pre class="astro-code github-dark" style="background-color:#24292e;color:#e1e4e8;overflow-x:auto" tabindex="0" data-language="bash" data-filename="check the RPM keyring"><code><span class="line"><span style="color:#B392F0">rpm</span><span style="color:#79B8FF"> -qa</span><span style="color:#9ECBFF"> gpg-pubkey</span><span style="color:#F97583"> |</span><span style="color:#B392F0"> xargs</span><span style="color:#79B8FF"> -I</span><span style="color:#E1E4E8">{} sh -c &#39;rpm -qi {} | grep -q &quot;opensource+cli@github.com&quot; &amp;&amp; echo {}&#39;</span></span></code></pre></div>
<p>One matching entry means the old key only; two means you’re already set. If <code>gpg --show-keys</code> can’t find the apt keyring at <code>/etc/apt/keyrings/</code>, check the older location instead: <code>gpg --show-keys /usr/share/keyrings/githubcli-archive-keyring.gpg</code>.</p>
<h2 id="update-the-apt-keyring-debianubuntu">Update the apt keyring (Debian/Ubuntu)</h2>
<p>If you’re down to one key, replace the local keyring file, then refresh and reinstall <code>gh</code>:</p>
<div class="code-block code-block--has-filename"><div class="code-block__header"><span class="code-block__filename">update the apt keyring</span><button type="button" class="code-block__copy" data-copy-button>Copy</button></div><pre class="astro-code github-dark" style="background-color:#24292e;color:#e1e4e8;overflow-x:auto" tabindex="0" data-language="bash" data-filename="update the apt keyring"><code><span class="line"><span style="color:#B392F0">sudo</span><span style="color:#9ECBFF"> mkdir</span><span style="color:#79B8FF"> -p</span><span style="color:#79B8FF"> -m</span><span style="color:#79B8FF"> 755</span><span style="color:#9ECBFF"> /etc/apt/keyrings</span></span>
<span class="line"><span style="color:#B392F0">sudo</span><span style="color:#9ECBFF"> wget</span><span style="color:#79B8FF"> -qO</span><span style="color:#9ECBFF"> /etc/apt/keyrings/githubcli-archive-keyring.gpg</span><span style="color:#9ECBFF"> https://cli.github.com/packages/githubcli-archive-keyring.gpg</span><span style="color:#79B8FF"> \</span></span>
<span class="line"><span style="color:#E1E4E8">    &amp;&amp; </span><span style="color:#B392F0">sudo</span><span style="color:#9ECBFF"> chmod</span><span style="color:#9ECBFF"> go+r</span><span style="color:#9ECBFF"> /etc/apt/keyrings/githubcli-archive-keyring.gpg</span></span>
<span class="line"><span style="color:#B392F0">sudo</span><span style="color:#9ECBFF"> apt</span><span style="color:#9ECBFF"> update</span></span>
<span class="line"><span style="color:#B392F0">sudo</span><span style="color:#9ECBFF"> apt</span><span style="color:#9ECBFF"> install</span><span style="color:#9ECBFF"> gh</span></span></code></pre></div>
<p>Prefer <code>curl</code>? Swap the middle line for <code>sudo curl -fsSL -o /etc/apt/keyrings/githubcli-archive-keyring.gpg https://cli.github.com/packages/githubcli-archive-keyring.gpg</code>, same effect. Re-run the <code>gpg --show-keys</code> check from the previous section afterward and confirm both <code>pub</code> entries are present.</p>
<h2 id="update-the-rpm-repo-config-fedorarhelcentosamazon-linux-2">Update the RPM repo config (Fedora/RHEL/CentOS/Amazon Linux 2)</h2>
<p>RPM-based systems import keys at install time from the repo config file itself, so the fix is re-fetching that file rather than swapping a standalone keyring. Match the block below to the package manager you originally installed with:</p>
<div class="code-block code-block--has-filename"><div class="code-block__header"><span class="code-block__filename">DNF5 (Fedora 41+)</span><button type="button" class="code-block__copy" data-copy-button>Copy</button></div><pre class="astro-code github-dark" style="background-color:#24292e;color:#e1e4e8;overflow-x:auto" tabindex="0" data-language="bash" data-filename="DNF5 (Fedora 41+)"><code><span class="line"><span style="color:#B392F0">sudo</span><span style="color:#9ECBFF"> dnf</span><span style="color:#9ECBFF"> config-manager</span><span style="color:#9ECBFF"> addrepo</span><span style="color:#79B8FF"> --overwrite</span><span style="color:#79B8FF"> --from-repofile=https://cli.github.com/packages/rpm/gh-cli.repo</span></span>
<span class="line"><span style="color:#B392F0">sudo</span><span style="color:#9ECBFF"> dnf</span><span style="color:#9ECBFF"> update</span><span style="color:#9ECBFF"> gh</span></span></code></pre></div>
<div class="code-block code-block--has-filename"><div class="code-block__header"><span class="code-block__filename">DNF4 (CentOS, RHEL, Fedora 40 or earlier)</span><button type="button" class="code-block__copy" data-copy-button>Copy</button></div><pre class="astro-code github-dark" style="background-color:#24292e;color:#e1e4e8;overflow-x:auto" tabindex="0" data-language="bash" data-filename="DNF4 (CentOS, RHEL, Fedora 40 or earlier)"><code><span class="line"><span style="color:#B392F0">sudo</span><span style="color:#9ECBFF"> dnf</span><span style="color:#9ECBFF"> config-manager</span><span style="color:#79B8FF"> --add-repo</span><span style="color:#9ECBFF"> https://cli.github.com/packages/rpm/gh-cli.repo</span></span>
<span class="line"><span style="color:#B392F0">sudo</span><span style="color:#9ECBFF"> dnf</span><span style="color:#9ECBFF"> update</span><span style="color:#9ECBFF"> gh</span></span></code></pre></div>
<div class="code-block code-block--has-filename"><div class="code-block__header"><span class="code-block__filename">Yum (Amazon Linux 2)</span><button type="button" class="code-block__copy" data-copy-button>Copy</button></div><pre class="astro-code github-dark" style="background-color:#24292e;color:#e1e4e8;overflow-x:auto" tabindex="0" data-language="bash" data-filename="Yum (Amazon Linux 2)"><code><span class="line"><span style="color:#B392F0">sudo</span><span style="color:#9ECBFF"> yum-config-manager</span><span style="color:#79B8FF"> --add-repo</span><span style="color:#9ECBFF"> https://cli.github.com/packages/rpm/gh-cli.repo</span></span>
<span class="line"><span style="color:#B392F0">sudo</span><span style="color:#9ECBFF"> yum</span><span style="color:#9ECBFF"> update</span><span style="color:#9ECBFF"> gh</span></span></code></pre></div>
<p>Your package manager prompts you to confirm the new key during that update. Check the fingerprint it shows against <code>7F38BBB59D064DBCB3D84D725612B36462313325</code> before accepting; it should match exactly.</p>
<div class="callout" role="note" data-astro-cid-q2ml7llr><p class="callout__title" data-astro-cid-q2ml7llr><span aria-hidden="true" data-astro-cid-q2ml7llr>⚠</span>Still failing after re-adding the repo?</p><div class="callout__body" data-astro-cid-q2ml7llr><p>RPM-based systems can hold onto the expired key in their own keyring even
after the repo config points at the new one. Find it with <code>sudo rpm -qa   gpg-pubkey</code>, confirm its Packager field reads <code>GitHub CLI   &amp;lt;opensource+cli@github.com&amp;gt;</code> with <code>rpm -qi &lt;name&gt;</code>, then remove it with
<code>sudo rpm -e &lt;name&gt;</code> before reinstalling <code>gh</code>. GitHub’s own troubleshooting
guide in <a href="https://github.com/cli/cli/issues/13118#removing-old-key-from-rpm-keyrings">issue
#13118</a>
walks through this exact sequence if the update command above still fails.</p></div></div>
<h2 id="docker-builds-and-ci-base-images">Docker builds and CI base images</h2>
<p>A base image that installs <code>gh</code> in one layer and runs <code>apt update</code> in a later, separate build is the case most likely to break silently, since the keyring layer doesn’t get rebuilt just because a downstream layer changed. Own that keyring-adding layer yourself? Trigger a fresh build of it instead of patching around it, so the image bakes in the current file automatically. When you don’t control it, add a dedicated layer before any <code>apt update</code>:</p>
<div class="code-block code-block--has-filename"><div class="code-block__header"><span class="code-block__filename">Dockerfile: refresh the keyring before apt update</span><button type="button" class="code-block__copy" data-copy-button>Copy</button></div><pre class="astro-code github-dark" style="background-color:#24292e;color:#e1e4e8;overflow-x:auto" tabindex="0" data-language="dockerfile" data-filename="Dockerfile: refresh the keyring before apt update"><code><span class="line"><span style="color:#F97583">RUN</span><span style="color:#E1E4E8"> wget -qO /etc/apt/keyrings/githubcli-archive-keyring.gpg https://cli.github.com/packages/githubcli-archive-keyring.gpg \</span></span>
<span class="line"><span style="color:#E1E4E8">    &amp;&amp; chmod go+r /etc/apt/keyrings/githubcli-archive-keyring.gpg</span></span></code></pre></div>
<p>If a base image happens to carry the <code>gh</code> repo but your build never actually installs <code>gh</code>, the simpler fix is dropping the repo instead of tracking its key rotations:</p>
<div class="code-block code-block--has-filename"><div class="code-block__header"><span class="code-block__filename">drop the repo if you don&#39;t use gh at all</span><button type="button" class="code-block__copy" data-copy-button>Copy</button></div><pre class="astro-code github-dark" style="background-color:#24292e;color:#e1e4e8;overflow-x:auto" tabindex="0" data-language="bash" data-filename="drop the repo if you don't use gh at all"><code><span class="line"><span style="color:#B392F0">sudo</span><span style="color:#9ECBFF"> rm</span><span style="color:#9ECBFF"> /etc/apt/sources.list.d/github-cli.list</span></span></code></pre></div>
<h2 id="installing-gh-for-the-first-time-needs-none-of-this">Installing gh for the first time needs none of this</h2>
<p>Everything above is remediation for a <code>gh</code> install that already exists. Run <code>apt</code>, <code>dnf</code>, or <code>yum</code> fresh today and you already pull the current keyring file, which has carried both fingerprints since April 8, 2026. Follow <a href="https://github.com/cli/cli/blob/trunk/docs/install_linux.md">GitHub CLI’s standard Linux install instructions</a>, and you’re covered without touching any command on this page.</p>
<p>This key rotation is one piece of a wider wave of <code>gh</code> changes across 2026, alongside new command groups like <code>gh skill</code> and two separate security-driven releases. See <a href="/dev-tools/github-cli-2026-agent-era-expansion/">GitHub CLI’s 2026 Agent-Era Expansion</a> for the full picture of what shipped and when.</p>
<p>Update your keyring the next time you touch a Linux box or CI image that provisions <code>gh</code>, not on September 5 when it starts failing. It’s a two-command fix today and a broken pipeline later if you wait.</p>
<p>Browse more coverage like this in the <a href="/dev-tools">Dev Tools</a> archive.</p>]]></content:encoded>
      <pubDate>Wed, 19 Aug 2026 00:00:00 GMT</pubDate>
    </item>
    <item>
      <title>Manage GitHub Sub-Issues and Dependencies via gh CLI</title>
      <link>https://bytetech247.com/dev-tools/gh-cli-sub-issues-dependencies/</link>
      <guid isPermaLink="true">https://bytetech247.com/dev-tools/gh-cli-sub-issues-dependencies/</guid>
      <description>Set parent issues, list sub-issues, and add blocked-by dependencies with gh CLI&apos;s issue edit and create commands, no browser needed.</description>
      <content:encoded><![CDATA[<aside class="quick-answer" aria-label="Quick answer"><h2 id="quick-answer">Quick Answer</h2>
<p>Use <code>gh issue edit &lt;number&gt; --parent &lt;number&gt;</code> to link an issue under a parent, <code>--add-sub-issue</code>/<code>--remove-sub-issue</code> on the parent to manage its children, and <code>--add-blocked-by</code>/<code>--add-blocking</code> for cross-issue dependencies. All of it needs GitHub CLI v2.94.0 or later. Skip the web UI or hand-rolled GraphQL calls once you’re scripting bulk changes to an issue tree.</p>
<p>GitHub CLI’s <code>gh issue</code> command picked up issue types, parent/sub-issue relationships, and cross-issue dependencies in <a href="https://github.blog/changelog/2026-06-10-manage-sub-issues-types-and-dependencies-from-github-cli/">v2.94.0</a>, shipped 2026-06-10. Before that release, every one of those relationships lived only in the GitHub.com web UI. Reading or changing a project’s issue tree from a script meant writing raw <code>gh api graphql</code> calls against the Issues GraphQL schema by hand, since <code>gh issue</code> itself had no concept of a parent, a sub-issue, or a dependency.</p>
</aside><h2 id="structural-comparison-matrix">Structural Comparison Matrix</h2>



































<table tabindex="0"><thead><tr><th scope="col" style="text-align:left">Operational Task</th><th scope="col" style="text-align:left">Before v2.94.0</th><th scope="col" style="text-align:left">gh CLI v2.94.0+</th></tr></thead><tbody><tr><td style="text-align:left"><strong>Set a parent issue</strong></td><td style="text-align:left">GitHub.com issue sidebar, or a handwritten <code>gh api graphql</code> mutation</td><td style="text-align:left"><code>gh issue edit &lt;number&gt; --parent &lt;number&gt;</code></td></tr><tr><td style="text-align:left"><strong>Link a sub-issue from the parent</strong></td><td style="text-align:left">Add it through the sub-issues panel in the web UI</td><td style="text-align:left"><code>gh issue edit &lt;parent&gt; --add-sub-issue &lt;number&gt;</code></td></tr><tr><td style="text-align:left"><strong>Read an issue’s hierarchy</strong></td><td style="text-align:left">Open the issue page and scroll to the sub-issues panel</td><td style="text-align:left"><code>gh issue view &lt;number&gt; --json parent,subIssues,subIssuesSummary</code></td></tr><tr><td style="text-align:left"><strong>Set a cross-issue dependency</strong></td><td style="text-align:left">No dedicated UI control; scripted against the GraphQL API</td><td style="text-align:left"><code>gh issue edit &lt;number&gt; --add-blocked-by &lt;number&gt;</code></td></tr><tr><td style="text-align:left"><strong>Filter issues by type</strong></td><td style="text-align:left"><code>type:</code> qualifier in the web search bar</td><td style="text-align:left"><code>gh issue list --type &lt;name&gt;</code></td></tr></tbody></table>
<h2 id="check-your-gh-version-before-any-of-this-works">Check your gh version before any of this works</h2>
<div class="code-block"><div class="code-block__header"><span class="code-block__filename"></span><button type="button" class="code-block__copy" data-copy-button>Copy</button></div><pre class="astro-code github-dark" style="background-color:#24292e;color:#e1e4e8;overflow-x:auto" tabindex="0" data-language="bash"><code><span class="line"><span style="color:#B392F0">gh</span><span style="color:#79B8FF"> --version</span></span></code></pre></div>
<p>Every flag in this post needs GitHub CLI v2.94.0 or later. If your version is older, update through whatever channel installed <code>gh</code>: <code>brew upgrade gh</code> on Homebrew, <code>winget upgrade --id GitHub.cli --source winget</code> on Windows, or your Linux package manager. This machine is running v2.97.0, and every command below was run against that install.</p>
<h2 id="set-change-or-remove-a-parent-issue">Set, change, or remove a parent issue</h2>
<p><code>gh issue edit</code> takes one number or URL argument for the parent, straight from <a href="https://cli.github.com/manual/gh_issue_edit">GitHub’s own manual page</a>:</p>
<div class="code-block code-block--has-filename"><div class="code-block__header"><span class="code-block__filename">link issue 23 under parent issue 100</span><button type="button" class="code-block__copy" data-copy-button>Copy</button></div><pre class="astro-code github-dark" style="background-color:#24292e;color:#e1e4e8;overflow-x:auto" tabindex="0" data-language="bash" data-filename="link issue 23 under parent issue 100"><code><span class="line"><span style="color:#B392F0">gh</span><span style="color:#9ECBFF"> issue</span><span style="color:#9ECBFF"> edit</span><span style="color:#79B8FF"> 23</span><span style="color:#79B8FF"> --parent</span><span style="color:#79B8FF"> 100</span></span></code></pre></div>
<p><code>--parent</code> does double duty. Run it once to link an unparented issue, or run it again later with a different number to move that issue under a new parent. There’s no separate flag for the two cases.</p>
<div class="code-block code-block--has-filename"><div class="code-block__header"><span class="code-block__filename">unlink the parent without closing or deleting the issue</span><button type="button" class="code-block__copy" data-copy-button>Copy</button></div><pre class="astro-code github-dark" style="background-color:#24292e;color:#e1e4e8;overflow-x:auto" tabindex="0" data-language="bash" data-filename="unlink the parent without closing or deleting the issue"><code><span class="line"><span style="color:#B392F0">gh</span><span style="color:#9ECBFF"> issue</span><span style="color:#9ECBFF"> edit</span><span style="color:#79B8FF"> 23</span><span style="color:#79B8FF"> --remove-parent</span></span></code></pre></div>
<p>GitHub’s own 2026-06-10 announcement describes the same capability this way:</p>
<blockquote>
<p>Link, change, or remove a parent with <code>--parent</code>, <code>--set-parent</code>, and <code>--remove-parent</code>.</p>
</blockquote>
<div class="callout" role="note" data-astro-cid-q2ml7llr><p class="callout__title" data-astro-cid-q2ml7llr><span aria-hidden="true" data-astro-cid-q2ml7llr>ℹ</span>That third flag doesn&#39;t exist in the shipped CLI</p><div class="callout__body" data-astro-cid-q2ml7llr><p>Running <code>gh issue edit --help</code> on a real v2.97.0 install shows only two flags
registered for parent handling: <code>--parent</code> and <code>--remove-parent</code>. The current
<a href="https://github.com/cli/cli/blob/trunk/pkg/cmd/issue/edit/edit.go"><code>pkg/cmd/issue/edit/edit.go</code></a>
source in <code>cli/cli</code> confirms it, just two calls to <code>cmd.Flags()</code> for this. A
script or an older tutorial that references <code>--set-parent</code> will fail with an
unknown-flag error. Use <code>--parent</code> for both the first link and any later
change; it does the job the announcement’s <code>--set-parent</code> describes.</p></div></div>
<h2 id="create-a-new-issue-directly-as-a-sub-issue">Create a new issue directly as a sub-issue</h2>
<p><code>gh issue create</code> takes the same <code>--parent</code> flag, so a new issue can join the hierarchy at creation instead of needing a follow-up <code>edit</code> call:</p>
<div class="code-block code-block--has-filename"><div class="code-block__header"><span class="code-block__filename">create issue 23&#39;s sibling, parented under 100 from the start</span><button type="button" class="code-block__copy" data-copy-button>Copy</button></div><pre class="astro-code github-dark" style="background-color:#24292e;color:#e1e4e8;overflow-x:auto" tabindex="0" data-language="bash" data-filename="create issue 23's sibling, parented under 100 from the start"><code><span class="line"><span style="color:#B392F0">gh</span><span style="color:#9ECBFF"> issue</span><span style="color:#9ECBFF"> create</span><span style="color:#79B8FF"> \</span></span>
<span class="line"><span style="color:#79B8FF">  --title</span><span style="color:#9ECBFF"> &quot;Fix login redirect loop after SSO callback&quot;</span><span style="color:#79B8FF"> \</span></span>
<span class="line"><span style="color:#79B8FF">  --body</span><span style="color:#9ECBFF"> &quot;Users land back on /login after a successful SSO callback instead of the app.&quot;</span><span style="color:#79B8FF"> \</span></span>
<span class="line"><span style="color:#79B8FF">  --parent</span><span style="color:#79B8FF"> 100</span></span></code></pre></div>
<p>The parent accepts a URL too, useful when the parent lives in a different repository: <code>--parent https://github.com/cli/go-gh/issues/42</code>, straight from the flag’s own documented example.</p>
<h2 id="link-and-unlink-sub-issues-from-the-parent-side">Link and unlink sub-issues from the parent side</h2>
<p>The same relationship can be managed from the parent issue instead of the child, and <code>--add-sub-issue</code> takes a comma-separated list for more than one at a time:</p>
<div class="code-block code-block--has-filename"><div class="code-block__header"><span class="code-block__filename">attach two existing issues as sub-issues of 100</span><button type="button" class="code-block__copy" data-copy-button>Copy</button></div><pre class="astro-code github-dark" style="background-color:#24292e;color:#e1e4e8;overflow-x:auto" tabindex="0" data-language="bash" data-filename="attach two existing issues as sub-issues of 100"><code><span class="line"><span style="color:#B392F0">gh</span><span style="color:#9ECBFF"> issue</span><span style="color:#9ECBFF"> edit</span><span style="color:#79B8FF"> 100</span><span style="color:#79B8FF"> --add-sub-issue</span><span style="color:#9ECBFF"> 123,124</span></span></code></pre></div>
<div class="code-block code-block--has-filename"><div class="code-block__header"><span class="code-block__filename">detach one of them again, without touching its open/closed state</span><button type="button" class="code-block__copy" data-copy-button>Copy</button></div><pre class="astro-code github-dark" style="background-color:#24292e;color:#e1e4e8;overflow-x:auto" tabindex="0" data-language="bash" data-filename="detach one of them again, without touching its open/closed state"><code><span class="line"><span style="color:#B392F0">gh</span><span style="color:#9ECBFF"> issue</span><span style="color:#9ECBFF"> edit</span><span style="color:#79B8FF"> 100</span><span style="color:#79B8FF"> --remove-sub-issue</span><span style="color:#79B8FF"> 124</span></span></code></pre></div>
<h2 id="read-the-hierarchy-back-with-json">Read the hierarchy back with —json</h2>
<p><code>gh issue view</code> and <code>gh issue list</code> both expose <code>parent</code>, <code>subIssues</code>, and <code>subIssuesSummary</code> as JSON fields, so a script can read the tree without scraping HTML. Run against a real public issue on <code>cli/cli</code> itself, this is the actual shape <code>gh</code> returns:</p>
<div class="code-block"><div class="code-block__header"><span class="code-block__filename"></span><button type="button" class="code-block__copy" data-copy-button>Copy</button></div><pre class="astro-code github-dark" style="background-color:#24292e;color:#e1e4e8;overflow-x:auto" tabindex="0" data-language="bash"><code><span class="line"><span style="color:#B392F0">gh</span><span style="color:#9ECBFF"> issue</span><span style="color:#9ECBFF"> view</span><span style="color:#79B8FF"> 5545</span><span style="color:#79B8FF"> --repo</span><span style="color:#9ECBFF"> cli/cli</span><span style="color:#79B8FF"> --json</span><span style="color:#9ECBFF"> parent,subIssues,subIssuesSummary</span></span></code></pre></div>
<div class="code-block code-block--has-filename"><div class="code-block__header"><span class="code-block__filename">real output, captured live against cli/cli issue 5545</span><button type="button" class="code-block__copy" data-copy-button>Copy</button></div><pre class="astro-code github-dark" style="background-color:#24292e;color:#e1e4e8;overflow-x:auto" tabindex="0" data-language="json" data-filename="real output, captured live against cli/cli issue 5545"><code><span class="line"><span style="color:#E1E4E8">{</span></span>
<span class="line"><span style="color:#79B8FF">  &quot;parent&quot;</span><span style="color:#E1E4E8">: </span><span style="color:#79B8FF">null</span><span style="color:#E1E4E8">,</span></span>
<span class="line"><span style="color:#79B8FF">  &quot;subIssues&quot;</span><span style="color:#E1E4E8">: { </span><span style="color:#79B8FF">&quot;nodes&quot;</span><span style="color:#E1E4E8">: [], </span><span style="color:#79B8FF">&quot;totalCount&quot;</span><span style="color:#E1E4E8">: </span><span style="color:#79B8FF">0</span><span style="color:#E1E4E8"> },</span></span>
<span class="line"><span style="color:#79B8FF">  &quot;subIssuesSummary&quot;</span><span style="color:#E1E4E8">: { </span><span style="color:#79B8FF">&quot;completed&quot;</span><span style="color:#E1E4E8">: </span><span style="color:#79B8FF">0</span><span style="color:#E1E4E8">, </span><span style="color:#79B8FF">&quot;percentCompleted&quot;</span><span style="color:#E1E4E8">: </span><span style="color:#79B8FF">0</span><span style="color:#E1E4E8">, </span><span style="color:#79B8FF">&quot;total&quot;</span><span style="color:#E1E4E8">: </span><span style="color:#79B8FF">0</span><span style="color:#E1E4E8"> }</span></span>
<span class="line"><span style="color:#E1E4E8">}</span></span></code></pre></div>
<p>That particular issue has no sub-issues linked, which is why the fields are empty. When they’re populated, <code>subIssues.nodes</code> holds the full list of child issue objects, and <code>subIssuesSummary</code> gives a ready-made completed/total/percentCompleted count, so a script doesn’t have to iterate the array itself to show progress.</p>
<h2 id="set-and-filter-by-issue-type">Set and filter by issue type</h2>
<div class="code-block code-block--has-filename"><div class="code-block__header"><span class="code-block__filename">set a type on an existing issue</span><button type="button" class="code-block__copy" data-copy-button>Copy</button></div><pre class="astro-code github-dark" style="background-color:#24292e;color:#e1e4e8;overflow-x:auto" tabindex="0" data-language="bash" data-filename="set a type on an existing issue"><code><span class="line"><span style="color:#B392F0">gh</span><span style="color:#9ECBFF"> issue</span><span style="color:#9ECBFF"> edit</span><span style="color:#79B8FF"> 23</span><span style="color:#79B8FF"> --type</span><span style="color:#9ECBFF"> Bug</span></span></code></pre></div>
<div class="code-block code-block--has-filename"><div class="code-block__header"><span class="code-block__filename">clear it again</span><button type="button" class="code-block__copy" data-copy-button>Copy</button></div><pre class="astro-code github-dark" style="background-color:#24292e;color:#e1e4e8;overflow-x:auto" tabindex="0" data-language="bash" data-filename="clear it again"><code><span class="line"><span style="color:#B392F0">gh</span><span style="color:#9ECBFF"> issue</span><span style="color:#9ECBFF"> edit</span><span style="color:#79B8FF"> 23</span><span style="color:#79B8FF"> --remove-type</span></span></code></pre></div>
<p><code>gh issue list</code> filters by the same field. This is real output from <code>cli/cli</code>’s own tracker, showing the full <code>issueType</code> object a populated field actually returns:</p>
<div class="code-block"><div class="code-block__header"><span class="code-block__filename"></span><button type="button" class="code-block__copy" data-copy-button>Copy</button></div><pre class="astro-code github-dark" style="background-color:#24292e;color:#e1e4e8;overflow-x:auto" tabindex="0" data-language="bash"><code><span class="line"><span style="color:#B392F0">gh</span><span style="color:#9ECBFF"> issue</span><span style="color:#9ECBFF"> list</span><span style="color:#79B8FF"> --repo</span><span style="color:#9ECBFF"> cli/cli</span><span style="color:#79B8FF"> --type</span><span style="color:#9ECBFF"> Bug</span><span style="color:#79B8FF"> --limit</span><span style="color:#79B8FF"> 1</span><span style="color:#79B8FF"> --json</span><span style="color:#9ECBFF"> number,title,issueType</span></span></code></pre></div>
<div class="code-block code-block--has-filename"><div class="code-block__header"><span class="code-block__filename">real output, captured live</span><button type="button" class="code-block__copy" data-copy-button>Copy</button></div><pre class="astro-code github-dark" style="background-color:#24292e;color:#e1e4e8;overflow-x:auto" tabindex="0" data-language="json" data-filename="real output, captured live"><code><span class="line"><span style="color:#E1E4E8">[</span></span>
<span class="line"><span style="color:#E1E4E8">  {</span></span>
<span class="line"><span style="color:#79B8FF">    &quot;issueType&quot;</span><span style="color:#E1E4E8">: {</span></span>
<span class="line"><span style="color:#79B8FF">      &quot;id&quot;</span><span style="color:#E1E4E8">: </span><span style="color:#9ECBFF">&quot;IT_kwDOA48Fh84AtH84&quot;</span><span style="color:#E1E4E8">,</span></span>
<span class="line"><span style="color:#79B8FF">      &quot;name&quot;</span><span style="color:#E1E4E8">: </span><span style="color:#9ECBFF">&quot;Bug&quot;</span><span style="color:#E1E4E8">,</span></span>
<span class="line"><span style="color:#79B8FF">      &quot;description&quot;</span><span style="color:#E1E4E8">: </span><span style="color:#9ECBFF">&quot;An unexpected problem or behavior&quot;</span><span style="color:#E1E4E8">,</span></span>
<span class="line"><span style="color:#79B8FF">      &quot;color&quot;</span><span style="color:#E1E4E8">: </span><span style="color:#9ECBFF">&quot;RED&quot;</span></span>
<span class="line"><span style="color:#E1E4E8">    },</span></span>
<span class="line"><span style="color:#79B8FF">    &quot;number&quot;</span><span style="color:#E1E4E8">: </span><span style="color:#79B8FF">9569</span><span style="color:#E1E4E8">,</span></span>
<span class="line"><span style="color:#79B8FF">    &quot;title&quot;</span><span style="color:#E1E4E8">: </span><span style="color:#9ECBFF">&quot;Can&#39;t install / update `gh` due to expired GPG key?&quot;</span></span>
<span class="line"><span style="color:#E1E4E8">  }</span></span>
<span class="line"><span style="color:#E1E4E8">]</span></span></code></pre></div>
<h2 id="set-cross-issue-dependencies">Set cross-issue dependencies</h2>
<p>Dependencies are separate from parent/child hierarchy. “Blocked by” and “blocking” describe ordering between two issues that don’t need a parent-child relationship at all:</p>
<div class="code-block code-block--has-filename"><div class="code-block__header"><span class="code-block__filename">issue 123 is blocked by 200, and itself blocks 300 and 301</span><button type="button" class="code-block__copy" data-copy-button>Copy</button></div><pre class="astro-code github-dark" style="background-color:#24292e;color:#e1e4e8;overflow-x:auto" tabindex="0" data-language="bash" data-filename="issue 123 is blocked by 200, and itself blocks 300 and 301"><code><span class="line"><span style="color:#B392F0">gh</span><span style="color:#9ECBFF"> issue</span><span style="color:#9ECBFF"> edit</span><span style="color:#79B8FF"> 123</span><span style="color:#79B8FF"> --add-blocked-by</span><span style="color:#79B8FF"> 200</span><span style="color:#79B8FF"> --add-blocking</span><span style="color:#9ECBFF"> 300,301</span></span></code></pre></div>
<p>Both flags take comma-separated lists, and both directions can be set in the same call, as shown in <code>gh issue edit</code>’s own <code>--help</code> output. Remove either side the same way:</p>
<div class="code-block code-block--has-filename"><div class="code-block__header"><span class="code-block__filename">clear the blocked-by relationship, leave blocking untouched</span><button type="button" class="code-block__copy" data-copy-button>Copy</button></div><pre class="astro-code github-dark" style="background-color:#24292e;color:#e1e4e8;overflow-x:auto" tabindex="0" data-language="bash" data-filename="clear the blocked-by relationship, leave blocking untouched"><code><span class="line"><span style="color:#B392F0">gh</span><span style="color:#9ECBFF"> issue</span><span style="color:#9ECBFF"> edit</span><span style="color:#79B8FF"> 123</span><span style="color:#79B8FF"> --remove-blocked-by</span><span style="color:#79B8FF"> 200</span></span></code></pre></div>
<p><code>gh issue create</code> has the equivalent flags for setting dependencies at creation time: <code>--blocked-by</code> and <code>--blocking</code>, both accepting the same comma-separated issue-number or URL lists.</p>
<h2 id="where-this-fits-in-the-wider-gh-cli-wave">Where this fits in the wider gh CLI wave</h2>
<p>Sub-issue and dependency support shipped in the same v2.94.0 release as <code>gh discussion</code>, GitHub CLI’s new command group for GitHub Discussions. Neither depends on the other; they landed the same day because both closed a gap where GitHub.com had a feature <code>gh</code> couldn’t touch at all. See <a href="/dev-tools/gh-discussion-command-github-cli/">gh discussion: GitHub Discussions in Your Terminal</a> for that side of the same release, and <a href="/dev-tools/github-cli-2026-agent-era-expansion/">GitHub CLI’s 2026 Agent-Era Expansion</a> for the full ten-part wave this cluster belongs to.</p>
<p>Reach for these flags the moment you’re managing more than a handful of issues in one hierarchy, triaging a big feature into sub-issues from a script, or wiring an agent to file work broken down by parent task automatically. For a one-off parent link on a single issue, the GitHub.com sidebar is still fewer keystrokes. Once you’re doing it more than once, <code>gh issue edit --parent</code> is the version that survives being scripted.</p>
<p>Browse more coverage like this in the <a href="/dev-tools">Dev Tools</a> archive.</p>]]></content:encoded>
      <pubDate>Wed, 19 Aug 2026 00:00:00 GMT</pubDate>
    </item>
    <item>
      <title>gh discussion: GitHub Discussions in Your Terminal</title>
      <link>https://bytetech247.com/dev-tools/gh-discussion-command-github-cli/</link>
      <guid isPermaLink="true">https://bytetech247.com/dev-tools/gh-discussion-command-github-cli/</guid>
      <description>gh discussion adds list, view, create, edit, and comment subcommands to GitHub CLI, giving GitHub Discussions the same terminal ergonomics as issues and PRs.</description>
      <content:encoded><![CDATA[<aside class="quick-answer" aria-label="Quick answer"><h2 id="quick-answer">Quick Answer</h2>
<p>Use <code>gh discussion list</code>, <code>view</code>, <code>create</code>, <code>edit</code>, and <code>comment</code> for day-to-day terminal work on GitHub Discussions: browsing threads, reading replies, opening a new discussion, or answering one. Reach for <code>gh api graphql</code> only when you need to close, lock, delete, or react to a discussion, since the command group doesn’t cover those yet. Requires GitHub CLI v2.94.0 or later.</p>
</aside><h2 id="what-gh-discussion-replaces">What gh discussion replaces</h2>
<p>GitHub Discussions has had a public GraphQL API for years. A repository exposes its threads through a <code>discussions</code> connection, and creating one meant firing a <code>createDiscussion</code> mutation by hand, but until 2026-06-10 there was no first-class <code>gh</code> command wrapping any of it.</p>
<p>Reading a thread’s replies meant writing a GraphQL query against that <code>discussions</code> connection yourself and paging through the results with <code>after</code>/<code>before</code> cursors, <a href="https://docs.github.com/en/graphql/guides/using-the-graphql-api-for-discussions">the same generic pagination pattern</a> every list field in GitHub’s GraphQL schema uses. Starting a new discussion meant looking up the target category’s internal node ID first, then composing a <code>createDiscussion</code> mutation with that ID, the repository’s ID, a title, and a body, all through <code>gh api graphql -f query=...</code>. <code>gh issue</code> and <code>gh pr</code> never asked a user to know GraphQL to do routine work. <code>gh discussion</code> closes that specific gap.</p>
<p>GitHub’s changelog entry for the release states the fix plainly:</p>
<blockquote>
<p>“Install or upgrade to GitHub CLI v2.94.0 to get started on any repository where GitHub Discussions is enabled.”</p>
</blockquote>
<p>That’s <a href="https://github.com/cli/cli/releases/tag/v2.94.0">the same v2.94.0 release</a> that added sub-issue and dependency flags to <code>gh issue</code>, shipped the same day. See <a href="/dev-tools/gh-cli-sub-issues-dependencies/">Manage GitHub Sub-Issues and Dependencies via gh CLI</a> for that half of the release; this post covers <code>gh discussion</code> only.</p>
<h2 id="structural-comparison-matrix">Structural Comparison Matrix</h2>
<p>| Operational Aspect                 | Before <code>gh discussion</code> (raw <code>gh api graphql</code>)                                                                                       | After (<code>gh discussion</code>, v2.94.0+)                                          |
| :--------------------------------- | :---------------------------------------------------------------------------------------------------------------------------------- | :------------------------------------------------------------------------- | -------- |
| <strong>Listing recent discussions</strong>     | Write a query against the repository’s <code>discussions</code> connection, handle cursor pagination by hand                                   | <code>gh discussion list --state &lt;state&gt; --label &lt;names&gt;</code>                       |
| <strong>Reading a thread’s replies</strong>     | Query nested comment/reply connections, tracking your own <code>after</code> cursor between pages                                              | <code>gh discussion view &lt;number&gt; --comments --order &lt;oldest                    | newest&gt;</code> |
| <strong>Starting a new discussion</strong>      | Look up the category’s node ID separately, then send a <code>createDiscussion</code> mutation with repository ID, category ID, title, and body | <code>gh discussion create --title &lt;title&gt; --category &lt;category&gt; --body &lt;body&gt;</code> |
| <strong>Editing title, body, or labels</strong> | Send an <code>updateDiscussion</code> mutation with only the fields you’re changing                                                            | <code>gh discussion edit 123 --title &quot;Updated title&quot; --add-label bug</code>           |
| <strong>Scripting against results</strong>      | Parse raw GraphQL JSON yourself                                                                                                     | <code>--json</code> plus <code>-q</code>/<code>--jq</code> for a filtered, scriptable field list            |</p>
<h2 id="before-you-run-anything-version-and-scope">Before you run anything: version and scope</h2>
<div class="code-block"><div class="code-block__header"><span class="code-block__filename"></span><button type="button" class="code-block__copy" data-copy-button>Copy</button></div><pre class="astro-code github-dark" style="background-color:#24292e;color:#e1e4e8;overflow-x:auto" tabindex="0" data-language="bash"><code><span class="line"><span style="color:#B392F0">gh</span><span style="color:#79B8FF"> --version</span></span></code></pre></div>
<p><code>gh discussion</code> needs GitHub CLI v2.94.0 or later, and it only works against a repository that has GitHub Discussions turned on; run it against a repo without that feature enabled and there’s nothing for the command to list. Every subcommand also accepts <code>-R</code>/<code>--repo &lt;[HOST/]OWNER/REPO&gt;</code> to target a repository other than the one in your current working directory.</p>
<div class="callout" role="note" data-astro-cid-q2ml7llr><p class="callout__title" data-astro-cid-q2ml7llr><span aria-hidden="true" data-astro-cid-q2ml7llr>ℹ</span>Still labeled preview</p><div class="callout__body" data-astro-cid-q2ml7llr><p><code>gh</code>’s own manual is direct about it: “Working with discussions in the GitHub
CLI is in preview and subject to change without notice.” Flag names and output
could shift in a later release, the same caveat that applies to <code>gh skill</code>.</p></div></div>
<h2 id="list-discussions-from-the-terminal">List discussions from the terminal</h2>
<div class="code-block"><div class="code-block__header"><span class="code-block__filename"></span><button type="button" class="code-block__copy" data-copy-button>Copy</button></div><pre class="astro-code github-dark" style="background-color:#24292e;color:#e1e4e8;overflow-x:auto" tabindex="0" data-language="bash"><code><span class="line"><span style="color:#9ca6b0"># every open discussion, most recently updated first (the defaults)</span></span>
<span class="line"><span style="color:#B392F0">gh</span><span style="color:#9ECBFF"> discussion</span><span style="color:#9ECBFF"> list</span></span>
<span class="line"></span>
<span class="line"><span style="color:#9ca6b0"># only General-category discussions</span></span>
<span class="line"><span style="color:#B392F0">gh</span><span style="color:#9ECBFF"> discussion</span><span style="color:#9ECBFF"> list</span><span style="color:#79B8FF"> --category</span><span style="color:#9ECBFF"> General</span></span>
<span class="line"></span>
<span class="line"><span style="color:#9ca6b0"># closed discussions from one author</span></span>
<span class="line"><span style="color:#B392F0">gh</span><span style="color:#9ECBFF"> discussion</span><span style="color:#9ECBFF"> list</span><span style="color:#79B8FF"> --state</span><span style="color:#9ECBFF"> closed</span><span style="color:#79B8FF"> --author</span><span style="color:#9ECBFF"> monalisa</span></span>
<span class="line"></span>
<span class="line"><span style="color:#9ca6b0"># any state, filtered by label</span></span>
<span class="line"><span style="color:#B392F0">gh</span><span style="color:#9ECBFF"> discussion</span><span style="color:#9ECBFF"> list</span><span style="color:#79B8FF"> --state</span><span style="color:#9ECBFF"> all</span><span style="color:#79B8FF"> --label</span><span style="color:#9ECBFF"> bug,enhancement</span></span>
<span class="line"></span>
<span class="line"><span style="color:#9ca6b0"># unanswered discussions, JSON output for scripting</span></span>
<span class="line"><span style="color:#B392F0">gh</span><span style="color:#9ECBFF"> discussion</span><span style="color:#9ECBFF"> list</span><span style="color:#79B8FF"> --answered=false</span><span style="color:#79B8FF"> --json</span><span style="color:#9ECBFF"> number,title,url</span></span></code></pre></div>
<p>Every one of those is a real example from <a href="https://cli.github.com/manual/gh_discussion_list"><code>gh</code>’s own manual for <code>gh discussion list</code></a>. <code>--state</code> accepts <code>open</code>, <code>closed</code>, or <code>all</code> (default <code>open</code>); <code>--sort</code> takes <code>created</code> or <code>updated</code> (default <code>updated</code>); <code>--order</code> takes <code>asc</code> or <code>desc</code> (default <code>desc</code>). The short alias <code>gh discussion ls</code> works too, matching the <code>gh issue ls</code> / <code>gh pr ls</code> convention the rest of <code>gh</code> already uses.</p>
<h2 id="read-a-discussion-and-its-replies">Read a discussion and its replies</h2>
<div class="code-block"><div class="code-block__header"><span class="code-block__filename"></span><button type="button" class="code-block__copy" data-copy-button>Copy</button></div><pre class="astro-code github-dark" style="background-color:#24292e;color:#e1e4e8;overflow-x:auto" tabindex="0" data-language="bash"><code><span class="line"><span style="color:#B392F0">gh</span><span style="color:#9ECBFF"> discussion</span><span style="color:#9ECBFF"> view</span><span style="color:#79B8FF"> 123</span></span>
<span class="line"><span style="color:#B392F0">gh</span><span style="color:#9ECBFF"> discussion</span><span style="color:#9ECBFF"> view</span><span style="color:#79B8FF"> 123</span><span style="color:#79B8FF"> --comments</span></span>
<span class="line"><span style="color:#B392F0">gh</span><span style="color:#9ECBFF"> discussion</span><span style="color:#9ECBFF"> view</span><span style="color:#79B8FF"> 123</span><span style="color:#79B8FF"> --comments</span><span style="color:#79B8FF"> --order</span><span style="color:#9ECBFF"> oldest</span></span>
<span class="line"><span style="color:#B392F0">gh</span><span style="color:#9ECBFF"> discussion</span><span style="color:#9ECBFF"> view</span><span style="color:#79B8FF"> 123</span><span style="color:#79B8FF"> --comments</span><span style="color:#79B8FF"> --limit</span><span style="color:#79B8FF"> 10</span></span></code></pre></div>
<p>Pass a bare number, a full discussion URL, a comment’s node ID, or a comment’s URL as the target; all four forms are documented and work interchangeably. <code>--comments</code> pulls the replies alongside the discussion body, <code>--order</code> controls whether they print oldest-first or newest-first (default <code>newest</code>), and <code>--limit</code> caps how many replies come back per page (default 30). Add <code>-w</code>/<code>--web</code> instead to skip the terminal output entirely and open the discussion in a browser.</p>
<h2 id="start-a-new-discussion">Start a new discussion</h2>
<div class="code-block"><div class="code-block__header"><span class="code-block__filename"></span><button type="button" class="code-block__copy" data-copy-button>Copy</button></div><pre class="astro-code github-dark" style="background-color:#24292e;color:#e1e4e8;overflow-x:auto" tabindex="0" data-language="bash"><code><span class="line"><span style="color:#9ca6b0"># interactive: gh prompts for category, title, and body</span></span>
<span class="line"><span style="color:#B392F0">gh</span><span style="color:#9ECBFF"> discussion</span><span style="color:#9ECBFF"> create</span></span>
<span class="line"></span>
<span class="line"><span style="color:#9ca6b0"># non-interactive: pass everything as flags</span></span>
<span class="line"><span style="color:#B392F0">gh</span><span style="color:#9ECBFF"> discussion</span><span style="color:#9ECBFF"> create</span><span style="color:#79B8FF"> --title</span><span style="color:#9ECBFF"> &quot;My question&quot;</span><span style="color:#79B8FF"> --category</span><span style="color:#9ECBFF"> &quot;Q&amp;A&quot;</span><span style="color:#79B8FF"> --body</span><span style="color:#9ECBFF"> &quot;Details here&quot;</span></span></code></pre></div>
<p>Both are <a href="https://cli.github.com/manual/gh_discussion_create">the documented examples</a> from <code>gh</code>’s manual. Running <code>gh discussion create</code> with no flags on an interactive terminal walks you through category, title, and body one at a time. For scripts, pass <code>--title</code>, <code>--category</code>, and either <code>--body</code> or <code>--body-file -</code> (the trailing <code>-</code> reads the body from stdin) to skip the prompts entirely. <code>--category</code> takes the category’s name or slug, not an internal ID, which is the piece the raw GraphQL mutation made you look up separately.</p>
<h2 id="edit-a-discussion-then-comment-on-it">Edit a discussion, then comment on it</h2>
<div class="code-block"><div class="code-block__header"><span class="code-block__filename"></span><button type="button" class="code-block__copy" data-copy-button>Copy</button></div><pre class="astro-code github-dark" style="background-color:#24292e;color:#e1e4e8;overflow-x:auto" tabindex="0" data-language="bash"><code><span class="line"><span style="color:#9ca6b0"># change title, body, and category together</span></span>
<span class="line"><span style="color:#B392F0">gh</span><span style="color:#9ECBFF"> discussion</span><span style="color:#9ECBFF"> edit</span><span style="color:#79B8FF"> 123</span><span style="color:#79B8FF"> --title</span><span style="color:#9ECBFF"> &quot;Updated title&quot;</span><span style="color:#79B8FF"> --body</span><span style="color:#9ECBFF"> &quot;Updated body&quot;</span><span style="color:#79B8FF"> --category</span><span style="color:#9ECBFF"> &quot;Ideas&quot;</span></span>
<span class="line"></span>
<span class="line"><span style="color:#9ca6b0"># add and remove labels in the same call</span></span>
<span class="line"><span style="color:#B392F0">gh</span><span style="color:#9ECBFF"> discussion</span><span style="color:#9ECBFF"> edit</span><span style="color:#79B8FF"> 123</span><span style="color:#79B8FF"> --add-label</span><span style="color:#9ECBFF"> &quot;bug,help wanted&quot;</span><span style="color:#79B8FF"> --remove-label</span><span style="color:#9ECBFF"> &quot;stale&quot;</span></span></code></pre></div>
<p><code>gh discussion edit</code> takes the same shape as <code>create</code>, plus <code>--add-label</code>/<code>--remove-label</code> for label changes that <code>create</code> doesn’t handle.</p>
<div class="code-block"><div class="code-block__header"><span class="code-block__filename"></span><button type="button" class="code-block__copy" data-copy-button>Copy</button></div><pre class="astro-code github-dark" style="background-color:#24292e;color:#e1e4e8;overflow-x:auto" tabindex="0" data-language="bash"><code><span class="line"><span style="color:#9ca6b0"># top-level comment</span></span>
<span class="line"><span style="color:#B392F0">gh</span><span style="color:#9ECBFF"> discussion</span><span style="color:#9ECBFF"> comment</span><span style="color:#79B8FF"> 123</span><span style="color:#79B8FF"> --body</span><span style="color:#9ECBFF"> &#39;Thanks&#39;</span></span>
<span class="line"></span>
<span class="line"><span style="color:#9ca6b0"># reply to a specific comment, by its URL</span></span>
<span class="line"><span style="color:#B392F0">gh</span><span style="color:#9ECBFF"> discussion</span><span style="color:#9ECBFF"> comment</span><span style="color:#9ECBFF"> &#39;https://github.com/OWNER/REPO/discussions/123#discussioncomment-456&#39;</span><span style="color:#79B8FF"> --body</span><span style="color:#9ECBFF"> &#39;Thanks&#39;</span></span>
<span class="line"></span>
<span class="line"><span style="color:#9ca6b0"># edit or delete a comment or reply</span></span>
<span class="line"><span style="color:#B392F0">gh</span><span style="color:#9ECBFF"> discussion</span><span style="color:#9ECBFF"> comment</span><span style="color:#9ECBFF"> &#39;https://github.com/OWNER/REPO/discussions/123#discussioncomment-456&#39;</span><span style="color:#79B8FF"> --edit</span><span style="color:#79B8FF"> --body</span><span style="color:#9ECBFF"> &#39;Updated&#39;</span></span>
<span class="line"><span style="color:#B392F0">gh</span><span style="color:#9ECBFF"> discussion</span><span style="color:#9ECBFF"> comment</span><span style="color:#9ECBFF"> &#39;https://github.com/OWNER/REPO/discussions/123#discussioncomment-456&#39;</span><span style="color:#79B8FF"> --delete</span><span style="color:#79B8FF"> --yes</span></span></code></pre></div>
<p><code>gh discussion comment</code> takes the same four target forms as <code>view</code>: a discussion number posts a new top-level comment, while a comment’s own URL or node ID replies to (or, with <code>--edit</code>/<code>--delete</code>, modifies) that specific comment. <code>--yes</code> skips the delete confirmation prompt, useful in a script where nothing is watching the terminal to answer it.</p>
<h2 id="what-gh-discussion-still-cant-do">What gh discussion still can’t do</h2>
<p>The five subcommands cover browsing and content, not moderation. <a href="https://docs.github.com/en/graphql/reference/discussions">GitHub’s GraphQL reference for Discussions</a> lists mutations that <code>gh discussion</code> doesn’t wrap at all: <code>closeDiscussion</code>, <code>reopenDiscussion</code>, <code>deleteDiscussion</code>, <code>addUpvote</code>/<code>removeUpvote</code>, and <code>markDiscussionCommentAsAnswer</code>. The <code>Discussion</code> type also implements GitHub’s generic <code>Lockable</code> interface, so <code>lockLockable</code>/<code>unlockLockable</code> apply too. Closing a discussion as resolved, deleting one outright, upvoting a reply, locking a thread, or marking a comment as the accepted answer all still mean dropping to <code>gh api graphql</code> and calling the matching mutation by hand, the exact friction this command group otherwise removes.</p>
<p>That gap is worth knowing before you script anything that depends on discussion state changing beyond editing content. A bot that needs to auto-close stale discussions, for instance, can list and read them through <code>gh discussion</code> but still needs a raw GraphQL call for the actual close.</p>
<h2 id="where-this-fits-in-github-clis-2026-agent-push">Where this fits in GitHub CLI’s 2026 agent push</h2>
<p><code>gh discussion</code> is one piece of a wider wave covered in <a href="/dev-tools/github-cli-2026-agent-era-expansion/">GitHub CLI’s 2026 agent-era expansion</a>, which also added <a href="/dev-tools/gh-skill-manage-ai-agent-skills/"><code>gh skill</code></a> for managing AI agent skills and, in this same v2.94.0 release, sub-issue and dependency flags on <code>gh issue</code> (see <a href="/dev-tools/gh-cli-sub-issues-dependencies/">Manage GitHub Sub-Issues and Dependencies via gh CLI</a>).</p>
<p>Reach for <code>gh discussion</code> the next time you’d otherwise open a browser tab to check on a repository’s Discussions tab, or the next time a script needs to read or post to one. Skip the raw <code>gh api graphql</code> route unless you land on one of the gaps above; hand-writing GraphQL for something <code>gh discussion list</code> already does in one line is wasted typing.</p>
<p>Browse more posts like this in the <a href="/dev-tools">Dev Tools</a> archive.</p>]]></content:encoded>
      <pubDate>Wed, 19 Aug 2026 00:00:00 GMT</pubDate>
    </item>
    <item>
      <title>gh release download No Longer Needs Auth (Public Repos)</title>
      <link>https://bytetech247.com/dev-tools/gh-release-download-no-auth-public-repos/</link>
      <guid isPermaLink="true">https://bytetech247.com/dev-tools/gh-release-download-no-auth-public-repos/</guid>
      <description>gh release download now works against public repos without auth, matching gh extension install. What changed in v2.96.0, and a CI example.</description>
      <content:encoded><![CDATA[<aside class="quick-answer" aria-label="Quick answer"><h2 id="quick-answer">Quick Answer</h2>
<p><code>gh release download</code> used to require an authenticated session even for public repos, hitting the same auth-gate error every other unauthenticated command threw. GitHub CLI v2.96.0 removes that gate for public repositories, matching <code>gh extension install</code>. Drop the <code>GH_TOKEN</code> line from CI steps that only pull public release assets; keep it for private repos.</p>
</aside><h2 id="what-the-auth-gate-actually-blocked">What the auth gate actually blocked</h2>
<p><code>gh</code> runs one shared check before most subcommands execute: is there a token, or a logged-in host, available. Individual commands don’t get their own custom login logic; they either run behind that shared gate or they’re explicitly marked to skip it. Before v2.96.0, <code>gh release download</code> wasn’t marked to skip it, so a CI step pulling a release asset from a completely public repo still hit the same “please authenticate” wall as any command that genuinely needed write access.</p>
<p><code>gh extension install</code> got the same exemption three months earlier, in v2.90.0. The problem there was more specific: Codespaces issues SAML-scoped tokens that fail authorization checks against extension repos they were never scoped for, so installing a public extension from inside a Codespace could fail even though installing it never needed elevated access. <a href="https://github.com/cli/cli/pull/13176">PR #13176</a> fixed it with a one-line opt-out, <code>cmdutil.DisableAuthCheck</code>, added specifically to the <code>extension install</code> subcommand.</p>
<p><a href="https://github.com/cli/cli/pull/13723">PR #13723</a>, which shipped the <code>gh release download</code> change in v2.96.0, points at that exact precedent:</p>
<blockquote>
<p>“Release endpoints for public repositories are readable anonymously over REST, so the login gate is unnecessary. Removing it is the same one-line <code>cmdutil.DisableAuthCheck</code> change that #13176 made for <code>gh extension install</code>.”</p>
</blockquote>
<p>Both fixes trace back to the same tracking issue, <a href="https://github.com/cli/cli/issues/2680">#2680, “Allow certain requests to be unauthenticated”</a> - a running list of <code>gh</code> commands that were gated by default even when the underlying GitHub API endpoint never required a session in the first place.</p>
<h2 id="structural-comparison-matrix">Structural Comparison Matrix</h2>



































<table tabindex="0"><thead><tr><th scope="col" style="text-align:left">Operational Aspect</th><th scope="col" style="text-align:left">Before v2.96.0</th><th scope="col" style="text-align:left">v2.96.0+</th></tr></thead><tbody><tr><td style="text-align:left"><strong>Public repo, no token present</strong></td><td style="text-align:left">Fails at the shared auth gate before the download starts</td><td style="text-align:left">Succeeds; the REST release endpoint is read anonymously</td></tr><tr><td style="text-align:left"><strong>Private repo, no token present</strong></td><td style="text-align:left">Fails at the shared auth gate</td><td style="text-align:left">Fails differently: “release not found,” the same response as a nonexistent repo</td></tr><tr><td style="text-align:left"><strong>Token present</strong></td><td style="text-align:left">Used for the request</td><td style="text-align:left">Still honored opportunistically; nothing about supplying one changed</td></tr><tr><td style="text-align:left"><strong>Parity with <code>gh extension install</code></strong></td><td style="text-align:left">None; that command got its own-command exemption in v2.90.0 (April 2026)</td><td style="text-align:left">Matched; both now carry the same <code>cmdutil.DisableAuthCheck</code> opt-out</td></tr><tr><td style="text-align:left"><strong>CI wiring for a public-asset step</strong></td><td style="text-align:left">A <code>GH_TOKEN</code> env line was required just to clear the gate</td><td style="text-align:left">Optional; only needed for private repos or to get the higher rate limit</td></tr></tbody></table>
<h2 id="what-actually-happens-now-verified-live">What actually happens now, verified live</h2>
<div class="callout" role="note" data-astro-cid-q2ml7llr><p class="callout__title" data-astro-cid-q2ml7llr><span aria-hidden="true" data-astro-cid-q2ml7llr>ℹ</span>How this was tested</p><div class="callout__body" data-astro-cid-q2ml7llr><p>Since GitHub CLI v2.96.0 has already shipped, showing the old behavior meant
pointing <code>gh</code> at an empty, isolated config directory with no <code>GH_TOKEN</code> or
<code>GITHUB_TOKEN</code> set, so it had zero stored credentials to fall back on. <code>gh   auth status</code> confirmed that isolated session was genuinely unauthenticated
before either command below ran against it.</p></div></div>
<p>With that isolated, credential-free config, <code>gh release download</code> against a real public release succeeds with no token at all:</p>
<div class="code-block code-block--has-filename"><div class="code-block__header"><span class="code-block__filename">unauthenticated gh release download, real repo, real asset</span><button type="button" class="code-block__copy" data-copy-button>Copy</button></div><pre class="astro-code github-dark" style="background-color:#24292e;color:#e1e4e8;overflow-x:auto" tabindex="0" data-language="bash" data-filename="unauthenticated gh release download, real repo, real asset"><code><span class="line"><span style="color:#B392F0">$</span><span style="color:#9ECBFF"> gh</span><span style="color:#9ECBFF"> release</span><span style="color:#9ECBFF"> download</span><span style="color:#9ECBFF"> v2.96.0</span><span style="color:#79B8FF"> --repo</span><span style="color:#9ECBFF"> cli/cli</span><span style="color:#79B8FF"> -p</span><span style="color:#9ECBFF"> &quot;gh_2.96.0_checksums.txt&quot;</span></span>
<span class="line"><span style="color:#B392F0">$</span><span style="color:#9ECBFF"> ls</span></span>
<span class="line"><span style="color:#B392F0">gh_2.96.0_checksums.txt</span></span></code></pre></div>
<p>No prompt, no error, exit code 0. That’s <code>gh</code>’s own v2.96.0 checksums file, pulled from <code>cli/cli</code>’s public release, using the exact repo the release notes’ own example points at.</p>
<p>The shared auth gate is still very much alive elsewhere in the same binary. <code>gh release view</code> never got the same opt-out <code>gh release download</code> did, so running it against the same isolated, unauthenticated session reproduces the exact wall <code>gh release download</code> itself used to hit before v2.96.0:</p>
<div class="code-block code-block--has-filename"><div class="code-block__header"><span class="code-block__filename">a command that still requires auth, same isolated session</span><button type="button" class="code-block__copy" data-copy-button>Copy</button></div><pre class="astro-code github-dark" style="background-color:#24292e;color:#e1e4e8;overflow-x:auto" tabindex="0" data-language="bash" data-filename="a command that still requires auth, same isolated session"><code><span class="line"><span style="color:#B392F0">$</span><span style="color:#9ECBFF"> gh</span><span style="color:#9ECBFF"> release</span><span style="color:#9ECBFF"> view</span><span style="color:#9ECBFF"> v2.96.0</span><span style="color:#79B8FF"> --repo</span><span style="color:#9ECBFF"> cli/cli</span><span style="color:#79B8FF"> --json</span><span style="color:#9ECBFF"> assets</span></span>
<span class="line"><span style="color:#B392F0">To</span><span style="color:#9ECBFF"> get</span><span style="color:#9ECBFF"> started</span><span style="color:#9ECBFF"> with</span><span style="color:#9ECBFF"> GitHub</span><span style="color:#9ECBFF"> CLI,</span><span style="color:#9ECBFF"> please</span><span style="color:#9ECBFF"> run:</span><span style="color:#9ECBFF">  gh</span><span style="color:#9ECBFF"> auth</span><span style="color:#9ECBFF"> login</span></span>
<span class="line"><span style="color:#B392F0">Alternatively,</span><span style="color:#9ECBFF"> populate</span><span style="color:#9ECBFF"> the</span><span style="color:#9ECBFF"> GH_TOKEN</span><span style="color:#9ECBFF"> environment</span><span style="color:#9ECBFF"> variable</span></span>
<span class="line"><span style="color:#B392F0">with</span><span style="color:#9ECBFF"> a</span><span style="color:#9ECBFF"> GitHub</span><span style="color:#9ECBFF"> API</span><span style="color:#9ECBFF"> authentication</span><span style="color:#9ECBFF"> token.</span></span></code></pre></div>
<p>Exit code 4. That message comes from the same shared pre-command check <code>gh release download</code> was routed through before v2.96.0; only the per-command opt-out changed, not the message itself.</p>
<h2 id="use-it-in-ci-drop-the-token-for-public-assets">Use it in CI: drop the token for public assets</h2>
<p><code>gh</code> ships preinstalled on GitHub-hosted runners, so a workflow step pulling a public release asset no longer needs a token wired in just to clear the auth gate:</p>
<div class="code-block code-block--has-filename"><div class="code-block__header"><span class="code-block__filename">.github/workflows/fetch-release-asset.yml</span><button type="button" class="code-block__copy" data-copy-button>Copy</button></div><pre class="astro-code github-dark" style="background-color:#24292e;color:#e1e4e8;overflow-x:auto" tabindex="0" data-language="yaml" data-filename=".github/workflows/fetch-release-asset.yml"><code><span class="line"><span style="color:#85E89D">name</span><span style="color:#E1E4E8">: </span><span style="color:#9ECBFF">Fetch a public release asset</span></span>
<span class="line"><span style="color:#79B8FF">on</span><span style="color:#E1E4E8">:</span></span>
<span class="line"><span style="color:#85E89D">  workflow_dispatch</span><span style="color:#E1E4E8">:</span></span>
<span class="line"></span>
<span class="line"><span style="color:#85E89D">jobs</span><span style="color:#E1E4E8">:</span></span>
<span class="line"><span style="color:#85E89D">  download</span><span style="color:#E1E4E8">:</span></span>
<span class="line"><span style="color:#85E89D">    runs-on</span><span style="color:#E1E4E8">: </span><span style="color:#9ECBFF">ubuntu-latest</span></span>
<span class="line"><span style="color:#85E89D">    steps</span><span style="color:#E1E4E8">:</span></span>
<span class="line"><span style="color:#E1E4E8">      - </span><span style="color:#85E89D">name</span><span style="color:#E1E4E8">: </span><span style="color:#9ECBFF">Download checksums from a public release</span></span>
<span class="line"><span style="color:#85E89D">        run</span><span style="color:#E1E4E8">: </span><span style="color:#F97583">&gt;</span></span>
<span class="line"><span style="color:#9ECBFF">          gh release download v2.96.0 --repo cli/cli</span></span>
<span class="line"><span style="color:#9ECBFF">          -p &quot;gh_2.96.0_checksums.txt&quot; --clobber</span></span></code></pre></div>
<p>No <code>env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}</code> line, and no PAT stored as a repo secret for a step that never needed write access in the first place. <code>--clobber</code> overwrites a stale file from a previous run instead of failing; <code>--skip-existing</code> does the opposite if the goal is to avoid re-downloading unchanged assets, and <code>-D &lt;directory&gt;</code> picks a target other than the current directory, per <a href="https://cli.github.com/manual/gh_release_download"><code>gh</code>’s own manual for <code>gh release download</code></a>.</p>
<p>One caveat worth keeping in mind: dropping the token doesn’t remove every reason to keep one. Unauthenticated requests share GitHub’s public rate limit of 60 requests per hour, against 5,000 per hour for an authenticated request. A workflow that only downloads one asset from one public repo won’t notice. A workflow that also calls the API repeatedly in the same run, across matrix jobs sharing the same runner IP, might still want the token, for the rate limit headroom, not because the download itself demands it anymore.</p>
<h2 id="what-else-shipped-in-the-same-release">What else shipped in the same release</h2>
<p><a href="https://github.com/cli/cli/releases/tag/v2.96.0">v2.96.0</a> wasn’t a single-change release. The same version fixed a real remote-code-execution path in <code>gh codespace jupyter</code>, where a malicious Codespace could hand the CLI a <code>vscode://</code> URL and get command execution on the victim’s machine after one click-through. That fix is unrelated to the auth-gate change here and covered on its own in <a href="/dev-tools/gh-cli-codespace-jupyter-rce-fixed/">Update gh CLI Now: Codespace Jupyter RCE Fixed</a>.</p>
<p>Both changes, along with the rest of <code>gh</code>’s 2026 expansion into agent-skill management and Discussions support, are covered in <a href="/dev-tools/github-cli-2026-agent-era-expansion">GitHub CLI’s 2026 agent-era expansion</a>, the hub this post is part of.</p>
<p>Drop the token from any CI step that only downloads a public release asset; it’s dead weight now, not a requirement. Keep it anywhere the repo is private, or where the same job leans on the API enough that the rate-limit gap actually matters.</p>
<p>Browse more coverage like this in the <a href="/dev-tools">Dev Tools</a> archive.</p>]]></content:encoded>
      <pubDate>Wed, 19 Aug 2026 00:00:00 GMT</pubDate>
    </item>
    <item>
      <title>gh skill: Manage AI Agent Skills From GitHub CLI</title>
      <link>https://bytetech247.com/dev-tools/gh-skill-manage-ai-agent-skills/</link>
      <guid isPermaLink="true">https://bytetech247.com/dev-tools/gh-skill-manage-ai-agent-skills/</guid>
      <description>gh skill installs, searches, updates, and publishes AI agent skills for Claude Code, Cursor, and more, right from GitHub CLI.</description>
      <content:encoded><![CDATA[<aside class="quick-answer" aria-label="Quick answer"><h2 id="quick-answer">Quick Answer</h2>
<p>Use <code>gh skill install &lt;repo&gt; &lt;skill&gt; --agent &lt;name&gt;</code> to add a published skill to one agent with no manual cloning. Run <code>gh skill search &lt;query&gt;</code> first when you don’t know the exact skill name; it queries GitHub’s Code Search API for <code>SKILL.md</code> files. Requires GitHub CLI v2.90.0 or later; the feature is still labeled preview.</p>
</aside><h2 id="what-gh-skill-replaces">What gh skill replaces</h2>
<p>Before GitHub <a href="https://github.blog/changelog/2026-04-16-manage-agent-skills-with-github-cli/">announced <code>gh skill</code></a> on 2026-04-16, installing an agent skill, a portable package of instructions, scripts, and resources built to the Agent Skills spec, for a specific coding agent meant finding the right repository, cloning or downloading it, and copying files into whatever directory that particular agent expected. You did that by hand, once per agent, with no install command, no update path, and no way to publish a skill back out that any agent’s CLI understood natively.</p>
<p><code>gh skill</code> puts that whole lifecycle behind a binary most developers already have installed. It ships five subcommands: <code>install</code>, <code>preview</code>, <code>search</code>, <code>update</code>, and <code>publish</code>, all sharing the same <code>--agent</code> flag so the same command works whether the target is Claude Code, GitHub Copilot, Cursor, Codex, Gemini CLI, or Antigravity. Beyond those six, the flag recognizes more than 30 agents total.</p>
<p>It’s worth being precise about what’s shipping here: <code>gh skill</code> is part of the standard GitHub CLI, <code>cli/cli</code>, the same binary that runs <code>gh pr create</code> and <code>gh issue list</code>. It’s a different product from the separate GitHub Copilot CLI.</p>
<h2 id="structural-comparison-matrix">Structural Comparison Matrix</h2>



































<table tabindex="0"><thead><tr><th scope="col" style="text-align:left">Operational Aspect</th><th scope="col" style="text-align:left">Manual per-agent install (before <code>gh skill</code>)</th><th scope="col" style="text-align:left"><code>gh skill</code> (v2.90.0+)</th></tr></thead><tbody><tr><td style="text-align:left"><strong>Adding a skill to one agent</strong></td><td style="text-align:left">Clone or download the repo, copy files into that agent’s own skills directory by hand</td><td style="text-align:left"><code>gh skill install &lt;repo&gt; &lt;skill&gt; --agent &lt;name&gt;</code></td></tr><tr><td style="text-align:left"><strong>Discovering skills</strong></td><td style="text-align:left">Browse GitHub manually or search the web</td><td style="text-align:left"><code>gh skill search &lt;query&gt;</code> queries the GitHub Code Search API for <code>SKILL.md</code> files</td></tr><tr><td style="text-align:left"><strong>Checking a skill before installing</strong></td><td style="text-align:left">Open the repo in a browser and read the files</td><td style="text-align:left"><code>gh skill preview &lt;repo&gt; [&lt;skill&gt;]</code> renders <code>SKILL.md</code> in your terminal pager</td></tr><tr><td style="text-align:left"><strong>Keeping skills current</strong></td><td style="text-align:left">Re-clone or re-copy by hand, no version tracking</td><td style="text-align:left"><code>gh skill update</code> compares the installed tree SHA against the remote repo</td></tr><tr><td style="text-align:left"><strong>Targeting a specific agent</strong></td><td style="text-align:left">Look up each agent’s own expected directory manually</td><td style="text-align:left">One <code>--agent</code> flag, recognized across 30+ agents</td></tr></tbody></table>
<h2 id="before-you-install-version-and-scope">Before you install: version and scope</h2>
<div class="code-block"><div class="code-block__header"><span class="code-block__filename"></span><button type="button" class="code-block__copy" data-copy-button>Copy</button></div><pre class="astro-code github-dark" style="background-color:#24292e;color:#e1e4e8;overflow-x:auto" tabindex="0" data-language="bash"><code><span class="line"><span style="color:#B392F0">gh</span><span style="color:#79B8FF"> --version</span></span></code></pre></div>
<p><code>gh skill</code> needs GitHub CLI v2.90.0 or later. If your version is older, update through whatever channel you installed <code>gh</code> with (<code>brew upgrade gh</code> on Homebrew, <code>winget upgrade --id GitHub.cli --source winget</code> on Windows, or your Linux package manager) before any command below will work.</p>
<p>Two more things worth knowing before your first install:</p>
<ul>
<li><code>--scope</code> controls where a skill lands. The default, <code>project</code>, installs into the current git repository. <code>user</code> installs into your home directory instead, making the skill available across every project.</li>
<li>Project scope isn’t a separate folder per agent, either: GitHub Copilot, Cursor, Codex, Cline, OpenCode, and Warp all point at the same <code>.agents/skills</code> path, so installing once there covers every one of those six without repeating the command.</li>
</ul>
<h2 id="install-a-skill-for-one-agent">Install a skill for one agent</h2>
<p>This is the <a href="https://cli.github.com/manual/gh_skill_install">documented example command</a> from GitHub’s own manual:</p>
<div class="code-block"><div class="code-block__header"><span class="code-block__filename"></span><button type="button" class="code-block__copy" data-copy-button>Copy</button></div><pre class="astro-code github-dark" style="background-color:#24292e;color:#e1e4e8;overflow-x:auto" tabindex="0" data-language="bash"><code><span class="line"><span style="color:#B392F0">gh</span><span style="color:#9ECBFF"> skill</span><span style="color:#9ECBFF"> install</span><span style="color:#9ECBFF"> github/awesome-copilot</span><span style="color:#9ECBFF"> documentation-writer</span><span style="color:#79B8FF"> --agent</span><span style="color:#9ECBFF"> claude-code</span><span style="color:#79B8FF"> --scope</span><span style="color:#9ECBFF"> user</span></span></code></pre></div>
<ol>
<li><code>github/awesome-copilot</code> is the repository the skill lives in.</li>
<li><code>documentation-writer</code> is the skill’s name inside that repo. Add <code>@v1.2.0</code> or <code>@&lt;commit-sha&gt;</code> after the name to pin an exact version instead of resolving to the latest tag.</li>
<li><code>--agent claude-code</code> is the target agent. Omit this flag in a non-interactive run and <code>gh</code> defaults to <code>github-copilot</code> instead, which is easy to miss if you’re scripting installs for a different agent.</li>
<li><code>--scope user</code> installs to your home directory instead of the current repo, so the skill is available to Claude Code across every project you open, not just the one you ran the command from.</li>
</ol>
<div class="callout" role="note" data-astro-cid-q2ml7llr><p class="callout__title" data-astro-cid-q2ml7llr><span aria-hidden="true" data-astro-cid-q2ml7llr>ℹ</span>This is still a preview feature</p><div class="callout__body" data-astro-cid-q2ml7llr><p>GitHub’s own docs are direct about it: “Working with agent skills in the
GitHub CLI is in preview and subject to change without notice.” Flag behavior,
output format, and even subcommand names could shift in a later release, so
don’t wire <code>gh skill</code> into a CI pipeline you can’t easily adjust afterward.</p></div></div>
<h2 id="search-preview-update-and-publish">Search, preview, update, and publish</h2>
<p>Four more subcommands round out the surface, each with a real, documented example:</p>
<div class="code-block"><div class="code-block__header"><span class="code-block__filename"></span><button type="button" class="code-block__copy" data-copy-button>Copy</button></div><pre class="astro-code github-dark" style="background-color:#24292e;color:#e1e4e8;overflow-x:auto" tabindex="0" data-language="bash"><code><span class="line"><span style="color:#9ca6b0"># find a skill when you don&#39;t know its exact name</span></span>
<span class="line"><span style="color:#B392F0">gh</span><span style="color:#9ECBFF"> skill</span><span style="color:#9ECBFF"> search</span><span style="color:#9ECBFF"> terraform</span></span>
<span class="line"></span>
<span class="line"><span style="color:#9ca6b0"># read a skill&#39;s SKILL.md in your terminal before installing it</span></span>
<span class="line"><span style="color:#B392F0">gh</span><span style="color:#9ECBFF"> skill</span><span style="color:#9ECBFF"> preview</span><span style="color:#9ECBFF"> github/awesome-copilot</span><span style="color:#9ECBFF"> documentation-writer</span></span>
<span class="line"></span>
<span class="line"><span style="color:#9ca6b0"># check every installed skill for updates and apply them</span></span>
<span class="line"><span style="color:#B392F0">gh</span><span style="color:#9ECBFF"> skill</span><span style="color:#9ECBFF"> update</span><span style="color:#79B8FF"> --all</span></span>
<span class="line"></span>
<span class="line"><span style="color:#9ca6b0"># validate a skill you&#39;re building against the Agent Skills spec, without publishing yet</span></span>
<span class="line"><span style="color:#B392F0">gh</span><span style="color:#9ECBFF"> skill</span><span style="color:#9ECBFF"> publish</span><span style="color:#79B8FF"> --dry-run</span></span></code></pre></div>
<p><code>gh skill search</code> runs against the GitHub Code Search API, matching your query against a skill’s name and description inside <code>SKILL.md</code> files; add <code>--owner &lt;name&gt;</code> to scope results to one organization. <code>gh skill preview</code> fetches and renders a skill’s <code>SKILL.md</code> without installing anything, useful for reading what a skill actually does before it touches your agent’s config. <code>gh skill update</code> compares each installed skill’s local tree SHA, stored in its own frontmatter, against the remote repository, and skips anything installed with <code>--pin</code> unless you also pass <code>--unpin</code>. <code>gh skill publish</code> checks a local skill’s frontmatter and naming against the Agent Skills spec before creating a GitHub release; <code>--fix</code> autocorrects what it can, stripping install metadata, for example, without publishing.</p>
<p>Pinning matters most for CI and provisioning scripts, where a moving tag could resolve to a different commit between runs. Covering when to reach for a tag versus a commit SHA is its own topic; for reproducible CI installs, see <a href="/dev-tools/gh-skill-pin-tag-vs-commit-sha">gh skill —pin: Tag vs Commit SHA, Which to Use</a>.</p>
<h2 id="where-this-fits-in-github-clis-2026-agent-push">Where this fits in GitHub CLI’s 2026 agent push</h2>
<p><code>gh skill</code> is one of ten changes covered in <a href="/dev-tools/github-cli-2026-agent-era-expansion">GitHub CLI’s 2026 agent-era expansion</a>, the wider wave that also added <code>gh discussion</code>, sub-issue management, and <code>gh agent-task</code> between April and July 2026.</p>
<p>Reach for <code>gh skill install</code> the next time you’re setting up a new machine or onboarding a teammate onto an agent your team already standardized on. It replaces a clone-and-copy step that used to look different for every agent with one command that stays the same across all of them. Skip it for a one-off skill you’re only testing locally; a plain <code>git clone</code> into the right folder is still less typing than learning five new subcommands for something you’ll delete in an hour.</p>
<p>Browse more posts like this in the <a href="/dev-tools">Dev Tools</a> archive.</p>]]></content:encoded>
      <pubDate>Wed, 19 Aug 2026 00:00:00 GMT</pubDate>
    </item>
    <item>
      <title>gh skill --pin: Tag vs Commit SHA, Which to Use</title>
      <link>https://bytetech247.com/dev-tools/gh-skill-pin-tag-vs-commit-sha/</link>
      <guid isPermaLink="true">https://bytetech247.com/dev-tools/gh-skill-pin-tag-vs-commit-sha/</guid>
      <description>gh skill install --pin accepts a tag or a commit SHA. Pin to a SHA for CI reproducibility; a tag is fine for casual, supervised installs.</description>
      <content:encoded><![CDATA[<p><code>gh skill install --pin</code> takes two different kinds of value: a git tag like <code>v1.2.0</code>, or a commit SHA like <code>abc123def</code>. GitHub’s own manual for the command names both as valid targets for the flag. They aren’t equally safe to build automation around, though. A tag can move. A commit SHA cannot.</p>
<p>That difference only matters once pinning is worth doing at all, which usually means a CI pipeline or a provisioning script installing the same skill on every run, not a one-off skill you’re trying out locally on your own machine.</p>
<aside class="quick-answer" aria-label="Quick answer"><h2 id="quick-answer">Quick Answer</h2>
<p>Pin to a commit SHA (<code>--pin abc123def</code>) for CI pipelines and provisioning scripts, where a script needs the exact same content installed on every run. Pin to a tag (<code>--pin v1.2.0</code>) for a casual, human-run install, where the tag reads clearly, and you’d likely notice if something looked wrong. A tag is a mutable pointer; a commit SHA is not.</p>
</aside><h2 id="what---pin-actually-resolves-to">What <code>--pin</code> actually resolves to</h2>
<p>Run without <code>--pin</code>, <code>gh skill install</code> resolves to the latest tagged release, falling back to the default branch’s HEAD if no tag exists. <code>--pin</code> overrides that resolution. GitHub’s manual for <a href="https://cli.github.com/manual/gh_skill_install"><code>gh skill install</code></a> states the mechanism plainly:</p>
<blockquote>
<p>“The version is resolved as a git tag or commit SHA.”</p>
</blockquote>
<p>Both of the following are documented, working commands, taken directly from <a href="https://github.blog/changelog/2026-04-16-manage-agent-skills-with-github-cli/">GitHub’s own changelog announcement</a>:</p>

<div class="code-tabs" data-astro-cid-tezb7zhy><div class="code-tabs__tablist" data-astro-cid-tezb7zhy><label class="code-tabs__label" data-astro-cid-tezb7zhy><input type="radio" name="code-tabs-ba3545a4-79b8-41b8-bda5-08e27df17ad0" checked class="code-tabs__radio" data-astro-cid-tezb7zhy><span data-astro-cid-tezb7zhy>Pin to a tag</span></label><label class="code-tabs__label" data-astro-cid-tezb7zhy><input type="radio" name="code-tabs-ba3545a4-79b8-41b8-bda5-08e27df17ad0" class="code-tabs__radio" data-astro-cid-tezb7zhy><span data-astro-cid-tezb7zhy>Pin to a commit SHA</span></label></div><div class="code-tabs__panel" data-panel-index="0" data-astro-cid-tezb7zhy><div class="code-block"><div class="code-block__header"><span class="code-block__filename"></span><button type="button" class="code-block__copy" data-copy-button>Copy</button></div><pre class="astro-code github-dark" style="background-color:#24292e;color:#e1e4e8;overflow-x:auto" tabindex="0" data-language="bash"><code><span class="line"><span style="color:#9ca6b0"># Pin to a release tag</span></span>
<span class="line"><span style="color:#B392F0">gh</span><span style="color:#9ECBFF"> skill</span><span style="color:#9ECBFF"> install</span><span style="color:#9ECBFF"> github/awesome-copilot</span><span style="color:#9ECBFF"> documentation-writer</span><span style="color:#79B8FF"> --pin</span><span style="color:#9ECBFF"> v1.2.0</span></span></code></pre></div></div><div class="code-tabs__panel" data-panel-index="1" data-astro-cid-tezb7zhy><div class="code-block"><div class="code-block__header"><span class="code-block__filename"></span><button type="button" class="code-block__copy" data-copy-button>Copy</button></div><pre class="astro-code github-dark" style="background-color:#24292e;color:#e1e4e8;overflow-x:auto" tabindex="0" data-language="bash"><code><span class="line"><span style="color:#9ca6b0"># Pin to a commit for maximum reproducibility</span></span>
<span class="line"><span style="color:#B392F0">gh</span><span style="color:#9ECBFF"> skill</span><span style="color:#9ECBFF"> install</span><span style="color:#9ECBFF"> github/awesome-copilot</span><span style="color:#9ECBFF"> documentation-writer</span><span style="color:#79B8FF"> --pin</span><span style="color:#9ECBFF"> abc123def</span></span></code></pre></div></div></div>
<p>Both commands install the same skill from the same repository. The only difference is what <code>--pin</code> points at, and that’s exactly where the two options stop being interchangeable.</p>
<h2 id="why-a-tag-is-a-mutable-pointer">Why a tag is a mutable pointer</h2>
<p>A git tag is a name attached to a commit, not the commit itself. By default, nothing stops a repository owner from deleting <code>v1.2.0</code> and recreating it against a different commit, whether that’s an honest re-release, a compromised account, or a supply-chain attack. <code>--pin v1.2.0</code> run today and the identical command run six months from now can resolve to different content, even though the flag and the value never changed on your end.</p>
<p>GitHub gives repo owners a way to close that gap. Its changelog describes <code>gh skill publish</code> offering to enable immutable releases:</p>
<blockquote>
<p>“Enabling immutable releases, for example, means even if someone gets control of your repository they cannot change existing releases, so users installing via tag pinning are fully protected.”</p>
</blockquote>
<p>That’s real protection, but notice whose decision it is. Enabling immutable releases is a setting the <em>source</em> repository turns on, not something the <code>--pin</code> flag or your own install command controls. The install command from the previous section doesn’t tell you whether <code>github/awesome-copilot</code> has that setting flipped on; nothing about the command line changes when it does.</p>
<div class="callout" role="note" data-astro-cid-q2ml7llr><p class="callout__title" data-astro-cid-q2ml7llr><span aria-hidden="true" data-astro-cid-q2ml7llr>✓</span>Check what a tag currently resolves to</p><div class="callout__body" data-astro-cid-q2ml7llr><p><code>git ls-remote &lt;repo-url&gt; &lt;tag&gt;</code> prints the exact commit SHA a tag points at
right now, without cloning anything: <code>git ls-remote   https://github.com/github/awesome-copilot v1.2.0</code>. Useful for converting a
tag you’re about to pin into the specific SHA it currently means, so your
<code>--pin</code> value stops depending on the tag staying put.</p></div></div>
<h2 id="why-a-commit-sha-cant-move">Why a commit SHA can’t move</h2>
<p>A commit SHA isn’t a label someone assigned; it’s computed from the commit’s own content: its tree, its parent, its author and message. Change any of that and the SHA changes with it. GitHub’s changelog calls this “content-addressed change detection,” and the same principle backs <code>gh skill update</code>’s own change checks: each installed skill’s git tree SHA gets recorded locally and compared against the remote, catching real content changes rather than just version bumps.</p>
<p>That same property is what makes a SHA a safe thing to automate against. Nobody can push a different commit that reuses <code>abc123def</code> and have <code>gh skill install --pin abc123def</code> silently fetch something else. The tradeoff is readability: a tag tells a human roughly what they’re getting at a glance, and a 7-to-40-character hex string doesn’t mean anything without looking up what it points to first.</p>
<h2 id="structural-comparison-matrix">Structural Comparison Matrix</h2>






























<table tabindex="0"><thead><tr><th scope="col" style="text-align:left">Operational Dimension</th><th scope="col" style="text-align:left">Tag (<code>--pin v1.2.0</code>)</th><th scope="col" style="text-align:left">Commit SHA (<code>--pin abc123def</code>)</th></tr></thead><tbody><tr><td style="text-align:left"><strong>Mutability</strong></td><td style="text-align:left">Points at whatever commit the tag currently references; the repo owner can delete and recreate it to point elsewhere</td><td style="text-align:left">Content-addressed: the SHA is derived from the commit’s own content, so it can’t be reassigned to different content</td></tr><tr><td style="text-align:left"><strong>Reproducibility across separate installs</strong></td><td style="text-align:left">Depends on a setting you don’t control: whether the source repo has immutable releases turned on</td><td style="text-align:left">Guaranteed by git’s own object model, regardless of what the source repo does or doesn’t configure</td></tr><tr><td style="text-align:left"><strong>Human readability</strong></td><td style="text-align:left">Reads as a version at a glance (<code>v1.2.0</code>)</td><td style="text-align:left">Opaque hex string, meaningless without looking up what it actually points to</td></tr><tr><td style="text-align:left"><strong>Best fit</strong></td><td style="text-align:left">A casual, human-run install where you’d likely notice if something looked wrong</td><td style="text-align:left">CI pipelines and provisioning scripts, where nobody is watching each run</td></tr></tbody></table>
<h2 id="what-happens-after-you-pin">What happens after you pin</h2>
<p>Pinning doesn’t just change what gets installed once. It also changes how <a href="https://cli.github.com/manual/gh_skill_update"><code>gh skill update</code></a> treats that skill afterward, for either kind of value. GitHub’s manual is direct about it:</p>
<blockquote>
<p>“Pinned skills (installed with <code>--pin</code>) are skipped with a notice. Use <code>--unpin</code> to clear the pinned version and include those skills in the update.”</p>
</blockquote>
<div class="code-block code-block--has-filename"><div class="code-block__header"><span class="code-block__filename">pinned skills stay put until you say otherwise</span><button type="button" class="code-block__copy" data-copy-button>Copy</button></div><pre class="astro-code github-dark" style="background-color:#24292e;color:#e1e4e8;overflow-x:auto" tabindex="0" data-language="bash" data-filename="pinned skills stay put until you say otherwise"><code><span class="line"><span style="color:#9ca6b0"># a skill installed with --pin gets a notice here, not an update</span></span>
<span class="line"><span style="color:#B392F0">gh</span><span style="color:#9ECBFF"> skill</span><span style="color:#9ECBFF"> update</span><span style="color:#79B8FF"> --all</span></span>
<span class="line"></span>
<span class="line"><span style="color:#9ca6b0"># clear every pin and let those skills update normally again</span></span>
<span class="line"><span style="color:#B392F0">gh</span><span style="color:#9ECBFF"> skill</span><span style="color:#9ECBFF"> update</span><span style="color:#79B8FF"> --unpin</span></span></code></pre></div>
<p>That skip behavior is identical for a tag pin and a SHA pin, which is worth stating plainly because it’s easy to assume otherwise: <code>--unpin</code> doesn’t care which kind of value you originally passed. What it doesn’t do is retroactively fix a tag-based install if the tag it resolved to at install time already pointed at compromised content. Pinning protects an already-installed skill from moving out from under you later. It doesn’t protect the moment of installation itself, and for a tag, that moment is exactly where the mutability risk lives.</p>
<h2 id="which-one-to-reach-for">Which one to reach for</h2>
<p>Reach for <code>--pin abc123def</code> anywhere a script installs the skill without a person watching: CI, a provisioning step, an onboarding script that runs unattended on a new machine. The reproducibility guarantee comes from git’s own object model, not from trusting a repo’s settings or a person’s memory. Reach for <code>--pin v1.2.0</code> for a skill you’re installing yourself, at your own keyboard, where a version tag is faster to read and a moved tag is something you’d likely notice going wrong in real time. This choice is one piece of <code>gh skill</code>’s broader install/manage/publish surface; see <a href="/dev-tools/gh-skill-manage-ai-agent-skills/">gh skill: Manage AI Agent Skills From GitHub CLI</a> for the rest of that command group, and <a href="/dev-tools/github-cli-2026-agent-era-expansion/">GitHub CLI’s 2026 agent-era expansion</a> for how this fits the wider wave of changes <code>gh</code> shipped around it.</p>
<p>Browse more posts like this in the <a href="/dev-tools">Dev Tools</a> archive.</p>]]></content:encoded>
      <pubDate>Wed, 19 Aug 2026 00:00:00 GMT</pubDate>
    </item>
    <item>
      <title>GitHub CLI&apos;s 2026 Agent-Era Expansion: Full Guide</title>
      <link>https://bytetech247.com/dev-tools/github-cli-2026-agent-era-expansion/</link>
      <guid isPermaLink="true">https://bytetech247.com/dev-tools/github-cli-2026-agent-era-expansion/</guid>
      <description>GitHub CLI (gh) added skill, discussion, and sub-issue commands in 2026, plus two security fixes. Full guide to all 10 changes and what to do.</description>
      <content:encoded><![CDATA[<aside class="quick-answer" aria-label="Quick answer"><h2 id="quick-answer">Quick Answer</h2>
<p>GitHub CLI (<code>gh</code>) shipped a dense wave of changes between April and July 2026: new <code>gh skill</code>, <code>gh discussion</code>, and sub-issue commands, a PGP signing-key rotation, and two security-driven point releases fixing a real remote-code-execution bug and a terminal-injection issue spanning seven commands. This is the hub for a 10-part series covering every piece, not the separate GitHub Copilot CLI product. Update to at least v2.97.0 first, then work through the table below.</p>
</aside><h2 id="why-gh-became-more-than-an-api-wrapper">Why gh became more than an API wrapper</h2>
<p><code>gh</code> started as a terminal front end for the same actions the GitHub web UI already handled: open an issue, review a PR, check a workflow run. Between April and July 2026, that scope expanded in two directions at once, inside the same four-month window, without either direction being announced as a coordinated plan.</p>
<p>On the agent side, <code>gh skill</code> (2026-04-16) gave <code>gh</code> a first cross-agent command for installing, pinning, and publishing agent skills. <code>gh agent-task</code> extended that further, letting a terminal session kick off GitHub’s own asynchronous Copilot coding-agent runs. Neither of these is the separate GitHub Copilot CLI product (<code>github/copilot-cli</code>), which had its own unrelated general-availability release in February 2026 and a terminal-interface GA in June 2026. This guide is about the classic <code>gh</code> tool, the <code>cli/cli</code> project, not that product.</p>
<p>On the security side, two point releases landed within a month of each other and fixed real, dated vulnerabilities rather than theoretical ones. v2.96.0 patched a remote-code-execution path in <code>gh codespace jupyter</code>. v2.97.0 sanitized terminal escape sequences across seven separate commands. Neither was cosmetic, and both are reasons to check your installed version today rather than just read about the new commands below.</p>
<h2 id="structural-comparison-matrix">Structural Comparison Matrix</h2>











































































<table tabindex="0"><thead><tr><th scope="col" style="text-align:left">#</th><th scope="col" style="text-align:left">Change</th><th scope="col" style="text-align:left">Shipped</th><th scope="col" style="text-align:left">Type</th><th scope="col" style="text-align:left">Applies to you if…</th></tr></thead><tbody><tr><td style="text-align:left">1</td><td style="text-align:left"><code>gh skill</code> command group</td><td style="text-align:left">2026-04-16 (v2.90.0+)</td><td style="text-align:left">New capability</td><td style="text-align:left">You install or manage AI agent skills across Claude Code, Copilot, Cursor, Codex, Gemini CLI, or Antigravity</td></tr><tr><td style="text-align:left">2</td><td style="text-align:left"><code>gh skill install --pin</code></td><td style="text-align:left">2026-04-16 (same release)</td><td style="text-align:left">Config / security choice</td><td style="text-align:left">You provision skills in CI or need a reproducible install, not just a moving tag</td></tr><tr><td style="text-align:left">3</td><td style="text-align:left"><code>gh discussion</code> command group</td><td style="text-align:left">2026-06-10 (v2.94.0)</td><td style="text-align:left">New capability</td><td style="text-align:left">You use GitHub Discussions and want terminal parity with issues and PRs</td></tr><tr><td style="text-align:left">4</td><td style="text-align:left">Sub-issues, issue types, dependencies</td><td style="text-align:left">2026-06-10 (v2.94.0)</td><td style="text-align:left">New capability</td><td style="text-align:left">You manage issue hierarchies or script bulk changes to a project’s issue tree</td></tr><tr><td style="text-align:left">5</td><td style="text-align:left"><code>gh codespace jupyter</code> RCE fixed</td><td style="text-align:left">2026-07-02 (v2.96.0)</td><td style="text-align:left">Security fix, GHSA-8cg3-r6g9-fpg2</td><td style="text-align:left">You’ve ever run <code>gh codespace jupyter</code> on a version between v2.10.0 and v2.95.0</td></tr><tr><td style="text-align:left">6</td><td style="text-align:left"><code>gh release download</code> auth requirement dropped</td><td style="text-align:left">2026-07-02 (v2.96.0)</td><td style="text-align:left">Behavior change</td><td style="text-align:left">Your CI pulls public release assets and carries a token it no longer needs</td></tr><tr><td style="text-align:left">7</td><td style="text-align:left">Terminal-injection issue fixed (7 commands)</td><td style="text-align:left">2026-07-31 (v2.97.0)</td><td style="text-align:left">Security fix, GHSA-3m3g-3wcr-px46</td><td style="text-align:left">You run <code>gh api</code>, <code>gh pr diff</code>, <code>gh gist view</code>, or four other commands against repos you don’t fully trust</td></tr><tr><td style="text-align:left">8</td><td style="text-align:left">New PGP signing key for Linux packages</td><td style="text-align:left">2026-04-08</td><td style="text-align:left">Security / provisioning</td><td style="text-align:left">You install or update <code>gh</code> via an <code>apt</code>/<code>yum</code>-style Linux package repo</td></tr><tr><td style="text-align:left">9</td><td style="text-align:left"><code>gh agent-task</code> (preview)</td><td style="text-align:left">Ongoing, still preview</td><td style="text-align:left">New capability</td><td style="text-align:left">You want to drive GitHub’s asynchronous Copilot coding-agent sessions from a terminal</td></tr></tbody></table>
<p>Every row is confirmed against <code>gh</code>’s own changelog entry, official manual page, or the relevant GHSA advisory for that release, not summarized from memory. Full mechanism, exact commands, and the fix live in each linked post below.</p>
<h2 id="new-command-surfaces-skill-discussion-and-issue-hierarchy">New command surfaces: skill, discussion, and issue hierarchy</h2>
<p><code>gh skill</code> is the biggest structural addition. The changelog puts the scope plainly:</p>
<blockquote>
<p>“install, pin, search, update, and publish agent skills”</p>
</blockquote>
<p>That is the full lifecycle, across Claude Code, GitHub Copilot, Cursor, Codex, Gemini CLI, and Antigravity, through one <code>--agent</code> flag rather than six separate per-tool workflows. Installing a skill from a repo looks like this:</p>
<div class="code-block code-block--has-filename"><div class="code-block__header"><span class="code-block__filename">install a skill, pinned to a tag</span><button type="button" class="code-block__copy" data-copy-button>Copy</button></div><pre class="astro-code github-dark" style="background-color:#24292e;color:#e1e4e8;overflow-x:auto" tabindex="0" data-language="bash" data-filename="install a skill, pinned to a tag"><code><span class="line"><span style="color:#B392F0">gh</span><span style="color:#9ECBFF"> skill</span><span style="color:#9ECBFF"> install</span><span style="color:#9ECBFF"> github/awesome-copilot</span><span style="color:#9ECBFF"> documentation-writer</span><span style="color:#79B8FF"> --pin</span><span style="color:#9ECBFF"> v1.2.0</span></span></code></pre></div>
<p>Pinning matters more than it looks. A tag like <code>v1.2.0</code> can be reassigned to point at a different commit unless the repo owner has Immutable Release turned on, so a CI pipeline pinned to a tag isn’t guaranteed reproducible. Pinning to a commit SHA instead is the option that’s actually reproducible:</p>
<div class="code-block code-block--has-filename"><div class="code-block__header"><span class="code-block__filename">install a skill, pinned to a commit SHA</span><button type="button" class="code-block__copy" data-copy-button>Copy</button></div><pre class="astro-code github-dark" style="background-color:#24292e;color:#e1e4e8;overflow-x:auto" tabindex="0" data-language="bash" data-filename="install a skill, pinned to a commit SHA"><code><span class="line"><span style="color:#B392F0">gh</span><span style="color:#9ECBFF"> skill</span><span style="color:#9ECBFF"> install</span><span style="color:#9ECBFF"> github/awesome-copilot</span><span style="color:#9ECBFF"> documentation-writer</span><span style="color:#79B8FF"> --pin</span><span style="color:#9ECBFF"> abc123def</span></span></code></pre></div>
<p>That distinction is documented directly in <a href="https://cli.github.com/manual/gh_skill_install"><code>gh</code>’s own manual for <code>gh skill install</code></a>, not buried in a GitHub issue thread.</p>
<p>Two months later, v2.94.0 (2026-06-10) shipped two more command groups in the same release. <code>gh discussion</code> (list, view, create, edit, comment) gave GitHub Discussions the same terminal parity issues and PRs already had, so working with a Discussion no longer means falling back to raw <code>gh api</code> calls against the Discussions GraphQL schema. The same release added issue types, sub-issues, and cross-issue dependencies to <code>gh issue</code>, through <code>--parent</code>, <code>--set-parent</code>, and <code>--remove-parent</code> flags, turning issue hierarchy from a GitHub.com-only feature into something a script can read and change directly.</p>
<p><code>gh agent-task</code> (create, view, list) is the newest of the group and still labeled preview. Per its own manual page, a task is what the command actually operates on:</p>
<blockquote>
<p>“a task is a GitHub issue that triggers automated code changes from natural language instructions”</p>
</blockquote>
<p>That makes <code>gh agent-task</code> a terminal driver for the same class of automation GitHub’s Actions platform tackled from a different angle around the same time. See <a href="/dev-tools/github-actions-agentic-workflows-setup/">Set Up GitHub Agentic Workflows in Actions</a> for that Actions-side surface, a separate product from <code>gh agent-task</code> covered here, built on Markdown-to-YAML compilation instead of a CLI-driven task.</p>
<h2 id="security-two-point-releases-and-a-key-rotation-worth-acting-on">Security: two point releases and a key rotation worth acting on</h2>
<p>v2.96.0 (2026-07-02) fixed a real remote-code-execution path. <code>gh codespace jupyter</code> opened a JupyterLab URL handed to it by the Codespace itself, without checking that the URL actually pointed at a loopback address. The advisory states the risk plainly:</p>
<blockquote>
<p>“connecting to a malicious Codespace via gh codespace jupyter can allow command execution”</p>
</blockquote>
<p>A crafted <code>vscode://</code> URL from inside a malicious or compromised Codespace could hand off command execution to the victim’s own machine, tracked as GHSA-8cg3-r6g9-fpg2. The same release changed <code>gh release download</code> to work against public repos without requiring authentication, matching how <code>gh extension install</code> already behaved, useful for a CI pipeline that was carrying a token for no real access-control reason.</p>
<div class="callout" role="note" data-astro-cid-q2ml7llr><p class="callout__title" data-astro-cid-q2ml7llr><span aria-hidden="true" data-astro-cid-q2ml7llr>⚠</span>Update gh before you do anything else in this guide</p><div class="callout__body" data-astro-cid-q2ml7llr><p>Two security-driven releases in one wave means the practical first step here
isn’t reading the rest of this guide, it’s running your package manager’s
update command and confirming <code>gh --version</code> reports at least 2.97.0.</p></div></div>
<p>Four weeks later, v2.97.0 (2026-07-31) fixed a single terminal escape-sequence injection issue, tracked as GHSA-3m3g-3wcr-px46, that spanned seven separate commands. (v2.97.0’s release notes also fixed three other, unrelated advisories that same day, a URL-path-escaping bug, an auth-token leak, and an attestation-verify bypass, worth naming so “four” isn’t mistaken for the count of terminal-injection bugs specifically.) Seven commands printed content someone else controlled, a gist body, an API response, a PR diff, agent-task output, without stripping raw terminal escape sequences first: <code>gh gist view</code>, <code>gh api</code>, <code>gh pr diff</code>, <code>gh release download --output -</code>, <code>gh codespace logs</code>, <code>gh skills preview</code>, and <code>gh agent-task view</code>/<code>create</code>. Any of those run against a malicious repo, gist, or PR could manipulate the victim’s own terminal, not just print unwanted text.</p>
<p>Underneath both releases sits a smaller but still real change: a PGP signing-key rotation for <code>gh</code>’s Linux package repos, shipped 2026-04-08. GitHub published a dual-key keyring, the current key alongside its replacement, so an <code>apt</code>/<code>yum</code>-style install or update during the rotation window doesn’t fail on a signature mismatch.</p>
<h2 id="the-10-pieces-of-this-guide">The 10 pieces of this guide</h2>
<p>This hub is the starting point for a 10-part series. All ten pieces are published below.</p>
<ol>
<li><strong><a href="/dev-tools/gh-skill-manage-ai-agent-skills/">gh skill: Manage AI Agent Skills From GitHub CLI</a></strong>: the command itself, cross-agent install/manage/publish.</li>
<li><strong><a href="/dev-tools/gh-skill-pin-tag-vs-commit-sha/">gh skill —pin: Tag vs Commit SHA, Which to Use</a></strong>: version pinning mechanics.</li>
<li><strong><a href="/dev-tools/gh-discussion-command-github-cli/">gh discussion: GitHub Discussions in Your Terminal</a></strong>: the new Discussions command group.</li>
<li><strong><a href="/dev-tools/gh-cli-sub-issues-dependencies/">Manage GitHub Sub-Issues and Dependencies via gh CLI</a></strong>: issue hierarchy flags.</li>
<li><strong><a href="/dev-tools/gh-cli-codespace-jupyter-rce-fixed/">Update gh CLI Now: Codespace Jupyter RCE Fixed</a></strong>: the v2.96.0 security fix.</li>
<li><strong><a href="/dev-tools/gh-cli-2-97-0-terminal-injection-fixes/">gh CLI 2.97.0 Fixes Terminal Injection in 7 Commands</a></strong>: the v2.97.0 security fixes.</li>
<li><strong><a href="/dev-tools/gh-release-download-no-auth-public-repos/">gh release download No Longer Needs Auth (Public Repos)</a></strong>: the auth-behavior change.</li>
<li><strong><a href="/dev-tools/gh-cli-new-pgp-signing-key-linux/">gh CLI’s New PGP Signing Key for Linux Packages</a></strong>: the key rotation.</li>
<li><strong><a href="/dev-tools/gh-agent-task-copilot-coding-sessions/">gh agent-task: Run Copilot Coding Sessions From gh</a></strong>: driving Copilot coding agent from the CLI.</li>
<li><strong><a href="/dev-tools/github-cli-ai-agent-control-surface/">How GitHub CLI Became an AI-Agent Control Surface</a></strong>: closing synthesis.</li>
</ol>
<h2 id="update-your-own-gh-install-before-you-rely-on-any-of-this">Update your own gh install before you rely on any of this</h2>
<p>None of the commands above help if the copy of <code>gh</code> on your machine predates them. Check what you’re actually running first:</p>
<div class="code-block"><div class="code-block__header"><span class="code-block__filename"></span><button type="button" class="code-block__copy" data-copy-button>Copy</button></div><pre class="astro-code github-dark" style="background-color:#24292e;color:#e1e4e8;overflow-x:auto" tabindex="0" data-language="bash"><code><span class="line"><span style="color:#B392F0">gh</span><span style="color:#79B8FF"> --version</span></span></code></pre></div>
<p><code>gh</code> has no built-in self-update command, so update through whatever installed it in the first place: <code>brew upgrade gh</code> on macOS, <code>apt update &amp;&amp; apt install gh</code> on Debian/Ubuntu, <code>scoop update gh</code> on Windows, or a fresh binary from <a href="https://cli.github.com/">cli.github.com</a> if you installed manually. If an <code>apt</code>/<code>yum</code>-style update fails on a signature error, the April 2026 key rotation is the first thing worth checking, not a broken mirror. Anything before v2.94.0 is missing <code>gh discussion</code> and sub-issues entirely. Anything before v2.97.0 is missing both security fixes.</p>
<p>Five months of changelog entries don’t usually deserve one guide. This wave does, because it’s the same tool becoming two different things at once: a control surface for AI-agent tooling, and a CLI that needed two security-driven releases in one month. Update first, then decide which of the ten pieces above actually changes your own workflow.</p>
<p>Browse more coverage like this in the <a href="/dev-tools">Dev Tools</a> archive.</p>]]></content:encoded>
      <pubDate>Wed, 19 Aug 2026 00:00:00 GMT</pubDate>
    </item>
    <item>
      <title>How GitHub CLI Became an AI-Agent Control Surface</title>
      <link>https://bytetech247.com/dev-tools/github-cli-ai-agent-control-surface/</link>
      <guid isPermaLink="true">https://bytetech247.com/dev-tools/github-cli-ai-agent-control-surface/</guid>
      <description>GitHub CLI (gh) shipped skill, discussion, and issue-hierarchy commands in 2026. See how gh became a real control surface for AI-agent tooling.</description>
      <content:encoded><![CDATA[<aside class="quick-answer" aria-label="Quick answer"><h2 id="quick-answer">Quick Answer</h2>
<p>Update <code>gh</code> to v2.97.0 or later regardless of which commands you use; two of this wave’s six changes are security fixes, not features. Then adopt <code>gh skill</code> and <code>gh agent-task</code> if you drive AI agents from a terminal, and <code>gh discussion</code> plus the sub-issue flags if you manage repo hierarchy or Discussions by script.</p>
</aside><h2 id="six-changes-two-directions-one-four-month-window">Six changes, two directions, one four-month window</h2>
<p>Between April and July 2026, <code>gh</code> shipped four new command groups and two security-driven point releases. Read as separate changelog entries, each looks routine: a new command here, a patched CVE there. Read together, against real dates, they point the same direction twice.</p>
<p><code>gh skill</code> (2026-04-16) and <code>gh agent-task</code> (still preview) give <code>gh</code> a terminal surface for AI-agent tooling: installing skills across more than 30 agents, and driving GitHub’s own asynchronous Copilot coding-agent runs. <code>gh discussion</code> and the sub-issue and dependency flags on <code>gh issue</code>, both shipped in the same v2.94.0 release on 2026-06-10, give <code>gh</code> a terminal surface for repo hierarchy that used to live only in the web UI or a handwritten GraphQL call. Underneath both threads, v2.96.0 and v2.97.0 closed a real remote-code-execution path and a real terminal-injection bug, hardening the same command surface that now touches more content it didn’t write itself: agent output, someone else’s PR diff, someone else’s gist.</p>
<p>That’s the actual synthesis, not a single command worth learning. <code>gh</code> spent four months becoming a control surface for two things GitHub used to gate behind a browser: an AI agent’s own tooling, and a repository’s own issue tree.</p>
<h2 id="structural-comparison-matrix">Structural Comparison Matrix</h2>



































<table tabindex="0"><thead><tr><th scope="col" style="text-align:left">Capability</th><th scope="col" style="text-align:left">Before this wave (pre-April 2026 <code>gh</code>)</th><th scope="col" style="text-align:left">After this wave (through v2.97.0)</th></tr></thead><tbody><tr><td style="text-align:left"><strong>Cross-agent skill install/update</strong></td><td style="text-align:left">Clone or download a repo, copy files into each agent’s own directory by hand, no update path</td><td style="text-align:left"><code>gh skill install/update/publish --agent &lt;name&gt;</code>, one flag across 30+ agents</td></tr><tr><td style="text-align:left"><strong>GitHub Discussions from the terminal</strong></td><td style="text-align:left">Raw <code>gh api graphql</code> against the Discussions schema, cursor pagination by hand</td><td style="text-align:left"><code>gh discussion list/view/create/edit/comment</code></td></tr><tr><td style="text-align:left"><strong>Issue hierarchy and dependencies</strong></td><td style="text-align:left">GitHub.com sidebar only, or a handwritten GraphQL mutation</td><td style="text-align:left"><code>gh issue edit --parent</code>, <code>--add-sub-issue</code>, <code>--add-blocked-by</code></td></tr><tr><td style="text-align:left"><strong>Driving Copilot’s async coding agent</strong></td><td style="text-align:left">No terminal path; GitHub.com only</td><td style="text-align:left"><code>gh agent-task create/view/list</code> (still preview)</td></tr><tr><td style="text-align:left"><strong>Terminal safety against untrusted content</strong></td><td style="text-align:left">Escape sequences in a gist, diff, or agent-task output printed raw</td><td style="text-align:left">Sanitized by default since v2.97.0, opt out only with <code>--allow-escape-sequences</code></td></tr></tbody></table>
<p>Every row above is sourced from the cluster post that actually researched it, linked below, not re-derived here.</p>
<h2 id="thread-one-gh-becomes-agent-tooling">Thread one: gh becomes agent tooling</h2>
<p><code>gh skill</code> gave <code>gh</code> its first real lifecycle for AI-agent skills: install, preview, search, update, and publish, all behind one <code>--agent</code> flag that recognizes more than 30 coding agents by name. <a href="/dev-tools/gh-skill-manage-ai-agent-skills/">gh skill: Manage AI Agent Skills From GitHub CLI</a> covers the full command set and the tag-versus-commit-SHA pinning distinction that matters for reproducible CI installs.</p>
<p><code>gh agent-task</code> extends the same thread further. <a href="https://cli.github.com/manual/gh_agent-task">Its own manual page</a> states plainly what it operates on:</p>
<blockquote>
<p>“a task is a GitHub issue that triggers automated code changes from natural language instructions”</p>
</blockquote>
<p>That makes it a terminal driver for GitHub’s own Copilot coding agent, still labeled preview, and one of the newest command groups this wave shipped. No dedicated cluster post exists for it yet in this series, so this synthesis leans on that manual definition and the hub’s own description rather than research this article didn’t do. It’s worth distinguishing from a separate GitHub product covering similar ground from a different angle: see the FAQ below on how it differs from GitHub Agentic Workflows in Actions.</p>
<h2 id="thread-two-gh-becomes-a-repo-hierarchy-client">Thread two: gh becomes a repo-hierarchy client</h2>
<p><code>gh discussion</code> and the sub-issue and dependency flags on <code>gh issue</code> landed in the same v2.94.0 release, 2026-06-10, closing two unrelated gaps at once. Neither depends on the other; they shipped together because both moved a GitHub.com-only feature into a scriptable terminal command.</p>
<p><a href="/dev-tools/gh-discussion-command-github-cli/">gh discussion: GitHub Discussions in Your Terminal</a> covers list, view, create, edit, and comment, plus what the command group still can’t do (closing, locking, or deleting a discussion still needs raw <code>gh api graphql</code>). <a href="/dev-tools/gh-cli-sub-issues-dependencies/">Manage GitHub Sub-Issues and Dependencies via gh CLI</a> covers <code>--parent</code>, <code>--add-sub-issue</code>, and <code>--add-blocked-by</code>, and one real correction worth repeating here rather than re-deriving: GitHub’s own changelog for that release names a <code>--set-parent</code> flag that does not exist in the shipped CLI. Only <code>--parent</code> and <code>--remove-parent</code> are registered; <code>--parent</code> handles both the first link and any later change.</p>
<h2 id="the-security-cost-of-touching-more-untrusted-content">The security cost of touching more untrusted content</h2>
<p>A command surface that reads more content it didn’t write, gist files, PR diffs, agent-task output, is also a command surface with more to sanitize before printing it to your screen. Both of this wave’s security releases fall out of that same expansion.</p>
<p>v2.96.0 (2026-07-02) closed a real remote-code-execution path in <code>gh codespace jupyter</code>: a crafted <code>vscode://</code> URL from inside a malicious or compromised Codespace could hand off command execution to the victim’s own machine, tracked as <a href="https://github.com/cli/cli/security/advisories/GHSA-8cg3-r6g9-fpg2">GHSA-8cg3-r6g9-fpg2</a>. Full detail in <a href="/dev-tools/gh-cli-codespace-jupyter-rce-fixed/">Update gh CLI Now: Codespace Jupyter RCE Fixed</a>.</p>
<div class="callout" role="note" data-astro-cid-q2ml7llr><p class="callout__title" data-astro-cid-q2ml7llr><span aria-hidden="true" data-astro-cid-q2ml7llr>⚠</span>Update before adopting anything else in this post</p><div class="callout__body" data-astro-cid-q2ml7llr><p>Neither security release above is optional reading. If <code>gh --version</code> reports
anything older than 2.97.0, run your package manager’s update command before
wiring any new command group from this post into a script or a habit.</p></div></div>
<p>v2.97.0 (2026-07-31) is worth one correction here, because a single changelog line invites a conflation that isn’t accurate. <a href="https://github.com/cli/cli/releases/tag/v2.97.0">GitHub’s own release notes for that version</a> list four separate security advisories fixed the same day, not four terminal-injection bugs. Only one of them, GHSA-3m3g-3wcr-px46, is the terminal-injection issue, and it touches seven separate command paths, not four, <code>gh api</code> and <code>gh pr diff</code> among them, both <code>gh agent-task</code> subcommands included. The other three advisories fixed that same day cover an unrelated URL-path-escaping bug, a partial auth-token leak in <code>gh auth status</code>, and a regex-escaping bypass in <code>gh attestation verify</code>, three different bug classes in three different commands. See <a href="/dev-tools/gh-cli-2-97-0-terminal-injection-fixes/">gh CLI 2.97.0 Fixes Terminal Injection in 7 Commands</a> for the full seven-command list and the fix itself.</p>
<p>Two of the seven sanitized commands, <code>gh agent-task view</code> and <code>gh agent-task create</code>, belong to the same agent-tooling thread this post opened with. That’s not a coincidence worth glossing over: the newest, least-tested command surface is also the one that needed a security patch in its first few months.</p>
<h2 id="whats-still-preview-and-what-already-shipped-stable">What’s still preview, and what already shipped stable</h2>
<p>Not every command in this wave carries the same production confidence. GitHub’s own <code>gh</code> manual states the same caveat for two of the four, word for word except for the one swapped noun:</p>
<blockquote>
<p>“Working with agent skills in the GitHub CLI is in preview and subject to change without notice.”</p>
</blockquote>
<p>Swap “agent skills” for “discussions” and that’s the exact sentence covering <code>gh discussion</code> too. <code>gh agent-task</code> carries its own preview label directly on its manual page, a shorter but equally direct signal.</p>
<p>Issue hierarchy is the exception. The <code>--parent</code>, <code>--add-sub-issue</code>, and <code>--add-blocked-by</code> flags shipped in v2.94.0 without a preview caveat attached anywhere in GitHub’s changelog or <code>gh</code>’s own docs. If you’re choosing where to script first, that’s a real, sourced reason to start with the hierarchy flags over the other three.</p>
<p>This is the tenth and closing post in a 10-part series; it earns its place the same way <a href="/ai-productivity/same-prompt-different-bill-gpt-claude-gemini/">Same Prompt, Different Bill</a> closes this site’s LLM pricing series, tying several individually-dated changes into one decision instead of adding an eleventh isolated fact. See <a href="/dev-tools/github-cli-2026-agent-era-expansion/">GitHub CLI’s 2026 Agent-Era Expansion</a> for the hub linking all ten pieces.</p>
<h2 id="update-first-then-adopt-selectively">Update first, then adopt selectively</h2>
<p>Check your version before anything else:</p>
<div class="code-block"><div class="code-block__header"><span class="code-block__filename"></span><button type="button" class="code-block__copy" data-copy-button>Copy</button></div><pre class="astro-code github-dark" style="background-color:#24292e;color:#e1e4e8;overflow-x:auto" tabindex="0" data-language="bash"><code><span class="line"><span style="color:#B392F0">gh</span><span style="color:#79B8FF"> --version</span></span></code></pre></div>
<p>Anything before v2.97.0 is missing a real security fix, not a feature. Update through whatever channel installed <code>gh</code>: <code>brew upgrade gh</code>, <code>apt update &amp;&amp; apt install gh</code>, <code>scoop update gh</code>, or a fresh binary from <a href="https://cli.github.com/">cli.github.com</a>. Past that baseline, adopt selectively rather than all at once. <code>gh skill</code> and <code>gh agent-task</code> earn their place the moment you’re managing AI-agent tooling across more than one machine or one agent. <code>gh discussion</code> and the sub-issue flags earn theirs the moment a script needs to read or change something that used to require a browser tab. None of the four demand adoption on day one, but the pattern behind them does demand attention: <code>gh</code> isn’t just tracking GitHub’s web features anymore, it’s becoming the terminal’s own control point for them, and a version bump in this series is worth reading before you skip it.</p>
<p>Browse more coverage like this in the <a href="/dev-tools">Dev Tools</a> archive.</p>]]></content:encoded>
      <pubDate>Wed, 19 Aug 2026 00:00:00 GMT</pubDate>
    </item>
    <item>
      <title>GitHub Copilot&apos;s AI Credits Cliff: Full Guide</title>
      <link>https://bytetech247.com/ai-productivity/github-copilot-ai-credits-cliff-2026/</link>
      <guid isPermaLink="true">https://bytetech247.com/ai-productivity/github-copilot-ai-credits-cliff-2026/</guid>
      <description>GitHub Copilot&apos;s promotional AI Credits allowances revert to standard levels on September 1, 2026. What changed June 1, what reverts, and how to prepare.</description>
      <content:encoded><![CDATA[<aside class="quick-answer" aria-label="Quick answer"><h2 id="quick-answer">Quick Answer</h2>
<p>GitHub’s promotional AI Credits allowances, granted when Premium Request Units retired June 1, 2026, revert to standard levels September 1: Business drops 3,000 to 1,900 credits/user/month, Enterprise 7,000 to 3,900. Check usage and set budgets before then: by default, Copilot doesn’t halt at zero, it bills metered usage automatically and uncapped.</p>
</aside><h2 id="why-a-promotional-number-felt-like-the-real-one">Why a promotional number felt like the real one</h2>
<p>GitHub Copilot changed its billing model twice in three months, and it’s easy to conflate the two changes. On June 1, 2026, GitHub retired Premium Request Units (PRU) in favor of AI Credits for every monthly-billed Pro, Pro+, Business, and Enterprise plan. At that same cutover, Business and Enterprise orgs got a promotional allowance, more credits than the tier’s standard allotment, to cushion the transition.</p>
<p>That promotional number was never the permanent one. It reverts to each tier’s real allotment on September 1, 2026. For a Business org running close to its promotional 3,000-credit ceiling, that’s not a policy footnote. It’s a drop to 1,900 credits/user/month, roughly a third less headroom overnight. Enterprise falls further, from 7,000 down to 3,900, a cut of about 44%. Nothing about the billing mechanism itself changes that day. Only the number does.</p>
<h2 id="structural-comparison-matrix">Structural Comparison Matrix</h2>



































<table tabindex="0"><thead><tr><th scope="col" style="text-align:left">Operational Aspect</th><th scope="col" style="text-align:left">Before September 1, 2026</th><th scope="col" style="text-align:left">After September 1, 2026</th></tr></thead><tbody><tr><td style="text-align:left"><strong>Business tier credits</strong></td><td style="text-align:left">3,000 credits/user/month (promotional, since 2026-06-01)</td><td style="text-align:left">1,900 credits/user/month (standard, permanent)</td></tr><tr><td style="text-align:left"><strong>Enterprise tier credits</strong></td><td style="text-align:left">7,000 credits/user/month (promotional, since 2026-06-01)</td><td style="text-align:left">3,900 credits/user/month (standard, permanent)</td></tr><tr><td style="text-align:left"><strong>Billing mechanism (monthly plans)</strong></td><td style="text-align:left">AI Credits: 1 credit = $0.01, priced per-token, per-model</td><td style="text-align:left">Unchanged, same Credits mechanism, only the pool size shrinks</td></tr><tr><td style="text-align:left"><strong>Annual-plan billing</strong></td><td style="text-align:left">Legacy PRU/multiplier system, with a revised multiplier table effective 2026-06-01</td><td style="text-align:left">Unchanged, annual plans don’t touch AI Credits at all</td></tr><tr><td style="text-align:left"><strong>Default behavior at pool exhaustion</strong></td><td style="text-align:left">“AI credits paid usage” enabled by default (Business/Enterprise); metered billing continues automatically, uncapped</td><td style="text-align:left">Same default, just reached sooner against a smaller pool</td></tr></tbody></table>
<p>Every figure in that table comes from GitHub’s own living documentation or its billing changelog, not aggregated secondary pricing trackers. The one exception is the “as much as 27x” characterization of the annual-plan multiplier table’s extremes, further down, that’s a third-party reading of the table, not GitHub’s own wording, and it’s labeled as such where it appears.</p>
<h2 id="from-flat-multipliers-to-real-per-token-pricing">From flat multipliers to real per-token pricing</h2>
<p>Premium Request Units billed every premium model call against a flat multiplier, a request against GPT-4-class models cost roughly the same number of units regardless of how long the prompt or response actually was. AI Credits replaced that on June 1, 2026, with 1 credit equal to $0.01, priced per-token and per-model: input tokens, output tokens, and cache-write tokens each carry their own rate, and that rate differs by model, per <a href="https://docs.github.com/en/copilot/reference/copilot-billing/models-and-pricing">GitHub’s own models-and-pricing documentation</a>.</p>
<p>That’s the same shift this site already covered for GPT, Claude, and Gemini in the <a href="/ai-productivity/2026-llm-token-pricing-reset/">2026 LLM Token &amp; Pricing Reset</a> hub: real per-token, per-model economics replacing a flatter, coarser unit. Copilot’s version just arrived wearing GitHub’s own billing vocabulary, and it means a long, context-heavy prompt against a frontier model now costs visibly more credits than a short one against a lighter model, a distinction PRU’s flat multiplier never captured.</p>
<h2 id="why-agent-mode-changes-the-math">Why Agent Mode changes the math</h2>
<p>A single-turn chat question in Copilot burns one round of tokens. Agent Mode’s read-file, edit, run-tests, re-read loop burns tokens on every step of that loop, not once per user message. GitHub’s own research, as relayed by third-party coverage, puts the difference at roughly 1,000 times more tokens consumed than a single-turn chat query for a comparable task, a figure worth treating as reported rather than a direct GitHub quote.</p>
<p>That multiple shows up in real reported bills: $29 jumping to $750 in a month, $50 jumping to $3,000. A per-message mental model of Copilot cost doesn’t predict that jump, because Agent Mode isn’t spending per message, it’s spending per step. Choosing a lighter model for routine steps, and reserving a frontier model for the step that actually needs it, is a real lever now that credits price by model instead of by flat multiplier. This site’s own <a href="/ai-productivity/prompting-guide-ai-coding-assistants/">prompting guide for AI coding assistants</a> covers the habits, working in checkpoints, specific constraints, that keep an agent loop from running longer than the task needs.</p>
<h2 id="annual-plans-are-on-a-different-quieter-clock">Annual plans are on a different, quieter clock</h2>
<p>Not every Copilot subscriber faces the September 1 cliff. Anyone on an annual-billed plan wasn’t migrated to AI Credits on June 1; annual plans stayed on the legacy PRU and model-multiplier system GitHub has run since premium requests first launched. GitHub revised that multiplier table itself on the same June 1 date, and third-party analysis of the table’s extremes describes some models’ multipliers rising as much as 27x, a characterization worth reading directly rather than secondhand, since it’s paraphrased from that analysis, not GitHub’s own phrasing.</p>
<p>Annual subscribers don’t hit a credit cliff on September 1. They already absorbed a different, quieter one back in June, and it doesn’t resolve until the plan comes up for renewal onto the Credits system.</p>
<h2 id="budgets-and-what-actually-happens-at-zero">Budgets, and what actually happens at zero</h2>
<p>GitHub gives admins a real lever before September 1 makes the decision for them, but it’s worth stating the default plainly first, because it runs backward from what most admins assume. <a href="https://docs.github.com/en/copilot/concepts/billing/usage-based-billing-for-organizations-and-enterprises">GitHub’s own docs on usage-based billing for organizations and enterprises</a> say it directly: additional usage is enabled by default for organizations and enterprises. An org that has never touched its AI Controls settings, never set a budget, and never disabled anything keeps billing metered AI credit usage the moment its shared pool empties, for as long as usage continues. There’s no default $0 budget protecting anyone; GitHub retired that for enterprise and team accounts on 2025-12-02, and the Credits system that replaced Premium Request Units on June 1, 2026 doesn’t carry it forward.</p>
<p>Budgets can be scoped to an organization, a cost center, or an individual user, per <a href="https://docs.github.com/en/copilot/tutorials/budgets/getting-started-with-budget-controls">GitHub’s own budgets documentation</a>. Org- and cost-center-level budgets carry a single toggle, “Stop usage when budget limit is reached”: switch it on and metered usage is blocked outright once the limit hits, leave it off (its own default) and admins get a notification while charges keep accruing past the limit instead. A universal user-level budget doesn’t get that choice; it always enforces a hard stop, no toggle, no notify-only option.</p>





























<table tabindex="0"><thead><tr><th scope="col" style="text-align:left">Configuration</th><th scope="col" style="text-align:left">What happens</th></tr></thead><tbody><tr><td style="text-align:left">Nothing configured (Business/Enterprise default)</td><td style="text-align:left">“AI credits paid usage” is already on; metered billing continues automatically and uncapped</td></tr><tr><td style="text-align:left">“AI credits paid usage” policy explicitly disabled</td><td style="text-align:left">The only configuration that produces a genuine automatic halt, usage blocks entirely once the pool empties</td></tr><tr><td style="text-align:left">Org/cost-center budget, “Stop usage” toggle on</td><td style="text-align:left">Metered usage is blocked outright once the limit is reached</td></tr><tr><td style="text-align:left">Org/cost-center budget, “Stop usage” toggle off (its own default)</td><td style="text-align:left">Admin is notified, but charges keep accruing past the limit</td></tr><tr><td style="text-align:left">Universal user-level budget</td><td style="text-align:left">Always hard-stops at the limit, no toggle available</td></tr></tbody></table>
<p>GitHub Community discussions <a href="https://github.com/orgs/community/discussions/197557">#197557</a>, <a href="https://github.com/orgs/community/discussions/197605">#197605</a>, and <a href="https://github.com/orgs/community/discussions/197089">#197089</a> capture real admins working this out the hard way, including one enterprise admin describing a setting that used to read “$0 budget” and block overage automatically now showing “No Usage Limit” instead. The <a href="/ai-productivity/what-happens-when-copilot-credits-run-out/">full exhaustion-behavior breakdown</a> in this series covers the request-evaluation order and every configuration state in detail.</p>
<div class="callout" role="note" data-astro-cid-q2ml7llr><p class="callout__title" data-astro-cid-q2ml7llr><span aria-hidden="true" data-astro-cid-q2ml7llr>⚠</span>A shrinking pool hits the same wall sooner</p><div class="callout__body" data-astro-cid-q2ml7llr><p>If your org is already close to its promotional ceiling, the September 1
revert doesn’t change how exhaustion behaves, it just moves the date it
happens on. Check per-user usage now, not after the first blocked completion.</p></div></div>
<h2 id="the-10-pieces-of-this-guide">The 10 pieces of this guide</h2>
<p>This hub is the starting point for a 10-part series on GitHub Copilot’s AI Credits system and the September 1 cliff.</p>
<ol>
<li><strong><a href="/ai-productivity/check-copilot-ai-credits-usage-before-sept-1/">Check Your Copilot AI Credits Usage Before Sept 1</a></strong>: how to view real usage via the new dashboards and API before the cliff hits.</li>
<li><strong><a href="/ai-productivity/github-copilot-ai-credits-pricing-explained/">How GitHub Copilot AI Credits Are Actually Priced</a></strong>: the 1 credit = $0.01 per-token, per-model mechanics.</li>
<li><strong><a href="/ai-productivity/github-copilot-pru-to-ai-credits-migration/">GitHub Copilot’s PRU to AI Credits Migration, Explained</a></strong>: what the June 1 cutover actually changed.</li>
<li><strong><a href="/ai-productivity/annual-copilot-plans-model-multipliers-spike/">Annual Copilot Plans: Model Multipliers Just Spiked</a></strong>: the separate legacy-multiplier mechanism for annual subscribers.</li>
<li><strong><a href="/ai-productivity/github-copilot-budget-controls-setup/">Setting Copilot Budget Controls Before the Sept Cliff</a></strong>: org, cost-center, and per-user budget configuration.</li>
<li><strong><a href="/ai-productivity/what-happens-when-copilot-credits-run-out/">What Happens When GitHub Copilot Credits Run Out</a></strong>: default exhaustion behavior, in detail.</li>
<li><strong><a href="/ai-productivity/copilot-agent-mode-credits-consumption/">Why Copilot Agent Mode Burns Through Credits So Fast</a></strong>: Agent Mode’s outsized token consumption.</li>
<li><strong><a href="/ai-productivity/github-copilot-plans-compared-by-ai-credits/">GitHub Copilot Plans Compared by AI Credits, Not Price</a></strong>: tier-by-tier allotment comparison, not just sticker price.</li>
<li><strong><a href="/ai-productivity/pick-cheaper-copilot-models-stretch-credits/">Pick Cheaper Copilot Models to Stretch Your Credits</a></strong>: model-selection as a real cost strategy.</li>
<li><strong><a href="/ai-productivity/github-copilot-sept-1-credit-cliff-checklist/">What to Do Before GitHub Copilot’s Sept 1 Credit Cliff</a></strong>: the closing synthesis and checklist.</li>
</ol>
<h2 id="what-to-do-before-september-1">What to do before September 1</h2>
<p>Three real levers exist right now, not hypothetical advice for later. Check actual usage first: GitHub shipped a per-cycle AI credits view (2026-07-20), a per-user usage-metrics API field (2026-06-19), and an org-level usage metrics impact dashboard (2026-07-22). None of the three existed before this year, so an org running Copilot since the PRU era may genuinely be checking real per-user numbers for the first time.</p>
<p>Set a budget with intent, not as an afterthought. Turn on “Stop usage when budget limit is reached” for a scope that must never lapse, and leave it off for one that should notify instead of blocking a shipping deadline. Apply it at whichever level, org, cost-center, or per-user, matches how the org actually assigns cost accountability. Then route routine agent steps to a cheaper model before September 1 makes the same credit pool feel smaller than it did in July. Credits price by model instead of by flat multiplier, so model choice is a real cost lever PRU never gave you.</p>
<p>None of this requires panic before September 1, but it does require one real decision: whether your org’s current usage pattern fits inside the standard allotment, or whether it’s been quietly leaning on the promotional cushion without anyone noticing. Check the actual per-user numbers before that date, not after next month’s invoice tells you the hard way, since the default here bills uncapped rather than stopping on its own.</p>
<p>Browse more coverage like this in the <a href="/ai-productivity">AI Productivity</a> archive.</p>]]></content:encoded>
      <pubDate>Wed, 19 Aug 2026 00:00:00 GMT</pubDate>
    </item>
    <item>
      <title>How GitHub Copilot AI Credits Are Actually Priced</title>
      <link>https://bytetech247.com/ai-productivity/github-copilot-ai-credits-pricing-explained/</link>
      <guid isPermaLink="true">https://bytetech247.com/ai-productivity/github-copilot-ai-credits-pricing-explained/</guid>
      <description>GitHub Copilot&apos;s AI Credits convert real per-token, per-model pricing into dollars at a fixed 1 credit = $0.01 rate. Here&apos;s exactly how the math works.</description>
      <content:encoded><![CDATA[<aside class="quick-answer" aria-label="Quick answer"><h2 id="quick-answer">Quick Answer</h2>
<p>GitHub Copilot’s AI Credits price real token counts, not flat per-request units. Each model charges separately for input tokens, cached input tokens, and (on some models) cache-write tokens, plus output tokens, then the dollar total converts to credits at a fixed 1 credit = $0.01. Model choice and context size now change your cost, not just your request count.</p>
</aside><h2 id="why-per-request-was-never-the-same-as-per-token">Why “per request” was never the same as “per token”</h2>
<p>Before June 1, 2026, a Copilot interaction was billed as a Premium Request Unit. Under that system, <a href="https://docs.github.com/en/copilot/reference/copilot-billing/request-based-billing-legacy/model-multipliers-for-annual-plans">GitHub assigned every supported model a fixed multiplier</a> tied to how heavy that model was to run, then subtracted that many units from your monthly allowance whenever you used it. A single chat call to Claude Opus 4.7 cost 27 premium requests. A call to GPT-5.5 cost 57. Neither number moved whether the prompt was one line or a whole file’s worth of context, because the multiplier attaches to the request, not to anything measured inside it.</p>
<p>AI Credits replaced that system for every monthly-billed Pro, Pro+, Business, and Enterprise plan. The unit is no longer the request. It’s the token, priced per model, then converted to a dollar figure at a fixed rate: 1 AI credit equals $0.01 USD, confirmed directly on <a href="https://docs.github.com/en/copilot/reference/copilot-billing/models-and-pricing">GitHub’s own models-and-pricing documentation</a>. That single conversion rate is the same across every plan; what a request actually costs depends entirely on the model and the tokens it consumes.</p>
<div class="callout" role="note" data-astro-cid-q2ml7llr><p class="callout__title" data-astro-cid-q2ml7llr><span aria-hidden="true" data-astro-cid-q2ml7llr>⚠</span>This post covers the mechanism, not the Sept 1 cliff itself</p><div class="callout__body" data-astro-cid-q2ml7llr><p>If you’re here because a promotional Business or Enterprise credit allowance
is about to shrink on <strong>September 1, 2026</strong>, this post explains the pricing
math sitting underneath that number. For the full reversion story and what to
do before it hits, see <a href="/ai-productivity/github-copilot-ai-credits-cliff-2026/">GitHub Copilot’s AI Credits
Cliff</a> hub.</p></div></div>
<h2 id="structural-comparison-matrix">Structural Comparison Matrix</h2>



































<table tabindex="0"><thead><tr><th scope="col" style="text-align:left">Operational Aspect</th><th scope="col" style="text-align:left">Premium Request Units (legacy)</th><th scope="col" style="text-align:left">AI Credits (current)</th></tr></thead><tbody><tr><td style="text-align:left"><strong>Billing unit</strong></td><td style="text-align:left">A flat multiplier per request (Claude Opus 4.7 = 27x, GPT-5.5 = 57x)</td><td style="text-align:left">Real token counts priced per model, converted to credits at 1 credit = $0.01</td></tr><tr><td style="text-align:left"><strong>Effect of prompt/response length</strong></td><td style="text-align:left">None. The multiplier is fixed no matter how many tokens the request used</td><td style="text-align:left">Direct. More input or output tokens means more credits, every time</td></tr><tr><td style="text-align:left"><strong>Cached context</strong></td><td style="text-align:left">Not a distinct concept under the multiplier system</td><td style="text-align:left">Cached input billed separately, consistently around 10% of that model’s fresh-input rate</td></tr><tr><td style="text-align:left"><strong>Writing to cache</strong></td><td style="text-align:left">Not priced at all</td><td style="text-align:left">Priced on OpenAI’s GPT-5.6 family and every Anthropic model, at 1.25x that model’s input rate</td></tr><tr><td style="text-align:left"><strong>Crossing a long-prompt threshold</strong></td><td style="text-align:left">No effect. Same multiplier regardless of context size</td><td style="text-align:left">Some models switch to a higher “Long context” rate once input tokens cross a set threshold</td></tr></tbody></table>
<p>The 10% cached-input figure and the 1.25x cache-write figure aren’t stated as round numbers anywhere in GitHub’s docs. They’re a pattern this post found by checking every row of the live pricing table that includes both prices, and it held consistently across every one checked, from GPT-5 mini up through Claude Opus 4.8. The next two sections show the arithmetic.</p>
<h2 id="the-real-pricing-table-and-how-to-read-it">The real pricing table, and how to read it</h2>
<p>GitHub organizes its per-model pricing into separate tables by provider, OpenAI, Anthropic, Google, Microsoft, xAI, Moonshot AI, and GitHub’s own fine-tuned models, each with the same shape: a model name, a release status, a category (Lightweight, Versatile, or Powerful), and up to four price columns covering input, cached input, cache write, and output. A handful of models also carry a Tier column, splitting Default pricing from a higher Long context rate past a stated token threshold.</p>
<div class="callout" role="note" data-astro-cid-q2ml7llr><p class="callout__title" data-astro-cid-q2ml7llr><span aria-hidden="true" data-astro-cid-q2ml7llr>ℹ</span>Every price is per 1 million tokens</p><div class="callout__body" data-astro-cid-q2ml7llr><p>Reading $5.00 as “$5 per token” instead of “$5 per million tokens” is the
single easiest way to badly misjudge a cost estimate from this table. A
100,000-token prompt against a $5.00 input rate costs $0.50 in input tokens,
not $500,000.</p></div></div>
<p>Here’s a representative snapshot, quoted directly from GitHub’s live pricing table on 2026-08-19:</p>
<blockquote>





























































<table tabindex="0"><thead><tr><th scope="col" style="text-align:left">Model</th><th scope="col" style="text-align:left">Category</th><th scope="col" style="text-align:right">Input</th><th scope="col" style="text-align:right">Cached input</th><th scope="col" style="text-align:right">Cache write</th><th scope="col" style="text-align:right">Output</th></tr></thead><tbody><tr><td style="text-align:left"><strong>GPT-5 mini</strong></td><td style="text-align:left">Lightweight</td><td style="text-align:right">$0.25</td><td style="text-align:right">$0.025</td><td style="text-align:right">Not applicable</td><td style="text-align:right">$2.00</td></tr><tr><td style="text-align:left"><strong>GPT-5.4</strong> (default tier)</td><td style="text-align:left">Versatile</td><td style="text-align:right">$2.50</td><td style="text-align:right">$0.25</td><td style="text-align:right">Not applicable</td><td style="text-align:right">$15.00</td></tr><tr><td style="text-align:left"><strong>GPT-5.6 Sol</strong> (default tier)</td><td style="text-align:left">Powerful</td><td style="text-align:right">$5.00</td><td style="text-align:right">$0.50</td><td style="text-align:right">$6.25</td><td style="text-align:right">$30.00</td></tr><tr><td style="text-align:left"><strong>Claude Haiku 4.5</strong></td><td style="text-align:left">Versatile</td><td style="text-align:right">$1.00</td><td style="text-align:right">$0.10</td><td style="text-align:right">$1.25</td><td style="text-align:right">$5.00</td></tr><tr><td style="text-align:left"><strong>Claude Opus 4.8</strong></td><td style="text-align:left">Powerful</td><td style="text-align:right">$5.00</td><td style="text-align:right">$0.50</td><td style="text-align:right">$6.25</td><td style="text-align:right">$25.00</td></tr><tr><td style="text-align:left"><strong>Gemini 3.6 Flash</strong></td><td style="text-align:left">Versatile</td><td style="text-align:right">$0.75</td><td style="text-align:right">$0.075</td><td style="text-align:right">n/a</td><td style="text-align:right">$3.75</td></tr></tbody></table>
</blockquote>
<p>All rates above are per 1 million tokens. Gemini 3.6 Flash’s row is promotional pricing in effect through December 31, 2026, per GitHub’s own table, not a permanent rate. This is a snapshot, not a live feed either way. GitHub’s table changes as models and rates change, so treat the columns and the pattern as the durable part and check <a href="https://docs.github.com/en/copilot/reference/copilot-billing/models-and-pricing">the live table</a> for the current numbers before budgeting against them.</p>
<h2 id="a-worked-example-the-same-prompt-two-different-models">A worked example: the same prompt, two different models</h2>
<p>Take a Copilot Chat request that sends 100,000 input tokens (a decent chunk of repo context) and gets back 20,000 output tokens, with nothing served from cache. Here’s what that costs on two models from the table above.</p>
<p><strong>GPT-5 mini</strong> (Lightweight, $0.25 input / $2.00 output per 1M tokens):</p>
<ol>
<li>Input: 100,000 tokens x $0.25 / 1,000,000 = $0.025</li>
<li>Output: 20,000 tokens x $2.00 / 1,000,000 = $0.04</li>
<li>Total: $0.025 + $0.04 = $0.065</li>
<li>Credits: $0.065 / $0.01 = 6.5 credits</li>
</ol>
<p><strong>Claude Opus 4.8</strong> (Powerful, $5.00 input / $25.00 output per 1M tokens):</p>
<ol>
<li>Input: 100,000 tokens x $5.00 / 1,000,000 = $0.50</li>
<li>Output: 20,000 tokens x $25.00 / 1,000,000 = $0.50</li>
<li>Total: $0.50 + $0.50 = $1.00</li>
<li>Credits: $1.00 / $0.01 = 100 credits</li>
</ol>
<p>Same prompt, same token counts, roughly 15 times more credits on the more capable model. That gap is the whole point of moving to per-token pricing: a flat PRU multiplier could only tell you a model costs “more,” never how much more a specific request actually spent.</p>
<h2 id="cached-tokens-cost-less-cache-writes-cost-more">Cached tokens cost less, cache writes cost more</h2>
<p>The Structural Comparison Matrix above flags a pattern worth showing directly. Take Claude Opus 4.8 again, this time for the same 100,000 tokens handled three different ways.</p>
<p>A fresh, uncached input call: 100,000 tokens x $5.00 / 1,000,000 = $0.50, or 50 credits.</p>
<p>Writing that same context to cache for the first time: 100,000 tokens x $6.25 / 1,000,000 = $0.625, or 62.5 credits, a 25% premium over the plain input rate.</p>
<p>Reading that context back from cache on a later call: 100,000 tokens x $0.50 / 1,000,000 = $0.05, or 5 credits, one tenth of the fresh-input cost.</p>
<p>The first cache write costs more than just sending the tokens fresh would have. Every reuse after that costs a tenth as much. A workflow that repeats the same large context across several calls, an agent looping over the same file, a chat session that keeps referencing the same repo, comes out well ahead by writing to cache once and reading from it repeatedly, instead of resending the same tokens as plain input every time.</p>
<h2 id="crossing-the-long-context-threshold-changes-the-rate-too">Crossing the long-context threshold changes the rate too</h2>
<p>Some models in the table carry a second lever: a Tier column that splits Default pricing from a higher Long context rate once your input crosses a stated threshold. GPT-5.4 is a clean example. Its Default tier applies at or below 272,000 input tokens, at $2.50 input and $15.00 output per million tokens. Its Long context tier applies above 272,000 input tokens, at $5.00 input and $22.50 output, exactly double the input rate and 1.5x the output rate.</p>
<p>GitHub’s table presents Default and Long context as two separate rows with two separate rates, not a blended formula, so a request appears to be billed against whichever tier its input falls into rather than only the tokens past the line. That reading isn’t spelled out explicitly in GitHub’s own pricing docs, so treat it as the most reasonable interpretation of how the table is structured, not a directly confirmed billing mechanic. Either way, the practical takeaway holds: a prompt that quietly grows past a model’s context threshold can jump to a meaningfully higher per-token rate, on top of simply containing more tokens to price.</p>
<h2 id="model-choice-not-request-count-is-now-the-lever">Model choice, not request count, is now the lever</h2>
<p>The mental model that mattered under Premium Request Units, count how many requests you’re sending, doesn’t transfer to AI Credits. What matters now is which model answers the request and how many tokens that answer actually took, input, cached, written to cache, and generated. The worked example above shows a 15x swing between two models on the exact same prompt, computed directly from GitHub’s published rates, not estimated.</p>
<p>That’s the same structural shift this site already covered for GPT, Claude, and Gemini’s own pricing in <a href="/ai-productivity/same-prompt-different-bill-gpt-claude-gemini/">Same Prompt, Different Bill</a> and <a href="/ai-productivity/2026-llm-token-pricing-reset/">The 2026 LLM Token &amp; Pricing Reset</a>: flat, coarse units giving way to real per-token, per-model economics that a reader actually has to compute rather than just look up. Copilot’s version arrived a few months later, wearing GitHub’s own credit vocabulary, but the underlying math is the same industry pattern. Before the standard credit allowance becomes the only allowance your org has to work with, check which models your default routing actually sends requests to, since that choice now moves your bill more than your request count ever did.</p>
<p>Browse more coverage like this in the <a href="/ai-productivity">AI Productivity</a> archive, or start from <a href="/ai-productivity/github-copilot-ai-credits-cliff-2026/">GitHub Copilot’s AI Credits Cliff</a> hub for the full reversion story.</p>]]></content:encoded>
      <pubDate>Wed, 19 Aug 2026 00:00:00 GMT</pubDate>
    </item>
    <item>
      <title>Setting Copilot Budget Controls Before the Sept Cliff</title>
      <link>https://bytetech247.com/ai-productivity/github-copilot-budget-controls-setup/</link>
      <guid isPermaLink="true">https://bytetech247.com/ai-productivity/github-copilot-budget-controls-setup/</guid>
      <description>Configure GitHub Copilot AI credits budgets before Sept 1, 2026: set the org/cost-center stop-usage toggle and per-user hard stop limits.</description>
      <content:encoded><![CDATA[<aside class="quick-answer" aria-label="Quick answer"><h2 id="quick-answer">Quick Answer</h2>
<p>GitHub Copilot budgets have one real lever: a toggle named “Stop usage when budget limit is reached,” available on organization-, cost-center-, and enterprise-scoped budgets. Turn it on to hard-block metered usage at the limit; leave it off and usage keeps running up charges past the limit with only a notification sent. Universal and individual per-user budgets skip the toggle entirely and always hard-stop.</p>
<p>Knowing your usage, <a href="/ai-productivity/check-copilot-ai-credits-usage-before-sept-1/">covered in the previous post in this series</a>, tells you where you stand. It doesn’t stop anything. A budget is the piece that actually enforces a limit, and GitHub gives admins real configuration steps to take before the September 1, 2026 promotional-allowance cliff drops Business seats from 3,000 to 1,900 credits and Enterprise seats from 7,000 to 3,900.</p>
</aside><h2 id="the-toggle-has-one-name-and-it-isnt-optional-twice">The toggle has one name, and it isn’t optional twice</h2>
<p>An earlier pass at this topic assumed budget enforcement came in two named modes, something like “stop” and “limit,” as if an admin picked between two enforcement strategies. That’s not what GitHub’s live documentation shows. There is one toggle, named exactly “Stop usage when budget limit is reached,” and it’s a binary on/off, not a choice between two named modes.</p>
<p><a href="https://docs.github.com/en/copilot/concepts/billing/budgets-for-usage-based-billing">GitHub’s budgets concepts page</a> states the toggle’s effect plainly: switch it on and the user is blocked once the limit hits; leave it off and spending keeps running past the limit uncapped, with only a notification to show for it. That’s the entire mechanism. No separate “limit” mode sits alongside it for Copilot AI credits.</p>
<p>Universal and individual per-user budgets don’t get a choice at all:</p>
<blockquote>
<p>User-level budgets always enforce a hard stop and do not have this setting.</p>
</blockquote>
<p>That line is GitHub’s own, from the same budgets concepts page. The <a href="https://docs.github.com/en/copilot/tutorials/budgets/getting-started-with-budget-controls">getting-started tutorial</a> describes the same rule from the setup side: a per-user budget skips the toggle step entirely, because it hard-stops automatically no matter what. A per-user budget is the one scope where GitHub removes the choice, rather than defaulting it one way.</p>
<div class="callout" role="note" data-astro-cid-q2ml7llr><p class="callout__title" data-astro-cid-q2ml7llr><span aria-hidden="true" data-astro-cid-q2ml7llr>ℹ</span>Where this shows up in the API, not just the UI</p><div class="callout__body" data-astro-cid-q2ml7llr><p>GitHub’s Budgets REST API makes the same rule explicit in its schema. The
<code>prevent_further_usage</code> boolean field “must be true” for the <code>user</code> and
<code>multi_user_customer</code> scopes, per the API reference, while it stays optional,
true or false, for <code>organization</code>, <code>cost_center</code>, and <code>enterprise</code> scopes. The
UI toggle and the API field are the same underlying setting.</p></div></div>
<h2 id="structural-comparison-matrix">Structural Comparison Matrix</h2>



































<table tabindex="0"><thead><tr><th scope="col" style="text-align:left">Operational Aspect</th><th scope="col" style="text-align:left">Universal/individual user budget</th><th scope="col" style="text-align:left">Org, cost-center, or enterprise budget</th></tr></thead><tbody><tr><td style="text-align:left"><strong>“Stop usage” toggle</strong></td><td style="text-align:left">Not available, no setting to change</td><td style="text-align:left">Available, on or off</td></tr><tr><td style="text-align:left"><strong>Behavior at limit, toggle on</strong></td><td style="text-align:left">N/A, always behaves this way</td><td style="text-align:left">Metered usage blocked outright once the limit is reached</td></tr><tr><td style="text-align:left"><strong>Behavior at limit, toggle off</strong></td><td style="text-align:left">Not possible, this scope has no off state</td><td style="text-align:left">Admin notified, charges keep accruing past the limit uncapped</td></tr><tr><td style="text-align:left"><strong>API field (<code>prevent_further_usage</code>)</strong></td><td style="text-align:left">Must be <code>true</code></td><td style="text-align:left">Optional, <code>true</code> or <code>false</code></td></tr><tr><td style="text-align:left"><strong>Typical role</strong></td><td style="text-align:left">Caps any one person’s consumption per cycle</td><td style="text-align:left">Caps a team, department, or the whole org/enterprise’s shared pool</td></tr></tbody></table>
<p>Every cell above comes from <a href="https://docs.github.com/en/copilot/concepts/billing/budgets-for-usage-based-billing">GitHub’s budgets concepts documentation</a> and <a href="https://docs.github.com/en/rest/billing/budgets">GitHub’s Budgets REST API reference</a>, not a secondary summary of either.</p>
<h2 id="step-1-set-the-universal-user-level-budget-first">Step 1: set the universal user-level budget first</h2>
<p>GitHub’s own <a href="https://docs.github.com/en/copilot/tutorials/budgets/getting-started-with-budget-controls">getting-started tutorial</a> calls the universal user-level budget (ULB) “the single most important control.” That one setting puts a ceiling on what any individual can spend in a cycle, and it kicks in automatically for every licensed user the moment it’s saved, no per-user setup required.</p>
<p>Set it above the per-license value, not below. GitHub’s <a href="https://docs.github.com/en/copilot/tutorials/budgets/optimizing-your-budget-configuration">optimizing-budget-configuration guidance</a> is specific about why: Copilot Business seats run $19/user/month and Enterprise seats run $39/user/month, and the ULB needs headroom above that figure for credit pooling to work at all. Set it at or below the per-license value and a light user’s unused credits can’t cover a heavier user’s overage, which is the whole point of pooling AI credits across a seat pool instead of hard-capping each person individually.</p>
<p>This is the one budget scope with no toggle to think about. Set the amount, and it hard-stops that user’s metered usage the moment they hit it.</p>
<h2 id="step-2-override-for-real-power-users">Step 2: override for real power users</h2>
<p>A single flat ULB doesn’t fit every user pattern. Someone running Agent Mode loops all day burns credits at a different rate than someone using single-turn chat occasionally, and a ULB sized for the average user blocks the heavy user constantly.</p>
<p>GitHub’s tutorial calls this out as its own step: pull the usage dashboard covered in <a href="/ai-productivity/check-copilot-ai-credits-usage-before-sept-1/">the previous post in this series</a>, identify the heaviest consumers, and set an individual user-level budget override for each. An override takes precedence over the universal default for that one user, letting an admin raise the ceiling for a known power user without loosening it for everyone.</p>
<h2 id="step-3-set-org-cost-center-or-enterprise-budgets-and-flip-the-toggle-deliberately">Step 3: set org, cost-center, or enterprise budgets, and flip the toggle deliberately</h2>
<p>This is the scope where the real decision lives. GitHub’s Budget scope options in the billing UI are Enterprise, Organization, Cost center, and Users, per <a href="https://docs.github.com/en/billing/how-tos/set-up-budgets">GitHub’s own setup instructions</a>. Any of the first three, enterprise, organization, or cost center, carries a “Stop usage when budget limit is reached” checkbox you can switch on or off.</p>
<p>The configuration path, per GitHub’s own steps: from Billing &amp; licensing in the enterprise or organization settings, open Budgets and alerts, click New budget, choose a Budget Type of Bundled AI credits budget to cover Copilot’s credits specifically, set the Budget scope, enter the Budget amount, and decide the toggle before saving. Set alert thresholds too, GitHub fires notifications at 75%, 90%, and 100% of the configured amount regardless of the toggle state.</p>
<p>Turning the toggle on isn’t automatically the right call everywhere. A cost center running a shipping-critical team’s Copilot access might reasonably leave it off and eat the notification, if a mid-sprint hard block costs more than the overage would. A cost center with a fixed departmental budget that genuinely can’t flex probably wants it on. GitHub’s own optimizing guidance frames this as a deliberate choice tied to how the org actually assigns cost accountability, not a default to leave untouched.</p>
<div class="callout" role="note" data-astro-cid-q2ml7llr><p class="callout__title" data-astro-cid-q2ml7llr><span aria-hidden="true" data-astro-cid-q2ml7llr>⚠</span>The toggle is off by default</p><div class="callout__body" data-astro-cid-q2ml7llr><p>A newly created org- or cost-center-scoped budget starts with the toggle off.
Reaching the limit sends a notification and nothing else, charges keep
accruing past it, until an admin explicitly flips “Stop usage when budget
limit is reached” from off to on. Skipping this step is the same as not having
set a limit at all, for enforcement purposes.</p></div></div>
<h2 id="configuring-the-same-budget-through-the-api">Configuring the same budget through the API</h2>
<p>Everything in the UI maps to GitHub’s Budgets REST API, which reached general availability on 2026-06-04 per <a href="https://github.blog/changelog/2026-06-04-budget-and-usage-management-apis-now-generally-available/">GitHub’s own changelog</a>. For a repeatable rollout across many cost centers, that’s the faster path than clicking through the UI once per team.</p>
<p>A per-user AI credits budget, hard-stop required, looks like this against <code>POST /enterprises/{enterprise}/settings/billing/budgets</code>:</p>
<div class="code-block code-block--has-filename"><div class="code-block__header"><span class="code-block__filename">Per-user budget: prevent_further_usage must be true</span><button type="button" class="code-block__copy" data-copy-button>Copy</button></div><pre class="astro-code github-dark" style="background-color:#24292e;color:#e1e4e8;overflow-x:auto" tabindex="0" data-language="json" data-filename="Per-user budget: prevent_further_usage must be true"><code><span class="line"><span style="color:#E1E4E8">{</span></span>
<span class="line"><span style="color:#79B8FF">  &quot;budget_amount&quot;</span><span style="color:#E1E4E8">: </span><span style="color:#79B8FF">30</span><span style="color:#E1E4E8">,</span></span>
<span class="line"><span style="color:#79B8FF">  &quot;prevent_further_usage&quot;</span><span style="color:#E1E4E8">: </span><span style="color:#79B8FF">true</span><span style="color:#E1E4E8">,</span></span>
<span class="line"><span style="color:#79B8FF">  &quot;budget_scope&quot;</span><span style="color:#E1E4E8">: </span><span style="color:#9ECBFF">&quot;user&quot;</span><span style="color:#E1E4E8">,</span></span>
<span class="line"><span style="color:#79B8FF">  &quot;budget_entity_name&quot;</span><span style="color:#E1E4E8">: </span><span style="color:#9ECBFF">&quot;&quot;</span><span style="color:#E1E4E8">,</span></span>
<span class="line"><span style="color:#79B8FF">  &quot;budget_type&quot;</span><span style="color:#E1E4E8">: </span><span style="color:#9ECBFF">&quot;BundlePricing&quot;</span><span style="color:#E1E4E8">,</span></span>
<span class="line"><span style="color:#79B8FF">  &quot;budget_product_sku&quot;</span><span style="color:#E1E4E8">: </span><span style="color:#9ECBFF">&quot;ai_credits&quot;</span><span style="color:#E1E4E8">,</span></span>
<span class="line"><span style="color:#79B8FF">  &quot;budget_alerting&quot;</span><span style="color:#E1E4E8">: {</span></span>
<span class="line"><span style="color:#79B8FF">    &quot;will_alert&quot;</span><span style="color:#E1E4E8">: </span><span style="color:#79B8FF">false</span><span style="color:#E1E4E8">,</span></span>
<span class="line"><span style="color:#79B8FF">    &quot;alert_recipients&quot;</span><span style="color:#E1E4E8">: []</span></span>
<span class="line"><span style="color:#E1E4E8">  },</span></span>
<span class="line"><span style="color:#79B8FF">  &quot;user&quot;</span><span style="color:#E1E4E8">: </span><span style="color:#9ECBFF">&quot;&lt;github-username&gt;&quot;</span></span>
<span class="line"><span style="color:#E1E4E8">}</span></span></code></pre></div>
<p>An organization-scoped budget with the toggle deliberately left off, notify-only, looks nearly identical, minus the <code>user</code> field and with <code>prevent_further_usage</code> set to <code>false</code>:</p>
<div class="code-block code-block--has-filename"><div class="code-block__header"><span class="code-block__filename">Org-level budget: toggle off, notify-only</span><button type="button" class="code-block__copy" data-copy-button>Copy</button></div><pre class="astro-code github-dark" style="background-color:#24292e;color:#e1e4e8;overflow-x:auto" tabindex="0" data-language="json" data-filename="Org-level budget: toggle off, notify-only"><code><span class="line"><span style="color:#E1E4E8">{</span></span>
<span class="line"><span style="color:#79B8FF">  &quot;budget_amount&quot;</span><span style="color:#E1E4E8">: </span><span style="color:#79B8FF">5000</span><span style="color:#E1E4E8">,</span></span>
<span class="line"><span style="color:#79B8FF">  &quot;prevent_further_usage&quot;</span><span style="color:#E1E4E8">: </span><span style="color:#79B8FF">false</span><span style="color:#E1E4E8">,</span></span>
<span class="line"><span style="color:#79B8FF">  &quot;budget_scope&quot;</span><span style="color:#E1E4E8">: </span><span style="color:#9ECBFF">&quot;organization&quot;</span><span style="color:#E1E4E8">,</span></span>
<span class="line"><span style="color:#79B8FF">  &quot;budget_entity_name&quot;</span><span style="color:#E1E4E8">: </span><span style="color:#9ECBFF">&quot;&lt;org-login&gt;&quot;</span><span style="color:#E1E4E8">,</span></span>
<span class="line"><span style="color:#79B8FF">  &quot;budget_type&quot;</span><span style="color:#E1E4E8">: </span><span style="color:#9ECBFF">&quot;BundlePricing&quot;</span><span style="color:#E1E4E8">,</span></span>
<span class="line"><span style="color:#79B8FF">  &quot;budget_product_sku&quot;</span><span style="color:#E1E4E8">: </span><span style="color:#9ECBFF">&quot;ai_credits&quot;</span><span style="color:#E1E4E8">,</span></span>
<span class="line"><span style="color:#79B8FF">  &quot;budget_alerting&quot;</span><span style="color:#E1E4E8">: {</span></span>
<span class="line"><span style="color:#79B8FF">    &quot;will_alert&quot;</span><span style="color:#E1E4E8">: </span><span style="color:#79B8FF">true</span><span style="color:#E1E4E8">,</span></span>
<span class="line"><span style="color:#79B8FF">    &quot;alert_recipients&quot;</span><span style="color:#E1E4E8">: [</span><span style="color:#9ECBFF">&quot;&lt;admin-username&gt;&quot;</span><span style="color:#E1E4E8">]</span></span>
<span class="line"><span style="color:#E1E4E8">  }</span></span>
<span class="line"><span style="color:#E1E4E8">}</span></span></code></pre></div>
<p><code>budget_type: &quot;BundlePricing&quot;</code> paired with <code>budget_product_sku: &quot;ai_credits&quot;</code> is what scopes either payload to Copilot’s AI credits specifically, covering the pooled credit spend rather than a single product SKU like Actions minutes. Every field name and requirement above is verified against <a href="https://docs.github.com/en/rest/billing/budgets">GitHub’s Budgets REST API reference</a>, current as of this writing; <code>budget_entity_name</code> for a cost-center-scoped budget follows the same pattern as the organization example, the cost center’s own name, though GitHub’s reference doesn’t spell out an exact ID format for that scope the way it does for <code>user</code>.</p>
<div class="callout" role="note" data-astro-cid-q2ml7llr><p class="callout__title" data-astro-cid-q2ml7llr><span aria-hidden="true" data-astro-cid-q2ml7llr>✓</span>Requires the current API version header</p><div class="callout__body" data-astro-cid-q2ml7llr><p>Calls to these endpoints need an <code>X-GitHub-Api-Version: 2026-03-10</code> header or
later. The budgets endpoints didn’t exist before the 2026-06-04 GA
announcement, so an older integration written against Copilot’s billing APIs
won’t have this path yet.</p></div></div>
<h2 id="step-4-monitor-dont-set-and-forget">Step 4: monitor, don’t set and forget</h2>
<p>GitHub’s own tutorial ends its walkthrough with monitoring, not configuration, as the last step: check the usage dashboard monthly for blocked users, unexpected metered charges beyond what a budget was sized for, and whether the pool is actually distributing the way it was designed to. A budget sized correctly in July against the promotional 3,000-credit Business ceiling is sized wrong in September against the standard 1,900-credit one, without anyone changing a single setting.</p>
<p>That’s the real reason this step exists as its own item and not a footnote. The September 1 revert doesn’t touch the budget configuration itself. It shrinks the pool the configuration is measured against, so a ULB or cost-center limit that looked generous in August can start blocking real work in September if nobody revisits it.</p>
<h2 id="set-the-budget-before-the-pool-shrinks-under-it">Set the budget before the pool shrinks under it</h2>
<p>The universal user-level budget is the one non-negotiable step, set it above the per-license value or pooling doesn’t function. Everything past that is a real judgment call: raise it for identified power users, decide deliberately whether each org or cost-center budget should hard-block or just notify, and revisit the numbers once the September 1 reversion changes what “generous” actually means.</p>
<p>Skip this step and the default behavior still applies, premium features halt once the shared pool runs dry, assuming usage-based billing was never turned on in the first place. For what actually happens at that point, and what changes if usage-based billing is already on, see <a href="/ai-productivity/what-happens-when-copilot-credits-run-out/">what happens when GitHub Copilot credits run out</a>.</p>
<p>Browse more coverage like this in the <a href="/ai-productivity">AI Productivity</a> archive, or start from <a href="/ai-productivity/github-copilot-ai-credits-cliff-2026/">GitHub Copilot’s AI Credits Cliff</a> hub for the full picture.</p>]]></content:encoded>
      <pubDate>Wed, 19 Aug 2026 00:00:00 GMT</pubDate>
    </item>
    <item>
      <title>GitHub Copilot Plans Compared by AI Credits, Not Price</title>
      <link>https://bytetech247.com/ai-productivity/github-copilot-plans-compared-by-ai-credits/</link>
      <guid isPermaLink="true">https://bytetech247.com/ai-productivity/github-copilot-plans-compared-by-ai-credits/</guid>
      <description>GitHub Copilot plans don&apos;t scale AI credits with price. Real credits-per-dollar math across Free, Pro, Pro+, Max, Business, and Enterprise.</description>
      <content:encoded><![CDATA[<aside class="quick-answer" aria-label="Quick answer"><h2 id="quick-answer">Quick Answer</h2>
<p>GitHub Copilot’s six plans don’t scale credits proportionally with price. Pro, Pro+, and Max each deliver more credits per dollar moving up the ladder, 150, about 180, and 200. Business and Enterprise, after the September 1 revert, both settle at 100, near half of what a same-priced Pro+ seat gets. Compare the ratio, not the sticker price.</p>
</aside><h2 id="why-upgrading-a-tier-doesnt-mean-proportionally-more-credits">Why upgrading a tier doesn’t mean proportionally more credits</h2>
<p>Every Copilot plan sets its own price and its own monthly AI credit allotment, both published on <a href="https://docs.github.com/en/copilot/get-started/plans">GitHub’s own plans page</a>. Nothing forces those two numbers to move together. A buyer comparing Pro against Business, or Pro+ against Enterprise, is really comparing two independent variables that GitHub priced separately for each tier, not one number scaled up.</p>
<p>That matters more than it sounds like it should. The instinct is to assume a $39/month plan buys roughly four times what a $10/month plan buys, since the price is four times higher. Run the actual figures from <a href="https://docs.github.com/en/copilot/concepts/billing/usage-based-billing-for-individuals">GitHub’s individual-plans billing documentation</a> and <a href="https://docs.github.com/en/copilot/concepts/billing/usage-based-billing-for-organizations-and-enterprises">its organization and enterprise billing documentation</a>, and that instinct breaks in both directions.</p>
<div class="callout" role="note" data-astro-cid-q2ml7llr><p class="callout__title" data-astro-cid-q2ml7llr><span aria-hidden="true" data-astro-cid-q2ml7llr>ℹ</span>This comparison covers monthly-billed plans only</p><div class="callout__body" data-astro-cid-q2ml7llr><p>Annual Copilot Pro and Pro+ subscribers run on a separate legacy multiplier
system, not AI Credits at all, until the plan converts or renews. There’s no
credits-per-dollar figure to compute for that group yet. See <a href="/ai-productivity/annual-copilot-plans-model-multipliers-spike/">Annual Copilot
Plans: Model Multipliers Just
Spiked</a> for
what changed on their side instead.</p></div></div>
<h2 id="structural-comparison-matrix">Structural Comparison Matrix</h2>
<p>Here’s every monthly-billed Copilot plan: its price, its published AI credit allotment, and what that allotment is worth at GitHub’s fixed 1 credit = $0.01 conversion rate, the same mechanism <a href="/ai-productivity/github-copilot-ai-credits-pricing-explained/">covered in more depth here</a>. Business and Enterprise are shown at their standard, permanent allotment, the number that applies from September 1, 2026, onward, since that’s the figure that persists once the promotional cushion runs out.</p>






















































<table tabindex="0"><thead><tr><th scope="col" style="text-align:left">Plan</th><th scope="col" style="text-align:left">Price</th><th scope="col" style="text-align:left">Monthly AI credits</th><th scope="col" style="text-align:right">Dollar value of credits</th><th scope="col" style="text-align:right">Credits per dollar</th></tr></thead><tbody><tr><td style="text-align:left"><strong>Free</strong></td><td style="text-align:left">$0/mo</td><td style="text-align:left">Not separately published; capped at 2,000 code completions/mo instead</td><td style="text-align:right">N/A</td><td style="text-align:right">N/A</td></tr><tr><td style="text-align:left"><strong>Pro</strong></td><td style="text-align:left">$10/mo</td><td style="text-align:left">1,500 (1,000 base + 500 flex)</td><td style="text-align:right">$15.00</td><td style="text-align:right">150</td></tr><tr><td style="text-align:left"><strong>Pro+</strong></td><td style="text-align:left">$39/mo</td><td style="text-align:left">7,000 (3,900 base + 3,100 flex)</td><td style="text-align:right">$70.00</td><td style="text-align:right">179.5</td></tr><tr><td style="text-align:left"><strong>Max</strong></td><td style="text-align:left">$100/mo</td><td style="text-align:left">20,000 (10,000 base + 10,000 flex)</td><td style="text-align:right">$200.00</td><td style="text-align:right">200</td></tr><tr><td style="text-align:left"><strong>Business</strong></td><td style="text-align:left">$19/seat/mo</td><td style="text-align:left">1,900/user/mo (standard, from Sept 1, 2026)</td><td style="text-align:right">$19.00</td><td style="text-align:right">100</td></tr><tr><td style="text-align:left"><strong>Enterprise</strong></td><td style="text-align:left">$39/seat/mo</td><td style="text-align:left">3,900/user/mo (standard, from Sept 1, 2026)</td><td style="text-align:right">$39.00</td><td style="text-align:right">100</td></tr></tbody></table>
<p>Every figure above was checked directly against GitHub’s own plan and billing documentation on 2026-08-19, not carried over from an earlier summary. Business and Enterprise’s promotional allowances, 3,000 and 7,000 credits/user/month, in effect until September 1, 2026, are covered in their own section below rather than folded into this table, since they’re temporary.</p>
<p>GitHub’s individual-plans documentation doesn’t publish a specific figure for Free’s credits the way it does for the three paid tiers. Quoted directly from that page:</p>
<blockquote>
<p>Copilot Free and Copilot Student both have an allowance of AI credits and access to models through auto model selection only.</p>
</blockquote>
<p>The only quantified number attached to Free in that same documentation is 2,000 code completions a month, not a base-plus-flex credit breakdown. Free was never part of the paid credit system the September cliff touches, and there’s no published free-tier number to run the same per-dollar math against.</p>
<h2 id="the-same-39-buys-two-different-credit-pools">The same $39 buys two different credit pools</h2>
<p>Pick the two plans that share an identical price and the gap stops being abstract. Copilot Pro+ costs $39 a month and includes 7,000 AI credits. Copilot Enterprise costs $39 per seat per month too, and once the promotional period ends on September 1, 2026, it includes 3,900.</p>
<p>Same price, same $0.01-per-credit conversion, two very different pools. An Enterprise seat’s standard allotment works out to 55.7% of what an individual Pro+ subscriber gets for the identical monthly price. That’s not a rounding difference. It’s most of a credit pool disappearing at the same price point.</p>
<p>It wasn’t always that far apart. Before September 1, 2026, GitHub’s promotional Enterprise allowance is also 7,000 credits/user/month, the exact same number Pro+ gets. Right now, an Enterprise seat and a Pro+ subscription buy identical credit pools at an identical price. The September 1 revert is what breaks that parity, not a price change on either side.</p>
<div class="callout" role="note" data-astro-cid-q2ml7llr><p class="callout__title" data-astro-cid-q2ml7llr><span aria-hidden="true" data-astro-cid-q2ml7llr>⚠</span>Enterprise and Pro+ are priced identically today, not after Sept 1</p><div class="callout__body" data-astro-cid-q2ml7llr><p>Right now, before the September 1 revert, a $39 Enterprise seat and a $39 Pro+
subscription include the exact same 7,000 credits. That parity breaks
permanently once the promotional period ends, not because either plan’s price
changes at all.</p></div></div>
<h2 id="individual-plans-get-more-credit-dense-as-you-go-up">Individual plans get more credit-dense as you go up</h2>
<p>The three individual plans move the opposite direction from Business and Enterprise. Pro, at $10/month, delivers 1,500 credits, exactly 150 per dollar spent. Pro+ raises that to 179.5 per dollar. Max, the newest and most expensive individual tier at $100/month with 20,000 credits, hits exactly 200 credits per dollar, the highest rate on the entire lineup.</p>
<p>Put another way: Max costs 10 times what Pro costs, but its credit pool is 13.3 times larger, 20,000 against 1,500. Upgrading from Pro to Max isn’t proportional. It’s a better deal per dollar, not a worse one, which is the opposite of what happens crossing from Pro+ into a same-priced Enterprise seat.</p>
<p>That gap didn’t come from June 1, 2026’s shift away from flat <a href="/ai-productivity/github-copilot-pru-to-ai-credits-migration/">Premium Request Unit multipliers</a> either. It’s simply how GitHub priced the three individual tiers against each other once real per-token, per-model billing replaced the old flat-multiplier system.</p>
<h2 id="team-plans-before-and-after-the-september-1-revert">Team plans, before and after the September 1 revert</h2>
<p>Business and Enterprise don’t scale against each other the way Pro, Pro+, and Max do, but they scale consistently against themselves. Both sit at exactly 100 credits per dollar on their standard, post-reversion allotment: Business at 1,900 credits for $19, Enterprise at 3,900 for $39. Double the price gets you double the credits, precisely.</p>
<p>The promotional numbers, in effect through September 1, 2026, tell a different story. Business’s promotional 3,000 credits/user/month works out to 157.9 credits per dollar. Enterprise’s promotional 7,000 works out to 179.5, identical to Pro+‘s rate, for the reason covered above. Both team tiers are, for now, priced closer to the individual plans than they will be once September arrives.</p>
<p>GitHub’s own organization and enterprise billing documentation describes the change as a scheduled reversion: once the promotional window closes, included usage drops back to the standard allotment on that date. It’s not a gradual taper and not something triggered by usage crossing a threshold.</p>
<h2 id="a-concrete-example-four-developers-two-ways-to-buy-them">A concrete example: four developers, two ways to buy them</h2>
<p>Run the numbers against an actual team size instead of a single seat. A four-person team can go two ways: four individual Copilot Pro+ subscriptions, or one Copilot Business team of four seats.</p>
<p>Four Pro+ subscriptions cost $156/month combined (4 x $39) and provide 28,000 credits total (4 x 7,000), but each person’s 7,000 is siloed to that person. Nobody can borrow from a teammate’s unused balance. A four-seat Business team costs $76/month (4 x $19), about half as much, and pools 7,600 credits (4 x 1,900) across all four seats once September 1 arrives, letting a light user’s slack cover a heavy user’s overage.</p>
<p>Business’s pooled total is 27.1% of what four separate Pro+ subscriptions provide, for 48.7% of the cost. Whether that trade is worth it depends on how lumpy the team’s actual usage is. One or two people running long Agent Mode loops while others use Copilot lightly benefits from pooling in a way these raw percentages don’t show on their own. A team with evenly spread usage is closer to just paying less for less.</p>
<h2 id="what-the-per-dollar-number-doesnt-capture">What the per-dollar number doesn’t capture</h2>
<p>None of the math above prices in what Business and Enterprise sell beyond the credit pool itself. Both add centralized billing, org-wide policy controls, SSO, and audit logging, plus the budget tooling this series’ <a href="/ai-productivity/github-copilot-budget-controls-setup/">budget-controls guide</a> walks through step by step. Enterprise layers on governance controls Business doesn’t include at all. None of that shows up in a credits-per-dollar ratio, and for a team that genuinely needs those controls, the ratio isn’t the deciding factor.</p>
<p>Credit pooling itself is a real advantage separate from the raw ratio too. A four-seat Business team with wildly uneven usage can outlast a same-cost group of individual subscriptions that each hit a hard per-person ceiling, even with a smaller combined pool, simply because nobody on the pooled plan gets blocked while a teammate’s credits sit unused.</p>
<h2 id="which-plan-actually-fits-your-usage-pattern">Which plan actually fits your usage pattern</h2>
<p>Use Pro or Pro+ when you’re buying for yourself and want the highest credits-per-dollar rate on the lineup. Reach for Max only if a single heavy user’s real usage genuinely exceeds Pro+‘s 7,000-credit pool, and paying $100 beats juggling two separate accounts. Use Business or Enterprise when what you actually need is pooling across an uneven team, or the governance controls that come bundled with a team seat, not the raw credit count. Dollar for dollar, both team tiers buy fewer credits than any individual plan once September 1 arrives.</p>
<p>Check your team’s actual usage pattern, <a href="/ai-productivity/check-copilot-ai-credits-usage-before-sept-1/">covered earlier in this series</a>, before assuming the plan you’re already on is sized right. A team paying Enterprise prices for what’s really an evenly loaded, per-person workload is paying for pooling it never uses.</p>
<p>Browse more coverage like this in the <a href="/ai-productivity">AI Productivity</a> archive, or start from <a href="/ai-productivity/github-copilot-ai-credits-cliff-2026/">GitHub Copilot’s AI Credits Cliff</a> hub for the full reversion story.</p>]]></content:encoded>
      <pubDate>Wed, 19 Aug 2026 00:00:00 GMT</pubDate>
    </item>
    <item>
      <title>GitHub Copilot&apos;s PRU to AI Credits Migration, Explained</title>
      <link>https://bytetech247.com/ai-productivity/github-copilot-pru-to-ai-credits-migration/</link>
      <guid isPermaLink="true">https://bytetech247.com/ai-productivity/github-copilot-pru-to-ai-credits-migration/</guid>
      <description>GitHub retired Premium Request Units for AI Credits on June 1, 2026, for monthly Copilot plans. Here&apos;s what changed under the hood, and what didn&apos;t.</description>
      <content:encoded><![CDATA[<aside class="quick-answer" aria-label="Quick answer"><h2 id="quick-answer">Quick Answer</h2>
<p>On June 1, 2026, GitHub retired Premium Request Units, a flat per-model multiplier, in favor of AI Credits, which price real input, output, and cached tokens per model. Every monthly-billed Pro, Pro+, Business, and Enterprise plan moved automatically. Annual Pro and Pro+ subscribers didn’t move at all; they stayed on the old multiplier system.</p>
</aside><h2 id="why-github-metered-premium-models-in-the-first-place">Why GitHub metered “premium” models in the first place</h2>
<p>GitHub didn’t invent request-based metering on June 1, 2026. It retired one it had already run for exactly a year. Starting June 18, 2025, GitHub began enforcing a monthly cap on premium requests for every seat on Pro, Pro+, Business, and Enterprise, with each plan’s spending limit for anything past that cap defaulting to $0 unless an admin raised it, per <a href="https://github.blog/changelog/2025-06-18-update-to-github-copilot-consumptive-billing-experience/">GitHub’s own 2025 changelog entry</a>.</p>
<p>A premium request wasn’t every interaction with Copilot. Per <a href="https://docs.github.com/en/copilot/reference/copilot-billing/request-based-billing-legacy/github-copilot-premium-requests">GitHub’s legacy billing documentation</a>, it metered the model-backed layer on top of the base subscription: chat sessions against a named premium model, extra-large context windows, advanced reasoning models, cloud agent runs, and generating a Spark app. Some of those models cost more to run than others, so GitHub attached a multiplier to each one: a request against a lightweight model like GPT-4o counted as 0.33 of a premium request, while an advanced reasoning model could burn through 5x or 20x the standard rate for a single call. That multiplier was the entire pricing mechanism. It didn’t care how long the prompt was or how much the model wrote back, only which model answered.</p>
<h2 id="structural-comparison-matrix">Structural Comparison Matrix</h2>



































<table tabindex="0"><thead><tr><th scope="col" style="text-align:left">Operational Aspect</th><th scope="col" style="text-align:left">Before June 1, 2026</th><th scope="col" style="text-align:left">After June 1, 2026</th></tr></thead><tbody><tr><td style="text-align:left"><strong>Billing unit</strong></td><td style="text-align:left">One premium request unit (PRU) per interaction, scaled by a fixed per-model multiplier</td><td style="text-align:left">Real input, output, and cached token counts, priced per model, converted to credits at 1 credit = $0.01</td></tr><tr><td style="text-align:left"><strong>Effect of prompt or response length</strong></td><td style="text-align:left">None. The multiplier is fixed no matter how many tokens the request used</td><td style="text-align:left">Direct. More tokens in or out means more credits spent, every time</td></tr><tr><td style="text-align:left"><strong>Who was on it</strong></td><td style="text-align:left">Every paid plan: Pro, Pro+, Business, Enterprise, monthly and annual billing alike</td><td style="text-align:left">Monthly-billed Pro, Pro+, Business, and Enterprise; annual Pro and Pro+ stayed on PRU</td></tr><tr><td style="text-align:left"><strong>Monthly plan price</strong></td><td style="text-align:left">Pro $10/month, Pro+ $39/month, Business $19/seat/month, Enterprise $39/seat/month</td><td style="text-align:left">Unchanged. Same sticker prices, a different billing mechanism underneath</td></tr><tr><td style="text-align:left"><strong>A lapsed annual Pro/Pro+ plan</strong></td><td style="text-align:left">Renews under the same PRU multiplier system</td><td style="text-align:left">Nothing to renew into. GitHub downgrades it to Copilot Free instead of moving it to Credits</td></tr></tbody></table>
<p>Every figure in that table traces to GitHub’s own changelog or living documentation, cited by section below. Nothing here is a third-party estimate.</p>
<h2 id="what-actually-changed-under-the-hood">What actually changed under the hood</h2>
<p>GitHub’s own docs page written specifically for subscribers affected by this change draws the line cleanly. Quoted directly from <a href="https://docs.github.com/en/copilot/reference/copilot-billing/request-based-billing-legacy/what-changed-with-billing">GitHub’s “What changed with Copilot billing” page</a>, describing the system before June 1:</p>
<blockquote>
<p>Each model interaction cost one premium request unit (PRU), and a multiplier was applied based on which model you used.</p>
</blockquote>
<p>And describing the system after:</p>
<blockquote>
<p>The cost of an interaction depends on two things: the model and the number of tokens consumed.</p>
</blockquote>
<p>That’s a structural change, not a rename. Under PRU, a five-word prompt and a five-thousand-token prompt against the same model cost identically, because the multiplier attached to the request as a whole, never to anything measured inside it. Under Credits, GitHub’s own <a href="https://docs.github.com/en/copilot/reference/copilot-billing/models-and-pricing">models-and-pricing documentation</a> prices input tokens, output tokens, and cached tokens separately for each model, then converts the dollar total to credits at a fixed 1 credit equals $0.01. The multiplier itself, the thing that used to do all the work, is gone for every plan this migration touched. What replaced it counts tokens instead of guessing at request weight.</p>
<p>The same changelog entry that announced this also folded Copilot code review into Actions-minutes billing and rolled out general availability for user-level budget controls, separate changes bundled into the same June 1 release. Worth knowing so you don’t mistake either one for part of the credits migration itself.</p>
<h2 id="a-single-request-priced-two-different-ways">A single request, priced two different ways</h2>
<p>Here’s what that structural difference looks like on one real model. Claude Haiku 4.5 prices at $1.00 per million input tokens and $5.00 per million output tokens under Credits, per GitHub’s live pricing table. Send it 8,000 tokens of context and get back a 1,200-token response, nothing served from cache, and the math runs like this:</p>
<ol>
<li>Input: 8,000 tokens x $1.00 / 1,000,000 = $0.008</li>
<li>Output: 1,200 tokens x $5.00 / 1,000,000 = $0.006</li>
<li>Total: $0.014, or 1.4 credits at the fixed $0.01-per-credit rate</li>
</ol>
<p>Today, on the legacy multiplier table that annual Pro and Pro+ subscribers still use, <a href="https://docs.github.com/en/copilot/reference/copilot-billing/request-based-billing-legacy/model-multipliers-for-annual-plans">Claude Haiku 4.5 carries a 0.33x multiplier</a>. That exchange, on that system, counts as 0.33 of a single premium request against the monthly allowance, full stop. Send it 500 tokens or 50,000, the multiplier doesn’t move. It’s the same contrast as the Structural Comparison Matrix above, just run through one specific model instead of stated abstractly.</p>
<div class="callout" role="note" data-astro-cid-q2ml7llr><p class="callout__title" data-astro-cid-q2ml7llr><span aria-hidden="true" data-astro-cid-q2ml7llr>ℹ</span>Two different tables, not one revised over time</p><div class="callout__body" data-astro-cid-q2ml7llr><p>The 0.33x figure above is what GitHub’s current annual-plan multiplier table
shows today, not a reconstruction of what monthly-plan users paid before June</p><ol>
<li>GitHub revised the annual multiplier table on the same date it retired PRU
for monthly plans, a separate event covered in <a href="/ai-productivity/annual-copilot-plans-model-multipliers-spike/">Annual Copilot Plans: Model
Multipliers Just
Spiked</a>.</li>
</ol></div></div>
<h2 id="what-didnt-change">What didn’t change</h2>
<p>Two things survived the migration intact. Plan prices held: Pro is still $10/month, Pro+ still $39/month, Business still $19 per seat/month, and Enterprise still $39 per seat/month, confirmed directly on <a href="https://docs.github.com/en/copilot/get-started/plans">GitHub’s plans page</a>. Whatever changed on June 1, it wasn’t what a subscription costs to buy.</p>
<p>And per-model cost variance never went away, it just changed units. A heavier reasoning model still costs more to run than a lightweight one; PRU expressed that with a multiplier, Credits express it with a higher per-token rate. That’s the same industry-wide shift this site already covered for OpenAI, Anthropic, and Google’s own pricing in <a href="/ai-productivity/2026-llm-token-pricing-reset/">The 2026 LLM Token &amp; Pricing Reset</a>: flat, coarse units giving way to real per-token economics. Copilot’s version just arrived wearing GitHub’s own billing vocabulary. The exact mechanics of how a token count turns into a credit figure, cached input, cache writes, long-context tiers, are covered in more depth in <a href="/ai-productivity/github-copilot-ai-credits-pricing-explained/">How GitHub Copilot AI Credits Are Actually Priced</a>.</p>
<h2 id="if-youre-on-an-annual-pro-or-pro-plan-none-of-this-happened-yet">If you’re on an annual Pro or Pro+ plan, none of this happened yet</h2>
<p>Everything above describes monthly billing. Individual subscribers who bought an annual Copilot Pro or Pro+ plan weren’t migrated on June 1 at all; they’re still metered by the PRU multiplier system this post just described, unchanged in mechanism. GitHub’s documentation lays out three explicit options for that group: stay on the annual plan under premium request-based billing, cancel for a prorated refund, or upgrade to a monthly plan and receive prorated credit for the plan’s remaining value.</p>
<div class="callout" role="note" data-astro-cid-q2ml7llr><p class="callout__title" data-astro-cid-q2ml7llr><span aria-hidden="true" data-astro-cid-q2ml7llr>⚠</span>Doing nothing doesn&#39;t mean staying put</p><div class="callout__body" data-astro-cid-q2ml7llr><p>Choosing to ride out an annual Pro or Pro+ plan isn’t a neutral choice.
GitHub’s own documentation states that when the plan ends, the account is
“automatically downgraded to Copilot Free,” not migrated to AI Credits. Nobody
lands on Credits without deliberately cancelling and re-subscribing, or
explicitly upgrading before the annual term runs out.</p></div></div>
<p>Annual subscribers aren’t skipping the credits system, they’re on a delayed track toward it, and the default outcome if they don’t act is a downgrade, not an upgrade. <a href="/ai-productivity/annual-copilot-plans-model-multipliers-spike/">Annual Copilot Plans: Model Multipliers Just Spiked</a> covers what changed for that group instead, a revised multiplier table on the very same date, a distinct event from the credits migration that’s easy to conflate with it.</p>
<p>Check which billing system your own plan is actually on before assuming this migration already happened to you. Monthly subscribers got a new pricing mechanism automatically. Annual Pro and Pro+ subscribers got three choices and a deadline, and reading a changelog post won’t tell you which one applies to your seat, your own billing settings will.</p>
<p>Browse more coverage like this in the <a href="/ai-productivity">AI Productivity</a> archive, or start from <a href="/ai-productivity/github-copilot-ai-credits-cliff-2026/">GitHub Copilot’s AI Credits Cliff</a> hub for the full reversion story.</p>]]></content:encoded>
      <pubDate>Wed, 19 Aug 2026 00:00:00 GMT</pubDate>
    </item>
    <item>
      <title>What to Do Before GitHub Copilot&apos;s Sept 1 Credit Cliff</title>
      <link>https://bytetech247.com/ai-productivity/github-copilot-sept-1-credit-cliff-checklist/</link>
      <guid isPermaLink="true">https://bytetech247.com/ai-productivity/github-copilot-sept-1-credit-cliff-checklist/</guid>
      <description>A step-by-step checklist to check Copilot AI credit usage, set budget controls, and size your budget before GitHub&apos;s Sept 1, 2026 credit cliff hits.</description>
      <content:encoded><![CDATA[<aside class="quick-answer" aria-label="Quick answer"><h2 id="quick-answer">Quick Answer</h2>
<p>Before September 1, 2026, work through four things in order: check real per-user usage, decide whether GitHub’s “AI credits paid usage” policy should stay on or off for your org, set a universal user-level budget above the per-license cost, and confirm every budget you’ve set is sized against the smaller 1,900/3,900 pool landing that day, not the promotional one.</p>
</aside><h2 id="four-posts-one-decision-sequence">Four posts, one decision sequence</h2>
<p>This is the last post in ByteTech247’s ten-part series on GitHub Copilot’s September 1 credit cliff, and it doesn’t introduce a single new fact. Everything here was already verified somewhere else in this series: how to check usage, what the budget toggle actually does, what happens when a pool hits zero, and what each tier’s allotment looks like. What’s been missing, until now, is the order to do them in.</p>
<p>That’s the same role this site’s <a href="/ai-productivity/same-prompt-different-bill-gpt-claude-gemini/">Same Prompt, Different Bill</a> post played for the LLM token-and-pricing-reset pillar: four separate provider-specific findings, folded into one comparison a reader could act on without reading all four source posts first. This post does the same job for Copilot’s AI Credits cliff, four mechanics posts reduced to one sequence.</p>
<p>The four pieces this synthesizes: <a href="/ai-productivity/check-copilot-ai-credits-usage-before-sept-1/">checking your actual usage</a> across the three surfaces GitHub shipped in mid-2026, <a href="/ai-productivity/github-copilot-budget-controls-setup/">setting budget controls</a> with the real toggle mechanism, <a href="/ai-productivity/what-happens-when-copilot-credits-run-out/">what actually happens at zero credits</a>, and the tier allotments the <a href="/ai-productivity/github-copilot-ai-credits-cliff-2026/">pillar hub</a> itself covers. Read any one of them, and you get a correct answer to a narrow question. Read them in this order, and you get a checklist.</p>
<h2 id="structural-comparison-matrix">Structural Comparison Matrix</h2>



































<table tabindex="0"><thead><tr><th scope="col" style="text-align:left">Decision point</th><th scope="col" style="text-align:left">Left unconfigured</th><th scope="col" style="text-align:left">After this checklist</th></tr></thead><tbody><tr><td style="text-align:left"><strong>Usage visibility</strong></td><td style="text-align:left">Nobody knows where any user, team, or org actually stands against the pool</td><td style="text-align:left">Real numbers pulled from Copilot settings, the <code>ai_credits_used</code> API field, and the org/enterprise dashboard</td></tr><tr><td style="text-align:left"><strong>“AI credits paid usage” policy</strong></td><td style="text-align:left">Stays on by default; metered billing continues automatically and uncapped once the pool empties</td><td style="text-align:left">Explicitly disabled for a hard stop, or deliberately left on with real budgets backing it</td></tr><tr><td style="text-align:left"><strong>Universal user-level budget</strong></td><td style="text-align:left">Not set; no ceiling protects any individual’s spend</td><td style="text-align:left">Set above the per-license cost ($19 Business, $39 Enterprise), hard-stopping automatically</td></tr><tr><td style="text-align:left"><strong>Org/cost-center “Stop usage” toggle</strong></td><td style="text-align:left">Off by default; a limit sends a notification while charges keep accruing</td><td style="text-align:left">Switched on or off as a deliberate choice, not an inherited default</td></tr><tr><td style="text-align:left"><strong>Credit pool assumption</strong></td><td style="text-align:left">Budgets sized against the promotional pool (3,000 Business, 7,000 Enterprise)</td><td style="text-align:left">Re-sized against the standard pool (1,900 Business, 3,900 Enterprise) landing September 1</td></tr></tbody></table>
<p>Every cell above restates a fact already verified, cited, and dated in one of the four linked posts, not a new claim introduced here.</p>
<h2 id="step-1-see-where-you-actually-stand">Step 1: See where you actually stand</h2>
<p>Before anything else, check the actual numbers. <a href="/ai-productivity/check-copilot-ai-credits-usage-before-sept-1/">Checking usage</a> covers three separate views GitHub shipped inside a five-week window in mid-2026: an individual “Usage this cycle” screen under Copilot settings that needs no admin role (shipped 2026-07-20), a per-user <code>ai_credits_used</code> field in the usage metrics API for org owners and enterprise admins (shipped 2026-06-19), and an org- or enterprise-level dashboard that groups usage by adoption phase (shipped 2026-07-22).</p>
<p>None of the three set a limit. They only show a number. A budget sized without that number first is a guess, not a control, and guessing at a budget is exactly the mistake this whole series exists to prevent.</p>
<h2 id="step-2-decide-what-credits-run-out-means-for-your-org">Step 2: Decide what “credits run out” means for your org</h2>
<p>This step is the one most likely to go wrong by default. An earlier framing on this pillar’s own hub post, and one that shows up constantly across GitHub Community threads, treats credit exhaustion as something that halts safely on its own. <a href="/ai-productivity/what-happens-when-copilot-credits-run-out/">What actually happens when Copilot credits run out</a> found that’s backward for Business and Enterprise orgs, quoting GitHub’s own <a href="https://docs.github.com/en/copilot/concepts/billing/usage-based-billing-for-organizations-and-enterprises">usage-based billing documentation</a> directly:</p>
<blockquote>
<p>Additional usage is enabled by default for organizations and enterprises. If you want to prevent any spending beyond your included AI credits, an administrator must explicitly disable the AI credits paid usage policy in your enterprise’s or organization’s AI Controls settings.</p>
</blockquote>
<p>An org that has never touched its AI Controls settings, never set a budget, and never disabled anything keeps billing metered AI credit usage at $0.01/credit the moment its shared pool empties, for as long as usage continues. That leaves a real decision, not a passive default to accept. Disable “AI credits paid usage” and accept a hard stop once the pool empties, matching what plenty of admins assume already happens. Or leave it on and back it with real budgets instead, the subject of the next step.</p>
<h2 id="step-3-set-the-budget-that-actually-enforces-your-decision">Step 3: Set the budget that actually enforces your decision</h2>
<p>Whichever way step 2 goes, <a href="/ai-productivity/github-copilot-budget-controls-setup/">budget controls</a> are the mechanism that makes it real instead of aspirational. Set the universal user-level budget first, above the per-license value, $19/user/month for Business, $39/user/month for Enterprise, or credit pooling across the org breaks before it can do its job. GitHub’s own <a href="https://docs.github.com/en/copilot/tutorials/budgets/getting-started-with-budget-controls">getting-started tutorial</a> calls this one setting “the single most important control,” and it’s the one budget scope with no toggle to configure. It always hard-stops the moment a user hits it.</p>
<p>Everything past that is a real judgment call, not a fixed rule. Override the universal budget for identified power users running heavy Agent Mode sessions. Decide, scope by scope, whether an org or cost-center budget’s “Stop usage when budget limit is reached” toggle should be on, a hard block once the limit hits, or off, a notification while charges keep accruing, per <a href="https://docs.github.com/en/copilot/concepts/billing/budgets-for-usage-based-billing">GitHub’s own budgets documentation</a>. That toggle starts off by default on every newly created org- or cost-center-scoped budget, so leaving it untouched is itself a choice, whether anyone meant it to be.</p>
<h2 id="step-4-know-the-number-youre-budgeting-against">Step 4: Know the number you’re budgeting against</h2>
<p>Every budget from step 3 is only as good as the pool it’s measured against, and that pool is about to shrink. Business drops from a promotional 3,000 credits/user/month to a standard 1,900 on September 1, 2026. Enterprise drops from 7,000 to 3,900, per <a href="/ai-productivity/github-copilot-ai-credits-cliff-2026/">the pillar hub covering the reversion itself</a>. A budget sized generously against the July pool can start blocking real work in September if nobody revisits the number.</p>
<p>Individual plans sit outside this specific cliff. Copilot Pro includes 1,500 credits a month, Pro+ includes 7,000, and Max includes 20,000, figures the exhaustion-behavior post verified against GitHub’s own individual-plans billing documentation, and none of those allotments revert on September 1 the way the pooled Business and Enterprise numbers do. A full plan-by-plan credit comparison, weighing whether an upgrade actually buys proportionally more headroom, is planned as its own post in this series and isn’t published as of this writing. Until then, the tier figures above are the numbers to plan against.</p>
<h2 id="the-five-minute-version">The five-minute version</h2>
<p>If there’s time for exactly one pass before September 1, work through this order:</p>
<ol>
<li>Open Copilot settings and check “Usage this cycle,” or pull the org dashboard if you manage more than your own seat.</li>
<li>Decide “AI credits paid usage” on purpose: disabled for a hard stop, or left on with a plan to back it.</li>
<li>Set the universal user-level budget above $19 (Business) or $39 (Enterprise) per user.</li>
<li>Re-check any existing budget against 1,900 (Business) or 3,900 (Enterprise), not the promotional number it might still be sized against.</li>
</ol>
<div class="callout" role="note" data-astro-cid-q2ml7llr><p class="callout__title" data-astro-cid-q2ml7llr><span aria-hidden="true" data-astro-cid-q2ml7llr>✓</span>If only one step happens today</p><div class="callout__body" data-astro-cid-q2ml7llr><p>Steps 1 and 3 matter most. Usage visibility tells you whether there’s a
problem at all, and the universal user-level budget is the one control
GitHub’s own tutorial calls “the single most important” of the bunch, the only
one that hard-stops with no toggle to accidentally leave off.</p></div></div>
<h2 id="do-this-before-the-pool-shrinks-under-it">Do this before the pool shrinks under it</h2>
<p>None of this requires a rebuild. It requires one afternoon: pull the real usage numbers, make the paid-usage-policy decision on purpose instead of by default, set the universal budget above the per-license floor, and re-size every existing budget against the pool that lands September 1, not the one that’s about to disappear.</p>
<p>Skip it, and the default still applies whether anyone chose it or not. Metered billing runs uncapped past an empty pool unless something explicit stops it first. That’s not a hypothetical, it’s the documented default for every Business and Enterprise org that hasn’t touched its AI Controls settings yet. Check the number today. September 1 doesn’t wait for a slow afternoon.</p>
<p>Browse the rest of this series starting from the <a href="/ai-productivity/github-copilot-ai-credits-cliff-2026/">AI Credits cliff hub</a>, or more coverage in the <a href="/ai-productivity">AI Productivity</a> archive.</p>]]></content:encoded>
      <pubDate>Wed, 19 Aug 2026 00:00:00 GMT</pubDate>
    </item>
    <item>
      <title>Pick Cheaper Copilot Models to Stretch Your Credits</title>
      <link>https://bytetech247.com/ai-productivity/pick-cheaper-copilot-models-stretch-credits/</link>
      <guid isPermaLink="true">https://bytetech247.com/ai-productivity/pick-cheaper-copilot-models-stretch-credits/</guid>
      <description>GitHub Copilot prices every model differently. Here&apos;s when a cheaper model is genuinely enough, and when frontier capability is worth the extra credits.</description>
      <content:encoded><![CDATA[<aside class="quick-answer" aria-label="Quick answer"><h2 id="quick-answer">Quick Answer</h2>
<p>Route routine Copilot requests, boilerplate, quick edits, single-function reviews, to a Lightweight or Versatile model like GPT-5 mini. Reserve a Powerful model like Claude Opus 4.8 for multi-file refactors, architecture decisions, and long agent loops. The price gap runs about 15x per token, a real credit-saving lever the old flat-multiplier system never gave you.</p>
</aside><h2 id="per-token-pricing-makes-model-choice-a-real-cost-decision">Per-token pricing makes model choice a real cost decision</h2>
<p>GitHub Copilot’s AI Credits price real token counts, not a flat per-request charge. <a href="/ai-productivity/github-copilot-ai-credits-pricing-explained/">How GitHub Copilot AI Credits Are Actually Priced</a> covers the full mechanics: 1 credit equals $0.01, and every model bills its own rate for input, cached input, cache-write, and output tokens. That post’s worked example lands on a specific number worth carrying forward: the same 100,000-token prompt costs about 15 times more credits on Claude Opus 4.8 than on GPT-5 mini.</p>
<p>Premium Request Units never let anyone act on that gap. A flat multiplier attached to the request, not to the model actually doing the work, so switching models didn’t change what you paid. Credits attach the cost to the model itself. That turns model choice into a repeatable lever for making a fixed credit pool last longer, not just a quality preference.</p>
<h2 id="githubs-own-task-guidance-not-just-a-price-label">GitHub’s own task guidance, not just a price label</h2>
<p>GitHub publishes two separate classification systems for its models, and conflating them is the easiest way to pick badly. The <a href="/ai-productivity/github-copilot-ai-credits-pricing-explained/">pricing table</a> sorts every model into a cost tier: Lightweight, Versatile, or Powerful, based on what it costs per token. A completely different page, <a href="https://docs.github.com/en/copilot/reference/ai-models/model-comparison">GitHub’s AI model comparison guide</a>, sorts models into task areas instead: General-purpose coding and writing, Fast help with simple or repetitive tasks, Deep reasoning and debugging, and Working with visuals.</p>
<p>Those two systems don’t line up as neatly as the names suggest. GPT-5.6 Luna prices as Lightweight and GitHub recommends it for the simple-tasks category, a clean match. Claude Haiku 4.5 prices as Versatile, a mid-tier label, yet GitHub recommends it for the same simple-tasks category, alongside GPT-5.6 Luna. And GPT-5 mini, priced Lightweight, the cheapest tier on the whole table, shows up in GitHub’s own recommendations for three different task areas at once: general-purpose coding, deep reasoning and debugging, and visual work. GitHub’s guide credits it with reasoning and debugging quality close to full-size GPT-5, just at noticeably faster response times and lower resource cost.</p>
<div class="callout" role="note" data-astro-cid-q2ml7llr><p class="callout__title" data-astro-cid-q2ml7llr><span aria-hidden="true" data-astro-cid-q2ml7llr>ℹ</span>Cheap tier does not mean simple-tasks-only</p><div class="callout__body" data-astro-cid-q2ml7llr><p>GPT-5 mini being GitHub’s cheapest-priced model and also its recommended pick
for “Deep reasoning and debugging” is the single most useful fact in this
post. It means the default assumption, that a low-cost model is only fit for
trivial work, doesn’t hold up against GitHub’s own guidance. Start cheap and
escalate on evidence, not on the price tier’s name alone.</p></div></div>
<h2 id="structural-comparison-matrix">Structural Comparison Matrix</h2>









































<table tabindex="0"><thead><tr><th scope="col" style="text-align:left">Model</th><th scope="col" style="text-align:left">Price tier (rate table)</th><th scope="col" style="text-align:right">Input / Output per 1M tokens</th><th scope="col" style="text-align:left">GitHub’s recommended task area(s)</th></tr></thead><tbody><tr><td style="text-align:left"><strong>GPT-5.6 Luna</strong></td><td style="text-align:left">Lightweight</td><td style="text-align:right">$0.20 / $1.20</td><td style="text-align:left">Fast help with simple or repetitive tasks</td></tr><tr><td style="text-align:left"><strong>GPT-5 mini</strong></td><td style="text-align:left">Lightweight</td><td style="text-align:right">$0.25 / $2.00</td><td style="text-align:left">General-purpose coding; deep reasoning and debugging; visuals</td></tr><tr><td style="text-align:left"><strong>Claude Haiku 4.5</strong></td><td style="text-align:left">Versatile</td><td style="text-align:right">$1.00 / $5.00</td><td style="text-align:left">Fast help with simple or repetitive tasks</td></tr><tr><td style="text-align:left"><strong>GPT-5.6 Sol</strong></td><td style="text-align:left">Powerful</td><td style="text-align:right">$5.00 / $30.00</td><td style="text-align:left">Deep reasoning and debugging</td></tr><tr><td style="text-align:left"><strong>Claude Opus 4.7</strong></td><td style="text-align:left">Powerful</td><td style="text-align:right">$5.00 / $25.00</td><td style="text-align:left">Deep reasoning and debugging</td></tr></tbody></table>
<p>Every price and task-area pairing above comes from cross-referencing GitHub’s live <a href="https://docs.github.com/en/copilot/reference/copilot-billing/models-and-pricing">models-and-pricing table</a> against its <a href="https://docs.github.com/en/copilot/reference/ai-models/model-comparison">model comparison guide</a>, both checked directly on 2026-08-19. Neither page cross-links the other’s classification, so this table is this post’s own cross-check, not something GitHub publishes as a single reference.</p>
<h2 id="what-good-enough-actually-costs">What “good enough” actually costs</h2>
<p>Take a routine Copilot Chat exchange: a quick function review or a small edit, something like 2,000 input tokens and 500 output tokens. That’s a realistic size for the kind of request GitHub’s own guidance points at Lightweight and Versatile models.</p>
<p><strong>GPT-5 mini</strong> ($0.25 input / $2.00 output per 1M tokens):</p>
<ol>
<li>Input: 2,000 tokens x $0.25 / 1,000,000 = $0.0005</li>
<li>Output: 500 tokens x $2.00 / 1,000,000 = $0.001</li>
<li>Total: $0.0015, or 0.15 credits</li>
</ol>
<p><strong>Claude Opus 4.8</strong> ($5.00 input / $25.00 output per 1M tokens):</p>
<ol>
<li>Input: 2,000 tokens x $5.00 / 1,000,000 = $0.01</li>
<li>Output: 500 tokens x $25.00 / 1,000,000 = $0.0125</li>
<li>Total: $0.0225, or 2.25 credits</li>
</ol>
<p>Same 15x gap the pricing post found at a much bigger token count, holding steady at a small one. The difference only starts to matter at volume, so scale it up: a developer sending something like 30 of these routine requests a day, every one of them on Claude Opus 4.8, spends about 67.5 credits a day. Over a 30-day month that’s 2,025 credits, more than the entire 1,900-credit standard Business allowance this site’s <a href="/ai-productivity/github-copilot-ai-credits-cliff-2026/">AI Credits cliff hub</a> covers, on routine requests alone, with nothing left for anything harder. The same 30 requests a day on GPT-5 mini cost about 135 credits for the month, leaving roughly 1,765 credits of that same pool for the work that actually needs a Powerful model.</p>
<div class="callout" role="note" data-astro-cid-q2ml7llr><p class="callout__title" data-astro-cid-q2ml7llr><span aria-hidden="true" data-astro-cid-q2ml7llr>⚠</span>This is an illustrative rate, not a GitHub-published usage figure</p><div class="callout__body" data-astro-cid-q2ml7llr><p>GitHub doesn’t publish an average requests-per-day number. The 30-requests
estimate above is a reasonable stand-in for a moderately active user, used to
show how the per-request gap compounds, not a claimed real average.</p></div></div>
<h2 id="when-the-extra-credits-are-worth-spending">When the extra credits are worth spending</h2>
<p>GitHub’s own guide is specific about what actually calls for a Powerful model, not just a rough “harder problems” gesture. Under Deep reasoning and debugging, it recommends reaching for one of those models when you want to:</p>
<blockquote>
<p>Debug complex issues with context across multiple files. Refactor large or interconnected codebases. Plan features or architecture across layers. Weigh trade-offs between libraries, patterns, or workflows. Analyze logs, performance data, or system behavior.</p>
</blockquote>
<p>GPT-5.6 Sol carries the strongest language of any model in that section. GitHub positions it as the top of the GPT-5.6 lineup for reasoning depth, the pick for large-codebase problems and agent sessions that run long without a break. Claude Opus 4.7 gets billed as Anthropic’s flagship, its most capable model at the time this was checked. Gemini 3.1 Pro is aimed squarely at long-context, technical analysis work, exactly the kind of task where losing context partway through actually costs you something.</p>
<p>None of that is about raw token count. A 5,000-token prompt asking a Powerful model to plan a cross-service refactor is a legitimate use of the extra credits. A 5,000-token prompt asking the same model to fix a missing semicolon isn’t, and GitHub’s own general-purpose and fast-help categories exist precisely to catch that second case before it reaches a Powerful model at all.</p>
<h2 id="let-auto-route-it-or-do-it-yourself-just-dont-switch-mid-session">Let Auto route it, or do it yourself, just don’t switch mid-session</h2>
<p>GitHub built an alternative to picking a model by hand: Auto with task optimization. GitHub says it’s now generally available in Copilot Chat’s web interface, VS Code, Copilot CLI, and the GitHub Copilot app, not a preview feature. Per <a href="https://docs.github.com/en/copilot/concepts/models/auto-model-selection">GitHub’s own Auto model selection documentation</a>, it combines two systems, one tracking real-time model availability, the other evaluating task complexity, and routes each request to whichever model fits. GitHub’s own framing of the goal is worth quoting directly, not paraphrasing:</p>
<blockquote>
<p>Reserving higher-cost reasoning models for problems that truly need it, while routing straightforward tasks to faster, lower-cost models that still deliver great results.</p>
</blockquote>
<p>Paid plans get something concrete out of trusting that routing, too: a 10% discount on model costs specifically while using Auto, applied in Copilot Chat, Copilot CLI, the GitHub Copilot app, or Copilot cloud agent.</p>
<p>That’s not the only Auto running across Copilot’s clients, and the distinction matters. JetBrains IDEs, Eclipse, Xcode, and Visual Studio’s preview run an older flavor, “optimized for reliability and availability,” that picks purely on real-time system health to cut rate limiting. It doesn’t evaluate task complexity or claim a cost benefit the way the task-optimized version does. Check which Auto your client is actually running before assuming it’s doing the routing this post describes.</p>
<div class="callout" role="note" data-astro-cid-q2ml7llr><p class="callout__title" data-astro-cid-q2ml7llr><span aria-hidden="true" data-astro-cid-q2ml7llr>⚠</span>Mid-session model switching has a real cost penalty</p><div class="callout__body" data-astro-cid-q2ml7llr><p>GitHub’s own documentation warns against exactly this habit: bouncing between
models partway through one chat session adds cost without a reliable gain in
answer quality, per its Auto model selection guidance quoted above. The
credit-saving move isn’t jumping between models mid-conversation. It’s picking
the right one, or trusting task-optimized Auto to pick it, before you send the
first message.</p></div></div>
<p>One more real constraint before any of this applies: on Business and Enterprise plans, <a href="https://docs.github.com/en/copilot/how-tos/use-ai-models/change-the-chat-model">changing the chat model</a> isn’t available by default. An organization or enterprise has to explicitly grant members the ability to switch models at all. If that policy isn’t on, every request runs on whatever model an admin configured, regardless of which one would actually be cheaper for the task in front of you.</p>
<h2 id="model-choice-matters-most-once-agent-mode-is-looping">Model choice matters most once Agent Mode is looping</h2>
<p>A single chat exchange burns tokens once. Agent Mode’s read-file, edit, run-tests, re-read loop burns tokens on every step of that loop, which means a model choice made at the start compounds across every iteration, not just one request. That outsized consumption pattern, and the specific levers for controlling it, gets its own full treatment in <a href="/ai-productivity/copilot-agent-mode-credits-consumption/">Why Copilot Agent Mode Burns Through Credits So Fast</a>, the next post in this series. The short version worth carrying over here: the routing decisions this post covers matter more, not less, once the requests stop being one-off chat questions and start being agent steps that repeat the same model choice dozens of times in a row.</p>
<h2 id="route-by-default-escalate-on-purpose">Route by default, escalate on purpose</h2>
<p>Default to a Lightweight or Versatile model for the requests that make up most of a working day, boilerplate, quick edits, single-function reviews, and let GitHub’s own task guidance, not the price tier’s name, tell you when a request actually needs a Powerful model. GPT-5 mini earning a spot in GitHub’s deep-reasoning recommendations is the proof that “cheap” and “only for simple work” aren’t the same claim. Escalate when a task genuinely needs multi-file context or architectural judgment, not by habit, and pick that model before the session starts rather than switching partway through.</p>
<p>Browse more coverage like this in the <a href="/ai-productivity">AI Productivity</a> archive, or start from <a href="/ai-productivity/github-copilot-ai-credits-cliff-2026/">GitHub Copilot’s AI Credits Cliff</a> hub for the full picture.</p>]]></content:encoded>
      <pubDate>Wed, 19 Aug 2026 00:00:00 GMT</pubDate>
    </item>
    <item>
      <title>What Happens When GitHub Copilot Credits Run Out</title>
      <link>https://bytetech247.com/ai-productivity/what-happens-when-copilot-credits-run-out/</link>
      <guid isPermaLink="true">https://bytetech247.com/ai-productivity/what-happens-when-copilot-credits-run-out/</guid>
      <description>GitHub Copilot&apos;s paid overage is on by default for orgs once AI credits run out - here is exactly what happens with and without a budget.</description>
      <content:encoded><![CDATA[<aside class="quick-answer" aria-label="Quick answer"><h2 id="quick-answer">Quick Answer</h2>
<p>On Business and Enterprise plans, running out of pooled AI credits with no budget configured doesn’t stop anything: GitHub’s “AI credits paid usage” policy is enabled by default, so metered billing continues automatically and uncapped. Individual Pro, Pro+, and Max users don’t get billed the same way; they’re prompted to upgrade, wait, or manually set a budget.</p>
</aside><h2 id="why-this-keeps-showing-up-in-githubs-own-community-forum">Why this keeps showing up in GitHub’s own community forum</h2>
<p>Search “Copilot credits run out” and you land in the middle of a real, ongoing argument among GitHub admins about what actually happens. GitHub’s own community-run FAQ thread, <a href="https://github.com/orgs/community/discussions/197089">discussion #197089, “All GitHub Copilot plans are now on usage-based billing”</a>, has drawn hundreds of comments since the June 2026 rollout, many of them asking the exact question this post answers.</p>
<p>Two other threads narrow the confusion to specific scenarios. In <a href="https://github.com/orgs/community/discussions/197557">discussion #197557</a>, a student reports their entire monthly Student-plan allowance gone after roughly 10 to 20 Agent Mode requests on the first day of a new cycle, a number the poster gives as 200 credits for that plan; that figure comes from the reporting user, not from GitHub’s own published plan table, which doesn’t list an exact Student allowance. In <a href="https://github.com/orgs/community/discussions/197605">discussion #197605</a>, an enterprise admin describes a setting that used to read “$0 budget” and block overage automatically now showing “No Usage Limit” instead, and asks how to stop unexpected charges. That admin’s confusion is the same one this post exists to resolve.</p>
<h2 id="what-happens-on-an-individual-plan-pro-pro-and-max">What happens on an individual plan: Pro, Pro+, and Max</h2>
<p>Copilot Pro includes 1,500 AI credits a month (1,000 base plus a 500 flex allotment), Pro+ includes 7,000 (3,900 base plus 3,100 flex), and Max includes 20,000 (10,000 base plus 10,000 flex), each priced at 1 AI credit = $0.01, per <a href="https://docs.github.com/en/copilot/concepts/billing/usage-based-billing-for-individuals">GitHub’s individual-plans billing documentation</a>. Once that allowance is gone mid-cycle, GitHub’s docs describe three options, not one automatic outcome:</p>
<ul>
<li>Upgrade to the next plan. You’re only charged the price difference, and credit already used in the cycle carries over into the larger allowance.</li>
<li>Stay on your current plan and pay for more. GitHub frames this as an action you take, not a default that’s already active: you have to set a budget for that additional usage before any of it is served.</li>
<li>Wait it out. Your allowance resets at 00:00 UTC on the 1st of the next calendar month, the same fixed reset time every plan uses.</li>
</ul>
<p>Nothing in that page says additional usage is already switched on the way it is for organizations. A Pro or Pro+ subscriber who never touches their billing settings and runs out mid-cycle doesn’t get a silent bill; they get prompted to act.</p>
<h2 id="what-happens-on-business-and-enterprise-with-no-budget-configured">What happens on Business and Enterprise with no budget configured</h2>
<p>This is where the brief that GitHub-admin folklore repeats gets it backward, and it’s worth quoting GitHub’s own docs directly rather than paraphrasing. <a href="https://docs.github.com/en/copilot/concepts/billing/usage-based-billing-for-organizations-and-enterprises">GitHub’s usage-based billing documentation for organizations and enterprises</a> answers this directly, in the section explaining what happens once a pooled allowance runs out:</p>
<blockquote>
<p>Additional usage is enabled by default for organizations and enterprises. If you want to prevent any spending beyond your included AI credits, an administrator must explicitly disable the AI credits paid usage policy in your enterprise’s or organization’s AI Controls settings.</p>
</blockquote>
<p>Read that twice. It’s not describing a safety net. An org that has never touched its AI Controls settings, never set a budget, and never disabled anything, keeps billing metered AI credit usage at published per-credit rates the moment its shared pool empties, for as long as usage continues. <a href="https://docs.github.com/en/copilot/tutorials/budgets/getting-started-with-budget-controls">GitHub’s companion budgets documentation</a> confirms the same default applies to any budget an admin does set:</p>
<blockquote>
<p>By default, reaching a spending limit sends a notification but does not stop usage. Charges continue to accrue without a cap until you manually intervene.</p>
</blockquote>
<div class="callout" role="note" data-astro-cid-q2ml7llr><p class="callout__title" data-astro-cid-q2ml7llr><span aria-hidden="true" data-astro-cid-q2ml7llr>⚠</span>There is no default $0 budget in the current system</p><div class="callout__body" data-astro-cid-q2ml7llr><p>A default $0 budget for premium requests was real once, but GitHub <a href="https://github.blog/changelog/2025-09-17-upcoming-removal-of-copilot-premium-request-0-budgets-for-enterprise-and-team-accounts/">retired it
for enterprise and team accounts on December 2,
2025</a>.
The AI Credits system that replaced Premium Request Units on June 1, 2026,
doesn’t carry that default forward. If you’re picturing a $0 cap protecting
you automatically, that assumption is describing a system GitHub no longer
runs.</p></div></div>
<p>The only configuration that produces a genuine, automatic halt at zero credits is disabling the “AI credits paid usage” policy itself. Do that, and the result is a real stop: nothing more gets served once the pool is empty, and it stays that way until the following month’s allowance lands, regardless of any budget you have or don’t have configured.</p>
<h2 id="structural-comparison-matrix">Structural Comparison Matrix</h2>

































<table tabindex="0"><thead><tr><th scope="col" style="text-align:left">Configuration state</th><th scope="col" style="text-align:left">What actually happens at exhaustion</th></tr></thead><tbody><tr><td style="text-align:left"><strong>Business/Enterprise, nothing configured</strong></td><td style="text-align:left">“AI credits paid usage” defaults on; metered billing continues automatically at $0.01/credit, uncapped</td></tr><tr><td style="text-align:left"><strong>Business/Enterprise, “AI credits paid usage” disabled</strong></td><td style="text-align:left">Usage blocks entirely once the pool empties, until the next billing cycle, regardless of budgets</td></tr><tr><td style="text-align:left"><strong>A budget is set, “Stop usage” toggle left off</strong> (its own default)</td><td style="text-align:left">Admin gets a notification; metered charges keep accruing past that budget’s limit anyway</td></tr><tr><td style="text-align:left"><strong>A budget is set, “Stop usage” toggle switched on</strong></td><td style="text-align:left">Metered usage blocks the moment that budget’s limit is reached</td></tr><tr><td style="text-align:left"><strong>A user-level budget (ULB) is set, any plan</strong></td><td style="text-align:left">Always a hard stop at the limit for that user, no toggle, active across both the pool and metered phases</td></tr><tr><td style="text-align:left"><strong>Individual Pro/Pro+/Max, no additional-usage budget set</strong></td><td style="text-align:left">No silent billing; Copilot prompts an upgrade, waits for reset, or requires a manually set budget</td></tr></tbody></table>
<p>Every row above is quoted or closely paraphrased from GitHub’s own <a href="https://docs.github.com/en/copilot/concepts/billing/budgets-for-usage-based-billing">budgets-for-usage-based-billing</a> and <a href="https://docs.github.com/en/copilot/concepts/billing/usage-based-billing-for-organizations-and-enterprises">usage-based-billing-for-organizations-and-enterprises</a> documentation, live-checked while drafting this post rather than carried over from an earlier summary.</p>
<h2 id="how-the-check-actually-runs-step-by-step">How the check actually runs, step by step</h2>
<p>GitHub’s docs describe the order a single Copilot request gets evaluated in, which is the fastest way to reason about your own org’s real exposure:</p>
<ol>
<li><strong>A user-level budget check runs first.</strong> Whichever ULB is most specific to that user, individual, then cost-center, then universal, gets checked before anything else happens. Already past that number, and the request never even reaches the shared pool.</li>
<li><strong>The shared pool absorbs whatever it can.</strong> Room left in the pool means this particular call costs nothing extra. The instant that pool hits zero, every subsequent call becomes metered, billed usage instead.</li>
<li><strong>Metered usage looks for a matching budget.</strong> GitHub checks a cost center’s budget first, then an organization’s, then the enterprise-wide spending limit, whichever one actually applies to that user. None of those can block anything unless its own “Stop usage when budget limit is reached” switch has been flipped on.</li>
</ol>
<p>Skip step 3 entirely, no cost center budget, no org budget, no enterprise limit, and there’s simply nothing left to check. The request goes through as paid overage. That’s the whole mechanism behind the default this post opened with: not a special case, just what happens when the chain above runs out of budgets to consult.</p>
<div class="callout" role="note" data-astro-cid-q2ml7llr><p class="callout__title" data-astro-cid-q2ml7llr><span aria-hidden="true" data-astro-cid-q2ml7llr>ℹ</span>Budgets never downgrade you to a cheaper model</p><div class="callout__body" data-astro-cid-q2ml7llr><p>Hitting any limit, ULB or budget, blocks the AI-credit-consuming feature
outright. GitHub’s docs are explicit that there’s no automatic fallback to a
lower-cost model. Code completions and next-edit suggestions keep working
regardless, since those aren’t billed in AI credits at all.</p></div></div>
<h2 id="what-to-do-with-this-before-september-1">What to do with this before September 1</h2>
<p>If your org is running on whatever GitHub set up by default, you are already exposed to uncapped metered billing the moment your pool empties, promotional allowance or not. Checking your org’s “AI credits paid usage” setting takes minutes; discovering it the hard way, in next month’s invoice, doesn’t. The <a href="/ai-productivity/github-copilot-budget-controls-setup/">budget-controls setup guide</a> in this series walks through configuring a universal user-level budget, sizing a spending limit, and turning on “Stop usage when budget limit is reached” so the default above stops being the thing protecting or exposing your org by accident.</p>
<p>Decide deliberately: either disable “AI credits paid usage” and accept a hard stop at the pool’s edge, or leave it on and set real budgets with the stop toggle enabled. Doing neither isn’t neutral. It’s the org quietly choosing uncapped, automatic billing, whether anyone meant to or not. For the fuller picture of what’s reverting and when, see the <a href="/ai-productivity/github-copilot-ai-credits-cliff-2026/">AI Credits cliff hub</a> this post is part of, and browse more coverage in the <a href="/ai-productivity">AI Productivity</a> archive.</p>]]></content:encoded>
      <pubDate>Wed, 19 Aug 2026 00:00:00 GMT</pubDate>
    </item>
    <item>
      <title>actions/checkout v7 Blocks pull_request_target PRs</title>
      <link>https://bytetech247.com/dev-tools/actions-checkout-v7-blocks-pull-request-target-prs/</link>
      <guid isPermaLink="true">https://bytetech247.com/dev-tools/actions-checkout-v7-blocks-pull-request-target-prs/</guid>
      <description>actions/checkout v7 refuses to fetch fork PR code in pull_request_target and workflow_run by default. Here&apos;s the fix if your workflow relied on it.</description>
      <content:encoded><![CDATA[<aside class="quick-answer" aria-label="Quick answer"><h2 id="quick-answer">Quick Answer</h2>
<p><code>actions/checkout</code> v7 refuses to check out a fork pull request’s head or merge ref inside <code>pull_request_target</code> and PR-flavored <code>workflow_run</code> workflows, closing the classic “pwn request” hole where a workflow runs with the base repo’s secrets but executes an attacker’s code. Floating tags (<code>@v4</code>) picked up the fix automatically once it was backported on 2026-07-20; pinned versions and SHAs need an explicit bump.</p>
</aside><h2 id="why-this-was-a-real-vulnerability-not-a-theoretical-one">Why this was a real vulnerability, not a theoretical one</h2>
<p><code>pull_request_target</code> exists so that a workflow can run with the base repository’s full secrets and write permissions even when triggered by a fork’s pull request, useful for things like commenting on the PR or triggering a deploy preview. The catch: many workflows using that trigger also checked out the fork’s own head commit, because that’s the code the PR is actually proposing to merge.</p>
<p>Put those two facts together, and you get the “pwn request” pattern: a workflow with real secrets, running arbitrary code from someone who doesn’t have write access to your repo. An attacker opens a PR from a fork, and any workflow step that executes something from the checked-out fork code (a build script, a test runner, a linter with a plugin system) runs with the base repo’s permissions.</p>
<p><code>actions/checkout</code> v7.0.0 (released 2026-06-18) closes this by refusing to fetch the fork’s code in that specific context. The action now fails on patterns like these when it detects a <code>pull_request_target</code> or PR-flavored <code>workflow_run</code> event:</p>
<div class="code-block"><div class="code-block__header"><span class="code-block__filename"></span><button type="button" class="code-block__copy" data-copy-button>Copy</button></div><pre class="astro-code github-dark" style="background-color:#24292e;color:#e1e4e8;overflow-x:auto" tabindex="0" data-language="yaml"><code><span class="line"><span style="color:#9ca6b0"># actions/checkout v7 blocks all three of these inside</span></span>
<span class="line"><span style="color:#9ca6b0"># pull_request_target and PR-flavored workflow_run:</span></span>
<span class="line"><span style="color:#85E89D">ref</span><span style="color:#E1E4E8">: </span><span style="color:#9ECBFF">refs/pull/${{ github.event.pull_request.number }}/merge</span></span>
<span class="line"><span style="color:#85E89D">ref</span><span style="color:#E1E4E8">: </span><span style="color:#9ECBFF">${{ github.event.pull_request.head.sha }}</span></span>
<span class="line"><span style="color:#85E89D">repository</span><span style="color:#E1E4E8">: </span><span style="color:#9ECBFF">${{ github.event.pull_request.head.repo.full_name }}</span></span></code></pre></div>
<p>The fix landed as a major release first, then GitHub backported it to every older supported major version on 2026-07-20: the v6 line got v6.1.0, v5 got v5.1.0, v4 got v4.4.0, v3 got v3.7.0, and v2 got v2.8.0, each shipping the same enforcement that same day. Workflows pinned to a floating major tag like <code>@v4</code> picked up the change automatically the next time that job ran.</p>
<div class="callout" role="note" data-astro-cid-q2ml7llr><p class="callout__title" data-astro-cid-q2ml7llr><span aria-hidden="true" data-astro-cid-q2ml7llr>⚠</span>This doesn&#39;t cover every pwn-request vector</p><div class="callout__body" data-astro-cid-q2ml7llr><p>Blocking the fork-checkout shortcut closes the most common path, but it isn’t
a complete fix for <code>pull_request_target</code> misuse. A workflow that manually runs
<code>git fetch</code> against the fork, or checks out an unrelated third-party
repository and executes something from it, sits outside what this specific
change catches. Review what a <code>pull_request_target</code> workflow actually
executes, not just how it checks out code.</p></div></div>
<h2 id="structural-comparison-matrix">Structural Comparison Matrix</h2>



































<table tabindex="0"><thead><tr><th scope="col" style="text-align:left">Operational Aspect</th><th scope="col" style="text-align:left">Before (unpatched)</th><th scope="col" style="text-align:left">After (v7.0.0 / backported)</th></tr></thead><tbody><tr><td style="text-align:left"><strong>Fork PR ref in <code>pull_request_target</code></strong></td><td style="text-align:left">Checked out normally, no warning</td><td style="text-align:left">Action fails by default</td></tr><tr><td style="text-align:left"><strong>Fork PR ref in PR-flavored <code>workflow_run</code></strong></td><td style="text-align:left">Checked out normally, no warning</td><td style="text-align:left">Action fails by default</td></tr><tr><td style="text-align:left"><strong>Floating tag pin (<code>@v4</code>)</strong></td><td style="text-align:left">Vulnerable until GitHub patched it</td><td style="text-align:left">Fixed automatically once backport landed</td></tr><tr><td style="text-align:left"><strong>Exact version/SHA pin below the fix</strong></td><td style="text-align:left">Vulnerable, stays vulnerable indefinitely</td><td style="text-align:left">Still vulnerable until you bump the pin yourself</td></tr><tr><td style="text-align:left"><strong>Genuinely intended unsafe checkout</strong></td><td style="text-align:left">No opt-in required, no audit trail</td><td style="text-align:left">Requires explicit <code>allow-unsafe-pr-checkout: true</code></td></tr></tbody></table>
<h2 id="fixing-a-workflow-that-hits-this">Fixing a workflow that hits this</h2>
<p>If a <code>pull_request_target</code> or <code>workflow_run</code> job in your repo starts failing at the checkout step after 2026-07-20, work through this in order.</p>
<ol>
<li>
<p><strong>Check what version you’re actually pinned to.</strong> Open the workflow file and look at the <code>uses:</code> line for every checkout step in a <code>pull_request_target</code> or <code>workflow_run</code> job.</p>
<div class="code-block code-block--has-filename"><div class="code-block__header"><span class="code-block__filename">.github/workflows/preview.yml</span><button type="button" class="code-block__copy" data-copy-button>Copy</button></div><pre class="astro-code github-dark" style="background-color:#24292e;color:#e1e4e8;overflow-x:auto" tabindex="0" data-language="yaml" data-filename=".github/workflows/preview.yml"><code><span class="line"><span style="color:#E1E4E8">- </span><span style="color:#85E89D">uses</span><span style="color:#E1E4E8">: </span><span style="color:#9ECBFF">actions/checkout@v4.2.0</span><span style="color:#9ca6b0"> # exact pin, below the 4.4.0 fix</span></span>
<span class="line"><span style="color:#85E89D">  with</span><span style="color:#E1E4E8">:</span></span>
<span class="line"><span style="color:#85E89D">    ref</span><span style="color:#E1E4E8">: </span><span style="color:#9ECBFF">${{ github.event.pull_request.head.sha }}</span></span></code></pre></div>
</li>
<li>
<p><strong>Bump the pin to the fixed release</strong> for your major version: v7.0.0 if you’re on the v7 line, or the matching backport otherwise (v6 got 6.1.0, v5 got 5.1.0, v4 got 4.4.0, v3 got 3.7.0, v2 got 2.8.0).</p>
<div class="code-block code-block--has-filename"><div class="code-block__header"><span class="code-block__filename">.github/workflows/preview.yml</span><button type="button" class="code-block__copy" data-copy-button>Copy</button></div><pre class="astro-code github-dark" style="background-color:#24292e;color:#e1e4e8;overflow-x:auto" tabindex="0" data-language="yaml" data-filename=".github/workflows/preview.yml"><code><span class="line"><span style="color:#E1E4E8">- </span><span style="color:#85E89D">uses</span><span style="color:#E1E4E8">: </span><span style="color:#9ECBFF">actions/checkout@v4.4.0</span><span style="color:#9ca6b0"> # fixed</span></span>
<span class="line"><span style="color:#85E89D">  with</span><span style="color:#E1E4E8">:</span></span>
<span class="line"><span style="color:#85E89D">    ref</span><span style="color:#E1E4E8">: </span><span style="color:#9ECBFF">${{ github.event.pull_request.head.sha }}</span></span></code></pre></div>
</li>
<li>
<p><strong>Confirm the job still fails after the bump.</strong> If it does, that’s the intended behavior: your workflow genuinely was checking out fork code under <code>pull_request_target</code>, and the failure is GitHub asking you to look at it again, not a bug.</p>
</li>
<li>
<p><strong>Redesign around the safer pattern if you can.</strong> Most <code>pull_request_target</code> use cases (posting a comment, labeling a PR, triggering a status check) don’t need the fork’s code at all. Split the job: run the untrusted build/test steps under plain <code>pull_request</code> (no secrets, no write access), and use a separate <code>pull_request_target</code> job only for the trusted, secrets-requiring step that reads the PR’s metadata rather than its code.</p>
</li>
<li>
<p><strong>Only if you’ve reviewed it and it’s genuinely necessary</strong>, opt back in explicitly:</p>
<div class="code-block code-block--has-filename"><div class="code-block__header"><span class="code-block__filename">.github/workflows/preview.yml</span><button type="button" class="code-block__copy" data-copy-button>Copy</button></div><pre class="astro-code github-dark" style="background-color:#24292e;color:#e1e4e8;overflow-x:auto" tabindex="0" data-language="yaml" data-filename=".github/workflows/preview.yml"><code><span class="line"><span style="color:#E1E4E8">- </span><span style="color:#85E89D">uses</span><span style="color:#E1E4E8">: </span><span style="color:#9ECBFF">actions/checkout@v4.4.0</span></span>
<span class="line"><span style="color:#85E89D">  with</span><span style="color:#E1E4E8">:</span></span>
<span class="line"><span style="color:#85E89D">    ref</span><span style="color:#E1E4E8">: </span><span style="color:#9ECBFF">${{ github.event.pull_request.head.sha }}</span></span>
<span class="line"><span style="color:#85E89D">    allow-unsafe-pr-checkout</span><span style="color:#E1E4E8">: </span><span style="color:#79B8FF">true</span></span></code></pre></div>
</li>
</ol>
<h2 id="does-this-affect-this-sites-own-deploy-workflow">Does this affect this site’s own deploy workflow?</h2>
<p>This site’s CI, <code>.github/workflows/ci.yml</code>, pins <code>actions/checkout@v7</code> already and only triggers on <code>push</code> to <code>main</code> and same-repo <code>pull_request</code> events, never <code>pull_request_target</code> or a fork-originated <code>workflow_run</code>. That specific attack surface doesn’t exist here. Worth saying plainly rather than implying a dogfooding story that isn’t real: this repo has nothing to fix for this particular change, because it was never exposed to the pattern it blocks.</p>
<p>Restricting which triggers and which people can even fire a workflow in the first place is a related, complementary control: see <a href="/dev-tools/github-actions-workflow-execution-protections">Set Up GitHub Actions Workflow Execution Protections</a> for the allow-list layer that sits in front of the checkout step covered here.</p>
<p>If you’re deploying this exact kind of static site through GitHub Actions rather than Cloudflare’s built-in Git integration, <a href="/data-automation/automate-static-site-deploys-github-actions-cloudflare-workers">Cloudflare Workers Deploys: Built-In Git vs. GitHub Actions</a> covers that pipeline choice, including the <code>actions/checkout</code> step in a from-scratch deploy workflow.</p>
<h2 id="source">Source</h2>
<p>GitHub’s own <a href="https://github.blog/changelog/2026-06-18-safer-pull_request_target-defaults-for-github-actions-checkout/">changelog entry announcing this checkout change</a> (2026-06-18, enforcement backported 2026-07-20), corroborated against the <a href="https://github.com/actions/checkout/releases">checkout action’s release history</a> on GitHub (major release plus five backported patch versions, all published 2026-07-20). Browse more posts like this in the <a href="/dev-tools">Dev Tools</a> archive.</p>]]></content:encoded>
      <pubDate>Thu, 13 Aug 2026 00:00:00 GMT</pubDate>
    </item>
    <item>
      <title>Approve Workflow Runs From github-actions[bot] PRs</title>
      <link>https://bytetech247.com/dev-tools/approve-workflow-runs-github-actions-bot-prs/</link>
      <guid isPermaLink="true">https://bytetech247.com/dev-tools/approve-workflow-runs-github-actions-bot-prs/</guid>
      <description>Pull requests from github-actions[bot] can now trigger CI workflows, but only after a collaborator approves the run. Here&apos;s how the gate works.</description>
      <content:encoded><![CDATA[<aside class="quick-answer" aria-label="Quick answer"><h2 id="quick-answer">Quick Answer</h2>
<p>Pull requests opened by <code>github-actions[bot]</code> (typically from a scheduled workflow that commits changes and opens a PR) can now trigger CI/CD workflows, as of 2026-06-11. But the run doesn’t start automatically: a repository collaborator with write access has to approve it first, the same gate already used for Copilot-authored PRs.</p>
</aside><h2 id="what-was-broken-before-this">What was broken before this</h2>
<p>A common automation pattern: a scheduled workflow runs, makes some change (dependency bumps, generated docs, a lockfile refresh), commits it, and opens a pull request using <code>github-actions[bot]</code> as the author. Before this change, that bot-authored PR could not trigger the repo’s own CI workflows at all, no lint, no test, no build check.</p>
<p>That’s a real gap. It meant bot-generated PRs either sat unchecked until someone manually re-ran the workflow, or worse, got merged without the same test/lint/build gate a human-authored PR would have gone through automatically. GitHub’s own framing of the fix is direct about why the fix isn’t “just let it run automatically” either:</p>
<blockquote>
<p>“Requiring approval is a security measure to ensure generated code does not automatically run workflows which may have access to sensitive information.”</p>
</blockquote>
<p>So the fix isn’t unrestricted trust, it’s a middle ground: bot PRs can now run workflows, but a human has to say so first, every time.</p>
<h2 id="how-the-approval-gate-works-in-practice">How the approval gate works in practice</h2>
<p>This builds on a setting that already existed: repository <strong>Settings → Actions → General → Workflow permissions</strong>, specifically the checkbox controlling whether GitHub Actions can create and approve pull requests. That checkbox is what lets a scheduled workflow open the PR in the first place. What’s new as of 2026-06-11 is what happens after the PR exists.</p>
<p>Once <code>github-actions[bot]</code> opens a PR against a workflow-triggering event (<code>pull_request</code>, for instance), the workflow run doesn’t start immediately the way it would for a normal contributor with write access. It sits pending, waiting for approval, visible the same way a first-time contributor’s workflow run already waited for maintainer approval before this change. A collaborator with write access reviews the PR’s diff and clicks approve on the workflow run, and only then does CI actually execute.</p>
<p>GitHub notes this matches an existing pattern rather than inventing a new one:</p>
<blockquote>
<p>“This matches the behavior of Copilot-generated pull requests.”</p>
</blockquote>
<p>If your team already reviews Copilot PRs’ workflow runs this way, bot-authored PRs from your own scheduled automation now follow the identical flow.</p>
<h2 id="structural-comparison-matrix">Structural Comparison Matrix</h2>






























<table tabindex="0"><thead><tr><th scope="col" style="text-align:left">Operational Aspect</th><th scope="col" style="text-align:left">Before</th><th scope="col" style="text-align:left">After (2026-06-11+)</th></tr></thead><tbody><tr><td style="text-align:left"><strong>CI workflow trigger on a bot-authored PR</strong></td><td style="text-align:left">Never triggered, no gate to pass</td><td style="text-align:left">Triggered, pending human approval</td></tr><tr><td style="text-align:left"><strong>Risk of merging unchecked generated code</strong></td><td style="text-align:left">Real: no CI ran unless manually re-run</td><td style="text-align:left">Reduced: CI runs once approved</td></tr><tr><td style="text-align:left"><strong>Approval requirement</strong></td><td style="text-align:left">N/A (nothing to approve)</td><td style="text-align:left">Collaborator with write access</td></tr><tr><td style="text-align:left"><strong>Consistency with Copilot-authored PRs</strong></td><td style="text-align:left">Different handling</td><td style="text-align:left">Same approval flow</td></tr></tbody></table>
<h2 id="reviewing-and-approving-a-pending-run">Reviewing and approving a pending run</h2>
<p>If your repo runs a scheduled workflow that opens PRs (a dependency-bump job, a generated-content refresh, anything committing via <code>GITHUB_TOKEN</code>), check for pending runs after this change ships rather than assuming CI already ran:</p>
<ol>
<li>Open the pull request from <code>github-actions[bot]</code> in the GitHub UI.</li>
<li>Look for the workflow run status. A pending approval shows as a banner on the PR, distinct from a normal “in progress” or “queued” state.</li>
<li>As a collaborator with write access, review the PR’s actual diff first, this is the security check the changelog describes, not a rubber stamp.</li>
<li>Click <strong>Approve and run</strong> on the workflow run to let it execute.</li>
</ol>
<p>If you want bot PRs to keep working exactly as before this change, and you’re comfortable with the tradeoff, there’s no toggle to bypass the approval step entirely; it’s the intended behavior, not an opt-in feature.</p>
<div class="callout" role="note" data-astro-cid-q2ml7llr><p class="callout__title" data-astro-cid-q2ml7llr><span aria-hidden="true" data-astro-cid-q2ml7llr>ℹ</span>This doesn&#39;t change who can commit via GITHUB_TOKEN</p><div class="callout__body" data-astro-cid-q2ml7llr><p>The existing repository setting for whether Actions can create and approve
pull requests still governs whether a workflow can open a PR at all. This
change only affects what happens after that PR exists: whether its own
triggered workflow runs automatically or waits for a person.</p></div></div>
<h2 id="source">Source</h2>
<p>GitHub’s <a href="https://github.blog/changelog/2026-06-11-bot-created-pull-requests-can-run-workflows-if-approved/">changelog entry on the bot-PR approval requirement</a> (2026-06-11). Browse more posts like this in the <a href="/dev-tools">Dev Tools</a> archive.</p>]]></content:encoded>
      <pubDate>Thu, 13 Aug 2026 00:00:00 GMT</pubDate>
    </item>
    <item>
      <title>Fix ChatGPT Conversation With Assistant Migration</title>
      <link>https://bytetech247.com/data-automation/fix-chatgpt-conversation-with-assistant-migration/</link>
      <guid isPermaLink="true">https://bytetech247.com/data-automation/fix-chatgpt-conversation-with-assistant-migration/</guid>
      <description>Zapier auto-migrates ChatGPT&apos;s Conversation With Assistant (Legacy) action but leaves the new Zap off. How to review and re-enable it before Aug 26, 2026.</description>
      <content:encoded><![CDATA[<aside class="quick-answer" aria-label="Quick answer"><h2 id="quick-answer">Quick Answer</h2>
<p>Zapier auto-migrates any Zap using the Conversation With Assistant (Legacy) action to the new Conversation action, built on OpenAI’s Responses API, but the migrated Zap stays switched off. Before August 26, 2026, open it, confirm the new action’s configuration, remap any downstream step reading the old output shape, and manually re-enable it.</p>
</aside><h2 id="why-this-is-worth-checking-even-if-nothing-looks-broken">Why this is worth checking even if nothing looks broken</h2>
<p>OpenAI is deprecating its Assistants API, and Zapier’s own <a href="https://help.zapier.com/hc/en-us/articles/44865998484365">ChatGPT deprecation notice</a> states the deadline plainly:</p>
<blockquote>
<p>“On August 26, 2026, Zaps using these actions will stop working.”</p>
</blockquote>
<p>A short honesty note before going further: ByteTech247 doesn’t run Zapier. This site’s own automation is Cloudflare Workers and GitHub Actions, covered in our <a href="/data-automation/cloudflares-july-2026-api-deprecation-wave/">Cloudflare API deprecation pillar</a>. Everything below is drawn directly from Zapier’s own Help Center documentation, not from running these exact Zaps ourselves.</p>
<p>For the Conversation With Assistant (Legacy) action specifically, Zapier already did the migration work. Any Zap using it gets automatically switched to the new Conversation action. The part that catches people off guard is what happens next: Zapier’s notice states that</p>
<blockquote>
<p>“Migrated Zaps are left turned off so you can review them first.”</p>
</blockquote>
<p>That’s a deliberate design choice, not a bug. But it means a Zap that “already migrated” weeks ago can still be sitting disabled today, with the only visible sign being a toggle set to off somewhere in a list of dozens of Zaps. There’s no separate ongoing alert once the initial migration notice scrolls past your inbox.</p>
<h2 id="structural-comparison-matrix">Structural Comparison Matrix</h2>






























<table tabindex="0"><thead><tr><th scope="col" style="text-align:left">Operational Aspect</th><th scope="col" style="text-align:left">Conversation With Assistant (Legacy)</th><th scope="col" style="text-align:left">Conversation (auto-migrated)</th></tr></thead><tbody><tr><td style="text-align:left"><strong>Underlying API</strong></td><td style="text-align:left">OpenAI Assistants API</td><td style="text-align:left">OpenAI Responses API</td></tr><tr><td style="text-align:left"><strong>Zap state after migration</strong></td><td style="text-align:left">Running normally</td><td style="text-align:left">Automatically turned off</td></tr><tr><td style="text-align:left"><strong>What it supports</strong></td><td style="text-align:left">Assistant-based conversation with a fixed assistant/thread model</td><td style="text-align:left">Adds memory plus file, web, and MCP-based tool access, per Zapier</td></tr><tr><td style="text-align:left"><strong>Action required</strong></td><td style="text-align:left">None, until migration runs</td><td style="text-align:left">Manual review, field remap, re-enable, before 2026-08-26</td></tr></tbody></table>
<h2 id="fix-it-find-review-and-re-enable">Fix it: find, review, and re-enable</h2>
<div class="callout" role="note" data-astro-cid-q2ml7llr><p class="callout__title" data-astro-cid-q2ml7llr><span aria-hidden="true" data-astro-cid-q2ml7llr>⚠</span>A migrated Zap isn&#39;t a working Zap</p><div class="callout__body" data-astro-cid-q2ml7llr><p>Auto-migration only changes which action the Zap points to. It does not
confirm the new action’s fields are mapped correctly, and it does not turn the
Zap back on. Treat “migrated” and “working” as two separate states.</p></div></div>
<p>Start by finding every Zap that’s been silently switched off:</p>
<ol>
<li>Open your Zaps list and filter by status: <strong>Off</strong>. Any Zap that used to run on Conversation With Assistant (Legacy) and now shows as off, without you having turned it off yourself, is a migration candidate.</li>
<li>Open the Zap’s editor and check the action step. If it now reads <strong>Conversation</strong> instead of <strong>Conversation With Assistant (Legacy)</strong>, this is one of the auto-migrated ones.</li>
<li>Review the Conversation action’s configuration. The legacy action ran against a specific assistant and thread; the new one is prompt- and memory-based, so confirm the model, prompt, and any file/web search options are actually set the way the old Zap intended, not left at defaults.</li>
<li>Check every downstream step (a Formatter step, a filter, a Slack or email notification) that reads a field off the ChatGPT step’s output. The legacy action’s output shape was built around assistant/thread objects; the Conversation action’s output is not guaranteed to match field-for-field, so a step reading <code>output.message</code> or similar needs to be checked against what the new action actually returns in a live test run.</li>
<li>Run a test, confirm the output looks right end to end, then flip the Zap back on.</li>
</ol>
<p>If the legacy Zap only ever needed a single one-shot response with no ongoing memory, it’s worth comparing against <strong>Send Prompt</strong> instead of sticking with the default Conversation migration, since Send Prompt is Zapier’s stated action for exactly that simpler case.</p>
<div class="callout" role="note" data-astro-cid-q2ml7llr><p class="callout__title" data-astro-cid-q2ml7llr><span aria-hidden="true" data-astro-cid-q2ml7llr>ℹ</span>Four other ChatGPT actions don&#39;t get this treatment</p><div class="callout__body" data-astro-cid-q2ml7llr><p>Conversation With Assistant (Legacy) is the one action Zapier auto-migrates.
Create Assistant, Upload File, Find Assistant, and Find or Create Assistant
get no automatic migration at all and need a full rebuild, covered in <a href="/data-automation/rebuild-chatgpt-create-assistant-zaps/">Rebuild
ChatGPT Create Assistant Zaps by Aug
26</a>.</p></div></div>
<h2 id="confirmed-version">Confirmed version</h2>
<p>Sourced from Zapier’s official Help Center advisory on the OpenAI Assistants API deprecation for ChatGPT, updated 2026-07-20, deadline confirmed as 2026-08-26. Browse more posts like this in the <a href="/data-automation">Data Automation</a> archive.</p>]]></content:encoded>
      <pubDate>Thu, 13 Aug 2026 00:00:00 GMT</pubDate>
    </item>
    <item>
      <title>Fix Greenhouse Zaps for Harvest v3 Field Changes</title>
      <link>https://bytetech247.com/data-automation/fix-greenhouse-zaps-for-harvest-v3-field-changes/</link>
      <guid isPermaLink="true">https://bytetech247.com/data-automation/fix-greenhouse-zaps-for-harvest-v3-field-changes/</guid>
      <description>Every Greenhouse trigger, action, and search built on Harvest v1/v2 needs checking against v3&apos;s field changes. Here&apos;s Zapier&apos;s exact affected-steps list.</description>
      <content:encoded><![CDATA[<aside class="quick-answer" aria-label="Quick answer"><h2 id="quick-answer">Quick Answer</h2>
<p>Even after reconnecting a Greenhouse Zap to OAuth 2.0, every trigger, action, and search built on Harvest v1 or v2 needs its fields checked against Harvest v3’s schema. Zapier names six triggers, five actions, and two searches as affected, plus any raw API Request or custom action calling the Harvest API directly, with no single marker to flag which of your Zaps’ steps need attention.</p>
</aside><h2 id="a-schema-problem-not-an-auth-problem">A schema problem, not an auth problem</h2>
<p>A quick honesty note before the specifics: ByteTech247 doesn’t run Zapier. Its own automation runs on Cloudflare Workers and GitHub Actions, covered separately in our <a href="/data-automation/cloudflares-july-2026-api-deprecation-wave/">Cloudflare API deprecation pillar</a>. What’s below is sourced from Zapier’s own Help Center advisory on the Greenhouse deprecation, not from operating these Zaps first-hand.</p>
<p>The <a href="https://help.zapier.com/hc/en-us/articles/47585848967437">same Zapier notice</a> that covers the <a href="/data-automation/reconnect-greenhouse-zaps-to-oauth-2-0/">OAuth 2.0 reconnect</a> also lists exactly which steps are built against the old Harvest v1/v2 API and need checking under v3. Unlike Zapier’s Pipedrive deprecation notice, which prepends a literal <code>[DEPRECATING JULY 31 2026]</code> label to every affected step name inside the Zap editor, this one gives no in-product marker. The list exists only in the Help Center article itself, so finding affected steps means manually comparing that list against every Greenhouse step across every live Zap.</p>
<p>Zapier’s affected-steps list, quoted directly:</p>
<blockquote>
<p><strong>Triggers:</strong> New Candidate Application, New Job Post, Candidate Hired, New Scheduled Interview, Job Updated, New Scorecard Due</p>
<p><strong>Actions:</strong> API Request (Beta), Create Candidate Note, Create Candidate, Create Prospect, Update Candidate</p>
<p><strong>Searches:</strong> Find Candidate, Find Due Scorecard</p>
</blockquote>
<p>For anything built with a raw API call rather than one of Zapier’s built-in steps, the notice is direct:</p>
<blockquote>
<p>“If you use the <em>API Request</em> action or <em>custom action</em>, you must migrate your request to the Harvest v3 API”</p>
</blockquote>
<p>That migration covers updating URLs, headers, body fields, and authentication parameters to match Greenhouse’s current Harvest v3 documentation, according to the same notice. Zapier doesn’t publish a field-by-field diff between v2 and v3 for each of these steps, so the practical path is checking each one’s current output against what the Zap’s downstream steps expect, then consulting Greenhouse’s own Harvest v3 API reference for anything that looks off.</p>
<h2 id="structural-comparison-matrix">Structural Comparison Matrix</h2>






























<table tabindex="0"><thead><tr><th scope="col" style="text-align:left">Operational Aspect</th><th scope="col" style="text-align:left">Harvest v1/v2 steps</th><th scope="col" style="text-align:left">Harvest v3 steps</th></tr></thead><tbody><tr><td style="text-align:left"><strong>Affected step count</strong></td><td style="text-align:left">13 named steps (6 triggers, 5 actions, 2 searches), per Zapier’s list</td><td style="text-align:left">Same steps, migrated</td></tr><tr><td style="text-align:left"><strong>In-product marker for affected steps</strong></td><td style="text-align:left">None</td><td style="text-align:left">None</td></tr><tr><td style="text-align:left"><strong>Raw API calls (API Request/custom action)</strong></td><td style="text-align:left">Built against v1/v2 endpoints</td><td style="text-align:left">Must be manually migrated per Greenhouse’s v3 docs</td></tr><tr><td style="text-align:left"><strong>How to find affected steps</strong></td><td style="text-align:left">N/A</td><td style="text-align:left">Manual cross-reference against Zapier’s published list</td></tr></tbody></table>
<h2 id="fix-it-cross-reference-every-greenhouse-step">Fix it: cross-reference every Greenhouse step</h2>
<div class="callout" role="note" data-astro-cid-q2ml7llr><p class="callout__title" data-astro-cid-q2ml7llr><span aria-hidden="true" data-astro-cid-q2ml7llr>ℹ</span>Reconnecting to OAuth 2.0 comes first</p><div class="callout__body" data-astro-cid-q2ml7llr><p>This field-level check only matters once the connection itself is on OAuth
2.0. If a Zap is still authenticating with the old API key, fix that first;
see <a href="/data-automation/reconnect-greenhouse-zaps-to-oauth-2-0/">Reconnect Greenhouse Zaps to OAuth 2.0 by Aug
26</a>.</p></div></div>
<ol>
<li><strong>List every Zap using a Greenhouse step.</strong> For each one, note the exact trigger, action, or search name in use.</li>
<li><strong>Check each step name against Zapier’s list above.</strong> If it matches one of the six triggers, five actions, or two searches, treat it as needing a field-level review, not just an auth reconnect.</li>
<li><strong>For built-in steps, re-test with real data after reconnecting.</strong> Run the Zap manually against a real record and compare the fields it returns or writes against what the downstream steps in the Zap actually expect. A field silently missing or renamed under v3 won’t throw an error the way a broken auth call does; it just produces blank or unexpected values further down the Zap.</li>
<li><strong>For API Request or custom action steps, migrate manually.</strong> Per Zapier’s notice, that means updating the request URL, headers, body fields, and auth parameters to match Harvest v3, referencing Greenhouse’s own current API documentation rather than the v1/v2 shape the request was originally built against.</li>
<li><strong>Don’t assume a Zap is clear just because it ran successfully once.</strong> Since there’s no per-step marker, the only reliable check is the manual cross-reference in step 2, done for every Zap, not a spot check on the ones that come to mind first.</li>
</ol>
<div class="callout" role="note" data-astro-cid-q2ml7llr><p class="callout__title" data-astro-cid-q2ml7llr><span aria-hidden="true" data-astro-cid-q2ml7llr>⚠</span>Two separate deadlines still apply</p><div class="callout__body" data-astro-cid-q2ml7llr><p>Zapier’s own cutoff for the old Greenhouse connection is August 26, 2026;
Greenhouse retires Harvest v1 and v2 on August 31, 2026. Field-level fixes
from this list need to land before whichever of those hits first, not after.</p></div></div>
<h2 id="confirmed-version">Confirmed version</h2>
<p>Sourced from Zapier’s official Help Center advisory on the Greenhouse Harvest API deprecation, published 2026-07-27, with the full affected-steps list and API Request migration instruction confirmed directly against that source. Browse more posts like this in the <a href="/data-automation">Data Automation</a> archive.</p>]]></content:encoded>
      <pubDate>Thu, 13 Aug 2026 00:00:00 GMT</pubDate>
    </item>
    <item>
      <title>Fix HubSpot Add Contact to List After V1 Sunset</title>
      <link>https://bytetech247.com/data-automation/fix-hubspot-add-contact-to-list-after-v1-sunset/</link>
      <guid isPermaLink="true">https://bytetech247.com/data-automation/fix-hubspot-add-contact-to-list-after-v1-sunset/</guid>
      <description>HubSpot&apos;s v1 Lists API sunset April 30, 2026, breaking Zapier&apos;s Add/Remove Contact from List actions. Here&apos;s what we know and how to check your Zaps.</description>
      <content:encoded><![CDATA[<aside class="quick-answer" aria-label="Quick answer"><h2 id="quick-answer">Quick Answer</h2>
<p>HubSpot retired its v1 Lists API on April 30, 2026. Zapier’s Add Contact to List and Remove Contact from List actions, built against that API, stopped working on that date. There’s no dedicated Zapier Help Center advisory for this one, only a single line in a May 2026 community digest post, so the practical fix is testing the action directly against a real contact and list rather than waiting for a Zap error to surface it.</p>
</aside><h2 id="why-this-one-is-sourced-differently-than-the-rest-of-this-series">Why this one is sourced differently than the rest of this series</h2>
<p>One honesty note first: ByteTech247 doesn’t run Zapier. This site’s own automation is Cloudflare Workers and GitHub Actions, covered in a separate <a href="/data-automation/cloudflares-july-2026-api-deprecation-wave/">Cloudflare API deprecation pillar</a>. What follows is sourced from Zapier’s own community documentation, not from running this integration ourselves.</p>
<p>This post also needs a second, more specific honesty note, because its sourcing is genuinely weaker than the Pipedrive, Greenhouse, and ChatGPT posts in this series. Those three each have a dedicated Zapier Help Center “action required” article with a clear publish date and an explicit deadline. This one doesn’t. The only corroborating source is a single bullet inside <a href="https://community.zapier.com/product-updates/what-s-new-73-updated-integrations-for-may-2026-53403">Zapier Community’s monthly integrations digest</a>, published May 28, 2026, buried among dozens of unrelated feature updates:</p>
<blockquote>
<p>“Add Contact to List / Remove Contact from List: Actions using deprecated v1 Lists API sunset April 30 2026”</p>
</blockquote>
<p>Both dates here sit slightly earlier than the freshness window the rest of this series targets. This is the same shape of caveat we’ve stated on two other posts on this site, <a href="/data-automation/cloudflare-amp-sxg-api-end-of-life/">Cloudflare’s AMP/SXG API end-of-life</a> and <a href="/data-automation/cloudflare-deprecates-gateway-audit-ssh-rules/">Cloudflare’s Gateway Audit SSH rules deprecation</a>, both of which document changes that had already happened before the corroborating post went up. The justification is the same here: this break is old enough to have already occurred, but recent enough, and undocumented enough by any dedicated advisory, that people are plausibly still finding silently broken list-management Zaps months later with nothing authoritative to search for and find.</p>
<h2 id="structural-comparison-matrix">Structural Comparison Matrix</h2>






























<table tabindex="0"><thead><tr><th scope="col" style="text-align:left">Operational Aspect</th><th scope="col" style="text-align:left">Before 2026-04-30</th><th scope="col" style="text-align:left">After 2026-04-30</th></tr></thead><tbody><tr><td style="text-align:left"><strong>HubSpot Lists API version</strong></td><td style="text-align:left">v1</td><td style="text-align:left">v1 retired</td></tr><tr><td style="text-align:left"><strong>Add/Remove Contact from List actions</strong></td><td style="text-align:left">Functional</td><td style="text-align:left">Broken, per the community digest</td></tr><tr><td style="text-align:left"><strong>Dedicated Zapier advisory</strong></td><td style="text-align:left">N/A</td><td style="text-align:left">None found; only a community digest bullet</td></tr><tr><td style="text-align:left"><strong>Failure mode</strong></td><td style="text-align:left">N/A</td><td style="text-align:left">Not described in the source; test directly rather than assume an error</td></tr></tbody></table>
<h2 id="fix-it-test-directly-since-theres-nothing-else-to-check-against">Fix it: test directly, since there’s nothing else to check against</h2>
<div class="callout" role="note" data-astro-cid-q2ml7llr><p class="callout__title" data-astro-cid-q2ml7llr><span aria-hidden="true" data-astro-cid-q2ml7llr>⚠</span>Weakest citation in this series, stated honestly</p><div class="callout__body" data-astro-cid-q2ml7llr><p>Every other post in this series links a dedicated Zapier Help Center advisory.
This one doesn’t have one. Treat the guidance below as a reasonable response
to a thin source, not as confirmed the way the Pipedrive, Greenhouse, and
ChatGPT deprecations in this series are.</p></div></div>
<ol>
<li><strong>Find every Zap using Add Contact to List or Remove Contact from List.</strong> These are the two actions the digest names directly.</li>
<li><strong>Run each one manually against a real contact and a real list</strong>, rather than trusting that a Zap showing “success” in its history actually moved the contact. Since the digest gives no description of how the failure presents (an error, a silent no-op, or something else), don’t assume a clean Zap history means the action actually worked.</li>
<li><strong>Check list membership in HubSpot directly after the test run.</strong> If the contact wasn’t added or removed despite the Zap reporting success, that’s consistent with the v1 API sunset described in the digest.</li>
<li><strong>Look for a current, non-deprecated list-management action in the Zapier HubSpot app</strong> as the fix, rather than assuming there’s a drop-in v2 equivalent to swap into the same action slot. Since no dedicated migration guide exists for this one, confirming the current action’s exact behavior against a live HubSpot list is the most reliable check available.</li>
<li><strong>Re-check periodically.</strong> Because this deprecation has no dedicated advisory to bookmark, there’s no single page to watch for updates. A recheck every so often is a reasonable substitute for the update notifications the other Zaps in this series get from their own Help Center articles.</li>
</ol>
<h2 id="confirmed-version">Confirmed version</h2>
<p>Sourced from Zapier Community’s monthly integrations digest for May 2026, published 2026-05-28, the only corroborating source found for this deprecation; no dedicated Zapier Help Center advisory exists for it as of this writing. Browse more posts like this in the <a href="/data-automation">Data Automation</a> archive.</p>]]></content:encoded>
      <pubDate>Thu, 13 Aug 2026 00:00:00 GMT</pubDate>
    </item>
    <item>
      <title>Fix Vitest 5 bench No Longer Top-Level Export</title>
      <link>https://bytetech247.com/guides-fixes/fix-vitest-5-bench-no-longer-top-level-export/</link>
      <guid isPermaLink="true">https://bytetech247.com/guides-fixes/fix-vitest-5-bench-no-longer-top-level-export/</guid>
      <description>Vitest 5 removes bench as a top-level export. Fix it by moving benchmarks into a test() context fixture instead.</description>
      <content:encoded><![CDATA[<aside class="quick-answer" aria-label="Quick answer"><h2 id="quick-answer">Quick Answer</h2>
<p>Vitest 5 removes <code>bench</code> as a top-level export from <code>vitest</code>. Code still written as <code>import { bench } from &#39;vitest&#39;</code> stops working, because <code>bench</code> now only exists as a fixture destructured from a regular <code>test()</code> context. Fix it by rewriting each benchmark as <code>test(&#39;name&#39;, async ({ bench }) =&gt; { await bench(fn).run() })</code>, and by replacing any removed <code>benchmark.*</code> config options with their documented equivalents.</p>
</aside><h2 id="why-the-top-level-bench-import-stops-working">Why the top-level bench import stops working</h2>
<p>Vitest’s own migration guide states this plainly under “Benchmarking API Rewrite”:</p>
<blockquote>
<p><code>bench</code> is no longer a top-level import from <code>vitest</code>; it is a test-context fixture accessed from inside a regular <code>test()</code>.</p>
</blockquote>
<p>That’s a structural change, not a rename. The old form:</p>
<div class="code-block code-block--has-filename"><div class="code-block__header"><span class="code-block__filename">bench.bench.ts - Vitest 4</span><button type="button" class="code-block__copy" data-copy-button>Copy</button></div><pre class="astro-code github-dark" style="background-color:#24292e;color:#e1e4e8;overflow-x:auto" tabindex="0" data-language="ts" data-filename="bench.bench.ts - Vitest 4"><code><span class="line"><span style="color:#F97583">import</span><span style="color:#E1E4E8"> { bench } </span><span style="color:#F97583">from</span><span style="color:#9ECBFF"> &quot;vitest&quot;</span><span style="color:#E1E4E8">;</span></span>
<span class="line"></span>
<span class="line"><span style="color:#B392F0">bench</span><span style="color:#E1E4E8">(</span><span style="color:#9ECBFF">&quot;sort&quot;</span><span style="color:#E1E4E8">, () </span><span style="color:#F97583">=&gt;</span><span style="color:#E1E4E8"> {</span></span>
<span class="line"><span style="color:#E1E4E8">  [</span><span style="color:#79B8FF">3</span><span style="color:#E1E4E8">, </span><span style="color:#79B8FF">1</span><span style="color:#E1E4E8">, </span><span style="color:#79B8FF">2</span><span style="color:#E1E4E8">].</span><span style="color:#B392F0">sort</span><span style="color:#E1E4E8">();</span></span>
<span class="line"><span style="color:#E1E4E8">});</span></span></code></pre></div>
<p>no longer resolves to a working benchmark call in Vitest 5, because <code>vitest</code> stops exporting a <code>bench</code> symbol at the module level at all. Exactly how that surfaces depends on your bundler and TypeScript setup: some configs throw at import time, others fail type-checking first, and a few just silently run nothing because <code>bench</code> resolves to <code>undefined</code>. Vitest’s guide doesn’t publish one canonical error string for this case, so treat the exact wording you see as environment-specific rather than a fixed message to search for. The mechanical cause is the same either way: <code>bench</code> isn’t there to import anymore.</p>
<p>The guide also confirms <code>bench.skip</code>, <code>bench.only</code>, and <code>bench.todo</code> are removed in the same rewrite. Any suite using those modifiers needs the same migration as a plain <code>bench</code> call.</p>
<h2 id="what-happens-to-benchmarkreporters-outputfile-compare-and-outputjson">What happens to benchmark.reporters, outputFile, compare, and outputJson</h2>
<p>This is the part of the change that breaks a CI pipeline quietly instead of loudly. A benchmark suite that imports <code>bench</code> correctly can still fail to produce the output a workflow expects, because four <code>benchmark.*</code> config options are removed entirely in the same rewrite, not renamed:</p>
<ul>
<li><code>benchmark.reporters</code> and <code>benchmark.outputFile</code> are gone. The migration guide says that output now ships through the standard reporter pipeline instead of a dedicated benchmark reporter, so there’s no separate config to point at a custom output stream anymore.</li>
<li><code>benchmark.compare</code> and the matching <code>--compare</code> CLI flag are removed, with no direct 1:1 replacement documented.</li>
<li><code>benchmark.outputJson</code> and the <code>--outputJson</code> CLI flag are removed. The guide’s replacement is <code>--reporter=json --outputFile=&lt;path&gt;</code>, run through Vitest’s general reporter system instead of a benchmark-specific option.</li>
</ul>
<p>None of these throw an import error the way the <code>bench</code> removal does. A <code>vitest.config.ts</code> that still sets <code>benchmark.compare</code> just has that setting silently ignored, so the first sign of trouble is usually a CI step that expects a comparison artifact or a JSON file and doesn’t get one.</p>
<h2 id="fix-it-move-bench-into-the-test-context-fixture">Fix it: move bench into the test context fixture</h2>
<p>Rewrite each benchmark to destructure <code>bench</code> from the <code>test()</code> callback’s context object, call it, then call <code>.run()</code> on the result:</p>
<div class="code-block code-block--has-filename"><div class="code-block__header"><span class="code-block__filename">bench.bench.ts - before</span><button type="button" class="code-block__copy" data-copy-button>Copy</button></div><pre class="astro-code github-dark" style="background-color:#24292e;color:#e1e4e8;overflow-x:auto" tabindex="0" data-language="ts" data-filename="bench.bench.ts - before"><code><span class="line"><span style="color:#F97583">import</span><span style="color:#E1E4E8"> { bench } </span><span style="color:#F97583">from</span><span style="color:#9ECBFF"> &quot;vitest&quot;</span><span style="color:#E1E4E8">; </span><span style="color:#9ca6b0">// BROKEN: no longer exported at module scope</span></span>
<span class="line"></span>
<span class="line"><span style="color:#B392F0">bench</span><span style="color:#E1E4E8">(</span><span style="color:#9ECBFF">&quot;sort&quot;</span><span style="color:#E1E4E8">, () </span><span style="color:#F97583">=&gt;</span><span style="color:#E1E4E8"> {</span></span>
<span class="line"><span style="color:#E1E4E8">  [</span><span style="color:#79B8FF">3</span><span style="color:#E1E4E8">, </span><span style="color:#79B8FF">1</span><span style="color:#E1E4E8">, </span><span style="color:#79B8FF">2</span><span style="color:#E1E4E8">].</span><span style="color:#B392F0">sort</span><span style="color:#E1E4E8">();</span></span>
<span class="line"><span style="color:#E1E4E8">});</span></span></code></pre></div>
<div class="code-block code-block--has-filename"><div class="code-block__header"><span class="code-block__filename">bench.bench.ts - after</span><button type="button" class="code-block__copy" data-copy-button>Copy</button></div><pre class="astro-code github-dark" style="background-color:#24292e;color:#e1e4e8;overflow-x:auto" tabindex="0" data-language="ts" data-filename="bench.bench.ts - after"><code><span class="line"><span style="color:#F97583">import</span><span style="color:#E1E4E8"> { test } </span><span style="color:#F97583">from</span><span style="color:#9ECBFF"> &quot;vitest&quot;</span><span style="color:#E1E4E8">;</span></span>
<span class="line"></span>
<span class="line"><span style="color:#B392F0">test</span><span style="color:#E1E4E8">(</span><span style="color:#9ECBFF">&quot;sort&quot;</span><span style="color:#E1E4E8">, </span><span style="color:#F97583">async</span><span style="color:#E1E4E8"> ({ </span><span style="color:#FFAB70">bench</span><span style="color:#E1E4E8"> }) </span><span style="color:#F97583">=&gt;</span><span style="color:#E1E4E8"> {</span></span>
<span class="line"><span style="color:#9ca6b0">  // FIXED: bench is a fixture on the test context now, and .run() is required</span></span>
<span class="line"><span style="color:#F97583">  await</span><span style="color:#B392F0"> bench</span><span style="color:#E1E4E8">(</span><span style="color:#9ECBFF">&quot;sort&quot;</span><span style="color:#E1E4E8">, () </span><span style="color:#F97583">=&gt;</span><span style="color:#E1E4E8"> {</span></span>
<span class="line"><span style="color:#E1E4E8">    [</span><span style="color:#79B8FF">3</span><span style="color:#E1E4E8">, </span><span style="color:#79B8FF">1</span><span style="color:#E1E4E8">, </span><span style="color:#79B8FF">2</span><span style="color:#E1E4E8">].</span><span style="color:#B392F0">sort</span><span style="color:#E1E4E8">();</span></span>
<span class="line"><span style="color:#E1E4E8">  }).</span><span style="color:#B392F0">run</span><span style="color:#E1E4E8">();</span></span>
<span class="line"><span style="color:#E1E4E8">});</span></span></code></pre></div>
<p>The <code>.run()</code> call is not optional decoration. Without it, <code>bench(...)</code> just constructs the benchmark and returns without ever executing it, so a half-migrated call site can look correct while producing no timing data at all.</p>
<div class="callout" role="note" data-astro-cid-q2ml7llr><p class="callout__title" data-astro-cid-q2ml7llr><span aria-hidden="true" data-astro-cid-q2ml7llr>⚠</span>Update CI scripts alongside the code</p><div class="callout__body" data-astro-cid-q2ml7llr><p>If a workflow file passes <code>--compare</code> or <code>--outputJson</code> to the <code>vitest bench</code>
command, update it in the same change as the source migration. A CI script
that still passes a removed flag either errors immediately or, depending on
how strict the CLI parsing is, gets silently ignored, and either way the
comparison or JSON artifact a later step depends on won’t exist.</p></div></div>
<h2 id="confirmed-version-range">Confirmed version range</h2>
<p>This is documented in <a href="https://main.vitest.dev/guide/migration">Vitest’s own current migration guide</a>, under “Benchmarking API Rewrite,” as an intentional Vitest 5 change, corroborated as in-window against the beta release available at research time (v5.0.0-beta.7). This repo’s own <code>vitest.config.ts</code> is pinned to <code>vitest@^3.0.0</code> and doesn’t use the benchmarking API, so this specific removal wasn’t independently reproducible against this repo’s own build. Treat the mechanics above as documented behavior from Vitest’s primary source, not a first-party reproduction. Browse more posts like this in the <a href="/guides-fixes">Guides &amp; Fixes</a> archive.</p>]]></content:encoded>
      <pubDate>Thu, 13 Aug 2026 00:00:00 GMT</pubDate>
    </item>
    <item>
      <title>Fix Vitest 5 Cannot Find Module vitest/reporters</title>
      <link>https://bytetech247.com/guides-fixes/fix-vitest-5-cannot-find-module-vitest-reporters/</link>
      <guid isPermaLink="true">https://bytetech247.com/guides-fixes/fix-vitest-5-cannot-find-module-vitest-reporters/</guid>
      <description>Fix Vitest 5&apos;s module resolution failure on vitest/reporters and vitest/coverage. Vitest 5 removes eight subpath imports; use vitest/node instead.</description>
      <content:encoded><![CDATA[<aside class="quick-answer" aria-label="Quick answer"><h2 id="quick-answer">Quick Answer</h2>
<p>Vitest 5 removes eight subpath entry points outright, including <code>vitest/reporters</code> and <code>vitest/coverage</code>. Vitest’s own migration guide states plainly: use <code>vitest/node</code> instead for both. A custom reporter or coverage config still importing the old path fails to resolve on upgrade. Update the import path to <code>vitest/node</code>; there’s no compatibility shim to fall back on.</p>
</aside><h2 id="why-the-import-breaks-eight-entry-points-are-gone">Why the import breaks: eight entry points are gone</h2>
<p>Vitest 4 exposed a wide set of subpath imports, letting a custom reporter, coverage provider, or internal tooling script reach directly into Vitest’s own internals. <a href="https://main.vitest.dev/guide/migration">Vitest’s migration guide</a> documents that Vitest 5 removes several of those paths outright, under a section titled <code>Removed Deprecated Entrypoints</code>. There’s no deprecation warning period for these specific paths and no runtime shim; the import simply stops resolving the moment a project upgrades to Vitest 5.</p>
<p>That’s a structural cleanup, not a one-off removal. The guide maps every removed path to a specific, named replacement:</p>









































<table tabindex="0"><thead><tr><th scope="col">Removed import</th><th scope="col">Documented replacement</th></tr></thead><tbody><tr><td><code>vitest/coverage</code></td><td><code>vitest/node</code></td></tr><tr><td><code>vitest/reporters</code></td><td><code>vitest/node</code></td></tr><tr><td><code>vitest/environments</code></td><td><code>vitest/runtime</code></td></tr><tr><td><code>vitest/snapshot</code></td><td><code>vitest/runtime</code></td></tr><tr><td><code>vitest/runners</code></td><td><code>TestRunner</code>, imported from <code>vitest</code></td></tr><tr><td><code>vitest/suite</code></td><td>static methods on <code>TestRunner</code>, e.g. <code>TestRunner.getCurrentTest()</code></td></tr><tr><td><code>vitest/mocker</code></td><td>the standalone <code>@vitest/mocker</code> package</td></tr><tr><td><code>vitest/internal/module-runner</code></td><td>none documented</td></tr></tbody></table>
<p>Six of the eight collapse onto two consolidated entry points, <code>vitest/node</code> and <code>vitest/runtime</code>. The other two, <code>vitest/mocker</code> and <code>vitest/internal/module-runner</code>, don’t map onto either: one moves to its own standalone package, the other is gone with nothing named in its place. A project can’t safely assume “swap in vitest/node” is the universal fix; each removed path needs checking against its own row above.</p>
<p>The exact wording of the resolution failure itself varies by bundler and by whether the import is a type-only import, a runtime <code>require</code>, or a dynamic <code>import()</code>, so this post doesn’t quote one specific error string as if every toolchain produced it. What’s consistent across all of them is the underlying cause: the module simply isn’t there anymore at that path, so resolution fails.</p>
<h2 id="fix-it-point-custom-reporters-and-coverage-config-at-the-new-paths">Fix it: point custom reporters and coverage config at the new paths</h2>
<h3 id="a-custom-reporter-importing-from-vitestreporters">A custom reporter importing from vitest/reporters</h3>
<div class="code-block code-block--has-filename"><div class="code-block__header"><span class="code-block__filename">custom-reporter.ts - before</span><button type="button" class="code-block__copy" data-copy-button>Copy</button></div><pre class="astro-code github-dark" style="background-color:#24292e;color:#e1e4e8;overflow-x:auto" tabindex="0" data-language="ts" data-filename="custom-reporter.ts - before"><code><span class="line"><span style="color:#F97583">import</span><span style="color:#F97583"> type</span><span style="color:#E1E4E8"> { Reporter } </span><span style="color:#F97583">from</span><span style="color:#9ECBFF"> &quot;vitest/reporters&quot;</span><span style="color:#E1E4E8">; </span><span style="color:#9ca6b0">// BROKEN: path removed in Vitest 5</span></span>
<span class="line"></span>
<span class="line"><span style="color:#F97583">export</span><span style="color:#F97583"> default</span><span style="color:#F97583"> class</span><span style="color:#B392F0"> CustomReporter</span><span style="color:#F97583"> implements</span><span style="color:#B392F0"> Reporter</span><span style="color:#E1E4E8"> {</span></span>
<span class="line"><span style="color:#B392F0">  onFinished</span><span style="color:#E1E4E8">(</span><span style="color:#FFAB70">files</span><span style="color:#E1E4E8">) {</span></span>
<span class="line"><span style="color:#E1E4E8">    console.</span><span style="color:#B392F0">log</span><span style="color:#E1E4E8">(</span><span style="color:#9ECBFF">`Ran ${</span><span style="color:#E1E4E8">files</span><span style="color:#9ECBFF">.</span><span style="color:#79B8FF">length</span><span style="color:#9ECBFF">} test files`</span><span style="color:#E1E4E8">);</span></span>
<span class="line"><span style="color:#E1E4E8">  }</span></span>
<span class="line"><span style="color:#E1E4E8">}</span></span></code></pre></div>
<div class="code-block code-block--has-filename"><div class="code-block__header"><span class="code-block__filename">custom-reporter.ts - after</span><button type="button" class="code-block__copy" data-copy-button>Copy</button></div><pre class="astro-code github-dark" style="background-color:#24292e;color:#e1e4e8;overflow-x:auto" tabindex="0" data-language="ts" data-filename="custom-reporter.ts - after"><code><span class="line"><span style="color:#F97583">import</span><span style="color:#F97583"> type</span><span style="color:#E1E4E8"> { Reporter } </span><span style="color:#F97583">from</span><span style="color:#9ECBFF"> &quot;vitest/node&quot;</span><span style="color:#E1E4E8">; </span><span style="color:#9ca6b0">// FIXED: vitest/reporters -&gt; vitest/node</span></span>
<span class="line"></span>
<span class="line"><span style="color:#F97583">export</span><span style="color:#F97583"> default</span><span style="color:#F97583"> class</span><span style="color:#B392F0"> CustomReporter</span><span style="color:#F97583"> implements</span><span style="color:#B392F0"> Reporter</span><span style="color:#E1E4E8"> {</span></span>
<span class="line"><span style="color:#B392F0">  onFinished</span><span style="color:#E1E4E8">(</span><span style="color:#FFAB70">files</span><span style="color:#E1E4E8">) {</span></span>
<span class="line"><span style="color:#E1E4E8">    console.</span><span style="color:#B392F0">log</span><span style="color:#E1E4E8">(</span><span style="color:#9ECBFF">`Ran ${</span><span style="color:#E1E4E8">files</span><span style="color:#9ECBFF">.</span><span style="color:#79B8FF">length</span><span style="color:#9ECBFF">} test files`</span><span style="color:#E1E4E8">);</span></span>
<span class="line"><span style="color:#E1E4E8">  }</span></span>
<span class="line"><span style="color:#E1E4E8">}</span></span></code></pre></div>
<h3 id="a-coverage-provider-importing-from-vitestcoverage">A coverage provider importing from vitest/coverage</h3>
<div class="code-block code-block--has-filename"><div class="code-block__header"><span class="code-block__filename">custom-coverage-provider.ts - before</span><button type="button" class="code-block__copy" data-copy-button>Copy</button></div><pre class="astro-code github-dark" style="background-color:#24292e;color:#e1e4e8;overflow-x:auto" tabindex="0" data-language="ts" data-filename="custom-coverage-provider.ts - before"><code><span class="line"><span style="color:#F97583">import</span><span style="color:#F97583"> type</span><span style="color:#E1E4E8"> { CoverageProvider } </span><span style="color:#F97583">from</span><span style="color:#9ECBFF"> &quot;vitest/coverage&quot;</span><span style="color:#E1E4E8">; </span><span style="color:#9ca6b0">// BROKEN: path removed in Vitest 5</span></span></code></pre></div>
<div class="code-block code-block--has-filename"><div class="code-block__header"><span class="code-block__filename">custom-coverage-provider.ts - after</span><button type="button" class="code-block__copy" data-copy-button>Copy</button></div><pre class="astro-code github-dark" style="background-color:#24292e;color:#e1e4e8;overflow-x:auto" tabindex="0" data-language="ts" data-filename="custom-coverage-provider.ts - after"><code><span class="line"><span style="color:#F97583">import</span><span style="color:#F97583"> type</span><span style="color:#E1E4E8"> { CoverageProvider } </span><span style="color:#F97583">from</span><span style="color:#9ECBFF"> &quot;vitest/node&quot;</span><span style="color:#E1E4E8">; </span><span style="color:#9ca6b0">// FIXED: vitest/coverage -&gt; vitest/node</span></span></code></pre></div>
<p>The change is mechanical once you know the target: swap the specifier, leave the rest of the file untouched. The type and value exports themselves didn’t change shape in these two cases, only where they live.</p>
<div class="callout" role="note" data-astro-cid-q2ml7llr><p class="callout__title" data-astro-cid-q2ml7llr><span aria-hidden="true" data-astro-cid-q2ml7llr>✓</span>Grep before you upgrade, not after</p><div class="callout__body" data-astro-cid-q2ml7llr><p>Search the repository for every one of the eight paths in the table above
(<code>vitest/coverage</code>, <code>vitest/reporters</code>, <code>vitest/environments</code>,
<code>vitest/snapshot</code>, <code>vitest/runners</code>, <code>vitest/suite</code>, <code>vitest/mocker</code>,
<code>vitest/internal/module-runner</code>) before bumping the <code>vitest</code> dependency.
Finding all the hits ahead of time turns a broken CI run into a five-minute
find-and-replace instead of a build failure discovered mid-upgrade.</p></div></div>
<h2 id="deprecated-not-yet-removed-three-more-packages-to-watch">Deprecated, not yet removed: three more packages to watch</h2>
<p>Not every change in this area is an outright removal. The migration guide separately flags <code>@vitest/runner</code> and <code>@vitest/ws-client</code> as deprecated, not removed:</p>
<blockquote>
<p>“The <code>@vitest/runner</code> and <code>@vitest/ws-client</code> packages are deprecated as of this release.” They “will no longer receive feature updates, but security fixes will continue to be backported.”</p>
</blockquote>
<p>Deprecated still means installed and working today. Treat a dependency on either package as something to plan away from over time, not something that breaks on the next upgrade.</p>
<p><code>@vitest/browser-webdriverio</code> changes ownership rather than disappearing. The guide documents that this provider now lives under the community-maintained <a href="https://github.com/vitest-community/vitest-webdriverio">vitest-community/vitest-webdriverio</a> organization instead of Vitest’s own core packages. Anyone pinning the old package path should point at that fork going forward.</p>
<p><code>@vitest/expect</code> is the subtlest of the three. Vitest 5 stops depending on it internally:</p>
<blockquote>
<p>“<code>vitest</code> also no longer depends on <code>@vitest/expect</code>: the assertion code is bundled into <code>vitest</code> itself.”</p>
</blockquote>
<p>Code that previously relied on <code>@vitest/expect</code> sharing runtime state with the rest of Vitest, rather than just using its type exports, should switch to importing assertions through the <code>vitest</code> entry point directly instead.</p>
<h2 id="confirmed-version-range">Confirmed version range</h2>
<p>This site’s own <code>vitest.config.ts</code> is pinned to <code>vitest@^3.0.0</code>, so this exact resolution failure wasn’t reproducible against this repo’s own build; Vitest 5 isn’t installed here. Every claim above is attributed to <a href="https://main.vitest.dev/guide/migration">Vitest’s own migration guide</a>, current as of Vitest 5.0.0-beta.7 (2026-07-24). If you’re on an earlier 5.0 beta, re-check the guide directly, since a beta’s own documented removals can still shift before a stable release. Browse more posts like this in the <a href="/guides-fixes">Guides &amp; Fixes</a> archive, or follow the rest of this Vitest 5 migration series under the <a href="/tag/vitest">vitest tag</a>.</p>]]></content:encoded>
      <pubDate>Thu, 13 Aug 2026 00:00:00 GMT</pubDate>
    </item>
    <item>
      <title>Fix Vitest 5 clearMocks Breaking Mock State</title>
      <link>https://bytetech247.com/guides-fixes/fix-vitest-5-clearmocks-breaking-mock-state/</link>
      <guid isPermaLink="true">https://bytetech247.com/guides-fixes/fix-vitest-5-clearmocks-breaking-mock-state/</guid>
      <description>Fix Vitest 5&apos;s clearMocks: true default wiping mock call history between tests, and decide whether to restore it or fix the test.</description>
      <content:encoded><![CDATA[<aside class="quick-answer" aria-label="Quick answer"><h2 id="quick-answer">Quick Answer</h2>
<p>Vitest 5 flips <code>clearMocks</code> to default <code>true</code>: it now calls <code>vi.clearAllMocks()</code> before every test, clearing each mock’s call history while keeping its implementation. A suite expecting call counts to persist across tests starts failing with no code change. Set <code>clearMocks: false</code> to restore Vitest 4’s behavior, or stop relying on cross-test mock state.</p>
</aside><h2 id="why-mock-call-counts-reset-with-no-code-change">Why mock call counts reset with no code change</h2>
<p>This one doesn’t throw an error. There’s no stack trace to search for, which is exactly why it’s confusing to debug: a suite that passed yesterday starts failing today, and nothing in the diff explains why.</p>
<p><a href="https://main.vitest.dev/guide/migration">Vitest’s own migration guide</a> states the change directly, in the section covering <code>clearMocks</code>’s new default:</p>
<blockquote>
<p><code>clearMocks</code> now defaults to <code>true</code>: Vitest calls <code>vi.clearAllMocks()</code> before every test, clearing the recorded history of every mock while leaving implementations intact.</p>
</blockquote>
<p>That last clause matters. <code>vi.clearAllMocks()</code> clears each mock’s recorded calls, results, and instances, but it doesn’t touch the mock’s implementation the way <code>vi.resetAllMocks()</code> or <code>vi.restoreAllMocks()</code> would. A mock still returns whatever it was told to return. It just forgets that it was ever called.</p>
<p>The guide also flags where this bites hardest:</p>
<blockquote>
<p>Tests that record calls outside of the test body (for example in a setup file, at the top level of a module, or in a <code>beforeAll</code> hook) are the most affected, because that history is cleared before the test that asserts on it runs.</p>
</blockquote>
<p>A call made once in <code>beforeAll</code> and asserted on later, or a suite that deliberately counts calls cumulatively across a <code>describe</code> block, gets wiped before the assertion that expects to see it.</p>
<div class="callout" role="note" data-astro-cid-q2ml7llr><p class="callout__title" data-astro-cid-q2ml7llr><span aria-hidden="true" data-astro-cid-q2ml7llr>⚠</span>This changes what &#39;passing&#39; means for the same test</p><div class="callout__body" data-astro-cid-q2ml7llr><p>The guide’s own before/after example makes the shift concrete: a second test
that used to see <code>toHaveBeenCalledTimes(2)</code> (because the first test’s call was
still counted) now sees <code>toHaveBeenCalledTimes(1)</code>. Nothing about the mock or
the test code changed. Only the default that decides whether history survives
between tests changed.</p></div></div>
<h2 id="fix-it-restore-the-old-default-or-stop-depending-on-it">Fix it: restore the old default, or stop depending on it</h2>
<h3 id="option-1-set-clearmocks-back-to-false">Option 1: set clearMocks back to false</h3>
<p>The fastest fix, and the right one if you have a lot of suites written against the old default and no time to audit every one of them right now:</p>
<div class="code-block code-block--has-filename"><div class="code-block__header"><span class="code-block__filename">vitest.config.ts - before (relies on the implicit Vitest 4 default)</span><button type="button" class="code-block__copy" data-copy-button>Copy</button></div><pre class="astro-code github-dark" style="background-color:#24292e;color:#e1e4e8;overflow-x:auto" tabindex="0" data-language="ts" data-filename="vitest.config.ts - before (relies on the implicit Vitest 4 default)"><code><span class="line"><span style="color:#F97583">import</span><span style="color:#E1E4E8"> { defineConfig } </span><span style="color:#F97583">from</span><span style="color:#9ECBFF"> &quot;vitest/config&quot;</span><span style="color:#E1E4E8">;</span></span>
<span class="line"></span>
<span class="line"><span style="color:#F97583">export</span><span style="color:#F97583"> default</span><span style="color:#B392F0"> defineConfig</span><span style="color:#E1E4E8">({</span></span>
<span class="line"><span style="color:#E1E4E8">  test: {</span></span>
<span class="line"><span style="color:#9ca6b0">    // clearMocks not set - Vitest 4 default (false) let call history</span></span>
<span class="line"><span style="color:#9ca6b0">    // BROKEN under Vitest 5: clearMocks now defaults to true instead</span></span>
<span class="line"><span style="color:#E1E4E8">  },</span></span>
<span class="line"><span style="color:#E1E4E8">});</span></span></code></pre></div>
<div class="code-block code-block--has-filename"><div class="code-block__header"><span class="code-block__filename">vitest.config.ts - after (explicit, matches Vitest 4 behavior)</span><button type="button" class="code-block__copy" data-copy-button>Copy</button></div><pre class="astro-code github-dark" style="background-color:#24292e;color:#e1e4e8;overflow-x:auto" tabindex="0" data-language="ts" data-filename="vitest.config.ts - after (explicit, matches Vitest 4 behavior)"><code><span class="line"><span style="color:#F97583">import</span><span style="color:#E1E4E8"> { defineConfig } </span><span style="color:#F97583">from</span><span style="color:#9ECBFF"> &quot;vitest/config&quot;</span><span style="color:#E1E4E8">;</span></span>
<span class="line"></span>
<span class="line"><span style="color:#F97583">export</span><span style="color:#F97583"> default</span><span style="color:#B392F0"> defineConfig</span><span style="color:#E1E4E8">({</span></span>
<span class="line"><span style="color:#E1E4E8">  test: {</span></span>
<span class="line"><span style="color:#E1E4E8">    clearMocks: </span><span style="color:#79B8FF">false</span><span style="color:#E1E4E8">, </span><span style="color:#9ca6b0">// FIXED: restores the old default, call history persists across tests</span></span>
<span class="line"><span style="color:#E1E4E8">  },</span></span>
<span class="line"><span style="color:#E1E4E8">});</span></span></code></pre></div>
<h3 id="option-2-rewrite-the-test-to-not-depend-on-cross-test-mock-state">Option 2: rewrite the test to not depend on cross-test mock state</h3>
<p>Vitest’s migration guide shows exactly what a test that relied on the old default looked like, and how it reads once the assumption is made explicit:</p>
<div class="code-block code-block--has-filename"><div class="code-block__header"><span class="code-block__filename">mock-history.test.ts - Vitest&#39;s own before/after example</span><button type="button" class="code-block__copy" data-copy-button>Copy</button></div><pre class="astro-code github-dark" style="background-color:#24292e;color:#e1e4e8;overflow-x:auto" tabindex="0" data-language="ts" data-filename="mock-history.test.ts - Vitest's own before/after example"><code><span class="line"><span style="color:#F97583">import</span><span style="color:#E1E4E8"> { expect, test, vi } </span><span style="color:#F97583">from</span><span style="color:#9ECBFF"> &quot;vitest&quot;</span><span style="color:#E1E4E8">;</span></span>
<span class="line"></span>
<span class="line"><span style="color:#F97583">const</span><span style="color:#79B8FF"> fn</span><span style="color:#F97583"> =</span><span style="color:#E1E4E8"> vi.</span><span style="color:#B392F0">fn</span><span style="color:#E1E4E8">();</span></span>
<span class="line"></span>
<span class="line"><span style="color:#B392F0">test</span><span style="color:#E1E4E8">(</span><span style="color:#9ECBFF">&quot;first&quot;</span><span style="color:#E1E4E8">, () </span><span style="color:#F97583">=&gt;</span><span style="color:#E1E4E8"> {</span></span>
<span class="line"><span style="color:#B392F0">  fn</span><span style="color:#E1E4E8">();</span></span>
<span class="line"><span style="color:#B392F0">  expect</span><span style="color:#E1E4E8">(fn).</span><span style="color:#B392F0">toHaveBeenCalledTimes</span><span style="color:#E1E4E8">(</span><span style="color:#79B8FF">1</span><span style="color:#E1E4E8">);</span></span>
<span class="line"><span style="color:#E1E4E8">});</span></span>
<span class="line"></span>
<span class="line"><span style="color:#B392F0">test</span><span style="color:#E1E4E8">(</span><span style="color:#9ECBFF">&quot;second&quot;</span><span style="color:#E1E4E8">, () </span><span style="color:#F97583">=&gt;</span><span style="color:#E1E4E8"> {</span></span>
<span class="line"><span style="color:#B392F0">  fn</span><span style="color:#E1E4E8">();</span></span>
<span class="line"><span style="color:#9ca6b0">  // v4: the call from &quot;first&quot; was kept, so this was 2</span></span>
<span class="line"><span style="color:#9ca6b0">  // v5: history is cleared before each test, so only this test&#39;s call counts</span></span>
<span class="line"><span style="color:#B392F0">  expect</span><span style="color:#E1E4E8">(fn).</span><span style="color:#B392F0">toHaveBeenCalledTimes</span><span style="color:#E1E4E8">(</span><span style="color:#79B8FF">1</span><span style="color:#E1E4E8">); </span><span style="color:#9ca6b0">// FIXED: asserts only this test&#39;s own call</span></span>
<span class="line"><span style="color:#E1E4E8">});</span></span></code></pre></div>
<p>A test that depends on another test’s mock history to pass is depending on execution order, which is fragile even outside of this specific default change. If you’re touching the suite anyway, this is the fix that actually removes the risk instead of papering over it with a config flag.</p>
<h2 id="confirmed-version-range">Confirmed version range</h2>
<p>Documented in <a href="https://main.vitest.dev/guide/migration">Vitest’s own current migration guide</a> as an intentional Vitest 5.0 default-behavior change, in the section covering <code>clearMocks</code>’s new default. The current <code>clearMocks</code> <a href="https://main.vitest.dev/config/#clearmocks">config reference</a> reflects the new default. This repository’s own <code>vitest.config.ts</code> is pinned to <code>vitest@^3.0.0</code>, so this default flip wasn’t independently reproduced against this project’s build; every claim above traces back to Vitest’s migration guide, not a local repro.</p>
<p>This pairs with <a href="/guides-fixes/fix-vitest-5-vi-mock-top-level-scope-error/">Fix Vitest 5 vi.mock Top-Level Scope Error</a>, another Vitest 5 mocking change that breaks suites with zero code changes. More fixes like this one are in the <a href="/guides-fixes">Guides &amp; Fixes</a> archive, and every Vitest post is tagged under <a href="/tag/vitest">vitest</a>.</p>]]></content:encoded>
      <pubDate>Thu, 13 Aug 2026 00:00:00 GMT</pubDate>
    </item>
    <item>
      <title>Fix Vitest 5 Config Not Found in Parent Directory</title>
      <link>https://bytetech247.com/guides-fixes/fix-vitest-5-config-not-found-parent-directory/</link>
      <guid isPermaLink="true">https://bytetech247.com/guides-fixes/fix-vitest-5-config-not-found-parent-directory/</guid>
      <description>Fix Vitest 5 no longer finding a vitest.config.ts in a parent directory when run from a subdirectory. Pass --config explicitly.</description>
      <content:encoded><![CDATA[<aside class="quick-answer" aria-label="Quick answer"><h2 id="quick-answer">Quick Answer</h2>
<p>Vitest 5 stops searching parent directories for a config file. Running <code>vitest</code> from a subdirectory that only inherited config from a parent folder in Vitest 4 now fails to find or apply it. Pass the config explicitly with <code>vitest --config ../vitest.config.ts</code> instead of relying on the old upward search, and use <code>--dir</code> to scope which files it discovers.</p>
</aside><h2 id="why-running-vitest-from-a-subdirectory-stops-finding-config">Why running vitest from a subdirectory stops finding config</h2>
<p>Vitest 4 walked up the directory tree looking for a config file if the current working directory didn’t have one. That made a certain style of monorepo or CI script “just work”: <code>cd packages/api &amp;&amp; vitest</code> would pick up a <code>vitest.config.ts</code> sitting at the repo root, even though nothing in that subdirectory pointed at it.</p>
<p><a href="https://main.vitest.dev/guide/migration">Vitest’s own migration guide</a> states the change plainly, in the section covering config file lookup:</p>
<blockquote>
<p>Vitest no longer searches parent directories for config files. If you previously relied on running <code>vitest</code> from a subdirectory while using a config file from a parent directory, pass the config explicitly and scope test discovery with <code>--dir</code>.</p>
</blockquote>
<p>The guide’s own example shows exactly what changes:</p>
<div class="code-block code-block--has-filename"><div class="code-block__header"><span class="code-block__filename">before: relies on the old upward search</span><button type="button" class="code-block__copy" data-copy-button>Copy</button></div><pre class="astro-code github-dark" style="background-color:#24292e;color:#e1e4e8;overflow-x:auto" tabindex="0" data-language="bash" data-filename="before: relies on the old upward search"><code><span class="line"><span style="color:#B392F0">$</span><span style="color:#9ECBFF"> cd</span><span style="color:#9ECBFF"> subdir</span><span style="color:#E1E4E8"> &amp;&amp; </span><span style="color:#B392F0">vitest</span></span></code></pre></div>
<div class="code-block code-block--has-filename"><div class="code-block__header"><span class="code-block__filename">after: config path passed explicitly</span><button type="button" class="code-block__copy" data-copy-button>Copy</button></div><pre class="astro-code github-dark" style="background-color:#24292e;color:#e1e4e8;overflow-x:auto" tabindex="0" data-language="bash" data-filename="after: config path passed explicitly"><code><span class="line"><span style="color:#B392F0">$</span><span style="color:#9ECBFF"> cd</span><span style="color:#9ECBFF"> subdir</span><span style="color:#E1E4E8"> &amp;&amp; </span><span style="color:#B392F0">vitest</span><span style="color:#79B8FF"> --config</span><span style="color:#9ECBFF"> ../vitest.config.ts</span></span></code></pre></div>
<p>The command that used to resolve config by walking upward now just doesn’t find one, because that walk no longer happens. Whatever Vitest does when no config file is found (fall back to defaults, or fail outright) depends on the rest of your setup, but either way it isn’t running with the config you meant it to use.</p>
<div class="callout" role="note" data-astro-cid-q2ml7llr><p class="callout__title" data-astro-cid-q2ml7llr><span aria-hidden="true" data-astro-cid-q2ml7llr>⚠</span>This is a workflow break, not a config-syntax error</p><div class="callout__body" data-astro-cid-q2ml7llr><p>Nothing about your <code>vitest.config.ts</code> file changes here. The file is correct
and untouched. What breaks is the <em>working directory</em> a command gets run from,
which makes this the kind of failure that’s easy to miss in a code review,
since the diff that breaks it is often in a CI YAML file or a <code>package.json</code>
script, not in the test config itself.</p></div></div>
<h2 id="fix-it-pass-the-config-path-and-scope-explicitly">Fix it: pass the config path (and scope) explicitly</h2>
<h3 id="option-1-pass-config-directly">Option 1: pass —config directly</h3>
<p>The direct fix, matching the guide’s own example. Point the command at the config file explicitly instead of hoping it gets found:</p>
<div class="code-block code-block--has-filename"><div class="code-block__header"><span class="code-block__filename">package.json - before (relies on parent-directory lookup)</span><button type="button" class="code-block__copy" data-copy-button>Copy</button></div><pre class="astro-code github-dark" style="background-color:#24292e;color:#e1e4e8;overflow-x:auto" tabindex="0" data-language="json" data-filename="package.json - before (relies on parent-directory lookup)"><code><span class="line"><span style="color:#E1E4E8">{</span></span>
<span class="line"><span style="color:#79B8FF">  &quot;scripts&quot;</span><span style="color:#E1E4E8">: {</span></span>
<span class="line"><span style="color:#79B8FF">    &quot;test&quot;</span><span style="color:#E1E4E8">: </span><span style="color:#9ECBFF">&quot;cd packages/api &amp;&amp; vitest&quot;</span></span>
<span class="line"><span style="color:#E1E4E8">  }</span></span>
<span class="line"><span style="color:#E1E4E8">}</span></span></code></pre></div>
<div class="code-block code-block--has-filename"><div class="code-block__header"><span class="code-block__filename">package.json - after (explicit config path)</span><button type="button" class="code-block__copy" data-copy-button>Copy</button></div><pre class="astro-code github-dark" style="background-color:#24292e;color:#e1e4e8;overflow-x:auto" tabindex="0" data-language="json" data-filename="package.json - after (explicit config path)"><code><span class="line"><span style="color:#E1E4E8">{</span></span>
<span class="line"><span style="color:#79B8FF">  &quot;scripts&quot;</span><span style="color:#E1E4E8">: {</span></span>
<span class="line"><span style="color:#79B8FF">    &quot;test&quot;</span><span style="color:#E1E4E8">: </span><span style="color:#9ECBFF">&quot;cd packages/api &amp;&amp; vitest --config ../../vitest.config.ts --dir .&quot;</span></span>
<span class="line"><span style="color:#E1E4E8">  }</span></span>
<span class="line"><span style="color:#E1E4E8">}</span></span></code></pre></div>
<p><code>--dir</code> scopes which directory Vitest treats as the root for discovering test files, which matters once you’re pointing <code>--config</code> somewhere outside the current directory. Confirm the exact <code>--dir</code> semantics against Vitest’s own CLI reference for your version before copying this into a script wholesale, since flag behavior can shift between beta releases faster than stable config surface does.</p>
<h3 id="option-2-give-the-subdirectory-its-own-config-file">Option 2: give the subdirectory its own config file</h3>
<p>If the subdirectory’s tests don’t genuinely need to share every setting from the parent config, moving (or adding) a <code>vitest.config.ts</code> directly in that directory sidesteps the whole issue. Vitest still checks the current working directory by default; only the upward search into parent directories is gone. A config file sitting right next to the code it tests never depended on that search in the first place.</p>
<h2 id="confirmed-version-range">Confirmed version range</h2>
<p>Documented in <a href="https://main.vitest.dev/guide/migration">Vitest’s own current migration guide</a> as an intentional Vitest 5.0 change, in the section covering config file lookup. This repository’s own <code>vitest.config.ts</code> is pinned to <code>vitest@^3.0.0</code>, so this behavior wasn’t independently reproduced against this project’s build; every claim above traces back to Vitest’s migration guide, not a local repro.</p>
<p>More fixes like this one are in the <a href="/guides-fixes">Guides &amp; Fixes</a> archive, and every Vitest post is tagged under <a href="/tag/vitest">vitest</a>. If your upgrade is also hitting removed test options, see <a href="/guides-fixes/fix-vitest-5-test-sequential-removed-error/">Fix Vitest 5 test.sequential Removed Error</a>.</p>]]></content:encoded>
      <pubDate>Thu, 13 Aug 2026 00:00:00 GMT</pubDate>
    </item>
    <item>
      <title>Fix Vitest 5 expect.poll Timeout Error</title>
      <link>https://bytetech247.com/guides-fixes/fix-vitest-5-expect-poll-timeout-error/</link>
      <guid isPermaLink="true">https://bytetech247.com/guides-fixes/fix-vitest-5-expect-poll-timeout-error/</guid>
      <description>Vitest 5 throws &apos;expect.poll() function didn&apos;t resolve in time.&apos; on timeout instead of letting a late result pass. Here&apos;s the fix.</description>
      <content:encoded><![CDATA[<aside class="quick-answer" aria-label="Quick answer"><h2 id="quick-answer">Quick Answer</h2>
<p>Vitest 5’s <code>expect.poll()</code> throws <code>expect.poll() function didn&#39;t resolve in time.</code> when the polled callback or its assertion doesn’t settle within the configured timeout. Vitest 4 let a late-resolving poll still succeed if it eventually settled after the deadline passed. Fix it by using the callback’s new <code>signal</code> parameter to cancel in-flight work on timeout, or by raising <code>timeout</code> only if the condition genuinely needs more time.</p>
</aside><h2 id="why-a-poll-that-used-to-pass-now-throws">Why a poll that used to pass now throws</h2>
<p><a href="https://main.vitest.dev/guide/migration">Vitest’s migration guide</a> documents this under “<code>expect.poll</code> Fails When It Times Out”:</p>
<blockquote>
<p><code>expect.poll</code> now rejects when its callback, or the polled assertion, does not settle within <code>timeout</code>.</p>
</blockquote>
<p>The guide gives the exact error text a reader will see, depending on which part times out:</p>
<blockquote>
<p>expect.poll() function didn’t resolve in time.</p>
<p>expect.poll() assertion didn’t resolve in time.</p>
</blockquote>
<p>The first form fires when the polled callback itself never settles; the second fires when the callback settles but the assertion chained onto it (<code>.toBe(200)</code>, for example) never does.</p>
<p><code>expect.poll()</code> exists to retry a callback repeatedly until an assertion against its return value passes, which is the standard pattern for waiting on a condition that becomes true asynchronously, like an HTTP status flipping to <code>200</code> once a service finishes starting. In Vitest 4, the poll loop itself didn’t treat the timeout as a hard stop. If a slow callback or a slow assertion was still in flight when the deadline passed, it could keep going and still report success once it eventually settled, deadline or not.</p>
<p>Vitest 5 changes that to an active rejection. The moment <code>timeout</code> elapses without a settled, passing result, the poll stops and throws the error above instead of letting a late result count.</p>
<h2 id="the-new-abortsignal-parameter">The new AbortSignal parameter</h2>
<p>The same change adds a way to react to that timeout instead of just discovering it after the fact. Vitest’s guide shows the callback now receiving a <code>signal</code>:</p>
<div class="code-block code-block--has-filename"><div class="code-block__header"><span class="code-block__filename">status.test.ts</span><button type="button" class="code-block__copy" data-copy-button>Copy</button></div><pre class="astro-code github-dark" style="background-color:#24292e;color:#e1e4e8;overflow-x:auto" tabindex="0" data-language="ts" data-filename="status.test.ts"><code><span class="line"><span style="color:#F97583">await</span><span style="color:#E1E4E8"> expect</span></span>
<span class="line"><span style="color:#E1E4E8">  .</span><span style="color:#B392F0">poll</span><span style="color:#E1E4E8">(</span></span>
<span class="line"><span style="color:#F97583">    async</span><span style="color:#E1E4E8"> ({ </span><span style="color:#FFAB70">signal</span><span style="color:#E1E4E8"> }) </span><span style="color:#F97583">=&gt;</span><span style="color:#E1E4E8"> {</span></span>
<span class="line"><span style="color:#F97583">      const</span><span style="color:#79B8FF"> response</span><span style="color:#F97583"> =</span><span style="color:#F97583"> await</span><span style="color:#B392F0"> fetch</span><span style="color:#E1E4E8">(</span><span style="color:#9ECBFF">&quot;/api/status&quot;</span><span style="color:#E1E4E8">, { signal });</span></span>
<span class="line"><span style="color:#F97583">      return</span><span style="color:#E1E4E8"> response.status;</span></span>
<span class="line"><span style="color:#E1E4E8">    },</span></span>
<span class="line"><span style="color:#E1E4E8">    { timeout: </span><span style="color:#79B8FF">1000</span><span style="color:#E1E4E8"> },</span></span>
<span class="line"><span style="color:#E1E4E8">  )</span></span>
<span class="line"><span style="color:#E1E4E8">  .</span><span style="color:#B392F0">toBe</span><span style="color:#E1E4E8">(</span><span style="color:#79B8FF">200</span><span style="color:#E1E4E8">);</span></span></code></pre></div>
<p>That <code>signal</code> is a standard <code>AbortSignal</code> that aborts the moment the poll’s timeout elapses. Passing it into <code>fetch</code> (or any API that accepts an <code>AbortSignal</code>) means the in-flight request actually stops instead of continuing to run in the background after Vitest has already decided the poll failed. Without it, a timed-out poll still leaves that last request hanging until it resolves or errors on its own, which wastes time and can leave dangling handles between tests.</p>
<h2 id="fix-it-dont-just-chase-the-timeout-number">Fix it: don’t just chase the timeout number</h2>
<div class="callout" role="note" data-astro-cid-q2ml7llr><p class="callout__title" data-astro-cid-q2ml7llr><span aria-hidden="true" data-astro-cid-q2ml7llr>⚠</span>A longer timeout can mask a real bug</p><div class="callout__body" data-astro-cid-q2ml7llr><p>Bumping <code>timeout</code> from 1000ms to 5000ms makes the error disappear if the
condition eventually becomes true. It does nothing if the condition never
becomes true at all, which is the failure mode this change exists to surface.
Confirm the condition genuinely needs more time before treating a longer
timeout as the fix.</p></div></div>
<div class="code-block code-block--has-filename"><div class="code-block__header"><span class="code-block__filename">status.test.ts - before</span><button type="button" class="code-block__copy" data-copy-button>Copy</button></div><pre class="astro-code github-dark" style="background-color:#24292e;color:#e1e4e8;overflow-x:auto" tabindex="0" data-language="ts" data-filename="status.test.ts - before"><code><span class="line"><span style="color:#9ca6b0">// BROKEN: no signal, so a timed-out request keeps running in the background</span></span>
<span class="line"><span style="color:#F97583">await</span><span style="color:#E1E4E8"> expect</span></span>
<span class="line"><span style="color:#E1E4E8">  .</span><span style="color:#B392F0">poll</span><span style="color:#E1E4E8">(</span><span style="color:#F97583">async</span><span style="color:#E1E4E8"> () </span><span style="color:#F97583">=&gt;</span><span style="color:#E1E4E8"> {</span></span>
<span class="line"><span style="color:#F97583">    const</span><span style="color:#79B8FF"> response</span><span style="color:#F97583"> =</span><span style="color:#F97583"> await</span><span style="color:#B392F0"> fetch</span><span style="color:#E1E4E8">(</span><span style="color:#9ECBFF">&quot;/api/status&quot;</span><span style="color:#E1E4E8">);</span></span>
<span class="line"><span style="color:#F97583">    return</span><span style="color:#E1E4E8"> response.status;</span></span>
<span class="line"><span style="color:#E1E4E8">  })</span></span>
<span class="line"><span style="color:#E1E4E8">  .</span><span style="color:#B392F0">toBe</span><span style="color:#E1E4E8">(</span><span style="color:#79B8FF">200</span><span style="color:#E1E4E8">);</span></span></code></pre></div>
<div class="code-block code-block--has-filename"><div class="code-block__header"><span class="code-block__filename">status.test.ts - after</span><button type="button" class="code-block__copy" data-copy-button>Copy</button></div><pre class="astro-code github-dark" style="background-color:#24292e;color:#e1e4e8;overflow-x:auto" tabindex="0" data-language="ts" data-filename="status.test.ts - after"><code><span class="line"><span style="color:#9ca6b0">// FIXED: signal cancels the in-flight fetch the moment the poll times out</span></span>
<span class="line"><span style="color:#F97583">await</span><span style="color:#E1E4E8"> expect</span></span>
<span class="line"><span style="color:#E1E4E8">  .</span><span style="color:#B392F0">poll</span><span style="color:#E1E4E8">(</span></span>
<span class="line"><span style="color:#F97583">    async</span><span style="color:#E1E4E8"> ({ </span><span style="color:#FFAB70">signal</span><span style="color:#E1E4E8"> }) </span><span style="color:#F97583">=&gt;</span><span style="color:#E1E4E8"> {</span></span>
<span class="line"><span style="color:#F97583">      const</span><span style="color:#79B8FF"> response</span><span style="color:#F97583"> =</span><span style="color:#F97583"> await</span><span style="color:#B392F0"> fetch</span><span style="color:#E1E4E8">(</span><span style="color:#9ECBFF">&quot;/api/status&quot;</span><span style="color:#E1E4E8">, { signal });</span></span>
<span class="line"><span style="color:#F97583">      return</span><span style="color:#E1E4E8"> response.status;</span></span>
<span class="line"><span style="color:#E1E4E8">    },</span></span>
<span class="line"><span style="color:#E1E4E8">    { timeout: </span><span style="color:#79B8FF">5000</span><span style="color:#E1E4E8"> }, </span><span style="color:#9ca6b0">// raised only because this endpoint genuinely takes longer to come up</span></span>
<span class="line"><span style="color:#E1E4E8">  )</span></span>
<span class="line"><span style="color:#E1E4E8">  .</span><span style="color:#B392F0">toBe</span><span style="color:#E1E4E8">(</span><span style="color:#79B8FF">200</span><span style="color:#E1E4E8">);</span></span></code></pre></div>
<h2 id="confirmed-version-range">Confirmed version range</h2>
<p>This error string and behavior change are documented in <a href="https://main.vitest.dev/guide/migration">Vitest’s own current migration guide</a>, under “<code>expect.poll</code> Fails When It Times Out,” as an intentional Vitest 5 change, corroborated as in-window against the beta release available at research time (v5.0.0-beta.7). This repo’s own <code>vitest.config.ts</code> is pinned to <code>vitest@^3.0.0</code> and doesn’t use <code>expect.poll()</code>, so this exact error wasn’t independently reproducible against this repo’s own test suite. Treat the error text above as quoted directly from Vitest’s primary source, not a first-party reproduction. If you’re also seeing tests fail on a forgotten <code>await</code>, see <a href="/guides-fixes/fix-vitest-5-unawaited-async-assertion-error/">Fix Vitest 5 Unawaited Async Assertion Error</a> — the other Vitest 5 change to how async assertion timing is enforced. Browse more posts like this in the <a href="/guides-fixes">Guides &amp; Fixes</a> archive.</p>]]></content:encoded>
      <pubDate>Thu, 13 Aug 2026 00:00:00 GMT</pubDate>
    </item>
    <item>
      <title>Fix Vitest 5 Projects Not Inheriting Root Config</title>
      <link>https://bytetech247.com/guides-fixes/fix-vitest-5-projects-not-inheriting-root-config/</link>
      <guid isPermaLink="true">https://bytetech247.com/guides-fixes/fix-vitest-5-projects-not-inheriting-root-config/</guid>
      <description>Vitest 5&apos;s extends option defaults to true, so inline test.projects now inherit root plugins and setupFiles automatically. Fix it with extends: false.</description>
      <content:encoded><![CDATA[<aside class="quick-answer" aria-label="Quick answer"><h2 id="quick-answer">Quick Answer</h2>
<p>Vitest 5 changes <code>test.projects</code> inheritance: the <code>extends</code> option now defaults to <code>true</code>, so every inline project automatically picks up the root’s Vite plugins and <code>resolve.alias</code>. Arrays merge instead of override, so root <code>setupFiles</code> get appended, not replaced. A project relying on the old isolated-by-default behavior now needs <code>extends: false</code> explicitly.</p>
</aside><h2 id="why-inline-projects-suddenly-inherit-everything">Why inline projects suddenly inherit everything</h2>
<p><code>test.projects</code> lets a single Vitest run cover multiple logically separate test configurations, unit tests with one setup, integration tests with another, inside one <code>vitest.config.ts</code>. In Vitest 4, an inline project entry in that array didn’t automatically pick up the root configuration. If you wanted a project to share the root’s Vite plugins or <code>setupFiles</code>, you had to wire that up yourself.</p>
<p><a href="https://main.vitest.dev/guide/migration">Vitest’s migration guide</a> documents that Vitest 5 flips the default:</p>
<blockquote>
<p>“The <code>extends</code> option now defaults to <code>true</code>: every project defined as an inline configuration in <code>test.projects</code> inherits all options from the root configuration, including Vite options like <code>plugins</code> or <code>resolve.alias</code>.”</p>
</blockquote>
<p>An inline project that previously ran in isolation from the root now automatically picks up its Vite plugins, its alias resolution, and more, without any config change on the project’s side.</p>
<p>That’s the actual break. Nothing about the project’s own config object changed. What changed is what Vitest does with it by default, so a project written under the old assumption can start behaving differently the moment the <code>vitest</code> dependency bumps to 5, with no diff to point at in the project’s own definition.</p>
<h2 id="what-inherits-means-here-merged-arrays-not-a-full-copy">What “inherits” means here: merged arrays, not a full copy</h2>
<p>Inheritance isn’t a blunt overwrite. The guide is specific about how array-valued options combine:</p>
<blockquote>
<p>“Arrays are merged, not overridden: if the root config defines <code>setupFiles</code>, the project’s own <code>setupFiles</code> are appended to the inherited ones.”</p>
</blockquote>
<p>A project that defines its own <code>setupFiles</code> doesn’t lose them and doesn’t silently swap to only the root’s; both lists run, root first, then the project’s own.</p>
<p>That matters for diagnosing the symptom in practice. If a root-level setup file registers something global, a test double, a database connection, an environment variable, that global effect now shows up inside every inline project too, even one that never referenced that setup file itself. The project’s tests aren’t broken by a bug in their own code; they’re picking up state from a setup file they never opted into, because the project object itself never said otherwise.</p>
<h2 id="fix-it-opt-back-into-isolation-explicitly">Fix it: opt back into isolation explicitly</h2>
<div class="code-block code-block--has-filename"><div class="code-block__header"><span class="code-block__filename">vitest.config.ts - before (relies on Vitest 4&#39;s isolated-by-default behavior)</span><button type="button" class="code-block__copy" data-copy-button>Copy</button></div><pre class="astro-code github-dark" style="background-color:#24292e;color:#e1e4e8;overflow-x:auto" tabindex="0" data-language="ts" data-filename="vitest.config.ts - before (relies on Vitest 4's isolated-by-default behavior)"><code><span class="line"><span style="color:#F97583">export</span><span style="color:#F97583"> default</span><span style="color:#B392F0"> defineConfig</span><span style="color:#E1E4E8">({</span></span>
<span class="line"><span style="color:#E1E4E8">  plugins: [</span><span style="color:#B392F0">tsconfigPaths</span><span style="color:#E1E4E8">()],</span></span>
<span class="line"><span style="color:#E1E4E8">  test: {</span></span>
<span class="line"><span style="color:#E1E4E8">    setupFiles: [</span><span style="color:#9ECBFF">&quot;./setup.global.ts&quot;</span><span style="color:#E1E4E8">],</span></span>
<span class="line"><span style="color:#E1E4E8">    projects: [</span></span>
<span class="line"><span style="color:#E1E4E8">      {</span></span>
<span class="line"><span style="color:#E1E4E8">        test: {</span></span>
<span class="line"><span style="color:#E1E4E8">          name: </span><span style="color:#9ECBFF">&quot;unit&quot;</span><span style="color:#E1E4E8">,</span></span>
<span class="line"><span style="color:#9ca6b0">          // BROKEN under Vitest 5: this project now also runs</span></span>
<span class="line"><span style="color:#9ca6b0">          // setup.global.ts automatically, which it was never meant to.</span></span>
<span class="line"><span style="color:#E1E4E8">          setupFiles: [</span><span style="color:#9ECBFF">&quot;./setup.unit.ts&quot;</span><span style="color:#E1E4E8">],</span></span>
<span class="line"><span style="color:#E1E4E8">        },</span></span>
<span class="line"><span style="color:#E1E4E8">      },</span></span>
<span class="line"><span style="color:#E1E4E8">    ],</span></span>
<span class="line"><span style="color:#E1E4E8">  },</span></span>
<span class="line"><span style="color:#E1E4E8">});</span></span></code></pre></div>
<div class="code-block code-block--has-filename"><div class="code-block__header"><span class="code-block__filename">vitest.config.ts - after (Vitest 5, isolation restored explicitly)</span><button type="button" class="code-block__copy" data-copy-button>Copy</button></div><pre class="astro-code github-dark" style="background-color:#24292e;color:#e1e4e8;overflow-x:auto" tabindex="0" data-language="ts" data-filename="vitest.config.ts - after (Vitest 5, isolation restored explicitly)"><code><span class="line"><span style="color:#F97583">export</span><span style="color:#F97583"> default</span><span style="color:#B392F0"> defineConfig</span><span style="color:#E1E4E8">({</span></span>
<span class="line"><span style="color:#E1E4E8">  plugins: [</span><span style="color:#B392F0">tsconfigPaths</span><span style="color:#E1E4E8">()],</span></span>
<span class="line"><span style="color:#E1E4E8">  test: {</span></span>
<span class="line"><span style="color:#E1E4E8">    setupFiles: [</span><span style="color:#9ECBFF">&quot;./setup.global.ts&quot;</span><span style="color:#E1E4E8">],</span></span>
<span class="line"><span style="color:#E1E4E8">    projects: [</span></span>
<span class="line"><span style="color:#E1E4E8">      {</span></span>
<span class="line"><span style="color:#E1E4E8">        extends: </span><span style="color:#79B8FF">false</span><span style="color:#E1E4E8">, </span><span style="color:#9ca6b0">// FIXED: opts out of the new default inheritance</span></span>
<span class="line"><span style="color:#E1E4E8">        test: {</span></span>
<span class="line"><span style="color:#E1E4E8">          name: </span><span style="color:#9ECBFF">&quot;unit&quot;</span><span style="color:#E1E4E8">,</span></span>
<span class="line"><span style="color:#E1E4E8">          setupFiles: [</span><span style="color:#9ECBFF">&quot;./setup.unit.ts&quot;</span><span style="color:#E1E4E8">], </span><span style="color:#9ca6b0">// runs alone again, as intended</span></span>
<span class="line"><span style="color:#E1E4E8">        },</span></span>
<span class="line"><span style="color:#E1E4E8">      },</span></span>
<span class="line"><span style="color:#E1E4E8">    ],</span></span>
<span class="line"><span style="color:#E1E4E8">  },</span></span>
<span class="line"><span style="color:#E1E4E8">});</span></span></code></pre></div>
<p>The fix adds exactly one key. <code>extends: false</code> is the exact opt-out the migration guide names, and it applies per project, so a config with several inline projects can mix defaults: leave the ones that genuinely want the root’s plugins and aliases alone, and set <code>extends: false</code> only on the one meant to stay isolated.</p>
<div class="callout" role="note" data-astro-cid-q2ml7llr><p class="callout__title" data-astro-cid-q2ml7llr><span aria-hidden="true" data-astro-cid-q2ml7llr>✓</span>Read this as an opportunity, not just a fix</p><div class="callout__body" data-astro-cid-q2ml7llr><p>For a project that always meant to share the root’s Vite plugins and aliases,
the new default removes config that was arguably always boilerplate. Only add
<code>extends: false</code> to the projects where isolation was the actual intent, not to
every project reflexively.</p></div></div>
<h2 id="a-related-change-one-shared-vite-server-not-one-per-project">A related change: one shared Vite server, not one per project</h2>
<p>The same rework carries a second effect worth knowing about even though it isn’t usually breaking on its own:</p>
<blockquote>
<p>“Inline projects that don’t modify the Vite config now reuse the Vite server of the config that declares them instead of resolving a new Vite config and creating a new server per project.”</p>
</blockquote>
<p>That’s a performance change, not a correctness one, fewer redundant dev servers spun up for projects that share an identical Vite setup, but it’s the same underlying rework that produced the inheritance default above, so it’s worth knowing both changes shipped together.</p>
<h2 id="confirmed-version-range">Confirmed version range</h2>
<p>This site’s own <code>vitest.config.ts</code> pins <code>vitest@^3.0.0</code> and doesn’t define any <code>test.projects</code> entries, so this exact inheritance change wasn’t independently reproducible against this repo’s own config; Vitest 5 isn’t installed here. The behavior described above is attributed directly to <a href="https://main.vitest.dev/guide/migration">Vitest’s migration guide</a>, under the guide’s section covering this exact default-inheritance change for inline projects, current as of Vitest 5.0.0-beta.7 (2026-07-24). Audit every inline project in your own config for an implicit isolation assumption before upgrading, rather than discovering the overlap through a failing test. Browse more posts like this in the <a href="/guides-fixes">Guides &amp; Fixes</a> archive, or follow the rest of this Vitest 5 migration series under the <a href="/tag/vitest">vitest tag</a>.</p>]]></content:encoded>
      <pubDate>Thu, 13 Aug 2026 00:00:00 GMT</pubDate>
    </item>
    <item>
      <title>Fix Vitest 5 test.sequential Removed Error</title>
      <link>https://bytetech247.com/guides-fixes/fix-vitest-5-test-sequential-removed-error/</link>
      <guid isPermaLink="true">https://bytetech247.com/guides-fixes/fix-vitest-5-test-sequential-removed-error/</guid>
      <description>Fix Vitest 5 removing test.sequential, describe.sequential, and the sequential option. Replace them with concurrent: false.</description>
      <content:encoded><![CDATA[<aside class="quick-answer" aria-label="Quick answer"><h2 id="quick-answer">Quick Answer</h2>
<p>Vitest 5 removes <code>test.sequential</code>, <code>describe.sequential</code>, and the <code>sequential</code> boolean option outright, not deprecated-with-warning. Vitest’s migration guide doesn’t document a specific runtime error for calling them; expect a type error or a plain “is not a function” failure, since it’s no longer a recognized API surface. Replace <code>test.sequential(&#39;name&#39;, fn)</code> with <code>test(&#39;name&#39;, { concurrent: false }, fn)</code>.</p>
</aside><h2 id="why-this-got-removed-instead-of-deprecated">Why this got removed instead of deprecated</h2>
<p><code>concurrent</code> and <code>sequential</code> described the same thing from two directions. A test was either allowed to run concurrently with its siblings or it wasn’t, and <code>sequential</code> only ever meant “not concurrent.” <a href="https://main.vitest.dev/guide/migration">Vitest’s own migration guide</a> states the removal directly, in the section covering these removed options:</p>
<blockquote>
<p>Vitest 5.0 removes the deprecated <code>test.sequential</code>, <code>describe.sequential</code>, and <code>sequential</code> test options. Use <code>concurrent: false</code> when you need a test or suite to opt out of inherited or globally configured concurrency.</p>
</blockquote>
<p>The redundancy is what made this a removal candidate rather than a permanent pair of options. <a href="https://github.com/vitest-dev/vitest/issues/10180">The GitHub issue that proposed this change</a> lists out the full set of ways Vitest let you express test concurrency, including combinations like <code>{ sequential: false }</code>, and points out that having two independent flags that can contradict each other (is <code>{ concurrent: false, sequential: false }</code> concurrent or not?) creates internal churn and reader confusion for no real benefit. Collapsing to one flag, <code>concurrent</code>, removes the ambiguity entirely.</p>
<div class="callout" role="note" data-astro-cid-q2ml7llr><p class="callout__title" data-astro-cid-q2ml7llr><span aria-hidden="true" data-astro-cid-q2ml7llr>⚠</span>Not a warning, an outright removal</p><div class="callout__body" data-astro-cid-q2ml7llr><p>This isn’t the usual deprecate-then-remove cycle where a warning gives you a
release or two of runway. <code>test.sequential</code>, <code>describe.sequential</code>, and the
<code>sequential</code> option object key are gone in Vitest 5.0. If your suite still
calls any of them, the upgrade breaks that suite immediately, not on some
future major version.</p></div></div>
<h2 id="fix-it-switch-to-concurrent-false">Fix it: switch to concurrent: false</h2>
<p>Vitest’s migration guide documents the exact replacement pattern for all three removed forms. Each is a mechanical rename, not a behavior change.</p>
<div class="code-block code-block--has-filename"><div class="code-block__header"><span class="code-block__filename">calculator.test.ts - test.sequential (removed)</span><button type="button" class="code-block__copy" data-copy-button>Copy</button></div><pre class="astro-code github-dark" style="background-color:#24292e;color:#e1e4e8;overflow-x:auto" tabindex="0" data-language="ts" data-filename="calculator.test.ts - test.sequential (removed)"><code><span class="line"><span style="color:#E1E4E8">test.</span><span style="color:#B392F0">sequential</span><span style="color:#E1E4E8">(</span><span style="color:#9ECBFF">&quot;example&quot;</span><span style="color:#E1E4E8">, </span><span style="color:#F97583">async</span><span style="color:#E1E4E8"> () </span><span style="color:#F97583">=&gt;</span><span style="color:#E1E4E8"> {</span></span>
<span class="line"><span style="color:#9ca6b0">  /* ... */</span></span>
<span class="line"><span style="color:#E1E4E8">}); </span><span style="color:#9ca6b0">// BROKEN: test.sequential no longer exists in Vitest 5</span></span></code></pre></div>
<div class="code-block code-block--has-filename"><div class="code-block__header"><span class="code-block__filename">calculator.test.ts - concurrent: false (fixed)</span><button type="button" class="code-block__copy" data-copy-button>Copy</button></div><pre class="astro-code github-dark" style="background-color:#24292e;color:#e1e4e8;overflow-x:auto" tabindex="0" data-language="ts" data-filename="calculator.test.ts - concurrent: false (fixed)"><code><span class="line"><span style="color:#B392F0">test</span><span style="color:#E1E4E8">(</span><span style="color:#9ECBFF">&quot;example&quot;</span><span style="color:#E1E4E8">, { concurrent: </span><span style="color:#79B8FF">false</span><span style="color:#E1E4E8"> }, </span><span style="color:#F97583">async</span><span style="color:#E1E4E8"> () </span><span style="color:#F97583">=&gt;</span><span style="color:#E1E4E8"> {</span></span>
<span class="line"><span style="color:#9ca6b0">  /* ... */</span></span>
<span class="line"><span style="color:#E1E4E8">}); </span><span style="color:#9ca6b0">// FIXED: same effect, using the surviving option</span></span></code></pre></div>
<p><code>describe.sequential</code> follows the identical pattern:</p>
<div class="code-block code-block--has-filename"><div class="code-block__header"><span class="code-block__filename">calculator.test.ts - describe.sequential (removed)</span><button type="button" class="code-block__copy" data-copy-button>Copy</button></div><pre class="astro-code github-dark" style="background-color:#24292e;color:#e1e4e8;overflow-x:auto" tabindex="0" data-language="ts" data-filename="calculator.test.ts - describe.sequential (removed)"><code><span class="line"><span style="color:#E1E4E8">describe.</span><span style="color:#B392F0">sequential</span><span style="color:#E1E4E8">(</span><span style="color:#9ECBFF">&quot;suite&quot;</span><span style="color:#E1E4E8">, () </span><span style="color:#F97583">=&gt;</span><span style="color:#E1E4E8"> {</span></span>
<span class="line"><span style="color:#9ca6b0">  /* ... */</span></span>
<span class="line"><span style="color:#E1E4E8">}); </span><span style="color:#9ca6b0">// BROKEN: describe.sequential no longer exists in Vitest 5</span></span></code></pre></div>
<div class="code-block code-block--has-filename"><div class="code-block__header"><span class="code-block__filename">calculator.test.ts - concurrent: false (fixed)</span><button type="button" class="code-block__copy" data-copy-button>Copy</button></div><pre class="astro-code github-dark" style="background-color:#24292e;color:#e1e4e8;overflow-x:auto" tabindex="0" data-language="ts" data-filename="calculator.test.ts - concurrent: false (fixed)"><code><span class="line"><span style="color:#B392F0">describe</span><span style="color:#E1E4E8">(</span><span style="color:#9ECBFF">&quot;suite&quot;</span><span style="color:#E1E4E8">, { concurrent: </span><span style="color:#79B8FF">false</span><span style="color:#E1E4E8"> }, () </span><span style="color:#F97583">=&gt;</span><span style="color:#E1E4E8"> {</span></span>
<span class="line"><span style="color:#9ca6b0">  /* ... */</span></span>
<span class="line"><span style="color:#E1E4E8">}); </span><span style="color:#9ca6b0">// FIXED: same effect, using the surviving option</span></span></code></pre></div>
<p>If your code used the <code>sequential</code> option-object key instead of the chained method, the same replacement applies directly to that key:</p>
<div class="code-block code-block--has-filename"><div class="code-block__header"><span class="code-block__filename">calculator.test.ts - sequential: true option key (removed)</span><button type="button" class="code-block__copy" data-copy-button>Copy</button></div><pre class="astro-code github-dark" style="background-color:#24292e;color:#e1e4e8;overflow-x:auto" tabindex="0" data-language="ts" data-filename="calculator.test.ts - sequential: true option key (removed)"><code><span class="line"><span style="color:#B392F0">test</span><span style="color:#E1E4E8">(</span><span style="color:#9ECBFF">&quot;example&quot;</span><span style="color:#E1E4E8">, { sequential: </span><span style="color:#79B8FF">true</span><span style="color:#E1E4E8"> }, </span><span style="color:#F97583">async</span><span style="color:#E1E4E8"> () </span><span style="color:#F97583">=&gt;</span><span style="color:#E1E4E8"> {</span></span>
<span class="line"><span style="color:#9ca6b0">  /* ... */</span></span>
<span class="line"><span style="color:#E1E4E8">}); </span><span style="color:#9ca6b0">// BROKEN: sequential key no longer recognized</span></span></code></pre></div>
<div class="code-block code-block--has-filename"><div class="code-block__header"><span class="code-block__filename">calculator.test.ts - concurrent: false (fixed)</span><button type="button" class="code-block__copy" data-copy-button>Copy</button></div><pre class="astro-code github-dark" style="background-color:#24292e;color:#e1e4e8;overflow-x:auto" tabindex="0" data-language="ts" data-filename="calculator.test.ts - concurrent: false (fixed)"><code><span class="line"><span style="color:#B392F0">test</span><span style="color:#E1E4E8">(</span><span style="color:#9ECBFF">&quot;example&quot;</span><span style="color:#E1E4E8">, { concurrent: </span><span style="color:#79B8FF">false</span><span style="color:#E1E4E8"> }, </span><span style="color:#F97583">async</span><span style="color:#E1E4E8"> () </span><span style="color:#F97583">=&gt;</span><span style="color:#E1E4E8"> {</span></span>
<span class="line"><span style="color:#9ca6b0">  /* ... */</span></span>
<span class="line"><span style="color:#E1E4E8">}); </span><span style="color:#9ca6b0">// FIXED: negate the surviving flag instead</span></span></code></pre></div>
<p>Search your suite for <code>.sequential(</code> and <code>sequential:</code> before you upgrade, not after. Every match needs this exact rename, and there’s no runtime compatibility shim carrying the old name forward.</p>
<h2 id="confirmed-version-range">Confirmed version range</h2>
<p>Documented in <a href="https://main.vitest.dev/guide/migration">Vitest’s own current migration guide</a> as an intentional Vitest 5.0 removal, in the section covering the removed <code>sequential</code> options. Corroborated by <a href="https://github.com/vitest-dev/vitest/issues/10180">vitest-dev/vitest#10180</a>, the issue that proposed consolidating concurrency into the single <code>concurrent</code> flag. This repository’s own <code>vitest.config.ts</code> is pinned to <code>vitest@^3.0.0</code>, so this removal wasn’t independently reproduced against this project’s build; every claim above traces back to Vitest’s migration guide and the linked issue, not a local repro.</p>
<p>More fixes like this one are in the <a href="/guides-fixes">Guides &amp; Fixes</a> archive, and every Vitest post is tagged under <a href="/tag/vitest">vitest</a>. If your suite is also hitting the hoisted-mocking change in the same upgrade, see <a href="/guides-fixes/fix-vitest-5-vi-mock-top-level-scope-error/">Fix Vitest 5 vi.mock Top-Level Scope Error</a>. Running <code>vitest</code> from a subdirectory in the same upgrade? See <a href="/guides-fixes/fix-vitest-5-config-not-found-parent-directory/">Fix Vitest 5 Config Not Found in Parent Directory</a>.</p>]]></content:encoded>
      <pubDate>Thu, 13 Aug 2026 00:00:00 GMT</pubDate>
    </item>
    <item>
      <title>Fix Vitest 5 toThrow(&apos;&apos;) Silently Passing Tests</title>
      <link>https://bytetech247.com/guides-fixes/fix-vitest-5-tothrow-empty-string-silently-passing/</link>
      <guid isPermaLink="true">https://bytetech247.com/guides-fixes/fix-vitest-5-tothrow-empty-string-silently-passing/</guid>
      <description>Vitest 5 changes toThrow(&apos;&apos;) to match any thrown error, not just an empty message. Fix the silent false-pass with toThrow(/^$/) or toThrow().</description>
      <content:encoded><![CDATA[<aside class="quick-answer" aria-label="Quick answer"><h2 id="quick-answer">Quick Answer</h2>
<p>Vitest 4 special-cased <code>toThrow(&#39;&#39;)</code> to match only an empty error message. Vitest 5 removes that special case: an empty string now behaves like any substring, and it’s “contained in every message.” So <code>toThrow(&#39;&#39;)</code> silently matches any thrown error. Fix it with <code>toThrow(/^$/)</code> for an exact match, or <code>toThrow()</code> if you meant “did it throw.”</p>
</aside><h2 id="why-an-empty-string-used-to-be-a-special-case">Why an empty string used to be a special case</h2>
<p><code>toThrow()</code> (and its alias <code>toThrowError()</code>) accepts a string argument and checks whether the thrown error’s message contains it as a substring. That’s normal, expected matcher behavior for every non-empty string you’d pass it. An empty string was always the edge case, because an empty substring is trivially present inside any string at all, including one that’s completely unrelated to what the test author meant to check.</p>
<p><a href="https://main.vitest.dev/guide/migration">Vitest’s migration guide</a> confirms Vitest 4 carved out an explicit exception for exactly this reason:</p>
<blockquote>
<p>“In Vitest 4 an empty string was special-cased to the <code>/^$/</code> pattern, so it matched only an error whose message was empty.”</p>
</blockquote>
<p>That special case made <code>toThrow(&#39;&#39;)</code> behave the way most people reading it would assume: it asserted the error had no message at all, not “the error had any message whatsoever.”</p>
<p>Vitest 5 removes that special case. The guide states the new behavior plainly:</p>
<blockquote>
<p>“<code>toThrow</code> now behaves like any other substring, and an empty string is contained in every message.”</p>
</blockquote>
<p>Nothing distinguishes an empty-string argument from any other string argument anymore, and an empty string is a substring of every possible string, including one with no message and one with a hundred-character message.</p>
<h2 id="why-this-is-the-dangerous-kind-of-breaking-change">Why this is the dangerous kind of breaking change</h2>
<p>Most breaking changes throw something: a removed API throws a resolution error, a stricter check throws a new failure. This one doesn’t. A test written as <code>expect(fn).toThrow(&#39;&#39;)</code> compiles, runs, and reports green under Vitest 5, exactly like it did under Vitest 4. The difference is what that green result actually means.</p>
<p>Under Vitest 4, that assertion meant “this function throws, and the error message is empty.” Under Vitest 5, the same line means “this function throws,” full stop, regardless of what the message says or whether there even is one. A test suite can upgrade to Vitest 5, pass every test, and quietly lose the specificity of every <code>toThrow(&#39;&#39;)</code> assertion it contains, with no failing test, no console warning, and no diff in the test file itself to flag it.</p>
<h2 id="fix-it-assert-what-you-actually-meant">Fix it: assert what you actually meant</h2>
<h3 id="case-1-you-meant-the-error-has-no-message">Case 1: you meant “the error has no message”</h3>
<div class="code-block code-block--has-filename"><div class="code-block__header"><span class="code-block__filename">parser.test.ts - before</span><button type="button" class="code-block__copy" data-copy-button>Copy</button></div><pre class="astro-code github-dark" style="background-color:#24292e;color:#e1e4e8;overflow-x:auto" tabindex="0" data-language="ts" data-filename="parser.test.ts - before"><code><span class="line"><span style="color:#B392F0">test</span><span style="color:#E1E4E8">(</span><span style="color:#9ECBFF">&quot;throws with no message on empty input&quot;</span><span style="color:#E1E4E8">, () </span><span style="color:#F97583">=&gt;</span><span style="color:#E1E4E8"> {</span></span>
<span class="line"><span style="color:#B392F0">  expect</span><span style="color:#E1E4E8">(() </span><span style="color:#F97583">=&gt;</span><span style="color:#B392F0"> parseConfig</span><span style="color:#E1E4E8">(</span><span style="color:#9ECBFF">&quot;&quot;</span><span style="color:#E1E4E8">)).</span><span style="color:#B392F0">toThrow</span><span style="color:#E1E4E8">(</span><span style="color:#9ECBFF">&quot;&quot;</span><span style="color:#E1E4E8">); </span><span style="color:#9ca6b0">// BROKEN: now matches any message, not just an empty one</span></span>
<span class="line"><span style="color:#E1E4E8">});</span></span></code></pre></div>
<div class="code-block code-block--has-filename"><div class="code-block__header"><span class="code-block__filename">parser.test.ts - after</span><button type="button" class="code-block__copy" data-copy-button>Copy</button></div><pre class="astro-code github-dark" style="background-color:#24292e;color:#e1e4e8;overflow-x:auto" tabindex="0" data-language="ts" data-filename="parser.test.ts - after"><code><span class="line"><span style="color:#B392F0">test</span><span style="color:#E1E4E8">(</span><span style="color:#9ECBFF">&quot;throws with no message on empty input&quot;</span><span style="color:#E1E4E8">, () </span><span style="color:#F97583">=&gt;</span><span style="color:#E1E4E8"> {</span></span>
<span class="line"><span style="color:#B392F0">  expect</span><span style="color:#E1E4E8">(() </span><span style="color:#F97583">=&gt;</span><span style="color:#B392F0"> parseConfig</span><span style="color:#E1E4E8">(</span><span style="color:#9ECBFF">&quot;&quot;</span><span style="color:#E1E4E8">)).</span><span style="color:#B392F0">toThrow</span><span style="color:#E1E4E8">(</span><span style="color:#9ECBFF">/</span><span style="color:#F97583">^$</span><span style="color:#9ECBFF">/</span><span style="color:#E1E4E8">); </span><span style="color:#9ca6b0">// FIXED: explicit regex, matches only an empty message</span></span>
<span class="line"><span style="color:#E1E4E8">});</span></span></code></pre></div>
<h3 id="case-2-you-meant-it-throws-i-dont-care-what-the-message-says">Case 2: you meant “it throws, I don’t care what the message says”</h3>
<div class="code-block code-block--has-filename"><div class="code-block__header"><span class="code-block__filename">parser.test.ts - before</span><button type="button" class="code-block__copy" data-copy-button>Copy</button></div><pre class="astro-code github-dark" style="background-color:#24292e;color:#e1e4e8;overflow-x:auto" tabindex="0" data-language="ts" data-filename="parser.test.ts - before"><code><span class="line"><span style="color:#B392F0">test</span><span style="color:#E1E4E8">(</span><span style="color:#9ECBFF">&quot;throws on malformed config&quot;</span><span style="color:#E1E4E8">, () </span><span style="color:#F97583">=&gt;</span><span style="color:#E1E4E8"> {</span></span>
<span class="line"><span style="color:#B392F0">  expect</span><span style="color:#E1E4E8">(() </span><span style="color:#F97583">=&gt;</span><span style="color:#B392F0"> parseConfig</span><span style="color:#E1E4E8">(</span><span style="color:#9ECBFF">&quot;{{{&quot;</span><span style="color:#E1E4E8">)).</span><span style="color:#B392F0">toThrow</span><span style="color:#E1E4E8">(</span><span style="color:#9ECBFF">&quot;&quot;</span><span style="color:#E1E4E8">); </span><span style="color:#9ca6b0">// BROKEN: reads like a message check, isn&#39;t one</span></span>
<span class="line"><span style="color:#E1E4E8">});</span></span></code></pre></div>
<div class="code-block code-block--has-filename"><div class="code-block__header"><span class="code-block__filename">parser.test.ts - after</span><button type="button" class="code-block__copy" data-copy-button>Copy</button></div><pre class="astro-code github-dark" style="background-color:#24292e;color:#e1e4e8;overflow-x:auto" tabindex="0" data-language="ts" data-filename="parser.test.ts - after"><code><span class="line"><span style="color:#B392F0">test</span><span style="color:#E1E4E8">(</span><span style="color:#9ECBFF">&quot;throws on malformed config&quot;</span><span style="color:#E1E4E8">, () </span><span style="color:#F97583">=&gt;</span><span style="color:#E1E4E8"> {</span></span>
<span class="line"><span style="color:#B392F0">  expect</span><span style="color:#E1E4E8">(() </span><span style="color:#F97583">=&gt;</span><span style="color:#B392F0"> parseConfig</span><span style="color:#E1E4E8">(</span><span style="color:#9ECBFF">&quot;{{{&quot;</span><span style="color:#E1E4E8">)).</span><span style="color:#B392F0">toThrow</span><span style="color:#E1E4E8">(); </span><span style="color:#9ca6b0">// FIXED: no argument, says exactly what it checks</span></span>
<span class="line"><span style="color:#E1E4E8">});</span></span></code></pre></div>
<p>The second case is the more common one in practice. A lot of <code>toThrow(&#39;&#39;)</code> call sites were never trying to assert an empty message at all; they were written by someone who wanted “does it throw” and reached for an empty string as a stand-in for “any message,” which happened to work under the old special case by coincidence rather than by design. Dropping the argument entirely says what the test actually checks.</p>
<div class="callout" role="note" data-astro-cid-q2ml7llr><p class="callout__title" data-astro-cid-q2ml7llr><span aria-hidden="true" data-astro-cid-q2ml7llr>⚠</span>Don&#39;t assume every hit is Case 2</p><div class="callout__body" data-astro-cid-q2ml7llr><p>Read each call site before changing it. A test that genuinely depends on
catching a blank-message error (validation code that deliberately throws <code>new   Error(&quot;&quot;)</code> as a sentinel, for instance) needs the explicit <code>toThrow(/^$/)</code>
form, not a bare <code>toThrow()</code> that would now also pass for a completely
different, unrelated failure.</p></div></div>
<h2 id="confirmed-version-range">Confirmed version range</h2>
<p>This site’s own <code>vitest.config.ts</code> pins <code>vitest@^3.0.0</code>, so this behavior change wasn’t independently reproducible against this repo’s own test run; Vitest 5 isn’t installed here. The matcher behavior described above is attributed directly to <a href="https://main.vitest.dev/guide/migration">Vitest’s migration guide</a>, under its section titled <code>toThrow(&quot;&quot;) Matches Any Error Message</code>, current as of Vitest 5.0.0-beta.7 (2026-07-24). Grep your own suite for <code>toThrow(&#39;&#39;)</code> and <code>toThrowError(&#39;&#39;)</code> before upgrading rather than waiting to notice a coverage gap after the fact. Browse more posts like this in the <a href="/guides-fixes">Guides &amp; Fixes</a> archive, or follow the rest of this Vitest 5 migration series under the <a href="/tag/vitest">vitest tag</a>.</p>]]></content:encoded>
      <pubDate>Thu, 13 Aug 2026 00:00:00 GMT</pubDate>
    </item>
    <item>
      <title>Fix Vitest 5 Unawaited Async Assertion Error</title>
      <link>https://bytetech247.com/guides-fixes/fix-vitest-5-unawaited-async-assertion-error/</link>
      <guid isPermaLink="true">https://bytetech247.com/guides-fixes/fix-vitest-5-unawaited-async-assertion-error/</guid>
      <description>Vitest 5 fails tests outright on an unawaited async assertion instead of just warning. Here&apos;s why, and the one-line fix.</description>
      <content:encoded><![CDATA[<aside class="quick-answer" aria-label="Quick answer"><h2 id="quick-answer">Quick Answer</h2>
<p>Vitest 5 fails a test outright when an async assertion like <code>expect(promise).resolves.toBe(1)</code> is called without <code>await</code> in front of it. Vitest 4 auto-awaited that same call at the end of the test and only printed a warning, so the test still passed. Fix it by adding the missing <code>await</code> in front of every <code>resolves</code>, <code>rejects</code>, or <code>toMatchFileSnapshot</code> assertion.</p>
</aside><h2 id="why-a-passing-test-suddenly-fails-after-upgrading">Why a passing test suddenly fails after upgrading</h2>
<p>This isn’t a new bug Vitest 5 introduces. It’s an old bug Vitest 4 was quietly hiding. <a href="https://main.vitest.dev/guide/migration">Vitest’s migration guide</a> states it directly, under “Unawaited Asynchronous Assertions Fail the Test”:</p>
<blockquote>
<p>Asynchronous assertions, like <code>resolves</code>, <code>rejects</code> and <code>toMatchFileSnapshot</code>, now fail the test if they are not awaited.</p>
</blockquote>
<p>The guide’s own example makes the before-and-after explicit:</p>
<div class="code-block code-block--has-filename"><div class="code-block__header"><span class="code-block__filename">async.test.ts</span><button type="button" class="code-block__copy" data-copy-button>Copy</button></div><pre class="astro-code github-dark" style="background-color:#24292e;color:#e1e4e8;overflow-x:auto" tabindex="0" data-language="ts" data-filename="async.test.ts"><code><span class="line"><span style="color:#B392F0">test</span><span style="color:#E1E4E8">(</span><span style="color:#9ECBFF">&quot;unawaited assertion&quot;</span><span style="color:#E1E4E8">, </span><span style="color:#F97583">async</span><span style="color:#E1E4E8"> () </span><span style="color:#F97583">=&gt;</span><span style="color:#E1E4E8"> {</span></span>
<span class="line"><span style="color:#9ca6b0">  // v4: prints a warning, the test passes</span></span>
<span class="line"><span style="color:#9ca6b0">  // v5: the test fails</span></span>
<span class="line"><span style="color:#B392F0">  expect</span><span style="color:#E1E4E8">(promise).resolves.</span><span style="color:#B392F0">toBe</span><span style="color:#E1E4E8">(</span><span style="color:#79B8FF">1</span><span style="color:#E1E4E8">);</span></span>
<span class="line"><span style="color:#F97583">  await</span><span style="color:#B392F0"> expect</span><span style="color:#E1E4E8">(promise).resolves.</span><span style="color:#B392F0">toBe</span><span style="color:#E1E4E8">(</span><span style="color:#79B8FF">1</span><span style="color:#E1E4E8">);</span></span>
<span class="line"><span style="color:#E1E4E8">});</span></span></code></pre></div>
<p>The mechanical reason this matters: <code>resolves</code> and <code>rejects</code> don’t run the comparison synchronously. Calling <code>expect(promise).resolves.toBe(1)</code> returns a <code>Promise</code> that has to settle before the assertion inside it actually runs. Skip the <code>await</code>, and the test function can finish and report success before that inner promise has resolved at all, which means the comparison it’s supposed to make might never happen before Vitest moves on.</p>
<p>Vitest 4 papered over that gap. It tracked pending assertion promises and auto-awaited them after the test body returned, so the check still ran, just late, with a console warning as the only sign anything was off. Vitest 5 removes that safety net and fails the test the moment it detects an assertion that was never awaited.</p>
<h2 id="why-this-is-a-real-bug-not-just-stricter-enforcement">Why this is a real bug, not just stricter enforcement</h2>
<p>A forgotten <code>await</code> on an async matcher is a latent bug regardless of which Vitest version is running it. If the promise the assertion checks actually rejects, or resolves to something other than what <code>toBe</code> expects, an unawaited assertion in Vitest 4 could still report a green test, because the warning goes to the console, not to the test’s pass/fail result, and nothing forces a developer to read console output on a passing CI run. Vitest 5 turns that same situation into a hard failure, which is the more honest outcome: a test that doesn’t actually verify what it claims to verify shouldn’t be counted as passing.</p>
<h2 id="fix-it-add-the-missing-await">Fix it: add the missing await</h2>
<p>The fix is almost always a single keyword. Find the assertion, add <code>await</code>:</p>
<div class="code-block code-block--has-filename"><div class="code-block__header"><span class="code-block__filename">async.test.ts - before</span><button type="button" class="code-block__copy" data-copy-button>Copy</button></div><pre class="astro-code github-dark" style="background-color:#24292e;color:#e1e4e8;overflow-x:auto" tabindex="0" data-language="ts" data-filename="async.test.ts - before"><code><span class="line"><span style="color:#B392F0">test</span><span style="color:#E1E4E8">(</span><span style="color:#9ECBFF">&quot;resolves to the expected value&quot;</span><span style="color:#E1E4E8">, </span><span style="color:#F97583">async</span><span style="color:#E1E4E8"> () </span><span style="color:#F97583">=&gt;</span><span style="color:#E1E4E8"> {</span></span>
<span class="line"><span style="color:#B392F0">  expect</span><span style="color:#E1E4E8">(</span><span style="color:#B392F0">fetchUser</span><span style="color:#E1E4E8">(</span><span style="color:#79B8FF">1</span><span style="color:#E1E4E8">)).resolves.</span><span style="color:#B392F0">toEqual</span><span style="color:#E1E4E8">({ id: </span><span style="color:#79B8FF">1</span><span style="color:#E1E4E8"> }); </span><span style="color:#9ca6b0">// BROKEN: promise never awaited</span></span>
<span class="line"><span style="color:#E1E4E8">});</span></span></code></pre></div>
<div class="code-block code-block--has-filename"><div class="code-block__header"><span class="code-block__filename">async.test.ts - after</span><button type="button" class="code-block__copy" data-copy-button>Copy</button></div><pre class="astro-code github-dark" style="background-color:#24292e;color:#e1e4e8;overflow-x:auto" tabindex="0" data-language="ts" data-filename="async.test.ts - after"><code><span class="line"><span style="color:#B392F0">test</span><span style="color:#E1E4E8">(</span><span style="color:#9ECBFF">&quot;resolves to the expected value&quot;</span><span style="color:#E1E4E8">, </span><span style="color:#F97583">async</span><span style="color:#E1E4E8"> () </span><span style="color:#F97583">=&gt;</span><span style="color:#E1E4E8"> {</span></span>
<span class="line"><span style="color:#F97583">  await</span><span style="color:#B392F0"> expect</span><span style="color:#E1E4E8">(</span><span style="color:#B392F0">fetchUser</span><span style="color:#E1E4E8">(</span><span style="color:#79B8FF">1</span><span style="color:#E1E4E8">)).resolves.</span><span style="color:#B392F0">toEqual</span><span style="color:#E1E4E8">({ id: </span><span style="color:#79B8FF">1</span><span style="color:#E1E4E8"> }); </span><span style="color:#9ca6b0">// FIXED: assertion runs before the test ends</span></span>
<span class="line"><span style="color:#E1E4E8">});</span></span></code></pre></div>
<div class="callout" role="note" data-astro-cid-q2ml7llr><p class="callout__title" data-astro-cid-q2ml7llr><span aria-hidden="true" data-astro-cid-q2ml7llr>✓</span>Search your suite for the pattern before running it</p><div class="callout__body" data-astro-cid-q2ml7llr><p>Grep your test files for <code>resolves</code> and <code>rejects</code> and check each match for a
leading <code>await</code> or a <code>return</code> in front of it. Both work, since returning the
assertion promise from an <code>async</code> test function lets the test runner wait on
it the same way an explicit <code>await</code> would.</p></div></div>
<h2 id="confirmed-version-range">Confirmed version range</h2>
<p>This behavior change is documented in <a href="https://main.vitest.dev/guide/migration">Vitest’s own current migration guide</a>, under “Unawaited Asynchronous Assertions Fail the Test,” as an intentional Vitest 5 change, corroborated as in-window against the beta release available at research time (v5.0.0-beta.7). This repo’s own <code>vitest.config.ts</code> is pinned to <code>vitest@^3.0.0</code>, so this exact failure mode wasn’t independently reproducible against this repo’s own test suite. Treat the mechanics above as documented behavior from Vitest’s primary source, not a first-party reproduction. If you’re also hitting timing changes around <code>expect.poll()</code>, see <a href="/guides-fixes/fix-vitest-5-expect-poll-timeout-error/">Fix Vitest 5 expect.poll Timeout Error</a> — a related but distinct Vitest 5 change to how long-running assertions are handled. Browse more posts like this in the <a href="/guides-fixes">Guides &amp; Fixes</a> archive.</p>]]></content:encoded>
      <pubDate>Thu, 13 Aug 2026 00:00:00 GMT</pubDate>
    </item>
    <item>
      <title>Fix Vitest 5 vi.mock Top-Level Scope Error</title>
      <link>https://bytetech247.com/guides-fixes/fix-vitest-5-vi-mock-top-level-scope-error/</link>
      <guid isPermaLink="true">https://bytetech247.com/guides-fixes/fix-vitest-5-vi-mock-top-level-scope-error/</guid>
      <description>Fix Vitest 5&apos;s &quot;defined outside of the module&apos;s top level scope&quot; error thrown by vi.mock, vi.unmock, and vi.hoisted.</description>
      <content:encoded><![CDATA[<aside class="quick-answer" aria-label="Quick answer"><h2 id="quick-answer">Quick Answer</h2>
<p>Vitest 5 throws this error when <code>vi.mock()</code>, <code>vi.unmock()</code>, or <code>vi.hoisted()</code> runs inside a function, <code>describe</code>, or <code>test</code> callback instead of at the top of the file:</p>
<blockquote>
<p>1 call in “file.test.ts” was defined outside of the module’s top level scope</p>
</blockquote>
<p>Move the call to module top level, or switch to <code>vi.doMock()</code>/<code>vi.doUnmock()</code>, which are not hoisted and can run anywhere.</p>
</aside><h2 id="why-the-call-throws-instead-of-just-warning">Why the call throws instead of just warning</h2>
<p><code>vi.mock()</code>, <code>vi.unmock()</code>, and <code>vi.hoisted()</code> don’t run where they’re written. Vitest’s compiler transform lifts them to the very top of the file at parse time, before any other module-level code, regardless of how deeply they’re nested in the source. That’s what makes them useful for mocking a module before your own imports even resolve.</p>
<p>The problem is when the code around a hoisted call implies an order that isn’t the order it actually runs in. <a href="https://main.vitest.dev/guide/migration">Vitest’s own migration guide</a> states the change plainly, in its “Hoisted Mocking Calls” section:</p>
<blockquote>
<p><code>vi.mock</code>, <code>vi.unmock</code>, and <code>vi.hoisted</code> are hoisted to the top of the file and run before any surrounding code. Calling them inside a function, block, or <code>describe</code>/<code>test</code> callback previously only logged a warning. Vitest 5.0 now throws, because the call does not execute where it is written.</p>
</blockquote>
<p>Vitest 4 tolerated the mismatch and just printed a warning. Vitest 5.0 stops tolerating it. The reported error names the exact offending call and its location, quoted here from the guide’s own example:</p>
<div class="code-block"><div class="code-block__header"><span class="code-block__filename"></span><button type="button" class="code-block__copy" data-copy-button>Copy</button></div><pre class="astro-code github-dark" style="background-color:#24292e;color:#e1e4e8;overflow-x:auto" tabindex="0" data-language="plaintext"><code><span class="line"><span>1 call in &quot;calculator.test.ts&quot; was defined outside of the module&#39;s top level scope:</span></span>
<span class="line"><span></span></span>
<span class="line"><span>- vi.mock(&quot;./calculator&quot;) at calculator.test.ts:2:3</span></span>
<span class="line"><span></span></span>
<span class="line"><span>Although it appears nested, it will be hoisted and executed before anything</span></span>
<span class="line"><span>in this file. Move it to the top level to reflect its actual execution order.</span></span></code></pre></div>
<div class="callout" role="note" data-astro-cid-q2ml7llr><p class="callout__title" data-astro-cid-q2ml7llr><span aria-hidden="true" data-astro-cid-q2ml7llr>⚠</span>This is a real behavior change, not a lint rule</p><div class="callout__body" data-astro-cid-q2ml7llr><p>A test suite that ran clean under Vitest 4 with a <code>vi.mock()</code> nested inside a
<code>describe</code> block will fail outright after upgrading to Vitest 5, with no code
change on your side. The mock call itself was never broken. What changed is
that Vitest now refuses to let its written position disagree with its real
execution order.</p></div></div>
<h2 id="fix-it-move-the-call-to-module-top-level">Fix it: move the call to module top level</h2>
<p>The guide’s own example shows the pattern directly. A <code>vi.mock()</code> call sitting inside a <code>describe</code> block gets hoisted anyway, so writing it there is misleading even before Vitest 5 makes it an error.</p>
<div class="code-block code-block--has-filename"><div class="code-block__header"><span class="code-block__filename">calculator.test.ts - before</span><button type="button" class="code-block__copy" data-copy-button>Copy</button></div><pre class="astro-code github-dark" style="background-color:#24292e;color:#e1e4e8;overflow-x:auto" tabindex="0" data-language="ts" data-filename="calculator.test.ts - before"><code><span class="line"><span style="color:#F97583">import</span><span style="color:#E1E4E8"> { describe, expect, it, vi } </span><span style="color:#F97583">from</span><span style="color:#9ECBFF"> &quot;vitest&quot;</span><span style="color:#E1E4E8">;</span></span>
<span class="line"></span>
<span class="line"><span style="color:#B392F0">describe</span><span style="color:#E1E4E8">(</span><span style="color:#9ECBFF">&quot;calculator&quot;</span><span style="color:#E1E4E8">, () </span><span style="color:#F97583">=&gt;</span><span style="color:#E1E4E8"> {</span></span>
<span class="line"><span style="color:#E1E4E8">  vi.</span><span style="color:#B392F0">mock</span><span style="color:#E1E4E8">(</span><span style="color:#9ECBFF">&quot;./calculator&quot;</span><span style="color:#E1E4E8">); </span><span style="color:#9ca6b0">// BROKEN: nested, throws in Vitest 5</span></span>
<span class="line"><span style="color:#9ca6b0">  // ...tests</span></span>
<span class="line"><span style="color:#E1E4E8">});</span></span></code></pre></div>
<div class="code-block code-block--has-filename"><div class="code-block__header"><span class="code-block__filename">calculator.test.ts - after</span><button type="button" class="code-block__copy" data-copy-button>Copy</button></div><pre class="astro-code github-dark" style="background-color:#24292e;color:#e1e4e8;overflow-x:auto" tabindex="0" data-language="ts" data-filename="calculator.test.ts - after"><code><span class="line"><span style="color:#F97583">import</span><span style="color:#E1E4E8"> { describe, expect, it, vi } </span><span style="color:#F97583">from</span><span style="color:#9ECBFF"> &quot;vitest&quot;</span><span style="color:#E1E4E8">;</span></span>
<span class="line"></span>
<span class="line"><span style="color:#E1E4E8">vi.</span><span style="color:#B392F0">mock</span><span style="color:#E1E4E8">(</span><span style="color:#9ECBFF">&quot;./calculator&quot;</span><span style="color:#E1E4E8">); </span><span style="color:#9ca6b0">// FIXED: written at module top level, matches hoisted order</span></span>
<span class="line"></span>
<span class="line"><span style="color:#B392F0">describe</span><span style="color:#E1E4E8">(</span><span style="color:#9ECBFF">&quot;calculator&quot;</span><span style="color:#E1E4E8">, () </span><span style="color:#F97583">=&gt;</span><span style="color:#E1E4E8"> {</span></span>
<span class="line"><span style="color:#9ca6b0">  // ...tests</span></span>
<span class="line"><span style="color:#E1E4E8">});</span></span></code></pre></div>
<p>The fix is mechanical: pull every <code>vi.mock()</code>, <code>vi.unmock()</code>, and <code>vi.hoisted()</code> call out to the top of the file, outside any <code>describe</code> or <code>test</code> callback. Nothing about the mock’s behavior changes. Only its written position moves to match where it already ran.</p>
<h2 id="when-you-actually-need-a-mock-set-up-at-runtime">When you actually need a mock set up at runtime</h2>
<p>Sometimes the whole point is deciding what to mock inside a test, based on something you only know at runtime, not at parse time. Hoisting a call there doesn’t just look wrong, it’s the wrong tool. Vitest’s migration guide is explicit that the hoisted variants have a non-hoisted counterpart built for exactly this:</p>
<blockquote>
<p>The dynamic variants <code>vi.doMock</code> and <code>vi.doUnmock</code> are not hoisted and may still be called anywhere.</p>
</blockquote>
<div class="code-block code-block--has-filename"><div class="code-block__header"><span class="code-block__filename">calculator.test.ts - dynamic mocking, still legal anywhere</span><button type="button" class="code-block__copy" data-copy-button>Copy</button></div><pre class="astro-code github-dark" style="background-color:#24292e;color:#e1e4e8;overflow-x:auto" tabindex="0" data-language="ts" data-filename="calculator.test.ts - dynamic mocking, still legal anywhere"><code><span class="line"><span style="color:#F97583">import</span><span style="color:#E1E4E8"> { describe, expect, it, vi } </span><span style="color:#F97583">from</span><span style="color:#9ECBFF"> &quot;vitest&quot;</span><span style="color:#E1E4E8">;</span></span>
<span class="line"></span>
<span class="line"><span style="color:#B392F0">describe</span><span style="color:#E1E4E8">(</span><span style="color:#9ECBFF">&quot;calculator&quot;</span><span style="color:#E1E4E8">, () </span><span style="color:#F97583">=&gt;</span><span style="color:#E1E4E8"> {</span></span>
<span class="line"><span style="color:#B392F0">  it</span><span style="color:#E1E4E8">(</span><span style="color:#9ECBFF">&quot;uses a mocked add() only for this test&quot;</span><span style="color:#E1E4E8">, </span><span style="color:#F97583">async</span><span style="color:#E1E4E8"> () </span><span style="color:#F97583">=&gt;</span><span style="color:#E1E4E8"> {</span></span>
<span class="line"><span style="color:#E1E4E8">    vi.</span><span style="color:#B392F0">doMock</span><span style="color:#E1E4E8">(</span><span style="color:#9ECBFF">&quot;./calculator&quot;</span><span style="color:#E1E4E8">, () </span><span style="color:#F97583">=&gt;</span><span style="color:#E1E4E8"> ({ </span><span style="color:#B392F0">add</span><span style="color:#E1E4E8">: () </span><span style="color:#F97583">=&gt;</span><span style="color:#79B8FF"> 42</span><span style="color:#E1E4E8"> })); </span><span style="color:#9ca6b0">// OK: not hoisted, runs in place</span></span>
<span class="line"><span style="color:#F97583">    const</span><span style="color:#E1E4E8"> { </span><span style="color:#79B8FF">add</span><span style="color:#E1E4E8"> } </span><span style="color:#F97583">=</span><span style="color:#F97583"> await</span><span style="color:#F97583"> import</span><span style="color:#E1E4E8">(</span><span style="color:#9ECBFF">&quot;./calculator&quot;</span><span style="color:#E1E4E8">);</span></span>
<span class="line"><span style="color:#B392F0">    expect</span><span style="color:#E1E4E8">(</span><span style="color:#B392F0">add</span><span style="color:#E1E4E8">(</span><span style="color:#79B8FF">1</span><span style="color:#E1E4E8">, </span><span style="color:#79B8FF">2</span><span style="color:#E1E4E8">)).</span><span style="color:#B392F0">toBe</span><span style="color:#E1E4E8">(</span><span style="color:#79B8FF">42</span><span style="color:#E1E4E8">);</span></span>
<span class="line"><span style="color:#E1E4E8">  });</span></span>
<span class="line"><span style="color:#E1E4E8">});</span></span></code></pre></div>
<p><code>vi.doMock()</code> and <code>vi.doUnmock()</code> take effect only for imports that happen after they run, so pair them with a dynamic <code>import()</code> inside the test rather than a static top-of-file import. That’s a real difference from <code>vi.mock()</code>, not a cosmetic one. If your suite genuinely needs per-test mock variation, this is the supported path, not a workaround.</p>
<h2 id="confirmed-version-range">Confirmed version range</h2>
<p>This is documented in <a href="https://main.vitest.dev/guide/migration">Vitest’s own current migration guide</a> as an intentional Vitest 5.0 change, in the section covering hoisted mocking calls. This repository’s own <code>vitest.config.ts</code> is pinned to <code>vitest@^3.0.0</code>, so this behavior wasn’t independently reproduced against this project’s build; every claim above is sourced directly from Vitest’s migration guide, not a local repro. Check the <a href="https://main.vitest.dev/api/vi#vi-hoisted"><code>vi.hoisted</code> API reference</a> if you’re touching hoisted setup logic beyond <code>vi.mock</code>/<code>vi.unmock</code>, since the same top-level rule applies there too.</p>
<p>If your suite also leans on <code>clearMocks</code>, see <a href="/guides-fixes/fix-vitest-5-clearmocks-breaking-mock-state/">Fix Vitest 5 clearMocks Breaking Mock State</a> for the other Vitest 5 mocking default that changes silently on upgrade. More fixes like this one are in the <a href="/guides-fixes">Guides &amp; Fixes</a> archive, and the full list of Vitest posts is tagged under <a href="/tag/vitest">vitest</a>.</p>]]></content:encoded>
      <pubDate>Thu, 13 Aug 2026 00:00:00 GMT</pubDate>
    </item>
    <item>
      <title>Set Up GitHub Agentic Workflows in Actions</title>
      <link>https://bytetech247.com/dev-tools/github-actions-agentic-workflows-setup/</link>
      <guid isPermaLink="true">https://bytetech247.com/dev-tools/github-actions-agentic-workflows-setup/</guid>
      <description>GitHub Agentic Workflows (public preview) compiles Markdown into Actions YAML for AI-driven triage and CI analysis, sandboxed behind a read-only default.</description>
      <content:encoded><![CDATA[<aside class="quick-answer" aria-label="Quick answer"><h2 id="quick-answer">Quick Answer</h2>
<p>Use GitHub Agentic Workflows when you want a reasoning-based repo task, issue triage, CI failure analysis, documentation updates, automated from a natural-language Markdown file instead of hand-written YAML plus your own LLM glue code and security boundary. Skip it for simple deterministic automation; plain Actions YAML is simpler and doesn’t need an AI engine, its secrets, or a sandboxed execution layer.</p>
</aside><h2 id="what-this-replaces">What this replaces</h2>
<p>Automating a reasoning-based repo task, deciding whether an issue is a duplicate, summarizing why a CI run failed, drafting a documentation update, used to mean hand-writing bespoke Actions YAML and whatever custom code called an LLM API, with no standard security boundary around what that code could read, write, or execute inside CI. Every team building this kind of automation solved the sandboxing and output-validation problem themselves, if they solved it at all.</p>
<p>GitHub’s changelog, published 2026-06-11:</p>
<blockquote>
<p>“GitHub Agentic Workflows is now in public preview. With agentic workflows, you can automate reasoning-based tasks like issue triage, CI failure analysis, and documentation updates by leveraging coding agents inside GitHub Actions.”</p>
</blockquote>
<p>Workflows are authored as plain-language Markdown, not YAML directly:</p>
<blockquote>
<p>“You define your automation using natural language Markdown files, and GitHub Agentic Workflows compiles them into standard Actions YAML.”</p>
</blockquote>
<div class="callout" role="note" data-astro-cid-q2ml7llr><p class="callout__title" data-astro-cid-q2ml7llr><span aria-hidden="true" data-astro-cid-q2ml7llr>ℹ</span>The security layer is the actual news here</p><div class="callout__body" data-astro-cid-q2ml7llr><p>Four separate safeguards ship with the preview: an integrity filter on what
content an agent can access, read-only permissions by default, sandboxed
execution inside an “Agent Workflow Firewall,” and a dedicated
threat-detection job that scans an agent’s proposed changes before they apply.
A “safe outputs” process validates what the agent produces before anything
from it lands in the repo. None of that existed as a standard feature for
hand-rolled agent-in-CI setups before this.</p></div></div>
<h2 id="structural-comparison-matrix">Structural Comparison Matrix</h2>



































<table tabindex="0"><thead><tr><th scope="col" style="text-align:left">Operational Aspect</th><th scope="col" style="text-align:left">Hand-rolled agent automation (before)</th><th scope="col" style="text-align:left">GitHub Agentic Workflows (public preview)</th></tr></thead><tbody><tr><td style="text-align:left"><strong>Authoring format</strong></td><td style="text-align:left">Actions YAML plus custom LLM-calling code</td><td style="text-align:left">Natural-language Markdown, compiled to Actions YAML</td></tr><tr><td style="text-align:left"><strong>Default permissions</strong></td><td style="text-align:left">Whatever the team’s own YAML granted</td><td style="text-align:left">Read-only by default</td></tr><tr><td style="text-align:left"><strong>Execution sandboxing</strong></td><td style="text-align:left">Whatever the team built themselves, if anything</td><td style="text-align:left">Runs inside the Agent Workflow Firewall</td></tr><tr><td style="text-align:left"><strong>Output validation</strong></td><td style="text-align:left">Custom, team-specific, if it exists at all</td><td style="text-align:left">A dedicated “safe outputs” process</td></tr><tr><td style="text-align:left"><strong>Pre-apply review</strong></td><td style="text-align:left">Manual code review of generated changes</td><td style="text-align:left">A threat-detection job scans proposed changes first</td></tr></tbody></table>
<h2 id="install-the-cli-extension-and-scaffold-a-workflow">Install the CLI extension and scaffold a workflow</h2>
<p>GitHub Agentic Workflows ships as a <code>gh</code> CLI extension, not a built-in <code>gh</code> subcommand:</p>
<div class="code-block code-block--has-filename"><div class="code-block__header"><span class="code-block__filename">install the extension</span><button type="button" class="code-block__copy" data-copy-button>Copy</button></div><pre class="astro-code github-dark" style="background-color:#24292e;color:#e1e4e8;overflow-x:auto" tabindex="0" data-language="bash" data-filename="install the extension"><code><span class="line"><span style="color:#B392F0">gh</span><span style="color:#9ECBFF"> extension</span><span style="color:#9ECBFF"> install</span><span style="color:#9ECBFF"> github/gh-aw</span></span></code></pre></div>
<p>Running <code>gh aw compile</code> produces a <code>.lock.yml</code> file containing the standard Actions YAML the Markdown source compiled into, which is the file Actions actually runs, not the Markdown itself. From a repo’s root, the wizard scaffolds a first workflow interactively:</p>
<div class="code-block code-block--has-filename"><div class="code-block__header"><span class="code-block__filename">scaffold a starter workflow</span><button type="button" class="code-block__copy" data-copy-button>Copy</button></div><pre class="astro-code github-dark" style="background-color:#24292e;color:#e1e4e8;overflow-x:auto" tabindex="0" data-language="bash" data-filename="scaffold a starter workflow"><code><span class="line"><span style="color:#B392F0">gh</span><span style="color:#9ECBFF"> aw</span><span style="color:#9ECBFF"> add-wizard</span><span style="color:#9ECBFF"> githubnext/agentics/daily-repo-status</span></span></code></pre></div>
<p>The wizard’s reference argument uses an <code>OWNER/REPO/WORKFLOW-NAME</code> format, pulling a starter template, in this case a daily repo status check, from GitHub’s own <code>githubnext/agentics</code> collection of prebuilt workflows. It walks through checking repository prerequisites, choosing an AI engine (Copilot by default, or Claude Code, OpenAI Codex, Google Gemini, or Pi instead), and setting up whichever secret that engine needs.</p>
<div class="callout" role="note" data-astro-cid-q2ml7llr><p class="callout__title" data-astro-cid-q2ml7llr><span aria-hidden="true" data-astro-cid-q2ml7llr>✓</span>No manual install step if your gh CLI is current</p><div class="callout__body" data-astro-cid-q2ml7llr><p>GitHub’s docs note that with <code>gh</code> CLI 2.90.0 or later, running any <code>gh aw</code>
command prompts an automatic install of the extension if it isn’t present yet,
so the explicit <code>gh extension install</code> step above is a fallback more than a
hard requirement on a recent <code>gh</code>.</p></div></div>
<h2 id="where-the-workflow-actually-lives">Where the workflow actually lives</h2>
<p>The scaffolded workflow is a Markdown file at <code>.github/workflows/&lt;workflow-name&gt;.md</code>, for example <code>.github/workflows/daily-repo-status.md</code>. After editing that file’s frontmatter or body, recompile it so the change actually takes effect:</p>
<div class="code-block code-block--has-filename"><div class="code-block__header"><span class="code-block__filename">recompile after editing the Markdown source</span><button type="button" class="code-block__copy" data-copy-button>Copy</button></div><pre class="astro-code github-dark" style="background-color:#24292e;color:#e1e4e8;overflow-x:auto" tabindex="0" data-language="bash" data-filename="recompile after editing the Markdown source"><code><span class="line"><span style="color:#B392F0">gh</span><span style="color:#9ECBFF"> aw</span><span style="color:#9ECBFF"> compile</span></span></code></pre></div>
<p>That command regenerates the matching <code>.lock.yml</code> file, the version Actions actually executes. Both files belong in the repo: the Markdown source, because it’s what a human edits, and the compiled <code>.lock.yml</code>, because Actions never compiles Markdown on its own at run time.</p>
<h2 id="would-this-help-this-sites-own-workflows">Would this help this site’s own workflows?</h2>
<p>Not yet, in any concrete way worth claiming. This site’s <code>.github/workflows/ci.yml</code> runs deterministic build, lint, and test steps, not the kind of reasoning-based task, issue triage, CI failure summarization, this feature targets. A plausible future use here would be automating triage on reader-submitted issues or summarizing why a Lighthouse CI budget failed, but nothing in this repo uses agentic workflows today, and this preview’s public-preview status is reason enough to wait before wiring it into a pipeline that gates a real deploy.</p>
<h2 id="confirmed-version">Confirmed version</h2>
<p>Sourced from <a href="https://github.blog/changelog/2026-06-11-github-agentic-workflows-is-now-in-public-preview/">GitHub’s changelog entry announcing the public preview</a>, published 2026-06-11, and corroborated against GitHub’s <a href="https://docs.github.com/en/copilot/how-tos/github-agentic-workflows/quickstart">Agentic Workflows quickstart</a> for the install and scaffolding commands above. Browse more posts like this in the <a href="/dev-tools">Dev Tools</a> archive.</p>]]></content:encoded>
      <pubDate>Thu, 13 Aug 2026 00:00:00 GMT</pubDate>
    </item>
    <item>
      <title>GitHub Actions Cache Goes Read-Only on Untrusted Triggers</title>
      <link>https://bytetech247.com/dev-tools/github-actions-cache-read-only-untrusted-triggers/</link>
      <guid isPermaLink="true">https://bytetech247.com/dev-tools/github-actions-cache-read-only-untrusted-triggers/</guid>
      <description>GitHub Actions now issues read-only cache tokens for untrusted triggers like pull_request_target, closing a cache-poisoning path. Here&apos;s what still writes.</description>
      <content:encoded><![CDATA[<aside class="quick-answer" aria-label="Quick answer"><h2 id="quick-answer">Quick Answer</h2>
<p>As of 2026-06-26, any default-branch cache scope only gets a read-only token when the triggering event doesn’t require write access to the repo, <code>pull_request_target</code>, <code>issue_comment</code>, and fork-originated <code>workflow_run</code> cascades among them. <code>push</code>, <code>schedule</code>, <code>workflow_dispatch</code>, and <code>repository_dispatch</code> keep read-write access. If a workflow used to save cache from one of the now-restricted triggers, split cache-saving into a separate trusted-trigger workflow.</p>
</aside><h2 id="the-attack-this-closes">The attack this closes</h2>
<p>The Actions cache was read-write for every triggering event before this change, including events anyone can cause without holding write access to the repo. <code>pull_request_target</code> is the clearest example: it runs with the base repo’s permissions, but the event itself can be caused by opening a pull request from a fork, something an outside contributor can always do.</p>
<p>That combination let an external contributor poison a default-branch cache entry, a dependency artifact, a build output, anything a later job would restore and trust. GitHub’s own changelog names the consequence directly:</p>
<blockquote>
<p>“a trusted workflow such as <code>push</code> or <code>schedule</code> would later restore” the poisoned entry, closing “a path to run arbitrary code and exfiltrate production secrets.”</p>
</blockquote>
<p>The fix, effective 2026-06-26, scopes cache-token write access to trust level of the triggering event rather than treating every event the same.</p>
<h2 id="which-events-still-get-read-write-and-which-dont">Which events still get read-write, and which don’t</h2>





































<table tabindex="0"><thead><tr><th scope="col" style="text-align:left">Trigger type</th><th scope="col" style="text-align:left">Cache token access after 2026-06-26</th></tr></thead><tbody><tr><td style="text-align:left"><code>push</code></td><td style="text-align:left">Read-write</td></tr><tr><td style="text-align:left"><code>schedule</code></td><td style="text-align:left">Read-write</td></tr><tr><td style="text-align:left"><code>workflow_dispatch</code></td><td style="text-align:left">Read-write</td></tr><tr><td style="text-align:left"><code>repository_dispatch</code></td><td style="text-align:left">Read-write</td></tr><tr><td style="text-align:left"><code>pull_request_target</code></td><td style="text-align:left">Read-only</td></tr><tr><td style="text-align:left"><code>issue_comment</code></td><td style="text-align:left">Read-only</td></tr><tr><td style="text-align:left">Fork-originated <code>workflow_run</code> cascades</td><td style="text-align:left">Read-only</td></tr></tbody></table>
<p>The line GitHub draws is straightforward:</p>
<blockquote>
<p>“…can be triggered without write permissions to the repository.”</p>
</blockquote>
<p>Anything an outside contributor can cause on their own, no maintainer action required, loses write access to the default branch’s cache scope. Anything that requires a maintainer or a scheduled job to fire keeps it.</p>
<h2 id="structural-comparison-matrix">Structural Comparison Matrix</h2>






























<table tabindex="0"><thead><tr><th scope="col" style="text-align:left">Operational Aspect</th><th scope="col" style="text-align:left">Before (2026-06-25 and earlier)</th><th scope="col" style="text-align:left">After (2026-06-26+)</th></tr></thead><tbody><tr><td style="text-align:left"><strong>Cache write access from <code>pull_request_target</code></strong></td><td style="text-align:left">Read-write, no restriction</td><td style="text-align:left">Read-only</td></tr><tr><td style="text-align:left"><strong>Cache write access from <code>push</code>/<code>schedule</code></strong></td><td style="text-align:left">Read-write</td><td style="text-align:left">Unchanged, still read-write</td></tr><tr><td style="text-align:left"><strong>Cache poisoning via an untrusted trigger</strong></td><td style="text-align:left">Possible</td><td style="text-align:left">Blocked: token can’t write</td></tr><tr><td style="text-align:left"><strong>Workflow needing to save cache from a restricted event</strong></td><td style="text-align:left">Single workflow handled save and restore</td><td style="text-align:left">Requires a separate trusted-trigger workflow</td></tr></tbody></table>
<h2 id="restructuring-a-workflow-that-saved-cache-from-an-untrusted-trigger">Restructuring a workflow that saved cache from an untrusted trigger</h2>
<p>If a <code>pull_request_target</code> (or <code>issue_comment</code>, or fork-triggered <code>workflow_run</code>) job in your repo relies on <code>actions/cache</code>’s <code>save</code> action or a post-job automatic save, it now silently stops writing rather than erroring loudly, check for stale cache hits as the symptom, not a clear failure message.</p>
<p>The fix is to split cache population from cache use across two workflows:</p>
<div class="code-block code-block--has-filename"><div class="code-block__header"><span class="code-block__filename">.github/workflows/cache-populate.yml</span><button type="button" class="code-block__copy" data-copy-button>Copy</button></div><pre class="astro-code github-dark" style="background-color:#24292e;color:#e1e4e8;overflow-x:auto" tabindex="0" data-language="yaml" data-filename=".github/workflows/cache-populate.yml"><code><span class="line"><span style="color:#9ca6b0"># Trusted trigger: read-write cache access</span></span>
<span class="line"><span style="color:#85E89D">name</span><span style="color:#E1E4E8">: </span><span style="color:#9ECBFF">Populate build cache</span></span>
<span class="line"><span style="color:#79B8FF">on</span><span style="color:#E1E4E8">:</span></span>
<span class="line"><span style="color:#85E89D">  push</span><span style="color:#E1E4E8">:</span></span>
<span class="line"><span style="color:#85E89D">    branches</span><span style="color:#E1E4E8">: [</span><span style="color:#9ECBFF">main</span><span style="color:#E1E4E8">]</span></span>
<span class="line"><span style="color:#85E89D">  schedule</span><span style="color:#E1E4E8">:</span></span>
<span class="line"><span style="color:#E1E4E8">    - </span><span style="color:#85E89D">cron</span><span style="color:#E1E4E8">: </span><span style="color:#9ECBFF">&quot;0 6 * * *&quot;</span></span>
<span class="line"></span>
<span class="line"><span style="color:#85E89D">jobs</span><span style="color:#E1E4E8">:</span></span>
<span class="line"><span style="color:#85E89D">  populate</span><span style="color:#E1E4E8">:</span></span>
<span class="line"><span style="color:#85E89D">    runs-on</span><span style="color:#E1E4E8">: </span><span style="color:#9ECBFF">ubuntu-latest</span></span>
<span class="line"><span style="color:#85E89D">    steps</span><span style="color:#E1E4E8">:</span></span>
<span class="line"><span style="color:#E1E4E8">      - </span><span style="color:#85E89D">uses</span><span style="color:#E1E4E8">: </span><span style="color:#9ECBFF">actions/checkout@v7</span></span>
<span class="line"><span style="color:#E1E4E8">      - </span><span style="color:#85E89D">uses</span><span style="color:#E1E4E8">: </span><span style="color:#9ECBFF">actions/cache@v4</span></span>
<span class="line"><span style="color:#85E89D">        with</span><span style="color:#E1E4E8">:</span></span>
<span class="line"><span style="color:#85E89D">          path</span><span style="color:#E1E4E8">: </span><span style="color:#9ECBFF">node_modules</span></span>
<span class="line"><span style="color:#85E89D">          key</span><span style="color:#E1E4E8">: </span><span style="color:#9ECBFF">deps-${{ hashFiles(&#39;package-lock.json&#39;) }}</span></span>
<span class="line"><span style="color:#E1E4E8">      - </span><span style="color:#85E89D">run</span><span style="color:#E1E4E8">: </span><span style="color:#9ECBFF">npm ci</span></span></code></pre></div>
<div class="code-block code-block--has-filename"><div class="code-block__header"><span class="code-block__filename">.github/workflows/pr-check.yml</span><button type="button" class="code-block__copy" data-copy-button>Copy</button></div><pre class="astro-code github-dark" style="background-color:#24292e;color:#e1e4e8;overflow-x:auto" tabindex="0" data-language="yaml" data-filename=".github/workflows/pr-check.yml"><code><span class="line"><span style="color:#9ca6b0"># Untrusted trigger: read-only cache access, restore only</span></span>
<span class="line"><span style="color:#85E89D">name</span><span style="color:#E1E4E8">: </span><span style="color:#9ECBFF">PR check</span></span>
<span class="line"><span style="color:#79B8FF">on</span><span style="color:#E1E4E8">:</span></span>
<span class="line"><span style="color:#85E89D">  pull_request_target</span><span style="color:#E1E4E8">:</span></span>
<span class="line"></span>
<span class="line"><span style="color:#85E89D">jobs</span><span style="color:#E1E4E8">:</span></span>
<span class="line"><span style="color:#85E89D">  check</span><span style="color:#E1E4E8">:</span></span>
<span class="line"><span style="color:#85E89D">    runs-on</span><span style="color:#E1E4E8">: </span><span style="color:#9ECBFF">ubuntu-latest</span></span>
<span class="line"><span style="color:#85E89D">    steps</span><span style="color:#E1E4E8">:</span></span>
<span class="line"><span style="color:#E1E4E8">      - </span><span style="color:#85E89D">uses</span><span style="color:#E1E4E8">: </span><span style="color:#9ECBFF">actions/checkout@v7</span></span>
<span class="line"><span style="color:#E1E4E8">      - </span><span style="color:#85E89D">uses</span><span style="color:#E1E4E8">: </span><span style="color:#9ECBFF">actions/cache/restore@v4</span></span>
<span class="line"><span style="color:#85E89D">        with</span><span style="color:#E1E4E8">:</span></span>
<span class="line"><span style="color:#85E89D">          path</span><span style="color:#E1E4E8">: </span><span style="color:#9ECBFF">node_modules</span></span>
<span class="line"><span style="color:#85E89D">          key</span><span style="color:#E1E4E8">: </span><span style="color:#9ECBFF">deps-${{ hashFiles(&#39;package-lock.json&#39;) }}</span></span>
<span class="line"><span style="color:#E1E4E8">      - </span><span style="color:#85E89D">run</span><span style="color:#E1E4E8">: </span><span style="color:#9ECBFF">npm ci</span><span style="color:#9ca6b0"> # falls back to a real install on a cache miss</span></span></code></pre></div>
<p>The <code>pull_request_target</code> workflow now only restores, using <code>actions/cache/restore</code> instead of the combined <code>actions/cache</code> action, since it never has permission to write back. The trusted <code>push</code>/<code>schedule</code> workflow owns keeping the cache current.</p>
<div class="callout" role="note" data-astro-cid-q2ml7llr><p class="callout__title" data-astro-cid-q2ml7llr><span aria-hidden="true" data-astro-cid-q2ml7llr>✓</span>A cache miss under the new model isn&#39;t a bug</p><div class="callout__body" data-astro-cid-q2ml7llr><p>If the untrusted-trigger workflow’s cache key doesn’t match anything the
trusted workflow already saved, it just runs a real install instead of
restoring from cache, the same as any normal cache miss. That’s expected
behavior under this model, not a sign something’s broken.</p></div></div>
<h2 id="source">Source</h2>
<p><a href="https://github.blog/changelog/2026-06-26-read-only-actions-cache-for-untrusted-triggers/">github.blog/changelog: Read-only Actions cache for untrusted triggers</a> (2026-06-26). Browse more posts like this in the <a href="/dev-tools">Dev Tools</a> archive.</p>]]></content:encoded>
      <pubDate>Thu, 13 Aug 2026 00:00:00 GMT</pubDate>
    </item>
    <item>
      <title>Build Custom GitHub Actions Runner Images in Layers</title>
      <link>https://bytetech247.com/dev-tools/github-actions-layered-custom-runner-images/</link>
      <guid isPermaLink="true">https://bytetech247.com/dev-tools/github-actions-layered-custom-runner-images/</guid>
      <description>GitHub Actions larger runners can now build a custom image on top of another custom image, plus conditional snapshot: generation for image versions.</description>
      <content:encoded><![CDATA[<aside class="quick-answer" aria-label="Quick answer"><h2 id="quick-answer">Quick Answer</h2>
<p>Layer a custom runner image when a shared base image (common tooling, base OS config) needs team-specific dependencies added on top, instead of every team maintaining its own flat, duplicated image. Add a conditional <code>if:</code> to the <code>snapshot</code> keyword when you only want a new image version generated on some builds, like main-branch merges, not every tag build or pull request.</p>
</aside><h2 id="what-was-flat-and-duplicated-before">What was flat and duplicated before</h2>
<p>Building a custom runner image for GitHub-hosted larger runners used to mean building it flat, independently, every single time. Picture a platform team maintaining one image with the org’s baseline toolchain, and three product teams each needing their own extra packages on top of it. Before this release, those three teams had exactly two bad options: each one duplicates the platform team’s build from scratch and maintains its own separate copy, or everyone shares a single image that grows more bloated with every team’s unrelated dependencies added to it.</p>
<p>GitHub’s changelog, published 2026-06-18:</p>
<blockquote>
<p>“Build custom images on top of other custom images”</p>
</blockquote>
<p>The docs describe how the layering itself is configured:</p>
<blockquote>
<p>“You can start from an existing custom image as the base, enabling layered image workflows.”</p>
</blockquote>
<p>That base-image selection happens in the GitHub UI, not in a workflow file, specifically in the Image dropdown when you configure the image-generation larger runner that will build the new, derived image.</p>
<p>GitHub’s docs are specific about how a derived image’s age clock works:</p>
<blockquote>
<p>“the derived image inherits the expiration timeline of its base image. The maximum version age is calculated from when the base custom image was built, not when the derived image was created.”</p>
</blockquote>
<div class="callout" role="note" data-astro-cid-q2ml7llr><p class="callout__title" data-astro-cid-q2ml7llr><span aria-hidden="true" data-astro-cid-q2ml7llr>⚠</span>A layered image doesn&#39;t get its own fresh clock</p><div class="callout__body" data-astro-cid-q2ml7llr><p>Easy to miss: a derived image’s countdown starts from its ancestor’s build
date, not its own. Walk the numbers GitHub uses as an example: a base image
built on day 2, a layered image built from it on day 4, both under a 7-day
expiration policy. They still both go stale on the same day, day 9, measured
from the base image, not day 11 from when the layered image itself was
created.</p></div></div>
<h2 id="structural-comparison-matrix">Structural Comparison Matrix</h2>






























<table tabindex="0"><thead><tr><th scope="col" style="text-align:left">Operational Aspect</th><th scope="col" style="text-align:left">Flat custom images (before)</th><th scope="col" style="text-align:left">Layered custom images (after)</th></tr></thead><tbody><tr><td style="text-align:left"><strong>Shared tooling across teams</strong></td><td style="text-align:left">Duplicated in every team’s own image build</td><td style="text-align:left">Built once in a base image, reused as a starting point</td></tr><tr><td style="text-align:left"><strong>Image version generation</strong></td><td style="text-align:left">Runs unconditionally on every build</td><td style="text-align:left">Gatable with <code>snapshot: if:</code>, e.g. skip tag builds</td></tr><tr><td style="text-align:left"><strong>Expiration timeline</strong></td><td style="text-align:left">Each flat image has its own independent clock</td><td style="text-align:left">A derived image inherits the base image’s clock</td></tr><tr><td style="text-align:left"><strong>Where the base image is chosen</strong></td><td style="text-align:left">N/A, images aren’t derived from one another</td><td style="text-align:left">The Image dropdown, when configuring the image-generation runner</td></tr></tbody></table>
<h2 id="generate-an-image-version-with-snapshot">Generate an image version with snapshot</h2>
<p>The <code>snapshot</code> keyword, added to a workflow job, is what actually triggers image generation. In its simplest form, it’s a string naming the image:</p>
<div class="code-block code-block--has-filename"><div class="code-block__header"><span class="code-block__filename">simplest form: generate an image version on every run</span><button type="button" class="code-block__copy" data-copy-button>Copy</button></div><pre class="astro-code github-dark" style="background-color:#24292e;color:#e1e4e8;overflow-x:auto" tabindex="0" data-language="yaml" data-filename="simplest form: generate an image version on every run"><code><span class="line"><span style="color:#85E89D">snapshot</span><span style="color:#E1E4E8">: </span><span style="color:#9ECBFF">my-custom-image</span></span></code></pre></div>
<p>The mapping form adds an explicit version pattern and, with <code>if:</code>, a condition that decides whether this run generates a new image version at all:</p>
<div class="code-block code-block--has-filename"><div class="code-block__header"><span class="code-block__filename">only generate a new image version off the default branch</span><button type="button" class="code-block__copy" data-copy-button>Copy</button></div><pre class="astro-code github-dark" style="background-color:#24292e;color:#e1e4e8;overflow-x:auto" tabindex="0" data-language="yaml" data-filename="only generate a new image version off the default branch"><code><span class="line"><span style="color:#85E89D">snapshot</span><span style="color:#E1E4E8">:</span></span>
<span class="line"><span style="color:#85E89D">  if</span><span style="color:#E1E4E8">: </span><span style="color:#9ECBFF">${{ ! startsWith(github.ref, &#39;refs/tags/&#39;) }}</span></span>
<span class="line"><span style="color:#85E89D">  image-name</span><span style="color:#E1E4E8">: </span><span style="color:#9ECBFF">my-custom-image</span></span>
<span class="line"><span style="color:#85E89D">  version</span><span style="color:#E1E4E8">: </span><span style="color:#9ECBFF">2.*</span></span></code></pre></div>
<p>That <code>if:</code> condition skips image creation for tag builds specifically, per GitHub’s own documentation, useful when a repo tags releases far more often than it actually needs a fresh runner image. <code>version: 2.*</code> tells GitHub to auto-increment the minor version under major version 2 (2.0.0, 2.1.0, and so on) each time a new image is generated, rather than requiring a hand-picked version string on every run.</p>
<h2 id="layer-a-team-specific-image-on-a-shared-base">Layer a team-specific image on a shared base</h2>
<ol>
<li>Build the shared base image first, on its own image-generation runner, with <code>snapshot:</code> targeting a name like <code>org-base-image</code>. This is the image every sub-team’s own image will start from.</li>
<li>Create a second image-generation larger runner for the team-specific build, and in that runner’s Settings, pick <code>org-base-image</code> in the Image dropdown as its starting point instead of a stock OS image.</li>
<li>Run a workflow job with its own <code>snapshot:</code> block on that runner, installing only the team’s extra dependencies on top of what the base image already has, and generating a new, derived image version.</li>
</ol>
<div class="code-block code-block--has-filename"><div class="code-block__header"><span class="code-block__filename">job that builds the layered, team-specific image</span><button type="button" class="code-block__copy" data-copy-button>Copy</button></div><pre class="astro-code github-dark" style="background-color:#24292e;color:#e1e4e8;overflow-x:auto" tabindex="0" data-language="yaml" data-filename="job that builds the layered, team-specific image"><code><span class="line"><span style="color:#85E89D">jobs</span><span style="color:#E1E4E8">:</span></span>
<span class="line"><span style="color:#85E89D">  build-team-image</span><span style="color:#E1E4E8">:</span></span>
<span class="line"><span style="color:#85E89D">    runs-on</span><span style="color:#E1E4E8">:</span></span>
<span class="line"><span style="color:#85E89D">      group</span><span style="color:#E1E4E8">: </span><span style="color:#9ECBFF">image-generation-runners</span></span>
<span class="line"><span style="color:#85E89D">      labels</span><span style="color:#E1E4E8">: </span><span style="color:#9ECBFF">team-checkout-image-gen</span></span>
<span class="line"><span style="color:#85E89D">    snapshot</span><span style="color:#E1E4E8">:</span></span>
<span class="line"><span style="color:#85E89D">      if</span><span style="color:#E1E4E8">: </span><span style="color:#9ECBFF">${{ ! startsWith(github.ref, &#39;refs/tags/&#39;) }}</span></span>
<span class="line"><span style="color:#85E89D">      image-name</span><span style="color:#E1E4E8">: </span><span style="color:#9ECBFF">team-checkout-image</span></span>
<span class="line"><span style="color:#85E89D">      version</span><span style="color:#E1E4E8">: </span><span style="color:#9ECBFF">2.*</span></span>
<span class="line"><span style="color:#85E89D">    steps</span><span style="color:#E1E4E8">:</span></span>
<span class="line"><span style="color:#E1E4E8">      - </span><span style="color:#85E89D">name</span><span style="color:#E1E4E8">: </span><span style="color:#9ECBFF">Install team-specific dependencies</span></span>
<span class="line"><span style="color:#85E89D">        run</span><span style="color:#E1E4E8">: </span><span style="color:#9ECBFF">./scripts/install-team-deps.sh</span></span></code></pre></div>
<p>The base-image selection itself, step 2 above, isn’t a YAML setting; it’s configured once when the image-generation runner is set up, which is why this walkthrough separates it from the <code>snapshot:</code> block that runs on every build.</p>
<h2 id="pair-this-with-restricting-who-can-use-these-runners">Pair this with restricting who can use these runners</h2>
<p>Layered images control what’s already installed on a runner before a job even starts. A separate June 2026 release, <a href="/dev-tools/restrict-github-hosted-runners-named-runner-groups">restricting GitHub-hosted runners to named runner groups</a>, controls who can request a runner at all. The two changes solve different problems, but they show up on the same roadmap often: a team that’s already built a shared base image with per-team layers is a strong candidate for also routing those runners through named groups, so a team’s custom image is only reachable by the repos that actually need it.</p>
<h2 id="confirmed-version">Confirmed version</h2>
<p>Sourced from <a href="https://github.blog/changelog/2026-06-18-actions-build-custom-images-from-custom-images/">GitHub’s changelog entry on layered custom runner images</a>, published 2026-06-18, and corroborated against GitHub’s <a href="https://docs.github.com/en/actions/how-tos/manage-runners/larger-runners/use-custom-images">custom images how-to guide</a>, which documents the base-image dropdown, the inherited expiration timeline, and the exact <code>snapshot:</code> syntax used above. Browse more posts like this in the <a href="/dev-tools">Dev Tools</a> archive.</p>]]></content:encoded>
      <pubDate>Thu, 13 Aug 2026 00:00:00 GMT</pubDate>
    </item>
    <item>
      <title>Run GitHub Actions Steps in Parallel With background</title>
      <link>https://bytetech247.com/dev-tools/github-actions-parallel-steps-background-keyword/</link>
      <guid isPermaLink="true">https://bytetech247.com/dev-tools/github-actions-parallel-steps-background-keyword/</guid>
      <description>GitHub Actions steps can now run in parallel with background: true, wait, wait-all, cancel, and parallel. Here is when to use each keyword.</description>
      <content:encoded><![CDATA[<aside class="quick-answer" aria-label="Quick answer"><h2 id="quick-answer">Quick Answer</h2>
<p>Use <code>background: true</code> plus <code>wait</code>, <code>wait-all</code>, or <code>cancel</code> when you need fine control, starting a service early and doing other work before waiting on it. Use the <code>parallel</code> keyword when you just want a group of independent steps to run together and have the job wait for all of them automatically. Both shipped 2026-06-25 and only apply within a single job.</p>
</aside><h2 id="what-used-to-force-artificial-serialization">What used to force artificial serialization</h2>
<p>Every step in a GitHub Actions job has always run strictly in order, one finishing before the next starts. That’s fine when steps genuinely depend on each other, but it forces unnecessary waiting when they don’t: three independent build tasks that don’t share state, or a background service (a test database, a mock API) that a later step needs running but doesn’t need to wait on immediately.</p>
<p>GitHub’s changelog, published 2026-06-25, adds four new step-level keywords to fix that:</p>
<blockquote>
<p>“<code>background: true</code>… runs a step asynchronously and immediately continues to the next step.”</p>
</blockquote>
<blockquote>
<p>“<code>wait</code>/<code>wait-all</code>… pauses execution until one or more named background steps complete. <code>wait</code> can target one or more specific background steps, while <code>wait-all</code> pauses until all prior background steps have completed.”</p>
</blockquote>
<blockquote>
<p>“<code>cancel</code>… gracefully terminates a background step when you no longer need it, enabling you to start long-running services with a background step.”</p>
</blockquote>
<blockquote>
<p>“<code>parallel</code>… takes a group of steps and converts them to background steps with a wait after, enabling you to easily run multiple steps in parallel.”</p>
</blockquote>
<p>None of these change how a job is triggered or how jobs relate to each other. They’re new keys inside an existing <code>steps:</code> block, not a new job or workflow structure.</p>
<h2 id="structural-comparison-matrix">Structural Comparison Matrix</h2>



































<table tabindex="0"><thead><tr><th scope="col" style="text-align:left">Operational Aspect</th><th scope="col" style="text-align:left">Plain sequential steps (default)</th><th scope="col" style="text-align:left"><code>background</code> + <code>wait</code>/<code>wait-all</code>/<code>cancel</code></th><th scope="col" style="text-align:left"><code>parallel</code></th></tr></thead><tbody><tr><td style="text-align:left"><strong>Execution order</strong></td><td style="text-align:left">Strictly one after another</td><td style="text-align:left">A step starts, the job moves on immediately</td><td style="text-align:left">A whole step group starts together</td></tr><tr><td style="text-align:left"><strong>Control over waiting</strong></td><td style="text-align:left">N/A, waiting is implicit and total</td><td style="text-align:left">Explicit: choose exactly which background step(s) to wait for, or none at all</td><td style="text-align:left">Implicit: the group is waited on automatically after it</td></tr><tr><td style="text-align:left"><strong>Best fit</strong></td><td style="text-align:left">Steps with a real order dependency</td><td style="text-align:left">A background service another step needs, started early and stopped or waited on later</td><td style="text-align:left">Several independent steps with no order dependency between them</td></tr><tr><td style="text-align:left"><strong>Syntax overhead</strong></td><td style="text-align:left">None</td><td style="text-align:left">An <code>id</code> on the background step, plus a separate <code>wait</code>/<code>cancel</code> step</td><td style="text-align:left">Lowest, one <code>parallel:</code> block</td></tr></tbody></table>
<h2 id="add-background-steps-to-a-job">Add background steps to a job</h2>
<p>A common case: a test suite needs a real database running, but starting it doesn’t need to block installing dependencies. Give the background step an <code>id</code>, mark it <code>background: true</code>, and reference that <code>id</code> later:</p>
<div class="code-block code-block--has-filename"><div class="code-block__header"><span class="code-block__filename">.github/workflows/test.yml</span><button type="button" class="code-block__copy" data-copy-button>Copy</button></div><pre class="astro-code github-dark" style="background-color:#24292e;color:#e1e4e8;overflow-x:auto" tabindex="0" data-language="yaml" data-filename=".github/workflows/test.yml"><code><span class="line"><span style="color:#85E89D">jobs</span><span style="color:#E1E4E8">:</span></span>
<span class="line"><span style="color:#85E89D">  integration-tests</span><span style="color:#E1E4E8">:</span></span>
<span class="line"><span style="color:#85E89D">    runs-on</span><span style="color:#E1E4E8">: </span><span style="color:#9ECBFF">ubuntu-latest</span></span>
<span class="line"><span style="color:#85E89D">    steps</span><span style="color:#E1E4E8">:</span></span>
<span class="line"><span style="color:#E1E4E8">      - </span><span style="color:#85E89D">uses</span><span style="color:#E1E4E8">: </span><span style="color:#9ECBFF">actions/checkout@v7</span></span>
<span class="line"></span>
<span class="line"><span style="color:#E1E4E8">      - </span><span style="color:#85E89D">name</span><span style="color:#E1E4E8">: </span><span style="color:#9ECBFF">Start test database</span></span>
<span class="line"><span style="color:#85E89D">        id</span><span style="color:#E1E4E8">: </span><span style="color:#9ECBFF">test-db</span></span>
<span class="line"><span style="color:#85E89D">        background</span><span style="color:#E1E4E8">: </span><span style="color:#79B8FF">true</span></span>
<span class="line"><span style="color:#85E89D">        run</span><span style="color:#E1E4E8">: </span><span style="color:#9ECBFF">docker run -d --name test-db -p 5432:5432 postgres:16</span></span>
<span class="line"></span>
<span class="line"><span style="color:#E1E4E8">      - </span><span style="color:#85E89D">name</span><span style="color:#E1E4E8">: </span><span style="color:#9ECBFF">Install dependencies</span></span>
<span class="line"><span style="color:#85E89D">        run</span><span style="color:#E1E4E8">: </span><span style="color:#9ECBFF">npm ci</span></span>
<span class="line"></span>
<span class="line"><span style="color:#E1E4E8">      - </span><span style="color:#85E89D">name</span><span style="color:#E1E4E8">: </span><span style="color:#9ECBFF">Wait for test database</span></span>
<span class="line"><span style="color:#85E89D">        wait</span><span style="color:#E1E4E8">: </span><span style="color:#9ECBFF">test-db</span></span>
<span class="line"></span>
<span class="line"><span style="color:#E1E4E8">      - </span><span style="color:#85E89D">name</span><span style="color:#E1E4E8">: </span><span style="color:#9ECBFF">Run integration tests</span></span>
<span class="line"><span style="color:#85E89D">        run</span><span style="color:#E1E4E8">: </span><span style="color:#9ECBFF">npm run test:integration</span></span>
<span class="line"></span>
<span class="line"><span style="color:#E1E4E8">      - </span><span style="color:#85E89D">name</span><span style="color:#E1E4E8">: </span><span style="color:#9ECBFF">Stop test database</span></span>
<span class="line"><span style="color:#85E89D">        cancel</span><span style="color:#E1E4E8">: </span><span style="color:#9ECBFF">test-db</span></span></code></pre></div>
<p><code>npm ci</code> starts running the moment <code>docker run</code> is dispatched, not after it finishes, since the database step already handed control back. The <code>wait: test-db</code> step is where the job actually pauses until the container is up, right before the tests that need it.</p>
<h2 id="group-independent-steps-with-parallel">Group independent steps with parallel</h2>
<p>For steps that have no order dependency on each other at all, wrapping them in <code>parallel</code> avoids hand-managing <code>id</code>s and <code>wait</code> steps:</p>
<div class="code-block code-block--has-filename"><div class="code-block__header"><span class="code-block__filename">.github/workflows/build.yml</span><button type="button" class="code-block__copy" data-copy-button>Copy</button></div><pre class="astro-code github-dark" style="background-color:#24292e;color:#e1e4e8;overflow-x:auto" tabindex="0" data-language="yaml" data-filename=".github/workflows/build.yml"><code><span class="line"><span style="color:#85E89D">jobs</span><span style="color:#E1E4E8">:</span></span>
<span class="line"><span style="color:#85E89D">  build</span><span style="color:#E1E4E8">:</span></span>
<span class="line"><span style="color:#85E89D">    runs-on</span><span style="color:#E1E4E8">: </span><span style="color:#9ECBFF">ubuntu-latest</span></span>
<span class="line"><span style="color:#85E89D">    steps</span><span style="color:#E1E4E8">:</span></span>
<span class="line"><span style="color:#E1E4E8">      - </span><span style="color:#85E89D">uses</span><span style="color:#E1E4E8">: </span><span style="color:#9ECBFF">actions/checkout@v7</span></span>
<span class="line"></span>
<span class="line"><span style="color:#E1E4E8">      - </span><span style="color:#85E89D">parallel</span><span style="color:#E1E4E8">:</span></span>
<span class="line"><span style="color:#E1E4E8">          - </span><span style="color:#85E89D">name</span><span style="color:#E1E4E8">: </span><span style="color:#9ECBFF">Build frontend bundle</span></span>
<span class="line"><span style="color:#85E89D">            run</span><span style="color:#E1E4E8">: </span><span style="color:#9ECBFF">npm run build:frontend</span></span>
<span class="line"><span style="color:#E1E4E8">          - </span><span style="color:#85E89D">name</span><span style="color:#E1E4E8">: </span><span style="color:#9ECBFF">Build backend bundle</span></span>
<span class="line"><span style="color:#85E89D">            run</span><span style="color:#E1E4E8">: </span><span style="color:#9ECBFF">npm run build:backend</span></span>
<span class="line"><span style="color:#E1E4E8">          - </span><span style="color:#85E89D">name</span><span style="color:#E1E4E8">: </span><span style="color:#9ECBFF">Build docs site</span></span>
<span class="line"><span style="color:#85E89D">            run</span><span style="color:#E1E4E8">: </span><span style="color:#9ECBFF">npm run build:docs</span></span>
<span class="line"></span>
<span class="line"><span style="color:#E1E4E8">      - </span><span style="color:#85E89D">name</span><span style="color:#E1E4E8">: </span><span style="color:#9ECBFF">Package release</span></span>
<span class="line"><span style="color:#85E89D">        run</span><span style="color:#E1E4E8">: </span><span style="color:#9ECBFF">npm run package</span></span></code></pre></div>
<div class="callout" role="note" data-astro-cid-q2ml7llr><p class="callout__title" data-astro-cid-q2ml7llr><span aria-hidden="true" data-astro-cid-q2ml7llr>ℹ</span>Exact syntax, verified against the changelog&#39;s own description</p><div class="callout__body" data-astro-cid-q2ml7llr><p>GitHub’s changelog describes the mechanics of <code>background</code>, <code>wait</code>,
<code>wait-all</code>, <code>cancel</code>, and <code>parallel</code> in the exact terms quoted above, but
doesn’t publish a full example workflow in the entry itself. The YAML shapes
here match that described behavior, id-based background steps referenced by a
later <code>wait</code> or <code>cancel</code>, and <code>parallel</code> grouping a set of steps with an
implicit wait after, but weren’t run against a live workflow here. Confirm the
exact indentation against GitHub’s <a href="https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions">workflow syntax
reference</a>
before shipping it to a production pipeline.</p></div></div>
<h2 id="would-this-help-this-sites-own-ci">Would this help this site’s own CI?</h2>
<p>This site’s <code>.github/workflows/ci.yml</code> runs its <code>build</code> job’s steps in strict sequence: install, lint, format check, Astro check, unit tests, build, then Playwright install and end-to-end tests. Lint, the format check, and the Astro check don’t depend on each other, only on <code>npm ci</code> finishing first, which makes them a real candidate for a <code>parallel</code> block once this repo adopts a runner fleet new enough to support it. The unit tests and the build step still need to stay sequential, since the end-to-end tests run against the built output.</p>
<h2 id="confirmed-version">Confirmed version</h2>
<p>Sourced from <a href="https://github.blog/changelog/2026-06-25-actions-steps-can-now-be-run-in-parallel/">GitHub’s changelog entry announcing the new step keywords</a>, published 2026-06-25. Browse more posts like this in the <a href="/dev-tools">Dev Tools</a> archive.</p>]]></content:encoded>
      <pubDate>Thu, 13 Aug 2026 00:00:00 GMT</pubDate>
    </item>
    <item>
      <title>Reference Same-Repo Actions With $/ Syntax</title>
      <link>https://bytetech247.com/dev-tools/github-actions-self-repository-dollar-slash-syntax/</link>
      <guid isPermaLink="true">https://bytetech247.com/dev-tools/github-actions-self-repository-dollar-slash-syntax/</guid>
      <description>GitHub Actions&apos; new $/ syntax resolves a same-repo action to the exact running commit, no checkout step needed. Requires runner 2.336.0 or newer.</description>
      <content:encoded><![CDATA[<aside class="quick-answer" aria-label="Quick answer"><h2 id="quick-answer">Quick Answer</h2>
<p>Use <code>$/</code> when a workflow step, composite action, or reusable workflow call references an action defined in the same repository. It resolves to your workflow’s own repository at the exact commit currently running, so you skip the checkout step <code>./</code> needs and avoid the version drift a hardcoded ref causes. Requires GitHub Actions runner 2.336.0 or newer.</p>
</aside><h2 id="what--actually-resolves-to">What $/ actually resolves to</h2>
<p>Calling an action that lives in the same repo as the workflow used to force a choice between two imperfect options. A workspace-relative path like <code>./.github/actions/build-tool</code> worked, but only after an explicit <code>actions/checkout</code> step ran first, since <code>./</code> resolves against files already sitting in the runner’s workspace. Or you hardcoded a version or tag on the reference, which then had to be bumped by hand every time the calling workflow’s own ref moved, and quietly drifted out of sync the moment someone forgot.</p>
<p>GitHub shipped a third option on 2026-07-30. From the changelog:</p>
<blockquote>
<p>“A <code>uses:</code> value that starts with <code>$/</code> resolves to your workflow’s own repository at the exact commit that is running, with no checkout required.”</p>
</blockquote>
<p>The same entry explains why this matters for teams enforcing commit-SHA pinning on every action reference:</p>
<blockquote>
<p>“Sibling actions and workflows automatically match the ref you are already running, so your internal references stay consistent even when callers pin to a full-length commit SHA.”</p>
</blockquote>
<p>That second sentence is the real payoff. A shop that enforces SHA-only pinning on every action reference has historically found its own internal actions awkward to comply with, since someone had to hand-update that SHA every time the internal action changed. <code>$/</code> sidesteps the whole problem because the resolution tracks the running commit automatically, with no separate SHA to keep current.</p>
<div class="callout" role="note" data-astro-cid-q2ml7llr><p class="callout__title" data-astro-cid-q2ml7llr><span aria-hidden="true" data-astro-cid-q2ml7llr>ℹ</span>Where $/ works</p><div class="callout__body" data-astro-cid-q2ml7llr><p>GitHub’s own docs confirm <code>$/</code> is a drop-in swap for <code>./</code> anywhere the latter
already worked: a plain step’s <code>uses:</code> line, a step inside a composite action,
an action nested inside another action, and the <code>uses:</code> line on a reusable
workflow call. If <code>./</code> used to resolve there, <code>$/</code> does too, minus the
checkout requirement.</p></div></div>
<h2 id="structural-comparison-matrix">Structural Comparison Matrix</h2>





























<table tabindex="0"><thead><tr><th scope="col" style="text-align:left">Reference Method</th><th scope="col" style="text-align:left">Requires a checkout step first</th><th scope="col" style="text-align:left">Stays in sync with the caller’s ref</th><th scope="col" style="text-align:left">Minimum runner version</th></tr></thead><tbody><tr><td style="text-align:left"><strong><code>./</code> relative path</strong></td><td style="text-align:left">Yes</td><td style="text-align:left">Yes, but only because it reads the same checked-out files</td><td style="text-align:left">None beyond checkout support</td></tr><tr><td style="text-align:left"><strong>Hardcoded version or SHA</strong></td><td style="text-align:left">No</td><td style="text-align:left">No, drifts until manually bumped</td><td style="text-align:left">None additional</td></tr><tr><td style="text-align:left"><strong><code>$/</code> self-repository syntax</strong></td><td style="text-align:left">No</td><td style="text-align:left">Yes, automatically, by resolving the running commit</td><td style="text-align:left">2.336.0+</td></tr></tbody></table>
<h2 id="add-it-to-a-workflow">Add it to a workflow</h2>
<p>Say a repo defines a composite action at <code>.github/actions/setup-project/action.yml</code> that other workflows in the same repo call to install dependencies and warm a cache. Before <code>$/</code>, calling it from another workflow meant checking out the repo first, then referencing the path:</p>
<div class="code-block code-block--has-filename"><div class="code-block__header"><span class="code-block__filename">.github/workflows/build.yml — before</span><button type="button" class="code-block__copy" data-copy-button>Copy</button></div><pre class="astro-code github-dark" style="background-color:#24292e;color:#e1e4e8;overflow-x:auto" tabindex="0" data-language="yaml" data-filename=".github/workflows/build.yml — before"><code><span class="line"><span style="color:#85E89D">jobs</span><span style="color:#E1E4E8">:</span></span>
<span class="line"><span style="color:#85E89D">  build</span><span style="color:#E1E4E8">:</span></span>
<span class="line"><span style="color:#85E89D">    runs-on</span><span style="color:#E1E4E8">: </span><span style="color:#9ECBFF">ubuntu-latest</span></span>
<span class="line"><span style="color:#85E89D">    steps</span><span style="color:#E1E4E8">:</span></span>
<span class="line"><span style="color:#E1E4E8">      - </span><span style="color:#85E89D">uses</span><span style="color:#E1E4E8">: </span><span style="color:#9ECBFF">actions/checkout@v7</span></span>
<span class="line"><span style="color:#E1E4E8">      - </span><span style="color:#85E89D">uses</span><span style="color:#E1E4E8">: </span><span style="color:#9ECBFF">./.github/actions/setup-project</span></span>
<span class="line"><span style="color:#E1E4E8">      - </span><span style="color:#85E89D">run</span><span style="color:#E1E4E8">: </span><span style="color:#9ECBFF">npm run build</span></span></code></pre></div>
<p>With <code>$/</code>, the checkout step is no longer required just to resolve the action reference:</p>
<div class="code-block code-block--has-filename"><div class="code-block__header"><span class="code-block__filename">.github/workflows/build.yml — after</span><button type="button" class="code-block__copy" data-copy-button>Copy</button></div><pre class="astro-code github-dark" style="background-color:#24292e;color:#e1e4e8;overflow-x:auto" tabindex="0" data-language="yaml" data-filename=".github/workflows/build.yml — after"><code><span class="line"><span style="color:#85E89D">jobs</span><span style="color:#E1E4E8">:</span></span>
<span class="line"><span style="color:#85E89D">  build</span><span style="color:#E1E4E8">:</span></span>
<span class="line"><span style="color:#85E89D">    runs-on</span><span style="color:#E1E4E8">: </span><span style="color:#9ECBFF">ubuntu-latest</span></span>
<span class="line"><span style="color:#85E89D">    steps</span><span style="color:#E1E4E8">:</span></span>
<span class="line"><span style="color:#E1E4E8">      - </span><span style="color:#85E89D">uses</span><span style="color:#E1E4E8">: </span><span style="color:#9ECBFF">$/.github/actions/setup-project</span></span>
<span class="line"><span style="color:#E1E4E8">      - </span><span style="color:#85E89D">run</span><span style="color:#E1E4E8">: </span><span style="color:#9ECBFF">npm run build</span></span></code></pre></div>
<p>If the job also needs the repo’s files for the build itself, <code>actions/checkout</code> still belongs in the workflow, but it’s no longer there solely to make the internal action reference resolvable. The same substitution applies to a reusable workflow call:</p>
<div class="code-block code-block--has-filename"><div class="code-block__header"><span class="code-block__filename">calling a reusable workflow defined in the same repo</span><button type="button" class="code-block__copy" data-copy-button>Copy</button></div><pre class="astro-code github-dark" style="background-color:#24292e;color:#e1e4e8;overflow-x:auto" tabindex="0" data-language="yaml" data-filename="calling a reusable workflow defined in the same repo"><code><span class="line"><span style="color:#85E89D">jobs</span><span style="color:#E1E4E8">:</span></span>
<span class="line"><span style="color:#85E89D">  call-shared-build</span><span style="color:#E1E4E8">:</span></span>
<span class="line"><span style="color:#85E89D">    uses</span><span style="color:#E1E4E8">: </span><span style="color:#9ECBFF">$/.github/workflows/shared-build.yml</span></span>
<span class="line"><span style="color:#85E89D">    with</span><span style="color:#E1E4E8">:</span></span>
<span class="line"><span style="color:#85E89D">      target</span><span style="color:#E1E4E8">: </span><span style="color:#9ECBFF">production</span></span></code></pre></div>
<div class="callout" role="note" data-astro-cid-q2ml7llr><p class="callout__title" data-astro-cid-q2ml7llr><span aria-hidden="true" data-astro-cid-q2ml7llr>⚠</span>Don&#39;t confuse this with the self-hosted runner version floor</p><div class="callout__body" data-astro-cid-q2ml7llr><p>GitHub also rolled out a <a href="/dev-tools/github-actions-upgrade-self-hosted-runners-july-31/">separate minimum-version enforcement schedule for
self-hosted
runners</a>
around the same summer, gated at 2.329.0 for registering and picking up jobs
at all. That’s a different requirement from the 2.336.0 floor here, which only
governs whether a runner understands the <code>$/</code> prefix. A self-hosted fleet
needs to clear both numbers, not just one, if it wants both the registration
floor and this syntax to work.</p></div></div>
<h2 id="this-repos-own-workflows">This repo’s own workflows</h2>
<p>This site’s own <code>.github/workflows/ci.yml</code> doesn’t define any custom composite actions or reusable workflows today, only calls to third-party actions like <code>actions/checkout</code> and <code>actions/setup-node</code>, so there’s nothing here to convert to <code>$/</code> yet. The moment this repo’s CI grows a shared composite action, an internal cache-warming step or a build-and-test action reused across the <code>build</code> and <code>deploy</code> jobs, <code>$/</code> is the version to reach for over a <code>./</code>-plus-checkout pattern.</p>
<h2 id="confirmed-version">Confirmed version</h2>
<p>Sourced from <a href="https://github.blog/changelog/2026-07-30-reference-same-repository-actions-with-self-repository-syntax/">GitHub’s own changelog entry on the $/ syntax</a>, published 2026-07-30, and corroborated against GitHub’s <a href="https://docs.github.com/en/actions/reference/workflows-and-actions/metadata-syntax">metadata syntax reference</a>, which documents the same <code>./</code>-equivalent behavior <code>$/</code> extends. Browse more posts like this in the <a href="/dev-tools">Dev Tools</a> archive.</p>]]></content:encoded>
      <pubDate>Thu, 13 Aug 2026 00:00:00 GMT</pubDate>
    </item>
    <item>
      <title>GitHub Actions&apos; Summer 2026 Overhaul: Full Guide</title>
      <link>https://bytetech247.com/dev-tools/github-actions-summer-2026-security-config-overhaul/</link>
      <guid isPermaLink="true">https://bytetech247.com/dev-tools/github-actions-summer-2026-security-config-overhaul/</guid>
      <description>GitHub shipped 10 Actions changes across June-July 2026: one breaking checkout default, a runner version cutoff, new security controls, and new syntax.</description>
      <content:encoded><![CDATA[<aside class="quick-answer" aria-label="Quick answer"><h2 id="quick-answer">Quick Answer</h2>
<p>Between 2026-06-11 and 2026-07-30, GitHub shipped 10 distinct changes to Actions: one real breaking default (<code>actions/checkout</code> v7 blocking fork PR checkouts under <code>pull_request_target</code>), a self-hosted runner version cutoff with an active brownout schedule, three new security controls (workflow execution allow-lists, bot-PR approval gating, read-only cache tokens on untrusted triggers), and five new capabilities (same-repo <code>$/</code> action syntax, parallel step execution, restricted runner groups, layered custom runner images, and GitHub Agentic Workflows). If you run GitHub Actions at all, check the table below against your own <code>.github/workflows/*.yml</code> files before assuming nothing here applies to you.</p>
<p>GitHub Actions is the CI/CD platform behind this site’s own deploy pipeline (<code>.github/workflows/ci.yml</code>: lint, test, build, then deploy to Cloudflare Workers on every push to <code>main</code>). That gives real first-person standing to discuss at least the checkout-default and workflow-trigger changes directly — though, as every linked post below says plainly, several of these changes target infrastructure (self-hosted runners, Team/Enterprise runner groups) this site doesn’t actually run, and the honest answer there is “doesn’t apply here,” not a forced dogfooding story.</p>
</aside><h2 id="structural-comparison-matrix">Structural Comparison Matrix</h2>







































































<table tabindex="0"><thead><tr><th scope="col" style="text-align:left">#</th><th scope="col" style="text-align:left">Change</th><th scope="col" style="text-align:left">Type</th><th scope="col" style="text-align:left">Applies to you if…</th></tr></thead><tbody><tr><td style="text-align:left">1</td><td style="text-align:left"><code>actions/checkout</code> v7 blocks fork PR checkouts</td><td style="text-align:left">Breaking (security)</td><td style="text-align:left">You use <code>pull_request_target</code> or PR-flavored <code>workflow_run</code> with fork checkouts</td></tr><tr><td style="text-align:left">2</td><td style="text-align:left">Self-hosted runner minimum version enforced</td><td style="text-align:left">Deprecation cutoff</td><td style="text-align:left">You run self-hosted Actions runners</td></tr><tr><td style="text-align:left">3</td><td style="text-align:left">Workflow execution protections (allow-lists)</td><td style="text-align:left">New security control</td><td style="text-align:left">You want to restrict who/what can trigger workflows</td></tr><tr><td style="text-align:left">4</td><td style="text-align:left">Bot-created PRs need approval to run workflows</td><td style="text-align:left">Behavior default</td><td style="text-align:left">A scheduled workflow in your repo opens PRs as <code>github-actions[bot]</code></td></tr><tr><td style="text-align:left">5</td><td style="text-align:left">Actions cache goes read-only on untrusted triggers</td><td style="text-align:left">Security tightening</td><td style="text-align:left">You populate a shared cache from a <code>pull_request_target</code>-style trigger</td></tr><tr><td style="text-align:left">6</td><td style="text-align:left"><code>$/</code> same-repository action syntax</td><td style="text-align:left">New syntax</td><td style="text-align:left">You reference a composite action or reusable workflow in the same repo</td></tr><tr><td style="text-align:left">7</td><td style="text-align:left">Parallel steps via <code>background</code>/<code>wait</code>/<code>parallel</code></td><td style="text-align:left">New capability</td><td style="text-align:left">Your workflow has independent steps forced into artificial sequence</td></tr><tr><td style="text-align:left">8</td><td style="text-align:left">Restrict GitHub-hosted runners to named groups</td><td style="text-align:left">New control (Team/Enterprise)</td><td style="text-align:left">You’re on a Team/Enterprise plan and want to gate standard runner labels</td></tr><tr><td style="text-align:left">9</td><td style="text-align:left">Layered custom runner images</td><td style="text-align:left">New capability</td><td style="text-align:left">You maintain custom runner images and duplicate a shared base image today</td></tr><tr><td style="text-align:left">10</td><td style="text-align:left">GitHub Agentic Workflows (public preview)</td><td style="text-align:left">New capability</td><td style="text-align:left">You want AI agents doing reasoning tasks (triage, CI analysis) inside CI</td></tr></tbody></table>
<p>Every row is confirmed against GitHub’s own changelog entry for that change, not summarized from memory. Full mechanism, exact quoted text, and the fix live in each linked post below.</p>
<h2 id="the-10-changes-in-detail">The 10 changes, in detail</h2>
<ol>
<li><strong><a href="/dev-tools/actions-checkout-v7-blocks-pull-request-target-prs/">actions/checkout v7 Blocks pull_request_target PRs</a></strong>: closes the classic “pwn request” hole where a workflow with real secrets checks out an attacker-controlled fork commit.</li>
<li><strong><a href="/dev-tools/github-actions-upgrade-self-hosted-runners-july-31/">GitHub Actions: Upgrade Self-Hosted Runners by Jul 31</a></strong>: a minimum runner version (2.329.0+) and a rolling 30-day update window, enforced through escalating brownout windows.</li>
<li><strong><a href="/dev-tools/github-actions-workflow-execution-protections/">Set Up GitHub Actions Workflow Execution Protections</a></strong>: a rulesets-based allow-list controlling who can trigger workflows and which events are permitted at all.</li>
<li><strong><a href="/dev-tools/approve-workflow-runs-github-actions-bot-prs/">Approve Workflow Runs From github-actions[bot] PRs</a></strong>: bot-authored PRs can now trigger CI, but only after a collaborator explicitly approves the run.</li>
<li><strong><a href="/dev-tools/github-actions-cache-read-only-untrusted-triggers/">GitHub Actions Cache Goes Read-Only on Untrusted Triggers</a></strong>: closes a cache-poisoning path where an external contributor could write to a default-branch cache scope.</li>
<li><strong><a href="/dev-tools/github-actions-self-repository-dollar-slash-syntax/">Reference Same-Repo Actions With $/ Syntax</a></strong>: resolves a same-repo action to the exact running commit, no checkout step required.</li>
<li><strong><a href="/dev-tools/github-actions-parallel-steps-background-keyword/">Run GitHub Actions Steps in Parallel With background</a></strong>: <code>background: true</code>, <code>wait</code>, <code>wait-all</code>, <code>cancel</code>, and <code>parallel</code> bring real concurrency inside a single job’s step list.</li>
<li><strong><a href="/dev-tools/restrict-github-hosted-runners-named-runner-groups/">Restrict GitHub-Hosted Runners to Named Runner Groups</a></strong>: Team/Enterprise admins can disable standard labels like <code>ubuntu-latest</code> org-wide and force named runner groups instead.</li>
<li><strong><a href="/dev-tools/github-actions-layered-custom-runner-images/">Build Custom GitHub Actions Runner Images in Layers</a></strong>: compose custom runner images on top of other custom images instead of duplicating a shared base.</li>
<li><strong><a href="/dev-tools/github-actions-agentic-workflows-setup/">Set Up GitHub Agentic Workflows in Actions</a></strong>: compiles natural-language Markdown into standard Actions YAML, sandboxed behind an Agent Workflow Firewall with read-only defaults.</li>
</ol>
<h2 id="why-this-happened-in-one-summer">Why this happened in one summer</h2>
<p>GitHub ships Actions changes on a rolling basis, but June and July 2026 concentrated an unusual amount of security-hardening into a seven-week window: a real breaking default aimed squarely at the “pwn request” pattern, two more security controls closing adjacent gaps (cache poisoning, unapproved bot-triggered runs), and a version-enforcement push for self-hosted infrastructure — alongside a separate cluster of new capability (parallel steps, same-repo action syntax, agentic workflows) that reads more like a normal feature cadence. None of the dates or quoted text above are estimated; every one traces to GitHub’s own <a href="https://github.blog/changelog/label/actions/">changelog</a>, cross-checked per cluster against the individual changelog post and, where one exists, GitHub’s own docs page for that feature.</p>
<p>Browse the rest of the <a href="/dev-tools">Dev Tools</a> archive for more CI/CD and Cloudflare Workers coverage.</p>]]></content:encoded>
      <pubDate>Thu, 13 Aug 2026 00:00:00 GMT</pubDate>
    </item>
    <item>
      <title>GitHub Actions: Upgrade Self-Hosted Runners by Jul 31</title>
      <link>https://bytetech247.com/dev-tools/github-actions-upgrade-self-hosted-runners-july-31/</link>
      <guid isPermaLink="true">https://bytetech247.com/dev-tools/github-actions-upgrade-self-hosted-runners-july-31/</guid>
      <description>GitHub now enforces a minimum self-hosted runner version (2.329.0+) with brownout windows before full enforcement. Check your version before jobs stop running.</description>
      <content:encoded><![CDATA[<aside class="quick-answer" aria-label="Quick answer"><h2 id="quick-answer">Quick Answer</h2>
<p>GitHub now enforces a minimum version for self-hosted Actions runners, and every runner has to pick up each new release inside a rolling 30-day window to stay compliant. Full enforcement starts 2026-07-31 for GitHub Enterprise Cloud with Data Residency and 2026-09-25 for standard GitHub Enterprise Cloud, preceded by four weeks of escalating brownout windows. Below the minimum, your runner won’t register or run jobs. Check your version now.</p>
</aside><h2 id="whats-actually-enforced-and-when">What’s actually enforced, and when</h2>
<p>GitHub’s changelog states the version floor directly:</p>
<blockquote>
<p>“The runner must be on version 2.329.0 or later.”</p>
</blockquote>
<p>A second, separate requirement rides alongside it:</p>
<blockquote>
<p>“The runner must stay up to date by installing each new runner release within 30 days of its publication.”</p>
</blockquote>
<p>That second rule is a rolling window, not a one-time bump. A runner that’s on 2.329.0 today but skips the next few releases can drift back out of compliance a few months from now.</p>
<p>Runners with auto-update enabled satisfy the 30-day rule without anyone touching them, since GitHub’s own guidance confirms auto-update installs each release well inside that window. Manually managed runners are the ones actually at risk here.</p>
<p>Enforcement isn’t a single flip-the-switch date. It rolls out as four weeks of brownout windows, each one a four-hour block scheduled for late morning through early afternoon Eastern time, progressively expanding in scope: early windows only block new runner registrations during that slot, later windows also start blocking job execution on outdated runners during the window. Full, permanent enforcement follows the brownout schedule: 2026-07-31 for GitHub Enterprise Cloud with Data Residency, 2026-09-25 for standard GitHub Enterprise Cloud.</p>
<div class="callout" role="note" data-astro-cid-q2ml7llr><p class="callout__title" data-astro-cid-q2ml7llr><span aria-hidden="true" data-astro-cid-q2ml7llr>⚠</span>What this looks like if you&#39;re unprepared</p><div class="callout__body" data-astro-cid-q2ml7llr><p>An outdated runner doesn’t throw a clear “upgrade your runner” error during a
brownout window. It just stops picking up jobs, or fails to register, for that
four-hour window and then works again. That reads as flaky infrastructure, not
a version problem, unless you already know the schedule exists. Once full
enforcement lands, the same failure becomes permanent instead of a four-hour
window.</p></div></div>
<h2 id="structural-comparison-matrix">Structural Comparison Matrix</h2>






























<table tabindex="0"><thead><tr><th scope="col" style="text-align:left">Operational Aspect</th><th scope="col" style="text-align:left">Runner below 2.329.0 / stale install</th><th scope="col" style="text-align:left">Runner on 2.329.0+, updated within 30 days</th></tr></thead><tbody><tr><td style="text-align:left"><strong>During a brownout window (11am-3pm ET)</strong></td><td style="text-align:left">Registration or job pickup blocked</td><td style="text-align:left">Unaffected</td></tr><tr><td style="text-align:left"><strong>Outside a brownout window, pre-enforcement</strong></td><td style="text-align:left">Works normally, still non-compliant</td><td style="text-align:left">Works normally</td></tr><tr><td style="text-align:left"><strong>After full enforcement date</strong></td><td style="text-align:left">Can’t register; existing runner stops jobs</td><td style="text-align:left">Unaffected</td></tr><tr><td style="text-align:left"><strong>Maintenance burden</strong></td><td style="text-align:left">Manual version checks needed indefinitely</td><td style="text-align:left">None, if auto-update is enabled</td></tr></tbody></table>
<h2 id="checking-and-upgrading-your-runner-version">Checking and upgrading your runner version</h2>
<p>Run this on the machine hosting the self-hosted runner to see what it’s currently on:</p>
<div class="code-block"><div class="code-block__header"><span class="code-block__filename"></span><button type="button" class="code-block__copy" data-copy-button>Copy</button></div><pre class="astro-code github-dark" style="background-color:#24292e;color:#e1e4e8;overflow-x:auto" tabindex="0" data-language="bash"><code><span class="line"><span style="color:#9ca6b0"># From the runner&#39;s install directory</span></span>
<span class="line"><span style="color:#B392F0">cat</span><span style="color:#9ECBFF"> .runner_version</span><span style="color:#F97583"> 2&gt;</span><span style="color:#9ECBFF">/dev/null</span><span style="color:#F97583"> ||</span><span style="color:#B392F0"> ./config.sh</span><span style="color:#79B8FF"> --version</span></span></code></pre></div>
<p>If it reports anything below <code>2.329.0</code>, or you don’t know when it last updated, upgrade it. GitHub Actions runners support auto-update by default unless it was explicitly disabled; check the runner service configuration first:</p>
<div class="code-block"><div class="code-block__header"><span class="code-block__filename"></span><button type="button" class="code-block__copy" data-copy-button>Copy</button></div><pre class="astro-code github-dark" style="background-color:#24292e;color:#e1e4e8;overflow-x:auto" tabindex="0" data-language="bash"><code><span class="line"><span style="color:#9ca6b0"># Check whether auto-update is disabled for this runner</span></span>
<span class="line"><span style="color:#B392F0">cat</span><span style="color:#9ECBFF"> .runner</span><span style="color:#F97583"> |</span><span style="color:#B392F0"> grep</span><span style="color:#79B8FF"> -i</span><span style="color:#9ECBFF"> &quot;disableupdate&quot;</span></span></code></pre></div>
<p>If auto-update is off, or you manage runners at a scale where you don’t want to rely on it, download and install the current release directly from the runner releases page, then restart the runner service so it picks up the new binary:</p>
<div class="code-block"><div class="code-block__header"><span class="code-block__filename"></span><button type="button" class="code-block__copy" data-copy-button>Copy</button></div><pre class="astro-code github-dark" style="background-color:#24292e;color:#e1e4e8;overflow-x:auto" tabindex="0" data-language="bash"><code><span class="line"><span style="color:#9ca6b0"># Example for a Linux x64 runner - use the current release asset URL</span></span>
<span class="line"><span style="color:#B392F0">curl</span><span style="color:#79B8FF"> -o</span><span style="color:#9ECBFF"> actions-runner-linux-x64.tar.gz</span><span style="color:#79B8FF"> -L</span><span style="color:#79B8FF"> \</span></span>
<span class="line"><span style="color:#9ECBFF">  https://github.com/actions/runner/releases/download/v2.329.0/actions-runner-linux-x64-2.329.0.tar.gz</span></span>
<span class="line"><span style="color:#B392F0">tar</span><span style="color:#9ECBFF"> xzf</span><span style="color:#9ECBFF"> ./actions-runner-linux-x64.tar.gz</span></span>
<span class="line"><span style="color:#B392F0">sudo</span><span style="color:#9ECBFF"> ./svc.sh</span><span style="color:#9ECBFF"> stop</span></span>
<span class="line"><span style="color:#B392F0">sudo</span><span style="color:#9ECBFF"> ./svc.sh</span><span style="color:#9ECBFF"> start</span></span></code></pre></div>
<p>At fleet scale, script the version check across every self-hosted runner group before the brownout windows start rather than finding out one at a time when a job silently stops queuing.</p>
<h2 id="does-this-affect-this-sites-own-workflows">Does this affect this site’s own workflows?</h2>
<p>No, and it’s worth saying so plainly rather than forcing a first-person angle that isn’t real. This site’s <code>.github/workflows/ci.yml</code> runs entirely on GitHub-hosted <code>ubuntu-latest</code> runners for both the build and deploy jobs, never a self-hosted runner. The enforcement covered here is scoped specifically to self-hosted runner registration and job execution; GitHub-hosted runners are versioned and managed by GitHub itself and sit outside this change entirely.</p>
<p>This cluster is still worth knowing if you run Actions at scale with your own infrastructure, self-hosted runners are common wherever a team needs specific hardware, network access, or licensing that GitHub-hosted runners can’t provide. If that’s your setup, the brownout schedule is the part most teams miss: it’s already running in the weeks before full enforcement, not a future date you can safely ignore until it arrives.</p>
<p>A separate but related change lets you <a href="/dev-tools/github-actions-self-repository-dollar-slash-syntax/">reference same-repository actions with a new <code>$/</code> syntax</a>, which requires Actions runner 2.336.0 or later, a different, higher version floor than the 2.329.0 registration minimum covered here. Don’t conflate the two version requirements if you’re checking both.</p>
<h2 id="source">Source</h2>
<p>GitHub’s <a href="https://github.blog/changelog/2026-06-12-github-actions-minimum-version-enforcement-timeline-for-self-hosted-runners/">changelog entry on the self-hosted runner version cutoff</a> (2026-06-12). Browse more posts like this in the <a href="/dev-tools">Dev Tools</a> archive.</p>]]></content:encoded>
      <pubDate>Thu, 13 Aug 2026 00:00:00 GMT</pubDate>
    </item>
    <item>
      <title>Set Up GitHub Actions Workflow Execution Protections</title>
      <link>https://bytetech247.com/dev-tools/github-actions-workflow-execution-protections/</link>
      <guid isPermaLink="true">https://bytetech247.com/dev-tools/github-actions-workflow-execution-protections/</guid>
      <description>Workflow execution protections let you allow-list who can trigger GitHub Actions workflows and which events are permitted, built on GitHub&apos;s rulesets.</description>
      <content:encoded><![CDATA[<aside class="quick-answer" aria-label="Quick answer"><h2 id="quick-answer">Quick Answer</h2>
<p>Workflow execution protections (public preview, 2026-06-18) add an allow-list layer in front of every GitHub Actions workflow: actor rules control who can trigger a run, event rules control which trigger types are permitted at all. Configure it under <strong>Settings → Actions → Policies</strong> at the repository, organization, or enterprise level. Use evaluate mode first to see what a rule would block before enforcing it.</p>
</aside><h2 id="the-gap-this-closes">The gap this closes</h2>
<p>Before this feature, anyone with write access to a repository could modify a workflow file to run arbitrary code with that repo’s secrets, and any event GitHub Actions supports (<code>push</code>, <code>pull_request_target</code>, <code>workflow_dispatch</code>, and the rest) could trigger a workflow with nothing standing in front of it. There was no repo-level or org-level control over who was allowed to cause a workflow to run, or which event types were even legitimate for a given repo’s actual usage pattern.</p>
<p>GitHub’s changelog describes the fix plainly:</p>
<blockquote>
<p>“Workflow execution protections are now in public preview for GitHub Enterprise, organizations, and repositories” and let you “define an allow list that controls who can trigger GitHub Actions workflows and which events are permitted to run them.”</p>
</blockquote>
<p>The feature is “built on the GitHub rulesets framework,” which means it inherits rulesets’ existing concepts, including an evaluate mode that reports exactly what a rule would have blocked without actually stopping any runs, so you can roll a new policy out and check its real-world impact before switching it to active enforcement, rather than introducing a parallel permissions system to learn from scratch.</p>
<h2 id="actor-rules-vs-event-rules">Actor rules vs. event rules</h2>
<p>The two rule types are independently configurable, and most real policies use both together.</p>
<p><strong>Actor rules</strong> control who can trigger a workflow at all. Straight from GitHub’s own description:</p>
<blockquote>
<p>“Individual users, repository roles (e.g., Read, Maintain, and Admin), GitHub Apps, Copilot, and Dependabot.”</p>
</blockquote>
<p>That’s the piece that separates “who can push code” from “who can cause a workflow to execute,” a distinction that didn’t exist as an enforceable setting before.</p>
<p><strong>Event rules</strong> control which trigger types are permitted, regardless of who fires them. <code>pull_request_target</code> is the one worth allow-listing carefully; ordinary triggers like <code>push</code> and <code>workflow_dispatch</code> are lower risk, and <code>pull_request</code> sits somewhere between the two depending on what the workflow actually does. A repo that has no legitimate reason to ever run a workflow off <code>pull_request_target</code> can block that event type outright, independent of who’s involved.</p>
<h2 id="structural-comparison-matrix">Structural Comparison Matrix</h2>






























<table tabindex="0"><thead><tr><th scope="col" style="text-align:left">Operational Aspect</th><th scope="col" style="text-align:left">Before</th><th scope="col" style="text-align:left">After (workflow execution protections)</th></tr></thead><tbody><tr><td style="text-align:left"><strong>Who can trigger a workflow run</strong></td><td style="text-align:left">Anyone with write access, no separate control</td><td style="text-align:left">Allow-listed by actor rule</td></tr><tr><td style="text-align:left"><strong>Which event types can run at all</strong></td><td style="text-align:left">Every event Actions supports, no restriction</td><td style="text-align:left">Allow-listed by event rule</td></tr><tr><td style="text-align:left"><strong>Testing a new policy before enforcing</strong></td><td style="text-align:left">No built-in dry-run</td><td style="text-align:left">Evaluate mode shows would-be blocks first</td></tr><tr><td style="text-align:left"><strong>Configuration scope</strong></td><td style="text-align:left">N/A (no equivalent existed)</td><td style="text-align:left">Repository, organization, or enterprise</td></tr></tbody></table>
<h2 id="setting-up-a-policy">Setting up a policy</h2>
<p>This lives in a new “Policies” section under Actions settings, layered on rulesets:</p>
<ol>
<li>
<p><strong>Navigate to the right level.</strong> For a single repository: <strong>Settings → Actions → Policies</strong>. For an organization-wide rule: the organization’s own <strong>Settings → Actions → Policies</strong>, which can apply across every repo in the org.</p>
</li>
<li>
<p><strong>Create a new ruleset scoped to workflow execution.</strong> Give it a name and decide whether it targets specific repositories (via repository custom properties, at the org/enterprise level) or the whole scope you’re configuring it at.</p>
</li>
<li>
<p><strong>Add actor rules.</strong> Start narrow: allow specific repository roles (<code>Write</code>, <code>Maintain</code>, <code>Admin</code>) or specific GitHub Apps, rather than defaulting to “everyone with write access.”</p>
</li>
<li>
<p><strong>Add event rules.</strong> List only the trigger events your workflows actually use. If nothing in the repo legitimately needs <code>pull_request_target</code>, don’t allow-list it, even if no current workflow uses it; this closes the door on someone adding one later without review.</p>
</li>
<li>
<p><strong>Set the ruleset to evaluate mode first</strong>, rather than jumping straight to enforcement. Let it run for a normal work cycle (a week is reasonable) and review what it would have blocked.</p>
</li>
<li>
<p><strong>Switch to active enforcement</strong> once evaluate mode shows no unexpected blocks against real usage.</p>
</li>
</ol>
<div class="callout" role="note" data-astro-cid-q2ml7llr><p class="callout__title" data-astro-cid-q2ml7llr><span aria-hidden="true" data-astro-cid-q2ml7llr>✓</span>Pair this with the checkout-level fix</p><div class="callout__body" data-astro-cid-q2ml7llr><p>Workflow execution protections and <code>actions/checkout</code> v7’s
<code>pull_request_target</code> block address the same fork-PR attack surface from
different angles: this feature decides whether the workflow runs at all for a
given actor or event, while the checkout change stops a specific unsafe
checkout pattern inside a workflow that’s already running. See
<a href="/dev-tools/actions-checkout-v7-blocks-pull-request-target-prs">actions/checkout v7 Blocks pull_request_target
PRs</a> for the
complementary control. Neither one alone covers the whole attack surface.</p></div></div>
<h2 id="does-this-apply-to-a-repo-like-this-sites">Does this apply to a repo like this site’s?</h2>
<p>Yes, and this is one of the changes in this series where a single repository genuinely doesn’t need an Enterprise plan or an organization admin to benefit. Per GitHub’s own docs, workflow execution protections are configurable “at the enterprise, organization, and repository level,” so a solo-maintained repo can opt in through its own <strong>Settings → Actions → Policies</strong> the same way an org-wide policy would be configured, just scoped to one repository instead of many.</p>
<h2 id="source">Source</h2>
<p>GitHub’s <a href="https://github.blog/changelog/2026-06-18-control-who-and-what-triggers-github-actions-workflows/">changelog entry introducing this allow-list feature</a> (2026-06-18), corroborated by <a href="https://docs.github.com/en/organizations/managing-organization-settings/actions-policies/workflow-execution-protections">GitHub’s own docs page on the feature</a>. Browse more posts like this in the <a href="/dev-tools">Dev Tools</a> archive.</p>]]></content:encoded>
      <pubDate>Thu, 13 Aug 2026 00:00:00 GMT</pubDate>
    </item>
    <item>
      <title>Migrate Zapier Functions Python Code to fetch()</title>
      <link>https://bytetech247.com/data-automation/migrate-zapier-functions-python-code-to-fetch/</link>
      <guid isPermaLink="true">https://bytetech247.com/data-automation/migrate-zapier-functions-python-code-to-fetch/</guid>
      <description>Zapier Functions shuts down September 1, 2026. Migrate Python requests calls to fetch() in Code by Zapier, except authenticated calls, handled differently.</description>
      <content:encoded><![CDATA[<aside class="quick-answer" aria-label="Quick answer"><h2 id="quick-answer">Quick Answer</h2>
<p>Zapier Functions shuts down entirely on 2026-09-01. Its replacement, Code by Zapier, runs on a different runtime with no <code>requests</code> library, so plain HTTP calls need to be rewritten using <code>fetch</code>. The one exception: Zapier’s own migration guide says not to use <code>fetch</code> for authenticated calls, those have to go through an API by Zapier connection instead.</p>
</aside><h2 id="why-this-isnt-a-find-and-replace">Why this isn’t a find-and-replace</h2>
<p>Zapier Functions let you write Python code with the <code>requests</code> library available for outbound HTTP calls, inside Zapier’s own hosted runtime, with a five-minute max execution time. <a href="https://help.zapier.com/hc/en-us/articles/45230556598157">Zapier’s Help Center</a> confirms the shutdown date plainly, in its migration guide updated 2026-07-13:</p>
<blockquote>
<p>“Zapier Functions is being deprecated on September 1, 2026”</p>
</blockquote>
<p>Code by Zapier is positioned as the direct replacement, but it runs JavaScript or Python on a different execution environment, one that doesn’t ship the <code>requests</code> library old Functions code relied on. A script built around <code>requests.get()</code> or <code>requests.post()</code> has no equivalent library to fall back to; it has to be rewritten against whatever HTTP mechanism the new runtime actually provides.</p>
<p>For JavaScript-based Code by Zapier steps, that mechanism is the standard <code>fetch</code> API:</p>
<div class="code-block code-block--has-filename"><div class="code-block__header"><span class="code-block__filename">before: Zapier Functions (Python, requests)</span><button type="button" class="code-block__copy" data-copy-button>Copy</button></div><pre class="astro-code github-dark" style="background-color:#24292e;color:#e1e4e8;overflow-x:auto" tabindex="0" data-language="python" data-filename="before: Zapier Functions (Python, requests)"><code><span class="line"><span style="color:#F97583">import</span><span style="color:#E1E4E8"> requests</span></span>
<span class="line"></span>
<span class="line"><span style="color:#E1E4E8">response </span><span style="color:#F97583">=</span><span style="color:#E1E4E8"> requests.get(</span><span style="color:#9ECBFF">&quot;https://api.example.com/items&quot;</span><span style="color:#E1E4E8">)</span></span>
<span class="line"><span style="color:#E1E4E8">data </span><span style="color:#F97583">=</span><span style="color:#E1E4E8"> response.json()</span></span>
<span class="line"><span style="color:#79B8FF">print</span><span style="color:#E1E4E8">(data)</span></span></code></pre></div>
<div class="code-block code-block--has-filename"><div class="code-block__header"><span class="code-block__filename">after: Code by Zapier (JavaScript, fetch)</span><button type="button" class="code-block__copy" data-copy-button>Copy</button></div><pre class="astro-code github-dark" style="background-color:#24292e;color:#e1e4e8;overflow-x:auto" tabindex="0" data-language="javascript" data-filename="after: Code by Zapier (JavaScript, fetch)"><code><span class="line"><span style="color:#F97583">const</span><span style="color:#79B8FF"> response</span><span style="color:#F97583"> =</span><span style="color:#F97583"> await</span><span style="color:#B392F0"> fetch</span><span style="color:#E1E4E8">(</span><span style="color:#9ECBFF">&quot;https://api.example.com/items&quot;</span><span style="color:#E1E4E8">);</span></span>
<span class="line"><span style="color:#F97583">const</span><span style="color:#79B8FF"> data</span><span style="color:#F97583"> =</span><span style="color:#F97583"> await</span><span style="color:#E1E4E8"> response.</span><span style="color:#B392F0">json</span><span style="color:#E1E4E8">();</span></span>
<span class="line"><span style="color:#E1E4E8">console.</span><span style="color:#B392F0">log</span><span style="color:#E1E4E8">(data);</span></span></code></pre></div>
<p><code>requests.post(url, json=data)</code> follows the same pattern, rewritten as a <code>fetch</code> call with an explicit method, JSON body, and content-type header:</p>
<div class="code-block code-block--has-filename"><div class="code-block__header"><span class="code-block__filename">POST requests in fetch()</span><button type="button" class="code-block__copy" data-copy-button>Copy</button></div><pre class="astro-code github-dark" style="background-color:#24292e;color:#e1e4e8;overflow-x:auto" tabindex="0" data-language="javascript" data-filename="POST requests in fetch()"><code><span class="line"><span style="color:#F97583">const</span><span style="color:#79B8FF"> response</span><span style="color:#F97583"> =</span><span style="color:#F97583"> await</span><span style="color:#B392F0"> fetch</span><span style="color:#E1E4E8">(url, {</span></span>
<span class="line"><span style="color:#E1E4E8">  method: </span><span style="color:#9ECBFF">&quot;POST&quot;</span><span style="color:#E1E4E8">,</span></span>
<span class="line"><span style="color:#E1E4E8">  body: </span><span style="color:#79B8FF">JSON</span><span style="color:#E1E4E8">.</span><span style="color:#B392F0">stringify</span><span style="color:#E1E4E8">(data),</span></span>
<span class="line"><span style="color:#E1E4E8">  headers: { </span><span style="color:#9ECBFF">&quot;Content-Type&quot;</span><span style="color:#E1E4E8">: </span><span style="color:#9ECBFF">&quot;application/json&quot;</span><span style="color:#E1E4E8"> },</span></span>
<span class="line"><span style="color:#E1E4E8">});</span></span></code></pre></div>
<div class="callout" role="note" data-astro-cid-q2ml7llr><p class="callout__title" data-astro-cid-q2ml7llr><span aria-hidden="true" data-astro-cid-q2ml7llr>ℹ</span>This site doesn&#39;t run Zapier</p><div class="callout__body" data-astro-cid-q2ml7llr><p>Direct statement, since this is the honesty note this whole series carries:
bytetech247.com’s own automation runs on Cloudflare Workers and GitHub
Actions, not Zapier (see <a href="/data-automation/automate-static-site-deploys-github-actions-cloudflare-workers/">this site’s actual deploy
automation</a>).
This is a structural walkthrough of Zapier’s own published migration guide,
not a first-person account of migrating our own Functions code.</p></div></div>
<h2 id="the-exception-that-breaks-a-naive-find-and-replace">The exception that breaks a naive find-and-replace</h2>
<p>Zapier’s guide is explicit about where this pattern stops working: authenticated HTTP calls. Its warning reads:</p>
<blockquote>
<p>“Do not use <code>fetch</code> for these calls.”</p>
</blockquote>
<p>The reasoning is that Code by Zapier’s execution model doesn’t let code hold onto secrets the way Functions could. A <code>fetch</code> call with an API key baked into a header or query string would mean putting that credential directly in the Code step’s source, exactly the pattern the runtime is designed not to support. Instead, authenticated requests have to go through <strong>API by Zapier</strong> with the Zapier SDK, which handles the credential through a stored connection rather than inline code. <a href="/data-automation/zapier-functions-secrets-move-to-api-by-zapier/">The last post in this series</a> covers that connection setup and its exact binding syntax in full.</p>
<h2 id="structural-comparison-matrix">Structural Comparison Matrix</h2>



































<table tabindex="0"><thead><tr><th scope="col" style="text-align:left">Operational Aspect</th><th scope="col" style="text-align:left">Zapier Functions (Python)</th><th scope="col" style="text-align:left">Code by Zapier</th></tr></thead><tbody><tr><td style="text-align:left"><strong>Shutdown / availability</strong></td><td style="text-align:left">Ends 2026-09-01</td><td style="text-align:left">Current replacement</td></tr><tr><td style="text-align:left"><strong>Language</strong></td><td style="text-align:left">Python only</td><td style="text-align:left">JavaScript or Python</td></tr><tr><td style="text-align:left"><strong>Unauthenticated HTTP calls</strong></td><td style="text-align:left"><code>requests.get()</code> / <code>.post()</code></td><td style="text-align:left"><code>fetch()</code></td></tr><tr><td style="text-align:left"><strong>Authenticated HTTP calls</strong></td><td style="text-align:left"><code>requests</code> with inline credentials</td><td style="text-align:left">API by Zapier connection via the Zapier SDK, not <code>fetch</code></td></tr><tr><td style="text-align:left"><strong>Console output</strong></td><td style="text-align:left"><code>print()</code></td><td style="text-align:left"><code>console.log()</code></td></tr></tbody></table>
<h2 id="fix-it-audit-before-you-convert">Fix it: audit before you convert</h2>
<p>Before rewriting anything, list every outbound HTTP call in the existing Functions code and mark whether it carries a credential (an API key header, a bearer token, a signed query string) or not. Unauthenticated calls convert straight to <code>fetch</code>. Authenticated ones need the API by Zapier connection instead, not a <code>fetch</code> call with the header simply carried over.</p>
<div class="code-block code-block--has-filename"><div class="code-block__header"><span class="code-block__filename">a call that must NOT become fetch()</span><button type="button" class="code-block__copy" data-copy-button>Copy</button></div><pre class="astro-code github-dark" style="background-color:#24292e;color:#e1e4e8;overflow-x:auto" tabindex="0" data-language="javascript" data-filename="a call that must NOT become fetch()"><code><span class="line"><span style="color:#9ca6b0">// Old Functions code:</span></span>
<span class="line"><span style="color:#9ca6b0">// requests.get(url, headers={&quot;Authorization&quot;: f&quot;Bearer {api_key}&quot;})</span></span>
<span class="line"><span style="color:#9ca6b0">//</span></span>
<span class="line"><span style="color:#9ca6b0">// This is an authenticated call. Per Zapier&#39;s own migration guide,</span></span>
<span class="line"><span style="color:#9ca6b0">// route it through an API by Zapier connection, not fetch().</span></span></code></pre></div>
<div class="callout" role="note" data-astro-cid-q2ml7llr><p class="callout__title" data-astro-cid-q2ml7llr><span aria-hidden="true" data-astro-cid-q2ml7llr>⚠</span>Two other migration problems live in the same guide</p><div class="callout__body" data-astro-cid-q2ml7llr><p>Converting HTTP calls is one of three distinct problems Zapier’s migration
guide covers. If the original Function handled multiple trigger events, <a href="/data-automation/zapier-functions-split-multi-trigger-functions/">see
the next post on splitting it into separate
Zaps</a>. If it
had hardcoded secrets anywhere, <a href="/data-automation/zapier-functions-secrets-move-to-api-by-zapier/">see the post on moving them to an API by
Zapier
connection</a>.
Fixing HTTP calls alone doesn’t cover either of those.</p></div></div>
<h2 id="confirmed-version">Confirmed version</h2>
<p>Sourced from <a href="https://help.zapier.com/hc/en-us/articles/45230556598157">Zapier’s official Help Center migration guide for Zapier Functions</a>, updated 2026-07-13. Browse more posts like this in the <a href="/data-automation">Data Automation</a> archive.</p>]]></content:encoded>
      <pubDate>Thu, 13 Aug 2026 00:00:00 GMT</pubDate>
    </item>
    <item>
      <title>Pipedrive V2 API Drops Fields Like deal_title</title>
      <link>https://bytetech247.com/data-automation/pipedrive-v2-api-drops-fields-like-deal-title/</link>
      <guid isPermaLink="true">https://bytetech247.com/data-automation/pipedrive-v2-api-drops-fields-like-deal-title/</guid>
      <description>Pipedrive&apos;s V2 API returns fewer fields than V1, including deal_title. Here&apos;s why remapping the trigger alone isn&apos;t enough, and how to backfill it.</description>
      <content:encoded><![CDATA[<aside class="quick-answer" aria-label="Quick answer"><h2 id="quick-answer">Quick Answer</h2>
<p>Pipedrive’s V2 API returns fewer fields per record than V1, including <code>deal_title</code> on activity records. Remapping a <code>[DEPRECATING JULY 31 2026]</code> step to its V2 event (covered in this series’ previous post) fixes which API version runs, but not this: any downstream step reading a now-missing field gets <code>undefined</code> instead of an error, so the Zap keeps running while quietly producing wrong output.</p>
</aside><h2 id="a-remap-alone-doesnt-restore-the-missing-data">A remap alone doesn’t restore the missing data</h2>
<p>This is a different failure mode from the cosmetic relabeling covered in <a href="/data-automation/pipedrive-zaps-tagged-deprecating-july-31-2026/">the previous post in this series</a>. That one is about a step still calling an API version Pipedrive is about to shut off. This one is about what happens after you’ve already done the fix: Pipedrive’s V2 API genuinely returns a smaller set of fields than V1 did.</p>
<p><a href="https://help.zapier.com/hc/en-us/articles/44170499172237">Zapier’s Help Center article on the Pipedrive deprecation</a> states the gap directly:</p>
<blockquote>
<p>“The V2 API returns less data than the V1 API”</p>
</blockquote>
<p>The guide’s own worked example is the New Activity trigger. Under V1, a Zap using that trigger could map a <code>deal_title</code> field straight from the trigger’s output. Under V2, that field is gone, and Zapier’s guide says as much: <code>deal_title</code> is “no longer available.”</p>
<div class="callout" role="note" data-astro-cid-q2ml7llr><p class="callout__title" data-astro-cid-q2ml7llr><span aria-hidden="true" data-astro-cid-q2ml7llr>⚠</span>This fails silently, not loudly</p><div class="callout__body" data-astro-cid-q2ml7llr><p>A missing field doesn’t throw an error in most downstream steps. A Filter step
checking a condition against <code>deal_title</code> sees an empty value and either fails
the condition unexpectedly or passes it through blank. A Formatter step
concatenating it into a message just gets a gap in the output. Nothing in the
Zap history necessarily flags this as a failure, so it can run “successfully”
for weeks while producing bad data downstream.</p></div></div>
<h2 id="structural-comparison-matrix">Structural Comparison Matrix</h2>






























<table tabindex="0"><thead><tr><th scope="col" style="text-align:left">Operational Aspect</th><th scope="col" style="text-align:left">Pipedrive V1</th><th scope="col" style="text-align:left">Pipedrive V2</th></tr></thead><tbody><tr><td style="text-align:left"><strong>Data returned per record</strong></td><td style="text-align:left">Fuller field set</td><td style="text-align:left">Reduced field set</td></tr><tr><td style="text-align:left"><strong><code>deal_title</code> on activities</strong></td><td style="text-align:left">Present</td><td style="text-align:left">Not returned</td></tr><tr><td style="text-align:left"><strong>Downstream behavior on gap</strong></td><td style="text-align:left">N/A</td><td style="text-align:left">Field resolves to blank/<code>undefined</code>, no error thrown</td></tr><tr><td style="text-align:left"><strong>Fix required</strong></td><td style="text-align:left">None</td><td style="text-align:left">Add a lookup step to backfill the missing field</td></tr></tbody></table>
<h2 id="fix-it-backfill-with-a-pipedrive-search-step">Fix it: backfill with a Pipedrive search step</h2>
<p>The structural fix is to add a step that looks the missing field back up, rather than trying to force it out of the trigger. Pipedrive’s <code>deal_id</code> is still present on the V2 activity trigger output, so a <strong>Find Deal</strong> search step keyed on that ID can pull the deal record and expose its <code>title</code> field for downstream steps to map instead:</p>
<div class="code-block code-block--has-filename"><div class="code-block__header"><span class="code-block__filename">Zap structure: backfilling deal_title after the V2 migration</span><button type="button" class="code-block__copy" data-copy-button>Copy</button></div><pre class="astro-code github-dark" style="background-color:#24292e;color:#e1e4e8;overflow-x:auto" tabindex="0" data-language="text" data-filename="Zap structure: backfilling deal_title after the V2 migration"><code><span class="line"><span>1. Trigger: New Activity in Pipedrive (V2)</span></span>
<span class="line"><span>   -&gt; outputs deal_id, but no deal_title</span></span>
<span class="line"><span></span></span>
<span class="line"><span>2. Action: Find Deal in Pipedrive</span></span>
<span class="line"><span>   -&gt; Search value: {{deal_id from step 1}}</span></span>
<span class="line"><span>   -&gt; outputs the deal record, including `title`</span></span>
<span class="line"><span></span></span>
<span class="line"><span>3. Downstream steps</span></span>
<span class="line"><span>   -&gt; map `title` from step 2 instead of `deal_title` from step 1</span></span></code></pre></div>
<p>If a raw <strong>API Request (Beta)</strong> action is already part of the Zap for other reasons, calling Pipedrive’s V2 deal-detail endpoint directly is an equally valid alternative to a dedicated Find Deal step. Either way, the fix is the same shape: fetch the deal explicitly instead of assuming the trigger step still carries it.</p>
<div class="callout" role="note" data-astro-cid-q2ml7llr><p class="callout__title" data-astro-cid-q2ml7llr><span aria-hidden="true" data-astro-cid-q2ml7llr>ℹ</span>This site doesn&#39;t run Zapier</p><div class="callout__body" data-astro-cid-q2ml7llr><p>As stated elsewhere in this series: bytetech247.com’s own automation is
Cloudflare Workers and GitHub Actions, not Zapier (see <a href="/data-automation/automate-static-site-deploys-github-actions-cloudflare-workers/">this site’s actual
deploy
automation</a>).
The fix above comes from Zapier’s own published migration guidance, not from
running this pipeline ourselves.</p></div></div>
<p>Once the backfill step is in place, go back through every downstream step and confirm each field reference points at a field that still exists in the new output, not just the one field the migration guide happened to name as its example.</p>
<h2 id="confirmed-version">Confirmed version</h2>
<p>Sourced from <a href="https://help.zapier.com/hc/en-us/articles/44170499172237">Zapier’s official Help Center advisory on the Pipedrive V1 API deprecation</a>, updated 2026-05-29, the same source as this series’ previous post. Browse more posts like this in the <a href="/data-automation">Data Automation</a> archive.</p>]]></content:encoded>
      <pubDate>Thu, 13 Aug 2026 00:00:00 GMT</pubDate>
    </item>
    <item>
      <title>Fix Pipedrive Zaps Tagged [DEPRECATING JULY 31 2026]</title>
      <link>https://bytetech247.com/data-automation/pipedrive-zaps-tagged-deprecating-july-31-2026/</link>
      <guid isPermaLink="true">https://bytetech247.com/data-automation/pipedrive-zaps-tagged-deprecating-july-31-2026/</guid>
      <description>Zapier tags affected Pipedrive steps [DEPRECATING JULY 31 2026] ahead of the V1 API sunset. The label is cosmetic until you remap the step yourself.</description>
      <content:encoded><![CDATA[<aside class="quick-answer" aria-label="Quick answer"><h2 id="quick-answer">Quick Answer</h2>
<p>Zapier has already renamed every Pipedrive trigger, action, and search step built on the V1 API to prepend <code>[DEPRECATING JULY 31 2026]</code> to its name. The step keeps running on V1 until Pipedrive turns it off. Fix it by opening the step, selecting the same event name without the label, reconfiguring it, remapping downstream fields, then re-enabling the Zap.</p>
</aside><h2 id="what-the-label-actually-means">What the label actually means</h2>
<p>Pipedrive is retiring every V1 API endpoint on 2026-07-31 in favor of V2. <a href="https://help.zapier.com/hc/en-us/articles/44170499172237">Zapier’s own Help Center article on the change</a>, updated 2026-05-29, states it plainly:</p>
<blockquote>
<p>“Pipedrive is deprecating all V1 API endpoints on July 31, 2026”</p>
</blockquote>
<p>To flag the fallout, Zapier went through every existing Zap and relabeled the affected Pipedrive step’s name with a literal prefix: <code>[DEPRECATING JULY 31 2026]</code>. That string shows up right in the Zap editor, on the step card itself, wherever the Zap uses a V1-backed trigger, action, or search.</p>
<p>Here’s the part that catches people off guard: the label is purely cosmetic. The step keeps calling the V1 endpoint and keeps working exactly as before, right up until Pipedrive actually shuts that endpoint off. Nothing about seeing the tag in your Zap editor changes behavior. It’s a warning sticker, not a broken-step icon, which is exactly why it’s easy to notice once, mentally file away as “later,” and then forget.</p>
<div class="callout" role="note" data-astro-cid-q2ml7llr><p class="callout__title" data-astro-cid-q2ml7llr><span aria-hidden="true" data-astro-cid-q2ml7llr>ℹ</span>This site doesn&#39;t run Zapier</p><div class="callout__body" data-astro-cid-q2ml7llr><p>Worth saying directly: bytetech247.com’s own automation runs on Cloudflare
Workers and GitHub Actions, not Zapier (see <a href="/data-automation/automate-static-site-deploys-github-actions-cloudflare-workers/">how this site handles its own
deploy
automation</a>).
This post is a structural breakdown of a real, dated Zapier deprecation notice
affecting a widely used integration, not a first-person account of fixing our
own Zaps.</p></div></div>
<h2 id="structural-comparison-matrix">Structural Comparison Matrix</h2>



































<table tabindex="0"><thead><tr><th scope="col" style="text-align:left">Operational Aspect</th><th scope="col" style="text-align:left">Tagged step, before fix</th><th scope="col" style="text-align:left">After remapping</th></tr></thead><tbody><tr><td style="text-align:left"><strong>Step name</strong></td><td style="text-align:left"><code>[DEPRECATING JULY 31 2026] New Deal</code></td><td style="text-align:left"><code>New Deal</code> (no label)</td></tr><tr><td style="text-align:left"><strong>API version called</strong></td><td style="text-align:left">Pipedrive V1</td><td style="text-align:left">Pipedrive V2</td></tr><tr><td style="text-align:left"><strong>Behavior before 2026-07-31</strong></td><td style="text-align:left">Works normally, label is cosmetic only</td><td style="text-align:left">Works normally</td></tr><tr><td style="text-align:left"><strong>Behavior after 2026-07-31</strong></td><td style="text-align:left">Fails once Pipedrive retires V1</td><td style="text-align:left">Continues working</td></tr><tr><td style="text-align:left"><strong>Downstream step field maps</strong></td><td style="text-align:left">Unaffected until step is reconfigured</td><td style="text-align:left">May need remapping (see cluster below)</td></tr></tbody></table>
<h2 id="fix-it-remap-the-step-in-the-setup-tab">Fix it: remap the step in the Setup tab</h2>
<p>Zapier’s guide lays out six steps to clear the label on one tagged step. None of it happens automatically, and skipping the field-remap step is the most common way this goes wrong even after the event itself is reselected:</p>
<ol>
<li>Click open the step carrying the <code>[DEPRECATING JULY 31 2026]</code> prefix.</li>
<li>Head into its Setup tab and choose that identical event again, now shown with no deprecation tag attached.</li>
<li>Rebuild the step’s configuration to match what it had before.</li>
<li>Run a test on the step to pull a fresh, current record from Pipedrive.</li>
<li>Go through every step below it and reassign fields that changed.</li>
<li>Test the entire Zap end to end, then flip it back on if it had been paused.</li>
</ol>
<div class="code-block code-block--has-filename"><div class="code-block__header"><span class="code-block__filename">what changes in the Zap editor</span><button type="button" class="code-block__copy" data-copy-button>Copy</button></div><pre class="astro-code github-dark" style="background-color:#24292e;color:#e1e4e8;overflow-x:auto" tabindex="0" data-language="text" data-filename="what changes in the Zap editor"><code><span class="line"><span>Before: [DEPRECATING JULY 31 2026] New Deal in Pipedrive  (trigger, V1)</span></span>
<span class="line"><span>After:  New Deal in Pipedrive                             (trigger, V2)</span></span></code></pre></div>
<p>Step 5 is where a remap actually matters. Reselecting the V2 event can shift the exact field names and shape of what the step outputs, so a Filter or Formatter step downstream that was matching against the old field names needs to be checked, not assumed to still work. <a href="/data-automation/pipedrive-v2-api-drops-fields-like-deal-title/">The next post in this series</a> covers a specific version of this problem: V2 drops fields V1 used to return, so a downstream step can end up reading <code>undefined</code> instead of erroring outright.</p>
<div class="callout" role="note" data-astro-cid-q2ml7llr><p class="callout__title" data-astro-cid-q2ml7llr><span aria-hidden="true" data-astro-cid-q2ml7llr>⚠</span>Test before re-enabling</p><div class="callout__body" data-astro-cid-q2ml7llr><p>Turning a Zap back on without testing it first (step 6) is how a Zap that
“looks migrated” ships a silent field-mapping gap into production. Run one
real test through the whole Zap, not just the reconfigured step, before
flipping it live again.</p></div></div>
<h2 id="confirmed-version">Confirmed version</h2>
<p>Sourced from <a href="https://help.zapier.com/hc/en-us/articles/44170499172237">Zapier’s official Help Center advisory on the Pipedrive V1 API deprecation</a>, updated 2026-05-29. Browse more posts like this in the <a href="/data-automation">Data Automation</a> archive.</p>]]></content:encoded>
      <pubDate>Thu, 13 Aug 2026 00:00:00 GMT</pubDate>
    </item>
    <item>
      <title>Rebuild ChatGPT Create Assistant Zaps by Aug 26</title>
      <link>https://bytetech247.com/data-automation/rebuild-chatgpt-create-assistant-zaps/</link>
      <guid isPermaLink="true">https://bytetech247.com/data-automation/rebuild-chatgpt-create-assistant-zaps/</guid>
      <description>Create Assistant, Upload File, Find Assistant, and Find or Create Assistant have no direct replacement in Zapier. Here&apos;s how to rebuild before Aug 26, 2026.</description>
      <content:encoded><![CDATA[<aside class="quick-answer" aria-label="Quick answer"><h2 id="quick-answer">Quick Answer</h2>
<p>Four ChatGPT actions, Create Assistant, Upload File, Find Assistant, and Find or Create Assistant, have no direct replacement in Zapier’s migration to OpenAI’s Responses API. Unlike the Conversation action, these Zaps don’t auto-migrate. Redesign them around the Responses API’s Conversation or Send Prompt actions before August 26, 2026, or they stop running with no fallback.</p>
</aside><h2 id="why-these-four-dont-get-an-automatic-fix">Why these four don’t get an automatic fix</h2>
<p>A short honesty note up front: ByteTech247 doesn’t run Zapier. This site automates with Cloudflare Workers and GitHub Actions, covered separately in our <a href="/data-automation/cloudflares-july-2026-api-deprecation-wave/">Cloudflare API deprecation pillar</a>. What follows comes from Zapier’s own Help Center documentation on the OpenAI Assistants API deprecation, not from operating these Zaps first-hand.</p>
<p>The <a href="https://help.zapier.com/hc/en-us/articles/44865998484365">same deprecation notice</a> covered in our post on the <a href="/data-automation/fix-chatgpt-conversation-with-assistant-migration/">Conversation With Assistant (Legacy) migration</a> also names four actions Zapier describes plainly:</p>
<blockquote>
<p>“no direct replacement”</p>
</blockquote>
<p>That phrase applies to <strong>Create Assistant</strong>, <strong>Upload File</strong>, <strong>Find Assistant</strong>, and <strong>Find or Create Assistant</strong>. All four exist to manage a persistent, server-side “Assistant” object, an entity the Assistants API defined and the Responses API simply doesn’t have. There’s no field mapping, no equivalent endpoint, and no automatic migration path, because there’s no destination concept for Zapier to migrate the action to. Zapier’s guidance is direct about what that means operationally:</p>
<blockquote>
<p>“manually update Zaps that use these actions before August 26, 2026”</p>
</blockquote>
<p>That’s a rebuild instruction, not a remap instruction. It’s the same underlying deprecation as the Conversation action’s migration, but a structurally harder problem: nothing runs after August 26 unless someone redesigns the workflow first.</p>
<h2 id="structural-comparison-matrix">Structural Comparison Matrix</h2>






























<table tabindex="0"><thead><tr><th scope="col" style="text-align:left">Operational Aspect</th><th scope="col" style="text-align:left">Assistant-object actions (Create/Upload/Find/Find-or-Create)</th><th scope="col" style="text-align:left">Conversation action</th></tr></thead><tbody><tr><td style="text-align:left"><strong>Underlying concept</strong></td><td style="text-align:left">A persistent, server-side Assistant object</td><td style="text-align:left">A stateless or memory-backed conversation</td></tr><tr><td style="text-align:left"><strong>Migration path</strong></td><td style="text-align:left">None, per Zapier’s own notice</td><td style="text-align:left">Automatic (see the companion post)</td></tr><tr><td style="text-align:left"><strong>Zap state after Aug 26, 2026</strong></td><td style="text-align:left">Stops running, no fallback</td><td style="text-align:left">Runs on the migrated Conversation action, if reviewed and re-enabled</td></tr><tr><td style="text-align:left"><strong>What has to happen</strong></td><td style="text-align:left">Full redesign around Responses API actions</td><td style="text-align:left">Review, field remap, re-enable</td></tr></tbody></table>
<h2 id="fix-it-redesign-around-conversation-or-send-prompt">Fix it: redesign around Conversation or Send Prompt</h2>
<div class="callout" role="note" data-astro-cid-q2ml7llr><p class="callout__title" data-astro-cid-q2ml7llr><span aria-hidden="true" data-astro-cid-q2ml7llr>✕</span>This is a rebuild, not a config change</p><div class="callout__body" data-astro-cid-q2ml7llr><p>There’s no setting, no beta flag, and no equivalent action to swap in for
Create Assistant, Upload File, Find Assistant, or Find or Create Assistant.
Treat any Zap using one of these as needing new steps built from scratch
around the Responses API, not an in-place fix.</p></div></div>
<ol>
<li><strong>Inventory first.</strong> Search your Zaps for the four action names: Create Assistant, Upload File, Find Assistant, and Find or Create Assistant. Zapier’s notice says these steps stop being selectable in new Zaps entirely, so an existing Zap using one keeps running only until the August 26 cutoff, with no way to add a fifth one to a new Zap today.</li>
<li><strong>Work out what the assistant object was actually doing.</strong> Most Create Assistant / Find Assistant patterns exist to give a Zap a consistent persona, instructions, or file-search context across runs. Since the Responses API has no persistent Assistant object, that context needs to move somewhere else: typically into the prompt itself, or into whatever your Zap’s Storage or a lookup step supplies at run time.</li>
<li><strong>Pick Conversation or Send Prompt based on whether state matters.</strong> Zapier positions Conversation for stateful, tool-using exchanges, with memory plus file, web, and MCP-based tool access, and Send Prompt for a simple one-off call that doesn’t need any memory carried between runs. A Zap that previously found-or-created an assistant just to ask it one question per run is very likely a Send Prompt candidate now, not a Conversation one.</li>
<li><strong>Rebuild file-upload steps around the new action’s inputs.</strong> If Upload File was feeding a document into an assistant’s file search, check whether Conversation’s own file search option covers the same use case directly, rather than trying to preserve a separate upload step that no longer has an Assistant object to attach to.</li>
<li><strong>Test against real inputs before the deadline</strong>, not just a sample record. A rebuilt Zap that only gets tested against one clean test case tends to surface the real gaps (a field that used to come from the assistant’s stored instructions, now missing) once it hits production data.</li>
</ol>
<div class="callout" role="note" data-astro-cid-q2ml7llr><p class="callout__title" data-astro-cid-q2ml7llr><span aria-hidden="true" data-astro-cid-q2ml7llr>ℹ</span>Not every ChatGPT action in this deprecation works the same way</p><div class="callout__body" data-astro-cid-q2ml7llr><p>If your Zaps also use Conversation With Assistant (Legacy), that one does get
an automatic migration, just left switched off pending review. See <a href="/data-automation/fix-chatgpt-conversation-with-assistant-migration/">Fix
ChatGPT Conversation With Assistant
Migration</a>
for that half of the same deprecation.</p></div></div>
<h2 id="confirmed-version">Confirmed version</h2>
<p>Sourced from Zapier’s official Help Center advisory on the OpenAI Assistants API deprecation for ChatGPT, updated 2026-07-20, with the “no direct replacement” phrasing and the August 26, 2026, deadline confirmed directly against that source. Browse more posts like this in the <a href="/data-automation">Data Automation</a> archive.</p>]]></content:encoded>
      <pubDate>Thu, 13 Aug 2026 00:00:00 GMT</pubDate>
    </item>
    <item>
      <title>Reconnect Greenhouse Zaps to OAuth 2.0 by Aug 26</title>
      <link>https://bytetech247.com/data-automation/reconnect-greenhouse-zaps-to-oauth-2-0/</link>
      <guid isPermaLink="true">https://bytetech247.com/data-automation/reconnect-greenhouse-zaps-to-oauth-2-0/</guid>
      <description>Greenhouse version 2.0.0 uses Harvest v3 and OAuth 2.0 auth. Zaps on the old API-key connection need a manual Reconnect before Aug 26, 2026.</description>
      <content:encoded><![CDATA[<aside class="quick-answer" aria-label="Quick answer"><h2 id="quick-answer">Quick Answer</h2>
<p>Zapier’s Greenhouse version 2.0.0 switches from API-key authentication to OAuth 2.0, running on Greenhouse’s Harvest v3 API. Every Zap still connected through the old version needs its Greenhouse connection manually reconnected through the new OAuth flow before August 26, 2026, or every step using that connection fails at the authentication layer, regardless of whether the step itself was otherwise migrated.</p>
</aside><h2 id="why-the-fix-here-is-the-connection-not-a-single-step">Why the fix here is the connection, not a single step</h2>
<p>One honesty note before the details: ByteTech247 doesn’t run Zapier. Its own automation runs on Cloudflare Workers and GitHub Actions, covered in our separate <a href="/data-automation/cloudflares-july-2026-api-deprecation-wave/">Cloudflare API deprecation pillar</a>. Everything below comes from Zapier’s own Help Center advisory on the Greenhouse deprecation, not from operating these Zaps ourselves.</p>
<p>Greenhouse is shutting down the older versions of its Harvest API, v1 and v2. <a href="https://help.zapier.com/hc/en-us/articles/47585848967437">Zapier’s own notice</a> explains what the replacement app version actually changes:</p>
<blockquote>
<p>“Zapier is releasing Greenhouse version 2.0.0, which uses Harvest v3 and OAuth 2.0 authentication.”</p>
</blockquote>
<p>That second half matters as much as the API version bump. The old Greenhouse connection in Zapier authenticated with a static API key, tied to the Greenhouse account, that Zapier stored and reused on every call. Version 2.0.0 authenticates through OAuth 2.0 instead, an actual authorization flow rather than a stored secret. Zapier’s fix instruction is a single, specific action:</p>
<blockquote>
<p>“Click Reconnect to connect your Greenhouse account using the new OAuth 2.0 flow.”</p>
</blockquote>
<p>The two relevant deadlines aren’t the same date. Zapier states that Greenhouse version 1.5.1, the old API-key connection, stops working August 26, 2026, while Greenhouse’s own Harvest API v1 and v2 are retired August 31, 2026. Zapier’s cutoff lands five days before Greenhouse’s, giving a small buffer, not a reason to wait until the later date.</p>
<h2 id="structural-comparison-matrix">Structural Comparison Matrix</h2>



































<table tabindex="0"><thead><tr><th scope="col" style="text-align:left">Operational Aspect</th><th scope="col" style="text-align:left">Greenhouse v1.5.1 (old)</th><th scope="col" style="text-align:left">Greenhouse v2.0.0 (current)</th></tr></thead><tbody><tr><td style="text-align:left"><strong>Auth method</strong></td><td style="text-align:left">Static API key</td><td style="text-align:left">OAuth 2.0</td></tr><tr><td style="text-align:left"><strong>API it calls</strong></td><td style="text-align:left">Harvest v1/v2</td><td style="text-align:left">Harvest v3</td></tr><tr><td style="text-align:left"><strong>Zapier cutoff</strong></td><td style="text-align:left">Stops working 2026-08-26</td><td style="text-align:left">N/A</td></tr><tr><td style="text-align:left"><strong>Greenhouse cutoff</strong></td><td style="text-align:left">Harvest v1/v2 retired 2026-08-31</td><td style="text-align:left">N/A</td></tr><tr><td style="text-align:left"><strong>Action required</strong></td><td style="text-align:left">Reconnect via OAuth 2.0, before either deadline</td><td style="text-align:left">N/A</td></tr></tbody></table>
<h2 id="fix-it-reconnect-before-touching-individual-steps">Fix it: reconnect before touching individual steps</h2>
<div class="callout" role="note" data-astro-cid-q2ml7llr><p class="callout__title" data-astro-cid-q2ml7llr><span aria-hidden="true" data-astro-cid-q2ml7llr>⚠</span>Reconnecting is a prerequisite, not the whole fix</p><div class="callout__body" data-astro-cid-q2ml7llr><p>Getting the connection onto OAuth 2.0 only fixes authentication. Individual
triggers, actions, and searches still need checking against Harvest v3’s field
and schema changes, a separate step covered in <a href="/data-automation/fix-greenhouse-zaps-for-harvest-v3-field-changes/">Fix Greenhouse Zaps for
Harvest v3 Field
Changes</a>.
Do the reconnect first; it’s the layer everything else depends on.</p></div></div>
<ol>
<li><strong>Find every Zap using a Greenhouse connection.</strong> Check each one’s app version. Anything still showing the older Greenhouse connection (pre-2.0.0, API-key based) is on borrowed time regardless of which specific trigger or action it uses.</li>
<li><strong>Open the connection settings and click Reconnect</strong>, per Zapier’s own instruction. This walks through Greenhouse’s OAuth 2.0 authorization screen rather than asking for an API key directly, so expect a real Greenhouse login/authorize prompt, not a paste-a-key field.</li>
<li><strong>Confirm the reconnected Zap picks up version 2.0.0</strong>, not just a refreshed token on the old version. The version shown in the app step is the signal that the underlying API surface actually changed from Harvest v1/v2 to v3, not just that the credential got refreshed.</li>
<li><strong>Don’t stop at reconnecting.</strong> A successful OAuth reconnect means the Zap can authenticate again. It says nothing about whether the specific trigger, action, or search fields still map correctly under Harvest v3, since v3 changes response shapes independently of the auth method.</li>
<li><strong>Do this before August 26, 2026</strong>, Zapier’s own cutoff for the old connection, five days ahead of Greenhouse’s own Harvest v1/v2 retirement on August 31.</li>
</ol>
<h2 id="confirmed-version">Confirmed version</h2>
<p>Sourced from Zapier’s official Help Center advisory on the Greenhouse Harvest API deprecation, published 2026-07-27, with the version, auth method, and both cutoff dates confirmed directly against that source. Browse more posts like this in the <a href="/data-automation">Data Automation</a> archive.</p>]]></content:encoded>
      <pubDate>Thu, 13 Aug 2026 00:00:00 GMT</pubDate>
    </item>
    <item>
      <title>Restrict GitHub-Hosted Runners to Named Runner Groups</title>
      <link>https://bytetech247.com/dev-tools/restrict-github-hosted-runners-named-runner-groups/</link>
      <guid isPermaLink="true">https://bytetech247.com/dev-tools/restrict-github-hosted-runners-named-runner-groups/</guid>
      <description>GitHub Team and Enterprise plans can disable standard runner labels like ubuntu-latest org-wide and force workflows through named runner groups instead.</description>
      <content:encoded><![CDATA[<aside class="quick-answer" aria-label="Quick answer"><h2 id="quick-answer">Quick Answer</h2>
<p>Disable standard GitHub-hosted runner labels and route workflows through named runner groups when you’re on a Team or Enterprise plan and need centralized control over which repos and orgs can request a shared runner. Keep standard labels enabled if you’re on Free or Pro, since the feature isn’t available there, or if your org has no reason to restrict who can spin up a <code>ubuntu-latest</code> job.</p>
</aside><h2 id="what-changed-and-who-its-for">What changed, and who it’s for</h2>
<p>Every GitHub-hosted runner label, <code>ubuntu-latest</code>, <code>windows-latest</code>, <code>macos-latest</code>, has always resolved to GitHub’s shared runner pool with no access control in front of it. Any workflow in any repo that can use Actions at all could request one. For most repos that’s exactly the point. For a large org managing cost, compliance, or which teams can spin up compute at all, it’s a gap: there was no way to say “only these repos get GitHub-hosted runners” without moving everything to self-hosted infrastructure.</p>
<p>GitHub’s changelog, published 2026-06-25:</p>
<blockquote>
<p>“Disable the standard labels for hosted runners such as <code>ubuntu-latest</code>”</p>
</blockquote>
<p>The same release also lets macOS runners join runner groups for the first time:</p>
<blockquote>
<p>“Add macOS runners to runner groups”</p>
</blockquote>
<p>Both land specifically for Team and Enterprise customers. Once an admin disables the standard labels at the organization or enterprise level, every job in every repo under that org has to target a runner group explicitly instead of a bare label, and that group’s own membership and permissions decide who actually gets a runner.</p>
<h2 id="structural-comparison-matrix">Structural Comparison Matrix</h2>






























<table tabindex="0"><thead><tr><th scope="col" style="text-align:left">Operational Aspect</th><th scope="col" style="text-align:left">Standard labels enabled (default)</th><th scope="col" style="text-align:left">Standard labels disabled, runner groups required</th></tr></thead><tbody><tr><td style="text-align:left"><strong>Who can request a GitHub-hosted runner</strong></td><td style="text-align:left">Any repo in the org with Actions enabled</td><td style="text-align:left">Only repos granted access to a specific runner group</td></tr><tr><td style="text-align:left"><strong><code>runs-on: ubuntu-latest</code> in an existing workflow</strong></td><td style="text-align:left">Resolves normally</td><td style="text-align:left">Fails to resolve; must be rewritten to target a group</td></tr><tr><td style="text-align:left"><strong>macOS runner access control</strong></td><td style="text-align:left">None; any workflow can request <code>macos-latest</code></td><td style="text-align:left">Restrictable per group, plus group-level concurrency limits</td></tr><tr><td style="text-align:left"><strong>Plan requirement</strong></td><td style="text-align:left">None</td><td style="text-align:left">Team or Enterprise</td></tr></tbody></table>
<h2 id="rewrite-runs-on-to-target-a-runner-group">Rewrite runs-on to target a runner group</h2>
<p>The syntax change is small, but it touches every job in every workflow that used a standard label. Before, a job referencing GitHub’s shared pool directly:</p>
<div class="code-block code-block--has-filename"><div class="code-block__header"><span class="code-block__filename">before — breaks once standard labels are disabled org-wide</span><button type="button" class="code-block__copy" data-copy-button>Copy</button></div><pre class="astro-code github-dark" style="background-color:#24292e;color:#e1e4e8;overflow-x:auto" tabindex="0" data-language="yaml" data-filename="before — breaks once standard labels are disabled org-wide"><code><span class="line"><span style="color:#85E89D">jobs</span><span style="color:#E1E4E8">:</span></span>
<span class="line"><span style="color:#85E89D">  build</span><span style="color:#E1E4E8">:</span></span>
<span class="line"><span style="color:#85E89D">    runs-on</span><span style="color:#E1E4E8">: </span><span style="color:#9ECBFF">ubuntu-latest</span></span></code></pre></div>
<p>After, the same job targeting a named runner group instead:</p>
<div class="code-block code-block--has-filename"><div class="code-block__header"><span class="code-block__filename">after — explicit runner group targeting</span><button type="button" class="code-block__copy" data-copy-button>Copy</button></div><pre class="astro-code github-dark" style="background-color:#24292e;color:#e1e4e8;overflow-x:auto" tabindex="0" data-language="yaml" data-filename="after — explicit runner group targeting"><code><span class="line"><span style="color:#85E89D">jobs</span><span style="color:#E1E4E8">:</span></span>
<span class="line"><span style="color:#85E89D">  build</span><span style="color:#E1E4E8">:</span></span>
<span class="line"><span style="color:#85E89D">    runs-on</span><span style="color:#E1E4E8">:</span></span>
<span class="line"><span style="color:#85E89D">      group</span><span style="color:#E1E4E8">: </span><span style="color:#9ECBFF">shared-ubuntu-runners</span></span></code></pre></div>
<p>If a job also needs a specific runner size or OS variant inside that group, <code>labels</code> combines with <code>group</code> in the same block, and a runner has to satisfy both to pick up the job:</p>
<div class="code-block code-block--has-filename"><div class="code-block__header"><span class="code-block__filename">group plus a label filter</span><button type="button" class="code-block__copy" data-copy-button>Copy</button></div><pre class="astro-code github-dark" style="background-color:#24292e;color:#e1e4e8;overflow-x:auto" tabindex="0" data-language="yaml" data-filename="group plus a label filter"><code><span class="line"><span style="color:#85E89D">jobs</span><span style="color:#E1E4E8">:</span></span>
<span class="line"><span style="color:#85E89D">  build</span><span style="color:#E1E4E8">:</span></span>
<span class="line"><span style="color:#85E89D">    runs-on</span><span style="color:#E1E4E8">:</span></span>
<span class="line"><span style="color:#85E89D">      group</span><span style="color:#E1E4E8">: </span><span style="color:#9ECBFF">shared-ubuntu-runners</span></span>
<span class="line"><span style="color:#85E89D">      labels</span><span style="color:#E1E4E8">: </span><span style="color:#9ECBFF">ubuntu-24.04-16core</span></span></code></pre></div>
<div class="callout" role="note" data-astro-cid-q2ml7llr><p class="callout__title" data-astro-cid-q2ml7llr><span aria-hidden="true" data-astro-cid-q2ml7llr>⚠</span>This is an org-wide switch, not a per-workflow opt-in</p><div class="callout__body" data-astro-cid-q2ml7llr><p>Disabling standard labels is configured at the organization or enterprise
level, in the runner groups settings, not per repository and not per workflow
file. Flip it before every affected workflow has been rewritten and every job
using a bare <code>ubuntu-latest</code>, <code>windows-latest</code>, or <code>macos-latest</code> label starts
failing to resolve a runner at once, not gradually.</p></div></div>
<h2 id="pair-this-with-layered-custom-runner-images">Pair this with layered custom runner images</h2>
<p>Runner groups control who can use a runner. A separate June 2026 release, <a href="/dev-tools/github-actions-layered-custom-runner-images">layering custom GitHub Actions runner images</a>, controls what’s already installed on the machine that runner boots from. An org adopting one is a natural candidate for the other: once workflows are already routed through named groups instead of standard labels, pointing a group at a custom image built specifically for it is a small additional step, and the two changes solve genuinely different problems that tend to show up on the same team’s roadmap together.</p>
<h2 id="does-this-affect-this-sites-own-workflows">Does this affect this site’s own workflows?</h2>
<p>No, and it’s worth being direct about that rather than stretching for a first-person claim. This site’s <code>.github/workflows/ci.yml</code> uses <code>runs-on: ubuntu-latest</code> directly in both its <code>build</code> and <code>deploy</code> jobs, and this site isn’t on a GitHub Team or Enterprise plan. Runner groups for GitHub-hosted runners aren’t something this repo can adopt today, and nothing here breaks from this change either, since nobody has disabled standard labels for it. This cluster matters for the dev-tools audience running Actions at organizational scale, where restricting who can request a shared runner is a real, common requirement this repo simply doesn’t have.</p>
<h2 id="confirmed-version">Confirmed version</h2>
<p>Sourced from <a href="https://github.blog/changelog/2026-06-25-more-control-over-your-github-hosted-runners/">GitHub’s changelog entry on hosted-runner controls</a>, published 2026-06-25, and corroborated against GitHub’s <a href="https://docs.github.com/en/actions/concepts/runners/runner-groups">runner groups documentation</a> and <a href="https://docs.github.com/actions/using-jobs/choosing-the-runner-for-a-job">choosing the runner for a job</a> reference for the <code>group</code>/<code>labels</code> <code>runs-on</code> syntax. Browse more posts like this in the <a href="/dev-tools">Dev Tools</a> archive.</p>]]></content:encoded>
      <pubDate>Thu, 13 Aug 2026 00:00:00 GMT</pubDate>
    </item>
    <item>
      <title>Vitest 5.0 Beta Breaking Changes: Full Guide</title>
      <link>https://bytetech247.com/guides-fixes/vitest-5-beta-breaking-changes/</link>
      <guid isPermaLink="true">https://bytetech247.com/guides-fixes/vitest-5-beta-breaking-changes/</guid>
      <description>Vitest 5.0 beta ships 10 breaking changes across mocking, config, and assertions. Full rundown, sourced to the official migration guide, plus fixes.</description>
      <content:encoded><![CDATA[<aside class="quick-answer" aria-label="Quick answer"><h2 id="quick-answer">Quick Answer</h2>
<p>Vitest 5.0 (currently beta, latest v5.0.0-beta.7) ships 10 breaking changes worth checking before you upgrade: two throw a new error outright (<code>vi.mock</code> scope enforcement, <code>expect.poll</code> timeouts), and eight change existing behavior or config shape silently. <code>clearMocks</code> defaulting to <code>true</code> and <code>toThrow(&#39;&#39;)</code> matching any error are the two most likely to change what your suite reports with zero visible code change. Check the table below against your own <code>vitest.config.ts</code> and test suite before upgrading.</p>
<p>Vitest is the test runner behind this site’s own <code>tests/unit</code> suite, currently pinned to <code>vitest@^3.0.0</code>. Vitest 5.0 is a real, substantial rework, not a routine bump: mocking defaults change, config inheritance changes, matcher behavior changes, and the benchmarking API is rewritten outright. None of the 10 posts below are speculative. Every one is sourced directly to <a href="https://main.vitest.dev/guide/migration">Vitest’s own official migration guide</a>, a continuously-maintained primary source, corroborated as active right now by the July 2026 beta release. This repo hasn’t upgraded yet, so nothing in this series claims a first-party reproduction - every technical claim traces back to Vitest’s own documentation, stated honestly as such throughout.</p>
</aside><h2 id="structural-comparison-matrix">Structural Comparison Matrix</h2>







































































<table tabindex="0"><thead><tr><th scope="col" style="text-align:left">#</th><th scope="col" style="text-align:left">Change</th><th scope="col" style="text-align:left">Throws or silent?</th><th scope="col" style="text-align:left">Applies to you if…</th></tr></thead><tbody><tr><td style="text-align:left">1</td><td style="text-align:left"><code>vi.mock</code> top-level scope enforcement</td><td style="text-align:left">Throws</td><td style="text-align:left">You call <code>vi.mock</code>/<code>vi.unmock</code>/<code>vi.hoisted</code> inside a describe/test block</td></tr><tr><td style="text-align:left">2</td><td style="text-align:left"><code>clearMocks</code> defaults to <code>true</code></td><td style="text-align:left">Silent</td><td style="text-align:left">Any suite that asserts on mock call counts across multiple tests</td></tr><tr><td style="text-align:left">3</td><td style="text-align:left"><code>test.sequential</code> / <code>describe.sequential</code> removed</td><td style="text-align:left">Throws (removed API)</td><td style="text-align:left">You use either option instead of <code>{ concurrent: false }</code></td></tr><tr><td style="text-align:left">4</td><td style="text-align:left">Config not found in parent directory</td><td style="text-align:left">Silent (CLI fails)</td><td style="text-align:left">You run <code>vitest</code> from a subdirectory relying on a parent config</td></tr><tr><td style="text-align:left">5</td><td style="text-align:left"><code>bench</code> no longer a top-level export</td><td style="text-align:left">Throws (removed API)</td><td style="text-align:left">You use Vitest’s benchmarking API</td></tr><tr><td style="text-align:left">6</td><td style="text-align:left">Unawaited async assertions fail</td><td style="text-align:left">Throws</td><td style="text-align:left">Your suite has an <code>expect(promise).resolves...</code> without <code>await</code></td></tr><tr><td style="text-align:left">7</td><td style="text-align:left"><code>expect.poll</code> rejects on timeout</td><td style="text-align:left">Throws</td><td style="text-align:left">You use <code>expect.poll()</code> to wait on an async condition</td></tr><tr><td style="text-align:left">8</td><td style="text-align:left">8 subpath entry points removed</td><td style="text-align:left">Throws (import error)</td><td style="text-align:left">You import from <code>vitest/reporters</code>, <code>vitest/coverage</code>, etc. directly</td></tr><tr><td style="text-align:left">9</td><td style="text-align:left"><code>toThrow(&#39;&#39;)</code> matches any error</td><td style="text-align:left">Silent</td><td style="text-align:left">Any test asserting <code>toThrow(&#39;&#39;)</code> expecting an empty-message-only match</td></tr><tr><td style="text-align:left">10</td><td style="text-align:left">Inline <code>projects</code> inherit root config</td><td style="text-align:left">Silent</td><td style="text-align:left">You use <code>test.projects</code> and relied on isolation from root config</td></tr></tbody></table>
<p>Every row is confirmed against Vitest’s own migration guide, not summarized from memory. Full mechanism, exact error text where one exists, and the fix live in each linked post below.</p>
<h2 id="the-10-changes-in-detail">The 10 changes, in detail</h2>
<ol>
<li><strong><a href="/guides-fixes/fix-vitest-5-vi-mock-top-level-scope-error/">Fix Vitest 5 vi.mock Top-Level Scope Error</a></strong>: <code>vi.mock</code>, <code>vi.unmock</code>, and <code>vi.hoisted</code> now throw if called outside module top level, instead of just warning.</li>
<li><strong><a href="/guides-fixes/fix-vitest-5-clearmocks-breaking-mock-state/">Fix Vitest 5 clearMocks Breaking Mock State</a></strong>: <code>clearMocks</code> flips to <code>true</code> by default, wiping mock call history before every test.</li>
<li><strong><a href="/guides-fixes/fix-vitest-5-test-sequential-removed-error/">Fix Vitest 5 test.sequential Removed Error</a></strong>: <code>test.sequential</code>/<code>describe.sequential</code> are gone; use <code>{ concurrent: false }</code> instead.</li>
<li><strong><a href="/guides-fixes/fix-vitest-5-config-not-found-parent-directory/">Fix Vitest 5 Config Not Found in Parent Directory</a></strong>: Vitest stops searching parent directories for a config file.</li>
<li><strong><a href="/guides-fixes/fix-vitest-5-bench-no-longer-top-level-export/">Fix Vitest 5 bench No Longer Top-Level Export</a></strong>: benchmarks move inside <code>test()</code> as a context fixture; several <code>benchmark.*</code> config options are removed.</li>
<li><strong><a href="/guides-fixes/fix-vitest-5-unawaited-async-assertion-error/">Fix Vitest 5 Unawaited Async Assertion Error</a></strong>: a forgotten <code>await</code> on an async assertion now fails the test instead of just warning.</li>
<li><strong><a href="/guides-fixes/fix-vitest-5-expect-poll-timeout-error/">Fix Vitest 5 expect.poll Timeout Error</a></strong>: <code>expect.poll()</code> actively rejects on timeout instead of letting a late result still pass.</li>
<li><strong><a href="/guides-fixes/fix-vitest-5-cannot-find-module-vitest-reporters/">Fix Vitest 5 Cannot Find Module vitest/reporters</a></strong>: eight subpath entry points (<code>vitest/reporters</code>, <code>vitest/coverage</code>, and more) are removed outright.</li>
<li><strong><a href="/guides-fixes/fix-vitest-5-tothrow-empty-string-silently-passing/">Fix Vitest 5 toThrow(”) Silently Passing Tests</a></strong>: an empty string is no longer special-cased, so it now matches any thrown error.</li>
<li><strong><a href="/guides-fixes/fix-vitest-5-projects-not-inheriting-root-config/">Fix Vitest 5 Projects Not Inheriting Root Config</a></strong>: inline <code>test.projects</code> entries now inherit root Vite plugins and <code>setupFiles</code> by default.</li>
</ol>
<h2 id="why-cover-a-beta-at-all">Why cover a beta at all</h2>
<p>Most pillars on this site cover a stable release most readers are already running. This one doesn’t, and that’s worth being upfront about rather than glossing over: Vitest 5.0 was still in beta (v5.0.0-beta.7, 2026-07-24) at the time this was researched, and this repo’s own test suite hasn’t upgraded past <code>vitest@^3.0.0</code>. The case for covering it anyway: the migration guide already documents 33 distinct breaking changes against a real, shipping beta, not a speculative RFC, and several of them (the mocking defaults especially) are exactly the kind of silent behavior change that’s worth knowing about before it surfaces as a confusing failure the week Vitest 5 goes stable and a dependency update pulls it in without anyone reading a changelog first.</p>
<p>If you’re evaluating whether to adopt the beta now versus wait for stable, that’s a real decision this guide doesn’t make for you - it only tries to make sure you’re not surprised by what’s actually in it either way.</p>
<p>Browse the rest of the <a href="/guides-fixes">Guides &amp; Fixes</a> archive for more framework and tooling breaking-change coverage.</p>]]></content:encoded>
      <pubDate>Thu, 13 Aug 2026 00:00:00 GMT</pubDate>
    </item>
    <item>
      <title>Zapier Functions Secrets: Move to API by Zapier</title>
      <link>https://bytetech247.com/data-automation/zapier-functions-secrets-move-to-api-by-zapier/</link>
      <guid isPermaLink="true">https://bytetech247.com/data-automation/zapier-functions-secrets-move-to-api-by-zapier/</guid>
      <description>Zapier Functions let you hardcode API keys in code. Code by Zapier doesn&apos;t. Move secrets to an API by Zapier connection before the September 1 shutdown.</description>
      <content:encoded><![CDATA[<aside class="quick-answer" aria-label="Quick answer"><h2 id="quick-answer">Quick Answer</h2>
<p>Zapier Functions let you hardcode API keys and tokens directly in Python source. Code by Zapier doesn’t support embedded secrets the same way. Credentials have to move into an API by Zapier connection and get referenced in code as <code>connections[&#39;api_by_zapier&#39;]</code>, matched to that connection’s Account ID Variable, before the 2026-09-01 Functions shutdown.</p>
</aside><h2 id="why-a-copy-paste-migration-is-actively-risky-here">Why a copy-paste migration is actively risky here</h2>
<p>The two earlier posts in this series cover the code-syntax fix (<a href="/data-automation/migrate-zapier-functions-python-code-to-fetch/"><code>requests</code> to <code>fetch</code></a>) and the architecture fix (<a href="/data-automation/zapier-functions-split-multi-trigger-functions/">splitting multi-trigger functions</a>). This one is different in kind: it’s a credentials problem, and getting it wrong doesn’t just break a Zap, it can leave a live secret exposed.</p>
<p>Zapier Functions ran on an execution model where a Python script could hold an API key as a plain string, right in the code, and use it directly in a <code>requests</code> call header. <a href="https://help.zapier.com/hc/en-us/articles/45230556598157">Zapier’s migration guide</a>, the same article covering the other two changes in this series, updated 2026-07-13, doesn’t describe Code by Zapier’s runtime as supporting that pattern the same way. Credentials instead route through <strong>API by Zapier</strong>, a dedicated connection type, referenced from code with a specific binding:</p>
<div class="code-block code-block--has-filename"><div class="code-block__header"><span class="code-block__filename">referencing a stored connection from Code by Zapier</span><button type="button" class="code-block__copy" data-copy-button>Copy</button></div><pre class="astro-code github-dark" style="background-color:#24292e;color:#e1e4e8;overflow-x:auto" tabindex="0" data-language="javascript" data-filename="referencing a stored connection from Code by Zapier"><code><span class="line"><span style="color:#F97583">const</span><span style="color:#79B8FF"> api</span><span style="color:#F97583"> =</span><span style="color:#E1E4E8"> zapier.apps.</span><span style="color:#B392F0">api_by_zapier</span><span style="color:#E1E4E8">({</span></span>
<span class="line"><span style="color:#E1E4E8">  connectionId: connections[</span><span style="color:#9ECBFF">&quot;api_by_zapier&quot;</span><span style="color:#E1E4E8">],</span></span>
<span class="line"><span style="color:#E1E4E8">});</span></span></code></pre></div>
<p>The guide is specific about what has to line up:</p>
<blockquote>
<p>“<code>connections[&#39;api_by_zapier&#39;]</code> must match the Account ID Variable for your API by Zapier connection.”</p>
</blockquote>
<p>Get that mismatched, and the code step fails at the connection lookup, before it even reaches the API call the credential was meant to authenticate.</p>
<div class="callout" role="note" data-astro-cid-q2ml7llr><p class="callout__title" data-astro-cid-q2ml7llr><span aria-hidden="true" data-astro-cid-q2ml7llr>⚠</span>A hardcoded key left in place is the worst outcome, not a safe fallback</p><div class="callout__body" data-astro-cid-q2ml7llr><p>If old Function code with an embedded key gets copy-pasted straight into a
Code by Zapier step instead of migrated to a connection, two things can
happen: the call fails because the runtime doesn’t handle it the way Functions
did, or it “works” for now with a live credential sitting in plaintext inside
a Zap step, visible to anyone with edit access to that Zap. Neither is an
acceptable stopping point.</p></div></div>
<h2 id="structural-comparison-matrix">Structural Comparison Matrix</h2>






























<table tabindex="0"><thead><tr><th scope="col" style="text-align:left">Operational Aspect</th><th scope="col" style="text-align:left">Zapier Functions</th><th scope="col" style="text-align:left">Code by Zapier</th></tr></thead><tbody><tr><td style="text-align:left"><strong>Where credentials live</strong></td><td style="text-align:left">Hardcoded in Python source</td><td style="text-align:left">API by Zapier connection</td></tr><tr><td style="text-align:left"><strong>How code accesses them</strong></td><td style="text-align:left">Direct string reference in the script</td><td style="text-align:left"><code>connections[&#39;api_by_zapier&#39;]</code> bound to the Account ID Variable</td></tr><tr><td style="text-align:left"><strong>Exposure if migrated wrong</strong></td><td style="text-align:left">N/A</td><td style="text-align:left">Plaintext key visible in the Code step to any editor</td></tr><tr><td style="text-align:left"><strong>Matching requirement</strong></td><td style="text-align:left">None</td><td style="text-align:left">Binding value must match the connection’s Account ID Variable</td></tr></tbody></table>
<h2 id="fix-it-create-the-connection-before-touching-the-code">Fix it: create the connection before touching the code</h2>
<ol>
<li>Set up an <strong>API by Zapier</strong> connection for the credential the old Function used, giving it an Account ID Variable you’ll reference from code.</li>
<li>In the Code by Zapier step, replace every hardcoded key reference with the <code>connections[&#39;api_by_zapier&#39;]</code> binding shown above, matched to that variable.</li>
<li>Delete the hardcoded string from the source entirely, not just from the line that used it, in case it was assigned to a variable reused elsewhere in the same file.</li>
<li>Test the step against a real call that requires the credential, confirming the connection resolves and the authenticated request succeeds.</li>
</ol>
<div class="code-block code-block--has-filename"><div class="code-block__header"><span class="code-block__filename">before: hardcoded key (do not carry this forward)</span><button type="button" class="code-block__copy" data-copy-button>Copy</button></div><pre class="astro-code github-dark" style="background-color:#24292e;color:#e1e4e8;overflow-x:auto" tabindex="0" data-language="javascript" data-filename="before: hardcoded key (do not carry this forward)"><code><span class="line"><span style="color:#9ca6b0">// Old Functions code - DO NOT copy into Code by Zapier as-is:</span></span>
<span class="line"><span style="color:#9ca6b0">// const apiKey = &quot;sk_live_...&quot; (hardcoded, plaintext in the script)</span></span></code></pre></div>
<div class="code-block code-block--has-filename"><div class="code-block__header"><span class="code-block__filename">after: credential via API by Zapier connection</span><button type="button" class="code-block__copy" data-copy-button>Copy</button></div><pre class="astro-code github-dark" style="background-color:#24292e;color:#e1e4e8;overflow-x:auto" tabindex="0" data-language="javascript" data-filename="after: credential via API by Zapier connection"><code><span class="line"><span style="color:#F97583">const</span><span style="color:#79B8FF"> api</span><span style="color:#F97583"> =</span><span style="color:#E1E4E8"> zapier.apps.</span><span style="color:#B392F0">api_by_zapier</span><span style="color:#E1E4E8">({</span></span>
<span class="line"><span style="color:#E1E4E8">  connectionId: connections[</span><span style="color:#9ECBFF">&quot;api_by_zapier&quot;</span><span style="color:#E1E4E8">],</span></span>
<span class="line"><span style="color:#E1E4E8">});</span></span>
<span class="line"><span style="color:#9ca6b0">// apiKey is no longer a literal string anywhere in this file</span></span></code></pre></div>
<div class="callout" role="note" data-astro-cid-q2ml7llr><p class="callout__title" data-astro-cid-q2ml7llr><span aria-hidden="true" data-astro-cid-q2ml7llr>ℹ</span>This site doesn&#39;t run Zapier</p><div class="callout__body" data-astro-cid-q2ml7llr><p>As stated across this series: bytetech247.com’s own automation runs on
Cloudflare Workers and GitHub Actions, not Zapier (see <a href="/data-automation/automate-static-site-deploys-github-actions-cloudflare-workers/">this site’s actual
deploy
automation</a>).
This fix is drawn directly from Zapier’s own published migration guide, not
from having migrated a hardcoded key in our own automation.</p></div></div>
<p>This is the last of three distinct problems in the same Zapier Functions migration guide. <a href="/data-automation/migrate-zapier-functions-python-code-to-fetch/">The HTTP/runtime port</a> and <a href="/data-automation/zapier-functions-split-multi-trigger-functions/">the trigger-splitting requirement</a> each need their own fix; none of the three substitutes for the others, and a Function that used all three patterns needs all three fixes applied before the 2026-09-01 shutdown.</p>
<h2 id="confirmed-version">Confirmed version</h2>
<p>Sourced from <a href="https://help.zapier.com/hc/en-us/articles/45230556598157">Zapier’s official Help Center migration guide for Zapier Functions</a>, updated 2026-07-13. Browse more posts like this in the <a href="/data-automation">Data Automation</a> archive.</p>]]></content:encoded>
      <pubDate>Thu, 13 Aug 2026 00:00:00 GMT</pubDate>
    </item>
    <item>
      <title>Zapier Functions: Split Multi-Trigger Functions</title>
      <link>https://bytetech247.com/data-automation/zapier-functions-split-multi-trigger-functions/</link>
      <guid isPermaLink="true">https://bytetech247.com/data-automation/zapier-functions-split-multi-trigger-functions/</guid>
      <description>Code by Zapier allows one trigger per Zap. A Zapier Function with multiple triggers has to split into separate Zaps before the September 1, 2026 shutdown.</description>
      <content:encoded><![CDATA[<aside class="quick-answer" aria-label="Quick answer"><h2 id="quick-answer">Quick Answer</h2>
<p>A single Zapier Function could handle multiple trigger events in one function file. Code by Zapier has no equivalent: each Zap supports exactly one trigger. A multi-trigger Function has to be split into one Zap per trigger event before the 2026-09-01 shutdown, with shared logic either duplicated across those Zaps or pulled into a callable sub-Zap.</p>
</aside><h2 id="why-this-is-an-architecture-problem-not-a-syntax-one">Why this is an architecture problem, not a syntax one</h2>
<p><a href="/data-automation/migrate-zapier-functions-python-code-to-fetch/">The previous post in this series</a> covers rewriting HTTP calls from Python’s <code>requests</code> to JavaScript’s <code>fetch</code>. That’s a code-level fix: same logic, different syntax, same Zap structure. This one is different. It’s about the shape of the Zap itself.</p>
<p><a href="https://help.zapier.com/hc/en-us/articles/45230556598157">Zapier’s own migration guide</a>, updated 2026-07-13, states the constraint directly:</p>
<blockquote>
<p>“Each function trigger becomes a separate Zap trigger. If your function had multiple triggers, create one Zap per trigger or share code logic in a sub-Zap.”</p>
</blockquote>
<p>A Zapier Function could be written to respond to more than one kind of event, for example, one function handling both a “record created” case and a “record updated” case inside the same file, branching on which event fired. Code by Zapier doesn’t carry that flexibility forward. Every Zap in Zapier’s automation model has exactly one trigger, full stop, and that hasn’t changed as part of this migration. A function built around multiple triggers doesn’t have a single-Zap home to migrate into.</p>
<div class="callout" role="note" data-astro-cid-q2ml7llr><p class="callout__title" data-astro-cid-q2ml7llr><span aria-hidden="true" data-astro-cid-q2ml7llr>⚠</span>The silent-failure version of this problem</p><div class="callout__body" data-astro-cid-q2ml7llr><p>Converting the code for one trigger event and calling the migration done is
the easy mistake here. The Zap runs, the tests for that one trigger pass, and
everything looks migrated. Meanwhile, whatever the original function did in
response to its other trigger events has no Zap running it at all, so those
code paths stop firing with nothing in the Zap history to flag it as a gap,
since there’s no longer a Zap for it to fail in.</p></div></div>
<h2 id="structural-comparison-matrix">Structural Comparison Matrix</h2>






























<table tabindex="0"><thead><tr><th scope="col" style="text-align:left">Operational Aspect</th><th scope="col" style="text-align:left">Zapier Functions</th><th scope="col" style="text-align:left">Code by Zapier</th></tr></thead><tbody><tr><td style="text-align:left"><strong>Triggers per code unit</strong></td><td style="text-align:left">One function, potentially multiple triggers</td><td style="text-align:left">One Zap, exactly one trigger</td></tr><tr><td style="text-align:left"><strong>Multi-event logic</strong></td><td style="text-align:left">Branches inside a single function file</td><td style="text-align:left">Split across separate Zaps</td></tr><tr><td style="text-align:left"><strong>Shared logic across events</strong></td><td style="text-align:left">Shared within the same file</td><td style="text-align:left">Duplicated per Zap, or extracted into a sub-Zap</td></tr><tr><td style="text-align:left"><strong>Migration risk</strong></td><td style="text-align:left">N/A</td><td style="text-align:left">Only the first migrated trigger is obviously visible</td></tr></tbody></table>
<h2 id="fix-it-map-every-trigger-before-splitting-anything">Fix it: map every trigger before splitting anything</h2>
<p>Start by listing every distinct trigger event the original Function actually responded to, not just the one that happens to be top of mind. That list is the number of Zaps this migration needs, not one Zap with a converted first branch.</p>
<div class="code-block code-block--has-filename"><div class="code-block__header"><span class="code-block__filename">one Zapier Function -&gt; multiple Zaps</span><button type="button" class="code-block__copy" data-copy-button>Copy</button></div><pre class="astro-code github-dark" style="background-color:#24292e;color:#e1e4e8;overflow-x:auto" tabindex="0" data-language="text" data-filename="one Zapier Function -> multiple Zaps"><code><span class="line"><span>Original function.py:</span></span>
<span class="line"><span>  handle_record_created(payload)  -&gt; logic A</span></span>
<span class="line"><span>  handle_record_updated(payload)  -&gt; logic A (shared) + logic B</span></span>
<span class="line"><span></span></span>
<span class="line"><span>Migrated structure:</span></span>
<span class="line"><span>  Zap 1: trigger = Record Created  -&gt; Code step running logic A</span></span>
<span class="line"><span>  Zap 2: trigger = Record Updated  -&gt; Code step running logic A + logic B</span></span></code></pre></div>
<p>For the shared logic (<code>logic A</code> in the example above), Zapier’s guide gives two options: duplicate it into the Code step of each split Zap, or extract it into a sub-Zap that both Zaps call. Duplication is faster to set up and fine for logic that’s genuinely small and stable. A sub-Zap is the better call when that shared logic is likely to change, since a future fix only needs to land in one place instead of being copied into every Zap that split off the original function.</p>
<div class="callout" role="note" data-astro-cid-q2ml7llr><p class="callout__title" data-astro-cid-q2ml7llr><span aria-hidden="true" data-astro-cid-q2ml7llr>ℹ</span>This site doesn&#39;t run Zapier</p><div class="callout__body" data-astro-cid-q2ml7llr><p>Same honesty note as the rest of this series: bytetech247.com’s own automation
runs on Cloudflare Workers and GitHub Actions, not Zapier (see <a href="/data-automation/automate-static-site-deploys-github-actions-cloudflare-workers/">this site’s
actual deploy
automation</a>).
This walkthrough follows Zapier’s own published migration guide, not a
first-person account of splitting our own Functions.</p></div></div>
<p>Once every trigger has its own Zap, test each one independently with a real event, not just the trigger that was easiest to verify. A Zap that “looks migrated” because its trigger fires correctly can still be missing the shared logic branch that only runs on the second or third event type.</p>
<p><a href="/data-automation/migrate-zapier-functions-python-code-to-fetch/">Two other distinct problems</a> live in this same migration guide: converting HTTP calls from <code>requests</code> to <code>fetch</code>, and <a href="/data-automation/zapier-functions-secrets-move-to-api-by-zapier/">moving hardcoded secrets to an API by Zapier connection</a>. Splitting triggers correctly doesn’t fix either of those on its own.</p>
<h2 id="confirmed-version">Confirmed version</h2>
<p>Sourced from <a href="https://help.zapier.com/hc/en-us/articles/45230556598157">Zapier’s official Help Center migration guide for Zapier Functions</a>, updated 2026-07-13. Browse more posts like this in the <a href="/data-automation">Data Automation</a> archive.</p>]]></content:encoded>
      <pubDate>Thu, 13 Aug 2026 00:00:00 GMT</pubDate>
    </item>
    <item>
      <title>Zapier&apos;s 2026 Deprecation Wave: Full Guide</title>
      <link>https://bytetech247.com/data-automation/zapiers-2026-api-deprecation-wave/</link>
      <guid isPermaLink="true">https://bytetech247.com/data-automation/zapiers-2026-api-deprecation-wave/</guid>
      <description>Zapier&apos;s Pipedrive, Functions, ChatGPT, and Greenhouse integrations all hit API deprecation deadlines in 2026. Full rundown of what breaks and the fix.</description>
      <content:encoded><![CDATA[<aside class="quick-answer" aria-label="Quick answer"><h2 id="quick-answer">Quick Answer</h2>
<p>Between 2026-05-29 and 2026-07-27, Zapier published four distinct “action required” advisories covering integrations whose underlying APIs are sunsetting: Pipedrive’s V1 API (deadline 2026-07-31), Zapier’s own built-in Functions feature (shuts down 2026-09-01), OpenAI’s Assistants API powering several ChatGPT actions (deadline 2026-08-26), and Greenhouse’s Harvest API v1/v2 (deadline 2026-08-26). A tenth, older HubSpot break rounds out the wave. None of these fail loudly on their own — Zapier either relabels a step, auto-migrates it into a disabled state, or says nothing at all, so a Zap can sit silently broken well past the deadline with no alert. Check the table below against what your own Zaps actually call.</p>
<p>This site (bytetech247.com) doesn’t run Zapier — its own automation is Cloudflare Workers and GitHub Actions, covered in this category’s other pillar, <a href="/data-automation/cloudflares-july-2026-api-deprecation-wave/">Cloudflare’s July 2026 API Deprecation Wave</a>. Worth saying plainly rather than implying otherwise: this pillar exists because it’s the same structural shape (automation breaking silently when the API underneath it sunsets) on a different, extremely widely used platform, not because this site operates these Zaps itself. Every fact below traces to Zapier’s own Help Center or Community advisories, not a remembered or assumed behavior.</p>
</aside><h2 id="structural-comparison-matrix">Structural Comparison Matrix</h2>


















































































<table tabindex="0"><thead><tr><th scope="col" style="text-align:left">#</th><th scope="col" style="text-align:left">Integration</th><th scope="col" style="text-align:left">What breaks</th><th scope="col" style="text-align:left">Deadline</th><th scope="col" style="text-align:left">Signal type</th></tr></thead><tbody><tr><td style="text-align:left">1</td><td style="text-align:left">Pipedrive (steps)</td><td style="text-align:left">V1-backed steps relabeled, cosmetic until fixed</td><td style="text-align:left">2026-07-31</td><td style="text-align:left">Literal <code>[DEPRECATING...]</code> marker in step name</td></tr><tr><td style="text-align:left">2</td><td style="text-align:left">Pipedrive (data)</td><td style="text-align:left">V2 API returns fewer fields (e.g. <code>deal_title</code>)</td><td style="text-align:left">2026-07-31</td><td style="text-align:left">Silent missing-field regression</td></tr><tr><td style="text-align:left">3</td><td style="text-align:left">Zapier Functions (runtime)</td><td style="text-align:left">Python <code>requests</code>-based HTTP calls need <code>fetch()</code></td><td style="text-align:left">2026-09-01</td><td style="text-align:left">Feature shutdown, migration guide</td></tr><tr><td style="text-align:left">4</td><td style="text-align:left">Zapier Functions (triggers)</td><td style="text-align:left">Multi-trigger functions must split into separate Zaps</td><td style="text-align:left">2026-09-01</td><td style="text-align:left">Architectural, no error until missed</td></tr><tr><td style="text-align:left">5</td><td style="text-align:left">Zapier Functions (secrets)</td><td style="text-align:left">Hardcoded credentials must move to a connection</td><td style="text-align:left">2026-09-01</td><td style="text-align:left">Silent auth failure or exposed secret</td></tr><tr><td style="text-align:left">6</td><td style="text-align:left">ChatGPT (Conversation)</td><td style="text-align:left">Auto-migrated but left switched off pending review</td><td style="text-align:left">2026-08-26</td><td style="text-align:left">Zap silently disabled</td></tr><tr><td style="text-align:left">7</td><td style="text-align:left">ChatGPT (Assistant management)</td><td style="text-align:left">No auto-migration path, full rebuild required</td><td style="text-align:left">2026-08-26</td><td style="text-align:left">“No direct replacement”</td></tr><tr><td style="text-align:left">8</td><td style="text-align:left">Greenhouse (auth)</td><td style="text-align:left">API-key auth replaced by OAuth 2.0</td><td style="text-align:left">2026-08-26</td><td style="text-align:left">Auth-layer failure on old connection</td></tr><tr><td style="text-align:left">9</td><td style="text-align:left">Greenhouse (data)</td><td style="text-align:left">Harvest v3 field/schema changes across multiple steps</td><td style="text-align:left">2026-08-26</td><td style="text-align:left">No single marker, manual cross-check needed</td></tr><tr><td style="text-align:left">10</td><td style="text-align:left">HubSpot</td><td style="text-align:left">v1 Lists API sunset, no dedicated advisory</td><td style="text-align:left">2026-04-30 (already past)</td><td style="text-align:left">Buried in a monthly community digest</td></tr></tbody></table>
<p>Every row traces to Zapier’s own Help Center or Community advisory for that integration, quoted with its real update date, not summarized from memory.</p>
<h2 id="the-10-breaks-in-detail">The 10 breaks, in detail</h2>
<ol>
<li><strong><a href="/data-automation/pipedrive-zaps-tagged-deprecating-july-31-2026/">Fix Pipedrive Zaps Tagged [DEPRECATING JULY 31 2026]</a></strong>: Zapier relabels affected steps with a literal marker, but the step keeps running on V1 until you manually remap it.</li>
<li><strong><a href="/data-automation/pipedrive-v2-api-drops-fields-like-deal-title/">Pipedrive V2 API Drops Fields Like deal_title</a></strong>: remapping the step alone isn’t enough — V2 returns less data, so downstream steps can silently read <code>undefined</code>.</li>
<li><strong><a href="/data-automation/migrate-zapier-functions-python-code-to-fetch/">Migrate Zapier Functions Python Code to fetch()</a></strong>: Functions shuts down entirely; authenticated HTTP calls need <code>API by Zapier</code>, not a naive <code>fetch()</code> swap.</li>
<li><strong><a href="/data-automation/zapier-functions-split-multi-trigger-functions/">Zapier Functions: Split Multi-Trigger Functions</a></strong>: a function with multiple triggers has to become multiple Zaps — Code by Zapier allows only one trigger per Zap.</li>
<li><strong><a href="/data-automation/zapier-functions-secrets-move-to-api-by-zapier/">Zapier Functions Secrets Move to API by Zapier</a></strong>: hardcoded API keys have to move into a proper connection, or risk a live credential sitting in plaintext.</li>
<li><strong><a href="/data-automation/fix-chatgpt-conversation-with-assistant-migration/">Fix ChatGPT Conversation With Assistant Migration</a></strong>: Zapier auto-migrates this action but leaves the Zap switched off until someone manually reviews and re-enables it.</li>
<li><strong><a href="/data-automation/rebuild-chatgpt-create-assistant-zaps/">Rebuild ChatGPT Create Assistant Zaps by Aug 26</a></strong>: four actions have no automatic migration path at all — a full rebuild around the Responses API, not a remap.</li>
<li><strong><a href="/data-automation/reconnect-greenhouse-zaps-to-oauth-2-0/">Reconnect Greenhouse Zaps to OAuth 2.0 by Aug 26</a></strong>: the new Greenhouse app version authenticates differently, so every step on the old connection fails at the auth layer until reconnected.</li>
<li><strong><a href="/data-automation/fix-greenhouse-zaps-for-harvest-v3-field-changes/">Fix Greenhouse Zaps for Harvest v3 Field Changes</a></strong>: separate from the auth problem, a payload/schema issue across a specific list of triggers, actions, and searches.</li>
<li><strong><a href="/data-automation/fix-hubspot-add-contact-to-list-after-v1-sunset/">Fix HubSpot Add Contact to List After V1 Sunset</a></strong>: the weakest-sourced break in this pillar — no dedicated advisory exists, only one line in a monthly digest, which is itself part of the problem.</li>
</ol>
<h2 id="why-this-happened-across-one-summer">Why this happened across one summer</h2>
<p>Zapier sits on top of hundreds of third-party APIs it doesn’t control, and 2026 saw several of them retire older API versions within weeks of each other: Pipedrive’s V1-to-V2 migration, OpenAI’s Assistants-API deprecation in favor of the Responses API, and Greenhouse’s Harvest v1/v2 sunset all landed inside the same rough window, alongside Zapier’s own decision to shut down its Functions feature. None of the dates or quoted marker text above are estimated — they come directly from <a href="https://help.zapier.com/hc/en-us/categories/13951101412877-Product-updates">Zapier’s Help Center</a> and Community advisories, cross-checked per cluster against the individual article’s own stated update date.</p>
<p>Browse the rest of the <a href="/data-automation">Data Automation</a> archive for more pipeline and API-breakage coverage.</p>]]></content:encoded>
      <pubDate>Thu, 13 Aug 2026 00:00:00 GMT</pubDate>
    </item>
    <item>
      <title>The 2026 LLM Token &amp; Pricing Reset: Full Guide</title>
      <link>https://bytetech247.com/ai-productivity/2026-llm-token-pricing-reset/</link>
      <guid isPermaLink="true">https://bytetech247.com/ai-productivity/2026-llm-token-pricing-reset/</guid>
      <description>GPT-5.6, Claude Opus 4.8, and Gemini 3.6 all changed token limits and pricing in 2026. Full breakdown of what changed, and how to check your own numbers.</description>
      <content:encoded><![CDATA[<aside class="quick-answer" aria-label="Quick answer"><h2 id="quick-answer">Quick Answer</h2>
<p>GPT-5.6, Claude Opus 4.8, and Gemini 3.6 all shipped within roughly the same 90-day window in 2026, and each one changed token counts, pricing, or both. This is the hub for a 10-part series covering every piece of that reset: context windows, prompt-caching economics, tokenizer changes, and model shutdown dates. Start with the comparison table below, then check your own prompts with the free AI Token Counter.</p>
</aside><h2 id="why-one-window-three-providers-and-a-stale-comparison">Why one window, three providers, and a stale comparison</h2>
<p>A cost estimate for calling GPT, Claude, or Gemini is really two numbers multiplied together: how many tokens your prompt turns into, and what each token costs. Most comparisons only track the second number, because it is the one that shows up on a pricing page. Between April and July 2026, OpenAI, Anthropic, and Google each shipped an update that moved one or both numbers, independently of each other and without any shared timing.</p>
<p>That is what makes this a reset rather than three unrelated announcements. A cost comparison written in early 2026, before any of these three updates shipped, is now wrong on two separate axes at once for every provider it covers. This hub exists to lay out exactly what moved, provider by provider, sourced and dated, so the comparison does not have to be taken on faith.</p>
<h2 id="structural-comparison-matrix">Structural Comparison Matrix</h2>

































<table tabindex="0"><thead><tr><th scope="col" style="text-align:left">Provider</th><th scope="col" style="text-align:left">Token-count change</th><th scope="col" style="text-align:left">Price change</th><th scope="col" style="text-align:left">Shipped</th><th scope="col" style="text-align:left">How confirmed</th></tr></thead><tbody><tr><td style="text-align:left"><strong>OpenAI GPT-5.6</strong></td><td style="text-align:left">Near-1M context window (up to 922K input, 128K output) on the <code>o200k_base</code> encoding</td><td style="text-align:left">Prompt caching moved from free and implicit to explicit breakpoints, with a 1.25x premium on cache writes</td><td style="text-align:left">GA 2026-07-09</td><td style="text-align:left">Paraphrased, aggregated from model-tracking sources, not OpenAI’s own announcement post directly</td></tr><tr><td style="text-align:left"><strong>Anthropic Claude Opus 4.8</strong></td><td style="text-align:left">Tokenizer inherited from Opus 4.7 counts up to ~35% more tokens for the same text than pre-4.7 models</td><td style="text-align:left">Base price unchanged ($5/$25 per MTok); Fast Mode cut from $30/$150 to $10/$50 per MTok</td><td style="text-align:left">2026-05-28</td><td style="text-align:left">Price paraphrased (third-party pricing coverage); tokenizer claim paraphrased, <strong>not</strong> in Anthropic’s own official GA changelog</td></tr><tr><td style="text-align:left"><strong>Google Gemini 3.6 Flash</strong></td><td style="text-align:left">Uses 17% fewer output tokens than Gemini 3.5 Flash for comparable tasks</td><td style="text-align:left">$0.75 / $3.75 per million input/output tokens now (through 2026-12-31), rising to $1.50 / $7.50 in 2027, 1M-token context window</td><td style="text-align:left">2026-07-21</td><td style="text-align:left">Paraphrased (token reduction) / confirmed on Google’s own pricing page (both price points)</td></tr></tbody></table>
<p>One number in this table is confirmed against a primary source rather than paraphrased from secondary coverage: OpenAI’s own deprecations page states a minimum six-month retirement notice for generally available models, and separately lists <code>gpt-4</code>, <code>o1</code>, and <code>o4-mini</code> shutting down October 23, 2026, with <code>gpt-5.6-sol</code> and <code>gpt-5.6-terra</code> as the named replacements. Everything else above is labeled honestly as paraphrased, because it’s aggregated from third-party tracking and reporting, not each provider’s own primary documentation.</p>
<h2 id="what-changed-provider-by-provider">What changed, provider by provider</h2>
<p><strong>OpenAI</strong> widened GPT-5.6’s context window to roughly 1.05M tokens, a figure that matches <a href="https://platform.openai.com/docs/models">OpenAI’s own model documentation</a>, and switched to the <code>o200k_base</code> encoding, the same one GPT-4o introduced, not the older <code>cl100k_base</code> encoding GPT-3.5 and GPT-4 used. It also ended free, automatic prompt caching in favor of explicit cache breakpoints, a 1.25x premium on cache writes, and a 30-minute minimum cache lifetime, while keeping the roughly 90% discount on cache reads. Underneath that, the older <code>gpt-4</code>, <code>o1</code>, and <code>o4-mini</code> model IDs stop responding entirely on October 23, 2026, a hard cutover, not a soft warning.</p>
<p><strong>Anthropic</strong> kept Claude Opus 4.8’s base price identical to Opus 4.7 ($5 per million input tokens, $25 per million output tokens, both confirmed on <a href="https://docs.claude.com/en/docs/about-claude/pricing">Anthropic’s own pricing page</a>) while cutting Fast Mode from $30/$150 to $10/$50 per million tokens. The harder number to pin down is the tokenizer: third-party technical coverage reports that the tokenizer Opus 4.7 introduced, and that 4.8 inherited unchanged, counts up to roughly 35% more tokens for the same input text than the tokenizer pre-4.7 models used. Anthropic’s own official GA changelog for Opus 4.7 does not mention a tokenizer change at all, so treat that figure as reported, not confirmed.</p>
<p><strong>Google</strong> launched Gemini 3.6 Flash at $0.75 per million input tokens and $3.75 per million output tokens, confirmed directly on <a href="https://ai.google.dev/gemini-api/docs/pricing">Google’s own Gemini API pricing page</a>, with a 1M-token context window, and reports it uses 17% fewer output tokens than Gemini 3.5 Flash for comparable tasks. That same page states the rate rises to $1.50 input / $7.50 output on 2027-01-01 — a scheduled increase, not the current price, worth flagging separately since an earlier version of the linked post below stated the future rate as if it were already in effect. Two cost levers are moving at once here regardless of which date applies: a lower per-token rate, and fewer tokens spent per task, which a sticker-price-only comparison misses entirely.</p>
<h2 id="the-10-pieces-of-this-reset">The 10 pieces of this reset</h2>
<p>This hub is the starting point for a 10-part series. All ten pieces are published below.</p>
<ol>
<li><strong><a href="/ai-productivity/gpt-5-6-context-window-922k-tokens/">GPT-5.6’s Context Window: 922K In, 128K Out Tokens</a></strong>: the new near-1M window and the <code>o200k_base</code> encoding switch.</li>
<li><strong><a href="/ai-productivity/gpt-5-6-prompt-cache-write-premium/">GPT-5.6 Ends Free Prompt-Cache Writes (1.25x Premium)</a></strong>: explicit cache breakpoints replace free, automatic caching.</li>
<li><strong><a href="/ai-productivity/openai-prompt-cache-24-hour-retention/">OpenAI Extends Prompt Cache Retention to 24 Hours</a></strong>: a much longer window for repeated-call workflows to hit the cache.</li>
<li><strong><a href="/ai-productivity/gpt-4-o1-o4-mini-shutdown-october-2026/">gpt-4, o1, and o4-mini Shut Down October 23, 2026</a></strong>: the one primary-sourced date in this series, straight from OpenAI’s own deprecations page.</li>
<li><strong><a href="/ai-productivity/claude-opus-4-8-fast-mode-pricing/">Claude Opus 4.8: Same Price, Cheaper Fast Mode</a></strong>: base pricing unchanged, Fast Mode cut roughly in half.</li>
<li><strong><a href="/ai-productivity/claudes-tokenizer-counts-up-to-35-percent-more/">Claude’s New Tokenizer Counts Up to 35% More</a></strong>: a reported change Anthropic’s own GA changelog doesn’t confirm.</li>
<li><strong><a href="/ai-productivity/claude-opus-4-7-breaks-temperature-top-p-params/">Claude Opus 4.7 Breaks temperature and top_p Params</a></strong>: a reported breaking change for a common sampling-parameter combination.</li>
<li><strong><a href="/ai-productivity/gemini-3-6-flash-output-tokens-price-cut/">Gemini 3.6 Flash Cuts Output Tokens 17%, Price Too</a></strong>: two cost levers moving independently, not one.</li>
<li><strong><a href="/ai-productivity/openai-vs-claude-prompt-caching-cost-math/">OpenAI vs Claude: Prompt Caching Cost Math in 2026</a></strong>: why a single discount percentage no longer describes either provider’s caching economics.</li>
<li><strong><a href="/ai-productivity/same-prompt-different-bill-gpt-claude-gemini/">Same Prompt, Different Bill: GPT-5.6 vs Claude vs Gemini</a></strong>: the closing synthesis, tying every provider’s change into one cost picture.</li>
</ol>
<h2 id="check-your-own-numbers-before-you-budget">Check your own numbers before you budget</h2>
<p>None of the figures above should be the last word on your own costs. Token counts depend on your actual prompts, and every provider’s own API response carries the real, billed count in its <code>usage</code> (or, for Gemini, <code>usageMetadata</code>) field, more reliable than any estimate in this post. If you want a fast check before committing to a call, this site’s <a href="/tools/ai-token-counter/">AI Token Counter</a> gives an exact GPT count next to clearly labeled Claude and Gemini estimates, side by side, entirely in your browser, with no API key required — and the <a href="/tools/llm-pricing-calculator/">LLM Pricing Calculator</a> turns those counts into an actual side-by-side dollar comparison across all three providers, cache math included.</p>
<p>Treat this hub, and every post in this series, as a snapshot with a real date attached, not a permanent reference. Re-check pricing and token counts against each provider’s current documentation before a budget review, not just once when a model first ships.</p>
<p>Browse more coverage like this in the <a href="/ai-productivity">AI Productivity</a> archive.</p>]]></content:encoded>
      <pubDate>Wed, 12 Aug 2026 00:00:00 GMT</pubDate>
    </item>
    <item>
      <title>Claude Opus 4.7 Breaks temperature and top_p Params</title>
      <link>https://bytetech247.com/ai-productivity/claude-opus-4-7-breaks-temperature-top-p-params/</link>
      <guid isPermaLink="true">https://bytetech247.com/ai-productivity/claude-opus-4-7-breaks-temperature-top-p-params/</guid>
      <description>Claude Opus 4.7 returns HTTP 400 for temperature, top_p, top_k, and budget_tokens in some configurations. What changed and how to fix it.</description>
      <content:encoded><![CDATA[<aside class="quick-answer" aria-label="Quick answer"><h2 id="quick-answer">Quick Answer</h2>
<p>Third-party technical coverage reports that Claude Opus 4.7 introduced a breaking change: passing <code>temperature</code>, <code>top_p</code>, <code>top_k</code>, or <code>budget_tokens</code> together in some configurations now returns an HTTP 400 error instead of a response. Anthropic’s own GA changelog doesn’t confirm this. If a previously-working call suddenly errors after upgrading, check these parameters first.</p>
</aside><h2 id="whats-actually-changing">What’s actually changing</h2>
<p>Third-party technical coverage of Opus 4.7, the same source cluster reporting the tokenizer change covered separately in this series, reports that certain sampling parameters now trigger HTTP 400 errors under some configurations: <code>temperature</code>, <code>top_p</code>, <code>top_k</code>, and <code>budget_tokens</code>, a restriction independently confirmed for <code>temperature</code>, <code>top_p</code>, and <code>top_k</code> in <a href="https://docs.claude.com/en/release-notes/api">Anthropic’s own API release notes</a>. Previously valid combinations of these parameters are reported to fail outright rather than degrade gracefully or get silently ignored.</p>
<p>As with the tokenizer claim, this is explicitly not confirmed by Anthropic’s own official GA changelog for Opus 4.7 (2026-04-16), which does not mention this behavior. Treat the exact trigger conditions as reported, not verified, and confirm against your own test calls before assuming a specific parameter combination is the cause.</p>
<h2 id="structural-comparison-matrix">Structural Comparison Matrix</h2>

























<table tabindex="0"><thead><tr><th scope="col" style="text-align:left">Operational Aspect</th><th scope="col" style="text-align:left">Opus 4.6 and earlier</th><th scope="col" style="text-align:left">Opus 4.7+</th></tr></thead><tbody><tr><td style="text-align:left"><strong><code>temperature</code> / <code>top_p</code> / <code>top_k</code> together</strong></td><td style="text-align:left">Accepted</td><td style="text-align:left">Reported to return HTTP 400 in some configurations</td></tr><tr><td style="text-align:left"><strong><code>budget_tokens</code></strong></td><td style="text-align:left">Accepted</td><td style="text-align:left">Reported to return HTTP 400 in some configurations</td></tr><tr><td style="text-align:left"><strong>Anthropic’s own GA changelog</strong></td><td style="text-align:left">N/A</td><td style="text-align:left">Does not mention this behavior</td></tr></tbody></table>
<h2 id="fix-it-isolate-the-failing-parameter-combination">Fix it: isolate the failing parameter combination</h2>
<p>If a request that worked before an Opus 4.7 upgrade now returns HTTP 400, the fastest path is elimination, not guessing. Strip <code>temperature</code>, <code>top_p</code>, <code>top_k</code>, and <code>budget_tokens</code> down to the minimum your call actually needs, then add them back one at a time against a real test request until the error reappears. That isolates the specific combination your integration is hitting, since the reported behavior is not confirmed to be identical across every configuration.</p>
<p>Once identified, the fix is usually straightforward: drop the redundant parameter rather than fight the error. Sending both <code>temperature</code> and <code>top_p</code> on the same request was already redundant in most sampling setups, since both control the same underlying randomness in different ways; picking one instead of both often resolves the error without changing the model’s actual output behavior much.</p>
<p>If you’re also re-checking cost impact while you’re in here, the <a href="/tools/llm-pricing-calculator/">LLM Pricing Calculator</a> covers Claude Opus 4.7’s current pricing and caching math separately from this parameter issue.</p>
<h2 id="confirmed-version">Confirmed version</h2>
<p>Sourced from the same third-party technical coverage of Opus 4.7 that reports the tokenizer change, not Anthropic’s own materials directly. GA date 2026-04-16 confirmed via GitHub’s official changelog, which does not itself confirm this specific claim. Browse more coverage in the <a href="/ai-productivity">AI Productivity</a> archive, or start from <a href="/ai-productivity/2026-llm-token-pricing-reset/">The 2026 LLM Token &amp; Pricing Reset</a> hub.</p>]]></content:encoded>
      <pubDate>Wed, 12 Aug 2026 00:00:00 GMT</pubDate>
    </item>
    <item>
      <title>Claude Opus 4.8: Same Price, Cheaper Fast Mode</title>
      <link>https://bytetech247.com/ai-productivity/claude-opus-4-8-fast-mode-pricing/</link>
      <guid isPermaLink="true">https://bytetech247.com/ai-productivity/claude-opus-4-8-fast-mode-pricing/</guid>
      <description>Claude Opus 4.8 kept base pricing at $5/$25 per MTok but cut Fast Mode from $30/$150 to $10/$50. What that means for latency-sensitive calls.</description>
      <content:encoded><![CDATA[<aside class="quick-answer" aria-label="Quick answer"><h2 id="quick-answer">Quick Answer</h2>
<p>Claude Opus 4.8 launched at the same base price as Opus 4.7: $5 per million input tokens, $25 per million output tokens. Fast Mode dropped from $30/$150 to $10/$50 per million tokens, a real cut for latency-sensitive workloads. If Fast Mode looked too expensive against 4.7, re-run the math against 4.8’s numbers.</p>
</aside><h2 id="whats-actually-changing">What’s actually changing</h2>
<p>Claude Opus 4.8 launched 2026-05-28 at the same base pricing as its predecessor: $5 per million input tokens and $25 per million output tokens, according to third-party pricing-tracking coverage (finout.io), not confirmed against Anthropic’s own pricing page directly. On its own, that reads as no change at all.</p>
<p>Fast Mode tells a different story. The same source reports Fast Mode’s price fell from $30 per million input tokens and $150 per million output tokens under Opus 4.7, to $10/$50 under Opus 4.8, a figure that matches <a href="https://docs.claude.com/en/docs/about-claude/pricing">Anthropic’s own Claude pricing page</a>. That is a real reduction in a mode specifically built for latency-sensitive workloads that pay a premium to get responses faster.</p>
<h2 id="structural-comparison-matrix">Structural Comparison Matrix</h2>

























<table tabindex="0"><thead><tr><th scope="col" style="text-align:left">Operational Aspect</th><th scope="col" style="text-align:left">Opus 4.7</th><th scope="col" style="text-align:left">Opus 4.8</th></tr></thead><tbody><tr><td style="text-align:left"><strong>Base price (input/output per MTok)</strong></td><td style="text-align:left">$5 / $25</td><td style="text-align:left">$5 / $25 (unchanged)</td></tr><tr><td style="text-align:left"><strong>Fast Mode price (input/output per MTok)</strong></td><td style="text-align:left">$30 / $150</td><td style="text-align:left">$10 / $50</td></tr><tr><td style="text-align:left"><strong>Tokenizer</strong></td><td style="text-align:left">Introduced in this version</td><td style="text-align:left">Inherited unchanged from Opus 4.7</td></tr></tbody></table>
<h2 id="fix-it-re-run-the-fast-mode-math">Fix it: re-run the Fast Mode math</h2>
<p>Anyone who evaluated Fast Mode against Opus 4.7’s $30/$150 pricing and decided the latency benefit wasn’t worth the cost has a real reason to revisit that decision under Opus 4.8. A workload that pays for Fast Mode specifically to cut response time now does so at roughly a third of the previous rate, which changes the break-even point against standard mode meaningfully, not marginally.</p>
<p>Base pricing staying flat is worth noting for a different reason: it means the price cut is isolated to Fast Mode specifically, not a general Opus 4.8 discount. Don’t assume standard-mode costs moved just because Fast Mode did.</p>
<p>The <a href="/tools/llm-pricing-calculator/">LLM Pricing Calculator</a> has both Opus 4.7 and 4.8, standard and Fast Mode, as selectable options — a faster way to re-run this exact break-even math against your own token counts than doing it by hand.</p>
<div class="callout" role="note" data-astro-cid-q2ml7llr><p class="callout__title" data-astro-cid-q2ml7llr><span aria-hidden="true" data-astro-cid-q2ml7llr>⚠</span>Same price doesn&#39;t mean same cost per prompt</p><div class="callout__body" data-astro-cid-q2ml7llr><p>Opus 4.8 inherited Opus 4.7’s tokenizer unchanged, and that tokenizer is
reported to count noticeably more tokens for the same text than older models.
See <a href="/ai-productivity/claudes-tokenizer-counts-up-to-35-percent-more/">Claude’s New Tokenizer Counts Up to 35%
More</a> before
assuming your cost per prompt is unchanged.</p></div></div>
<h2 id="confirmed-version">Confirmed version</h2>
<p>Both the base-price figure and the Fast Mode figures are paraphrased from finout.io’s Claude Opus 4.8 pricing breakdown, tied to the model’s 2026-05-28 launch, not confirmed against Anthropic’s own pricing page directly. Browse more coverage in the <a href="/ai-productivity">AI Productivity</a> archive, or start from <a href="/ai-productivity/2026-llm-token-pricing-reset/">The 2026 LLM Token &amp; Pricing Reset</a> hub.</p>]]></content:encoded>
      <pubDate>Wed, 12 Aug 2026 00:00:00 GMT</pubDate>
    </item>
    <item>
      <title>Claude&apos;s New Tokenizer Counts Up to 35% More</title>
      <link>https://bytetech247.com/ai-productivity/claudes-tokenizer-counts-up-to-35-percent-more/</link>
      <guid isPermaLink="true">https://bytetech247.com/ai-productivity/claudes-tokenizer-counts-up-to-35-percent-more/</guid>
      <description>Claude Opus 4.7&apos;s tokenizer reportedly counts up to 35% more tokens for the same text. Anthropic&apos;s own GA changelog doesn&apos;t confirm it.</description>
      <content:encoded><![CDATA[<aside class="quick-answer" aria-label="Quick answer"><h2 id="quick-answer">Quick Answer</h2>
<p>Third-party technical coverage reports that Opus 4.7’s tokenizer counts up to roughly 35% more tokens for the same input text than pre-4.7 models. Anthropic’s own official GA changelog does not mention a tokenizer change at all. Same price per token does not mean same cost per prompt if the token count itself went up; verify against your own prompts.</p>
</aside><h2 id="whats-actually-changing">What’s actually changing</h2>
<p>Third-party technical coverage (byteiota.com, developersdigest.tech) reports that Claude Opus 4.7 shipped an updated tokenizer counting roughly 1.0 to 1.35 times as many tokens, up to about 35% more, for the same input text compared to the tokenizer pre-4.7 models used. <a href="https://docs.claude.com/en/docs/about-claude/pricing">Anthropic’s own pricing documentation</a> corroborates a smaller figure for the same underlying change:</p>
<blockquote>
<p>The newer tokenizer produces approximately 30% more tokens for the same text.</p>
</blockquote>
<p>That claim is explicitly not confirmed by Anthropic’s own materials. A direct fetch of Anthropic’s official GitHub changelog announcing Opus 4.7’s general availability (github.blog/changelog/2026-04-16-claude-opus-4-7-is-generally-available, dated 2026-04-16) contains no mention of a tokenizer or token-count change anywhere, only general performance claims. Stating that gap plainly matters more here than almost anywhere else in this series: a wrong number repeated with confidence does more damage than an honest “unconfirmed.”</p>
<h2 id="structural-comparison-matrix">Structural Comparison Matrix</h2>

























<table tabindex="0"><thead><tr><th scope="col" style="text-align:left">Operational Aspect</th><th scope="col" style="text-align:left">Pre-4.7 tokenizer</th><th scope="col" style="text-align:left">Opus 4.7+ tokenizer</th></tr></thead><tbody><tr><td style="text-align:left"><strong>Tokens for identical text</strong></td><td style="text-align:left">Baseline</td><td style="text-align:left">Up to ~35% more (reported, not confirmed)</td></tr><tr><td style="text-align:left"><strong>Anthropic’s own GA changelog mentions this</strong></td><td style="text-align:left">N/A</td><td style="text-align:left">No</td></tr><tr><td style="text-align:left"><strong>Effective cost per prompt at unchanged $/MTok</strong></td><td style="text-align:left">Baseline</td><td style="text-align:left">Higher, if the reported figure holds</td></tr></tbody></table>
<h2 id="why-this-matters-even-if-the-price-sheet-looks-unchanged">Why this matters even if the price sheet looks unchanged</h2>
<p><a href="/ai-productivity/claude-opus-4-8-fast-mode-pricing/">Claude Opus 4.8 kept the same base price as Opus 4.7</a>, and Opus 4.8 inherited this same tokenizer unchanged, confirmed still in effect as of the 4.8 launch on 2026-05-28. That combination is exactly the trap a sticker-price comparison misses: a rate card that looks identical to the previous version can still produce a higher bill, if the same text is quietly tokenizing to a larger number under the hood.</p>
<div class="callout" role="note" data-astro-cid-q2ml7llr><p class="callout__title" data-astro-cid-q2ml7llr><span aria-hidden="true" data-astro-cid-q2ml7llr>ℹ</span>Verify with a real call, not an estimate</p><div class="callout__body" data-astro-cid-q2ml7llr><p>This site’s own <a href="/tools/ai-token-counter/">AI Token Counter</a>
labels its Claude count as an estimate, not an exact figure, because Anthropic
doesn’t publish a client-side tokenizer. If the 35% claim matters to your
budget, compare the <code>input_tokens</code> field in a real Messages API
response against your own prior numbers rather than relying on any estimate,
including this site’s. The 
<a href="/tools/llm-pricing-calculator/">LLM Pricing Calculator</a> has a
toggle for this exact adjustment, so you can see the dollar impact of the 35%
claim directly instead of just the token-count difference.</p></div></div>
<h2 id="confirmed-version">Confirmed version</h2>
<p>The 35% figure is sourced to byteiota.com and developersdigest.tech’s technical coverage of Opus 4.7, not Anthropic directly. The 2026-04-16 GA date is confirmed via GitHub’s official changelog, which does <strong>not</strong> itself mention the tokenizer change. Persistence into Opus 4.8 is per finout.io’s 2026-05-28 coverage. Browse more coverage in the <a href="/ai-productivity">AI Productivity</a> archive, or start from <a href="/ai-productivity/2026-llm-token-pricing-reset/">The 2026 LLM Token &amp; Pricing Reset</a> hub.</p>]]></content:encoded>
      <pubDate>Wed, 12 Aug 2026 00:00:00 GMT</pubDate>
    </item>
    <item>
      <title>Gemini 3.6 Flash Cuts Output Tokens 17%, Price Too</title>
      <link>https://bytetech247.com/ai-productivity/gemini-3-6-flash-output-tokens-price-cut/</link>
      <guid isPermaLink="true">https://bytetech247.com/ai-productivity/gemini-3-6-flash-output-tokens-price-cut/</guid>
      <description>Gemini 3.6 Flash uses 17% fewer output tokens than 3.5 Flash and costs $0.75/$3.75 per million tokens through 2026, rising to $1.50/$7.50 in 2027.</description>
      <content:encoded><![CDATA[<aside class="quick-answer" aria-label="Quick answer"><h2 id="quick-answer">Quick Answer</h2>
<p>Gemini 3.6 Flash reportedly uses 17% fewer output tokens than Gemini 3.5 Flash for comparable tasks, and launched at $0.75 per million input tokens and $3.75 per million output tokens, with a 1M-token context window. That current rate holds through 2026-12-31 — Google’s own pricing page confirms it rises to $1.50 and $7.50 on 2027-01-01. That’s two cost levers moving at once: a lower per-token rate, and fewer tokens spent per task.</p>
<div class="callout" role="note" data-astro-cid-q2ml7llr><p class="callout__title" data-astro-cid-q2ml7llr><span aria-hidden="true" data-astro-cid-q2ml7llr>⚠</span>Correction</p><div class="callout__body" data-astro-cid-q2ml7llr><p>An earlier version of this post stated Gemini 3.6 Flash’s price as $1.50 input
/ $7.50 output per million tokens — that is the rate scheduled to take effect
2027-01-01, not the price actually in effect today. The figures throughout
this post now reflect the current 2026 rate, with the 2027 change stated
separately and dated.</p></div></div>
</aside><h2 id="whats-actually-changing">What’s actually changing</h2>
<p>Tech-press launch coverage (9to5google.com, “Google launches Gemini 3.6 Flash and 3.5 Flash-Lite,” dated 2026-07-21) reports that Gemini 3.6 Flash uses 17% fewer output tokens than Gemini 3.5 Flash for comparable tasks. That figure is sourced to reputable tech-press coverage, not Google’s own blog post directly, and is stated honestly as such.</p>
<p>Separately, and confirmed directly against <a href="https://ai.google.dev/gemini-api/docs/pricing">Google’s own Gemini API pricing page</a>, Gemini 3.6 Flash launched at $0.75 per million input tokens and $3.75 per million output tokens, with a 1M-token context window. That page also states the rate rises to $1.50 input / $7.50 output on 2027-01-01 — a scheduled increase, not a current price, and worth budgeting around separately if a project’s usage extends past that date.</p>
<h2 id="structural-comparison-matrix">Structural Comparison Matrix</h2>



































<table tabindex="0"><thead><tr><th scope="col" style="text-align:left">Operational Aspect</th><th scope="col" style="text-align:left">Gemini 3.5 Flash</th><th scope="col" style="text-align:left">Gemini 3.6 Flash (now, through 2026-12-31)</th><th scope="col" style="text-align:left">Gemini 3.6 Flash (from 2027-01-01)</th></tr></thead><tbody><tr><td style="text-align:left"><strong>Output tokens for comparable tasks</strong></td><td style="text-align:left">Baseline</td><td style="text-align:left">~17% fewer (reported)</td><td style="text-align:left">~17% fewer (reported)</td></tr><tr><td style="text-align:left"><strong>Input price (per million tokens)</strong></td><td style="text-align:left">Not part of the sourced figures for this post</td><td style="text-align:left">$0.75</td><td style="text-align:left">$1.50</td></tr><tr><td style="text-align:left"><strong>Output price (per million tokens)</strong></td><td style="text-align:left">Not part of the sourced figures for this post</td><td style="text-align:left">$3.75</td><td style="text-align:left">$7.50</td></tr><tr><td style="text-align:left"><strong>Context window</strong></td><td style="text-align:left">Not part of the sourced figures for this post</td><td style="text-align:left">1M tokens</td><td style="text-align:left">1M tokens</td></tr></tbody></table>
<h2 id="why-two-levers-matter-more-than-one">Why two levers matter more than one</h2>
<p>A comparison that only checks the per-token rate misses half the picture here. If Gemini 3.6 Flash really does produce fewer output tokens for the same task, the cost saving compounds: a lower rate applied to a smaller token count, not just a lower rate applied to the same count as before. Two providers can advertise similar-looking per-token prices and still produce meaningfully different bills for the same workload, once actual token counts per task are accounted for.</p>
<p>That is exactly the trap a sticker-price-only comparison falls into, and exactly why <a href="/ai-productivity/same-prompt-different-bill-gpt-claude-gemini/">Same Prompt, Different Bill</a> treats token count and price per token as two separate numbers to check, not one.</p>
<div class="callout" role="note" data-astro-cid-q2ml7llr><p class="callout__title" data-astro-cid-q2ml7llr><span aria-hidden="true" data-astro-cid-q2ml7llr>✓</span>Measure your own task, not the reported average</p><div class="callout__body" data-astro-cid-q2ml7llr><p>A 17% average reduction across “comparable tasks” may not match your specific
prompt shape. Run your actual prompts through the free 
<a href="/tools/ai-token-counter/">AI Token Counter</a> to see your own
estimated Gemini token counts before assuming the reported figure applies
directly to your workload, then use the 
<a href="/tools/llm-pricing-calculator/">LLM Pricing Calculator</a> to turn
that count into an actual dollar figure against the current 2026 rate above.</p></div></div>
<h2 id="confirmed-version">Confirmed version</h2>
<p>Pricing (both the current 2026 rate and the scheduled 2027-01-01 change) is confirmed directly against <a href="https://ai.google.dev/gemini-api/docs/pricing">Google’s own Gemini API pricing page</a>, re-checked 2026-08-13. The 17% output-token reduction claim is sourced to 9to5google.com’s launch coverage, dated 2026-07-21 — a reputable tech-press source, but not Google’s own blog post directly, and labeled as such throughout. Browse more coverage in the <a href="/ai-productivity">AI Productivity</a> archive, or start from <a href="/ai-productivity/2026-llm-token-pricing-reset/">The 2026 LLM Token &amp; Pricing Reset</a> hub.</p>]]></content:encoded>
      <pubDate>Wed, 12 Aug 2026 00:00:00 GMT</pubDate>
    </item>
    <item>
      <title>gpt-4, o1, and o4-mini Shut Down October 23, 2026</title>
      <link>https://bytetech247.com/ai-productivity/gpt-4-o1-o4-mini-shutdown-october-2026/</link>
      <guid isPermaLink="true">https://bytetech247.com/ai-productivity/gpt-4-o1-o4-mini-shutdown-october-2026/</guid>
      <description>OpenAI&apos;s gpt-4, o1, and o4-mini model IDs stop responding on October 23, 2026. The exact deprecation notice, and how to migrate to GPT-5.6.</description>
      <content:encoded><![CDATA[<aside class="quick-answer" aria-label="Quick answer"><h2 id="quick-answer">Quick Answer</h2>
<p>OpenAI’s <code>gpt-4</code>, <code>gpt-4-0613</code>, <code>o1</code>, <code>o1-pro</code>, and <code>o4-mini</code> model IDs stop responding entirely on October 23, 2026, per OpenAI’s own deprecations page. This is a hard cutover, not a warning: calls to these model IDs will error after that date. The named replacements are <code>gpt-5.6-sol</code> and <code>gpt-5.6-terra</code>.</p>
</aside><h2 id="whats-actually-changing">What’s actually changing</h2>
<p>This is the one figure in this series confirmed directly against a primary source, not paraphrased from third-party coverage. <a href="https://platform.openai.com/docs/deprecations">OpenAI’s own deprecations page</a> states its retirement policy in plain terms:</p>
<blockquote>
<p>“Unless safety or compliance concerns require a faster timeline, we provide the following minimum notice periods before model retirement: Generally available models: At least 6 months.”</p>
</blockquote>
<p>Under the “2026-04-22: Legacy GPT model snapshots” notice on that same page, <code>gpt-4</code>, <code>gpt-4-0613</code>, <code>o1</code>, <code>o1-pro</code>, and <code>o4-mini</code> are listed with a shutdown date of October 23, 2026, with <code>gpt-5.6-sol</code> and <code>gpt-5.6-terra</code> named as the direct replacements. That is roughly six months of notice from the 2026-04-22 posting date, consistent with OpenAI’s own stated minimum.</p>
<h2 id="structural-comparison-matrix">Structural Comparison Matrix</h2>






























<table tabindex="0"><thead><tr><th scope="col" style="text-align:left">Operational Aspect</th><th scope="col" style="text-align:left">Before (until 2026-10-23)</th><th scope="col" style="text-align:left">After (2026-10-23 onward)</th></tr></thead><tbody><tr><td style="text-align:left"><strong><code>gpt-4</code> / <code>gpt-4-0613</code> calls</strong></td><td style="text-align:left">Respond normally</td><td style="text-align:left">Return errors</td></tr><tr><td style="text-align:left"><strong><code>o1</code> / <code>o1-pro</code> calls</strong></td><td style="text-align:left">Respond normally</td><td style="text-align:left">Return errors</td></tr><tr><td style="text-align:left"><strong><code>o4-mini</code> calls</strong></td><td style="text-align:left">Respond normally</td><td style="text-align:left">Return errors</td></tr><tr><td style="text-align:left"><strong>Recommended model</strong></td><td style="text-align:left">Any of the above</td><td style="text-align:left"><code>gpt-5.6-sol</code> or <code>gpt-5.6-terra</code></td></tr></tbody></table>
<h2 id="fix-it-migrate-before-the-cutover-not-after">Fix it: migrate before the cutover, not after</h2>
<p>A hard cutover means there is no graceful fallback once October 23, 2026 passes: a call to a retired model ID fails, full stop. The practical fix is a model-string swap to <code>gpt-5.6-sol</code> or <code>gpt-5.6-terra</code>, but treat that as the start of the migration, not the whole thing.</p>
<p><code>gpt-5.6-sol</code> and <code>gpt-5.6-terra</code> use the <code>o200k_base</code> encoding, not the <code>cl100k_base</code> encoding the retiring models used, which changes token counts for identical prompts. See <a href="/ai-productivity/gpt-5-6-context-window-922k-tokens/">GPT-5.6’s Context Window</a> for what that means for chunking and cost estimation. GPT-5.6 also changed prompt-caching behavior, covered in <a href="/ai-productivity/gpt-5-6-prompt-cache-write-premium/">GPT-5.6 Ends Free Prompt-Cache Writes</a>, so a migration that only swaps the model string and skips re-checking caching configuration will get a real, avoidable cost surprise.</p>
<div class="callout" role="note" data-astro-cid-q2ml7llr><p class="callout__title" data-astro-cid-q2ml7llr><span aria-hidden="true" data-astro-cid-q2ml7llr>⚠</span>Check your own numbers before the deadline</p><div class="callout__body" data-astro-cid-q2ml7llr><p>Use the free <a href="/tools/ai-token-counter/">AI Token Counter</a> to see
how your actual prompts tokenize under the newer encoding before you migrate,
not after, then run those counts through the 
<a href="/tools/llm-pricing-calculator/">LLM Pricing Calculator</a> to see
what the migration actually costs against GPT-5.6’s current rates.</p></div></div>
<h2 id="confirmed-version">Confirmed version</h2>
<p>Fetched directly from <a href="https://developers.openai.com/api/docs/deprecations">OpenAI’s own API deprecations page</a>, notice dated 2026-04-22, shutdown date 2026-10-23. This is a primary source, not third-party reporting. Browse more coverage in the <a href="/ai-productivity">AI Productivity</a> archive, or start from <a href="/ai-productivity/2026-llm-token-pricing-reset/">The 2026 LLM Token &amp; Pricing Reset</a> hub.</p>]]></content:encoded>
      <pubDate>Wed, 12 Aug 2026 00:00:00 GMT</pubDate>
    </item>
    <item>
      <title>GPT-5.6&apos;s Context Window: 922K In, 128K Out Tokens</title>
      <link>https://bytetech247.com/ai-productivity/gpt-5-6-context-window-922k-tokens/</link>
      <guid isPermaLink="true">https://bytetech247.com/ai-productivity/gpt-5-6-context-window-922k-tokens/</guid>
      <description>GPT-5.6 shipped a ~1.05M-token context window (922K input, 128K output) on the o200k_base encoding. What changes for existing integrations.</description>
      <content:encoded><![CDATA[<aside class="quick-answer" aria-label="Quick answer"><h2 id="quick-answer">Quick Answer</h2>
<p>GPT-5.6 reached general availability with a context window reported at roughly 1.05M tokens: up to 922K input tokens and 128K output tokens. It uses the <code>o200k_base</code> encoding, not the older <code>cl100k_base</code> encoding GPT-3.5 and GPT-4 used. Re-check any chunking logic sized for a smaller window, and any token counter tied to the old encoding.</p>
</aside><h2 id="whats-actually-changing">What’s actually changing</h2>
<p>GPT-5.6 (the Sol, Terra, and Luna variants) reached general availability on 2026-07-09 with a context window aggregated across multiple 2026 model-tracking sources at roughly 1.05M tokens, split into up to 922K input tokens and 128K output tokens, figures that match <a href="https://platform.openai.com/docs/models">OpenAI’s own model documentation</a>. That figure is paraphrased from third-party tracking, not confirmed against OpenAI’s own primary announcement post directly, and is stated honestly as such here.</p>
<p>What is more mechanically certain: GPT-5.6 shares the <code>o200k_base</code> encoding that GPT-4o introduced, not the <code>cl100k_base</code> encoding that GPT-3.5 and the GPT-4 family used. A prompt that tokenizes to a given count under <code>cl100k_base</code> does not tokenize to the same count under <code>o200k_base</code>. Any system that estimates cost or truncates input based on a <code>cl100k_base</code> count will be wrong for GPT-5.6 calls specifically.</p>
<h2 id="structural-comparison-matrix">Structural Comparison Matrix</h2>

























<table tabindex="0"><thead><tr><th scope="col" style="text-align:left">Operational Aspect</th><th scope="col" style="text-align:left">Before (GPT-4 family)</th><th scope="col" style="text-align:left">After (GPT-5.6)</th></tr></thead><tbody><tr><td style="text-align:left"><strong>Context window</strong></td><td style="text-align:left">128K-200K tokens</td><td style="text-align:left">~1.05M tokens (922K input, 128K output)</td></tr><tr><td style="text-align:left"><strong>Tokenizer encoding</strong></td><td style="text-align:left"><code>cl100k_base</code></td><td style="text-align:left"><code>o200k_base</code></td></tr><tr><td style="text-align:left"><strong>Chunking/summarization threshold</strong></td><td style="text-align:left">Sized for ~128K</td><td style="text-align:left">Needs re-sizing for a near-1M window</td></tr></tbody></table>
<h2 id="fix-it-re-check-chunking-logic-and-token-counting">Fix it: re-check chunking logic and token counting</h2>
<p>A near-1M context window changes the entire “when do I need to chunk or summarize” calculus for any integration built against the smaller GPT-4-family windows. Code that split documents into 100K-token chunks to stay under an older limit may no longer need to split at all, which is a real architecture simplification, not just a bigger number to note.</p>
<p>The encoding change matters just as much for cost estimation. If your integration counts tokens client-side before sending a request, whether to enforce a budget or warn a user, that logic needs an <code>o200k_base</code>-aware tokenizer for GPT-5.6 calls specifically. This site’s own <a href="/tools/ai-token-counter/">AI Token Counter</a> currently implements <code>cl100k_base</code> only, accurate for GPT-3.5 and GPT-4-family models but not for GPT-5.6, and that limitation is disclosed directly on the tool’s own comparison table rather than left for a reader to discover after the fact. Once you have a real count, the <a href="/tools/llm-pricing-calculator/">LLM Pricing Calculator</a> turns it into an actual cost across GPT-5.6’s four pricing tiers.</p>
<div class="callout" role="note" data-astro-cid-q2ml7llr><p class="callout__title" data-astro-cid-q2ml7llr><span aria-hidden="true" data-astro-cid-q2ml7llr>ℹ</span>Same window, different bill</p><div class="callout__body" data-astro-cid-q2ml7llr><p>A bigger context window does not mean cheaper calls. See <a href="/ai-productivity/same-prompt-different-bill-gpt-claude-gemini/">Same Prompt,
Different
Bill</a> for how
GPT-5.6’s pricing and caching changes interact with this larger window.</p></div></div>
<h2 id="confirmed-version">Confirmed version</h2>
<p>The <code>o200k_base</code> encoding and the shared GPT-4o lineage are well-documented technical facts. The specific 922K input / 128K output split is paraphrased, aggregated from 2026 model-tracking sources (including Wikipedia’s GPT-5.6 entry and wavespeed.ai’s release-date tracking), general-availability date 2026-07-09, not confirmed against OpenAI’s own announcement post directly. Browse more coverage in the <a href="/ai-productivity">AI Productivity</a> archive, or start from <a href="/ai-productivity/2026-llm-token-pricing-reset/">The 2026 LLM Token &amp; Pricing Reset</a> hub.</p>]]></content:encoded>
      <pubDate>Wed, 12 Aug 2026 00:00:00 GMT</pubDate>
    </item>
    <item>
      <title>GPT-5.6 Ends Free Prompt-Cache Writes (1.25x Premium)</title>
      <link>https://bytetech247.com/ai-productivity/gpt-5-6-prompt-cache-write-premium/</link>
      <guid isPermaLink="true">https://bytetech247.com/ai-productivity/gpt-5-6-prompt-cache-write-premium/</guid>
      <description>GPT-5.6 replaced OpenAI&apos;s free implicit prompt caching with explicit breakpoints and a 1.25x write premium. What changes in your integration.</description>
      <content:encoded><![CDATA[<aside class="quick-answer" aria-label="Quick answer"><h2 id="quick-answer">Quick Answer</h2>
<p>GPT-5.6 replaced OpenAI’s free, automatic prompt caching with explicit cache breakpoints, a 1.25x premium on cache writes, and a 30-minute minimum cache lifetime. Cache reads still get roughly the same 90% discount. If your integration relied on caching happening automatically, it now needs explicit breakpoint configuration to get any discount at all.</p>
</aside><h2 id="whats-actually-changing">What’s actually changing</h2>
<p>Third-party cost-analysis coverage reports that GPT-5.6 changed prompt caching from a free, invisible optimization into an explicit, configured one. Previously, OpenAI cached repeated prompt prefixes automatically at no extra cost. Under GPT-5.6, a request has to declare cache breakpoints explicitly, and writing to the cache for the first time now carries a 1.25x premium over the standard input-token rate. This detail is paraphrased from third-party cost analysis (effloow.com), tied to GPT-5.6’s 2026-07-09 general availability, not confirmed against OpenAI’s own primary pricing documentation directly.</p>
<p>The cache’s minimum lifetime also moved to 30 minutes. Cache reads keep roughly the same 90% discount off the standard input-token rate that caching offered before this change, so the discount itself did not shrink, only the path to earning it did, details now documented directly on <a href="https://platform.openai.com/docs/guides/prompt-caching">OpenAI’s own prompt caching guide</a>.</p>
<h2 id="structural-comparison-matrix">Structural Comparison Matrix</h2>






























<table tabindex="0"><thead><tr><th scope="col" style="text-align:left">Operational Aspect</th><th scope="col" style="text-align:left">Before (pre-GPT-5.6 caching)</th><th scope="col" style="text-align:left">After (GPT-5.6)</th></tr></thead><tbody><tr><td style="text-align:left"><strong>Cache activation</strong></td><td style="text-align:left">Automatic, implicit</td><td style="text-align:left">Explicit cache breakpoints required</td></tr><tr><td style="text-align:left"><strong>Cache write cost</strong></td><td style="text-align:left">Free</td><td style="text-align:left">1.25x premium per write</td></tr><tr><td style="text-align:left"><strong>Minimum cache lifetime</strong></td><td style="text-align:left">Short, a few minutes typical</td><td style="text-align:left">30-minute minimum</td></tr><tr><td style="text-align:left"><strong>Cache read discount</strong></td><td style="text-align:left">~90% off standard rate</td><td style="text-align:left">~90% off standard rate (unchanged)</td></tr></tbody></table>
<h2 id="fix-it-add-explicit-cache-breakpoints">Fix it: add explicit cache breakpoints</h2>
<p>Any integration that never configured caching, because it previously happened for free without any setup, is not getting a silent downgrade under GPT-5.6. It is getting no caching at all until breakpoints are added explicitly. That is a real code change, not a pricing footnote: identify the shared prefixes in your prompts (a system instruction, a long reference document, a set of few-shot examples) and mark them as cache breakpoints so repeated calls actually hit the discount.</p>
<p>Budget for the write side too. The first call that populates a cache breakpoint now costs 1.25x the standard input rate for that portion of the prompt, a real cost that did not exist before. For a workload that reuses a cached prefix many times before it expires, that write cost is easily recovered by the read discount. For a prefix cached once and rarely reused, the write premium may cost more than it saves, especially with a 30-minute minimum lifetime to work around.</p>
<p>The <a href="/tools/llm-pricing-calculator/">LLM Pricing Calculator</a> has a write-vs-read toggle for exactly this scenario, so you can compare the write-premium cost against the accumulated read savings for your own reuse pattern instead of doing the arithmetic by hand.</p>
<div class="callout" role="note" data-astro-cid-q2ml7llr><p class="callout__title" data-astro-cid-q2ml7llr><span aria-hidden="true" data-astro-cid-q2ml7llr>ℹ</span>Retention moved too</p><div class="callout__body" data-astro-cid-q2ml7llr><p>OpenAI separately extended cache retention further, up to 24 hours by default
in some configurations. See <a href="/ai-productivity/openai-prompt-cache-24-hour-retention/">OpenAI Extends Prompt Cache Retention to 24
Hours</a> for how that
changes the reuse-window math above.</p></div></div>
<h2 id="confirmed-version">Confirmed version</h2>
<p>Sourced from third-party cost-analysis coverage (effloow.com) tied to GPT-5.6’s general availability, 2026-07-09, not OpenAI’s own primary pricing documentation directly. Browse more coverage in the <a href="/ai-productivity">AI Productivity</a> archive, or start from <a href="/ai-productivity/2026-llm-token-pricing-reset/">The 2026 LLM Token &amp; Pricing Reset</a> hub.</p>]]></content:encoded>
      <pubDate>Wed, 12 Aug 2026 00:00:00 GMT</pubDate>
    </item>
    <item>
      <title>OpenAI Extends Prompt Cache Retention to 24 Hours</title>
      <link>https://bytetech247.com/ai-productivity/openai-prompt-cache-24-hour-retention/</link>
      <guid isPermaLink="true">https://bytetech247.com/ai-productivity/openai-prompt-cache-24-hour-retention/</guid>
      <description>OpenAI extended prompt cache retention from a few minutes to up to 24 hours. What that changes for agents and pipelines with repeated calls.</description>
      <content:encoded><![CDATA[<aside class="quick-answer" aria-label="Quick answer"><h2 id="quick-answer">Quick Answer</h2>
<p>OpenAI extended prompt cache retention from a short, few-minute default to up to 24 hours. Workflows that make repeated calls sharing a prompt prefix, like an agent looping over the same system instructions, can now rely on a cache hit hours later instead of only minutes later. Re-check any workaround you built for the old short window.</p>
</aside><h2 id="whats-actually-changing">What’s actually changing</h2>
<p>Third-party cost-analysis coverage (effloow.com), dated 2026-05-29, reports that OpenAI moved the default prompt-cache lifetime from a few minutes to up to 24 hours. That is a meaningfully longer window for anything that reuses the same prompt prefix across separate calls that are not tightly clustered in time.</p>
<p>This is paraphrased from measured third-party analysis, not confirmed against OpenAI’s own primary documentation directly, and is stated honestly as such. The mechanism it changes is retention duration specifically, distinct from the cache-write premium and breakpoint requirements covered separately, and <a href="https://platform.openai.com/docs/guides/prompt-caching">OpenAI’s own prompt caching guide</a> documents an up-to-24-hour extended retention window using the same figure for eligible models.</p>
<h2 id="structural-comparison-matrix">Structural Comparison Matrix</h2>

























<table tabindex="0"><thead><tr><th scope="col" style="text-align:left">Operational Aspect</th><th scope="col" style="text-align:left">Before</th><th scope="col" style="text-align:left">After</th></tr></thead><tbody><tr><td style="text-align:left"><strong>Default cache lifetime</strong></td><td style="text-align:left">A few minutes</td><td style="text-align:left">Up to 24 hours</td></tr><tr><td style="text-align:left"><strong>Repeated-call workflows</strong></td><td style="text-align:left">Needed calls within minutes to hit cache</td><td style="text-align:left">Can span hours and still hit cache</td></tr><tr><td style="text-align:left"><strong>Workarounds for short TTL</strong></td><td style="text-align:left">Common (keep-alive pings, tight batching)</td><td style="text-align:left">Likely unnecessary now</td></tr></tbody></table>
<h2 id="fix-it-stop-working-around-a-window-that-no-longer-exists">Fix it: stop working around a window that no longer exists</h2>
<p>If your pipeline previously batched calls tightly, or sent a periodic no-op request, specifically to keep a cache entry alive before it expired, that workaround is likely solving a problem that no longer exists at this scale. A 24-hour window covers most batch jobs, scheduled agent runs, and even a full business day of interactive use without needing an artificial keep-alive.</p>
<p>The more useful change is upstream: this is a good time to restructure prompts so the reusable part, a system instruction, a long reference document, a tool schema, sits at a stable prefix marked as a cache breakpoint (see <a href="/ai-productivity/gpt-5-6-prompt-cache-write-premium/">GPT-5.6 Ends Free Prompt-Cache Writes</a> for how breakpoints work). A longer retention window makes that investment pay off across a wider span of real usage than it did before.</p>
<div class="callout" role="note" data-astro-cid-q2ml7llr><p class="callout__title" data-astro-cid-q2ml7llr><span aria-hidden="true" data-astro-cid-q2ml7llr>✓</span>Model the reuse window, not just the write cost</p><div class="callout__body" data-astro-cid-q2ml7llr><p>A longer retention window only pays off if your workload actually reuses the
cache inside it. The <a href="/tools/llm-pricing-calculator/">LLM Pricing Calculator</a>
lets you compare the cache-write cost against accumulated read savings for
your own call pattern, rather than assuming a 24-hour window automatically
makes caching worthwhile.</p></div></div>
<h2 id="confirmed-version">Confirmed version</h2>
<p>Sourced from effloow.com’s cost-analysis coverage, dated 2026-05-29, not OpenAI’s own primary documentation directly. Browse more coverage in the <a href="/ai-productivity">AI Productivity</a> archive, or start from <a href="/ai-productivity/2026-llm-token-pricing-reset/">The 2026 LLM Token &amp; Pricing Reset</a> hub.</p>]]></content:encoded>
      <pubDate>Wed, 12 Aug 2026 00:00:00 GMT</pubDate>
    </item>
    <item>
      <title>OpenAI vs Claude: Prompt Caching Cost Math in 2026</title>
      <link>https://bytetech247.com/ai-productivity/openai-vs-claude-prompt-caching-cost-math/</link>
      <guid isPermaLink="true">https://bytetech247.com/ai-productivity/openai-vs-claude-prompt-caching-cost-math/</guid>
      <description>OpenAI and Anthropic both charge a 1.25x premium on cache writes; Anthropic&apos;s rises to 2x for its 1-hour tier. How the real costs compare.</description>
      <content:encoded><![CDATA[<aside class="quick-answer" aria-label="Quick answer"><h2 id="quick-answer">Quick Answer</h2>
<p>OpenAI’s GPT-5.6 caching charges a 1.25x premium on cache writes, with roughly a 90% discount on reads. Anthropic’s Claude caching charges that same 1.25x premium for a 5-minute cache, or 2x for a 1-hour cache, with an identical ~90% read discount either way. Model the write premium and cache duration together, not just the read discount.</p>
</aside><h2 id="whats-actually-changing">What’s actually changing</h2>
<p>This is a synthesis cluster, not a single dated announcement: it pulls together <a href="/ai-productivity/gpt-5-6-prompt-cache-write-premium/">GPT-5.6’s new caching structure</a> and Anthropic’s own published caching structure to compare the two directly, sourced to each provider’s own current pricing documentation rather than third-party coverage.</p>
<p>OpenAI’s GPT-5.6 requires explicit cache breakpoints and charges a reported 1.25x premium on the first write to a breakpoint, while reads against an established cache entry get roughly a 90% discount off the standard input rate, figures that match <a href="https://platform.openai.com/docs/guides/prompt-caching">OpenAI’s own prompt caching documentation</a>. Anthropic’s Claude caching charges the identical 1.25x premium for a 5-minute cache write, or 2x for a 1-hour cache write, with a 0.1x (90% off) rate on cache reads either way, per <a href="https://docs.claude.com/en/docs/about-claude/pricing">Anthropic’s own published pricing multipliers</a>. Both providers charge a real, documented write premium; the actual difference is that Anthropic gives you a choice of two durations at two different premiums, where OpenAI has one fixed 30-minute tier.</p>
<h2 id="structural-comparison-matrix">Structural Comparison Matrix</h2>

























<table tabindex="0"><thead><tr><th scope="col" style="text-align:left">Caching Dimension</th><th scope="col" style="text-align:left">OpenAI (GPT-5.6)</th><th scope="col" style="text-align:left">Anthropic (Claude)</th></tr></thead><tbody><tr><td style="text-align:left"><strong>Cache write cost</strong></td><td style="text-align:left">1.25x premium</td><td style="text-align:left">1.25x premium (5-minute cache), 2x premium (1-hour cache)</td></tr><tr><td style="text-align:left"><strong>Cache read discount</strong></td><td style="text-align:left">~90% off</td><td style="text-align:left">~90% off (0.1x base rate), same for either duration</td></tr><tr><td style="text-align:left"><strong>Cache duration</strong></td><td style="text-align:left">Fixed at 30 minutes</td><td style="text-align:left">Choice of 5 minutes or 1 hour</td></tr></tbody></table>
<h2 id="fix-it-model-the-write-side-not-just-the-read-discount">Fix it: model the write side, not just the read discount</h2>
<p>Picking a provider based on caching-friendliness used to be a simple read-discount comparison. It no longer is for OpenAI, and it never really was for Anthropic either, since Anthropic’s own multipliers show the same tradeoff. The real cost of caching a given prefix depends on how many times it gets reused before expiring: reuse it enough times and the write premium is a rounding error against the accumulated read savings; reuse it once or twice and the premium may cost more than caching saved.</p>
<p>Anthropic’s two-tier structure adds one more variable: the 1-hour tier’s steeper 2x premium can still win over the 5-minute tier’s 1.25x if it means paying the write premium once instead of paying the 1.25x premium repeatedly across an hour of reuse. Do that comparison explicitly rather than assuming the cheaper-looking multiplier is actually cheaper for your access pattern.</p>
<div class="callout" role="note" data-astro-cid-q2ml7llr><p class="callout__title" data-astro-cid-q2ml7llr><span aria-hidden="true" data-astro-cid-q2ml7llr>✓</span>Run this exact comparison on your own numbers</p><div class="callout__body" data-astro-cid-q2ml7llr><p>This post’s whole argument, that the write premium and cache duration have to
be modeled together, not just the read discount, is exactly what the 
<a href="/tools/llm-pricing-calculator/">LLM Pricing Calculator</a> computes.
Enter your own token counts and reuse pattern and see both providers’ real
cache-write and cache-read costs side by side, instead of doing this
arithmetic by hand.</p></div></div>
<div class="callout" role="note" data-astro-cid-q2ml7llr><p class="callout__title" data-astro-cid-q2ml7llr><span aria-hidden="true" data-astro-cid-q2ml7llr>ℹ</span>Two related pieces of this reset</p><div class="callout__body" data-astro-cid-q2ml7llr><p>See <a href="/ai-productivity/gpt-5-6-prompt-cache-write-premium/">GPT-5.6 Ends Free Prompt-Cache
Writes</a> and <a href="/ai-productivity/openai-prompt-cache-24-hour-retention/">OpenAI
Extends Prompt Cache Retention to 24
Hours</a> for the two
OpenAI-side changes this comparison draws on.</p></div></div>
<h2 id="confirmed-version">Confirmed version</h2>
<p>Sourced directly to <a href="https://platform.openai.com/docs/guides/prompt-caching">OpenAI’s prompt caching documentation</a> and <a href="https://docs.claude.com/en/docs/about-claude/pricing">Anthropic’s pricing documentation</a>, re-checked 2026-08-13. An earlier version of this post cited third-party cost-math coverage and stated Anthropic had no documented write premium; that was wrong, corrected here against each provider’s own primary pricing page. Browse more coverage in the <a href="/ai-productivity">AI Productivity</a> archive, or start from <a href="/ai-productivity/2026-llm-token-pricing-reset/">The 2026 LLM Token &amp; Pricing Reset</a> hub.</p>]]></content:encoded>
      <pubDate>Wed, 12 Aug 2026 00:00:00 GMT</pubDate>
    </item>
    <item>
      <title>Same Prompt, Different Bill: GPT-5.6 vs Claude vs Gemini</title>
      <link>https://bytetech247.com/ai-productivity/same-prompt-different-bill-gpt-claude-gemini/</link>
      <guid isPermaLink="true">https://bytetech247.com/ai-productivity/same-prompt-different-bill-gpt-claude-gemini/</guid>
      <description>GPT-5.6, Claude Opus 4.8, and Gemini 3.6 all changed token counts and per-token pricing in 2026. See what shifted, and check your own prompt free.</description>
      <content:encoded><![CDATA[<aside class="quick-answer" aria-label="Quick answer"><h2 id="quick-answer">Quick Answer</h2>
<p>GPT-5.6, Claude Opus 4.8, and Gemini 3.6 all shipped within about 90 days of each other, and each changed both token count and price per token. The same prompt now costs a different amount on every provider than it did months ago. Check your own numbers with the free AI Token Counter instead of trusting a static comparison.</p>
</aside><h2 id="three-providers-one-narrow-window">Three providers, one narrow window</h2>
<p>Between April and July 2026, OpenAI, Anthropic, and Google each shipped a model update that touched tokenization, pricing, or both. None of the three coordinated with each other, but the timing overlaps closely enough that a cost comparison written even in early 2026 is already stale on two separate axes: how many tokens your prompt turns into, and what each of those tokens costs.</p>
<p>That distinction matters more than it sounds. A price cut on its own is easy to track, since it shows up as a smaller number on a pricing page. A tokenizer change is quieter. The same block of text can silently turn into more tokens without any price changing at all, and your bill still goes up.</p>
<h2 id="structural-comparison-matrix">Structural Comparison Matrix</h2>

































<table tabindex="0"><thead><tr><th scope="col" style="text-align:left">Provider</th><th scope="col" style="text-align:left">Token-count change</th><th scope="col" style="text-align:left">Price change</th><th scope="col" style="text-align:left">Shipped</th><th scope="col" style="text-align:left">How confirmed</th></tr></thead><tbody><tr><td style="text-align:left"><strong>OpenAI GPT-5.6</strong></td><td style="text-align:left">Near-1M context window (up to 922K input, 128K output) on the <code>o200k_base</code> encoding</td><td style="text-align:left">Prompt caching moved from free and implicit to explicit breakpoints, with a 1.25x premium on cache writes</td><td style="text-align:left">GA 2026-07-09</td><td style="text-align:left">Paraphrased, aggregated from model-tracking sources, not OpenAI’s own announcement post directly</td></tr><tr><td style="text-align:left"><strong>Anthropic Claude Opus 4.8</strong></td><td style="text-align:left">Tokenizer inherited from Opus 4.7 counts up to ~35% more tokens for the same text than pre-4.7 models</td><td style="text-align:left">Base price unchanged ($5/$25 per MTok); Fast Mode cut from $30/$150 to $10/$50 per MTok</td><td style="text-align:left">2026-05-28</td><td style="text-align:left">Price paraphrased (third-party pricing coverage); tokenizer claim paraphrased, <strong>not</strong> in Anthropic’s own official GA changelog</td></tr><tr><td style="text-align:left"><strong>Google Gemini 3.6 Flash</strong></td><td style="text-align:left">Uses 17% fewer output tokens than Gemini 3.5 Flash for comparable tasks</td><td style="text-align:left">$0.75 / $3.75 per million input/output tokens now (through 2026-12-31), rising to $1.50 / $7.50 in 2027, 1M-token context window</td><td style="text-align:left">2026-07-21</td><td style="text-align:left">Paraphrased (token reduction) / confirmed on Google’s own pricing page (both price points)</td></tr></tbody></table>
<p>Every cell above carries its own confidence label on purpose. Two of the six numbers in this table (the context window figures and the tokenizer claim) come from secondary reporting, not a primary source Anthropic or OpenAI published themselves. That’s stated plainly rather than smoothed over, because a wrong number stated with confidence does more damage to a cost estimate than an honest “unconfirmed.”</p>
<h2 id="openai-a-bigger-window-and-caching-that-finally-costs-something">OpenAI: a bigger window, and caching that finally costs something</h2>
<p>GPT-5.6 reached general availability on 2026-07-09 with a context window reported at roughly 1.05M tokens, split into up to 922K input tokens and 128K output tokens. It uses the <code>o200k_base</code> encoding, the same one GPT-4o introduced, not the older <code>cl100k_base</code> encoding that GPT-3.5 and GPT-4 used.</p>
<p>That encoding detail is not trivia. A prompt that tokenizes to, say, 500 tokens under <code>cl100k_base</code> will not tokenize to the same count under <code>o200k_base</code>. This site’s own AI Token Counter currently implements <code>cl100k_base</code> only, which is accurate for GPT-3.5 and GPT-4-family models but not for GPT-5.6’s <code>o200k_base</code> encoding. That’s a real, current limitation, and it’s disclosed directly on the tool’s own comparison table rather than left for a reader to discover after the fact.</p>
<p>The other change is quieter but hits every high-volume integration: OpenAI’s earlier free, automatic prompt caching is gone in favor of explicit cache breakpoints, a 1.25x premium on cache writes, and a 30-minute minimum time-to-live. Cache reads still carry roughly the same 90% discount as before. If your integration leaned on “caching just happens” without configuring breakpoints, it’s paying the new write premium without getting the read discount to offset it.</p>
<div class="callout" role="note" data-astro-cid-q2ml7llr><p class="callout__title" data-astro-cid-q2ml7llr><span aria-hidden="true" data-astro-cid-q2ml7llr>⚠</span>Legacy models have a real shutdown date</p><div class="callout__body" data-astro-cid-q2ml7llr><p>If anything in your codebase still points at <code>gpt-4</code>, <code>o1</code>, or <code>o4-mini</code>,
those model IDs stop responding on <strong>October 23, 2026</strong>. This is confirmed
directly from <a href="https://platform.openai.com/docs/deprecations">OpenAI’s own deprecations
page</a>, not third-party
reporting: OpenAI states a minimum six-month notice period for generally
available models, and lists <code>gpt-5.6-sol</code> and <code>gpt-5.6-terra</code> as the named
replacements. Migrating early also means re-checking token counts against the
new <code>o200k_base</code> encoding, not just swapping the model string.</p></div></div>
<h2 id="anthropic-same-sticker-price-a-heavier-tokenizer">Anthropic: same sticker price, a heavier tokenizer</h2>
<p>Claude Opus 4.8 launched 2026-05-28 at the same base price as Opus 4.7: $5 per million input tokens, $25 per million output tokens, confirmed on <a href="https://docs.claude.com/en/docs/about-claude/pricing">Anthropic’s own Claude pricing page</a>. On its own, that reads as “no change.” Fast Mode tells a different story: its price dropped from $30/$150 per million tokens to $10/$50, a real cut for anyone running latency-sensitive workloads that specifically pay for Fast Mode.</p>
<p>The harder number to verify is the tokenizer. Third-party technical coverage reports that the tokenizer Opus 4.7 introduced, and that Opus 4.8 inherited unchanged, counts up to roughly 35% more tokens for the same input text than the tokenizer pre-4.7 models used. A direct read of Anthropic’s own official GitHub changelog announcing Opus 4.7’s general availability does not mention a tokenizer change anywhere, only general performance claims. That gap is worth stating outright rather than papering over: the 35% figure is plausible, widely repeated, and unconfirmed by Anthropic directly.</p>
<p>The practical effect, if the figure holds: the same price per token does not mean the same price per prompt. If your text now tokenizes to more tokens than it used to, your cost per call goes up even though the rate card looks unchanged. A budget built by multiplying an old token count by a new price will be wrong twice over.</p>
<p>Opus 4.7 also introduced a real breaking change worth flagging for anyone touching the API directly: passing <code>temperature</code>, <code>top_p</code>, <code>top_k</code>, or <code>budget_tokens</code> now returns an HTTP 400 error under some configurations, according to the same third-party technical coverage, again not called out in Anthropic’s own GA changelog. If a call that used to work suddenly 400s after an upgrade, check those parameters first.</p>
<h2 id="google-gemini-36-flash-spends-fewer-tokens-not-just-less-money">Google: Gemini 3.6 Flash spends fewer tokens, not just less money</h2>
<p>Gemini 3.6 Flash launched 2026-07-21 at $0.75 per million input tokens and $3.75 per million output tokens, confirmed directly on <a href="https://ai.google.dev/gemini-api/docs/pricing">Google’s own Gemini API pricing page</a>, with a 1M-token context window. That same page states the rate rises to $1.50 input / $7.50 output on 2027-01-01, a scheduled increase worth budgeting around separately. Reported separately from the price: it uses 17% fewer output tokens than Gemini 3.5 Flash for comparable tasks, according to tech-press launch coverage rather than a Google blog post directly.</p>
<p>That’s two levers moving independently. A cheaper per-token rate lowers cost on its own. A model that genuinely produces fewer output tokens for the same task lowers cost again, on top of the rate change. Comparing only the sticker price between 3.5 Flash and 3.6 Flash understates the real difference, because it misses the second lever entirely.</p>
<h2 id="read-the-real-count-straight-from-the-api-not-a-blog-post">Read the real count straight from the API, not a blog post</h2>
<p>Every provider’s own API response already carries the real, billed token count. You don’t need an estimate if you can make a live call: OpenAI’s Chat Completions response includes a <code>usage</code> object, Anthropic’s Messages API includes its own <code>usage</code> object with different field names, and Google’s Gemini API includes a <code>usageMetadata</code> object. The shapes differ slightly across providers, which is itself a small but real integration detail:</p>
<div class="code-block code-block--has-filename"><div class="code-block__header"><span class="code-block__filename">OpenAI response usage object (Chat Completions)</span><button type="button" class="code-block__copy" data-copy-button>Copy</button></div><pre class="astro-code github-dark" style="background-color:#24292e;color:#e1e4e8;overflow-x:auto" tabindex="0" data-language="json" data-filename="OpenAI response usage object (Chat Completions)"><code><span class="line"><span style="color:#9ECBFF">&quot;usage&quot;</span><span style="color:#E1E4E8">: {</span></span>
<span class="line"><span style="color:#79B8FF">  &quot;prompt_tokens&quot;</span><span style="color:#E1E4E8">: </span><span style="color:#79B8FF">812</span><span style="color:#E1E4E8">,</span></span>
<span class="line"><span style="color:#79B8FF">  &quot;completion_tokens&quot;</span><span style="color:#E1E4E8">: </span><span style="color:#79B8FF">194</span><span style="color:#E1E4E8">,</span></span>
<span class="line"><span style="color:#79B8FF">  &quot;total_tokens&quot;</span><span style="color:#E1E4E8">: </span><span style="color:#79B8FF">1006</span></span>
<span class="line"><span style="color:#E1E4E8">}</span></span></code></pre></div>
<div class="code-block code-block--has-filename"><div class="code-block__header"><span class="code-block__filename">Anthropic response usage object (Messages API)</span><button type="button" class="code-block__copy" data-copy-button>Copy</button></div><pre class="astro-code github-dark" style="background-color:#24292e;color:#e1e4e8;overflow-x:auto" tabindex="0" data-language="json" data-filename="Anthropic response usage object (Messages API)"><code><span class="line"><span style="color:#9ECBFF">&quot;usage&quot;</span><span style="color:#E1E4E8">: {</span></span>
<span class="line"><span style="color:#79B8FF">  &quot;input_tokens&quot;</span><span style="color:#E1E4E8">: </span><span style="color:#79B8FF">812</span><span style="color:#E1E4E8">,</span></span>
<span class="line"><span style="color:#79B8FF">  &quot;output_tokens&quot;</span><span style="color:#E1E4E8">: </span><span style="color:#79B8FF">194</span></span>
<span class="line"><span style="color:#E1E4E8">}</span></span></code></pre></div>
<div class="code-block code-block--has-filename"><div class="code-block__header"><span class="code-block__filename">Google response usage object (Gemini API)</span><button type="button" class="code-block__copy" data-copy-button>Copy</button></div><pre class="astro-code github-dark" style="background-color:#24292e;color:#e1e4e8;overflow-x:auto" tabindex="0" data-language="json" data-filename="Google response usage object (Gemini API)"><code><span class="line"><span style="color:#9ECBFF">&quot;usageMetadata&quot;</span><span style="color:#E1E4E8">: {</span></span>
<span class="line"><span style="color:#79B8FF">  &quot;promptTokenCount&quot;</span><span style="color:#E1E4E8">: </span><span style="color:#79B8FF">812</span><span style="color:#E1E4E8">,</span></span>
<span class="line"><span style="color:#79B8FF">  &quot;candidatesTokenCount&quot;</span><span style="color:#E1E4E8">: </span><span style="color:#79B8FF">194</span><span style="color:#E1E4E8">,</span></span>
<span class="line"><span style="color:#79B8FF">  &quot;totalTokenCount&quot;</span><span style="color:#E1E4E8">: </span><span style="color:#79B8FF">1006</span></span>
<span class="line"><span style="color:#E1E4E8">}</span></span></code></pre></div>
<p>The field names above are illustrative placeholders, not a captured live call, but the shape matches each provider’s own documented response schema. If you need a certain answer for a specific prompt, a real call and its <code>usage</code>/<code>usageMetadata</code> field beats any estimate, including the ones in this post.</p>
<p>If you just want a fast, no-API-key check before you commit to a call, that’s exactly what this site’s <a href="/tools/ai-token-counter/">AI Token Counter</a> is for: paste a prompt once and see an exact GPT count next to clearly-labeled Claude and Gemini estimates, side by side, entirely in your browser. The <a href="/tools/llm-pricing-calculator/">LLM Pricing Calculator</a> takes that same prompt and turns it into the actual side-by-side dollar comparison this post has been building toward.</p>
<h2 id="dont-let-this-comparison-go-stale-either">Don’t let this comparison go stale either</h2>
<p>Every number in this post has a real date attached to it, on purpose. Six months from now, at least one of these providers will likely ship another change, and a reader who bookmarks this page instead of re-checking it will be making the same mistake this post is warning against. Treat any cost comparison, including this one, as a snapshot, not a constant. Before a budget review or a provider switch, run your actual prompts through the <a href="/tools/ai-token-counter/">AI Token Counter</a> and the <a href="/tools/llm-pricing-calculator/">LLM Pricing Calculator</a>, and check the provider’s own pricing page for the current rate, rather than trusting a number that was accurate the day it was written.</p>
<p>Browse more coverage like this in the <a href="/ai-productivity">AI Productivity</a> archive.</p>]]></content:encoded>
      <pubDate>Tue, 11 Aug 2026 00:00:00 GMT</pubDate>
    </item>
    <item>
      <title>Cloudflare AMP/SXG API Has Reached End of Life</title>
      <link>https://bytetech247.com/data-automation/cloudflare-amp-sxg-api-end-of-life/</link>
      <guid isPermaLink="true">https://bytetech247.com/data-automation/cloudflare-amp-sxg-api-end-of-life/</guid>
      <description>Cloudflare confirms AMP/SXG reached end of life on 2026-06-23 with no replacement. If /zones/{id}/amp/sxg calls started failing quietly, this is why.</description>
      <content:encoded><![CDATA[<aside class="quick-answer" aria-label="Quick answer"><h2 id="quick-answer">Quick Answer</h2>
<p>Cloudflare’s AMP/SXG feature and its API (<code>GET</code>/<code>PUT /zones/{zone_id}/amp/sxg</code>) reached end of life on 2026-06-23, with no replacement. Cloudflare’s changelog entry confirming this was published 2026-07-08, weeks after the actual cutoff. If automation calling this endpoint started failing in late June with no obvious cause, this is the explanation: the feature is gone, not a temporary outage.</p>
</aside><h2 id="why-this-one-reads-differently-than-the-rest-of-this-pillar">Why this one reads differently than the rest of this pillar</h2>
<p>Every other post in this series is forward-looking: a deadline that hasn’t arrived yet, with time to migrate before it does. This one is retroactive. <a href="https://developers.cloudflare.com/changelog/post/2026-06-23-amp-sxg-end-of-life/">Cloudflare’s own changelog entry</a> documents a deprecation date (2025-09-18) and an end-of-life date (2026-06-23) that both came before the changelog post itself went up on 2026-07-08:</p>
<blockquote>
<p>“The AMP/SXG features have reached end of life. There will be no replacement”</p>
</blockquote>
<p>That gap between when something actually stopped working and when Cloudflare documented it is exactly the kind of thing that makes a real support ticket. Automation calling this endpoint didn’t get a deprecation warning window; it just started failing on a date with no changelog entry to explain it until roughly two weeks later.</p>
<h2 id="structural-comparison-matrix">Structural Comparison Matrix</h2>

























<table tabindex="0"><thead><tr><th scope="col" style="text-align:left">Operational Aspect</th><th scope="col" style="text-align:left">Before 2026-06-23</th><th scope="col" style="text-align:left">After 2026-06-23</th></tr></thead><tbody><tr><td style="text-align:left"><strong><code>GET</code>/<code>PUT /zones/{zone_id}/amp/sxg</code></strong></td><td style="text-align:left">Functional</td><td style="text-align:left">No longer available</td></tr><tr><td style="text-align:left"><strong>Replacement</strong></td><td style="text-align:left">N/A</td><td style="text-align:left">None; full feature removal</td></tr><tr><td style="text-align:left"><strong>Changelog notice</strong></td><td style="text-align:left">N/A</td><td style="text-align:left">Published 2026-07-08, after the fact</td></tr></tbody></table>
<h2 id="fix-it-remove-the-dead-call-not-just-retry-it">Fix it: remove the dead call, not just retry it</h2>
<div class="callout" role="note" data-astro-cid-q2ml7llr><p class="callout__title" data-astro-cid-q2ml7llr><span aria-hidden="true" data-astro-cid-q2ml7llr>✕</span>Retrying won&#39;t help</p><div class="callout__body" data-astro-cid-q2ml7llr><p>If a script’s error-handling logic treats a failure here as transient and
retries with backoff, that retry loop now runs forever against an endpoint
that isn’t coming back. Remove the call entirely rather than wrapping it in
more resilient retry logic.</p></div></div>
<div class="code-block code-block--has-filename"><div class="code-block__header"><span class="code-block__filename">find every call site to remove</span><button type="button" class="code-block__copy" data-copy-button>Copy</button></div><pre class="astro-code github-dark" style="background-color:#24292e;color:#e1e4e8;overflow-x:auto" tabindex="0" data-language="bash" data-filename="find every call site to remove"><code><span class="line"><span style="color:#B392F0">grep</span><span style="color:#79B8FF"> -rn</span><span style="color:#9ECBFF"> &quot;amp/sxg&quot;</span><span style="color:#79B8FF"> --include=</span><span style="color:#9ECBFF">&quot;*.tf&quot;</span><span style="color:#79B8FF"> --include=</span><span style="color:#9ECBFF">&quot;*.sh&quot;</span><span style="color:#79B8FF"> --include=</span><span style="color:#9ECBFF">&quot;*.mjs&quot;</span><span style="color:#9ECBFF"> .</span></span></code></pre></div>
<p>There’s no migration path to point automation at instead, since Cloudflare’s own changelog says there’s no replacement. The fix here is deletion: remove the call, and whatever downstream logic depended on its result, from the pipeline entirely.</p>
<h2 id="confirmed-version">Confirmed version</h2>
<p>Sourced from Cloudflare’s official changelog, published 2026-07-08, documenting a deprecation dated 2025-09-18 and an end of life dated 2026-06-23, both already in effect at publication time. Browse more posts like this in the <a href="/data-automation">Data Automation</a> archive.</p>]]></content:encoded>
      <pubDate>Thu, 06 Aug 2026 00:00:00 GMT</pubDate>
    </item>
    <item>
      <title>Cloudflare Deprecates the Account Roles API</title>
      <link>https://bytetech247.com/data-automation/cloudflare-deprecates-account-roles-api/</link>
      <guid isPermaLink="true">https://bytetech247.com/data-automation/cloudflare-deprecates-account-roles-api/</guid>
      <description>Cloudflare deprecates the Account Roles API for the Permission Groups API. The data model changes from a flat role list to fine-grained permission groups.</description>
      <content:encoded><![CDATA[<aside class="quick-answer" aria-label="Quick answer"><h2 id="quick-answer">Quick Answer</h2>
<p>Cloudflare deprecates the Account Roles API (<code>GET /accounts/{account_id}/roles</code> and <code>GET /accounts/{account_id}/roles/{role_id}</code>) in favor of the Permission Groups API (<code>GET /accounts/{account_id}/iam/permission_groups</code>). This isn’t a drop-in endpoint swap: the old API returns a flat list of account-level roles, while the new one models fine-grained, named permission groups. Automation checking role membership needs its logic rewritten, not just its endpoint URL.</p>
</aside><h2 id="whats-actually-changing">What’s actually changing</h2>
<p><a href="https://developers.cloudflare.com/changelog/post/2026-07-21-account-role-api-deprecated/">Cloudflare’s own changelog entry</a> gives a real functional gap as its reasoning for the deprecation, not just a naming preference:</p>
<blockquote>
<p>“The Account Roles API only returns account-level roles today, and is deprecated in favor of the Permission Groups API”</p>
</blockquote>
<p>The Account Roles API’s whole limitation was scope: it only ever surfaced account-level roles, a flat list like “Administrator” or “Billing.” The <a href="https://developers.cloudflare.com/api/resources/iam/subresources/permission_groups/methods/list/">Permission Groups API</a>, confirmed live at <code>GET /accounts/{account_id}/iam/permission_groups</code>, returns a paginated array of permission groups, each a named group of fine-grained permissions mapped to specific operations against specific resources, a genuinely different and more expressive model.</p>
<h2 id="structural-comparison-matrix">Structural Comparison Matrix</h2>






























<table tabindex="0"><thead><tr><th scope="col" style="text-align:left">Operational Aspect</th><th scope="col" style="text-align:left">Account Roles API (deprecated)</th><th scope="col" style="text-align:left">Permission Groups API</th></tr></thead><tbody><tr><td style="text-align:left"><strong>Data model</strong></td><td style="text-align:left">Flat list of account-level role names</td><td style="text-align:left">Named groups of fine-grained permissions</td></tr><tr><td style="text-align:left"><strong>List endpoint</strong></td><td style="text-align:left"><code>GET /accounts/{account_id}/roles</code></td><td style="text-align:left"><code>GET /accounts/{account_id}/iam/permission_groups</code></td></tr><tr><td style="text-align:left"><strong>Filtering</strong></td><td style="text-align:left">Not documented for this endpoint</td><td style="text-align:left">Optional <code>name</code> query parameter</td></tr><tr><td style="text-align:left"><strong>Granularity</strong></td><td style="text-align:left">Whole role only</td><td style="text-align:left">Individual permission-to-operation mappings</td></tr></tbody></table>
<h2 id="fix-it-migrate-the-logic-not-just-the-url">Fix it: migrate the logic, not just the URL</h2>
<div class="code-block code-block--has-filename"><div class="code-block__header"><span class="code-block__filename">before: listing flat account roles</span><button type="button" class="code-block__copy" data-copy-button>Copy</button></div><pre class="astro-code github-dark" style="background-color:#24292e;color:#e1e4e8;overflow-x:auto" tabindex="0" data-language="bash" data-filename="before: listing flat account roles"><code><span class="line"><span style="color:#B392F0">curl</span><span style="color:#79B8FF"> -X</span><span style="color:#9ECBFF"> GET</span><span style="color:#79B8FF"> \</span></span>
<span class="line"><span style="color:#9ECBFF">  &quot;https://api.cloudflare.com/client/v4/accounts/&lt;account_id&gt;/roles&quot;</span><span style="color:#79B8FF"> \</span></span>
<span class="line"><span style="color:#79B8FF">  -H</span><span style="color:#9ECBFF"> &quot;Authorization: Bearer &lt;api_token&gt;&quot;</span></span></code></pre></div>
<div class="code-block code-block--has-filename"><div class="code-block__header"><span class="code-block__filename">after: listing permission groups</span><button type="button" class="code-block__copy" data-copy-button>Copy</button></div><pre class="astro-code github-dark" style="background-color:#24292e;color:#e1e4e8;overflow-x:auto" tabindex="0" data-language="bash" data-filename="after: listing permission groups"><code><span class="line"><span style="color:#B392F0">curl</span><span style="color:#79B8FF"> -X</span><span style="color:#9ECBFF"> GET</span><span style="color:#79B8FF"> \</span></span>
<span class="line"><span style="color:#9ECBFF">  &quot;https://api.cloudflare.com/client/v4/accounts/&lt;account_id&gt;/iam/permission_groups&quot;</span><span style="color:#79B8FF"> \</span></span>
<span class="line"><span style="color:#79B8FF">  -H</span><span style="color:#9ECBFF"> &quot;Authorization: Bearer &lt;api_token&gt;&quot;</span></span></code></pre></div>
<div class="callout" role="note" data-astro-cid-q2ml7llr><p class="callout__title" data-astro-cid-q2ml7llr><span aria-hidden="true" data-astro-cid-q2ml7llr>✕</span>This is a data-model migration, not a URL swap</p><div class="callout__body" data-astro-cid-q2ml7llr><p>Automation that checks <code>if role.name == &quot;Administrator&quot;</code> against the old API
has no direct equivalent field to swap in. The Permission Groups API models
permissions, not a single named role, so gating logic built around role names
needs to be rebuilt around specific permission group membership instead, real
design work, not a one-line change.</p></div></div>
<p>Before rewriting anything, pull the actual permission groups available on the account and map which ones correspond to the old role-based checks the automation relied on:</p>
<div class="code-block code-block--has-filename"><div class="code-block__header"><span class="code-block__filename">find the permission groups an account actually has</span><button type="button" class="code-block__copy" data-copy-button>Copy</button></div><pre class="astro-code github-dark" style="background-color:#24292e;color:#e1e4e8;overflow-x:auto" tabindex="0" data-language="bash" data-filename="find the permission groups an account actually has"><code><span class="line"><span style="color:#B392F0">curl</span><span style="color:#79B8FF"> -X</span><span style="color:#9ECBFF"> GET</span><span style="color:#79B8FF"> \</span></span>
<span class="line"><span style="color:#9ECBFF">  &quot;https://api.cloudflare.com/client/v4/accounts/&lt;account_id&gt;/iam/permission_groups?name=Administrator&quot;</span><span style="color:#79B8FF"> \</span></span>
<span class="line"><span style="color:#79B8FF">  -H</span><span style="color:#9ECBFF"> &quot;Authorization: Bearer &lt;api_token&gt;&quot;</span></span></code></pre></div>
<h2 id="confirmed-version">Confirmed version</h2>
<p>Sourced from Cloudflare’s official changelog, published 2026-07-21, deprecation effective the same day, corroborated by Cloudflare’s own live API reference for the Permission Groups API endpoint. Browse more posts like this in the <a href="/data-automation">Data Automation</a> archive.</p>]]></content:encoded>
      <pubDate>Thu, 06 Aug 2026 00:00:00 GMT</pubDate>
    </item>
    <item>
      <title>Cloudflare Deprecates the foundation_dns DNS Setting</title>
      <link>https://bytetech247.com/data-automation/cloudflare-deprecates-foundation-dns-setting/</link>
      <guid isPermaLink="true">https://bytetech247.com/data-automation/cloudflare-deprecates-foundation-dns-setting/</guid>
      <description>Cloudflare removes the foundation_dns boolean from DNS settings endpoints on 2026-11-23. Automation toggling it needs to find the replacement before then.</description>
      <content:encoded><![CDATA[<aside class="quick-answer" aria-label="Quick answer"><h2 id="quick-answer">Quick Answer</h2>
<p>Cloudflare deprecates the <code>foundation_dns</code> boolean in the DNS settings endpoints for both zone settings and account defaults (<code>/zones/{zone_id}/dns_settings</code> and <code>/accounts/{account_id}/dns_settings</code>). It stops being honored on 2026-11-23. Automation reading or toggling this field needs to check the current DNS settings API reference for its replacement before that date, since the exact field name Cloudflare intends as the successor isn’t specified in the changelog entry itself.</p>
</aside><h2 id="whats-actually-changing">What’s actually changing</h2>
<p><a href="https://developers.cloudflare.com/fundamentals/api/reference/deprecations/">Cloudflare’s changelog</a> states the change directly:</p>
<blockquote>
<p>“The <code>foundation_dns</code> boolean is deprecated in the DNS settings endpoints for zone settings and account defaults”</p>
</blockquote>
<p>Foundation DNS itself, Cloudflare’s enterprise-grade authoritative DNS offering enabled via <code>&quot;foundation_dns&quot;: true</code> on the DNS settings endpoint, isn’t described as being discontinued. What’s deprecated is specifically this boolean field on the <a href="https://developers.cloudflare.com/api/resources/dns/subresources/settings/"><code>/zones/{zone_id}/dns_settings</code> and <code>/accounts/{account_id}/dns_settings</code></a> endpoints, published and made effective the same day, 2026-07-27, with a longer runway before removal, 2026-11-23.</p>
<div class="callout" role="note" data-astro-cid-q2ml7llr><p class="callout__title" data-astro-cid-q2ml7llr><span aria-hidden="true" data-astro-cid-q2ml7llr>⚠</span>Don&#39;t guess the replacement field name</p><div class="callout__body" data-astro-cid-q2ml7llr><p>Cloudflare’s own changelog entry for this specific deprecation doesn’t name an
exact successor field. Rather than assume a plausible-sounding replacement,
pull the current DNS settings API reference directly before updating
automation, and confirm the field name against a real API response, not a
guess.</p></div></div>
<h2 id="structural-comparison-matrix">Structural Comparison Matrix</h2>

























<table tabindex="0"><thead><tr><th scope="col" style="text-align:left">Operational Aspect</th><th scope="col" style="text-align:left">Before 2026-11-23</th><th scope="col" style="text-align:left">After 2026-11-23</th></tr></thead><tbody><tr><td style="text-align:left"><strong><code>foundation_dns</code> field</strong></td><td style="text-align:left">Read/write, controls Foundation DNS state</td><td style="text-align:left">No longer honored</td></tr><tr><td style="text-align:left"><strong>Endpoints affected</strong></td><td style="text-align:left"><code>/zones/{zone_id}/dns_settings</code>, <code>/accounts/{account_id}/dns_settings</code></td><td style="text-align:left">Same endpoints, different accepted fields</td></tr><tr><td style="text-align:left"><strong>Foundation DNS itself</strong></td><td style="text-align:left">Available</td><td style="text-align:left">Still available, through a different field</td></tr></tbody></table>
<h2 id="fix-it-audit-before-the-field-goes-silent">Fix it: audit before the field goes silent</h2>
<p>The real risk here isn’t a thrown error. A script that <code>PATCH</code>es <code>foundation_dns</code> and checks only the HTTP status code, not the actual resulting DNS settings state, may keep reporting success on a field the API silently stops honoring, while Foundation DNS quietly stops being toggled the way the automation expects.</p>
<div class="code-block code-block--has-filename"><div class="code-block__header"><span class="code-block__filename">check what a DNS settings call currently returns</span><button type="button" class="code-block__copy" data-copy-button>Copy</button></div><pre class="astro-code github-dark" style="background-color:#24292e;color:#e1e4e8;overflow-x:auto" tabindex="0" data-language="bash" data-filename="check what a DNS settings call currently returns"><code><span class="line"><span style="color:#B392F0">curl</span><span style="color:#79B8FF"> -X</span><span style="color:#9ECBFF"> GET</span><span style="color:#79B8FF"> \</span></span>
<span class="line"><span style="color:#9ECBFF">  &quot;https://api.cloudflare.com/client/v4/zones/&lt;zone_id&gt;/dns_settings&quot;</span><span style="color:#79B8FF"> \</span></span>
<span class="line"><span style="color:#79B8FF">  -H</span><span style="color:#9ECBFF"> &quot;Authorization: Bearer &lt;api_token&gt;&quot;</span></span></code></pre></div>
<div class="code-block code-block--has-filename"><div class="code-block__header"><span class="code-block__filename">find every call site to review before 2026-11-23</span><button type="button" class="code-block__copy" data-copy-button>Copy</button></div><pre class="astro-code github-dark" style="background-color:#24292e;color:#e1e4e8;overflow-x:auto" tabindex="0" data-language="bash" data-filename="find every call site to review before 2026-11-23"><code><span class="line"><span style="color:#B392F0">grep</span><span style="color:#79B8FF"> -rn</span><span style="color:#9ECBFF"> &quot;foundation_dns&quot;</span><span style="color:#79B8FF"> --include=</span><span style="color:#9ECBFF">&quot;*.tf&quot;</span><span style="color:#79B8FF"> --include=</span><span style="color:#9ECBFF">&quot;*.sh&quot;</span><span style="color:#79B8FF"> --include=</span><span style="color:#9ECBFF">&quot;*.mjs&quot;</span><span style="color:#9ECBFF"> .</span></span></code></pre></div>
<p>Run the audit now, confirm the current API response shape against the live DNS settings docs, and update automation to match, rather than waiting for a silent no-op to surface as a support ticket in November.</p>
<h2 id="confirmed-version">Confirmed version</h2>
<p>Sourced from Cloudflare’s official changelog, published 2026-07-27, deprecation effective the same day, end of life 2026-11-23. The exact replacement field name wasn’t found in this deprecation’s own changelog text; verify against the live DNS settings API reference before updating automation. Browse more posts like this in the <a href="/data-automation">Data Automation</a> archive.</p>]]></content:encoded>
      <pubDate>Thu, 06 Aug 2026 00:00:00 GMT</pubDate>
    </item>
    <item>
      <title>Cloudflare Deprecates Gateway Audit SSH Rules</title>
      <link>https://bytetech247.com/data-automation/cloudflare-deprecates-gateway-audit-ssh-rules/</link>
      <guid isPermaLink="true">https://bytetech247.com/data-automation/cloudflare-deprecates-gateway-audit-ssh-rules/</guid>
      <description>Cloudflare fully removed Gateway audit_ssh network policy rules on 2026-07-15 after a staged rollout. Migrate to SSH with Access for Infrastructure.</description>
      <content:encoded><![CDATA[<aside class="quick-answer" aria-label="Quick answer"><h2 id="quick-answer">Quick Answer</h2>
<p>Cloudflare’s Gateway <code>audit_ssh</code> network policy action is fully gone as of 2026-07-15, <a href="https://developers.cloudflare.com/fundamentals/api/reference/deprecations/">the end of a staged rollout</a> that started in December 2024. If Terraform-managed Gateway policies referencing <code>action: &quot;audit_ssh&quot;</code> started failing to apply months before the final cutoff, that’s expected: API and Terraform rule creation was already disabled on 2025-11-03, and editing existing rules stopped on 2026-01-15. Migrate to SSH with Access for Infrastructure, Cloudflare’s stated replacement.</p>
</aside><h2 id="the-actual-timeline-staged-over-19-months">The actual timeline, staged over 19 months</h2>
<p>This deprecation didn’t happen on one date. Cloudflare rolled it out in four distinct stages:</p>
<ul>
<li><strong>December 2024</strong>: creating new <code>audit_ssh</code> rules through the dashboard disabled.</li>
<li><strong>2025-11-03</strong>: creating new rules through the API and Terraform disabled.</li>
<li><strong>2026-01-15</strong>: editing existing rules disabled across dashboard, API, and Terraform alike.</li>
<li><strong>2026-07-15</strong>: every remaining <code>audit_ssh</code> rule stops working entirely.</li>
</ul>
<p>For a policy-as-code pipeline that only creates or edits Gateway rules occasionally, this staged rollout means a <code>terraform apply</code> could have started failing on the 2025-11-03 or 2026-01-15 cutoff, months before the feature actually stopped functioning at runtime on 2026-07-15. Three separate dates, three separate places automation could have quietly broken.</p>
<h2 id="structural-comparison-matrix">Structural Comparison Matrix</h2>






























<table tabindex="0"><thead><tr><th scope="col" style="text-align:left">Stage</th><th scope="col" style="text-align:left">What stopped</th><th scope="col" style="text-align:left">Date</th></tr></thead><tbody><tr><td style="text-align:left"><strong>New rules, dashboard</strong></td><td style="text-align:left">Creating <code>audit_ssh</code> rules by hand</td><td style="text-align:left">December 2024</td></tr><tr><td style="text-align:left"><strong>New rules, API/Terraform</strong></td><td style="text-align:left">Creating <code>audit_ssh</code> rules programmatically</td><td style="text-align:left">2025-11-03</td></tr><tr><td style="text-align:left"><strong>Editing existing rules</strong></td><td style="text-align:left">Any update, any interface</td><td style="text-align:left">2026-01-15</td></tr><tr><td style="text-align:left"><strong>Runtime enforcement</strong></td><td style="text-align:left">Existing rules stop applying at all</td><td style="text-align:left">2026-07-15</td></tr></tbody></table>
<h2 id="fix-it-migrate-to-access-for-infrastructure">Fix it: migrate to Access for Infrastructure</h2>
<div class="code-block code-block--has-filename"><div class="code-block__header"><span class="code-block__filename">find Terraform-managed audit_ssh rules</span><button type="button" class="code-block__copy" data-copy-button>Copy</button></div><pre class="astro-code github-dark" style="background-color:#24292e;color:#e1e4e8;overflow-x:auto" tabindex="0" data-language="bash" data-filename="find Terraform-managed audit_ssh rules"><code><span class="line"><span style="color:#B392F0">grep</span><span style="color:#79B8FF"> -rln</span><span style="color:#9ECBFF"> &quot;audit_ssh&quot;</span><span style="color:#79B8FF"> --include=</span><span style="color:#9ECBFF">&quot;*.tf&quot;</span><span style="color:#9ECBFF"> .</span></span></code></pre></div>
<p>Cloudflare’s stated replacement is <a href="https://developers.cloudflare.com/cloudflare-one/connections/connect-networks/use-cases/ssh/ssh-infrastructure-access/">SSH with Access for Infrastructure</a>, described as providing deeper functionality than the old Gateway network policy action rather than being a like-for-like field rename. Treat this as a genuine migration to a different feature area, not a config tweak: any Terraform module referencing <code>audit_ssh</code> needs its resource block replaced with the Access for Infrastructure equivalent, not just a renamed action string.</p>
<div class="callout" role="note" data-astro-cid-q2ml7llr><p class="callout__title" data-astro-cid-q2ml7llr><span aria-hidden="true" data-astro-cid-q2ml7llr>⚠</span>Check for a stale Terraform state, not just live rules</p><div class="callout__body" data-astro-cid-q2ml7llr><p>If Terraform stopped being able to apply changes to an <code>audit_ssh</code> rule back
on 2026-01-15, the resource may still exist in state without matching what the
dashboard or API currently reports. Run a plan against current state before
assuming Terraform’s view of these rules is accurate.</p></div></div>
<h2 id="confirmed-version">Confirmed version</h2>
<p>Sourced from Cloudflare Community’s mirror of the official Gateway changelog announcement and Cloudflare’s own staged-deprecation timeline, full removal confirmed for 2026-07-15. This entry’s own changelog corroboration in Cloudflare’s API deprecations data was published 2026-05-13, ahead of the final cutoff. Browse more posts like this in the <a href="/data-automation">Data Automation</a> archive.</p>]]></content:encoded>
      <pubDate>Thu, 06 Aug 2026 00:00:00 GMT</pubDate>
    </item>
    <item>
      <title>Cloudflare Deprecates Legacy Registrar Domain API</title>
      <link>https://bytetech247.com/data-automation/cloudflare-deprecates-legacy-registrar-domain-api/</link>
      <guid isPermaLink="true">https://bytetech247.com/data-automation/cloudflare-deprecates-legacy-registrar-domain-api/</guid>
      <description>Cloudflare retires the legacy Registrar domain management endpoints on 2026-09-27 in favor of the new Registrar API. Low-traffic automation is easy to miss.</description>
      <content:encoded><![CDATA[<aside class="quick-answer" aria-label="Quick answer"><h2 id="quick-answer">Quick Answer</h2>
<p>Cloudflare deprecates the legacy Registrar domain management endpoints, <code>GET</code>/<code>PUT /accounts/{account_id}/registrar/domains</code>, reaching end of life 2026-09-27. Automation registering, renewing, or updating domains through this endpoint needs to migrate to the new Registrar API before then. Domain-management automation tends to be low-frequency and easy to forget about, which makes it exactly the kind of script that goes unmaintained until it starts failing.</p>
</aside><h2 id="whats-actually-changing">What’s actually changing</h2>
<p><a href="https://developers.cloudflare.com/fundamentals/api/reference/deprecations/">Cloudflare’s changelog</a> states the change and its deadline directly:</p>
<blockquote>
<p>“The legacy Registrar domain management endpoints are deprecated and will reach their end of life on September 27, 2026”</p>
</blockquote>
<p>Unlike the Workers KV deprecation earlier in this pillar, which is a pure URL path swap with an identical request/response shape, this migration points to a genuinely newer, broader API: Cloudflare describes the <a href="https://developers.cloudflare.com/registrar/registrar-api/">replacement Registrar API</a> as adding domain search and availability checking alongside registration and management, not just the same operations at a new path.</p>
<div class="callout" role="note" data-astro-cid-q2ml7llr><p class="callout__title" data-astro-cid-q2ml7llr><span aria-hidden="true" data-astro-cid-q2ml7llr>⚠</span>Confirm the exact new endpoint shape yourself</p><div class="callout__body" data-astro-cid-q2ml7llr><p>This deprecation doesn’t reduce to a mechanical path substitution the way the
KV routes change does. Pull the current Registrar API reference directly and
confirm the exact request/response shape for whatever operation your
automation performs, register, renew, transfer, or update, rather than
assuming it mirrors the legacy endpoint’s structure.</p></div></div>
<h2 id="structural-comparison-matrix">Structural Comparison Matrix</h2>

























<table tabindex="0"><thead><tr><th scope="col" style="text-align:left">Operational Aspect</th><th scope="col" style="text-align:left">Legacy Registrar API (deprecated)</th><th scope="col" style="text-align:left">New Registrar API</th></tr></thead><tbody><tr><td style="text-align:left"><strong>Domain management endpoints</strong></td><td style="text-align:left"><code>GET</code>/<code>PUT /accounts/{account_id}/registrar/domains</code></td><td style="text-align:left">Documented separately in the new Registrar API reference</td></tr><tr><td style="text-align:left"><strong>Domain search/availability check</strong></td><td style="text-align:left">Not part of this API</td><td style="text-align:left">Included</td></tr><tr><td style="text-align:left"><strong>Available after 2026-09-27</strong></td><td style="text-align:left">Removed</td><td style="text-align:left">Active</td></tr></tbody></table>
<h2 id="fix-it-find-and-prioritize-low-traffic-automation">Fix it: find and prioritize low-traffic automation</h2>
<div class="code-block code-block--has-filename"><div class="code-block__header"><span class="code-block__filename">find every call site to review before 2026-09-27</span><button type="button" class="code-block__copy" data-copy-button>Copy</button></div><pre class="astro-code github-dark" style="background-color:#24292e;color:#e1e4e8;overflow-x:auto" tabindex="0" data-language="bash" data-filename="find every call site to review before 2026-09-27"><code><span class="line"><span style="color:#B392F0">grep</span><span style="color:#79B8FF"> -rn</span><span style="color:#9ECBFF"> &quot;registrar/domains&quot;</span><span style="color:#79B8FF"> --include=</span><span style="color:#9ECBFF">&quot;*.tf&quot;</span><span style="color:#79B8FF"> --include=</span><span style="color:#9ECBFF">&quot;*.sh&quot;</span><span style="color:#79B8FF"> --include=</span><span style="color:#9ECBFF">&quot;*.mjs&quot;</span><span style="color:#9ECBFF"> .</span></span></code></pre></div>
<p>Domain registration and renewal automation often runs rarely, once a year at renewal time, or only when a new domain is provisioned, which makes it easy for a deprecation notice to slip past whoever maintains it. If this grep finds anything, flag it for review now rather than waiting for the next renewal cycle to discover it silently stopped working.</p>
<h2 id="confirmed-version">Confirmed version</h2>
<p>Sourced from Cloudflare’s official changelog, published 2026-06-29, documenting a deprecation effective 2026-04-10 and an end of life of 2026-09-27. The new Registrar API’s exact endpoint shape wasn’t independently verified here; confirm against Cloudflare’s live Registrar API reference before migrating. Browse more posts like this in the <a href="/data-automation">Data Automation</a> archive.</p>]]></content:encoded>
      <pubDate>Thu, 06 Aug 2026 00:00:00 GMT</pubDate>
    </item>
    <item>
      <title>Cloudflare Deprecates Legacy Workers KV API Routes</title>
      <link>https://bytetech247.com/data-automation/cloudflare-deprecates-legacy-workers-kv-api-routes/</link>
      <guid isPermaLink="true">https://bytetech247.com/data-automation/cloudflare-deprecates-legacy-workers-kv-api-routes/</guid>
      <description>Cloudflare retires /workers/namespaces/* on 2026-10-15 for /storage/kv/namespaces/*. Scripts calling the old KV REST path directly need one path change.</description>
      <content:encoded><![CDATA[<aside class="quick-answer" aria-label="Quick answer"><h2 id="quick-answer">Quick Answer</h2>
<p>Cloudflare deprecated the legacy Workers KV REST API routes under <code>/accounts/{account_id}/workers/namespaces/*</code> on 2026-07-15; they stop working entirely on 2026-10-15. If any automation script, Terraform config, or CI pipeline calls that path directly, not through Wrangler, update the URL segment to <code>/storage/kv/namespaces/*</code>. The request parameters and response payloads are unchanged, so this is a one-line fix, not a rewrite.</p>
</aside><h2 id="whats-actually-changing">What’s actually changing</h2>
<p>Cloudflare is reorganizing its storage APIs under a unified <code>/storage/</code> namespace: Workers KV moves to <code>/storage/kv/</code>, R2 moves to <code>/storage/r2/</code>. Storage is becoming a first-class platform product, not a Workers-specific feature bolted under <code>/workers/</code>.</p>
<p><a href="https://developers.cloudflare.com/changelog/post/2026-07-15-kv-legacy-namespace-routes-deprecation/">Cloudflare’s own changelog entry</a> states it directly:</p>
<blockquote>
<p>“The legacy Workers KV API routes under <code>/accounts/{account_id}/workers/namespaces/*</code> are deprecated”</p>
</blockquote>
<p>Published 2026-07-15, with an end-of-life date of 2026-10-15, exactly 90 days out from publication, a fairly tight window for infrastructure code that isn’t actively maintained.</p>
<div class="callout" role="note" data-astro-cid-q2ml7llr><p class="callout__title" data-astro-cid-q2ml7llr><span aria-hidden="true" data-astro-cid-q2ml7llr>ℹ</span>Wrangler itself already handles this</p><div class="callout__body" data-astro-cid-q2ml7llr><p>If your project only touches KV through <code>wrangler kv namespace create</code>,
<code>wrangler kv key put</code>, or the runtime binding declared in
<code>wrangler.toml</code>/<code>wrangler.jsonc</code>, Cloudflare’s own tooling is responsible for
calling a working endpoint. This deprecation is only a problem for code that
calls the REST API directly, outside Wrangler.</p></div></div>
<h2 id="structural-comparison-matrix">Structural Comparison Matrix</h2>
<p>All paths below are relative to <code>/accounts/{account_id}</code>.</p>






























<table tabindex="0"><thead><tr><th scope="col" style="text-align:left">Operational Aspect</th><th scope="col" style="text-align:left">Legacy route (deprecated)</th><th scope="col" style="text-align:left">Current route</th></tr></thead><tbody><tr><td style="text-align:left"><strong>Base path</strong></td><td style="text-align:left"><code>/workers/namespaces/*</code></td><td style="text-align:left"><code>/storage/kv/namespaces/*</code></td></tr><tr><td style="text-align:left"><strong>Request parameters</strong></td><td style="text-align:left">Unchanged</td><td style="text-align:left">Identical to legacy</td></tr><tr><td style="text-align:left"><strong>Response payload shape</strong></td><td style="text-align:left">Unchanged</td><td style="text-align:left">Identical to legacy</td></tr><tr><td style="text-align:left"><strong>Availability after 2026-10-15</strong></td><td style="text-align:left">Removed</td><td style="text-align:left">Active</td></tr></tbody></table>
<h2 id="fix-it-change-one-url-segment">Fix it: change one URL segment</h2>
<div class="code-block code-block--has-filename"><div class="code-block__header"><span class="code-block__filename">before: legacy path</span><button type="button" class="code-block__copy" data-copy-button>Copy</button></div><pre class="astro-code github-dark" style="background-color:#24292e;color:#e1e4e8;overflow-x:auto" tabindex="0" data-language="bash" data-filename="before: legacy path"><code><span class="line"><span style="color:#B392F0">curl</span><span style="color:#79B8FF"> -X</span><span style="color:#9ECBFF"> GET</span><span style="color:#79B8FF"> \</span></span>
<span class="line"><span style="color:#9ECBFF">  &quot;https://api.cloudflare.com/client/v4/accounts/&lt;account_id&gt;/workers/namespaces&quot;</span><span style="color:#79B8FF"> \</span></span>
<span class="line"><span style="color:#79B8FF">  -H</span><span style="color:#9ECBFF"> &quot;Authorization: Bearer &lt;api_token&gt;&quot;</span></span></code></pre></div>
<div class="code-block code-block--has-filename"><div class="code-block__header"><span class="code-block__filename">after: current path</span><button type="button" class="code-block__copy" data-copy-button>Copy</button></div><pre class="astro-code github-dark" style="background-color:#24292e;color:#e1e4e8;overflow-x:auto" tabindex="0" data-language="bash" data-filename="after: current path"><code><span class="line"><span style="color:#B392F0">curl</span><span style="color:#79B8FF"> -X</span><span style="color:#9ECBFF"> GET</span><span style="color:#79B8FF"> \</span></span>
<span class="line"><span style="color:#9ECBFF">  &quot;https://api.cloudflare.com/client/v4/accounts/&lt;account_id&gt;/storage/kv/namespaces&quot;</span><span style="color:#79B8FF"> \</span></span>
<span class="line"><span style="color:#79B8FF">  -H</span><span style="color:#9ECBFF"> &quot;Authorization: Bearer &lt;api_token&gt;&quot;</span></span></code></pre></div>
<p>The same substitution applies to every route under this prefix: listing namespaces, reading/writing keys, and bulk operations all move from <code>/workers/namespaces/</code> to <code>/storage/kv/namespaces/</code> with no other change required. Grep infrastructure code for the literal string <code>workers/namespaces</code> to find every call site before the October cutoff:</p>
<div class="code-block code-block--has-filename"><div class="code-block__header"><span class="code-block__filename">find every call site before the cutoff</span><button type="button" class="code-block__copy" data-copy-button>Copy</button></div><pre class="astro-code github-dark" style="background-color:#24292e;color:#e1e4e8;overflow-x:auto" tabindex="0" data-language="bash" data-filename="find every call site before the cutoff"><code><span class="line"><span style="color:#B392F0">grep</span><span style="color:#79B8FF"> -rn</span><span style="color:#9ECBFF"> &quot;workers/namespaces&quot;</span><span style="color:#79B8FF"> --include=</span><span style="color:#9ECBFF">&quot;*.tf&quot;</span><span style="color:#79B8FF"> --include=</span><span style="color:#9ECBFF">&quot;*.sh&quot;</span><span style="color:#79B8FF"> --include=</span><span style="color:#9ECBFF">&quot;*.mjs&quot;</span><span style="color:#9ECBFF"> .</span></span></code></pre></div>
<p>This site’s own <code>wrangler.toml</code> declares two real KV namespace bindings (<code>COUNTERS_KV</code>, <code>SESSION_KV</code>), so if a script here ever called the KV REST API directly against those same namespace IDs, instead of going through Wrangler, this is the exact path change it would need before 2026-10-15.</p>
<h2 id="confirmed-version">Confirmed version</h2>
<p>Sourced from Cloudflare’s official changelog, “Deprecate legacy Workers KV namespace API routes,” published 2026-07-15. Browse more posts like this in the <a href="/data-automation">Data Automation</a> archive.</p>]]></content:encoded>
      <pubDate>Thu, 06 Aug 2026 00:00:00 GMT</pubDate>
    </item>
    <item>
      <title>Cloudflare Deprecates the Zone Settings Batch API</title>
      <link>https://bytetech247.com/data-automation/cloudflare-deprecates-zone-settings-batch-api/</link>
      <guid isPermaLink="true">https://bytetech247.com/data-automation/cloudflare-deprecates-zone-settings-batch-api/</guid>
      <description>Cloudflare retires batched zone settings reads/writes on 2027-03-31. Automation using the single-request endpoint needs to switch to per-setting calls.</description>
      <content:encoded><![CDATA[<aside class="quick-answer" aria-label="Quick answer"><h2 id="quick-answer">Quick Answer</h2>
<p>Cloudflare deprecates the Zone Settings Batch API, <code>GET</code>/<code>PATCH /zones/{zone_id}/settings</code>, which reads or edits multiple zone settings in a single request. It reaches end of life on 2027-03-31 (Cloudflare extended this from an original 2026-09-15 date). Automation using that batch endpoint needs to migrate to per-setting endpoints (<code>/zones/{zone_id}/settings/{setting_name}</code>) before then, one call per setting instead of one call for all of them.</p>
</aside><h2 id="whats-actually-changing">What’s actually changing</h2>
<p><a href="https://developers.cloudflare.com/fundamentals/api/reference/deprecations/">Cloudflare’s own changelog</a> states the change plainly:</p>
<blockquote>
<p>“The Zone Settings Batch API endpoints, which read and edit multiple zone settings in a single request, are deprecated”</p>
</blockquote>
<p>Unlike most of the other deprecations in this wave, this isn’t a URL path swap with the same request/response shape. The batch endpoint’s whole value was reading or writing several settings (SSL mode, always-use-HTTPS, minify, and so on) in one call. The <a href="https://developers.cloudflare.com/api/resources/zones/subresources/settings/methods/edit/">per-setting endpoints</a> Cloudflare points to instead follow the pattern <code>/zones/{zone_id}/settings/{setting_name}</code>, confirmed elsewhere in Cloudflare’s own API deprecations data for individual settings like <code>cname_flattening</code>. Migrating means genuinely restructuring any code that built one batched request into a loop, or a series, of per-setting requests.</p>
<h2 id="structural-comparison-matrix">Structural Comparison Matrix</h2>

























<table tabindex="0"><thead><tr><th scope="col" style="text-align:left">Operational Aspect</th><th scope="col" style="text-align:left">Batch API (deprecated)</th><th scope="col" style="text-align:left">Per-setting API</th></tr></thead><tbody><tr><td style="text-align:left"><strong>Requests to configure N settings</strong></td><td style="text-align:left">1</td><td style="text-align:left">N</td></tr><tr><td style="text-align:left"><strong>Endpoint shape</strong></td><td style="text-align:left"><code>/zones/{zone_id}/settings</code></td><td style="text-align:left"><code>/zones/{zone_id}/settings/{setting_name}</code></td></tr><tr><td style="text-align:left"><strong>Available after 2027-03-31</strong></td><td style="text-align:left">Removed</td><td style="text-align:left">Active</td></tr></tbody></table>
<h2 id="fix-it-loop-over-per-setting-calls">Fix it: loop over per-setting calls</h2>
<div class="code-block code-block--has-filename"><div class="code-block__header"><span class="code-block__filename">before: one batched request</span><button type="button" class="code-block__copy" data-copy-button>Copy</button></div><pre class="astro-code github-dark" style="background-color:#24292e;color:#e1e4e8;overflow-x:auto" tabindex="0" data-language="bash" data-filename="before: one batched request"><code><span class="line"><span style="color:#B392F0">curl</span><span style="color:#79B8FF"> -X</span><span style="color:#9ECBFF"> PATCH</span><span style="color:#79B8FF"> \</span></span>
<span class="line"><span style="color:#9ECBFF">  &quot;https://api.cloudflare.com/client/v4/zones/&lt;zone_id&gt;/settings&quot;</span><span style="color:#79B8FF"> \</span></span>
<span class="line"><span style="color:#79B8FF">  -H</span><span style="color:#9ECBFF"> &quot;Authorization: Bearer &lt;api_token&gt;&quot;</span><span style="color:#79B8FF"> \</span></span>
<span class="line"><span style="color:#79B8FF">  -H</span><span style="color:#9ECBFF"> &quot;Content-Type: application/json&quot;</span><span style="color:#79B8FF"> \</span></span>
<span class="line"><span style="color:#79B8FF">  --data</span><span style="color:#9ECBFF"> &#39;{&quot;items&quot;:[{&quot;id&quot;:&quot;ssl&quot;,&quot;value&quot;:&quot;full&quot;},{&quot;id&quot;:&quot;always_use_https&quot;,&quot;value&quot;:&quot;on&quot;}]}&#39;</span></span></code></pre></div>
<div class="code-block code-block--has-filename"><div class="code-block__header"><span class="code-block__filename">after: one request per setting</span><button type="button" class="code-block__copy" data-copy-button>Copy</button></div><pre class="astro-code github-dark" style="background-color:#24292e;color:#e1e4e8;overflow-x:auto" tabindex="0" data-language="bash" data-filename="after: one request per setting"><code><span class="line"><span style="color:#B392F0">curl</span><span style="color:#79B8FF"> -X</span><span style="color:#9ECBFF"> PATCH</span><span style="color:#79B8FF"> \</span></span>
<span class="line"><span style="color:#9ECBFF">  &quot;https://api.cloudflare.com/client/v4/zones/&lt;zone_id&gt;/settings/ssl&quot;</span><span style="color:#79B8FF"> \</span></span>
<span class="line"><span style="color:#79B8FF">  -H</span><span style="color:#9ECBFF"> &quot;Authorization: Bearer &lt;api_token&gt;&quot;</span><span style="color:#79B8FF"> \</span></span>
<span class="line"><span style="color:#79B8FF">  -H</span><span style="color:#9ECBFF"> &quot;Content-Type: application/json&quot;</span><span style="color:#79B8FF"> \</span></span>
<span class="line"><span style="color:#79B8FF">  --data</span><span style="color:#9ECBFF"> &#39;{&quot;value&quot;:&quot;full&quot;}&#39;</span></span>
<span class="line"></span>
<span class="line"><span style="color:#B392F0">curl</span><span style="color:#79B8FF"> -X</span><span style="color:#9ECBFF"> PATCH</span><span style="color:#79B8FF"> \</span></span>
<span class="line"><span style="color:#9ECBFF">  &quot;https://api.cloudflare.com/client/v4/zones/&lt;zone_id&gt;/settings/always_use_https&quot;</span><span style="color:#79B8FF"> \</span></span>
<span class="line"><span style="color:#79B8FF">  -H</span><span style="color:#9ECBFF"> &quot;Authorization: Bearer &lt;api_token&gt;&quot;</span><span style="color:#79B8FF"> \</span></span>
<span class="line"><span style="color:#79B8FF">  -H</span><span style="color:#9ECBFF"> &quot;Content-Type: application/json&quot;</span><span style="color:#79B8FF"> \</span></span>
<span class="line"><span style="color:#79B8FF">  --data</span><span style="color:#9ECBFF"> &#39;{&quot;value&quot;:&quot;on&quot;}&#39;</span></span></code></pre></div>
<div class="callout" role="note" data-astro-cid-q2ml7llr><p class="callout__title" data-astro-cid-q2ml7llr><span aria-hidden="true" data-astro-cid-q2ml7llr>⚠</span>This changes request volume, not just syntax</p><div class="callout__body" data-astro-cid-q2ml7llr><p>A pipeline configuring many zones with many settings each goes from one
request per zone to one request per setting per zone. If the automation runs
against API rate limits or is billed per request anywhere in its stack, this
is a real capacity-planning change, not a drop-in fix.</p></div></div>
<h2 id="confirmed-version">Confirmed version</h2>
<p>Sourced from <a href="https://developers.cloudflare.com/fundamentals/api/reference/deprecations/">Cloudflare’s own API deprecations reference</a>, re-checked 2026-08-13. That page now states the end-of-life date “has been extended to March 31, 2027 (previously September 15, 2026)”: this post originally used the earlier date, corrected here to match Cloudflare’s current published timeline. Migrate before the current 2027-03-31 date rather than the original one. Browse more posts like this in the <a href="/data-automation">Data Automation</a> archive.</p>]]></content:encoded>
      <pubDate>Thu, 06 Aug 2026 00:00:00 GMT</pubDate>
    </item>
    <item>
      <title>Cloudflare Drops connections Field From Tunnel API</title>
      <link>https://bytetech247.com/data-automation/cloudflare-drops-connections-field-from-tunnel-api/</link>
      <guid isPermaLink="true">https://bytetech247.com/data-automation/cloudflare-drops-connections-field-from-tunnel-api/</guid>
      <description>Cloudflare removes the connections array from cfd_tunnel/warp_connector responses on 2026-10-05. Use the new dedicated connections endpoint instead.</description>
      <content:encoded><![CDATA[<aside class="quick-answer" aria-label="Quick answer"><h2 id="quick-answer">Quick Answer</h2>
<p>Starting 2026-10-05, Cloudflare Tunnel and Cloudflare Mesh (the <code>cfd_tunnel</code> and <code>warp_connector</code> API resources) drop the <code>connections</code> array from their list and get responses. Code reading <code>.connections</code> off a tunnel list/get call gets a missing field instead of the array it expects. Switch to the new dedicated endpoint, <code>GET /accounts/{account_id}/cfd_tunnel/{tunnel_id}/connections</code>, to fetch that same data.</p>
</aside><h2 id="whats-actually-changing-and-why">What’s actually changing, and why</h2>
<p>This is a distinct technical change from <a href="/data-automation/cloudflare-removes-zero-trust-cidr-route-endpoints/">the CIDR-encoded route endpoint removal</a> bundled into the same Cloudflare changelog post and taking effect on the same date. That one is about how a route is addressed; this one is about what fields come back in a response.</p>
<p><a href="https://developers.cloudflare.com/changelog/post/2026-07-09-tunnel-routes-and-connections-api-changes/">Cloudflare’s own changelog</a> gives a real performance problem as its stated reasoning: a Tunnel or Mesh node with many active connections was inflating the response body of every list and get call, since full connection detail came back regardless of whether the caller needed it. Splitting connection detail into its own endpoint means smaller, faster default responses, with connection detail fetched only when actually requested.</p>
<p>The dedicated replacement endpoint, confirmed from <a href="https://developers.cloudflare.com/api/resources/zero_trust/subresources/tunnels/subresources/cloudflared/subresources/connections/">Cloudflare’s own API reference</a>, returns connection records shaped as:</p>
<div class="code-block code-block--has-filename"><div class="code-block__header"><span class="code-block__filename">GET /accounts/{account_id}/cfd_tunnel/{tunnel_id}/connections (response shape)</span><button type="button" class="code-block__copy" data-copy-button>Copy</button></div><pre class="astro-code github-dark" style="background-color:#24292e;color:#e1e4e8;overflow-x:auto" tabindex="0" data-language="json" data-filename="GET /accounts/{account_id}/cfd_tunnel/{tunnel_id}/connections (response shape)"><code><span class="line"><span style="color:#E1E4E8">{</span></span>
<span class="line"><span style="color:#79B8FF">  &quot;id&quot;</span><span style="color:#E1E4E8">: </span><span style="color:#9ECBFF">&quot;&lt;connection uuid&gt;&quot;</span><span style="color:#E1E4E8">,</span></span>
<span class="line"><span style="color:#79B8FF">  &quot;arch&quot;</span><span style="color:#E1E4E8">: </span><span style="color:#9ECBFF">&quot;&lt;cloudflared OS architecture&gt;&quot;</span><span style="color:#E1E4E8">,</span></span>
<span class="line"><span style="color:#79B8FF">  &quot;config_version&quot;</span><span style="color:#E1E4E8">: </span><span style="color:#9ECBFF">&quot;&lt;remote tunnel config version&gt;&quot;</span></span>
<span class="line"><span style="color:#E1E4E8">}</span></span></code></pre></div>
<h2 id="structural-comparison-matrix">Structural Comparison Matrix</h2>

























<table tabindex="0"><thead><tr><th scope="col" style="text-align:left">Operational Aspect</th><th scope="col" style="text-align:left">Before (removed 2026-10-05)</th><th scope="col" style="text-align:left">After</th></tr></thead><tbody><tr><td style="text-align:left"><strong>Where connection data lives</strong></td><td style="text-align:left">Inline <code>connections</code> array on every list/get response</td><td style="text-align:left">A dedicated <code>/cfd_tunnel/{tunnel_id}/connections</code> call</td></tr><tr><td style="text-align:left"><strong>Response size for tunnels with many connections</strong></td><td style="text-align:left">Inflated by full connection detail</td><td style="text-align:left">Small, connection detail fetched separately</td></tr><tr><td style="text-align:left"><strong>Fields available per connection</strong></td><td style="text-align:left">Whatever the inline array included</td><td style="text-align:left"><code>id</code>, <code>arch</code>, <code>config_version</code></td></tr></tbody></table>
<h2 id="fix-it-call-the-dedicated-endpoint">Fix it: call the dedicated endpoint</h2>
<div class="code-block code-block--has-filename"><div class="code-block__header"><span class="code-block__filename">before: reading connections off the tunnel response</span><button type="button" class="code-block__copy" data-copy-button>Copy</button></div><pre class="astro-code github-dark" style="background-color:#24292e;color:#e1e4e8;overflow-x:auto" tabindex="0" data-language="bash" data-filename="before: reading connections off the tunnel response"><code><span class="line"><span style="color:#B392F0">curl</span><span style="color:#79B8FF"> -X</span><span style="color:#9ECBFF"> GET</span><span style="color:#79B8FF"> \</span></span>
<span class="line"><span style="color:#9ECBFF">  &quot;https://api.cloudflare.com/client/v4/accounts/&lt;account_id&gt;/cfd_tunnel/&lt;tunnel_id&gt;&quot;</span><span style="color:#79B8FF"> \</span></span>
<span class="line"><span style="color:#79B8FF">  -H</span><span style="color:#9ECBFF"> &quot;Authorization: Bearer &lt;api_token&gt;&quot;</span></span>
<span class="line"><span style="color:#9ca6b0"># .result.connections used to be here</span></span></code></pre></div>
<div class="code-block code-block--has-filename"><div class="code-block__header"><span class="code-block__filename">after: the dedicated connections endpoint</span><button type="button" class="code-block__copy" data-copy-button>Copy</button></div><pre class="astro-code github-dark" style="background-color:#24292e;color:#e1e4e8;overflow-x:auto" tabindex="0" data-language="bash" data-filename="after: the dedicated connections endpoint"><code><span class="line"><span style="color:#B392F0">curl</span><span style="color:#79B8FF"> -X</span><span style="color:#9ECBFF"> GET</span><span style="color:#79B8FF"> \</span></span>
<span class="line"><span style="color:#9ECBFF">  &quot;https://api.cloudflare.com/client/v4/accounts/&lt;account_id&gt;/cfd_tunnel/&lt;tunnel_id&gt;/connections&quot;</span><span style="color:#79B8FF"> \</span></span>
<span class="line"><span style="color:#79B8FF">  -H</span><span style="color:#9ECBFF"> &quot;Authorization: Bearer &lt;api_token&gt;&quot;</span></span></code></pre></div>
<div class="callout" role="note" data-astro-cid-q2ml7llr><p class="callout__title" data-astro-cid-q2ml7llr><span aria-hidden="true" data-astro-cid-q2ml7llr>⚠</span>Check both cfd_tunnel and warp_connector</p><div class="callout__body" data-astro-cid-q2ml7llr><p>This change applies to both Cloudflare Tunnel (<code>cfd_tunnel</code>) and Cloudflare
Mesh (<code>warp_connector</code>) resources. If automation monitors connection health
for both, both call sites need the same fix, not just the more commonly used
Tunnel one.</p></div></div>
<p>Code that only reads other tunnel fields (name, status, created date) and never touches <code>.connections</code> needs no change at all; audit for the literal field access before assuming a rewrite is required.</p>
<h2 id="confirmed-version">Confirmed version</h2>
<p>Sourced from Cloudflare’s official changelog, “Zero Trust Networks route endpoints and Cloudflare Tunnel connections field retiring on October 5, 2026,” published 2026-07-09, and Cloudflare’s own API reference for the dedicated connections endpoint’s response shape. Browse more posts like this in the <a href="/data-automation">Data Automation</a> archive.</p>]]></content:encoded>
      <pubDate>Thu, 06 Aug 2026 00:00:00 GMT</pubDate>
    </item>
    <item>
      <title>Cloudflare Now Enforces a 65-Char Account Name Limit</title>
      <link>https://bytetech247.com/data-automation/cloudflare-enforces-65-char-account-name-limit/</link>
      <guid isPermaLink="true">https://bytetech247.com/data-automation/cloudflare-enforces-65-char-account-name-limit/</guid>
      <description>Cloudflare enforces a 65-character account name cap on 2026-09-27. Automation creating or renaming accounts with longer names starts failing validation.</description>
      <content:encoded><![CDATA[<aside class="quick-answer" aria-label="Quick answer"><h2 id="quick-answer">Quick Answer</h2>
<p>Cloudflare enforces a 65-character maximum on account names across <code>POST /accounts</code> and <code>PUT /accounts/{account_id}</code> starting 2026-09-27. Automation that generates account names programmatically, prefixing a project ID or a long descriptive string, needs to validate length before the call, or the request starts failing once enforcement begins.</p>
</aside><h2 id="whats-actually-changing">What’s actually changing</h2>
<p>This is a new validation constraint, not an endpoint removal or a schema change. <a href="https://developers.cloudflare.com/fundamentals/api/reference/deprecations/">Cloudflare’s changelog</a> states it directly:</p>
<blockquote>
<p>“Account names will be limited to a maximum of 65 characters across all account creation and update APIs”</p>
</blockquote>
<p>Before enforcement begins, a longer name is accepted the same way it always has been. After 2026-09-27, the same request that used to succeed starts returning a validation error instead, purely because of name length, with no other change to the request shape.</p>
<h2 id="structural-comparison-matrix">Structural Comparison Matrix</h2>

























<table tabindex="0"><thead><tr><th scope="col" style="text-align:left">Operational Aspect</th><th scope="col" style="text-align:left">Before 2026-09-27</th><th scope="col" style="text-align:left">After 2026-09-27</th></tr></thead><tbody><tr><td style="text-align:left"><strong>Account name over 65 characters</strong></td><td style="text-align:left">Accepted</td><td style="text-align:left">Rejected with a validation error</td></tr><tr><td style="text-align:left"><strong>Request shape</strong></td><td style="text-align:left">Unchanged</td><td style="text-align:left">Unchanged</td></tr><tr><td style="text-align:left"><strong>Endpoints affected</strong></td><td style="text-align:left"><code>POST /accounts</code>, <code>PUT /accounts/{account_id}</code></td><td style="text-align:left">Same endpoints</td></tr></tbody></table>
<h2 id="fix-it-validate-length-before-the-call">Fix it: validate length before the call</h2>
<div class="code-block code-block--has-filename"><div class="code-block__header"><span class="code-block__filename">before: a name that will start failing</span><button type="button" class="code-block__copy" data-copy-button>Copy</button></div><pre class="astro-code github-dark" style="background-color:#24292e;color:#e1e4e8;overflow-x:auto" tabindex="0" data-language="bash" data-filename="before: a name that will start failing"><code><span class="line"><span style="color:#B392F0">curl</span><span style="color:#79B8FF"> -X</span><span style="color:#9ECBFF"> POST</span><span style="color:#9ECBFF"> &quot;https://api.cloudflare.com/client/v4/accounts&quot;</span><span style="color:#79B8FF"> \</span></span>
<span class="line"><span style="color:#79B8FF">  -H</span><span style="color:#9ECBFF"> &quot;Authorization: Bearer &lt;api_token&gt;&quot;</span><span style="color:#79B8FF"> \</span></span>
<span class="line"><span style="color:#79B8FF">  -H</span><span style="color:#9ECBFF"> &quot;Content-Type: application/json&quot;</span><span style="color:#79B8FF"> \</span></span>
<span class="line"><span style="color:#79B8FF">  --data</span><span style="color:#9ECBFF"> &#39;{&quot;name&quot;: &quot;acme-corp-production-deployment-pipeline-service-account-2026-eu-west-region&quot;}&#39;</span></span></code></pre></div>
<p>That name is 76 characters, over the limit. Truncate or restructure the naming scheme before enforcement lands:</p>
<div class="code-block code-block--has-filename"><div class="code-block__header"><span class="code-block__filename">after: within the 65-character cap</span><button type="button" class="code-block__copy" data-copy-button>Copy</button></div><pre class="astro-code github-dark" style="background-color:#24292e;color:#e1e4e8;overflow-x:auto" tabindex="0" data-language="bash" data-filename="after: within the 65-character cap"><code><span class="line"><span style="color:#B392F0">curl</span><span style="color:#79B8FF"> -X</span><span style="color:#9ECBFF"> POST</span><span style="color:#9ECBFF"> &quot;https://api.cloudflare.com/client/v4/accounts&quot;</span><span style="color:#79B8FF"> \</span></span>
<span class="line"><span style="color:#79B8FF">  -H</span><span style="color:#9ECBFF"> &quot;Authorization: Bearer &lt;api_token&gt;&quot;</span><span style="color:#79B8FF"> \</span></span>
<span class="line"><span style="color:#79B8FF">  -H</span><span style="color:#9ECBFF"> &quot;Content-Type: application/json&quot;</span><span style="color:#79B8FF"> \</span></span>
<span class="line"><span style="color:#79B8FF">  --data</span><span style="color:#9ECBFF"> &#39;{&quot;name&quot;: &quot;acme-corp-prod-deploy-pipeline-2026-eu-west&quot;}&#39;</span></span></code></pre></div>
<div class="callout" role="note" data-astro-cid-q2ml7llr><p class="callout__title" data-astro-cid-q2ml7llr><span aria-hidden="true" data-astro-cid-q2ml7llr>✓</span>Add a length check in code, not just at deploy time</p><div class="callout__body" data-astro-cid-q2ml7llr><p>If account names are generated programmatically (a template string combining a
project slug, environment, and region, for example), add an explicit length
assertion in the code that builds the name, not just a manual check before
running a script. A silent truncation or an unhandled 400 error at 2 a.m.
during an automated account-creation flow is a worse failure mode than
catching it in code review.</p></div></div>
<h2 id="confirmed-version">Confirmed version</h2>
<p>Sourced from Cloudflare’s official changelog, published 2026-07-22, with enforcement beginning 2026-09-27. Browse more posts like this in the <a href="/data-automation">Data Automation</a> archive.</p>]]></content:encoded>
      <pubDate>Thu, 06 Aug 2026 00:00:00 GMT</pubDate>
    </item>
    <item>
      <title>Cloudflare Removes Zero Trust CIDR Route Endpoints</title>
      <link>https://bytetech247.com/data-automation/cloudflare-removes-zero-trust-cidr-route-endpoints/</link>
      <guid isPermaLink="true">https://bytetech247.com/data-automation/cloudflare-removes-zero-trust-cidr-route-endpoints/</guid>
      <description>Cloudflare removes the CIDR-encoded route endpoints in the Zero Trust Networks API on 2026-10-05. Tunnel/WARP Connector automation needs to migrate before then.</description>
      <content:encoded><![CDATA[<aside class="quick-answer" aria-label="Quick answer"><h2 id="quick-answer">Quick Answer</h2>
<p>Cloudflare removes the CIDR-encoded route endpoints in the Zero Trust Networks API on 2026-10-05. Automation calling <code>POST</code>/<code>PATCH</code>/<code>DELETE</code> on <code>/accounts/{account_id}/teamnet/routes/network/{ip_network_encoded}</code> needs to switch to the standard, <code>route_id</code>-based endpoints instead, capturing each route’s <code>route_id</code> from the List tunnel routes call or from the create response, before that date.</p>
</aside><h2 id="whats-actually-changing">What’s actually changing</h2>
<p>Two related changes, <a href="https://developers.cloudflare.com/changelog/post/2026-07-09-tunnel-routes-and-connections-api-changes/">detailed in Cloudflare’s official changelog</a>, land on the same date across the Zero Trust Networks API and Cloudflare Tunnel API. This post covers the route-endpoint removal; the other, a response field drop, is covered separately since it’s a distinct technical change with its own fix.</p>
<p>The CIDR-encoded route endpoints, which addressed a route by URL-encoding its IP network directly into the path, are being deprecated in favor of the <code>route_id</code>-based endpoints that already exist today:</p>
<ul>
<li>Old (removed 2026-10-05): <code>POST</code>/<code>PATCH</code>/<code>DELETE /accounts/{account_id}/teamnet/routes/network/{ip_network_encoded}</code></li>
<li>New (already available): <code>PATCH</code>/<code>DELETE /accounts/{account_id}/teamnet/routes/{route_id}</code></li>
</ul>
<p>Consolidating on <code>route_id</code> matches how every other resource in the Zero Trust Networks API is already addressed, and drops the need to URL-encode a CIDR range into the path at all.</p>
<h2 id="structural-comparison-matrix">Structural Comparison Matrix</h2>

























<table tabindex="0"><thead><tr><th scope="col" style="text-align:left">Operational Aspect</th><th scope="col" style="text-align:left">CIDR-encoded endpoint (removed)</th><th scope="col" style="text-align:left">route_id endpoint (current)</th></tr></thead><tbody><tr><td style="text-align:left"><strong>How a route is addressed</strong></td><td style="text-align:left">IP network URL-encoded into the path</td><td style="text-align:left">A stable <code>route_id</code></td></tr><tr><td style="text-align:left"><strong>URL encoding required</strong></td><td style="text-align:left">Yes, the CIDR range itself</td><td style="text-align:left">No</td></tr><tr><td style="text-align:left"><strong>Consistency with rest of the API</strong></td><td style="text-align:left">Inconsistent, this was the only CIDR-addressed resource</td><td style="text-align:left">Matches every other Zero Trust Networks resource</td></tr></tbody></table>
<h2 id="fix-it-capture-route_id-then-switch-endpoints">Fix it: capture route_id, then switch endpoints</h2>
<div class="code-block code-block--has-filename"><div class="code-block__header"><span class="code-block__filename">find the route_id for every existing route</span><button type="button" class="code-block__copy" data-copy-button>Copy</button></div><pre class="astro-code github-dark" style="background-color:#24292e;color:#e1e4e8;overflow-x:auto" tabindex="0" data-language="bash" data-filename="find the route_id for every existing route"><code><span class="line"><span style="color:#B392F0">curl</span><span style="color:#79B8FF"> -X</span><span style="color:#9ECBFF"> GET</span><span style="color:#79B8FF"> \</span></span>
<span class="line"><span style="color:#9ECBFF">  &quot;https://api.cloudflare.com/client/v4/accounts/&lt;account_id&gt;/teamnet/routes&quot;</span><span style="color:#79B8FF"> \</span></span>
<span class="line"><span style="color:#79B8FF">  -H</span><span style="color:#9ECBFF"> &quot;Authorization: Bearer &lt;api_token&gt;&quot;</span></span></code></pre></div>
<p>Each route in that response includes its <code>route_id</code>. Store it wherever the automation currently stores the CIDR range, then switch the update/delete calls:</p>
<div class="code-block code-block--has-filename"><div class="code-block__header"><span class="code-block__filename">before: CIDR-encoded path</span><button type="button" class="code-block__copy" data-copy-button>Copy</button></div><pre class="astro-code github-dark" style="background-color:#24292e;color:#e1e4e8;overflow-x:auto" tabindex="0" data-language="bash" data-filename="before: CIDR-encoded path"><code><span class="line"><span style="color:#B392F0">curl</span><span style="color:#79B8FF"> -X</span><span style="color:#9ECBFF"> DELETE</span><span style="color:#79B8FF"> \</span></span>
<span class="line"><span style="color:#9ECBFF">  &quot;https://api.cloudflare.com/client/v4/accounts/&lt;account_id&gt;/teamnet/routes/network/10.0.0.0%2F24&quot;</span><span style="color:#79B8FF"> \</span></span>
<span class="line"><span style="color:#79B8FF">  -H</span><span style="color:#9ECBFF"> &quot;Authorization: Bearer &lt;api_token&gt;&quot;</span></span></code></pre></div>
<div class="code-block code-block--has-filename"><div class="code-block__header"><span class="code-block__filename">after: route_id path</span><button type="button" class="code-block__copy" data-copy-button>Copy</button></div><pre class="astro-code github-dark" style="background-color:#24292e;color:#e1e4e8;overflow-x:auto" tabindex="0" data-language="bash" data-filename="after: route_id path"><code><span class="line"><span style="color:#B392F0">curl</span><span style="color:#79B8FF"> -X</span><span style="color:#9ECBFF"> DELETE</span><span style="color:#79B8FF"> \</span></span>
<span class="line"><span style="color:#9ECBFF">  &quot;https://api.cloudflare.com/client/v4/accounts/&lt;account_id&gt;/teamnet/routes/&lt;route_id&gt;&quot;</span><span style="color:#79B8FF"> \</span></span>
<span class="line"><span style="color:#79B8FF">  -H</span><span style="color:#9ECBFF"> &quot;Authorization: Bearer &lt;api_token&gt;&quot;</span></span></code></pre></div>
<div class="callout" role="note" data-astro-cid-q2ml7llr><p class="callout__title" data-astro-cid-q2ml7llr><span aria-hidden="true" data-astro-cid-q2ml7llr>⚠</span>Creating new routes already uses route_id</p><div class="callout__body" data-astro-cid-q2ml7llr><p>Route creation isn’t CIDR-encoded to begin with, only update and delete were.
If your automation only ever creates routes and never updates or deletes them
by CIDR, this deprecation may not touch it at all. Check which HTTP methods
your scripts actually call before assuming a rewrite is needed.</p></div></div>
<h2 id="confirmed-version">Confirmed version</h2>
<p>Sourced from Cloudflare’s official changelog, “Zero Trust Networks route endpoints and Cloudflare Tunnel connections field retiring on October 5, 2026,” published 2026-07-09. Browse more posts like this in the <a href="/data-automation">Data Automation</a> archive.</p>]]></content:encoded>
      <pubDate>Thu, 06 Aug 2026 00:00:00 GMT</pubDate>
    </item>
    <item>
      <title>Cloudflare&apos;s July 2026 API Deprecation Wave: Full Guide</title>
      <link>https://bytetech247.com/data-automation/cloudflares-july-2026-api-deprecation-wave/</link>
      <guid isPermaLink="true">https://bytetech247.com/data-automation/cloudflares-july-2026-api-deprecation-wave/</guid>
      <description>Cloudflare published 10 API deprecations affecting automated pipelines between May and July 2026. Full rundown of every endpoint, deadline, and fix.</description>
      <content:encoded><![CDATA[<aside class="quick-answer" aria-label="Quick answer"><h2 id="quick-answer">Quick Answer</h2>
<p>Cloudflare published 10 distinct API deprecations affecting automated pipelines between 2026-05-13 and 2026-07-27: mostly REST endpoint and field removals across Workers KV, Zero Trust, zone settings, account management, DNS, and domain registration. Two have already taken full effect; the other eight have deadlines between September 2026 and March 2027. Check the table below against what your own scripts, Terraform configs, or CI pipeline actually call.</p>
<p>Cloudflare’s own API deprecations tracker logged nine distinct, dated entries in this window, all inside the active research period and several within weeks of each other. Every one is a REST API endpoint or field removal that can silently break infrastructure automation calling Cloudflare’s API directly, not through a UI a human would notice failing. This site’s own <code>wrangler.toml</code> declares real KV bindings, making the first cluster below a genuinely first-person, dogfooded example rather than a hypothetical.</p>
</aside><h2 id="structural-comparison-matrix">Structural Comparison Matrix</h2>


















































































<table tabindex="0"><thead><tr><th scope="col" style="text-align:left">#</th><th scope="col" style="text-align:left">Deprecation</th><th scope="col" style="text-align:left">API surface</th><th scope="col" style="text-align:left">Deadline</th><th scope="col" style="text-align:left">Status</th></tr></thead><tbody><tr><td style="text-align:left">1</td><td style="text-align:left">Legacy Workers KV namespace routes</td><td style="text-align:left"><code>/accounts/{account_id}/workers/namespaces/*</code></td><td style="text-align:left">2026-10-15</td><td style="text-align:left">Upcoming</td></tr><tr><td style="text-align:left">2</td><td style="text-align:left">Zero Trust CIDR route endpoints</td><td style="text-align:left">Zero Trust Networks routes API</td><td style="text-align:left">2026-10-05</td><td style="text-align:left">Upcoming</td></tr><tr><td style="text-align:left">3</td><td style="text-align:left"><code>connections</code> field dropped from Tunnel API</td><td style="text-align:left">Tunnel/Mesh list and get responses</td><td style="text-align:left">2026-10-05</td><td style="text-align:left">Upcoming</td></tr><tr><td style="text-align:left">4</td><td style="text-align:left">Zone Settings Batch API</td><td style="text-align:left"><code>/zones/{zone_id}/settings</code> (batch)</td><td style="text-align:left">2026-09-15</td><td style="text-align:left">Upcoming</td></tr><tr><td style="text-align:left">5</td><td style="text-align:left">65-character account name limit enforced</td><td style="text-align:left">Account creation/update APIs</td><td style="text-align:left">2026-09-27</td><td style="text-align:left">Upcoming</td></tr><tr><td style="text-align:left">6</td><td style="text-align:left"><code>foundation_dns</code> setting</td><td style="text-align:left">DNS settings endpoints</td><td style="text-align:left">2026-11-23</td><td style="text-align:left">Upcoming</td></tr><tr><td style="text-align:left">7</td><td style="text-align:left">Account Roles API</td><td style="text-align:left"><code>/accounts/{account_id}/roles</code></td><td style="text-align:left">2026-07-21 (no published EOL yet)</td><td style="text-align:left">Deprecated</td></tr><tr><td style="text-align:left">8</td><td style="text-align:left">AMP/SXG API</td><td style="text-align:left"><code>/zones/{zone_id}/amp/sxg</code></td><td style="text-align:left">2026-06-23</td><td style="text-align:left"><strong>Already ended</strong></td></tr><tr><td style="text-align:left">9</td><td style="text-align:left">Legacy Registrar domain API</td><td style="text-align:left"><code>/accounts/{account_id}/registrar/domains</code></td><td style="text-align:left">2026-09-27</td><td style="text-align:left">Upcoming</td></tr><tr><td style="text-align:left">10</td><td style="text-align:left">Gateway Audit SSH rules</td><td style="text-align:left">Gateway network policy <code>audit_ssh</code> action</td><td style="text-align:left">2026-07-15</td><td style="text-align:left"><strong>Already ended</strong></td></tr></tbody></table>
<p>Every deadline above is Cloudflare’s own stated date, cross-checked against its official changelog entry for that deprecation. Two (AMP/SXG and Gateway Audit SSH) already reached end of life before this guide was published - if automation calling either has been failing with no obvious cause, that is why.</p>
<h2 id="the-10-deprecations-in-detail">The 10 deprecations, in detail</h2>
<ol>
<li><strong><a href="/data-automation/cloudflare-deprecates-legacy-workers-kv-api-routes/">Cloudflare Deprecates Legacy Workers KV API Routes</a></strong>: the legacy <code>/workers/namespaces/*</code> routes stop 2026-10-15. This site’s own KV bindings are the dogfooded example.</li>
<li><strong><a href="/data-automation/cloudflare-removes-zero-trust-cidr-route-endpoints/">Cloudflare Removes Zero Trust CIDR Route Endpoints</a></strong>: CIDR-encoded Tunnel/WARP Connector routing goes away 2026-10-05.</li>
<li><strong><a href="/data-automation/cloudflare-drops-connections-field-from-tunnel-api/">Cloudflare Drops connections Field From Tunnel API</a></strong>: a schema change on the same 2026-10-05 date, distinct from cluster 2: a missing response field, not a removed endpoint.</li>
<li><strong><a href="/data-automation/cloudflare-deprecates-zone-settings-batch-api/">Cloudflare Deprecates the Zone Settings Batch API</a></strong>: batched zone-settings reads/writes end 2026-09-15; migrate to per-setting calls.</li>
<li><strong><a href="/data-automation/cloudflare-enforces-65-char-account-name-limit/">Cloudflare Now Enforces a 65-Char Account Name Limit</a></strong>: a new validation constraint on account creation/rename APIs, effective 2026-09-27.</li>
<li><strong><a href="/data-automation/cloudflare-deprecates-foundation-dns-setting/">Cloudflare Deprecates the foundation_dns DNS Setting</a></strong>: the <code>foundation_dns</code> boolean stops working 2026-11-23.</li>
<li><strong><a href="/data-automation/cloudflare-deprecates-account-roles-api/">Cloudflare Deprecates the Account Roles API</a></strong>: already in effect since 2026-07-21; migrate to the Permission Groups API, a genuinely different data model.</li>
<li><strong><a href="/data-automation/cloudflare-amp-sxg-api-end-of-life/">Cloudflare AMP/SXG API Has Reached End of Life</a></strong>: already ended 2026-06-23, with no replacement.</li>
<li><strong><a href="/data-automation/cloudflare-deprecates-legacy-registrar-domain-api/">Cloudflare Deprecates Legacy Registrar Domain API</a></strong>: domain registration/renewal automation loses its endpoint 2026-09-27.</li>
<li><strong><a href="/data-automation/cloudflare-deprecates-gateway-audit-ssh-rules/">Cloudflare Deprecates Gateway Audit SSH Rules</a></strong>: a staged rollout that fully ended 2026-07-15, three separate dates where a policy-as-code pipeline could have quietly broken.</li>
</ol>
<h2 id="why-this-happened-in-one-window">Why this happened in one window</h2>
<p>Cloudflare is consolidating storage APIs under a unified <code>/storage/</code> namespace and tightening validation across account management, alongside routine Zero Trust and Gateway feature retirements. None of the dates or endpoint paths above are estimated - they come directly from <a href="https://developers.cloudflare.com/fundamentals/api/reference/deprecations/">Cloudflare’s official API deprecations reference</a> and its changelog, cross-checked per cluster against the individual changelog post.</p>
<p>Browse the rest of the <a href="/data-automation">Data Automation</a> archive for more Cloudflare API and pipeline coverage.</p>]]></content:encoded>
      <pubDate>Thu, 06 Aug 2026 00:00:00 GMT</pubDate>
    </item>
    <item>
      <title>The MCP 2026-07-28 Spec Rewrite: Full Guide</title>
      <link>https://bytetech247.com/ai-productivity/mcp-2026-07-28-spec-rewrite/</link>
      <guid isPermaLink="true">https://bytetech247.com/ai-productivity/mcp-2026-07-28-spec-rewrite/</guid>
      <description>The MCP 2026-07-28 spec rewrites the protocol core: sessions gone, a new required RPC, subscriptions replaced. Full rundown of every breaking change.</description>
      <content:encoded><![CDATA[<aside class="quick-answer" aria-label="Quick answer"><h2 id="quick-answer">Quick Answer</h2>
<p>The MCP 2026-07-28 specification is a comprehensive rewrite of the protocol core: a stateless architecture replaces session-based state, <code>initialize</code> is gone in favor of per-request <code>_meta</code> negotiation, servers must implement a new <code>server/discover</code> RPC, and SSE subscriptions/resumability are replaced. Eight changes are already-effective breaking changes; two (Roots/Sampling/Logging, and OAuth Dynamic Client Registration) are deprecations with a runway. Check the table below against what your own MCP servers and clients actually implement.</p>
<p>Published 2026-07-28, this is directly dogfooded: this exact session runs on Claude Code, connected to real MCP servers, so every change below describes a protocol this environment’s own tooling actually speaks. Sourced from the <a href="https://modelcontextprotocol.io/specification/2026-07-28/changelog">official MCP specification changelog</a>, which lists far more than 10 distinct, verbatim, non-overlapping technical changes.</p>
</aside><h2 id="structural-comparison-matrix">Structural Comparison Matrix</h2>


















































































<table tabindex="0"><thead><tr><th scope="col" style="text-align:left">#</th><th scope="col" style="text-align:left">Change</th><th scope="col" style="text-align:left">Old behavior</th><th scope="col" style="text-align:left">New behavior</th><th scope="col" style="text-align:left">Status</th></tr></thead><tbody><tr><td style="text-align:left">1</td><td style="text-align:left">Protocol-level sessions</td><td style="text-align:left"><code>Mcp-Session-Id</code> header, sticky routing</td><td style="text-align:left">Explicit server-minted handles as tool arguments</td><td style="text-align:left">Breaking</td></tr><tr><td style="text-align:left">2</td><td style="text-align:left">Handshake</td><td style="text-align:left">One-time <code>initialize</code>/<code>initialized</code> exchange</td><td style="text-align:left">Per-request <code>_meta</code> version/capability fields</td><td style="text-align:left">Breaking</td></tr><tr><td style="text-align:left">3</td><td style="text-align:left">Server discovery</td><td style="text-align:left">Implicit, via <code>initialize</code></td><td style="text-align:left">Mandatory <code>server/discover</code> RPC</td><td style="text-align:left">Breaking (new requirement)</td></tr><tr><td style="text-align:left">4</td><td style="text-align:left">Change notifications</td><td style="text-align:left">Persistent SSE GET stream, all types</td><td style="text-align:left">Opt-in <code>subscriptions/listen</code>, typed</td><td style="text-align:left">Breaking</td></tr><tr><td style="text-align:left">5</td><td style="text-align:left">Health check / log level</td><td style="text-align:left"><code>ping</code>, <code>logging/setLevel</code> RPCs</td><td style="text-align:left">No direct replacement; log level via <code>_meta</code></td><td style="text-align:left">Breaking (removed)</td></tr><tr><td style="text-align:left">6</td><td style="text-align:left">Long-running tasks</td><td style="text-align:left">Experimental core, blocking <code>tasks/result</code></td><td style="text-align:left">Formal extension, polling via <code>tasks/get</code></td><td style="text-align:left">Breaking</td></tr><tr><td style="text-align:left">7</td><td style="text-align:left">Result shape</td><td style="text-align:left">Implicit result, server-initiated mid-call requests</td><td style="text-align:left">Mandatory <code>resultType</code>, client-driven MRTR pattern</td><td style="text-align:left">Breaking</td></tr><tr><td style="text-align:left">8</td><td style="text-align:left">Stream resumability</td><td style="text-align:left"><code>Last-Event-ID</code>, resume in place</td><td style="text-align:left">None; re-issue the request as new</td><td style="text-align:left">Breaking (removed)</td></tr><tr><td style="text-align:left">9</td><td style="text-align:left">Roots, Sampling, Logging</td><td style="text-align:left">Core features</td><td style="text-align:left">Deprecated, 12-month minimum runway</td><td style="text-align:left">Deprecated</td></tr><tr><td style="text-align:left">10</td><td style="text-align:left">OAuth client registration</td><td style="text-align:left">Dynamic Client Registration (RFC 7591)</td><td style="text-align:left">Client ID Metadata Documents</td><td style="text-align:left">Deprecated</td></tr></tbody></table>
<p>Every row above is drawn directly from the spec’s own changelog entry for that change, not summarized from memory. Full mechanism, exact spec language, and migration path live in each linked post.</p>
<h2 id="the-10-changes-in-detail">The 10 changes, in detail</h2>
<ol>
<li><strong><a href="/ai-productivity/mcp-removes-protocol-level-sessions/">MCP Removes Protocol-Level Sessions (Mcp-Session-Id)</a></strong>: cross-call state now requires explicit, server-minted handles, not a session header.</li>
<li><strong><a href="/ai-productivity/mcp-replaces-initialize-handshake-with-meta-fields/">MCP Replaces initialize Handshake With _meta Fields</a></strong>: version and capability negotiation happens per-request now, not once at connection start.</li>
<li><strong><a href="/ai-productivity/mcp-servers-must-implement-server-discover/">MCP Servers Must Now Implement server/discover</a></strong>: a new mandatory RPC, since there’s no <code>initialize</code> call left to fall back on for discovery.</li>
<li><strong><a href="/ai-productivity/mcp-replaces-sse-subscriptions-with-subscriptions-listen/">MCP Replaces SSE Subscriptions With subscriptions/listen</a></strong>: clients must explicitly opt into each notification type they care about.</li>
<li><strong><a href="/ai-productivity/mcp-removes-ping-and-logging-setlevel-methods/">MCP Removes the ping and logging/setLevel Methods</a></strong>: no direct replacement for either; log level moves to a per-request <code>_meta</code> field.</li>
<li><strong><a href="/ai-productivity/mcp-moves-tasks-out-of-core-into-extension/">MCP Moves Tasks Out of Core Into an Extension</a></strong>: a namespace change and a control-flow rewrite, from blocking to polling.</li>
<li><strong><a href="/ai-productivity/mcp-requires-resulttype-on-every-result/">MCP Requires resultType on Every Returned Result</a></strong>: every response needs an explicit discriminator; the MRTR pattern replaces server-initiated mid-call requests.</li>
<li><strong><a href="/ai-productivity/mcp-removes-sse-resumability/">MCP Removes SSE Resumability (Last-Event-ID Gone)</a></strong>: a broken stream now means re-issuing the whole request, not resuming in place.</li>
<li><strong><a href="/ai-productivity/mcp-deprecates-roots-sampling-and-logging/">MCP Deprecates Roots, Sampling, and Logging</a></strong>: a deprecation with a runway, not an immediate break.</li>
<li><strong><a href="/ai-productivity/mcp-deprecates-oauth-dynamic-client-registration/">MCP Deprecates OAuth Dynamic Client Registration</a></strong>: a migration path to Client ID Metadata Documents, at the authorization layer.</li>
</ol>
<h2 id="why-this-happened-in-one-revision">Why this happened in one revision</h2>
<p>MCP’s 2026-07-28 revision moves the protocol from a connection-oriented, session-based design toward a stateless core, a structural shift that touches transport, discovery, notifications, and result handling all at once rather than landing as incremental point changes. None of the mechanisms above are speculative: every one is drawn directly from the <a href="https://modelcontextprotocol.io/specification/2026-07-28/changelog">official specification changelog</a>, published the same day as the revision itself.</p>
<p>Browse the rest of the <a href="/ai-productivity">AI Productivity</a> archive for more MCP and agent-tooling coverage.</p>]]></content:encoded>
      <pubDate>Thu, 06 Aug 2026 00:00:00 GMT</pubDate>
    </item>
    <item>
      <title>MCP Deprecates OAuth Dynamic Client Registration</title>
      <link>https://bytetech247.com/ai-productivity/mcp-deprecates-oauth-dynamic-client-registration/</link>
      <guid isPermaLink="true">https://bytetech247.com/ai-productivity/mcp-deprecates-oauth-dynamic-client-registration/</guid>
      <description>The 2026-07-28 MCP spec deprecates OAuth DCR for Client ID Metadata Documents. A stable HTTPS URL becomes the client_id instead of a minted registration.</description>
      <content:encoded><![CDATA[<aside class="quick-answer" aria-label="Quick answer"><h2 id="quick-answer">Quick Answer</h2>
<p>The MCP 2026-07-28 specification deprecates the OAuth 2.0 Dynamic Client Registration Protocol (RFC 7591) in favor of <a href="https://modelcontextprotocol.io/specification/2026-07-28/basic/authorization/client-registration#client-id-metadata-documents">Client ID Metadata Documents</a> (CIMD, tracked under SEP-991). Instead of an authorization server minting a new <code>client_id</code> at registration time, the client hosts a JSON metadata document at a stable HTTPS URL it controls, and that URL becomes the <code>client_id</code> directly. DCR still works for backward compatibility, but CIMD is now the preferred default.</p>
</aside><h2 id="whats-actually-changing-and-why">What’s actually changing, and why</h2>
<p>The specification changelog states the change directly:</p>
<blockquote>
<p>“OAuth 2.0 Dynamic Client Registration Protocol (RFC 7591) deprecated in favor of Client ID Metadata Documents”</p>
</blockquote>
<p>DCR’s real weakness: an authorization server minting a <code>client_id</code> for any client that asks doesn’t provide a reliable way to verify who’s actually asking, which makes it vulnerable to phishing-style client impersonation. CIMD flips the identity model to be web-based instead: if a client controls the domain hosting its metadata document, it controls its own identity, verified by the authorization server fetching that URL directly rather than trusting a self-reported registration.</p>
<h2 id="structural-comparison-matrix">Structural Comparison Matrix</h2>






























<table tabindex="0"><thead><tr><th scope="col" style="text-align:left">Operational Aspect</th><th scope="col" style="text-align:left">OAuth DCR (deprecated)</th><th scope="col" style="text-align:left">Client ID Metadata Documents</th></tr></thead><tbody><tr><td style="text-align:left"><strong>Where <code>client_id</code> comes from</strong></td><td style="text-align:left">Minted by the authorization server at registration</td><td style="text-align:left">The client’s own stable HTTPS metadata URL</td></tr><tr><td style="text-align:left"><strong>Identity verification</strong></td><td style="text-align:left">Self-reported at registration time</td><td style="text-align:left">Verified by fetching the client-controlled URL</td></tr><tr><td style="text-align:left"><strong>Hosting requirement</strong></td><td style="text-align:left">None, beyond the registration call</td><td style="text-align:left">Client must host a JSON document at a stable URL</td></tr><tr><td style="text-align:left"><strong>Pre-registration needed</strong></td><td style="text-align:left">No, that’s the point of DCR</td><td style="text-align:left">No, CIMD is also stateless in this sense</td></tr></tbody></table>
<h2 id="fix-it-host-a-metadata-document-use-its-url-as-client_id">Fix it: host a metadata document, use its URL as client_id</h2>
<div class="code-block code-block--has-filename"><div class="code-block__header"><span class="code-block__filename">a Client ID Metadata Document, hosted at a stable HTTPS URL</span><button type="button" class="code-block__copy" data-copy-button>Copy</button></div><pre class="astro-code github-dark" style="background-color:#24292e;color:#e1e4e8;overflow-x:auto" tabindex="0" data-language="json" data-filename="a Client ID Metadata Document, hosted at a stable HTTPS URL"><code><span class="line"><span style="color:#E1E4E8">{</span></span>
<span class="line"><span style="color:#79B8FF">  &quot;client_name&quot;</span><span style="color:#E1E4E8">: </span><span style="color:#9ECBFF">&quot;Example MCP Client&quot;</span><span style="color:#E1E4E8">,</span></span>
<span class="line"><span style="color:#79B8FF">  &quot;redirect_uris&quot;</span><span style="color:#E1E4E8">: [</span><span style="color:#9ECBFF">&quot;https://client.example.com/oauth/callback&quot;</span><span style="color:#E1E4E8">],</span></span>
<span class="line"><span style="color:#79B8FF">  &quot;grant_types&quot;</span><span style="color:#E1E4E8">: [</span><span style="color:#9ECBFF">&quot;authorization_code&quot;</span><span style="color:#E1E4E8">],</span></span>
<span class="line"><span style="color:#79B8FF">  &quot;token_endpoint_auth_method&quot;</span><span style="color:#E1E4E8">: </span><span style="color:#9ECBFF">&quot;none&quot;</span></span>
<span class="line"><span style="color:#E1E4E8">}</span></span></code></pre></div>
<p>The URL where this document is hosted, for example <code>https://client.example.com/.well-known/oauth-client</code>, is what gets used as the <code>client_id</code> in authorization requests, not an ID returned by a registration call. This is a genuine architecture change for any client currently calling a DCR registration endpoint at startup: that call, and whatever code stores the resulting minted <code>client_id</code>, gets replaced by hosting a static document and referencing its own URL.</p>
<div class="callout" role="note" data-astro-cid-q2ml7llr><p class="callout__title" data-astro-cid-q2ml7llr><span aria-hidden="true" data-astro-cid-q2ml7llr>⚠</span>DCR still works, this isn&#39;t a hard break yet</p><div class="callout__body" data-astro-cid-q2ml7llr><p>Dynamic Client Registration continues to function for backward compatibility
per <a href="https://modelcontextprotocol.io/specification/2026-07-28/deprecated">the specification’s deprecated features
registry</a>,
so an existing DCR-based integration isn’t broken today. Treat this as a
signal to plan the CIMD migration, not an emergency fix, unless you’re
building new client integrations, where CIMD should be the default from the
start.</p></div></div>
<h2 id="confirmed-version">Confirmed version</h2>
<p>Sourced from the official Model Context Protocol specification changelog, 2026-07-28 revision, published 2026-07-28, and independent sourcing on the Client ID Metadata Documents mechanism (SEP-991) for the concrete hosting and verification model. Browse more posts like this in the <a href="/ai-productivity">AI Productivity</a> archive.</p>]]></content:encoded>
      <pubDate>Thu, 06 Aug 2026 00:00:00 GMT</pubDate>
    </item>
    <item>
      <title>MCP Deprecates Roots, Sampling, and Logging</title>
      <link>https://bytetech247.com/ai-productivity/mcp-deprecates-roots-sampling-and-logging/</link>
      <guid isPermaLink="true">https://bytetech247.com/ai-productivity/mcp-deprecates-roots-sampling-and-logging/</guid>
      <description>The 2026-07-28 MCP spec deprecates Roots, Sampling, and Logging with a 12-month runway. Each has a real migration path: config, MRTR, and stderr/OTel.</description>
      <content:encoded><![CDATA[<aside class="quick-answer" aria-label="Quick answer"><h2 id="quick-answer">Quick Answer</h2>
<p>The MCP 2026-07-28 specification deprecates three feature areas: Roots, Sampling, and Logging. All three keep working for at least twelve months from this release, per <a href="https://modelcontextprotocol.io/specification/2026-07-28/deprecated">the specification’s deprecated features registry</a>, so there’s no forced break yet. Each has a real suggested migration: Roots to tool parameters, resource URIs, or config; Sampling to the new Multi Round-Trip Requests pattern; Logging to stderr and OpenTelemetry.</p>
</aside><h2 id="whats-actually-changing-and-the-real-migration-path-for-each">What’s actually changing, and the real migration path for each</h2>
<p>The specification changelog states the deprecation plainly:</p>
<blockquote>
<p>“Roots, Sampling, and Logging features deprecated; suggested migrations provided”</p>
</blockquote>
<p>Each deprecated feature has a distinct, real replacement, not a vague “figure it out later”:</p>
<ul>
<li><strong>Roots</strong>: move to tool parameters, resource URIs, or plain configuration instead of the dedicated Roots mechanism.</li>
<li><strong>Sampling</strong>: replaced by <a href="/ai-productivity/mcp-requires-resulttype-on-every-result/">the Multi Round-Trip Requests pattern</a> (tracked under SEP-2322). A server that used to initiate a sampling call back to the client now returns an <code>InputRequiredResult</code> carrying <code>inputRequests</code>, and the client gathers answers and re-issues the original call with <code>inputResponses</code>, the same mechanism this pillar’s <code>resultType</code> post covers in depth.</li>
<li><strong>Logging</strong>: move to <a href="https://modelcontextprotocol.io/specification/2026-07-28/server/utilities/logging">stderr and OpenTelemetry</a> instead of the protocol-level logging feature, which connects to <a href="/ai-productivity/mcp-removes-ping-and-logging-setlevel-methods/">the removal of the <code>logging/setLevel</code> method</a> already covered in this pillar.</li>
</ul>
<h2 id="structural-comparison-matrix">Structural Comparison Matrix</h2>

























<table tabindex="0"><thead><tr><th scope="col" style="text-align:left">Feature</th><th scope="col" style="text-align:left">Deprecated Mechanism</th><th scope="col" style="text-align:left">Suggested Migration</th></tr></thead><tbody><tr><td style="text-align:left"><strong>Roots</strong></td><td style="text-align:left">Dedicated Roots protocol feature</td><td style="text-align:left">Tool parameters, resource URIs, or config</td></tr><tr><td style="text-align:left"><strong>Sampling</strong></td><td style="text-align:left">Server-initiated sampling calls</td><td style="text-align:left">Multi Round-Trip Requests (<code>InputRequiredResult</code>)</td></tr><tr><td style="text-align:left"><strong>Logging</strong></td><td style="text-align:left">Protocol-level logging feature</td><td style="text-align:left">stderr and OpenTelemetry</td></tr></tbody></table>
<h2 id="fix-it-audit-which-of-the-three-an-implementation-actually-uses">Fix it: audit which of the three an implementation actually uses</h2>
<div class="callout" role="note" data-astro-cid-q2ml7llr><p class="callout__title" data-astro-cid-q2ml7llr><span aria-hidden="true" data-astro-cid-q2ml7llr>✓</span>Twelve months is a planning window, not a reason to wait</p><div class="callout__body" data-astro-cid-q2ml7llr><p>There’s no forced deadline yet, but the migrations here are real architectural
changes, especially Sampling’s move to Multi Round-Trip Requests. Auditing
now, while there’s no time pressure, is a better use of the runway than
starting the migration in month eleven.</p></div></div>
<div class="code-block code-block--has-filename"><div class="code-block__header"><span class="code-block__filename">find usage of the three deprecated features</span><button type="button" class="code-block__copy" data-copy-button>Copy</button></div><pre class="astro-code github-dark" style="background-color:#24292e;color:#e1e4e8;overflow-x:auto" tabindex="0" data-language="bash" data-filename="find usage of the three deprecated features"><code><span class="line"><span style="color:#B392F0">grep</span><span style="color:#79B8FF"> -rln</span><span style="color:#9ECBFF"> &quot;</span><span style="color:#79B8FF">\&quot;</span><span style="color:#9ECBFF">method</span><span style="color:#79B8FF">\&quot;</span><span style="color:#9ECBFF">: </span><span style="color:#79B8FF">\&quot;</span><span style="color:#9ECBFF">roots/\|</span><span style="color:#79B8FF">\&quot;</span><span style="color:#9ECBFF">method</span><span style="color:#79B8FF">\&quot;</span><span style="color:#9ECBFF">: </span><span style="color:#79B8FF">\&quot;</span><span style="color:#9ECBFF">sampling/\|logging/&quot;</span><span style="color:#79B8FF"> --include=</span><span style="color:#9ECBFF">&quot;*.py&quot;</span><span style="color:#79B8FF"> --include=</span><span style="color:#9ECBFF">&quot;*.ts&quot;</span><span style="color:#79B8FF"> --include=</span><span style="color:#9ECBFF">&quot;*.mjs&quot;</span><span style="color:#9ECBFF"> .</span></span></code></pre></div>
<p>For each match, confirm which of the three features is actually in use and start planning against its specific suggested migration rather than treating all three as one generic “logging and stuff” cleanup task; the three replacements are genuinely different in shape.</p>
<h2 id="confirmed-version">Confirmed version</h2>
<p>Sourced from the official Model Context Protocol specification changelog, 2026-07-28 revision, published 2026-07-28, and the specification’s governance policy defining the minimum twelve-month deprecation window. Browse more posts like this in the <a href="/ai-productivity">AI Productivity</a> archive.</p>]]></content:encoded>
      <pubDate>Thu, 06 Aug 2026 00:00:00 GMT</pubDate>
    </item>
    <item>
      <title>MCP Moves Tasks Out of Core Into an Extension</title>
      <link>https://bytetech247.com/ai-productivity/mcp-moves-tasks-out-of-core-into-extension/</link>
      <guid isPermaLink="true">https://bytetech247.com/ai-productivity/mcp-moves-tasks-out-of-core-into-extension/</guid>
      <description>The 2026-07-28 MCP spec moves Tasks into io.modelcontextprotocol/tasks and replaces blocking tasks/result with polling via tasks/get and tasks/update.</description>
      <content:encoded><![CDATA[<aside class="quick-answer" aria-label="Quick answer"><h2 id="quick-answer">Quick Answer</h2>
<p>The MCP 2026-07-28 specification moves long-running Tasks out of the experimental protocol core and into <a href="https://modelcontextprotocol.io/extensions/tasks/overview">a formal, official extension</a>, <code>io.modelcontextprotocol/tasks</code> (SEP-2663). The blocking <code>tasks/result</code> call is replaced by polling: call <code>tasks/get</code> with a <code>taskId</code>, respecting the server’s <code>pollIntervalMs</code>, until the task reaches a terminal status carrying the final result or error. A new <code>tasks/update</code> and <code>tasks/cancel</code> round out the extension.</p>
</aside><h2 id="whats-actually-changing">What’s actually changing</h2>
<p>The specification changelog states it directly:</p>
<blockquote>
<p>“Moved experimental tasks to official extension <code>io.modelcontextprotocol/tasks</code>; replaced blocking <code>tasks/result</code> with polling via <code>tasks/get</code> and new <code>tasks/update</code>”</p>
</blockquote>
<p>The extension introduces three methods: <code>tasks/get</code>, <code>tasks/update</code>, and <code>tasks/cancel</code>, plus a polymorphic-result discriminator (<code>resultType: &quot;task&quot;</code>) and a Task shape carrying status, any in-progress server-to-client requests, and a final result or error. Reads (<code>tasks/get</code>) and writes (<code>tasks/update</code>) are kept separate deliberately, so polling stays idempotent and cacheable rather than mutating state as a side effect of checking on it.</p>
<h2 id="structural-comparison-matrix">Structural Comparison Matrix</h2>






























<table tabindex="0"><thead><tr><th scope="col" style="text-align:left">Operational Aspect</th><th scope="col" style="text-align:left">Before (experimental core)</th><th scope="col" style="text-align:left">After (io.modelcontextprotocol/tasks extension)</th></tr></thead><tbody><tr><td style="text-align:left"><strong>Namespace</strong></td><td style="text-align:left">Experimental core protocol</td><td style="text-align:left">Formal extension, <code>io.modelcontextprotocol/tasks</code></td></tr><tr><td style="text-align:left"><strong>Getting a result</strong></td><td style="text-align:left">Blocking <code>tasks/result</code> call</td><td style="text-align:left">Poll <code>tasks/get</code> until terminal status</td></tr><tr><td style="text-align:left"><strong>Updating a task</strong></td><td style="text-align:left">Not separated from reads</td><td style="text-align:left">Dedicated <code>tasks/update</code> call</td></tr><tr><td style="text-align:left"><strong>Canceling a task</strong></td><td style="text-align:left">Not present</td><td style="text-align:left">New <code>tasks/cancel</code> method</td></tr></tbody></table>
<h2 id="fix-it-rewrite-blocking-waits-into-a-polling-loop">Fix it: rewrite blocking waits into a polling loop</h2>
<div class="code-block code-block--has-filename"><div class="code-block__header"><span class="code-block__filename">before: blocking tasks/result (removed)</span><button type="button" class="code-block__copy" data-copy-button>Copy</button></div><pre class="astro-code github-dark" style="background-color:#24292e;color:#e1e4e8;overflow-x:auto" tabindex="0" data-language="json" data-filename="before: blocking tasks/result (removed)"><code><span class="line"><span style="color:#E1E4E8">{</span></span>
<span class="line"><span style="color:#79B8FF">  &quot;jsonrpc&quot;</span><span style="color:#E1E4E8">: </span><span style="color:#9ECBFF">&quot;2.0&quot;</span><span style="color:#E1E4E8">,</span></span>
<span class="line"><span style="color:#79B8FF">  &quot;id&quot;</span><span style="color:#E1E4E8">: </span><span style="color:#79B8FF">1</span><span style="color:#E1E4E8">,</span></span>
<span class="line"><span style="color:#79B8FF">  &quot;method&quot;</span><span style="color:#E1E4E8">: </span><span style="color:#9ECBFF">&quot;tasks/result&quot;</span><span style="color:#E1E4E8">,</span></span>
<span class="line"><span style="color:#79B8FF">  &quot;params&quot;</span><span style="color:#E1E4E8">: { </span><span style="color:#79B8FF">&quot;taskId&quot;</span><span style="color:#E1E4E8">: </span><span style="color:#9ECBFF">&quot;task_abc123&quot;</span><span style="color:#E1E4E8"> }</span></span>
<span class="line"><span style="color:#E1E4E8">}</span></span></code></pre></div>
<div class="code-block code-block--has-filename"><div class="code-block__header"><span class="code-block__filename">after: poll tasks/get on an interval until terminal</span><button type="button" class="code-block__copy" data-copy-button>Copy</button></div><pre class="astro-code github-dark" style="background-color:#24292e;color:#e1e4e8;overflow-x:auto" tabindex="0" data-language="json" data-filename="after: poll tasks/get on an interval until terminal"><code><span class="line"><span style="color:#E1E4E8">{</span></span>
<span class="line"><span style="color:#79B8FF">  &quot;jsonrpc&quot;</span><span style="color:#E1E4E8">: </span><span style="color:#9ECBFF">&quot;2.0&quot;</span><span style="color:#E1E4E8">,</span></span>
<span class="line"><span style="color:#79B8FF">  &quot;id&quot;</span><span style="color:#E1E4E8">: </span><span style="color:#79B8FF">1</span><span style="color:#E1E4E8">,</span></span>
<span class="line"><span style="color:#79B8FF">  &quot;method&quot;</span><span style="color:#E1E4E8">: </span><span style="color:#9ECBFF">&quot;tasks/get&quot;</span><span style="color:#E1E4E8">,</span></span>
<span class="line"><span style="color:#79B8FF">  &quot;params&quot;</span><span style="color:#E1E4E8">: { </span><span style="color:#79B8FF">&quot;taskId&quot;</span><span style="color:#E1E4E8">: </span><span style="color:#9ECBFF">&quot;task_abc123&quot;</span><span style="color:#E1E4E8"> }</span></span>
<span class="line"><span style="color:#E1E4E8">}</span></span></code></pre></div>
<div class="callout" role="note" data-astro-cid-q2ml7llr><p class="callout__title" data-astro-cid-q2ml7llr><span aria-hidden="true" data-astro-cid-q2ml7llr>✓</span>Respect the server&#39;s pollIntervalMs</p><div class="callout__body" data-astro-cid-q2ml7llr><p>Don’t hardcode a polling interval on the client. The server communicates its
expected <code>pollIntervalMs</code>, and polling faster than that wastes requests
without getting the result any sooner, while polling much slower delays how
quickly the client notices completion.</p></div></div>
<p>Client code written around a single blocking <code>tasks/result</code> call needs a real control-flow rewrite: a loop that calls <code>tasks/get</code>, checks the returned status, and exits once that status is terminal, carrying either the final result or an error, rather than treating one call as sufficient.</p>
<h2 id="confirmed-version">Confirmed version</h2>
<p>Sourced from the official Model Context Protocol specification changelog, 2026-07-28 revision, published 2026-07-28, and the Tasks extension’s own specification (SEP-2663) for the method names and polling pattern. Browse more posts like this in the <a href="/ai-productivity">AI Productivity</a> archive.</p>]]></content:encoded>
      <pubDate>Thu, 06 Aug 2026 00:00:00 GMT</pubDate>
    </item>
    <item>
      <title>MCP Removes the ping and logging/setLevel Methods</title>
      <link>https://bytetech247.com/ai-productivity/mcp-removes-ping-and-logging-setlevel-methods/</link>
      <guid isPermaLink="true">https://bytetech247.com/ai-productivity/mcp-removes-ping-and-logging-setlevel-methods/</guid>
      <description>The 2026-07-28 MCP spec removes ping and logging/setLevel. Log level now sets per-request via _meta, and there is no stated ping replacement.</description>
      <content:encoded><![CDATA[<aside class="quick-answer" aria-label="Quick answer"><h2 id="quick-answer">Quick Answer</h2>
<p>The MCP 2026-07-28 specification removes three protocol methods outright: <code>ping</code>, <code>logging/setLevel</code>, and <code>notifications/roots/list_changed</code>. Log level now sets per-request through a <a href="https://modelcontextprotocol.io/specification/2026-07-28/server/utilities/logging"><code>_meta</code> field</a> (<code>io.modelcontextprotocol/logLevel</code>) instead of a standalone RPC call. There’s no stated direct replacement for <code>ping</code> as a liveness check in <a href="https://modelcontextprotocol.io/specification/2026-07-28/changelog">this changelog entry</a>.</p>
</aside><h2 id="whats-actually-changing">What’s actually changing</h2>
<p>The specification changelog states it directly:</p>
<blockquote>
<p>“Removed protocols: <code>ping</code>, <code>logging/setLevel</code>, <code>notifications/roots/list_changed</code>; log level now set per-request via <code>io.modelcontextprotocol/logLevel</code> in <code>_meta</code>”</p>
</blockquote>
<p>Two of these three removals have a clear migration path; one doesn’t. <code>logging/setLevel</code>’s replacement is explicit: a <code>_meta</code> field on each request instead of a dedicated call. <code>notifications/roots/list_changed</code>’s removal connects to <a href="/ai-productivity/mcp-deprecates-roots-sampling-and-logging/">the broader Roots feature deprecation</a>, which has its own migration guidance. <code>ping</code> simply has no stated replacement in this entry.</p>
<h2 id="structural-comparison-matrix">Structural Comparison Matrix</h2>

























<table tabindex="0"><thead><tr><th scope="col" style="text-align:left">Operational Aspect</th><th scope="col" style="text-align:left">Before (removed 2026-07-28)</th><th scope="col" style="text-align:left">After</th></tr></thead><tbody><tr><td style="text-align:left"><strong>Setting log verbosity</strong></td><td style="text-align:left">Standalone <code>logging/setLevel</code> call</td><td style="text-align:left"><code>_meta.io.modelcontextprotocol/logLevel</code> on each request</td></tr><tr><td style="text-align:left"><strong>Liveness checking</strong></td><td style="text-align:left">Standalone <code>ping</code> call</td><td style="text-align:left">No stated direct replacement</td></tr><tr><td style="text-align:left"><strong>Roots list-changed notifications</strong></td><td style="text-align:left"><code>notifications/roots/list_changed</code></td><td style="text-align:left">Tied to the broader Roots deprecation</td></tr></tbody></table>
<h2 id="fix-it-move-log-level-into-_meta-reconsider-liveness-checks">Fix it: move log level into _meta, reconsider liveness checks</h2>
<div class="code-block code-block--has-filename"><div class="code-block__header"><span class="code-block__filename">before: standalone logging/setLevel call (removed)</span><button type="button" class="code-block__copy" data-copy-button>Copy</button></div><pre class="astro-code github-dark" style="background-color:#24292e;color:#e1e4e8;overflow-x:auto" tabindex="0" data-language="json" data-filename="before: standalone logging/setLevel call (removed)"><code><span class="line"><span style="color:#E1E4E8">{</span></span>
<span class="line"><span style="color:#79B8FF">  &quot;jsonrpc&quot;</span><span style="color:#E1E4E8">: </span><span style="color:#9ECBFF">&quot;2.0&quot;</span><span style="color:#E1E4E8">,</span></span>
<span class="line"><span style="color:#79B8FF">  &quot;method&quot;</span><span style="color:#E1E4E8">: </span><span style="color:#9ECBFF">&quot;logging/setLevel&quot;</span><span style="color:#E1E4E8">,</span></span>
<span class="line"><span style="color:#79B8FF">  &quot;params&quot;</span><span style="color:#E1E4E8">: { </span><span style="color:#79B8FF">&quot;level&quot;</span><span style="color:#E1E4E8">: </span><span style="color:#9ECBFF">&quot;debug&quot;</span><span style="color:#E1E4E8"> }</span></span>
<span class="line"><span style="color:#E1E4E8">}</span></span></code></pre></div>
<div class="code-block code-block--has-filename"><div class="code-block__header"><span class="code-block__filename">after: log level carried per-request in _meta</span><button type="button" class="code-block__copy" data-copy-button>Copy</button></div><pre class="astro-code github-dark" style="background-color:#24292e;color:#e1e4e8;overflow-x:auto" tabindex="0" data-language="json" data-filename="after: log level carried per-request in _meta"><code><span class="line"><span style="color:#E1E4E8">{</span></span>
<span class="line"><span style="color:#79B8FF">  &quot;jsonrpc&quot;</span><span style="color:#E1E4E8">: </span><span style="color:#9ECBFF">&quot;2.0&quot;</span><span style="color:#E1E4E8">,</span></span>
<span class="line"><span style="color:#79B8FF">  &quot;id&quot;</span><span style="color:#E1E4E8">: </span><span style="color:#79B8FF">1</span><span style="color:#E1E4E8">,</span></span>
<span class="line"><span style="color:#79B8FF">  &quot;method&quot;</span><span style="color:#E1E4E8">: </span><span style="color:#9ECBFF">&quot;tools/call&quot;</span><span style="color:#E1E4E8">,</span></span>
<span class="line"><span style="color:#79B8FF">  &quot;params&quot;</span><span style="color:#E1E4E8">: {</span></span>
<span class="line"><span style="color:#79B8FF">    &quot;name&quot;</span><span style="color:#E1E4E8">: </span><span style="color:#9ECBFF">&quot;search&quot;</span><span style="color:#E1E4E8">,</span></span>
<span class="line"><span style="color:#79B8FF">    &quot;arguments&quot;</span><span style="color:#E1E4E8">: { </span><span style="color:#79B8FF">&quot;query&quot;</span><span style="color:#E1E4E8">: </span><span style="color:#9ECBFF">&quot;example&quot;</span><span style="color:#E1E4E8"> },</span></span>
<span class="line"><span style="color:#79B8FF">    &quot;_meta&quot;</span><span style="color:#E1E4E8">: {</span></span>
<span class="line"><span style="color:#79B8FF">      &quot;io.modelcontextprotocol/logLevel&quot;</span><span style="color:#E1E4E8">: </span><span style="color:#9ECBFF">&quot;debug&quot;</span></span>
<span class="line"><span style="color:#E1E4E8">    }</span></span>
<span class="line"><span style="color:#E1E4E8">  }</span></span>
<span class="line"><span style="color:#E1E4E8">}</span></span></code></pre></div>
<div class="callout" role="note" data-astro-cid-q2ml7llr><p class="callout__title" data-astro-cid-q2ml7llr><span aria-hidden="true" data-astro-cid-q2ml7llr>⚠</span>Don&#39;t assume a health-check request works the same</p><div class="callout__body" data-astro-cid-q2ml7llr><p>If monitoring or orchestration code calls <code>ping</code> on an interval to confirm a
server is alive, that call has no direct replacement here. Consider whether
any ordinary low-cost request (like <code>server/discover</code>) serves the same purpose
in practice, and verify that assumption against the current spec rather than
treating this post’s inference as confirmed guidance.</p></div></div>
<h2 id="confirmed-version">Confirmed version</h2>
<p>Sourced from the official Model Context Protocol specification changelog, 2026-07-28 revision, published 2026-07-28. The lack of a stated <code>ping</code> replacement is confirmed by its absence from this changelog entry, not an independent claim that no replacement exists anywhere in the spec; check current spec text directly if liveness checking is critical to your integration. Browse more posts like this in the <a href="/ai-productivity">AI Productivity</a> archive.</p>]]></content:encoded>
      <pubDate>Thu, 06 Aug 2026 00:00:00 GMT</pubDate>
    </item>
    <item>
      <title>MCP Removes Protocol-Level Sessions (Mcp-Session-Id)</title>
      <link>https://bytetech247.com/ai-productivity/mcp-removes-protocol-level-sessions/</link>
      <guid isPermaLink="true">https://bytetech247.com/ai-productivity/mcp-removes-protocol-level-sessions/</guid>
      <description>The 2026-07-28 MCP spec eliminates the Mcp-Session-Id header. Cross-call state now needs explicit, server-minted handles passed as tool arguments.</description>
      <content:encoded><![CDATA[<aside class="quick-answer" aria-label="Quick answer"><h2 id="quick-answer">Quick Answer</h2>
<p>The MCP 2026-07-28 specification <a href="https://modelcontextprotocol.io/specification/2026-07-28/basic/transports/streamable-http#backward-compatibility">eliminates the <code>Mcp-Session-Id</code> header</a> and protocol-level sessions from the Streamable HTTP transport. Servers that held state across tool calls using that header need to switch to explicit, server-minted handles instead: mint an opaque identifier from one tool call, return it in the result, and have the client pass it back as an ordinary argument on later calls.</p>
</aside><h2 id="whats-actually-changing">What’s actually changing</h2>
<p>The MCP specification changelog for the 2026-07-28 revision states it directly:</p>
<blockquote>
<p>“Eliminated <code>Mcp-Session-Id</code> header from Streamable HTTP transport; list endpoints no longer vary per-connection; servers use explicit handles for cross-call state”</p>
</blockquote>
<p>This is tracked as SEP-2567, “Sessionless MCP via Explicit State Handles.” The reasoning: implicit, transport-level session state is invisible to the model and to anyone debugging a multi-step interaction. Making state explicit, an ordinary string the model actually sees and passes around, is the same pattern HTTP APIs have used for years: a <code>basket_id</code>, a <code>browser_id</code>, minted once and referenced afterward.</p>
<h2 id="structural-comparison-matrix">Structural Comparison Matrix</h2>






























<table tabindex="0"><thead><tr><th scope="col" style="text-align:left">Operational Aspect</th><th scope="col" style="text-align:left">Before (protocol-level sessions)</th><th scope="col" style="text-align:left">After (2026-07-28+)</th></tr></thead><tbody><tr><td style="text-align:left"><strong>Where cross-call state lives</strong></td><td style="text-align:left">Implicit, keyed by <code>Mcp-Session-Id</code></td><td style="text-align:left">Explicit, an ordinary handle string</td></tr><tr><td style="text-align:left"><strong>Visibility to the model</strong></td><td style="text-align:left">Hidden in transport metadata</td><td style="text-align:left">Visible, passed as a normal tool argument</td></tr><tr><td style="text-align:left"><strong>List endpoint behavior</strong></td><td style="text-align:left">Could vary per connection</td><td style="text-align:left">No longer varies per connection</td></tr><tr><td style="text-align:left"><strong>Handle shape (recommended)</strong></td><td style="text-align:left">N/A</td><td style="text-align:left">Opaque, e.g. <code>bsk_a1b2c3</code>, not <code>cart_user42_2026-03-11</code></td></tr></tbody></table>
<h2 id="fix-it-mint-and-pass-an-explicit-handle">Fix it: mint and pass an explicit handle</h2>
<div class="callout" role="note" data-astro-cid-q2ml7llr><p class="callout__title" data-astro-cid-q2ml7llr><span aria-hidden="true" data-astro-cid-q2ml7llr>⚠</span>Keep handles opaque, not descriptive</p><div class="callout__body" data-astro-cid-q2ml7llr><p>A handle that encodes internal structure invites clients to parse it or models
to guess adjacent ones. Use an opaque token, not something human-readable like
a username or a date embedded in the string.</p></div></div>
<div class="code-block code-block--has-filename"><div class="code-block__header"><span class="code-block__filename">before: implicit session state (removed 2026-07-28)</span><button type="button" class="code-block__copy" data-copy-button>Copy</button></div><pre class="astro-code github-dark" style="background-color:#24292e;color:#e1e4e8;overflow-x:auto" tabindex="0" data-language="json" data-filename="before: implicit session state (removed 2026-07-28)"><code><span class="line"><span style="color:#9ca6b0">// Client relied on the Mcp-Session-Id header to keep cart state</span></span>
<span class="line"><span style="color:#9ca6b0">// scoped to this connection across multiple tool calls.</span></span></code></pre></div>
<div class="code-block code-block--has-filename"><div class="code-block__header"><span class="code-block__filename">after: explicit handle, minted once</span><button type="button" class="code-block__copy" data-copy-button>Copy</button></div><pre class="astro-code github-dark" style="background-color:#24292e;color:#e1e4e8;overflow-x:auto" tabindex="0" data-language="json" data-filename="after: explicit handle, minted once"><code><span class="line"><span style="color:#E1E4E8">{</span></span>
<span class="line"><span style="color:#79B8FF">  &quot;content&quot;</span><span style="color:#E1E4E8">: [{ </span><span style="color:#79B8FF">&quot;type&quot;</span><span style="color:#E1E4E8">: </span><span style="color:#9ECBFF">&quot;text&quot;</span><span style="color:#E1E4E8">, </span><span style="color:#79B8FF">&quot;text&quot;</span><span style="color:#E1E4E8">: </span><span style="color:#9ECBFF">&quot;Cart created.&quot;</span><span style="color:#E1E4E8"> }],</span></span>
<span class="line"><span style="color:#79B8FF">  &quot;structuredContent&quot;</span><span style="color:#E1E4E8">: {</span></span>
<span class="line"><span style="color:#79B8FF">    &quot;basket_id&quot;</span><span style="color:#E1E4E8">: </span><span style="color:#9ECBFF">&quot;bsk_a1b2c3&quot;</span></span>
<span class="line"><span style="color:#E1E4E8">  }</span></span>
<span class="line"><span style="color:#E1E4E8">}</span></span></code></pre></div>
<div class="code-block code-block--has-filename"><div class="code-block__header"><span class="code-block__filename">after: the model passes the handle back on later calls</span><button type="button" class="code-block__copy" data-copy-button>Copy</button></div><pre class="astro-code github-dark" style="background-color:#24292e;color:#e1e4e8;overflow-x:auto" tabindex="0" data-language="json" data-filename="after: the model passes the handle back on later calls"><code><span class="line"><span style="color:#E1E4E8">{</span></span>
<span class="line"><span style="color:#79B8FF">  &quot;name&quot;</span><span style="color:#E1E4E8">: </span><span style="color:#9ECBFF">&quot;add_item_to_cart&quot;</span><span style="color:#E1E4E8">,</span></span>
<span class="line"><span style="color:#79B8FF">  &quot;arguments&quot;</span><span style="color:#E1E4E8">: {</span></span>
<span class="line"><span style="color:#79B8FF">    &quot;basket_id&quot;</span><span style="color:#E1E4E8">: </span><span style="color:#9ECBFF">&quot;bsk_a1b2c3&quot;</span><span style="color:#E1E4E8">,</span></span>
<span class="line"><span style="color:#79B8FF">    &quot;item&quot;</span><span style="color:#E1E4E8">: </span><span style="color:#9ECBFF">&quot;widget-42&quot;</span></span>
<span class="line"><span style="color:#E1E4E8">  }</span></span>
<span class="line"><span style="color:#E1E4E8">}</span></span></code></pre></div>
<p>The server mints <code>basket_id</code> once, from whatever tool call first needs a piece of persistent state, and returns it in <code>structuredContent</code>. Every subsequent tool call that touches the same state passes it back as an ordinary string argument, no different from any other parameter. A server implementation with connection-scoped in-memory state needs to move that state into whatever store the handle actually looks up, not just stop reading the removed header.</p>
<h2 id="confirmed-version">Confirmed version</h2>
<p>Sourced from the official Model Context Protocol specification changelog, 2026-07-28 revision, published 2026-07-28, and SEP-2567’s own specification text for the explicit-handle pattern’s recommended shape. Browse more posts like this in the <a href="/ai-productivity">AI Productivity</a> archive.</p>]]></content:encoded>
      <pubDate>Thu, 06 Aug 2026 00:00:00 GMT</pubDate>
    </item>
    <item>
      <title>MCP Removes SSE Resumability (Last-Event-ID Gone)</title>
      <link>https://bytetech247.com/ai-productivity/mcp-removes-sse-resumability/</link>
      <guid isPermaLink="true">https://bytetech247.com/ai-productivity/mcp-removes-sse-resumability/</guid>
      <description>The 2026-07-28 MCP spec eliminates Last-Event-ID and SSE event IDs. A broken stream now requires re-issuing the whole request, not resuming in place.</description>
      <content:encoded><![CDATA[<aside class="quick-answer" aria-label="Quick answer"><h2 id="quick-answer">Quick Answer</h2>
<p>The MCP 2026-07-28 specification <a href="https://modelcontextprotocol.io/specification/2026-07-28/basic/transports/streamable-http#backward-compatibility">eliminates the <code>Last-Event-ID</code> header</a> and SSE event IDs entirely. Previously, a client whose SSE stream dropped mid-request could reconnect and resume from where it left off using <code>Last-Event-ID</code>. Under the new spec, a broken stream has no resumption path: the client has to re-issue the entire request as new.</p>
</aside><h2 id="whats-actually-changing">What’s actually changing</h2>
<p>The specification changelog states it directly:</p>
<blockquote>
<p>“Eliminated <code>Last-Event-ID</code> header and SSE event IDs; broken streams require re-issuing as new requests”</p>
</blockquote>
<p>This is a real reliability-handling change, not a cosmetic one. Client code with retry logic built around SSE resumption, catch the disconnect, reconnect with the last known event ID, keep receiving from that point, has no equivalent behavior to fall back to. The correct behavior now is treating a broken stream the same as a failed request: discard whatever partial state existed and start over.</p>
<h2 id="structural-comparison-matrix">Structural Comparison Matrix</h2>

























<table tabindex="0"><thead><tr><th scope="col" style="text-align:left">Operational Aspect</th><th scope="col" style="text-align:left">Before (SSE resumability)</th><th scope="col" style="text-align:left">After (2026-07-28+)</th></tr></thead><tbody><tr><td style="text-align:left"><strong>Stream drops mid-request</strong></td><td style="text-align:left">Reconnect with <code>Last-Event-ID</code>, resume in place</td><td style="text-align:left">No resumption; re-issue the whole request</td></tr><tr><td style="text-align:left"><strong>Client-side state to track</strong></td><td style="text-align:left">Last received event ID</td><td style="text-align:left">None needed for this purpose</td></tr><tr><td style="text-align:left"><strong>Retry logic shape</strong></td><td style="text-align:left">Reconnect-and-resume</td><td style="text-align:left">Full request retry</td></tr></tbody></table>
<h2 id="fix-it-replace-resume-logic-with-a-full-retry">Fix it: replace resume logic with a full retry</h2>
<div class="callout" role="note" data-astro-cid-q2ml7llr><p class="callout__title" data-astro-cid-q2ml7llr><span aria-hidden="true" data-astro-cid-q2ml7llr>⚠</span>Idempotency matters more now</p><div class="callout__body" data-astro-cid-q2ml7llr><p>If re-issuing a broken request as brand new could cause a duplicate side
effect (creating a resource twice, for example, rather than resuming a read),
that’s a real correctness risk this change surfaces. Confirm which of a
client’s streamed operations are safe to retry outright versus needing an
idempotency key or similar guard before relying on blind re-issue as the
recovery strategy.</p></div></div>
<div class="code-block code-block--has-filename"><div class="code-block__header"><span class="code-block__filename">before: resumable reconnect logic (no longer applicable)</span><button type="button" class="code-block__copy" data-copy-button>Copy</button></div><pre class="astro-code github-dark" style="background-color:#24292e;color:#e1e4e8;overflow-x:auto" tabindex="0" data-language="text" data-filename="before: resumable reconnect logic (no longer applicable)"><code><span class="line"><span>on stream error:</span></span>
<span class="line"><span>  reconnect with header Last-Event-ID: &lt;last received id&gt;</span></span>
<span class="line"><span>  continue receiving from that point</span></span></code></pre></div>
<div class="code-block code-block--has-filename"><div class="code-block__header"><span class="code-block__filename">after: full re-issue on stream failure</span><button type="button" class="code-block__copy" data-copy-button>Copy</button></div><pre class="astro-code github-dark" style="background-color:#24292e;color:#e1e4e8;overflow-x:auto" tabindex="0" data-language="text" data-filename="after: full re-issue on stream failure"><code><span class="line"><span>on stream error:</span></span>
<span class="line"><span>  discard partial state from the broken stream</span></span>
<span class="line"><span>  re-issue the original request as a new call</span></span></code></pre></div>
<p>Any client library or hand-rolled retry wrapper that specifically implements SSE reconnect-with-<code>Last-Event-ID</code> needs that code path removed and replaced with a plain retry of the original request, plus whatever idempotency handling the specific operation actually needs.</p>
<h2 id="confirmed-version">Confirmed version</h2>
<p>Sourced from the official Model Context Protocol specification changelog, 2026-07-28 revision, published 2026-07-28. Browse more posts like this in the <a href="/ai-productivity">AI Productivity</a> archive.</p>]]></content:encoded>
      <pubDate>Thu, 06 Aug 2026 00:00:00 GMT</pubDate>
    </item>
    <item>
      <title>MCP Replaces initialize Handshake With _meta Fields</title>
      <link>https://bytetech247.com/ai-productivity/mcp-replaces-initialize-handshake-with-meta-fields/</link>
      <guid isPermaLink="true">https://bytetech247.com/ai-productivity/mcp-replaces-initialize-handshake-with-meta-fields/</guid>
      <description>The 2026-07-28 MCP spec removes the initialize handshake. Every request now carries protocol version and capabilities in _meta fields instead.</description>
      <content:encoded><![CDATA[<aside class="quick-answer" aria-label="Quick answer"><h2 id="quick-answer">Quick Answer</h2>
<p>The MCP 2026-07-28 specification <a href="https://modelcontextprotocol.io/specification/2026-07-28/basic/versioning">removes the <code>initialize</code>/<code>notifications/initialized</code> handshake</a> entirely. Protocol version and client capabilities now travel in every request’s <code>_meta</code> field (<code>io.modelcontextprotocol/protocolVersion</code>, <code>io.modelcontextprotocol/clientCapabilities</code>), not negotiated once at connection start. A client or server built around “negotiate once, trust it for the session” needs to move that negotiation into per-request <code>_meta</code> handling instead.</p>
</aside><h2 id="whats-actually-changing">What’s actually changing</h2>
<p>The specification changelog states it directly:</p>
<blockquote>
<p>“Removed <code>initialize</code>/<code>notifications/initialized</code> handshake; requests now carry protocol version and capabilities in <code>_meta</code> (<code>io.modelcontextprotocol/protocolVersion</code>, <code>io.modelcontextprotocol/clientCapabilities</code>); version mismatches return <code>UnsupportedProtocolVersionError</code>”</p>
</blockquote>
<p>This follows directly from <a href="/ai-productivity/mcp-removes-protocol-level-sessions/">the removal of protocol-level sessions</a>: if there’s no persistent session, there’s no connection-scoped handshake result to hold onto either. Every request has to be self-describing.</p>
<p>A real request under the new shape:</p>
<div class="code-block code-block--has-filename"><div class="code-block__header"><span class="code-block__filename">tools/call request with protocol version and capabilities in _meta</span><button type="button" class="code-block__copy" data-copy-button>Copy</button></div><pre class="astro-code github-dark" style="background-color:#24292e;color:#e1e4e8;overflow-x:auto" tabindex="0" data-language="json" data-filename="tools/call request with protocol version and capabilities in _meta"><code><span class="line"><span style="color:#E1E4E8">{</span></span>
<span class="line"><span style="color:#79B8FF">  &quot;jsonrpc&quot;</span><span style="color:#E1E4E8">: </span><span style="color:#9ECBFF">&quot;2.0&quot;</span><span style="color:#E1E4E8">,</span></span>
<span class="line"><span style="color:#79B8FF">  &quot;id&quot;</span><span style="color:#E1E4E8">: </span><span style="color:#79B8FF">1</span><span style="color:#E1E4E8">,</span></span>
<span class="line"><span style="color:#79B8FF">  &quot;method&quot;</span><span style="color:#E1E4E8">: </span><span style="color:#9ECBFF">&quot;tools/call&quot;</span><span style="color:#E1E4E8">,</span></span>
<span class="line"><span style="color:#79B8FF">  &quot;params&quot;</span><span style="color:#E1E4E8">: {</span></span>
<span class="line"><span style="color:#79B8FF">    &quot;name&quot;</span><span style="color:#E1E4E8">: </span><span style="color:#9ECBFF">&quot;search&quot;</span><span style="color:#E1E4E8">,</span></span>
<span class="line"><span style="color:#79B8FF">    &quot;arguments&quot;</span><span style="color:#E1E4E8">: { </span><span style="color:#79B8FF">&quot;query&quot;</span><span style="color:#E1E4E8">: </span><span style="color:#9ECBFF">&quot;stateless MCP&quot;</span><span style="color:#E1E4E8"> },</span></span>
<span class="line"><span style="color:#79B8FF">    &quot;_meta&quot;</span><span style="color:#E1E4E8">: {</span></span>
<span class="line"><span style="color:#79B8FF">      &quot;io.modelcontextprotocol/protocolVersion&quot;</span><span style="color:#E1E4E8">: </span><span style="color:#9ECBFF">&quot;2026-07-28&quot;</span><span style="color:#E1E4E8">,</span></span>
<span class="line"><span style="color:#79B8FF">      &quot;io.modelcontextprotocol/clientInfo&quot;</span><span style="color:#E1E4E8">: {</span></span>
<span class="line"><span style="color:#79B8FF">        &quot;name&quot;</span><span style="color:#E1E4E8">: </span><span style="color:#9ECBFF">&quot;example-client&quot;</span><span style="color:#E1E4E8">,</span></span>
<span class="line"><span style="color:#79B8FF">        &quot;version&quot;</span><span style="color:#E1E4E8">: </span><span style="color:#9ECBFF">&quot;1.0.0&quot;</span></span>
<span class="line"><span style="color:#E1E4E8">      },</span></span>
<span class="line"><span style="color:#79B8FF">      &quot;io.modelcontextprotocol/clientCapabilities&quot;</span><span style="color:#E1E4E8">: {</span></span>
<span class="line"><span style="color:#79B8FF">        &quot;extensions&quot;</span><span style="color:#E1E4E8">: {}</span></span>
<span class="line"><span style="color:#E1E4E8">      }</span></span>
<span class="line"><span style="color:#E1E4E8">    }</span></span>
<span class="line"><span style="color:#E1E4E8">  }</span></span>
<span class="line"><span style="color:#E1E4E8">}</span></span></code></pre></div>
<h2 id="structural-comparison-matrix">Structural Comparison Matrix</h2>

























<table tabindex="0"><thead><tr><th scope="col" style="text-align:left">Operational Aspect</th><th scope="col" style="text-align:left">Before (initialize handshake)</th><th scope="col" style="text-align:left">After (2026-07-28+)</th></tr></thead><tbody><tr><td style="text-align:left"><strong>When negotiation happens</strong></td><td style="text-align:left">Once, at connection start</td><td style="text-align:left">Every request</td></tr><tr><td style="text-align:left"><strong>Where version/capabilities live</strong></td><td style="text-align:left">Handshake response, held for the session</td><td style="text-align:left"><code>_meta.io.modelcontextprotocol/protocolVersion</code> and <code>clientCapabilities</code> on each call</td></tr><tr><td style="text-align:left"><strong>Version mismatch behavior</strong></td><td style="text-align:left">Handshake failure</td><td style="text-align:left"><code>UnsupportedProtocolVersionError</code>, listing supported revisions</td></tr></tbody></table>
<h2 id="fix-it-move-negotiation-into-per-request-_meta">Fix it: move negotiation into per-request _meta</h2>
<div class="callout" role="note" data-astro-cid-q2ml7llr><p class="callout__title" data-astro-cid-q2ml7llr><span aria-hidden="true" data-astro-cid-q2ml7llr>⚠</span>Handle UnsupportedProtocolVersionError explicitly</p><div class="callout__body" data-astro-cid-q2ml7llr><p>Since the server’s error response lists the revisions it actually supports, a
client should retry with a mutually supported version rather than treating any
mismatch as a hard failure. Skipping that retry logic means a client that only
speaks an older revision fails outright against a server that could have
served it a compatible version.</p></div></div>
<p>Any code that previously stored a “session established” flag after a successful <code>initialize</code> call, then skipped re-sending capabilities on later calls, needs that flag and the associated shortcut removed. Every outgoing request needs the <code>_meta</code> block populated fresh, not conditionally based on connection state that no longer exists.</p>
<h2 id="confirmed-version">Confirmed version</h2>
<p>Sourced from the official Model Context Protocol specification changelog, 2026-07-28 revision, published 2026-07-28, including the real example request shape from the spec’s own migration documentation. Browse more posts like this in the <a href="/ai-productivity">AI Productivity</a> archive.</p>]]></content:encoded>
      <pubDate>Thu, 06 Aug 2026 00:00:00 GMT</pubDate>
    </item>
    <item>
      <title>MCP Replaces SSE Subscriptions With subscriptions/listen</title>
      <link>https://bytetech247.com/ai-productivity/mcp-replaces-sse-subscriptions-with-subscriptions-listen/</link>
      <guid isPermaLink="true">https://bytetech247.com/ai-productivity/mcp-replaces-sse-subscriptions-with-subscriptions-listen/</guid>
      <description>The 2026-07-28 MCP spec replaces the SSE GET endpoint with an opt-in subscriptions/listen stream. Clients now request each notification type explicitly.</description>
      <content:encoded><![CDATA[<aside class="quick-answer" aria-label="Quick answer"><h2 id="quick-answer">Quick Answer</h2>
<p>The MCP 2026-07-28 specification replaces the old SSE GET endpoint for server-to-client notifications with <a href="https://modelcontextprotocol.io/specification/2026-07-28/basic/patterns/subscriptions"><code>subscriptions/listen</code></a>, a single long-lived POST-response stream. Unlike the old model, notifications aren’t delivered by default: clients must opt into specific types (<code>toolsListChanged</code>, <code>promptsListChanged</code>, <code>resourcesListChanged</code>, <code>resourceSubscriptions</code>) when opening the stream, or they simply don’t arrive.</p>
</aside><h2 id="whats-actually-changing">What’s actually changing</h2>
<p>The specification changelog states it directly:</p>
<blockquote>
<p>“Introduced <code>subscriptions/listen</code> as a single long-lived POST-response stream for opted-in server-to-client change notifications; clients opt into specific types (<code>toolsListChanged</code>, <code>promptsListChanged</code>, <code>resourcesListChanged</code>, <code>resourceSubscriptions</code>)”</p>
</blockquote>
<p>The mechanics of a delivered notification are unchanged in shape, still a standard JSON-RPC notification, sent over the stream once a server’s list actually changes:</p>
<div class="code-block code-block--has-filename"><div class="code-block__header"><span class="code-block__filename">a real notification delivered over subscriptions/listen</span><button type="button" class="code-block__copy" data-copy-button>Copy</button></div><pre class="astro-code github-dark" style="background-color:#24292e;color:#e1e4e8;overflow-x:auto" tabindex="0" data-language="json" data-filename="a real notification delivered over subscriptions/listen"><code><span class="line"><span style="color:#E1E4E8">{</span></span>
<span class="line"><span style="color:#79B8FF">  &quot;jsonrpc&quot;</span><span style="color:#E1E4E8">: </span><span style="color:#9ECBFF">&quot;2.0&quot;</span><span style="color:#E1E4E8">,</span></span>
<span class="line"><span style="color:#79B8FF">  &quot;method&quot;</span><span style="color:#E1E4E8">: </span><span style="color:#9ECBFF">&quot;notifications/tools/list_changed&quot;</span></span>
<span class="line"><span style="color:#E1E4E8">}</span></span></code></pre></div>
<p>What changed is delivery, not payload shape: this notification only arrives if the client opened a <code>subscriptions/listen</code> stream and explicitly opted into <code>toolsListChanged</code>. A client that assumed every notification type arrived automatically, the old SSE behavior, silently stops receiving the types it never opted into.</p>
<h2 id="structural-comparison-matrix">Structural Comparison Matrix</h2>

























<table tabindex="0"><thead><tr><th scope="col" style="text-align:left">Operational Aspect</th><th scope="col" style="text-align:left">Before (SSE GET endpoint)</th><th scope="col" style="text-align:left">After (subscriptions/listen)</th></tr></thead><tbody><tr><td style="text-align:left"><strong>Delivery default</strong></td><td style="text-align:left">All notification types, implicitly</td><td style="text-align:left">Opt-in per type, explicitly</td></tr><tr><td style="text-align:left"><strong>Persistence across disconnects</strong></td><td style="text-align:left">N/A</td><td style="text-align:left">Subscription lasts only while the stream is open</td></tr><tr><td style="text-align:left"><strong>Connection model</strong></td><td style="text-align:left">Persistent SSE GET</td><td style="text-align:left">Single long-lived POST-response stream</td></tr></tbody></table>
<h2 id="fix-it-opt-into-every-type-the-client-actually-needs">Fix it: opt into every type the client actually needs</h2>
<div class="callout" role="note" data-astro-cid-q2ml7llr><p class="callout__title" data-astro-cid-q2ml7llr><span aria-hidden="true" data-astro-cid-q2ml7llr>⚠</span>A silently missing notification is easy to miss</p><div class="callout__body" data-astro-cid-q2ml7llr><p>If a client used to react to <code>tools/list_changed</code> without ever explicitly
requesting it, migrating to <code>subscriptions/listen</code> without opting into
<code>toolsListChanged</code> means that handler simply stops firing, with no error to
explain why. Audit every notification type the client logic depends on before
migrating, not just the connection mechanism.</p></div></div>
<div class="code-block code-block--has-filename"><div class="code-block__header"><span class="code-block__filename">find notification handlers to cross-check against opt-in types</span><button type="button" class="code-block__copy" data-copy-button>Copy</button></div><pre class="astro-code github-dark" style="background-color:#24292e;color:#e1e4e8;overflow-x:auto" tabindex="0" data-language="bash" data-filename="find notification handlers to cross-check against opt-in types"><code><span class="line"><span style="color:#B392F0">grep</span><span style="color:#79B8FF"> -rn</span><span style="color:#9ECBFF"> &quot;list_changed\|resourceSubscriptions&quot;</span><span style="color:#79B8FF"> --include=</span><span style="color:#9ECBFF">&quot;*.ts&quot;</span><span style="color:#79B8FF"> --include=</span><span style="color:#9ECBFF">&quot;*.py&quot;</span><span style="color:#9ECBFF"> .</span></span></code></pre></div>
<p>For each handler found, confirm the client explicitly opts into the corresponding type (<code>toolsListChanged</code>, <code>promptsListChanged</code>, <code>resourcesListChanged</code>, or <code>resourceSubscriptions</code>) when opening the <code>subscriptions/listen</code> stream, and reopen the stream on disconnect rather than assuming the subscription survives a dropped connection.</p>
<h2 id="confirmed-version">Confirmed version</h2>
<p>Sourced from the official Model Context Protocol specification changelog, 2026-07-28 revision, published 2026-07-28, including a real notification payload example from the current specification’s tools documentation. Browse more posts like this in the <a href="/ai-productivity">AI Productivity</a> archive.</p>]]></content:encoded>
      <pubDate>Thu, 06 Aug 2026 00:00:00 GMT</pubDate>
    </item>
    <item>
      <title>MCP Requires resultType on Every Returned Result</title>
      <link>https://bytetech247.com/ai-productivity/mcp-requires-resulttype-on-every-result/</link>
      <guid isPermaLink="true">https://bytetech247.com/ai-productivity/mcp-requires-resulttype-on-every-result/</guid>
      <description>The 2026-07-28 MCP spec makes resultType mandatory on every result. The old server-initiated request pattern is replaced by an input_required result.</description>
      <content:encoded><![CDATA[<aside class="quick-answer" aria-label="Quick answer"><h2 id="quick-answer">Quick Answer</h2>
<p>The MCP 2026-07-28 specification makes <a href="https://modelcontextprotocol.io/specification/2026-07-28/basic#resulttype"><code>resultType</code> mandatory on every result</a> a server returns: <code>&quot;complete&quot;</code> for an ordinary finished result, <code>&quot;input_required&quot;</code> for a new interim result type. This replaces the old pattern of a server initiating a mid-call request back to the client; instead, a call to <code>tools/call</code>, <code>prompts/get</code>, or <code>resources/read</code> can return an <a href="https://modelcontextprotocol.io/specification/2026-07-28/basic/patterns/mrtr"><code>InputRequiredResult</code></a> carrying <code>inputRequests</code> the client must fulfill, then re-issue the call with the answers.</p>
</aside><h2 id="whats-actually-changing">What’s actually changing</h2>
<p>The specification changelog states both halves of this change:</p>
<blockquote>
<p>“All results now carry mandatory <code>resultType</code>: <code>complete</code> or <code>input_required</code>”</p>
</blockquote>
<blockquote>
<p>“Multi Round-Trip Requests (MRTR) pattern replaces server-initiated requests; servers return <code>InputRequiredResult</code> with <code>resultType: input_required</code> and <code>inputRequests</code> field”</p>
</blockquote>
<p>A real example of the interim result shape:</p>
<div class="code-block code-block--has-filename"><div class="code-block__header"><span class="code-block__filename">a real InputRequiredResult, asking the client to confirm before continuing</span><button type="button" class="code-block__copy" data-copy-button>Copy</button></div><pre class="astro-code github-dark" style="background-color:#24292e;color:#e1e4e8;overflow-x:auto" tabindex="0" data-language="json" data-filename="a real InputRequiredResult, asking the client to confirm before continuing"><code><span class="line"><span style="color:#E1E4E8">{</span></span>
<span class="line"><span style="color:#79B8FF">  &quot;resultType&quot;</span><span style="color:#E1E4E8">: </span><span style="color:#9ECBFF">&quot;input_required&quot;</span><span style="color:#E1E4E8">,</span></span>
<span class="line"><span style="color:#79B8FF">  &quot;inputRequests&quot;</span><span style="color:#E1E4E8">: {</span></span>
<span class="line"><span style="color:#79B8FF">    &quot;confirm&quot;</span><span style="color:#E1E4E8">: {</span></span>
<span class="line"><span style="color:#79B8FF">      &quot;type&quot;</span><span style="color:#E1E4E8">: </span><span style="color:#9ECBFF">&quot;elicitation&quot;</span><span style="color:#E1E4E8">,</span></span>
<span class="line"><span style="color:#79B8FF">      &quot;message&quot;</span><span style="color:#E1E4E8">: </span><span style="color:#9ECBFF">&quot;Delete 3 files?&quot;</span><span style="color:#E1E4E8">,</span></span>
<span class="line"><span style="color:#79B8FF">      &quot;schema&quot;</span><span style="color:#E1E4E8">: { </span><span style="color:#79B8FF">&quot;type&quot;</span><span style="color:#E1E4E8">: </span><span style="color:#9ECBFF">&quot;boolean&quot;</span><span style="color:#E1E4E8"> }</span></span>
<span class="line"><span style="color:#E1E4E8">    }</span></span>
<span class="line"><span style="color:#E1E4E8">  },</span></span>
<span class="line"><span style="color:#79B8FF">  &quot;requestState&quot;</span><span style="color:#E1E4E8">: </span><span style="color:#9ECBFF">&quot;eyJzdGVwIjoxLCJmaWxlcyI6WyJhIiwiYiIsImMiXX0=&quot;</span></span>
<span class="line"><span style="color:#E1E4E8">}</span></span></code></pre></div>
<p><code>inputRequests</code> is a map of server-initiated requests the client must resolve. <code>requestState</code> is an opaque blob the client echoes back unmodified on the follow-up call, carrying whatever internal progress state the server needs to resume, without the client needing to understand its contents.</p>
<h2 id="structural-comparison-matrix">Structural Comparison Matrix</h2>

























<table tabindex="0"><thead><tr><th scope="col" style="text-align:left">Operational Aspect</th><th scope="col" style="text-align:left">Before (pre-2026-07-28)</th><th scope="col" style="text-align:left">After (2026-07-28+)</th></tr></thead><tbody><tr><td style="text-align:left"><strong><code>resultType</code> field</strong></td><td style="text-align:left">Not present on ordinary results</td><td style="text-align:left">Mandatory: <code>&quot;complete&quot;</code> or <code>&quot;input_required&quot;</code></td></tr><tr><td style="text-align:left"><strong>Mid-call server-initiated requests</strong></td><td style="text-align:left">A separate server-to-client request pattern</td><td style="text-align:left">An <code>InputRequiredResult</code> returned from the original call</td></tr><tr><td style="text-align:left"><strong>Resuming after input is provided</strong></td><td style="text-align:left">N/A</td><td style="text-align:left">Client re-issues the call, echoing <code>requestState</code> unmodified</td></tr></tbody></table>
<h2 id="fix-it-handle-both-branches-on-both-ends">Fix it: handle both branches, on both ends</h2>
<div class="callout" role="note" data-astro-cid-q2ml7llr><p class="callout__title" data-astro-cid-q2ml7llr><span aria-hidden="true" data-astro-cid-q2ml7llr>✕</span>This breaks both servers and clients that skip it</p><div class="callout__body" data-astro-cid-q2ml7llr><p>A server that doesn’t set <code>resultType</code> on its ordinary results is sending
malformed responses under this spec. A client that doesn’t branch on
<code>resultType: &quot;input_required&quot;</code> will either crash on the unfamiliar shape or
silently treat an interim result as a final one, neither of which is correct.</p></div></div>
<div class="code-block code-block--has-filename"><div class="code-block__header"><span class="code-block__filename">ordinary result: resultType now required</span><button type="button" class="code-block__copy" data-copy-button>Copy</button></div><pre class="astro-code github-dark" style="background-color:#24292e;color:#e1e4e8;overflow-x:auto" tabindex="0" data-language="json" data-filename="ordinary result: resultType now required"><code><span class="line"><span style="color:#E1E4E8">{</span></span>
<span class="line"><span style="color:#79B8FF">  &quot;resultType&quot;</span><span style="color:#E1E4E8">: </span><span style="color:#9ECBFF">&quot;complete&quot;</span><span style="color:#E1E4E8">,</span></span>
<span class="line"><span style="color:#79B8FF">  &quot;content&quot;</span><span style="color:#E1E4E8">: [{ </span><span style="color:#79B8FF">&quot;type&quot;</span><span style="color:#E1E4E8">: </span><span style="color:#9ECBFF">&quot;text&quot;</span><span style="color:#E1E4E8">, </span><span style="color:#79B8FF">&quot;text&quot;</span><span style="color:#E1E4E8">: </span><span style="color:#9ECBFF">&quot;3 files deleted.&quot;</span><span style="color:#E1E4E8"> }]</span></span>
<span class="line"><span style="color:#E1E4E8">}</span></span></code></pre></div>
<p>Client-side handling needs an explicit branch: check <code>resultType</code>, and if it’s <code>&quot;input_required&quot;</code>, resolve every entry in <code>inputRequests</code>, then re-issue the original call with the answers and the untouched <code>requestState</code>. Server-side, every result-returning code path needs the field added, not just the new interim-result path.</p>
<h2 id="confirmed-version">Confirmed version</h2>
<p>Sourced from the official Model Context Protocol specification changelog, 2026-07-28 revision, published 2026-07-28, including a real <code>InputRequiredResult</code> example from the current specification. Browse more posts like this in the <a href="/ai-productivity">AI Productivity</a> archive.</p>]]></content:encoded>
      <pubDate>Thu, 06 Aug 2026 00:00:00 GMT</pubDate>
    </item>
    <item>
      <title>MCP Servers Must Now Implement server/discover</title>
      <link>https://bytetech247.com/ai-productivity/mcp-servers-must-implement-server-discover/</link>
      <guid isPermaLink="true">https://bytetech247.com/ai-productivity/mcp-servers-must-implement-server-discover/</guid>
      <description>The 2026-07-28 MCP spec adds a required server/discover RPC. Existing servers need a handler for it before a compliant client can negotiate at all.</description>
      <content:encoded><![CDATA[<aside class="quick-answer" aria-label="Quick answer"><h2 id="quick-answer">Quick Answer</h2>
<p>The MCP 2026-07-28 specification adds <a href="https://modelcontextprotocol.io/specification/2026-07-28/server/discover">a mandatory <code>server/discover</code> RPC</a> that every compliant server must implement, advertising its supported protocol versions, capabilities, and identity. This is the replacement discovery path for <a href="/ai-productivity/mcp-replaces-initialize-handshake-with-meta-fields/">the removed <code>initialize</code> handshake</a>: any existing server implementation needs a new handler added for this method before a 2026-07-28-compliant client can safely negotiate with it.</p>
</aside><h2 id="whats-actually-changing">What’s actually changing</h2>
<p>The specification changelog states it directly:</p>
<blockquote>
<p>“Added <code>server/discover</code> RPC: Servers must implement this to advertise their supported protocol versions, capabilities, and identity; clients may call before other requests”</p>
</blockquote>
<p>This closes the gap left by removing <code>initialize</code>. Previously, a client learned a server’s supported version and capabilities as part of the connection handshake. With that handshake gone, <code>server/discover</code> is the explicit, callable replacement, a request a client can make at any point (typically first) to learn what it’s talking to.</p>
<div class="callout" role="note" data-astro-cid-q2ml7llr><p class="callout__title" data-astro-cid-q2ml7llr><span aria-hidden="true" data-astro-cid-q2ml7llr>⚠</span>No fallback path if this handler is missing</p><div class="callout__body" data-astro-cid-q2ml7llr><p>A server built before the 2026-07-28 revision, with no <code>initialize</code>
replacement added, has no discovery mechanism at all under the new spec. This
isn’t a graceful-degradation case; it’s a required capability gap that blocks
a compliant client from negotiating safely.</p></div></div>
<h2 id="structural-comparison-matrix">Structural Comparison Matrix</h2>

























<table tabindex="0"><thead><tr><th scope="col" style="text-align:left">Operational Aspect</th><th scope="col" style="text-align:left">Before (initialize handshake)</th><th scope="col" style="text-align:left">After (2026-07-28+)</th></tr></thead><tbody><tr><td style="text-align:left"><strong>How a client learns server capabilities</strong></td><td style="text-align:left">Connection-start handshake response</td><td style="text-align:left">Explicit <code>server/discover</code> call</td></tr><tr><td style="text-align:left"><strong>Is this required on the server?</strong></td><td style="text-align:left"><code>initialize</code> was implicit to the protocol</td><td style="text-align:left"><code>server/discover</code> is an explicit, mandatory RPC</td></tr><tr><td style="text-align:left"><strong>When a client can call it</strong></td><td style="text-align:left">N/A, automatic at connection</td><td style="text-align:left">Any time, typically before other requests</td></tr></tbody></table>
<h2 id="fix-it-add-the-required-handler">Fix it: add the required handler</h2>
<p>The exact response schema for <code>server/discover</code> wasn’t confirmed from a primary source with full field-level detail here; check the current MCP specification text directly for the precise shape before implementing. What’s confirmed is the requirement itself and its purpose: the response needs to communicate supported protocol versions, server capabilities, and server identity, the same three categories a pre-2026-07-28 <code>initialize</code> response would have returned from the server side.</p>
<div class="code-block code-block--has-filename"><div class="code-block__header"><span class="code-block__filename">audit existing servers for this gap</span><button type="button" class="code-block__copy" data-copy-button>Copy</button></div><pre class="astro-code github-dark" style="background-color:#24292e;color:#e1e4e8;overflow-x:auto" tabindex="0" data-language="bash" data-filename="audit existing servers for this gap"><code><span class="line"><span style="color:#B392F0">grep</span><span style="color:#79B8FF"> -rln</span><span style="color:#9ECBFF"> &quot;</span><span style="color:#79B8FF">\&quot;</span><span style="color:#9ECBFF">method</span><span style="color:#79B8FF">\&quot;</span><span style="color:#9ECBFF">: </span><span style="color:#79B8FF">\&quot;</span><span style="color:#9ECBFF">initialize</span><span style="color:#79B8FF">\&quot;</span><span style="color:#9ECBFF">&quot;</span><span style="color:#79B8FF"> --include=</span><span style="color:#9ECBFF">&quot;*.py&quot;</span><span style="color:#79B8FF"> --include=</span><span style="color:#9ECBFF">&quot;*.ts&quot;</span><span style="color:#79B8FF"> --include=</span><span style="color:#9ECBFF">&quot;*.mjs&quot;</span><span style="color:#9ECBFF"> .</span></span></code></pre></div>
<p>If a server codebase only ever implemented <code>initialize</code> and never added a <code>server/discover</code> handler, that’s the concrete gap to close. Cross-reference against the current SDK version for your language; the official Tier 1 SDKs updated for this revision should already expose the right interface to implement against, rather than hand-rolling the RPC from scratch.</p>
<h2 id="confirmed-version">Confirmed version</h2>
<p>Sourced from the official Model Context Protocol specification changelog, 2026-07-28 revision, published 2026-07-28. The requirement itself and its stated purpose are confirmed verbatim; the exact response field names weren’t independently verified against a full schema reference, so confirm those against the live spec before implementing. Browse more posts like this in the <a href="/ai-productivity">AI Productivity</a> archive.</p>]]></content:encoded>
      <pubDate>Thu, 06 Aug 2026 00:00:00 GMT</pubDate>
    </item>
    <item>
      <title>Wrangler Removes Service Environments (v4.111.0)</title>
      <link>https://bytetech247.com/dev-tools/wrangler-4-111-removes-service-environments/</link>
      <guid isPermaLink="true">https://bytetech247.com/dev-tools/wrangler-4-111-removes-service-environments/</guid>
      <description>Wrangler 4.111 removes service environments and legacy_env. Each environment now deploys as its own Worker, and here is the exact error and how to migrate.</description>
      <content:encoded><![CDATA[<aside class="quick-answer" aria-label="Quick answer"><h2 id="quick-answer">Quick Answer</h2>
<p>Wrangler 4.111.0 removes the <code>legacy_env</code> configuration field entirely. If your <code>wrangler.toml</code> or <code>wrangler.jsonc</code> still sets <code>legacy_env</code> (either value), <code>wrangler deploy</code> now fails at config-parse time with a hard error. Delete the field: each environment already deploys as its own Worker named <code>&lt;name&gt;-&lt;environment&gt;</code>, which is what <code>legacy_env = true</code> (the old default) already did.</p>
</aside><h2 id="what-actually-changed">What actually changed</h2>
<p>Wrangler used to support two ways of handling <a href="https://developers.cloudflare.com/workers/wrangler/environments/">named environments</a> (<code>[env.staging]</code>, <code>[env.production]</code>) in one config file: the modern default, where each environment deploys as a completely separate Worker (<code>my-worker-staging</code>, <code>my-worker-production</code>), and a legacy mode, toggled by <code>legacy_env</code>, where every environment shared a single deployed Worker with environment-scoped variables layered on top.</p>
<p><a href="https://github.com/cloudflare/workers-sdk/releases/tag/wrangler%404.111.0"><code>wrangler@4.111.0</code></a>, released 2026-07-15, removes the legacy mode outright:</p>
<blockquote>
<p>“Remove support for service environments and the <code>legacy_env</code> configuration field”</p>
</blockquote>
<p>That’s the exact changelog entry, and it’s labeled a Breaking Change in <code>cloudflare/workers-sdk</code>’s own <code>CHANGELOG.md</code>, not a deprecation warning, a removal.</p>
<h2 id="the-exact-error">The exact error</h2>
<p>Reproduced directly against <code>wrangler@4.119.0</code> with a minimal config that still sets <code>legacy_env</code>:</p>
<div class="code-block code-block--has-filename"><div class="code-block__header"><span class="code-block__filename">wrangler.toml</span><button type="button" class="code-block__copy" data-copy-button>Copy</button></div><pre class="astro-code github-dark" style="background-color:#24292e;color:#e1e4e8;overflow-x:auto" tabindex="0" data-language="toml" data-filename="wrangler.toml"><code><span class="line"><span style="color:#E1E4E8">name = </span><span style="color:#9ECBFF">&quot;test-worker&quot;</span></span>
<span class="line"><span style="color:#E1E4E8">main = </span><span style="color:#9ECBFF">&quot;index.js&quot;</span></span>
<span class="line"><span style="color:#E1E4E8">compatibility_date = </span><span style="color:#9ECBFF">&quot;2026-08-01&quot;</span></span>
<span class="line"><span style="color:#E1E4E8">legacy_env = </span><span style="color:#79B8FF">false</span></span>
<span class="line"></span>
<span class="line"><span style="color:#E1E4E8">[</span><span style="color:#B392F0">env</span><span style="color:#E1E4E8">.</span><span style="color:#B392F0">staging</span><span style="color:#E1E4E8">]</span></span>
<span class="line"><span style="color:#E1E4E8">name = </span><span style="color:#9ECBFF">&quot;test-worker-staging&quot;</span></span></code></pre></div>
<div class="code-block code-block--has-filename"><div class="code-block__header"><span class="code-block__filename">wrangler deploy --dry-run</span><button type="button" class="code-block__copy" data-copy-button>Copy</button></div><pre class="astro-code github-dark" style="background-color:#24292e;color:#e1e4e8;overflow-x:auto" tabindex="0" data-language="bash" data-filename="wrangler deploy --dry-run"><code><span class="line"><span style="color:#B392F0">✘</span><span style="color:#E1E4E8"> [ERROR] Processing wrangler.toml configuration:</span></span>
<span class="line"></span>
<span class="line"><span style="color:#B392F0">    -</span><span style="color:#9ECBFF"> The</span><span style="color:#9ECBFF"> &quot;legacy_env&quot;</span><span style="color:#9ECBFF"> field</span><span style="color:#9ECBFF"> is</span><span style="color:#9ECBFF"> no</span><span style="color:#9ECBFF"> longer</span><span style="color:#9ECBFF"> supported,</span><span style="color:#9ECBFF"> so</span><span style="color:#9ECBFF"> please</span><span style="color:#9ECBFF"> remove</span><span style="color:#9ECBFF"> it</span><span style="color:#9ECBFF"> from</span><span style="color:#9ECBFF"> your</span><span style="color:#9ECBFF"> configuration</span><span style="color:#9ECBFF"> file.</span></span>
<span class="line"><span style="color:#B392F0">      Service</span><span style="color:#9ECBFF"> environments</span><span style="color:#9ECBFF"> have</span><span style="color:#9ECBFF"> been</span><span style="color:#9ECBFF"> removed,</span><span style="color:#9ECBFF"> and</span><span style="color:#9ECBFF"> each</span><span style="color:#9ECBFF"> environment</span><span style="color:#9ECBFF"> is</span><span style="color:#9ECBFF"> now</span><span style="color:#9ECBFF"> deployed</span><span style="color:#9ECBFF"> as</span><span style="color:#9ECBFF"> its</span><span style="color:#9ECBFF"> own</span><span style="color:#9ECBFF"> Worker</span><span style="color:#9ECBFF"> named</span><span style="color:#9ECBFF"> &quot;&lt;name&gt;-&lt;environment&gt;&quot;.</span><span style="color:#9ECBFF"> This</span><span style="color:#9ECBFF"> matches</span><span style="color:#9ECBFF"> the</span><span style="color:#9ECBFF"> behaviour</span><span style="color:#9ECBFF"> of</span><span style="color:#9ECBFF"> &quot;legacy_env = true&quot;,</span><span style="color:#9ECBFF"> which</span><span style="color:#9ECBFF"> was</span><span style="color:#9ECBFF"> the</span><span style="color:#9ECBFF"> default,</span><span style="color:#9ECBFF"> so</span><span style="color:#9ECBFF"> removing</span><span style="color:#9ECBFF"> the</span><span style="color:#9ECBFF"> field</span><span style="color:#9ECBFF"> will</span><span style="color:#9ECBFF"> not</span><span style="color:#9ECBFF"> change</span><span style="color:#9ECBFF"> how</span><span style="color:#9ECBFF"> your</span><span style="color:#9ECBFF"> Worker</span><span style="color:#9ECBFF"> is</span><span style="color:#9ECBFF"> deployed.</span></span>
<span class="line"><span style="color:#B392F0">      Refer</span><span style="color:#9ECBFF"> to</span><span style="color:#9ECBFF"> https://developers.cloudflare.com/workers/wrangler/environments/</span><span style="color:#9ECBFF"> for</span><span style="color:#9ECBFF"> more</span><span style="color:#9ECBFF"> information.</span></span></code></pre></div>
<p>Wrangler’s own error message is unusually direct about the fix: since <code>legacy_env = true</code> was already the default, most configs don’t need to change deploy behavior at all, just delete the field.</p>
<h2 id="structural-comparison-matrix">Structural Comparison Matrix</h2>

























<table tabindex="0"><thead><tr><th scope="col" style="text-align:left">Operational Aspect</th><th scope="col" style="text-align:left">Before (<code>legacy_env</code>)</th><th scope="col" style="text-align:left">After (v4.111.0+)</th></tr></thead><tbody><tr><td style="text-align:left"><strong>Worker per environment</strong></td><td style="text-align:left">One shared Worker, environment-scoped variables layered on top</td><td style="text-align:left">Separate Worker per environment, named <code>&lt;name&gt;-&lt;environment&gt;</code></td></tr><tr><td style="text-align:left"><strong>Config field required</strong></td><td style="text-align:left"><code>legacy_env = true</code> or <code>false</code></td><td style="text-align:left">Field removed entirely; present at all is a hard error</td></tr><tr><td style="text-align:left"><strong>CI/DNS/dashboard impact</strong></td><td style="text-align:left">Single deploy target to track</td><td style="text-align:left">Must reference the per-environment Worker name for each environment</td></tr></tbody></table>
<h2 id="fix-it-delete-the-field-check-your-automation">Fix it: delete the field, check your automation</h2>
<div class="callout" role="note" data-astro-cid-q2ml7llr><p class="callout__title" data-astro-cid-q2ml7llr><span aria-hidden="true" data-astro-cid-q2ml7llr>⚠</span>Check CI/CD and dashboards before deploying, not after</p><div class="callout__body" data-astro-cid-q2ml7llr><p>If any script, GitHub Actions workflow, or monitoring dashboard still
references the old single-Worker name for an environment (<code>my-worker</code> instead
of <code>my-worker-staging</code>), it will silently point at the wrong target once
<code>legacy_env</code> is gone. Wrangler’s error only catches the config file, not
everything downstream that assumed the old naming.</p></div></div>
<h3 id="before-config-that-fails-on-4111">Before: config that fails on 4.111+</h3>
<div class="code-block code-block--has-filename"><div class="code-block__header"><span class="code-block__filename">wrangler.toml (fails on 4.111+)</span><button type="button" class="code-block__copy" data-copy-button>Copy</button></div><pre class="astro-code github-dark" style="background-color:#24292e;color:#e1e4e8;overflow-x:auto" tabindex="0" data-language="toml" data-filename="wrangler.toml (fails on 4.111+)"><code><span class="line"><span style="color:#E1E4E8">legacy_env = </span><span style="color:#79B8FF">false</span></span>
<span class="line"></span>
<span class="line"><span style="color:#E1E4E8">[</span><span style="color:#B392F0">env</span><span style="color:#E1E4E8">.</span><span style="color:#B392F0">staging</span><span style="color:#E1E4E8">]</span></span>
<span class="line"><span style="color:#E1E4E8">name = </span><span style="color:#9ECBFF">&quot;my-worker-staging&quot;</span></span></code></pre></div>
<h3 id="after-remove-the-field-keep-the-environment-block">After: remove the field, keep the environment block</h3>
<div class="code-block code-block--has-filename"><div class="code-block__header"><span class="code-block__filename">wrangler.toml (fixed)</span><button type="button" class="code-block__copy" data-copy-button>Copy</button></div><pre class="astro-code github-dark" style="background-color:#24292e;color:#e1e4e8;overflow-x:auto" tabindex="0" data-language="toml" data-filename="wrangler.toml (fixed)"><code><span class="line"><span style="color:#E1E4E8">[</span><span style="color:#B392F0">env</span><span style="color:#E1E4E8">.</span><span style="color:#B392F0">staging</span><span style="color:#E1E4E8">]</span></span>
<span class="line"><span style="color:#E1E4E8">name = </span><span style="color:#9ECBFF">&quot;my-worker-staging&quot;</span></span></code></pre></div>
<p>If your config previously relied on <code>legacy_env = true</code> (sharing one Worker across environments), the fix is more than deleting a line: each environment now deploys as its own independent Worker, so anything that assumed a single shared Worker instance needs to be re-verified per environment. That includes KV namespaces bound only in the top-level config, a single <code>workers.dev</code> route, and secrets set once and expected to apply everywhere. Run <code>wrangler deploy --dry-run</code> for each environment after the change and confirm the resulting Worker name matches what your DNS routes, CI scripts, and dashboards expect.</p>
<h2 id="confirmed-version">Confirmed version</h2>
<p>Reproduced directly: <code>legacy_env</code> in any form is rejected by <code>wrangler@4.119.0</code>, tracing back to the labeled Breaking Change in <code>wrangler@4.111.0</code> (released 2026-07-15). There is no flag to restore the removed behavior; this is unconditional past that version. Browse more posts like this in the <a href="/dev-tools">Dev Tools</a> archive.</p>]]></content:encoded>
      <pubDate>Thu, 06 Aug 2026 00:00:00 GMT</pubDate>
    </item>
    <item>
      <title>Wrangler 4.117 Drops containerEngine From Miniflare</title>
      <link>https://bytetech247.com/dev-tools/wrangler-4-117-removes-containerengine-miniflare/</link>
      <guid isPermaLink="true">https://bytetech247.com/dev-tools/wrangler-4-117-removes-containerengine-miniflare/</guid>
      <description>Wrangler 4.117 removes containerEngine from unstable_getMiniflareWorkerOptions. Custom local-testing harnesses reading it now get undefined, silently.</description>
      <content:encoded><![CDATA[<aside class="quick-answer" aria-label="Quick answer"><h2 id="quick-answer">Quick Answer</h2>
<p>Wrangler 4.117.0 removes <code>containerEngine</code> from the object <code>unstable_getMiniflareWorkerOptions</code> returns. If a custom local-testing harness reads <code>containerEngine</code> off that returned options object, it now gets <code>undefined</code> instead of a real value, with no error thrown. <code>containerEngine</code> needs to be set at the Miniflare instance level instead, not read from per-worker options.</p>
</aside><h2 id="who-this-actually-affects">Who this actually affects</h2>
<p><code>unstable_getMiniflareWorkerOptions</code> is part of Wrangler’s unstable Miniflare integration API: code that programmatically builds a Miniflare-backed local Worker instance, most commonly a custom Vitest or testing-harness setup that needs finer control than plain <code>wrangler dev</code> gives. <code>containerEngine</code> configures which local container runtime backs Cloudflare’s Containers feature when a Worker uses container bindings.</p>
<p>The change itself, from <code>cloudflare/workers-sdk</code>’s <code>packages/wrangler/CHANGELOG.md</code>:</p>
<blockquote>
<p>“Remove <code>containerEngine</code> from the worker options returned by <code>unstable_getMiniflareWorkerOptions</code>”</p>
</blockquote>
<p>This shipped in <a href="https://github.com/cloudflare/workers-sdk/releases/tag/wrangler%404.117.0"><code>wrangler@4.117.0</code></a>, released 2026-07-31, as part of the broader Miniflare v5 upgrade bundled into that release.</p>
<h2 id="structural-comparison-matrix">Structural Comparison Matrix</h2>

























<table tabindex="0"><thead><tr><th scope="col" style="text-align:left">Operational Aspect</th><th scope="col" style="text-align:left">Before (Miniflare v4-era)</th><th scope="col" style="text-align:left">After (v4.117.0+)</th></tr></thead><tbody><tr><td style="text-align:left"><strong>Where <code>containerEngine</code> lives</strong></td><td style="text-align:left">Read off the per-worker options object returned by <code>unstable_getMiniflareWorkerOptions</code></td><td style="text-align:left">Set at the Miniflare instance level, not per-worker</td></tr><tr><td style="text-align:left"><strong>Failure mode for old code</strong></td><td style="text-align:left">Field present, real value</td><td style="text-align:left">Field absent; reads as <code>undefined</code>, no error thrown</td></tr><tr><td style="text-align:left"><strong>Who is affected</strong></td><td style="text-align:left">Anyone using the plain <code>wrangler dev</code> flow: not affected</td><td style="text-align:left">Custom harnesses calling the unstable API directly</td></tr></tbody></table>
<h2 id="fix-it-move-the-setting-to-the-miniflare-instance">Fix it: move the setting to the Miniflare instance</h2>
<div class="callout" role="note" data-astro-cid-q2ml7llr><p class="callout__title" data-astro-cid-q2ml7llr><span aria-hidden="true" data-astro-cid-q2ml7llr>✕</span>This fails silently, not loudly</p><div class="callout__body" data-astro-cid-q2ml7llr><p>There is no thrown error, no deprecation warning printed at runtime, and no
type error unless your harness has strict typing on the returned shape. A
harness that destructures <code>containerEngine</code> off the worker options and passes
it along gets <code>undefined</code> and keeps running, which can mean local
container-backed tests silently stop using the engine you intended.</p></div></div>
<h3 id="before-reading-it-off-worker-options">Before: reading it off worker options</h3>
<div class="code-block code-block--has-filename"><div class="code-block__header"><span class="code-block__filename">custom test harness (broken on 4.117+)</span><button type="button" class="code-block__copy" data-copy-button>Copy</button></div><pre class="astro-code github-dark" style="background-color:#24292e;color:#e1e4e8;overflow-x:auto" tabindex="0" data-language="ts" data-filename="custom test harness (broken on 4.117+)"><code><span class="line"><span style="color:#F97583">const</span><span style="color:#79B8FF"> workerOptions</span><span style="color:#F97583"> =</span><span style="color:#F97583"> await</span><span style="color:#B392F0"> unstable_getMiniflareWorkerOptions</span><span style="color:#E1E4E8">(config);</span></span>
<span class="line"></span>
<span class="line"><span style="color:#9ca6b0">// containerEngine is now undefined here, not thrown</span></span>
<span class="line"><span style="color:#F97583">const</span><span style="color:#79B8FF"> engine</span><span style="color:#F97583"> =</span><span style="color:#E1E4E8"> workerOptions.containerEngine;</span></span></code></pre></div>
<h3 id="after-set-it-on-the-miniflare-instance-directly">After: set it on the Miniflare instance directly</h3>
<div class="code-block code-block--has-filename"><div class="code-block__header"><span class="code-block__filename">custom test harness (fixed)</span><button type="button" class="code-block__copy" data-copy-button>Copy</button></div><pre class="astro-code github-dark" style="background-color:#24292e;color:#e1e4e8;overflow-x:auto" tabindex="0" data-language="ts" data-filename="custom test harness (fixed)"><code><span class="line"><span style="color:#F97583">const</span><span style="color:#79B8FF"> workerOptions</span><span style="color:#F97583"> =</span><span style="color:#F97583"> await</span><span style="color:#B392F0"> unstable_getMiniflareWorkerOptions</span><span style="color:#E1E4E8">(config);</span></span>
<span class="line"></span>
<span class="line"><span style="color:#F97583">const</span><span style="color:#79B8FF"> mf</span><span style="color:#F97583"> =</span><span style="color:#F97583"> new</span><span style="color:#B392F0"> Miniflare</span><span style="color:#E1E4E8">({</span></span>
<span class="line"><span style="color:#F97583">  ...</span><span style="color:#E1E4E8">workerOptions,</span></span>
<span class="line"><span style="color:#E1E4E8">  containerEngine: </span><span style="color:#9ECBFF">&quot;docker&quot;</span><span style="color:#E1E4E8">, </span><span style="color:#9ca6b0">// set at the instance level now</span></span>
<span class="line"><span style="color:#E1E4E8">});</span></span></code></pre></div>
<p>The exact shape of the instance-level option depends on which Miniflare version your harness pins, so confirm the current constructor signature against your installed <code>miniflare</code> package version rather than copying this verbatim; the point that matters is which layer owns the setting, not the exact call shape.</p>
<h2 id="confirmed-version">Confirmed version</h2>
<p>Sourced directly from <code>cloudflare/workers-sdk</code>’s <code>packages/wrangler/CHANGELOG.md</code>, <code>wrangler@4.117.0</code>, released 2026-07-31. This one wasn’t independently reproduced against a live Containers-backed harness, since that requires a container runtime and a bound Worker beyond what this post’s scope covers; the changelog entry and the API’s documented shape are the source. If your own harness pins an older Wrangler and hasn’t hit this yet, check your <code>wrangler</code> and <code>miniflare</code> versions before upgrading past 4.117.0. Browse more posts like this in the <a href="/dev-tools">Dev Tools</a> archive.</p>]]></content:encoded>
      <pubDate>Thu, 06 Aug 2026 00:00:00 GMT</pubDate>
    </item>
    <item>
      <title>Check Worker Bundle Size With wrangler check startup</title>
      <link>https://bytetech247.com/dev-tools/wrangler-check-startup-bundle-size/</link>
      <guid isPermaLink="true">https://bytetech247.com/dev-tools/wrangler-check-startup-bundle-size/</guid>
      <description>wrangler check startup graduated from alpha in v4.116 and now shows real bundle size and a startup CPU profile. Run on this site: 3.95 KiB, gzip 1.54 KiB.</description>
      <content:encoded><![CDATA[<aside class="quick-answer" aria-label="Quick answer"><h2 id="quick-answer">Quick Answer</h2>
<p><code>wrangler check startup</code> graduated from alpha in <code>wrangler@4.116.0</code> and now reports your Worker’s real bundle size and a local startup CPU profile, all from a single terminal command, no deploy required. Run it before a deploy that would otherwise only surface a bundle-bloat or slow-startup problem as a production cold-start regression or a dashboard warning after the fact.</p>
</aside><h2 id="what-it-actually-reports">What it actually reports</h2>
<p>The changelog entry, from <code>cloudflare/workers-sdk</code>’s <code>packages/wrangler/CHANGELOG.md</code>, <a href="https://github.com/cloudflare/workers-sdk/releases/tag/wrangler%404.116.0"><code>wrangler@4.116.0</code></a> (released 2026-07-30):</p>
<blockquote>
<p>“Graduate <code>wrangler check startup</code> from alpha and show bundle size”</p>
</blockquote>
<p>Run directly against this site’s own built Worker:</p>
<div class="code-block code-block--has-filename"><div class="code-block__header"><span class="code-block__filename">wrangler check startup, run on this repo</span><button type="button" class="code-block__copy" data-copy-button>Copy</button></div><pre class="astro-code github-dark" style="background-color:#24292e;color:#e1e4e8;overflow-x:auto" tabindex="0" data-language="bash" data-filename="wrangler check startup, run on this repo"><code><span class="line"><span style="color:#B392F0">$</span><span style="color:#9ECBFF"> wrangler</span><span style="color:#9ECBFF"> check</span><span style="color:#9ECBFF"> startup</span></span>
<span class="line"></span>
<span class="line"><span style="color:#B392F0"> ⛅️</span><span style="color:#9ECBFF"> wrangler</span><span style="color:#79B8FF"> 4.118.0</span></span>
<span class="line"><span style="color:#B392F0">────────────────────</span></span>
<span class="line"><span style="color:#B392F0">├</span><span style="color:#9ECBFF"> Building</span><span style="color:#9ECBFF"> your</span><span style="color:#9ECBFF"> Worker</span></span>
<span class="line"><span style="color:#B392F0">│</span><span style="color:#9ECBFF"> Worker</span><span style="color:#9ECBFF"> Built!</span><span style="color:#9ECBFF"> 🎉</span></span>
<span class="line"><span style="color:#B392F0">│</span></span>
<span class="line"><span style="color:#B392F0">├</span><span style="color:#9ECBFF"> Analysing</span></span>
<span class="line"><span style="color:#B392F0">│</span><span style="color:#9ECBFF"> Startup</span><span style="color:#9ECBFF"> phase</span><span style="color:#9ECBFF"> analysed</span></span>
<span class="line"><span style="color:#B392F0">│</span></span>
<span class="line"><span style="color:#B392F0">│</span><span style="color:#9ECBFF"> Bundle:</span><span style="color:#79B8FF"> 3.95</span><span style="color:#9ECBFF"> KiB</span><span style="color:#9ECBFF"> /</span><span style="color:#9ECBFF"> gzip:</span><span style="color:#79B8FF"> 1.54</span><span style="color:#9ECBFF"> KiB</span></span>
<span class="line"><span style="color:#B392F0">│</span></span>
<span class="line"><span style="color:#B392F0">│</span><span style="color:#9ECBFF"> Local</span><span style="color:#9ECBFF"> startup</span><span style="color:#9ECBFF"> profile:</span></span>
<span class="line"><span style="color:#B392F0">│</span><span style="color:#9ECBFF">   Profile</span><span style="color:#9ECBFF"> window:</span><span style="color:#79B8FF"> 153.0</span><span style="color:#9ECBFF"> ms</span></span>
<span class="line"><span style="color:#B392F0">│</span><span style="color:#9ECBFF">   Sampled</span><span style="color:#9ECBFF"> time:</span><span style="color:#79B8FF"> 76.2</span><span style="color:#9ECBFF"> ms</span></span>
<span class="line"><span style="color:#B392F0">│</span><span style="color:#9ECBFF">   Active:</span><span style="color:#79B8FF"> 71.6</span><span style="color:#9ECBFF"> ms</span><span style="color:#E1E4E8"> (including </span><span style="color:#79B8FF">0.0</span><span style="color:#9ECBFF"> ms</span><span style="color:#9ECBFF"> garbage</span><span style="color:#9ECBFF"> collection</span><span style="color:#E1E4E8">)</span></span>
<span class="line"><span style="color:#B392F0">│</span><span style="color:#9ECBFF">   Idle:</span><span style="color:#79B8FF"> 4.6</span><span style="color:#9ECBFF"> ms</span></span>
<span class="line"><span style="color:#B392F0">│</span><span style="color:#9ECBFF">   Samples:</span><span style="color:#79B8FF"> 3</span></span>
<span class="line"><span style="color:#B392F0">│</span></span>
<span class="line"><span style="color:#B392F0">│</span><span style="color:#9ECBFF"> CPU</span><span style="color:#9ECBFF"> Profile</span><span style="color:#9ECBFF"> has</span><span style="color:#9ECBFF"> been</span><span style="color:#9ECBFF"> written</span><span style="color:#9ECBFF"> to</span><span style="color:#9ECBFF"> worker-startup.cpuprofile.</span></span></code></pre></div>
<p>Two real numbers come out of this: the actual bundle size (gzip and raw), and a sampled CPU profile of the Worker’s startup phase, written to a <code>.cpuprofile</code> file that loads directly into Chrome DevTools’ profiler or VS Code for a flamegraph view.</p>
<div class="callout" role="note" data-astro-cid-q2ml7llr><p class="callout__title" data-astro-cid-q2ml7llr><span aria-hidden="true" data-astro-cid-q2ml7llr>ℹ</span>Local timing is not production timing</p><div class="callout__body" data-astro-cid-q2ml7llr><p>Wrangler’s own output is direct about this: the CPU profile runs on your local
machine, which has a different CPU than Cloudflare’s runtime. Use it to see
where time is spent during startup, not to predict the exact cold-start number
a real deploy will show.</p></div></div>
<h2 id="structural-comparison-matrix">Structural Comparison Matrix</h2>

























<table tabindex="0"><thead><tr><th scope="col" style="text-align:left">Operational Aspect</th><th scope="col" style="text-align:left">Before (alpha, pre-4.116.0)</th><th scope="col" style="text-align:left">After (v4.116.0+)</th></tr></thead><tbody><tr><td style="text-align:left"><strong>Command availability</strong></td><td style="text-align:left">Behind alpha gating</td><td style="text-align:left">Generally available</td></tr><tr><td style="text-align:left"><strong>Bundle size reported</strong></td><td style="text-align:left">Not shown</td><td style="text-align:left">Raw and gzip size, in the same output</td></tr><tr><td style="text-align:left"><strong>How you’d catch bloat before this</strong></td><td style="text-align:left">Only after a deploy, via dashboard or cold-start complaints</td><td style="text-align:left">Locally, before deploying at all</td></tr></tbody></table>
<h2 id="fix-it-add-this-to-your-pre-deploy-routine">Fix it: add this to your pre-deploy routine</h2>
<div class="code-block code-block--has-filename"><div class="code-block__header"><span class="code-block__filename">run before a deploy, not after a regression report</span><button type="button" class="code-block__copy" data-copy-button>Copy</button></div><pre class="astro-code github-dark" style="background-color:#24292e;color:#e1e4e8;overflow-x:auto" tabindex="0" data-language="bash" data-filename="run before a deploy, not after a regression report"><code><span class="line"><span style="color:#B392F0">npm</span><span style="color:#9ECBFF"> run</span><span style="color:#9ECBFF"> build</span></span>
<span class="line"><span style="color:#B392F0">wrangler</span><span style="color:#9ECBFF"> check</span><span style="color:#9ECBFF"> startup</span></span></code></pre></div>
<p>The <code>.cpuprofile</code> file it writes isn’t meant to be committed. It’s a local diagnostic artifact, so add it to <code>.gitignore</code> if your project doesn’t already ignore Wrangler’s build output:</p>
<div class="code-block code-block--has-filename"><div class="code-block__header"><span class="code-block__filename">.gitignore</span><button type="button" class="code-block__copy" data-copy-button>Copy</button></div><pre class="astro-code github-dark" style="background-color:#24292e;color:#e1e4e8;overflow-x:auto" tabindex="0" data-language="plaintext" data-filename=".gitignore"><code><span class="line"><span>worker-startup.cpuprofile</span></span></code></pre></div>
<h2 id="confirmed-version">Confirmed version</h2>
<p>Reproduced directly against this site’s own build, <code>wrangler@4.118.0</code>, tracing back to the labeled graduation in <code>wrangler@4.116.0</code> (released 2026-07-30). The bundle size and profile numbers above are this site’s real Worker, not illustrative figures. Browse more posts like this in the <a href="/dev-tools">Dev Tools</a> archive.</p>]]></content:encoded>
      <pubDate>Thu, 06 Aug 2026 00:00:00 GMT</pubDate>
    </item>
    <item>
      <title>Wrangler&apos;s New cloudflare.config.ts settings Export</title>
      <link>https://bytetech247.com/dev-tools/wrangler-cloudflare-config-ts-settings-export/</link>
      <guid isPermaLink="true">https://bytetech247.com/dev-tools/wrangler-cloudflare-config-ts-settings-export/</guid>
      <description>Wrangler 4.113 adds a defineSettings export to the experimental cloudflare.config.ts format, moving accountId and complianceRegion out of the Worker config.</description>
      <content:encoded><![CDATA[<aside class="quick-answer" aria-label="Quick answer"><h2 id="quick-answer">Quick Answer</h2>
<p>Wrangler 4.113.0 adds a <code>settings</code> export to the experimental <code>cloudflare.config.ts</code> config format, built with a new <code>defineSettings</code> function. Account-level fields (<code>accountId</code>, <code>complianceRegion</code>) that used to sit inline in the Worker’s own config now need their own dedicated export, separate from the <code>default</code> export that defines the Worker itself. Miss the move and those settings silently stop applying.</p>
</aside><h2 id="what-the-new-export-looks-like">What the new export looks like</h2>
<p><code>cloudflare.config.ts</code> is Wrangler’s experimental TypeScript-based alternative to <code>wrangler.toml</code>/<code>wrangler.jsonc</code>, available behind <code>wrangler --experimental-new-config</code>. Before this change, everything, including account-level settings, lived in the file’s single <code>default</code> export alongside the Worker’s own bindings and routes.</p>
<p><a href="https://github.com/cloudflare/workers-sdk/releases/tag/wrangler%404.113.0"><code>wrangler@4.113.0</code></a>’s changelog states it plainly:</p>
<blockquote>
<p>“Add a <code>settings</code> export to the experimental <code>cloudflare.config.ts</code> config”</p>
</blockquote>
<p>The actual code shape, from the merged pull request (<a href="https://github.com/cloudflare/workers-sdk/pull/14724"><code>cloudflare/workers-sdk#14724</code></a>, “Support settings export in new config,” merged 2026-07-20):</p>
<div class="code-block code-block--has-filename"><div class="code-block__header"><span class="code-block__filename">cloudflare.config.ts</span><button type="button" class="code-block__copy" data-copy-button>Copy</button></div><pre class="astro-code github-dark" style="background-color:#24292e;color:#e1e4e8;overflow-x:auto" tabindex="0" data-language="ts" data-filename="cloudflare.config.ts"><code><span class="line"><span style="color:#F97583">import</span><span style="color:#E1E4E8"> { defineSettings } </span><span style="color:#F97583">from</span><span style="color:#9ECBFF"> &quot;wrangler/config&quot;</span><span style="color:#E1E4E8">;</span></span>
<span class="line"></span>
<span class="line"><span style="color:#F97583">export</span><span style="color:#F97583"> const</span><span style="color:#79B8FF"> settings</span><span style="color:#F97583"> =</span><span style="color:#B392F0"> defineSettings</span><span style="color:#E1E4E8">({</span></span>
<span class="line"><span style="color:#E1E4E8">  accountId: </span><span style="color:#9ECBFF">&quot;&lt;your-account-id&gt;&quot;</span><span style="color:#E1E4E8">,</span></span>
<span class="line"><span style="color:#E1E4E8">});</span></span></code></pre></div>
<p><code>settings</code> accepts <code>accountId</code> and <code>complianceRegion</code>. The Worker’s own configuration stays on the <code>default</code> export, unchanged; <code>settings</code> is a new, separate export sitting next to it in the same file.</p>
<h2 id="structural-comparison-matrix">Structural Comparison Matrix</h2>

























<table tabindex="0"><thead><tr><th scope="col" style="text-align:left">Operational Aspect</th><th scope="col" style="text-align:left">Before (v4.112.0 and earlier)</th><th scope="col" style="text-align:left">After (v4.113.0+)</th></tr></thead><tbody><tr><td style="text-align:left"><strong>Where account settings live</strong></td><td style="text-align:left">Inline in the single <code>default</code> export</td><td style="text-align:left">Dedicated <code>settings</code> export via <code>defineSettings</code></td></tr><tr><td style="text-align:left"><strong>Compliance region config</strong></td><td style="text-align:left">Mixed with Worker-level fields</td><td style="text-align:left">Isolated in <code>settings</code>, alongside <code>accountId</code></td></tr><tr><td style="text-align:left"><strong>Failure mode if not migrated</strong></td><td style="text-align:left">N/A</td><td style="text-align:left">Settings silently don’t apply; no error thrown</td></tr></tbody></table>
<h2 id="fix-it-split-the-exports">Fix it: split the exports</h2>
<div class="callout" role="note" data-astro-cid-q2ml7llr><p class="callout__title" data-astro-cid-q2ml7llr><span aria-hidden="true" data-astro-cid-q2ml7llr>⚠</span>No error on a missing settings export</p><div class="callout__body" data-astro-cid-q2ml7llr><p>Wrangler doesn’t fail the build if <code>cloudflare.config.ts</code> has no <code>settings</code>
export. It just means account-level fields you expected to apply, like a
compliance region restriction, don’t take effect. Confirm the setting is
actually active through the dashboard or API, not just by the file compiling.</p></div></div>
<h3 id="before-everything-on-the-default-export">Before: everything on the default export</h3>
<div class="code-block code-block--has-filename"><div class="code-block__header"><span class="code-block__filename">cloudflare.config.ts (pre-4.113 shape)</span><button type="button" class="code-block__copy" data-copy-button>Copy</button></div><pre class="astro-code github-dark" style="background-color:#24292e;color:#e1e4e8;overflow-x:auto" tabindex="0" data-language="ts" data-filename="cloudflare.config.ts (pre-4.113 shape)"><code><span class="line"><span style="color:#F97583">export</span><span style="color:#F97583"> default</span><span style="color:#E1E4E8"> {</span></span>
<span class="line"><span style="color:#E1E4E8">  name: </span><span style="color:#9ECBFF">&quot;my-worker&quot;</span><span style="color:#E1E4E8">,</span></span>
<span class="line"><span style="color:#E1E4E8">  main: </span><span style="color:#9ECBFF">&quot;src/index.ts&quot;</span><span style="color:#E1E4E8">,</span></span>
<span class="line"><span style="color:#E1E4E8">  accountId: </span><span style="color:#9ECBFF">&quot;&lt;your-account-id&gt;&quot;</span><span style="color:#E1E4E8">,</span></span>
<span class="line"><span style="color:#E1E4E8">};</span></span></code></pre></div>
<h3 id="after-account-settings-split-into-their-own-export">After: account settings split into their own export</h3>
<div class="code-block code-block--has-filename"><div class="code-block__header"><span class="code-block__filename">cloudflare.config.ts (4.113.0+)</span><button type="button" class="code-block__copy" data-copy-button>Copy</button></div><pre class="astro-code github-dark" style="background-color:#24292e;color:#e1e4e8;overflow-x:auto" tabindex="0" data-language="ts" data-filename="cloudflare.config.ts (4.113.0+)"><code><span class="line"><span style="color:#F97583">import</span><span style="color:#E1E4E8"> { defineSettings } </span><span style="color:#F97583">from</span><span style="color:#9ECBFF"> &quot;wrangler/config&quot;</span><span style="color:#E1E4E8">;</span></span>
<span class="line"></span>
<span class="line"><span style="color:#F97583">export</span><span style="color:#F97583"> const</span><span style="color:#79B8FF"> settings</span><span style="color:#F97583"> =</span><span style="color:#B392F0"> defineSettings</span><span style="color:#E1E4E8">({</span></span>
<span class="line"><span style="color:#E1E4E8">  accountId: </span><span style="color:#9ECBFF">&quot;&lt;your-account-id&gt;&quot;</span><span style="color:#E1E4E8">,</span></span>
<span class="line"><span style="color:#E1E4E8">  complianceRegion: </span><span style="color:#9ECBFF">&quot;eu&quot;</span><span style="color:#E1E4E8">,</span></span>
<span class="line"><span style="color:#E1E4E8">});</span></span>
<span class="line"></span>
<span class="line"><span style="color:#F97583">export</span><span style="color:#F97583"> default</span><span style="color:#E1E4E8"> {</span></span>
<span class="line"><span style="color:#E1E4E8">  name: </span><span style="color:#9ECBFF">&quot;my-worker&quot;</span><span style="color:#E1E4E8">,</span></span>
<span class="line"><span style="color:#E1E4E8">  main: </span><span style="color:#9ECBFF">&quot;src/index.ts&quot;</span><span style="color:#E1E4E8">,</span></span>
<span class="line"><span style="color:#E1E4E8">};</span></span></code></pre></div>
<h2 id="confirmed-version">Confirmed version</h2>
<p>Sourced from <code>cloudflare/workers-sdk</code>’s <code>packages/wrangler/CHANGELOG.md</code> (<code>wrangler@4.113.0</code>, released 2026-07-21) and the merged pull request implementing it (<code>#14724</code>, merged 2026-07-20). This wasn’t independently reproduced against a live account here, since <code>cloudflare.config.ts</code> is still experimental and this site’s own deploy runs on <code>wrangler.toml</code>; the changelog and the PR’s own code example are the primary sources. Browse more posts like this in the <a href="/dev-tools">Dev Tools</a> archive.</p>]]></content:encoded>
      <pubDate>Thu, 06 Aug 2026 00:00:00 GMT</pubDate>
    </item>
    <item>
      <title>Wrangler Now Traces Local Dev by Default (v4.118)</title>
      <link>https://bytetech247.com/dev-tools/wrangler-local-dev-observability-default-on/</link>
      <guid isPermaLink="true">https://bytetech247.com/dev-tools/wrangler-local-dev-observability-default-on/</guid>
      <description>Wrangler 4.118 captures request traces and console logs into Local Explorer by default. Opt out with X_LOCAL_OBSERVABILITY=false if it causes trouble.</description>
      <content:encoded><![CDATA[<aside class="quick-answer" aria-label="Quick answer"><h2 id="quick-answer">Quick Answer</h2>
<p>Starting in <code>wrangler@4.118.0</code>, <code>wrangler dev</code> and the Cloudflare Vite plugin capture request traces and console logs into the Local Explorer’s Observability tab automatically, with no flag needed. This used to be opt-in. If the extra per-worker collector and streaming-tail services this spins up cause trouble, for example in a multi-process dev-registry setup, set <code>X_LOCAL_OBSERVABILITY=false</code> to opt back out.</p>
</aside><h2 id="what-changed-and-when">What changed, and when</h2>
<p>Local dev observability shipped in two stages. <a href="https://github.com/cloudflare/workers-sdk/releases/tag/wrangler%404.114.0"><code>wrangler@4.114.0</code></a> (released 2026-07-23) first added the capability:</p>
<blockquote>
<p>“Add local-dev observability”</p>
</blockquote>
<p><a href="https://github.com/cloudflare/workers-sdk/releases/tag/wrangler%404.118.0"><code>wrangler@4.118.0</code></a> (released 2026-07-31) then flipped it on by default:</p>
<blockquote>
<p>“Enable local observability capture by default in dev”</p>
</blockquote>
<p>The same default-on behavior landed in the Cloudflare Vite plugin at version 1.50.0, via pull request #14944. Before 4.118.0, a developer had to explicitly opt in to get traces and console logs surfaced in the Local Explorer; after it, every <code>wrangler dev</code> or Vite-plugin dev session gets this automatically, whether or not anyone asked for it.</p>
<h2 id="structural-comparison-matrix">Structural Comparison Matrix</h2>

























<table tabindex="0"><thead><tr><th scope="col" style="text-align:left">Operational Aspect</th><th scope="col" style="text-align:left">Before (v4.113.0 and earlier)</th><th scope="col" style="text-align:left">After (v4.118.0+)</th></tr></thead><tbody><tr><td style="text-align:left"><strong>Default state</strong></td><td style="text-align:left">Opt-in only</td><td style="text-align:left">Enabled automatically, no flag needed</td></tr><tr><td style="text-align:left"><strong>Background processes</strong></td><td style="text-align:left">Just the dev server</td><td style="text-align:left">Adds per-worker collector/streaming-tail services</td></tr><tr><td style="text-align:left"><strong>Opt-out mechanism</strong></td><td style="text-align:left">N/A</td><td style="text-align:left"><code>X_LOCAL_OBSERVABILITY=false</code></td></tr></tbody></table>
<h2 id="fix-it-opt-out-if-it-conflicts-with-your-setup">Fix it: opt out if it conflicts with your setup</h2>
<div class="callout" role="note" data-astro-cid-q2ml7llr><p class="callout__title" data-astro-cid-q2ml7llr><span aria-hidden="true" data-astro-cid-q2ml7llr>✓</span>Most single-worker setups don&#39;t need to do anything</p><div class="callout__body" data-astro-cid-q2ml7llr><p>If you run one Worker locally at a time, the added background services are
unlikely to be noticeable. The opt-out matters most for multi-process
dev-registry setups running several local workers simultaneously, where the
extra per-worker services can compete for resources or interfere with existing
tooling that already captures logs another way.</p></div></div>
<div class="code-block code-block--has-filename"><div class="code-block__header"><span class="code-block__filename">opt out of local observability capture</span><button type="button" class="code-block__copy" data-copy-button>Copy</button></div><pre class="astro-code github-dark" style="background-color:#24292e;color:#e1e4e8;overflow-x:auto" tabindex="0" data-language="bash" data-filename="opt out of local observability capture"><code><span class="line"><span style="color:#E1E4E8">X_LOCAL_OBSERVABILITY</span><span style="color:#F97583">=</span><span style="color:#9ECBFF">false</span><span style="color:#B392F0"> wrangler</span><span style="color:#9ECBFF"> dev</span></span></code></pre></div>
<p>For a persistent opt-out across every local session, set it in your shell profile or the project’s <code>.env</code>/<code>.dev.vars</code> file rather than prefixing every command by hand:</p>
<div class="code-block code-block--has-filename"><div class="code-block__header"><span class="code-block__filename">.dev.vars</span><button type="button" class="code-block__copy" data-copy-button>Copy</button></div><pre class="astro-code github-dark" style="background-color:#24292e;color:#e1e4e8;overflow-x:auto" tabindex="0" data-language="bash" data-filename=".dev.vars"><code><span class="line"><span style="color:#E1E4E8">X_LOCAL_OBSERVABILITY</span><span style="color:#F97583">=</span><span style="color:#9ECBFF">false</span></span></code></pre></div>
<p>If you want the feature but it isn’t showing traces, confirm the installed version first. This is version-gated, not a config toggle that exists on every Wrangler release:</p>
<div class="code-block code-block--has-filename"><div class="code-block__header"><span class="code-block__filename">confirm you&#39;re on a version that has this</span><button type="button" class="code-block__copy" data-copy-button>Copy</button></div><pre class="astro-code github-dark" style="background-color:#24292e;color:#e1e4e8;overflow-x:auto" tabindex="0" data-language="bash" data-filename="confirm you're on a version that has this"><code><span class="line"><span style="color:#B392F0">wrangler</span><span style="color:#79B8FF"> --version</span></span>
<span class="line"><span style="color:#9ca6b0"># needs wrangler 4.118.0+ or Cloudflare Vite plugin 1.50.0+</span></span></code></pre></div>
<h2 id="confirmed-version">Confirmed version</h2>
<p>Sourced from <code>cloudflare/workers-sdk</code>’s <code>packages/wrangler/CHANGELOG.md</code>: the capability landed in <code>wrangler@4.114.0</code> (2026-07-23), and the default-on switch in <code>wrangler@4.118.0</code> (2026-07-31), corroborated by the Vite plugin’s own 1.50.0 changelog entry for pull request #14944. Browse more posts like this in the <a href="/dev-tools">Dev Tools</a> archive.</p>]]></content:encoded>
      <pubDate>Thu, 06 Aug 2026 00:00:00 GMT</pubDate>
    </item>
    <item>
      <title>Wrangler Login Now Supports OAuth Device Grant</title>
      <link>https://bytetech247.com/dev-tools/wrangler-login-oauth-device-grant/</link>
      <guid isPermaLink="true">https://bytetech247.com/dev-tools/wrangler-login-oauth-device-grant/</guid>
      <description>wrangler login --device (RFC 8628) skips the localhost callback for headless terminals. Confirmed live against wrangler 4.119.0, from this remote sandbox.</description>
      <content:encoded><![CDATA[<aside class="quick-answer" aria-label="Quick answer"><h2 id="quick-answer">Quick Answer</h2>
<p><code>wrangler@4.119.0</code> adds OAuth 2.0 Device Authorization Grant support to <code>wrangler login</code>, via a new <code>--device</code> flag. Instead of opening a local browser for the OAuth callback, it prints a code and a URL you visit from any device to approve the login. This fixes authentication in headless environments, containers, remote SSH sessions, or a sandboxed terminal like the one this post was written in, where there’s no local browser for the default flow to open at all.</p>
</aside><h2 id="the-exact-flag-confirmed-live">The exact flag, confirmed live</h2>
<p><code>wrangler login</code>’s default flow opens <code>localhost:8976</code> and waits for an OAuth callback through a local browser. That fails outright in any environment with no browser to open, which is precisely the situation in a remote, terminal-only sandbox. Run directly against the installed CLI here:</p>
<div class="code-block code-block--has-filename"><div class="code-block__header"><span class="code-block__filename">wrangler login --help, run in this exact sandboxed environment</span><button type="button" class="code-block__copy" data-copy-button>Copy</button></div><pre class="astro-code github-dark" style="background-color:#24292e;color:#e1e4e8;overflow-x:auto" tabindex="0" data-language="bash" data-filename="wrangler login --help, run in this exact sandboxed environment"><code><span class="line"><span style="color:#B392F0">$</span><span style="color:#9ECBFF"> wrangler</span><span style="color:#9ECBFF"> login</span><span style="color:#79B8FF"> --help</span></span>
<span class="line"></span>
<span class="line"><span style="color:#B392F0">OPTIONS</span></span>
<span class="line"><span style="color:#B392F0">      --browser</span><span style="color:#9ECBFF">        Automatically</span><span style="color:#9ECBFF"> open</span><span style="color:#9ECBFF"> the</span><span style="color:#9ECBFF"> OAuth</span><span style="color:#9ECBFF"> link</span><span style="color:#9ECBFF"> in</span><span style="color:#9ECBFF"> a</span><span style="color:#9ECBFF"> browser</span><span style="color:#E1E4E8">  [boolean] [default: </span><span style="color:#79B8FF">true</span><span style="color:#E1E4E8">]</span></span>
<span class="line"><span style="color:#B392F0">      --scopes</span><span style="color:#9ECBFF">         Pick</span><span style="color:#9ECBFF"> the</span><span style="color:#9ECBFF"> set</span><span style="color:#9ECBFF"> of</span><span style="color:#9ECBFF"> applicable</span><span style="color:#9ECBFF"> OAuth</span><span style="color:#9ECBFF"> scopes</span><span style="color:#9ECBFF"> when</span><span style="color:#9ECBFF"> logging</span><span style="color:#9ECBFF"> in</span><span style="color:#E1E4E8">  [array]</span></span>
<span class="line"><span style="color:#B392F0">      --callback-host</span><span style="color:#9ECBFF">  Use</span><span style="color:#9ECBFF"> the</span><span style="color:#9ECBFF"> ip</span><span style="color:#9ECBFF"> or</span><span style="color:#9ECBFF"> host</span><span style="color:#9ECBFF"> address</span><span style="color:#9ECBFF"> for</span><span style="color:#9ECBFF"> the</span><span style="color:#9ECBFF"> temporary</span><span style="color:#9ECBFF"> login</span><span style="color:#9ECBFF"> callback</span><span style="color:#9ECBFF"> server.</span><span style="color:#E1E4E8">  [string] [default: </span><span style="color:#9ECBFF">&quot;localhost&quot;</span><span style="color:#E1E4E8">]</span></span>
<span class="line"><span style="color:#B392F0">      --callback-port</span><span style="color:#9ECBFF">  Use</span><span style="color:#9ECBFF"> the</span><span style="color:#9ECBFF"> port</span><span style="color:#9ECBFF"> for</span><span style="color:#9ECBFF"> the</span><span style="color:#9ECBFF"> temporary</span><span style="color:#9ECBFF"> login</span><span style="color:#9ECBFF"> callback</span><span style="color:#9ECBFF"> server.</span><span style="color:#E1E4E8">  [number] [default: 8976]</span></span>
<span class="line"><span style="color:#B392F0">      --scopes-list</span><span style="color:#9ECBFF">    List</span><span style="color:#9ECBFF"> all</span><span style="color:#9ECBFF"> the</span><span style="color:#9ECBFF"> available</span><span style="color:#9ECBFF"> OAuth</span><span style="color:#9ECBFF"> scopes</span><span style="color:#9ECBFF"> with</span><span style="color:#9ECBFF"> descriptions</span></span>
<span class="line"><span style="color:#B392F0">      --use-keyring</span><span style="color:#9ECBFF">    Store</span><span style="color:#9ECBFF"> OAuth</span><span style="color:#9ECBFF"> credentials</span><span style="color:#9ECBFF"> in</span><span style="color:#9ECBFF"> the</span><span style="color:#9ECBFF"> OS</span><span style="color:#9ECBFF"> keychain</span><span style="color:#9ECBFF"> instead</span><span style="color:#9ECBFF"> of</span><span style="color:#9ECBFF"> a</span><span style="color:#9ECBFF"> plaintext</span><span style="color:#9ECBFF"> file</span><span style="color:#E1E4E8"> (persisted </span><span style="color:#9ECBFF">across</span><span style="color:#9ECBFF"> invocations</span><span style="color:#E1E4E8">)  [boolean]</span></span>
<span class="line"><span style="color:#B392F0">      --device</span><span style="color:#9ECBFF">         Use</span><span style="color:#9ECBFF"> the</span><span style="color:#9ECBFF"> OAuth</span><span style="color:#79B8FF"> 2.0</span><span style="color:#9ECBFF"> Device</span><span style="color:#9ECBFF"> Authorization</span><span style="color:#9ECBFF"> Grant</span><span style="color:#E1E4E8"> (RFC </span><span style="color:#79B8FF">8628</span><span style="color:#E1E4E8">) instead of the localhost callback flow. Useful in containers, remote SSH sessions, or other environments where localhost:8976 is unreachable from your browser.  [boolean] [default: </span><span style="color:#79B8FF">false</span><span style="color:#E1E4E8">]</span></span></code></pre></div>
<p>The <code>--device</code> flag’s own description names the exact use case: “containers, remote SSH sessions, or other environments where <code>localhost:8976</code> is unreachable from your browser.” The changelog entry backing this, from <code>cloudflare/workers-sdk</code>’s <code>packages/wrangler/CHANGELOG.md</code>, <a href="https://github.com/cloudflare/workers-sdk/releases/tag/wrangler%404.119.0"><code>wrangler@4.119.0</code></a> (released 2026-08-05):</p>
<blockquote>
<p>“Add support for OAuth 2.0 Device Authorization Grant to <code>wrangler login</code>”</p>
</blockquote>
<h2 id="structural-comparison-matrix">Structural Comparison Matrix</h2>

























<table tabindex="0"><thead><tr><th scope="col" style="text-align:left">Operational Aspect</th><th scope="col" style="text-align:left">Default flow (<code>--browser</code>, unchanged)</th><th scope="col" style="text-align:left">Device flow (<code>--device</code>, new)</th></tr></thead><tbody><tr><td style="text-align:left"><strong>Requires a local browser</strong></td><td style="text-align:left">Yes</td><td style="text-align:left">No</td></tr><tr><td style="text-align:left"><strong>Login mechanism</strong></td><td style="text-align:left">Local callback server on <code>localhost:8976</code></td><td style="text-align:left">A printed code, approved from any device</td></tr><tr><td style="text-align:left"><strong>Fits headless/SSH/container sessions</strong></td><td style="text-align:left">No</td><td style="text-align:left">Yes, by design</td></tr></tbody></table>
<h2 id="fix-it-use---device-when-theres-no-local-browser">Fix it: use <code>--device</code> when there’s no local browser</h2>
<div class="code-block code-block--has-filename"><div class="code-block__header"><span class="code-block__filename">authenticate from a headless or remote terminal</span><button type="button" class="code-block__copy" data-copy-button>Copy</button></div><pre class="astro-code github-dark" style="background-color:#24292e;color:#e1e4e8;overflow-x:auto" tabindex="0" data-language="bash" data-filename="authenticate from a headless or remote terminal"><code><span class="line"><span style="color:#B392F0">wrangler</span><span style="color:#9ECBFF"> login</span><span style="color:#79B8FF"> --device</span></span></code></pre></div>
<p>Wrangler prints a short code and a URL. Open that URL on any device with a browser, not necessarily the machine running the command, enter the code, and approve. The CLI polls in the background and completes the login once approval goes through, the same end state as the browser flow, just without needing a browser anywhere near the terminal that ran the command.</p>
<div class="callout" role="note" data-astro-cid-q2ml7llr><p class="callout__title" data-astro-cid-q2ml7llr><span aria-hidden="true" data-astro-cid-q2ml7llr>ℹ</span>Both flags default to off for a reason</p><div class="callout__body" data-astro-cid-q2ml7llr><p><code>--browser</code> defaults to <code>true</code> and <code>--device</code> defaults to <code>false</code>, so nothing
changes for a normal interactive login unless you explicitly ask for the
device flow. This is purely additive: existing scripts and habits keep working
exactly as they did before 4.119.0.</p></div></div>
<h2 id="confirmed-version">Confirmed version</h2>
<p>Reproduced directly: <code>wrangler login --help</code> on <code>wrangler@4.119.0</code>, run from inside a remote, browser-less terminal session, exactly the kind of environment this flag exists for. Tracing back to <code>wrangler@4.119.0</code>, released 2026-08-05. Browse more posts like this in the <a href="/dev-tools">Dev Tools</a> archive.</p>]]></content:encoded>
      <pubDate>Thu, 06 Aug 2026 00:00:00 GMT</pubDate>
    </item>
    <item>
      <title>New r2_buckets Local Dev Credentials in Wrangler</title>
      <link>https://bytetech247.com/dev-tools/wrangler-r2-buckets-local-dev-credentials/</link>
      <guid isPermaLink="true">https://bytetech247.com/dev-tools/wrangler-r2-buckets-local-dev-credentials/</guid>
      <description>Wrangler 4.115 adds local_dev.experimental_s3_credentials to r2_buckets bindings, scoping real S3-compatible credentials to local dev instead of faking R2.</description>
      <content:encoded><![CDATA[<aside class="quick-answer" aria-label="Quick answer"><h2 id="quick-answer">Quick Answer</h2>
<p><code>wrangler@4.115.0</code> adds an experimental <code>local_dev.experimental_s3_credentials</code> field to <code>r2_buckets</code> bindings, letting a project supply real S3-compatible credentials scoped specifically to local development. Before this, local R2 testing either used Miniflare’s built-in simulated store or had no clean way to point at a real S3-compatible endpoint without touching production-adjacent credentials.</p>
</aside><h2 id="what-this-actually-solves">What this actually solves</h2>
<p>The changelog entry, from <code>cloudflare/workers-sdk</code>’s <code>packages/wrangler/CHANGELOG.md</code>, <a href="https://github.com/cloudflare/workers-sdk/releases/tag/wrangler%404.115.0"><code>wrangler@4.115.0</code></a> (released 2026-07-28):</p>
<blockquote>
<p>“Add experimental <code>local_dev.experimental_s3_credentials</code> to <code>r2_buckets</code> config”</p>
</blockquote>
<p>R2’s Workers API is S3-compatible, and Miniflare’s default local dev experience simulates an R2 bucket without needing any credentials at all. That default is fine for most local testing, but it isn’t a real S3-compatible endpoint, so any local test that genuinely needs S3-specific behavior, not just an R2 binding’s basic get/put/list surface, had no first-class way to supply real credentials scoped just to that local session.</p>
<div class="callout" role="note" data-astro-cid-q2ml7llr><p class="callout__title" data-astro-cid-q2ml7llr><span aria-hidden="true" data-astro-cid-q2ml7llr>⚠</span>The field name itself has already changed once</p><div class="callout__body" data-astro-cid-q2ml7llr><p>This configuration key was originally shipped as a flat
<code>experimental_local_s3_credentials</code> field, then moved to the current nested
<code>local_dev.experimental_s3_credentials</code> shape (tracked across
<code>cloudflare/workers-sdk</code> pull requests #14119 and #14280). It’s marked
experimental for a reason: confirm the current field name and its exact
sub-fields against <code>wrangler r2 bucket --help</code> or the current Cloudflare R2
docs before copying a config block from anywhere, including this post.</p></div></div>
<h2 id="structural-comparison-matrix">Structural Comparison Matrix</h2>

























<table tabindex="0"><thead><tr><th scope="col" style="text-align:left">Operational Aspect</th><th scope="col" style="text-align:left">Before (v4.114.0 and earlier)</th><th scope="col" style="text-align:left">After (v4.115.0+)</th></tr></thead><tbody><tr><td style="text-align:left"><strong>Default local R2</strong></td><td style="text-align:left">Miniflare’s simulated store, no credentials needed</td><td style="text-align:left">Unchanged, still the default</td></tr><tr><td style="text-align:left"><strong>Real S3-compatible endpoint locally</strong></td><td style="text-align:left">No first-class config field</td><td style="text-align:left"><code>local_dev.experimental_s3_credentials</code> on the binding</td></tr><tr><td style="text-align:left"><strong>Credential scope</strong></td><td style="text-align:left">N/A</td><td style="text-align:left">Scoped to local dev, separate from production credentials</td></tr></tbody></table>
<h2 id="fix-it-add-the-field-to-your-local-dev-config">Fix it: add the field to your local dev config</h2>
<div class="code-block code-block--has-filename"><div class="code-block__header"><span class="code-block__filename">wrangler.jsonc (shape as of wrangler@4.115.0+; verify exact sub-fields yourself)</span><button type="button" class="code-block__copy" data-copy-button>Copy</button></div><pre class="astro-code github-dark" style="background-color:#24292e;color:#e1e4e8;overflow-x:auto" tabindex="0" data-language="jsonc" data-filename="wrangler.jsonc (shape as of wrangler@4.115.0+; verify exact sub-fields yourself)"><code><span class="line"><span style="color:#E1E4E8">{</span></span>
<span class="line"><span style="color:#79B8FF">  &quot;r2_buckets&quot;</span><span style="color:#E1E4E8">: [</span></span>
<span class="line"><span style="color:#E1E4E8">    {</span></span>
<span class="line"><span style="color:#79B8FF">      &quot;binding&quot;</span><span style="color:#E1E4E8">: </span><span style="color:#9ECBFF">&quot;MY_BUCKET&quot;</span><span style="color:#E1E4E8">,</span></span>
<span class="line"><span style="color:#79B8FF">      &quot;bucket_name&quot;</span><span style="color:#E1E4E8">: </span><span style="color:#9ECBFF">&quot;my-bucket-name&quot;</span><span style="color:#E1E4E8">,</span></span>
<span class="line"><span style="color:#79B8FF">      &quot;local_dev&quot;</span><span style="color:#E1E4E8">: {</span></span>
<span class="line"><span style="color:#79B8FF">        &quot;experimental_s3_credentials&quot;</span><span style="color:#E1E4E8">: {</span></span>
<span class="line"><span style="color:#9ca6b0">          // exact sub-field names are experimental and have already</span></span>
<span class="line"><span style="color:#9ca6b0">          // changed once; confirm against `wrangler r2 bucket --help`</span></span>
<span class="line"><span style="color:#9ca6b0">          // before filling this in</span></span>
<span class="line"><span style="color:#E1E4E8">        },</span></span>
<span class="line"><span style="color:#E1E4E8">      },</span></span>
<span class="line"><span style="color:#E1E4E8">    },</span></span>
<span class="line"><span style="color:#E1E4E8">  ],</span></span>
<span class="line"><span style="color:#E1E4E8">}</span></span></code></pre></div>
<p>If you don’t need to test against a real S3-compatible endpoint locally, there’s nothing to change. Miniflare’s default simulated R2 store keeps working exactly as it did before this field existed.</p>
<h2 id="confirmed-version">Confirmed version</h2>
<p>Sourced from <code>cloudflare/workers-sdk</code>’s <code>packages/wrangler/CHANGELOG.md</code>, <code>wrangler@4.115.0</code>, released 2026-07-28, corroborated by the field-rename history tracked across pull requests #14119 and #14280. The exact sub-field shape wasn’t independently reproduced here, since it’s explicitly experimental and this site doesn’t use R2 bindings; verify current syntax before adopting it. Browse more posts like this in the <a href="/dev-tools">Dev Tools</a> archive.</p>]]></content:encoded>
      <pubDate>Thu, 06 Aug 2026 00:00:00 GMT</pubDate>
    </item>
    <item>
      <title>Wrangler Skips Naming Prompts in Agent/CI Deploys</title>
      <link>https://bytetech247.com/dev-tools/wrangler-skips-naming-prompts-ci-deploys/</link>
      <guid isPermaLink="true">https://bytetech247.com/dev-tools/wrangler-skips-naming-prompts-ci-deploys/</guid>
      <description>Wrangler 4.116 detects non-interactive terminals and skips the first-deploy naming prompt automatically, fixing CI pipelines and AI agents driving the CLI.</description>
      <content:encoded><![CDATA[<aside class="quick-answer" aria-label="Quick answer"><h2 id="quick-answer">Quick Answer</h2>
<p>Before <code>wrangler@4.116.0</code>, the first <code>wrangler deploy</code> for a new Worker could block on an interactive naming prompt, a real problem for any non-interactive CI pipeline or AI agent driving the CLI directly, since there’s no human there to answer it. <code>wrangler@4.116.0</code> detects that non-interactive context and skips the prompt automatically. If your CI deploy used to hang or fail on a first-time deploy, this is why it stopped.</p>
</aside><h2 id="the-actual-problem-this-closes">The actual problem this closes</h2>
<p>Deploying a Worker for the first time historically asked the developer to confirm its name and whether to register a <code>workers.dev</code> subdomain, an interactive question with no default a script can safely assume. In a normal terminal, a person answers it once and moves on. In a CI pipeline like a GitHub Actions workflow, or a terminal session driven by an AI coding agent typing commands directly, there’s no one to answer, so the process either hung waiting for input or failed outright depending on how the runner handled stdin.</p>
<p>The changelog entry, from <code>cloudflare/workers-sdk</code>’s <code>packages/wrangler/CHANGELOG.md</code>, <a href="https://github.com/cloudflare/workers-sdk/releases/tag/wrangler%404.116.0"><code>wrangler@4.116.0</code></a> (released 2026-07-30):</p>
<blockquote>
<p>“Avoid Worker and workers.dev naming prompts in agent-driven deploys”</p>
</blockquote>
<h2 id="structural-comparison-matrix">Structural Comparison Matrix</h2>

























<table tabindex="0"><thead><tr><th scope="col" style="text-align:left">Operational Aspect</th><th scope="col" style="text-align:left">Before (v4.115.0 and earlier)</th><th scope="col" style="text-align:left">After (v4.116.0+)</th></tr></thead><tbody><tr><td style="text-align:left"><strong>Interactive terminal, first deploy</strong></td><td style="text-align:left">Prompts for name/subdomain confirmation</td><td style="text-align:left">Same behavior, unchanged</td></tr><tr><td style="text-align:left"><strong>CI pipeline, first deploy</strong></td><td style="text-align:left">Could hang or fail waiting for input</td><td style="text-align:left">Prompt skipped automatically</td></tr><tr><td style="text-align:left"><strong>AI agent driving the CLI directly</strong></td><td style="text-align:left">Same risk as CI: no one to answer</td><td style="text-align:left">Prompt skipped automatically</td></tr></tbody></table>
<h2 id="why-this-matters-for-this-sites-own-pipeline">Why this matters for this site’s own pipeline</h2>
<p>This site deploys through a GitHub Actions workflow, not Cloudflare’s built-in Git integration (the tradeoffs between the two are covered in <a href="/data-automation/automate-static-site-deploys-github-actions-cloudflare-workers">Cloudflare Workers Deploys: Built-In Git vs. GitHub Actions</a>). A first-time deploy of a brand-new Worker through that exact kind of pipeline is precisely the scenario this change targets: a non-interactive runner with no terminal attached for a human to answer a naming prompt.</p>
<div class="callout" role="note" data-astro-cid-q2ml7llr><p class="callout__title" data-astro-cid-q2ml7llr><span aria-hidden="true" data-astro-cid-q2ml7llr>✓</span>Existing Workers are unaffected</p><div class="callout__body" data-astro-cid-q2ml7llr><p>This only matters on a Worker’s first deploy, the one time the naming prompt
would have appeared at all. A CI pipeline redeploying an existing Worker was
never blocked by this in the first place, since the prompt only fires when
Wrangler doesn’t yet know the Worker’s name is already registered.</p></div></div>
<h2 id="confirmed-version">Confirmed version</h2>
<p>Sourced from <code>cloudflare/workers-sdk</code>’s <code>packages/wrangler/CHANGELOG.md</code>, <code>wrangler@4.116.0</code>, released 2026-07-30. The exact non-interactive detection heuristic wasn’t independently reproduced here since it requires a genuinely fresh Worker name and a non-TTY environment to trigger the old behavior for comparison; the changelog entry is the primary source. Browse more posts like this in the <a href="/dev-tools">Dev Tools</a> archive.</p>]]></content:encoded>
      <pubDate>Thu, 06 Aug 2026 00:00:00 GMT</pubDate>
    </item>
    <item>
      <title>Fix Wrangler wrangler.toml vs jsonc Precedence</title>
      <link>https://bytetech247.com/dev-tools/wrangler-toml-vs-jsonc-precedence/</link>
      <guid isPermaLink="true">https://bytetech247.com/dev-tools/wrangler-toml-vs-jsonc-precedence/</guid>
      <description>When both wrangler.toml and wrangler.jsonc exist, Wrangler silently uses jsonc with no warning. Confirmed by test, plus the fix for teams mid-migration.</description>
      <content:encoded><![CDATA[<aside class="quick-answer" aria-label="Quick answer"><h2 id="quick-answer">Quick Answer</h2>
<p>If a project has both <code>wrangler.toml</code> and <code>wrangler.jsonc</code> (or <code>wrangler.json</code>), Wrangler uses the JSON/JSONC file and silently ignores the TOML one, with no warning printed either way. Confirmed directly: giving the two files conflicting settings and running <code>wrangler deploy --dry-run</code> only ever reflects the JSONC file’s values. If you’re migrating gradually, delete the old file the moment the new one is ready. Leaving both around invites a config drift nobody notices until deploy time.</p>
</aside><h2 id="confirmed-jsonc-wins-and-wrangler-says-nothing">Confirmed: jsonc wins, and Wrangler says nothing</h2>
<p>To find the actual precedence rule instead of guessing from the docs, this was tested directly against <code>wrangler@4.119.0</code> in an isolated project: a valid <code>wrangler.toml</code> alongside a <code>wrangler.jsonc</code> whose <code>main</code> field pointed at a file that doesn’t exist.</p>
<div class="code-block code-block--has-filename"><div class="code-block__header"><span class="code-block__filename">wrangler.toml (valid)</span><button type="button" class="code-block__copy" data-copy-button>Copy</button></div><pre class="astro-code github-dark" style="background-color:#24292e;color:#e1e4e8;overflow-x:auto" tabindex="0" data-language="toml" data-filename="wrangler.toml (valid)"><code><span class="line"><span style="color:#E1E4E8">name = </span><span style="color:#9ECBFF">&quot;test-worker&quot;</span></span>
<span class="line"><span style="color:#E1E4E8">main = </span><span style="color:#9ECBFF">&quot;index.js&quot;</span></span>
<span class="line"><span style="color:#E1E4E8">compatibility_date = </span><span style="color:#9ECBFF">&quot;2026-08-01&quot;</span></span></code></pre></div>
<div class="code-block code-block--has-filename"><div class="code-block__header"><span class="code-block__filename">wrangler.jsonc (deliberately broken main)</span><button type="button" class="code-block__copy" data-copy-button>Copy</button></div><pre class="astro-code github-dark" style="background-color:#24292e;color:#e1e4e8;overflow-x:auto" tabindex="0" data-language="jsonc" data-filename="wrangler.jsonc (deliberately broken main)"><code><span class="line"><span style="color:#E1E4E8">{</span></span>
<span class="line"><span style="color:#79B8FF">  &quot;name&quot;</span><span style="color:#E1E4E8">: </span><span style="color:#9ECBFF">&quot;test-worker-jsonc&quot;</span><span style="color:#E1E4E8">,</span></span>
<span class="line"><span style="color:#79B8FF">  &quot;main&quot;</span><span style="color:#E1E4E8">: </span><span style="color:#9ECBFF">&quot;does-not-exist.js&quot;</span><span style="color:#E1E4E8">,</span></span>
<span class="line"><span style="color:#79B8FF">  &quot;compatibility_date&quot;</span><span style="color:#E1E4E8">: </span><span style="color:#9ECBFF">&quot;2026-08-01&quot;</span><span style="color:#E1E4E8">,</span></span>
<span class="line"><span style="color:#E1E4E8">}</span></span></code></pre></div>
<div class="code-block code-block--has-filename"><div class="code-block__header"><span class="code-block__filename">wrangler deploy --dry-run</span><button type="button" class="code-block__copy" data-copy-button>Copy</button></div><pre class="astro-code github-dark" style="background-color:#24292e;color:#e1e4e8;overflow-x:auto" tabindex="0" data-language="bash" data-filename="wrangler deploy --dry-run"><code><span class="line"><span style="color:#B392F0">✘</span><span style="color:#E1E4E8"> [ERROR] The entry-point file at </span><span style="color:#9ECBFF">&quot;does-not-exist.js&quot;</span><span style="color:#E1E4E8"> was not found.</span></span>
<span class="line"></span>
<span class="line"><span style="color:#B392F0">  This</span><span style="color:#9ECBFF"> might</span><span style="color:#9ECBFF"> mean</span><span style="color:#9ECBFF"> that</span><span style="color:#9ECBFF"> your</span><span style="color:#9ECBFF"> entry-point</span><span style="color:#9ECBFF"> file</span><span style="color:#9ECBFF"> needs</span><span style="color:#9ECBFF"> to</span><span style="color:#9ECBFF"> be</span><span style="color:#9ECBFF"> generated</span><span style="color:#E1E4E8"> (which </span><span style="color:#9ECBFF">is</span></span>
<span class="line"><span style="color:#B392F0">  the</span><span style="color:#9ECBFF"> general</span><span style="color:#9ECBFF"> case</span><span style="color:#9ECBFF"> when</span><span style="color:#9ECBFF"> a</span><span style="color:#9ECBFF"> framework</span><span style="color:#9ECBFF"> is</span><span style="color:#9ECBFF"> being</span><span style="color:#9ECBFF"> used</span><span style="color:#E1E4E8">). If that</span><span style="color:#9ECBFF">&#39;s the case</span></span>
<span class="line"><span style="color:#9ECBFF">  please run your project&#39;</span><span style="color:#E1E4E8">s build command and try again.</span></span></code></pre></div>
<p>The error came from the <code>jsonc</code> file’s broken <code>main</code> path, not the <code>toml</code> file’s valid one. That confirms the precedence: <code>wrangler.jsonc</code> wins whenever both files exist, and Wrangler never mentions the <code>toml</code> file is being ignored. A team that starts migrating by copying settings into a new <code>wrangler.jsonc</code>, then keeps editing the old <code>wrangler.toml</code> out of habit, gets no signal at all that their edits stopped mattering the moment the new file appeared.</p>
<h2 id="why-this-keeps-happening">Why this keeps happening</h2>
<p>Cloudflare’s own docs recommend <code>wrangler.jsonc</code> for new projects and note that some newer Wrangler features are JSON-config-only, but there’s no enforced migration path. <a href="https://github.com/cloudflare/workers-sdk/issues/14501"><code>cloudflare/workers-sdk</code> issue #14501</a>, opened 2026-07-01, asks for exactly the tooling that’s missing:</p>
<blockquote>
<p>A proposal for a <code>wrangler config migrate</code> subcommand that would rewrite <code>wrangler.toml</code> to <code>wrangler.jsonc</code> in place while preserving comments where jsonc allows.</p>
</blockquote>
<p>Until that ships, there’s no built-in safety net for the conversion. This site’s own deploy config is still on <code>wrangler.toml</code>, which makes the migration path a real, not hypothetical, question here too.</p>
<div class="callout" role="note" data-astro-cid-q2ml7llr><p class="callout__title" data-astro-cid-q2ml7llr><span aria-hidden="true" data-astro-cid-q2ml7llr>⚠</span>Check for both files before debugging anything else</p><div class="callout__body" data-astro-cid-q2ml7llr><p>If a Wrangler command is behaving differently than the <code>wrangler.toml</code> in
front of you suggests it should, check for a <code>wrangler.jsonc</code> or
<code>wrangler.json</code> sitting in the same directory first. It is the config actually
being read, and grepping the toml file for the setting in question will not
explain the behavior.</p></div></div>
<h2 id="structural-comparison-matrix">Structural Comparison Matrix</h2>

























<table tabindex="0"><thead><tr><th scope="col" style="text-align:left">Operational Aspect</th><th scope="col" style="text-align:left">Only <code>wrangler.toml</code> present</th><th scope="col" style="text-align:left">Both files present</th></tr></thead><tbody><tr><td style="text-align:left"><strong>File Wrangler reads</strong></td><td style="text-align:left"><code>wrangler.toml</code></td><td style="text-align:left"><code>wrangler.jsonc</code>, silently</td></tr><tr><td style="text-align:left"><strong>Warning printed about the other file</strong></td><td style="text-align:left">N/A</td><td style="text-align:left">None, confirmed by direct test</td></tr><tr><td style="text-align:left"><strong>Official conversion tooling</strong></td><td style="text-align:left">N/A</td><td style="text-align:left">Not yet shipped; issue #14501 is still open</td></tr></tbody></table>
<h2 id="fix-it-migrate-deliberately-then-delete-the-old-file">Fix it: migrate deliberately, then delete the old file</h2>
<h3 id="manual-conversion-no-official-tool-yet">Manual conversion (no official tool yet)</h3>
<div class="code-block code-block--has-filename"><div class="code-block__header"><span class="code-block__filename">convert by hand</span><button type="button" class="code-block__copy" data-copy-button>Copy</button></div><pre class="astro-code github-dark" style="background-color:#24292e;color:#e1e4e8;overflow-x:auto" tabindex="0" data-language="bash" data-filename="convert by hand"><code><span class="line"><span style="color:#9ca6b0"># 1. Read the existing wrangler.toml values</span></span>
<span class="line"><span style="color:#B392F0">cat</span><span style="color:#9ECBFF"> wrangler.toml</span></span>
<span class="line"></span>
<span class="line"><span style="color:#9ca6b0"># 2. Write the equivalent wrangler.jsonc by hand,</span></span>
<span class="line"><span style="color:#9ca6b0">#    matching every field (bindings, routes, vars, compatibility_date)</span></span>
<span class="line"><span style="color:#E1E4E8">$EDITOR wrangler.jsonc</span></span>
<span class="line"></span>
<span class="line"><span style="color:#9ca6b0"># 3. Confirm the new file alone produces the expected deploy</span></span>
<span class="line"><span style="color:#B392F0">wrangler</span><span style="color:#9ECBFF"> deploy</span><span style="color:#79B8FF"> --dry-run</span></span>
<span class="line"></span>
<span class="line"><span style="color:#9ca6b0"># 4. Only once step 3 looks right, remove the old file</span></span>
<span class="line"><span style="color:#B392F0">rm</span><span style="color:#9ECBFF"> wrangler.toml</span></span></code></pre></div>
<p>Don’t keep both files around “just in case.” Since Wrangler reads the JSONC file exclusively once it exists, the TOML file becomes dead weight the instant the new one is created, not a fallback. Anyone editing it later is editing a file nothing reads.</p>
<h2 id="confirmed-version">Confirmed version</h2>
<p>Reproduced directly against <code>wrangler@4.119.0</code>. The precedence and silent-ignore behavior aren’t new in that specific release; they reflect how Wrangler has resolved config files for some time. What’s newer and dated is the open request for tooling to close the gap: issue #14501, 2026-07-01. Browse more posts like this in the <a href="/dev-tools">Dev Tools</a> archive.</p>]]></content:encoded>
      <pubDate>Thu, 06 Aug 2026 00:00:00 GMT</pubDate>
    </item>
    <item>
      <title>VPC Bindings Now Connect in Wrangler Local Dev</title>
      <link>https://bytetech247.com/dev-tools/wrangler-vpc-bindings-local-dev-connect/</link>
      <guid isPermaLink="true">https://bytetech247.com/dev-tools/wrangler-vpc-bindings-local-dev-connect/</guid>
      <description>Wrangler 4.115 lets connect() work on remote VPC Network and VPC Service bindings during local dev, closing a real gap that used to force stubs.</description>
      <content:encoded><![CDATA[<aside class="quick-answer" aria-label="Quick answer"><h2 id="quick-answer">Quick Answer</h2>
<p><code>wrangler@4.115.0</code> adds support for calling <code>.connect()</code> on remote VPC Network and VPC Service bindings during local development. Before this, any code path calling <code>.connect()</code> on one of these bindings had to be stubbed out or skipped entirely until a real deploy, since local dev had no way to exercise it. If your project has a workaround for that gap, this is the release where it stops being necessary.</p>
</aside><h2 id="what-was-actually-broken-before">What was actually broken before</h2>
<p>VPC Network and VPC Service bindings let a Worker reach resources inside a private network Cloudflare’s platform connects to. Before <code>wrangler@4.115.0</code>, <code>wrangler dev</code> had no path to actually open that connection locally, so any test or code path exercising <code>.connect()</code> on one of these bindings either had to be skipped in local dev, mocked out entirely, or only ever verified after a real deploy, which defeats a large part of the point of local development in the first place.</p>
<p>The changelog entry, from <code>cloudflare/workers-sdk</code>’s <code>packages/wrangler/CHANGELOG.md</code>, <a href="https://github.com/cloudflare/workers-sdk/releases/tag/wrangler%404.115.0"><code>wrangler@4.115.0</code></a> (released 2026-07-28):</p>
<blockquote>
<p>“Support <code>connect()</code> on remote VPC Network and VPC Service bindings in local development”</p>
</blockquote>
<p>The word “remote” is doing real work in that sentence: this isn’t a fully offline local simulation the way Miniflare’s default R2 or KV stores are. The local dev session actually reaches the real, deployed VPC resource, which is a meaningfully different testing story than a purely local mock.</p>
<h2 id="structural-comparison-matrix">Structural Comparison Matrix</h2>

























<table tabindex="0"><thead><tr><th scope="col" style="text-align:left">Operational Aspect</th><th scope="col" style="text-align:left">Before (v4.114.0 and earlier)</th><th scope="col" style="text-align:left">After (v4.115.0+)</th></tr></thead><tbody><tr><td style="text-align:left"><strong><code>.connect()</code> on VPC bindings locally</strong></td><td style="text-align:left">Not supported; had to stub or skip</td><td style="text-align:left">Works, reaching the real remote resource</td></tr><tr><td style="text-align:left"><strong>Where verification happened</strong></td><td style="text-align:left">Only after a real deploy</td><td style="text-align:left">Available during local <code>wrangler dev</code></td></tr><tr><td style="text-align:left"><strong>Nature of the local connection</strong></td><td style="text-align:left">N/A</td><td style="text-align:left">Remote, not a fully offline simulation</td></tr></tbody></table>
<h2 id="fix-it-remove-the-old-workaround">Fix it: remove the old workaround</h2>
<div class="callout" role="note" data-astro-cid-q2ml7llr><p class="callout__title" data-astro-cid-q2ml7llr><span aria-hidden="true" data-astro-cid-q2ml7llr>✓</span>Search for the workaround before assuming it&#39;s gone</p><div class="callout__body" data-astro-cid-q2ml7llr><p>If a previous developer added a stub, a mock, or a conditional that skips VPC
binding calls specifically during local dev, it was very likely added because
of this exact gap. Grep the codebase for comments or conditionals referencing
VPC bindings and local dev, confirm the real <code>.connect()</code> call works now, and
remove the workaround rather than leaving a dead code path that silently
diverges from what local dev now actually does.</p></div></div>
<div class="code-block code-block--has-filename"><div class="code-block__header"><span class="code-block__filename">confirm you&#39;re on a version that has this</span><button type="button" class="code-block__copy" data-copy-button>Copy</button></div><pre class="astro-code github-dark" style="background-color:#24292e;color:#e1e4e8;overflow-x:auto" tabindex="0" data-language="bash" data-filename="confirm you're on a version that has this"><code><span class="line"><span style="color:#B392F0">wrangler</span><span style="color:#79B8FF"> --version</span></span>
<span class="line"><span style="color:#9ca6b0"># needs wrangler 4.115.0 or later</span></span></code></pre></div>
<h2 id="confirmed-version">Confirmed version</h2>
<p>Sourced from <code>cloudflare/workers-sdk</code>’s <code>packages/wrangler/CHANGELOG.md</code>, <code>wrangler@4.115.0</code>, released 2026-07-28. This wasn’t independently reproduced against a real VPC Network or VPC Service resource here, since this site’s own Worker doesn’t use VPC bindings; the changelog entry is the primary source. Browse more posts like this in the <a href="/dev-tools">Dev Tools</a> archive.</p>]]></content:encoded>
      <pubDate>Thu, 06 Aug 2026 00:00:00 GMT</pubDate>
    </item>
    <item>
      <title>Wrangler&apos;s July 2026 Breaking Changes: Full Guide</title>
      <link>https://bytetech247.com/dev-tools/wranglers-july-2026-breaking-changes/</link>
      <guid isPermaLink="true">https://bytetech247.com/dev-tools/wranglers-july-2026-breaking-changes/</guid>
      <description>Wrangler shipped 10 changes across v4.111-4.119 in July 2026. One real breaking change, plus nine config, local-dev, and CLI shifts. Full rundown and fixes.</description>
      <content:encoded><![CDATA[<aside class="quick-answer" aria-label="Quick answer"><h2 id="quick-answer">Quick Answer</h2>
<p>Wrangler shipped 10 distinct changes across versions 4.111.0 through 4.119.0 in July 2026: one hard breaking change (service environments removed), plus nine config-format, local-dev, and CLI shifts. If you deploy with Wrangler, check the table below against your own <code>wrangler.toml</code>/<code>jsonc</code> and CI pipeline before your next deploy.</p>
<p>Wrangler is the CLI every Cloudflare Workers developer runs on every deploy and every local dev session, including this site’s own. Between 2026-07-15 and 2026-08-05, ten releases landed changes spanning config-file precedence, local-dev tooling, new CLI capability, and CI/agent automation. None of them are cosmetic. Each one below links to a full breakdown with the exact error, the exact fix, and the exact source.</p>
</aside><h2 id="structural-comparison-matrix">Structural Comparison Matrix</h2>


















































































<table tabindex="0"><thead><tr><th scope="col" style="text-align:left">#</th><th scope="col" style="text-align:left">Change</th><th scope="col" style="text-align:left">Version</th><th scope="col" style="text-align:left">Type</th><th scope="col" style="text-align:left">Applies to you if…</th></tr></thead><tbody><tr><td style="text-align:left">1</td><td style="text-align:left">Service environments removed</td><td style="text-align:left">4.111.0</td><td style="text-align:left">Breaking</td><td style="text-align:left">Your config uses <code>legacy_env</code> or <code>[env.*]</code> blocks</td></tr><tr><td style="text-align:left">2</td><td style="text-align:left"><code>wrangler.toml</code> vs <code>jsonc</code> precedence</td><td style="text-align:left">n/a (ongoing)</td><td style="text-align:left">Config hazard</td><td style="text-align:left">You have both a <code>wrangler.toml</code> and <code>wrangler.jsonc</code> in the same project</td></tr><tr><td style="text-align:left">3</td><td style="text-align:left"><code>containerEngine</code> dropped from Miniflare worker options</td><td style="text-align:left">4.117.0</td><td style="text-align:left">Breaking (API)</td><td style="text-align:left">You call <code>unstable_getMiniflareWorkerOptions()</code> directly in a custom test harness</td></tr><tr><td style="text-align:left">4</td><td style="text-align:left"><code>cloudflare.config.ts</code> gains a <code>settings</code> export</td><td style="text-align:left">4.113.0</td><td style="text-align:left">Config shape</td><td style="text-align:left">You use the experimental <code>cloudflare.config.ts</code> format</td></tr><tr><td style="text-align:left">5</td><td style="text-align:left">Local dev observability on by default</td><td style="text-align:left">4.114.0 / 4.118.0</td><td style="text-align:left">Behavior default</td><td style="text-align:left">You run <code>wrangler dev</code> or the Cloudflare Vite plugin</td></tr><tr><td style="text-align:left">6</td><td style="text-align:left"><code>wrangler check startup</code> graduates from alpha</td><td style="text-align:left">4.116.0</td><td style="text-align:left">New capability</td><td style="text-align:left">You want real bundle-size numbers before deploying</td></tr><tr><td style="text-align:left">7</td><td style="text-align:left">Naming prompts skipped in non-interactive deploys</td><td style="text-align:left">4.116.0</td><td style="text-align:left">Fix</td><td style="text-align:left">You deploy via CI or an AI agent driving the CLI</td></tr><tr><td style="text-align:left">8</td><td style="text-align:left"><code>r2_buckets</code> local dev credentials</td><td style="text-align:left">4.115.0</td><td style="text-align:left">New config option</td><td style="text-align:left">You test R2 bindings locally</td></tr><tr><td style="text-align:left">9</td><td style="text-align:left">VPC bindings <code>.connect()</code> works in local dev</td><td style="text-align:left">4.115.0</td><td style="text-align:left">New capability</td><td style="text-align:left">You use VPC Network/Service bindings and previously had to stub <code>.connect()</code></td></tr><tr><td style="text-align:left">10</td><td style="text-align:left"><code>wrangler login</code> supports OAuth Device Grant</td><td style="text-align:left">4.119.0</td><td style="text-align:left">New capability</td><td style="text-align:left">You authenticate from a headless/SSH/remote environment</td></tr></tbody></table>
<p>Every row above is confirmed against the real <code>cloudflare/workers-sdk</code> changelog entry for its version, not summarized from memory. Full mechanism, exact error text, and the fix live in each linked post.</p>
<h2 id="the-10-changes-in-detail">The 10 changes, in detail</h2>
<ol>
<li><strong><a href="/dev-tools/wrangler-4-111-removes-service-environments/">Wrangler Removes Service Environments (v4.111.0)</a></strong>: the one hard breaking change. <code>legacy_env</code> is gone; every environment now deploys as its own independent Worker.</li>
<li><strong><a href="/dev-tools/wrangler-toml-vs-jsonc-precedence/">Fix Wrangler wrangler.toml vs jsonc Precedence</a></strong>: Wrangler accepts both formats with no enforced precedence rule and no migration command. This site’s own config hit this directly.</li>
<li><strong><a href="/dev-tools/wrangler-4-117-removes-containerengine-miniflare/">Wrangler 4.117 Drops containerEngine From Miniflare</a></strong>: a custom Vitest harness reading <code>containerEngine</code> off the old worker-options shape now silently gets <code>undefined</code>.</li>
<li><strong><a href="/dev-tools/wrangler-cloudflare-config-ts-settings-export/">Wrangler’s New cloudflare.config.ts settings Export</a></strong>: account-level settings need a dedicated <code>settings</code> export now, or they stop applying silently.</li>
<li><strong><a href="/dev-tools/wrangler-local-dev-observability-default-on/">Wrangler Now Traces Local Dev by Default (v4.118)</a></strong>: <code>wrangler dev</code> captures request traces automatically, which can conflict with multi-process dev-registry setups.</li>
<li><strong><a href="/dev-tools/wrangler-check-startup-bundle-size/">Check Worker Bundle Size With wrangler check startup</a></strong>: no longer alpha-gated, and now reports real bundle size before you deploy.</li>
<li><strong><a href="/dev-tools/wrangler-skips-naming-prompts-ci-deploys/">Wrangler Skips Naming Prompts in Agent/CI Deploys</a></strong>: fixes a real blocker for non-interactive CI pipelines and AI coding agents driving the CLI.</li>
<li><strong><a href="/dev-tools/wrangler-r2-buckets-local-dev-credentials/">New r2_buckets Local Dev Credentials in Wrangler</a></strong>: a dedicated config key to scope credentials to local R2 testing instead of faking it or sharing production-adjacent access.</li>
<li><strong><a href="/dev-tools/wrangler-vpc-bindings-local-dev-connect/">VPC Bindings Now Connect in Wrangler Local Dev</a></strong>: <code>.connect()</code> on VPC bindings finally works locally instead of requiring a stub.</li>
<li><strong><a href="/dev-tools/wrangler-login-oauth-device-grant/">Wrangler Login Now Supports OAuth Device Grant</a></strong>: authenticate from a headless terminal with no local browser required.</li>
</ol>
<h2 id="why-this-happened-in-one-month">Why this happened in one month</h2>
<p>Cloudflare ships Wrangler on a near-weekly cadence, and July 2026 concentrated an unusual amount of structural churn into a five-week window: one deliberate breaking change (service environments), plus a cluster of local-dev tooling upgrades (observability, R2 credentials, VPC bindings) that all landed within days of each other. None of these are speculative. Every version number and change description above is drawn directly from <code>cloudflare/workers-sdk</code>’s own <a href="https://github.com/cloudflare/workers-sdk/blob/main/packages/wrangler/CHANGELOG.md"><code>packages/wrangler/CHANGELOG.md</code></a>, cross-checked against the release dates in that same file.</p>
<p>Browse the rest of the <a href="/dev-tools">Dev Tools</a> archive for more Cloudflare Workers and CLI coverage.</p>]]></content:encoded>
      <pubDate>Thu, 06 Aug 2026 00:00:00 GMT</pubDate>
    </item>
    <item>
      <title>Fix Astro 7 Cannot Find Package satteri Error (pnpm)</title>
      <link>https://bytetech247.com/guides-fixes/fix-astro-7-cannot-find-package-satteri-pnpm/</link>
      <guid isPermaLink="true">https://bytetech247.com/guides-fixes/fix-astro-7-cannot-find-package-satteri-pnpm/</guid>
      <description>Fix Astro 7&apos;s Cannot find package satteri error under pnpm. Still open upstream, with a live-tested workaround using node-linker=hoisted.</description>
      <content:encoded><![CDATA[<aside class="quick-answer" aria-label="Quick answer"><h2 id="quick-answer">Quick Answer</h2>
<p>Building an Astro 7 project with pnpm can fail with: <code>Cannot find package &#39;satteri&#39; imported from .../node_modules/@astrojs/mdx/dist/satteri/index.js</code>. This bug is still open upstream as of 2026-08-05. Until it’s fixed, add <code>node-linker=hoisted</code> to your project’s <code>.npmrc</code> and reinstall.</p>
</aside><h2 id="why-the-error-happens">Why the error happens</h2>
<p><code>@astrojs/mdx</code> imports <code>satteri</code> directly in its own code, but its <code>package.json</code> never lists <code>satteri</code> as a dependency, only <code>@astrojs/markdown-satteri</code>, which depends on <code>satteri</code> itself. pnpm’s default isolated <code>node_modules</code> layout only links a package’s own declared dependencies into its private <code>node_modules</code> folder. Since <code>@astrojs/mdx</code> never declares <code>satteri</code>, pnpm has no reason to link it there.</p>
<p>Some installs succeed anyway. pnpm keeps every package in a flat content-addressable store, and Node’s module resolution can occasionally walk up to that store’s shared <code>.pnpm/node_modules</code> fallback and find <code>satteri</code> by accident. Whether that fallback link exists depends on install order and what else is in the dependency tree, not on anything the reader controls. <a href="https://github.com/withastro/astro/issues/17371">Issue #17371</a>, opened 2026-07-13 against <code>@astrojs/mdx@7.0.3</code>, is still open. The reporter’s own diagnosis states the real fix directly: <code>satteri</code> should be a declared dependency of <code>@astrojs/mdx</code> so pnpm links it into <code>@astrojs/mdx</code>’s isolated <code>node_modules</code>.</p>
<div class="callout" role="note" data-astro-cid-q2ml7llr><p class="callout__title" data-astro-cid-q2ml7llr><span aria-hidden="true" data-astro-cid-q2ml7llr>⚠</span>Still open, no merged fix yet</p><div class="callout__body" data-astro-cid-q2ml7llr><p>A related fix attempt, PR #17372, was closed without merging on 2026-07-16. As
of this writing there’s no patched version to upgrade to, only the workaround
below.</p></div></div>
<p>I confirmed the resolution failure directly. In a fresh <code>pnpm add astro @astrojs/mdx</code> install, <code>satteri</code> was present in pnpm’s store but not reachable by a plain module lookup:</p>
<div class="code-block code-block--has-filename"><div class="code-block__header"><span class="code-block__filename">reproduced live with pnpm&#39;s default isolated linker</span><button type="button" class="code-block__copy" data-copy-button>Copy</button></div><pre class="astro-code github-dark" style="background-color:#24292e;color:#e1e4e8;overflow-x:auto" tabindex="0" data-language="bash" data-filename="reproduced live with pnpm's default isolated linker"><code><span class="line"><span style="color:#B392F0">$</span><span style="color:#9ECBFF"> node</span><span style="color:#79B8FF"> -e</span><span style="color:#9ECBFF"> &quot;require.resolve(&#39;satteri&#39;)&quot;</span></span>
<span class="line"><span style="color:#B392F0">Error:</span><span style="color:#9ECBFF"> Cannot</span><span style="color:#9ECBFF"> find</span><span style="color:#9ECBFF"> module</span><span style="color:#9ECBFF"> &#39;satteri&#39;</span></span></code></pre></div>
<h2 id="the-workaround-node-linkerhoisted">The workaround: node-linker=hoisted</h2>
<p>pnpm’s <code>node-linker</code> setting controls whether it uses isolated <code>node_modules</code> (the default, and the cause of this bug) or a flatter, npm-style hoisted layout. Setting it to <code>hoisted</code> makes every installed package resolvable from anywhere in the tree, the same way npm and classic Yarn already behave, which sidesteps the missing declaration entirely.</p>
<div class="code-block code-block--has-filename"><div class="code-block__header"><span class="code-block__filename">.npmrc - before</span><button type="button" class="code-block__copy" data-copy-button>Copy</button></div><pre class="astro-code github-dark" style="background-color:#24292e;color:#e1e4e8;overflow-x:auto" tabindex="0" data-language="ini" data-filename=".npmrc - before"><code><span class="line"><span style="color:#9ca6b0"># BROKEN, pnpm&#39;s default isolated linker only links declared dependencies</span></span></code></pre></div>
<div class="code-block code-block--has-filename"><div class="code-block__header"><span class="code-block__filename">.npmrc - after</span><button type="button" class="code-block__copy" data-copy-button>Copy</button></div><pre class="astro-code github-dark" style="background-color:#24292e;color:#e1e4e8;overflow-x:auto" tabindex="0" data-language="ini" data-filename=".npmrc - after"><code><span class="line"><span style="color:#F97583">node-linker</span><span style="color:#E1E4E8">=hoisted</span></span></code></pre></div>
<div class="code-block code-block--has-filename"><div class="code-block__header"><span class="code-block__filename">reinstall after adding the setting</span><button type="button" class="code-block__copy" data-copy-button>Copy</button></div><pre class="astro-code github-dark" style="background-color:#24292e;color:#e1e4e8;overflow-x:auto" tabindex="0" data-language="bash" data-filename="reinstall after adding the setting"><code><span class="line"><span style="color:#B392F0">rm</span><span style="color:#79B8FF"> -rf</span><span style="color:#9ECBFF"> node_modules</span></span>
<span class="line"><span style="color:#B392F0">pnpm</span><span style="color:#9ECBFF"> install</span></span></code></pre></div>
<p>I tested this exact change against the same project. After adding <code>node-linker=hoisted</code> and reinstalling, the same lookup resolved cleanly:</p>
<div class="code-block code-block--has-filename"><div class="code-block__header"><span class="code-block__filename">reproduced live with node-linker=hoisted</span><button type="button" class="code-block__copy" data-copy-button>Copy</button></div><pre class="astro-code github-dark" style="background-color:#24292e;color:#e1e4e8;overflow-x:auto" tabindex="0" data-language="bash" data-filename="reproduced live with node-linker=hoisted"><code><span class="line"><span style="color:#B392F0">$</span><span style="color:#9ECBFF"> node</span><span style="color:#79B8FF"> -e</span><span style="color:#9ECBFF"> &quot;console.log(require.resolve(&#39;satteri&#39;))&quot;</span></span>
<span class="line"><span style="color:#B392F0">node_modules/satteri/dist/index.js</span></span></code></pre></div>
<div class="callout" role="note" data-astro-cid-q2ml7llr><p class="callout__title" data-astro-cid-q2ml7llr><span aria-hidden="true" data-astro-cid-q2ml7llr>ℹ</span>A workaround, not a permanent setting</p><div class="callout__body" data-astro-cid-q2ml7llr><p><code>node-linker=hoisted</code> gives up pnpm’s stricter dependency isolation for the
whole project, not just for this one package. It’s a reasonable temporary fix
while this bug is open, but switch back once <code>@astrojs/mdx</code> declares <code>satteri</code>
directly, since isolated linking is the behavior pnpm defaults to for good
reasons.</p></div></div>
<h2 id="confirmed-version-range">Confirmed version range</h2>
<p>This repro was run against <code>@astrojs/mdx@7.0.5</code> and <code>astro@7.1.6</code>, resolved fresh from npm’s registry with <code>pnpm@10.33.0</code>, still exhibiting the underlying resolution gap. This site itself runs <code>npm</code> (<code>package-lock.json</code>, not a pnpm lockfile), which resolves dependencies with a flatter layout by default, so this repo was never exposed to this bug in the first place.</p>
<p>If a fix lands upstream, watch <a href="https://github.com/withastro/astro/issues/17371">issue #17371</a> for the merged PR and the <code>@astrojs/mdx</code> version it ships in, then drop <code>node-linker=hoisted</code> from <code>.npmrc</code> once you’ve confirmed the update.</p>
<p>Browse more fixes from this same upgrade in the <a href="/guides-fixes">Guides &amp; Fixes</a> archive.</p>]]></content:encoded>
      <pubDate>Wed, 05 Aug 2026 00:00:00 GMT</pubDate>
    </item>
    <item>
      <title>Fix Astro 7&apos;s False markdown.gfm Deprecation Warning</title>
      <link>https://bytetech247.com/guides-fixes/fix-astro-7-false-markdown-gfm-deprecation-warning/</link>
      <guid isPermaLink="true">https://bytetech247.com/guides-fixes/fix-astro-7-false-markdown-gfm-deprecation-warning/</guid>
      <description>Fix Astro 7&apos;s false markdown.gfm and markdown.smartypants deprecation warning from the Container API, and the version that fixes it.</description>
      <content:encoded><![CDATA[<aside class="quick-answer" aria-label="Quick answer"><h2 id="quick-answer">Quick Answer</h2>
<p>Rendering MDX content through Astro 7’s Container API could print: <code>[astro] markdown.gfm and markdown.smartypants are deprecated. Move them onto your processor instead...</code> even in a project that never set either option. It was a false positive, fixed in <code>astro@7.0.6</code>. Upgrade past that version and the warning stops on its own.</p>
</aside><h2 id="why-the-warning-fires-with-no-matching-config">Why the warning fires with no matching config</h2>
<p>Astro validates <code>markdown.gfm</code> and <code>markdown.smartypants</code> against a project’s config to print this deprecation warning only when a reader actually set one of them. The Container API’s <code>AstroContainer.create()</code> and <code>createFromManifest()</code> methods broke that check. They passed Astro’s internal <code>ASTRO_CONFIG_DEFAULTS</code>, which already sets <code>gfm: true</code> and <code>smartypants: true</code> as baseline defaults, straight into the same validation function normal builds use.</p>
<p>The validator can’t tell a default value from a value a reader actually typed into <code>astro.config.mjs</code>. Every Container API render saw <code>gfm: true</code> and treated it as user-set, so the warning fired unconditionally. <a href="https://github.com/withastro/astro/issues/17206">Issue #17206</a>, opened 2026-06-26 against <code>astro@7.0.3</code>, reproduces this with the Container API rendering MDX content.</p>
<div class="callout" role="note" data-astro-cid-q2ml7llr><p class="callout__title" data-astro-cid-q2ml7llr><span aria-hidden="true" data-astro-cid-q2ml7llr>ℹ</span>A real bug, not a hint to change your config</p><div class="callout__body" data-astro-cid-q2ml7llr><p>Nothing about this warning pointed at an actual problem. It fired identically
whether a project set <code>gfm</code>/<code>smartypants</code> or left them untouched, which is
exactly what made it confusing to debug: there was no config change that made
it stop.</p></div></div>
<h2 id="is-this-still-a-problem-on-astro-7-today">Is this still a problem on Astro 7 today?</h2>
<p>No. <a href="https://github.com/withastro/astro/pull/17261">PR #17261</a> fixed it, merged 2026-07-01, shipped in <code>astro@7.0.6</code>. The fix strips <code>gfm</code> and <code>smartypants</code> out of the defaults object before it reaches the validator, so the Container API now validates a reader’s raw config the same way a normal build already did.</p>
<div class="code-block code-block--has-filename"><div class="code-block__header"><span class="code-block__filename">Container API render on astro@7.0.3 - 7.0.5</span><button type="button" class="code-block__copy" data-copy-button>Copy</button></div><pre class="astro-code github-dark" style="background-color:#24292e;color:#e1e4e8;overflow-x:auto" tabindex="0" data-language="bash" data-filename="Container API render on astro@7.0.3 - 7.0.5"><code><span class="line"><span style="color:#E1E4E8">[astro] </span><span style="color:#9ECBFF">`</span><span style="color:#B392F0">markdown.gfm</span><span style="color:#9ECBFF">`</span><span style="color:#B392F0"> and</span><span style="color:#9ECBFF"> `</span><span style="color:#B392F0">markdown.smartypants</span><span style="color:#9ECBFF">`</span><span style="color:#B392F0"> are</span><span style="color:#9ECBFF"> deprecated.</span><span style="color:#9ECBFF"> Move</span><span style="color:#9ECBFF"> them</span></span>
<span class="line"><span style="color:#B392F0">onto</span><span style="color:#9ECBFF"> your</span><span style="color:#9ECBFF"> processor</span><span style="color:#9ECBFF"> instead</span><span style="color:#E1E4E8"> (e.g. </span><span style="color:#9ECBFF">`</span><span style="color:#B392F0">satteri(</span><span style="color:#9ECBFF">{ features: { gfm: </span><span style="color:#79B8FF">false</span><span style="color:#9ECBFF">,</span></span>
<span class="line"><span style="color:#B392F0">smartPunctuation:</span><span style="color:#79B8FF"> false</span><span style="color:#9ECBFF"> } })`</span><span style="color:#B392F0">,</span><span style="color:#9ECBFF"> or</span><span style="color:#9ECBFF"> `</span><span style="color:#B392F0">unified(</span><span style="color:#9ECBFF">{ gfm: </span><span style="color:#79B8FF">false</span><span style="color:#9ECBFF">, smartypants: </span><span style="color:#79B8FF">false</span><span style="color:#9ECBFF"> })`</span></span>
<span class="line"><span style="color:#B392F0">from</span><span style="color:#9ECBFF"> `</span><span style="color:#B392F0">@astrojs/markdown-remark</span><span style="color:#9ECBFF">`</span><span style="color:#E1E4E8">). Will be removed in a future major.</span></span></code></pre></div>
<p>There’s no config change to make on your end. Updating <code>astro</code> to <code>7.0.6</code> or later removes the false positive completely.</p>
<div class="code-block code-block--has-filename"><div class="code-block__header"><span class="code-block__filename">fix: update past the patched version</span><button type="button" class="code-block__copy" data-copy-button>Copy</button></div><pre class="astro-code github-dark" style="background-color:#24292e;color:#e1e4e8;overflow-x:auto" tabindex="0" data-language="bash" data-filename="fix: update past the patched version"><code><span class="line"><span style="color:#B392F0">npm</span><span style="color:#9ECBFF"> install</span><span style="color:#9ECBFF"> astro@latest</span></span></code></pre></div>
<h2 id="confirmed-version-range">Confirmed version range</h2>
<p>This site runs <code>astro@^7.1.3</code>, well past the <code>7.0.6</code> fix, and doesn’t use the Container API anywhere in its own source, so it never hit this specific warning. If you’re rendering MDX through <code>AstroContainer</code> and see this exact message on <code>astro@7.0.3</code> through <code>7.0.5</code>, it’s this bug, not a real deprecation to act on. Anyone genuinely migrating <code>markdown.gfm</code>/<code>markdown.smartypants</code> off deprecated top-level keys should follow the <a href="/guides-fixes/fix-astro-7-rehypeplugins-deprecation-warning">markdown.rehypePlugins deprecation fix</a> instead, which covers the real version of this migration.</p>
<p>Browse more fixes from this same upgrade in the <a href="/guides-fixes">Guides &amp; Fixes</a> archive.</p>]]></content:encoded>
      <pubDate>Wed, 05 Aug 2026 00:00:00 GMT</pubDate>
    </item>
    <item>
      <title>Fix Astro 7 MISSING_EXPORT satteriCollectImagesPlugin</title>
      <link>https://bytetech247.com/guides-fixes/fix-astro-7-missing-export-satteri-images/</link>
      <guid isPermaLink="true">https://bytetech247.com/guides-fixes/fix-astro-7-missing-export-satteri-images/</guid>
      <description>Fix Astro 7&apos;s MISSING_EXPORT satteriCollectImagesPlugin error from @astrojs/mdx. The real cause, and the version that already fixes it.</description>
      <content:encoded><![CDATA[<aside class="quick-answer" aria-label="Quick answer"><h2 id="quick-answer">Quick Answer</h2>
<p>An Astro 7 beta build can fail with: <code>[MISSING_EXPORT] &quot;satteriCollectImagesPlugin&quot; is not exported by &quot;__vite-optional-peer-dep:@astrojs/markdown-satteri:@astrojs/mdx&quot;</code>. It means <code>@astrojs/mdx</code> and its Sätteri dependency landed at mismatched versions during the upgrade. Run <code>npm install @astrojs/mdx@latest</code> and rebuild.</p>
</aside><h2 id="why-the-missing-export-happens">Why the missing export happens</h2>
<p>Astro 7’s MDX integration collects images referenced inside <code>.mdx</code> files using a helper called <code>satteriCollectImagesPlugin</code>, part of the new Sätteri Markdown pipeline. <code>@astrojs/mdx</code> imports that helper from <code>@astrojs/markdown-satteri</code>, an optional peer dependency Vite resolves separately at build time.</p>
<p>An interrupted or partial upgrade, commonly through <code>@astrojs/upgrade</code> stopping mid-way or a lockfile only partially updating, can leave <code>@astrojs/mdx</code> newer than the installed <code>@astrojs/markdown-satteri</code>. If <code>@astrojs/mdx</code> expects <code>satteriCollectImagesPlugin</code> to exist and the installed <code>@astrojs/markdown-satteri</code> predates it, Vite’s build fails resolving that named export instead of silently skipping it. <a href="https://github.com/withastro/astro/issues/17068">Issue #17068</a>, opened 2026-06-13 against an Astro 7 beta, documents this exact failure.</p>
<div class="callout" role="note" data-astro-cid-q2ml7llr><p class="callout__title" data-astro-cid-q2ml7llr><span aria-hidden="true" data-astro-cid-q2ml7llr>⚠</span>This one fails the build, not just a warning</p><div class="callout__body" data-astro-cid-q2ml7llr><p>Unlike some other Sätteri-era errors, <code>MISSING_EXPORT</code> stops the build
outright. There’s no silent runtime version of this one to miss, it shows up
the moment you try to build or run <code>astro dev</code>.</p></div></div>
<h2 id="is-this-still-a-problem-on-astro-7-today">Is this still a problem on Astro 7 today?</h2>
<p>No. The same <a href="https://github.com/withastro/astro/pull/17093">PR #17093</a> that fixed the related Rollup <code>satteri</code> import error also fixed this one, merged 2026-06-17, five days before Astro 7.0.0 went stable on 2026-06-22. The fix shipped in <code>@astrojs/mdx@7.0.0</code>, which keeps its Sätteri dependency in sync automatically.</p>
<div class="code-block code-block--has-filename"><div class="code-block__header"><span class="code-block__filename">fix: reinstall @astrojs/mdx past the version skew</span><button type="button" class="code-block__copy" data-copy-button>Copy</button></div><pre class="astro-code github-dark" style="background-color:#24292e;color:#e1e4e8;overflow-x:auto" tabindex="0" data-language="bash" data-filename="fix: reinstall @astrojs/mdx past the version skew"><code><span class="line"><span style="color:#B392F0">npm</span><span style="color:#9ECBFF"> install</span><span style="color:#9ECBFF"> @astrojs/mdx@latest</span></span>
<span class="line"><span style="color:#B392F0">npm</span><span style="color:#9ECBFF"> run</span><span style="color:#9ECBFF"> build</span></span></code></pre></div>
<p>A plain reinstall is enough. There’s no config option to set, since the bug was a version mismatch between two packages, not a setting either package exposes.</p>
<h2 id="confirmed-version-range">Confirmed version range</h2>
<p>This site’s <code>astro.config.mjs</code> registers the <code>mdx()</code> integration and runs <code>@astrojs/mdx@^7.0.3</code>, already past the fix. A fresh <code>npm ci</code> in this repo installs matching, compatible versions of <code>@astrojs/mdx</code> and its Sätteri dependency, and the build completes without the <code>MISSING_EXPORT</code> error.</p>
<p>If you hit this during Astro 7’s beta cycle, before 2026-06-17, the fix is the same reinstall shown above. Avoid pinning <code>@astrojs/mdx</code> to an exact beta version in <code>package.json</code>; a caret range lets npm pull the matching Sätteri dependency automatically the next time you install.</p>
<p>See the <a href="/guides-fixes/fix-astro-7-rollup-satteri-import-error">Rollup failed to resolve import satteri</a> post for the related build warning this same PR fixed, or browse more fixes in the <a href="/guides-fixes">Guides &amp; Fixes</a> archive.</p>]]></content:encoded>
      <pubDate>Wed, 05 Aug 2026 00:00:00 GMT</pubDate>
    </item>
    <item>
      <title>Fix Astro 7 markdown.rehypePlugins Deprecation Warning</title>
      <link>https://bytetech247.com/guides-fixes/fix-astro-7-rehypeplugins-deprecation-warning/</link>
      <guid isPermaLink="true">https://bytetech247.com/guides-fixes/fix-astro-7-rehypeplugins-deprecation-warning/</guid>
      <description>Fix Astro 7&apos;s markdown.rehypePlugins deprecation warning. The exact config fix, tested live against a real Astro 7 build.</description>
      <content:encoded><![CDATA[<aside class="quick-answer" aria-label="Quick answer"><h2 id="quick-answer">Quick Answer</h2>
<p>Setting <code>markdown.rehypePlugins</code> (or <code>remarkPlugins</code> / <code>remarkRehype</code>) directly in <code>astro.config.mjs</code> now prints: <code>[astro] markdown.remarkPlugins, markdown.rehypePlugins, and markdown.remarkRehype are deprecated. Pass them to unified({...}) from @astrojs/markdown-remark directly instead.</code> Wrap the same plugins inside <code>processor: unified({ rehypePlugins: [...] })</code> and the warning is gone.</p>
</aside><h2 id="why-the-warning-happens">Why the warning happens</h2>
<p>Astro 7 defaults to Sätteri, a new Rust Markdown processor with no plugin system of its own. Setting <code>markdown.remarkPlugins</code>, <code>markdown.rehypePlugins</code>, or <code>markdown.remarkRehype</code> at the top level tells Astro to fall back to the older <code>@astrojs/markdown-remark</code> unified pipeline instead, since only that pipeline can run remark and rehype plugins at all.</p>
<p>That fallback still works. The top-level config keys used to trigger it are deprecated, in favor of passing the same options directly to a <code>unified()</code> processor object. This site’s own <code>astro.config.mjs</code> hits the warning for exactly this reason: it sets <code>markdown.rehypePlugins</code> to add <code>scope=&quot;col&quot;</code> to table headers and to style code block chrome, both real rehype plugins this site depends on.</p>
<div class="code-block code-block--has-filename"><div class="code-block__header"><span class="code-block__filename">npx astro build on this site&#39;s own astro@7.1.3</span><button type="button" class="code-block__copy" data-copy-button>Copy</button></div><pre class="astro-code github-dark" style="background-color:#24292e;color:#e1e4e8;overflow-x:auto" tabindex="0" data-language="bash" data-filename="npx astro build on this site's own astro@7.1.3"><code><span class="line"><span style="color:#E1E4E8">[astro] </span><span style="color:#9ECBFF">`</span><span style="color:#B392F0">markdown.remarkPlugins</span><span style="color:#9ECBFF">`</span><span style="color:#B392F0">,</span><span style="color:#9ECBFF"> `</span><span style="color:#B392F0">markdown.rehypePlugins</span><span style="color:#9ECBFF">`</span><span style="color:#B392F0">,</span><span style="color:#9ECBFF"> and</span><span style="color:#9ECBFF"> `</span><span style="color:#B392F0">markdown.remarkRehype</span><span style="color:#9ECBFF">`</span><span style="color:#B392F0"> are</span><span style="color:#9ECBFF"> deprecated.</span><span style="color:#9ECBFF"> Pass</span><span style="color:#9ECBFF"> them</span><span style="color:#9ECBFF"> to</span><span style="color:#9ECBFF"> `</span><span style="color:#B392F0">unified(</span><span style="color:#9ECBFF">{</span><span style="color:#79B8FF">...</span><span style="color:#9ECBFF">})`</span><span style="color:#B392F0"> from</span><span style="color:#9ECBFF"> `</span><span style="color:#B392F0">@astrojs/markdown-remark</span><span style="color:#9ECBFF">`</span><span style="color:#B392F0"> directly</span><span style="color:#9ECBFF"> instead.</span></span></code></pre></div>
<div class="callout" role="note" data-astro-cid-q2ml7llr><p class="callout__title" data-astro-cid-q2ml7llr><span aria-hidden="true" data-astro-cid-q2ml7llr>⚠</span>Scheduled for removal in Astro 8, not just deprecated</p><div class="callout__body" data-astro-cid-q2ml7llr><p>Astro’s <a href="https://astro.build/blog/astro-640/">6.4 release notes</a> list this as
scheduled for removal in Astro 8.0. It still works fully on Astro 7, but treat
the warning as a heads-up to migrate, not noise to ignore.</p></div></div>
<h2 id="the-fix-move-plugins-into-unified">The fix: move plugins into unified()</h2>
<p><code>@astrojs/markdown-remark</code> exports a <code>unified()</code> processor factory built exactly for this. Its own type definitions document the replacement shape directly:</p>
<div class="code-block code-block--has-filename"><div class="code-block__header"><span class="code-block__filename">astro.config.mjs - before</span><button type="button" class="code-block__copy" data-copy-button>Copy</button></div><pre class="astro-code github-dark" style="background-color:#24292e;color:#e1e4e8;overflow-x:auto" tabindex="0" data-language="js" data-filename="astro.config.mjs - before"><code><span class="line"><span style="color:#9ca6b0">// BROKEN, prints the deprecation warning on every build</span></span>
<span class="line"><span style="color:#B392F0">markdown</span><span style="color:#E1E4E8">: {</span></span>
<span class="line"><span style="color:#B392F0">  rehypePlugins</span><span style="color:#E1E4E8">: [rehypeTableHeaderScope, rehypeCodeBlockChrome],</span></span>
<span class="line"><span style="color:#E1E4E8">},</span></span></code></pre></div>
<div class="code-block code-block--has-filename"><div class="code-block__header"><span class="code-block__filename">astro.config.mjs - after</span><button type="button" class="code-block__copy" data-copy-button>Copy</button></div><pre class="astro-code github-dark" style="background-color:#24292e;color:#e1e4e8;overflow-x:auto" tabindex="0" data-language="js" data-filename="astro.config.mjs - after"><code><span class="line"><span style="color:#F97583">import</span><span style="color:#E1E4E8"> { unified } </span><span style="color:#F97583">from</span><span style="color:#9ECBFF"> &quot;@astrojs/markdown-remark&quot;</span><span style="color:#E1E4E8">;</span></span>
<span class="line"></span>
<span class="line"><span style="color:#9ca6b0">// FIXED, same plugins, no warning</span></span>
<span class="line"><span style="color:#B392F0">markdown</span><span style="color:#E1E4E8">: {</span></span>
<span class="line"><span style="color:#B392F0">  processor</span><span style="color:#E1E4E8">: </span><span style="color:#B392F0">unified</span><span style="color:#E1E4E8">({</span></span>
<span class="line"><span style="color:#E1E4E8">    rehypePlugins: [rehypeTableHeaderScope, rehypeCodeBlockChrome],</span></span>
<span class="line"><span style="color:#E1E4E8">  }),</span></span>
<span class="line"><span style="color:#E1E4E8">},</span></span></code></pre></div>
<p><code>remarkPlugins</code> and <code>remarkRehype</code> move the same way, as options on that same <code>unified({...})</code> call.</p>
<h2 id="confirmed-against-a-real-build">Confirmed against a real build</h2>
<p>Applying this exact change to this site’s own <code>astro.config.mjs</code> and running <code>npx astro build</code> removes the warning completely, with all 52 pages still building. The <code>scope=&quot;col&quot;</code> attribute the <code>rehypeTableHeaderScope</code> plugin adds still shows up in the rendered output afterward, confirmed by grepping the built HTML for both posts that use a table. Nothing about the page output changed, only the warning disappeared.</p>
<p>This site hasn’t made the change permanent yet since the deprecated form still works cleanly on <code>astro@7.1.3</code>, but the fix above is the exact, tested change to make when migrating ahead of Astro 8.</p>
<p>See the <a href="/guides-fixes">Guides &amp; Fixes</a> archive for more fixes from this same upgrade.</p>]]></content:encoded>
      <pubDate>Wed, 05 Aug 2026 00:00:00 GMT</pubDate>
    </item>
    <item>
      <title>Fix Astro 7 Rollup Failed to Resolve Import satteri</title>
      <link>https://bytetech247.com/guides-fixes/fix-astro-7-rollup-satteri-import-error/</link>
      <guid isPermaLink="true">https://bytetech247.com/guides-fixes/fix-astro-7-rollup-satteri-import-error/</guid>
      <description>Fix Astro 7&apos;s Rollup failed to resolve import satteri error from getContainerRenderer. Real cause, and the exact @astrojs/mdx version already fixing it.</description>
      <content:encoded><![CDATA[<aside class="quick-answer" aria-label="Quick answer"><h2 id="quick-answer">Quick Answer</h2>
<p>Calling <code>getContainerRenderer()</code> on an Astro 7 beta build could throw: <code>Rollup failed to resolve import &quot;satteri&quot; from &quot;.../node_modules/@astrojs/mdx/dist/satteri/index.js&quot;. This is most likely unintended because it can break your application at runtime.</code> It’s already fixed. Update to <code>@astrojs/mdx@^7.0.0</code> or later and the error is gone for good.</p>
</aside><h2 id="why-the-rollup-error-happens">Why the Rollup error happens</h2>
<p>Astro 7 replaced its Markdown pipeline with Sätteri, a new Rust processor. A project can still opt out and keep the older <code>@astrojs/markdown-remark</code> unified pipeline instead, by setting <code>markdown.processor</code> to <code>unified()</code>. That choice is what triggers this bug.</p>
<p>Calling <code>getContainerRenderer()</code>, the function integrations use to render content outside the normal page build (an RSS feed script is the most common case), makes <code>@astrojs/mdx</code> eagerly import its Sätteri module path from its package root. It does this even when the project’s <code>markdown.processor</code> is still set to the legacy unified pipeline. Rollup then tries to bundle <code>satteri</code> as an optional peer dependency, and fails, because that package is only installed when Sätteri is the active processor. <a href="https://github.com/withastro/astro/issues/16954">Issue #16954</a>, opened 2026-06-02 against an Astro 7 beta, documents the exact warning.</p>
<div class="callout" role="note" data-astro-cid-q2ml7llr><p class="callout__title" data-astro-cid-q2ml7llr><span aria-hidden="true" data-astro-cid-q2ml7llr>ℹ</span>A warning, not always a hard crash</p><div class="callout__body" data-astro-cid-q2ml7llr><p>Vite reports this as a build warning, not a failure that stops the build
outright. The issue’s own title calls it out directly: it “can break your
application at runtime” rather than at build time, which makes it easy to miss
until the affected code path actually runs.</p></div></div>
<h2 id="is-this-still-a-problem-on-astro-7-today">Is this still a problem on Astro 7 today?</h2>
<p>No. <a href="https://github.com/withastro/astro/pull/17093">PR #17093</a> fixed this issue, along with the related <a href="https://github.com/withastro/astro/issues/17068">#17068</a>, on 2026-06-17, five days before Astro 7.0.0 went stable on 2026-06-22. The fix shipped in <code>@astrojs/mdx@7.0.0</code>.</p>
<p>The fix moves <code>getContainerRenderer()</code> out of each integration’s package root and into a dedicated <code>/container-renderer</code> entrypoint. Importing from the old root path still works, but now only logs a deprecation warning instead of trying to eagerly resolve <code>satteri</code>.</p>
<div class="code-block code-block--has-filename"><div class="code-block__header"><span class="code-block__filename">scripts/render-feed.mjs - before</span><button type="button" class="code-block__copy" data-copy-button>Copy</button></div><pre class="astro-code github-dark" style="background-color:#24292e;color:#e1e4e8;overflow-x:auto" tabindex="0" data-language="js" data-filename="scripts/render-feed.mjs - before"><code><span class="line"><span style="color:#9ca6b0">// BROKEN on @astrojs/mdx versions before 7.0.0, eagerly resolves</span></span>
<span class="line"><span style="color:#9ca6b0">// the satteri module even when markdown.processor stays on unified()</span></span>
<span class="line"><span style="color:#F97583">import</span><span style="color:#E1E4E8"> { getContainerRenderer } </span><span style="color:#F97583">from</span><span style="color:#9ECBFF"> &quot;@astrojs/mdx&quot;</span><span style="color:#E1E4E8">;</span></span></code></pre></div>
<div class="code-block code-block--has-filename"><div class="code-block__header"><span class="code-block__filename">scripts/render-feed.mjs - after</span><button type="button" class="code-block__copy" data-copy-button>Copy</button></div><pre class="astro-code github-dark" style="background-color:#24292e;color:#e1e4e8;overflow-x:auto" tabindex="0" data-language="js" data-filename="scripts/render-feed.mjs - after"><code><span class="line"><span style="color:#9ca6b0">// FIXED, imports from the dedicated entrypoint added in PR #17093,</span></span>
<span class="line"><span style="color:#9ca6b0">// available on @astrojs/mdx 7.0.0 and later</span></span>
<span class="line"><span style="color:#F97583">import</span><span style="color:#E1E4E8"> { getContainerRenderer } </span><span style="color:#F97583">from</span><span style="color:#9ECBFF"> &quot;@astrojs/mdx/container-renderer&quot;</span><span style="color:#E1E4E8">;</span></span></code></pre></div>
<p>The first block still runs on a patched <code>@astrojs/mdx</code>, it just prints a deprecation notice. The second block is the version <a href="https://docs.astro.build/en/guides/upgrade-to/v7/">Astro’s own upgrade guide</a> recommends going forward.</p>
<h2 id="confirmed-version-range">Confirmed version range</h2>
<p>This site runs <code>@astrojs/mdx@^7.0.3</code>, already past the fix, and its own RSS feed (<code>src/lib/rss.ts</code>) never renders post bodies at all. It only serializes each post’s frontmatter <code>description</code> into the feed XML, so it never calls <code>getContainerRenderer()</code> and was never exposed to this bug in the first place.</p>
<p>If your project only hit this during Astro 7’s beta cycle, in the window between Sätteri becoming the default processor and 2026-06-17, a plain dependency bump to <code>@astrojs/mdx@^7.0.3</code> (or any current 7.x release) removes it. There’s nothing left to configure once the version is current.</p>
<p>The same PR also fixed a related error, <a href="/guides-fixes/fix-astro-7-missing-export-satteri-images">MISSING_EXPORT “satteriCollectImagesPlugin”</a>, a hard build failure rather than a warning. Read more fixes for this same upgrade in the <a href="/guides-fixes">Guides &amp; Fixes</a> archive.</p>]]></content:encoded>
      <pubDate>Wed, 05 Aug 2026 00:00:00 GMT</pubDate>
    </item>
    <item>
      <title>Fix Astro 7 Duplicate Heading IDs From Sätteri Bug</title>
      <link>https://bytetech247.com/guides-fixes/fix-astro-7-satteri-duplicate-heading-ids/</link>
      <guid isPermaLink="true">https://bytetech247.com/guides-fixes/fix-astro-7-satteri-duplicate-heading-ids/</guid>
      <description>Fix Astro 7 Satteri duplicate heading entries when Starlight assigns its own IDs first. Confirmed fixed in this site&apos;s own installed version.</description>
      <content:encoded><![CDATA[<aside class="quick-answer" aria-label="Quick answer"><h2 id="quick-answer">Quick Answer</h2>
<p>Astro 7’s Sätteri heading-ids plugin could list the same heading twice in a page’s <code>headings</code> metadata, the array used for tables of contents and sidebar anchors, when another integration like Starlight assigned its own heading IDs first. It’s already fixed, merged 2026-06-23 in <code>@astrojs/markdown-satteri</code>. No config change is needed, just running a current version.</p>
</aside><h2 id="why-headings-got-listed-twice">Why headings got listed twice</h2>
<p>Sätteri’s heading-ids plugin originally pushed each heading straight onto the page’s shared <code>astro.headings</code> array as it walked the document: <code>astro?.headings.push({ depth, slug, text })</code>. That’s safe the first time the plugin runs. It’s not safe the second time, because pushing again appends to whatever the array already contains instead of replacing it.</p>
<p>Starlight, Astro’s own documentation theme, runs its own heading pass to assign IDs before adding anchor links. On a Starlight site, that pass and Sätteri’s own heading-ids plugin could both touch the same page, and the second pass appended duplicate entries instead of producing one clean list. The official changeset for the fix states this directly: “Fixes headings being listed twice in a page’s <code>headings</code> metadata when an integration (such as Starlight) assigns heading IDs with its own heading pass before adding anchor links.”</p>
<div class="callout" role="note" data-astro-cid-q2ml7llr><p class="callout__title" data-astro-cid-q2ml7llr><span aria-hidden="true" data-astro-cid-q2ml7llr>ℹ</span>A default blog was never exposed to this</p><div class="callout__body" data-astro-cid-q2ml7llr><p>Nothing about a normal Astro 7 blog build triggers this on its own. It only
shows up when a second integration also processes headings before Sätteri’s
pass runs, which is a Starlight-shaped setup, not the common case.</p></div></div>
<h2 id="the-fix">The fix</h2>
<p><a href="https://github.com/withastro/astro/pull/17165">PR #17165</a>, “fix(satteri): Make heading-ids plugin idempotent,” merged 2026-06-23, changes the plugin to collect headings into a local array first, then assign that whole array to <code>astro.headings</code> once, instead of pushing onto the shared array directly.</p>
<div class="code-block code-block--has-filename"><div class="code-block__header"><span class="code-block__filename">packages/markdown/satteri/src/satteri-processor.ts - before</span><button type="button" class="code-block__copy" data-copy-button>Copy</button></div><pre class="astro-code github-dark" style="background-color:#24292e;color:#e1e4e8;overflow-x:auto" tabindex="0" data-language="js" data-filename="packages/markdown/satteri/src/satteri-processor.ts - before"><code><span class="line"><span style="color:#9ca6b0">// BROKEN, appends to whatever astro.headings already contains</span></span>
<span class="line"><span style="color:#E1E4E8">astro?.headings.</span><span style="color:#B392F0">push</span><span style="color:#E1E4E8">({ depth, slug, text });</span></span></code></pre></div>
<div class="code-block code-block--has-filename"><div class="code-block__header"><span class="code-block__filename">packages/markdown/satteri/src/satteri-processor.ts - after</span><button type="button" class="code-block__copy" data-copy-button>Copy</button></div><pre class="astro-code github-dark" style="background-color:#24292e;color:#e1e4e8;overflow-x:auto" tabindex="0" data-language="js" data-filename="packages/markdown/satteri/src/satteri-processor.ts - after"><code><span class="line"><span style="color:#9ca6b0">// FIXED, local array is built fully, then assigned once</span></span>
<span class="line"><span style="color:#E1E4E8">headings.</span><span style="color:#B392F0">push</span><span style="color:#E1E4E8">({ depth, slug, text });</span></span>
<span class="line"><span style="color:#F97583">if</span><span style="color:#E1E4E8"> (astro) astro.headings </span><span style="color:#F97583">=</span><span style="color:#E1E4E8"> headings;</span></span></code></pre></div>
<p>Running a version of <code>@astrojs/markdown-satteri</code> that includes this fix is the entire fix. There’s nothing to configure.</p>
<h2 id="confirmed-against-this-repos-own-installed-version">Confirmed against this repo’s own installed version</h2>
<p>This site’s own <code>node_modules/@astrojs/markdown-satteri@0.3.4</code> already has the fixed code. Checking the real installed file directly shows the exact after-state from the PR: a local <code>headings</code> array collected first, then assigned once. This site doesn’t use Starlight, so it was never exposed to the duplication itself, but its dependency tree confirms the fix is present in the version this site (and any current Astro 7 install) actually runs.</p>
<p>If you maintain a Starlight site or any integration that assigns heading IDs of its own, confirm <code>@astrojs/markdown-satteri</code> resolves to a version at or after this fix, the same way any other transitive dependency gets checked after a dependency bump.</p>
<p>Browse more fixes from this same upgrade in the <a href="/guides-fixes">Guides &amp; Fixes</a> archive.</p>]]></content:encoded>
      <pubDate>Wed, 05 Aug 2026 00:00:00 GMT</pubDate>
    </item>
    <item>
      <title>Fix Astro Dev Toolbar &apos;Not Implemented&apos; Crash</title>
      <link>https://bytetech247.com/guides-fixes/fix-astro-dev-toolbar-not-implemented-crash/</link>
      <guid isPermaLink="true">https://bytetech247.com/guides-fixes/fix-astro-dev-toolbar-not-implemented-crash/</guid>
      <description>Fix Astro&apos;s dev toolbar crashing with a &apos;Not implemented&apos; error under Vite 8&apos;s Rolldown esbuild-compat shim. Real workaround included.</description>
      <content:encoded><![CDATA[<aside class="quick-answer" aria-label="Quick answer"><h2 id="quick-answer">Quick Answer</h2>
<p>Astro’s built-in dev toolbar can crash with <code>Not implemented</code> thrown from <code>PluginContextImpl.generateBundle</code>, because its <code>onEnd</code> callback reads <code>result.metafile</code>, a field Vite 8’s Rolldown esbuild-compatibility shim doesn’t implement. Strip <code>optimizeDeps.esbuildOptions.plugins</code> in a <code>configResolved</code> hook in your Astro config to stop the crash without disabling the toolbar.</p>
</aside><h2 id="why-the-dev-toolbar-crashes-under-rolldown">Why the dev toolbar crashes under Rolldown</h2>
<p>Vite 8 switched its dependency optimizer from esbuild to Rolldown, and ships an esbuild-plugin compatibility shim so existing <code>optimizeDeps.esbuildOptions.plugins</code> configs keep working without every plugin author rewriting for Rolldown’s own plugin API.</p>
<p>Astro’s own built-in <code>astro:dev-toolbar</code> integration registers exactly this kind of plugin: an esbuild plugin with an <code>onEnd</code> callback that reads <code>result.metafile</code>, a field esbuild’s real dependency-scan output includes. Rolldown doesn’t produce an esbuild-style metafile, and its compatibility shim doesn’t implement that field. Calling the callback throws instead of quietly returning nothing:</p>
<div class="code-block"><div class="code-block__header"><span class="code-block__filename"></span><button type="button" class="code-block__copy" data-copy-button>Copy</button></div><pre class="astro-code github-dark" style="background-color:#24292e;color:#e1e4e8;overflow-x:auto" tabindex="0" data-language="plaintext"><code><span class="line"><span>Not implemented</span></span>
<span class="line"><span>  at PluginContextImpl.generateBundle (vite/dist/node/chunks/node.js:33847)</span></span></code></pre></div>
<p>The crash was reported directly: “<a href="https://github.com/withastro/astro/issues/16636">vite rolldown compatibility issue</a>,” opened 2026-05-07 against Astro 6.2.2 and Vite 8.0.11. It was closed as not planned; a workaround shipped for one specific project’s own config, not a fix to Astro’s dev-toolbar plugin itself.</p>
<div class="callout" role="note" data-astro-cid-q2ml7llr><p class="callout__title" data-astro-cid-q2ml7llr><span aria-hidden="true" data-astro-cid-q2ml7llr>ℹ</span>Verified: current Astro 7.1.3 no longer triggers this specific path</p><div class="callout__body" data-astro-cid-q2ml7llr><p>Checked directly against this site’s own installed
<code>node_modules/astro/dist/toolbar/vite-plugin-dev-toolbar.js</code> (<code>astro@7.1.3</code>):
its <code>config()</code> hook only sets <code>optimizeDeps.include</code> today, not
<code>optimizeDeps.esbuildOptions.plugins</code> at all. The exact crash described above
doesn’t appear to trigger from Astro’s own dev-toolbar integration on current
7.x releases. It’s still real and worth knowing the fix for if you’re on an
older Astro 6.x project, or if a <em>different</em> plugin in your app sets an
esbuild <code>onEnd</code> callback that reads <code>result.metafile</code>, the fix below is
defensive against that exact pattern regardless of which plugin triggers it.</p></div></div>
<h2 id="fix-it-strip-the-incompatible-esbuild-plugin">Fix it: strip the incompatible esbuild plugin</h2>
<h3 id="before-default-astro-config">Before: default Astro config</h3>
<div class="code-block code-block--has-filename"><div class="code-block__header"><span class="code-block__filename">astro.config.mjs</span><button type="button" class="code-block__copy" data-copy-button>Copy</button></div><pre class="astro-code github-dark" style="background-color:#24292e;color:#e1e4e8;overflow-x:auto" tabindex="0" data-language="js" data-filename="astro.config.mjs"><code><span class="line"><span style="color:#F97583">export</span><span style="color:#F97583"> default</span><span style="color:#B392F0"> defineConfig</span><span style="color:#E1E4E8">({</span></span>
<span class="line"><span style="color:#9ca6b0">  // dev toolbar enabled by default (the Astro 7 default) —</span></span>
<span class="line"><span style="color:#9ca6b0">  // its esbuild plugin crashes under Rolldown (BROKEN)</span></span>
<span class="line"><span style="color:#E1E4E8">});</span></span></code></pre></div>
<h3 id="after-add-a-plugin-that-clears-the-broken-hook">After: add a plugin that clears the broken hook</h3>
<div class="code-block code-block--has-filename"><div class="code-block__header"><span class="code-block__filename">astro.config.mjs</span><button type="button" class="code-block__copy" data-copy-button>Copy</button></div><pre class="astro-code github-dark" style="background-color:#24292e;color:#e1e4e8;overflow-x:auto" tabindex="0" data-language="js" data-filename="astro.config.mjs"><code><span class="line"><span style="color:#F97583">export</span><span style="color:#F97583"> default</span><span style="color:#B392F0"> defineConfig</span><span style="color:#E1E4E8">({</span></span>
<span class="line"><span style="color:#E1E4E8">  vite: {</span></span>
<span class="line"><span style="color:#E1E4E8">    plugins: [</span></span>
<span class="line"><span style="color:#E1E4E8">      {</span></span>
<span class="line"><span style="color:#E1E4E8">        name: </span><span style="color:#9ECBFF">&quot;fix-rolldown-esbuild-compat&quot;</span><span style="color:#E1E4E8">,</span></span>
<span class="line"><span style="color:#B392F0">        configResolved</span><span style="color:#E1E4E8">(</span><span style="color:#FFAB70">config</span><span style="color:#E1E4E8">) {</span></span>
<span class="line"><span style="color:#9ca6b0">          // FIXED: the dev toolbar&#39;s esbuild onEnd callback reads</span></span>
<span class="line"><span style="color:#9ca6b0">          // result.metafile, which Rolldown&#39;s compat shim doesn&#39;t</span></span>
<span class="line"><span style="color:#9ca6b0">          // implement — clearing the plugin list avoids the crash</span></span>
<span class="line"><span style="color:#F97583">          if</span><span style="color:#E1E4E8"> (config.optimizeDeps?.esbuildOptions?.plugins) {</span></span>
<span class="line"><span style="color:#E1E4E8">            config.optimizeDeps.esbuildOptions.plugins </span><span style="color:#F97583">=</span><span style="color:#E1E4E8"> [];</span></span>
<span class="line"><span style="color:#E1E4E8">          }</span></span>
<span class="line"><span style="color:#E1E4E8">        },</span></span>
<span class="line"><span style="color:#E1E4E8">      },</span></span>
<span class="line"><span style="color:#E1E4E8">    ],</span></span>
<span class="line"><span style="color:#E1E4E8">  },</span></span>
<span class="line"><span style="color:#E1E4E8">});</span></span></code></pre></div>
<p>This runs at config-resolution time, before the dev server starts optimizing dependencies, so it removes the broken callback before anything can invoke it. The dev toolbar itself keeps working; only the specific esbuild <code>onEnd</code> hook that Rolldown can’t satisfy gets cleared.</p>
<div class="callout" role="note" data-astro-cid-q2ml7llr><p class="callout__title" data-astro-cid-q2ml7llr><span aria-hidden="true" data-astro-cid-q2ml7llr>⚠</span>This edits a live config object</p><div class="callout__body" data-astro-cid-q2ml7llr><p><code>configResolved</code> receives Vite’s final, merged config; mutating
<code>optimizeDeps.esbuildOptions.plugins</code> here only removes the array Astro’s
dev-toolbar integration already pushed onto, not anything you configured
yourself under a different plugin name. If you also rely on other
<code>optimizeDeps.esbuildOptions.plugins</code> entries, filter this list instead of
clearing it outright.</p></div></div>
<h2 id="confirmed-version-range">Confirmed version range</h2>
<p>Reported against Astro 6.2.2 and Vite 8.0.11. Checked directly against this site’s own <code>astro@7.1.3</code> install: its dev-toolbar plugin’s <code>config()</code> hook has already moved away from setting <code>optimizeDeps.esbuildOptions.plugins</code>, so the specific crash reported in the original issue doesn’t reproduce from Astro’s own integration on current 7.x. If you’re on Astro 6.x, or hitting the identical <code>Not implemented</code> error from a different plugin, the fix above still applies: the trigger is any esbuild plugin with an <code>onEnd</code> callback reading <code>result.metafile</code>, not something specific to the dev toolbar itself. Browse more posts like this in the <a href="/guides-fixes">Guides &amp; Fixes</a> archive.</p>]]></content:encoded>
      <pubDate>Wed, 05 Aug 2026 00:00:00 GMT</pubDate>
    </item>
    <item>
      <title>Fix astro-mermaid Diagrams Breaking Under Astro 7</title>
      <link>https://bytetech247.com/guides-fixes/fix-astro-mermaid-diagrams-breaking-astro-7/</link>
      <guid isPermaLink="true">https://bytetech247.com/guides-fixes/fix-astro-mermaid-diagrams-breaking-astro-7/</guid>
      <description>Fix astro-mermaid diagrams silently rendering as plain code under Astro 7&apos;s Satteri processor, and the version that fixes it.</description>
      <content:encoded><![CDATA[<aside class="quick-answer" aria-label="Quick answer"><h2 id="quick-answer">Quick Answer</h2>
<p>Upgrading to Astro 7 can leave <code>astro-mermaid</code> diagrams rendering as plain, unstyled code blocks instead of real diagrams, with this warning printed: <code>markdown.remarkPlugins/rehypePlugins/remarkRehype are set, but your satteri processor doesn&#39;t run them.</code> Update to <code>astro-mermaid@^2.1.0</code> or later. It adds native Sätteri support and the diagrams render again.</p>
</aside><h2 id="why-the-diagrams-stop-rendering">Why the diagrams stop rendering</h2>
<p><code>astro-mermaid</code> only registered its diagram transform through the classic remark/rehype pipeline, checked with a helper that confirms the active processor is <code>unified()</code>. Astro 7’s default Markdown processor is Sätteri, a separate Rust-based pipeline that never runs remark or rehype plugins at all. The moment that check fails, <code>astro-mermaid</code>’s transform never registers, so every <code>```mermaid</code> fence passes through untouched.</p>
<p>There’s no error to signal this. The fenced code block is still valid Markdown on its own, so it renders exactly like any other code block, syntax-highlighted but not converted into a diagram. <a href="https://github.com/joesaby/astro-mermaid/issues/71">Issue #71</a>, opened 2026-06-24 against <code>astro-mermaid@^2.0.4</code>, documents the exact warning Astro itself prints in this situation.</p>
<div class="callout" role="note" data-astro-cid-q2ml7llr><p class="callout__title" data-astro-cid-q2ml7llr><span aria-hidden="true" data-astro-cid-q2ml7llr>⚠</span>No crash, just a silent downgrade</p><div class="callout__body" data-astro-cid-q2ml7llr><p>This is the kind of regression that survives a build and even a quick visual
check, since a plain code block still looks intentional at a glance. The tell
is checking whether the block actually rendered as a diagram, not whether the
build succeeded.</p></div></div>
<h2 id="the-fix-upgrade-to-astro-mermaid-210">The fix: upgrade to astro-mermaid 2.1.0+</h2>
<p><a href="https://github.com/joesaby/astro-mermaid/pull/72">PR #72</a>, merged the same day as the issue, 2026-06-24, adds real Sätteri support. It detects the active processor and dispatches to one of three paths: a native Sätteri mdast plugin on Astro 7, the existing remark/rehype plugins unchanged on Astro 6.4’s unified pipeline, or the original plugin arrays on anything older. The fix shipped in <code>astro-mermaid@2.1.0</code>.</p>
<div class="code-block code-block--has-filename"><div class="code-block__header"><span class="code-block__filename">fix: update astro-mermaid</span><button type="button" class="code-block__copy" data-copy-button>Copy</button></div><pre class="astro-code github-dark" style="background-color:#24292e;color:#e1e4e8;overflow-x:auto" tabindex="0" data-language="bash" data-filename="fix: update astro-mermaid"><code><span class="line"><span style="color:#B392F0">npm</span><span style="color:#9ECBFF"> install</span><span style="color:#9ECBFF"> astro-mermaid@latest</span></span></code></pre></div>
<p>The fenced code block syntax in your Markdown doesn’t change at all:</p>
<div class="code-block code-block--has-filename"><div class="code-block__header"><span class="code-block__filename">src/content/blog/example/index.mdx</span><button type="button" class="code-block__copy" data-copy-button>Copy</button></div><pre class="astro-code github-dark" style="background-color:#24292e;color:#e1e4e8;overflow-x:auto" tabindex="0" data-language="md" data-filename="src/content/blog/example/index.mdx"><code><span class="line"><span style="color:#E1E4E8">```mermaid</span></span>
<span class="line"><span style="color:#E1E4E8">graph TD</span></span>
<span class="line"><span style="color:#E1E4E8">  A[Start] --&gt; B{Decision}</span></span>
<span class="line"><span style="color:#E1E4E8">  B --&gt;|Yes| C[Continue]</span></span>
<span class="line"><span style="color:#E1E4E8">```</span></span></code></pre></div>
<div class="callout" role="note" data-astro-cid-q2ml7llr><p class="callout__title" data-astro-cid-q2ml7llr><span aria-hidden="true" data-astro-cid-q2ml7llr>ℹ</span>Why the fix targets rawHtml specifically</p><div class="callout__body" data-astro-cid-q2ml7llr><p>The PR’s own notes call out a subtlety worth knowing: it returns a plain HTML
node rather than using Sätteri’s MDX-style brace escaping, because that
escaping would corrupt decision-node syntax like <code>B{Decision}</code> in the diagram
source itself. A naive port of the old plugin wouldn’t have caught this.</p></div></div>
<h2 id="confirmed-version-range">Confirmed version range</h2>
<p>This site doesn’t use <code>astro-mermaid</code>, so this bug was never dogfooded here directly. It’s a clean example of the broader pattern covered elsewhere in this series: any remark or rehype plugin written only against the old unified pipeline needs its own Sätteri-aware update, not just a version bump, since Sätteri has no compatibility layer for the old plugin API.</p>
<p>If you maintain a different remark or rehype plugin and see this same warning, check whether it’s been updated for Astro 7 the same way <code>astro-mermaid</code> was, rather than assuming a plain upgrade will fix it.</p>
<p>Browse more fixes from this same upgrade in the <a href="/guides-fixes">Guides &amp; Fixes</a> archive.</p>]]></content:encoded>
      <pubDate>Wed, 05 Aug 2026 00:00:00 GMT</pubDate>
    </item>
    <item>
      <title>Fix @tailwindcss/vite Rolldown Resolver Crash</title>
      <link>https://bytetech247.com/guides-fixes/fix-tailwindcss-vite-rolldown-resolver-crash/</link>
      <guid isPermaLink="true">https://bytetech247.com/guides-fixes/fix-tailwindcss-vite-rolldown-resolver-crash/</guid>
      <description>Fix @tailwindcss/vite crashing with &apos;Missing field tsconfigPaths&apos; under Vite 8&apos;s Rolldown resolver. Upgrade Vite and Rolldown to clear it.</description>
      <content:encoded><![CDATA[<aside class="quick-answer" aria-label="Quick answer"><h2 id="quick-answer">Quick Answer</h2>
<p><code>@tailwindcss/vite</code> can crash with <code>Missing field `tsconfigPaths` on BindingViteResolvePluginConfig.resolveOptions</code> on Vite 8.0.10 with <code>rolldown@1.0.0-rc.17</code>, because Vite’s resolver API stopped populating every field the native Rolldown binding expects. Upgrade both <code>vite</code> and <code>rolldown</code> to a current release. The gap doesn’t reproduce on newer patches.</p>
</aside><h2 id="why-the-resolver-crashes">Why the resolver crashes</h2>
<p>Vite 8’s dependency resolver runs on Rolldown, a native (Rust) binding exposed to JavaScript through a public resolver API. Vite’s own code is supposed to fully populate that binding’s config object before handing it to consuming plugins. In Vite 8.0.10, paired with <code>rolldown@1.0.0-rc.17</code>, that public API stopped populating every field the native binding expects.</p>
<p><code>@tailwindcss/vite</code> calls Vite’s resolver directly during its own build-time CSS-generation step, to locate the <code>tailwindcss</code> package itself on disk. With the config object incomplete, that lookup throws instead of resolving:</p>
<div class="code-block"><div class="code-block__header"><span class="code-block__filename"></span><button type="button" class="code-block__copy" data-copy-button>Copy</button></div><pre class="astro-code github-dark" style="background-color:#24292e;color:#e1e4e8;overflow-x:auto" tabindex="0" data-language="plaintext"><code><span class="line"><span>[@tailwindcss/vite:generate:build] Missing field `tsconfigPaths` on BindingViteResolvePluginConfig.resolveOptions</span></span></code></pre></div>
<p>The same underlying resolver gap was reported twice within a week: once directly against Vite (<a href="https://github.com/vitejs/vite/issues/22322">vitejs/vite#22322</a>, opened 2026-04-24) and once against Astro’s own <code>astro add tailwind</code> flow, which installs <code>@tailwindcss/vite</code> by default (<a href="https://github.com/withastro/astro/issues/16542">withastro/astro#16542</a>, opened 2026-04-30, “astro add tailwind installs incompatible @tailwindcss/vite”). Both point at the same root cause: an incomplete Rolldown resolver config, not a Tailwind bug.</p>
<h2 id="fix-it-upgrade-vite-and-rolldown">Fix it: upgrade Vite and Rolldown</h2>
<h3 id="before-the-crash">Before: the crash</h3>
<div class="code-block code-block--has-filename"><div class="code-block__header"><span class="code-block__filename">npm run build on the affected versions</span><button type="button" class="code-block__copy" data-copy-button>Copy</button></div><pre class="astro-code github-dark" style="background-color:#24292e;color:#e1e4e8;overflow-x:auto" tabindex="0" data-language="bash" data-filename="npm run build on the affected versions"><code><span class="line"><span style="color:#B392F0">vite</span><span style="color:#9ECBFF"> v8.0.10</span><span style="color:#9ECBFF"> building</span><span style="color:#9ECBFF"> for</span><span style="color:#9ECBFF"> production...</span></span>
<span class="line"><span style="color:#E1E4E8">[@tailwindcss/vite:generate:build] Missing field </span><span style="color:#9ECBFF">`</span><span style="color:#B392F0">tsconfigPaths</span><span style="color:#9ECBFF">`</span><span style="color:#B392F0"> on</span><span style="color:#9ECBFF"> BindingViteResolvePluginConfig.resolveOptions</span></span></code></pre></div>
<h3 id="after-bump-both-packages">After: bump both packages</h3>
<div class="code-block code-block--has-filename"><div class="code-block__header"><span class="code-block__filename">fix: upgrade vite and rolldown together</span><button type="button" class="code-block__copy" data-copy-button>Copy</button></div><pre class="astro-code github-dark" style="background-color:#24292e;color:#e1e4e8;overflow-x:auto" tabindex="0" data-language="bash" data-filename="fix: upgrade vite and rolldown together"><code><span class="line"><span style="color:#B392F0">npm</span><span style="color:#9ECBFF"> install</span><span style="color:#9ECBFF"> vite@latest</span><span style="color:#9ECBFF"> rolldown@latest</span></span>
<span class="line"><span style="color:#B392F0">npm</span><span style="color:#9ECBFF"> run</span><span style="color:#9ECBFF"> build</span></span></code></pre></div>
<p>Upgrading just <code>vite</code> isn’t always enough on its own, since <code>rolldown</code> is a separate package in your lockfile and npm won’t necessarily pull a newer one unless something forces it. Check both versions directly if the crash persists after a plain <code>vite</code> bump:</p>
<div class="code-block code-block--has-filename"><div class="code-block__header"><span class="code-block__filename">confirm the resolved versions</span><button type="button" class="code-block__copy" data-copy-button>Copy</button></div><pre class="astro-code github-dark" style="background-color:#24292e;color:#e1e4e8;overflow-x:auto" tabindex="0" data-language="bash" data-filename="confirm the resolved versions"><code><span class="line"><span style="color:#B392F0">npm</span><span style="color:#9ECBFF"> ls</span><span style="color:#9ECBFF"> vite</span><span style="color:#9ECBFF"> rolldown</span></span></code></pre></div>
<div class="callout" role="note" data-astro-cid-q2ml7llr><p class="callout__title" data-astro-cid-q2ml7llr><span aria-hidden="true" data-astro-cid-q2ml7llr>⚠</span>No PostCSS fallback needed if you&#39;re already past this range</p><div class="callout__body" data-astro-cid-q2ml7llr><p>If your lockfile already resolves <code>vite</code> and <code>rolldown</code> newer than the
versions above, you were never exposed to this crash. There’s nothing to fix.
Check your resolved versions before changing anything.</p></div></div>
<h2 id="confirmed-version-range">Confirmed version range</h2>
<p>Confirmed <strong>not reproducible</strong> against this site’s own dependencies: <code>vite@8.1.5</code> and <code>rolldown@1.1.5</code> (both resolved via <code>astro@7.1.3</code>’s <code>&quot;vite&quot;: &quot;^8.0.13&quot;</code> dependency), with <code>@tailwindcss/vite</code> actively wired into <code>astro.config.mjs</code>’s <code>vite.plugins</code> array on this exact site. <code>npm run build</code> completes cleanly. The resolver gap appears to have been closed somewhere between <code>rolldown@1.0.0-rc.17</code> and <code>1.1.5</code>, even though neither GitHub issue was closed with a linked fix PR. Both were closed as “not planned,” which usually means later dependency updates made the original report stale rather than a deliberate patch.</p>
<p>If you’re hitting this today, you’re very likely on an intermediate Vite 8.0.x patch. Upgrading is the fix, not a workaround. Browse more posts like this in the <a href="/guides-fixes">Guides &amp; Fixes</a> archive.</p>]]></content:encoded>
      <pubDate>Wed, 05 Aug 2026 00:00:00 GMT</pubDate>
    </item>
    <item>
      <title>Fix Vite 8 Browser/Module Field Resolution Change</title>
      <link>https://bytetech247.com/guides-fixes/fix-vite-8-browser-module-field-resolution/</link>
      <guid isPermaLink="true">https://bytetech247.com/guides-fixes/fix-vite-8-browser-module-field-resolution/</guid>
      <description>Fix Vite 8 removing format sniffing between package.json browser and module fields. Explicit resolve.mainFields order now matters.</description>
      <content:encoded><![CDATA[<aside class="quick-answer" aria-label="Quick answer"><h2 id="quick-answer">Quick Answer</h2>
<p>Vite 8 removes format sniffing: it no longer inspects a package’s actual file content to guess whether its <code>browser</code> or <code>module</code> field is the right entry point. <code>resolve.mainFields</code>’s declared order is now always followed literally. A dual-published package that only worked by accident under the old sniffing fallback can resolve to the wrong entry after upgrading.</p>
</aside><h2 id="why-the-resolved-file-changes">Why the resolved file changes</h2>
<p>Some npm packages publish both a <code>browser</code> field and a <code>module</code> field in <code>package.json</code>, pointing at differently-shaped code, commonly a CJS-flavored <code>browser</code> build alongside an ESM <code>module</code> build, or vice versa. <a href="https://vite.dev/guide/migration">Vite’s migration guide</a> documents the removal directly, under “Module Resolution Updates”: “Module resolution using format sniffing (selecting between <code>browser</code> and <code>module</code> fields based on content) has been removed.”</p>
<p>Vite 7 and earlier could look past a misconfigured or misleading <code>mainFields</code> order by inspecting the actual file content each field pointed to, and picking whichever one looked like the right format. That fallback papered over packages with an inconsistent <code>package.json</code>. The declared field order didn’t have to be exactly right, because Vite would sniff its way to a working result anyway.</p>
<p>Vite 8 removes that inference entirely. <code>resolve.mainFields</code>’s order is now followed literally, with no content-based override. A package that only resolved correctly before because of the sniffing fallback can now resolve to the wrong (or straight-up broken) entry point, with no signal from Vite itself that anything changed. It’s still resolving <em>a</em> file, just not the one that used to work.</p>
<h2 id="fix-it-set-an-explicit-correct-field-order">Fix it: set an explicit, correct field order</h2>
<h3 id="before-relying-on-the-removed-fallback">Before: relying on the removed fallback</h3>
<div class="code-block code-block--has-filename"><div class="code-block__header"><span class="code-block__filename">vite.config.ts</span><button type="button" class="code-block__copy" data-copy-button>Copy</button></div><pre class="astro-code github-dark" style="background-color:#24292e;color:#e1e4e8;overflow-x:auto" tabindex="0" data-language="js" data-filename="vite.config.ts"><code><span class="line"><span style="color:#F97583">export</span><span style="color:#F97583"> default</span><span style="color:#B392F0"> defineConfig</span><span style="color:#E1E4E8">({</span></span>
<span class="line"><span style="color:#E1E4E8">  resolve: {</span></span>
<span class="line"><span style="color:#9ca6b0">    // no explicit mainFields — Vite 7 could sniff file content</span></span>
<span class="line"><span style="color:#9ca6b0">    // to still pick the right field even with a suboptimal order (BROKEN in Vite 8)</span></span>
<span class="line"><span style="color:#E1E4E8">  },</span></span>
<span class="line"><span style="color:#E1E4E8">});</span></span></code></pre></div>
<h3 id="after-an-explicit-deliberate-order">After: an explicit, deliberate order</h3>
<div class="code-block code-block--has-filename"><div class="code-block__header"><span class="code-block__filename">vite.config.ts</span><button type="button" class="code-block__copy" data-copy-button>Copy</button></div><pre class="astro-code github-dark" style="background-color:#24292e;color:#e1e4e8;overflow-x:auto" tabindex="0" data-language="js" data-filename="vite.config.ts"><code><span class="line"><span style="color:#F97583">export</span><span style="color:#F97583"> default</span><span style="color:#B392F0"> defineConfig</span><span style="color:#E1E4E8">({</span></span>
<span class="line"><span style="color:#E1E4E8">  resolve: {</span></span>
<span class="line"><span style="color:#E1E4E8">    mainFields: [</span><span style="color:#9ECBFF">&quot;browser&quot;</span><span style="color:#E1E4E8">, </span><span style="color:#9ECBFF">&quot;module&quot;</span><span style="color:#E1E4E8">, </span><span style="color:#9ECBFF">&quot;main&quot;</span><span style="color:#E1E4E8">], </span><span style="color:#9ca6b0">// FIXED: literal order, no sniffing fallback</span></span>
<span class="line"><span style="color:#E1E4E8">  },</span></span>
<span class="line"><span style="color:#E1E4E8">});</span></span></code></pre></div>
<p>The right order depends on your build target, not a universal default. A browser-targeted app generally wants <code>browser</code> checked first; an SSR or Node-target build usually wants <code>module</code> (or <code>main</code>) ahead of <code>browser</code>, since a package’s <code>browser</code> field often assumes a browser environment that doesn’t exist in Node.</p>
<div class="callout" role="note" data-astro-cid-q2ml7llr><p class="callout__title" data-astro-cid-q2ml7llr><span aria-hidden="true" data-astro-cid-q2ml7llr>✓</span>Diagnosing which field a package actually needs</p><div class="callout__body" data-astro-cid-q2ml7llr><p>Open the affected package’s own <code>package.json</code> and compare what each of
<code>browser</code>, <code>module</code>, and <code>main</code> actually points to. Sometimes one of them is
stale or simply wrong, which sniffing used to mask. If the package itself has
the mismatch, report it upstream; reordering <code>mainFields</code> on your end is a
workaround for their config, not a fix for it.</p></div></div>
<h2 id="confirmed-version-range">Confirmed version range</h2>
<p>Documented in Vite’s own current migration guide as an intentional Vite 8 removal, not a bug to be patched. This site’s own dependency tree doesn’t include a package relying on browser/module format sniffing, so this specific resolution change wasn’t independently reproducible against this repo’s own build. The mechanism applies to any Vite 8 project depending on a dual-published package whose <code>package.json</code> field order doesn’t already match what <code>resolve.mainFields</code> expects. Cross-reference this with <a href="/guides-fixes/fix-vite-8-lightning-css-backdrop-filter/">Fix Vite 8 Lightning CSS Dropping backdrop-filter</a>, a different silent default-behavior change in the same Vite 8 build step. Browse more posts like this in the <a href="/guides-fixes">Guides &amp; Fixes</a> archive.</p>]]></content:encoded>
      <pubDate>Wed, 05 Aug 2026 00:00:00 GMT</pubDate>
    </item>
    <item>
      <title>Fix Vite 8&apos;s build.rollupOptions Deprecation</title>
      <link>https://bytetech247.com/guides-fixes/fix-vite-8-build-rollupoptions-deprecation/</link>
      <guid isPermaLink="true">https://bytetech247.com/guides-fixes/fix-vite-8-build-rollupoptions-deprecation/</guid>
      <description>Fix the build.rollupOptions deprecation warning in Vite 8. Rename to build.rolldownOptions before the old key is removed.</description>
      <content:encoded><![CDATA[<aside class="quick-answer" aria-label="Quick answer"><h2 id="quick-answer">Quick Answer</h2>
<p>Vite 8 renames <code>build.rollupOptions</code> to <code>build.rolldownOptions</code> (and <code>worker.rollupOptions</code> to <code>worker.rolldownOptions</code>), since Rolldown replaces Rollup as the production bundler. The old key still works today, deprecated and auto-mapped, but any project customizing <code>manualChunks</code>, <code>external</code>, or <code>output</code> under it now prints a deprecation warning on every build. Rename the key to silence it.</p>
</aside><h2 id="why-the-key-was-renamed">Why the key was renamed</h2>
<p>Rolldown, not Rollup, is Vite 8’s production bundler. <a href="https://vite.dev/guide/migration">Vite’s migration guide</a> lists the rename directly under “Deprecated Options”: <code>build.rollupOptions</code> → <code>build.rolldownOptions</code>, alongside the same rename for <code>worker.rollupOptions</code> → <code>worker.rolldownOptions</code>.</p>
<p><code>build.rollupOptions</code> is one of the single most commonly customized Vite options. It’s where <code>manualChunks</code>, <code>external</code>, and custom <code>output</code> naming patterns live for most real projects. That popularity is exactly why this deprecation is loud: any project with a nontrivial build config sees the warning on every single build the moment it upgrades to Vite 8, even though nothing is actually broken yet.</p>
<h2 id="fix-it-rename-the-key">Fix it: rename the key</h2>
<h3 id="before-the-deprecated-key">Before: the deprecated key</h3>
<div class="code-block code-block--has-filename"><div class="code-block__header"><span class="code-block__filename">vite.config.ts</span><button type="button" class="code-block__copy" data-copy-button>Copy</button></div><pre class="astro-code github-dark" style="background-color:#24292e;color:#e1e4e8;overflow-x:auto" tabindex="0" data-language="js" data-filename="vite.config.ts"><code><span class="line"><span style="color:#F97583">export</span><span style="color:#F97583"> default</span><span style="color:#B392F0"> defineConfig</span><span style="color:#E1E4E8">({</span></span>
<span class="line"><span style="color:#E1E4E8">  build: {</span></span>
<span class="line"><span style="color:#E1E4E8">    rollupOptions: {</span></span>
<span class="line"><span style="color:#E1E4E8">      output: {</span></span>
<span class="line"><span style="color:#B392F0">        manualChunks</span><span style="color:#E1E4E8">(</span><span style="color:#FFAB70">id</span><span style="color:#E1E4E8">) {</span></span>
<span class="line"><span style="color:#F97583">          if</span><span style="color:#E1E4E8"> (id.</span><span style="color:#B392F0">includes</span><span style="color:#E1E4E8">(</span><span style="color:#9ECBFF">&quot;node_modules&quot;</span><span style="color:#E1E4E8">)) </span><span style="color:#F97583">return</span><span style="color:#9ECBFF"> &quot;vendor&quot;</span><span style="color:#E1E4E8">;</span></span>
<span class="line"><span style="color:#E1E4E8">        },</span></span>
<span class="line"><span style="color:#E1E4E8">      },</span></span>
<span class="line"><span style="color:#E1E4E8">    },</span></span>
<span class="line"><span style="color:#E1E4E8">  },</span></span>
<span class="line"><span style="color:#E1E4E8">});</span></span></code></pre></div>
<h3 id="after-the-same-config-under-the-new-key">After: the same config under the new key</h3>
<div class="code-block code-block--has-filename"><div class="code-block__header"><span class="code-block__filename">vite.config.ts</span><button type="button" class="code-block__copy" data-copy-button>Copy</button></div><pre class="astro-code github-dark" style="background-color:#24292e;color:#e1e4e8;overflow-x:auto" tabindex="0" data-language="js" data-filename="vite.config.ts"><code><span class="line"><span style="color:#F97583">export</span><span style="color:#F97583"> default</span><span style="color:#B392F0"> defineConfig</span><span style="color:#E1E4E8">({</span></span>
<span class="line"><span style="color:#E1E4E8">  build: {</span></span>
<span class="line"><span style="color:#E1E4E8">    rolldownOptions: {</span></span>
<span class="line"><span style="color:#E1E4E8">      output: {</span></span>
<span class="line"><span style="color:#B392F0">        manualChunks</span><span style="color:#E1E4E8">(</span><span style="color:#FFAB70">id</span><span style="color:#E1E4E8">) {</span></span>
<span class="line"><span style="color:#F97583">          if</span><span style="color:#E1E4E8"> (id.</span><span style="color:#B392F0">includes</span><span style="color:#E1E4E8">(</span><span style="color:#9ECBFF">&quot;node_modules&quot;</span><span style="color:#E1E4E8">)) </span><span style="color:#F97583">return</span><span style="color:#9ECBFF"> &quot;vendor&quot;</span><span style="color:#E1E4E8">;</span></span>
<span class="line"><span style="color:#E1E4E8">        },</span></span>
<span class="line"><span style="color:#E1E4E8">      },</span></span>
<span class="line"><span style="color:#E1E4E8">    },</span></span>
<span class="line"><span style="color:#E1E4E8">  },</span></span>
<span class="line"><span style="color:#E1E4E8">});</span></span></code></pre></div>
<p>Nothing inside the object changes. If your project also customizes <code>worker.rollupOptions</code> for a web worker build, rename that key the same way.</p>
<div class="callout" role="note" data-astro-cid-q2ml7llr><p class="callout__title" data-astro-cid-q2ml7llr><span aria-hidden="true" data-astro-cid-q2ml7llr>✓</span>Check manualChunks while you&#39;re in there</p><div class="callout__body" data-astro-cid-q2ml7llr><p>If your <code>manualChunks</code> uses the older object-map shorthand instead of a
function, the rename alone won’t fix your build. Rolldown only supports the
function form. See <a href="/guides-fixes/fix-vite-8-manualchunks-object-form-removed/">Fix Vite 8 manualChunks Object Form
Removed</a> for that
separate, sharper break.</p></div></div>
<h2 id="confirmed-version-range">Confirmed version range</h2>
<p>Documented in Vite’s own current migration guide as an intentional Vite 8 rename, auto-mapped for backward compatibility today. This site’s own <code>astro.config.mjs</code> doesn’t customize <code>build.rollupOptions</code> at all, so this specific deprecation warning doesn’t fire on this repo’s own build. The rename still applies to any project that does customize it, which is common enough to be worth fixing proactively rather than waiting for a future major version to remove the old key outright. Browse more posts like this in the <a href="/guides-fixes">Guides &amp; Fixes</a> archive.</p>]]></content:encoded>
      <pubDate>Wed, 05 Aug 2026 00:00:00 GMT</pubDate>
    </item>
    <item>
      <title>Fix Vite 8 CommonJS Default Import Breaking Change</title>
      <link>https://bytetech247.com/guides-fixes/fix-vite-8-commonjs-default-import-change/</link>
      <guid isPermaLink="true">https://bytetech247.com/guides-fixes/fix-vite-8-commonjs-default-import-change/</guid>
      <description>Fix Vite 8 breaking CommonJS default imports under Rolldown&apos;s stricter interop. Restore the old behavior with one legacy config flag.</description>
      <content:encoded><![CDATA[<aside class="quick-answer" aria-label="Quick answer"><h2 id="quick-answer">Quick Answer</h2>
<p>Vite 8’s Rolldown bundler standardizes CommonJS default-import resolution, which can make a default import that used to work suddenly return the wrong value, throwing <code>TypeError: &lt;x&gt; is not a function</code>. Set <code>legacy.inconsistentCjsInterop: true</code> in your Vite config as a temporary escape hatch while you audit affected dependencies.</p>
</aside><h2 id="why-the-import-suddenly-breaks">Why the import suddenly breaks</h2>
<p>Rollup’s CommonJS interop (Vite 7 and earlier) was permissive and could vary depending on exactly how a module was loaded. Vite 8 replaces it with Rolldown’s stricter, standardized rule, stated verbatim in <a href="https://vite.dev/guide/migration">Vite’s own migration guide</a>: “Default import handling from CommonJS modules now operates consistently. The default import represents <code>module.exports</code> when: the importer is <code>.mjs</code> or <code>.mts</code>, the closest <code>package.json</code> specifies <code>type: &quot;module&quot;</code>, the importee’s <code>module.exports.__esModule</code> is not <code>true</code>.”</p>
<p>Older CommonJS packages commonly export a single function directly, without setting <code>module.exports.__esModule</code>:</p>
<div class="code-block code-block--has-filename"><div class="code-block__header"><span class="code-block__filename">node_modules/some-legacy-lib/index.js - a real, older CJS pattern</span><button type="button" class="code-block__copy" data-copy-button>Copy</button></div><pre class="astro-code github-dark" style="background-color:#24292e;color:#e1e4e8;overflow-x:auto" tabindex="0" data-language="js" data-filename="node_modules/some-legacy-lib/index.js - a real, older CJS pattern"><code><span class="line"><span style="color:#79B8FF">module</span><span style="color:#E1E4E8">.</span><span style="color:#79B8FF">exports</span><span style="color:#F97583"> =</span><span style="color:#F97583"> function</span><span style="color:#B392F0"> doThing</span><span style="color:#E1E4E8">() {</span></span>
<span class="line"><span style="color:#9ca6b0">  /* ... */</span></span>
<span class="line"><span style="color:#E1E4E8">};</span></span></code></pre></div>
<p>Under Rollup’s old interop, a default import from a package like this could resolve to the function itself in some cases. Under Rolldown’s rule above, the same import now consistently resolves to <code>module.exports</code> as a whole, which, since it <em>is</em> the function here, should still work, but plenty of real packages wrap their export differently enough that the resolved value stops being callable. The failure only shows up at the call site, once code tries to invoke something that’s no longer a function:</p>
<div class="code-block code-block--has-filename"><div class="code-block__header"><span class="code-block__filename">your code - unchanged, but the import now resolves differently</span><button type="button" class="code-block__copy" data-copy-button>Copy</button></div><pre class="astro-code github-dark" style="background-color:#24292e;color:#e1e4e8;overflow-x:auto" tabindex="0" data-language="js" data-filename="your code - unchanged, but the import now resolves differently"><code><span class="line"><span style="color:#F97583">import</span><span style="color:#E1E4E8"> doThing </span><span style="color:#F97583">from</span><span style="color:#9ECBFF"> &quot;some-legacy-lib&quot;</span><span style="color:#E1E4E8">;</span></span>
<span class="line"></span>
<span class="line"><span style="color:#B392F0">doThing</span><span style="color:#E1E4E8">(); </span><span style="color:#9ca6b0">// TypeError: doThing is not a function</span></span></code></pre></div>
<p>Nothing about your code or the dependency changed. Only the bundler’s interop rule did.</p>
<h2 id="fix-it-restore-the-old-interop-temporarily">Fix it: restore the old interop temporarily</h2>
<h3 id="before-default-vite-8-config">Before: default Vite 8 config</h3>
<div class="code-block code-block--has-filename"><div class="code-block__header"><span class="code-block__filename">vite.config.ts</span><button type="button" class="code-block__copy" data-copy-button>Copy</button></div><pre class="astro-code github-dark" style="background-color:#24292e;color:#e1e4e8;overflow-x:auto" tabindex="0" data-language="js" data-filename="vite.config.ts"><code><span class="line"><span style="color:#F97583">export</span><span style="color:#F97583"> default</span><span style="color:#B392F0"> defineConfig</span><span style="color:#E1E4E8">({</span></span>
<span class="line"><span style="color:#9ca6b0">  // no legacy override — Rolldown&#39;s strict CJS interop applies (BROKEN</span></span>
<span class="line"><span style="color:#9ca6b0">  // for packages relying on the old permissive resolution)</span></span>
<span class="line"><span style="color:#E1E4E8">});</span></span></code></pre></div>
<h3 id="after-opt-back-into-the-old-behavior">After: opt back into the old behavior</h3>
<div class="code-block code-block--has-filename"><div class="code-block__header"><span class="code-block__filename">vite.config.ts</span><button type="button" class="code-block__copy" data-copy-button>Copy</button></div><pre class="astro-code github-dark" style="background-color:#24292e;color:#e1e4e8;overflow-x:auto" tabindex="0" data-language="js" data-filename="vite.config.ts"><code><span class="line"><span style="color:#F97583">export</span><span style="color:#F97583"> default</span><span style="color:#B392F0"> defineConfig</span><span style="color:#E1E4E8">({</span></span>
<span class="line"><span style="color:#E1E4E8">  legacy: {</span></span>
<span class="line"><span style="color:#E1E4E8">    inconsistentCjsInterop: </span><span style="color:#79B8FF">true</span><span style="color:#E1E4E8">, </span><span style="color:#9ca6b0">// FIXED: restores Vite 7&#39;s permissive interop</span></span>
<span class="line"><span style="color:#E1E4E8">  },</span></span>
<span class="line"><span style="color:#E1E4E8">});</span></span></code></pre></div>
<div class="callout" role="note" data-astro-cid-q2ml7llr><p class="callout__title" data-astro-cid-q2ml7llr><span aria-hidden="true" data-astro-cid-q2ml7llr>⚠</span>This only fixes one specific failure mode</p><div class="callout__body" data-astro-cid-q2ml7llr><p><code>legacy.inconsistentCjsInterop</code> restores Rollup’s permissive default-export
resolution. It does nothing for other CJS/ESM interop differences, like
execution-order changes or dynamic <code>import()</code> regressions. If your crash
doesn’t match the exact “default import resolves to the wrong value” shape,
this flag won’t fix it.</p></div></div>
<h2 id="confirmed-version-range">Confirmed version range</h2>
<p>Documented in Vite’s own current migration guide as an intentional Vite 8 behavior change, not a bug. <code>legacy.inconsistentCjsInterop</code> is the officially documented escape hatch, not a community workaround. This site’s own dependencies are all modern ESM-first packages, so this specific interop change wasn’t independently reproducible against this repo’s own build; treat the fix above as effective for any Vite 8 project hitting the exact <code>TypeError: &lt;x&gt; is not a function</code> shape on a previously-working default import from a CommonJS dependency. See also <a href="/guides-fixes/fix-vite-8-externalized-require-behavior-change/">Fix Vite 8 Externalized require() Behavior Change</a>, a related but distinct change to the same CJS/ESM boundary, from the require side rather than the import side. Browse more posts like this in the <a href="/guides-fixes">Guides &amp; Fixes</a> archive.</p>]]></content:encoded>
      <pubDate>Wed, 05 Aug 2026 00:00:00 GMT</pubDate>
    </item>
    <item>
      <title>Fix Vite 8 Externalized require() Behavior Change</title>
      <link>https://bytetech247.com/guides-fixes/fix-vite-8-externalized-require-behavior-change/</link>
      <guid isPermaLink="true">https://bytetech247.com/guides-fixes/fix-vite-8-externalized-require-behavior-change/</guid>
      <description>Fix Vite 8 no longer converting require() of externals into import statements. ESM-only output can now throw at runtime.</description>
      <content:encoded><![CDATA[<aside class="quick-answer" aria-label="Quick answer"><h2 id="quick-answer">Quick Answer</h2>
<p>Vite 8 stops rewriting <code>require()</code> calls for externalized dependencies into ESM <code>import</code> statements. A <code>require()</code> call now stays literal in the output, so ESM-only output (an <code>.mjs</code> build, or any Node context without CJS interop) can throw <code>ReferenceError: require is not defined</code> at runtime. Rolldown documents an <code>esmExternalRequirePlugin</code> as the opt-in path back to the old conversion, or sidestep it entirely by not marking the dependency external, or by using Node’s own <code>createRequire</code> interop explicitly.</p>
</aside><h2 id="why-the-require-call-survives-untouched">Why the require() call survives untouched</h2>
<p>When a dependency is marked external (not bundled, common for SSR/Node-target builds and for libraries expecting the consumer to provide certain packages), the bundler still has to emit <em>something</em> at every place your code imports it. <a href="https://vite.dev/guide/migration">Vite’s migration guide</a> documents exactly what changed here: “Require calls for externalized modules are now preserved as require calls and not converted to import statements.”</p>
<p>Vite 7 and earlier rewrote a bundled <code>require(&quot;external-pkg&quot;)</code> call into a real ESM <code>import</code> statement targeting that external, so the output stayed valid ESM regardless of how the original source code referenced the dependency. Vite 8 stops doing that rewrite. Whatever form the original call took, <code>require()</code> or <code>import</code>, survives into the output unchanged.</p>
<p>That’s a real problem specifically when the output itself is meant to run as ESM. A literal <code>require()</code> call in an <code>.mjs</code> file, or any module Node loads under <code>&quot;type&quot;: &quot;module&quot;</code>, throws at runtime, because nothing in that file (or Node’s own ESM loader) defines a <code>require</code> function by default.</p>
<h2 id="fix-it-avoid-depending-on-the-old-conversion">Fix it: avoid depending on the old conversion</h2>
<h3 id="option-1-dont-mark-the-dependency-external">Option 1: don’t mark the dependency external</h3>
<p>The most reliable fix is the one that doesn’t depend on Vite’s bundling behavior at all. If you don’t specifically need the dependency to stay external, bundle it instead:</p>
<div class="code-block code-block--has-filename"><div class="code-block__header"><span class="code-block__filename">vite.config.ts - before</span><button type="button" class="code-block__copy" data-copy-button>Copy</button></div><pre class="astro-code github-dark" style="background-color:#24292e;color:#e1e4e8;overflow-x:auto" tabindex="0" data-language="js" data-filename="vite.config.ts - before"><code><span class="line"><span style="color:#F97583">export</span><span style="color:#F97583"> default</span><span style="color:#B392F0"> defineConfig</span><span style="color:#E1E4E8">({</span></span>
<span class="line"><span style="color:#E1E4E8">  build: {</span></span>
<span class="line"><span style="color:#E1E4E8">    rollupOptions: {</span></span>
<span class="line"><span style="color:#E1E4E8">      external: [</span><span style="color:#9ECBFF">&quot;some-node-only-pkg&quot;</span><span style="color:#E1E4E8">], </span><span style="color:#9ca6b0">// BROKEN: require() to this survives raw in ESM output</span></span>
<span class="line"><span style="color:#E1E4E8">    },</span></span>
<span class="line"><span style="color:#E1E4E8">  },</span></span>
<span class="line"><span style="color:#E1E4E8">});</span></span></code></pre></div>
<div class="code-block code-block--has-filename"><div class="code-block__header"><span class="code-block__filename">vite.config.ts - after</span><button type="button" class="code-block__copy" data-copy-button>Copy</button></div><pre class="astro-code github-dark" style="background-color:#24292e;color:#e1e4e8;overflow-x:auto" tabindex="0" data-language="js" data-filename="vite.config.ts - after"><code><span class="line"><span style="color:#F97583">export</span><span style="color:#F97583"> default</span><span style="color:#B392F0"> defineConfig</span><span style="color:#E1E4E8">({</span></span>
<span class="line"><span style="color:#E1E4E8">  build: {</span></span>
<span class="line"><span style="color:#E1E4E8">    rollupOptions: {</span></span>
<span class="line"><span style="color:#9ca6b0">      // FIXED: not external, Vite bundles it, no require()/import</span></span>
<span class="line"><span style="color:#9ca6b0">      // mismatch possible because nothing is left unresolved</span></span>
<span class="line"><span style="color:#E1E4E8">    },</span></span>
<span class="line"><span style="color:#E1E4E8">  },</span></span>
<span class="line"><span style="color:#E1E4E8">});</span></span></code></pre></div>
<h3 id="option-2-interop-explicitly-with-nodes-own-createrequire">Option 2: interop explicitly with Node’s own createRequire</h3>
<p>If the dependency genuinely needs to stay external (a peer dependency, or something environment-specific), don’t rely on the bundler to paper over the CJS/ESM boundary. Handle it explicitly with Node’s own supported interop:</p>
<div class="code-block code-block--has-filename"><div class="code-block__header"><span class="code-block__filename">entry.mjs - explicit interop, works regardless of bundler behavior</span><button type="button" class="code-block__copy" data-copy-button>Copy</button></div><pre class="astro-code github-dark" style="background-color:#24292e;color:#e1e4e8;overflow-x:auto" tabindex="0" data-language="js" data-filename="entry.mjs - explicit interop, works regardless of bundler behavior"><code><span class="line"><span style="color:#F97583">import</span><span style="color:#E1E4E8"> { createRequire } </span><span style="color:#F97583">from</span><span style="color:#9ECBFF"> &quot;node:module&quot;</span><span style="color:#E1E4E8">;</span></span>
<span class="line"><span style="color:#F97583">const</span><span style="color:#79B8FF"> require</span><span style="color:#F97583"> =</span><span style="color:#B392F0"> createRequire</span><span style="color:#E1E4E8">(</span><span style="color:#F97583">import</span><span style="color:#E1E4E8">.</span><span style="color:#79B8FF">meta</span><span style="color:#E1E4E8">.url);</span></span>
<span class="line"></span>
<span class="line"><span style="color:#F97583">const</span><span style="color:#79B8FF"> somePkg</span><span style="color:#F97583"> =</span><span style="color:#B392F0"> require</span><span style="color:#E1E4E8">(</span><span style="color:#9ECBFF">&quot;some-node-only-pkg&quot;</span><span style="color:#E1E4E8">); </span><span style="color:#9ca6b0">// FIXED: real require, defined explicitly</span></span></code></pre></div>
<p>This is the same pattern Node itself documents for using <code>require()</code> inside an ESM module. It doesn’t depend on Vite 8, Rolldown, or any bundler-specific compatibility plugin, so it keeps working even if this exact behavior changes again in a future Vite release.</p>
<div class="callout" role="note" data-astro-cid-q2ml7llr><p class="callout__title" data-astro-cid-q2ml7llr><span aria-hidden="true" data-astro-cid-q2ml7llr>✓</span>A third option exists, but verify it yourself before relying on it</p><div class="callout__body" data-astro-cid-q2ml7llr><p>Rolldown documents an <code>esmExternalRequirePlugin</code> specifically for converting
external <code>require()</code> calls back into <code>import</code> statements. Check Rolldown’s own
current plugin documentation for the exact import path and usage before adding
it. Plugin APIs for a tool this new can shift between releases faster than a
bundler’s stable config surface.</p></div></div>
<h2 id="confirmed-version-range">Confirmed version range</h2>
<p>Documented in Vite’s own current migration guide as an intentional Vite 8 behavior change, under “External Module Requires.” This site deploys as a static build with no SSR externals, so this specific behavior change wasn’t independently reproducible against this repo’s own build. The mechanism applies to any Vite 8 SSR or Node-target build with externalized CommonJS-style dependencies. See also <a href="/guides-fixes/fix-vite-8-commonjs-default-import-change/">Fix Vite 8 CommonJS Default Import Breaking Change</a>, a related but distinct change to how Vite 8 handles the CJS/ESM boundary, from the import side rather than the require side. Browse more posts like this in the <a href="/guides-fixes">Guides &amp; Fixes</a> archive.</p>]]></content:encoded>
      <pubDate>Wed, 05 Aug 2026 00:00:00 GMT</pubDate>
    </item>
    <item>
      <title>Fix Vite 8 Lightning CSS Dropping backdrop-filter</title>
      <link>https://bytetech247.com/guides-fixes/fix-vite-8-lightning-css-backdrop-filter/</link>
      <guid isPermaLink="true">https://bytetech247.com/guides-fixes/fix-vite-8-lightning-css-backdrop-filter/</guid>
      <description>Fix Vite 8&apos;s Lightning CSS minifier silently dropping unprefixed backdrop-filter in production builds. One cssMinify config line restores it.</description>
      <content:encoded><![CDATA[<aside class="quick-answer" aria-label="Quick answer"><h2 id="quick-answer">Quick Answer</h2>
<p>Vite 8 defaults CSS minification to Lightning CSS, which drops the unprefixed <code>backdrop-filter</code> property when a vendor-prefixed <code>-webkit-backdrop-filter</code> is also present, breaking glass/blur effects only in production builds. Set <code>build.cssMinify: &quot;esbuild&quot;</code> in your Vite (or Astro) config to restore Vite 7’s minifier and keep both properties.</p>
</aside><h2 id="why-lightning-css-drops-the-property">Why Lightning CSS drops the property</h2>
<p>Vite 8 flips <code>build.cssMinify</code>’s default from esbuild to <a href="https://lightningcss.dev/">Lightning CSS</a>, a Rust-based CSS minifier, as part of the same toolchain swap that replaced Rollup with Rolldown. The change is silent: nothing in your config has to opt in, and nothing warns you when it changes what ships.</p>
<p>The regression shows up specifically on the standard cross-browser <code>backdrop-filter</code> pattern:</p>
<div class="code-block code-block--has-filename"><div class="code-block__header"><span class="code-block__filename">the normal, correct authoring pattern</span><button type="button" class="code-block__copy" data-copy-button>Copy</button></div><pre class="astro-code github-dark" style="background-color:#24292e;color:#e1e4e8;overflow-x:auto" tabindex="0" data-language="css" data-filename="the normal, correct authoring pattern"><code><span class="line"><span style="color:#B392F0">.glass-panel</span><span style="color:#E1E4E8"> {</span></span>
<span class="line"><span style="color:#79B8FF">  backdrop-filter</span><span style="color:#E1E4E8">: </span><span style="color:#79B8FF">blur</span><span style="color:#E1E4E8">(</span><span style="color:#79B8FF">12</span><span style="color:#F97583">px</span><span style="color:#E1E4E8">);</span></span>
<span class="line"><span style="color:#79B8FF">  -webkit-backdrop-filter</span><span style="color:#E1E4E8">: </span><span style="color:#79B8FF">blur</span><span style="color:#E1E4E8">(</span><span style="color:#79B8FF">12</span><span style="color:#F97583">px</span><span style="color:#E1E4E8">);</span></span>
<span class="line"><span style="color:#E1E4E8">}</span></span></code></pre></div>
<p>Declaring both properties is deliberate, not redundant. Browsers that don’t need the <code>-webkit-</code> prefix ignore it under normal CSS cascade rules (an unrecognized property is simply skipped), and browsers that still need the prefix ignore the unprefixed version. Having both is the safe, standard way to support every browser at once.</p>
<p>Lightning CSS’s minifier reads it differently. When it sees both properties on the same rule, it assumes the prefixed version alone is sufficient and strips the unprefixed one as dead weight, backwards from what’s actually needed, since it’s the <em>unprefixed</em> property that modern, non-Safari browsers require to apply the effect at all. The bug was reported directly against this exact behavior: “<a href="https://github.com/vitejs/vite/issues/22649">Vite 8 default <code>cssMinify: &#39;lightningcss&#39;</code> drops unprefixed <code>backdrop-filter</code>, breaking glass/blur effects (regression vs Vite 7)</a>,” opened 2026-06-09 against Vite 8.0.16.</p>
<p>Because the bug only fires during minification, <code>astro dev</code> (and <code>vite dev</code>) never reproduce it. The CSS looks correct through local development and only breaks once you run a real production build.</p>
<h2 id="fix-it-pin-cssminify-back-to-esbuild">Fix it: pin cssMinify back to esbuild</h2>
<h3 id="before-default-vite-8-config">Before: default Vite 8 config</h3>
<div class="code-block code-block--has-filename"><div class="code-block__header"><span class="code-block__filename">vite.config.ts</span><button type="button" class="code-block__copy" data-copy-button>Copy</button></div><pre class="astro-code github-dark" style="background-color:#24292e;color:#e1e4e8;overflow-x:auto" tabindex="0" data-language="js" data-filename="vite.config.ts"><code><span class="line"><span style="color:#F97583">export</span><span style="color:#F97583"> default</span><span style="color:#B392F0"> defineConfig</span><span style="color:#E1E4E8">({</span></span>
<span class="line"><span style="color:#E1E4E8">  build: {</span></span>
<span class="line"><span style="color:#9ca6b0">    // no cssMinify override — Vite 8 defaults to Lightning CSS,</span></span>
<span class="line"><span style="color:#9ca6b0">    // which drops unprefixed backdrop-filter (BROKEN)</span></span>
<span class="line"><span style="color:#E1E4E8">  },</span></span>
<span class="line"><span style="color:#E1E4E8">});</span></span></code></pre></div>
<h3 id="after-explicit-esbuild-minifier">After: explicit esbuild minifier</h3>
<div class="code-block code-block--has-filename"><div class="code-block__header"><span class="code-block__filename">vite.config.ts</span><button type="button" class="code-block__copy" data-copy-button>Copy</button></div><pre class="astro-code github-dark" style="background-color:#24292e;color:#e1e4e8;overflow-x:auto" tabindex="0" data-language="js" data-filename="vite.config.ts"><code><span class="line"><span style="color:#F97583">export</span><span style="color:#F97583"> default</span><span style="color:#B392F0"> defineConfig</span><span style="color:#E1E4E8">({</span></span>
<span class="line"><span style="color:#E1E4E8">  build: {</span></span>
<span class="line"><span style="color:#E1E4E8">    cssMinify: </span><span style="color:#9ECBFF">&quot;esbuild&quot;</span><span style="color:#E1E4E8">, </span><span style="color:#9ca6b0">// FIXED: esbuild doesn&#39;t strip unprefixed backdrop-filter</span></span>
<span class="line"><span style="color:#E1E4E8">  },</span></span>
<span class="line"><span style="color:#E1E4E8">});</span></span></code></pre></div>
<p>For an Astro project, the same option lives under the <code>vite</code> key in <code>astro.config.mjs</code>:</p>
<div class="code-block code-block--has-filename"><div class="code-block__header"><span class="code-block__filename">astro.config.mjs</span><button type="button" class="code-block__copy" data-copy-button>Copy</button></div><pre class="astro-code github-dark" style="background-color:#24292e;color:#e1e4e8;overflow-x:auto" tabindex="0" data-language="js" data-filename="astro.config.mjs"><code><span class="line"><span style="color:#F97583">export</span><span style="color:#F97583"> default</span><span style="color:#B392F0"> defineConfig</span><span style="color:#E1E4E8">({</span></span>
<span class="line"><span style="color:#E1E4E8">  vite: {</span></span>
<span class="line"><span style="color:#E1E4E8">    build: {</span></span>
<span class="line"><span style="color:#E1E4E8">      cssMinify: </span><span style="color:#9ECBFF">&quot;esbuild&quot;</span><span style="color:#E1E4E8">, </span><span style="color:#9ca6b0">// FIXED: restores Vite 7&#39;s CSS minifier behavior</span></span>
<span class="line"><span style="color:#E1E4E8">    },</span></span>
<span class="line"><span style="color:#E1E4E8">  },</span></span>
<span class="line"><span style="color:#E1E4E8">});</span></span></code></pre></div>
<p>This is a one-line, whole-project fix. It trades Lightning CSS’s minification for esbuild’s on every stylesheet, not just the rule that’s breaking. Worth it until the underlying minifier bug is actually resolved, since there’s no way to scope <code>cssMinify</code> to a single file or rule.</p>
<div class="callout" role="note" data-astro-cid-q2ml7llr><p class="callout__title" data-astro-cid-q2ml7llr><span aria-hidden="true" data-astro-cid-q2ml7llr>ℹ</span>Not fixed upstream yet</p><div class="callout__body" data-astro-cid-q2ml7llr><p>The report was closed as a duplicate of a broader Lightning CSS minifier
issue, not resolved. If you’re hitting this today, pin <code>cssMinify</code> rather than
waiting on a patch. There’s no tracked fix version to upgrade to yet.</p></div></div>
<h2 id="confirmed-version-range">Confirmed version range</h2>
<p>Reported against Vite 8.0.16. This site’s own <code>vite@8.1.5</code> (resolved via <code>astro@7.1.3</code>’s <code>&quot;vite&quot;: &quot;^8.0.13&quot;</code> dependency) was not independently re-tested against this specific minifier bug, since the site doesn’t use <code>backdrop-filter</code> anywhere in its own stylesheets. Treat the fix above as effective for any Vite 8.0.x-8.1.x project using the default minifier until Lightning CSS ships a real patch.</p>
<p>If you’re not sure which minifier is active, check for an explicit <code>cssMinify</code> key in your Vite config first. Its absence means you’re on whatever Vite’s current default is, which is exactly what changed here. Cross-reference this with <a href="/guides-fixes/fix-vite-8-browser-module-field-resolution/">Fix Vite 8 Browser/Module Field Resolution Change</a>, a different silent default-behavior change in the same Vite 8 build step. Browse more posts like this in the <a href="/guides-fixes">Guides &amp; Fixes</a> archive.</p>]]></content:encoded>
      <pubDate>Wed, 05 Aug 2026 00:00:00 GMT</pubDate>
    </item>
    <item>
      <title>Fix Vite 8 manualChunks Object Form Removed</title>
      <link>https://bytetech247.com/guides-fixes/fix-vite-8-manualchunks-object-form-removed/</link>
      <guid isPermaLink="true">https://bytetech247.com/guides-fixes/fix-vite-8-manualchunks-object-form-removed/</guid>
      <description>Fix Vite 8 rejecting object-form manualChunks. Rolldown only supports the function form - here&apos;s the real error and the fix.</description>
      <content:encoded><![CDATA[<aside class="quick-answer" aria-label="Quick answer"><h2 id="quick-answer">Quick Answer</h2>
<p>Vite 8’s Rolldown bundler rejects the object form of <code>output.manualChunks</code> outright: “the object form … is not supported anymore.” A real migration tool hit this directly: <code>Invalid type: Expected Function but received Object</code>. Rewrite <code>manualChunks</code> as a function, the same chunk-splitting logic, just called per module instead of declared as a static map.</p>
</aside><h2 id="why-the-object-form-stopped-working">Why the object form stopped working</h2>
<p>Rollup’s <code>output.manualChunks</code> accepted two shapes: a function that gets called once per module and returns the chunk name it belongs to, or a plain object mapping chunk names to arrays of module IDs, a common shorthand for a simple vendor/app split. <a href="https://vite.dev/guide/migration">Vite’s migration guide</a> is direct about what changed: “The object form <code>output.manualChunks</code> option is not supported anymore. The function form <code>output.manualChunks</code> is deprecated.”</p>
<p>Rolldown only implements the function form. The object-map shorthand (arguably the more commonly used of the two, since it’s the one most copy-pasted vendor-chunk snippets use) is rejected at build time, not silently ignored:</p>
<div class="code-block"><div class="code-block__header"><span class="code-block__filename"></span><button type="button" class="code-block__copy" data-copy-button>Copy</button></div><pre class="astro-code github-dark" style="background-color:#24292e;color:#e1e4e8;overflow-x:auto" tabindex="0" data-language="plaintext"><code><span class="line"><span>Warning: Invalid output options (1 issue found) - For the &#39;manualChunks&#39;. Invalid type: Expected Function but received Object.</span></span></code></pre></div>
<p>That exact error comes from a real migration tool’s own config validator (<a href="https://github.com/voidzero-dev/vite-plus/issues/900">voidzero-dev/vite-plus#900</a>, opened 2026-03-15), hit while trying to convert a project’s Rollup-era config to Rolldown. The same shape of error appears in a plain Vite 8 build with an object-form <code>manualChunks</code>, not just inside that specific tool.</p>
<h2 id="fix-it-rewrite-as-a-function">Fix it: rewrite as a function</h2>
<h3 id="before-the-object-form">Before: the object form</h3>
<div class="code-block code-block--has-filename"><div class="code-block__header"><span class="code-block__filename">vite.config.ts - Vite 7 pattern</span><button type="button" class="code-block__copy" data-copy-button>Copy</button></div><pre class="astro-code github-dark" style="background-color:#24292e;color:#e1e4e8;overflow-x:auto" tabindex="0" data-language="js" data-filename="vite.config.ts - Vite 7 pattern"><code><span class="line"><span style="color:#F97583">export</span><span style="color:#F97583"> default</span><span style="color:#B392F0"> defineConfig</span><span style="color:#E1E4E8">({</span></span>
<span class="line"><span style="color:#E1E4E8">  build: {</span></span>
<span class="line"><span style="color:#E1E4E8">    rollupOptions: {</span></span>
<span class="line"><span style="color:#E1E4E8">      output: {</span></span>
<span class="line"><span style="color:#E1E4E8">        manualChunks: {</span></span>
<span class="line"><span style="color:#E1E4E8">          vendor: [</span><span style="color:#9ECBFF">&quot;react&quot;</span><span style="color:#E1E4E8">, </span><span style="color:#9ECBFF">&quot;react-dom&quot;</span><span style="color:#E1E4E8">], </span><span style="color:#9ca6b0">// BROKEN in Vite 8: object form rejected</span></span>
<span class="line"><span style="color:#E1E4E8">        },</span></span>
<span class="line"><span style="color:#E1E4E8">      },</span></span>
<span class="line"><span style="color:#E1E4E8">    },</span></span>
<span class="line"><span style="color:#E1E4E8">  },</span></span>
<span class="line"><span style="color:#E1E4E8">});</span></span></code></pre></div>
<h3 id="after-the-same-split-as-a-function">After: the same split, as a function</h3>
<div class="code-block code-block--has-filename"><div class="code-block__header"><span class="code-block__filename">vite.config.ts - Vite 8 pattern</span><button type="button" class="code-block__copy" data-copy-button>Copy</button></div><pre class="astro-code github-dark" style="background-color:#24292e;color:#e1e4e8;overflow-x:auto" tabindex="0" data-language="js" data-filename="vite.config.ts - Vite 8 pattern"><code><span class="line"><span style="color:#F97583">export</span><span style="color:#F97583"> default</span><span style="color:#B392F0"> defineConfig</span><span style="color:#E1E4E8">({</span></span>
<span class="line"><span style="color:#E1E4E8">  build: {</span></span>
<span class="line"><span style="color:#E1E4E8">    rollupOptions: {</span></span>
<span class="line"><span style="color:#E1E4E8">      output: {</span></span>
<span class="line"><span style="color:#B392F0">        manualChunks</span><span style="color:#E1E4E8">(</span><span style="color:#FFAB70">id</span><span style="color:#E1E4E8">) {</span></span>
<span class="line"><span style="color:#9ca6b0">          // FIXED: same vendor-chunk logic, called per module</span></span>
<span class="line"><span style="color:#F97583">          if</span><span style="color:#E1E4E8"> (</span></span>
<span class="line"><span style="color:#E1E4E8">            id.</span><span style="color:#B392F0">includes</span><span style="color:#E1E4E8">(</span><span style="color:#9ECBFF">&quot;node_modules/react&quot;</span><span style="color:#E1E4E8">) </span><span style="color:#F97583">||</span></span>
<span class="line"><span style="color:#E1E4E8">            id.</span><span style="color:#B392F0">includes</span><span style="color:#E1E4E8">(</span><span style="color:#9ECBFF">&quot;node_modules/react-dom&quot;</span><span style="color:#E1E4E8">)</span></span>
<span class="line"><span style="color:#E1E4E8">          ) {</span></span>
<span class="line"><span style="color:#F97583">            return</span><span style="color:#9ECBFF"> &quot;vendor&quot;</span><span style="color:#E1E4E8">;</span></span>
<span class="line"><span style="color:#E1E4E8">          }</span></span>
<span class="line"><span style="color:#E1E4E8">        },</span></span>
<span class="line"><span style="color:#E1E4E8">      },</span></span>
<span class="line"><span style="color:#E1E4E8">    },</span></span>
<span class="line"><span style="color:#E1E4E8">  },</span></span>
<span class="line"><span style="color:#E1E4E8">});</span></span></code></pre></div>
<p>The function receives each module’s resolved ID and runs once per module during the build. Returning a string assigns that module to a named chunk; returning nothing leaves Rolldown’s default chunking in place for it, the same contract the function form already had under Rollup.</p>
<div class="callout" role="note" data-astro-cid-q2ml7llr><p class="callout__title" data-astro-cid-q2ml7llr><span aria-hidden="true" data-astro-cid-q2ml7llr>✓</span>Consider skipping manualChunks entirely</p><div class="callout__body" data-astro-cid-q2ml7llr><p>If your object form was a simple vendor/app split rather than something tuned
for a specific loading strategy, removing <code>manualChunks</code> and letting
Rolldown’s own chunking handle it automatically is a real option, not just a
fallback. Rolldown also exposes a <code>codeSplitting</code> option built for this exact
case, which is worth checking before porting a complex object map line by line
into a function.</p></div></div>
<h2 id="confirmed-version-range">Confirmed version range</h2>
<p>Confirmed via Rolldown’s real-world error output, cited above, against <code>rolldown@1.0.0-rc.9</code> and <code>vite@8.0.0</code>. Documented as an intentional, permanent removal in Vite’s own current migration guide, not a temporary regression. This site’s own <code>astro.config.mjs</code> doesn’t customize <code>manualChunks</code> at all, so this specific error wasn’t independently reproducible against this repo’s own build. Same key rename to check while you’re here: <a href="/guides-fixes/fix-vite-8-build-rollupoptions-deprecation/">Fix Vite 8’s build.rollupOptions Deprecation</a> - <code>manualChunks</code> is the single most common thing people configure inside that renamed key. Browse more posts like this in the <a href="/guides-fixes">Guides &amp; Fixes</a> archive.</p>]]></content:encoded>
      <pubDate>Wed, 05 Aug 2026 00:00:00 GMT</pubDate>
    </item>
    <item>
      <title>Fix Vite 8 Oxc Not Lowering Native Decorators</title>
      <link>https://bytetech247.com/guides-fixes/fix-vite-8-oxc-native-decorators/</link>
      <guid isPermaLink="true">https://bytetech247.com/guides-fixes/fix-vite-8-oxc-native-decorators/</guid>
      <description>Fix Vite 8&apos;s Oxc transformer not lowering native class decorators. Understand the limitation and the real workaround for older build targets.</description>
      <content:encoded><![CDATA[<aside class="quick-answer" aria-label="Quick answer"><h2 id="quick-answer">Quick Answer</h2>
<p>Vite 8 replaces esbuild with Oxc for JavaScript transformation, and Oxc’s transformer doesn’t support lowering native decorators, the exact words from Vite’s own migration guide. A build target that needed decorators downlevel-compiled to older JS now fails or ships unsupported syntax. If you don’t need native decorator semantics specifically, switching to TypeScript’s <code>experimentalDecorators</code> avoids the limitation entirely.</p>
</aside><h2 id="why-oxc-cant-lower-this-syntax">Why Oxc can’t lower this syntax</h2>
<p>Vite 8’s <a href="https://vite.dev/guide/migration">migration guide</a> states the limitation directly, under the section on replacing esbuild with Oxc for JavaScript transformation: “The Oxc transformer does not support lowering native decorators.”</p>
<p>“Lowering” means downlevel-compiling newer syntax into older, broadly-supported JavaScript for a build target that doesn’t understand it natively, the same job esbuild used to do for decorators alongside everything else it transformed. Oxc, Vite 8’s replacement for that transform step, doesn’t implement this specific lowering pass.</p>
<p>This is easy to conflate with a different, much older feature: TypeScript’s <code>experimentalDecorators</code>, the decorator implementation most real-world projects (Angular-style DI containers, some ORMs, class-based state management) have used for years. That’s a separate TypeScript-level transform, unaffected by this Oxc gap. It keeps working the same as it always has. The limitation is specifically about TC39’s newer native decorator proposal, a related but distinct syntax that TypeScript can also emit when <code>experimentalDecorators</code> is off.</p>
<h2 id="fix-it-use-the-transform-that-already-works">Fix it: use the transform that already works</h2>
<h3 id="before-native-decorators-targeting-an-older-environment">Before: native decorators targeting an older environment</h3>
<div class="code-block code-block--has-filename"><div class="code-block__header"><span class="code-block__filename">tsconfig.json</span><button type="button" class="code-block__copy" data-copy-button>Copy</button></div><pre class="astro-code github-dark" style="background-color:#24292e;color:#e1e4e8;overflow-x:auto" tabindex="0" data-language="json" data-filename="tsconfig.json"><code><span class="line"><span style="color:#E1E4E8">{</span></span>
<span class="line"><span style="color:#79B8FF">  &quot;compilerOptions&quot;</span><span style="color:#E1E4E8">: {</span></span>
<span class="line"><span style="color:#79B8FF">    &quot;target&quot;</span><span style="color:#E1E4E8">: </span><span style="color:#9ECBFF">&quot;ES2020&quot;</span></span>
<span class="line"><span style="color:#9ca6b0">    // no experimentalDecorators — TypeScript emits native</span></span>
<span class="line"><span style="color:#9ca6b0">    // decorator syntax, which Oxc can&#39;t lower for ES2020 (BROKEN)</span></span>
<span class="line"><span style="color:#E1E4E8">  }</span></span>
<span class="line"><span style="color:#E1E4E8">}</span></span></code></pre></div>
<h3 id="after-typescripts-legacy-decorator-transform">After: TypeScript’s legacy decorator transform</h3>
<div class="code-block code-block--has-filename"><div class="code-block__header"><span class="code-block__filename">tsconfig.json</span><button type="button" class="code-block__copy" data-copy-button>Copy</button></div><pre class="astro-code github-dark" style="background-color:#24292e;color:#e1e4e8;overflow-x:auto" tabindex="0" data-language="json" data-filename="tsconfig.json"><code><span class="line"><span style="color:#E1E4E8">{</span></span>
<span class="line"><span style="color:#79B8FF">  &quot;compilerOptions&quot;</span><span style="color:#E1E4E8">: {</span></span>
<span class="line"><span style="color:#79B8FF">    &quot;target&quot;</span><span style="color:#E1E4E8">: </span><span style="color:#9ECBFF">&quot;ES2020&quot;</span><span style="color:#E1E4E8">,</span></span>
<span class="line"><span style="color:#79B8FF">    &quot;experimentalDecorators&quot;</span><span style="color:#E1E4E8">: </span><span style="color:#79B8FF">true</span><span style="color:#E1E4E8">, </span><span style="color:#9ca6b0">// FIXED: TypeScript itself lowers</span></span>
<span class="line"><span style="color:#79B8FF">    &quot;emitDecoratorMetadata&quot;</span><span style="color:#E1E4E8">: </span><span style="color:#79B8FF">true</span><span style="color:#9ca6b0"> // this transform, not Oxc</span></span>
<span class="line"><span style="color:#E1E4E8">  }</span></span>
<span class="line"><span style="color:#E1E4E8">}</span></span></code></pre></div>
<p><code>experimentalDecorators</code> changes which decorator semantics your code actually gets, not just how it’s compiled. This is a real behavior difference, not a drop-in syntax swap, so check the library or framework you’re using decorators for to confirm which mode it expects before flipping this setting.</p>
<div class="callout" role="note" data-astro-cid-q2ml7llr><p class="callout__title" data-astro-cid-q2ml7llr><span aria-hidden="true" data-astro-cid-q2ml7llr>⚠</span>If you specifically need native decorator semantics</p><div class="callout__body" data-astro-cid-q2ml7llr><p>Switching to <code>experimentalDecorators</code> isn’t an option if a dependency requires
the real TC39 native decorator behavior. In that case, either target a
JavaScript environment new enough to run native decorators without lowering at
all, or pre-transform those files with a separate tool (Babel or SWC both
support native decorator lowering) before Vite processes them.</p></div></div>
<h2 id="confirmed-version-range">Confirmed version range</h2>
<p>Documented in Vite’s own current migration guide as a known limitation of Oxc’s transformer in Vite 8, with no committed fix timeline. This site’s own TypeScript config doesn’t use class decorators in any form, so this limitation wasn’t independently reproducible against this repo’s own build. The distinction between native and <code>experimentalDecorators</code> syntax is the same regardless of which project hits it. Browse more posts like this in the <a href="/guides-fixes">Guides &amp; Fixes</a> archive.</p>]]></content:encoded>
      <pubDate>Wed, 05 Aug 2026 00:00:00 GMT</pubDate>
    </item>
    <item>
      <title>Fix Vite 8 resolve.alias customResolver Removal</title>
      <link>https://bytetech247.com/guides-fixes/fix-vite-8-resolve-alias-customresolver-removal/</link>
      <guid isPermaLink="true">https://bytetech247.com/guides-fixes/fix-vite-8-resolve-alias-customresolver-removal/</guid>
      <description>Fix Vite 8 removing resolve.alias customResolver, which breaks CSS/JS path-alias plugins. Astro&apos;s own core fix shows the replacement.</description>
      <content:encoded><![CDATA[<aside class="quick-answer" aria-label="Quick answer"><h2 id="quick-answer">Quick Answer</h2>
<p>Vite 8 removes the <code>resolve.alias[].customResolver</code> hook entirely: “use a custom plugin with <code>resolveId</code> hook and <code>enforce: &#39;pre&#39;</code> instead.” Any alias-resolution plugin relying on the old hook needs rewriting as a real Vite plugin. Astro’s own core hit this and already shipped the fix in 7.0.0 stable. See its <code>resolveId</code>-based replacement below.</p>
</aside><h2 id="why-customresolver-stopped-working">Why customResolver stopped working</h2>
<p>Vite’s <code>resolve.alias</code> option has always supported two shapes: a plain string/RegExp <code>find</code> and <code>replacement</code> pair (still fully supported in Vite 8), and a version that supplies its own <code>customResolver</code> function to override how Vite resolved that specific aliased path. <a href="https://vite.dev/guide/migration">Vite’s migration guide</a> lists the second form under “Deprecated Options,” removed outright: “<code>resolve.alias[].customResolver</code>: use a custom plugin with <code>resolveId</code> hook and <code>enforce: &#39;pre&#39;</code> instead.”</p>
<p>Alias resolution is no longer an extension point in Vite 8. Any config or plugin that supplied a <code>customResolver</code> function silently stops taking effect. Vite doesn’t error on the unrecognized option, it just never calls it, so the alias quietly resolves to nothing or falls through to a different resolution path.</p>
<p>Astro’s own core hit this directly. Its tsconfig path-alias logic for CSS <code>@import</code> statements used <code>customResolver</code> internally. <a href="https://github.com/withastro/astro/pull/17090">PR #17090, “Fix Vite and Rolldown build warnings in Astro 7”</a>, merged 2026-06-18, replaced it with two separate plugins using <code>resolveId</code>/transform hooks instead, and shipped already-fixed in Astro 7.0.0 stable.</p>
<h2 id="fix-it-replace-customresolver-with-a-resolveid-plugin">Fix it: replace customResolver with a resolveId plugin</h2>
<h3 id="before-the-removed-pattern">Before: the removed pattern</h3>
<div class="code-block code-block--has-filename"><div class="code-block__header"><span class="code-block__filename">vite.config.ts - Vite 7 pattern</span><button type="button" class="code-block__copy" data-copy-button>Copy</button></div><pre class="astro-code github-dark" style="background-color:#24292e;color:#e1e4e8;overflow-x:auto" tabindex="0" data-language="js" data-filename="vite.config.ts - Vite 7 pattern"><code><span class="line"><span style="color:#F97583">export</span><span style="color:#F97583"> default</span><span style="color:#B392F0"> defineConfig</span><span style="color:#E1E4E8">({</span></span>
<span class="line"><span style="color:#E1E4E8">  resolve: {</span></span>
<span class="line"><span style="color:#E1E4E8">    alias: [</span></span>
<span class="line"><span style="color:#E1E4E8">      {</span></span>
<span class="line"><span style="color:#E1E4E8">        find:</span><span style="color:#9ECBFF"> /</span><span style="color:#F97583">^</span><span style="color:#DBEDFF">~(</span><span style="color:#79B8FF">.</span><span style="color:#F97583">+</span><span style="color:#DBEDFF">)</span><span style="color:#9ECBFF">/</span><span style="color:#E1E4E8">,</span></span>
<span class="line"><span style="color:#E1E4E8">        replacement: </span><span style="color:#9ECBFF">&quot;$1&quot;</span><span style="color:#E1E4E8">,</span></span>
<span class="line"><span style="color:#B392F0">        customResolver</span><span style="color:#E1E4E8">(</span><span style="color:#FFAB70">source</span><span style="color:#E1E4E8">) {</span></span>
<span class="line"><span style="color:#9ca6b0">          // BROKEN in Vite 8: customResolver is no longer called</span></span>
<span class="line"><span style="color:#F97583">          return</span><span style="color:#B392F0"> resolveTsconfigPath</span><span style="color:#E1E4E8">(source);</span></span>
<span class="line"><span style="color:#E1E4E8">        },</span></span>
<span class="line"><span style="color:#E1E4E8">      },</span></span>
<span class="line"><span style="color:#E1E4E8">    ],</span></span>
<span class="line"><span style="color:#E1E4E8">  },</span></span>
<span class="line"><span style="color:#E1E4E8">});</span></span></code></pre></div>
<h3 id="after-the-same-resolution-logic-as-a-real-plugin">After: the same resolution logic, as a real plugin</h3>
<div class="code-block code-block--has-filename"><div class="code-block__header"><span class="code-block__filename">vite.config.ts - Vite 8 pattern</span><button type="button" class="code-block__copy" data-copy-button>Copy</button></div><pre class="astro-code github-dark" style="background-color:#24292e;color:#e1e4e8;overflow-x:auto" tabindex="0" data-language="js" data-filename="vite.config.ts - Vite 8 pattern"><code><span class="line"><span style="color:#F97583">export</span><span style="color:#F97583"> default</span><span style="color:#B392F0"> defineConfig</span><span style="color:#E1E4E8">({</span></span>
<span class="line"><span style="color:#E1E4E8">  plugins: [</span></span>
<span class="line"><span style="color:#E1E4E8">    {</span></span>
<span class="line"><span style="color:#E1E4E8">      name: </span><span style="color:#9ECBFF">&quot;tsconfig-alias&quot;</span><span style="color:#E1E4E8">,</span></span>
<span class="line"><span style="color:#E1E4E8">      enforce: </span><span style="color:#9ECBFF">&quot;pre&quot;</span><span style="color:#E1E4E8">,</span></span>
<span class="line"><span style="color:#B392F0">      resolveId</span><span style="color:#E1E4E8">(</span><span style="color:#FFAB70">source</span><span style="color:#E1E4E8">) {</span></span>
<span class="line"><span style="color:#9ca6b0">        // FIXED: resolveId + enforce: &#39;pre&#39; replaces the removed</span></span>
<span class="line"><span style="color:#9ca6b0">        // resolve.alias[].customResolver hook, running before</span></span>
<span class="line"><span style="color:#9ca6b0">        // Vite&#39;s own default resolution</span></span>
<span class="line"><span style="color:#F97583">        if</span><span style="color:#E1E4E8"> (source.</span><span style="color:#B392F0">startsWith</span><span style="color:#E1E4E8">(</span><span style="color:#9ECBFF">&quot;~&quot;</span><span style="color:#E1E4E8">)) {</span></span>
<span class="line"><span style="color:#F97583">          return</span><span style="color:#B392F0"> resolveTsconfigPath</span><span style="color:#E1E4E8">(source.</span><span style="color:#B392F0">slice</span><span style="color:#E1E4E8">(</span><span style="color:#79B8FF">1</span><span style="color:#E1E4E8">));</span></span>
<span class="line"><span style="color:#E1E4E8">        }</span></span>
<span class="line"><span style="color:#F97583">        return</span><span style="color:#79B8FF"> null</span><span style="color:#E1E4E8">;</span></span>
<span class="line"><span style="color:#E1E4E8">      },</span></span>
<span class="line"><span style="color:#E1E4E8">    },</span></span>
<span class="line"><span style="color:#E1E4E8">  ],</span></span>
<span class="line"><span style="color:#E1E4E8">});</span></span></code></pre></div>
<p><code>enforce: &quot;pre&quot;</code> matters here. Without it, your plugin’s <code>resolveId</code> runs after Vite’s built-in resolvers, by which point the aliased path may have already resolved (or failed to resolve) the wrong way, the same ordering guarantee <code>customResolver</code> used to provide implicitly.</p>
<h2 id="confirmed-version-range">Confirmed version range</h2>
<p>Verified against Astro’s own real fix: <a href="https://github.com/withastro/astro/pull/17090">PR #17090</a> merged 2026-06-18, shipped in Astro 7.0.0 stable, replacing the exact <code>customResolver</code> pattern shown above with a <code>resolveId</code>-based plugin for the same tsconfig-path-alias logic. If you’re on Astro 7.0.0 or later, Astro’s own core already handles this. This fix applies to your own project’s Vite config or any third-party Vite plugin that still supplies a <code>customResolver</code> function. Browse more posts like this in the <a href="/guides-fixes">Guides &amp; Fixes</a> archive.</p>]]></content:encoded>
      <pubDate>Wed, 05 Aug 2026 00:00:00 GMT</pubDate>
    </item>
    <item>
      <title>Fix Astro 7 compressHTML Spacing Bug After Upgrade</title>
      <link>https://bytetech247.com/guides-fixes/fix-astro-7-compresshtml-spacing-bug/</link>
      <guid isPermaLink="true">https://bytetech247.com/guides-fixes/fix-astro-7-compresshtml-spacing-bug/</guid>
      <description>Astro 7 changes compressHTML&apos;s default, collapsing space between inline elements on separate lines. Here&apos;s why, and the two ways to fix it.</description>
      <content:encoded><![CDATA[<aside class="quick-answer" aria-label="Quick answer"><h2 id="quick-answer">Quick Answer</h2>
<p>Astro 7 changes <code>compressHTML</code>’s default to JSX-style whitespace stripping. Inline elements written on separate lines, like <code>&lt;strong&gt;</code> and <code>&lt;span&gt;</code>, lose the space between them after upgrading, with no build error. Add an explicit <code>{&quot; &quot;}</code> between them to restore it. It’s the same fix JSX frameworks use for this exact rule.</p>
</aside><h2 id="why-astro-7-collapses-this-space">Why Astro 7 collapses this space</h2>
<p>Astro 7 changes <code>compressHTML</code>’s default value to <code>&#39;jsx&#39;</code>. The <a href="https://docs.astro.build/en/guides/upgrade-to/v7/">official Astro v7 upgrade guide</a> explains the new rule: whitespace and line breaks around elements are stripped the same way JSX frameworks like React strip them, replacing Astro 6’s own HTML-aware compression. A real space typed on the same line survives. A line break with nothing else on it does not.</p>
<p>Verified directly on this site’s own <code>astro@7.1.3</code> install, which doesn’t override <code>compressHTML</code> in <code>astro.config.mjs</code> and so runs on the new default: two inline elements written on separate lines compile to <code>&lt;strong&gt;5&lt;/strong&gt;&lt;span&gt;posts&lt;/span&gt;</code>, no space at all, while the same markup with a space typed on one line compiles to <code>&lt;strong&gt;5&lt;/strong&gt; &lt;span&gt;posts&lt;/span&gt;</code>.</p>
<div class="callout" role="note" data-astro-cid-q2ml7llr><p class="callout__title" data-astro-cid-q2ml7llr><span aria-hidden="true" data-astro-cid-q2ml7llr>ℹ</span>Two expressions merging instead? Same cause, different post</p><div class="callout__body" data-astro-cid-q2ml7llr><p>If your two pieces are <code>{expression}</code> blocks rather than real HTML tags, like
<code>{count}</code> and <code>{label}</code>, you’re hitting the same <code>compressHTML</code> default flip,
just on a different pair of node types. Astro 6’s <code>compressHTML: true</code>
preserved that spacing too, confirmed by testing both cases against this
repo’s own <code>astro.config.mjs</code>. See <a href="/guides-fixes/astro-whitespace-collapse-expression-bug/">why Astro silently merges text like
‘5posts’ into one
word</a> for the
expression-specific fix.</p></div></div>
<h2 id="fix-it-add-the-explicit-space-back">Fix it: add the explicit space back</h2>
<h3 id="before-renders-fine-in-astro-6-broken-in-astro-7">Before: renders fine in Astro 6, broken in Astro 7</h3>
<div class="code-block code-block--has-filename"><div class="code-block__header"><span class="code-block__filename">two inline elements on separate lines</span><button type="button" class="code-block__copy" data-copy-button>Copy</button></div><pre class="astro-code github-dark" style="background-color:#24292e;color:#e1e4e8;overflow-x:auto" tabindex="0" data-language="astro" data-filename="two inline elements on separate lines"><code><span class="line"><span style="color:#E1E4E8">&lt;</span><span style="color:#85E89D">p</span><span style="color:#E1E4E8">&gt;</span></span>
<span class="line"><span style="color:#E1E4E8">  &lt;</span><span style="color:#85E89D">strong</span><span style="color:#E1E4E8">&gt;5&lt;/</span><span style="color:#85E89D">strong</span><span style="color:#E1E4E8">&gt;</span></span>
<span class="line"><span style="color:#E1E4E8">  &lt;</span><span style="color:#85E89D">span</span><span style="color:#E1E4E8">&gt;posts&lt;/</span><span style="color:#85E89D">span</span><span style="color:#E1E4E8">&gt;</span></span>
<span class="line"><span style="color:#E1E4E8">&lt;/</span><span style="color:#85E89D">p</span><span style="color:#E1E4E8">&gt;</span></span></code></pre></div>
<p>Compiles to, confirmed on <code>astro@7.1.3</code>:</p>
<div class="code-block code-block--has-filename"><div class="code-block__header"><span class="code-block__filename">Astro 7 output, space gone</span><button type="button" class="code-block__copy" data-copy-button>Copy</button></div><pre class="astro-code github-dark" style="background-color:#24292e;color:#e1e4e8;overflow-x:auto" tabindex="0" data-language="html" data-filename="Astro 7 output, space gone"><code><span class="line"><span style="color:#E1E4E8">&lt;</span><span style="color:#85E89D">p</span><span style="color:#E1E4E8">&gt;&lt;</span><span style="color:#85E89D">strong</span><span style="color:#E1E4E8">&gt;5&lt;/</span><span style="color:#85E89D">strong</span><span style="color:#E1E4E8">&gt;&lt;</span><span style="color:#85E89D">span</span><span style="color:#E1E4E8">&gt;posts&lt;/</span><span style="color:#85E89D">span</span><span style="color:#E1E4E8">&gt;&lt;/</span><span style="color:#85E89D">p</span><span style="color:#E1E4E8">&gt;</span></span></code></pre></div>
<h3 id="after-explicit-space-expression">After: explicit space expression</h3>
<div class="code-block code-block--has-filename"><div class="code-block__header"><span class="code-block__filename">fixed: explicit space between elements</span><button type="button" class="code-block__copy" data-copy-button>Copy</button></div><pre class="astro-code github-dark" style="background-color:#24292e;color:#e1e4e8;overflow-x:auto" tabindex="0" data-language="astro" data-filename="fixed: explicit space between elements"><code><span class="line"><span style="color:#E1E4E8">&lt;</span><span style="color:#85E89D">p</span><span style="color:#E1E4E8">&gt;</span></span>
<span class="line"><span style="color:#E1E4E8">  &lt;</span><span style="color:#85E89D">strong</span><span style="color:#E1E4E8">&gt;5&lt;/</span><span style="color:#85E89D">strong</span><span style="color:#E1E4E8">&gt;{</span><span style="color:#9ECBFF">&quot; &quot;</span><span style="color:#E1E4E8">}</span></span>
<span class="line"><span style="color:#E1E4E8">  &lt;</span><span style="color:#85E89D">span</span><span style="color:#E1E4E8">&gt;posts&lt;/</span><span style="color:#85E89D">span</span><span style="color:#E1E4E8">&gt;</span></span>
<span class="line"><span style="color:#E1E4E8">&lt;/</span><span style="color:#85E89D">p</span><span style="color:#E1E4E8">&gt;</span></span></code></pre></div>
<p>Compiles to, confirmed on the same install:</p>
<div class="code-block code-block--has-filename"><div class="code-block__header"><span class="code-block__filename">fixed output, space restored</span><button type="button" class="code-block__copy" data-copy-button>Copy</button></div><pre class="astro-code github-dark" style="background-color:#24292e;color:#e1e4e8;overflow-x:auto" tabindex="0" data-language="html" data-filename="fixed output, space restored"><code><span class="line"><span style="color:#E1E4E8">&lt;</span><span style="color:#85E89D">p</span><span style="color:#E1E4E8">&gt;&lt;</span><span style="color:#85E89D">strong</span><span style="color:#E1E4E8">&gt;5&lt;/</span><span style="color:#85E89D">strong</span><span style="color:#E1E4E8">&gt; &lt;</span><span style="color:#85E89D">span</span><span style="color:#E1E4E8">&gt;posts&lt;/</span><span style="color:#85E89D">span</span><span style="color:#E1E4E8">&gt;&lt;/</span><span style="color:#85E89D">p</span><span style="color:#E1E4E8">&gt;</span></span></code></pre></div>
<p><code>{&quot; &quot;}</code> is a string literal, not whitespace-only text, so Astro renders it exactly as written no matter how the surrounding lines wrap.</p>
<h2 id="restore-astro-6s-behavior-sitewide">Restore Astro 6’s behavior sitewide</h2>
<p>If this shows up in many places at once, patching every instance is slower than restoring the old default in one place.</p>
<div class="code-block code-block--has-filename"><div class="code-block__header"><span class="code-block__filename">astro.config.mjs</span><button type="button" class="code-block__copy" data-copy-button>Copy</button></div><pre class="astro-code github-dark" style="background-color:#24292e;color:#e1e4e8;overflow-x:auto" tabindex="0" data-language="js" data-filename="astro.config.mjs"><code><span class="line"><span style="color:#F97583">export</span><span style="color:#F97583"> default</span><span style="color:#B392F0"> defineConfig</span><span style="color:#E1E4E8">({</span></span>
<span class="line"><span style="color:#E1E4E8">  compressHTML: </span><span style="color:#79B8FF">true</span><span style="color:#E1E4E8">,</span></span>
<span class="line"><span style="color:#9ca6b0">  // ...rest of your config</span></span>
<span class="line"><span style="color:#E1E4E8">});</span></span></code></pre></div>
<p>This brings back Astro 6’s HTML-aware compression everywhere. It’s still real compression, so genuinely meaningless whitespace still gets removed. It just stops applying the JSX rule that strips a line break sitting between two elements. This option is documented directly in Astro’s own upgrade guide; the explicit-space fix above is the one independently confirmed against this site’s build.</p>
<p>Check every place your markup pairs two inline elements across separate lines after upgrading, not just the one spot where someone already noticed. It costs nothing to look, and the mistake stays invisible until a reader points it out. Browse more posts like this in the <a href="/guides-fixes">Guides &amp; Fixes</a> archive.</p>]]></content:encoded>
      <pubDate>Tue, 04 Aug 2026 00:00:00 GMT</pubDate>
    </item>
    <item>
      <title>Fix Astro InvalidContentEntryDataError Schema Errors</title>
      <link>https://bytetech247.com/guides-fixes/fix-astro-invalidcontententrydataerror/</link>
      <guid isPermaLink="true">https://bytetech247.com/guides-fixes/fix-astro-invalidcontententrydataerror/</guid>
      <description>InvalidContentEntryDataError means a post&apos;s frontmatter doesn&apos;t match its schema. Here&apos;s how to read the message and fix the four most common causes.</description>
      <content:encoded><![CDATA[<aside class="quick-answer" aria-label="Quick answer"><h2 id="quick-answer">Quick Answer</h2>
<p><code>InvalidContentEntryDataError</code> means a post’s frontmatter does not match the Zod schema in <code>content.config.ts</code>, commonly an invalid category, a missing required field, or the wrong type. Astro prints the exact field and reason in the error message. Fix the named field and rebuild.</p>
</aside><h2 id="reading-the-error-message">Reading the error message</h2>
<p>Astro reports a content schema mismatch through one error class: <code>InvalidContentEntryDataError</code>. In every case tested here, the message has the same shape: which collection and entry, then the specific field and why it failed. <a href="https://docs.astro.build/en/reference/errors/invalid-content-entry-data-error/">Astro’s own error reference</a> documents this as “a content entry does not match its collection schema.”</p>
<h2 id="four-real-examples-confirmed-against-this-sites-own-schema">Four real examples, confirmed against this site’s own schema</h2>
<p>Each of these was reproduced directly against this repo’s <code>src/content.config.ts</code>, not guessed at from the message format.</p>
<h3 id="invalid-enum-value">Invalid enum value</h3>
<div class="code-block code-block--has-filename"><div class="code-block__header"><span class="code-block__filename">frontmatter</span><button type="button" class="code-block__copy" data-copy-button>Copy</button></div><pre class="astro-code github-dark" style="background-color:#24292e;color:#e1e4e8;overflow-x:auto" tabindex="0" data-language="yaml" data-filename="frontmatter"><code><span class="line"><span style="color:#85E89D">category</span><span style="color:#E1E4E8">: </span><span style="color:#9ECBFF">guide-fixes</span></span></code></pre></div>
<div class="code-block code-block--has-filename"><div class="code-block__header"><span class="code-block__filename">the real error</span><button type="button" class="code-block__copy" data-copy-button>Copy</button></div><pre class="astro-code github-dark" style="background-color:#24292e;color:#e1e4e8;overflow-x:auto" tabindex="0" data-language="text" data-filename="the real error"><code><span class="line"><span>category: Invalid option: expected one of &quot;dev-tools&quot;|&quot;data-automation&quot;|&quot;ai-productivity&quot;|&quot;guides-fixes&quot;</span></span></code></pre></div>
<p>Fix: match one of the listed options exactly. This is almost always a typo, not a category that genuinely needs adding.</p>
<h3 id="missing-required-field">Missing required field</h3>
<div class="code-block code-block--has-filename"><div class="code-block__header"><span class="code-block__filename">frontmatter</span><button type="button" class="code-block__copy" data-copy-button>Copy</button></div><pre class="astro-code github-dark" style="background-color:#24292e;color:#e1e4e8;overflow-x:auto" tabindex="0" data-language="yaml" data-filename="frontmatter"><code><span class="line"><span style="color:#9ca6b0"># coverImageAlt omitted entirely</span></span></code></pre></div>
<div class="code-block code-block--has-filename"><div class="code-block__header"><span class="code-block__filename">the real error</span><button type="button" class="code-block__copy" data-copy-button>Copy</button></div><pre class="astro-code github-dark" style="background-color:#24292e;color:#e1e4e8;overflow-x:auto" tabindex="0" data-language="text" data-filename="the real error"><code><span class="line"><span>coverImageAlt**: **coverImageAlt: Required</span></span></code></pre></div>
<p>Fix: add the field. The field name shows up twice with stray asterisks around it. That’s an Astro formatting quirk in how it bolds the field name for this particular message, not a second error to chase down; the meaning is just “coverImageAlt: Required.”</p>
<h3 id="wrong-type">Wrong type</h3>
<div class="code-block code-block--has-filename"><div class="code-block__header"><span class="code-block__filename">frontmatter</span><button type="button" class="code-block__copy" data-copy-button>Copy</button></div><pre class="astro-code github-dark" style="background-color:#24292e;color:#e1e4e8;overflow-x:auto" tabindex="0" data-language="yaml" data-filename="frontmatter"><code><span class="line"><span style="color:#85E89D">tags</span><span style="color:#E1E4E8">: </span><span style="color:#9ECBFF">&quot;astro&quot;</span></span></code></pre></div>
<div class="code-block code-block--has-filename"><div class="code-block__header"><span class="code-block__filename">the real error</span><button type="button" class="code-block__copy" data-copy-button>Copy</button></div><pre class="astro-code github-dark" style="background-color:#24292e;color:#e1e4e8;overflow-x:auto" tabindex="0" data-language="text" data-filename="the real error"><code><span class="line"><span>tags: Expected type &quot;array&quot;, received &quot;string&quot;</span></span></code></pre></div>
<p>Fix: wrap the value in the type the schema expects. Here, <code>tags: [&quot;astro&quot;]</code>.</p>
<h3 id="a-schema-authors-own-validation-message">A schema author’s own validation message</h3>
<div class="code-block code-block--has-filename"><div class="code-block__header"><span class="code-block__filename">frontmatter</span><button type="button" class="code-block__copy" data-copy-button>Copy</button></div><pre class="astro-code github-dark" style="background-color:#24292e;color:#e1e4e8;overflow-x:auto" tabindex="0" data-language="yaml" data-filename="frontmatter"><code><span class="line"><span style="color:#85E89D">title</span><span style="color:#E1E4E8">: </span><span style="color:#9ECBFF">&quot;This title is deliberately far too long to fit inside the sixty character limit the schema enforces&quot;</span></span></code></pre></div>
<div class="code-block code-block--has-filename"><div class="code-block__header"><span class="code-block__filename">the real error</span><button type="button" class="code-block__copy" data-copy-button>Copy</button></div><pre class="astro-code github-dark" style="background-color:#24292e;color:#e1e4e8;overflow-x:auto" tabindex="0" data-language="text" data-filename="the real error"><code><span class="line"><span>title: Title must be 60 characters or fewer</span></span></code></pre></div>
<p>Fix: whatever this message says, literally. Unlike the first three, this text comes from a custom <code>.max()</code>/<code>.refine()</code> call in the schema, not one of Zod’s built-in defaults, so it’s usually the most specific one to read.</p>
<h2 id="confirmed-behavior">Confirmed behavior</h2>
<ul>
<li>The build fails at the content-sync step, before Astro generates any page, not just the one with broken frontmatter. Confirmed by building each example above: the process never reached the page-build phase until that single entry validated.</li>
<li><code>astro check</code> reports the same error before <code>astro build</code> runs, because it triggers the identical content sync step first. Confirmed directly against this repo.</li>
<li>Every example here was tested against this site’s own <code>astro@7.1.3</code> install and its real schema in <code>content.config.ts</code>, not a synthetic example project.</li>
</ul>
<p>Read the field name and reason literally before assuming Astro found something more complicated. Most of the time it’s a one-line frontmatter fix, not a real schema problem. Browse more posts like this in the <a href="/guides-fixes">Guides &amp; Fixes</a> archive.</p>]]></content:encoded>
      <pubDate>Tue, 04 Aug 2026 00:00:00 GMT</pubDate>
    </item>
    <item>
      <title>Fix Cloudflare Workers Empty 404 Page on Static Sites</title>
      <link>https://bytetech247.com/guides-fixes/fix-cloudflare-workers-empty-404-astro/</link>
      <guid isPermaLink="true">https://bytetech247.com/guides-fixes/fix-cloudflare-workers-empty-404-astro/</guid>
      <description>Cloudflare Workers Static Assets doesn&apos;t serve your custom 404.html by default. Here&apos;s the exact config that fixes it, confirmed on a live production site.</description>
      <content:encoded><![CDATA[<aside class="quick-answer" aria-label="Quick answer"><h2 id="quick-answer">Quick Answer</h2>
<p>Cloudflare Workers Static Assets doesn’t serve your custom <code>404.html</code> by default. A request to an unmatched path gets a generic response instead, even if <code>dist/404.html</code> exists. Add <code>assets.not_found_handling: &quot;404-page&quot;</code> to <code>wrangler.toml</code>. Workers then serves the nearest <code>404.html</code> with a real 404 status.</p>
</aside><h2 id="why-the-default-doesnt-just-work">Why the default doesn’t just work</h2>
<p>Astro’s static build always outputs a real <code>dist/404.html</code> alongside every other page. It’s reasonable to assume Cloudflare Workers would find and serve it automatically for any unmatched route, the way most static hosts do. It doesn’t, unless you tell it to.</p>
<p><a href="https://developers.cloudflare.com/workers/static-assets/routing/static-site-generation/">Cloudflare’s own docs</a> are explicit that <code>not_found_handling: &quot;404-page&quot;</code> “overrides the default serving behavior of Workers for static assets.” That confirms the default is a separate, generic behavior, not automatic 404.html detection. This site hit exactly that gap directly: without the setting, an unmatched route returned a 404 status with an empty body, and <code>dist/404.html</code> was completely ignored despite existing right there in the deploy.</p>
<h2 id="fix-it-one-line-in-wranglertoml">Fix it: one line in wrangler.toml</h2>
<h3 id="before-404html-exists-but-is-never-served">Before: 404.html exists but is never served</h3>
<div class="code-block code-block--has-filename"><div class="code-block__header"><span class="code-block__filename">wrangler.toml</span><button type="button" class="code-block__copy" data-copy-button>Copy</button></div><pre class="astro-code github-dark" style="background-color:#24292e;color:#e1e4e8;overflow-x:auto" tabindex="0" data-language="toml" data-filename="wrangler.toml"><code><span class="line"><span style="color:#E1E4E8">[</span><span style="color:#B392F0">assets</span><span style="color:#E1E4E8">]</span></span>
<span class="line"><span style="color:#E1E4E8">directory = </span><span style="color:#9ECBFF">&quot;./dist&quot;</span></span>
<span class="line"><span style="color:#E1E4E8">binding = </span><span style="color:#9ECBFF">&quot;ASSETS&quot;</span></span></code></pre></div>
<h3 id="after-the-one-setting-that-changes-it">After: the one setting that changes it</h3>
<div class="code-block code-block--has-filename"><div class="code-block__header"><span class="code-block__filename">wrangler.toml</span><button type="button" class="code-block__copy" data-copy-button>Copy</button></div><pre class="astro-code github-dark" style="background-color:#24292e;color:#e1e4e8;overflow-x:auto" tabindex="0" data-language="toml" data-filename="wrangler.toml"><code><span class="line"><span style="color:#E1E4E8">[</span><span style="color:#B392F0">assets</span><span style="color:#E1E4E8">]</span></span>
<span class="line"><span style="color:#E1E4E8">directory = </span><span style="color:#9ECBFF">&quot;./dist&quot;</span></span>
<span class="line"><span style="color:#E1E4E8">binding = </span><span style="color:#9ECBFF">&quot;ASSETS&quot;</span></span>
<span class="line"><span style="color:#E1E4E8">not_found_handling = </span><span style="color:#9ECBFF">&quot;404-page&quot;</span></span></code></pre></div>
<p>That’s the entire fix. No code change, no build step, no Worker script required if you don’t already have one.</p>
<div class="callout" role="note" data-astro-cid-q2ml7llr><p class="callout__title" data-astro-cid-q2ml7llr><span aria-hidden="true" data-astro-cid-q2ml7llr>ℹ</span>Works the same with or without a custom Worker script</p><div class="callout__body" data-astro-cid-q2ml7llr><p>This setting lives on the <code>assets</code> config itself, not inside any Worker code
you write. If your project has a hand-written <code>main</code> entrypoint alongside the
assets binding (for API routes or similar), <code>not_found_handling</code> still applies
to any request that falls through to static-asset serving, confirmed directly
against this site’s own setup, which does exactly that.</p></div></div>
<h2 id="confirmed-on-a-live-production-site">Confirmed on a live production site</h2>
<p>This isn’t a guess at Cloudflare’s behavior. <code>bytetech247.com</code> runs <code>not_found_handling: &quot;404-page&quot;</code> in its own <code>wrangler.toml</code>, alongside a hand-written Worker script that handles a couple of API routes and falls through to static assets for everything else. Requesting a genuinely nonexistent path on the live site returns HTTP 404 with the real, fully rendered <code>404.html</code> content, not an empty body.</p>
<p>Check your own deploy the same way: request a path you know doesn’t exist and look at the actual response body, not just the status code. A 404 status with an empty or generic body is the tell that this setting is missing, even though the status code alone looks correct.</p>
<p>If you’re deploying an Astro static site to Cloudflare Workers and skipped this, add the one line. Browse more posts like this in the <a href="/guides-fixes">Guides &amp; Fixes</a> archive.</p>]]></content:encoded>
      <pubDate>Tue, 04 Aug 2026 00:00:00 GMT</pubDate>
    </item>
    <item>
      <title>Fix ERESOLVE Vite 8 Peer Dependency Errors in Astro 7</title>
      <link>https://bytetech247.com/guides-fixes/fix-eresolve-vite-8-peer-dependency-astro-7/</link>
      <guid isPermaLink="true">https://bytetech247.com/guides-fixes/fix-eresolve-vite-8-peer-dependency-astro-7/</guid>
      <description>Fix the ERESOLVE peer dependency error when npm install fails after upgrading to Astro 7, caused by plugins that don&apos;t yet support Vite 8.</description>
      <content:encoded><![CDATA[<aside class="quick-answer" aria-label="Quick answer"><h2 id="quick-answer">Quick Answer</h2>
<p>Astro 7 bundles Vite 8. An ERESOLVE error on <code>npm install</code> right after upgrading means a Vite plugin in your project still caps its peer dependency at Vite 7. Update that plugin to its latest version, or run <code>npm install --legacy-peer-deps</code> as a temporary workaround until it does.</p>
</aside><h2 id="why-the-eresolve-error-happens">Why the ERESOLVE error happens</h2>
<p>Astro 7 depends directly on Vite 8. <code>astro@7.1.3</code>’s <a href="https://www.npmjs.com/package/astro/v/7.1.3">published dependencies on npm</a> list <code>&quot;vite&quot;: &quot;^8.0.13&quot;</code> as a direct dependency, not a peer. The <a href="https://docs.astro.build/en/guides/upgrade-to/v7/">official Astro v7 upgrade guide</a> covers this change. The moment you run <code>npm install</code>, npm resolves that Vite version first, then checks every other package’s peer dependency against it.</p>
<p>npm has refused to silently paper over a peer dependency conflict by default since npm 7. If any plugin in your tree still declares something like <code>&quot;peerDependencies&quot;: { &quot;vite&quot;: &quot;^6.0.0 || ^7.0.0&quot; }</code>, with no <code>^8.0.0</code> in the range, npm has no valid version left to install and stops with <code>ERESOLVE</code> instead of guessing.</p>
<p><code>vite-plugin-pwa</code> hit exactly this. Version 1.2.0, published 2025-11-27, still capped its Vite peer range at <code>^7.0.0</code>. <a href="https://github.com/vite-pwa/vite-plugin-pwa/issues/923">Issue #923</a> asked for Vite 8 support in March 2026, and version 1.3.0, published 2026-05-05, added it. A project still pinned below 1.3.0 hits the same wall today. The same shape of error appears for any other plugin whose peer range hasn’t caught up yet.</p>
<h2 id="fix-it-update-the-plugin-not-just-the-flag">Fix it: update the plugin, not just the flag</h2>
<h3 id="before-the-real-error">Before: the real error</h3>
<div class="code-block code-block--has-filename"><div class="code-block__header"><span class="code-block__filename">npm install right after upgrading to Astro 7</span><button type="button" class="code-block__copy" data-copy-button>Copy</button></div><pre class="astro-code github-dark" style="background-color:#24292e;color:#e1e4e8;overflow-x:auto" tabindex="0" data-language="bash" data-filename="npm install right after upgrading to Astro 7"><code><span class="line"><span style="color:#B392F0">npm</span><span style="color:#9ECBFF"> error</span><span style="color:#9ECBFF"> code</span><span style="color:#9ECBFF"> ERESOLVE</span></span>
<span class="line"><span style="color:#B392F0">npm</span><span style="color:#9ECBFF"> error</span><span style="color:#9ECBFF"> ERESOLVE</span><span style="color:#9ECBFF"> unable</span><span style="color:#9ECBFF"> to</span><span style="color:#9ECBFF"> resolve</span><span style="color:#9ECBFF"> dependency</span><span style="color:#9ECBFF"> tree</span></span>
<span class="line"><span style="color:#B392F0">npm</span><span style="color:#9ECBFF"> error</span></span>
<span class="line"><span style="color:#B392F0">npm</span><span style="color:#9ECBFF"> error</span><span style="color:#9ECBFF"> While</span><span style="color:#9ECBFF"> resolving:</span><span style="color:#9ECBFF"> your-project@1.0.0</span></span>
<span class="line"><span style="color:#B392F0">npm</span><span style="color:#9ECBFF"> error</span><span style="color:#9ECBFF"> Found:</span><span style="color:#9ECBFF"> vite@8.2.0</span></span>
<span class="line"><span style="color:#B392F0">npm</span><span style="color:#9ECBFF"> error</span><span style="color:#9ECBFF"> node_modules/vite</span></span>
<span class="line"><span style="color:#B392F0">npm</span><span style="color:#9ECBFF"> error</span><span style="color:#9ECBFF">   vite@&quot;^8.0.13&quot;</span><span style="color:#9ECBFF"> from</span><span style="color:#9ECBFF"> the</span><span style="color:#9ECBFF"> root</span><span style="color:#9ECBFF"> project</span></span>
<span class="line"><span style="color:#B392F0">npm</span><span style="color:#9ECBFF"> error</span></span>
<span class="line"><span style="color:#B392F0">npm</span><span style="color:#9ECBFF"> error</span><span style="color:#9ECBFF"> Could</span><span style="color:#9ECBFF"> not</span><span style="color:#9ECBFF"> resolve</span><span style="color:#9ECBFF"> dependency:</span></span>
<span class="line"><span style="color:#B392F0">npm</span><span style="color:#9ECBFF"> error</span><span style="color:#9ECBFF"> peer</span><span style="color:#9ECBFF"> vite@&quot;^3.1.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0&quot;</span><span style="color:#9ECBFF"> from</span><span style="color:#9ECBFF"> vite-plugin-pwa@1.2.0</span></span>
<span class="line"><span style="color:#B392F0">npm</span><span style="color:#9ECBFF"> error</span><span style="color:#9ECBFF"> node_modules/vite-plugin-pwa</span></span>
<span class="line"><span style="color:#B392F0">npm</span><span style="color:#9ECBFF"> error</span></span>
<span class="line"><span style="color:#B392F0">npm</span><span style="color:#9ECBFF"> error</span><span style="color:#9ECBFF"> Fix</span><span style="color:#9ECBFF"> the</span><span style="color:#9ECBFF"> upstream</span><span style="color:#9ECBFF"> dependency</span><span style="color:#9ECBFF"> conflict,</span><span style="color:#9ECBFF"> or</span><span style="color:#9ECBFF"> retry</span></span>
<span class="line"><span style="color:#B392F0">npm</span><span style="color:#9ECBFF"> error</span><span style="color:#9ECBFF"> this</span><span style="color:#9ECBFF"> command</span><span style="color:#9ECBFF"> with</span><span style="color:#79B8FF"> --force</span><span style="color:#9ECBFF"> or</span><span style="color:#79B8FF"> --legacy-peer-deps</span></span></code></pre></div>
<p>Trimmed to the conflict that matters. Vite’s own optional peer dependencies add a few more lines in the real output, but this is the line that actually blocks the install.</p>
<h3 id="after-upgrade-the-plugin-then-reinstall">After: upgrade the plugin, then reinstall</h3>
<div class="code-block code-block--has-filename"><div class="code-block__header"><span class="code-block__filename">fix: bump the plugin, then reinstall</span><button type="button" class="code-block__copy" data-copy-button>Copy</button></div><pre class="astro-code github-dark" style="background-color:#24292e;color:#e1e4e8;overflow-x:auto" tabindex="0" data-language="bash" data-filename="fix: bump the plugin, then reinstall"><code><span class="line"><span style="color:#B392F0">npm</span><span style="color:#9ECBFF"> install</span><span style="color:#9ECBFF"> vite-plugin-pwa@latest</span></span>
<span class="line"><span style="color:#B392F0">npm</span><span style="color:#9ECBFF"> install</span></span></code></pre></div>
<p>The first line is the actual fix. It moves <code>vite-plugin-pwa</code> to a version whose peer range includes <code>^8.0.0</code>, which satisfies the Vite version Astro 7 already installed. The second line is just a normal reinstall to confirm the tree now resolves cleanly.</p>
<div class="callout" role="note" data-astro-cid-q2ml7llr><p class="callout__title" data-astro-cid-q2ml7llr><span aria-hidden="true" data-astro-cid-q2ml7llr>⚠</span>--legacy-peer-deps is a workaround, not a fix</p><div class="callout__body" data-astro-cid-q2ml7llr><p>It tells npm to install the mismatched versions anyway instead of stopping.
The plugin still runs against a major Vite version it was never tested
against, so anything that touches Vite’s plugin API internally can break in
ways npm will no longer warn you about. Use it to keep working while you wait
on an upstream update, then remove it once that update ships.</p></div></div>
<h2 id="confirmed-version-range">Confirmed version range</h2>
<p>Verified against this site’s own <code>astro@7.1.3</code>, which declares <code>vite@^8.0.13</code> as a direct dependency. A dry-run install against <code>vite-plugin-pwa@1.2.0</code> fails with the exact error above. Version 1.3.0’s published <code>peerDependencies</code> range already includes <code>^8.0.0</code>, which is what removes the conflict once you upgrade to it.</p>
<p>This pattern isn’t unique to <code>vite-plugin-pwa</code>. Any Vite plugin whose <code>package.json</code> hasn’t shipped an update adding <code>^8.0.0</code> to its peer dependency range will produce the identical error shape the moment it sits in a project next to Astro 7.</p>
<p>Check the plugin’s own changelog or issue tracker before reaching for <code>--legacy-peer-deps</code> as a permanent fix. If Vite 8 support has already shipped, a plain version bump is the real fix and takes about the same amount of time. Browse more posts like this in the <a href="/guides-fixes">Guides &amp; Fixes</a> archive.</p>]]></content:encoded>
      <pubDate>Tue, 04 Aug 2026 00:00:00 GMT</pubDate>
    </item>
    <item>
      <title>Why Astro Silently Merges Text Like &apos;5posts&apos; Into One Word</title>
      <link>https://bytetech247.com/guides-fixes/astro-whitespace-collapse-expression-bug/</link>
      <guid isPermaLink="true">https://bytetech247.com/guides-fixes/astro-whitespace-collapse-expression-bug/</guid>
      <description>Astro silently collapses whitespace between two expressions on separate lines, merging {count} and {label} into &apos;5posts&apos;. Here&apos;s the one-character fix.</description>
      <content:encoded><![CDATA[<p>Astro collapses the whitespace between two expressions when they sit on separate lines with nothing but a line break between them. Write <code>{count}</code> and <code>{label}</code> as two lines inside a component, and the rendered page shows <code>5posts</code> instead of <code>5 posts</code>. The space silently disappears. The fix is a one-character addition: an explicit <code>{&quot; &quot;}</code> expression where the space used to be.</p>
<p>It’s easy to miss because the source looks completely normal. The bug only shows up in the rendered output, and by the time a word runs together on the live page, the actual cause is several files and a formatting pass away from where you’re looking.</p>
<aside class="quick-answer" aria-label="Quick answer"><h2 id="quick-answer">Quick Answer</h2>
<p>Astro 7’s <code>compressHTML: &#39;jsx&#39;</code> default trims a line break between two expressions like <code>{count}</code> and <code>{label}</code> down to nothing, merging the rendered output into <code>5posts</code> instead of <code>5 posts</code>. Fix it by adding an explicit <code>{&quot; &quot;}</code> expression between them, or set <code>compressHTML: true</code> in <code>astro.config.mjs</code> to restore Astro 6’s spacing behavior sitewide.</p>
</aside><h2 id="why-astro-does-this">Why Astro does this</h2>
<p>Astro’s template syntax is JSX-derived, and JSX has a long-standing whitespace rule: text made up of nothing but spaces, tabs, and newlines between two elements or expressions gets trimmed away entirely, not condensed into a single space. That rule exists because indentation whitespace is assumed to be there for readability, not as meaningful content.</p>
<p>The trap is that the rule doesn’t distinguish between “this newline is just indentation” and “this newline replaced a space I actually wanted.” When both things you’re rendering are expressions, rather than static text, there’s no literal space character anywhere in the template for the compiler to preserve. So nothing renders between them.</p>
<div class="callout" role="note" data-astro-cid-q2ml7llr><p class="callout__title" data-astro-cid-q2ml7llr><span aria-hidden="true" data-astro-cid-q2ml7llr>⚠</span>This is an Astro 7 change, not a permanent Astro characteristic</p><div class="callout__body" data-astro-cid-q2ml7llr><p>This rule is controlled by the <code>compressHTML</code> config option, which defaults to <code>&#39;jsx&#39;</code> as of Astro 7. Astro 6 defaulted to <code>compressHTML: true</code> instead, which preserved this spacing (confirmed by building this exact page under both settings). Setting <code>compressHTML: true</code> in <code>astro.config.mjs</code> restores Astro 6’s behavior sitewide if patching every instance isn’t practical. The same default flip also breaks spacing between real HTML elements like <code>&lt;strong&gt;</code> and <code>&lt;span&gt;</code> on separate lines; see <a href="/guides-fixes/fix-astro-7-compresshtml-spacing-bug/">fixing the Astro 7 compressHTML spacing bug</a> for that variant.</p></div></div>
<h2 id="the-fix-an-explicit-space-expression">The fix: an explicit space expression</h2>
<div class="code-block code-block--has-filename"><div class="code-block__header"><span class="code-block__filename">broken - renders as 5posts</span><button type="button" class="code-block__copy" data-copy-button>Copy</button></div><pre class="astro-code github-dark" style="background-color:#24292e;color:#e1e4e8;overflow-x:auto" tabindex="0" data-language="astro" data-filename="broken - renders as 5posts"><code><span class="line"><span style="color:#E1E4E8">&lt;</span><span style="color:#85E89D">p</span><span style="color:#E1E4E8">&gt;</span></span>
<span class="line"><span style="color:#E1E4E8">  {count}</span></span>
<span class="line"><span style="color:#E1E4E8">  {label}</span></span>
<span class="line"><span style="color:#E1E4E8">&lt;/</span><span style="color:#85E89D">p</span><span style="color:#E1E4E8">&gt;</span></span></code></pre></div>
<div class="code-block code-block--has-filename"><div class="code-block__header"><span class="code-block__filename">fixed - explicit space</span><button type="button" class="code-block__copy" data-copy-button>Copy</button></div><pre class="astro-code github-dark" style="background-color:#24292e;color:#e1e4e8;overflow-x:auto" tabindex="0" data-language="astro" data-filename="fixed - explicit space"><code><span class="line"><span style="color:#E1E4E8">&lt;</span><span style="color:#85E89D">p</span><span style="color:#E1E4E8">&gt;</span></span>
<span class="line"><span style="color:#E1E4E8">  {count}{</span><span style="color:#9ECBFF">&quot; &quot;</span><span style="color:#E1E4E8">}</span></span>
<span class="line"><span style="color:#E1E4E8">  {label}</span></span>
<span class="line"><span style="color:#E1E4E8">&lt;/</span><span style="color:#85E89D">p</span><span style="color:#E1E4E8">&gt;</span></span></code></pre></div>
<p><code>{&quot; &quot;}</code> is itself an expression, a string literal containing one space, so it’s no longer whitespace-only text sitting between two expressions. It’s a real value, and Astro renders it exactly as written.</p>
<div class="callout" role="note" data-astro-cid-q2ml7llr><p class="callout__title" data-astro-cid-q2ml7llr><span aria-hidden="true" data-astro-cid-q2ml7llr>⚠</span>This can appear after you didn&#39;t touch the content</p><div class="callout__body" data-astro-cid-q2ml7llr><p>Prettier collapses the explicit space placeholder from the fixed example above
back down to a plain, single-line space whenever the whole tag’s content fits
on one line. A single-line string there is unambiguous. The moment that same
content later wraps across multiple lines, whether from Prettier’s own
line-length limit, a longer word, or a prop added nearby, the placeholder
becomes required again. Leave it out and the space vanishes with no change to
the words themselves. A totally unrelated formatting pass is what can
introduce a text-merging bug days after the paragraph was written correctly.</p></div></div>
<h2 id="how-to-actually-confirm-its-fixed">How to actually confirm it’s fixed</h2>
<p>Don’t trust how the source looks. Trust the built output. A missing space collapses invisibly in the editor; nothing about <code>{count}{&quot;·&quot;}\n{label}</code> versus <code>{count}\n{label}</code> reads differently at a glance. The only way to know for certain is to inspect the compiled HTML.</p>
<div class="code-block code-block--has-filename"><div class="code-block__header"><span class="code-block__filename">check the real rendered text</span><button type="button" class="code-block__copy" data-copy-button>Copy</button></div><pre class="astro-code github-dark" style="background-color:#24292e;color:#e1e4e8;overflow-x:auto" tabindex="0" data-language="bash" data-filename="check the real rendered text"><code><span class="line"><span style="color:#B392F0">node</span><span style="color:#79B8FF"> -e</span><span style="color:#9ECBFF"> &quot;</span></span>
<span class="line"><span style="color:#9ECBFF">const fs = require(&#39;fs&#39;);</span></span>
<span class="line"><span style="color:#9ECBFF">const html = fs.readFileSync(&#39;dist/client/archive/index.html&#39;, &#39;utf8&#39;);</span></span>
<span class="line"><span style="color:#9ECBFF">const match = html.match(/&lt;p class=</span><span style="color:#79B8FF">\&quot;</span><span style="color:#9ECBFF">archive-count</span><span style="color:#79B8FF">\&quot;</span><span style="color:#9ECBFF">[^&gt;]*&gt;([^&lt;]*)&lt;\/p&gt;/);</span></span>
<span class="line"><span style="color:#9ECBFF">console.log(match &amp;&amp; match[1]);</span></span>
<span class="line"><span style="color:#9ECBFF">&quot;</span></span></code></pre></div>
<p>If that prints <code>5 posts across 4 categories</code> with real spaces, it’s fixed. If it prints <code>5postsacross4categories</code>, a <code>{&quot; &quot;}</code> is missing somewhere in that block. Go find it before shipping, not after a reader points it out.</p>
<h2 id="when-this-is-actually-worth-checking">When this is actually worth checking</h2>
<p>Reach for this check wherever a paragraph or heading interleaves more than one expression with no static text holding them apart, counts, dates, or anything assembled from a few pieces like <code>{count} {label}</code>. A component that renders a single expression, or expressions already separated by real static text (<code>Posted on {date}</code>), never hits this rule and doesn’t need it.</p>
<p>This is one of a few Astro-specific gotchas this site has run into directly. See <a href="/guides-fixes/fix-missing-author-json-ld-astro-blog/">fixing the Rich Results Test’s missing-author error</a> for another one that only shows up after you’ve already shipped. For the full rundown of Astro’s template expression syntax, see the <a href="https://docs.astro.build/en/basics/astro-syntax/">official Astro syntax docs</a>.</p>]]></content:encoded>
      <pubDate>Thu, 30 Jul 2026 00:00:00 GMT</pubDate>
    </item>
    <item>
      <title>Fixing &lt;details&gt; Dropdowns That Won&apos;t Close on Outside Click</title>
      <link>https://bytetech247.com/guides-fixes/fixing-details-summary-dropdown-outside-click/</link>
      <guid isPermaLink="true">https://bytetech247.com/guides-fixes/fixing-details-summary-dropdown-outside-click/</guid>
      <description>Native &lt;details&gt;/&lt;summary&gt; dropdowns only toggle via their own &lt;summary&gt;. Click anywhere else and it stays open. Here&apos;s the small, dependency-free fix.</description>
      <content:encoded><![CDATA[<p>A native <code>&lt;details&gt;</code> element only toggles when you click its own <code>&lt;summary&gt;</code>. Click anywhere else (a different dropdown, a button, empty page space) and it stays open, because the browser never wired up outside-click handling for it. The fix is a small, dependency-free script: on every click anywhere in the document, close any open <code>&lt;details&gt;</code> unless the click happened inside that same element.</p>
<p>This isn’t a bug in the browser. It’s a real gap between what <code>&lt;details&gt;</code> was built for (a simple content disclosure, like a spoiler) and what people actually reach for it to do (a dropdown menu), and the second use case needs code the first one never required.</p>
<aside class="quick-answer" aria-label="Quick answer"><h2 id="quick-answer">Quick Answer</h2>
<p><code>&lt;details&gt;</code> only wires up one interaction: toggling via its own <code>&lt;summary&gt;</code>. Fix outside-click-to-close by adding a document-wide <code>click</code> listener that closes any open <code>.site-header details[open]</code> unless the click happened inside that element, plus a <code>keydown</code> listener that closes every open one on Escape. No dependency, about ten lines total.</p>
</aside><h2 id="why-details-doesnt-close-on-its-own">Why <code>&lt;details&gt;</code> doesn’t close on its own</h2>
<p>The HTML spec gives <code>&lt;details&gt;</code> exactly one interaction: clicking (or pressing Enter/Space on) its <code>&lt;summary&gt;</code> toggles the <code>open</code> attribute. That’s it. There’s no concept of “outside” built in, because for a plain collapsible section (an FAQ answer, a spoiler tag), staying open regardless of where else the reader clicks is the <em>correct</em> behavior. A dropdown menu wants the opposite, and the browser has no way to know which one you’re building.</p>
<h2 id="the-fix-close-on-any-outside-click">The fix: close on any outside click</h2>
<div class="code-block code-block--has-filename"><div class="code-block__header"><span class="code-block__filename">src/components/Header.astro</span><button type="button" class="code-block__copy" data-copy-button>Copy</button></div><pre class="astro-code github-dark" style="background-color:#24292e;color:#e1e4e8;overflow-x:auto" tabindex="0" data-language="js" data-filename="src/components/Header.astro"><code><span class="line"><span style="color:#F97583">function</span><span style="color:#B392F0"> closeOpenHeaderDetails</span><span style="color:#E1E4E8">(</span><span style="color:#FFAB70">except</span><span style="color:#E1E4E8">) {</span></span>
<span class="line"><span style="color:#E1E4E8">  document.</span><span style="color:#B392F0">querySelectorAll</span><span style="color:#E1E4E8">(</span><span style="color:#9ECBFF">&quot;.site-header details[open]&quot;</span><span style="color:#E1E4E8">).</span><span style="color:#B392F0">forEach</span><span style="color:#E1E4E8">((</span><span style="color:#FFAB70">details</span><span style="color:#E1E4E8">) </span><span style="color:#F97583">=&gt;</span><span style="color:#E1E4E8"> {</span></span>
<span class="line"><span style="color:#F97583">    if</span><span style="color:#E1E4E8"> (</span><span style="color:#F97583">!</span><span style="color:#E1E4E8">except </span><span style="color:#F97583">||</span><span style="color:#F97583"> !</span><span style="color:#E1E4E8">details.</span><span style="color:#B392F0">contains</span><span style="color:#E1E4E8">(except)) {</span></span>
<span class="line"><span style="color:#E1E4E8">      details.</span><span style="color:#B392F0">removeAttribute</span><span style="color:#E1E4E8">(</span><span style="color:#9ECBFF">&quot;open&quot;</span><span style="color:#E1E4E8">);</span></span>
<span class="line"><span style="color:#E1E4E8">    }</span></span>
<span class="line"><span style="color:#E1E4E8">  });</span></span>
<span class="line"><span style="color:#E1E4E8">}</span></span>
<span class="line"></span>
<span class="line"><span style="color:#E1E4E8">document.</span><span style="color:#B392F0">addEventListener</span><span style="color:#E1E4E8">(</span><span style="color:#9ECBFF">&quot;click&quot;</span><span style="color:#E1E4E8">, (</span><span style="color:#FFAB70">event</span><span style="color:#E1E4E8">) </span><span style="color:#F97583">=&gt;</span><span style="color:#E1E4E8"> {</span></span>
<span class="line"><span style="color:#B392F0">  closeOpenHeaderDetails</span><span style="color:#E1E4E8">(event.target);</span></span>
<span class="line"><span style="color:#E1E4E8">});</span></span></code></pre></div>
<p>Every click on the document runs this function, passing the exact element that was clicked. Any open <code>&lt;details&gt;</code> that doesn’t contain that element gets closed. Passing the click target (rather than closing everything unconditionally) matters for one specific case: clicking a <em>different</em> dropdown’s own <code>&lt;summary&gt;</code> still opens it natively, on the same click that this script is busy closing the first one. Switching between two sibling dropdowns ends up feeling like one continuous gesture instead of two separate clicks.</p>
<h2 id="handling-escape-too">Handling Escape too</h2>
<div class="code-block code-block--has-filename"><div class="code-block__header"><span class="code-block__filename">src/components/Header.astro</span><button type="button" class="code-block__copy" data-copy-button>Copy</button></div><pre class="astro-code github-dark" style="background-color:#24292e;color:#e1e4e8;overflow-x:auto" tabindex="0" data-language="js" data-filename="src/components/Header.astro"><code><span class="line"><span style="color:#E1E4E8">document.</span><span style="color:#B392F0">addEventListener</span><span style="color:#E1E4E8">(</span><span style="color:#9ECBFF">&quot;keydown&quot;</span><span style="color:#E1E4E8">, (</span><span style="color:#FFAB70">event</span><span style="color:#E1E4E8">) </span><span style="color:#F97583">=&gt;</span><span style="color:#E1E4E8"> {</span></span>
<span class="line"><span style="color:#F97583">  if</span><span style="color:#E1E4E8"> (event.key </span><span style="color:#F97583">===</span><span style="color:#9ECBFF"> &quot;Escape&quot;</span><span style="color:#E1E4E8">) </span><span style="color:#B392F0">closeOpenHeaderDetails</span><span style="color:#E1E4E8">();</span></span>
<span class="line"><span style="color:#E1E4E8">});</span></span></code></pre></div>
<p>No exception argument here. Escape means close everything that’s open, regardless of where focus currently sits. There’s no sibling dropdown to preserve, so the conditional the click handler needs doesn’t apply.</p>
<div class="callout" role="note" data-astro-cid-q2ml7llr><p class="callout__title" data-astro-cid-q2ml7llr><span aria-hidden="true" data-astro-cid-q2ml7llr>✓</span>Scope the selector to what you actually built</p><div class="callout__body" data-astro-cid-q2ml7llr><p>Query a scoped selector like <code>.site-header details[open]</code>, not every
<code>&lt;details&gt;</code> on the page. A blog post can legitimately use <code>&lt;details&gt;</code> for an
unrelated collapsible section, and that one should stay open exactly as long
as the reader left it, regardless of what else they click elsewhere on the
page.</p></div></div>
<h2 id="why-this-beats-a-click-outside-library">Why this beats a click-outside library</h2>
<p>No dependency, no bundle weight, and it scales to every dropdown on the page for free. A desktop nav menu and a mobile hamburger menu built the same way both match <code>.site-header details[open]</code>, so one listener handles both instead of one listener per menu. For a site that ships zero JavaScript by default and only adds it where an interaction genuinely needs it, this is the entire cost of making dropdown menus behave the way users already expect them to.</p>
<h2 id="how-to-verify-it-actually-works">How to verify it actually works</h2>
<p>Three checks cover the real failure modes: open one dropdown, click something that isn’t a dropdown (a button, the page body), and it should close. Open one dropdown, then click a sibling dropdown’s own <code>&lt;summary&gt;</code>; the first should close and the second should open, in that single click. Press Escape while one is open, and it should close. If any of those three don’t hold, the exception logic or the selector scope is the first place to look.</p>
<p>Reach for this exact pattern anywhere <code>&lt;details&gt;</code> is standing in for a dropdown menu (navigation, a filter panel, an actions menu), wherever outside-click-to-close is the behavior a user already expects from every other menu on the web. Skip it for a genuine collapsible content section, where staying open no matter what else gets clicked is the whole point.</p>
<p>See the <a href="https://developer.mozilla.org/en-US/docs/Web/HTML/Element/details">MDN reference for the <code>&lt;details&gt;</code> element</a> for the native toggle behavior this fix builds on, and <a href="/guides-fixes/astro-whitespace-collapse-expression-bug/">the Astro whitespace-collapse bug</a> for another small, real bug from this same site’s header.</p>]]></content:encoded>
      <pubDate>Thu, 30 Jul 2026 00:00:00 GMT</pubDate>
    </item>
    <item>
      <title>Cloudflare Workers Deploys: Built-In Git vs. GitHub Actions</title>
      <link>https://bytetech247.com/data-automation/automate-static-site-deploys-github-actions-cloudflare-workers/</link>
      <guid isPermaLink="true">https://bytetech247.com/data-automation/automate-static-site-deploys-github-actions-cloudflare-workers/</guid>
      <description>Two ways to auto-deploy a static site to Cloudflare Workers: built-in Git integration vs. a custom GitHub Actions workflow, and how to choose.</description>
      <content:encoded><![CDATA[<p>Running <code>wrangler deploy</code> from your own machine works fine for the first week of a project. It stops working the moment you want deploys to happen consistently: from a clean environment, without depending on whoever happens to be at their keyboard, and ideally gated on the build actually passing first. That’s what automated deploys solve, and for a static site on Cloudflare Workers there are two reasonable ways to set it up.</p>
<aside class="quick-answer" aria-label="Quick answer"><h2 id="quick-answer">Quick Answer</h2>
<p>Cloudflare Workers Builds deploys straight from a dashboard Git connection with zero YAML, on every push, but with no pre-deploy checks beyond the build itself. GitHub Actions needs a workflow file but lets you gate the deploy on tests, lint, or an accessibility check passing first. Start with Workers Builds; move to Actions once a bad deploy reaching production would actually cost you something.</p>
</aside><h2 id="option-a-cloudflare-workers-builds">Option A: Cloudflare Workers Builds</h2>
<p>Cloudflare can watch your GitHub repository directly and deploy on every push, with no YAML file of your own to maintain. In the dashboard: <strong>Workers &amp; Pages → your Worker → Settings → Builds → Connect to Git</strong>, point it at the repo and branch, and set a <a href="https://developers.cloudflare.com/workers/ci-cd/builds/configuration/">build command (<code>npm run build</code>) and the directory the build outputs to</a>.</p>
<p>From then on, every push to the connected branch triggers a build and deploy automatically, and the dashboard shows build logs and history per deploy, with no infrastructure to maintain on your side.</p>
<div class="callout" role="note" data-astro-cid-q2ml7llr><p class="callout__title" data-astro-cid-q2ml7llr><span aria-hidden="true" data-astro-cid-q2ml7llr>✓</span>Scope the deploy, not the whole account</p><div class="callout__body" data-astro-cid-q2ml7llr><p>If you later add a GitHub Actions workflow alongside or instead of Workers
Builds, generate a Cloudflare API token scoped to exactly <code>Workers   Scripts:Edit</code> for the account/zone in question, not a broad, all-permissions
token. A workflow only needs enough access to deploy the one Worker; if the
token ever leaks, narrow scope limits the blast radius.</p></div></div>
<h2 id="option-b-github-actions">Option B: GitHub Actions</h2>
<p>The tradeoff with Workers Builds is control: it runs your build command and deploys, full stop. If you want to run tests, linting, or accessibility checks first, and only deploy if they pass, you need a workflow you author yourself.</p>
<div class="code-block code-block--has-filename"><div class="code-block__header"><span class="code-block__filename">.github/workflows/deploy.yml</span><button type="button" class="code-block__copy" data-copy-button>Copy</button></div><pre class="astro-code github-dark" style="background-color:#24292e;color:#e1e4e8;overflow-x:auto" tabindex="0" data-language="yaml" data-filename=".github/workflows/deploy.yml"><code><span class="line"><span style="color:#85E89D">name</span><span style="color:#E1E4E8">: </span><span style="color:#9ECBFF">Deploy</span></span>
<span class="line"></span>
<span class="line"><span style="color:#79B8FF">on</span><span style="color:#E1E4E8">:</span></span>
<span class="line"><span style="color:#85E89D">  push</span><span style="color:#E1E4E8">:</span></span>
<span class="line"><span style="color:#85E89D">    branches</span><span style="color:#E1E4E8">: [</span><span style="color:#9ECBFF">main</span><span style="color:#E1E4E8">]</span></span>
<span class="line"></span>
<span class="line"><span style="color:#85E89D">jobs</span><span style="color:#E1E4E8">:</span></span>
<span class="line"><span style="color:#85E89D">  deploy</span><span style="color:#E1E4E8">:</span></span>
<span class="line"><span style="color:#85E89D">    runs-on</span><span style="color:#E1E4E8">: </span><span style="color:#9ECBFF">ubuntu-latest</span></span>
<span class="line"><span style="color:#85E89D">    steps</span><span style="color:#E1E4E8">:</span></span>
<span class="line"><span style="color:#E1E4E8">      - </span><span style="color:#85E89D">uses</span><span style="color:#E1E4E8">: </span><span style="color:#9ECBFF">actions/checkout@v5</span></span>
<span class="line"></span>
<span class="line"><span style="color:#E1E4E8">      - </span><span style="color:#85E89D">uses</span><span style="color:#E1E4E8">: </span><span style="color:#9ECBFF">actions/setup-node@v5</span></span>
<span class="line"><span style="color:#85E89D">        with</span><span style="color:#E1E4E8">:</span></span>
<span class="line"><span style="color:#85E89D">          node-version</span><span style="color:#E1E4E8">: </span><span style="color:#79B8FF">24</span></span>
<span class="line"></span>
<span class="line"><span style="color:#E1E4E8">      - </span><span style="color:#85E89D">run</span><span style="color:#E1E4E8">: </span><span style="color:#9ECBFF">npm ci</span></span>
<span class="line"><span style="color:#E1E4E8">      - </span><span style="color:#85E89D">run</span><span style="color:#E1E4E8">: </span><span style="color:#9ECBFF">npm run lint</span></span>
<span class="line"><span style="color:#E1E4E8">      - </span><span style="color:#85E89D">run</span><span style="color:#E1E4E8">: </span><span style="color:#9ECBFF">npm run build</span></span>
<span class="line"></span>
<span class="line"><span style="color:#E1E4E8">      - </span><span style="color:#85E89D">name</span><span style="color:#E1E4E8">: </span><span style="color:#9ECBFF">Deploy to Cloudflare Workers</span></span>
<span class="line"><span style="color:#85E89D">        uses</span><span style="color:#E1E4E8">: </span><span style="color:#9ECBFF">cloudflare/wrangler-action@v3</span></span>
<span class="line"><span style="color:#85E89D">        with</span><span style="color:#E1E4E8">:</span></span>
<span class="line"><span style="color:#85E89D">          apiToken</span><span style="color:#E1E4E8">: </span><span style="color:#9ECBFF">${{ secrets.CLOUDFLARE_API_TOKEN }}</span></span>
<span class="line"><span style="color:#85E89D">          command</span><span style="color:#E1E4E8">: </span><span style="color:#9ECBFF">deploy</span></span></code></pre></div>
<p>The deploy step only runs if every step above it succeeds: a broken build or a failing lint check never reaches production. The <code>CLOUDFLARE_API_TOKEN</code> secret is set once under the repo’s <a href="https://docs.github.com/en/actions/security-guides/using-secrets-in-github-actions"><strong>Settings → Secrets and variables → Actions</strong></a>, scoped the same way as described above.</p>
<h2 id="choosing-between-them">Choosing between them</h2>






























<table tabindex="0"><thead><tr><th scope="col"></th><th scope="col">Workers Builds</th><th scope="col">GitHub Actions</th></tr></thead><tbody><tr><td>Setup effort</td><td>Minutes, no files to write</td><td>A YAML file to author and maintain</td></tr><tr><td>Pre-deploy checks</td><td>Build command only</td><td>Any number of steps, deploy gated on all passing</td></tr><tr><td>Where logs live</td><td>Cloudflare dashboard</td><td>GitHub Actions tab</td></tr><tr><td>Best for</td><td>Small projects, fast iteration</td><td>Projects with tests/lint you want enforced before deploy</td></tr></tbody></table>
<p>A reasonable default: start with Workers Builds, since it costs nothing to set up and gets you automatic deploys immediately. Move to GitHub Actions once there’s a build step you genuinely don’t want to skip, such as tests, an accessibility check, or a broken-link scan, and you want a bad result to block the deploy rather than just show up in a log after the fact.</p>
<p>Both approaches deploy the exact same <code>wrangler deploy</code> command underneath; the difference is entirely about what runs before it.</p>
<p>Once deploys are automated, check the actual runtime behavior too, not just that the build succeeded: see <a href="/guides-fixes/fix-cloudflare-workers-empty-404-astro/">fixing Cloudflare Workers’ empty 404 page</a> for a config gap that a passing build won’t catch.</p>]]></content:encoded>
      <pubDate>Mon, 27 Jul 2026 00:00:00 GMT</pubDate>
    </item>
    <item>
      <title>Fixing &apos;Missing Field Author&apos; in Google&apos;s Rich Results Test</title>
      <link>https://bytetech247.com/guides-fixes/fix-missing-author-json-ld-astro-blog/</link>
      <guid isPermaLink="true">https://bytetech247.com/guides-fixes/fix-missing-author-json-ld-astro-blog/</guid>
      <description>Why Google&apos;s Rich Results Test flags a missing author field on Article pages, and how to add correct Person and Organization JSON-LD in Astro to resolve it.</description>
      <content:encoded><![CDATA[<p>You run a post through Google’s <a href="https://search.google.com/test/rich-results">Rich Results Test</a> and the <code>Article</code> and <code>BreadcrumbList</code> blocks both come back valid, but there’s a note sitting under Article: <strong>“Missing field ‘author’ (optional but recommended).”</strong> The page visibly shows a byline. So why is Google saying the field is missing?</p>
<aside class="quick-answer" aria-label="Quick answer"><h2 id="quick-answer">Quick Answer</h2>
<p>Google reads <code>author</code> from the page’s JSON-LD, not the rendered byline text — a visible “By Jane Doe” and a real <code>author</code> property in structured data are unrelated. Fix it by adding an <code>author</code> (<code>Person</code>, with a <code>name</code> and a URL to a real About page) and a <code>publisher</code> (<code>Organization</code>, with a <code>name</code> and a resolvable <code>logo</code>) to the <code>Article</code> JSON-LD block.</p>
</aside><h2 id="why-this-happens">Why this happens</h2>
<p>A byline you can see on the page and an <code>author</code> field in the page’s structured data are two unrelated things. Rich results are read from a <code>&lt;script type=&quot;application/ld+json&quot;&gt;</code> block, not from the rendered HTML, so a page can display “By Jane Doe” in plain text while emitting no <code>author</code> property in its JSON-LD at all, and the two will never be reconciled automatically. This is exactly what happens on a lot of Astro blogs: the visual layout was built first, structured data was never added, and everything looks correct until you check what Google actually parses.</p>
<p>The <code>Article</code> type’s <code>author</code> property expects a <code>Person</code> (or <code>Organization</code>) object with, at minimum, a <code>name</code>. Google also recommends a <code>publisher</code> (an <code>Organization</code> with its own <code>name</code> and <code>logo</code>) since that’s what ties the article to the site as a whole rather than just the individual page.</p>
<h2 id="the-fix">The fix</h2>
<p>Build the JSON-LD from the same data already driving the page (the post’s frontmatter and the site’s config) rather than hand-typing it per post. In an Astro layout:</p>
<div class="code-block code-block--has-filename"><div class="code-block__header"><span class="code-block__filename">src/layouts/BaseLayout.astro</span><button type="button" class="code-block__copy" data-copy-button>Copy</button></div><pre class="astro-code github-dark" style="background-color:#24292e;color:#e1e4e8;overflow-x:auto" tabindex="0" data-language="astro" data-filename="src/layouts/BaseLayout.astro"><code><span class="line"><span style="color:#9ca6b0">---</span></span>
<span class="line"><span style="color:#F97583">const</span><span style="color:#79B8FF"> jsonLd</span><span style="color:#F97583"> =</span><span style="color:#E1E4E8"> {</span></span>
<span class="line"><span style="color:#9ECBFF">  &quot;@context&quot;</span><span style="color:#E1E4E8">: </span><span style="color:#9ECBFF">&quot;https://schema.org&quot;</span><span style="color:#E1E4E8">,</span></span>
<span class="line"><span style="color:#9ECBFF">  &quot;@type&quot;</span><span style="color:#E1E4E8">: </span><span style="color:#9ECBFF">&quot;Article&quot;</span><span style="color:#E1E4E8">,</span></span>
<span class="line"><span style="color:#E1E4E8">  headline: title,</span></span>
<span class="line"><span style="color:#E1E4E8">  datePublished: date.</span><span style="color:#B392F0">toISOString</span><span style="color:#E1E4E8">(),</span></span>
<span class="line"><span style="color:#E1E4E8">  author: {</span></span>
<span class="line"><span style="color:#9ECBFF">    &quot;@type&quot;</span><span style="color:#E1E4E8">: </span><span style="color:#9ECBFF">&quot;Person&quot;</span><span style="color:#E1E4E8">,</span></span>
<span class="line"><span style="color:#E1E4E8">    name: siteConfig.author.name,</span></span>
<span class="line"><span style="color:#E1E4E8">    url: </span><span style="color:#9ECBFF">`${</span><span style="color:#E1E4E8">siteConfig</span><span style="color:#9ECBFF">.</span><span style="color:#E1E4E8">url</span><span style="color:#9ECBFF">}/about/`</span><span style="color:#E1E4E8">,</span></span>
<span class="line"><span style="color:#E1E4E8">  },</span></span>
<span class="line"><span style="color:#E1E4E8">  publisher: {</span></span>
<span class="line"><span style="color:#9ECBFF">    &quot;@type&quot;</span><span style="color:#E1E4E8">: </span><span style="color:#9ECBFF">&quot;Organization&quot;</span><span style="color:#E1E4E8">,</span></span>
<span class="line"><span style="color:#E1E4E8">    name: siteConfig.name,</span></span>
<span class="line"><span style="color:#E1E4E8">    logo: {</span></span>
<span class="line"><span style="color:#9ECBFF">      &quot;@type&quot;</span><span style="color:#E1E4E8">: </span><span style="color:#9ECBFF">&quot;ImageObject&quot;</span><span style="color:#E1E4E8">,</span></span>
<span class="line"><span style="color:#E1E4E8">      url: </span><span style="color:#9ECBFF">`${</span><span style="color:#E1E4E8">siteConfig</span><span style="color:#9ECBFF">.</span><span style="color:#E1E4E8">url</span><span style="color:#9ECBFF">}/logo-mark.svg`</span><span style="color:#E1E4E8">,</span></span>
<span class="line"><span style="color:#E1E4E8">    },</span></span>
<span class="line"><span style="color:#E1E4E8">  },</span></span>
<span class="line"><span style="color:#E1E4E8">};</span></span>
<span class="line"><span style="color:#9ca6b0">---</span></span>
<span class="line"></span>
<span class="line"><span style="color:#E1E4E8">&lt;</span><span style="color:#85E89D">script</span><span style="color:#B392F0"> type</span><span style="color:#E1E4E8">=</span><span style="color:#9ECBFF">&quot;application/ld+json&quot;</span><span style="color:#B392F0"> set:html</span><span style="color:#E1E4E8">={</span><span style="color:#79B8FF">JSON</span><span style="color:#E1E4E8">.</span><span style="color:#B392F0">stringify</span><span style="color:#E1E4E8">(jsonLd)} /&gt;</span></span></code></pre></div>
<p>Two details matter here. First, <code>author</code> links to a real URL (typically an About or author-bio page) because Google treats an author with a resolvable page as a stronger, more trustworthy signal than a bare name string. Second, <code>publisher.logo</code> needs a real image URL that actually resolves; a broken or placeholder logo link can cause its own separate validation warning.</p>
<div class="callout" role="note" data-astro-cid-q2ml7llr><p class="callout__title" data-astro-cid-q2ml7llr><span aria-hidden="true" data-astro-cid-q2ml7llr>⚠</span>Use set:html with JSON.stringify, not a template string</p><div class="callout__body" data-astro-cid-q2ml7llr><p>Don’t build the JSON-LD by hand-interpolating values into a string. An
apostrophe or unescaped quote in a title will silently break the JSON parse,
and the whole block fails without any visible error on the page.
<code>JSON.stringify()</code> handles escaping correctly; Astro’s <code>set:html</code> then injects
it without HTML-escaping the JSON itself (which would corrupt it just as
badly).</p></div></div>
<h2 id="verifying-the-fix">Verifying the fix</h2>
<p>After deploying, run the live URL back through the Rich Results Test. The “Missing field ‘author’” note should be gone, and Google’s own <a href="https://support.google.com/webmasters/answer/3069489">Structured Data Markup Helper</a> or <a href="https://validator.schema.org/">Schema Markup Validator</a> will show the <code>author</code> and <code>publisher</code> objects present with their resolved values. It’s worth checking a couple of different posts, not just one. A bug in how the layout pulls frontmatter (a missing <code>date</code>, for instance) can make the field present on some pages and absent on others, which is easy to miss if you only ever test the same one URL.</p>
<div class="callout" role="note" data-astro-cid-q2ml7llr><p class="callout__title" data-astro-cid-q2ml7llr><span aria-hidden="true" data-astro-cid-q2ml7llr>✓</span>One layout, every post</p><div class="callout__body" data-astro-cid-q2ml7llr><p>Because this lives in the shared layout and reads from <code>siteConfig</code> and each
post’s own frontmatter, every existing and future post gets correct <code>author</code>
and <code>publisher</code> data automatically; there’s nothing to remember to add per
article.</p></div></div>
<p>Browse more posts like this in the <a href="/guides-fixes">Guides &amp; Fixes</a> archive.</p>]]></content:encoded>
      <pubDate>Mon, 27 Jul 2026 00:00:00 GMT</pubDate>
    </item>
    <item>
      <title>Git Worktrees: Multiple Branches Without the Stash Shuffle</title>
      <link>https://bytetech247.com/dev-tools/git-worktrees-parallel-feature-development/</link>
      <guid isPermaLink="true">https://bytetech247.com/dev-tools/git-worktrees-parallel-feature-development/</guid>
      <description>A guide to git worktree: check out several branches into separate folders at once, skip the stash-switch-unstash cycle, and dodge the cloud-sync pitfall.</description>
      <content:encoded><![CDATA[<p>Every developer knows the interruption: you’re mid-feature, half your files are edited, and a teammate needs an urgent fix on <code>main</code>. The usual move is <code>git stash</code>, switch branches, fix the bug, switch back, <code>git stash pop</code>, and hope nothing conflicts. It works, but it’s friction you pay every single time context-switching happens, and on a bad day it happens five times before lunch.</p>
<p><code>git worktree</code> removes the friction entirely. Instead of one working directory that can only ever point at one branch, a worktree lets you check out several branches into separate folders at the same time, all sharing the same underlying repository.</p>
<aside class="quick-answer" aria-label="Quick answer"><h2 id="quick-answer">Quick Answer</h2>
<p><code>git worktree add ../myproject-hotfix main</code> checks out another branch into its own folder while your current one stays exactly as you left it, sharing one <code>.git</code> history across both. Reach for it when an interruption is long enough that losing your place would cost real time; a plain <code>git stash</code> is still less typing for a thirty-second fix.</p>
</aside><h2 id="what-a-worktree-actually-is">What a worktree actually is</h2>
<p>A normal clone has one working directory and one <code>.git</code> folder. <a href="https://git-scm.com/docs/git-worktree"><code>git worktree add</code></a> creates an additional working directory linked to that same <code>.git</code>: same commit history, same remotes, same config, but checked out to a different branch. Nothing is duplicated except the files themselves.</p>
<div class="code-block"><div class="code-block__header"><span class="code-block__filename"></span><button type="button" class="code-block__copy" data-copy-button>Copy</button></div><pre class="astro-code github-dark" style="background-color:#24292e;color:#e1e4e8;overflow-x:auto" tabindex="0" data-language="bash"><code><span class="line"><span style="color:#9ca6b0"># from inside your existing repo</span></span>
<span class="line"><span style="color:#B392F0">git</span><span style="color:#9ECBFF"> worktree</span><span style="color:#9ECBFF"> add</span><span style="color:#9ECBFF"> ../myproject-hotfix</span><span style="color:#9ECBFF"> main</span></span></code></pre></div>
<p>That command creates a sibling folder, <code>../myproject-hotfix</code>, already checked out to <code>main</code>, ready to work in immediately: no stash, no losing your place in the feature branch sitting untouched in the original folder.</p>
<h2 id="the-core-commands">The core commands</h2>
<div class="code-block code-block--has-filename"><div class="code-block__header"><span class="code-block__filename">worktree basics</span><button type="button" class="code-block__copy" data-copy-button>Copy</button></div><pre class="astro-code github-dark" style="background-color:#24292e;color:#e1e4e8;overflow-x:auto" tabindex="0" data-language="bash" data-filename="worktree basics"><code><span class="line"><span style="color:#9ca6b0"># add a new worktree for an existing branch</span></span>
<span class="line"><span style="color:#B392F0">git</span><span style="color:#9ECBFF"> worktree</span><span style="color:#9ECBFF"> add</span><span style="color:#9ECBFF"> ../myproject-hotfix</span><span style="color:#9ECBFF"> main</span></span>
<span class="line"></span>
<span class="line"><span style="color:#9ca6b0"># add a new worktree AND create a new branch at the same time</span></span>
<span class="line"><span style="color:#B392F0">git</span><span style="color:#9ECBFF"> worktree</span><span style="color:#9ECBFF"> add</span><span style="color:#79B8FF"> -b</span><span style="color:#9ECBFF"> feature/new-nav</span><span style="color:#9ECBFF"> ../myproject-nav</span></span>
<span class="line"></span>
<span class="line"><span style="color:#9ca6b0"># see every worktree attached to this repo</span></span>
<span class="line"><span style="color:#B392F0">git</span><span style="color:#9ECBFF"> worktree</span><span style="color:#9ECBFF"> list</span></span>
<span class="line"></span>
<span class="line"><span style="color:#9ca6b0"># remove one once you&#39;re done with it</span></span>
<span class="line"><span style="color:#B392F0">git</span><span style="color:#9ECBFF"> worktree</span><span style="color:#9ECBFF"> remove</span><span style="color:#9ECBFF"> ../myproject-hotfix</span></span>
<span class="line"></span>
<span class="line"><span style="color:#9ca6b0"># clean up references to worktrees whose folders were deleted manually</span></span>
<span class="line"><span style="color:#B392F0">git</span><span style="color:#9ECBFF"> worktree</span><span style="color:#9ECBFF"> prune</span></span></code></pre></div>
<div class="callout" role="note" data-astro-cid-q2ml7llr><p class="callout__title" data-astro-cid-q2ml7llr><span aria-hidden="true" data-astro-cid-q2ml7llr>✓</span>Skip the manual folder delete</p><div class="callout__body" data-astro-cid-q2ml7llr><p>Always remove a worktree with <code>git worktree remove</code>, not by deleting the
folder yourself. If you do delete it manually, Git still thinks it exists
until you run <code>git worktree prune</code>, and until then, commands like <code>git branch   -d</code> on that branch will refuse, claiming it’s checked out somewhere.</p></div></div>
<h2 id="whats-shared-and-what-isnt">What’s shared, and what isn’t</h2>

<div class="code-tabs" data-astro-cid-tezb7zhy><div class="code-tabs__tablist" data-astro-cid-tezb7zhy><label class="code-tabs__label" data-astro-cid-tezb7zhy><input type="radio" name="code-tabs-c939d8d6-ba1a-47ab-b03d-832f38d5b842" checked class="code-tabs__radio" data-astro-cid-tezb7zhy><span data-astro-cid-tezb7zhy>Without worktrees</span></label><label class="code-tabs__label" data-astro-cid-tezb7zhy><input type="radio" name="code-tabs-c939d8d6-ba1a-47ab-b03d-832f38d5b842" class="code-tabs__radio" data-astro-cid-tezb7zhy><span data-astro-cid-tezb7zhy>With worktrees</span></label></div><div class="code-tabs__panel" data-panel-index="0" data-astro-cid-tezb7zhy><div class="code-block"><div class="code-block__header"><span class="code-block__filename"></span><button type="button" class="code-block__copy" data-copy-button>Copy</button></div><pre class="astro-code github-dark" style="background-color:#24292e;color:#e1e4e8;overflow-x:auto" tabindex="0" data-language="bash"><code><span class="line"><span style="color:#B392F0">git</span><span style="color:#9ECBFF"> stash</span></span>
<span class="line"><span style="color:#B392F0">git</span><span style="color:#9ECBFF"> checkout</span><span style="color:#9ECBFF"> main</span></span>
<span class="line"><span style="color:#9ca6b0"># fix the bug</span></span>
<span class="line"><span style="color:#B392F0">git</span><span style="color:#9ECBFF"> checkout</span><span style="color:#9ECBFF"> feature/new-nav</span></span>
<span class="line"><span style="color:#B392F0">git</span><span style="color:#9ECBFF"> stash</span><span style="color:#9ECBFF"> pop</span></span>
<span class="line"><span style="color:#9ca6b0"># hope nothing conflicted</span></span></code></pre></div></div><div class="code-tabs__panel" data-panel-index="1" data-astro-cid-tezb7zhy><div class="code-block"><div class="code-block__header"><span class="code-block__filename"></span><button type="button" class="code-block__copy" data-copy-button>Copy</button></div><pre class="astro-code github-dark" style="background-color:#24292e;color:#e1e4e8;overflow-x:auto" tabindex="0" data-language="bash"><code><span class="line"><span style="color:#79B8FF">cd</span><span style="color:#9ECBFF"> ../myproject-hotfix</span></span>
<span class="line"><span style="color:#9ca6b0"># fix the bug, already on main, feature branch untouched</span></span>
<span class="line"><span style="color:#79B8FF">cd</span><span style="color:#9ECBFF"> ../myproject-nav</span></span>
<span class="line"><span style="color:#9ca6b0"># still exactly where you left off</span></span></code></pre></div></div></div>
<p>Commit history, branches, tags, and remotes are shared instantly across every worktree: a commit made in one is visible to <code>git log</code> in all the others right away. What’s <strong>not</strong> shared is anything outside Git’s own tracking: <code>node_modules</code>, build output, <code>.env</code> files. Each worktree needs its own <code>npm install</code> and its own build. That’s the most common surprise for people trying worktrees for the first time, the code appears instantly, but the project isn’t actually runnable until dependencies are installed in that specific folder.</p>
<div class="callout" role="note" data-astro-cid-q2ml7llr><p class="callout__title" data-astro-cid-q2ml7llr><span aria-hidden="true" data-astro-cid-q2ml7llr>⚠</span>Cloud-synced folders and stale locks</p><div class="callout__body" data-astro-cid-q2ml7llr><p>If your repository lives inside a cloud-synced folder (OneDrive, Dropbox,
iCloud Drive), be aware that every worktree still writes to the one shared
<code>.git</code> directory. A sync client racing a git command mid-write can leave a
stale <code>index.lock</code> or <code>HEAD.lock</code> file behind. If a worktree command fails
with a <code>File exists</code> error on a <code>.lock</code> file, confirm nothing else is actually
mid-operation before removing it by hand, and consider keeping active repos
outside the synced folder, or excluding <code>.git</code> from sync, if this happens
often.</p></div></div>
<h2 id="when-its-not-worth-it">When it’s not worth it</h2>
<p>Worktrees shine for genuinely parallel work: a long-running feature branch plus an urgent hotfix, or reviewing a colleague’s PR without disturbing your own uncommitted changes. For a thirty-second fix you were going to make anyway, a plain <code>git stash</code> is still less typing. Reach for worktrees when the interruption is long enough that losing your place would actually cost you something.</p>
<p>Browse more posts like this in the <a href="/dev-tools">Dev Tools</a> archive.</p>]]></content:encoded>
      <pubDate>Mon, 27 Jul 2026 00:00:00 GMT</pubDate>
    </item>
    <item>
      <title>A Practical Prompting Guide for AI Coding Assistants</title>
      <link>https://bytetech247.com/ai-productivity/prompting-guide-ai-coding-assistants/</link>
      <guid isPermaLink="true">https://bytetech247.com/ai-productivity/prompting-guide-ai-coding-assistants/</guid>
      <description>Concrete techniques for prompting AI coding assistants: specificity, constraints, checkpoints, and reviewing diffs, for working code on the first pass.</description>
      <content:encoded><![CDATA[<p>The gap between a frustrating session with an AI coding assistant and a genuinely productive one is rarely the model. It’s almost always the prompt. The same assistant that produces a confused, half-working patch from a vague request can produce a clean, correct one from a well-specified request. The technique matters more than most people expect.</p>
<aside class="quick-answer" aria-label="Quick answer"><h2 id="quick-answer">Quick Answer</h2>
<p>Name the exact file, field, and existing pattern to follow instead of a vague goal, state constraints up front (no new dependencies, match the existing error-handling style), and ask for a short plan before any change that touches multiple files. Specific prompts produce a better first draft; review is what makes that draft safe to ship.</p>
</aside><h2 id="be-specific-about-the-shape-of-the-change">Be specific about the shape of the change</h2>
<p>“Add validation to the signup form” leaves the assistant guessing at which fields, which rules, and which existing pattern to follow. “Add a required check and email-format check to the <code>email</code> field in <code>SignupForm.tsx</code>, following the same <code>Error</code> component the <code>password</code> field already uses” leaves nothing to guess.</p>





















<table tabindex="0"><thead><tr><th scope="col">Vague prompt</th><th scope="col">Specific prompt</th></tr></thead><tbody><tr><td>“Fix the bug in the login page”</td><td>“The login form in <code>src/pages/login.astro</code> submits even when the password field is empty, add a client-side check before the fetch call, matching the pattern used in <code>signup.astro</code>”</td></tr><tr><td>“Make this faster”</td><td>“This function re-sorts the array on every render, memoize the sort with <code>useMemo</code>, keyed on the array reference”</td></tr><tr><td>“Clean up this file”</td><td>“Remove the two unused imports at the top of <code>utils.ts</code> and the commented-out function on lines 40–55; leave everything else unchanged”</td></tr></tbody></table>
<p>The specific version on the right isn’t longer because it’s polite. Every extra word is a constraint that removes a wrong turn.</p>
<h2 id="give-it-constraints-not-just-a-goal">Give it constraints, not just a goal</h2>
<p>A goal alone (“make this component reusable”) is compatible with a hundred different implementations, some of which will conflict with decisions already made elsewhere in the codebase. Naming the constraints up front, “no new dependencies,” “keep the existing prop names,” “match the error-handling style used in the other components in this folder,” narrows the space to the one implementation that actually fits.</p>
<h2 id="work-in-checkpoints-on-anything-non-trivial">Work in checkpoints on anything non-trivial</h2>
<p>For a one-line fix, just ask for the fix. For anything that touches multiple files or has more than one reasonable approach, ask for a plan first: “Before making changes, describe how you’d implement this and which files you’d touch.” Reading a three-sentence plan takes ten seconds and catches a wrong assumption before it’s baked into fifty lines of code, much cheaper than catching it in review.</p>
<div class="callout" role="note" data-astro-cid-q2ml7llr><p class="callout__title" data-astro-cid-q2ml7llr><span aria-hidden="true" data-astro-cid-q2ml7llr>✓</span>Go deeper</p><div class="callout__body" data-astro-cid-q2ml7llr><p>For a full treatment of these techniques, including how to structure longer
prompts with XML tags and how to request step-by-step reasoning, Anthropic’s
own prompt engineering documentation at </p><a href="https://docs.claude.com/en/docs/build-with-claude/prompt-engineering/overview"><p>docs.claude.com</p></a> <p>is a solid next step.</p></div></div>
<h2 id="always-review-the-diff">Always review the diff</h2>
<p>None of the above replaces reading the actual change. Treat AI-written code exactly like a pull request from a new teammate: read every line that changed before running it, let alone merging it. The assistant doesn’t know your production incident history, your team’s unwritten conventions, or which shortcut bit you last quarter. You do. Specific prompts get you a better first draft; review is what makes it safe to ship.</p>
<div class="callout" role="note" data-astro-cid-q2ml7llr><p class="callout__title" data-astro-cid-q2ml7llr><span aria-hidden="true" data-astro-cid-q2ml7llr>⚠</span>Don&#39;t skip verification</p><div class="callout__body" data-astro-cid-q2ml7llr><p>A confident-sounding explanation is not the same as a correct one. Run the
tests, check the diff against the actual requirement, and don’t take “this
should work now” as proof that it does.</p></div></div>]]></content:encoded>
      <pubDate>Mon, 27 Jul 2026 00:00:00 GMT</pubDate>
    </item>
  </channel>
</rss>
