<?xml version="1.0" encoding="utf-8" standalone="yes"?><rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom"><channel><title>Exit-Codes on PHP Boy Scout</title><link>https://phpboyscout.uk/tags/exit-codes/</link><description>Recent content in Exit-Codes on PHP Boy Scout</description><generator>Hugo -- gohugo.io</generator><language>en-gb</language><copyright>Matt Cockayne</copyright><lastBuildDate>Wed, 07 Oct 2026 00:00:00 +0000</lastBuildDate><atom:link href="https://phpboyscout.uk/tags/exit-codes/index.xml" rel="self" type="application/rss+xml"/><item><title>Exit 0 is the strongest claim a program can make</title><link>https://phpboyscout.uk/exit-0-is-the-strongest-claim-a-program-can-make/</link><pubDate>Wed, 07 Oct 2026 00:00:00 +0000</pubDate><guid>https://phpboyscout.uk/exit-0-is-the-strongest-claim-a-program-can-make/</guid><description>&lt;img src="https://phpboyscout.uk/exit-0-is-the-strongest-claim-a-program-can-make/cover-exit-0-is-the-strongest-claim-a-program-can-make_hu_d129e724a8cfb890.jpg" alt="Featured image of post Exit 0 is the strongest claim a program can make" /&gt;&lt;p&gt;The first time the job ran on a real instruction, the instruction failed and the job printed this:&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-fallback" data-lang="fallback"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;failed cicd/wikis/specs/0086-a-project-that-names-its-own-assets — 404 Not Found: not found
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;1 of 1 needed attention. The merge stands; nothing here failed the pipeline.
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;Job succeeded
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;It told you, very politely and in so many words, that the thing it was there to do hadn&amp;rsquo;t happened and that it wasn&amp;rsquo;t going to make a fuss about it.&lt;/p&gt;
&lt;h2 id="a-job-to-stop-specs-lying"&gt;A job to stop specs lying
&lt;/h2&gt;&lt;p&gt;Some context, because the job matters more than it sounds. Every project in the estate carries its specs on a wiki, and a spec has a status: draft, approved, implemented. When the CI/CD project went and counted, 59 of its 82 specs were sat at &lt;code&gt;APPROVED&lt;/code&gt; with the work already shipped, which is to say most of the status fields in that wiki were wrong, and they were wrong in the direction that makes a reader think work is still to do.&lt;/p&gt;
&lt;p&gt;So a job was built to fix it. When a merge request carries a trailer saying which spec it implements, &lt;a class="link" href="https://colophon.phpboyscout.uk/" target="_blank" rel="noopener"
 &gt;colophon&lt;/a&gt;&amp;rsquo;s &lt;code&gt;apply&lt;/code&gt; verb runs after the merge and moves that spec&amp;rsquo;s status. The report above is its first go at the real thing. A 404, and a green pipeline, and the status stayed exactly as wrong as it had been. The only reason anyone found out was that someone opened the trace to see whether it had worked.&lt;/p&gt;
&lt;p&gt;The session that found it raised a ticket on colophon, and a spec to fix it was on the wiki within half an hour.&lt;/p&gt;
&lt;h2 id="the-careful-fix"&gt;The careful fix
&lt;/h2&gt;&lt;p&gt;The spec was careful, and the caution was reasonable. It laid out three ways a caller could learn that an instruction had failed: a non-zero exit code, an opt-in flag, or a machine-readable summary. Then it recommended the flag. &lt;code&gt;--fail-on-problems&lt;/code&gt;, off by default, exiting with code 2 when an instruction had failed.&lt;/p&gt;
&lt;p&gt;The reasoning was a constraint it had written down first: whatever changed, a project that upgraded colophon and touched nothing else must not start failing. A release that silently turned default branches red across the whole estate is not one anybody would thank you for, and pre-1.0 or not, that&amp;rsquo;s a fair thing to worry about.&lt;/p&gt;
&lt;p&gt;I read it, and six minutes after the page went up I was asking why we weren&amp;rsquo;t just exiting 1 on error. Why have an exit code of 2 at all? And while I was in there, was it going through the framework&amp;rsquo;s error handling to set the code, or exiting directly from the command? Because exiting directly bypasses the CLI middleware, and that would want fixing whatever else we did.&lt;/p&gt;
&lt;p&gt;Then, five minutes later, the question that took the flag apart: why did colophon not already exit 1 on problems? Why had we decided, in the first place, to exit 0 when something had failed?&lt;/p&gt;
&lt;h2 id="where-the-zero-came-from"&gt;Where the zero came from
&lt;/h2&gt;&lt;p&gt;Nine days earlier, as it turned out, and I&amp;rsquo;d signed it off.&lt;/p&gt;
&lt;p&gt;The &lt;code&gt;apply&lt;/code&gt; verb&amp;rsquo;s own spec had a decision in it called &amp;ldquo;it never fails the pipeline&amp;rdquo;, and the reasoning is still sound on its face. By the time &lt;code&gt;apply&lt;/code&gt; runs the merge has happened, and a red pipeline because an annotation didn&amp;rsquo;t post is worse signal than a yellow one. A red default branch that can&amp;rsquo;t be acted on is how red stops meaning anything. That decision cited the estate&amp;rsquo;s release-stamping job as its precedent, and the code carried the comment faithfully:&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-go" data-lang="go"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="c1"&gt;// Never a non-zero exit for a target that failed. By the time this runs&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="c1"&gt;// the merge has happened, and a red pipeline over an annotation is worse&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="c1"&gt;// signal than a yellow one (spec 0012 D5).&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;return&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;out&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;err&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;So I went and read the precedent. The release-stamping script raises on anything below an HTTP 500, 404 and 403 included, and an uncaught exception exits 1. The Discord release job runs &lt;code&gt;exit 1&lt;/code&gt; when Discord rejects the post. Neither of them exits zero. What both of them do is set &lt;code&gt;allow_failure: true&lt;/code&gt; in their CI component, so a failure shows yellow in the pipeline instead of red.&lt;/p&gt;
&lt;p&gt;The precedent said the opposite of what had been taken from it. &amp;ldquo;Never fail the release&amp;rdquo; had been translated into &amp;ldquo;the binary exits zero&amp;rdquo;, and that is a different and much stronger claim.&lt;/p&gt;
&lt;table&gt;
 &lt;thead&gt;
 &lt;tr&gt;
 &lt;th&gt;&lt;/th&gt;
 &lt;th&gt;exit 0 on failure&lt;/th&gt;
 &lt;th&gt;exit 1 with &lt;code&gt;allow_failure: true&lt;/code&gt;&lt;/th&gt;
 &lt;/tr&gt;
 &lt;/thead&gt;
 &lt;tbody&gt;
 &lt;tr&gt;
 &lt;td&gt;pipeline colour&lt;/td&gt;
 &lt;td&gt;green&lt;/td&gt;
 &lt;td&gt;yellow&lt;/td&gt;
 &lt;/tr&gt;
 &lt;tr&gt;
 &lt;td&gt;can the caller tell&lt;/td&gt;
 &lt;td&gt;no&lt;/td&gt;
 &lt;td&gt;yes&lt;/td&gt;
 &lt;/tr&gt;
 &lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;Yellow was what the original decision wanted all along, and exit 1 with &lt;code&gt;allow_failure&lt;/code&gt; on is how you get it. Exit 0 got green instead, which is neither what it asked for nor true. Two questions had been collapsed into one: &lt;em&gt;should the pipeline go red&lt;/em&gt; and &lt;em&gt;can the caller know&lt;/em&gt;. Answering the first with no had answered the second with no by accident, and a flag defaulting to off would have kept that accident as the default.&lt;/p&gt;
&lt;p&gt;Yellow is acceptable. It means the thing completed and there may be items for an engineer to check. That was the whole of my answer, and the spec went to approved on it.&lt;/p&gt;
&lt;h2 id="not-every-problem-is-a-signal"&gt;Not every problem is a signal
&lt;/h2&gt;&lt;p&gt;With no flag, the next question was what should turn the exit code non-zero, and that one wasn&amp;rsquo;t obvious.&lt;/p&gt;
&lt;p&gt;&lt;code&gt;apply&lt;/code&gt; counts two kinds of trouble. &lt;strong&gt;Failed&lt;/strong&gt; means the forge refused: a 404 from a mistyped page, a 403 from a token without the scope. Actionable, and usually somebody&amp;rsquo;s mistake. &lt;strong&gt;Skipped&lt;/strong&gt; means the forge can&amp;rsquo;t do the thing at all. Bitbucket has no wiki, Gitea&amp;rsquo;s adapter has no comment capability, and that&amp;rsquo;s a fact about the forge, not about the commit.&lt;/p&gt;
&lt;p&gt;Gate on both and a repository on Gitea reports a problem on each merge, for ever, that no change to the repository could ever clear. That&amp;rsquo;s the definition of a signal people learn to ignore, and it would have made the exit code useless on three of the four forges colophon supports. So &lt;a class="link" href="https://gitlab.com/phpboyscout/colophon/-/blob/e3a1ae2e/pkg/cmd/apply/main.go#L160-175" target="_blank" rel="noopener"
 &gt;only &lt;code&gt;Failed&lt;/code&gt; turns the exit non-zero&lt;/a&gt;, and the summary line still counts both, because a skipped instruction is worth a person&amp;rsquo;s attention once.&lt;/p&gt;
&lt;h2 id="through-the-front-door"&gt;Through the front door
&lt;/h2&gt;&lt;p&gt;My other question, the one about the error handler, turned into a decision of its own. The verb returns an error from its command and does nothing else. There&amp;rsquo;s no &lt;code&gt;os.Exit&lt;/code&gt; anywhere in colophon.&lt;/p&gt;
&lt;p&gt;That matters more than it looks. The framework&amp;rsquo;s root &lt;code&gt;Execute&lt;/code&gt; does four things after the command tree returns, in order: it runs cleanup, which stops the config-watcher goroutines; it asks the error handler for the exit code; it flushes telemetry; and then it exits. An &lt;code&gt;os.Exit&lt;/code&gt; inside a command skips all four, including the signal-aware path that exits 128 plus the signal number. None of that shows up in a passing test, which is why it&amp;rsquo;s written into the spec instead of left for whoever&amp;rsquo;s next in that file with a deadline.&lt;/p&gt;
&lt;h2 id="its-a-breaking-change-so-say-so"&gt;It&amp;rsquo;s a breaking change, so say so
&lt;/h2&gt;&lt;p&gt;A repository running &lt;code&gt;apply&lt;/code&gt; without &lt;code&gt;allow_failure&lt;/code&gt; had a green pipeline before this and a red one after it. That&amp;rsquo;s the correct behaviour and it&amp;rsquo;s still something a person can be ambushed by, so it shipped with a &lt;code&gt;Release-Note:&lt;/code&gt; trailer on the commit naming &lt;code&gt;allow_failure&lt;/code&gt; as the line that restores the old colour. Colophon is below 1.0, and a breaking-change footer down there cuts v1.0.0, which this didn&amp;rsquo;t deserve.&lt;/p&gt;
&lt;p&gt;Building it turned up two small things. The spec had promised no output would change, and it had to, because the summary line said &lt;em&gt;&amp;ldquo;nothing here failed the pipeline&amp;rdquo;&lt;/em&gt;, and after this change that sentence was false. It now says which of the two happened. And three tests were pinning the old decision, two of which had been written an hour earlier for an unrelated ticket and used &lt;code&gt;require.NoError&lt;/code&gt; because that had been true when they were written.&lt;/p&gt;
&lt;h2 id="the-one-that-stays-at-zero"&gt;The one that stays at zero
&lt;/h2&gt;&lt;p&gt;The counter-example is in the same tool, and it&amp;rsquo;s what stops this becoming a slogan. colophon&amp;rsquo;s &lt;code&gt;announce&lt;/code&gt; verb, which posts a release to chat channels and to this blog&amp;rsquo;s changelog, still exits zero when an adapter fails. On purpose. By the time it runs the release is already public, nothing downstream gates on whether Discord took the message, and no caller is waiting to find out.&lt;/p&gt;
&lt;p&gt;&lt;code&gt;apply&lt;/code&gt; gets the non-zero exit because a job wants to gate on it. &lt;code&gt;announce&lt;/code&gt; doesn&amp;rsquo;t, because nothing does. That&amp;rsquo;s a less satisfying rule than &amp;ldquo;always exit non-zero on failure&amp;rdquo;, and I think it&amp;rsquo;s the truer one. Report and carry on, by all means&amp;hellip; just don&amp;rsquo;t report and succeed.&lt;/p&gt;</description></item></channel></rss>