Read a registry failure as a failure in the other publish checks

The crates.io check went wrong by treating every unhappy answer as "not published yet". npm, PyPI and NuGet were written the same way. None of them misbehave today, since only crates.io gates on User-Agent, but an outage or a blocked request would walk each of them into the same publish error. PyPI and NuGet now branch on the HTTP status: 200 skips the publish, 404 runs it, anything else stops the job. NuGet's fetch and version match are split apart too, because piping curl into grep hid a failed fetch behind an empty match. A 404 there means the package has no versions at all, which is a real "go ahead" rather than an error. npm has no status code to read, so the two publish steps keep using "npm view" but only accept an E404 as proof the version is missing. Checked against the live registries: 1.5.6 reports published on all three, 99.99.99 reports missing, and npm answers E404 both for an unknown version and an unknown package.

paydii committed Sep 4, 2026 at 15:47 UTC a04cfa27f48ad84016a6043cadb536bb12250fc2
3 files changed +65 -14
.github/workflows/release-npm.yml
+17 -2
@@ -166,9 +166,18 @@ jobs:
166 cd "${node_pkg}"
167
168 npm_package_name="@getsigit/${node_pkg}"
169 - if npm view "${npm_package_name}@${node_version}" version >/dev/null 2>&1; then
169 +
170 + # `npm view` exits non-zero both when the version is genuinely
171 + # unpublished and when the registry could not be reached, so treat
172 + # only an E404 as "not published yet". Anything else stops the job
173 + # instead of publishing over a version that is already there.
174 + if view_output=$(npm view "${npm_package_name}@${node_version}" version 2>&1); then
175 echo "${npm_package_name}@${node_version} already exists on npm, skipping publish"
176 exit 0
177 + elif ! grep -q "E404" <<<"${view_output}"; then
178 + echo "npm view failed while checking ${npm_package_name}@${node_version}:" >&2
179 + echo "${view_output}" >&2
180 + exit 1
181 fi
182
183 npm publish --access public
@@ -222,9 +231,15 @@ jobs:
231 npm_package_name="@getsigit/sigit"
232 npm_package_version="${{ env.RELEASE_VERSION }}"
233
225 - if npm view "${npm_package_name}@${npm_package_version}" version >/dev/null 2>&1; then
234 + # Same E404-only reading as the platform packages above: a registry
235 + # failure must not look like an unpublished version.
236 + if view_output=$(npm view "${npm_package_name}@${npm_package_version}" version 2>&1); then
237 echo "${npm_package_name}@${npm_package_version} already exists on npm, skipping publish"
238 exit 0
239 + elif ! grep -q "E404" <<<"${view_output}"; then
240 + echo "npm view failed while checking ${npm_package_name}@${npm_package_version}:" >&2
241 + echo "${view_output}" >&2
242 + exit 1
243 fi
244
245 npm install
.github/workflows/release-nuget.yml
+28 -6
@@ -296,12 +296,34 @@ jobs:
296 shell: bash
297 run: |
298 package_index_url="https://api.nuget.org/v3-flatcontainer/sigit.code/index.json"
299 - if curl -fsS "${package_index_url}" | grep -F "\"${RELEASE_VERSION}\"" >/dev/null; then
300 - echo "${NUGET_PACKAGE_ID} ${RELEASE_VERSION} already exists on NuGet, skipping publish"
301 - echo "exists=true" >> "$GITHUB_OUTPUT"
302 - else
303 - echo "exists=false" >> "$GITHUB_OUTPUT"
304 - fi
299 +
300 + # Keep the fetch and the version match separate. Piping curl into
301 + # grep hides a failed fetch behind an empty match, so an outage or a
302 + # blocked request would read as "not published yet" and send the
303 + # publish below into a version that is already there. A 404 here
304 + # means the package itself is not on NuGet, which is the first
305 + # release and a real "not published".
306 + index=$(mktemp)
307 + STATUS=$(curl -sS -o "${index}" -w '%{http_code}' "${package_index_url}")
308 +
309 + case "$STATUS" in
310 + 200)
311 + if grep -F "\"${RELEASE_VERSION}\"" "${index}" >/dev/null; then
312 + echo "${NUGET_PACKAGE_ID} ${RELEASE_VERSION} already exists on NuGet, skipping publish"
313 + echo "exists=true" >> "$GITHUB_OUTPUT"
314 + else
315 + echo "exists=false" >> "$GITHUB_OUTPUT"
316 + fi
317 + ;;
318 + 404)
319 + echo "${NUGET_PACKAGE_ID} is not on NuGet yet, publishing its first version"
320 + echo "exists=false" >> "$GITHUB_OUTPUT"
321 + ;;
322 + *)
323 + echo "Unexpected ${STATUS} from NuGet while checking whether ${NUGET_PACKAGE_ID} ${RELEASE_VERSION} is published" >&2
324 + exit 1
325 + ;;
326 + esac
327
328 # Exchange the workflow's OIDC token for a short-lived NuGet API key. The
329 # key is written to the environment for the subsequent `dotnet nuget push`
.github/workflows/release-pypi.yml
+20 -6
@@ -181,12 +181,26 @@ jobs:
181 id: pypi-check
182 shell: bash
183 run: |
184 - if curl -fsS "https://pypi.org/pypi/sigit-code/${RELEASE_VERSION}/json" >/dev/null 2>&1; then
185 - echo "sigit-code ${RELEASE_VERSION} already exists on PyPI, skipping publish"
186 - echo "exists=true" >> "$GITHUB_OUTPUT"
187 - else
188 - echo "exists=false" >> "$GITHUB_OUTPUT"
189 - fi
184 + # Branch on the status code rather than on curl's exit status: with
185 + # -f every non-2xx answer looks alike, so a registry outage or a
186 + # blocked request would read as "not published yet" and send the
187 + # publish below into a version that is already there.
188 + STATUS=$(curl -sS -o /dev/null -w '%{http_code}' \
189 + "https://pypi.org/pypi/sigit-code/${RELEASE_VERSION}/json")
190 +
191 + case "$STATUS" in
192 + 200)
193 + echo "sigit-code ${RELEASE_VERSION} already exists on PyPI, skipping publish"
194 + echo "exists=true" >> "$GITHUB_OUTPUT"
195 + ;;
196 + 404)
197 + echo "exists=false" >> "$GITHUB_OUTPUT"
198 + ;;
199 + *)
200 + echo "Unexpected ${STATUS} from PyPI while checking whether sigit-code ${RELEASE_VERSION} is published" >&2
201 + exit 1
202 + ;;
203 + esac
204
205 - name: Publish distribution
206 if: steps.pypi-check.outputs.exists != 'true'