name: Release on: push: branches: [main] paths-ignore: - "docs/**" - "tests/benchmarks/cloudopsbench/infra/**" - "platform/deployment/install-proxy/**" - "tests/e2e/kubernetes/helm/scripts/**" - "tests/**" - "**/*.md" - "**/*.mdx" - ".claude/**" schedule: - cron: "30 0 * * *" workflow_dispatch: inputs: channel: description: "Release channel to publish." required: false default: release type: choice options: - release - main tag: description: "Optional tag to release (e.g. v0.1.2026.6.26 or v0.1). Ignored for the main channel." required: false type: string permissions: contents: write actions: read models: read concurrency: group: release-${{ github.event_name == 'push' && 'main' || (github.event_name == 'workflow_dispatch' && inputs.channel == 'main' && 'main') || 'stable' }} cancel-in-progress: true jobs: prepare: if: github.repository == 'Tracer-Cloud/opensre' runs-on: ubuntu-latest outputs: channel: ${{ steps.meta.outputs.channel }} tag_name: ${{ steps.meta.outputs.tag_name }} version_name: ${{ steps.meta.outputs.version_name }} steps: - uses: actions/checkout@v5 with: fetch-depth: 0 - name: Resolve release metadata id: meta env: EVENT_NAME: ${{ github.event_name }} DISPATCH_CHANNEL: ${{ inputs.channel }} DISPATCH_TAG: ${{ inputs.tag }} shell: bash run: | set -euo pipefail channel="release" if [ "$EVENT_NAME" = "push" ]; then channel="main" fi if [ "$EVENT_NAME" = "workflow_dispatch" ] && [ "$DISPATCH_CHANNEL" = "main" ]; then channel="main" fi if [ "$channel" = "main" ]; then if [ "$EVENT_NAME" = "workflow_dispatch" ] && [ -n "$DISPATCH_TAG" ]; then echo "The main channel does not accept a custom tag." >&2 exit 1 fi year="$(date -u +%Y)" month="$(date -u +%-m)" day="$(date -u +%-d)" short_sha="${GITHUB_SHA:0:7}" main_version="0.1.${year}.${month}.${day}+main.${short_sha}" echo "channel=main" >> "$GITHUB_OUTPUT" echo "tag_name=main-build" >> "$GITHUB_OUTPUT" echo "version_name=${main_version}" >> "$GITHUB_OUTPUT" exit 0 fi if [ "$EVENT_NAME" = "workflow_dispatch" ] && [ -n "$DISPATCH_TAG" ]; then tag_name="$DISPATCH_TAG" else year="$(date -u +%Y)" month="$(date -u +%-m)" day="$(date -u +%-d)" # Keep the v0.1 prefix so the tag makes clear the product is still # v0.1, while the date stays useful (e.g. v0.1.2026.6.26). tag_name="v0.1.${year}.${month}.${day}" fi echo "channel=release" >> "$GITHUB_OUTPUT" echo "tag_name=${tag_name}" >> "$GITHUB_OUTPUT" echo "version_name=${tag_name#v}" >> "$GITHUB_OUTPUT" verify: runs-on: ubuntu-latest needs: prepare if: github.event_name != 'push' steps: - uses: actions/checkout@v5 - name: Install uv uses: astral-sh/setup-uv@v8.1.0 with: enable-cache: true cache-dependency-glob: | pyproject.toml uv.lock - name: Set up Python uses: actions/setup-python@v6 with: python-version: "3.13" - name: Install dependencies run: uv sync --frozen --extra dev - name: Lint run: make lint - name: Type check run: make typecheck - name: CLI smoke tests run: make test-cli-smoke build-python-dist: if: needs.prepare.outputs.channel == 'release' && needs.verify.result == 'success' runs-on: ubuntu-latest needs: [verify, prepare] env: TAG_NAME: ${{ needs.prepare.outputs.tag_name }} steps: - uses: actions/checkout@v5 - name: Install uv uses: astral-sh/setup-uv@v8.1.0 with: enable-cache: true cache-dependency-glob: | pyproject.toml uv.lock - name: Set up Python uses: actions/setup-python@v6 with: python-version: "3.13" - name: Sync release version shell: bash run: python platform/packaging/sync_release_version.py --tag "$TAG_NAME" - name: Build Python distributions run: | uv sync --frozen --extra release-dist uv run python -m build uv run twine check dist/* - name: Verify Python distribution version shell: bash run: | set -euo pipefail VERSION="${TAG_NAME#v}" test -f "dist/opensre-${VERSION}.tar.gz" test -f "dist/opensre-${VERSION}-py3-none-any.whl" - name: Upload Python distributions uses: actions/upload-artifact@v4 with: name: release-python-dist path: dist/* if-no-files-found: error build-binaries: if: always() && (needs.prepare.outputs.channel == 'main' || needs.verify.result == 'success') needs: [verify, prepare] strategy: fail-fast: false matrix: include: - runner: ubuntu-22.04 target: linux-x64 binary_name: opensre archive_ext: tar.gz pyinstaller_mode: onedir - runner: ubuntu-22.04-arm target: linux-arm64 binary_name: opensre archive_ext: tar.gz pyinstaller_mode: onedir - runner: macos-15-intel target: darwin-x64 binary_name: opensre archive_ext: tar.gz pyinstaller_mode: onedir - runner: macos-latest target: darwin-arm64 binary_name: opensre archive_ext: tar.gz pyinstaller_mode: onedir - runner: windows-latest target: windows-x64 binary_name: opensre.exe archive_ext: zip pyinstaller_mode: onefile # windows-arm64 is currently excluded from the default release matrix: # cryptography does not publish win_arm64 wheels, so dependency install # falls back to a source build that requires an OpenSSL toolchain on the # GitHub-hosted runner. runs-on: ${{ matrix.runner }} env: RELEASE_CHANNEL: ${{ needs.prepare.outputs.channel }} TAG_NAME: ${{ needs.prepare.outputs.tag_name }} VERSION_NAME: ${{ needs.prepare.outputs.version_name }} steps: - uses: actions/checkout@v5 - name: Install uv uses: astral-sh/setup-uv@v8.1.0 with: enable-cache: true cache-dependency-glob: | pyproject.toml uv.lock - name: Set up Python uses: actions/setup-python@v6 with: python-version: "3.13" - name: Sync binary version shell: bash run: python platform/packaging/sync_release_version.py --version "$VERSION_NAME" - name: Install binary build dependencies shell: bash run: uv sync --frozen --extra release-binary - name: Stage stdlib platform module for bundling shell: bash run: | set -euo pipefail # The first-party ``platform`` package shadows the stdlib ``platform`` # module. PyInstaller does not lay out the stdlib as loose ``.py`` files, # so bundle a copy of the genuine module that platform/__init__.py can # load from ``sys._MEIPASS`` at runtime (see _FROZEN_STDLIB_DIR). rm -rf .stdlib_vendor mkdir -p .stdlib_vendor uv run python -c "import os, shutil, sysconfig; src = os.path.join(sysconfig.get_path('stdlib'), 'platform.py'); shutil.copy(src, os.path.join('.stdlib_vendor', 'platform.py')); print('Staged stdlib platform from ' + src)" - name: Build binary run: >- uv run pyinstaller surfaces/cli/__main__.py --name opensre --${{ matrix.pyinstaller_mode }} --clean --noconfirm --collect-data surfaces.cli --collect-data config --copy-metadata opensre --collect-data litellm --hidden-import tiktoken_ext --hidden-import tiktoken_ext.openai_public --collect-submodules integrations --collect-submodules surfaces.interactive_shell --collect-submodules tools --add-data "platform:platform" --add-data ".stdlib_vendor:_opensre_stdlib_platform" - name: Smoke test binary (Unix) if: runner.os != 'Windows' shell: bash run: | set -uo pipefail # Capture the exit status explicitly so a crashing binary still prints # its stdout/stderr (under `set -e` the failed substitution aborted the # step before the output was ever shown, hiding the real error). # onefile builds place the executable at ./dist/; onedir builds # produce a ./dist/opensre/ directory containing the executable. A bare # `-x` test matches the onedir directory too (dirs carry the search # bit), so require a regular file before treating it as the binary. if [ -f "./dist/${{ matrix.binary_name }}" ] && [ -x "./dist/${{ matrix.binary_name }}" ]; then BINARY_PATH="./dist/${{ matrix.binary_name }}" else BINARY_PATH="./dist/opensre/${{ matrix.binary_name }}" fi set +e VERSION_OUTPUT="$("$BINARY_PATH" --version 2>&1)" VERSION_STATUS=$? set -e printf '%s\n' "$VERSION_OUTPUT" if [ "$VERSION_STATUS" -ne 0 ]; then printf '::error::%s --version exited with status %s\n' "${{ matrix.binary_name }}" "$VERSION_STATUS" >&2 exit "$VERSION_STATUS" fi case "$VERSION_OUTPUT" in *"$VERSION_NAME"*) ;; *) printf 'Binary version mismatch: expected %s but saw %s\n' "$VERSION_NAME" "$VERSION_OUTPUT" >&2 exit 1 ;; esac "$BINARY_PATH" -h >/dev/null LITELLM_DATA="./dist/opensre/_internal/litellm/model_prices_and_context_window_backup.json" if [ ! -f "$LITELLM_DATA" ]; then echo "LiteLLM package data missing from Unix onedir bundle: ${LITELLM_DATA}" >&2 exit 1 fi - name: Check Linux binary glibc compatibility if: runner.os == 'Linux' shell: bash run: | set -euo pipefail max_glibc="$( find dist/opensre -type f \( -name opensre -o -name '*.so' -o -name '*.so.*' \) -print0 \ | xargs -0 strings 2>/dev/null \ | grep -Eo 'GLIBC_[0-9]+\.[0-9]+(\.[0-9]+)?' \ | sort -Vu \ | tail -n 1 \ || true )" echo "Max required glibc symbol: ${max_glibc:-none}" if [ -n "$max_glibc" ] && [ "$(printf '%s\n' "$max_glibc" GLIBC_2.35 | sort -V | tail -n 1)" != "GLIBC_2.35" ]; then echo "Linux binary requires ${max_glibc}; pin the Linux runner back to Ubuntu 22.04 or lower dependency wheel requirements." >&2 exit 1 fi - name: Smoke test binary (Windows) if: runner.os == 'Windows' shell: pwsh run: | $versionOutput = & ".\dist\${{ matrix.binary_name }}" --version 2>&1 | Out-String $versionText = $versionOutput.Trim() Write-Host $versionText $expectedVersion = $env:VERSION_NAME if ($versionText -notmatch [regex]::Escape($expectedVersion)) { throw "Binary version mismatch. Expected '$expectedVersion' but saw '$versionText'." } & ".\dist\${{ matrix.binary_name }}" -h | Out-Null - name: Package binary archive (Unix) if: runner.os != 'Windows' shell: bash run: | set -euo pipefail if [ "$RELEASE_CHANNEL" = "release" ]; then ASSET_BASENAME="opensre_${TAG_NAME#v}_${{ matrix.target }}" else ASSET_BASENAME="opensre_main_${{ matrix.target }}" fi tar -C dist -czf "${ASSET_BASENAME}.tar.gz" "${{ matrix.binary_name }}" shasum -a 256 "${ASSET_BASENAME}.tar.gz" > "${ASSET_BASENAME}.tar.gz.sha256" - name: Package binary archive (Windows) if: runner.os == 'Windows' shell: pwsh run: | if ($env:RELEASE_CHANNEL -eq "release") { $assetBaseName = "opensre_$($env:TAG_NAME.TrimStart('v'))_${{ matrix.target }}" } else { $assetBaseName = "opensre_main_${{ matrix.target }}" } Compress-Archive -Path "dist\${{ matrix.binary_name }}" -DestinationPath "${assetBaseName}.zip" $hash = (Get-FileHash -Algorithm SHA256 "${assetBaseName}.zip").Hash.ToLowerInvariant() Set-Content -Path "${assetBaseName}.zip.sha256" -Value "$hash ${assetBaseName}.zip" - name: Upload binary archive uses: actions/upload-artifact@v4 with: name: ${{ env.RELEASE_CHANNEL == 'main' && 'main-' || '' }}release-${{ matrix.target }} path: | opensre_*_${{ matrix.target }}.${{ matrix.archive_ext }} opensre_*_${{ matrix.target }}.${{ matrix.archive_ext }}.sha256 if-no-files-found: error publish-release: if: needs.prepare.outputs.channel == 'release' runs-on: ubuntu-latest needs: - prepare - build-python-dist - build-binaries steps: - uses: actions/checkout@v5 with: ref: ${{ github.event.repository.default_branch }} fetch-depth: 0 - name: Download release artifacts uses: actions/download-artifact@v4 with: pattern: release-* path: release-assets merge-multiple: true - name: Resolve release context id: release_ctx env: TAG_NAME: ${{ needs.prepare.outputs.tag_name }} GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} shell: bash run: | set -euo pipefail default_branch="${{ github.event.repository.default_branch }}" git fetch origin "$default_branch" --tags --force target_sha="$(git rev-parse "origin/$default_branch")" previous_tag="$( git tag --list 'v0.1.[0-9][0-9][0-9][0-9].[0-9]*.[0-9]*' --sort=-v:refname \ | grep -v -x "$TAG_NAME" \ | head -n 1 \ || true )" range_spec="$target_sha" if [ -n "$previous_tag" ]; then range_spec="${previous_tag}..${target_sha}" fi { printf 'target_sha=%s\n' "$target_sha" printf 'previous_tag=%s\n' "$previous_tag" printf 'range_spec=%s\n' "$range_spec" } >> "$GITHUB_OUTPUT" - name: Create release notes env: TAG_NAME: ${{ needs.prepare.outputs.tag_name }} RANGE_SPEC: ${{ steps.release_ctx.outputs.range_spec }} PREVIOUS_TAG: ${{ steps.release_ctx.outputs.previous_tag }} GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} shell: bash run: | set -euo pipefail { echo "## Changelog" echo if [ -n "$PREVIOUS_TAG" ]; then echo "_Changes since ${PREVIOUS_TAG}_" else echo "_Changes up to ${TAG_NAME}_" fi echo git log "$RANGE_SPEC" --no-merges -n 100 --pretty='- %s (%h) — %an' echo } > GENERATED_CHANGELOG.md # Summarize commits into prose via GitHub Models (gpt-4o-mini). # Falls back to raw changelog if the API is unavailable. raw_commits="$(git log "$RANGE_SPEC" --no-merges --pretty='%s' | head -40 || true)" if [ -n "$raw_commits" ]; then api_body="$(jq -n --arg commits "$raw_commits" '{ model: "gpt-4o-mini", messages: [ { role: "system", content: "You are writing a Discord release announcement for an open-source SRE CLI tool called opensre. Summarize the git commits into 2-4 short, punchy prose sentences — no bullet points, no headers, no markdown. Write in present tense, active voice. Focus on what users will notice: new features, fixes, performance, integrations. Be specific but concise." }, {role: "user", content: ("Commits:\n" + $commits)} ], max_tokens: 300, temperature: 0.4 }')" curl -sS --max-time 20 \ -H "Authorization: Bearer $GITHUB_TOKEN" \ -H "Content-Type: application/json" \ "https://models.inference.ai.azure.com/chat/completions" \ -d "$api_body" 2>/dev/null \ | jq -r '.choices[0].message.content // empty' 2>/dev/null \ | tr -d '\r' > DISCORD_NARRATIVE.md || true if [ -s DISCORD_NARRATIVE.md ]; then echo "LLM narrative generated ($(wc -c < DISCORD_NARRATIVE.md) bytes)." else echo "LLM summary unavailable; Discord will use raw changelog." rm -f DISCORD_NARRATIVE.md fi fi CB='```' { printf '## Install\n\n' printf '### cURL (macOS / Linux)\n\n%sbash\ncurl -fsSL https://install.opensre.com | bash -s -- --release --version %s\n%s\n\n' "$CB" "${TAG_NAME#v}" "$CB" printf '### Homebrew (macOS / Linux)\n\n%sbash\nbrew tap tracer-cloud/tap\nbrew install tracer-cloud/tap/opensre\n%s\n\n' "$CB" "$CB" printf '### PowerShell (Windows)\n\n%spowershell\n$env:OPENSRE_INSTALL_CHANNEL="release"; $env:OPENSRE_VERSION="%s"; irm https://install.opensre.com | iex\n%s\n\n' "$CB" "${TAG_NAME#v}" "$CB" printf '### Python\n\n%sbash\npipx install opensre\n%s\n\n' "$CB" "$CB" } > RELEASE_NOTES.md cat GENERATED_CHANGELOG.md >> RELEASE_NOTES.md - name: Create GitHub release id: github_release env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} TAG_NAME: ${{ needs.prepare.outputs.tag_name }} TARGET_SHA: ${{ steps.release_ctx.outputs.target_sha }} shell: bash run: | set -euo pipefail if gh release view "$TAG_NAME" --repo "${{ github.repository }}" >/dev/null 2>&1; then echo "created=false" >> "$GITHUB_OUTPUT" echo "Release $TAG_NAME already exists; nothing to do." exit 0 fi VERSION="${TAG_NAME#v}" # Every release-channel publish is the newest stable build at this # point, so mark it as GitHub's Latest. The title keeps the full # version (e.g. "OpenSRE 0.1.2026.6.26") so the v0.1 line is clear. gh release create "$TAG_NAME" \ release-assets/* \ --repo "${{ github.repository }}" \ --target "$TARGET_SHA" \ --title "OpenSRE ${VERSION}" \ --notes-file RELEASE_NOTES.md \ --latest echo "created=true" >> "$GITHUB_OUTPUT" - name: Announce on Discord if: steps.github_release.outputs.created == 'true' && !github.event.repository.fork && !github.event.repository.private continue-on-error: true env: DISCORD_WEBHOOK_URL_UPDATE: ${{ secrets.DISCORD_WEBHOOK_URL_UPDATE }} DISCORD_RELEASES_ROLE_ID: ${{ secrets.DISCORD_RELEASES_ROLE_ID }} DISCORD_RELEASE_LOGO_EMOJI: ${{ secrets.DISCORD_RELEASE_LOGO_EMOJI }} DISCORD_RELEASE_LOGO_URL: ${{ secrets.DISCORD_RELEASE_LOGO_URL }} RELEASE_TAG: ${{ needs.prepare.outputs.tag_name }} RELEASE_URL: ${{ github.server_url }}/${{ github.repository }}/releases/tag/${{ needs.prepare.outputs.tag_name }} shell: bash run: | set -euo pipefail if [ -z "${DISCORD_WEBHOOK_URL_UPDATE:-}" ]; then echo "DISCORD_WEBHOOK_URL_UPDATE is not set; skipping Discord announcement." exit 0 fi # Prefer LLM-generated narrative; fall back to raw changelog. if [ -f DISCORD_NARRATIVE.md ] && [ -s DISCORD_NARRATIVE.md ]; then changelog_file="DISCORD_NARRATIVE.md" elif [ -f GENERATED_CHANGELOG.md ]; then changelog_file="GENERATED_CHANGELOG.md" else echo "No changelog available." > /tmp/discord_fallback.md changelog_file="/tmp/discord_fallback.md" fi # Use an external Python script to build the JSON payload. # This avoids jq issues with multiline LLM output containing control characters. payload="$(CHANGELOG_FILE="$changelog_file" python3 .github/scripts/build-discord-payload.py)" curl --fail -sS --max-time 30 -X POST -H "Content-Type: application/json" \ -d "$payload" "$DISCORD_WEBHOOK_URL_UPDATE" - name: Sync Homebrew tap formula if: steps.github_release.outputs.created == 'true' continue-on-error: true env: HOMEBREW_TAP_GITHUB_TOKEN: ${{ secrets.HOMEBREW_TAP_GITHUB_TOKEN }} VERSION: ${{ needs.prepare.outputs.tag_name }} ASSET_DIR: release-assets shell: bash run: | set -euo pipefail if [ -z "${HOMEBREW_TAP_GITHUB_TOKEN:-}" ]; then echo "HOMEBREW_TAP_GITHUB_TOKEN is not set; skipping Homebrew tap sync." exit 0 fi export VERSION="${VERSION#v}" bash .github/scripts/sync-homebrew-tap-formula.sh publish-main-release: if: always() && needs.prepare.outputs.channel == 'main' && needs.build-binaries.result == 'success' runs-on: ubuntu-latest needs: - prepare - build-binaries steps: - uses: actions/checkout@v5 with: fetch-depth: 0 # The rolling `main-build` tag is force-moved to each new main commit # below. Whenever main has advanced past a commit that touches # .github/workflows/**, the default GITHUB_TOKEN (the # github-actions[bot] GitHub App) is refused with "refusing to allow a # GitHub App to create or update workflow ... without `workflows` # permission" — and that scope cannot be granted via the permissions # block. MAIN_BUILD_TAG_TOKEN must be a token that carries workflow # write access (classic PAT with `repo` + `workflow`, a fine-grained # PAT with Contents + Workflows: write, or a GitHub App token). It is # persisted so the tag push below authenticates with it. Falls back to # GITHUB_TOKEN so the rest of the job still runs if the secret is unset # (the tag push will then fail as before until the secret is added). token: ${{ secrets.MAIN_BUILD_TAG_TOKEN || secrets.GITHUB_TOKEN }} persist-credentials: true - name: Download main release artifacts uses: actions/download-artifact@v4 with: pattern: main-release-* path: main-release-assets merge-multiple: true - name: Create release notes env: VERSION_NAME: ${{ needs.prepare.outputs.version_name }} shell: bash run: | set -euo pipefail short_sha="$(printf '%s' "$GITHUB_SHA" | cut -c1-7)" built_at="$(date -u +"%Y-%m-%d %H:%M UTC")" CB='```' { printf '## Main build\n\nRolling binary build from `main`.\n\n' printf -- '- Version: `%s`\n- Commit: `%s`\n- Built: %s\n\n' "$VERSION_NAME" "$short_sha" "$built_at" printf '### Install\n\nmacOS / Linux:\n\n%sbash\ncurl -fsSL https://install.opensre.com | bash\n%s\n\n' "$CB" "$CB" printf 'Equivalent explicit main channel:\n\n%sbash\ncurl -fsSL https://install.opensre.com | bash -s -- --main\n%s\n\n' "$CB" "$CB" printf 'Windows:\n\n%spowershell\nirm https://install.opensre.com | iex\n%s\n' "$CB" "$CB" } > MAIN_RELEASE_NOTES.md - name: Move main build tag to the latest commit shell: bash run: | set -euo pipefail git config user.name "github-actions[bot]" git config user.email "41898282+github-actions[bot]@users.noreply.github.com" git tag -f "${{ needs.prepare.outputs.tag_name }}" "$GITHUB_SHA" git push origin "refs/tags/${{ needs.prepare.outputs.tag_name }}" --force - name: Publish rolling main release env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} shell: bash run: | set -euo pipefail tag_name="${{ needs.prepare.outputs.tag_name }}" if gh release view "$tag_name" --repo "${{ github.repository }}" >/dev/null 2>&1; then gh release upload "$tag_name" main-release-assets/* --repo "${{ github.repository }}" --clobber gh release edit "$tag_name" \ --repo "${{ github.repository }}" \ --title "Main" \ --notes-file MAIN_RELEASE_NOTES.md exit 0 fi gh release create "$tag_name" \ main-release-assets/* \ --repo "${{ github.repository }}" \ --target "$GITHUB_SHA" \ --title "Main" \ --notes-file MAIN_RELEASE_NOTES.md \ --prerelease