mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-12 21:53:00 -06:00
fix(ci): retry transient artifact API reads (#120654)
This commit is contained in:
@@ -419,34 +419,9 @@ jobs:
|
||||
echo "Root image artifact name does not match the target and producer run attempt." >&2
|
||||
exit 1
|
||||
}
|
||||
artifact_json="$(gh api "repos/${GITHUB_REPOSITORY}/actions/artifacts/${ARTIFACT_ID}")"
|
||||
jq -e \
|
||||
--arg digest "sha256:${ARTIFACT_DIGEST}" \
|
||||
--arg id "$ARTIFACT_ID" \
|
||||
--arg name "$ARTIFACT_NAME" \
|
||||
--arg run_id "$ARTIFACT_RUN_ID" \
|
||||
'
|
||||
(.id | tostring) == $id and
|
||||
.name == $name and
|
||||
.expired == false and
|
||||
.digest == $digest and
|
||||
(.workflow_run.id | tostring) == $run_id
|
||||
' <<< "$artifact_json" >/dev/null || {
|
||||
echo "Root image artifact identity does not match the requested immutable tuple." >&2
|
||||
exit 1
|
||||
}
|
||||
attempt_json="$(
|
||||
gh api \
|
||||
"repos/${GITHUB_REPOSITORY}/actions/runs/${ARTIFACT_RUN_ID}/attempts/${ARTIFACT_RUN_ATTEMPT}"
|
||||
)"
|
||||
jq -e \
|
||||
--arg attempt "$ARTIFACT_RUN_ATTEMPT" \
|
||||
--arg run_id "$ARTIFACT_RUN_ID" \
|
||||
'(.id | tostring) == $run_id and (.run_attempt | tostring) == $attempt' \
|
||||
<<< "$attempt_json" >/dev/null || {
|
||||
echo "Root image artifact producer run attempt does not match the requested tuple." >&2
|
||||
exit 1
|
||||
}
|
||||
bash .release-harness/scripts/docker/shared-image-artifact.sh \
|
||||
verify-upload "Root image" "$ARTIFACT_ID" "$ARTIFACT_NAME" "$ARTIFACT_DIGEST" \
|
||||
"$ARTIFACT_RUN_ID" "$ARTIFACT_RUN_ATTEMPT"
|
||||
|
||||
- name: Download root Dockerfile image artifact
|
||||
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8
|
||||
@@ -623,34 +598,9 @@ jobs:
|
||||
echo "Root image artifact name does not match the target and producer run attempt." >&2
|
||||
exit 1
|
||||
}
|
||||
artifact_json="$(gh api "repos/${GITHUB_REPOSITORY}/actions/artifacts/${ARTIFACT_ID}")"
|
||||
jq -e \
|
||||
--arg digest "sha256:${ARTIFACT_DIGEST}" \
|
||||
--arg id "$ARTIFACT_ID" \
|
||||
--arg name "$ARTIFACT_NAME" \
|
||||
--arg run_id "$ARTIFACT_RUN_ID" \
|
||||
'
|
||||
(.id | tostring) == $id and
|
||||
.name == $name and
|
||||
.expired == false and
|
||||
.digest == $digest and
|
||||
(.workflow_run.id | tostring) == $run_id
|
||||
' <<< "$artifact_json" >/dev/null || {
|
||||
echo "Root image artifact identity does not match the requested immutable tuple." >&2
|
||||
exit 1
|
||||
}
|
||||
attempt_json="$(
|
||||
gh api \
|
||||
"repos/${GITHUB_REPOSITORY}/actions/runs/${ARTIFACT_RUN_ID}/attempts/${ARTIFACT_RUN_ATTEMPT}"
|
||||
)"
|
||||
jq -e \
|
||||
--arg attempt "$ARTIFACT_RUN_ATTEMPT" \
|
||||
--arg run_id "$ARTIFACT_RUN_ID" \
|
||||
'(.id | tostring) == $run_id and (.run_attempt | tostring) == $attempt' \
|
||||
<<< "$attempt_json" >/dev/null || {
|
||||
echo "Root image artifact producer run attempt does not match the requested tuple." >&2
|
||||
exit 1
|
||||
}
|
||||
bash .release-harness/scripts/docker/shared-image-artifact.sh \
|
||||
verify-upload "Root image" "$ARTIFACT_ID" "$ARTIFACT_NAME" "$ARTIFACT_DIGEST" \
|
||||
"$ARTIFACT_RUN_ID" "$ARTIFACT_RUN_ATTEMPT"
|
||||
|
||||
- name: Download root Dockerfile image artifact
|
||||
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8
|
||||
@@ -804,34 +754,9 @@ jobs:
|
||||
echo "Root image artifact name does not match the target and producer run attempt." >&2
|
||||
exit 1
|
||||
}
|
||||
artifact_json="$(gh api "repos/${GITHUB_REPOSITORY}/actions/artifacts/${ARTIFACT_ID}")"
|
||||
jq -e \
|
||||
--arg digest "sha256:${ARTIFACT_DIGEST}" \
|
||||
--arg id "$ARTIFACT_ID" \
|
||||
--arg name "$ARTIFACT_NAME" \
|
||||
--arg run_id "$ARTIFACT_RUN_ID" \
|
||||
'
|
||||
(.id | tostring) == $id and
|
||||
.name == $name and
|
||||
.expired == false and
|
||||
.digest == $digest and
|
||||
(.workflow_run.id | tostring) == $run_id
|
||||
' <<< "$artifact_json" >/dev/null || {
|
||||
echo "Root image artifact identity does not match the requested immutable tuple." >&2
|
||||
exit 1
|
||||
}
|
||||
attempt_json="$(
|
||||
gh api \
|
||||
"repos/${GITHUB_REPOSITORY}/actions/runs/${ARTIFACT_RUN_ID}/attempts/${ARTIFACT_RUN_ATTEMPT}"
|
||||
)"
|
||||
jq -e \
|
||||
--arg attempt "$ARTIFACT_RUN_ATTEMPT" \
|
||||
--arg run_id "$ARTIFACT_RUN_ID" \
|
||||
'(.id | tostring) == $run_id and (.run_attempt | tostring) == $attempt' \
|
||||
<<< "$attempt_json" >/dev/null || {
|
||||
echo "Root image artifact producer run attempt does not match the requested tuple." >&2
|
||||
exit 1
|
||||
}
|
||||
bash .release-harness/scripts/docker/shared-image-artifact.sh \
|
||||
verify-upload "Root image" "$ARTIFACT_ID" "$ARTIFACT_NAME" "$ARTIFACT_DIGEST" \
|
||||
"$ARTIFACT_RUN_ID" "$ARTIFACT_RUN_ATTEMPT"
|
||||
|
||||
- name: Download root Dockerfile image artifact
|
||||
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8
|
||||
|
||||
@@ -51,6 +51,61 @@ configure_image_artifact_inputs() {
|
||||
archive_path="${artifact_dir}/${archive_name}"
|
||||
}
|
||||
|
||||
is_transient_gh_api_get_error() {
|
||||
local error_text="$1"
|
||||
if [[ "$error_text" =~ (^|[^0-9])(401|403|404|422)([^0-9]|$) ||
|
||||
"$error_text" =~ [Bb]ad[[:space:]]+[Cc]redentials ||
|
||||
"$error_text" =~ [Cc]redential ||
|
||||
"$error_text" =~ [Aa]uthentication ]]; then
|
||||
return 1
|
||||
fi
|
||||
|
||||
[[ "$error_text" == *"i/o timeout"* ||
|
||||
"$error_text" =~ [Cc]ontext[[:space:]]+deadline[[:space:]]+exceeded ||
|
||||
"$error_text" =~ [Cc]onnection[[:space:]]+(refused|reset) ||
|
||||
"$error_text" =~ [Nn]etwork[[:space:]]+is[[:space:]]+unreachable ||
|
||||
"$error_text" =~ [Nn]o[[:space:]]+such[[:space:]]+host ||
|
||||
"$error_text" =~ [Tt]emporary[[:space:]]+failure ||
|
||||
"$error_text" =~ TLS[[:space:]]+handshake[[:space:]]+timeout ||
|
||||
"$error_text" =~ [Uu]nexpected[[:space:]]+EOF ||
|
||||
"$error_text" =~ (^|[^0-9])429([^0-9]|$) ||
|
||||
"$error_text" =~ [Rr]ate[[:space:]-]*limit ||
|
||||
"$error_text" =~ ([Hh][Tt][Tt][Pp]|[Ss]tatus([[:space:]_-]*code)?)[^0-9]*5[0-9]{2} ]]
|
||||
}
|
||||
|
||||
gh_api_get_with_retry() {
|
||||
local label="$1"
|
||||
local endpoint="$2"
|
||||
local attempt error_file response_file retry_delay retry_dir
|
||||
retry_dir="$(mktemp -d)"
|
||||
response_file="${retry_dir}/response"
|
||||
error_file="${retry_dir}/error"
|
||||
|
||||
for attempt in 1 2 3; do
|
||||
: > "$response_file"
|
||||
: > "$error_file"
|
||||
if gh api --method GET "$endpoint" > "$response_file" 2> "$error_file"; then
|
||||
cat "$response_file"
|
||||
rm -rf -- "$retry_dir"
|
||||
return 0
|
||||
fi
|
||||
|
||||
if [[ "$attempt" -lt 3 ]] && is_transient_gh_api_get_error "$(cat "$error_file")"; then
|
||||
retry_delay=$((attempt * 2))
|
||||
printf \
|
||||
'warning: %s GitHub API GET failed transiently on attempt %d/3; retrying in %ss.\n' \
|
||||
"$label" "$attempt" "$retry_delay" >&2
|
||||
cat "$error_file" >&2
|
||||
sleep "$retry_delay"
|
||||
continue
|
||||
fi
|
||||
|
||||
cat "$error_file" >&2
|
||||
rm -rf -- "$retry_dir"
|
||||
fail "$label GitHub API GET failed after $attempt attempt(s)."
|
||||
done
|
||||
}
|
||||
|
||||
verify_uploaded_artifact() {
|
||||
if [[ "$#" -ne 6 ]]; then
|
||||
fail "usage: $0 verify-upload <label> <artifact-id> <artifact-name> <artifact-digest> <run-id> <run-attempt>"
|
||||
@@ -84,7 +139,11 @@ verify_uploaded_artifact() {
|
||||
command -v jq >/dev/null
|
||||
|
||||
local artifact_json attempt_json
|
||||
artifact_json="$(gh api "repos/${GITHUB_REPOSITORY}/actions/artifacts/${artifact_id}")"
|
||||
artifact_json="$(
|
||||
gh_api_get_with_retry \
|
||||
"$artifact_label artifact metadata" \
|
||||
"repos/${GITHUB_REPOSITORY}/actions/artifacts/${artifact_id}"
|
||||
)"
|
||||
jq -e \
|
||||
--arg digest "sha256:${artifact_digest}" \
|
||||
--arg id "$artifact_id" \
|
||||
@@ -100,7 +159,8 @@ verify_uploaded_artifact() {
|
||||
fail "$artifact_label artifact identity does not match the immutable producer tuple."
|
||||
|
||||
attempt_json="$(
|
||||
gh api \
|
||||
gh_api_get_with_retry \
|
||||
"$artifact_label producer run attempt metadata" \
|
||||
"repos/${GITHUB_REPOSITORY}/actions/runs/${artifact_run_id}/attempts/${artifact_run_attempt}"
|
||||
)"
|
||||
jq -e \
|
||||
|
||||
@@ -230,12 +230,16 @@ describe("install smoke no-push root image transport", () => {
|
||||
expect(binding.run, jobName).toContain(
|
||||
'expected_artifact_name="install-smoke-root-image-${TARGET_SHA:0:12}-${ARTIFACT_RUN_ID}-${ARTIFACT_RUN_ATTEMPT}"',
|
||||
);
|
||||
expect(binding.run, jobName).toContain('[[ "$ARCHIVE_SHA256" =~ ^[a-f0-9]{64}$ ]]');
|
||||
expect(binding.run, jobName).toContain(
|
||||
"repos/${GITHUB_REPOSITORY}/actions/artifacts/${ARTIFACT_ID}",
|
||||
);
|
||||
expect(binding.run, jobName).toContain(
|
||||
"repos/${GITHUB_REPOSITORY}/actions/runs/${ARTIFACT_RUN_ID}/attempts/${ARTIFACT_RUN_ATTEMPT}",
|
||||
"bash .release-harness/scripts/docker/shared-image-artifact.sh",
|
||||
);
|
||||
expect(binding.run, jobName).toContain('verify-upload "Root image"');
|
||||
expect(binding.run, jobName).toContain('"$ARTIFACT_RUN_ID" "$ARTIFACT_RUN_ATTEMPT"');
|
||||
expect(binding.run, jobName).not.toContain("gh api");
|
||||
expect(binding.run, jobName).not.toContain("artifact_json=");
|
||||
expect(binding.run, jobName).not.toContain("attempt_json=");
|
||||
expect(binding.run, jobName).not.toContain("<<<");
|
||||
|
||||
const download = step(consumer, "Download root Dockerfile image artifact");
|
||||
expect(download.if, jobName).toBeUndefined();
|
||||
@@ -257,6 +261,10 @@ describe("install smoke no-push root image transport", () => {
|
||||
expect(requireLocal.if, jobName).toBeUndefined();
|
||||
expect(requireLocal.run, jobName).toBe('docker image inspect "$IMAGE_REF" >/dev/null');
|
||||
}
|
||||
|
||||
const text = readFileSync(INSTALL_SMOKE_REUSABLE, "utf8");
|
||||
expect(text.match(/verify-upload "Root image"/g)).toHaveLength(3);
|
||||
expect(text).not.toContain("gh api");
|
||||
});
|
||||
|
||||
it("selects the read-only reusable core from release checks", () => {
|
||||
|
||||
@@ -5894,7 +5894,10 @@ wait_for_run plugin-clawhub-new.yml 123 "${expectedSha}" || status=$?
|
||||
writeFileSync(
|
||||
ghPath,
|
||||
`#!/bin/sh
|
||||
case "$2" in
|
||||
if [ "$#" -ne 4 ] || [ "$1" != "api" ] || [ "$2" != "--method" ] || [ "$3" != "GET" ]; then
|
||||
exit 1
|
||||
fi
|
||||
case "$4" in
|
||||
*/actions/artifacts/123)
|
||||
printf '%s\n' '{"id":123,"name":"docker-e2e-package-456-1","expired":false,"digest":"sha256:${"b".repeat(64)}","workflow_run":{"id":456}}'
|
||||
;;
|
||||
|
||||
@@ -85,9 +85,13 @@ function createFixture() {
|
||||
const artifactDir = join(root, "artifact");
|
||||
const dockerLog = join(root, "docker.log");
|
||||
const ghLog = join(root, "gh.log");
|
||||
const ghState = join(root, "gh-state");
|
||||
const sleepLog = join(root, "sleep.log");
|
||||
mkdirSync(bin);
|
||||
mkdirSync(ghState);
|
||||
writeFileSync(dockerLog, "");
|
||||
writeFileSync(ghLog, "");
|
||||
writeFileSync(sleepLog, "");
|
||||
|
||||
writeExecutable(
|
||||
join(bin, "docker"),
|
||||
@@ -185,14 +189,52 @@ printf '%s\\n' "$*" >> "$FAKE_GH_LOG"
|
||||
echo "unexpected gh invocation: $*" >&2
|
||||
exit 2
|
||||
}
|
||||
path="$2"
|
||||
shift
|
||||
method=""
|
||||
path=""
|
||||
while [[ "$#" -gt 0 ]]; do
|
||||
case "$1" in
|
||||
--method)
|
||||
method="\${2:?method required}"
|
||||
shift 2
|
||||
;;
|
||||
*)
|
||||
[[ -z "$path" ]] || {
|
||||
echo "unexpected gh api argument: $1" >&2
|
||||
exit 2
|
||||
}
|
||||
path="$1"
|
||||
shift
|
||||
;;
|
||||
esac
|
||||
done
|
||||
[[ "$method" == "GET" ]] || {
|
||||
echo "unexpected gh api method: $method" >&2
|
||||
exit 2
|
||||
}
|
||||
|
||||
case "$path" in
|
||||
"repos/\${GITHUB_REPOSITORY}/actions/artifacts/${ARTIFACT_ID}")
|
||||
count_file="$FAKE_GH_STATE/artifact"
|
||||
count="$(( $(cat "$count_file" 2>/dev/null || printf 0) + 1 ))"
|
||||
printf '%s\\n' "$count" > "$count_file"
|
||||
if [[ "$count" -le "\${FAKE_GH_ARTIFACT_FAILURES:-0}" ]]; then
|
||||
printf 'partial artifact response\\n'
|
||||
printf '%s\\n' "\${FAKE_GH_ARTIFACT_ERROR:-gh: request failed}" >&2
|
||||
exit 1
|
||||
fi
|
||||
if [[ -n "\${FAKE_ARTIFACT_JSON:-}" ]]; then
|
||||
printf '%s\\n' "$FAKE_ARTIFACT_JSON"
|
||||
exit 0
|
||||
fi
|
||||
printf '{"id":%s,"name":"%s","expired":%s,"digest":"sha256:%s","workflow_run":{"id":%s}}\\n' \
|
||||
"$FAKE_ARTIFACT_ID" "$FAKE_ARTIFACT_NAME" "$FAKE_ARTIFACT_EXPIRED" \
|
||||
"$FAKE_ARTIFACT_DIGEST" "$FAKE_ARTIFACT_RUN_ID"
|
||||
;;
|
||||
"repos/\${GITHUB_REPOSITORY}/actions/runs/${ARTIFACT_RUN_ID}/attempts/${ARTIFACT_RUN_ATTEMPT}")
|
||||
count_file="$FAKE_GH_STATE/attempt"
|
||||
count="$(( $(cat "$count_file" 2>/dev/null || printf 0) + 1 ))"
|
||||
printf '%s\\n' "$count" > "$count_file"
|
||||
printf '{"id":%s,"run_attempt":%s}\\n' \
|
||||
"$FAKE_ATTEMPT_RUN_ID" "$FAKE_ARTIFACT_RUN_ATTEMPT"
|
||||
;;
|
||||
@@ -204,6 +246,14 @@ esac
|
||||
`,
|
||||
);
|
||||
|
||||
writeExecutable(
|
||||
join(bin, "sleep"),
|
||||
`#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
printf '%s\\n' "$*" >> "$FAKE_SLEEP_LOG"
|
||||
`,
|
||||
);
|
||||
|
||||
const env = {
|
||||
...process.env,
|
||||
FAKE_ARTIFACT_DIGEST: ARTIFACT_DIGEST,
|
||||
@@ -215,6 +265,8 @@ esac
|
||||
FAKE_ATTEMPT_RUN_ID: ARTIFACT_RUN_ID,
|
||||
FAKE_DOCKER_LOG: dockerLog,
|
||||
FAKE_GH_LOG: ghLog,
|
||||
FAKE_GH_STATE: ghState,
|
||||
FAKE_SLEEP_LOG: sleepLog,
|
||||
GH_TOKEN: "test-token",
|
||||
GITHUB_REPOSITORY: "openclaw/openclaw",
|
||||
GITHUB_RUN_ATTEMPT: "2",
|
||||
@@ -223,7 +275,7 @@ esac
|
||||
RUNNER_TEMP: root,
|
||||
OPENCLAW_SHARED_IMAGE_PACKAGE_SHA256: PACKAGE_SHA256,
|
||||
};
|
||||
return { artifactDir, dockerLog, env, ghLog, root };
|
||||
return { artifactDir, dockerLog, env, ghLog, root, sleepLog };
|
||||
}
|
||||
|
||||
function expectedArchiveEnv(fixture: ReturnType<typeof createFixture>): NodeJS.ProcessEnv {
|
||||
@@ -245,12 +297,13 @@ describe("shared Docker image artifacts", () => {
|
||||
const verified = verifyUploadedArtifact(fixture);
|
||||
expect(verified.status, `${verified.stdout}\n${verified.stderr}`).toBe(0);
|
||||
expect(readFileSync(fixture.ghLog, "utf8")).toContain(
|
||||
`api repos/openclaw/openclaw/actions/artifacts/${ARTIFACT_ID}`,
|
||||
`api --method GET repos/openclaw/openclaw/actions/artifacts/${ARTIFACT_ID}`,
|
||||
);
|
||||
expect(readFileSync(fixture.ghLog, "utf8")).toContain(
|
||||
`api repos/openclaw/openclaw/actions/runs/${ARTIFACT_RUN_ID}/attempts/${ARTIFACT_RUN_ATTEMPT}`,
|
||||
`api --method GET repos/openclaw/openclaw/actions/runs/${ARTIFACT_RUN_ID}/attempts/${ARTIFACT_RUN_ATTEMPT}`,
|
||||
);
|
||||
|
||||
writeFileSync(fixture.ghLog, "");
|
||||
const digestMismatch = verifyUploadedArtifact(fixture, {
|
||||
artifactDigest: "e".repeat(64),
|
||||
});
|
||||
@@ -258,7 +311,21 @@ describe("shared Docker image artifacts", () => {
|
||||
expect(digestMismatch.stderr).toContain(
|
||||
"artifact identity does not match the immutable producer tuple",
|
||||
);
|
||||
expect(readFileSync(fixture.ghLog, "utf8").trim().split("\n")).toHaveLength(1);
|
||||
expect(digestMismatch.stderr).not.toContain("retrying");
|
||||
|
||||
writeFileSync(fixture.ghLog, "");
|
||||
const invalidJson = verifyUploadedArtifact(fixture, {
|
||||
env: { FAKE_ARTIFACT_JSON: "not-json" },
|
||||
});
|
||||
expect(invalidJson.status).not.toBe(0);
|
||||
expect(invalidJson.stderr).toContain(
|
||||
"artifact identity does not match the immutable producer tuple",
|
||||
);
|
||||
expect(readFileSync(fixture.ghLog, "utf8").trim().split("\n")).toHaveLength(1);
|
||||
expect(invalidJson.stderr).not.toContain("retrying");
|
||||
|
||||
writeFileSync(fixture.ghLog, "");
|
||||
const attemptMismatch = verifyUploadedArtifact(fixture, {
|
||||
env: { FAKE_ARTIFACT_RUN_ATTEMPT: "3" },
|
||||
});
|
||||
@@ -266,11 +333,111 @@ describe("shared Docker image artifacts", () => {
|
||||
expect(attemptMismatch.stderr).toContain(
|
||||
"producer run attempt does not match the immutable tuple",
|
||||
);
|
||||
expect(readFileSync(fixture.ghLog, "utf8").trim().split("\n")).toHaveLength(2);
|
||||
expect(attemptMismatch.stderr).not.toContain("retrying");
|
||||
} finally {
|
||||
rmSync(fixture.root, { force: true, recursive: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("retries an exact i/o timeout without leaking partial output", () => {
|
||||
const fixture = createFixture();
|
||||
try {
|
||||
const verified = verifyUploadedArtifact(fixture, {
|
||||
env: {
|
||||
FAKE_GH_ARTIFACT_ERROR: "Get https://api.github.com: dial tcp: i/o timeout",
|
||||
FAKE_GH_ARTIFACT_FAILURES: "1",
|
||||
},
|
||||
});
|
||||
expect(verified.status, `${verified.stdout}\n${verified.stderr}`).toBe(0);
|
||||
expect(verified.stdout).not.toContain("partial artifact response");
|
||||
expect(verified.stderr).toContain(
|
||||
"artifact metadata GitHub API GET failed transiently on attempt 1/3; retrying in 2s",
|
||||
);
|
||||
expect(readFileSync(fixture.sleepLog, "utf8")).toBe("2\n");
|
||||
const calls = readFileSync(fixture.ghLog, "utf8");
|
||||
expect(calls.match(/actions\/artifacts/g)).toHaveLength(2);
|
||||
expect(calls.match(/actions\/runs/g)).toHaveLength(1);
|
||||
} finally {
|
||||
rmSync(fixture.root, { force: true, recursive: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("recovers on the third attempt and fails cleanly when all attempts are exhausted", () => {
|
||||
const recoveredFixture = createFixture();
|
||||
try {
|
||||
const recovered = verifyUploadedArtifact(recoveredFixture, {
|
||||
env: {
|
||||
FAKE_GH_ARTIFACT_ERROR: "gh: upstream unavailable (HTTP 503)",
|
||||
FAKE_GH_ARTIFACT_FAILURES: "2",
|
||||
},
|
||||
});
|
||||
expect(recovered.status, `${recovered.stdout}\n${recovered.stderr}`).toBe(0);
|
||||
expect(readFileSync(recoveredFixture.sleepLog, "utf8")).toBe("2\n4\n");
|
||||
expect(
|
||||
readFileSync(recoveredFixture.ghLog, "utf8").match(/actions\/artifacts/g),
|
||||
).toHaveLength(3);
|
||||
} finally {
|
||||
rmSync(recoveredFixture.root, { force: true, recursive: true });
|
||||
}
|
||||
|
||||
const exhaustedFixture = createFixture();
|
||||
try {
|
||||
const exhausted = verifyUploadedArtifact(exhaustedFixture, {
|
||||
env: {
|
||||
FAKE_GH_ARTIFACT_ERROR: "Get https://api.github.com: dial tcp: i/o timeout",
|
||||
FAKE_GH_ARTIFACT_FAILURES: "3",
|
||||
},
|
||||
});
|
||||
expect(exhausted.status).not.toBe(0);
|
||||
expect(exhausted.stdout).not.toContain("partial artifact response");
|
||||
expect(exhausted.stderr).toContain("i/o timeout");
|
||||
expect(exhausted.stderr).toContain("GitHub API GET failed after 3 attempt(s)");
|
||||
expect(readFileSync(exhaustedFixture.sleepLog, "utf8")).toBe("2\n4\n");
|
||||
const calls = readFileSync(exhaustedFixture.ghLog, "utf8");
|
||||
expect(calls.match(/actions\/artifacts/g)).toHaveLength(3);
|
||||
expect(calls).not.toContain("actions/runs");
|
||||
} finally {
|
||||
rmSync(exhaustedFixture.root, { force: true, recursive: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("retries explicit rate limiting but not permanent HTTP or credential failures", () => {
|
||||
const rateLimitFixture = createFixture();
|
||||
try {
|
||||
const rateLimited = verifyUploadedArtifact(rateLimitFixture, {
|
||||
env: {
|
||||
FAKE_GH_ARTIFACT_ERROR: "gh: API rate limit exceeded (HTTP 429)",
|
||||
FAKE_GH_ARTIFACT_FAILURES: "1",
|
||||
},
|
||||
});
|
||||
expect(rateLimited.status, `${rateLimited.stdout}\n${rateLimited.stderr}`).toBe(0);
|
||||
expect(readFileSync(rateLimitFixture.sleepLog, "utf8")).toBe("2\n");
|
||||
} finally {
|
||||
rmSync(rateLimitFixture.root, { force: true, recursive: true });
|
||||
}
|
||||
|
||||
for (const error of ["gh: Not Found (HTTP 404)", "gh: Bad credentials (HTTP 500)"]) {
|
||||
const fixture = createFixture();
|
||||
try {
|
||||
const failed = verifyUploadedArtifact(fixture, {
|
||||
env: {
|
||||
FAKE_GH_ARTIFACT_ERROR: error,
|
||||
FAKE_GH_ARTIFACT_FAILURES: "3",
|
||||
},
|
||||
});
|
||||
expect(failed.status, error).not.toBe(0);
|
||||
expect(failed.stderr).toContain(error);
|
||||
expect(failed.stderr).toContain("GitHub API GET failed after 1 attempt(s)");
|
||||
expect(failed.stderr).not.toContain("retrying");
|
||||
expect(readFileSync(fixture.ghLog, "utf8").trim().split("\n")).toHaveLength(1);
|
||||
expect(readFileSync(fixture.sleepLog, "utf8")).toBe("");
|
||||
} finally {
|
||||
rmSync(fixture.root, { force: true, recursive: true });
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it("packs provenance-bound images and verifies them before loading", () => {
|
||||
const fixture = createFixture();
|
||||
try {
|
||||
|
||||
Reference in New Issue
Block a user