diff --git a/.github/workflows/_build-platform.yml b/.github/workflows/_build-platform.yml index 9812dc2..e8993c0 100644 --- a/.github/workflows/_build-platform.yml +++ b/.github/workflows/_build-platform.yml @@ -1717,8 +1717,13 @@ jobs: run: | set -euo pipefail mkdir -p tools - gh release download v0.1.9 --repo tamatebako/tebako \ - --pattern 'tfs-0.1.9-windows-ucrt64.exe' --output tools/tfs.exe --clobber + # release-asset GETs are transient-prone under publish load — + # bounded retry, same contract as scripts/sign_release.rb. + for attempt in 1 2 3 4; do + gh release download v0.1.9 --repo tamatebako/tebako \ + --pattern 'tfs-0.1.9-windows-ucrt64.exe' --output tools/tfs.exe --clobber && break + [ "$attempt" -lt 4 ] && sleep $((2 ** attempt)) || exit 1 + done - name: Run the spec22-gems acceptance (msys) shell: msys2 {0} env: diff --git a/scripts/sign_release.rb b/scripts/sign_release.rb index 0667151..375aad3 100755 --- a/scripts/sign_release.rb +++ b/scripts/sign_release.rb @@ -325,14 +325,26 @@ def listed_sha(asset) # The default command seam: argv in, stdout out, named failure on a # non-zero exit. Specs inject a recording stand-in. class ShellExecutor + # gh's release-asset edges are transient-prone under release-storm + # load: the 5xx class and the intermediary 403 clear on a re-ask (the + # 0.16.24 publish lost a signing leg to an HTTP 500 on a manifest + # download — after every asset had already converged). Deterministic + # failures (404s, auth, usage) raise at once. Bounded, with backoff. + TRANSIENT = /HTTP 5\d\d|intermediary/i + ATTEMPTS = 4 + def run(*argv, chdir: ".") - out, err, status = Open3.capture3(*argv, chdir: chdir) - unless status.success? - raise SigningGateError, - "NAMED FAILURE: `#{argv.join(" ")}` exited #{status.exitstatus}: #{err.strip}" - end + attempts = 0 + loop do + out, err, status = Open3.capture3(*argv, chdir: chdir) + break out if status.success? - out + unless err =~ TRANSIENT && (attempts += 1) < ATTEMPTS + raise SigningGateError, + "NAMED FAILURE: `#{argv.join(" ")}` exited #{status.exitstatus}: #{err.strip}" + end + sleep(2**attempts) + end end end end diff --git a/spec/sign_release_spec.rb b/spec/sign_release_spec.rb index bbb714d..beef116 100644 --- a/spec/sign_release_spec.rb +++ b/spec/sign_release_spec.rb @@ -327,3 +327,42 @@ def client.upload_asset(_url, _path, content_type:, name:) .to raise_error(ReleaseSigner::SigningGateError, /did not converge/) end end + +# The real executor's transient class: 5xx and the intermediary 403 earn +# a bounded re-ask (0.16.24's publish lost a signing leg to an HTTP 500 +# after every asset had converged); deterministic failures raise at once. +RSpec.describe ReleaseSigner::ShellExecutor do + subject(:executor) { described_class.new } + + def capture3_queue(*results) + calls = [] + allow(Open3).to receive(:capture3) do |*argv, **| + calls << argv + out, err, code = results[[calls.length - 1, results.length - 1].min] + [out, err, instance_double(Process::Status, success?: code.zero?, exitstatus: code)] + end + calls + end + + it "retries a 5xx and returns the first success" do + calls = capture3_queue(["", "HTTP 500", 1], ["ok-bytes", "", 0]) + allow(executor).to receive(:sleep) + expect(executor.run("gh", "release", "download", "vX")).to eq("ok-bytes") + expect(calls.length).to eq(2) + end + + it "raises immediately on a deterministic failure (no retry budget spent)" do + calls = capture3_queue(["", "HTTP 404 Not Found", 1]) + expect { executor.run("gh", "release", "download", "vX") } + .to raise_error(ReleaseSigner::SigningGateError, /NAMED FAILURE.*404/) + expect(calls.length).to eq(1) + end + + it "spends the whole budget on a persistent transient, then names it" do + calls = capture3_queue(["", "Error from intermediary with HTTP status code 403", 1]) + allow(executor).to receive(:sleep) + expect { executor.run("gh", "release", "download", "vX") } + .to raise_error(ReleaseSigner::SigningGateError, /NAMED FAILURE.*intermediary/) + expect(calls.length).to eq(ReleaseSigner::ShellExecutor::ATTEMPTS) + end +end