Skip to content

Commit 0469392

Browse files
committed
feat(ci): add delta patch generation for stable releases
Extract patch generation from publish-nightly into a shared generate-patches job that runs on both main and release/** branches. Old-binary source switches by branch: main fetches previous nightly from GHCR, release/** fetches previous stable release from GitHub Releases. Patches are uploaded as sentry-patches workflow artifact that craft picks up for stable releases. publish-nightly downloads the artifact and pushes to GHCR as before. On failure, an issue is filed automatically.
1 parent e01b252 commit 0469392

File tree

1 file changed

+191
-72
lines changed

1 file changed

+191
-72
lines changed

.github/workflows/ci.yml

Lines changed: 191 additions & 72 deletions
Original file line numberDiff line numberDiff line change
@@ -11,8 +11,10 @@ concurrency:
1111
cancel-in-progress: true
1212

1313
# packages:write is needed for publish-nightly to push to GHCR
14+
# issues:write is needed for generate-patches to file issues on failure
1415
permissions:
1516
contents: read
17+
issues: write
1618
packages: write
1719

1820
env:
@@ -312,11 +314,164 @@ jobs:
312314
name: sentry-${{ matrix.target }}-gz
313315
path: dist-bin/*.gz
314316

317+
generate-patches:
318+
name: Generate Delta Patches
319+
needs: [changes, build-binary]
320+
# Only on main (nightlies) and release branches (stable) — skip PRs
321+
if: github.event_name != 'pull_request'
322+
runs-on: ubuntu-latest
323+
continue-on-error: true
324+
steps:
325+
- name: Install ORAS CLI
326+
# Only needed on main (to fetch previous nightly from GHCR)
327+
if: github.ref == 'refs/heads/main'
328+
run: |
329+
VERSION=1.2.3
330+
EXPECTED_SHA256="b4efc97a91f471f323f193ea4b4d63d8ff443ca3aab514151a30751330852827"
331+
TARBALL="oras_${VERSION}_linux_amd64.tar.gz"
332+
curl -sfLo "$TARBALL" "https://github.com/oras-project/oras/releases/download/v${VERSION}/${TARBALL}"
333+
echo "${EXPECTED_SHA256} ${TARBALL}" | sha256sum -c -
334+
tar -xz -C /usr/local/bin oras < "$TARBALL"
335+
rm "$TARBALL"
336+
337+
- name: Install zig-bsdiff
338+
run: |
339+
VERSION=0.1.19
340+
EXPECTED_SHA256="9f1ac75a133ee09883ad2096a86d57791513de5fc6f262dfadee8dcee94a71b9"
341+
TARBALL="zig-bsdiff-linux-x64.tar.gz"
342+
curl -sfLo "$TARBALL" "https://github.com/blackboardsh/zig-bsdiff/releases/download/v${VERSION}/${TARBALL}"
343+
echo "${EXPECTED_SHA256} ${TARBALL}" | sha256sum -c -
344+
tar -xz -C /usr/local/bin < "$TARBALL"
345+
rm "$TARBALL"
346+
347+
- name: Download current binaries
348+
uses: actions/download-artifact@v8
349+
with:
350+
pattern: sentry-*
351+
path: new-binaries
352+
merge-multiple: true
353+
354+
- name: Download previous nightly binaries (main)
355+
if: github.ref == 'refs/heads/main'
356+
run: |
357+
VERSION="${{ needs.changes.outputs.nightly-version }}"
358+
REPO="ghcr.io/getsentry/cli"
359+
360+
echo "${{ secrets.GITHUB_TOKEN }}" | oras login ghcr.io -u ${{ github.actor }} --password-stdin
361+
362+
# Find previous nightly tag
363+
TAGS=$(oras repo tags "${REPO}" 2>/dev/null | grep '^nightly-' | sort -V || echo "")
364+
PREV_TAG=""
365+
for tag in $TAGS; do
366+
# Current tag may not exist yet (publish-nightly hasn't run),
367+
# but that's fine — we want the latest existing nightly tag
368+
if [ "$tag" = "nightly-${VERSION}" ]; then
369+
break
370+
fi
371+
PREV_TAG="$tag"
372+
done
373+
374+
if [ -z "$PREV_TAG" ]; then
375+
echo "No previous versioned nightly found, skipping patches"
376+
exit 0
377+
fi
378+
echo "HAS_PREV=true" >> "$GITHUB_ENV"
379+
echo "Previous nightly: ${PREV_TAG}"
380+
381+
# Download and decompress previous nightly binaries
382+
mkdir -p old-binaries
383+
PREV_MANIFEST_JSON=$(oras manifest fetch "${REPO}:${PREV_TAG}")
384+
echo "$PREV_MANIFEST_JSON" | jq -r '.layers[] | select(.annotations["org.opencontainers.image.title"] | endswith(".gz")) | .annotations["org.opencontainers.image.title"] + " " + .digest' | while read -r filename digest; do
385+
basename="${filename%.gz}"
386+
echo " Downloading previous ${basename}..."
387+
oras blob fetch "${REPO}@${digest}" --output "old-binaries/${filename}"
388+
gunzip -f "old-binaries/${filename}"
389+
done
390+
391+
- name: Download previous release binaries (release/**)
392+
if: startsWith(github.ref, 'refs/heads/release/')
393+
env:
394+
GH_TOKEN: ${{ github.token }}
395+
run: |
396+
PREV_TAG=$(gh api "repos/${{ github.repository }}/releases?per_page=5" \
397+
--jq '[.[] | select(.prerelease == false and .draft == false)] | .[0].tag_name // empty')
398+
399+
if [ -z "$PREV_TAG" ]; then
400+
echo "No previous stable release found — skipping patch generation"
401+
exit 0
402+
fi
403+
echo "HAS_PREV=true" >> "$GITHUB_ENV"
404+
echo "Previous release: ${PREV_TAG}"
405+
406+
mkdir -p old-binaries
407+
for gz in new-binaries/*.gz; do
408+
name=$(basename "${gz%.gz}")
409+
echo " Downloading ${name}.gz from ${PREV_TAG}..."
410+
gh release download "${PREV_TAG}" \
411+
--repo "${{ github.repository }}" \
412+
--pattern "${name}.gz" \
413+
--dir old-binaries || echo " Warning: not found, skipping"
414+
done
415+
gunzip old-binaries/*.gz 2>/dev/null || true
416+
417+
- name: Generate delta patches
418+
if: env.HAS_PREV == 'true'
419+
run: |
420+
mkdir -p patches
421+
GENERATED=0
422+
423+
for new_binary in new-binaries/sentry-*; do
424+
name=$(basename "$new_binary")
425+
case "$name" in *.gz) continue ;; esac
426+
427+
old_binary="old-binaries/${name}"
428+
[ -f "$old_binary" ] || continue
429+
430+
echo "Generating patch: ${name}.patch"
431+
bsdiff "$old_binary" "$new_binary" "patches/${name}.patch" --use-zstd
432+
433+
patch_size=$(stat --printf='%s' "patches/${name}.patch")
434+
new_size=$(stat --printf='%s' "$new_binary")
435+
ratio=$(awk "BEGIN { printf \"%.1f\", ($patch_size / $new_size) * 100 }")
436+
echo " ${patch_size} bytes (${ratio}% of binary)"
437+
GENERATED=$((GENERATED + 1))
438+
done
439+
440+
echo "Generated ${GENERATED} patches"
441+
442+
- name: Upload patch artifacts
443+
if: env.HAS_PREV == 'true'
444+
uses: actions/upload-artifact@v7
445+
with:
446+
name: sentry-patches
447+
path: patches/*.patch
448+
449+
- name: File issue on failure
450+
if: failure()
451+
env:
452+
GH_TOKEN: ${{ github.token }}
453+
run: |
454+
TITLE="Delta patch generation failed"
455+
BODY="The \`generate-patches\` job failed on [\`${GITHUB_REF_NAME}\`](${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}/actions/runs/${GITHUB_RUN_ID}).
456+
457+
**Branch:** \`${GITHUB_REF_NAME}\`
458+
**Commit:** ${GITHUB_SHA}
459+
**Run:** ${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}/actions/runs/${GITHUB_RUN_ID}"
460+
461+
# Check for existing open issue with same title to avoid duplicates
462+
EXISTING=$(gh issue list --repo "$GITHUB_REPOSITORY" --state open --search "$TITLE" --json number --jq '.[0].number // empty')
463+
if [ -n "$EXISTING" ]; then
464+
echo "Issue #${EXISTING} already open, adding comment"
465+
gh issue comment "$EXISTING" --repo "$GITHUB_REPOSITORY" --body "$BODY"
466+
else
467+
gh issue create --repo "$GITHUB_REPOSITORY" --title "$TITLE" --body "$BODY"
468+
fi
469+
315470
publish-nightly:
316471
name: Publish Nightly to GHCR
317472
# Only run on pushes to main, not on PRs or release branches
318473
if: github.ref == 'refs/heads/main' && github.event_name == 'push'
319-
needs: [changes, build-binary]
474+
needs: [changes, build-binary, generate-patches]
320475
runs-on: ubuntu-latest
321476
steps:
322477
- name: Download compressed artifacts
@@ -326,13 +481,21 @@ jobs:
326481
path: artifacts
327482
merge-multiple: true
328483

329-
- name: Download uncompressed artifacts (for patch generation)
484+
- name: Download uncompressed artifacts (for SHA-256 computation)
330485
uses: actions/download-artifact@v8
331486
with:
332487
pattern: sentry-*
333488
path: binaries
334489
merge-multiple: true
335490

491+
- name: Download patch artifacts
492+
uses: actions/download-artifact@v8
493+
continue-on-error: true
494+
id: download-patches
495+
with:
496+
name: sentry-patches
497+
path: patches
498+
336499
- name: Install ORAS CLI
337500
run: |
338501
VERSION=1.2.3
@@ -343,20 +506,10 @@ jobs:
343506
tar -xz -C /usr/local/bin oras < "$TARBALL"
344507
rm "$TARBALL"
345508
346-
- name: Install zig-bsdiff
347-
run: |
348-
VERSION=0.1.19
349-
EXPECTED_SHA256="9f1ac75a133ee09883ad2096a86d57791513de5fc6f262dfadee8dcee94a71b9"
350-
TARBALL="zig-bsdiff-linux-x64.tar.gz"
351-
curl -sfLo "$TARBALL" "https://github.com/blackboardsh/zig-bsdiff/releases/download/v${VERSION}/${TARBALL}"
352-
echo "${EXPECTED_SHA256} ${TARBALL}" | sha256sum -c -
353-
tar -xz -C /usr/local/bin < "$TARBALL"
354-
rm "$TARBALL"
355-
356509
- name: Log in to GHCR
357510
run: echo "${{ secrets.GITHUB_TOKEN }}" | oras login ghcr.io -u ${{ github.actor }} --password-stdin
358511

359-
- name: Push to GHCR
512+
- name: Push binaries to GHCR
360513
# Push from inside the artifacts directory so ORAS records bare
361514
# filenames (e.g. "sentry-linux-x64.gz") as layer titles, not
362515
# "artifacts/sentry-linux-x64.gz". The CLI matches layers by
@@ -377,77 +530,43 @@ jobs:
377530
VERSION="${{ needs.changes.outputs.nightly-version }}"
378531
oras tag ghcr.io/getsentry/cli:nightly "nightly-${VERSION}"
379532
380-
- name: Generate and push delta patches
381-
# Download the previous nightly's binaries, generate TRDIFF10 patches,
382-
# and push a :patch-<version> manifest with SHA-256 annotations.
533+
- name: Push delta patches to GHCR
534+
# Upload pre-generated patches from generate-patches job as a
535+
# :patch-<version> manifest with SHA-256 annotations.
383536
# Failure here is non-fatal — delta upgrades are optional.
537+
if: steps.download-patches.outcome == 'success'
384538
continue-on-error: true
385539
run: |
386540
VERSION="${{ needs.changes.outputs.nightly-version }}"
387541
REPO="ghcr.io/getsentry/cli"
388542
389-
# Find the previous nightly by listing versioned tags and picking
390-
# the one immediately before ours in version-sorted order.
391-
TAGS=$(oras repo tags "${REPO}" 2>/dev/null | grep '^nightly-' | sort -V || echo "")
392-
PREV_TAG=""
393-
for tag in $TAGS; do
394-
if [ "$tag" = "nightly-${VERSION}" ]; then
395-
break
396-
fi
397-
PREV_TAG="$tag"
398-
done
399-
400-
if [ -z "$PREV_TAG" ]; then
401-
echo "No previous versioned nightly found, skipping patches"
402-
exit 0
403-
fi
404-
405-
PREV_VERSION="${PREV_TAG#nightly-}"
406-
echo "Generating patches: ${PREV_VERSION} → ${VERSION}"
407-
408-
# Download previous nightly binaries
409-
mkdir -p prev-binaries
410-
PREV_MANIFEST_JSON=$(oras manifest fetch "${REPO}:${PREV_TAG}")
411-
# Extract .gz layer digests and download + decompress each
412-
echo "$PREV_MANIFEST_JSON" | jq -r '.layers[] | select(.annotations["org.opencontainers.image.title"] | endswith(".gz")) | .annotations["org.opencontainers.image.title"] + " " + .digest' | while read -r filename digest; do
413-
basename="${filename%.gz}"
414-
echo " Downloading previous ${basename}..."
415-
oras blob fetch "${REPO}@${digest}" --output "prev-binaries/${filename}"
416-
gunzip -f "prev-binaries/${filename}"
417-
done
418-
419-
# Generate patches and compute SHA-256 hashes of NEW binaries
420-
mkdir -p patches
543+
# Compute SHA-256 annotations from new binaries and collect patch files
421544
ANNOTATIONS=""
422545
PATCH_FILES=""
423-
424-
for new_binary in binaries/sentry-*; do
425-
basename=$(basename "$new_binary")
426-
# Skip .gz files
427-
case "$basename" in *.gz) continue ;; esac
428-
429-
old_binary="prev-binaries/${basename}"
430-
if [ ! -f "$old_binary" ]; then
431-
echo " No previous binary for ${basename}, skipping"
432-
continue
546+
for patch_file in patches/*.patch; do
547+
basename_patch=$(basename "$patch_file" .patch)
548+
new_binary="binaries/${basename_patch}"
549+
if [ -f "$new_binary" ]; then
550+
sha256=$(sha256sum "$new_binary" | cut -d' ' -f1)
551+
ANNOTATIONS="${ANNOTATIONS} --annotation sha256-${basename_patch}=${sha256}"
433552
fi
434-
435-
patch_file="patches/${basename}.patch"
436-
echo " Generating patch for ${basename}..."
437-
bsdiff "$old_binary" "$new_binary" "$patch_file" --use-zstd
438-
439-
# Compute SHA-256 of the NEW (target) binary for verification
440-
sha256=$(sha256sum "$new_binary" | cut -d' ' -f1)
441-
ANNOTATIONS="${ANNOTATIONS} --annotation sha256-${basename}=${sha256}"
442-
PATCH_FILES="${PATCH_FILES} ${basename}.patch"
553+
PATCH_FILES="${PATCH_FILES} $(basename "$patch_file")"
443554
done
444555
445556
if [ -z "$PATCH_FILES" ]; then
446-
echo "No patches generated"
557+
echo "No patches to push"
447558
exit 0
448559
fi
449560
450-
# Push patch manifest with from-version and SHA-256 annotations
561+
# Find from-version by listing GHCR nightly tags
562+
TAGS=$(oras repo tags "${REPO}" 2>/dev/null | grep '^nightly-' | sort -V || echo "")
563+
PREV_TAG=""
564+
for tag in $TAGS; do
565+
if [ "$tag" = "nightly-${VERSION}" ]; then break; fi
566+
PREV_TAG="$tag"
567+
done
568+
PREV_VERSION="${PREV_TAG#nightly-}"
569+
451570
cd patches
452571
eval oras push "${REPO}:patch-${VERSION}" \
453572
--artifact-type application/vnd.sentry.cli.patch \
@@ -547,15 +666,15 @@ jobs:
547666
ci-status:
548667
name: CI Status
549668
if: always()
550-
needs: [changes, check-skill, eval-skill, build-binary, build-npm, build-docs, test-e2e, publish-nightly]
669+
needs: [changes, check-skill, eval-skill, build-binary, build-npm, build-docs, test-e2e, generate-patches, publish-nightly]
551670
runs-on: ubuntu-latest
552671
permissions: {}
553672
steps:
554673
- name: Check CI status
555674
run: |
556675
# Check for explicit failures or cancellations in all jobs
557-
# publish-nightly is skipped on PRs (if: github.ref == 'refs/heads/main') — that's expected
558-
results="${{ needs.check-skill.result }} ${{ needs.eval-skill.result }} ${{ needs.build-binary.result }} ${{ needs.build-npm.result }} ${{ needs.build-docs.result }} ${{ needs.test-e2e.result }} ${{ needs.publish-nightly.result }}"
676+
# generate-patches and publish-nightly are skipped on PRs — that's expected
677+
results="${{ needs.check-skill.result }} ${{ needs.eval-skill.result }} ${{ needs.build-binary.result }} ${{ needs.build-npm.result }} ${{ needs.build-docs.result }} ${{ needs.test-e2e.result }} ${{ needs.generate-patches.result }} ${{ needs.publish-nightly.result }}"
559678
for result in $results; do
560679
if [[ "$result" == "failure" || "$result" == "cancelled" ]]; then
561680
echo "::error::CI failed"

0 commit comments

Comments
 (0)