<?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: Just a Side Project</title>
    <description>The latest articles on DEV Community by Just a Side Project (@just_a_side_project).</description>
    <link>https://dev.to/just_a_side_project</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%2F4063172%2F2d4a6757-a858-4445-a5c2-acf8d9ab9994.png</url>
      <title>DEV Community: Just a Side Project</title>
      <link>https://dev.to/just_a_side_project</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/just_a_side_project"/>
    <language>en</language>
    <item>
      <title>What's Actually Automated in an 'AI-Run' Blog, and What Isn't</title>
      <dc:creator>Just a Side Project</dc:creator>
      <pubDate>Tue, 15 Sep 2026 12:00:50 +0000</pubDate>
      <link>https://dev.to/just_a_side_project/whats-actually-automated-in-an-ai-run-blog-and-what-isnt-58c0</link>
      <guid>https://dev.to/just_a_side_project/whats-actually-automated-in-an-ai-run-blog-and-what-isnt-58c0</guid>
      <description>&lt;p&gt;I call this an automated blog, and technically that's true -- Windows Task Scheduler wakes a sleeping machine, runs a script, and a post goes live with zero human action, on a repeating schedule, unattended. What I haven't said explicitly anywhere in seventeen posts is how much of the surrounding infrastructure required a human sitting at a browser, clicking through screens no script could touch. It's time to draw the actual line.&lt;/p&gt;

&lt;h3&gt;What genuinely runs without me&lt;/h3&gt;

&lt;p&gt;The core loop is real automation, not a stretch of the word. A PowerShell task fires on a timer, waits for the network to actually be usable after waking from sleep, calls a Python script that posts to Blogger via its API, then cross-posts the same content to dev.to with the image handling and canonical URL set correctly. Separately, on demand rather than a schedule, I can ask for the current state of the blog -- post count, dev.to view counts, Google Search Console clicks and impressions, GA4 visitor numbers -- and get real numbers back from real APIs, no browser involved. All of that is exactly as automated as it sounds.&lt;/p&gt;

&lt;h3&gt;What actually required a human in a browser&lt;/h3&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;&lt;tr&gt;
&lt;th&gt;Task&lt;/th&gt;
&lt;th&gt;Why it couldn't be scripted&lt;/th&gt;
&lt;/tr&gt;&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Every first-time OAuth consent (Blogger, Search Console, GA4, dev.to key)&lt;/td&gt;
&lt;td&gt;Google requires an actual click on an actual "Allow" button in an actual browser -- that's the entire point of the flow, and rightly so&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Picking the correct Google account when two are logged into the same browser&lt;/td&gt;
&lt;td&gt;No API can tell a script which of several logged-in sessions a human intended; this caused three separate outages on this project&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Editing the Blogger theme's raw HTML (Prism.js, verification meta tags)&lt;/td&gt;
&lt;td&gt;The Blogger API v3 has no endpoint for template/theme content -- it's UI-only, full stop&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Registering with Google Search Console, Naver Search Advisor, and Bing Webmaster Tools&lt;/td&gt;
&lt;td&gt;Each is a one-time console flow behind its own login; none expose a public "register a new site" API&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Replying to a comment on dev.to&lt;/td&gt;
&lt;td&gt;dev.to's public API supports reading comments, not posting them -- confirmed directly against their API docs, not assumed&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h3&gt;The pattern behind every item on that list&lt;/h3&gt;

&lt;p&gt;None of these are missing because nobody's gotten around to building the feature. Every single one is a deliberate access boundary: a login screen, a consent flow, a UI-only editor, a read-only API. That's not a gap in this particular pipeline -- it's the shape of what "automatable" means for any project built on top of platforms other people control. A script can drive anything with a documented, authenticated, write-capable API. It cannot drive a human clicking "Allow," and every platform in this stack puts a human-click requirement somewhere on purpose, usually as a security boundary rather than an oversight.&lt;/p&gt;

&lt;h3&gt;What that means in practice&lt;/h3&gt;

&lt;p&gt;In practice it means this project has a permanent, small, recurring tax of screenshots and clicks that automation will never fully absorb -- not because the pipeline is unfinished, but because the boundary is structural. Every new integration this blog has picked up (Search Console, GA4, Naver, Bing) added one more one-time browser session to set up, even though every one of them runs unattended afterward. The automation compounds; the setup tax resets to zero effort per integration only after that integration exists, never before.&lt;/p&gt;

&lt;p&gt;The honest way to describe this project isn't "an AI runs this blog." It's closer to: a script handles everything that has an API and doesn't require proving I'm a specific human being, and a human handles the handful of moments where a platform specifically wants to know it's talking to a person. Both halves are real work. Only one of them shows up in the commit log.&lt;/p&gt;

&lt;h3&gt;Related reading&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://justasideproject.blogspot.com/2026/08/how-i-wired-up-fully-automated-cross.html" rel="noopener noreferrer"&gt;How I Wired Up Fully-Automated Cross-Posting Between Blogger and dev.to (With Working Code)&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://justasideproject.blogspot.com/2026/08/i-wired-google-search-console-into-my.html" rel="noopener noreferrer"&gt;I Wired Google Search Console Into My Publishing Pipeline and Immediately Hit Someone Else's Google Account&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>ai</category>
      <category>buildinpublic</category>
      <category>productivity</category>
    </item>
    <item>
      <title>Debug Log #2: A Reader Found a Bug in My Pipeline Before I Did</title>
      <dc:creator>Just a Side Project</dc:creator>
      <pubDate>Sat, 12 Sep 2026 12:15:29 +0000</pubDate>
      <link>https://dev.to/just_a_side_project/debug-log-2-a-reader-found-a-bug-in-my-pipeline-before-i-did-797</link>
      <guid>https://dev.to/just_a_side_project/debug-log-2-a-reader-found-a-bug-in-my-pipeline-before-i-did-797</guid>
      <description>&lt;p&gt;Three days after I published a post about hitting a wrong-account bug in my own Search Console integration, a stranger left a comment pointing out a second, deeper version of the same problem I hadn't noticed yet. Not a typo fix, not a "nice post" -- a specific, correct critique of the actual architecture of my error handling, from someone I'd never interacted with before. This is the first time in this whole project that "build in public" has produced something back.&lt;/p&gt;

&lt;h3&gt;What showed up in the comments&lt;/h3&gt;

&lt;p&gt;The comment came from a developer identifying himself as ten-plus years into a career, on the post about the Search Console wrong-account mixup. The core of it was one line: &lt;em&gt;"OAuth debugging is often an identity problem before it becomes an API problem."&lt;/em&gt; Underneath that framing were four concrete suggestions -- log the authorized account identity, token metadata, granted scopes, and expiration separately from application config; call &lt;code&gt;sites.list&lt;/code&gt; before querying to confirm the expected property actually exists for the authenticated account; add structured error classification and exponential backoff for 401/403/429/5xx; and build a small adapter layer to normalize date-range formats across Google APIs.&lt;/p&gt;

&lt;p&gt;None of it was vague. Every suggestion mapped to something specific enough that I could tell this person had actually read the post carefully, not skimmed it for a place to leave a generic comment.&lt;/p&gt;

&lt;h3&gt;Taking it seriously without taking it whole&lt;/h3&gt;

&lt;p&gt;The instinct when a stranger with more stated experience gives you a list of "you should do X" is to just do all of X. I didn't, and I think that instinct -- resisting wholesale adoption -- was the actually useful part of this exercise. Two of the four suggestions were clearly worth doing. Two weren't, for this project specifically, and the reasoning for each split matters more than the split itself.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Worth doing: identity and token logging, plus a pre-flight resource check.&lt;/strong&gt; This wasn't a hypothetical improvement -- it was a direct fix for a bug class I'd already been burned by three separate times on this exact project (Blogger, Search Console, and Google Analytics all silently authenticated as the wrong Google account at different points). Printing which account a token belongs to, and which resources it can actually see, before running a real query, converts a confusing 403 into an immediate, readable answer. Cheap to add, directly addressed a real recurring failure.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Not worth doing, for now: retry/backoff and a date-format adapter layer.&lt;/strong&gt; Exponential backoff and structured error classification are the right call for a service under real traffic, handling requests from users who notice when something silently fails. This is a script I run myself, by hand or on a timer, a handful of times a day. If a call fails, I see it immediately and rerun it -- there's no queue of angry users behind a failed request. Building retry infrastructure for that is solving a problem I don't have yet. Same logic for the date adapter: I've hit exactly one date-format mismatch across three separate Google API integrations so far. One occurrence doesn't justify an abstraction layer; the actual fix was two lines of &lt;code&gt;datetime&lt;/code&gt; math, and the next time it happens (if it happens again) I'll fix it the same way.&lt;/p&gt;

&lt;h3&gt;What I actually shipped&lt;/h3&gt;

&lt;p&gt;Same afternoon, I added a small identity-check function to each of the three scripts that authenticate against Google APIs -- Blogger, Search Console, and Analytics. Each one now prints the token's expiration and granted scopes, then does a lightweight pre-flight lookup (which blogs this account manages, which sites it can query, which GA4 properties it can see) before running anything real. If the expected resource isn't in that list, the script says so immediately instead of failing three steps later with an opaque permission error. Here's the actual function, from the Search Console script:&lt;/p&gt;

&lt;pre&gt;&lt;code&gt;def print_identity(creds, service) -&amp;gt; None:
    print(f"[인증 확인] 토큰 만료: {creds.expiry} / 권한 범위: {creds.scopes}")
    try:
        sites = service.sites().list().execute()
        urls = [s["siteUrl"] for s in sites.get("siteEntry", [])]
    except Exception:
        urls = []
    if SITE_URL.rstrip("/") in [u.rstrip("/") for u in urls]:
        print(f"[인증 확인] 대상 사이트 접근 가능 확인됨: {SITE_URL}")
    else:
        print(f"[인증 확인] 경고: 이 계정에서 접근 가능한 사이트 목록에 {SITE_URL}이 없음 -- 계정 확인 필요")
        print(f"[인증 확인] 접근 가능한 사이트: {urls}")&lt;/code&gt;&lt;/pre&gt;

&lt;p&gt;The Blogger and Analytics versions follow the same shape, adapted to whatever "list the resources this token can see" call each API exposes. Nine lines of code, and every account mixup since has surfaced as a printed warning before the real query even runs, instead of three steps later as a bare 403.&lt;/p&gt;

&lt;h3&gt;Replying in kind&lt;/h3&gt;

&lt;p&gt;I wrote a reply back in roughly the same register the comment arrived in -- short declarative sentences, specific about what changed, specific about what didn't and why, no filler thanking language stacked on top. It felt like the right way to close the loop: not just accepting the feedback, but showing the actual reasoning for the parts I kept and the parts I set aside, the same way the original comment showed its reasoning rather than just listing opinions.&lt;/p&gt;

&lt;h3&gt;Why this is the post I most wanted to write&lt;/h3&gt;

&lt;p&gt;Every other post in this series has been me finding my own bugs and writing them up afterward. This is the first time someone outside the project found something in it, said so specifically enough to be useful, and the result was a real commit rather than a vague "thanks for the feedback." That's the actual mechanism "build in public" is supposed to run on, and until this week it had only ever been theoretical for this blog.&lt;/p&gt;

&lt;h3&gt;Related reading&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://justasideproject.blogspot.com/2026/08/i-wired-google-search-console-into-my.html" rel="noopener noreferrer"&gt;I Wired Google Search Console Into My Publishing Pipeline and Immediately Hit Someone Else's Google Account&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://justasideproject.blogspot.com/2026/08/my-oauth-tokens-kept-expiring-every-7.html" rel="noopener noreferrer"&gt;My OAuth Tokens Kept Expiring Every 7 Days, and the Reason Was a Dropdown Labeled 'Testing'&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>ai</category>
      <category>buildinpublic</category>
      <category>productivity</category>
    </item>
    <item>
      <title>Three Weeks In: What the Real Numbers Say (Including the Ones I'd Rather Not Show You)</title>
      <dc:creator>Just a Side Project</dc:creator>
      <pubDate>Thu, 10 Sep 2026 12:00:07 +0000</pubDate>
      <link>https://dev.to/just_a_side_project/three-weeks-in-what-the-real-numbers-say-including-the-ones-id-rather-not-show-you-25f9</link>
      <guid>https://dev.to/just_a_side_project/three-weeks-in-what-the-real-numbers-say-including-the-ones-id-rather-not-show-you-25f9</guid>
      <description>&lt;p&gt;Twenty-one days ago I published the first post on this blog. Today I want to do something I haven't done yet in this series: stop telling individual bug stories and just show the actual numbers, unfiltered, including the ones that aren't flattering. No spin, no "just wait and see" -- here's exactly where things stand three weeks in.&lt;/p&gt;

&lt;h3&gt;The dev.to side: real, if modest, growth&lt;/h3&gt;

&lt;p&gt;Ten posts are live. Every one of them cross-posts automatically to dev.to a few seconds after Blogger publishes it. The view counts there are the closest thing this project has to an actual audience metric, since dev.to has its own built-in discovery separate from search engines entirely.&lt;/p&gt;

&lt;p&gt;The spread is wide -- from 0 to 34 -- and there's no clean story explaining why one post did better than another. The two highest performers (the cross-posting pipeline writeup and the free-tier comparison of Google Flow, Kling, and Hailuo) aren't obviously better written than the others; if anything they're just topics dev.to's audience happened to be more curious about that week. The lowest, sitting at zero, is a post about reading the fine print on free AI tools -- arguably decent advice, evidently not what anyone was looking for that day.&lt;/p&gt;

&lt;h3&gt;The search engine side: still basically silent&lt;/h3&gt;

&lt;p&gt;This is the part with nothing good to report yet. Google Search Console, checked fresh as of today, shows zero clicks and zero impressions over the last 28 days -- not low, zero, for every query, for the entire period this blog has existed. That's despite the site being registered, sitemap submitted, and individual URLs manually pushed through Search Console's indexing request tool weeks ago.&lt;/p&gt;

&lt;p&gt;Naver and Bing were registered more recently, so it's too early to expect anything from them yet. But Google alone has had close to three weeks, which is long enough that "just wait" is starting to feel less like patience and more like something worth watching closely rather than assuming will resolve itself.&lt;/p&gt;

&lt;h3&gt;The direct-traffic side: a couple of real humans&lt;/h3&gt;

&lt;p&gt;Google Analytics tells a slightly different story than Search Console, because it counts every visit regardless of where it came from -- direct links, dev.to referrals, anything. Over the last 7 days: 2 visitors, 6 pageviews. Small enough that it could plausibly be traffic I generated myself while testing things, and I don't have a clean way to rule that out from inside GA4's aggregate numbers alone.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;&lt;tr&gt;
&lt;th&gt;지표&lt;/th&gt;
&lt;th&gt;숫자&lt;/th&gt;
&lt;/tr&gt;&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;발행 글 수&lt;/td&gt;
&lt;td&gt;10편&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;dev.to 누적 조회수&lt;/td&gt;
&lt;td&gt;159회&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;구글 Search Console 클릭 / 노출 (28일)&lt;/td&gt;
&lt;td&gt;0 / 0&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;GA4 방문자 (최근 7일)&lt;/td&gt;
&lt;td&gt;2명&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;GA4 페이지뷰 (최근 7일)&lt;/td&gt;
&lt;td&gt;6회&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h3&gt;What these numbers actually tell me&lt;/h3&gt;

&lt;p&gt;Put together, the honest read is: dev.to is working as a small but real distribution channel on its own, completely independent of whether Google ever notices this blog exists. Google search, so far, has contributed nothing measurable. That's a genuinely useful thing to know at three weeks rather than three months, because it means the "SEO will pick up eventually" assumption I've been operating under is still unproven, not confirmed.&lt;/p&gt;

&lt;p&gt;I don't think that's a reason to change course yet -- three weeks is short for a domain with zero backlinks and zero prior history, and the registration work is done. But it is a reason to keep checking Search Console specifically rather than folding it into a vague sense that "traffic is growing," when the piece of traffic search engines are supposed to bring hasn't shown up at all.&lt;/p&gt;

&lt;h3&gt;Related reading&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://justasideproject.blogspot.com/2026/08/what-free-actually-means-across-multi.html" rel="noopener noreferrer"&gt;What 'Free' Actually Means Across a Multi-Service Google Stack&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://justasideproject.blogspot.com/2026/08/i-wired-google-search-console-into-my.html" rel="noopener noreferrer"&gt;I Wired Google Search Console Into My Publishing Pipeline and Immediately Hit Someone Else's Google Account&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>ai</category>
      <category>buildinpublic</category>
      <category>productivity</category>
    </item>
    <item>
      <title>Debug Log #1: I Fixed the Same Error Twice in One Afternoon, and It Still Wasn't Fixed</title>
      <dc:creator>Just a Side Project</dc:creator>
      <pubDate>Mon, 07 Sep 2026 12:00:48 +0000</pubDate>
      <link>https://dev.to/just_a_side_project/debug-log-1-i-fixed-the-same-error-twice-in-one-afternoon-and-it-still-wasnt-fixed-3bo1</link>
      <guid>https://dev.to/just_a_side_project/debug-log-1-i-fixed-the-same-error-twice-in-one-afternoon-and-it-still-wasnt-fixed-3bo1</guid>
      <description>&lt;p&gt;I fixed the exact same error twice in one afternoon, in the exact same place, and it still wasn't fixed. The first fix worked -- I could tell because the error message changed. What I hadn't clocked yet was that it had changed into a second, nearly identical error from a completely different API that happened to share half its name with the one I'd just dealt with.&lt;/p&gt;

&lt;h3&gt;Setting the stage&lt;/h3&gt;

&lt;p&gt;I was wiring up a script to pull real visitor numbers from Google Analytics (GA4) instead of checking the dashboard by hand -- the same pattern I'd already used for Blogger and Search Console. The script does two things: first it asks Google's Analytics &lt;em&gt;Admin&lt;/em&gt; API which GA4 property is connected to the account, then it asks the Analytics &lt;em&gt;Data&lt;/em&gt; API for the actual visitor numbers on that property. Two API calls, two lines apart in the code, using what I assumed was basically one product.&lt;/p&gt;

&lt;h3&gt;The first wall&lt;/h3&gt;

&lt;p&gt;The very first run failed before it got anywhere near real data:&lt;/p&gt;

&lt;pre&gt;&lt;code&gt;google.api_core.exceptions.PermissionDenied: 403 Google Analytics Admin API
has not been used in project 25469707117 before or it is disabled.&lt;/code&gt;&lt;/pre&gt;

&lt;p&gt;Straightforward enough -- a GCP project needs each API it calls individually switched on, the same as I'd already hit with Text-to-Speech and Blogger earlier in this project. I opened the Cloud Console, found "Google Analytics Admin API," clicked Enable, waited the requisite couple of minutes for it to propagate, and reran the script.&lt;/p&gt;

&lt;h3&gt;The second wall, wearing the first wall's clothes&lt;/h3&gt;

&lt;p&gt;The property lookup succeeded this time -- genuine progress, the script moved past the line that had failed before. Then it died one function call later, with what looked, at a glance, like the exact same error come back to life:&lt;/p&gt;

&lt;pre&gt;&lt;code&gt;google.api_core.exceptions.PermissionDenied: 403 Google Analytics Data API
has not been used in project 25469707117 before or it is disabled.&lt;/code&gt;&lt;/pre&gt;

&lt;p&gt;My first reaction was that the Enable click hadn't actually taken, or that Google's "wait a few minutes for it to propagate" note was doing more work than the couple of minutes I'd given it. It took an actual side-by-side read of both error messages to notice they name two different services: &lt;code&gt;analyticsadmin.googleapis.com&lt;/code&gt; the first time, &lt;code&gt;analyticsdata.googleapis.com&lt;/code&gt; the second. Not a retry of the same failure -- a second, separate permission gate, for a second, separate API, that Google happens to package under the same "Analytics" umbrella in its own documentation and even in the same Python library (&lt;code&gt;google-analytics-data&lt;/code&gt; and &lt;code&gt;google-analytics-admin&lt;/code&gt; ship as two distinct PyPI packages, which in hindsight was the tell I skimmed past).&lt;/p&gt;

&lt;h3&gt;Why this one is easy to misread&lt;/h3&gt;

&lt;p&gt;Admin and Data sound like two names for the same thing, and functionally, in this script, they're two halves of one task -- find the property, then read from it. But Google treats them as fully independent products with independent enablement, independent quotas, and independent line items in the API library. Nothing in the first error message hints that a second, differently-named API is waiting behind it. The only way to know is to actually read which service name is in the error, rather than pattern-matching "PermissionDenied, not enabled" to "the same thing I just fixed" and assuming a propagation delay.&lt;/p&gt;

&lt;h3&gt;The actual fix&lt;/h3&gt;

&lt;p&gt;Once I'd read the second message properly, the fix was identical in shape to the first: open the Cloud Console, search for "Google Analytics Data API" specifically, click Enable, wait, rerun. No code changed at all -- the script had been correct the entire time. Both walls were pure GCP configuration, and both were one click each. The whole detour, start to finish, was maybe fifteen minutes, but a good chunk of that was spent staring at a "the same error again?" screen before actually comparing the two messages character by character.&lt;/p&gt;

&lt;p&gt;If I'm setting up any Google API integration that touches more than one product under a shared brand name again, I'm reading the exact service string in the error before assuming a retry will fix it -- &lt;code&gt;analyticsadmin&lt;/code&gt; and &lt;code&gt;analyticsdata&lt;/code&gt; look close enough at a glance that I nearly debugged the wrong problem twice in a row.&lt;/p&gt;

&lt;h3&gt;Related reading&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://justasideproject.blogspot.com/2026/08/i-wired-google-search-console-into-my.html" rel="noopener noreferrer"&gt;I Wired Google Search Console Into My Publishing Pipeline and Immediately Hit Someone Else's Google Account&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://justasideproject.blogspot.com/2026/08/my-oauth-tokens-kept-expiring-every-7.html" rel="noopener noreferrer"&gt;My OAuth Tokens Kept Expiring Every 7 Days, and the Reason Was a Dropdown Labeled 'Testing'&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>ai</category>
      <category>buildinpublic</category>
      <category>productivity</category>
    </item>
    <item>
      <title>I Registered This Blog With Three Search Engines in One Afternoon (And Discovered IndexNow Doesn't Work on Blogger)</title>
      <dc:creator>Just a Side Project</dc:creator>
      <pubDate>Sat, 05 Sep 2026 12:16:42 +0000</pubDate>
      <link>https://dev.to/just_a_side_project/i-registered-this-blog-with-three-search-engines-in-one-afternoon-and-discovered-indexnow-doesnt-9i9</link>
      <guid>https://dev.to/just_a_side_project/i-registered-this-blog-with-three-search-engines-in-one-afternoon-and-discovered-indexnow-doesnt-9i9</guid>
      <description>&lt;p&gt;Two weeks after this blog went live, Google Search Console was still reporting exactly zero impressions -- not low, not slow, literally zero, for every single query, for the entire period. Typing the blog's own name into Google turned up nothing. That's normal for a two-week-old site with no backlinks, but it was still the push I needed to stop assuming Google alone would eventually notice this blog existed, and go register it everywhere I reasonably could in one sitting: Google, Naver, and Bing, back to back, on the same afternoon.&lt;/p&gt;

&lt;h3&gt;Three tools, three different ideas of "prove you own this"&lt;/h3&gt;

&lt;p&gt;All three services want the same basic thing -- confirm you control the site, then hand over a sitemap so their crawler doesn't have to discover every page by accident -- but each one insists on a different mechanic for the ownership check, and Blogger's specific limitations (no root file uploads, no server-side control) ruled some of them out before I even started.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;&lt;tr&gt;
&lt;th&gt;Service&lt;/th&gt;
&lt;th&gt;Verification method used&lt;/th&gt;
&lt;th&gt;Worked on Blogger?&lt;/th&gt;
&lt;/tr&gt;&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Google Search Console&lt;/td&gt;
&lt;td&gt;HTML meta tag in &amp;lt;head&amp;gt;&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Naver Search Advisor&lt;/td&gt;
&lt;td&gt;HTML meta tag in &amp;lt;head&amp;gt; (file-upload option explicitly unsupported on Blogger)&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Bing Webmaster Tools&lt;/td&gt;
&lt;td&gt;Imported directly from an already-verified Google Search Console property&lt;/td&gt;
&lt;td&gt;Yes -- no separate verification needed at all&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The Bing import turned out to be the pleasant surprise of the afternoon. Because Google Search Console was already set up, Bing offered to pull the verified property and its sitemap straight from Google's records, with no separate proof-of-ownership step. What I expected to be the same 10-minute dance a third time took closer to ninety seconds.&lt;/p&gt;

&lt;h3&gt;The one tool that flatly doesn't work on Blogger&lt;/h3&gt;

&lt;p&gt;Encouraged by how easy Bing was, I went looking for one more thing: IndexNow, a protocol Microsoft and a handful of other search engines use to let a site push "this URL just changed" notifications instead of waiting to be crawled. It sounded like exactly the kind of thing that fits an automated publishing pipeline -- one more API call at the end of &lt;code&gt;publish.py&lt;/code&gt;, and every new post would announce itself immediately instead of waiting for a crawler to stumble onto it.&lt;/p&gt;

&lt;p&gt;IndexNow's ownership proof is different from the other three: it requires hosting an actual text file, named after your API key, at the root of your domain -- something like &lt;code&gt;https://yourdomain.com/a1b2c3.txt&lt;/code&gt;. Not a meta tag, not an import. A real static file, at a real path, that the search engine fetches directly. Blogger has no mechanism for that. There's no file system, no root directory you can write to -- the entire platform is built around posts and pages served through Blogger's own templating, not arbitrary static files. Meta tags work because Blogger lets you edit the theme's HTML. A standalone file at the domain root is a different category of access that Blogger simply doesn't grant, to anyone, at any tier.&lt;/p&gt;

&lt;p&gt;The workaround that shows up in most guides is to host the key file somewhere else entirely -- GitHub Pages, Netlify, any static host -- and point IndexNow's &lt;code&gt;keyLocation&lt;/code&gt; parameter at that instead. It would technically work. It would also mean standing up and maintaining an entire second free-tier hosting account for the sole purpose of holding one text file, in a project whose whole premise has been staying inside a single Google-centric, zero-infrastructure footprint. That trade felt backwards, so I skipped it -- one manual URL submission per new post, across three dashboards, is a smaller cost than a permanent second hosting dependency for a feature that only saves a few days of crawl delay.&lt;/p&gt;

&lt;h3&gt;What actually changed by the end of the afternoon&lt;/h3&gt;

&lt;p&gt;Nothing showed up in search results that day -- registration and indexing are two different clocks, and only one of them moves fast. What did change is that all three services now know this blog exists and where its sitemap lives, which is the precondition for showing up at all rather than a guarantee of it. Naver in particular is worth being honest about: even fully registered, external blogs are generally understood to rank behind Naver's own blog platform in Naver's results, so "registered" here means "eligible to appear," not "will appear prominently."&lt;/p&gt;

&lt;p&gt;The concrete next check is whether Google's Search Console starts reporting a nonzero page count under "indexed," and whether Bing's dashboard shows the submitted URLs moving out of "pending." Both of those are just a matter of checking back in a week or two -- there was no more setup left to do that afternoon, which for a zero-budget, single-person pipeline felt like its own small milestone.&lt;/p&gt;

&lt;h3&gt;Related reading&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://justasideproject.blogspot.com/2026/08/i-wired-google-search-console-into-my.html" rel="noopener noreferrer"&gt;I Wired Google Search Console Into My Publishing Pipeline and Immediately Hit Someone Else's Google Account&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://justasideproject.blogspot.com/2026/08/the-silent-scheduled-task-failure.html" rel="noopener noreferrer"&gt;The Silent Scheduled-Task Failure Nobody Warns You About (And How I Caught It)&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>ai</category>
      <category>buildinpublic</category>
      <category>productivity</category>
    </item>
    <item>
      <title>dev.to Rejected My Cross-Post With a Cryptic Error, and the Cause Was a Picture</title>
      <dc:creator>Just a Side Project</dc:creator>
      <pubDate>Wed, 02 Sep 2026 23:36:39 +0000</pubDate>
      <link>https://dev.to/just_a_side_project/devto-rejected-my-cross-post-with-a-cryptic-error-and-the-cause-was-a-picture-1m3f</link>
      <guid>https://dev.to/just_a_side_project/devto-rejected-my-cross-post-with-a-cryptic-error-and-the-cause-was-a-picture-1m3f</guid>
      <description>&lt;p&gt;The Blogger post went out fine, like it always does. The dev.to cross-post that's supposed to follow it a few seconds later didn't. What I got back instead was &lt;code&gt;{"error":"Invalid markdown detected!","status":422}&lt;/code&gt; -- no line number, no hint about which part of the "markdown" was invalid, just a flat rejection. The strange part was that the exact same script had successfully cross-posted a dozen times before. Nothing about the pipeline had changed. Only the post had.&lt;/p&gt;

&lt;h3&gt;Ruling out the usual suspects&lt;/h3&gt;

&lt;p&gt;My first assumption was a broken quote or an unescaped character somewhere in the post text -- the kind of thing that trips up a naive markdown parser. I went through the HTML looking for stray backticks, unmatched asterisks, anything that could read as a formatting directive gone wrong. Nothing stood out. The post was written the same way as every other one: a handful of &lt;code&gt;&amp;lt;p&amp;gt;&lt;/code&gt; and &lt;code&gt;&amp;lt;h3&amp;gt;&lt;/code&gt; tags, one &lt;code&gt;&amp;lt;pre&amp;gt;&amp;lt;code&amp;gt;&lt;/code&gt; block. dev.to's API had swallowed posts with code blocks before without complaint.&lt;/p&gt;

&lt;p&gt;What was actually different about this post, and the thing I almost didn't think to check, was that it had a diagram in it -- a small flow chart I'd generated with a Python/Pillow script and embedded directly as a base64 &lt;code&gt;&amp;lt;img&amp;gt;&lt;/code&gt; tag, the same way I do for any post where a picture genuinely helps. Every other post in the cross-posting pipeline up to that point happened to be text-only, so this was the first time that code path had actually been exercised end to end.&lt;/p&gt;

&lt;h3&gt;Confirming it with the ugliest possible test&lt;/h3&gt;

&lt;p&gt;Rather than guess, I wrote a five-line script that posted the exact same payload directly against dev.to's API outside of my normal pipeline, so I could read the raw response instead of whatever my own error handling was hiding. Same 422, same message. Then I stripped just the &lt;code&gt;&amp;lt;img&amp;gt;&lt;/code&gt; tag out of the HTML by hand and re-ran it. It went through immediately. That was the whole bug: a single embedded image, nothing else about the post, was enough to make dev.to's markdown processor give up entirely.&lt;/p&gt;

&lt;p&gt;My best guess is that a base64 data URI is just an enormous single "word" from a markdown parser's point of view -- often tens of thousands of characters with no spaces -- and something about that length or character mix breaks whatever heuristics dev.to uses to detect front matter or malformed syntax. I don't have dev.to's parser source in front of me to confirm the exact mechanism, so I'll flag that part as a reasonable inference rather than a verified fact. What I do know for certain, because I reproduced it directly, is that the embedded image is what triggers the rejection.&lt;/p&gt;

&lt;h3&gt;The fix I didn't want to need&lt;/h3&gt;

&lt;p&gt;The annoying part is that removing the image isn't really a loss for the cross-post specifically. Every dev.to post already sets &lt;code&gt;canonical_url&lt;/code&gt; back to the original Blogger post, precisely so readers and search engines both know where the "real" version lives. dev.to was never meant to carry every visual element -- it's a mirror with a pointer home. So the fix was to strip base64 images out only for the copy that goes to dev.to, and leave the Blogger original untouched:&lt;/p&gt;

&lt;pre&gt;&lt;code&gt;import re

def _strip_base64_images(html: str) -&amp;gt; str:
    """dev.to's markdown parser rejects posts containing inline base64
    images with a generic 'Invalid markdown detected' error. The image
    still displays fine on the canonical (Blogger) post, so we only
    need to drop it from the copy sent to dev.to."""
    return re.sub(r'&amp;lt;img[^&amp;gt;]*src="​data:image[^"]*"[^&amp;gt;]*/?&amp;gt;', "", html)&lt;/code&gt;&lt;/pre&gt;

&lt;p&gt;One function, called right before the payload gets built, and every future post with a diagram in it cross-posts cleanly without me having to remember to do anything differently.&lt;/p&gt;

&lt;h3&gt;What made this one different from my other pipeline bugs&lt;/h3&gt;

&lt;p&gt;Most of the pipeline failures I've hit so far in this project have been about authentication -- the wrong Google account, an expired token, a permission that quietly vanished. This one was refreshing in a boring way: no account confusion, no silent failure, just a specific input shape that a specific API doesn't like, caught on the very first real attempt to send it. The error message was useless on its own, but the failure was 100% reproducible the moment I isolated the variable, which made it one of the faster bugs in this whole project to actually pin down once I stopped assuming it was the same class of problem as everything else that had gone wrong that week.&lt;/p&gt;

&lt;h3&gt;Related reading&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://justasideproject.blogspot.com/2026/08/how-i-wired-up-fully-automated-cross.html" rel="noopener noreferrer"&gt;How I Wired Up Fully-Automated Cross-Posting Between Blogger and dev.to (With Working Code)&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://justasideproject.blogspot.com/2026/08/the-silent-scheduled-task-failure.html" rel="noopener noreferrer"&gt;The Silent Scheduled-Task Failure Nobody Warns You About (And How I Caught It)&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>api</category>
      <category>debugging</category>
      <category>buildinpublic</category>
    </item>
    <item>
      <title>I Wired Google Search Console Into My Publishing Pipeline and Immediately Hit Someone Else's Google Account</title>
      <dc:creator>Just a Side Project</dc:creator>
      <pubDate>Mon, 31 Aug 2026 12:01:33 +0000</pubDate>
      <link>https://dev.to/just_a_side_project/i-wired-google-search-console-into-my-publishing-pipeline-and-immediately-hit-someone-elses-google-3733</link>
      <guid>https://dev.to/just_a_side_project/i-wired-google-search-console-into-my-publishing-pipeline-and-immediately-hit-someone-elses-google-3733</guid>
      <description>&lt;p&gt;I'd been checking my blog's search traffic by hand -- log into Search Console, click the property, read two numbers, close the tab -- often enough that wiring it into a script felt like an obvious next step. What I didn't expect was to spend the first ten minutes of that "obvious next step" locked out of my own property by a Google account I'd forgotten I was signed into.&lt;/p&gt;

&lt;h3&gt;Why I wanted this as a script instead of a habit&lt;/h3&gt;

&lt;p&gt;Everything else in this project already reports on itself through small Python scripts -- how many posts are live, how many views each one has on dev.to. Search Console was the one piece still living entirely in a browser tab, which meant it was also the one piece I sometimes forgot to check. Wrapping it in the same pattern as the others -- run a script, read three numbers -- was less about saving clicks and more about making it something I'd actually keep doing.&lt;/p&gt;

&lt;p&gt;The Search Console API needs its own OAuth scope (&lt;code&gt;webmasters.readonly&lt;/code&gt;) and its own token file, separate from the one Blogger already uses, but otherwise reuses the same client secret and the same installed-app-flow pattern I'd already built. I expected this to be the fast, boring part of the afternoon.&lt;/p&gt;

&lt;h3&gt;The first run&lt;/h3&gt;

&lt;p&gt;The browser consent screen popped up like it always does. I had two Google accounts signed in on that machine -- a main one and a very similarly named second one I'd created ages ago for something unrelated -- and picked one without thinking too hard about which. The script ran, called &lt;code&gt;sites().list()&lt;/code&gt; to confirm which properties it could see, and came back empty. Querying the actual site URL directly returned a 403: &lt;code&gt;User does not have sufficient permission&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;My first assumption was a scope problem -- maybe &lt;code&gt;webmasters.readonly&lt;/code&gt; wasn't actually enough, or maybe I'd fat-fingered the site URL format (Search Console is picky about the trailing slash and about domain-property vs URL-prefix-property matching). Neither was it. The account that had just clicked "Allow" simply wasn't the account that owned the property.&lt;/p&gt;

&lt;h3&gt;Finding out which account actually owned it&lt;/h3&gt;

&lt;p&gt;The two email addresses differ by a single character -- one has a trailing digit the other doesn't -- which is exactly the kind of near-miss that's invisible when you're clicking through a consent screen on autopilot. To sort it out, I went to Blogger directly and opened the small account-switcher in the corner, the one that lists every Google account currently signed in on that browser. One of the two was marked "Default," and that was the one actually attached to the blog. Re-running the OAuth flow and deliberately picking that account fixed the 403 immediately -- and, on the very next attempt, I picked wrong again out of habit and had to redo it a second time before it stuck.&lt;/p&gt;

&lt;p&gt;Nothing about the API error mentioned accounts at all -- a 403 for insufficient permission looks identical whether the real problem is a missing scope, a missing role, or, like here, an entirely different human identity behind the token. Worth checking before anything else if a Google API call fails with a permissions error on a property you're certain you own: which account actually clicked "Allow."&lt;/p&gt;

&lt;h3&gt;A second, smaller bug once the account was right&lt;/h3&gt;

&lt;p&gt;With the right account authenticated, the summary query still failed, this time with a much more specific complaint: &lt;code&gt;HttpError 400: '28daysAgo' is not a valid date string. Please use YYYY-MM-DD format.&lt;/code&gt; I'd copied the date-range pattern straight from how I already query Analytics, where relative strings like &lt;code&gt;"28daysAgo"&lt;/code&gt; or &lt;code&gt;"today"&lt;/code&gt; are first-class citizens. The Search Console API doesn't support that convenience at all -- it wants literal ISO dates, computed by the caller.&lt;/p&gt;

&lt;p&gt;The fix was a couple of lines with &lt;code&gt;datetime&lt;/code&gt;:&lt;/p&gt;

&lt;pre&gt;&lt;code&gt;from datetime import date, timedelta

END_DATE = date.today().isoformat()
START_DATE = (date.today() - timedelta(days=28)).isoformat()

summary = service.searchanalytics().query(
    siteUrl=SITE_URL,
    body={"startDate": START_DATE, "endDate": END_DATE, "dimensions": []},
).execute()&lt;/code&gt;&lt;/pre&gt;

&lt;p&gt;Small fix, but it's the kind of thing that's easy to assume by analogy from a sibling API and only discover is wrong when the error message spells it out.&lt;/p&gt;

&lt;h3&gt;What it actually reports&lt;/h3&gt;

&lt;p&gt;With both bugs out of the way, the script runs cleanly and prints the same numbers I'd have gotten clicking through the UI: clicks, impressions, average CTR, and average position over the trailing 28 days, plus optional breakdowns by page and by query when there's enough data to break down. Right now those numbers are still close to zero -- the site is new enough that Google hasn't sent it meaningful search traffic yet -- so the script isn't reporting good news so much as it's reporting &lt;em&gt;the same, real, unflattering number&lt;/em&gt; without me having to remember to go look for it.&lt;/p&gt;

&lt;h3&gt;The actual lesson&lt;/h3&gt;

&lt;p&gt;None of this was a hard bug in the sense of needing deep debugging -- both fixes were small once identified. The time sink was entirely in misdiagnosing a permissions error as a technical one when it was really a "which human is this token pretending to be" problem. Any time an OAuth-based script throws a 403 on a resource you're sure you have access to, checking which account actually granted consent is now the first thing I do, before touching scopes, roles, or API documentation at all.&lt;/p&gt;

&lt;h3&gt;Related reading&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://justasideproject.blogspot.com/2026/08/my-oauth-tokens-kept-expiring-every-7.html" rel="noopener noreferrer"&gt;My OAuth Tokens Kept Expiring Every 7 Days, and the Reason Was a Dropdown Labeled 'Testing'&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://justasideproject.blogspot.com/2026/08/the-silent-scheduled-task-failure.html" rel="noopener noreferrer"&gt;The Silent Scheduled-Task Failure Nobody Warns You About (And How I Caught It)&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>googlecloud</category>
      <category>api</category>
      <category>seo</category>
    </item>
    <item>
      <title>My OAuth Tokens Kept Expiring Every 7 Days, and the Reason Was a Dropdown Labeled 'Testing'</title>
      <dc:creator>Just a Side Project</dc:creator>
      <pubDate>Fri, 28 Aug 2026 12:02:01 +0000</pubDate>
      <link>https://dev.to/just_a_side_project/my-oauth-tokens-kept-expiring-every-7-days-and-the-reason-was-a-dropdown-labeled-testing-47ni</link>
      <guid>https://dev.to/just_a_side_project/my-oauth-tokens-kept-expiring-every-7-days-and-the-reason-was-a-dropdown-labeled-testing-47ni</guid>
      <description>&lt;p&gt;Three weeks into running my publishing pipeline on autopilot, it just stopped. Not with a crash -- with the exact same error I'd already spent a week debugging and fixing once before: &lt;code&gt;invalid_grant: Token has been expired or revoked&lt;/code&gt;. Same message, same script, same working setup. I assumed I'd broken my own fix. I hadn't. This was a completely different bug wearing the same disguise, and it had been sitting there from day one, counting down.&lt;/p&gt;

&lt;h3&gt;The first time I saw this error&lt;/h3&gt;

&lt;p&gt;The first time &lt;code&gt;invalid_grant&lt;/code&gt; showed up, I'd just spent days chasing a silent scheduled-publish failure that turned out to be a PowerShell file encoding bug. Once that was fixed and confirmed working for a couple of runs, I filed the OAuth error away as "collateral damage from the encoding mess" and moved on. When it came back three weeks later, with the encoding fix still verified working, that theory stopped holding up.&lt;/p&gt;

&lt;p&gt;My setup uses the OAuth installed-app flow: run the script once, a browser tab opens, I click through my own Google account's consent screen, and a refresh token gets cached to a local JSON file. Every run after that is supposed to be silent -- the script uses the refresh token to mint a fresh access token automatically, no browser, no human. That's what makes it usable for something a scheduled task fires at 9pm while nobody's watching.&lt;/p&gt;

&lt;h3&gt;Ruling things out&lt;/h3&gt;

&lt;p&gt;I checked the obvious suspects first. The Google account's security page showed no manual revocation, no "you signed out of this app" notice, nothing in the activity log around the time it broke. The token file itself was untouched -- same modification date as when it was first created. The client secret hadn't changed. The script hadn't changed. And critically, it had worked fine for roughly a week after the last real fix, then died on a schedule that didn't line up with anything I'd done.&lt;/p&gt;

&lt;p&gt;That "roughly a week" detail turned out to be the whole answer, and I almost talked myself out of taking it seriously because it seemed too tidy to be a real pattern rather than a coincidence.&lt;/p&gt;

&lt;h3&gt;The setting I'd never touched&lt;/h3&gt;

&lt;p&gt;Every OAuth app in Google Cloud has a consent screen with a &lt;strong&gt;Publishing status&lt;/strong&gt;, managed under what Google now calls the Google Auth Platform, in the Audience tab. There are two states: &lt;strong&gt;Testing&lt;/strong&gt; and &lt;strong&gt;In production&lt;/strong&gt;. I'd never touched this setting because nothing in the initial setup flow flagged it as something that mattered -- I clicked through the defaults to get my client secret and started writing code.&lt;/p&gt;

&lt;p&gt;The default is Testing. And in Testing, Google caps refresh tokens at &lt;strong&gt;7 days&lt;/strong&gt;, regardless of how often the app actually uses them. It doesn't matter that my script was calling the API daily, keeping the token "warm" in any normal sense of the word -- Testing mode expires the token on a fixed clock, not on an inactivity timer. The publishing status isn't about code review or app quality; it's a literal switch that determines whether Google treats your app as a short-lived experiment or a real, ongoing thing.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;&lt;tr&gt;
&lt;th&gt;Publishing status&lt;/th&gt;
&lt;th&gt;Refresh token lifetime&lt;/th&gt;
&lt;th&gt;Who can authenticate&lt;/th&gt;
&lt;/tr&gt;&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Testing (default)&lt;/td&gt;
&lt;td&gt;Fixed 7 days, no matter how active&lt;/td&gt;
&lt;td&gt;Only accounts added as test users&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;In production&lt;/td&gt;
&lt;td&gt;Long-lived, standard OAuth behavior&lt;/td&gt;
&lt;td&gt;Any Google account&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;For a project with sensitive or restricted scopes, moving to In production can trigger Google's verification review process. Mine only touches Blogger and Search Console data under my own account, which falls under the kind of low-risk scope that doesn't require that review -- switching the status was a one-click "Publish app" action, not a submission-and-wait process.&lt;/p&gt;

&lt;h3&gt;The fix, and the question right after it&lt;/h3&gt;

&lt;p&gt;I re-authenticated once more to get a fresh token, then switched Publishing status to In production. That was the actual fix. Before doing it, the obvious question was whether this touches billing at all -- it doesn't. Publishing status is an identity/consent configuration on an OAuth client, completely separate from any Google Cloud API usage or pricing. Flipping it costs nothing and doesn't enable any paid product on its own.&lt;/p&gt;

&lt;p&gt;Since making that change, the token has survived multiple scheduled runs well past the old 7-day mark without needing to be touched again.&lt;/p&gt;

&lt;h3&gt;The defensive layer I added anyway&lt;/h3&gt;

&lt;p&gt;Fixing the root cause didn't stop me from also making the failure mode less silent, because "the script just stops running with no notification" is a bad default regardless of what caused it this time. I updated the credential-loading function to catch a refresh failure explicitly instead of letting it propagate as an unhandled crash, and fall back to interactive re-auth:&lt;/p&gt;

&lt;pre&gt;&lt;code&gt;from google.auth.exceptions import RefreshError
from google.auth.transport.requests import Request

def get_credentials():
    creds = None
    if TOKEN_FILE.exists():
        creds = Credentials.from_authorized_user_file(str(TOKEN_FILE), SCOPES)
    if not creds or not creds.valid:
        refreshed = False
        if creds and creds.expired and creds.refresh_token:
            try:
                creds.refresh(Request())
                refreshed = True
            except RefreshError:
                refreshed = False
        if not refreshed:
            flow = InstalledAppFlow.from_client_secrets_file(str(CLIENT_SECRET_FILE), SCOPES)
            creds = flow.run_local_server(port=0)
        TOKEN_FILE.write_text(creds.to_json(), encoding="utf-8")
    return creds&lt;/code&gt;&lt;/pre&gt;

&lt;p&gt;This doesn't fix an expired-token problem by itself -- if a scheduled task with no browser hits the interactive fallback, it'll still fail, just with a clearer stack trace in the log instead of a bare &lt;code&gt;invalid_grant&lt;/code&gt;. What it buys me is that the next time something in this pipeline breaks for a genuinely new reason, I'll be able to tell the difference between "token expired, needs a human to click through consent once" and something else, instead of staring at the same cryptic error and assuming I already fixed it.&lt;/p&gt;

&lt;h3&gt;What I'd check first next time&lt;/h3&gt;

&lt;p&gt;If I were setting up any personal OAuth automation script again, the Publishing status is now the very first thing I'd check, before writing a single line of the actual integration. It's easy to miss because nothing about the initial "create OAuth client, download client secret" flow calls it out as consequential, and a script that authenticates fine on day one gives no visual signal that a 7-day clock just started. The bug doesn't announce itself until the exact same failure you already fixed once shows back up, wearing the same error message, for a completely unrelated reason.&lt;/p&gt;

&lt;h3&gt;Related reading&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://justasideproject.blogspot.com/2026/08/the-silent-scheduled-task-failure.html" rel="noopener noreferrer"&gt;The Silent Scheduled-Task Failure Nobody Warns You About (And How I Caught It)&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://justasideproject.blogspot.com/2026/08/i-wired-google-search-console-into-my.html" rel="noopener noreferrer"&gt;I Wired Google Search Console Into My Publishing Pipeline and Immediately Hit Someone Else's Google Account&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>oauth</category>
      <category>googlecloud</category>
      <category>api</category>
    </item>
    <item>
      <title>I Checked Google's Own Billing Console So You Don't Have To: The Real Text-to-Speech Free Tier, Voice by Voice</title>
      <dc:creator>Just a Side Project</dc:creator>
      <pubDate>Wed, 26 Aug 2026 12:01:33 +0000</pubDate>
      <link>https://dev.to/just_a_side_project/i-checked-googles-own-billing-console-so-you-dont-have-to-the-real-text-to-speech-free-tier-2l0m</link>
      <guid>https://dev.to/just_a_side_project/i-checked-googles-own-billing-console-so-you-dont-have-to-the-real-text-to-speech-free-tier-2l0m</guid>
      <description>&lt;p&gt;I went looking for one number -- how many characters of WaveNet audio I get for free every month on Google Cloud Text-to-Speech -- and found two sources that flatly contradicted each other. One search result, synthesized from several pricing-comparison sites, said the free tier was alive and generous: 4M Standard characters, 1M WaveNet, 1M Neural2, stacking every month. A second source, fetched directly from a different pricing-aggregator page, said the opposite in plain language: "Google Cloud Text-to-Speech does not offer a free plan as of August 2026." Same product, same month, opposite claims. Neither of those sites is Google. So I stopped trusting either one and went to check the only source that could actually settle it -- my own billing account.&lt;/p&gt;

&lt;h3&gt;Where the real numbers live&lt;/h3&gt;

&lt;p&gt;Google Cloud's console has a page most people never open on purpose: &lt;strong&gt;Billing → Pricing&lt;/strong&gt;, which lists every SKU tied to your billing account along with its tiered pricing, filterable by service. It's not a marketing page -- it's the same data that generates your actual invoice, last updated (as of this writing) the same day I checked it. Filtering to "Text-to-Speech" surfaced 21 separate SKUs, because "free tier" isn't one number at all; it's a distinct allowance per voice model, each with its own reset and its own price once you're past it.&lt;/p&gt;

&lt;h3&gt;The actual numbers, per voice type&lt;/h3&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;&lt;tr&gt;
&lt;th&gt;Voice type&lt;/th&gt;
&lt;th&gt;Free tier (characters/month)&lt;/th&gt;
&lt;th&gt;Price after free tier (per 1M chars)&lt;/th&gt;
&lt;/tr&gt;&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Standard&lt;/td&gt;
&lt;td&gt;4,000,000&lt;/td&gt;
&lt;td&gt;₩5,806.96&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;WaveNet&lt;/td&gt;
&lt;td&gt;1,000,000&lt;/td&gt;
&lt;td&gt;₩23,227.84&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Chirp3-HD&lt;/td&gt;
&lt;td&gt;1,000,000&lt;/td&gt;
&lt;td&gt;₩43,552.20&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Studio&lt;/td&gt;
&lt;td&gt;1,000,000&lt;/td&gt;
&lt;td&gt;₩232,278.40&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;So: the "no free tier" claim was wrong, at least for my account, on this date. Standard voices in particular are more generous than either source I'd read suggested -- the free allowance runs to 4 million characters before any charge kicks in, not 1 million. WaveNet, Chirp3-HD, and Studio each get their own separate 1M-character allowance, and -- this is easy to miss -- those tiers appear to stack rather than share a pool, since each showed up as an independent SKU with its own "0 to 1,000,000 = free" row.&lt;/p&gt;

&lt;h3&gt;The one voice type that's quietly gone&lt;/h3&gt;

&lt;p&gt;I went in expecting to find Neural2 in this list, because it's the voice tier most tutorials still reference. It isn't there. What &lt;em&gt;is&lt;/em&gt; there instead is a set of Gemini 2.5 and Gemini 3 Flash/Pro TTS SKUs, billed by input and output &lt;em&gt;tokens&lt;/em&gt; rather than by character count. I can't tell you with confidence whether that's a straight rename, a pricing-model change, or Neural2 being phased out in favor of Gemini-branded voices -- I'm reporting what my console actually shows, not what I assume happened between whatever documentation I'd half-remembered and today. If your project specifically depends on Neural2 pricing, this is exactly the kind of thing to check on your own billing account before you build around a number you read somewhere else, including this post.&lt;/p&gt;

&lt;h3&gt;Why I'm not just linking you the pricing page instead&lt;/h3&gt;

&lt;p&gt;I could have sent you to &lt;code&gt;cloud.google.com/text-to-speech/pricing&lt;/code&gt; and called it done, and normally that's the right move. The reason I didn't fully trust it as my only source here is that fetching it directly returned a truncated page with no pricing table content at all -- which is itself a useful data point. A pricing page that won't reliably render for an automated fetch is a pricing page you shouldn't take a single confident read of, from any source, without a second check. The billing console's Pricing tab turned out to be the more reliable source specifically because it isn't a marketing page trying to load ads and comparison widgets around the numbers -- it's a plain filtered table, tied to your actual account, generated the same way your invoice is.&lt;/p&gt;

&lt;h3&gt;If you want to check your own account&lt;/h3&gt;

&lt;p&gt;Console → Billing → Pricing → toggle to "view all SKUs" → filter by service description for whatever API you're relying on. It takes about two minutes once you know the page exists, and it settles arguments that a dozen search results won't.&lt;/p&gt;

&lt;h3&gt;Related reading&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://justasideproject.blogspot.com/2026/08/googles-free-tiers-compared-cloud-tts.html" rel="noopener noreferrer"&gt;Google's Free Tiers Compared: Cloud TTS vs. Translate vs. Flow&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://justasideproject.blogspot.com/2026/08/what-free-actually-means-across-multi.html" rel="noopener noreferrer"&gt;What 'Free' Actually Means Across a Multi-Service Google Stack&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>googlecloud</category>
      <category>ai</category>
      <category>buildinpublic</category>
    </item>
    <item>
      <title>What 'Free' Actually Means Across a Multi-Service Google Stack</title>
      <dc:creator>Just a Side Project</dc:creator>
      <pubDate>Mon, 24 Aug 2026 12:00:30 +0000</pubDate>
      <link>https://dev.to/just_a_side_project/what-free-actually-means-across-a-multi-service-google-stack-349m</link>
      <guid>https://dev.to/just_a_side_project/what-free-actually-means-across-a-multi-service-google-stack-349m</guid>
      <description>&lt;p&gt;I want to be upfront about something before this post goes any further: I don't have a clean dollar figure to show you. I went looking for one -- I wanted to open the Google Cloud billing console, screenshot a $0.00 total, and call it proof. What I found instead was that "free" across a multi-service Google stack isn't one number at all. It's a pile of separate ceilings, each with its own reset clock, its own fine print, and in at least one case, a wall that had nothing to do with usage limits and everything to do with a policy I didn't know existed. This is the more honest version of that post -- what "free" actually meant, service by service, including the parts that cost me time instead of money.&lt;/p&gt;

&lt;h3&gt;The free tiers that behaved exactly like the docs said&lt;/h3&gt;

&lt;p&gt;Two services in this pipeline were boring in the best way -- I read the limit once, stayed under it, and never thought about it again.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Blogger API v3&lt;/strong&gt; has no meaningful quota for a single-author blog. It's a legacy Google product that's still maintained, and nothing about posting, updating, or publishing through the API has a cost attached at this scale.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;dev.to's API&lt;/strong&gt; is free for posting and reading, full stop, with no billing account involved at all -- refreshing, after spending so much time inside Google Cloud's console.&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;The free tier that has more than one number in it&lt;/h3&gt;

&lt;p&gt;Google Cloud Text-to-Speech looks like a single "free tier" on the pricing page, but it's actually several different ceilings stacked depending on which voice model you use -- Standard, WaveNet, and Neural2 voices are metered separately, and the Neural2 allowance is meaningfully smaller than the other two. I'd recommend checking the current numbers directly on Google's pricing page before you plan a project around them, since free-tier terms are exactly the kind of thing that gets revised without much fanfare -- but the shape of it, at least when I set this up, was: generous character allowances on the older voice models, a noticeably tighter one on the newest, most natural-sounding tier. If your project leans on the nicest-sounding voices specifically, that's the number to actually watch, not the headline "free tier" claim.&lt;/p&gt;

&lt;h3&gt;The one that wasn't a usage limit at all&lt;/h3&gt;

&lt;p&gt;The most expensive part of this build, in time if not in dollars, wasn't a quota. It was an organization policy -- &lt;code&gt;iam.disableServiceAccountKeyCreation&lt;/code&gt; -- silently blocking me from generating a service account key on a project I owned outright, because my Google account happened to sit inside an org where that constraint defaults to on. Nothing about that shows up on a pricing page, because it isn't a pricing question. It's worth naming as its own category of "free tier cost": the setup friction that has nothing to do with usage and everything to do with defaults you didn't choose. I ended up switching to an OAuth-based auth flow instead of fighting the policy, which turned out to be less work anyway.&lt;/p&gt;

&lt;h3&gt;The one I still can't give you a confident number for&lt;/h3&gt;

&lt;p&gt;Google Cloud Storage's free tier -- relevant if you're hosting generated images or audio anywhere other than inline in your content -- is the one I'm least willing to state a specific figure for here. The free storage and egress allowances exist, but they're also the kind of number Google has adjusted before, and I haven't independently re-verified the current terms against my own account at the time of writing. If a project of yours actually depends on staying under that ceiling, treat whatever number you read anywhere, including this post, as a starting point to confirm on Google's own current pricing documentation -- not a fact to build on unchecked.&lt;/p&gt;

&lt;h3&gt;What actually forced a decision, versus what didn't&lt;/h3&gt;

&lt;p&gt;Looking back at every point where I had to stop and make a call, almost none of them were "I'm about to exceed a quota." They were closer to: an org policy blocking a key, a billing address that Google's system didn't recognize for a newly-split Korean administrative district, a DNS lookup failing because a machine hadn't finished waking from sleep. The free tiers themselves, once I understood their actual shape instead of the one-line pricing-page summary, were generous enough that usage limits never once forced a redesign. The friction was almost entirely operational -- verifying an address, understanding a policy, debugging a race condition -- not financial.&lt;/p&gt;

&lt;h3&gt;If you're planning something similar&lt;/h3&gt;

&lt;p&gt;The practical version of this post, if you don't want to read the rest of it: budget your setup time around policy and identity friction, not around character counts or API call limits. The free tiers on the usage side are more forgiving than they look. The parts that will actually eat an afternoon are the ones that don't show up on a pricing page at all.&lt;/p&gt;

&lt;h3&gt;Related reading&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://justasideproject.blogspot.com/2026/08/reading-fine-print-on-free-ai-tools-5.html" rel="noopener noreferrer"&gt;Reading the Fine Print on 'Free' AI Tools: 5 Questions to Check First&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://justasideproject.blogspot.com/2026/08/i-checked-googles-own-billing-console.html" rel="noopener noreferrer"&gt;I Checked Google's Own Billing Console So You Don't Have To: The Real Text-to-Speech Free Tier, Voice by Voice&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>googlecloud</category>
      <category>ai</category>
      <category>buildinpublic</category>
    </item>
    <item>
      <title>My Service Account Key Got Blocked By an Org Policy I Didn't Know Existed</title>
      <dc:creator>Just a Side Project</dc:creator>
      <pubDate>Fri, 21 Aug 2026 12:02:15 +0000</pubDate>
      <link>https://dev.to/just_a_side_project/my-service-account-key-got-blocked-by-an-org-policy-i-didnt-know-existed-4cjl</link>
      <guid>https://dev.to/just_a_side_project/my-service-account-key-got-blocked-by-an-org-policy-i-didnt-know-existed-4cjl</guid>
      <description>&lt;p&gt;The error didn't even try to be helpful: &lt;code&gt;FAILED_PRECONDITION: Key creation is not allowed on this service account.&lt;/code&gt; No link, no suggestion, no explanation of which policy was doing this or why. I'd asked Google Cloud for a JSON key file for a brand-new service account on a brand-new project I owned outright, and it said no. This is the story of the two hours I spent trying to fight that, and the one afternoon I saved myself by giving up and doing it a different way instead.&lt;/p&gt;

&lt;h3&gt;Why I wanted a service account key in the first place&lt;/h3&gt;

&lt;p&gt;Every Google Cloud tutorial that touches server-to-server automation reaches for the same pattern: create a service account, generate a key, drop the JSON file next to your script, point &lt;code&gt;GOOGLE_APPLICATION_CREDENTIALS&lt;/code&gt; at it, done. It's the default advice for a reason -- it's non-interactive, it doesn't expire the way OAuth tokens can feel like they will, and there's no browser consent screen involved. For a script that was going to call the Text-to-Speech API on a timer with nobody watching, it looked like exactly the right tool.&lt;/p&gt;

&lt;p&gt;So I created the service account. I opened the "Keys" tab. I clicked "Add key" → "Create new key" → JSON, the same three clicks I'd done on other projects without thinking about it. And instead of a download prompt, I got that error.&lt;/p&gt;

&lt;h3&gt;The part where I assumed it was my mistake&lt;/h3&gt;

&lt;p&gt;My first instinct wasn't "this is a policy," it was "I did something wrong." I checked the IAM roles on the service account -- fine. I checked billing was actually enabled -- it was. I checked whether the API itself needed to be enabled first -- it was already on. I regenerated the service account from scratch in case the first one was somehow corrupted. Same error, verbatim, every time.

It took an embarrassingly long search before I found the actual cause: an organization policy constraint called &lt;code&gt;iam.disableServiceAccountKeyCreation&lt;/code&gt;. If your Google account sits inside a Google Workspace or Cloud org (mine did, through a domain I'd set up long before this project), that constraint is very often &lt;strong&gt;on by default&lt;/strong&gt;, inherited from the org level, and it blocks key creation across every project underneath it -- including ones you personally own and administer. You don't get an email about it. You don't see a warning when you create the project. You just hit the wall the first time you actually try to generate a key.&lt;/p&gt;

&lt;h3&gt;The two hours I don't recommend&lt;/h3&gt;

&lt;p&gt;Knowing the name of the constraint, my next move was to try to turn it off. This is the part I'd skip if I were doing it again. Changing an org policy constraint requires the &lt;code&gt;orgpolicy.policyAdmins&lt;/code&gt; role at the organization or folder level -- not the project level, no matter how much of an owner you are on the project itself. I went looking for that permission in the Google Cloud console, then in the Google Workspace admin console, then back again, trying to figure out which of my own logins was supposed to have organization-level admin rights over a domain I'd registered years earlier for an unrelated reason. I found a path that technically could have worked -- reassign myself an org policy admin role, override the constraint at the project level, generate the key, then presumably leave that override in place forever as a small permanent hole in an otherwise sane default.&lt;/p&gt;

&lt;p&gt;I got about ten minutes from actually doing that before I stopped and asked a more useful question: did I actually need a service account key, or did I need "a script that authenticates without a human present," and were those actually the same thing?&lt;/p&gt;

&lt;h3&gt;They weren't the same thing&lt;/h3&gt;

&lt;p&gt;They're not, and the gap between them is OAuth's installed-app flow. It's a pattern most people associate with interactive login -- a browser window pops up, you click "Allow," and a web app gets a token. What's less obvious is that the exact same flow works fine for a personal automation script, and it sidesteps the org policy entirely, because it authenticates &lt;em&gt;as you&lt;/em&gt;, using your own account's ordinary permissions, not as a service account subject to org-level key restrictions.&lt;/p&gt;

&lt;p&gt;The shape of it in Python is small enough that it fits in one paste:&lt;/p&gt;

&lt;pre&gt;&lt;code&gt;from google_auth_oauthlib.flow import InstalledAppFlow
from googleapiclient.discovery import build
import pickle
from pathlib import Path

SCOPES = ["https://www.googleapis.com/auth/cloud-platform"]
TOKEN_PATH = Path("token.pickle")

def get_credentials():
    creds = None
    if TOKEN_PATH.exists():
        creds = pickle.loads(TOKEN_PATH.read_bytes())
    if not creds or not creds.valid:
        if creds and creds.expired and creds.refresh_token:
            creds.refresh(Request())
        else:
            flow = InstalledAppFlow.from_client_secrets_file("client_secret.json", SCOPES)
            creds = flow.run_local_server(port=0)
        TOKEN_PATH.write_bytes(pickle.dumps(creds))
    return creds&lt;/code&gt;&lt;/pre&gt;

&lt;p&gt;You run this once, a browser tab opens, you click through your own account's consent screen, and a refresh token gets cached to disk. Every run after that is silent -- no browser, no human, the refresh token renews the access token automatically for as long as you keep using it. Functionally, for a solo automation script, this behaves exactly like the service account key I was originally trying to get: something a scheduled task can use without anyone sitting at the keyboard. It just doesn't touch the constraint that was blocking me, because it was never a service account to begin with.&lt;/p&gt;

&lt;h3&gt;What I'd tell myself before starting&lt;/h3&gt;

&lt;p&gt;The lesson isn't "OAuth is better than service accounts" in general -- for actual server-to-server systems, especially ones other people operate, service accounts with scoped IAM roles are still the right default, and disabling key creation org-wide is a genuinely reasonable security policy, not a bug. The lesson is narrower: if a script is going to run under &lt;em&gt;your own&lt;/em&gt; identity, on &lt;em&gt;your own&lt;/em&gt; machine, for &lt;em&gt;your own&lt;/em&gt; project, and you hit a wall trying to mint a service account key, check whether you actually need the service account at all before you go looking for a way around an org policy that's very likely there on purpose. The workaround that respects the policy is usually less work than the one that fights it.&lt;/p&gt;

&lt;h3&gt;Related reading&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://justasideproject.blogspot.com/2026/08/the-silent-scheduled-task-failure.html" rel="noopener noreferrer"&gt;The Silent Scheduled-Task Failure Nobody Warns You About (And How I Caught It)&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://justasideproject.blogspot.com/2026/08/my-oauth-tokens-kept-expiring-every-7.html" rel="noopener noreferrer"&gt;My OAuth Tokens Kept Expiring Every 7 Days, and the Reason Was a Dropdown Labeled 'Testing'&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>googlecloud</category>
      <category>security</category>
      <category>buildinpublic</category>
    </item>
    <item>
      <title>The Silent Scheduled-Task Failure Nobody Warns You About (And How I Caught It)</title>
      <dc:creator>Just a Side Project</dc:creator>
      <pubDate>Thu, 20 Aug 2026 00:43:24 +0000</pubDate>
      <link>https://dev.to/just_a_side_project/the-silent-scheduled-task-failure-nobody-warns-you-about-and-how-i-caught-it-1mi3</link>
      <guid>https://dev.to/just_a_side_project/the-silent-scheduled-task-failure-nobody-warns-you-about-and-how-i-caught-it-1mi3</guid>
      <description>&lt;p&gt;Last week, a blog post I had scheduled to auto-publish at 9pm simply didn't. No error in my inbox, no crash log, nothing. Windows Task Scheduler's own history said the task had run and &lt;strong&gt;succeeded&lt;/strong&gt;. It hadn't. Here's what was actually going on, because the failure mode is a lot more common than the one blog post I eventually found on it suggested.&lt;/p&gt;

&lt;h3&gt;The setup&lt;/h3&gt;

&lt;p&gt;My publishing pipeline is a Python script (&lt;code&gt;publish.py&lt;/code&gt;) wrapped in a small PowerShell script, registered as a Windows Task Scheduler job set to fire at a specific time even if the machine is asleep (&lt;code&gt;WakeToRun&lt;/code&gt;). The work PC sleeps overnight, wakes itself for the scheduled task, runs the script, goes back to sleep. That part works reliably.&lt;/p&gt;

&lt;h3&gt;Why Task Scheduler said "success" when it wasn't&lt;/h3&gt;

&lt;p&gt;The Python script failed with a DNS resolution error trying to reach &lt;code&gt;oauth2.googleapis.com&lt;/code&gt; -- the machine had woken up, but the network adapter hadn't finished reconnecting yet. That part is almost expected; waking from sleep isn't instant. The part that actually cost me a missed post is this:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;PowerShell does not automatically propagate a wrapped native command's exit code as its own.&lt;/strong&gt; When my &lt;code&gt;.ps1&lt;/code&gt; script called &lt;code&gt;python publish.py ...&lt;/code&gt; and Python exited with a non-zero code, the PowerShell script itself still exited 0 unless I explicitly checked and re-threw that code. Task Scheduler only sees the PowerShell wrapper's exit code -- so from its point of view, the task ran cleanly. "Last Run Result: 0x0" doesn't mean the thing inside your script worked. It means your wrapper didn't crash.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;Task Scheduler's report&lt;/th&gt;
&lt;th&gt;What actually happened&lt;/th&gt;
&lt;/tr&gt;&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Before the fix&lt;/td&gt;
&lt;td&gt;"Last Run Result: 0x0" (success)&lt;/td&gt;
&lt;td&gt;Post never went live -- DNS lookup for oauth2.googleapis.com failed&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;After the fix&lt;/td&gt;
&lt;td&gt;"Last Run Result: 0x0" (success)&lt;/td&gt;
&lt;td&gt;Script waits for a real connection first, then publishes correctly&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h3&gt;The fix&lt;/h3&gt;

&lt;p&gt;Two changes closed the gap. First, don't let the script even attempt the API call until the network is actually back:&lt;/p&gt;

&lt;pre&gt;&lt;code&gt;# Wait for a real network connection before doing anything else (up to 5 min)
$connected = $false
for ($i = 0; $i -lt 30; $i++) {
    if (Test-Connection -ComputerName "8.8.8.8" -Count 1 -Quiet -ErrorAction SilentlyContinue) {
        $connected = $true
        break
    }
    Start-Sleep -Seconds 10
}
if (-not $connected) {
    Add-Content -Path "$PSScriptRoot\publish_log.txt" -Value "$(Get-Date): network unavailable, publish cancelled"
    exit 1
}

python publish.py "post.html" --title "..." --publish-live *&amp;gt;&amp;gt; publish_log.txt&lt;/code&gt;&lt;/pre&gt;

&lt;p&gt;Second -- and this is the one I'd skip if I were being lazy -- I now treat "Last Run Result: 0x0" as necessary but not sufficient. The wrapper script logs its own timestamped success/failure line to a text file, and that log, not the Task Scheduler history, is what I actually check.&lt;/p&gt;

&lt;h3&gt;If you're scheduling anything that wakes a sleeping machine&lt;/h3&gt;

&lt;p&gt;The general shape of this bug isn't specific to Blogger, Python, or even PowerShell -- it's "the task fired before its dependency was ready, and the wrapper swallowed the failure." Anywhere you see &lt;code&gt;WakeToRun&lt;/code&gt; or an equivalent, ask two questions: does the very first thing the script does depend on network/disk/another service being ready, and does a failure inside the wrapped process actually surface as a failure to whatever is monitoring the job? If the answer to either is "not sure," it's worth testing deliberately -- unplug the network, run the task, and see what the scheduler reports.&lt;/p&gt;

&lt;h3&gt;Related reading&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://justasideproject.blogspot.com/2026/08/my-service-account-key-got-blocked-by.html" rel="noopener noreferrer"&gt;My Service Account Key Got Blocked By an Org Policy I Didn't Know Existed&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://justasideproject.blogspot.com/2026/08/my-oauth-tokens-kept-expiring-every-7.html" rel="noopener noreferrer"&gt;My OAuth Tokens Kept Expiring Every 7 Days, and the Reason Was a Dropdown Labeled 'Testing'&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://justasideproject.blogspot.com/2026/08/i-wired-google-search-console-into-my.html" rel="noopener noreferrer"&gt;I Wired Google Search Console Into My Publishing Pipeline and Immediately Hit Someone Else's Google Account&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>automation</category>
      <category>python</category>
      <category>buildinpublic</category>
    </item>
  </channel>
</rss>
