Skip to content

refactor: convert remaining Tier 1 commands to return-based output #1756

refactor: convert remaining Tier 1 commands to return-based output

refactor: convert remaining Tier 1 commands to return-based output #1756

Workflow file for this run

name: Build
on:
push:
branches: [main, release/**]
pull_request:
workflow_call:
concurrency:
group: ci-${{ github.ref }}
cancel-in-progress: true
# packages:write is needed for publish-nightly to push to GHCR
permissions:
contents: read
packages: write
env:
# Commit timestamp used for deterministic nightly version strings.
# Defined at workflow level so build-binary and publish-nightly always agree.
COMMIT_TIMESTAMP: ${{ github.event.head_commit.timestamp }}
jobs:
changes:
name: Detect Changes
runs-on: ubuntu-latest
permissions:
pull-requests: read
outputs:
skill: ${{ steps.filter.outputs.skill == 'true' || startsWith(github.ref, 'refs/heads/release/') }}
code: ${{ steps.filter.outputs.code == 'true' || startsWith(github.ref, 'refs/heads/release/') }}
build-targets: ${{ steps.targets.outputs.matrix }}
nightly-version: ${{ steps.nightly.outputs.version }}
steps:
- uses: actions/checkout@v4
- uses: dorny/paths-filter@de90cc6fb38fc0963ad72b210f1f284cd68cea36 # v3.0.2
id: filter
with:
filters: |
skill:
- 'src/**'
- 'docs/**'
- 'plugins/**'
- 'script/generate-skill.ts'
code:
- 'src/**'
- 'test/**'
- 'script/**'
- 'patches/**'
- 'docs/**'
- 'plugins/**'
- 'package.json'
- 'bun.lock'
- '.github/workflows/ci.yml'
- name: Compute build matrix
id: targets
run: |
{
echo 'matrix<<MATRIX_EOF'
if [[ "${{ github.event_name }}" == "pull_request" ]]; then
# PRs only need linux-x64 for smoke test and e2e — skip macOS/Windows
echo '{"include":[
{"target":"linux-x64", "os":"ubuntu-latest", "can-test":true}
]}'
else
# main, release/**, workflow_call: full cross-platform matrix
echo '{"include":[
{"target":"darwin-arm64", "os":"macos-latest", "can-test":true},
{"target":"linux-x64", "os":"ubuntu-latest", "can-test":true},
{"target":"windows-x64", "os":"windows-latest","can-test":true},
{"target":"darwin-x64", "os":"macos-latest", "can-test":false},
{"target":"linux-arm64", "os":"ubuntu-latest", "can-test":false}
]}'
fi
echo 'MATRIX_EOF'
} >> "$GITHUB_OUTPUT"
- name: Compute nightly version
id: nightly
if: github.ref == 'refs/heads/main' && github.event_name == 'push'
run: |
TS=$(date -d "$COMMIT_TIMESTAMP" +%s)
CURRENT=$(jq -r .version package.json)
VERSION=$(echo "$CURRENT" | sed "s/-dev\.[0-9]*$/-dev.${TS}/")
echo "version=${VERSION}" >> "$GITHUB_OUTPUT"
echo "Nightly version: ${VERSION}"
check-skill:
name: Check SKILL.md
needs: [changes]
if: needs.changes.outputs.skill == 'true'
runs-on: ubuntu-latest
steps:
- name: Get auth token
id: token
# Fork PRs don't have access to secrets, so this step is skipped
if: github.event.pull_request.head.repo.full_name == github.repository || github.event_name != 'pull_request'
uses: actions/create-github-app-token@v2.2.1
with:
app-id: ${{ vars.SENTRY_RELEASE_BOT_CLIENT_ID }}
private-key: ${{ secrets.SENTRY_RELEASE_BOT_PRIVATE_KEY }}
- uses: actions/checkout@v4
with:
token: ${{ steps.token.outputs.token || github.token }}
ref: ${{ github.head_ref || github.ref_name }}
- uses: oven-sh/setup-bun@v2
- uses: actions/cache@v4
id: cache
with:
path: node_modules
key: node-modules-${{ hashFiles('bun.lock', 'patches/**') }}
- if: steps.cache.outputs.cache-hit != 'true'
run: bun install --frozen-lockfile
- name: Check SKILL.md
id: check
run: bun run check:skill
continue-on-error: true
- name: Auto-commit regenerated SKILL.md
if: steps.check.outcome == 'failure' && steps.token.outcome == 'success'
run: |
git config user.name "github-actions[bot]"
git config user.email "github-actions[bot]@users.noreply.github.com"
git add plugins/sentry-cli/skills/sentry-cli/SKILL.md
git commit -m "chore: regenerate SKILL.md"
git push
- name: Fail for fork PRs with stale SKILL.md
if: steps.check.outcome == 'failure' && steps.token.outcome != 'success'
run: |
echo "::error::SKILL.md is out of date. Run 'bun run generate:skill' locally and commit the result."
exit 1
lint:
name: Lint & Typecheck
needs: [changes]
if: needs.changes.outputs.code == 'true'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: oven-sh/setup-bun@v2
- uses: actions/cache@v4
id: cache
with:
path: node_modules
key: node-modules-${{ hashFiles('bun.lock', 'patches/**') }}
- if: steps.cache.outputs.cache-hit != 'true'
run: bun install --frozen-lockfile
- run: bun run lint
- run: bun run typecheck
- run: bun run check:deps
test-unit:
name: Unit Tests
needs: [changes]
if: needs.changes.outputs.code == 'true'
runs-on: ubuntu-latest
permissions:
contents: read
actions: read
pull-requests: write
statuses: write
steps:
- uses: actions/checkout@v4
- uses: oven-sh/setup-bun@v2
- uses: actions/cache@v4
id: cache
with:
path: node_modules
key: node-modules-${{ hashFiles('bun.lock', 'patches/**') }}
- if: steps.cache.outputs.cache-hit != 'true'
run: bun install --frozen-lockfile
- name: Unit Tests
run: bun run test:unit
- name: Isolated Tests
run: bun run test:isolated --coverage --coverage-reporter=lcov --coverage-dir=coverage-isolated
- name: Merge Coverage Reports
run: bun run script/merge-lcov.ts coverage/lcov.info coverage-isolated/lcov.info > coverage/merged.lcov
- name: Coverage Report
uses: getsentry/codecov-action@main
with:
token: ${{ secrets.GITHUB_TOKEN }}
files: ./coverage/merged.lcov
build-binary:
name: Build Binary (${{ matrix.target }})
needs: [changes, lint, test-unit]
runs-on: ${{ matrix.os }}
strategy:
fail-fast: false
matrix: ${{ fromJSON(needs.changes.outputs.build-targets) }}
steps:
- uses: actions/checkout@v4
- uses: oven-sh/setup-bun@v2
- uses: actions/cache@v4
id: cache
with:
path: node_modules
key: node-modules-${{ matrix.os }}-${{ hashFiles('bun.lock', 'patches/**') }}
- name: Install dependencies
if: steps.cache.outputs.cache-hit != 'true'
shell: bash
run: |
# Retry logic for Windows Bun patch bug (ENOTEMPTY errors)
for i in 1 2 3; do
if bun install --frozen-lockfile; then
exit 0
fi
echo "Attempt $i failed, clearing Bun cache and retrying..."
bun pm cache rm 2>/dev/null || true
done
echo "All install attempts failed"
exit 1
- name: Set nightly version
# Inject the nightly version (computed once in the changes job) into
# package.json before the build so it gets baked into the binary.
if: needs.changes.outputs.nightly-version != ''
shell: bash
run: |
jq --arg v "${{ needs.changes.outputs.nightly-version }}" '.version = $v' package.json > package.json.tmp
mv package.json.tmp package.json
- name: Build
env:
SENTRY_CLIENT_ID: ${{ vars.SENTRY_CLIENT_ID }}
# Set on main/release branches so build.ts runs binpunch + creates .gz
RELEASE_BUILD: ${{ github.event_name != 'pull_request' && '1' || '' }}
run: bun run build --target ${{ matrix.target }}
- name: Smoke test
if: matrix.can-test
shell: bash
run: |
if [[ "${{ matrix.target }}" == "windows-x64" ]]; then
./dist-bin/sentry-windows-x64.exe --help
else
./dist-bin/sentry-${{ matrix.target }} --help
fi
- name: Upload binary artifact
uses: actions/upload-artifact@v4
with:
name: sentry-${{ matrix.target }}
path: |
dist-bin/sentry-*
!dist-bin/*.gz
- name: Upload compressed artifact
if: github.event_name != 'pull_request'
uses: actions/upload-artifact@v4
with:
name: sentry-${{ matrix.target }}-gz
path: dist-bin/*.gz
publish-nightly:
name: Publish Nightly to GHCR
# Only run on pushes to main, not on PRs or release branches
if: github.ref == 'refs/heads/main' && github.event_name == 'push'
needs: [changes, build-binary]
runs-on: ubuntu-latest
steps:
- name: Download compressed artifacts
uses: actions/download-artifact@v4
with:
pattern: sentry-*-gz
path: artifacts
merge-multiple: true
- name: Download uncompressed artifacts (for patch generation)
uses: actions/download-artifact@v4
with:
pattern: sentry-*
path: binaries
merge-multiple: true
- name: Install ORAS CLI
run: |
VERSION=1.2.3
EXPECTED_SHA256="b4efc97a91f471f323f193ea4b4d63d8ff443ca3aab514151a30751330852827"
TARBALL="oras_${VERSION}_linux_amd64.tar.gz"
curl -sfLo "$TARBALL" "https://github.com/oras-project/oras/releases/download/v${VERSION}/${TARBALL}"
echo "${EXPECTED_SHA256} ${TARBALL}" | sha256sum -c -
tar -xz -C /usr/local/bin oras < "$TARBALL"
rm "$TARBALL"
- name: Install zig-bsdiff
run: |
VERSION=0.1.19
EXPECTED_SHA256="9f1ac75a133ee09883ad2096a86d57791513de5fc6f262dfadee8dcee94a71b9"
TARBALL="zig-bsdiff-linux-x64.tar.gz"
curl -sfLo "$TARBALL" "https://github.com/blackboardsh/zig-bsdiff/releases/download/v${VERSION}/${TARBALL}"
echo "${EXPECTED_SHA256} ${TARBALL}" | sha256sum -c -
tar -xz -C /usr/local/bin < "$TARBALL"
rm "$TARBALL"
- name: Log in to GHCR
run: echo "${{ secrets.GITHUB_TOKEN }}" | oras login ghcr.io -u ${{ github.actor }} --password-stdin
- name: Push to GHCR
# Push from inside the artifacts directory so ORAS records bare
# filenames (e.g. "sentry-linux-x64.gz") as layer titles, not
# "artifacts/sentry-linux-x64.gz". The CLI matches layers by
# filename in findLayerByFilename().
working-directory: artifacts
run: |
VERSION="${{ needs.changes.outputs.nightly-version }}"
oras push ghcr.io/getsentry/cli:nightly \
--artifact-type application/vnd.sentry.cli.nightly \
--annotation "org.opencontainers.image.source=https://github.com/getsentry/cli" \
--annotation "version=${VERSION}" \
*.gz
- name: Tag versioned nightly
# Create an immutable versioned tag via zero-copy oras tag.
# This enables patch chain resolution to find specific nightly versions.
run: |
VERSION="${{ needs.changes.outputs.nightly-version }}"
oras tag ghcr.io/getsentry/cli:nightly "nightly-${VERSION}"
- name: Generate and push delta patches
# Download the previous nightly's binaries, generate TRDIFF10 patches,
# and push a :patch-<version> manifest with SHA-256 annotations.
# Failure here is non-fatal — delta upgrades are optional.
continue-on-error: true
run: |
VERSION="${{ needs.changes.outputs.nightly-version }}"
REPO="ghcr.io/getsentry/cli"
# Find the previous nightly by listing versioned tags and picking
# the one immediately before ours in version-sorted order.
TAGS=$(oras repo tags "${REPO}" 2>/dev/null | grep '^nightly-' | sort -V || echo "")
PREV_TAG=""
for tag in $TAGS; do
if [ "$tag" = "nightly-${VERSION}" ]; then
break
fi
PREV_TAG="$tag"
done
if [ -z "$PREV_TAG" ]; then
echo "No previous versioned nightly found, skipping patches"
exit 0
fi
PREV_VERSION="${PREV_TAG#nightly-}"
echo "Generating patches: ${PREV_VERSION} → ${VERSION}"
# Download previous nightly binaries
mkdir -p prev-binaries
PREV_MANIFEST_JSON=$(oras manifest fetch "${REPO}:${PREV_TAG}")
# Extract .gz layer digests and download + decompress each
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
basename="${filename%.gz}"
echo " Downloading previous ${basename}..."
oras blob fetch "${REPO}@${digest}" --output "prev-binaries/${filename}"
gunzip -f "prev-binaries/${filename}"
done
# Generate patches and compute SHA-256 hashes of NEW binaries
mkdir -p patches
ANNOTATIONS=""
PATCH_FILES=""
for new_binary in binaries/sentry-*; do
basename=$(basename "$new_binary")
# Skip .gz files
case "$basename" in *.gz) continue ;; esac
old_binary="prev-binaries/${basename}"
if [ ! -f "$old_binary" ]; then
echo " No previous binary for ${basename}, skipping"
continue
fi
patch_file="patches/${basename}.patch"
echo " Generating patch for ${basename}..."
bsdiff "$old_binary" "$new_binary" "$patch_file" --use-zstd
# Compute SHA-256 of the NEW (target) binary for verification
sha256=$(sha256sum "$new_binary" | cut -d' ' -f1)
ANNOTATIONS="${ANNOTATIONS} --annotation sha256-${basename}=${sha256}"
PATCH_FILES="${PATCH_FILES} ${basename}.patch"
done
if [ -z "$PATCH_FILES" ]; then
echo "No patches generated"
exit 0
fi
# Push patch manifest with from-version and SHA-256 annotations
cd patches
eval oras push "${REPO}:patch-${VERSION}" \
--artifact-type application/vnd.sentry.cli.patch \
--annotation "from-version=${PREV_VERSION}" \
${ANNOTATIONS} \
${PATCH_FILES}
echo "Pushed patch manifest: patch-${VERSION} (from ${PREV_VERSION})"
test-e2e:
name: E2E Tests
needs: [build-binary]
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: oven-sh/setup-bun@v2
- uses: actions/cache@v4
id: cache
with:
path: node_modules
key: node-modules-${{ hashFiles('bun.lock', 'patches/**') }}
- if: steps.cache.outputs.cache-hit != 'true'
run: bun install --frozen-lockfile
- name: Download Linux binary
uses: actions/download-artifact@v4
with:
name: sentry-linux-x64
path: dist-bin
- name: Make binary executable
run: chmod +x dist-bin/sentry-linux-x64
- name: E2E Tests
env:
SENTRY_CLI_BINARY: ${{ github.workspace }}/dist-bin/sentry-linux-x64
run: bun run test:e2e
build-npm:
name: Build npm Package (Node ${{ matrix.node }})
needs: [lint, test-unit]
runs-on: ubuntu-latest
environment: ${{ (github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/heads/release/')) && 'production' || '' }}
strategy:
fail-fast: false
matrix:
node: ["22", "24"]
steps:
- uses: actions/checkout@v4
- uses: oven-sh/setup-bun@v2
- uses: actions/setup-node@v4
with:
node-version: ${{ matrix.node }}
- uses: actions/cache@v4
id: cache
with:
path: node_modules
key: node-modules-${{ hashFiles('bun.lock', 'patches/**') }}
- if: steps.cache.outputs.cache-hit != 'true'
run: bun install --frozen-lockfile
- name: Bundle
env:
SENTRY_CLIENT_ID: ${{ vars.SENTRY_CLIENT_ID }}
SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}
run: bun run bundle
- name: Smoke test (Node.js)
run: node dist/bin.cjs --help
- run: npm pack
- name: Upload artifact
if: matrix.node == '22'
uses: actions/upload-artifact@v4
with:
name: npm-package
path: "*.tgz"
build-docs:
name: Build Docs
needs: [lint]
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: oven-sh/setup-bun@v2
- name: Build Docs
working-directory: docs
run: |
bun install --frozen-lockfile
bun run build
- name: Package Docs
run: |
cp .nojekyll docs/dist/
cd docs/dist && zip -r ../../gh-pages.zip .
- name: Upload artifact
uses: actions/upload-artifact@v4
with:
name: gh-pages
path: gh-pages.zip
ci-status:
name: CI Status
if: always()
needs: [changes, check-skill, build-binary, build-npm, build-docs, test-e2e, publish-nightly]
runs-on: ubuntu-latest
permissions: {}
steps:
- name: Check CI status
run: |
# Check for explicit failures or cancellations in all jobs
# publish-nightly is skipped on PRs (if: github.ref == 'refs/heads/main') — that's expected
results="${{ needs.check-skill.result }} ${{ needs.build-binary.result }} ${{ needs.build-npm.result }} ${{ needs.build-docs.result }} ${{ needs.test-e2e.result }} ${{ needs.publish-nightly.result }}"
for result in $results; do
if [[ "$result" == "failure" || "$result" == "cancelled" ]]; then
echo "::error::CI failed"
exit 1
fi
done
# Detect upstream failures: if changes were detected but jobs were skipped,
# it means an upstream job failed (skipped jobs cascade to dependents)
if [[ "${{ needs.changes.outputs.code }}" == "true" && "${{ needs.test-e2e.result }}" == "skipped" ]]; then
echo "::error::CI failed - upstream job failed causing test-e2e to be skipped"
exit 1
fi
if [[ "${{ needs.changes.outputs.skill }}" == "true" && "${{ needs.check-skill.result }}" == "skipped" ]]; then
echo "::error::CI failed - upstream job failed causing check-skill to be skipped"
exit 1
fi
echo "CI passed"