refactor: convert remaining Tier 1 commands to return-based output #1756
Workflow file for this run
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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" |