<?xml version="1.0" encoding="UTF-8"?><rss xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:content="http://purl.org/rss/1.0/modules/content/" xmlns:atom="http://www.w3.org/2005/Atom" version="2.0"><channel><title><![CDATA[WiseFrame]]></title><description><![CDATA[WiseFrame]]></description><link>https://wiseframe.dev</link><image><url>https://cdn.hashnode.com/res/hashnode/image/upload/v1593680282896/kNC7E8IR4.png</url><title>WiseFrame</title><link>https://wiseframe.dev</link></image><generator>RSS for Node</generator><lastBuildDate>Fri, 16 Oct 2026 11:48:33 GMT</lastBuildDate><atom:link href="https://wiseframe.dev/rss.xml" rel="self" type="application/rss+xml"/><language><![CDATA[en]]></language><ttl>60</ttl><item><title><![CDATA[Godot pixel-art shaders #2 — toon lighting, dithering, and the gray-shadow trap]]></title><description><![CDATA[Not a native English speaker — corrections welcome!



▶ Open the full interactive playground
Every demo in one place, full-screen — drag the sliders yourself.

Part 2 of the series. Part 1 was about ]]></description><link>https://wiseframe.dev/godot-pixel-art-shaders-toon-lighting-and-dithering</link><guid isPermaLink="true">https://wiseframe.dev/godot-pixel-art-shaders-toon-lighting-and-dithering</guid><category><![CDATA[Godot]]></category><category><![CDATA[Shaders]]></category><dc:creator><![CDATA[Wise]]></dc:creator><pubDate>Tue, 01 Sep 2026 13:10:11 GMT</pubDate><content:encoded><![CDATA[<blockquote>
<p>Not a native English speaker — corrections welcome!</p>
</blockquote>
<p><img src="https://cdn.hashnode.com/uploads/gql/6a6f35e3295b247db5b8116e/bc5560d9-7756-4779-ae77-a891de9b43a5.gif" alt="Dithered toon shading on a sphere in Godot" /></p>
<blockquote>
<p>▶ <strong><a href="https://passingg.github.io/wiseframe-shader-demos/l2/">Open the full interactive playground</a></strong>
Every demo in one place, full-screen — drag the sliders yourself.</p>
</blockquote>
<p>Part 2 of the series. Part 1 was about the pipeline and quantization — <code>floor(x*n)/n</code> turning smooth values into pixel-art bands. This time it's toon lighting: how a surface decides how bright it is, and what happens when I quantize that brightness too.</p>
<p>I'm following David Holland's <a href="https://www.davidhol.land/articles/3d-pixel-art-rendering/">3D Pixel Art Rendering</a> in stock Godot 4.7, no engine patches. For the toon shading specifically I also leaned on CaptainProton42's Flexible Toon Shader as a reference.</p>
<h2>The math — toon lighting</h2>
<p>Brightness at a point on a surface comes down to one dot product: the surface normal <code>N</code> and the direction to the light <code>L</code>.</p>
<pre><code>max(dot(N, L), 0.0)
</code></pre>
<p><code>dot(N, L)</code> is largest when the surface faces the light straight-on, and it goes negative when the surface faces away — the back of the sphere, basically. Negative light doesn't mean anything, so it gets clamped to 0.</p>
<p>That alone gives a smooth gradient from dark to bright. Toon shading is just: take that gradient and run it through part 1's quantization. <code>floor()</code> turns the smooth ramp into hard steps — cel shading.</p>
<p>In Godot, this lives in the <code>light()</code> processor function, and it accumulates into <code>DIFFUSE_LIGHT</code>:</p>
<pre><code class="language-glsl">shader_type spatial;

uniform int bands : hint_range(2, 6) = 4;

void light() {
	float ndotl = max(dot(normalize(NORMAL), normalize(LIGHT)), 0.0);
	float toon = floor(ndotl * float(bands)) / float(bands);
	DIFFUSE_LIGHT += toon * ATTENUATION * LIGHT_COLOR * ALBEDO;
}
</code></pre>
<p><code>light()</code> runs once per light in the scene, and each run adds this light's contribution to <code>DIFFUSE_LIGHT</code> — how bright this light makes this surface. Swap <code>floor</code> for <code>round</code> and the bright band gets wider (it rounds up into the top step sooner).</p>
<h2>Where I actually got stuck — my shadows came out gray</h2>
<p><code>light()</code> only adds <em>direct</em> light. So the side of the sphere facing away from the light should get <code>ndotl = 0</code> and stay black. Instead, mine came out gray.</p>
<p>Turned out the culprit wasn't my shader at all — it was the scene's ambient light. <code>light()</code> only handles direct contributions, but Godot's <code>WorldEnvironment</code> also feeds ambient light (and sky radiance) into the surface separately, and that's not something my <code>floor()</code> step ever touched. So the dark side of the sphere was dark <em>from the light I wrote</em>, but still lit <em>from the environment</em>.</p>
<p>What I actually did: tweaked the Ambient Light setting on the <code>WorldEnvironment</code>, and the gray shifted to a different tint (turning it off entirely gets you pure black). There's a more direct fix too — add <code>render_mode ambient_light_disabled;</code> to the top of the shader, which turns off ambient contribution for just this material, so its shadows go pure black regardless of scene settings. Pure black vs. a tinted dark shadow is really just a look you pick.</p>
<h2>Dithering (Bayer)</h2>
<p>Dithering is the trick of using only two colors (on/off) to fake more brightness steps than you actually have. A Bayer 4×4 matrix is a threshold table — it just says "if I only have room for N pixels turned on in this tile, which ones go first." Index it with screen coordinates (<code>FRAGCOORD</code>) instead of object UVs, and the dither pattern sticks to the screen, not to the object.</p>
<h2>Going off the map — dithering the shading itself</h2>
<p>Normally dithering gets applied to a smooth gradient — a color, a fog falloff, something continuous. What I tried instead: apply it inside <code>light()</code>, to the lighting <em>strength</em> itself, using the Bayer threshold as an on/off gate per pixel. So the shading itself gets dithered, not just a color afterward. That's what's in the header GIF.</p>
<p>Here's the shader as I actually wrote it:</p>
<pre><code class="language-glsl">shader_type spatial;

const float bayer4[16] = float[](
	 0.0,  8.0,  2.0, 10.0,
	12.0,  4.0, 14.0,  6.0,
	 3.0, 11.0,  1.0,  9.0,
	15.0,  7.0, 13.0,  5.0
);

uniform int bands : hint_range(2, 6) = 4;
uniform vec3 color = vec3(1.0, 1.0, 1.0);

void light() {
	float ndotl = max(dot(normalize(NORMAL), normalize(LIGHT)), 0.0);
	float toon = round(ndotl * float(bands)) / float(bands);

	ivec2 p = ivec2(FRAGCOORD.xy) % 4;
	float threshold = (bayer4[p.y * 4 + p.x] + 0.5) / 16.0;

	float lightStrength = toon * ATTENUATION;
	DIFFUSE_LIGHT += vec3(lightStrength &gt; threshold ? 1.0 : 0.0) * LIGHT_COLOR * ALBEDO;
}

void fragment() {
	ALBEDO = color;
}
</code></pre>
<p>The key line: <code>lightStrength &gt; threshold ? 1.0 : 0.0</code>. Every pixel checks its own lighting strength against its own spot in the Bayer table, and switches fully on or fully off. So instead of a clean stepped edge between bands, the boundary breaks up into a scatter of dots — dithered, not banded.</p>
<h2>Wrap / next</h2>
<p>So: one dot product for brightness, <code>floor()</code> (or <code>round()</code>) to band it into cel shading, and if the shadows look gray instead of black, it's not the toon math — it's ambient light from the environment sneaking in. Dithering fakes extra brightness levels with a threshold table, and applying that threshold inside the lighting function itself (instead of to a finished gradient) is what produced the header GIF.</p>
<p>Next up: depth textures.</p>
]]></content:encoded></item><item><title><![CDATA[Godot pixel-art shaders from scratch #1 — the pipeline, and the one bit of math that makes pixels]]></title><description><![CDATA[Not a native English speaker — corrections welcome!



▶ Open the full interactive playground
Every demo in one place, full-screen — drag the sliders yourself.

I'm teaching myself Godot 3D pixel-art ]]></description><link>https://wiseframe.dev/godot-pixel-art-shaders-pipeline-and-quantization</link><guid isPermaLink="true">https://wiseframe.dev/godot-pixel-art-shaders-pipeline-and-quantization</guid><category><![CDATA[Godot]]></category><category><![CDATA[Shaders]]></category><category><![CDATA[Math]]></category><dc:creator><![CDATA[Wise]]></dc:creator><pubDate>Sun, 09 Aug 2026 14:03:24 GMT</pubDate><content:encoded><![CDATA[<blockquote>
<p>Not a native English speaker — corrections welcome!</p>
</blockquote>
<p><img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1786898486557/5a67058a-2d30-44f8-86f0-85c88be8a842.gif" alt="Quantization — a smooth color gradient stepping into pixel bands" /></p>
<blockquote>
<p>▶ <strong><a href="https://passingg.github.io/wiseframe-shader-demos/">Open the full interactive playground</a></strong>
Every demo in one place, full-screen — drag the sliders yourself.</p>
</blockquote>
<p>I'm teaching myself Godot 3D pixel-art shaders and building interactive explainers as I go. This is part 1.</p>
<h2>What I'm building (series intro)</h2>
<p>The goal, a few posts from now: a 3D pixel-art shader that works for both perspective and orthographic cameras, from one codebase. Outlines. Water. God rays. Bloom. The kind of look you see in games that render "real" 3D geometry but make it read as pixel art.</p>
<p>I don't know how to do most of that yet. That's the point of the series — I'm learning it from the ground up and writing down what actually happened, not a polished summary after the fact.</p>
<p>This first post is the boring-but-necessary part: what a shader even is, the pipeline that turns a 3D scene into a pixel-art image, and the one piece of math — quantization — that does most of the actual work.</p>
<h2>Where this comes from (credit where it's due)</h2>
<p>I didn't invent any of this. I fell down this rabbit hole after reading David Holland's <a href="https://www.davidhol.land/articles/3d-pixel-art-rendering/">3D Pixel Art Rendering</a> — the idea that you can render real 3D geometry and still make it read as <em>pixel art</em> kind of broke my brain. I didn't want to just copy a finished shader; I wanted to rebuild each piece from scratch until I actually understood why it works. That's what this series is.</p>
<p>People and work this stands on:</p>
<ul>
<li><strong>David Holland — <a href="https://www.davidhol.land/articles/3d-pixel-art-rendering/">3D Pixel Art Rendering</a></strong> — the article that started it all: outlines, camera snapping, water, volumetrics, the whole map. He built this in Godot itself — a custom 4.3 build with a handful of engine-source patches for the trickiest parts (a custom camera projection, sampling the shadow map inside shaders). I'm redoing it in <em>stock</em> Godot 4.7 with no engine patches, which is exactly where most of my "wait, how do I do <em>this</em> part?" moments come from.</li>
<li><strong><a href="https://www.youtube.com/@t3ssel8r">t3ssel8r</a></strong> — pretty much defined this look for a lot of us: orthographic + low resolution + camera snapping, and shell-based volumetrics.</li>
<li><strong><a href="https://roystan.net/articles/outline-shader/">Roystan</a></strong> — clear, patient outline and toon-water tutorials. Unity, but the ideas carry straight over.</li>
<li><strong><a href="https://godotshaders.com/shader/3d-pixel-art-outline-highlight-post-processing-shader/">godotshaders.com outline shader</a></strong> — a concrete Godot reference for the outline pass.</li>
</ul>
<p>If you're new to this, honestly go read and watch their stuff first — they're the real teachers here. This series is just me working through it out loud, in vanilla Godot, writing down what actually happened (mistakes included). Corrections welcome.</p>
<h2>The mental model: a shader is a per-pixel function</h2>
<p>A shader has (at least) two stages. The <strong>vertex</strong> stage takes a mesh vertex and decides where it lands on screen. The <strong>fragment</strong> stage takes a pixel and decides what color it is.</p>
<p>Both are tiny functions. The part that took me a while to really internalize: the GPU runs the fragment function on thousands of pixels at the same time, and each run knows nothing about the others.</p>
<p>So the mental model I keep coming back to is:</p>
<blockquote>
<p>I am one pixel. I don't know what color my neighbor is. I can only read my own coordinate and sample textures.</p>
</blockquote>
<p>No loops over "all pixels," no shared state between pixels (within a single pass, at least). If I want a pixel to react to its neighbors — for an outline, for example — I have to bring that information in some other way (usually by sampling a texture at nearby coordinates, which is its own topic for a later post).</p>
<p>This is also why a lot of shader code looks weirdly restrictive compared to normal programming. You can't just say "loop over the image and find the edges." You have to ask: what can <em>this one pixel</em> figure out using only its own coordinate and whatever textures it's allowed to sample? Everything downstream gets built out of that constraint.</p>
<h2>Coordinates: UV vs SCREEN_UV</h2>
<p>A coordinate is only ever meaningful relative to an origin and a set of axes — that's an agreement, not a fact about the universe. Godot's shaders hand you a few different ones, and mixing them up was my first real point of confusion.</p>
<ul>
<li><strong>UV</strong> is a 0..1 coordinate glued to the <em>surface</em> of the mesh. If the object moves or rotates, the UV-based pattern moves and rotates with it — like a texture printed on the object.</li>
<li><strong>SCREEN_UV</strong> is a 0..1 coordinate glued to the <em>screen</em>. If the object moves, a SCREEN_UV-based pattern stays put — the object is more like a window you're looking at the screen-space pattern through.</li>
</ul>
<div class="hn-embed-widget" id="l1-uv"></div><p>Here's the shader I used to see this for myself. Two spheres, the same shader on both — the only thing I flip is whether it reads <code>UV</code> or <code>SCREEN_UV</code>:</p>
<pre><code class="language-glsl">shader_type spatial;
render_mode unshaded;

uniform bool use_screen_uv = false;

void fragment() {
    vec2 uv = use_screen_uv ? SCREEN_UV : UV;
    ALBEDO = vec3(uv.x, uv.y, 0.0);
}
</code></pre>
<p>Two new keywords here compared to the 2D shader further down. <code>shader_type spatial</code> marks this as a 3D material (the 2D one is <code>canvas_item</code>). <code>render_mode unshaded</code> tells Godot to skip all lighting math and draw <code>ALBEDO</code> straight to the screen — I want to see the raw coordinate <em>as</em> a color, not a lit surface. And in a 3D shader the output color goes into <code>ALBEDO</code>, not <code>COLOR</code>.</p>
<p>With <code>UV</code>, the gradient is printed onto each sphere's surface. Both spheres look identical, and if I rotated one the colors would ride along with it:</p>
<p><img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1786898487494/1998e037-d3b3-42ab-b45a-b6887eba00e7.png" alt="Two spheres with a UV gradient — the color is glued to each sphere's surface, so both look identical" /></p>
<p>Flip to <code>SCREEN_UV</code> and the gradient belongs to the screen instead. Now the left sphere reads greener and the right one oranger, purely because of where they sit in the window — each sphere is just a window onto one shared screen-space gradient:</p>
<p><img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1786898487680/d6cb96f7-3d96-4c07-bc68-f904fd0e66dd.png" alt="Two spheres with a SCREEN_UV gradient — the color is glued to the screen, so the left sphere is greener and the right is oranger" /></p>
<p>This distinction matters more than it sounds like it should, because SCREEN_UV is the seed for basically all post-processing — outlines, screen-space effects, anything that needs to know "where am I on the final image" instead of "where am I on this specific object."</p>
<h2>The one bit of math: quantization</h2>
<p>If pixel art has a mathematical heart, it's this one line:</p>
<pre><code>floor(x * n) / n
</code></pre>
<p>Read it left to right: multiply x up by n, floor it (drop everything after the decimal point), divide back down by n. In plain words: force a lower resolution onto a value that was smooth.</p>
<p>There's an identity that goes with it, and it's the thing that made quantization click for me:</p>
<pre><code>x = floor(x) + fract(x)
</code></pre>
<p>Any number is exactly its floor (the "staircase" part) plus its fract (the "sawtooth" part, always between 0 and 1). This is always true, not an approximation — floor throws information away, fract holds exactly what floor threw away, and adding them back gives you the original value with zero error.</p>
<p>Here's what that looks like plotted. Top: a smooth ramp against the quantized staircase for n = 8. Bottom: the floor and fract pieces separately, and their sum landing exactly back on the original line.</p>
<div class="hn-embed-widget" id="l1-quant"></div><h2>My first shader (Godot 4.x)</h2>
<p>Small enough to paste into a new <code>.gdshader</code> file and run:</p>
<pre><code class="language-glsl">shader_type canvas_item;

uniform int steps : hint_range(2, 32) = 8;

void fragment() {
    // UV goes 0..1 across the surface.
    // Each pixel only knows its own UV — nothing about its neighbors.
    float q = floor(UV.x * float(steps)) / float(steps);
    COLOR = vec4(vec3(q), 1.0);
}
</code></pre>
<p>The <code>: hint_range(2, 32)</code> part just tells Godot's inspector to show <code>steps</code> as a slider from 2 to 32 instead of a plain number field — pure editor convenience, it doesn't touch the math.</p>
<p>Attach it to a ColorRect or a Sprite2D and you get a horizontal grayscale gradient — except it's banded, not smooth. Bump <code>steps</code> up and the bands get thinner; push it high enough and it looks smooth again (you've just made n large enough that the human eye can't tell the difference). That one <code>floor()</code> call is the heart of the pixel-art look.</p>
<p>Here it is running in Godot — as <code>steps</code> drops, the smooth ramp collapses into fewer and fewer bands:</p>
<p><img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1786898487852/459a6f0d-6661-41d6-9770-c9ba1bb3c15a.gif" alt="Quantized grayscale gradient in Godot, banding into fewer steps as the steps uniform is lowered" /></p>
<h2>Where I actually got stuck (the pipeline in practice)</h2>
<p>The math above is one line. Getting an actual 3D scene to <em>look</em> pixel-art in Godot is a separate job, and it's mostly plumbing, not shader code.</p>
<p>The approach: render the 3D scene into a <strong>SubViewport</strong> at a low internal resolution, then display that render on a full-screen <code>TextureRect</code> with the <strong>Nearest</strong> texture filter, scaled up to fill the window.</p>
<p><img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1786898488102/31df8277-7f8b-46e5-974e-41c1b4ef9cc7.png" alt="Pipeline: Scene to SubViewport 640x360 to TextureRect (Nearest) to Window 1920x1080 (integer 3x scale)" /></p>
<p><strong>The one thing that actually tripped me up: Full Rect did nothing.</strong></p>
<p>I started from a 2D Scene, dropped in a <code>TextureRect</code>, and set its anchor preset to Full Rect expecting it to fill the window. Instead its size stayed <strong>0</strong> — the node was there, but it had no area, so nothing showed up. I stared at the inspector for a while before the cause clicked: my scene root was a <code>Node2D</code>, and Full Rect anchoring only means something inside a <code>Control</code>-based layout tree. The fix was almost silly — make the root a plain <code>Node</code> (or just start the scene with a <code>Node</code> root in the first place). The moment I did that, Full Rect filled the window like I'd expected all along.</p>
<p>Every shader in this post worked as-is once the layout was right — the bug was never in the shader, it was in the scene setup around it. That seems to be the theme with this kind of work: the code compiles, nothing crashes, and the "bug" is a UI element quietly doing nothing, with no stack trace to follow.</p>
<p><strong>Nearest vs Linear — the one setting that makes or breaks the look.</strong></p>
<p>The last piece is the <code>TextureRect</code>'s Texture Filter. On the default, <strong>Linear</strong>, the low-res render gets smoothed as it scales up, so you end up with a blurry image instead of pixel art:</p>
<p><img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1786898488265/3d529d96-1de8-48b4-8421-7cbad00b7556.png" alt="Upscaled cube with the Linear filter — soft, blurry edges" /></p>
<p>Switch it to <strong>Nearest</strong> and each low-res pixel is held as a hard-edged block. Same render, same resolution — the only thing that changed is the filter, and that one setting is the whole difference between "blurry 3D" and "pixel art":</p>
<p><img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1786898488433/a35f8ff3-9434-4f4f-8ff0-0af266cbdd27.png" alt="Upscaled cube with the Nearest filter — crisp, hard-edged pixels" /></p>
<div class="hn-embed-widget" id="l1-filter"></div><h2>Wrap-up / next</h2>
<p>So: a shader is a tiny per-pixel function running in parallel with no memory of its neighbors, UV vs SCREEN_UV is about what the coordinate is glued to, and <code>floor(x*n)/n</code> is the one line of math that turns "smooth" into "pixel art." The rest of this post was really just the practical plumbing to get that math onto a real 3D scene.</p>
<p>Next up: toon lighting — dot products, Lambertian shading, and dithering to fake more color depth than the quantized palette actually has. If any of the math or the Godot specifics above is wrong or could be explained better, corrections welcome — I'm still figuring this out.</p>
]]></content:encoded></item><item><title><![CDATA[GitHub Actions workflow not triggering after force push (paths filter gotcha)]]></title><description><![CDATA[Not a native English speaker — corrections welcome!

The symptom
I was setting up an auto-publish pipeline for this blog: push a markdown file to posts/, and a GitHub Action publishes it to Hashnode. ]]></description><link>https://wiseframe.dev/github-actions-not-triggering-after-force-push</link><guid isPermaLink="true">https://wiseframe.dev/github-actions-not-triggering-after-force-push</guid><category><![CDATA[GitHub Actions]]></category><category><![CDATA[Git]]></category><category><![CDATA[ci]]></category><category><![CDATA[automation]]></category><dc:creator><![CDATA[Wise]]></dc:creator><pubDate>Tue, 04 Aug 2026 17:53:58 GMT</pubDate><content:encoded><![CDATA[<blockquote>
<p>Not a native English speaker — corrections welcome!</p>
</blockquote>
<h2>The symptom</h2>
<p>I was setting up an auto-publish pipeline for this blog: push a markdown file to <code>posts/</code>, and a GitHub Action publishes it to Hashnode. The workflow triggers like this:</p>
<pre><code class="language-yaml">on:
  push:
    branches: [main]
    paths:
      - "posts/**"
</code></pre>
<p>I force-pushed a commit that definitely changed files under <code>posts/</code>. Then... nothing.</p>
<p>No workflow run. Not a failed run. Not a skipped run. <code>gh run list</code> showed nothing for that commit, and the Actions tab was empty too.</p>
<p>I checked the usual suspects:</p>
<ul>
<li>Actions enabled for the repo — yes</li>
<li>Workflow file state <code>active</code> — yes</li>
<li><code>main</code> actually pointing at my new commit — yes</li>
</ul>
<p>Everything looked correct. Still silence. This was the confusing part: a failed run gives you a log to read. No run gives you nothing.</p>
<h2>The cause</h2>
<p>The force push was the problem — combined with the <code>paths</code> filter.</p>
<p>I was consolidating two repositories, so my force push replaced <code>main</code> with a <strong>completely unrelated history</strong> (no common ancestor between the old and new commits).</p>
<p>To apply a <code>paths</code> filter, GitHub has to answer "which files changed in this push?" by diffing the before/after commits. My best understanding: when the push is a force push to an unrelated history, that diff can't be computed in a meaningful way — and in my case, GitHub silently skipped the workflow instead of running it.</p>
<p>(The docs say that with very large pushes or diff timeouts the workflow "always runs", which is the opposite behavior — so the unrelated-history case seems to be its own thing. If you know the exact rule, please correct me in the comments.)</p>
<h2>The fix</h2>
<p>One normal commit that touches a path matching the filter:</p>
<pre><code class="language-bash"># any real change under posts/ works
echo "" &gt;&gt; posts/my-post.md
git add posts/my-post.md
git commit -m "Kick CI after force push"
git push origin main
</code></pre>
<p>This triggered the workflow immediately, because now GitHub can diff against the previous commit like normal.</p>
<p>Two details that matter:</p>
<ul>
<li><code>git commit --allow-empty</code> does <strong>not</strong> work here. An empty commit changes no files, so the <code>paths</code> filter matches nothing.</li>
<li>The kick commit must touch a file that matches your filter (<code>posts/**</code> in my case).</li>
</ul>
<h2>What I learned</h2>
<ul>
<li>Avoid force-pushing to a branch that has <code>paths</code>-filtered workflows.</li>
<li>If you must force-push, follow it with a normal commit touching a filtered path to "restart" the trigger.</li>
<li>When a push produces <strong>no run at all</strong> (not even a failed one), suspect the trigger filters before suspecting the workflow itself.</li>
</ul>
<hr />
<p><em>Keywords for fellow searchers: github actions workflow not running after force push · on push paths not triggering · workflow silently skipped · no workflow run created after push</em></p>
]]></content:encoded></item></channel></rss>