fix(ci): retry transient artifact API reads (#120654)

This commit is contained in:
Vincent Koc
2026-08-09 02:05:04 +08:00
committed by GitHub
parent 01ae18c076
commit 0d45efd5da
5 changed files with 258 additions and 95 deletions
+9 -84
View File
@@ -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
+62 -2
View File
@@ -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}}'
;;
+171 -4
View File
@@ -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 {