diff --git a/.github/workflows/intake.yml b/.github/workflows/intake.yml index d77904f..ffc36c7 100644 --- a/.github/workflows/intake.yml +++ b/.github/workflows/intake.yml @@ -40,13 +40,23 @@ jobs: run: | set -eu printf '%s' "$BODY" > "$RUNNER_TEMP/body.md" - ./tools/intake-check.sh "$RUNNER_TEMP/body.md" > "$RUNNER_TEMP/verdict.md" || { + rc=0 + ./tools/intake-check.sh "$RUNNER_TEMP/body.md" > "$RUNNER_TEMP/verdict.md" || rc=$? + # 8 is an upstream outage (tools/net.sh): nothing was decided, and the + # requester has nothing to fix. Anything else non-zero is a bug here. + if [ "$rc" -eq 8 ]; then + { + echo "## Automated intake check" + echo + echo "GitHub or the manifest's host did not answer while this ran, so nothing was checked yet. You do not need to change anything; editing the issue or a maintainer's rerun checks it again." + } > "$RUNNER_TEMP/verdict.md" + elif [ "$rc" -ne 0 ]; then { echo "## Automated intake check" echo echo "The check itself failed to run. That is a bug here, not in your request." } > "$RUNNER_TEMP/verdict.md" - } + fi cat "$RUNNER_TEMP/verdict.md" gh issue comment "${{ github.event.issue.number }}" \ --repo "${{ github.repository }}" \ diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml index 3aded23..757a0b3 100644 --- a/.github/workflows/pr.yml +++ b/.github/workflows/pr.yml @@ -96,7 +96,7 @@ jobs: steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: Self-test the scripts under tools/ - run: sh tools/test-sources.sh && sh tools/test-land-updates.sh && sh tools/test-check-updates.sh + run: sh tools/test-sources.sh && sh tools/test-land-updates.sh && sh tools/test-check-updates.sh && sh tools/test-net.sh check: needs: tools diff --git a/RUNBOOK.md b/RUNBOOK.md index a5039a2..578522e 100644 --- a/RUNBOOK.md +++ b/RUNBOOK.md @@ -134,8 +134,19 @@ nothing reported. container output: usually a dependency that does not resolve on a stock image, or a file installed somewhere nothing looks. -**`sha256 … pinned …`** in the fetch step. The upstream replaced a release in place. Do not update -the pin to make it pass — find out why the bytes changed first. +**`!! upstream outage: …` and exit code 8** in the fetch step. GitHub or another upstream answered +5xx, 429, timed out, or could not be reached, and kept doing so for five attempts over about 75 +seconds. Nothing about the package was found wrong. Rerun the job once +[githubstatus.com](https://www.githubstatus.com) is green. The same message comes from +`check-updates.sh`, `land-updates.sh` and the intake job, all through `tools/net.sh`. Before this +change the symptom was `curl: (22) The requested URL returned error: 500` repeated six times and +`exit code 22` — also an outage, also a rerun. + +**`curl: (22) … 404` and exit code 7** in the fetch step. The asset is not in the release: the tag +or the file name in `upstream.sh` is wrong, or upstream deleted it. A rerun gets the same answer. + +**`sha256 … pinned …`** in the fetch step, exit code 7. The upstream replaced a release in place. Do +not update the pin to make it pass — find out why the bytes changed first. **`NO ORIGIN …`** from `tools/check-origin.sh`, after the index is built. A package reached the tree without saying where it comes from, and this feed does not publish it. Nothing here fixes that: the @@ -145,8 +156,8 @@ somewhere a user can go. **`tools` failed.** A self-test of a script under `tools/` is red, and `check` did not start. The log prints `FAIL` beside the broken case. Run the tests locally before you push a change to `tools/`: -`sh tools/test-sources.sh`, `sh tools/test-land-updates.sh`, `sh tools/test-check-updates.sh`. They -need `git` and `jq` and reach no network. +`sh tools/test-sources.sh`, `sh tools/test-land-updates.sh`, `sh tools/test-check-updates.sh`, +`sh tools/test-net.sh`. They need `git` and `jq` and reach no network. --- diff --git a/RUNBOOK_ru.md b/RUNBOOK_ru.md index 7a22201..fbb1c06 100644 --- a/RUNBOOK_ru.md +++ b/RUNBOOK_ru.md @@ -134,8 +134,19 @@ MISSING luci-theme-footstrap luci-theme-footstrap_0.11.6-r1_all.ipk on 24.10: ab контейнера: обычно это зависимость, которая не резолвится на стоковом образе, или файл, положенный туда, куда никто не смотрит. -**`sha256 … pinned …`** на шаге fetch. Апстрим подменил релиз на месте. Не правьте пин, чтобы -прошло, — сначала выясните, почему изменились байты. +**`!! upstream outage: …` и код выхода 8** на шаге fetch. GitHub или другой апстрим отвечал 5xx, +429, не отвечал вовсе или не резолвился — и так пять попыток подряд, около 75 секунд. С пакетом +ничего не нашлось. Перезапустите job, когда [githubstatus.com](https://www.githubstatus.com) +позеленеет. То же сообщение печатают `check-updates.sh`, `land-updates.sh` и intake — все ходят +через `tools/net.sh`. До этого изменения симптом выглядел как шесть строк +`curl: (22) The requested URL returned error: 500` и `exit code 22` — тоже сбой апстрима, тоже +перезапуск. + +**`curl: (22) … 404` и код выхода 7** на шаге fetch. Файла нет в релизе: в `upstream.sh` неверный +тег или имя файла, или апстрим его удалил. Перезапуск даст тот же ответ. + +**`sha256 … pinned …`** на шаге fetch, код выхода 7. Апстрим подменил релиз на месте. Не правьте +пин, чтобы прошло, — сначала выясните, почему изменились байты. **`NO ORIGIN …`** от `tools/check-origin.sh`, после сборки индекса. Пакет дошёл до дерева, не сказав, откуда он, и этот фид его не публикует. Здесь это не чинится: поле ставится там, где пакет @@ -144,8 +155,8 @@ MISSING luci-theme-footstrap luci-theme-footstrap_0.11.6-r1_all.ipk on 24.10: ab **Упал `tools`.** Покраснел самотест одного из скриптов в `tools/`, и `check` не запустился. В логе рядом со сломанным случаем стоит `FAIL`. Прогоните тесты у себя, прежде чем пушить правку в `tools/`: -`sh tools/test-sources.sh`, `sh tools/test-land-updates.sh`, `sh tools/test-check-updates.sh`. Им -нужны `git` и `jq`, в сеть они не ходят. +`sh tools/test-sources.sh`, `sh tools/test-land-updates.sh`, `sh tools/test-check-updates.sh`, +`sh tools/test-net.sh`. Им нужны `git` и `jq`, в сеть они не ходят. --- diff --git a/tools/check-updates.sh b/tools/check-updates.sh index 920e7dc..39af9d9 100755 --- a/tools/check-updates.sh +++ b/tools/check-updates.sh @@ -48,6 +48,12 @@ set -eu # can guess a different repository than the one this checkout came from. SELF="${GITHUB_REPOSITORY:-$(gh repo view --json nameWithOwner -q .nameWithOwner)}" +# Every read from an upstream release goes through tools/net.sh: it retries an outage +# and exits 8 when one outlasts it, so "GitHub answered 502" never reads as "this +# release is broken". Resolved beside this file, not from the working directory, +# because the tests run this script from inside a repository with no tools/ in it. +NET="$(cd "$(dirname "$0")" && pwd)/net.sh" + # may_automerge # # A signature says the author published these bytes. It does not say the release is @@ -196,6 +202,7 @@ BASE="$(git rev-parse --abbrev-ref HEAD)" # Packages whose check stopped, reported together once the loop is done. See the end # of the loop for why one of them must not end it. failed="" +outages="" for up in packages/*/upstream.sh; do dir="$(dirname "$up")" @@ -244,18 +251,31 @@ for up in packages/*/upstream.sh; do # fails, the release list; a renamed or deleted upstream is a REPO to fix, # not a quiet hour. Every other failure stops this package through the # handler after the subshell. - if ! latest_tag="$(gh release view --repo "$REPO" --json tagName -q .tagName 2>"$tmp/view.err")"; then - if [ "$(cat "$tmp/view.err")" = "release not found" ] && - gh api "repos/$REPO/releases?per_page=1" -q length >/dev/null 2>"$tmp/list.err"; then - echo "$name: upstream has no releases" - exit 0 + # + # tools/net.sh passes gh's stderr through verbatim when the answer is definite, + # so the `release not found` comparison still sees exactly what gh printed. + rc=0 + latest_tag="$("$NET" gh release view --repo "$REPO" --json tagName -q .tagName 2>"$tmp/view.err")" || rc=$? + if [ "$rc" -ne 0 ]; then + if [ "$rc" -ne 8 ] && [ "$(cat "$tmp/view.err")" = "release not found" ]; then + rc=0 + "$NET" gh api "repos/$REPO/releases?per_page=1" -q length >/dev/null 2>"$tmp/list.err" || rc=$? + if [ "$rc" -eq 0 ]; then + echo "$name: upstream has no releases" + exit 0 + fi fi { echo "$name: could not read the latest release of $REPO" cat "$tmp/view.err" "$tmp/list.err" 2>/dev/null | sed 's/^/ /' echo " failed: gh release view --repo $REPO --json tagName, then gh api repos/$REPO/releases" - echo " a renamed or deleted repository needs REPO fixed in $up; an outage clears on a later run" + if [ "$rc" -eq 8 ]; then + echo " GitHub did not answer after retries: an outage, not a finding; a later run checks it again" + else + echo " a renamed or deleted repository needs REPO fixed in $up" + fi } >&2 + [ "$rc" -ne 8 ] || exit 8 exit 1 fi [ -n "$latest_tag" ] || { echo "$name: gh release view answered an empty tag for $REPO" >&2; exit 1; } @@ -326,7 +346,7 @@ for up in packages/*/upstream.sh; do # none of them would be pure waste. pattern='*' [ "$KIND" = "manifest" ] && pattern='manifest.txt' - gh release download "$latest_tag" --repo "$REPO" --dir "$tmp" --pattern "$pattern" >/dev/null + "$NET" gh release download "$latest_tag" --repo "$REPO" --dir "$tmp" --pattern "$pattern" >/dev/null # Recompute the pins from the bytes the release actually served, rewriting # values in place. Nothing but data changes, so the diff is readable. @@ -518,13 +538,28 @@ can be merged.")"; then # both back: the branch and every tracked file. echo "$name: stopped; the remaining packages are still checked" >&2 git checkout -q -f "$BASE" - failed="$failed $name" + # Exit 8 is tools/net.sh saying GitHub did not answer. Kept apart so the end + # of the run can say which of the two it was. + if [ "$stopped" -eq 8 ]; then + outages="$outages $name" + else + failed="$failed $name" + fi fi done # Still red when anything stopped. Continuing past a failure is about checking the # rest, not about hiding this one: a check that could not run counts as failed. +# +# Exit 8 only when an outage is the whole story. One real failure beside it makes the +# run a 1, because exit 8 tells whoever reads it that a rerun is all it needs. if [ -n "$failed" ]; then echo "check stopped for:$failed" >&2 + [ -z "$outages" ] || echo "and GitHub did not answer for:$outages" >&2 exit 1 fi +if [ -n "$outages" ]; then + echo "check stopped because GitHub did not answer for:$outages" >&2 + echo " an upstream outage, not a finding; the next scheduled run checks them again" >&2 + exit 8 +fi diff --git a/tools/fetch.sh b/tools/fetch.sh index fed1dae..3fe5058 100755 --- a/tools/fetch.sh +++ b/tools/fetch.sh @@ -7,6 +7,11 @@ # start with copying somebody else's shell. # # Usage: tools/fetch.sh packages/ +# +# Exit 8: upstream outage that outlasted the retries in tools/net.sh -- rerun the job. +# Exit 7: a check failed -- a 404 of a pinned asset, a checksum, size or signature +# that does not match. Never rerun that one to make it pass. Anything else: a bug here +# or in upstream.sh. set -eu DIR="${1:?usage: tools/fetch.sh packages/}" @@ -31,21 +36,25 @@ base="https://github.com/${REPO}/releases/download/${TAG}" # get # -# --retry, because a release of ninety-odd assets meets a 502 from the CDN sooner or -# later and a whole publish failing on one is noise, not a finding. Retrying is safe -# here for the reason it usually is not: every byte fetched is checked against a hash -# or a signature afterwards, so a retry cannot smuggle anything past. +# Through tools/net.sh, which retries an outage in place and then exits 8, and exits 7 +# at once for a definite answer such as a 404. A release of ninety-odd assets meets a +# 502 from the CDN sooner or later, and a whole publish failing on one is noise, not a +# finding. Retrying is safe here for the reason it usually is not: every byte fetched +# is checked against a hash or a signature afterwards, so a retry cannot smuggle +# anything past. get() { - curl -fsSL --proto '=https' --tlsv1.2 \ - --retry 5 --retry-delay 2 --retry-connrefused --retry-all-errors \ - -o "$2" "$1" + "$ROOT/tools/net.sh" get "$1" "$2" } # download +# +# A mismatch is exit 7, a failed check: the bytes arrived and are not the pinned ones, +# and no rerun changes that. `|| exit $?` rather than trusting errexit, so the 8 of an +# outage reaches the job unchanged from inside the `while read` loops below too. download() { - get "$1" "$2" + get "$1" "$2" || exit $? got="$(sha256sum "$2" | cut -d' ' -f1)" - [ "$got" = "$3" ] || { echo "$1: sha256 $got, pinned $3" >&2; rm -f "$2"; exit 1; } + [ "$got" = "$3" ] || { echo "$1: sha256 $got, pinned $3" >&2; rm -f "$2"; exit 7; } } # fetch_source @@ -137,7 +146,14 @@ fetch_source() { # known until the index is built -- so tools/sources.sh is what refuses, # per package, once it can read one. Failing here instead would take down # the publish of a permissive package whose upstream simply moved a tag. - if ! get "$url" "$dest"; then + # + # An outage is not that answer. Reading a 503 as "no source archive" would + # record the package as sourceless and, for a copyleft one, turn a GitHub + # incident into a refusal naming the package. So exit 8 goes straight out. + rc=0 + get "$url" "$dest" || rc=$? + [ "$rc" -ne 8 ] || exit 8 + if [ "$rc" -ne 0 ]; then echo "!! $NAME: no source archive at $url" >&2 echo " set SOURCE_URL in packages/$NAME/upstream.sh if the source lives elsewhere;" >&2 echo " if this package is copyleft the publish will refuse it later, by name" >&2 @@ -352,7 +368,7 @@ manifest) download "$base/$file" "$dest/$out" "$sum" got_size="$(wc -c < "$dest/$out" | tr -d ' ')" - [ "$got_size" = "$size" ] || { echo "$file: $got_size bytes, manifest says $size" >&2; exit 1; } + [ "$got_size" = "$size" ] || { echo "$file: $got_size bytes, manifest says $size" >&2; exit 7; } # The manifest's signature already covers this file's hash, so a detached # signature beside it adds nothing here -- it exists for consumers that know diff --git a/tools/intake-check.sh b/tools/intake-check.sh index fb4d0d1..9d30775 100755 --- a/tools/intake-check.sh +++ b/tools/intake-check.sh @@ -15,7 +15,12 @@ # Usage: tools/intake-check.sh > verdict.md set -eu +# +# Exit 8: GitHub or the manifest's host did not answer, so nothing was decided and +# intake.yml says that instead of posting a verdict. A 503 read as "no such release" +# would send a requester looking for a mistake they did not make. BODY="${1:?usage: tools/intake-check.sh }" +NET="$(cd "$(dirname "$0")" && pwd)/net.sh" WORK="$(mktemp -d)" trap 'rm -rf "$WORK"' EXIT @@ -56,7 +61,10 @@ say "\`$REPO\` @ \`$TAG\` — declared shape: \`$KIND\`" say "" # The release has to exist before anything else is worth checking. -if ! gh release view "$TAG" --repo "$REPO" --json tagName >/dev/null 2>&1; then +rc=0 +"$NET" gh release view "$TAG" --repo "$REPO" --json tagName >/dev/null 2>&1 || rc=$? +[ "$rc" -ne 8 ] || exit 8 +if [ "$rc" -ne 0 ]; then bad "No release \`$TAG\` in \`$REPO\`, or the repository is private." say "" say "Nothing else could be checked." @@ -72,8 +80,13 @@ manifest) bad "The manifest shape needs a manifest URL, and none was given." else printf '%s\n' "$PUBKEY" > "$WORK/claimed.pub" - if ! curl -fsSL --max-time 60 "$MF_URL" -o "$WORK/manifest.txt" || - ! curl -fsSL --max-time 60 "$MF_URL.sig" -o "$WORK/manifest.txt.sig"; then + # 60 s per attempt, as before: the URL is the requester's choice, and a + # server that trickles bytes must not hold the job for hours. + rc=0 + NET_MAX_TIME=60 "$NET" get "$MF_URL" "$WORK/manifest.txt" || rc=$? + [ "$rc" -ne 0 ] || NET_MAX_TIME=60 "$NET" get "$MF_URL.sig" "$WORK/manifest.txt.sig" || rc=$? + [ "$rc" -ne 8 ] || exit 8 + if [ "$rc" -ne 0 ]; then bad "Could not fetch the manifest and its \`.sig\` from that URL." else # VERIFY BEFORE READING, exactly as ingest does: every value inside diff --git a/tools/land-updates.sh b/tools/land-updates.sh index a33aa13..771893c 100755 --- a/tools/land-updates.sh +++ b/tools/land-updates.sh @@ -38,6 +38,11 @@ set -eu # guess is a call that can guess a different repository. SELF="${GITHUB_REPOSITORY:-$(gh repo view --json nameWithOwner -q .nameWithOwner)}" +# The reads below go through tools/net.sh, which retries a GitHub outage and exits 8 +# when one outlasts it. Only reads: the push and the delete are git, and a retried +# write is not something to do without knowing whether the first one landed. +NET="$(cd "$(dirname "$0")" && pwd)/net.sh" + # The contexts this script requires before it pushes, spelled exactly as the check # runs report them: `pr.yml`'s job is `check` and it calls owfeed's reusable `feed.yml`, # whose jobs are `build` and `check`, so each name is " / ". @@ -166,11 +171,15 @@ land() { # for every command in it: a failed call left `pr` empty and the function # carried on to push a branch whose pull request was waiting for a person. # Reproduced with a stub `gh` that exits 1 -- tools/test-land-updates.sh. - if ! pr="$(gh pr list -R "$SELF" --head "$branch" --state open --json number -q '.[0].number')"; then + # + # The status is returned as it came, so the 8 of an outage reaches the loop below. + rc=0 + pr="$("$NET" gh pr list -R "$SELF" --head "$branch" --state open --json number -q '.[0].number')" || rc=$? + if [ "$rc" -ne 0 ]; then echo "$branch: could not read its pull requests, so it is not landed this run" echo " failed: gh pr list -R $SELF --head $branch --state open" echo " a branch waiting for a person looks like any other until this answers; the next run asks again" - return 1 + return "$rc" fi if [ -n "$pr" ]; then echo "$branch: pull request #$pr is open; a person merges that one" @@ -197,11 +206,13 @@ land() { # A failed read already falls the safe way -- no runs seen is "no run" -- but it # would say so as "not green yet", which sends whoever reads the log to the # checks instead of to the API call that failed. - if ! checks="$(gh api "repos/$SELF/commits/$sha/check-runs?per_page=100" \ - -q '.check_runs[] | "\(.status)/\(.conclusion // "pending")\t\(.name)"')"; then + rc=0 + checks="$("$NET" gh api "repos/$SELF/commits/$sha/check-runs?per_page=100" \ + -q '.check_runs[] | "\(.status)/\(.conclusion // "pending")\t\(.name)"')" || rc=$? + if [ "$rc" -ne 0 ]; then echo "$branch: could not read its check runs, so it is not landed this run" echo " failed: gh api repos/$SELF/commits/$sha/check-runs" - return 1 + return "$rc" fi # Every run of a required name has to be completed and successful, not just @@ -332,7 +343,7 @@ land() { # `matching-refs` rather than `gh pr list`: these branches have no pull request, and # it answers with the head sha in the same call, so nothing is read twice from a # repository that may change between calls. An empty result is `[]` and a normal day. -refs="$(gh api "repos/$SELF/git/matching-refs/heads/update/" \ +refs="$("$NET" gh api "repos/$SELF/git/matching-refs/heads/update/" \ -q '.[] | "\(.object.sha) \(.ref)"')" if [ -z "$refs" ]; then echo "no update branches" @@ -354,12 +365,22 @@ fi # `failed` survives it; a pipeline would run the loop in a subshell and lose it. # `land` reads nothing from stdin, and `&2 + [ -z "$outages" ] || echo "not landed because GitHub did not answer:$outages" >&2 echo " every other branch was still read; the lines above name the command that failed" >&2 exit 1 fi +if [ -n "$outages" ]; then + echo "not landed because GitHub did not answer:$outages" >&2 + echo " an upstream outage, not a finding; the next scheduled run reads them again" >&2 + exit 8 +fi diff --git a/tools/net.sh b/tools/net.sh new file mode 100755 index 0000000..1a265ab --- /dev/null +++ b/tools/net.sh @@ -0,0 +1,147 @@ +#!/bin/sh +# Reach upstream with bounded retries, and say which kind of failure ended it. +# +# Usage: +# tools/net.sh get download over https with curl +# tools/net.sh gh a READ-ONLY gh call; stdout is passed through +# +# Exit status, the same split owfeed's CLI makes: +# 0 success +# 7 a definite answer: 404, 403, "release not found", a certificate that does not +# verify. Retrying cannot change it, and it is never retried. +# 8 upstream outage: 5xx, 429, 408, a refused or reset connection, DNS, a timeout. +# Retried here first; exit 8 means it outlasted every attempt. Rerunning the job +# later is safe, and nothing about the package was found wrong. +# +# Why this exists: on 2026-09-14 a pull request's `check / build` failed with +# `curl: (22) The requested URL returned error: 500` six times during a GitHub +# incident (run 34836792047). fetch.sh already retried -- five times, two seconds +# apart, ten seconds in all -- and then exited 22, which reads exactly like a +# checksum or signature failure. A rerun passed. +# +# A separate script rather than a sourced function file, because nothing under tools/ +# sources another script and every caller already runs its tools as commands. +# +# Only idempotent reads go through here. `gh pr create` and `gh workflow run` do not: +# a create that timed out after GitHub accepted it would be repeated. +set -eu + +# Attempts, and the first pause between them. The pause doubles: 5, 10, 20, 40 s, so a +# five-attempt run waits 75 s before calling it an outage. The tests set the delay to 0. +attempts="${NET_ATTEMPTS:-5}" +delay="${NET_RETRY_DELAY:-5}" + +mode="${1:?usage: tools/net.sh get | tools/net.sh gh }" +shift + +tmp="$(mktemp -d)" +trap 'rm -rf "$tmp"' EXIT + +# outage +outage() { + { + echo "!! upstream outage: $1" + echo " $2" + echo " tried $attempts times over about $(( delay * ((1 << (attempts - 1)) - 1) ))s; nothing about the package was found wrong" + echo " rerun the job once the service recovers (https://www.githubstatus.com for GitHub)" + } >&2 + # An annotation, so the reason is on the check's summary page and not only deep in + # the log of a step that says "exit code 8". + if [ "${GITHUB_ACTIONS:-}" = "true" ]; then + echo "::error title=Upstream outage (exit 8, safe to rerun)::$1: $2" + fi + exit 8 +} + +# curl_transient +# +# Measured with curl 8.7.1 (macOS) and 8.5.0 (ubuntu:24.04, the runner's) against a +# local stub: every HTTP error under -f is exit 22 whatever the status, so the status +# comes from -w '%{http_code}'; refused connection is 7, unresolvable host 6, timeout 28. +curl_transient() { + case "$1" in + 22) case "$2" in 408|425|429|5[0-9][0-9]) return 0 ;; esac; return 1 ;; + # 5 proxy unresolvable, 6 host unresolvable, 7 connect failed, 16 HTTP/2 framing, + # 18 partial file, 28 timeout, 35 TLS connect, 52 empty reply, 55 send, 56 receive, + # 92 HTTP/2 stream. A certificate failure (60) is not here on purpose: it is + # exactly what an interception looks like, and it does not heal by asking again. + 5|6|7|16|18|28|35|52|55|56|92) return 0 ;; + esac + return 1 +} + +# gh_transient +# +# gh exits 1 for every failure, so the text is all there is. Measured with gh 2.99.0: +# release view, 5xx HTTP 503: (https://api.github.com/repos/...) +# gh api, 5xx gh: (HTTP 503) +# graphql, 5xx non-200 OK status code: 503 ... +# DNS / connect error connecting to check your internet connection ... +# proxy unreachable ... proxyconnect tcp: dial tcp ...: connect: connection refused +# attestation, no Sigstore trust root reachable +# error creating Sigstore verifier: no valid Sigstore verifiers ... +# and the definite ones: `release not found`, `no assets match the file pattern`, +# `gh: Not Found (HTTP 404)`, `HTTP 404: Not Found (...)`. +gh_transient() { + grep -Eqi 'HTTP (408|429|5[0-9][0-9])|status code: (408|429|5[0-9][0-9])|error connecting to|connection (refused|reset)|i/o timeout|TLS handshake timeout|timeout awaiting|no such host|unexpected EOF|server misbehaving|proxyconnect|rate limit|error creating Sigstore verifier' "$1" +} + +case "$mode" in +get) + url="${1:?tools/net.sh get }" + dest="${2:?tools/net.sh get }" + n=1 + wait="$delay" + while :; do + # No --retry: the loop below is the retry, so that a 404 is asked once. With + # --retry-all-errors, which fetch.sh used, curl 8.5.0 asked for a 404 three + # times out of three with --retry 2 -- measured on the stub. + rc=0 + # NET_MAX_TIME caps one attempt, for a URL somebody else chose (intake); + # a release asset gets no cap, because a slow mirror is not a failure. + # shellcheck disable=SC2086 # two words or none, on purpose + code="$(curl -fsSL --proto '=https' --tlsv1.2 --connect-timeout 30 \ + ${NET_MAX_TIME:+--max-time $NET_MAX_TIME} \ + -w '%{http_code}' -o "$dest" "$url" 2>"$tmp/err")" || rc=$? + [ "$rc" -ne 0 ] || exit 0 + rm -f "$dest" + if ! curl_transient "$rc" "$code"; then + cat "$tmp/err" >&2 + echo " failed: curl -fsSL $url (exit $rc, HTTP ${code:-none}); asking again would get the same answer" >&2 + exit 7 + fi + echo ">> $url: attempt $n of $attempts: $(tr '\n' ' ' <"$tmp/err")" >&2 + [ "$n" -lt "$attempts" ] || outage "$url" "curl exit $rc, HTTP ${code:-none}: $(tr '\n' ' ' <"$tmp/err")" + sleep "$wait" + n=$((n + 1)) + wait=$((wait * 2)) + done + ;; +gh) + n=1 + wait="$delay" + while :; do + rc=0 + gh "$@" >"$tmp/out" 2>"$tmp/err" || rc=$? + if [ "$rc" -eq 0 ]; then + cat "$tmp/out" + cat "$tmp/err" >&2 + exit 0 + fi + if ! gh_transient "$tmp/err"; then + # Verbatim: callers compare this text (`release not found`). + cat "$tmp/err" >&2 + exit 7 + fi + echo ">> gh $*: attempt $n of $attempts: $(tr '\n' ' ' <"$tmp/err")" >&2 + [ "$n" -lt "$attempts" ] || outage "gh $*" "$(tr '\n' ' ' <"$tmp/err")" + sleep "$wait" + n=$((n + 1)) + wait=$((wait * 2)) + done + ;; +*) + echo "tools/net.sh: unknown mode $mode; use get or gh" >&2 + exit 2 + ;; +esac diff --git a/tools/test-check-updates.sh b/tools/test-check-updates.sh index bc7aba4..f8df6d7 100755 --- a/tools/test-check-updates.sh +++ b/tools/test-check-updates.sh @@ -14,8 +14,10 @@ # a0-unfetchable upstream names a latest release, and downloading it fails # -> this package stops, every package after it is still checked, # and the run is red naming it -# b0-outage asking for the latest release fails with an HTTP error -# -> stops like a0-unfetchable; never read as "no releases" +# b0-outage asking for the latest release fails with an HTTP 502 +# -> asked 5 times (tools/net.sh), then stops as an outage; never read +# as "no releases". The run exits 8 when nothing else failed +# (fifth run), 1 when something did # b1-missing `release not found`, and the repository itself is gone # -> stops: a REPO to fix, not an upstream with nothing released # f-norelease `release not found` from a repository that exists @@ -75,7 +77,7 @@ mkdir -p "$bin" GH_STATE="$work/state" export GH_STATE -mkdir -p "$GH_STATE/releases" "$GH_STATE/broken" "$GH_STATE/outage" "$GH_STATE/missing" +mkdir -p "$GH_STATE/releases" "$GH_STATE/broken" "$GH_STATE/outage" "$GH_STATE/missing" "$GH_STATE/download-outage" : > "$GH_STATE/downloads" # Stand-in for gh(1). It answers the four calls the script makes and fails on @@ -97,6 +99,7 @@ case "$what" in # error names itself; no releases and no repository both say exactly # `release not found`. s="$(slug "$2")" + printf '%s\n' "$2" >>"$GH_STATE/views" if [ -f "$GH_STATE/outage/$s" ]; then echo "HTTP 502: Bad Gateway (https://api.github.com/repos/$2/releases/latest)" >&2 exit 1 @@ -119,6 +122,10 @@ case "$what" in "release download") # gh release download --repo --dir --pattern printf '%s\n' "$1" >>"$GH_STATE/downloads" + if [ -f "$GH_STATE/download-outage/$(slug "$3")" ]; then + echo "HTTP 503: Service Unavailable (https://api.github.com/repos/$3/releases/tags/$1)" >&2 + exit 1 + fi f="$GH_STATE/releases/$(slug "$3")" if [ -f "$GH_STATE/broken/$(slug "$3")" ] || [ ! -f "$f" ] || [ "$(cat "$f")" != "$1" ]; then echo "release not found" >&2 @@ -223,10 +230,24 @@ git checkout -q main status=0 run() { status=0 - PATH="$bin:$PATH" GITHUB_REPOSITORY="owfeed/test" sh "$script" >"$1" 2>&1 || status=$? + # NET_RETRY_DELAY=0: tools/net.sh still makes every attempt, without the sleeps. + PATH="$bin:$PATH" GITHUB_REPOSITORY="owfeed/test" NET_RETRY_DELAY=0 \ + sh "$script" >"$1" 2>&1 || status=$? sed 's/^/ | /' "$1" } +# exited -- 8 is an outage and 1 a failed check; a reader reruns only the 8. +exited() { + if [ "$status" = "$1" ]; then ok "exit $1: $2"; else no "exited $status, expected $1: $2"; fi +} + +# views -- how many times the latest release of was asked for +views() { + got="$(grep -cxF "$1" "$GH_STATE/views" 2>/dev/null || true)" + if [ "$got" = "$2" ]; then ok "asked for the latest release of $1 $2 time(s)" + else no "asked for the latest release of $1 ${got:-0} time(s), expected $2"; fi +} + # said said() { if grep -qF "$2" "$1"; then ok "said: $2"; else no "never said: $2"; fi @@ -263,12 +284,18 @@ run "$work/out" # a scheduled job reporting that nothing was released when it never asked. if [ "$status" -ne 0 ]; then ok "the run is red"; else no "the run went green with a package it could not check"; fi said "$work/out" "a0-unfetchable: stopped; the remaining packages are still checked" -said "$work/out" "check stopped for: a0-unfetchable b0-outage b1-missing" +said "$work/out" "check stopped for: a0-unfetchable b1-missing" +said "$work/out" "and GitHub did not answer for: b0-outage" +exited 1 "a0-unfetchable and b1-missing are failures, whatever else was an outage" # Not being able to ask is not an answer. Both failures stop their package, and the # one repository that exists and has released nothing is still the green case. said "$work/out" "b0-outage: could not read the latest release of example/b0-outage" said "$work/out" "HTTP 502: Bad Gateway" +said "$work/out" "GitHub did not answer after retries: an outage, not a finding" +# The 502 is asked again; `release not found` from a missing repository is not. +views example/b0-outage 5 +views example/b1-missing 1 unsaid "$work/out" "b0-outage: upstream has no releases" said "$work/out" "b1-missing: could not read the latest release of example/b1-missing" unsaid "$work/out" "b1-missing: upstream has no releases" @@ -376,6 +403,26 @@ rm -f "$GH_STATE/git-fails" said "$work/out4" "c-noprefix: https://example.invalid/pull/1" unsaid "$work/out4" "c-noprefix: update/c-noprefix-2026.09 pushed, no pull request" +# An outage and nothing else: the run says so and exits 8, the code a reader may rerun. +# a0-unfetchable's download now answers 503 instead of `release not found`, and the +# missing repository is back with nothing released. +echo "--- fifth run: GitHub answers 5xx and nothing else is wrong" +rm -f "$GH_STATE/broken/example_a0-unfetchable" "$GH_STATE/missing/example_b1-missing" +: >"$GH_STATE/download-outage/example_a0-unfetchable" +: >"$GH_STATE/downloads" +run "$work/out5" +exited 8 "only GitHub failed" +said "$work/out5" "check stopped because GitHub did not answer for: a0-unfetchable b0-outage" +unsaid "$work/out5" "check stopped for:" +got="$(grep -cxF v1.0.1 "$GH_STATE/downloads" || true)" +if [ "$got" = 5 ]; then ok "the 503 download was attempted 5 times" +else no "the 503 download was attempted ${got:-0} time(s), expected 5"; fi +if [ -z "$(git ls-remote --heads origin 'update/a0-unfetchable-*')" ]; then + ok "a0-unfetchable pushed nothing during the outage" +else + no "a0-unfetchable pushed a branch for a release it could not download" +fi + if [ "$result" = 0 ]; then echo "PASS" else diff --git a/tools/test-land-updates.sh b/tools/test-land-updates.sh index 18dd99e..bb3feb2 100755 --- a/tools/test-land-updates.sh +++ b/tools/test-land-updates.sh @@ -132,6 +132,7 @@ case "${1:-} ${2:-}" in done if [ -f "$GH_STATE/pr-list-fails" ] && { [ ! -s "$GH_STATE/pr-list-fails" ] || [ "$(cat "$GH_STATE/pr-list-fails")" = "$head" ]; }; then + echo "$head" >>"$GH_STATE/pr-list-calls" echo "HTTP 502: Bad Gateway" >&2 exit 1 fi @@ -166,10 +167,17 @@ listing() { git ls-remote --heads origin 'update/*' | tr '\t' ' ' >"$GH_STATE/re status=0 run() { status=0 - PATH="$work/bin:$PATH" GITHUB_REPOSITORY="owfeed/test" sh "$script" >"$1" 2>&1 || status=$? + # NET_RETRY_DELAY=0: tools/net.sh still makes every attempt, without the sleeps. + PATH="$work/bin:$PATH" GITHUB_REPOSITORY="owfeed/test" NET_RETRY_DELAY=0 \ + sh "$script" >"$1" 2>&1 || status=$? sed 's/^/ | /' "$1" } +# exited -- 8 is an outage and 1 a failed step; a reader reruns only the 8. +exited() { + if [ "$status" = "$1" ]; then ok "exit $1: $2"; else no "exited $status, expected $1: $2"; fi +} + # The run's colour, both ways. Only a step that could not run is red; every ordinary # reason not to land is green, or the scheduled job is red on a normal hour and # nobody reads it any more. @@ -303,6 +311,12 @@ red "gh pr list failed" kept update/gamma-1.2.0 unmoved logged "$work/out3" "update/gamma-1.2.0: could not read its pull requests" +# A 502 is an outage: asked again before giving up, and reported as one. +exited 8 "every failure was GitHub answering 502" +logged "$work/out3" "not landed because GitHub did not answer:" +calls="$(grep -c '^update/gamma-1.2.0$' "$GH_STATE/pr-list-calls" 2>/dev/null || true)" +if [ "$calls" = 5 ]; then ok "gh pr list was asked 5 times for gamma" +else no "gh pr list was asked ${calls:-0} time(s) for gamma; tools/net.sh makes 5 attempts"; fi echo "--- fourth run: git diff --name-only fails" offer @@ -311,6 +325,7 @@ listing run "$work/out4" rm -f "$GH_STATE/git-fails" red "git diff failed" +exited 1 "a failed git step is not an outage" kept update/gamma-1.2.0 kept update/delta-3.0.0 unmoved @@ -339,7 +354,7 @@ run "$work/out6" rm -f "$GH_STATE/pr-list-fails" red "one branch could not be read" logged "$work/out6" "update/delta-3.0.0: could not read its pull requests" -logged "$work/out6" "not landed because a step failed: update/delta-3.0.0" +logged "$work/out6" "not landed because GitHub did not answer: update/delta-3.0.0" head="$(git ls-remote origin refs/heads/main | cut -f1)" if [ "$head" = "$gamma2" ]; then ok "the branch after the failure was still read, and landed" diff --git a/tools/test-net.sh b/tools/test-net.sh new file mode 100755 index 0000000..f474ce1 --- /dev/null +++ b/tools/test-net.sh @@ -0,0 +1,275 @@ +#!/bin/sh +# Test `tools/net.sh` and `tools/fetch.sh` against a stub `curl` and a stub `gh`. +# +# Run it from anywhere: `sh tools/test-net.sh`. No network: both tools are replaced on +# PATH by scripts that answer from files this test writes, one answer per call, and +# record every call so the number of attempts can be counted. +# +# WHAT IT PINS. The split owfeed's CLI makes, applied to every download here: +# +# exit 8 an upstream outage (5xx, 429, DNS, refused, timeout) that outlasted the +# retries -- asked 5 times, then reported as an outage, safe to rerun +# exit 7 a definite answer (404, a certificate that does not verify, a checksum or +# size that does not match) -- asked once, never retried +# +# THE REGRESSION. Run 34836792047, `check / build` on a pull request, 2026-09-14: +# +# curl: (22) The requested URL returned error: 500 (six times) +# ##[error]Process completed with exit code 22 +# +# A GitHub incident, read exactly like a checksum failure; a manual rerun passed. +# The stub answers below reproduce that shape: curl exits 22 for every HTTP error, and +# only `-w '%{http_code}'` tells a 500 from a 404 -- measured with curl 8.5.0 and 8.7.1. +set -eu + +root="$(cd "$(dirname "$0")/.." && pwd)" +# Overridable so the fetch.sh cases can be run against another copy -- the one from +# before a change, to see a case go red for the reason it claims to. +fetch_src="${FETCH:-$root/tools/fetch.sh}" +net_src="$root/tools/net.sh" + +work="$(mktemp -d)" +trap 'rm -rf "$work"' EXIT INT TERM + +result=0 +ok() { echo "ok $1"; } +no() { echo "FAIL $1"; result=1; } + +bin="$work/bin" +STUB="$work/state" +export STUB +mkdir -p "$bin" "$STUB/answers" "$STUB/bodies" + +# No sleeping between attempts; every attempt is still made. +NET_RETRY_DELAY=0 +export NET_RETRY_DELAY +unset GITHUB_ACTIONS 2>/dev/null || true + +slug() { printf '%s' "$1" | tr -c 'A-Za-z0-9' _; } + +# Stand-in for curl(1). Reads `-o `, `-w ` and the URL, ignores every +# other flag -- so fetch.sh from before this change, which passed --retry flags of its +# own, runs against it unmodified. Each call takes the first line of the URL's answer +# file, " ", until one line is left, which then repeats. A URL +# with no answer file is a 404. +cat >"$bin/curl" <<'STUB_CURL' +#!/bin/sh +set -eu +dest="" +fmt="" +url="" +while [ $# -gt 0 ]; do + case "$1" in + -o) dest="$2"; shift 2 ;; + -w) fmt="$2"; shift 2 ;; + --proto|--connect-timeout|--max-time|--retry|--retry-delay) shift 2 ;; + -*) shift ;; + *) url="$1"; shift ;; + esac +done +s="$(printf '%s' "$url" | tr -c 'A-Za-z0-9' _)" +printf '%s\n' "$url" >>"$STUB/curl-calls" +f="$STUB/answers/$s" +if [ -f "$f" ]; then + line="$(head -n1 "$f")" + if [ "$(wc -l <"$f" | tr -d ' ')" -gt 1 ]; then + tail -n +2 "$f" >"$f.next" && mv "$f.next" "$f" + fi +else + line="22 404" +fi +rc="${line%% *}" +code="${line#* }" +[ -z "$fmt" ] || printf '%s' "$code" +if [ "$rc" = 0 ]; then + cp "$STUB/bodies/$s" "$dest" + exit 0 +fi +case "$rc" in +22) echo "curl: (22) The requested URL returned error: $code" >&2 ;; +6) echo "curl: (6) Could not resolve host: github.com" >&2 ;; +*) echo "curl: ($rc) stub failure" >&2 ;; +esac +exit "$rc" +STUB_CURL +chmod +x "$bin/curl" + +# Stand-in for gh(1). Each call takes the first line of $STUB/gh, "||", +# the same way the curl stub does. +cat >"$bin/gh" <<'STUB_GH' +#!/bin/sh +set -eu +echo "gh $*" >>"$STUB/gh-calls" +f="$STUB/gh" +line="$(head -n1 "$f")" +if [ "$(wc -l <"$f" | tr -d ' ')" -gt 1 ]; then + tail -n +2 "$f" >"$f.next" && mv "$f.next" "$f" +fi +rc="${line%%|*}" +rest="${line#*|}" +err="${rest%%|*}" +out="${rest#*|}" +[ -z "$out" ] || printf '%s\n' "$out" +[ -z "$err" ] || printf '%s\n' "$err" >&2 +exit "$rc" +STUB_GH +chmod +x "$bin/gh" + +PATH="$bin:$PATH" + +# answer ... -- what the stub curl says to that URL, call by call +answer() { + u="$1" + shift + : >"$STUB/answers/$(slug "$u")" + for l in "$@"; do printf '%s\n' "$l" >>"$STUB/answers/$(slug "$u")"; done +} +body() { printf '%s' "$2" >"$STUB/bodies/$(slug "$1")"; } +calls() { grep -cxF "$1" "$STUB/curl-calls" 2>/dev/null || true; } +reset() { rm -f "$STUB/answers/"* "$STUB/bodies/"* "$STUB/curl-calls" "$STUB/gh-calls" "$STUB/gh"; } + +status=0 +# expect -- the exit status of the last run +expect() { + if [ "$status" = "$2" ]; then ok "$1: exit $2"; else no "$1: exited $status, expected $2"; fi +} +attempts() { # + got="$(calls "$2")" + if [ "$got" = "$3" ]; then ok "$1: asked $3 time(s)"; else no "$1: asked ${got:-0} time(s), expected $3"; fi +} +said() { # + if grep -qF "$3" "$2"; then ok "$1: said \"$3\""; else no "$1: never said \"$3\""; fi +} + +U="https://example.invalid/a/file" +get() { + status=0 + sh "$net_src" get "$U" "$work/dest" >"$work/out" 2>"$work/err" || status=$? +} + +echo "--- tools/net.sh get" + +reset; answer "$U" "0 200"; body "$U" "bytes"; get +expect "200" 0; attempts "200" "$U" 1 +if [ "$(cat "$work/dest")" = "bytes" ]; then ok "200: the body is in place"; else no "200: the body is not in place"; fi + +reset; rm -f "$work/dest"; answer "$U" "22 404"; get +expect "404" 7; attempts "404" "$U" 1 +if [ ! -e "$work/dest" ]; then ok "404: nothing left behind"; else no "404: left a file behind"; fi + +reset; answer "$U" "22 500"; get +expect "500 throughout" 8; attempts "500 throughout" "$U" 5 +said "500 throughout" "$work/err" "upstream outage" + +reset; answer "$U" "22 503" "0 200"; body "$U" "bytes"; get +expect "503 once, then 200" 0; attempts "503 once, then 200" "$U" 2 + +for c in "22 429" "22 502" "6 000" "7 000" "28 000" "56 000"; do + reset; answer "$U" "$c"; get + expect "curl $c" 8; attempts "curl $c" "$U" 5 +done + +# A certificate that does not verify is what interception looks like; asking again +# does not heal it, and reading it as an outage would invite a rerun past it. +reset; answer "$U" "60 000"; get +expect "certificate failure" 7; attempts "certificate failure" "$U" 1 + +reset; answer "$U" "22 500" +status=0 +GITHUB_ACTIONS=true sh "$net_src" get "$U" "$work/dest" >"$work/out" 2>"$work/err" || status=$? +expect "outage in Actions" 8 +said "outage in Actions" "$work/out" "::error title=Upstream outage (exit 8, safe to rerun)::" + +echo "--- tools/net.sh gh" + +ghrun() { + status=0 + sh "$net_src" gh release view --repo example/x --json tagName >"$work/out" 2>"$work/err" || status=$? +} +ghcalls() { # + got="$(wc -l <"$STUB/gh-calls" | tr -d ' ')" + if [ "$got" = "$2" ]; then ok "$1: gh called $2 time(s)"; else no "$1: gh called $got time(s), expected $2"; fi +} + +reset; echo "1|release not found|" >"$STUB/gh"; ghrun +expect "release not found" 7; ghcalls "release not found" 1 +# check-updates.sh compares this text exactly, so it must arrive untouched. +if [ "$(cat "$work/err")" = "release not found" ]; then ok "release not found: stderr verbatim" +else no "release not found: stderr is \"$(cat "$work/err")\""; fi + +reset; echo "1|HTTP 503: Service Unavailable (https://api.github.com/repos/example/x/releases/latest)|" >"$STUB/gh"; ghrun +expect "gh HTTP 503" 8; ghcalls "gh HTTP 503" 5 + +reset; echo "1|error connecting to api.github.com check your internet connection or https://githubstatus.com|" >"$STUB/gh"; ghrun +expect "gh cannot connect" 8; ghcalls "gh cannot connect" 5 + +reset; echo "1|gh: Not Found (HTTP 404)|" >"$STUB/gh"; ghrun +expect "gh HTTP 404" 7; ghcalls "gh HTTP 404" 1 + +reset; printf '%s\n' "1|HTTP 502: Bad Gateway (https://api.github.com/)|" "0||v1.2.3" >"$STUB/gh"; ghrun +expect "gh 502, then an answer" 0; ghcalls "gh 502, then an answer" 2 +if [ "$(cat "$work/out")" = "v1.2.3" ]; then ok "gh 502, then an answer: stdout once" +else no "gh 502, then an answer: stdout is \"$(cat "$work/out")\""; fi + +echo "--- tools/fetch.sh" + +# One KIND="apk" package: a finished artifact and the tag's source archive. +tree="$work/root" +mkdir -p "$tree/tools" "$tree/packages/example" +cp "$fetch_src" "$tree/tools/fetch.sh" +cp "$net_src" "$tree/tools/net.sh" +art="https://github.com/example/example/releases/download/v1.0.0/example-1.0.0-r1.apk" +src="https://github.com/example/example/archive/refs/tags/v1.0.0.tar.gz" +good="package bytes" +sum="$(printf '%s' "$good" | sha256sum | cut -d' ' -f1)" +cat >"$tree/packages/example/upstream.sh" <"$work/out" 2>&1 || status=$? + sed 's/^/ | /' "$work/out" +} + +reset; answer "$art" "0 200"; body "$art" "$good"; answer "$src" "0 200"; body "$src" "source"; fetch +expect "everything answers" 0 +if [ -f "$tree/dist/noarch/example-1.0.0-r1.apk" ]; then ok "everything answers: staged"; else no "everything answers: nothing staged"; fi + +reset; answer "$art" "22 500"; fetch +expect "artifact 500 throughout" 8; attempts "artifact 500 throughout" "$art" 5 +attempts "artifact 500 throughout, no source fetched after it" "$src" 0 + +reset; answer "$art" "22 404"; fetch +expect "artifact 404" 7; attempts "artifact 404" "$art" 1 + +reset; answer "$art" "0 200"; body "$art" "replaced bytes"; fetch +expect "checksum mismatch" 7; attempts "checksum mismatch" "$art" 1 +said "checksum mismatch" "$work/out" "pinned $sum" + +# The unpinned source archive: a 404 is the documented "no source archive" path and +# stays non-fatal; an outage is not that answer and must not be recorded as it. +reset; answer "$art" "0 200"; body "$art" "$good"; answer "$src" "22 404"; fetch +expect "source 404" 0 +if grep -q "^example 1.0.0-r1 example $src" "$tree/dist/sources/unstaged.txt" 2>/dev/null; then + ok "source 404: recorded as unstaged" +else + no "source 404: not recorded in dist/sources/unstaged.txt" +fi + +reset; answer "$art" "0 200"; body "$art" "$good"; answer "$src" "22 503"; fetch +expect "source 503 throughout" 8; attempts "source 503 throughout" "$src" 5 +if [ ! -e "$tree/dist/sources/unstaged.txt" ]; then ok "source 503 throughout: not recorded as sourceless" +else no "source 503 throughout: recorded as sourceless: $(cat "$tree/dist/sources/unstaged.txt")"; fi + +if [ "$result" = 0 ]; then + echo "PASS" +else + echo "tools/test-net.sh: case(s) failed" >&2 +fi +exit "$result" diff --git a/tools/test-sources.sh b/tools/test-sources.sh index 7ff9f03..b7c53dd 100755 --- a/tools/test-sources.sh +++ b/tools/test-sources.sh @@ -90,6 +90,9 @@ rel="$d/release" stub="$d/bin" mkdir -p "$rel" "$stub" "$d/tools" "$d/packages/luci-app-example" "$d/keys" cp "$FETCH" "$d/tools/fetch.sh" +# fetch.sh downloads through tools/net.sh beside it. The stub `curl` below prints no +# `-w` status, and net.sh reads that only when curl fails, which this one never does. +cp "$ROOT/tools/net.sh" "$d/tools/net.sh" cat > "$d/packages/luci-app-example/upstream.sh" <<'EOF' KIND="manifest" REPO="example/luci-app-example"