Skip to content

Commit f41bf4f

Browse files
kenvandineclaude
andcommitted
ci: fail release if expected artifacts are missing, per backend family
Port the artifact-completeness gate from lemonade-sdk/llama.cpp#16 to this repo's release job, adapted to its build matrix: - cuda: 8 sm_* variants on ubuntu x64 + windows x64, plus sm_121-only on ubuntu arm64 (GB10) — 17 files total. - rocm-therock: TheRock nightly ROCm 7.13.0 on windows + ubuntu. - rocm-hipsdk: official/legacy HIP SDK ROCm on windows (7.1.1) + ubuntu (7.2.1) — kept separate from rocm-therock since they're different SDKs, not just different platforms of the same backend. - cpu: ubuntu + windows avx2 builds. - metal: macos-14 + macos-latest builds. Each family is all-or-nothing: if any artifact in a family is missing, every artifact for that family is dropped from the release so a release never contains a partial/inconsistent set (e.g. CUDA split across sm_* or platforms). Other families are unaffected by a gap in one. A release with zero artifacts overall is never created. Also brings the release/upload mechanism up to parity with llama.cpp's: an idempotent create-or-get-release step (safe to re-run after a partial failure) and a retrying, resumable asset upload, replacing the old anzz1/action-create-release + github-script@v3 flow. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
1 parent 1206d2d commit f41bf4f

1 file changed

Lines changed: 192 additions & 29 deletions

File tree

‎.github/workflows/build.yml‎

Lines changed: 192 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -972,48 +972,211 @@ jobs:
972972
TAG_NAME="${{ env.BRANCH_NAME }}-${{ steps.commit_count.outputs.count }}-${{ steps.commit.outputs.short }}"
973973
echo "name=${TAG_NAME}" >> $GITHUB_OUTPUT
974974
975-
- name: Check if tag exists
976-
id: check_tag
975+
- name: Check release artifacts
976+
id: check_artifacts
977977
env:
978-
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
978+
BRANCH: ${{ env.BRANCH_NAME }}
979+
SHORT: ${{ steps.commit.outputs.short }}
979980
run: |
980-
TAG_NAME="${{ steps.tag_name.outputs.name }}"
981-
if git ls-remote --tags https://github.com/${{ github.repository }}.git | grep -q "refs/tags/${TAG_NAME}$"; then
982-
echo "Tag ${TAG_NAME} already exists in ${{ github.repository }}. Skipping release creation."
983-
echo "exists=true" >> $GITHUB_OUTPUT
981+
total_missing=0
982+
summary=""
983+
984+
check_family() {
985+
local family="$1"; shift
986+
local patterns=("$@")
987+
local missing=0
988+
for pattern in "${patterns[@]}"; do
989+
if ! ls $pattern >/dev/null 2>&1; then
990+
echo "::error::[$family] Missing artifact: $pattern"
991+
missing=$((missing + 1))
992+
fi
993+
done
994+
if [ "$missing" -gt 0 ]; then
995+
echo "::warning::$family family is incomplete ($missing artifact(s) missing) — dropping all $family artifacts from this release."
996+
for pattern in "${patterns[@]}"; do
997+
rm -fv $pattern 2>/dev/null || true
998+
done
999+
total_missing=$((total_missing + missing))
1000+
summary="${summary}${summary:+, }${family}: $missing missing, family dropped"
1001+
fi
1002+
}
1003+
1004+
# Each backend family must be published as a complete, internally
1005+
# consistent set across every platform/variant/SDK it covers — never
1006+
# a partial mix. A gap in one family drops that whole family from
1007+
# the release; other families are unaffected.
1008+
1009+
# CUDA: 8 sm_* variants on ubuntu x64 and windows x64; only sm_121
1010+
# (GB10) is built for ubuntu arm64.
1011+
cuda_patterns=()
1012+
for sm in sm_75 sm_80 sm_86 sm_89 sm_90 sm_100 sm_120 sm_121; do
1013+
cuda_patterns+=("artifact/sd-${BRANCH}-${SHORT}-ubuntu-cuda-${sm}-x64.tar.xz")
1014+
cuda_patterns+=("artifact/sd-${BRANCH}-${SHORT}-windows-cuda-${sm}-x64.zip")
1015+
done
1016+
cuda_patterns+=("artifact/sd-${BRANCH}-${SHORT}-ubuntu-cuda-sm_121-arm64.tar.xz")
1017+
check_family cuda "${cuda_patterns[@]}"
1018+
1019+
# TheRock nightly ROCm 7.13.0 (windows-latest-rocm + the 7.13.0 leg
1020+
# of ubuntu-latest-rocm). Kept separate from the HIP SDK line below
1021+
# since they're different SDKs, not just different platforms.
1022+
check_family rocm-therock \
1023+
"artifact/sd-${BRANCH}-${SHORT}-bin-win-rocm-7.13.0-x64.zip" \
1024+
"artifact/sd-${BRANCH}-${SHORT}-bin-Linux-Ubuntu-24.04-x86_64-rocm-7.13.0.zip"
1025+
1026+
# Official/legacy HIP SDK ROCm (windows-latest-cmake-hip 7.1.1 +
1027+
# the 7.2.1 leg of ubuntu-latest-rocm).
1028+
check_family rocm-hipsdk \
1029+
"artifact/sd-${BRANCH}-${SHORT}-bin-win-rocm-7.1.1-x64.zip" \
1030+
"artifact/sd-${BRANCH}-${SHORT}-bin-Linux-Ubuntu-24.04-x86_64-rocm-7.2.1.zip"
1031+
1032+
# CPU: ubuntu-latest-cmake's filename embeds the runner's OS name/
1033+
# version via lsb_release, so it's wildcarded rather than pinned.
1034+
check_family cpu \
1035+
"artifact/sd-${BRANCH}-${SHORT}-bin-Linux-*-x86_64.zip" \
1036+
"artifact/sd-${BRANCH}-${SHORT}-bin-win-avx2-x64.zip"
1037+
1038+
check_family metal \
1039+
"artifact/sd-${BRANCH}-${SHORT}-bin-Darwin-arm64-metal.zip" \
1040+
"artifact/sd-${BRANCH}-${SHORT}-bin-Darwin-arm64-metal-latest.zip"
1041+
1042+
echo "missing=$total_missing" >> "$GITHUB_OUTPUT"
1043+
echo "summary=$summary" >> "$GITHUB_OUTPUT"
1044+
1045+
files=$(find ./artifact -maxdepth 1 \( -name '*.zip' -o -name '*.tar.xz' \) 2>/dev/null | wc -l)
1046+
1047+
# Never create or update a release with nothing to publish.
1048+
if [ "$files" -eq 0 ]; then
1049+
echo "::error::No release artifacts found in ./artifact — aborting before creating a release."
1050+
exit 1
1051+
fi
1052+
1053+
if [ "$total_missing" -gt 0 ]; then
1054+
echo "$total_missing artifact(s) missing ($summary) — continuing to publish the $files artifact(s) from complete families. The job will still fail below so the gap gets noticed."
9841055
else
985-
echo "Tag ${TAG_NAME} does not exist in ${{ github.repository }}. Proceeding with release."
986-
echo "exists=false" >> $GITHUB_OUTPUT
1056+
echo "All expected artifacts present. Found $files artifact(s) ready to upload."
9871057
fi
9881058
989-
- name: Create release
990-
id: create_release
991-
if: steps.check_tag.outputs.exists == 'false'
992-
uses: anzz1/action-create-release@v1
1059+
- name: Determine release summary
1060+
id: release_summary
1061+
run: |
1062+
if [[ "${{ github.event_name }}" == "schedule" ]]; then
1063+
echo "value=Nightly release for ${{ github.sha }}" >> "$GITHUB_OUTPUT"
1064+
else
1065+
echo "value=$(git log -1 --pretty=%s)" >> "$GITHUB_OUTPUT"
1066+
fi
1067+
1068+
# Get the release for this tag, creating it if it does not exist yet.
1069+
# This is idempotent: if a previous run created the release but failed
1070+
# partway through uploading (e.g. a transient GitHub outage), re-running
1071+
# reuses the existing release so the upload step below can fill the gaps.
1072+
- name: Create or get release
1073+
id: release
1074+
uses: actions/github-script@v8
9931075
env:
994-
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
1076+
TAG_NAME: ${{ steps.tag_name.outputs.name }}
1077+
RELEASE_SUMMARY: ${{ steps.release_summary.outputs.value }}
9951078
with:
996-
tag_name: ${{ steps.tag_name.outputs.name }}
1079+
github-token: ${{ secrets.GITHUB_TOKEN }}
1080+
script: |
1081+
const tag = process.env.TAG_NAME;
1082+
const { owner, repo } = context.repo;
1083+
let release;
1084+
try {
1085+
release = (await github.rest.repos.getReleaseByTag({ owner, repo, tag })).data;
1086+
core.info(`Reusing existing release ${tag} (id ${release.id})`);
1087+
} catch (e) {
1088+
if (e.status !== 404) throw e;
1089+
try {
1090+
release = (await github.rest.repos.createRelease({ owner, repo, tag_name: tag, body: process.env.RELEASE_SUMMARY })).data;
1091+
core.info(`Created release ${tag} (id ${release.id})`);
1092+
} catch (createError) {
1093+
if (createError.status !== 422) throw createError;
1094+
release = (await github.rest.repos.getReleaseByTag({ owner, repo, tag })).data;
1095+
core.info(`Reusing concurrently created release ${tag} (id ${release.id})`);
1096+
}
1097+
}
1098+
core.setOutput('id', release.id);
9971099
9981100
- name: Upload release
9991101
id: upload_release
1000-
if: steps.check_tag.outputs.exists == 'false'
1001-
uses: actions/github-script@v3
1102+
uses: actions/github-script@v8
1103+
env:
1104+
RELEASE_ID: ${{ steps.release.outputs.id }}
10021105
with:
10031106
github-token: ${{secrets.GITHUB_TOKEN}}
10041107
script: |
1005-
const path = require('path');
10061108
const fs = require('fs');
1007-
const release_id = '${{ steps.create_release.outputs.id }}';
1008-
for (let file of await fs.readdirSync('./artifact')) {
1009-
if (path.extname(file) === '.zip' || file.endsWith('.tar.xz')) {
1010-
console.log('uploadReleaseAsset', file);
1011-
await github.repos.uploadReleaseAsset({
1012-
owner: context.repo.owner,
1013-
repo: context.repo.repo,
1014-
release_id: release_id,
1015-
name: file,
1016-
data: await fs.readFileSync(`./artifact/${file}`)
1017-
});
1109+
const { owner, repo } = context.repo;
1110+
const release_id = Number(process.env.RELEASE_ID);
1111+
1112+
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
1113+
1114+
// Retry transient failures (network errors, 5xx, rate limits) with
1115+
// exponential backoff so a momentary GitHub blip does not abort the
1116+
// whole upload and leave the release partially populated.
1117+
async function withRetry(label, fn, attempts = 5) {
1118+
for (let i = 1; ; i++) {
1119+
try {
1120+
return await fn();
1121+
} catch (e) {
1122+
const transient = !e.status || e.status >= 500 || e.status === 429;
1123+
if (i >= attempts || !transient) throw e;
1124+
const delay = Math.min(30000, 1000 * 2 ** (i - 1));
1125+
core.warning(`${label} failed (attempt ${i}/${attempts}): ${e.message}. Retrying in ${delay}ms`);
1126+
await sleep(delay);
1127+
}
1128+
}
1129+
}
1130+
1131+
// Assets already attached (from an earlier partial run). GitHub only
1132+
// creates an asset once its upload completes, so anything listed here
1133+
// is intact and can be skipped — re-runs only fill the gaps.
1134+
const existing = new Set();
1135+
for (const a of await github.paginate(github.rest.repos.listReleaseAssets, { owner, repo, release_id })) {
1136+
if (a.state === 'uploaded') {
1137+
existing.add(a.name);
1138+
} else {
1139+
core.warning(`deleting incomplete asset ${a.name} (${a.state})`);
1140+
await github.rest.repos.deleteReleaseAsset({ owner, repo, asset_id: a.id });
1141+
}
1142+
}
1143+
1144+
const files = fs.readdirSync('./artifact').filter((f) => f.endsWith('.zip') || f.endsWith('.tar.xz'));
1145+
1146+
let uploaded = 0;
1147+
let skipped = 0;
1148+
for (const file of files) {
1149+
if (existing.has(file)) {
1150+
core.info(`skipping ${file} (already uploaded)`);
1151+
skipped++;
1152+
continue;
10181153
}
1154+
const data = fs.readFileSync(`./artifact/${file}`);
1155+
await withRetry(`upload ${file}`, () =>
1156+
github.rest.repos.uploadReleaseAsset({ owner, repo, release_id, name: file, data })
1157+
.catch(async (e) => {
1158+
if (e.status !== 422) throw e;
1159+
const assets = await github.paginate(github.rest.repos.listReleaseAssets, { owner, repo, release_id });
1160+
const asset = assets.find((a) => a.name === file);
1161+
if (asset?.state === 'uploaded') {
1162+
core.info(`accepting ${file} (already uploaded)`);
1163+
return;
1164+
}
1165+
if (asset) {
1166+
core.warning(`deleting conflicting asset ${file} (${asset.state})`);
1167+
await github.rest.repos.deleteReleaseAsset({ owner, repo, asset_id: asset.id });
1168+
throw new Error(`retrying upload for ${file} after deleting conflicting asset`);
1169+
}
1170+
throw e;
1171+
}));
1172+
core.info(`uploaded ${file}`);
1173+
uploaded++;
10191174
}
1175+
1176+
core.info(`Done: ${uploaded} uploaded, ${skipped} already present, ${files.length} total.`);
1177+
1178+
- name: Fail if any artifacts were missing
1179+
if: steps.check_artifacts.outputs.missing != '0'
1180+
run: |
1181+
echo "::error::${{ steps.check_artifacts.outputs.missing }} artifact(s) were missing (${{ steps.check_artifacts.outputs.summary }}) — the incomplete families were dropped from this release; other families were still published. See the 'Check release artifacts' step for the full per-file list."
1182+
exit 1

0 commit comments

Comments
 (0)