<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Susumu Takahashi</title>
    <description>The latest articles on DEV Community by Susumu Takahashi (@susumun).</description>
    <link>https://dev.to/susumun</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F3961116%2F87a59747-8eb8-43eb-9db6-c160d3592934.JPG</url>
      <title>DEV Community: Susumu Takahashi</title>
      <link>https://dev.to/susumun</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/susumun"/>
    <language>en</language>
    <item>
      <title>The excluded-plugin setting that Playwright ignored — fixing browser-mode updates and false residual warnings</title>
      <dc:creator>Susumu Takahashi</dc:creator>
      <pubDate>Thu, 20 Aug 2026 03:08:25 +0000</pubDate>
      <link>https://dev.to/susumun/the-excluded-plugin-setting-that-playwright-ignored-fixing-browser-mode-updates-and-false-3ceo</link>
      <guid>https://dev.to/susumun/the-excluded-plugin-setting-that-playwright-ignored-fixing-browser-mode-updates-and-false-3ceo</guid>
      <description>&lt;h2&gt;
  
  
  The symptom
&lt;/h2&gt;

&lt;p&gt;In browser-mode maintenance (Playwright, no SSH), plugins marked as "excluded from update checks" were still being updated. After the run, a "plugin updates remaining" WARNING email arrived every time.&lt;/p&gt;

&lt;p&gt;The excluded plugins were intentionally left behind, but the residual check treated them as unfinished updates and fired a warning — a two-part problem: wrong behavior and a misleading alert.&lt;/p&gt;

&lt;h2&gt;
  
  
  SSH path vs. Playwright path
&lt;/h2&gt;

&lt;p&gt;On SSH-capable sites, WP-CLI's &lt;code&gt;--skip-plugins&lt;/code&gt; flag carries the &lt;code&gt;ignored_plugins&lt;/code&gt; list into the update command. That path already excluded them correctly.&lt;/p&gt;

&lt;p&gt;The Playwright path was different. &lt;code&gt;browser_update_remaining_plugins()&lt;/code&gt; worked by clicking the "select all" checkbox on &lt;code&gt;update-core.php&lt;/code&gt; and submitting the form — no filtering at all.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="c1"&gt;# Before: select-all, ignored_plugins never consulted
&lt;/span&gt;&lt;span class="n"&gt;page&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;check&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;input[name=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;action&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;][value=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;update-selected-plugins&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;]&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;cb&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;page&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;query_selector_all&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;input[name=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;checked[]&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;]&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;cb&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;check&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This function is the chokepoint for two flows: pure browser-mode updates (&lt;code&gt;run_browser_update_flow&lt;/code&gt;) and the browser residual pass that runs after SSH updates (&lt;code&gt;run_browser_residual_update&lt;/code&gt;, on by default). So even SSH sites could have excluded plugins updated by the residual pass. Browser-driven bulk updates are also outside the scope of pinpoint rollback, meaning there is no automatic recovery if a wrong update goes through.&lt;/p&gt;

&lt;h2&gt;
  
  
  Fix 1 — &lt;code&gt;_ignored_plugin_slugs()&lt;/code&gt; and &lt;code&gt;_plugin_slug_from_checkbox_value()&lt;/code&gt;
&lt;/h2&gt;

&lt;p&gt;When excluded plugins are configured, the fix replaces the select-all approach with per-checkbox evaluation.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;_ignored_plugin_slugs&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;site&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;dict&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;set&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;]:&lt;/span&gt;
    &lt;span class="n"&gt;raw&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;site&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;ignored_plugins&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;""&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="n"&gt;s&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;strip&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;lower&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;s&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;raw&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;split&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;,&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;s&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;strip&lt;/span&gt;&lt;span class="p"&gt;()}&lt;/span&gt;

&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;_plugin_slug_from_checkbox_value&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;value&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;value&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;split&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;/&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)[&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="nf"&gt;strip&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;lower&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;/&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;value&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="n"&gt;value&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;strip&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;lower&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Plugin checkboxes on &lt;code&gt;update-core.php&lt;/code&gt; use a &lt;code&gt;value&lt;/code&gt; like &lt;code&gt;contact-form-7/wp-contact-form-7.php&lt;/code&gt;. The slug is the part before the slash. Normalizing to lowercase covers casing differences in user input.&lt;/p&gt;

&lt;p&gt;With excluded plugins configured:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;ignored&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;_ignored_plugin_slugs&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;site&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;ignored&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;cb&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;page&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;query_selector_all&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;input[name=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;checked[]&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;]&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="n"&gt;val&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;cb&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get_attribute&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;value&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="ow"&gt;or&lt;/span&gt; &lt;span class="sh"&gt;""&lt;/span&gt;
        &lt;span class="n"&gt;slug&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;_plugin_slug_from_checkbox_value&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;val&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;slug&lt;/span&gt; &lt;span class="ow"&gt;and&lt;/span&gt; &lt;span class="n"&gt;slug&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;ignored&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="n"&gt;cb&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;check&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
        &lt;span class="c1"&gt;# if value is missing, keep it checked (safe side: update rather than skip)
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Sites with no excluded plugins keep the original behavior (select-all with individual fallback) exactly as before.&lt;/p&gt;

&lt;h2&gt;
  
  
  Fix 2 — &lt;code&gt;_browser_plugin_residual_state()&lt;/code&gt; stops false residual warnings
&lt;/h2&gt;

&lt;p&gt;The residual check had the same gap. Before the fix, any remaining update form on &lt;code&gt;update-core.php&lt;/code&gt; counted as "updates pending." An excluded plugin left intentionally on the page was enough to trigger a WARNING.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;_browser_plugin_residual_state&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;page&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;site&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;dict&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;bool&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;ignored&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;_ignored_plugin_slugs&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;site&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;checkboxes&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;page&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;query_selector_all&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;input[name=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;checked[]&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;]&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="n"&gt;checkboxes&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="bp"&gt;False&lt;/span&gt;  &lt;span class="c1"&gt;# no form = nothing pending
&lt;/span&gt;    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="n"&gt;ignored&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="bp"&gt;True&lt;/span&gt;   &lt;span class="c1"&gt;# no exclusions = form present = pending (original behavior)
&lt;/span&gt;    &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;cb&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;checkboxes&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;val&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;cb&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get_attribute&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;value&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="ow"&gt;or&lt;/span&gt; &lt;span class="sh"&gt;""&lt;/span&gt;
        &lt;span class="n"&gt;slug&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;_plugin_slug_from_checkbox_value&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;val&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;slug&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;ignored&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="bp"&gt;True&lt;/span&gt;  &lt;span class="c1"&gt;# at least one non-excluded plugin still pending
&lt;/span&gt;    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="bp"&gt;False&lt;/span&gt;  &lt;span class="c1"&gt;# only excluded plugins remain — not pending
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;After this change, a site where only excluded plugins remain on &lt;code&gt;update-core.php&lt;/code&gt; resolves to &lt;code&gt;False&lt;/code&gt; (not pending), and no WARNING email is sent.&lt;/p&gt;

&lt;h2&gt;
  
  
  Test coverage
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;tests/test_browser_ignored_plugins.py&lt;/code&gt; — 21 new tests:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Slug normalization (7)&lt;/strong&gt;: &lt;code&gt;slug/file.php&lt;/code&gt; format, single-file format, case and whitespace variation, missing value&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Exclusion behavior (9)&lt;/strong&gt;: single exclusion, multiple exclusions, all excluded (zero updates), no exclusions (all updated), nonexistent slug in exclusion list does not over-exclude&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Residual check (5)&lt;/strong&gt;: only excluded remain → not pending, excluded + non-excluded remain → pending, no exclusions (original behavior), no form → not pending&lt;/li&gt;
&lt;/ul&gt;




&lt;p&gt;&lt;em&gt;Related: &lt;a href="https://en.wpmm.jp/blog/select-all-checkbox-verification-fallback/" rel="noopener noreferrer"&gt;The select-all checkbox that Playwright checked but the page ignored&lt;/a&gt; / &lt;a href="https://en.wpmm.jp/blog/playwright-friendly-error-translation/" rel="noopener noreferrer"&gt;Translating Playwright's raw error messages into readable text&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;

</description>
      <category>wordpress</category>
      <category>php</category>
      <category>tutorial</category>
    </item>
    <item>
      <title>The cp932 crash in the build gate that only happened on Windows — static detection, behavioral testing, and a negative check</title>
      <dc:creator>Susumu Takahashi</dc:creator>
      <pubDate>Wed, 19 Aug 2026 01:26:25 +0000</pubDate>
      <link>https://dev.to/susumun/the-cp932-crash-in-the-build-gate-that-only-happened-on-windows-static-detection-behavioral-mpd</link>
      <guid>https://dev.to/susumun/the-cp932-crash-in-the-build-gate-that-only-happened-on-windows-static-detection-behavioral-mpd</guid>
      <description>&lt;h2&gt;
  
  
  The symptom
&lt;/h2&gt;

&lt;p&gt;During the Windows build for v1.6.11, the build gate reported "version number consistency check failed." But the version numbers were correct everywhere.&lt;/p&gt;

&lt;p&gt;The real cause was not a version mismatch. The build gate in &lt;code&gt;build_app.py&lt;/code&gt; calls &lt;code&gt;tools/bump_version.py&lt;/code&gt; via subprocess and checks its exit code. The success messages in &lt;code&gt;bump_version.py&lt;/code&gt; contained emoji (✅ and similar). On Japanese Windows, the default code page is cp932, which cannot encode those characters. Python raised a &lt;code&gt;UnicodeEncodeError&lt;/code&gt; on the very first write to stdout, the process exited non-zero, and the build gate misread that as a version inconsistency.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;UnicodeEncodeError: 'cp932' codec can't encode character '✅' in position 0
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Why it didn't show up on Mac
&lt;/h2&gt;

&lt;p&gt;macOS terminals use UTF-8 by default. Running &lt;code&gt;bump_version.py&lt;/code&gt; directly or via subprocess on Mac produces no encoding errors — emoji flows through cleanly.&lt;/p&gt;

&lt;p&gt;On Japanese Windows, the process inherits the cp932 code page. U+2705 (✅) has no cp932 representation, so the write fails immediately. Because the build was always done on Mac first, this failure had no chance to appear in local testing. It only surfaced when the Windows build ran.&lt;/p&gt;

&lt;h2&gt;
  
  
  Fix 1 — replace emoji with ASCII
&lt;/h2&gt;

&lt;p&gt;All emoji in &lt;code&gt;tools/bump_version.py&lt;/code&gt; output were replaced with ASCII equivalents.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="c1"&gt;# Before
&lt;/span&gt;&lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;✅ &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nb"&gt;file&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;: &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;old&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt; → &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;new&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;❌ NG: &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nb"&gt;file&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="c1"&gt;# After
&lt;/span&gt;&lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;[OK] &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nb"&gt;file&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;: &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;old&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt; → &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;new&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;[NG] &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nb"&gt;file&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Emoji in terminal output can be helpful, but when output is consumed by another process over a pipe, encoding compatibility cannot be assumed. Tools invoked from build scripts should limit their output to ASCII.&lt;/p&gt;

&lt;h2&gt;
  
  
  Fix 2 — force UTF-8 in the subprocess call
&lt;/h2&gt;

&lt;p&gt;The build gate was also updated to enforce UTF-8 on its end.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;result&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;subprocess&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;run&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;sys&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;executable&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;tools/bump_version.py&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;--check&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
    &lt;span class="n"&gt;capture_output&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;encoding&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;utf-8&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;errors&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;replace&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;env&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="o"&gt;**&lt;/span&gt;&lt;span class="n"&gt;os&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;environ&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;PYTHONUTF8&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;1&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;PYTHONIOENCODING&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;utf-8&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;PYTHONUTF8=1&lt;/code&gt; forces UTF-8 for stdin/stdout/stderr in Python 3.7+. &lt;code&gt;PYTHONIOENCODING=utf-8&lt;/code&gt; covers older-style enforcement. &lt;code&gt;errors="replace"&lt;/code&gt; prevents a crash if any unencodable character slips through — defense in depth.&lt;/p&gt;

&lt;h2&gt;
  
  
  Fix 3 — three-layer regression guard in &lt;code&gt;test_windows_cp932_safety.py&lt;/code&gt;
&lt;/h2&gt;

&lt;p&gt;Fixing the bug is not enough if the same failure can re-emerge. Three test layers were added to catch it before it reaches Windows.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Layer 1 — static detection&lt;/strong&gt;: read the source file and verify that no cp932-incompatible character is present.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;test_bump_version_no_cp932_unsafe_chars&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt;
    &lt;span class="n"&gt;src&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;pathlib&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;Path&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;tools/bump_version.py&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;read_text&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;encoding&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;utf-8&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;i&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;ch&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="nf"&gt;enumerate&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;src&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="k"&gt;try&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="n"&gt;ch&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;encode&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;cp932&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="nf"&gt;except &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;UnicodeEncodeError&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nb"&gt;UnicodeDecodeError&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
            &lt;span class="k"&gt;raise&lt;/span&gt; &lt;span class="nc"&gt;AssertionError&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
                &lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;cp932-unsafe char U+&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nf"&gt;ord&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ch&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="si"&gt;:&lt;/span&gt;&lt;span class="mi"&gt;04&lt;/span&gt;&lt;span class="n"&gt;X&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt; at pos &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;i&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;: &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;ch&lt;/span&gt;&lt;span class="si"&gt;!r}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
            &lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Layer 2 — behavioral verification&lt;/strong&gt;: run &lt;code&gt;bump_version.py --check&lt;/code&gt; in a subprocess with &lt;code&gt;PYTHONIOENCODING=cp932&lt;/code&gt; set and assert that it exits 0.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;test_bump_version_check_runs_under_cp932&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt;
    &lt;span class="n"&gt;result&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;subprocess&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;run&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;sys&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;executable&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;tools/bump_version.py&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;--check&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
        &lt;span class="n"&gt;capture_output&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;env&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="o"&gt;**&lt;/span&gt;&lt;span class="n"&gt;os&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;environ&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;PYTHONIOENCODING&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;cp932&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;assert&lt;/span&gt; &lt;span class="n"&gt;result&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;returncode&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;result&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;stderr&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Layer 3 — negative check&lt;/strong&gt;: inject an emoji intentionally and confirm that layer 1 raises &lt;code&gt;AssertionError&lt;/code&gt;. This ensures the detector cannot become a test that always passes.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;test_cp932_detector_catches_emoji&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt;
    &lt;span class="k"&gt;with&lt;/span&gt; &lt;span class="n"&gt;pytest&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;raises&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;AssertionError&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="n"&gt;src&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;print(&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;✅ ok&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;)&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
        &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;ch&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;src&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="n"&gt;ch&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;encode&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;cp932&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  What to take away
&lt;/h2&gt;

&lt;p&gt;When development and builds both happen on Mac, Windows-specific encoding failures have nowhere to appear until the Windows build runs. The failure mode here — correct Mac behavior, crash on Windows, misread error message pointing at the wrong cause — is easy to miss and easy to misdiagnose.&lt;/p&gt;

&lt;p&gt;Designing tests that simulate Windows encoding constraints on Mac, before the Windows build, catches this category of failure at the CI level rather than at the point of release.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Related: &lt;a href="https://en.wpmm.jp/blog/version-bump-single-source-check-gate/" rel="noopener noreferrer"&gt;Version bump oversights — a single-source script with a --check gate&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;

</description>
      <category>python</category>
      <category>webdev</category>
      <category>programming</category>
    </item>
    <item>
      <title>Placing corrections exactly where an LLM is tempted to sound plausible instead of right</title>
      <dc:creator>Susumu Takahashi</dc:creator>
      <pubDate>Tue, 18 Aug 2026 00:12:55 +0000</pubDate>
      <link>https://dev.to/susumun/placing-corrections-exactly-where-an-llm-is-tempted-to-sound-plausible-instead-of-right-248i</link>
      <guid>https://dev.to/susumun/placing-corrections-exactly-where-an-llm-is-tempted-to-sound-plausible-instead-of-right-248i</guid>
      <description>&lt;h2&gt;
  
  
  Background
&lt;/h2&gt;

&lt;p&gt;The support chatbot on our landing page feeds the contents of a knowledge base to an LLM as its system prompt, then lets it answer whatever a user asks. Running it in practice surfaced something a generic instruction like "answer accurately" doesn't cover: the same plausible-sounding mistake, repeated consistently, for certain specific kinds of questions.&lt;/p&gt;

&lt;p&gt;An LLM has absorbed a huge amount of general technical knowledge. That's exactly why, the moment it's asked something, it tends to combine that general knowledge into an answer that sounds thoroughly convincing. The tricky part is that &lt;strong&gt;the answer is convincing enough that being wrong is hard to notice.&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Example 1: guessing a hosting provider's SSH hostname format
&lt;/h2&gt;

&lt;p&gt;When a user asks "I don't know my SSH hostname," an LLM is tempted to lean on general patterns and offer something like "it's usually formatted like such-and-such." And to be fair, some hosting providers do have typical hostname conventions.&lt;/p&gt;

&lt;p&gt;But that's a dangerous kind of helpfulness. The actual format varies by hosting provider, plan tier, and even contract vintage — sometimes even within the same provider across different plans. An LLM offering "the common format" has &lt;strong&gt;no guarantee whatsoever that it matches this particular user's actual contract.&lt;/strong&gt; If it's wrong, the user ends up with a new kind of confusion on top of the original one: "I looked exactly where the app told me to, and it's not there."&lt;/p&gt;

&lt;p&gt;The correct answer is "check your hosting provider's control panel for the SSH connection details, and copy the exact string shown there" — not a guessed format sample. But the judgment that &lt;em&gt;not&lt;/em&gt; offering a specific format is the right move isn't something a generic "answer accurately" instruction reliably produces on its own.&lt;/p&gt;

&lt;h2&gt;
  
  
  Example 2: a plausible-sounding but wrong "you need to convert this" instruction
&lt;/h2&gt;

&lt;p&gt;Another case involves the format of private keys issued by one hosting provider we support. These keys are issued in PKCS#8 format, and since version 1.6.3 the app reads that format natively, with no extra steps.&lt;/p&gt;

&lt;p&gt;But if an LLM is only given the question "my private key won't load," it can reach for general SSH knowledge and suggest something plausible-but-wrong, like "you might need to convert it to PuTTY format (.ppk)" — a step that's both unnecessary and beside the point for the current version. This is the same pattern: a suggestion that sounds helpful and technically coherent, but is simply incorrect for this product. The correct answer is the far simpler "update the app to the latest version, then point it at the downloaded key file as-is" — but the LLM finds the general-purpose conversion procedure more plausible-sounding.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why a generic instruction alone isn't enough
&lt;/h2&gt;

&lt;p&gt;Instructions like "answer accurately" or "don't guess when you don't know" can be written once, up front, in the system prompt. But they're too abstract to catch individual traps like these. From the model's perspective, both guessing a hostname format and describing a key-conversion procedure are "generally correct technical knowledge" — nothing in an abstract instruction signals that &lt;strong&gt;this specific product's circumstances make that generally-correct knowledge wrong here.&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  The fix: place a targeted correction right next to the trap
&lt;/h2&gt;

&lt;p&gt;The approach we took was to attach a note — invisible to the end user — directly at the exact spot in the knowledge base where a particular question and fact live. Right next to the relevant FAQ entry, a note says, in effect: "when this question comes up, there's a plausible-sounding mistake the model tends to make here; the actual answer is this instead."&lt;/p&gt;

&lt;p&gt;Rather than stating one abstract "don't hallucinate" rule in a single place, the idea is closer to &lt;strong&gt;administering a targeted inoculation at each specific spot where a mistake actually occurs.&lt;/strong&gt; Whenever a new trap surfaces, a note gets added right at that spot. In practice, a pile of small, localized corrections does more for real answer quality than a single sweeping rule stated once at the top.&lt;/p&gt;

&lt;h2&gt;
  
  
  Wrap-up
&lt;/h2&gt;

&lt;p&gt;An LLM's confidence and its accuracy are often hard to tell apart. Users tend to trust specific, confidently-stated information more, which makes it worse when that confidence turns out to be misplaced. Rather than relying solely on generic instructions to improve accuracy, &lt;strong&gt;identifying the exact spots where mistakes actually happen and placing a targeted correction there&lt;/strong&gt; — one trap at a time — is the unglamorous foundation that makes a genuinely knowledgeable-sounding chatbot actually knowledgeable.&lt;/p&gt;

</description>
      <category>python</category>
      <category>webdev</category>
      <category>programming</category>
    </item>
    <item>
      <title>A support chatbot needs to be designed for what it won't answer, not just what it will</title>
      <dc:creator>Susumu Takahashi</dc:creator>
      <pubDate>Mon, 17 Aug 2026 02:32:16 +0000</pubDate>
      <link>https://dev.to/susumun/a-support-chatbot-needs-to-be-designed-for-what-it-wont-answer-not-just-what-it-will-3j3m</link>
      <guid>https://dev.to/susumun/a-support-chatbot-needs-to-be-designed-for-what-it-wont-answer-not-just-what-it-will-3j3m</guid>
      <description>&lt;h2&gt;
  
  
  Background
&lt;/h2&gt;

&lt;p&gt;The support chatbot on our landing page has a simple job: answer questions about the product's features, pricing, and troubleshooting. But a conversational AI built to answer questions shares a common weakness. Left unguarded, it tends to comply with requests designed to surface its own system prompt verbatim, or with phrasing that falsely claims special authority to unlock a different kind of answer.&lt;/p&gt;

&lt;p&gt;The chatbot works by feeding a knowledge base file to the LLM as its system prompt, then letting it answer whatever the user asks. Rather than implementing this defense in application code — say, a regex filter on incoming messages — we chose to write it &lt;strong&gt;as an explicit policy inside the system prompt itself.&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Thinking in categories of information, not keywords
&lt;/h2&gt;

&lt;p&gt;The first decision was to describe what must be protected — things like source code or credentials — as &lt;strong&gt;categories of information&lt;/strong&gt;, rather than relying on a list of specific banned words.&lt;/p&gt;

&lt;p&gt;A filter built around "block any question containing word X" is trivially bypassed by rephrasing. Letting the model itself judge "does this question touch this category?" lets it generalize to phrasings nobody anticipated in advance. Unlike a static keyword filter, this leans on the model's own language understanding to hold up against variation in wording.&lt;/p&gt;

&lt;h2&gt;
  
  
  Making the refusal itself part of the design
&lt;/h2&gt;

&lt;p&gt;The response to a request that falls into a protected category is also governed by policy. The key principle: &lt;strong&gt;don't explain the reasoning behind a refusal in detail.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;That might look unhelpful at first glance. But the more a refusal explains why something can't be answered, the more that explanation becomes a clue about exactly where the boundary sits — and a clue about a boundary tends to help someone find a way around it. A short, matter-of-fact refusal is, somewhat counterintuitively, often the safer choice.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why this lives in the system prompt, not an application-level filter
&lt;/h2&gt;

&lt;p&gt;This kind of defense could have been implemented in application code instead. We deliberately chose to write it as policy inside the system prompt handed to the LLM.&lt;/p&gt;

&lt;p&gt;The reason is that keyword-based filters are fragile against rephrasing. Two requests can carry the exact same intent while using entirely different words, and a static filter built to catch one may simply miss the other. Giving the model a judgment criterion — "requests with this character" — instead lets it lean on its own trained language understanding to generalize toward phrasings nobody wrote down in advance. It isn't foolproof, but it holds up better against novel wording than a static filter typically does, and that's the reasoning behind this design choice.&lt;/p&gt;

&lt;h2&gt;
  
  
  Wrap-up
&lt;/h2&gt;

&lt;p&gt;How "on point" a support chatbot is isn't only measured by how well it answers legitimate questions. &lt;strong&gt;Correctly recognizing which questions it must not answer, no matter how they're phrased,&lt;/strong&gt; is just as much a design problem as answering the ones it should. Building a category-based judgment for what must stay protected, and treating the refusal itself as something to design rather than an afterthought — folding both into policy for the LLM rather than application code — moved the bot a step beyond a simple "try to answer everything" assistant, toward something that holds up under real-world use.&lt;/p&gt;

</description>
      <category>python</category>
      <category>webdev</category>
      <category>programming</category>
    </item>
    <item>
      <title>The free plan wasn't actually "no registration required" — fixing a landing page claim that contradicted itself</title>
      <dc:creator>Susumu Takahashi</dc:creator>
      <pubDate>Sat, 15 Aug 2026 01:06:21 +0000</pubDate>
      <link>https://dev.to/susumun/the-free-plan-wasnt-actually-no-registration-required-fixing-a-landing-page-claim-that-2dfl</link>
      <guid>https://dev.to/susumun/the-free-plan-wasnt-actually-no-registration-required-fixing-a-landing-page-claim-that-2dfl</guid>
      <description>&lt;h2&gt;
  
  
  Background
&lt;/h2&gt;

&lt;p&gt;An external marketing review flagged what looked like a contradiction in our landing page's FAQ. Checking it against the actual code confirmed the claim was accurate.&lt;/p&gt;

&lt;p&gt;Two statements coexisted on the same page:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Quick Start, step 2: "Register with your email to start for free"&lt;/li&gt;
&lt;li&gt;The FAQ answer: "The Free plan &lt;strong&gt;requires no registration&lt;/strong&gt; and allows up to 1 site, 3 runs per month — completely free."&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;One says "please register." The other says "no registration needed." Checking the implementation confirmed that the Free plan requires an email address to be registered and verified on first launch — without that step, the app cannot be used. The FAQ's "no registration required" simply wasn't true.&lt;/p&gt;

&lt;h2&gt;
  
  
  Conflating "free" with "no registration"
&lt;/h2&gt;

&lt;p&gt;Tracing the contradiction back to its root, it came from folding two independent axes into a single phrase.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Cost axis&lt;/strong&gt;: no credit card, no charge at any point&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Procedural axis&lt;/strong&gt;: one step — registering an email address — is required&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Both are true characteristics of the Free plan here, but the phrase "no registration required" denies the procedural axis outright. Most likely, an accurate statement like "no credit card required" was shortened over time into "no registration of any kind required," and that shorter version stuck. It's a reminder that landing page copy is never really "done" — it needs to be re-verified against the implementation every time the underlying feature changes.&lt;/p&gt;

&lt;h2&gt;
  
  
  Fixing what's actually true, not just deleting the claim
&lt;/h2&gt;

&lt;p&gt;There were two ways to resolve this:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Keep the FAQ's "no registration required" claim as-is, and remove the email registration requirement from the product instead&lt;/li&gt;
&lt;li&gt;Leave the implementation untouched, and rewrite the FAQ to match reality&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Email registration underpins verification-email deliverability and license management, so removing it wasn't a realistic option. We went with option 2 — matching the copy to what's actually true.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight diff"&gt;&lt;code&gt;&lt;span class="gd"&gt;- The Free plan requires no registration and allows up to 1 site,
- 3 runs per month — completely free.
&lt;/span&gt;&lt;span class="gi"&gt;+ The Free plan only asks for your email address and allows up to
+ 1 site, 3 runs per month — completely free. No credit card, no
+ time limit.
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The key detail: rather than just deleting the "no registration" claim, we explicitly kept the cost axis (no credit card, no time limit) in the rewritten sentence. What users usually want to know is whether they'll be asked for a credit card and how long the free tier lasts. Affirming both of those clearly, while precisely correcting only the procedural claim (email registration is required), landed on wording that neither overstates nor understates what's true.&lt;/p&gt;

&lt;p&gt;The same phrasing also appeared in the Quick Start description.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight diff"&gt;&lt;code&gt;&lt;span class="gd"&gt;- No credit card required. Just install it and start right away.
- The Free plan stays free forever.
&lt;/span&gt;&lt;span class="gi"&gt;+ No credit card required. Just register your email and start
+ right away. The Free plan stays free forever.
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Across the Japanese and English versions and duplicate FAQ entries on two pages, three files and four separate spots were brought in line with the same corrected wording.&lt;/p&gt;

&lt;h2&gt;
  
  
  What we deliberately left alone
&lt;/h2&gt;

&lt;p&gt;During the same review, we noticed a second inconsistency: the product name appeared on the landing page in two forms, "WP Maintenance Manager" and "WP Maintenance Pro." The binary actually being distributed carries the "Pro" name. Standardizing the landing page alone to "Manager" would have created the &lt;strong&gt;opposite kind of mismatch&lt;/strong&gt; — the landing page and the distributed product disagreeing with each other.&lt;/p&gt;

&lt;p&gt;We left that alone here and set it aside as a separate naming decision to make later. Trying to fix every inconsistency uncovered during a single review, in a single commit, risks mixing problems with very different blast radii into one change and leaving one of them half-fixed. Correcting the "no registration" claim was a wording-only fix that could be completed cleanly on its own; the product-naming mismatch touches the binary name, the installer, and past distributed builds — a decision of an entirely different order. Keeping the scopes separate was deliberate.&lt;/p&gt;

&lt;h2&gt;
  
  
  Wrap-up
&lt;/h2&gt;

&lt;p&gt;Landing page copy can be correct the day it's written and quietly drift out of sync as the implementation changes underneath it — in this case, once email verification was added to the registration flow. This particular gap surfaced through an outside review, but the real fix is the underlying habit: periodically checking landing page claims against what the code actually does. Just as important as fixing what you find is resisting the urge to fix everything discovered in the same pass — problems with different scopes deserve to be handled on their own timelines.&lt;/p&gt;

</description>
      <category>python</category>
      <category>webdev</category>
      <category>programming</category>
    </item>
    <item>
      <title>Resuming email verification after the app is closed — the pending_email state that prevents re-sending</title>
      <dc:creator>Susumu Takahashi</dc:creator>
      <pubDate>Thu, 13 Aug 2026 23:28:20 +0000</pubDate>
      <link>https://dev.to/susumun/resuming-email-verification-after-the-app-is-closed-the-pendingemail-state-that-prevents-55g8</link>
      <guid>https://dev.to/susumun/resuming-email-verification-after-the-app-is-closed-the-pendingemail-state-that-prevents-55g8</guid>
      <description>&lt;h2&gt;
  
  
  Background
&lt;/h2&gt;

&lt;p&gt;On first launch, the desktop app asks the user for an email address, sends a confirmation email, and completes registration once the user clicks the link in that email. There was a trap here for anyone who closed the app before clicking the link.&lt;/p&gt;

&lt;p&gt;Email does not always arrive immediately. Closing the app with the intention of clicking the link once the email shows up, then reopening later, is a perfectly natural way to use it. But on restart the app decided that registration was still incomplete and showed the first-launch screen (the email input) from the beginning. The user had to type their email address again.&lt;/p&gt;

&lt;p&gt;That is where the second half of the trap sprang. Re-entering the address made the app call the registration endpoint on the server again, and the server &lt;strong&gt;issued a new verification token&lt;/strong&gt;. The link in the email that had already arrived was now invalid. When the user finally clicked that first link, they got an "invalid link" error. Two traps, back to back.&lt;/p&gt;

&lt;h2&gt;
  
  
  Naming the symptom
&lt;/h2&gt;

&lt;p&gt;The loop looked like this:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Enter email address → confirmation email is sent&lt;/li&gt;
&lt;li&gt;Close the app before clicking the link&lt;/li&gt;
&lt;li&gt;Restart → the first-launch screen reappears and asks for the email again&lt;/li&gt;
&lt;li&gt;Re-enter → the server regenerates the token → the first email's link is invalidated&lt;/li&gt;
&lt;li&gt;Click the link in the first email → "invalid link"&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The root cause was that the app never remembered the intermediate state of "sent, awaiting verification." As far as the app was concerned, state was binary — either "not registered" or "registered" — with no room for "the email has been sent but not yet verified." So every restart fell back to treating the user as not registered.&lt;/p&gt;

&lt;h2&gt;
  
  
  Persisting pending_email to the config file
&lt;/h2&gt;

&lt;p&gt;The fix is to persist that intermediate state. The moment the confirmation email is sent successfully, the target address is saved to &lt;code&gt;app_config.json&lt;/code&gt; as &lt;code&gt;pending_email&lt;/code&gt;.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Note: &lt;code&gt;app_config.json&lt;/code&gt; is the app's local configuration file, holding per-user settings such as the license key and the registered email. JSON (JavaScript Object Notation) is a structured data format that is easy for both machines and humans to read.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;It is saved only when the server reports a successful send (&lt;code&gt;status: ok&lt;/code&gt;). Creating an "awaiting verification" state when the send itself failed would leave the user waiting for an email that never went out.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="c1"&gt;# Save the email only on a successful send
&lt;/span&gt;&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;status&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;ok&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="k"&gt;try&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;core.license&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;_load_config&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;_save_config&lt;/span&gt;
        &lt;span class="n"&gt;cfg&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;_load_config&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
        &lt;span class="n"&gt;cfg&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;pending_email&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;email&lt;/span&gt;
        &lt;span class="nf"&gt;_save_config&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;cfg&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;except&lt;/span&gt; &lt;span class="nb"&gt;Exception&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;e&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;[registration_start] failed to save pending_email (ignored): &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;e&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The save is wrapped in &lt;code&gt;try/except&lt;/code&gt; and never blocks the registration flow. &lt;code&gt;pending_email&lt;/code&gt; is only an aid for resuming; even if it cannot be written, the primary goal — sending the confirmation email — has already been achieved.&lt;/p&gt;

&lt;h2&gt;
  
  
  Startup check and returning to the right screen
&lt;/h2&gt;

&lt;p&gt;Next, the startup status API returns this &lt;code&gt;pending_email&lt;/code&gt;.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;jsonify&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
    &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;first_launch&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;     &lt;span class="nf"&gt;is_first_launch&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
    &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;registered_email&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;registered_email&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="c1"&gt;# Email sent before verification, so we can resume the waiting screen on restart
&lt;/span&gt;    &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;pending_email&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;    &lt;span class="n"&gt;cfg&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;pending_email&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;''&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
&lt;span class="p"&gt;})&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;On the front end, when it is a first launch (&lt;code&gt;first_launch&lt;/code&gt;) and a &lt;code&gt;pending_email&lt;/code&gt; exists, the app goes &lt;strong&gt;straight to the verification-waiting screen&lt;/strong&gt; instead of the email input.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;first_launch&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nb"&gt;document&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getElementById&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;firstLaunchModal&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nx"&gt;style&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;display&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;flex&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="c1"&gt;// If an email was already sent, resume the waiting screen instead of asking again.&lt;/span&gt;
    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;pending_email&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nx"&gt;_flEmail&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;pending_email&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
        &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;inp&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;document&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getElementById&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;flEmailInput&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
        &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;inp&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nx"&gt;inp&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;value&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;pending_email&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
        &lt;span class="nf"&gt;_flUpdateVerifyMessage&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
        &lt;span class="nf"&gt;flShowStep&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;step_verifying&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;   &lt;span class="c1"&gt;// go to the waiting screen&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Now someone who closed the app before clicking the link comes back, on restart, to a screen that says "A confirmation email has been sent to this address. Click the link in the email, then press 'Verified'." No need to type the email address a second time.&lt;/p&gt;

&lt;h2&gt;
  
  
  Not regenerating the token is the whole point
&lt;/h2&gt;

&lt;p&gt;The single most important part of this design is that returning to the waiting screen &lt;strong&gt;does not call the server's registration endpoint again&lt;/strong&gt;. The resume path only reflects the address read from the local config file onto the screen; it never talks to the server.&lt;/p&gt;

&lt;p&gt;A registration request to the server comes with the issuance of a new token. Calling it during resume would invalidate the link in the email that had already arrived — rebuilding the very trap we were trying to remove. Resume is kept to purely mirroring local state onto the screen, which keeps the first email's link valid.&lt;/p&gt;

&lt;p&gt;If the user actively wants to resend the email, the waiting screen has a separate "Resend email" button. A resend is an explicit action, so regenerating the token there is fine. Separating automatic resume (leaves the token alone) from manual resend (updates the token) is the key distinction.&lt;/p&gt;

&lt;h2&gt;
  
  
  Clearing conditions
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;pending_email&lt;/code&gt; is a marker for "verification not yet complete," so it must be removed once verification finishes. The routine that records completion sets the registered flag and drops &lt;code&gt;pending_email&lt;/code&gt; at the same time.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;mark_registration_done&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;email&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="sh"&gt;"""&lt;/span&gt;&lt;span class="s"&gt;Persist the completed-registration flags to app_config.json on verification.&lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;
    &lt;span class="n"&gt;cfg&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;_load_config&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="n"&gt;cfg&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;email_registered&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="bp"&gt;True&lt;/span&gt;
    &lt;span class="n"&gt;cfg&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;registered_email&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;email&lt;/span&gt;
    &lt;span class="c1"&gt;# Clear the awaiting-verification marker (resume is no longer needed)
&lt;/span&gt;    &lt;span class="n"&gt;cfg&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;pop&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;pending_email&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="nf"&gt;_save_config&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;cfg&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That closes the state cycle: not registered → email sent sets &lt;code&gt;pending_email&lt;/code&gt; → verification clears &lt;code&gt;pending_email&lt;/code&gt; and marks registered. Close the app anywhere in between, and the next launch lands you back at the correct point.&lt;/p&gt;

&lt;h2&gt;
  
  
  Wrap-up
&lt;/h2&gt;

&lt;p&gt;It is a small change — persisting the intermediate "sent but not yet verified" state to a config file — but it stops an interruption before clicking the link from turning into an error. Representing state as something richer than a binary, with an "in progress" phase saved in a resumable form, is a plain and widely applicable pattern for any flow that can be interrupted.&lt;/p&gt;

&lt;p&gt;Around the same time, we also revisited the deliverability of the confirmation email itself. That is covered in a separate article.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Related: &lt;a href="https://en.wpmm.jp/blog/spf-dkim-dmarc-email-deliverability/" rel="noopener noreferrer"&gt;SPF, DKIM, and DMARC together — why the missing DMARC record was blocking registration emails&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;

</description>
      <category>python</category>
      <category>webdev</category>
      <category>programming</category>
    </item>
    <item>
      <title>SPF, DKIM, and DMARC together — why the missing DMARC record was blocking registration emails</title>
      <dc:creator>Susumu Takahashi</dc:creator>
      <pubDate>Thu, 13 Aug 2026 00:46:10 +0000</pubDate>
      <link>https://dev.to/susumun/spf-dkim-and-dmarc-together-why-the-missing-dmarc-record-was-blocking-registration-emails-3a49</link>
      <guid>https://dev.to/susumun/spf-dkim-and-dmarc-together-why-the-missing-dmarc-record-was-blocking-registration-emails-3a49</guid>
      <description>&lt;h2&gt;
  
  
  Background
&lt;/h2&gt;

&lt;p&gt;Registration confirmation emails were not reliably reaching users on Gmail and Outlook outside Japan — sometimes landing in spam, sometimes not arriving at all. Investigation pointed to a single root cause: the wpmm.jp domain had SPF and DKIM configured, but &lt;strong&gt;no DMARC record&lt;/strong&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  What each of the three does
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;SPF (Sender Policy Framework)&lt;/strong&gt; declares in DNS which IP addresses are authorized to send mail for a domain. Receiving servers check the sending IP against the SPF record to confirm the source is legitimate.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;DKIM (DomainKeys Identified Mail)&lt;/strong&gt; adds a cryptographic signature to the message headers and body. The receiving server looks up the public key in DNS and verifies that the message has not been tampered with and was signed by a party controlling that domain.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;DMARC (Domain-based Message Authentication, Reporting and Conformance)&lt;/strong&gt; sits above both. It tells receiving servers what to do when SPF and DKIM alignment fails, and it collects aggregate reports about how mail from the domain is being treated.&lt;/p&gt;

&lt;p&gt;The key point is that SPF and DKIM are independent checks. Without DMARC, there is no single authoritative statement about how the alignment result should influence delivery decisions. Major providers including Gmail weigh the absence of DMARC when scoring incoming mail.&lt;/p&gt;

&lt;h2&gt;
  
  
  Adding the DMARC record
&lt;/h2&gt;

&lt;p&gt;The following TXT record was added to the wpmm.jp DNS:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;_dmarc.wpmm.jp  TXT  "v=DMARC1; p=none; rua=mailto:info@wpmm.jp"
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;p=none&lt;/code&gt; means "collect data, but do not reject or quarantine mail that fails alignment." Starting with &lt;code&gt;p=reject&lt;/code&gt; or &lt;code&gt;p=quarantine&lt;/code&gt; risks blocking legitimate mail if DKIM alignment turns out to be misconfigured somewhere. The safe approach is to start with &lt;code&gt;p=none&lt;/code&gt;, monitor the reports, and tighten the policy gradually.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;rua=mailto:info@wpmm.jp&lt;/code&gt; sets the destination for aggregate reports. Google and other receivers periodically send XML summaries showing which mail passed or failed SPF/DKIM alignment. This moves visibility from passive (you notice when users complain) to active (you see the numbers).&lt;/p&gt;

&lt;p&gt;Propagation was confirmed against both Xserver's authoritative DNS and Google Public DNS before considering the change live.&lt;/p&gt;

&lt;h2&gt;
  
  
  Hardening &lt;code&gt;register_free.php&lt;/code&gt;
&lt;/h2&gt;

&lt;p&gt;The same release also tightened the sending code itself.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Stop silently swallowing send failures&lt;/strong&gt;: &lt;code&gt;mb_send_mail()&lt;/code&gt; returns &lt;code&gt;false&lt;/code&gt; on failure, but the return value was not being checked. A failed send would report success to the user and leave no trace in the server log.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Before: return value ignored&lt;/span&gt;
&lt;span class="nb"&gt;mb_send_mail&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$to&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$subject&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$body&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$headers&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="k"&gt;echo&lt;/span&gt; &lt;span class="nb"&gt;json_encode&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="s1"&gt;'status'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'ok'&lt;/span&gt;&lt;span class="p"&gt;]);&lt;/span&gt;

&lt;span class="c1"&gt;// After: detect failure and return an error&lt;/span&gt;
&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nb"&gt;mb_send_mail&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$to&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$subject&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$body&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$headers&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nb"&gt;http_response_code&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;500&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="k"&gt;echo&lt;/span&gt; &lt;span class="nb"&gt;json_encode&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="s1"&gt;'status'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'error'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'message'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'Failed to send email'&lt;/span&gt;&lt;span class="p"&gt;]);&lt;/span&gt;
    &lt;span class="k"&gt;exit&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Add headers that reduce spam scores&lt;/strong&gt;: &lt;code&gt;Reply-To&lt;/code&gt;, &lt;code&gt;Date&lt;/code&gt;, and &lt;code&gt;Message-ID&lt;/code&gt; were added explicitly.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="nv"&gt;$headers&lt;/span&gt; &lt;span class="mf"&gt;.&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"Reply-To: info@wpmm.jp&lt;/span&gt;&lt;span class="se"&gt;\r\n&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="nv"&gt;$headers&lt;/span&gt; &lt;span class="mf"&gt;.&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"Date: "&lt;/span&gt; &lt;span class="mf"&gt;.&lt;/span&gt; &lt;span class="nb"&gt;date&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'r'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="mf"&gt;.&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="se"&gt;\r\n&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="nv"&gt;$headers&lt;/span&gt; &lt;span class="mf"&gt;.&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"Message-ID: &amp;lt;"&lt;/span&gt; &lt;span class="mf"&gt;.&lt;/span&gt; &lt;span class="nb"&gt;uniqid&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'wpmm-'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="mf"&gt;.&lt;/span&gt; &lt;span class="s2"&gt;"@wpmm.jp&amp;gt;&lt;/span&gt;&lt;span class="se"&gt;\r\n&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A missing &lt;code&gt;Message-ID&lt;/code&gt; is a common spam signal. &lt;code&gt;Date&lt;/code&gt; is required by RFC 5322 but &lt;code&gt;mb_send_mail()&lt;/code&gt; does not always add it automatically.&lt;/p&gt;

&lt;h2&gt;
  
  
  What changed
&lt;/h2&gt;

&lt;p&gt;After adding the DMARC record, Google started sending aggregate reports to &lt;code&gt;info@wpmm.jp&lt;/code&gt;. The reports confirm that legitimate mail from wpmm.jp is passing DKIM alignment, and spoofed mail is failing.&lt;/p&gt;

&lt;p&gt;Email deliverability does not improve overnight — sender reputation builds gradually. What DMARC provides immediately is the ability to see what is happening, rather than relying on user complaints to find out.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Related: &lt;a href="https://en.wpmm.jp/blog/pending-email-resume-auth-ux/" rel="noopener noreferrer"&gt;Resuming email verification after the app is closed — the pending_email state that prevents re-sending&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;

</description>
      <category>python</category>
      <category>webdev</category>
      <category>programming</category>
    </item>
    <item>
      <title>When maintenance ends with an error, the plugin update badge disappears — designing a backend marker and frontend count sync</title>
      <dc:creator>Susumu Takahashi</dc:creator>
      <pubDate>Wed, 12 Aug 2026 02:26:39 +0000</pubDate>
      <link>https://dev.to/susumun/when-maintenance-ends-with-an-error-the-plugin-update-badge-disappears-designing-a-backend-lcl</link>
      <guid>https://dev.to/susumun/when-maintenance-ends-with-an-error-the-plugin-update-badge-disappears-designing-a-backend-lcl</guid>
      <description>&lt;p&gt;The WordPress maintenance tool shows a badge on each site card when there are pending plugin updates. Under normal operation, when maintenance completes successfully, the badge clears until the next dashboard scan picks up fresh data. That's correct behavior.&lt;/p&gt;

&lt;p&gt;But a report came in: when maintenance ends with a warning or an SSH error partway through, the badge disappears even if plugins were left unupdated. "If the update didn't finish, I need to know — but the badge is telling me there's nothing left to do."&lt;/p&gt;

&lt;h2&gt;
  
  
  Why the badge was disappearing
&lt;/h2&gt;

&lt;p&gt;Tracing the bug, the root cause was in the frontend badge-update logic, which was built on a hard-coded assumption: &lt;strong&gt;maintenance complete = all plugins updated = zero remaining&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;When the frontend received a maintenance-complete event, it deleted the site's entry from the badge cache (&lt;code&gt;_updatesDashState&lt;/code&gt;). "The run is done, so the count must be zero." That assumption.&lt;/p&gt;

&lt;p&gt;The assumption breaks whenever maintenance ends in a "failed" state:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;A DB backup fails before the plugin update phase is ever reached&lt;/li&gt;
&lt;li&gt;An SSH disconnect interrupts mid-update&lt;/li&gt;
&lt;li&gt;An HTTP check detects a site anomaly, triggers rollback, and exits with a warning&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;In all of these cases, a completion event still fires (the flow always ends, whether cleanly or not). The old design deleted the badge cache on that event — regardless of how many plugins were actually left behind.&lt;/p&gt;

&lt;h2&gt;
  
  
  The fix: a backend marker line
&lt;/h2&gt;

&lt;p&gt;The redesign is: &lt;strong&gt;the backend outputs the real remaining count, and the frontend reads it before deciding what to show the badge&lt;/strong&gt;. Replace the assumption with observed facts.&lt;/p&gt;

&lt;p&gt;The backend (&lt;code&gt;_check_pending_updates_ssh()&lt;/code&gt; in &lt;code&gt;maintenance_agent.py&lt;/code&gt;) now emits one marker line into the streaming log whenever the post-update residual check completes:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;[SiteName] Pending plugins remaining: 2 {akismet, contact-form-7}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;In Japanese mode:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;[サイト名] 未更新プラグイン: 2 件 {akismet, contact-form-7}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The marker includes the count and also the plugin slugs in &lt;code&gt;{}&lt;/code&gt;. The slugs are there so the frontend can populate the badge cache with real plugin names — the same names that appear in the cross-site dashboard.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Key design constraint&lt;/strong&gt;: the marker is only emitted if the residual check actually runs. If the run failed before reaching the plugin update phase (e.g., a DB backup error aborted early), no marker is output. The frontend treats that as "unknown" and leaves the existing badge untouched.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;plugin_count_known&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;_names_part&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;, &lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;join&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;plugin_remaining_names&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;
    &lt;span class="n"&gt;logger&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;info&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;[&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;site_name&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;] Pending plugins remaining: &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;plugin_remaining_count&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt; &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;_names_part&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
    &lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Three-path frontend logic
&lt;/h2&gt;

&lt;p&gt;The frontend (&lt;code&gt;templates/index.html&lt;/code&gt;) scans each streaming log chunk for the marker regex, records &lt;code&gt;site_id → {count, names[]}&lt;/code&gt; in &lt;code&gt;_postMaintPluginCount&lt;/code&gt;, and applies it at completion time via &lt;code&gt;_applyPendingCountsForSites()&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Three paths for each site:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;State&lt;/th&gt;
&lt;th&gt;Badge action&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Marker present, N = 0&lt;/td&gt;
&lt;td&gt;Delete cache entry (badge hidden — update succeeded)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Marker present, N &amp;gt; 0&lt;/td&gt;
&lt;td&gt;Upsert entry with real plugin names (badge shows count)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;No marker&lt;/td&gt;
&lt;td&gt;Do nothing (leave existing badge as-is)&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The "no marker" case is deliberately passive. The frontend has no way to know why the residual check never ran. "When information is missing, don't change anything" is the conservative fallback.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;_applyPendingCountsForSites&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;siteIds&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;sid&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nx"&gt;siteIds&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;Object&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;prototype&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;hasOwnProperty&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;call&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;_postMaintPluginCount&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;sid&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="nf"&gt;_applyPostMaintPluginCountToBadge&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;sid&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;_postMaintPluginCount&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;sid&lt;/span&gt;&lt;span class="p"&gt;]);&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;
        &lt;span class="c1"&gt;// no marker → leave badge alone&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Scoped plugin updates
&lt;/h2&gt;

&lt;p&gt;The tool supports "update only these specific plugins" mode. In this mode, the badge should still show the &lt;strong&gt;total pending count for the whole site&lt;/strong&gt; — not just the count of the selected plugins that were skipped.&lt;/p&gt;

&lt;p&gt;If you update one of three pending plugins, two remain. The badge should say two, not "zero selected-scope plugins remaining." The backend captures the count before applying the scope filter, so the marker always reflects site-wide truth.&lt;/p&gt;

&lt;h2&gt;
  
  
  Consolidating three call sites
&lt;/h2&gt;

&lt;p&gt;Previously, "update the badge after maintenance" was duplicated in three places:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;code&gt;_refreshCompletedSitesNow()&lt;/code&gt; — per-site immediate refresh&lt;/li&gt;
&lt;li&gt;Full-run completion event handler&lt;/li&gt;
&lt;li&gt;Scoped plugin update completion handler&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Each had its own "delete the cache entry" code. The fix replaced all three with calls to &lt;code&gt;_applyPendingCountsForSites()&lt;/code&gt;. One function, one place to change next time badge behavior needs to evolve.&lt;/p&gt;

&lt;h2&gt;
  
  
  Summary
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Aspect&lt;/th&gt;
&lt;th&gt;Detail&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Bug&lt;/td&gt;
&lt;td&gt;Hard-coded "done = zero remaining" deleted the badge cache on every completion event&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Root cause&lt;/td&gt;
&lt;td&gt;Frontend judged by the completion event alone, without reading what the backend actually found&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Fix&lt;/td&gt;
&lt;td&gt;Backend emits a marker line with the real count; frontend parses it and sets the badge accordingly&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Three paths&lt;/td&gt;
&lt;td&gt;N=0 → hide; N&amp;gt;0 → show with real names; no marker → preserve existing&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Design principle&lt;/td&gt;
&lt;td&gt;"If information is missing, don't change state" — conservative fallback under uncertainty&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;A badge that shows "updates pending" is only useful if it reflects reality. When maintenance runs clean, clear it. When maintenance fails partway through, keep it — or better, update it with what actually remains.&lt;/p&gt;

&lt;p&gt;For the initial badge implementation, see &lt;a href="https://en.wpmm.jp/blog/pending-plugin-count-badge/" rel="noopener noreferrer"&gt;the pending plugin count badge design&lt;/a&gt;. For the cache lifetime design that controls how long badge data persists, see &lt;a href="https://en.wpmm.jp/blog/dashboard-cache-lifetime-pitfalls/" rel="noopener noreferrer"&gt;dashboard cache lifetime pitfalls&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>python</category>
      <category>webdev</category>
      <category>programming</category>
    </item>
    <item>
      <title>A csh bug we thought we fixed came back in a new feature — designing a login-shell-independent SSH command wrapper</title>
      <dc:creator>Susumu Takahashi</dc:creator>
      <pubDate>Mon, 10 Aug 2026 05:48:33 +0000</pubDate>
      <link>https://dev.to/susumun/a-csh-bug-we-thought-we-fixed-came-back-in-a-new-feature-designing-a-login-shell-independent-ssh-4hob</link>
      <guid>https://dev.to/susumun/a-csh-bug-we-thought-we-fixed-came-back-in-a-new-feature-designing-a-login-shell-independent-ssh-4hob</guid>
      <description>&lt;p&gt;In May 2026, bash commands sent over SSH stopped working correctly on WordPress sites hosted on Sakura Internet. The root cause: Sakura's default login shell is csh, which can't interpret bash syntax. We patched the codebase, consolidating all bash-syntax &lt;code&gt;c.run()&lt;/code&gt; calls behind a &lt;code&gt;_safe_run&lt;/code&gt; helper, and the problem went away. (That original incident is documented in &lt;a href="https://en.wpmm.jp/blog/ssh-csh-bash-portability/" rel="noopener noreferrer"&gt;the csh login-shell portability article&lt;/a&gt;.)&lt;/p&gt;

&lt;p&gt;Then in June 2026, a report came in: DB backups were failing every single time on all Sakura-hosted sites with an "SSH update error."&lt;/p&gt;

&lt;h2&gt;
  
  
  Why the same problem came back
&lt;/h2&gt;

&lt;p&gt;The cause: a new feature had walked into the same trap.&lt;/p&gt;

&lt;p&gt;The v1.6.9 development cycle added progress monitoring to DB backups — sampling the backup file size every 30 seconds while the dump ran, then writing a one-line summary after completion. The script looked like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;wp db &lt;span class="nb"&gt;export&lt;/span&gt; /path/backup.sql 2&amp;gt;&amp;amp;1 &amp;amp;
&lt;span class="nv"&gt;PID&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nv"&gt;$!&lt;/span&gt;
&lt;span class="k"&gt;while &lt;/span&gt;&lt;span class="nb"&gt;kill&lt;/span&gt; &lt;span class="nt"&gt;-0&lt;/span&gt; &lt;span class="nv"&gt;$PID&lt;/span&gt; 2&amp;gt;/dev/null&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;do
  &lt;/span&gt;&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"size=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;wc&lt;/span&gt; &lt;span class="nt"&gt;-c&lt;/span&gt; &amp;lt; /path/backup.sql&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
  &lt;span class="nb"&gt;sleep &lt;/span&gt;30
&lt;span class="k"&gt;done
&lt;/span&gt;&lt;span class="nb"&gt;wait&lt;/span&gt; &lt;span class="nv"&gt;$PID&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nb"&gt;exit&lt;/span&gt; &lt;span class="nv"&gt;$?&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The script is correct bash. The problem is that &lt;strong&gt;paramiko's exec channel runs commands through the user's login shell on the server&lt;/strong&gt;. On Sakura, that login shell is csh. csh can't interpret these constructs:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Syntax&lt;/th&gt;
&lt;th&gt;What csh does&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;2&amp;gt;/dev/null&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;Ambiguous output redirect&lt;/code&gt; — or silently returns empty output (most dangerous)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;$!&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Can't hold the background process ID&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;while ...; do ... done&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;for: Command not found&lt;/code&gt; style error&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;VAR=val&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Variable assignment doesn't work&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The earlier fix covered the existing code. The new feature called &lt;code&gt;c.run()&lt;/code&gt; directly — outside the scope of that fix — and had no guard at all.&lt;/p&gt;

&lt;h2&gt;
  
  
  The first fix attempt: base64 encoding
&lt;/h2&gt;

&lt;p&gt;The first approach: base64-encode the script body, send it as a string, and have the server decode and run it through &lt;code&gt;/bin/sh&lt;/code&gt;.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;base64&lt;/span&gt;

&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;wrap_remote_sh_v1&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;script&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;encoded&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;base64&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;b64encode&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;script&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;encode&lt;/span&gt;&lt;span class="p"&gt;()).&lt;/span&gt;&lt;span class="nf"&gt;decode&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;echo &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;encoded&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt; | openssl base64 -d -A | /bin/sh&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;echo &amp;lt;base64&amp;gt;&lt;/code&gt; runs fine in any shell. The server decodes with &lt;code&gt;openssl base64 -d&lt;/code&gt; and pipes the output to &lt;code&gt;/bin/sh&lt;/code&gt;. The login shell only needs to handle &lt;code&gt;echo&lt;/code&gt;, a pipe, and an external command call — nothing bash-specific.&lt;/p&gt;

&lt;p&gt;Testing on a Sakura instance confirmed the progress script ran correctly.&lt;/p&gt;

&lt;h2&gt;
  
  
  It broke again on heteml
&lt;/h2&gt;

&lt;p&gt;While validating the fix across environments, a new fact emerged:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;heteml (ssh-layer.heteml.net) has neither &lt;code&gt;openssl&lt;/code&gt; nor a &lt;code&gt;base64&lt;/code&gt; command.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The base64 approach depends on &lt;code&gt;openssl base64 -d&lt;/code&gt;. On heteml, that's &lt;code&gt;command not found&lt;/code&gt;. The Sakura problem would be solved, but heteml would fail completely. Dependencies on external commands break silently on servers you haven't tested against.&lt;/p&gt;

&lt;h2&gt;
  
  
  The final design: &lt;code&gt;/bin/sh -c&lt;/code&gt;
&lt;/h2&gt;

&lt;p&gt;To eliminate external-command dependencies entirely, the approach shifted to passing the script directly as an argument to &lt;code&gt;/bin/sh -c&lt;/code&gt;.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;shlex&lt;/span&gt;

&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;wrap_remote_sh&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;script&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="se"&gt;\n&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;script&lt;/span&gt; &lt;span class="ow"&gt;or&lt;/span&gt; &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="se"&gt;\r&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;script&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;raise&lt;/span&gt; &lt;span class="nc"&gt;ValueError&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
            &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;wrap_remote_sh requires a single-line script &lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
            &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;(csh single-quotes cannot span newlines); &lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
            &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;join statements with &lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;;&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt; instead&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
        &lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;/bin/sh -c &lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;shlex&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;quote&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;script&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;shlex.quote()&lt;/code&gt; is Python's standard library function for POSIX single-quoting. Embedded &lt;code&gt;'&lt;/code&gt; characters become &lt;code&gt;'\''&lt;/code&gt;. The result looks like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ruby"&gt;&lt;code&gt;&lt;span class="n"&gt;wrap_remote_sh&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;"echo hi &amp;amp;&amp;amp; wc -c &amp;lt; /tmp/file"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="c1"&gt;# → "/bin/sh -c 'echo hi &amp;amp;&amp;amp; wc -c &amp;lt; /tmp/file'"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;When csh receives this via the exec channel, it treats the single-quoted content as a single token and hands it to &lt;code&gt;/bin/sh&lt;/code&gt;. The shell that interprets &lt;code&gt;2&amp;gt;/dev/null&lt;/code&gt;, &lt;code&gt;$!&lt;/code&gt;, and &lt;code&gt;while...do...done&lt;/code&gt; is &lt;code&gt;/bin/sh&lt;/code&gt;, not csh. The login shell is irrelevant.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Zero external-command dependencies.&lt;/strong&gt; &lt;code&gt;/bin/sh&lt;/code&gt; exists on every server.&lt;/p&gt;

&lt;p&gt;One constraint applies: &lt;strong&gt;the script must be a single line&lt;/strong&gt;. csh single-quotes can't span newlines — a multi-line script will produce &lt;code&gt;Unmatched '&lt;/code&gt;. Multi-line logic needs to be joined with semicolons into a single line (note: &lt;code&gt;do;&lt;/code&gt; is a shell syntax error in sh; use &lt;code&gt;do &amp;lt;cmd&amp;gt;&lt;/code&gt; to connect statements inside a loop). Violations throw a &lt;code&gt;ValueError&lt;/code&gt; so the issue is caught at development time, not at runtime.&lt;/p&gt;

&lt;h2&gt;
  
  
  Verified across three environments
&lt;/h2&gt;

&lt;p&gt;After the fix, we verified on three environments:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Environment&lt;/th&gt;
&lt;th&gt;Login shell&lt;/th&gt;
&lt;th&gt;openssl / base64&lt;/th&gt;
&lt;th&gt;Result&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Sakura&lt;/td&gt;
&lt;td&gt;csh&lt;/td&gt;
&lt;td&gt;present&lt;/td&gt;
&lt;td&gt;✅ Progress script ran to completion&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Xserver&lt;/td&gt;
&lt;td&gt;bash&lt;/td&gt;
&lt;td&gt;present&lt;/td&gt;
&lt;td&gt;✅ Same&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;heteml&lt;/td&gt;
&lt;td&gt;bash&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;absent&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;✅ No external commands needed&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The base64 approach had failed on heteml. The &lt;code&gt;/bin/sh -c&lt;/code&gt; approach passed all three.&lt;/p&gt;

&lt;h2&gt;
  
  
  A static test to prevent it from coming back
&lt;/h2&gt;

&lt;p&gt;Having &lt;code&gt;wrap_remote_sh&lt;/code&gt; available doesn't guarantee it gets used in the next new feature — that's exactly the pattern that caused this regression in the first place.&lt;/p&gt;

&lt;p&gt;The test suite now includes a static check: scan &lt;code&gt;maintenance_agent.py&lt;/code&gt; for any &lt;code&gt;c.run()&lt;/code&gt; call that contains csh-unsafe syntax without going through &lt;code&gt;wrap_remote_sh&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;test_no_csh_unsafe_bare_crun_in_maintenance_agent&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;src&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;PROJECT_ROOT&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;maintenance_agent.py&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;read_text&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;encoding&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;utf-8&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;i&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;line&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="nf"&gt;enumerate&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;src&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;splitlines&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;c.run(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;line&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="k"&gt;continue&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;wrap_remote_sh&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;line&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="k"&gt;continue&lt;/span&gt;
        &lt;span class="c1"&gt;# csh-unsafe markers: redirects, command substitution, loops
&lt;/span&gt;        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;re&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;search&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;r&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;2&amp;gt;|`\$!`|\$\(|while |for.*in.*do&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;line&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
            &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;fail&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;line &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;i&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;: csh-unsafe c.run without wrap: &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;line&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;strip&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If a future change adds a &lt;code&gt;c.run()&lt;/code&gt; containing &lt;code&gt;2&amp;gt;&lt;/code&gt; without wrapping it, the test fails. The check is mechanical — no human review required.&lt;/p&gt;

&lt;h2&gt;
  
  
  Summary
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Aspect&lt;/th&gt;
&lt;th&gt;Detail&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Problem&lt;/td&gt;
&lt;td&gt;A new feature called &lt;code&gt;c.run()&lt;/code&gt; directly, outside the scope of the earlier csh fix&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Root cause&lt;/td&gt;
&lt;td&gt;paramiko runs commands through the server's login shell; Sakura's default login shell is csh, which can't parse bash syntax&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;First attempt&lt;/td&gt;
&lt;td&gt;base64-encode the script, decode with &lt;code&gt;openssl base64 -d&lt;/code&gt; — broke on heteml (no openssl)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Final fix&lt;/td&gt;
&lt;td&gt;Single-quote the script with &lt;code&gt;shlex.quote()&lt;/code&gt;, pass it as the argument to &lt;code&gt;/bin/sh -c&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Regression prevention&lt;/td&gt;
&lt;td&gt;A static source-code test detects unwrapped csh-unsafe &lt;code&gt;c.run()&lt;/code&gt; calls automatically&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;When a bug class that was already fixed comes back in new code, it usually means the original fix covered existing code, but a separate guard was needed for new additions. Either a development habit of checking "am I going through the safety wrapper?" or a mechanical test that enforces it — one of the two needs to be in place. Read alongside &lt;a href="https://en.wpmm.jp/blog/ssh-csh-bash-portability/" rel="noopener noreferrer"&gt;the original incident&lt;/a&gt; to see how the same bug class evolved over time.&lt;/p&gt;

</description>
      <category>wordpress</category>
      <category>php</category>
      <category>tutorial</category>
    </item>
    <item>
      <title>Deploying and committing to git are not the same "done" — the trap of assuming uploaded means synced</title>
      <dc:creator>Susumu Takahashi</dc:creator>
      <pubDate>Sun, 09 Aug 2026 00:52:10 +0000</pubDate>
      <link>https://dev.to/susumun/deploying-and-committing-to-git-are-not-the-same-done-the-trap-of-assuming-uploaded-means-synced-4jjc</link>
      <guid>https://dev.to/susumun/deploying-and-committing-to-git-are-not-the-same-done-the-trap-of-assuming-uploaded-means-synced-4jjc</guid>
      <description>&lt;p&gt;Near the end of a release, every file transfer to the production server succeeded, and the version file that triggers distribution was updated too. With that confirmed, the release got reported as complete — except the local git repository never actually had those changes committed.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Note: "Deploying" here means transferring changed files to the production server (via scp, for example) so they're actually live for users. "git push" is a separate operation that records the change history in a remote repository.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  What happened
&lt;/h2&gt;

&lt;p&gt;This release involved transferring seven files to the production server: five landing-page update-notice files, the version file that triggers distribution, and a progress-log file. The transfer itself succeeded completely, and the production site confirmed it was showing the new version number.&lt;/p&gt;

&lt;p&gt;The problem: after editing these files locally, the work moved straight to the transfer step &lt;strong&gt;without ever committing&lt;/strong&gt;. The files on the production server were fully up to date, but the local git repository had no record of those changes — and the release got reported as complete in that state.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why this is easy to miss
&lt;/h2&gt;

&lt;p&gt;Transferring files with &lt;code&gt;scp&lt;/code&gt; and recording them in the repository with &lt;code&gt;git commit&lt;/code&gt; / &lt;code&gt;git push&lt;/code&gt; are completely independent operations, both as commands and as goals. Verifying production (HTTP 200, checking the rendered content) confirms "did the deployment succeed" — a different question from "is the local change history recorded." Treat the first check as proof of "done," and the second check quietly never happens.&lt;/p&gt;

&lt;p&gt;When both steps get mentally bundled into one "release complete" state, there's no natural moment to notice that only one of them actually finished. In this case, it surfaced because someone else looking at the repo noticed it hadn't been committed yet.&lt;/p&gt;

&lt;h2&gt;
  
  
  The fix — treat "uploaded" and "git synced" as two separate checks
&lt;/h2&gt;

&lt;p&gt;Add a git-sync verification step to the deploy checklist, independent from the file-transfer confirmation.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# Commit before deploying&lt;/span&gt;
git add &amp;lt;changed files&amp;gt;
git commit &lt;span class="nt"&gt;-m&lt;/span&gt; &lt;span class="s2"&gt;"..."&lt;/span&gt;
git push origin main

&lt;span class="c"&gt;# Transfer to the server via scp&lt;/span&gt;
scp &amp;lt;files&amp;gt; user@server:path/

&lt;span class="c"&gt;# Verify production&lt;/span&gt;
curl &lt;span class="nt"&gt;-s&lt;/span&gt; &lt;span class="nt"&gt;-o&lt;/span&gt; /dev/null &lt;span class="nt"&gt;-w&lt;/span&gt; &lt;span class="s2"&gt;"%{http_code}"&lt;/span&gt; https://example.com/version.json

&lt;span class="c"&gt;# Verify git sync (make this its own explicit step)&lt;/span&gt;
git status   &lt;span class="c"&gt;# confirm "nothing to commit, working tree clean"&lt;/span&gt;
git rev-list &lt;span class="nt"&gt;--left-right&lt;/span&gt; &lt;span class="nt"&gt;--count&lt;/span&gt; origin/main...main   &lt;span class="c"&gt;# confirm "0 0"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That last &lt;code&gt;git rev-list&lt;/code&gt; command counts how many commits differ between the local and remote branches in each direction. Output of &lt;code&gt;0 0&lt;/code&gt; means local and remote are fully in sync. It turns "I'm pretty sure I pushed" from a subjective memory into something a command output confirms mechanically.&lt;/p&gt;

&lt;h2&gt;
  
  
  What this incident reveals
&lt;/h2&gt;

&lt;p&gt;The root of this incident was treating two states with different meanings — "transferred via scp" and "committed to git" — as a single mental category called "release complete." The fix isn't really a technical mechanism; it's &lt;strong&gt;splitting the checklist into finer granularity&lt;/strong&gt;. "Reflected in production" and "change history recorded" became two separate, explicit verification steps, and only when both are confirmed does the work count as done.&lt;/p&gt;

&lt;h2&gt;
  
  
  Summary
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Aspect&lt;/th&gt;
&lt;th&gt;Detail&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Problem&lt;/td&gt;
&lt;td&gt;Transferring files to production (scp) and recording them in git (commit/push) are independent steps, but only one got verified before calling the work "done"&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Why it's easy to miss&lt;/td&gt;
&lt;td&gt;Verifying production confirms deployment success, not whether local change history was recorded — two different questions&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Fix&lt;/td&gt;
&lt;td&gt;Add git-sync verification as its own explicit item on the deploy checklist&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Verification commands&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;git status&lt;/code&gt; and &lt;code&gt;git rev-list --left-right --count origin/main...main&lt;/code&gt; (expecting &lt;code&gt;0 0&lt;/code&gt;) confirm sync mechanically instead of relying on memory&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;A word like "done" that can carry multiple meanings is an easy trap for a checklist to fall into at too coarse a granularity. Naming each thing that actually needs to finish, and turning each into its own verification step, is what closes this kind of gap.&lt;/p&gt;

</description>
      <category>python</category>
      <category>webdev</category>
      <category>programming</category>
    </item>
    <item>
      <title>Checking that a count didn't change — a sanity check before writing a reorder API's result</title>
      <dc:creator>Susumu Takahashi</dc:creator>
      <pubDate>Fri, 07 Aug 2026 00:18:39 +0000</pubDate>
      <link>https://dev.to/susumun/checking-that-a-count-didnt-change-a-sanity-check-before-writing-a-reorder-apis-result-hg9</link>
      <guid>https://dev.to/susumun/checking-that-a-count-didnt-change-a-sanity-check-before-writing-a-reorder-apis-result-hg9</guid>
      <description>&lt;p&gt;Building a drag-and-drop reorder feature for a list of items means sending the new order to the server and saving it. If there's even a small flaw in the reordering logic, that save can quietly persist data with items missing or duplicated.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Note: A "sanity check" here means a lightweight check that the result of a process satisfies a condition that should obviously hold — not a thorough validation, just a last-resort check for "is this clearly wrong?"&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  The shape of the reorder logic
&lt;/h2&gt;

&lt;p&gt;The new order from a drag-and-drop interaction arrives at the server as an array of IDs. The server reorders the existing data to match.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="c1"&gt;# Place items according to the order sent by the client
&lt;/span&gt;&lt;span class="n"&gt;reordered&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[]&lt;/span&gt;
&lt;span class="n"&gt;placed_ids&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;set&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;sid&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;dedup_order&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;sid&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;by_id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;reordered&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;append&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;by_id&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;sid&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;
        &lt;span class="n"&gt;placed_ids&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;add&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;sid&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="c1"&gt;# Anything not covered by the order gets appended,
# keeping its existing relative order
&lt;/span&gt;&lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;site&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;data&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;sid&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;str&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;site&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;_id&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;''&lt;/span&gt;&lt;span class="p"&gt;)).&lt;/span&gt;&lt;span class="nf"&gt;strip&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;sid&lt;/span&gt; &lt;span class="ow"&gt;and&lt;/span&gt; &lt;span class="n"&gt;sid&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;placed_ids&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;continue&lt;/span&gt;
    &lt;span class="n"&gt;reordered&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;append&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;site&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This two-step structure makes sense on its own. The order the client sends might only cover a filtered or searched subset of the data. Appending whatever wasn't covered by that order, while preserving its existing relative position, prevents items that weren't currently visible from disappearing.&lt;/p&gt;

&lt;h2&gt;
  
  
  The risk that remains
&lt;/h2&gt;

&lt;p&gt;The problem is what happens if the combination of "place in order" and "append the rest" has a flaw of its own. ID deduplication, string-conversion edge cases, malformed entries — the more complex this logic gets, the more the underlying assumption ("every item appears exactly once in the output") is at risk of breaking. If there's a bug here, the reordered data could end up shorter than the original (items lost) or longer (items duplicated).&lt;/p&gt;

&lt;h2&gt;
  
  
  The fix — verify the count matches right before writing
&lt;/h2&gt;

&lt;p&gt;Right before writing the reordered result to disk, check one more thing: does the input count match the output count?&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="c1"&gt;# Sanity check: counts must match (no items lost or duplicated)
&lt;/span&gt;&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="nf"&gt;len&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;reordered&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="nf"&gt;len&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;data&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;app&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;logger&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;reorder_sites: count mismatch (in=%d, out=%d) — refusing to write&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="nf"&gt;len&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;data&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="nf"&gt;len&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;reordered&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;jsonify&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
        &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;status&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;error&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;message&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;Reorder count mismatch; refusing to save&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;}),&lt;/span&gt; &lt;span class="mi"&gt;500&lt;/span&gt;

&lt;span class="nf"&gt;_atomic_write_json&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;filepath&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;reordered&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If the counts don't match, the error log records both the input and output counts, and &lt;strong&gt;the write itself is aborted&lt;/strong&gt;. There's no need to pinpoint exactly where the reorder logic went wrong — whether the result is safe to save can be determined mechanically.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why a count match is enough of a defense
&lt;/h2&gt;

&lt;p&gt;This reorder operation doesn't change the content of any item — it only changes the order. That means before and after, the set of elements should always stay the same. If the count changed, something either disappeared or got duplicated. A matching count doesn't prove the content is perfectly intact, but as a lightweight way to catch obvious corruption — items lost or duplicated — comparing counts does the job.&lt;/p&gt;

&lt;h2&gt;
  
  
  What this design gets right
&lt;/h2&gt;

&lt;p&gt;This isn't about writing the reorder logic perfectly — it's about &lt;strong&gt;catching a flaw in that logic before it turns into data corruption, even when the logic itself isn't perfect.&lt;/strong&gt; The more complex a piece of logic gets, the harder it is to cover every case in tests. Identifying one invariant that should always hold before and after, and checking only that at the very end, acts as insurance against bugs that tests didn't anticipate.&lt;/p&gt;

&lt;h2&gt;
  
  
  Summary
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Aspect&lt;/th&gt;
&lt;th&gt;Detail&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Feature&lt;/td&gt;
&lt;td&gt;An API that takes a drag-and-drop reorder result as an array of IDs and reorders existing data to match&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Risk&lt;/td&gt;
&lt;td&gt;A flaw in the two-step "place in order, then append the rest" logic can cause data loss or duplication&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Fix&lt;/td&gt;
&lt;td&gt;Compare input and output counts right before writing; abort and return an error on mismatch&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Design principle&lt;/td&gt;
&lt;td&gt;Not aiming for perfect logic, but mechanically preventing a write while an invariant is broken&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Code that rewrites a batch of items at once tends to be written assuming the logic works correctly. Thinking through what happens when that assumption breaks is what actually protects the data. The trap of a word like "done" lumping together multiple distinct meanings shows up again in &lt;a href="https://en.wpmm.jp/blog/deploy-complete-vs-git-sync-separate-checks/" rel="noopener noreferrer"&gt;the incident that conflated deploy-complete with git-commit-complete&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>python</category>
      <category>webdev</category>
      <category>programming</category>
    </item>
    <item>
      <title>A version bump that got left behind — fixing it with a single source of truth and a `--check` gate</title>
      <dc:creator>Susumu Takahashi</dc:creator>
      <pubDate>Wed, 05 Aug 2026 23:55:12 +0000</pubDate>
      <link>https://dev.to/susumun/a-version-bump-that-got-left-behind-fixing-it-with-a-single-source-of-truth-and-a-check-gate-5flb</link>
      <guid>https://dev.to/susumun/a-version-bump-that-got-left-behind-fixing-it-with-a-single-source-of-truth-and-a-check-gate-5flb</guid>
      <description>&lt;p&gt;Bumping a desktop app's version number sounds like a one-line change. In practice it can mean editing five different files at once. During one release, exactly one of those files got missed, and an installer nearly got built carrying the old version number.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Note: A "version bump" just means incrementing the version number — say, from &lt;code&gt;1.6.9&lt;/code&gt; to &lt;code&gt;1.6.10&lt;/code&gt;. The operation sounds trivial, but it can end up scattered across multiple files in practice.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  What happened
&lt;/h2&gt;

&lt;p&gt;This app's version number lives in five separate places:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The &lt;code&gt;VERSION&lt;/code&gt; variable in &lt;code&gt;version.py&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;MyAppVersion&lt;/code&gt; in the Windows installer definition file (&lt;code&gt;.iss&lt;/code&gt;)&lt;/li&gt;
&lt;li&gt;The version number and download URLs in &lt;code&gt;version.json&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;Two download links on the landing page (Japanese and English versions)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Every release, these got updated by hand, relying on memory of "which files need touching this time." During one release, the &lt;code&gt;.iss&lt;/code&gt; file on the Windows build side was left at the old version — and it only surfaced when someone happened to notice, visually, that the version number still looked old right before rebuilding. There was no automated check that would have caught it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why memory-based manual edits invite this kind of failure
&lt;/h2&gt;

&lt;p&gt;Five files isn't a huge number on its own, but the list of "files to touch this release" lived entirely in someone's head. Succeeding once is no guarantee the same steps get reproduced correctly next time. On top of that, each file uses a different syntax (a Python variable assignment, an INI-style definition, JSON, link strings inside PHP), which makes a quick grep-based sanity check more tedious than it sounds.&lt;/p&gt;

&lt;h2&gt;
  
  
  The fix — list every target file in one place, and fold verification into the same script
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;tools/bump_version.py&lt;/code&gt; centralizes the list of target files and their matching patterns into a single script.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;VERSION_TARGETS&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;path&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;version.py&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
     &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;checks&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;regex&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sa"&gt;r&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;^VERSION\s*=\s*&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;([^&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;]+)&lt;/span&gt;&lt;span class="sh"&gt;"'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                 &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;replace&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;VERSION = &lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;{version}&lt;/span&gt;&lt;span class="sh"&gt;"'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;label&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;VERSION&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;}]},&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;path&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;WP_Maintenance_Pro.iss&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
     &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;checks&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;regex&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sa"&gt;r&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;#define\s+MyAppVersion\s+&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;([^&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;]+)&lt;/span&gt;&lt;span class="sh"&gt;"'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                 &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;replace&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;#define MyAppVersion   &lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;{version}&lt;/span&gt;&lt;span class="sh"&gt;"'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                 &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;label&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;MyAppVersion&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;}]},&lt;/span&gt;
    &lt;span class="c1"&gt;# ... version.json and both index.php files (JP/EN) listed the same way
&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The script has two modes:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# Bump everything from the old version to the new one&lt;/span&gt;
python tools/bump_version.py 1.6.11 2026-06-11

&lt;span class="c"&gt;# Don't bump anything — just verify current consistency&lt;/span&gt;
python tools/bump_version.py &lt;span class="nt"&gt;--check&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;--check&lt;/code&gt; mode treats &lt;code&gt;VERSION&lt;/code&gt; in &lt;code&gt;version.py&lt;/code&gt; as the source of truth and verifies every other file against it. If even one mismatch turns up, it prints exactly which file, which field, and what value it found, then exits with code 1.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;check_all&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;expected_version&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;mismatches&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[]&lt;/span&gt;
    &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;target&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;VERSION_TARGETS&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;content&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;_read&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;os&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;path&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;join&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;PROJECT_ROOT&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;target&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;path&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]))&lt;/span&gt;
        &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;chk&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;target&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;checks&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]:&lt;/span&gt;
            &lt;span class="n"&gt;m&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;re&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;search&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;chk&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;regex&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="n"&gt;content&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
            &lt;span class="n"&gt;actual&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;m&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;group&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;m&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;&amp;lt;not found&amp;gt;&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
            &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;actual&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="n"&gt;expected_version&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
                &lt;span class="n"&gt;mismatches&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;append&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="n"&gt;target&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;path&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="n"&gt;chk&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;label&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="n"&gt;actual&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;expected_version&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;mismatches&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Wiring the check into the build script as a gate
&lt;/h2&gt;

&lt;p&gt;Running &lt;code&gt;--check&lt;/code&gt; on its own still leaves the same problem: someone has to remember to run it. So the verification gets wired directly into the app's build script, which refuses to proceed if anything's inconsistent.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;_check_result&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;subprocess&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;run&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;sys&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;executable&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;tools/bump_version.py&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;--check&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
    &lt;span class="n"&gt;capture_output&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;text&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;_check_result&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;returncode&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Release file version consistency check failed&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;_check_result&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;stdout&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;sys&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;exit&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This means the check always runs before signing and distribution, regardless of whether the person building remembers &lt;code&gt;--check&lt;/code&gt; exists. Because the same script runs on both Mac and Windows, it also doubles as a cross-platform consistency check.&lt;/p&gt;

&lt;h2&gt;
  
  
  What this design gets right
&lt;/h2&gt;

&lt;p&gt;This isn't really about "editing a version string" — it's about answering &lt;strong&gt;who guarantees consistency across files scattered in multiple places, and where.&lt;/strong&gt; Centralizing the list of target files in one script makes it obvious where to add a new file when the release process grows. And by folding "bump" and "verify" into two modes of the same script, rather than two separately maintained pieces of logic, there's no duplicate implementation of the matching rules to drift out of sync.&lt;/p&gt;

&lt;h2&gt;
  
  
  Summary
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Aspect&lt;/th&gt;
&lt;th&gt;Detail&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Problem&lt;/td&gt;
&lt;td&gt;Version number scattered across 5 files; memory-based manual updates dropped one of them&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Fix&lt;/td&gt;
&lt;td&gt;Centralize target files and regex patterns in one script with both bump and verify modes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Verification&lt;/td&gt;
&lt;td&gt;Treat &lt;code&gt;version.py&lt;/code&gt; as the source of truth, regex-match every other file against it, report specific mismatches&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Permanence&lt;/td&gt;
&lt;td&gt;Wire the check into the build script as a gate, removing the dependency on someone remembering to run it&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Consistency across multiple files, even once fixed by hand, tends to drift again on the next pass. The core of this design is refusing to let verification depend on a choice — instead pinning it to a path (the build) that always gets taken anyway. The same principle — not perfecting the logic, but mechanically catching it when an invariant breaks — shows up again in &lt;a href="https://en.wpmm.jp/blog/reorder-api-count-sanity-check/" rel="noopener noreferrer"&gt;the count sanity check on a reorder API&lt;/a&gt;.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Follow-up: &lt;a href="https://en.wpmm.jp/blog/build-gate-cp932-crash-windows-fix/" rel="noopener noreferrer"&gt;The cp932 crash in the build gate that only happened on Windows&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;

</description>
      <category>python</category>
      <category>webdev</category>
      <category>programming</category>
    </item>
  </channel>
</rss>
