From 583dcb9f44a4a72b421d8599fd29d176c92888c2 Mon Sep 17 00:00:00 2001 From: konard Date: Fri, 28 Aug 2026 12:23:46 +0000 Subject: [PATCH 1/7] Initial commit with task details Adding .gitkeep for PR creation (default mode). This file will be removed when the task is complete. Issue: https://github.com/link-foundation/links-notation/issues/298 --- .gitkeep | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.gitkeep b/.gitkeep index d365b986..487e7055 100644 --- a/.gitkeep +++ b/.gitkeep @@ -1,3 +1,4 @@ # .gitkeep file auto-generated at 2026-08-28T06:09:39.810Z for PR creation at branch issue-288-cc91e23553e8 for issue https://github.com/link-foundation/links-notation/issues/288 # Updated: 2026-08-28T07:33:05.436Z -# Updated: 2026-08-28T11:11:17.987Z \ No newline at end of file +# Updated: 2026-08-28T11:11:17.987Z +# Updated: 2026-08-28T12:23:46.151Z \ No newline at end of file From 13b56bf20f605ade98548b50c829814cff034b85 Mon Sep 17 00:00:00 2001 From: konard Date: Fri, 28 Aug 2026 12:37:37 +0000 Subject: [PATCH 2/7] fix(ci): send an identifying User-Agent when probing package registries The rust workflow published links-notation 0.16.0 successfully and then failed itself. `cargo publish` reported "Published links-notation v0.16.0 at registry crates-io" at 11:50:09, and the very next step spent five minutes deciding the crate was not there. The step ran `curl -fsS https://crates.io/api/v1/crates/$NAME/$VERSION`. crates.io answers 403 to curl's default `User-Agent: curl/8.x`, so every one of the twenty attempts failed for a reason that had nothing to do with the release. `>/dev/null 2>&1` then threw the status code away, which is why twenty identical "Not visible yet" lines were the only evidence. The same bare-curl pattern was in eleven places across seven workflows, so rather than patching the one that happened to fail, all of them now go through scripts/ci/registry-probe.sh: - every request carries an identifying User-Agent, as crates.io asks; - the status code of the final attempt is always in the failure message, so 404 (indexing lag) is distinguishable from 403 (broken probe); - CI_VERBOSE=true logs the status of every attempt. Default off. Two related defects in the same job: - the crates.io OIDC step wrote an `::error::` annotation on runs that published fine through CARGO_TOKEN, because trusted publishing is not registered yet. It is now gated on an opt-in repository variable. - the dependency wait polled the JSON API, but `cargo` resolves against the sparse index, and the index answers 200 for any existing crate. wait_for_crate_version matches the version inside the index document. scripts/ci/registry-probe.test.sh reproduces the 403 against a local server, so it needs no network. It fails against the old behaviour. Refs #298 --- .github/workflows/csharp.yml | 23 ++- .github/workflows/go.yml | 15 +- .github/workflows/java.yml | 21 +-- .github/workflows/js.yml | 15 +- .github/workflows/php.yml | 24 ++- .github/workflows/python.yml | 19 +- .github/workflows/rust.yml | 44 ++--- .github/workflows/workflows.yml | 24 +++ .../issue-298/registry-user-agent-probe.sh | 35 ++++ scripts/ci/registry-probe.sh | 164 ++++++++++++++++++ scripts/ci/registry-probe.test.sh | 136 +++++++++++++++ 11 files changed, 432 insertions(+), 88 deletions(-) create mode 100755 experiments/issue-298/registry-user-agent-probe.sh create mode 100755 scripts/ci/registry-probe.sh create mode 100755 scripts/ci/registry-probe.test.sh diff --git a/.github/workflows/csharp.yml b/.github/workflows/csharp.yml index c619a16f..313059d2 100644 --- a/.github/workflows/csharp.yml +++ b/.github/workflows/csharp.yml @@ -150,6 +150,8 @@ jobs: id: version-check run: | set -euo pipefail + # shellcheck source=scripts/ci/registry-probe.sh + . "$GITHUB_WORKSPACE/scripts/ci/registry-probe.sh" PACKAGE_VERSION=$(sed -n 's|.*\(.*\).*|\1|p' "$PROJECT_PATH") if [ -z "$PACKAGE_VERSION" ]; then echo "::error::Could not read from $PROJECT_PATH" @@ -161,7 +163,7 @@ jobs: echo "Package: $PACKAGE_ID@$PACKAGE_VERSION" # Flat-container URLs are lowercase-only. - if curl -fsS "https://api.nuget.org/v3-flatcontainer/$PACKAGE_ID_LOWER/$PACKAGE_VERSION/$PACKAGE_ID_LOWER.nuspec" > /dev/null 2>&1; then + if probe_registry "https://api.nuget.org/v3-flatcontainer/$PACKAGE_ID_LOWER/$PACKAGE_VERSION/$PACKAGE_ID_LOWER.nuspec"; then echo "Version $PACKAGE_VERSION already exists on NuGet.org" echo "should_publish=false" >> "$GITHUB_OUTPUT" else @@ -207,18 +209,15 @@ jobs: PACKAGE_VERSION: ${{ steps.version-check.outputs.version }} run: | set -euo pipefail + # shellcheck source=scripts/ci/registry-probe.sh + . "$GITHUB_WORKSPACE/scripts/ci/registry-probe.sh" # NuGet.org indexes asynchronously; poll rather than assume success. - for attempt in $(seq 1 20); do - if curl -fsS "https://api.nuget.org/v3-flatcontainer/${PACKAGE_ID_LOWER}/index.json" \ - | grep -q "\"${PACKAGE_VERSION}\""; then - echo "Verified ${PACKAGE_ID}@${PACKAGE_VERSION} on NuGet.org (attempt ${attempt})" - exit 0 - fi - echo "Not indexed yet, retrying in 30s (attempt ${attempt}/20)" - sleep 30 - done - echo "::error::${PACKAGE_ID}@${PACKAGE_VERSION} did not appear on NuGet.org within 10 minutes" - exit 1 + # The flat container exposes one document per package, so the body + # has to be matched -- a 200 only proves the package exists at all. + wait_for_registry_match \ + "${PACKAGE_ID}@${PACKAGE_VERSION} on NuGet.org" \ + "https://api.nuget.org/v3-flatcontainer/${PACKAGE_ID_LOWER}/index.json" \ + "\"${PACKAGE_VERSION}\"" 20 30 generatePdfWithCode: runs-on: ubuntu-latest diff --git a/.github/workflows/go.yml b/.github/workflows/go.yml index bd959c68..f5306f84 100644 --- a/.github/workflows/go.yml +++ b/.github/workflows/go.yml @@ -212,15 +212,10 @@ jobs: PACKAGE_VERSION: ${{ steps.version-check.outputs.version }} run: | set -euo pipefail + # shellcheck source=scripts/ci/registry-probe.sh + . "$GITHUB_WORKSPACE/scripts/ci/registry-probe.sh" # The proxy only fetches a version once someone asks for it, so this # also warms it for the first consumer. - for attempt in $(seq 1 20); do - if curl -fsS "https://proxy.golang.org/${MODULE}/@v/v${PACKAGE_VERSION}.info" >/dev/null 2>&1; then - echo "Verified ${MODULE}@v${PACKAGE_VERSION} on proxy.golang.org (attempt ${attempt})" - exit 0 - fi - echo "Not on the proxy yet, retrying in 15s (attempt ${attempt}/20)" - sleep 15 - done - echo "::error::${MODULE}@v${PACKAGE_VERSION} did not appear on proxy.golang.org within 5 minutes" - exit 1 + wait_for_registry \ + "${MODULE}@v${PACKAGE_VERSION} on proxy.golang.org" \ + "https://proxy.golang.org/${MODULE}/@v/v${PACKAGE_VERSION}.info" 20 15 diff --git a/.github/workflows/java.yml b/.github/workflows/java.yml index 98a4c95b..e1e7b77a 100644 --- a/.github/workflows/java.yml +++ b/.github/workflows/java.yml @@ -187,6 +187,8 @@ jobs: id: version-check run: | set -euo pipefail + # shellcheck source=scripts/ci/registry-probe.sh + . "$GITHUB_WORKSPACE/scripts/ci/registry-probe.sh" PACKAGE_VERSION=$(mvn help:evaluate -Dexpression=project.version -q -DforceStdout) PACKAGE_GROUP=$(mvn help:evaluate -Dexpression=project.groupId -q -DforceStdout) PACKAGE_ARTIFACT=$(mvn help:evaluate -Dexpression=project.artifactId -q -DforceStdout) @@ -198,7 +200,7 @@ jobs: echo "Package: $PACKAGE_GROUP:$PACKAGE_ARTIFACT:$PACKAGE_VERSION" URL="https://repo1.maven.org/maven2/$(echo "$PACKAGE_GROUP" | tr '.' '/')/$PACKAGE_ARTIFACT/$PACKAGE_VERSION/" - if curl --head --silent --fail "$URL" > /dev/null 2>&1; then + if probe_registry "$URL"; then echo "Version $PACKAGE_VERSION already exists on Maven Central" echo "should_publish=false" >> "$GITHUB_OUTPUT" else @@ -235,18 +237,13 @@ jobs: PACKAGE_VERSION: ${{ steps.version-check.outputs.version }} run: | set -euo pipefail + # shellcheck source=scripts/ci/registry-probe.sh + . "$GITHUB_WORKSPACE/scripts/ci/registry-probe.sh" GROUP_PATH=$(echo "$PACKAGE_GROUP" | tr '.' '/') - URL="https://repo1.maven.org/maven2/${GROUP_PATH}/${PACKAGE_ARTIFACT}/${PACKAGE_VERSION}/${PACKAGE_ARTIFACT}-${PACKAGE_VERSION}.pom" - for attempt in $(seq 1 30); do - if curl --head --silent --fail "$URL" > /dev/null 2>&1; then - echo "Verified ${PACKAGE_GROUP}:${PACKAGE_ARTIFACT}:${PACKAGE_VERSION} on Maven Central (attempt ${attempt})" - exit 0 - fi - echo "Not synced yet, retrying in 30s (attempt ${attempt}/30)" - sleep 30 - done - echo "::error::${PACKAGE_GROUP}:${PACKAGE_ARTIFACT}:${PACKAGE_VERSION} did not appear on Maven Central within 15 minutes" - exit 1 + wait_for_registry \ + "${PACKAGE_GROUP}:${PACKAGE_ARTIFACT}:${PACKAGE_VERSION} on Maven Central" \ + "https://repo1.maven.org/maven2/${GROUP_PATH}/${PACKAGE_ARTIFACT}/${PACKAGE_VERSION}/${PACKAGE_ARTIFACT}-${PACKAGE_VERSION}.pom" \ + 30 30 publishRelease: runs-on: ubuntu-latest diff --git a/.github/workflows/js.yml b/.github/workflows/js.yml index dfc15004..8b95d1e2 100644 --- a/.github/workflows/js.yml +++ b/.github/workflows/js.yml @@ -237,16 +237,11 @@ jobs: PACKAGE_VERSION: ${{ steps.version-check.outputs.version }} run: | set -euo pipefail - for attempt in $(seq 1 10); do - if curl -fsS "https://registry.npmjs.org/${PACKAGE_NAME}/${PACKAGE_VERSION}" >/dev/null 2>&1; then - echo "Verified ${PACKAGE_NAME}@${PACKAGE_VERSION} on npm (attempt ${attempt})" - exit 0 - fi - echo "Not visible yet, retrying in 15s (attempt ${attempt}/10)" - sleep 15 - done - echo "::error::${PACKAGE_NAME}@${PACKAGE_VERSION} did not appear on the npm registry within 2.5 minutes" - exit 1 + # shellcheck source=scripts/ci/registry-probe.sh + . "$GITHUB_WORKSPACE/scripts/ci/registry-probe.sh" + wait_for_registry \ + "${PACKAGE_NAME}@${PACKAGE_VERSION} on npm" \ + "https://registry.npmjs.org/${PACKAGE_NAME}/${PACKAGE_VERSION}" 10 15 publishRelease: runs-on: ubuntu-latest diff --git a/.github/workflows/php.yml b/.github/workflows/php.yml index a6502545..e870b401 100644 --- a/.github/workflows/php.yml +++ b/.github/workflows/php.yml @@ -150,6 +150,8 @@ jobs: id: version-check run: | set -euo pipefail + # shellcheck source=scripts/ci/registry-probe.sh + . "$GITHUB_WORKSPACE/scripts/ci/registry-probe.sh" PACKAGE_NAME=$(php -r 'echo json_decode(file_get_contents("composer.json"))->name;') PACKAGE_VERSION=$(php -r 'echo json_decode(file_get_contents("composer.json"))->version;') echo "name=$PACKAGE_NAME" >> "$GITHUB_OUTPUT" @@ -159,7 +161,7 @@ jobs: # Distinguish "package is not on Packagist at all" from "this version # is not there yet". The old check collapsed both into should_publish # and then the update call quietly did nothing. - if ! METADATA=$(curl -fsS "https://repo.packagist.org/p2/${PACKAGE_NAME}.json" 2>/dev/null); then + if ! METADATA=$(fetch_registry "https://repo.packagist.org/p2/${PACKAGE_NAME}.json"); then echo "package_known=false" >> "$GITHUB_OUTPUT" echo "should_publish=true" >> "$GITHUB_OUTPUT" exit 0 @@ -217,18 +219,14 @@ jobs: PACKAGE_VERSION: ${{ steps.version-check.outputs.version }} run: | set -euo pipefail - # Packagist crawls asynchronously after the update call returns 202. - for attempt in $(seq 1 20); do - if curl -fsS "https://repo.packagist.org/p2/${PACKAGE_NAME}.json" \ - | grep -q "\"version\":\"${PACKAGE_VERSION}\""; then - echo "Verified ${PACKAGE_NAME}@${PACKAGE_VERSION} on Packagist (attempt ${attempt})" - exit 0 - fi - echo "Not crawled yet, retrying in 30s (attempt ${attempt}/20)" - sleep 30 - done - echo "::error::${PACKAGE_NAME}@${PACKAGE_VERSION} did not appear on Packagist within 10 minutes" - exit 1 + # shellcheck source=scripts/ci/registry-probe.sh + . "$GITHUB_WORKSPACE/scripts/ci/registry-probe.sh" + # Packagist crawls asynchronously after the update call returns 202, + # and serves every version from one document, so match the body. + wait_for_registry_match \ + "${PACKAGE_NAME}@${PACKAGE_VERSION} on Packagist" \ + "https://repo.packagist.org/p2/${PACKAGE_NAME}.json" \ + "\"version\":\"${PACKAGE_VERSION}\"" 20 30 publishRelease: runs-on: ubuntu-latest diff --git a/.github/workflows/python.yml b/.github/workflows/python.yml index 5d04d2ca..e3812752 100644 --- a/.github/workflows/python.yml +++ b/.github/workflows/python.yml @@ -157,6 +157,8 @@ jobs: id: version-check run: | set -euo pipefail + # shellcheck source=scripts/ci/registry-probe.sh + . "$GITHUB_WORKSPACE/scripts/ci/registry-probe.sh" read -r PACKAGE_NAME PACKAGE_VERSION <<<"$(python -c " import tomllib with open('pyproject.toml','rb') as f: @@ -170,7 +172,7 @@ jobs: # The JSON API is an exact lookup. The previous `pip index versions | # grep` matched substrings, so 0.1.0 looked published once 0.1.0.1 # existed. - if curl -fsS "https://pypi.org/pypi/${PACKAGE_NAME}/${PACKAGE_VERSION}/json" >/dev/null 2>&1; then + if probe_registry "https://pypi.org/pypi/${PACKAGE_NAME}/${PACKAGE_VERSION}/json"; then echo "Version $PACKAGE_VERSION already exists on PyPI" echo "should_publish=false" >> "$GITHUB_OUTPUT" else @@ -201,16 +203,11 @@ jobs: PACKAGE_VERSION: ${{ steps.version-check.outputs.version }} run: | set -euo pipefail - for attempt in $(seq 1 10); do - if curl -fsS "https://pypi.org/pypi/${PACKAGE_NAME}/${PACKAGE_VERSION}/json" >/dev/null 2>&1; then - echo "Verified ${PACKAGE_NAME}@${PACKAGE_VERSION} on PyPI (attempt ${attempt})" - exit 0 - fi - echo "Not visible yet, retrying in 15s (attempt ${attempt}/10)" - sleep 15 - done - echo "::error::${PACKAGE_NAME}@${PACKAGE_VERSION} did not appear on PyPI within 2.5 minutes" - exit 1 + # shellcheck source=scripts/ci/registry-probe.sh + . "$GITHUB_WORKSPACE/scripts/ci/registry-probe.sh" + wait_for_registry \ + "${PACKAGE_NAME}@${PACKAGE_VERSION} on PyPI" \ + "https://pypi.org/pypi/${PACKAGE_NAME}/${PACKAGE_VERSION}/json" 10 15 publishRelease: runs-on: ubuntu-latest diff --git a/.github/workflows/rust.yml b/.github/workflows/rust.yml index 6ee916bb..daaa04cd 100644 --- a/.github/workflows/rust.yml +++ b/.github/workflows/rust.yml @@ -150,10 +150,17 @@ jobs: - name: Build run: cargo build --release # Exchanges the workflow's OIDC identity for a short-lived crates.io - # token. Returns an empty token when trusted publishing is not - # configured for the crate, which the publish step reports explicitly. + # token. Trusted publishing has to be registered on crates.io before it + # can be used; until that is done the action fails with + # "No Trusted Publishing config found" and, because it writes an + # `::error::` annotation, paints a red cross on a run that then publishes + # perfectly well through CARGO_TOKEN. Gating on an opt-in repository + # variable keeps that false positive out of the run summary. Set the + # `CRATES_IO_TRUSTED_PUBLISHING` variable to `true` after configuring + # https://crates.io/crates/links-notation/settings to switch over. - name: Authenticate to crates.io id: cratesio-auth + if: ${{ vars.CRATES_IO_TRUSTED_PUBLISHING == 'true' }} continue-on-error: true uses: rust-lang/crates-io-auth-action@v1 - name: Publish to crates.io @@ -165,6 +172,8 @@ jobs: CARGO_TOKEN: ${{ secrets.CARGO_TOKEN }} run: | set -euo pipefail + # shellcheck source=scripts/ci/registry-probe.sh + . "$GITHUB_WORKSPACE/scripts/ci/registry-probe.sh" read_field() { grep "^$2 = " "$1" | head -1 | sed "s/$2 = \"\(.*\)\"/\1/"; } MACRO_VERSION=$(read_field links-notation-macro/Cargo.toml version) @@ -209,14 +218,11 @@ jobs: if [ $MACRO_STATUS -eq 1 ]; then exit 1; fi if [ $MACRO_STATUS -eq 0 ]; then - for attempt in $(seq 1 20); do - if curl -fsS "https://crates.io/api/v1/crates/links-notation-macro/${MACRO_VERSION}" >/dev/null 2>&1; then - echo "links-notation-macro@${MACRO_VERSION} is visible in the index" - break - fi - echo "Waiting for the index to catch up (attempt ${attempt}/20)" - sleep 15 - done + # The sparse index, not the JSON API, is what `cargo publish` + # resolves the dependency against, so that is what has to catch up. + if ! wait_for_crate_version links-notation-macro "$MACRO_VERSION" 20 15; then + echo "::warning::Continuing anyway; cargo will fail explicitly if the dependency is still unresolvable" + fi fi set +e @@ -235,16 +241,14 @@ jobs: PACKAGE_VERSION: ${{ steps.publish.outputs.version }} run: | set -euo pipefail - for attempt in $(seq 1 20); do - if curl -fsS "https://crates.io/api/v1/crates/${PACKAGE_NAME}/${PACKAGE_VERSION}" >/dev/null 2>&1; then - echo "Verified ${PACKAGE_NAME}@${PACKAGE_VERSION} on crates.io (attempt ${attempt})" - exit 0 - fi - echo "Not visible yet, retrying in 15s (attempt ${attempt}/20)" - sleep 15 - done - echo "::error::${PACKAGE_NAME}@${PACKAGE_VERSION} did not appear on crates.io within 5 minutes" - exit 1 + # shellcheck source=scripts/ci/registry-probe.sh + . "$GITHUB_WORKSPACE/scripts/ci/registry-probe.sh" + # Check the index first: it is the artifact consumers actually + # resolve against, and unlike the JSON API it is not rate limited. + wait_for_crate_version "$PACKAGE_NAME" "$PACKAGE_VERSION" 20 15 + wait_for_registry \ + "${PACKAGE_NAME}@${PACKAGE_VERSION} on crates.io" \ + "https://crates.io/api/v1/crates/${PACKAGE_NAME}/${PACKAGE_VERSION}" 20 15 publishRelease: runs-on: ubuntu-latest diff --git a/.github/workflows/workflows.yml b/.github/workflows/workflows.yml index 38516981..81a85745 100644 --- a/.github/workflows/workflows.yml +++ b/.github/workflows/workflows.yml @@ -11,9 +11,11 @@ on: branches: main paths: - '.github/**' + - 'scripts/ci/**' pull_request: paths: - '.github/**' + - 'scripts/ci/**' workflow_dispatch: permissions: @@ -40,6 +42,28 @@ jobs: with: args: -color + ci-scripts: + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - uses: actions/checkout@v7 + with: + persist-credentials: false + # actionlint only reaches shell that is inlined in a `run:` block, so the + # shared helper the publish workflows source needs its own gate. + - name: Run shellcheck + run: | + set -euo pipefail + sudo apt-get update + sudo apt-get install --yes shellcheck + shellcheck -x scripts/ci/*.sh + # Guards the fix for issue #298: the registry probes have to send an + # identifying User-Agent, because crates.io answers 403 without one. The + # test runs against a local server, so it needs no network and cannot go + # flaky. + - name: Test the registry probe + run: bash scripts/ci/registry-probe.test.sh + zizmor: runs-on: ubuntu-latest timeout-minutes: 10 diff --git a/experiments/issue-298/registry-user-agent-probe.sh b/experiments/issue-298/registry-user-agent-probe.sh new file mode 100755 index 00000000..f191b513 --- /dev/null +++ b/experiments/issue-298/registry-user-agent-probe.sh @@ -0,0 +1,35 @@ +#!/usr/bin/env bash +# Reproduces the root cause of https://github.com/link-foundation/links-notation/issues/298 +# +# crates.io rejects requests that carry curl's default `User-Agent: curl/8.x` +# with HTTP 403. Every other registry used by this repository answers 200. +# +# `rust.yml` verified a freshly published crate with a bare `curl -fsS ...`, +# so the verification could never succeed: the crate was published, the poll +# 403'd twenty times in a row, and the `rust` workflow reported a failure for a +# release that had actually gone out. A false negative. +# +# Usage: experiments/issue-298/registry-user-agent-probe.sh +set -uo pipefail + +UA='links-notation-ci (https://github.com/link-foundation/links-notation)' + +probe() { + local label="$1" url="$2" + local bare with + bare=$(curl -sS -o /dev/null -w '%{http_code}' "$url" 2>/dev/null || echo 'ERR') + with=$(curl -sS -o /dev/null -w '%{http_code}' -H "User-Agent: ${UA}" "$url" 2>/dev/null || echo 'ERR') + printf '%-12s default-UA=%-4s explicit-UA=%-4s %s\n' "$label" "$bare" "$with" \ + "$([ "$bare" = "$with" ] && echo 'same' || echo '<-- DIFFERS')" +} + +echo 'Registry reachability with curl'"'"'s default User-Agent vs an explicit one:' +echo +probe crates.io "https://crates.io/api/v1/crates/links-notation/0.16.0" +probe npm "https://registry.npmjs.org/links-notation" +probe pypi "https://pypi.org/pypi/links-notation/json" +probe nuget "https://api.nuget.org/v3-flatcontainer/link.foundation.links.notation/index.json" +probe goproxy "https://proxy.golang.org/github.com/link-foundation/links-notation/go/@v/list" +probe packagist "https://repo.packagist.org/p2/link-foundation/links-notation.json" +echo +echo 'Expected: only crates.io differs (403 without a User-Agent, 200 with one).' diff --git a/scripts/ci/registry-probe.sh b/scripts/ci/registry-probe.sh new file mode 100755 index 00000000..77189e00 --- /dev/null +++ b/scripts/ci/registry-probe.sh @@ -0,0 +1,164 @@ +#!/usr/bin/env bash +# Shared registry HTTP probe for the publish workflows. +# +# Every language workflow polls its package registry to confirm that a release +# really landed. Those polls used to be bare `curl -fsS "$URL" >/dev/null 2>&1` +# calls, which had two defects: +# +# 1. crates.io answers 403 to curl's default `User-Agent: curl/8.x`, so the +# rust workflow could never verify a crate it had just published +# successfully. See https://github.com/link-foundation/links-notation/issues/298 +# and https://github.com/rust-lang/crates.io/issues/13482. +# 2. `>/dev/null 2>&1` discarded the status code, so twenty identical +# "Not visible yet" lines were the only evidence in the log and the real +# cause (403, not 404) was invisible. +# +# Both are fixed here, once, so the individual workflows cannot drift apart +# again. +# +# probe_registry -> 0 when the URL answers 2xx +# wait_for_registry