Harden the build failure analysis fetch step - #17436
Conversation
Backports six robustness corrections that came out of review of the same workflow in dotnet/roslyn (dotnet/roslyn#85046) and were validated end to end there against real Azure DevOps builds. 1. Fail closed on Azure DevOps fetches. `build_json` and `artifacts_json` used a bare `curl` whose failure produced an empty body, which then fell through `jq` to an empty result and a misleading "nothing to analyze" warning. A new `ado_get` helper checks the curl exit status and validates that the body is JSON, and a transport or parse failure is now reported as a data-resolution failure. It returns a status instead of calling `emit_none` directly, because `emit_none` inside a command substitution would only exit the subshell. 2. Guard `GITHUB_OUTPUT` once up front, so the success path's writes are covered too rather than only the `emit_none` path. 3. Add `--connect-timeout`/`--max-time` to the metadata fetches, so a stalled endpoint fails in seconds instead of consuming the job timeout. 4. Raise the per-artifact compressed cap from 500 MB to 2 GB. Real log artifacts land close enough to 500 MB that an ordinary build trips the cap and the job silently skips exactly the leg it exists to diagnose. Only one archive is on disk at a time, so this bounds peak disk use, not the sum. 5. Add `MAX_TOTAL_ZIP_BYTES` (3 GB), charged *before* each transfer via a `ZIP_CAP = min(MAX_ZIP_BYTES, remaining)` clamp. Without it, raising the per-artifact cap would raise the worst-case bytes pulled over the network by the same factor, since nothing else bounded the sum across artifacts. Charging before the transfer (rather than after) keeps the last artifact from starting just under the limit and still pulling a full cap's worth. The post-download size guard and the `ulimit -f` backstop both use `ZIP_CAP`, or the clamp would be defeated. 6. Bound the artifact download with `--connect-timeout 15 --max-time 300`. The previous `--max-time 600` exceeded no limit on its own, but anything at or above this job's `timeout-minutes: 15` would let a stalled transfer kill the job before the script could emit its controlled no-op. A full artifact set measurably downloads in well under a minute, so 5 minutes per artifact is already very generous. The generated lock files are edited in lockstep with the sources rather than recompiled, so they stay on the `gh aw` version they were built with (v0.77.5) and the diff contains no unrelated toolchain churn. The embedded run blocks were verified byte-identical to the sources, and both were checked with `shellcheck` and `bash -n`. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
There was a problem hiding this comment.
Pull request overview
This PR backports robustness improvements to the GitHub Actions “build failure analysis” fetch step in Arcade, aiming to make Azure DevOps metadata/artifact retrieval fail safely (and predictably) while bounding download time and resource usage.
Changes:
- Introduces an
ado_gethelper to validate Azure DevOps fetches (curl status + JSON validation) and avoids misleading “no artifacts” outcomes on fetch failures. - Adds upfront
GITHUB_OUTPUTguarding and adds explicit curl connect/overall timeouts for metadata and artifact downloads. - Increases per-artifact compressed cap to 2GB and adds a cumulative compressed download budget (3GB) via
ZIP_CAPclamping.
Reviewed changes
Copilot reviewed 4 out of 4 changed files in this pull request and generated 4 comments.
| File | Description |
|---|---|
| .github/workflows/build-failure-analysis.md | Hardens ADO metadata/artifact fetching and enforces time/budget limits for binlog extraction. |
| .github/workflows/build-failure-analysis.lock.yml | Lockfile kept in sync with the hardened fetch script logic. |
| .github/workflows/build-failure-analysis-command.md | Applies the same hardened fetch logic to the slash-command driven workflow variant. |
| .github/workflows/build-failure-analysis-command.lock.yml | Lockfile kept in sync with the command workflow hardened fetch script logic. |
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
| ( | ||
| ulimit -f $((MAX_ZIP_BYTES / 512)) | ||
| ulimit -f $((ZIP_CAP / 512)) | ||
| trap '' XFSZ | ||
| curl -sSL --fail --retry 3 --retry-delay 2 --max-time 600 -o /tmp/a.zip "${url}" | ||
| curl -sSL --fail --retry 3 --retry-delay 2 --connect-timeout 15 --max-time 300 -o /tmp/a.zip "${url}" | ||
| ) 2>/dev/null |
There was a problem hiding this comment.
Good catch, and this was a real defect - fixed in 11144a5a.
The budget check only rejected a non-positive ZIP_CAP. A remainder of, say,
300 bytes was "positive", so the loop went ahead and ran
ulimit -f $((ZIP_CAP / 512)), which floors to 0 blocks - every write then
fails and the artifact is discarded anyway, after paying for the request. Even
above 512 bytes the transfer could never produce a usable archive.
Rather than special-casing the ulimit arithmetic, the floor is now expressed in
terms of what a transfer needs to be worth starting, which makes a zero-block
ulimit unreachable by construction:
MAX_TOTAL_ZIP_BYTES=3221225472 # 3 GB compressed across all artifacts
# A transfer smaller than this can't yield a usable archive, and
# `ulimit -f` works in 512-byte blocks, so a cap under 512 bytes
# would floor to a 0-block file limit and fail every write. Treat a
# remaining allowance below this as exhausted rather than starting a
# transfer that is guaranteed to be discarded.
MIN_ZIP_BYTES=1048576 # 1 MBif [ "${ZIP_CAP}" -lt "${MIN_ZIP_BYTES}" ]; then
echo "::warning::Cumulative compressed download budget ${MAX_TOTAL_ZIP_BYTES} is exhausted before ${safe_name}; stopping downloads."
break
fiSince you flagged this on four locations, I treated it as a class rather than a
list: it's fixed in both arcade workflows and both generated locks, and also
backported to the same workflow in
dotnet/roslyn#85046,
dotnet/runtime#132609,
dotnet/sdk#55985 and
microsoft/testfx#10835.
A new test-fetch-guards.sh in the roslyn reference covers the clamp across a
fresh, partial, sub-block, exactly-exhausted and over-spent budget, and asserts
that no cap the loop accepts can yield a zero-block ulimit (13/13 pass).
Note
This reply was generated with GitHub Copilot.
| ( | ||
| ulimit -f $((MAX_ZIP_BYTES / 512)) | ||
| ulimit -f $((ZIP_CAP / 512)) | ||
| trap '' XFSZ | ||
| curl -sSL --fail --retry 3 --retry-delay 2 --max-time 600 -o /tmp/a.zip "${url}" | ||
| curl -sSL --fail --retry 3 --retry-delay 2 --connect-timeout 15 --max-time 300 -o /tmp/a.zip "${url}" | ||
| ) 2>/dev/null |
There was a problem hiding this comment.
Good catch, and this was a real defect - fixed in 11144a5a.
The budget check only rejected a non-positive ZIP_CAP. A remainder of, say,
300 bytes was "positive", so the loop went ahead and ran
ulimit -f $((ZIP_CAP / 512)), which floors to 0 blocks - every write then
fails and the artifact is discarded anyway, after paying for the request. Even
above 512 bytes the transfer could never produce a usable archive.
Rather than special-casing the ulimit arithmetic, the floor is now expressed in
terms of what a transfer needs to be worth starting, which makes a zero-block
ulimit unreachable by construction:
MAX_TOTAL_ZIP_BYTES=3221225472 # 3 GB compressed across all artifacts
# A transfer smaller than this can't yield a usable archive, and
# `ulimit -f` works in 512-byte blocks, so a cap under 512 bytes
# would floor to a 0-block file limit and fail every write. Treat a
# remaining allowance below this as exhausted rather than starting a
# transfer that is guaranteed to be discarded.
MIN_ZIP_BYTES=1048576 # 1 MBif [ "${ZIP_CAP}" -lt "${MIN_ZIP_BYTES}" ]; then
echo "::warning::Cumulative compressed download budget ${MAX_TOTAL_ZIP_BYTES} is exhausted before ${safe_name}; stopping downloads."
break
fiSince you flagged this on four locations, I treated it as a class rather than a
list: it's fixed in both arcade workflows and both generated locks, and also
backported to the same workflow in
dotnet/roslyn#85046,
dotnet/runtime#132609,
dotnet/sdk#55985 and
microsoft/testfx#10835.
A new test-fetch-guards.sh in the roslyn reference covers the clamp across a
fresh, partial, sub-block, exactly-exhausted and over-spent budget, and asserts
that no cap the loop accepts can yield a zero-block ulimit (13/13 pass).
Note
This reply was generated with GitHub Copilot.
| ( | ||
| ulimit -f $((MAX_ZIP_BYTES / 512)) | ||
| ulimit -f $((ZIP_CAP / 512)) | ||
| trap '' XFSZ | ||
| curl -sSL --fail --retry 3 --retry-delay 2 --max-time 600 -o /tmp/a.zip "${url}" | ||
| curl -sSL --fail --retry 3 --retry-delay 2 --connect-timeout 15 --max-time 300 -o /tmp/a.zip "${url}" | ||
| ) 2>/dev/null |
There was a problem hiding this comment.
Good catch, and this was a real defect - fixed in 11144a5a.
The budget check only rejected a non-positive ZIP_CAP. A remainder of, say,
300 bytes was "positive", so the loop went ahead and ran
ulimit -f $((ZIP_CAP / 512)), which floors to 0 blocks - every write then
fails and the artifact is discarded anyway, after paying for the request. Even
above 512 bytes the transfer could never produce a usable archive.
Rather than special-casing the ulimit arithmetic, the floor is now expressed in
terms of what a transfer needs to be worth starting, which makes a zero-block
ulimit unreachable by construction:
MAX_TOTAL_ZIP_BYTES=3221225472 # 3 GB compressed across all artifacts
# A transfer smaller than this can't yield a usable archive, and
# `ulimit -f` works in 512-byte blocks, so a cap under 512 bytes
# would floor to a 0-block file limit and fail every write. Treat a
# remaining allowance below this as exhausted rather than starting a
# transfer that is guaranteed to be discarded.
MIN_ZIP_BYTES=1048576 # 1 MBif [ "${ZIP_CAP}" -lt "${MIN_ZIP_BYTES}" ]; then
echo "::warning::Cumulative compressed download budget ${MAX_TOTAL_ZIP_BYTES} is exhausted before ${safe_name}; stopping downloads."
break
fiSince you flagged this on four locations, I treated it as a class rather than a
list: it's fixed in both arcade workflows and both generated locks, and also
backported to the same workflow in
dotnet/roslyn#85046,
dotnet/runtime#132609,
dotnet/sdk#55985 and
microsoft/testfx#10835.
A new test-fetch-guards.sh in the roslyn reference covers the clamp across a
fresh, partial, sub-block, exactly-exhausted and over-spent budget, and asserts
that no cap the loop accepts can yield a zero-block ulimit (13/13 pass).
Note
This reply was generated with GitHub Copilot.
| ( | ||
| ulimit -f $((MAX_ZIP_BYTES / 512)) | ||
| ulimit -f $((ZIP_CAP / 512)) | ||
| trap '' XFSZ | ||
| curl -sSL --fail --retry 3 --retry-delay 2 --max-time 600 -o /tmp/a.zip "${url}" | ||
| curl -sSL --fail --retry 3 --retry-delay 2 --connect-timeout 15 --max-time 300 -o /tmp/a.zip "${url}" | ||
| ) 2>/dev/null |
There was a problem hiding this comment.
Good catch, and this was a real defect - fixed in 11144a5a.
The budget check only rejected a non-positive ZIP_CAP. A remainder of, say,
300 bytes was "positive", so the loop went ahead and ran
ulimit -f $((ZIP_CAP / 512)), which floors to 0 blocks - every write then
fails and the artifact is discarded anyway, after paying for the request. Even
above 512 bytes the transfer could never produce a usable archive.
Rather than special-casing the ulimit arithmetic, the floor is now expressed in
terms of what a transfer needs to be worth starting, which makes a zero-block
ulimit unreachable by construction:
MAX_TOTAL_ZIP_BYTES=3221225472 # 3 GB compressed across all artifacts
# A transfer smaller than this can't yield a usable archive, and
# `ulimit -f` works in 512-byte blocks, so a cap under 512 bytes
# would floor to a 0-block file limit and fail every write. Treat a
# remaining allowance below this as exhausted rather than starting a
# transfer that is guaranteed to be discarded.
MIN_ZIP_BYTES=1048576 # 1 MBif [ "${ZIP_CAP}" -lt "${MIN_ZIP_BYTES}" ]; then
echo "::warning::Cumulative compressed download budget ${MAX_TOTAL_ZIP_BYTES} is exhausted before ${safe_name}; stopping downloads."
break
fiSince you flagged this on four locations, I treated it as a class rather than a
list: it's fixed in both arcade workflows and both generated locks, and also
backported to the same workflow in
dotnet/roslyn#85046,
dotnet/runtime#132609,
dotnet/sdk#55985 and
microsoft/testfx#10835.
A new test-fetch-guards.sh in the roslyn reference covers the clamp across a
fresh, partial, sub-block, exactly-exhausted and over-spent budget, and asserts
that no cap the loop accepts can yield a zero-block ulimit (13/13 pass).
Note
This reply was generated with GitHub Copilot.
Two review findings that turned out to apply to every repo carrying this workflow rather than only where they were reported. The GITHUB_OUTPUT guard only checked that the variable was non-empty. A set but unwritable path still passed and then failed on every append, so the step produced no outputs at all instead of the intended controlled no-op. The guard now probes the path with a zero-byte append, which verifies writability without adding content. The cumulative compressed budget rejected only a non-positive remaining allowance. A positive remainder below 512 bytes still started a transfer, but ulimit -f counts 512-byte blocks, so the file limit floored to 0 and every write failed - the artifact was guaranteed to be discarded after paying for the request. A new MIN_ZIP_BYTES (1 MB) floor treats such a remainder as exhausted, which also makes a zero-block ulimit unreachable by construction. Validated in the roslyn reference implementation by test-fetch-guards.sh (13/13) and by fork E2E run 33167910190, whose fetch job downloaded the full artifact set with no cap or budget warnings. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 4 out of 4 changed files in this pull request and generated no new comments.
Suppressed comments (4)
Previously missed (4) — in code that hasn't changed since the last review.
.github/workflows/build-failure-analysis.md:401
- The comment about the
ulimit -fcalculation is incorrect:ZIP_CAP / 512floors, so the ulimit can be up to 511 bytes belowZIP_CAP(not >=). This can cause a capped download to be classified viacurl_rcas truncation rather than being caught by theZIP_BYTES -ge ZIP_CAPguard, and it misdocuments the intent of the guard/ulimit pairing.
# `ulimit -f` is only a disk backstop for a response that declares
# no Content-Length; the `-ge ZIP_CAP` guard below is
# authoritative. Divide by 512 so the cap is >= ZIP_CAP under
# either block-size reading (bash uses 1024, POSIX says 512).
.github/workflows/build-failure-analysis-command.md:459
- The comment about the
ulimit -fcalculation is incorrect:ZIP_CAP / 512floors, so the ulimit can be up to 511 bytes belowZIP_CAP(not >=). This can cause a capped download to be classified viacurl_rcas truncation rather than being caught by theZIP_BYTES -ge ZIP_CAPguard, and it misdocuments the intent of the guard/ulimit pairing.
# `ulimit -f` is only a disk backstop for a response that declares
# no Content-Length; the `-ge ZIP_CAP` guard below is
# authoritative. Divide by 512 so the cap is >= ZIP_CAP under
# either block-size reading (bash uses 1024, POSIX says 512).
.github/workflows/build-failure-analysis.lock.yml:1678
- The comment about the
ulimit -fcalculation is incorrect:ZIP_CAP / 512floors, so the ulimit can be up to 511 bytes belowZIP_CAP(not >=). This can cause a capped download to be classified viacurl_rcas truncation rather than being caught by theZIP_BYTES -ge ZIP_CAPguard, and it misdocuments the intent of the guard/ulimit pairing.
# `ulimit -f` is only a disk backstop for a response that declares
# no Content-Length; the `-ge ZIP_CAP` guard below is
# authoritative. Divide by 512 so the cap is >= ZIP_CAP under
# either block-size reading (bash uses 1024, POSIX says 512).
.github/workflows/build-failure-analysis-command.lock.yml:1771
- The comment about the
ulimit -fcalculation is incorrect:ZIP_CAP / 512floors, so the ulimit can be up to 511 bytes belowZIP_CAP(not >=). This can cause a capped download to be classified viacurl_rcas truncation rather than being caught by theZIP_BYTES -ge ZIP_CAPguard, and it misdocuments the intent of the guard/ulimit pairing.
# `ulimit -f` is only a disk backstop for a response that declares
# no Content-Length; the `-ge ZIP_CAP` guard below is
# authoritative. Divide by 512 so the cap is >= ZIP_CAP under
# either block-size reading (bash uses 1024, POSIX says 512).
`--max-time` applies per attempt, so `--retry 3` permitted four full transfers plus backoff and could outlive the job's `timeout-minutes`. Give the download loop a wall-clock deadline (DOWNLOAD_BUDGET=420s), cap each attempt at 120s, and derive both `--max-time` and `--retry-max-time` from the time actually left. Bound the metadata calls the same way. Worst case is now 720s against a 1800s job timeout. Also revert the 1 MB MIN_ZIP_BYTES floor: it dropped legs whose archive was legitimately smaller than the floor. Round the `ulimit -f` block count up instead, which guarantees at least one block for any positive cap without rejecting small artifacts. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 4 out of 4 changed files in this pull request and generated 6 comments.
Suppressed comments (4)
.github/workflows/build-failure-analysis.md:399
- If the download wall-clock budget is exhausted, this branch only prints a warning (and appends an unexpected literal
None) but then continues, potentially passingTIME_LEFT <= 0into curl’s--max-time/--retry-max-time.
Break out of the artifact loop when no time remains and remove the stray None suffix.
if [ "${TIME_LEFT}" -le 0 ]; then
echo "::warning::Download time budget ${DOWNLOAD_BUDGET}s exhausted before ${safe_name}; stopping downloads."None
fi
.github/workflows/build-failure-analysis.lock.yml:1676
- If the download wall-clock budget is exhausted, this branch only prints a warning (and appends an unexpected literal
None) but then continues, potentially passingTIME_LEFT <= 0into curl’s--max-time/--retry-max-time.
Break out of the artifact loop when no time remains and remove the stray None suffix.
if [ "${TIME_LEFT}" -le 0 ]; then
echo "::warning::Download time budget ${DOWNLOAD_BUDGET}s exhausted before ${safe_name}; stopping downloads."None
fi
.github/workflows/build-failure-analysis-command.md:457
- If the download wall-clock budget is exhausted, this branch only prints a warning (and appends an unexpected literal
None) but then continues, potentially passingTIME_LEFT <= 0into curl’s--max-time/--retry-max-time.
Break out of the artifact loop when no time remains and remove the stray None suffix.
if [ "${TIME_LEFT}" -le 0 ]; then
echo "::warning::Download time budget ${DOWNLOAD_BUDGET}s exhausted before ${safe_name}; stopping downloads."None
fi
.github/workflows/build-failure-analysis-command.lock.yml:1769
- If the download wall-clock budget is exhausted, this branch only prints a warning (and appends an unexpected literal
None) but then continues, potentially passingTIME_LEFT <= 0into curl’s--max-time/--retry-max-time.
Break out of the artifact loop when no time remains and remove the stray None suffix.
if [ "${TIME_LEFT}" -le 0 ]; then
echo "::warning::Download time budget ${DOWNLOAD_BUDGET}s exhausted before ${safe_name}; stopping downloads."None
fi
| if [ "${ZIP_CAP}" -le 0 ]; then | ||
| echo "::warning::Cumulative compressed download budget ${MAX_TOTAL_ZIP_BYTES} is exhausted before ${safe_name}; stopping downloads."None | ||
| fi |
There was a problem hiding this comment.
Fixed — you're right on both counts, and the missing break was the serious one.
The None was not cosmetic: a scripted substitution I used to apply the retry-window change across the sibling repos had a bug, and a regex group absent in this repo's variant came back as None, which Python stringified into the output in place of the break. So an exhausted compressed or time budget printed its warning and then carried straight on to the next artifact — neither cap was enforced at all, exactly as you describe.
Both guards now read:
if [ "${ZIP_CAP}" -le 0 ]; then
echo "::warning::Cumulative compressed download budget ${MAX_TOTAL_ZIP_BYTES} is exhausted before ${safe_name}; stopping downloads."
break
fiWorth noting why this got through: neither bash -n nor shellcheck objects, because None is simply an extra argument to echo, so the script stays valid while silently losing its loop exit — and it survived a read-through of the diff. I added a check that parses the generated .lock.yml and fails if any stopping downloads. warning is not followed by a break within its guard block. It reproduces the bug on the old files, passes on the new ones, and now runs over the generated workflows in every repo carrying this code.
Note
This reply was drafted with AI assistance and reviewed by me.
| if [ "${ZIP_CAP}" -le 0 ]; then | ||
| echo "::warning::Cumulative compressed download budget ${MAX_TOTAL_ZIP_BYTES} is exhausted before ${safe_name}; stopping downloads."None | ||
| fi |
There was a problem hiding this comment.
Fixed — you're right on both counts, and the missing break was the serious one.
The None was not cosmetic: a scripted substitution I used to apply the retry-window change across the sibling repos had a bug, and a regex group absent in this repo's variant came back as None, which Python stringified into the output in place of the break. So an exhausted compressed or time budget printed its warning and then carried straight on to the next artifact — neither cap was enforced at all, exactly as you describe.
Both guards now read:
if [ "${ZIP_CAP}" -le 0 ]; then
echo "::warning::Cumulative compressed download budget ${MAX_TOTAL_ZIP_BYTES} is exhausted before ${safe_name}; stopping downloads."
break
fiWorth noting why this got through: neither bash -n nor shellcheck objects, because None is simply an extra argument to echo, so the script stays valid while silently losing its loop exit — and it survived a read-through of the diff. I added a check that parses the generated .lock.yml and fails if any stopping downloads. warning is not followed by a break within its guard block. It reproduces the bug on the old files, passes on the new ones, and now runs over the generated workflows in every repo carrying this code.
Note
This reply was drafted with AI assistance and reviewed by me.
| if [ "${ZIP_CAP}" -le 0 ]; then | ||
| echo "::warning::Cumulative compressed download budget ${MAX_TOTAL_ZIP_BYTES} is exhausted before ${safe_name}; stopping downloads."None | ||
| fi |
There was a problem hiding this comment.
Fixed — you're right on both counts, and the missing break was the serious one.
The None was not cosmetic: a scripted substitution I used to apply the retry-window change across the sibling repos had a bug, and a regex group absent in this repo's variant came back as None, which Python stringified into the output in place of the break. So an exhausted compressed or time budget printed its warning and then carried straight on to the next artifact — neither cap was enforced at all, exactly as you describe.
Both guards now read:
if [ "${ZIP_CAP}" -le 0 ]; then
echo "::warning::Cumulative compressed download budget ${MAX_TOTAL_ZIP_BYTES} is exhausted before ${safe_name}; stopping downloads."
break
fiWorth noting why this got through: neither bash -n nor shellcheck objects, because None is simply an extra argument to echo, so the script stays valid while silently losing its loop exit — and it survived a read-through of the diff. I added a check that parses the generated .lock.yml and fails if any stopping downloads. warning is not followed by a break within its guard block. It reproduces the bug on the old files, passes on the new ones, and now runs over the generated workflows in every repo carrying this code.
Note
This reply was drafted with AI assistance and reviewed by me.
| builds_json=$(curl -sSL --retry 3 --connect-timeout 10 --max-time 20 --retry-max-time 40 \ | ||
| "${ADO_API}/build/builds?definitions=${ADO_BUILD_DEFINITION_ID}&branchName=refs/pull/${PR_NUMBER}/merge&queryOrder=queueTimeDescending&\$top=1&api-version=7.1") | ||
| BUILD_ID=$(printf '%s' "${builds_json}" | jq -r '.value // [] | .[0].id // empty') |
There was a problem hiding this comment.
Fixed — this one slipped through the first pass.
The ado_get helper was added in this PR and build_json and artifacts_json were converted to use it, but builds_json on the latest resolution path was missed. It kept the failure mode the helper exists to remove: a network error or an HTML error page yields an empty body, jq turns that into an empty .value, and the step reports "No arcade-pr build found for PR #N" — a message that says there is nothing to analyze when what actually happened is we could not find out. A transient ADO outage looks like a healthy PR with no builds.
It now goes through the helper like the others:
ado_get "build list for PR #${PR_NUMBER}" \
"${ADO_API}/build/builds?definitions=${ADO_BUILD_DEFINITION_ID}&branchName=refs/pull/${PR_NUMBER}/merge&queryOrder=queueTimeDescending&\$top=1&api-version=7.1" || emit_none
builds_json="${ADO_DOC}"which checks the curl exit status and validates the body with jq -e . before anything downstream reads it.
I also swept for the rest of the class rather than fixing just this one: a check now walks every curl in the generated workflows across all the repos carrying this code and flags any that is not routed through ado_get or explicitly handled. That found one more — the timeline fetch in the automatic workflow — which is fixed in the same commit. The two remaining raw fetches are the completeness-check timelines that deliberately use || true, because there an unreadable timeline must not look like a complete build; they already fail closed.
Note
This reply was drafted with AI assistance and reviewed by me.
| if [ "${ZIP_CAP}" -le 0 ]; then | ||
| echo "::warning::Cumulative compressed download budget ${MAX_TOTAL_ZIP_BYTES} is exhausted before ${safe_name}; stopping downloads."None | ||
| fi |
There was a problem hiding this comment.
Fixed — you're right on both counts, and the missing break was the serious one.
The None was not cosmetic: a scripted substitution I used to apply the retry-window change across the sibling repos had a bug, and a regex group absent in this repo's variant came back as None, which Python stringified into the output in place of the break. So an exhausted compressed or time budget printed its warning and then carried straight on to the next artifact — neither cap was enforced at all, exactly as you describe.
Both guards now read:
if [ "${ZIP_CAP}" -le 0 ]; then
echo "::warning::Cumulative compressed download budget ${MAX_TOTAL_ZIP_BYTES} is exhausted before ${safe_name}; stopping downloads."
break
fiWorth noting why this got through: neither bash -n nor shellcheck objects, because None is simply an extra argument to echo, so the script stays valid while silently losing its loop exit — and it survived a read-through of the diff. I added a check that parses the generated .lock.yml and fails if any stopping downloads. warning is not followed by a break within its guard block. It reproduces the bug on the old files, passes on the new ones, and now runs over the generated workflows in every repo carrying this code.
Note
This reply was drafted with AI assistance and reviewed by me.
| builds_json=$(curl -sSL --retry 3 --connect-timeout 10 --max-time 20 --retry-max-time 40 \ | ||
| "${ADO_API}/build/builds?definitions=${ADO_BUILD_DEFINITION_ID}&branchName=refs/pull/${PR_NUMBER}/merge&queryOrder=queueTimeDescending&\$top=1&api-version=7.1") | ||
| BUILD_ID=$(printf '%s' "${builds_json}" | jq -r '.value // [] | .[0].id // empty') |
There was a problem hiding this comment.
Fixed — this one slipped through the first pass.
The ado_get helper was added in this PR and build_json and artifacts_json were converted to use it, but builds_json on the latest resolution path was missed. It kept the failure mode the helper exists to remove: a network error or an HTML error page yields an empty body, jq turns that into an empty .value, and the step reports "No arcade-pr build found for PR #N" — a message that says there is nothing to analyze when what actually happened is we could not find out. A transient ADO outage looks like a healthy PR with no builds.
It now goes through the helper like the others:
ado_get "build list for PR #${PR_NUMBER}" \
"${ADO_API}/build/builds?definitions=${ADO_BUILD_DEFINITION_ID}&branchName=refs/pull/${PR_NUMBER}/merge&queryOrder=queueTimeDescending&\$top=1&api-version=7.1" || emit_none
builds_json="${ADO_DOC}"which checks the curl exit status and validates the body with jq -e . before anything downstream reads it.
I also swept for the rest of the class rather than fixing just this one: a check now walks every curl in the generated workflows across all the repos carrying this code and flags any that is not routed through ado_get or explicitly handled. That found one more — the timeline fetch in the automatic workflow — which is fixed in the same commit. The two remaining raw fetches are the completeness-check timelines that deliberately use || true, because there an unreadable timeline must not look like a complete build; they already fail closed.
Note
This reply was drafted with AI assistance and reviewed by me.
The extract loop globs the whole directory, so any *.binlog left behind by an earlier run on the same runner would be uploaded and analyzed as if it belonged to this build. Remove pre-existing binlogs right after the directory is created, before anything is written into it. Also correct a test header that still described the reverted MIN_ZIP_BYTES clamp instead of the guards actually in the script. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 4 out of 4 changed files in this pull request and generated no new comments.
Suppressed comments (8)
Previously missed (4) — in code that hasn't changed since the last review.
.github/workflows/build-failure-analysis.md:403
- When the download deadline is reached, the script currently only prints a warning (and includes a stray
None) but continues, which can makeTIME_LEFTnegative and pass an invalid--max-time/--retry-max-timeto curl. This should stop downloading further artifacts.
if [ "${TIME_LEFT}" -le 0 ]; then
echo "::warning::Download time budget ${DOWNLOAD_BUDGET}s exhausted before ${safe_name}; stopping downloads."None
fi
.github/workflows/build-failure-analysis.lock.yml:1680
- The time budget exhaustion guard only prints a warning (with a stray
None) and then continues, which can makeTIME_LEFTnegative and lead to invalid curl timeout arguments. This shouldbreakout of the artifact loop.
if [ "${TIME_LEFT}" -le 0 ]; then
echo "::warning::Download time budget ${DOWNLOAD_BUDGET}s exhausted before ${safe_name}; stopping downloads."None
fi
.github/workflows/build-failure-analysis-command.md:461
- When
TIME_LEFTis exhausted, the script currently warns (and appends a strayNone) but continues, which can makeATTEMPT_SECONDSnegative and pass invalid timeout values to curl. This shouldbreakout of the artifact loop.
if [ "${TIME_LEFT}" -le 0 ]; then
echo "::warning::Download time budget ${DOWNLOAD_BUDGET}s exhausted before ${safe_name}; stopping downloads."None
fi
.github/workflows/build-failure-analysis-command.lock.yml:1773
- The time budget exhaustion check currently only prints a warning (and includes a stray
None) but continues, which can result in negativeTIME_LEFTand invalid curl timeout arguments. This should stop downloading further artifacts.
if [ "${TIME_LEFT}" -le 0 ]; then
echo "::warning::Download time budget ${DOWNLOAD_BUDGET}s exhausted before ${safe_name}; stopping downloads."None
fi
.github/workflows/build-failure-analysis.md:397
- The budget/time exhaustion guards don't actually stop the loop: they just print a warning (with an extra
Nonetoken) and then continue, which can lead to invalidZIP_CAP/negative timeouts being used forulimit/curl. These branches shouldbreakout of the artifact loop once the budget is exhausted, and the strayNoneshould be removed.
if [ "${ZIP_CAP}" -le 0 ]; then
echo "::warning::Cumulative compressed download budget ${MAX_TOTAL_ZIP_BYTES} is exhausted before ${safe_name}; stopping downloads."None
fi
.github/workflows/build-failure-analysis.lock.yml:1674
- The budget exhaustion guard only prints a warning (with an extra
Nonetoken) and then continues the loop. That can leaveZIP_CAPnon-positive and still attemptulimit/curl. This shouldbreakout of the artifact loop and remove the strayNone.
if [ "${ZIP_CAP}" -le 0 ]; then
echo "::warning::Cumulative compressed download budget ${MAX_TOTAL_ZIP_BYTES} is exhausted before ${safe_name}; stopping downloads."None
fi
.github/workflows/build-failure-analysis-command.md:455
- The budget exhaustion check prints a warning (and includes a stray
None) but does not stop the loop. IfZIP_CAPis non-positive, subsequentulimit/curlcalls can behave unpredictably. This shouldbreakout of the artifact loop once the budget is exhausted.
if [ "${ZIP_CAP}" -le 0 ]; then
echo "::warning::Cumulative compressed download budget ${MAX_TOTAL_ZIP_BYTES} is exhausted before ${safe_name}; stopping downloads."None
fi
.github/workflows/build-failure-analysis-command.lock.yml:1767
- The budget exhaustion guard only prints a warning (with an extra
None) and then continues. IfZIP_CAPis non-positive, the loop should stop rather than runningulimit/curlwith invalid limits.
if [ "${ZIP_CAP}" -le 0 ]; then
echo "::warning::Cumulative compressed download budget ${MAX_TOTAL_ZIP_BYTES} is exhausted before ${safe_name}; stopping downloads."None
fi
A bad scripted substitution in the previous commit replaced the `break` in both budget guards with a literal `None`, so an exhausted compressed or time budget printed its warning and then carried straight on to the next artifact. The caps were not enforced at all. `bash -n` and shellcheck both accept it, since `None` is just an extra argument to echo — only a check that each guard actually leaves the loop catches it. Also route the last two bare metadata `curl`s through the fail-closed `ado_get` helper, so a network error or a non-JSON body is reported as a data-resolution failure instead of becoming an empty `.value` and a misleading "nothing to analyze". Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
`--retry-max-time` only decides whether curl may start another retry; a
retry begun just inside it still runs a further `--max-time`, so a
transfer could overshoot DOWNLOAD_DEADLINE by up to MAX_ATTEMPT_SECONDS
and the advertised wall-clock budget was not actually enforced. Wrap the
whole invocation in `timeout "${TIME_LEFT}"`, keeping the curl limits, so
the phase really is bounded by DOWNLOAD_BUDGET. A killed transfer is
treated like any other failed one: the leg is reported missing and the
analysis is disabled, which fails closed.
Also state plainly that the compressed budget is a budget rather than a
byte-exact ceiling: `ulimit -f` rounds up to 512-byte blocks, so a
transfer can overshoot its cap by under 512 bytes before the size check
rejects it, bounded overall by MAX_ARTIFACTS.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 4 out of 4 changed files in this pull request and generated no new comments.
Suppressed comments (4)
Previously missed (4) — in code that hasn't changed since the last review.
.github/workflows/build-failure-analysis.md:398
ZIP_CAPis meant to clamp downloads to the remaining compressed budget, butulimit -f $(( (ZIP_CAP + 511) / 512 ))rounds up to 512-byte blocks, so the file-size backstop can exceedZIP_CAPby up to 511 bytes. WhenZIP_CAPcomes from a non-512-byte-aligned remaining allowance, a transfer can exceedMAX_TOTAL_ZIP_BYTESeven though the code thinks it is clamped. Consider roundingZIP_CAPdown to a 512-byte multiple (or otherwise aligning the cap/ulimit/guards) before starting the transfer.
ZIP_CAP="${MAX_ZIP_BYTES}"
ZIP_ALLOWANCE=$((MAX_TOTAL_ZIP_BYTES - TOTAL_ZIP_BYTES))
[ "${ZIP_ALLOWANCE}" -lt "${ZIP_CAP}" ] && ZIP_CAP="${ZIP_ALLOWANCE}"
if [ "${ZIP_CAP}" -le 0 ]; then
echo "::warning::Cumulative compressed download budget ${MAX_TOTAL_ZIP_BYTES} is exhausted before ${safe_name}; stopping downloads."
.github/workflows/build-failure-analysis-command.md:457
ZIP_CAPis intended to clamp downloads to the remaining compressed budget, but the laterulimituses 512-byte blocks and is rounded up, which can allow the download to exceedZIP_CAP(and thusMAX_TOTAL_ZIP_BYTES) by up to 511 bytes when the remaining allowance isn’t block-aligned. Consider roundingZIP_CAPdown to a 512-byte multiple (or aligning the ulimit/guards to the same effective cap) before starting the transfer.
ZIP_CAP="${MAX_ZIP_BYTES}"
ZIP_ALLOWANCE=$((MAX_TOTAL_ZIP_BYTES - TOTAL_ZIP_BYTES))
[ "${ZIP_ALLOWANCE}" -lt "${ZIP_CAP}" ] && ZIP_CAP="${ZIP_ALLOWANCE}"
if [ "${ZIP_CAP}" -le 0 ]; then
echo "::warning::Cumulative compressed download budget ${MAX_TOTAL_ZIP_BYTES} is exhausted before ${safe_name}; stopping downloads."
.github/workflows/build-failure-analysis.lock.yml:1675
ZIP_CAPis clamped to the remaining compressed budget, but the laterulimitis rounded up in 512-byte blocks, which means the download backstop can still allow up to 511 bytes more thanZIP_CAP. IfZIP_CAPcomes from a non-block-aligned remaining allowance, this can exceedMAX_TOTAL_ZIP_BYTESdespite the clamp. Consider roundingZIP_CAPdown to a 512-byte multiple before starting the transfer.
ZIP_CAP="${MAX_ZIP_BYTES}"
ZIP_ALLOWANCE=$((MAX_TOTAL_ZIP_BYTES - TOTAL_ZIP_BYTES))
[ "${ZIP_ALLOWANCE}" -lt "${ZIP_CAP}" ] && ZIP_CAP="${ZIP_ALLOWANCE}"
if [ "${ZIP_CAP}" -le 0 ]; then
echo "::warning::Cumulative compressed download budget ${MAX_TOTAL_ZIP_BYTES} is exhausted before ${safe_name}; stopping downloads."
.github/workflows/build-failure-analysis-command.lock.yml:1769
ZIP_CAPis clamped to the remaining compressed budget, but the laterulimitis rounded up in 512-byte blocks, which can allow the download to exceedZIP_CAP(and thereforeMAX_TOTAL_ZIP_BYTES) by up to 511 bytes when the remaining allowance isn’t 512-byte aligned. Consider roundingZIP_CAPdown to a 512-byte multiple before starting the transfer.
ZIP_CAP="${MAX_ZIP_BYTES}"
ZIP_ALLOWANCE=$((MAX_TOTAL_ZIP_BYTES - TOTAL_ZIP_BYTES))
[ "${ZIP_ALLOWANCE}" -lt "${ZIP_CAP}" ] && ZIP_CAP="${ZIP_ALLOWANCE}"
if [ "${ZIP_CAP}" -le 0 ]; then
echo "::warning::Cumulative compressed download budget ${MAX_TOTAL_ZIP_BYTES} is exhausted before ${safe_name}; stopping downloads."
`curl --retry` can only rewind seekable output. Through a pipe or a command substitution a retry appends to whatever the failed attempt already wrote, so a *successful* retry produces a corrupt two-response payload: - The artifact download streamed through `head -c` into the zip. A 503 error page followed by a successful retry yielded `<error page><zip>`, which could still pass the size and path guards and then make `unzip` return warning status 1 -- read as failure, leaving staged_legs short and suppressing the whole analysis. It now downloads with `-o` to a freshly removed file, behind `--fail` so HTTP error bodies never reach it and `ulimit -f` as the disk backstop. - `ado_get` and the completeness-check timeline fetch captured curl in a command substitution. A partial body plus a successful retry parsed as neither document, so a recoverable blip was reported as a data-resolution failure. Both write to a file that curl truncates before each attempt. Tests assert the invariant rather than the text: no retrying curl may be piped or captured, every one writes to a file, and every one uses `--fail`. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
| # because a call in a command substitution would only exit the | ||
| # subshell. | ||
| ado_get() { | ||
| local what="$1" url="$2" rc tmp=/tmp/ado-response.json |
There was a problem hiding this comment.
Fair point, and it applies to more than ado_get — the artifact archive was going to a fixed /tmp/a.zip for the same reason. Both are now mktemp:
tmp=$(mktemp) || {
echo "::warning::Could not create a temporary file for the ${what}; treating as a data-resolution failure."
return 1
}The download allocates one scratch file before the loop and truncates it per artifact (: > "${ZIP_TMP}") instead of removing and recreating it — removing it would hand the name back and undo the point of mktemp. The rm -f "${tmp}" that used to precede the fetch is gone too: mktemp already yields an empty private file and curl truncates before each attempt, so the old line only reintroduced a window at a known path.
These jobs run on ephemeral ubuntu-latest, so I don't think this was reachable today. But the cost of not depending on that is a one-line change, and these workflows get copied into repos that do use self-hosted runners — this one is a backport of exactly that kind.
One deliberate exception: BINLOG_DIR keeps its fixed path. It is an interface rather than scratch space — the workflow sets it, the agent container maps it, and the upload step reads it — and the script creates it with mkdir -p and clears stale binlogs from it rather than writing through a name it does not control. A test now asserts that no curl writes to a fixed /tmp path and that the scratch files come from mktemp, with that exception spelled out.
Validated end to end on a real failed Azure Pipelines build: run 33181881513, all seven jobs green with no warnings.
Note
Parts of this reply were generated with AI assistance.
| # because a call in a command substitution would only exit the | ||
| # subshell. | ||
| ado_get() { | ||
| local what="$1" url="$2" rc tmp=/tmp/ado-response.json |
There was a problem hiding this comment.
Fair point, and it applies to more than ado_get — the artifact archive was going to a fixed /tmp/a.zip for the same reason. Both are now mktemp:
tmp=$(mktemp) || {
echo "::warning::Could not create a temporary file for the ${what}; treating as a data-resolution failure."
return 1
}The download allocates one scratch file before the loop and truncates it per artifact (: > "${ZIP_TMP}") instead of removing and recreating it — removing it would hand the name back and undo the point of mktemp. The rm -f "${tmp}" that used to precede the fetch is gone too: mktemp already yields an empty private file and curl truncates before each attempt, so the old line only reintroduced a window at a known path.
These jobs run on ephemeral ubuntu-latest, so I don't think this was reachable today. But the cost of not depending on that is a one-line change, and these workflows get copied into repos that do use self-hosted runners — this one is a backport of exactly that kind.
One deliberate exception: BINLOG_DIR keeps its fixed path. It is an interface rather than scratch space — the workflow sets it, the agent container maps it, and the upload step reads it — and the script creates it with mkdir -p and clears stale binlogs from it rather than writing through a name it does not control. A test now asserts that no curl writes to a fixed /tmp path and that the scratch files come from mktemp, with that exception spelled out.
Validated end to end on a real failed Azure Pipelines build: run 33181881513, all seven jobs green with no warnings.
Note
Parts of this reply were generated with AI assistance.
| # because a call in a command substitution would only exit the | ||
| # subshell. | ||
| ado_get() { | ||
| local what="$1" url="$2" rc tmp=/tmp/ado-response.json |
There was a problem hiding this comment.
Fair point, and it applies to more than ado_get — the artifact archive was going to a fixed /tmp/a.zip for the same reason. Both are now mktemp:
tmp=$(mktemp) || {
echo "::warning::Could not create a temporary file for the ${what}; treating as a data-resolution failure."
return 1
}The download allocates one scratch file before the loop and truncates it per artifact (: > "${ZIP_TMP}") instead of removing and recreating it — removing it would hand the name back and undo the point of mktemp. The rm -f "${tmp}" that used to precede the fetch is gone too: mktemp already yields an empty private file and curl truncates before each attempt, so the old line only reintroduced a window at a known path.
These jobs run on ephemeral ubuntu-latest, so I don't think this was reachable today. But the cost of not depending on that is a one-line change, and these workflows get copied into repos that do use self-hosted runners — this one is a backport of exactly that kind.
One deliberate exception: BINLOG_DIR keeps its fixed path. It is an interface rather than scratch space — the workflow sets it, the agent container maps it, and the upload step reads it — and the script creates it with mkdir -p and clears stale binlogs from it rather than writing through a name it does not control. A test now asserts that no curl writes to a fixed /tmp path and that the scratch files come from mktemp, with that exception spelled out.
Validated end to end on a real failed Azure Pipelines build: run 33181881513, all seven jobs green with no warnings.
Note
Parts of this reply were generated with AI assistance.
| # because a call in a command substitution would only exit the | ||
| # subshell. | ||
| ado_get() { | ||
| local what="$1" url="$2" rc tmp=/tmp/ado-response.json |
There was a problem hiding this comment.
Fair point, and it applies to more than ado_get — the artifact archive was going to a fixed /tmp/a.zip for the same reason. Both are now mktemp:
tmp=$(mktemp) || {
echo "::warning::Could not create a temporary file for the ${what}; treating as a data-resolution failure."
return 1
}The download allocates one scratch file before the loop and truncates it per artifact (: > "${ZIP_TMP}") instead of removing and recreating it — removing it would hand the name back and undo the point of mktemp. The rm -f "${tmp}" that used to precede the fetch is gone too: mktemp already yields an empty private file and curl truncates before each attempt, so the old line only reintroduced a window at a known path.
These jobs run on ephemeral ubuntu-latest, so I don't think this was reachable today. But the cost of not depending on that is a one-line change, and these workflows get copied into repos that do use self-hosted runners — this one is a backport of exactly that kind.
One deliberate exception: BINLOG_DIR keeps its fixed path. It is an interface rather than scratch space — the workflow sets it, the agent container maps it, and the upload step reads it — and the script creates it with mkdir -p and clears stale binlogs from it rather than writing through a name it does not control. A test now asserts that no curl writes to a fixed /tmp path and that the scratch files come from mktemp, with that exception spelled out.
Validated end to end on a real failed Azure Pipelines build: run 33181881513, all seven jobs green with no warnings.
Note
Parts of this reply were generated with AI assistance.
The Azure DevOps response and the artifact archive were written to fixed paths under /tmp. On a runner that is not exclusively ours -- self-hosted, or simply running two of these jobs at once -- a pre-existing file or symlink at a known path decides where curl writes and what gets read back into ADO_DOC. Both now come from `mktemp`. The download reuses one scratch file across artifacts and truncates it per iteration rather than removing and recreating it, so the private file we were handed stays the one we write to. BINLOG_DIR keeps its fixed path deliberately: it is an interface, set by the workflow and read by the upload step, and it is created and cleared rather than written through. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
| timeout "${TIME_LEFT}" curl -sSL --fail --retry 3 --retry-delay 2 --connect-timeout 15 --max-time "${ATTEMPT_SECONDS}" --retry-max-time "${TIME_LEFT}" -o "${ZIP_TMP}" "${url}" | ||
| ) 2>/dev/null | ||
| curl_rc=$? | ||
| ZIP_BYTES=$(stat -c%s /tmp/a.zip 2>/dev/null || echo 0) | ||
| ZIP_BYTES=$(stat -c%s "${ZIP_TMP}" 2>/dev/null || echo 0) | ||
| # Charge the budget with the bytes that actually crossed the wire, |
There was a problem hiding this comment.
You are right about /tmp/ax, and I have taken that half. It is pure scratch, so it is now mktemp -d, emptied per artifact and released after the loop:
AX_DIR=$(mktemp -d) || { echo "::warning::Could not create a temporary directory for extraction."; emit_none; }
...
find "${AX_DIR:?}" -mindepth 1 -delete # per artifact
...
rm -rf "${AX_DIR:?}" "${ZIP_TMP}" # after the loopBINLOG_DIR I have deliberately left alone, and I want to be straight that this is a real limitation rather than a rebuttal. It is not scratch space — it is an interface. The workflow sets it, the agent container maps it to /data/binlogs, and the upload step reads it; that mount is part of the generated lock file, so a mktemp -d path would have to be threaded through the container spec that gh aw emits. That is a change to generated infrastructure, not to this script, and I would rather not make it as a drive-by in a backport.
What that leaves is the residual you identified: two of these jobs on one shared runner could collide in /tmp/binlogs. The rm -f "${BINLOG_DIR}"/*.binlog is there for the sequential case, which is the one that actually bites — a stale binlog from an earlier run being uploaded and attributed to this build, which produces a confidently wrong root cause on someone's PR. For the concurrent case it is not sufficient, and I have not claimed otherwise.
These workflows target ubuntu-latest, where each job gets its own VM, so this is not reachable as configured. If a repo moves them to shared runners, BINLOG_DIR is the thing to revisit, and it is a one-line change at the call site now that nothing else in the script hardcodes a path.
Note
Parts of this reply were generated with AI assistance.
| mkdir -p /tmp/binlogs | ||
| # Only binlogs extracted by this run may be analyzed. Anything left in | ||
| # the directory by an earlier run on the same runner would otherwise be | ||
| # uploaded and attributed to this build. | ||
| rm -f /tmp/binlogs/*.binlog |
There was a problem hiding this comment.
You are right about /tmp/ax, and I have taken that half. It is pure scratch, so it is now mktemp -d, emptied per artifact and released after the loop:
AX_DIR=$(mktemp -d) || { echo "::warning::Could not create a temporary directory for extraction."; emit_none; }
...
find "${AX_DIR:?}" -mindepth 1 -delete # per artifact
...
rm -rf "${AX_DIR:?}" "${ZIP_TMP}" # after the loopBINLOG_DIR I have deliberately left alone, and I want to be straight that this is a real limitation rather than a rebuttal. It is not scratch space — it is an interface. The workflow sets it, the agent container maps it to /data/binlogs, and the upload step reads it; that mount is part of the generated lock file, so a mktemp -d path would have to be threaded through the container spec that gh aw emits. That is a change to generated infrastructure, not to this script, and I would rather not make it as a drive-by in a backport.
What that leaves is the residual you identified: two of these jobs on one shared runner could collide in /tmp/binlogs. The rm -f "${BINLOG_DIR}"/*.binlog is there for the sequential case, which is the one that actually bites — a stale binlog from an earlier run being uploaded and attributed to this build, which produces a confidently wrong root cause on someone's PR. For the concurrent case it is not sufficient, and I have not claimed otherwise.
These workflows target ubuntu-latest, where each job gets its own VM, so this is not reachable as configured. If a repo moves them to shared runners, BINLOG_DIR is the thing to revisit, and it is a one-line change at the call site now that nothing else in the script hardcodes a path.
Note
Parts of this reply were generated with AI assistance.
- `ulimit -f` was allowed to fail silently. A shell that refuses to apply
the limit left responses with no usable Content-Length free to fill the
disk before the post-download size check ran. It now exits the subshell,
so the leg fails and the completeness check reports it.
- testfx/sdk discarded the transfer exit status, so a timed-out or
size-limited curl was accepted whenever the partial file happened to
parse as a ZIP -- including a file left exactly at the cap, which the
`-gt` check does not reject. The status is captured and the leg skipped.
- `{"records": null}` satisfied `has("records")` and marked the timeline
readable; the extraction then yielded an empty missing-legs value that
looked like verified completeness. Requires an actual array.
- The extraction directory was a fixed /tmp path, so the mktemp reasoning
applied to it too. It is `mktemp -d`, cleaned per artifact and released
after the loop.
Also corrects a guard comment that still described the `head -c` pipe the
download no longer uses.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
The counter is charged from the file left on disk, but the comment claimed it recorded "the bytes that actually crossed the wire". Since `-o` truncates before each retry, failed attempts are not counted, so the comment overstated what the number means to anyone auditing the caps. It is a disk and extraction budget. Network transfer is bounded by DOWNLOAD_DEADLINE through the `timeout` wrapper, and every individual attempt is capped at ZIP_CAP by `ulimit -f`. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Two ways the download guards were weaker than advertised. bash's `ulimit -f` counts in 1024-byte units, not the POSIX 512-byte block. Computing blocks as `(ZIP_CAP + 511) / 512` therefore asked for twice the intended limit, so a single scratch file could reach nearly 4 GB against a 2 GB ZIP_CAP -- past the 3 GB cumulative cap on its own. The post-download size check still rejected the file, but the backstop that exists for responses with no usable Content-Length was not enforcing what it claimed. The wall-clock deadline bounded transfers only. Extraction is separately bounded per artifact, so a run that spent most of its budget downloading could still queue one extraction per remaining artifact and walk past `timeout-minutes` without reaching the controlled no-op. Extraction now reads the same deadline, stops when it is gone, and clamps its own timeout to what is left. DOWNLOAD_DEADLINE is renamed FETCH_DEADLINE because it now bounds the whole phase. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
bash counts `ulimit -f` in 1024-byte units, but in POSIX mode it counts 512-byte blocks -- so the same arithmetic means two different caps depending on how the runner's shell was invoked. Under POSIX mode the 1024-based value would halve the limit and truncate downloads that fit, which drops a leg and suppresses the analysis. `set +o posix` pins it. Artifact names come from Azure DevOps metadata and are echoed straight into `::warning::`, where a crafted name could forge workflow commands in the log. A sanitized copy already existed for this reason; these call sites were still using the raw value. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 4 out of 4 changed files in this pull request and generated no new comments.
Suppressed comments (4)
Previously missed (4) — in code that hasn't changed since the last review.
.github/workflows/build-failure-analysis.md:220
- The warning message in
ado_getreports "curl exit ${rc}", butrcis the exit status of thetimeout ... curl ...wrapper (e.g., 124 on timeout), not always curl’s own exit code. This can be misleading when diagnosing ADO fetch failures.
if [ "${rc}" -ne 0 ] || [ -z "${ADO_DOC}" ]; then
echo "::warning::Could not fetch the ${what} from Azure DevOps (curl exit ${rc}); treating as a data-resolution failure."
return 1
fi
.github/workflows/build-failure-analysis-command.md:311
- The warning message in
ado_getreports "curl exit ${rc}", butrcis the exit status of thetimeout ... curl ...wrapper (e.g., 124 on timeout), not always curl’s own exit code. This can be misleading when diagnosing ADO fetch failures.
if [ "${rc}" -ne 0 ] || [ -z "${ADO_DOC}" ]; then
echo "::warning::Could not fetch the ${what} from Azure DevOps (curl exit ${rc}); treating as a data-resolution failure."
return 1
fi
.github/workflows/build-failure-analysis.lock.yml:1497
- The warning message in
ado_getreports "curl exit ${rc}", butrcis the exit status of thetimeout ... curl ...wrapper (e.g., 124 on timeout), not always curl’s own exit code. This can be misleading when diagnosing ADO fetch failures.
if [ "${rc}" -ne 0 ] || [ -z "${ADO_DOC}" ]; then
echo "::warning::Could not fetch the ${what} from Azure DevOps (curl exit ${rc}); treating as a data-resolution failure."
return 1
fi
.github/workflows/build-failure-analysis-command.lock.yml:1623
- The warning message in
ado_getreports "curl exit ${rc}", butrcis the exit status of thetimeout ... curl ...wrapper (e.g., 124 on timeout), not always curl’s own exit code. This can be misleading when diagnosing ADO fetch failures.
if [ "${rc}" -ne 0 ] || [ -z "${ADO_DOC}" ]; then
echo "::warning::Could not fetch the ${what} from Azure DevOps (curl exit ${rc}); treating as a data-resolution failure."
return 1
fi
Backports the robustness corrections to both copies of the build failure
analysis fetch step. They came out of review of the same workflow in
dotnet/roslyn#85046 and were
validated end to end there against real Azure DevOps builds. Companion PRs:
dotnet/sdk#55985,
microsoft/testfx#10835.
What changes
build_jsonandartifacts_jsonused a bare
curl; on failure the empty body fell throughjqto anempty result and the job reported a misleading "no artifacts" warning
instead of a fetch failure. A new
ado_gethelper checks the curl exitstatus and validates the body with
jq -e .. It returns a status ratherthan calling
emit_nonedirectly, becauseemit_noneinside a commandsubstitution would only exit the subshell.
GITHUB_OUTPUTonce up front, so the success path's writes arecovered too, not just
emit_none.(
--connect-timeout 10 --max-time 20 --retry-max-time 40), so a stalledendpoint fails in seconds rather than eating the job timeout.
--max-timeis per attempt, so
--retry-max-timeis what actually bounds a retryingcall; there are three of these before any download starts, so their
combined retry windows must not consume the job on their own.
real log artifacts land close enough to 500 MB that an ordinary build trips
the cap and the job silently skips exactly the leg it exists to diagnose.
Only one archive is on disk at a time, so this bounds peak disk use, not the
sum across artifacts.
MAX_TOTAL_ZIP_BYTES(3 GB), charged before each transfer.Arcade had no cumulative compressed budget at all, so raising the
per-artifact cap would have raised worst-case bytes pulled over the network
by the same factor. Each transfer is now clamped to
ZIP_CAP = min(MAX_ZIP_BYTES, MAX_TOTAL_ZIP_BYTES - TOTAL_ZIP_BYTES).Charging before rather than after the transfer stops the last artifact from
starting just under the limit and still pulling a full cap's worth. The
post-download size guard and the
ulimit -fbackstop both useZIP_CAP,otherwise the clamp is defeated.
transfer must not kill the job before the script can emit its controlled
no-op. Because
--max-timeis per attempt,--retry 3alone permitted fourfull transfers plus backoff — around 20 minutes against
timeout-minutes: 15. The loop now has a deadline (DOWNLOAD_BUDGET=420,MAX_ATTEMPT_SECONDS=120) and each transfer derives both--max-timeand--retry-max-timefrom the time left. Since--retry-max-timeonly gateswhether a new retry may start, the whole invocation is also wrapped in
timeout "${TIME_LEFT}"— that is what makes the deadline real. A transferkilled at the deadline is treated like any other failed download: the leg is
reported missing and the analysis is disabled rather than run on a partial
picture.
ulimit -f's 512-byteblock count is rounded up, so a small remaining allowance still buys at
least one block instead of flooring to zero and failing every write. The
compressed budget is a budget rather than a byte-exact ceiling: a transfer
can overshoot its cap by under 512 bytes before the size check rejects it.
The extract and upload steps glob the whole directory, so a binlog left by
an earlier run on the same runner would otherwise be attributed to this
build.
On the lock files
The generated
.lock.ymlfiles are edited in lockstep with the sourcesrather than recompiled, so they stay on the
gh awversion they were builtwith (v0.77.5) and this diff carries no unrelated toolchain churn — no
uses:bumps and noactions-lock.jsonchanges. The run blocks embeddedin the locks were verified byte-identical to the ones in the sources.
Validation
shellcheckandbash -nclean on both generated fetch scripts.directly, so they cannot silently rot: the worst-case download time fits the
job's
timeout-minutes; every retryingcurlbounds its whole retry window;the download is wrapped in
timeout; every budget guard actually leaves theloop; and the binlog directory is cleared. It earned its keep by catching a
real regression during this work — a bad scripted substitution had replaced a
breakwith a literalNone, which bothbash -nand shellcheck acceptwithout complaint.
builds, most recently
run 33172351332:
all seven jobs green, the large log artifact downloaded with no cap or budget
warnings, and a correct root cause with an inline fix suggestion.