<?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: loach</title>
    <description>The latest articles on DEV Community by loach (@loach2009).</description>
    <link>https://dev.to/loach2009</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%2F4119133%2F9a7d0107-ed42-4ca8-a57a-d5b67333a4f5.png</url>
      <title>DEV Community: loach</title>
      <link>https://dev.to/loach2009</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/loach2009"/>
    <language>en</language>
    <item>
      <title>Publishing .NET Packages to NuGet.org with an API Key</title>
      <dc:creator>loach</dc:creator>
      <pubDate>Thu, 10 Sep 2026 12:00:55 +0000</pubDate>
      <link>https://dev.to/loach2009/publishing-net-packages-to-nugetorg-with-an-api-key-37n5</link>
      <guid>https://dev.to/loach2009/publishing-net-packages-to-nugetorg-with-an-api-key-37n5</guid>
      <description>&lt;p&gt;Publishing a .NET library manually is fine once or twice, but it becomes tedious when releases are frequent or automated through CI/CD.&lt;/p&gt;

&lt;p&gt;The standard solution is to push packages to NuGet.org with &lt;code&gt;dotnet nuget push&lt;/code&gt; and an API key.&lt;/p&gt;

&lt;p&gt;This article covers the basic flow and the most important security consideration: &lt;strong&gt;never commit the API key into the repository&lt;/strong&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Prerequisites
&lt;/h2&gt;

&lt;p&gt;You need:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;a .NET project&lt;/li&gt;
&lt;li&gt;a generated &lt;code&gt;.nupkg&lt;/code&gt; file&lt;/li&gt;
&lt;li&gt;a NuGet.org account&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;For example, your package might look like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;./bin/Release/YourPackage.1.0.0.nupkg
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Create a NuGet API key
&lt;/h2&gt;

&lt;p&gt;Sign in to NuGet.org and open the API Keys page.&lt;/p&gt;

&lt;p&gt;When creating a new key, configure at least:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;a recognizable key name&lt;/li&gt;
&lt;li&gt;an expiration period&lt;/li&gt;
&lt;li&gt;the &lt;code&gt;Push&lt;/code&gt; scope&lt;/li&gt;
&lt;li&gt;an optional package pattern if you want to restrict which packages the key can publish&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The generated key should be treated like a password.&lt;/p&gt;

&lt;p&gt;Do not put it in source code, scripts committed to Git, or Markdown examples containing a real value.&lt;/p&gt;

&lt;h2&gt;
  
  
  Push a package
&lt;/h2&gt;

&lt;p&gt;The basic command is:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;dotnet nuget push &amp;lt;PACKAGE_FILE&amp;gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--api-key&lt;/span&gt; &amp;lt;YOUR_API_KEY&amp;gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--source&lt;/span&gt; https://api.nuget.org/v3/index.json
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;For example:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;dotnet nuget push ./bin/Release/YourPackage.1.0.0.nupkg &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--api-key&lt;/span&gt; YOUR_API_KEY_HERE &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--source&lt;/span&gt; https://api.nuget.org/v3/index.json
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A useful option for CI is &lt;code&gt;--skip-duplicate&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;dotnet nuget push ./bin/Release/&lt;span class="k"&gt;*&lt;/span&gt;.nupkg &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--api-key&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$NUGET_API_KEY&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--source&lt;/span&gt; https://api.nuget.org/v3/index.json &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--skip-duplicate&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This avoids failing a rerun just because the same package version was already published.&lt;/p&gt;

&lt;h2&gt;
  
  
  Store the key in GitHub Actions Secrets
&lt;/h2&gt;

&lt;p&gt;For GitHub Actions, store the key as a repository secret, for example:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;NUGET_API_KEY
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then reference it from the workflow:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Publish package&lt;/span&gt;
  &lt;span class="na"&gt;env&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;NUGET_API_KEY&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${{ secrets.NUGET_API_KEY }}&lt;/span&gt;
  &lt;span class="na"&gt;run&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;|&lt;/span&gt;
    &lt;span class="s"&gt;dotnet nuget push ./bin/Release/*.nupkg \&lt;/span&gt;
      &lt;span class="s"&gt;--api-key "$NUGET_API_KEY" \&lt;/span&gt;
      &lt;span class="s"&gt;--source https://api.nuget.org/v3/index.json \&lt;/span&gt;
      &lt;span class="s"&gt;--skip-duplicate&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The API key stays outside the repository while still being available to the CI job.&lt;/p&gt;

&lt;h2&gt;
  
  
  Prefer narrow credentials
&lt;/h2&gt;

&lt;p&gt;If the publishing key only needs to release one package family, restrict it to that package pattern where possible.&lt;/p&gt;

&lt;p&gt;A narrow key reduces the impact if it is ever exposed.&lt;/p&gt;

&lt;p&gt;The same principle applies to other CI credentials: give automation only the permissions it actually needs.&lt;/p&gt;

&lt;h2&gt;
  
  
  Closing thoughts
&lt;/h2&gt;

&lt;p&gt;The mechanics of publishing a NuGet package are simple. The part worth designing carefully is credential handling.&lt;/p&gt;

&lt;p&gt;A solid release flow usually looks like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;build
  ↓
pack
  ↓
secret injected by CI
  ↓
dotnet nuget push
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Keeping the credential out of the repository makes the process both automatable and reasonably safe.&lt;/p&gt;

</description>
      <category>automation</category>
      <category>dotnet</category>
      <category>security</category>
    </item>
    <item>
      <title>PureSharp: Bringing Functional Safety to C# with Roslyn Analyzers</title>
      <dc:creator>loach</dc:creator>
      <pubDate>Thu, 10 Sep 2026 11:55:44 +0000</pubDate>
      <link>https://dev.to/loach2009/puresharp-bringing-functional-safety-to-c-with-roslyn-analyzers-4293</link>
      <guid>https://dev.to/loach2009/puresharp-bringing-functional-safety-to-c-with-roslyn-analyzers-4293</guid>
      <description>&lt;p&gt;C# is a very flexible language, but that flexibility also makes it easy for side effects and mutable state to spread through a codebase.&lt;/p&gt;

&lt;p&gt;I built &lt;strong&gt;PureSharp&lt;/strong&gt; to bring a few functional-programming ideas into everyday C# development and make them enforceable at compile time.&lt;/p&gt;

&lt;p&gt;GitHub: &lt;a href="https://github.com/mao2009/PureSharp" rel="noopener noreferrer"&gt;https://github.com/mao2009/PureSharp&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;NuGet: &lt;a href="https://www.nuget.org/packages/loach.PureSharp" rel="noopener noreferrer"&gt;https://www.nuget.org/packages/loach.PureSharp&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  What PureSharp tries to enforce
&lt;/h2&gt;

&lt;p&gt;PureSharp focuses on three ideas:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Purity&lt;/strong&gt;: methods explicitly marked as pure should not perform side effects.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Immutability&lt;/strong&gt;: local variables can opt into a "do not reassign" convention.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Safer control flow&lt;/strong&gt;: fluent conditional expressions should terminate explicitly.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The key point is that these rules are not just documentation. Roslyn analyzers report violations while you write and build the code.&lt;/p&gt;

&lt;h2&gt;
  
  
  Declaring a pure method
&lt;/h2&gt;

&lt;p&gt;A method can be marked with &lt;code&gt;[PureMethod]&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="k"&gt;using&lt;/span&gt; &lt;span class="nn"&gt;PureSharp.Core&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;Calculator&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;PureMethod&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="kt"&gt;int&lt;/span&gt; &lt;span class="nf"&gt;Add&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;int&lt;/span&gt; &lt;span class="n"&gt;a&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kt"&gt;int&lt;/span&gt; &lt;span class="n"&gt;b&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="n"&gt;a&lt;/span&gt; &lt;span class="p"&gt;+&lt;/span&gt; &lt;span class="n"&gt;b&lt;/span&gt;&lt;span class="p"&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;Inside such a method, PureSharp can report operations that violate the declared purity contract, such as accessing mutable static state, calling methods that are not known to be pure, or performing I/O.&lt;/p&gt;

&lt;p&gt;The goal is not to claim that every C# method can be proven mathematically pure. The goal is more practical: if a developer explicitly says "this method should be pure," the analyzer should help keep it that way.&lt;/p&gt;

&lt;h2&gt;
  
  
  Opt-in immutability for local variables
&lt;/h2&gt;

&lt;p&gt;C# does not have a general-purpose &lt;code&gt;const&lt;/code&gt;-style feature for local variables initialized at runtime.&lt;/p&gt;

&lt;p&gt;PureSharp uses a naming convention: local variables beginning with &lt;code&gt;_&lt;/code&gt; are treated as non-reassignable.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;void&lt;/span&gt; &lt;span class="nf"&gt;ProcessData&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kt"&gt;int&lt;/span&gt; &lt;span class="n"&gt;_result&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;CalculateValue&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

    &lt;span class="c1"&gt;// Reported by the analyzer:&lt;/span&gt;
    &lt;span class="c1"&gt;// _result = 100;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This turns a simple convention into something the compiler can enforce.&lt;/p&gt;

&lt;p&gt;The analyzer can also require immediate initialization and can suggest the immutable convention for locals that are never reassigned.&lt;/p&gt;

&lt;h2&gt;
  
  
  Fluent conditional expressions
&lt;/h2&gt;

&lt;p&gt;PureSharp also provides &lt;code&gt;Fluent.If&lt;/code&gt;, which makes it possible to use conditional branching as an expression:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="kt"&gt;int&lt;/span&gt; &lt;span class="n"&gt;status&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;Fluent&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;If&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;score&lt;/span&gt; &lt;span class="p"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="m"&gt;80&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="m"&gt;1&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;ElseIf&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;score&lt;/span&gt; &lt;span class="p"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="m"&gt;60&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="m"&gt;2&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Else&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="m"&gt;0&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The analyzer checks that a fluent chain is properly terminated with &lt;code&gt;.Else()&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;The motivation is simple: if the construct is intended to produce a value, all paths should be explicit.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why Roslyn analyzers?
&lt;/h2&gt;

&lt;p&gt;These rules could be described in a style guide, but a style guide is only useful when someone remembers to enforce it.&lt;/p&gt;

&lt;p&gt;Roslyn analyzers make the feedback immediate:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;violations appear in the IDE&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;dotnet build&lt;/code&gt; can fail on violations&lt;/li&gt;
&lt;li&gt;CI does not need a separate custom validator&lt;/li&gt;
&lt;li&gt;rules stay close to the normal C# development workflow&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That makes them especially useful for conventions that are easy to understand but tedious to police in code review.&lt;/p&gt;

&lt;h2&gt;
  
  
  Installation
&lt;/h2&gt;

&lt;p&gt;Install the package from NuGet:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;dotnet add package loach.PureSharp
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;After that, the analyzer participates in normal builds.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I learned from building it
&lt;/h2&gt;

&lt;p&gt;The interesting part of a tool like PureSharp is not the syntax of a single diagnostic. It is deciding where static analysis can give useful guarantees without pretending to understand more than it actually can.&lt;/p&gt;

&lt;p&gt;C# remains an imperative, object-oriented language with many escape hatches. PureSharp is therefore intentionally opt-in: developers declare the rules they want, and the analyzer checks those declarations as far as static analysis reasonably allows.&lt;/p&gt;

&lt;p&gt;That balance is what makes Roslyn analyzers attractive for this kind of developer tooling.&lt;/p&gt;

&lt;p&gt;PureSharp is open source here:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://github.com/mao2009/PureSharp" rel="noopener noreferrer"&gt;https://github.com/mao2009/PureSharp&lt;/a&gt;&lt;/p&gt;

</description>
      <category>csharp</category>
      <category>dotnet</category>
      <category>opensource</category>
      <category>softwareengineering</category>
    </item>
    <item>
      <title>Using Ctrl+Enter to Submit in OpenCode and Enter for New Lines</title>
      <dc:creator>loach</dc:creator>
      <pubDate>Thu, 10 Sep 2026 11:55:33 +0000</pubDate>
      <link>https://dev.to/loach2009/using-ctrlenter-to-submit-in-opencode-and-enter-for-new-lines-2065</link>
      <guid>https://dev.to/loach2009/using-ctrlenter-to-submit-in-opencode-and-enter-for-new-lines-2065</guid>
      <description>&lt;p&gt;Another OpenCode behavior I wanted to change was message submission.&lt;/p&gt;

&lt;p&gt;By default, pressing &lt;code&gt;Enter&lt;/code&gt; submits the current prompt. If you are used to chat tools where &lt;code&gt;Enter&lt;/code&gt; inserts a new line and &lt;code&gt;Ctrl + Enter&lt;/code&gt; sends, this makes accidental submissions easy.&lt;/p&gt;

&lt;p&gt;My preferred setup is:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;Enter&lt;/code&gt;: insert a new line&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;Ctrl + Enter&lt;/code&gt;: submit the prompt&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  OpenCode configuration
&lt;/h2&gt;

&lt;p&gt;Edit your OpenCode configuration file and update the keybindings:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"keybinds"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"app_exit"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"ctrl+d,&amp;lt;leader&amp;gt;q"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"input_submit"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"ctrl+return"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"input_newline"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"shift+return,return,alt+return,ctrl+j"&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This tells OpenCode to treat normal Enter-style keys as new lines and reserve &lt;code&gt;Ctrl + Enter&lt;/code&gt; for submission.&lt;/p&gt;

&lt;h2&gt;
  
  
  The terminal may need configuration too
&lt;/h2&gt;

&lt;p&gt;There is one important detail: some terminals do not send a distinct key sequence for &lt;code&gt;Ctrl + Enter&lt;/code&gt; by default.&lt;/p&gt;

&lt;p&gt;In that case, OpenCode cannot distinguish it from plain Enter.&lt;/p&gt;

&lt;p&gt;For Windows Terminal, you can define a custom sequence:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="nl"&gt;"actions"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"command"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"action"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"sendInput"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"input"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="se"&gt;\u&lt;/span&gt;&lt;span class="s2"&gt;001b[13;5u"&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"id"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"User.sendInput.CtrlEnterCustom"&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then bind &lt;code&gt;Ctrl + Enter&lt;/code&gt; to that action in the terminal settings.&lt;/p&gt;

&lt;p&gt;The exact terminal configuration can vary, but the principle is the same: the terminal must send a distinguishable sequence before OpenCode can bind it separately.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why this improves the workflow
&lt;/h2&gt;

&lt;p&gt;Long prompts often contain code blocks, lists, and multiple paragraphs.&lt;/p&gt;

&lt;p&gt;Making &lt;code&gt;Enter&lt;/code&gt; safe for editing removes the pressure to finish the whole prompt on one line and reduces accidental sends.&lt;/p&gt;

&lt;p&gt;It also makes OpenCode behave more like many chat and editor-oriented interfaces.&lt;/p&gt;

&lt;h2&gt;
  
  
  Closing thoughts
&lt;/h2&gt;

&lt;p&gt;Keybindings are a small detail, but AI coding tools are used interactively for long periods. Small interaction problems become surprisingly expensive when repeated all day.&lt;/p&gt;

&lt;p&gt;For me, separating newline and submit made OpenCode much more comfortable to use.&lt;/p&gt;

</description>
      <category>productivity</category>
      <category>software</category>
      <category>tools</category>
    </item>
    <item>
      <title>Preventing Accidental OpenCode Exits with Ctrl+C</title>
      <dc:creator>loach</dc:creator>
      <pubDate>Thu, 10 Sep 2026 11:50:23 +0000</pubDate>
      <link>https://dev.to/loach2009/preventing-accidental-opencode-exits-with-ctrlc-39dh</link>
      <guid>https://dev.to/loach2009/preventing-accidental-opencode-exits-with-ctrlc-39dh</guid>
      <description>&lt;p&gt;OpenCode is powerful, but one default behavior kept getting in my way: pressing &lt;code&gt;Ctrl + C&lt;/code&gt; could close the application itself.&lt;/p&gt;

&lt;p&gt;If you spend a lot of time in terminals, &lt;code&gt;Ctrl + C&lt;/code&gt; is muscle memory. You press it to interrupt a command, stop a long-running process, or simply because your hands move before you think.&lt;/p&gt;

&lt;p&gt;Losing the whole OpenCode session because of that is frustrating.&lt;/p&gt;

&lt;p&gt;The fix is simple: move the application exit action to a different keybinding.&lt;/p&gt;

&lt;h2&gt;
  
  
  Change the exit keybinding
&lt;/h2&gt;

&lt;p&gt;Open your OpenCode configuration file and customize the keybindings.&lt;/p&gt;

&lt;p&gt;For example:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"keybinds"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"app_exit"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"ctrl+d,&amp;lt;leader&amp;gt;q"&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;With this configuration, &lt;code&gt;Ctrl + C&lt;/code&gt; is no longer the application-exit shortcut.&lt;/p&gt;

&lt;p&gt;You can then use &lt;code&gt;Ctrl + D&lt;/code&gt; or the leader-based shortcut to exit intentionally.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why this small change matters
&lt;/h2&gt;

&lt;p&gt;AI coding agents often stay open for long sessions with valuable context.&lt;/p&gt;

&lt;p&gt;A keybinding that is common for interruption in terminal workflows should not unexpectedly destroy that session.&lt;/p&gt;

&lt;p&gt;Separating "interrupt what is happening" from "close the entire application" makes the interface much harder to use incorrectly.&lt;/p&gt;

&lt;h2&gt;
  
  
  Closing thoughts
&lt;/h2&gt;

&lt;p&gt;This is a tiny configuration change, but it removed one of the most annoying sources of accidental session loss for me.&lt;/p&gt;

&lt;p&gt;If you use OpenCode inside a terminal and frequently hit &lt;code&gt;Ctrl + C&lt;/code&gt; out of habit, remapping &lt;code&gt;app_exit&lt;/code&gt; is worth doing early.&lt;/p&gt;

</description>
      <category>productivity</category>
      <category>software</category>
      <category>tools</category>
    </item>
    <item>
      <title>Setting Up Japanese Input on Fedora KDE with Fcitx5 and Mozc</title>
      <dc:creator>loach</dc:creator>
      <pubDate>Thu, 10 Sep 2026 11:50:12 +0000</pubDate>
      <link>https://dev.to/loach2009/setting-up-japanese-input-on-fedora-kde-with-fcitx5-and-mozc-4gi3</link>
      <guid>https://dev.to/loach2009/setting-up-japanese-input-on-fedora-kde-with-fcitx5-and-mozc-4gi3</guid>
      <description>&lt;p&gt;After moving to Fedora KDE, one of the first things I wanted to fix was Japanese input.&lt;/p&gt;

&lt;p&gt;My goal was not only to install Mozc, but also to make the conversion keys feel closer to what I was used to on macOS:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;Henkan&lt;/code&gt; enables the IME&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;Muhenkan&lt;/code&gt; disables the IME&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;This article documents the setup I used with &lt;strong&gt;Fcitx5 + Mozc&lt;/strong&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Install Fcitx5 and Mozc
&lt;/h2&gt;

&lt;p&gt;On Fedora, install the required packages with:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;sudo &lt;/span&gt;dnf &lt;span class="nb"&gt;install&lt;/span&gt; &lt;span class="nt"&gt;-y&lt;/span&gt; fcitx5-mozc fcitx5-configtool
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Enable Fcitx5 in KDE
&lt;/h2&gt;

&lt;p&gt;Open KDE System Settings and go to the keyboard / virtual keyboard settings.&lt;/p&gt;

&lt;p&gt;Select Fcitx 5, or the Fcitx 5 Wayland launcher depending on your environment, and apply the change.&lt;/p&gt;

&lt;p&gt;After logging out and back in, Fcitx5 should start as your input method framework.&lt;/p&gt;

&lt;h2&gt;
  
  
  Add Mozc
&lt;/h2&gt;

&lt;p&gt;Open the Fcitx5 configuration tool:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;fcitx5-configtool
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Add Mozc to the list of available input methods.&lt;/p&gt;

&lt;p&gt;If the keyboard layout is detected incorrectly, verify that the underlying keyboard layout matches your physical keyboard before adjusting IME shortcuts. Otherwise, a shortcut problem can actually be a layout problem.&lt;/p&gt;

&lt;h2&gt;
  
  
  Mac-like conversion key behavior
&lt;/h2&gt;

&lt;p&gt;For Japanese keyboards, the &lt;code&gt;Henkan&lt;/code&gt; and &lt;code&gt;Muhenkan&lt;/code&gt; keys are convenient dedicated switches.&lt;/p&gt;

&lt;p&gt;I configured them so that:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;Henkan&lt;/code&gt; activates Japanese input&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;Muhenkan&lt;/code&gt; returns to direct input&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;This avoids using a single toggle key and makes the current IME state more predictable.&lt;/p&gt;

&lt;p&gt;That small change made Fedora much more comfortable for daily development because I no longer had to wonder whether the next keystroke would be interpreted as Japanese or ASCII.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why I prefer explicit on/off keys
&lt;/h2&gt;

&lt;p&gt;Toggle shortcuts are compact, but they depend on remembering the current state.&lt;/p&gt;

&lt;p&gt;Separate on/off shortcuts are more deterministic:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Henkan   -&amp;gt; IME ON
Muhenkan -&amp;gt; IME OFF
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;For programming, this is especially useful because switching from Japanese prose back to symbols, identifiers, and shell commands happens constantly.&lt;/p&gt;

&lt;h2&gt;
  
  
  Closing thoughts
&lt;/h2&gt;

&lt;p&gt;Fcitx5 + Mozc works well on Fedora KDE once the desktop integration and keyboard shortcuts are configured.&lt;/p&gt;

&lt;p&gt;The installation itself is simple; the quality-of-life improvement comes from tuning the input method to your keyboard habits.&lt;/p&gt;

&lt;p&gt;If you are moving from macOS or another environment where dedicated IME on/off keys are part of your muscle memory, mapping &lt;code&gt;Henkan&lt;/code&gt; and &lt;code&gt;Muhenkan&lt;/code&gt; explicitly is worth trying.&lt;/p&gt;

</description>
      <category>linux</category>
      <category>tools</category>
      <category>tutorial</category>
    </item>
    <item>
      <title>Keeping Translated OSS Documentation Fresh with an SSOT and CI</title>
      <dc:creator>loach</dc:creator>
      <pubDate>Thu, 10 Sep 2026 11:11:01 +0000</pubDate>
      <link>https://dev.to/loach2009/keeping-translated-oss-documentation-fresh-with-an-ssot-and-ci-3h61</link>
      <guid>https://dev.to/loach2009/keeping-translated-oss-documentation-fresh-with-an-ssot-and-ci-3h61</guid>
      <description>&lt;p&gt;Publishing an OSS project in multiple languages is useful, but translated documentation introduces a maintenance problem very quickly.&lt;/p&gt;

&lt;p&gt;Typical failure modes are familiar:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;the English README changes but the Japanese version does not&lt;/li&gt;
&lt;li&gt;nobody is sure which version is authoritative&lt;/li&gt;
&lt;li&gt;a translation gains content that never reaches the canonical document&lt;/li&gt;
&lt;li&gt;developers or AI tools read an outdated translation as if it were current design documentation&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;I ran into this while expanding the English documentation for &lt;strong&gt;PSXRecompStudio&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;GitHub: &lt;a href="https://github.com/mao2009/PSXRecompStudio" rel="noopener noreferrer"&gt;https://github.com/mao2009/PSXRecompStudio&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;My solution was to make the English documents the explicit &lt;strong&gt;canonical source / SSOT&lt;/strong&gt; and add CI checks that detect stale translations.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why translations need a source of truth
&lt;/h2&gt;

&lt;p&gt;Suppose a repository has:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;README.md
README.ja.md
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If both files are treated as equally authoritative, every edit creates a coordination problem.&lt;/p&gt;

&lt;p&gt;Instead, I use a simpler rule:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;English is canonical. Translations are derived documents.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;A translated document is still valuable for readers, but architectural and specification decisions ultimately refer back to the canonical file.&lt;/p&gt;

&lt;h2&gt;
  
  
  Store the relationship as data
&lt;/h2&gt;

&lt;p&gt;The canonical-to-translation mapping is kept in JSON rather than hard-coded into the CI script.&lt;/p&gt;

&lt;p&gt;For example:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"version"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"pairs"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"canonical"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"README.md"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"translation"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"README.ja.md"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"language"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"ja"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"canonicalMarker"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"[English README (Canonical / SSOT)](README.md)"&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This describes which file is authoritative, which file is its translation, the language, and which canonical marker should appear in the translation.&lt;/p&gt;

&lt;p&gt;As the documentation set grows, adding a new pair is a data change rather than a code change.&lt;/p&gt;

&lt;h2&gt;
  
  
  Make the canonical relationship visible to humans too
&lt;/h2&gt;

&lt;p&gt;The translated document should clearly say that it is a translation and link back to the canonical source.&lt;/p&gt;

&lt;p&gt;That helps in two ways:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;readers know which file wins when there is disagreement&lt;/li&gt;
&lt;li&gt;CI can verify that the relationship is still declared&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;This avoids a common documentation smell: two almost-identical files with no explicit indication of which one is authoritative.&lt;/p&gt;

&lt;h2&gt;
  
  
  Detect stale translations in CI
&lt;/h2&gt;

&lt;p&gt;The next step is to make freshness machine-checkable.&lt;/p&gt;

&lt;p&gt;A practical approach is to record which canonical revision a translation was last synchronized against. CI can then compare that information with the current canonical file.&lt;/p&gt;

&lt;p&gt;Conceptually:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;canonical changed
      ↓
translation metadata still points to old revision
      ↓
CI reports stale translation
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The exact implementation can vary. The important part is that freshness becomes a build-time property instead of something maintainers have to remember manually.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why this matters for AI-assisted development
&lt;/h2&gt;

&lt;p&gt;Outdated translations are especially dangerous when repositories are consumed by AI coding agents.&lt;/p&gt;

&lt;p&gt;A human may notice that a translated document looks old. An agent may simply treat the first relevant file it finds as the current specification.&lt;/p&gt;

&lt;p&gt;By declaring one SSOT and checking synchronization automatically, the repository gives both humans and tools a clearer hierarchy of trust.&lt;/p&gt;

&lt;h2&gt;
  
  
  Treat documentation like code
&lt;/h2&gt;

&lt;p&gt;The broader lesson is that documentation maintenance benefits from the same ideas we already use for source code:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;define ownership&lt;/li&gt;
&lt;li&gt;define the source of truth&lt;/li&gt;
&lt;li&gt;represent relationships in machine-readable form&lt;/li&gt;
&lt;li&gt;validate invariants in CI&lt;/li&gt;
&lt;li&gt;fail visibly when derived artifacts become stale&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Translations are derived artifacts, even when humans maintain them manually.&lt;/p&gt;

&lt;p&gt;Once I started treating them that way, the maintenance model became much clearer.&lt;/p&gt;

&lt;h2&gt;
  
  
  Closing thoughts
&lt;/h2&gt;

&lt;p&gt;Multilingual OSS documentation is valuable, but duplicating information without an explicit authority model creates ambiguity.&lt;/p&gt;

&lt;p&gt;Making one language canonical and validating translation freshness in CI gives the repository a predictable rule:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;translations may lag temporarily, but they cannot silently pretend to be current.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;That small distinction makes documentation easier to maintain and safer to consume.&lt;/p&gt;

</description>
    </item>
    <item>
      <title>Enforcing C# Architecture Rules at Compile Time with a Roslyn Analyzer</title>
      <dc:creator>loach</dc:creator>
      <pubDate>Thu, 10 Sep 2026 11:01:55 +0000</pubDate>
      <link>https://dev.to/loach2009/enforcing-c-architecture-rules-at-compile-time-with-a-roslyn-analyzer-4fbj</link>
      <guid>https://dev.to/loach2009/enforcing-c-architecture-rules-at-compile-time-with-a-roslyn-analyzer-4fbj</guid>
      <description>&lt;p&gt;When a C# project adopts layered architecture or Clean Architecture, we often define rules such as:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;the Domain layer must not depend on the Application layer&lt;/li&gt;
&lt;li&gt;the Domain layer must not call I/O APIs such as &lt;code&gt;Console&lt;/code&gt; or &lt;code&gt;File&lt;/code&gt; directly&lt;/li&gt;
&lt;li&gt;interop entry points must stay inside a specific boundary&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The problem is that rules written only in README files, ADRs, or design documents are still easy to violate.&lt;/p&gt;

&lt;p&gt;A review can miss a dependency. A temporary shortcut can become permanent. Over time, the implementation slowly drifts away from the architecture described in the documentation.&lt;/p&gt;

&lt;p&gt;To make these rules executable, I built &lt;strong&gt;ArchitectureAnalyzer&lt;/strong&gt;, a Roslyn Analyzer that validates architecture rules during normal C# compilation.&lt;/p&gt;

&lt;p&gt;GitHub: &lt;a href="https://github.com/mao2009/ArchitectureAnalyzer" rel="noopener noreferrer"&gt;https://github.com/mao2009/ArchitectureAnalyzer&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Project site: &lt;a href="https://mao2009.github.io/ArchitectureAnalyzer/" rel="noopener noreferrer"&gt;https://mao2009.github.io/ArchitectureAnalyzer/&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Define the architecture as data
&lt;/h2&gt;

&lt;p&gt;ArchitectureAnalyzer keeps project-specific rules outside the analyzer itself. They live in an &lt;code&gt;architecture.contract.json&lt;/code&gt; file.&lt;/p&gt;

&lt;p&gt;For example:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"layers"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"name"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Domain"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"namespaceRoots"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"MyApp.Domain"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"name"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Application"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"namespaceRoots"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"MyApp.Application"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"forbiddenDependencies"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"from"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Domain"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"to"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Application"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"reason"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Domain must not depend on the outer Application layer."&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;With this contract, any type below &lt;code&gt;MyApp.Domain&lt;/code&gt; is classified as Domain and any type below &lt;code&gt;MyApp.Application&lt;/code&gt; as Application.&lt;/p&gt;

&lt;p&gt;If Domain references Application, the build fails with a Roslyn diagnostic such as:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;error AARC002: 'MyApp.Domain.Order' (Domain) must not depend on
'MyApp.Application.OrderService' (Application):
Domain must not depend on the outer Application layer.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This means the compiler catches the violation before a human reviewer has to.&lt;/p&gt;

&lt;h2&gt;
  
  
  Forbidden APIs
&lt;/h2&gt;

&lt;p&gt;Architecture rules are not limited to layer-to-layer dependencies.&lt;/p&gt;

&lt;p&gt;You can also block specific APIs from a layer. For example, to keep console I/O out of Domain:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"forbiddenApis"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"layer"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Domain"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"type"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"System.Console"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"reason"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Console I/O must be abstracted behind an Infrastructure adapter."&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then code like this can be rejected at compile time:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="k"&gt;namespace&lt;/span&gt; &lt;span class="nn"&gt;MyApp.Domain&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;Order&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;void&lt;/span&gt; &lt;span class="nf"&gt;Print&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="n"&gt;Console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;WriteLine&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"Order"&lt;/span&gt;&lt;span class="p"&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;h2&gt;
  
  
  Why JSON instead of hard-coded rules?
&lt;/h2&gt;

&lt;p&gt;Different projects use different architectures. Hard-coding &lt;code&gt;Domain&lt;/code&gt;, &lt;code&gt;Application&lt;/code&gt;, and &lt;code&gt;Infrastructure&lt;/code&gt; into the analyzer would make it project-specific.&lt;/p&gt;

&lt;p&gt;Instead, ArchitectureAnalyzer follows a simple split:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;The analyzer implements the mechanism. The project defines the policy.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;That also means architecture rules can be reviewed and versioned in Git like source code.&lt;/p&gt;

&lt;h2&gt;
  
  
  Attribute-based layer declarations
&lt;/h2&gt;

&lt;p&gt;Version 0.1.0 also supports explicit layer markers using attributes.&lt;/p&gt;

&lt;p&gt;A contract can map attributes to layers:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"layerDeclaration"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"required"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"markerAttributes"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="nl"&gt;"attributeFqn"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"MyApp.Architecture.DomainAttribute"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="nl"&gt;"layer"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Domain"&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="nl"&gt;"attributeFqn"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"MyApp.Architecture.ApplicationAttribute"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="nl"&gt;"layer"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Application"&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"validateNamespaceConsistency"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"markerNamespace"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"MyApp.Architecture"&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then a type can declare its architectural role directly:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="k"&gt;using&lt;/span&gt; &lt;span class="nn"&gt;MyApp.Architecture&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;namespace&lt;/span&gt; &lt;span class="nn"&gt;MyApp.Domain&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;Domain&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
&lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;Order&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;When namespace and attribute declarations disagree, the analyzer can report the mismatch.&lt;/p&gt;

&lt;p&gt;This is useful when you want architecture to be visible both from project structure and directly in code.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why a Roslyn Analyzer?
&lt;/h2&gt;

&lt;p&gt;The same validation could be implemented as a custom CLI or CI script, but a Roslyn Analyzer has an important advantage: it becomes part of the normal development loop.&lt;/p&gt;

&lt;p&gt;You get diagnostics in the IDE, during &lt;code&gt;dotnet build&lt;/code&gt;, and in CI without teaching developers a separate command.&lt;/p&gt;

&lt;p&gt;Install the package and provide the contract as an additional file:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight xml"&gt;&lt;code&gt;&lt;span class="nt"&gt;&amp;lt;ItemGroup&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;PackageReference&lt;/span&gt; &lt;span class="na"&gt;Include=&lt;/span&gt;&lt;span class="s"&gt;"loach.ArchitectureAnalyzer"&lt;/span&gt;
                    &lt;span class="na"&gt;Version=&lt;/span&gt;&lt;span class="s"&gt;"0.1.0"&lt;/span&gt;
                    &lt;span class="na"&gt;PrivateAssets=&lt;/span&gt;&lt;span class="s"&gt;"all"&lt;/span&gt; &lt;span class="nt"&gt;/&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;AdditionalFiles&lt;/span&gt; &lt;span class="na"&gt;Include=&lt;/span&gt;&lt;span class="s"&gt;"architecture.contract.json"&lt;/span&gt; &lt;span class="nt"&gt;/&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;/ItemGroup&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then the normal build is enough:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;dotnet build
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The same command naturally becomes a CI architecture gate.&lt;/p&gt;

&lt;h2&gt;
  
  
  What it does not do
&lt;/h2&gt;

&lt;p&gt;ArchitectureAnalyzer does not decide whether your architecture is good.&lt;/p&gt;

&lt;p&gt;It only answers a narrower question:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Does the implementation still obey the architecture rules this project explicitly defined?&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Reflection, &lt;code&gt;dynamic&lt;/code&gt;, generated code that is excluded from analysis, and some indirect access patterns are outside the guarantees of a normal static analyzer.&lt;/p&gt;

&lt;p&gt;That limitation is intentional. The tool is designed to prevent architecture drift, not to replace architectural design.&lt;/p&gt;

&lt;h2&gt;
  
  
  Closing thoughts
&lt;/h2&gt;

&lt;p&gt;Architecture documentation is valuable, but documentation alone cannot stop code from drifting away from it.&lt;/p&gt;

&lt;p&gt;By moving the rules into a machine-readable contract and validating them during compilation, architecture becomes something the build can enforce rather than something reviewers merely have to remember.&lt;/p&gt;

&lt;p&gt;ArchitectureAnalyzer is open source, and the project is available here:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://github.com/mao2009/ArchitectureAnalyzer" rel="noopener noreferrer"&gt;https://github.com/mao2009/ArchitectureAnalyzer&lt;/a&gt;&lt;/p&gt;

</description>
      <category>architecture</category>
      <category>csharp</category>
      <category>dotnet</category>
      <category>softwareengineering</category>
    </item>
    <item>
      <title>I Built a Python Script to Cross-Post X Lists to Bluesky — x2b</title>
      <dc:creator>loach</dc:creator>
      <pubDate>Thu, 10 Sep 2026 10:56:55 +0000</pubDate>
      <link>https://dev.to/loach2009/i-built-a-python-script-to-cross-post-x-lists-to-bluesky-x2b-2c50</link>
      <guid>https://dev.to/loach2009/i-built-a-python-script-to-cross-post-x-lists-to-bluesky-x2b-2c50</guid>
      <description>&lt;h2&gt;
  
  
  Introduction
&lt;/h2&gt;

&lt;p&gt;Using both X and Bluesky means that useful information often ends up split across two timelines.&lt;/p&gt;

&lt;p&gt;In my case, I keep a curated X list for official accounts and news in a specific area, while I increasingly use Bluesky as my main social feed.&lt;/p&gt;

&lt;p&gt;So I built &lt;strong&gt;x2b&lt;/strong&gt;, a Python script that automatically cross-posts new posts from an X list to Bluesky.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://github.com/mao2009/x2b" rel="noopener noreferrer"&gt;https://github.com/mao2009/x2b&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;At first, I thought the job would be simple: fetch posts from X and send them to Bluesky.&lt;/p&gt;

&lt;p&gt;Once I started running it regularly, however, a lot of edge cases appeared:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;avoiding duplicate posts&lt;/li&gt;
&lt;li&gt;staying within Bluesky's text limit&lt;/li&gt;
&lt;li&gt;not breaking emoji or combined Unicode characters&lt;/li&gt;
&lt;li&gt;handling oversized OGP images&lt;/li&gt;
&lt;li&gt;retrying only temporary failures&lt;/li&gt;
&lt;li&gt;testing safely without publishing anything&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;This article is about how a small automation script gradually turned into something that can be operated more reliably.&lt;/p&gt;

&lt;h2&gt;
  
  
  What x2b Does
&lt;/h2&gt;

&lt;p&gt;The basic pipeline looks like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;X List
  ↓
Fetch posts
  ↓
Check whether each post was already processed
  ↓
Build Bluesky text
  ↓
Fetch OGP metadata and thumbnail
  ↓
Publish to Bluesky
  ↓
Persist processed post IDs
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;After setup, the normal command is simply:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;.venv/bin/python x2b.py
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Configuration is kept in &lt;code&gt;.env&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;BSKY_HANDLE=your-handle.bsky.social
BSKY_APP_PASSWORD=xxxx-xxxx-xxxx-xxxx
X_LIST_ID=1234567890
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Preventing Duplicate Posts
&lt;/h2&gt;

&lt;p&gt;A scheduled cross-poster needs to know which X posts it has already handled.&lt;/p&gt;

&lt;p&gt;x2b stores processed X post IDs in &lt;code&gt;seen.json&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;The important part is not only &lt;em&gt;what&lt;/em&gt; gets stored, but &lt;em&gt;when&lt;/em&gt; it gets stored.&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_seen&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;seen&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;post_id&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;seen&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;post_id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="nf"&gt;save_seen&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;seen&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A post is normally marked as seen only after a successful Bluesky post.&lt;/p&gt;

&lt;p&gt;If it were marked before publishing, a temporary Bluesky outage could cause the post to be skipped forever on the next run.&lt;/p&gt;

&lt;p&gt;On the other hand, known permanent failures can safely be marked as seen because replaying the exact same payload would produce the same result.&lt;/p&gt;

&lt;p&gt;So x2b does not treat every failure the same way.&lt;/p&gt;

&lt;h2&gt;
  
  
  300 Characters Is Not Just &lt;code&gt;len()&lt;/code&gt;
&lt;/h2&gt;

&lt;p&gt;Bluesky limits post text by grapheme count.&lt;/p&gt;

&lt;p&gt;That matters because Python's &lt;code&gt;len()&lt;/code&gt; does not always match what a user perceives as a single character. Emoji and combining sequences can consist of multiple code points while still appearing as one character.&lt;/p&gt;

&lt;p&gt;x2b uses the &lt;code&gt;grapheme&lt;/code&gt; package when available:&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;count_graphemes&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;text&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;grapheme&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;grapheme&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;length&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;text&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;len&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;text&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Truncation also happens on grapheme boundaries:&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;truncate_text_to_graphemes&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;text&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;max_graphemes&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;max_graphemes&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;=&lt;/span&gt; &lt;span class="mi"&gt;0&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="k"&gt;if&lt;/span&gt; &lt;span class="nf"&gt;count_graphemes&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;text&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;=&lt;/span&gt; &lt;span class="n"&gt;max_graphemes&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;text&lt;/span&gt;

    &lt;span class="n"&gt;keep&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;max_graphemes&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;

    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;grapheme&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;grapheme&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;slice&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;text&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="n"&gt;keep&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="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;text&lt;/span&gt;&lt;span class="p"&gt;[:&lt;/span&gt;&lt;span class="n"&gt;keep&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This reduces the risk of cutting an emoji or combined character in the middle.&lt;/p&gt;

&lt;p&gt;x2b also has to account for the prefix added before the original X text, such as the author name and handle.&lt;/p&gt;

&lt;p&gt;So the available body length is calculated from the final Bluesky limit:&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;available_length&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;BSKY_MAX_TEXT_GRAPHEMES&lt;/span&gt;
    &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;prefix_length&lt;/span&gt;
    &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;TEXT_LENGTH_MARGIN&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This is safer than blindly truncating the X text to a fixed number of characters.&lt;/p&gt;

&lt;h2&gt;
  
  
  OGP Failure Should Not Kill the Post
&lt;/h2&gt;

&lt;p&gt;When an X post contains a URL, x2b tries to build a Bluesky external embed using OGP metadata.&lt;/p&gt;

&lt;p&gt;But OGP data is external and unreliable by nature.&lt;/p&gt;

&lt;p&gt;A remote page may have:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;an unreachable image&lt;/li&gt;
&lt;li&gt;a timeout&lt;/li&gt;
&lt;li&gt;an unsupported or broken image&lt;/li&gt;
&lt;li&gt;a thumbnail larger than Bluesky allows&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The important design decision is that &lt;strong&gt;a thumbnail failure should not prevent the text itself from being posted&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;If the thumbnail is too large, x2b tries to resize it with Pillow. If that still fails, the post continues without the thumbnail.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Download thumbnail
  ↓
Within limit → use it
  ↓
Too large → try resizing
  ↓
Still unusable → continue without thumbnail
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This turned out to be essential for unattended operation.&lt;/p&gt;

&lt;h2&gt;
  
  
  Classifying API Errors
&lt;/h2&gt;

&lt;p&gt;An HTTP 500 response and an invalid payload are both failures, but they should not be handled the same way.&lt;/p&gt;

&lt;p&gt;x2b classifies errors into three groups:&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;class&lt;/span&gt; &lt;span class="nc"&gt;PermanentError&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;Exception&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="k"&gt;pass&lt;/span&gt;

&lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;TransientError&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;Exception&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="k"&gt;pass&lt;/span&gt;

&lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;UnknownError&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;Exception&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="k"&gt;pass&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  PermanentError
&lt;/h3&gt;

&lt;p&gt;Used for known failures that will not be fixed by resending the same payload, for example:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;text length violations&lt;/li&gt;
&lt;li&gt;payload-too-large errors&lt;/li&gt;
&lt;li&gt;known validation failures&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;These are not retried.&lt;/p&gt;

&lt;h3&gt;
  
  
  TransientError
&lt;/h3&gt;

&lt;p&gt;Used for failures that may succeed later:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;HTTP 429&lt;/li&gt;
&lt;li&gt;HTTP 5xx&lt;/li&gt;
&lt;li&gt;timeouts&lt;/li&gt;
&lt;li&gt;network failures&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Only these are retried, using exponential backoff:&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;delay&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;RETRY_BASE_DELAY&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt; &lt;span class="o"&gt;**&lt;/span&gt; &lt;span class="n"&gt;attempt&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;time&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;sleep&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;delay&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  UnknownError
&lt;/h3&gt;

&lt;p&gt;This category is intentionally conservative.&lt;/p&gt;

&lt;p&gt;If an error cannot be classified, x2b does &lt;strong&gt;not&lt;/strong&gt; silently assume it is permanent.&lt;/p&gt;

&lt;p&gt;Unknown failures are not retried immediately and are not marked as seen, so they become visible again on the next run and can be investigated.&lt;/p&gt;

&lt;p&gt;For automation, continuing to run is important, but silently losing work is worse.&lt;/p&gt;

&lt;h2&gt;
  
  
  Dry-Run Through the Real Pipeline
&lt;/h2&gt;

&lt;p&gt;Testing an auto-posting script is uncomfortable if every test publishes something to a real social account.&lt;/p&gt;

&lt;p&gt;So x2b supports:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;.venv/bin/python x2b.py &lt;span class="nt"&gt;--dry-run&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;In dry-run mode, it does not:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;publish to Bluesky&lt;/li&gt;
&lt;li&gt;upload blobs&lt;/li&gt;
&lt;li&gt;modify &lt;code&gt;seen.json&lt;/code&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;But it still executes the same pipeline for:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;fetching X posts&lt;/li&gt;
&lt;li&gt;filtering&lt;/li&gt;
&lt;li&gt;building Bluesky text&lt;/li&gt;
&lt;li&gt;grapheme validation&lt;/li&gt;
&lt;li&gt;OGP fetching&lt;/li&gt;
&lt;li&gt;thumbnail validation and resizing&lt;/li&gt;
&lt;li&gt;error classification&lt;/li&gt;
&lt;li&gt;retry paths&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The key idea is that dry-run disables only the side effects.&lt;/p&gt;

&lt;p&gt;It is not just an early return such as:&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;dry_run&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Instead, it runs as much of the production path as possible and reports what &lt;em&gt;would&lt;/em&gt; have been posted.&lt;/p&gt;

&lt;p&gt;This makes it much safer to check what a cron job will do before enabling real publishing.&lt;/p&gt;

&lt;h2&gt;
  
  
  Testing Without External Services
&lt;/h2&gt;

&lt;p&gt;x2b also includes pytest-based tests:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;pytest
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The test suite does not require a real Bluesky account, X API access, or external network access.&lt;/p&gt;

&lt;p&gt;The most important cases are the ones that could cause operational problems:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;no side effects in dry-run mode&lt;/li&gt;
&lt;li&gt;retries only for transient errors&lt;/li&gt;
&lt;li&gt;no retry loops for permanent errors&lt;/li&gt;
&lt;li&gt;unknown failures are not silently marked as seen&lt;/li&gt;
&lt;li&gt;oversized images are handled safely&lt;/li&gt;
&lt;li&gt;generated text remains within the grapheme limit&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;External APIs are exactly where a small script benefits from clearly separated boundaries.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I Learned
&lt;/h2&gt;

&lt;p&gt;Fetching a post from X and sending it to Bluesky is not particularly difficult by itself.&lt;/p&gt;

&lt;p&gt;The difficult part is everything around it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;What if fetching fails?
What if the same post appears again?
What if the text is too long?
What if it contains complex emoji?
What if the OGP image is huge?
What if Bluesky is temporarily unavailable?
What if the error is unknown?
How do I test it without publishing?
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Handling these cases one by one is what turned x2b from a one-off script into something closer to an operational tool.&lt;/p&gt;

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

&lt;p&gt;x2b started as a small personal utility for using X and Bluesky together.&lt;/p&gt;

&lt;p&gt;As it evolved, it ended up touching several concerns that are common to API-based automation in general: idempotency, Unicode handling, retries, external media, error classification, and side-effect isolation.&lt;/p&gt;

&lt;p&gt;If you are building a small Python automation tool of your own, I hope some of these design choices are useful.&lt;/p&gt;

&lt;p&gt;The source code is available on GitHub:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://github.com/mao2009/x2b" rel="noopener noreferrer"&gt;https://github.com/mao2009/x2b&lt;/a&gt;&lt;/p&gt;

</description>
      <category>automation</category>
      <category>github</category>
      <category>opensource</category>
      <category>python</category>
    </item>
  </channel>
</rss>
