Implement HTTPStatus in test_webapp_investigate.py (#4842) #1566
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: Release | |
| on: | |
| push: | |
| branches: [main] | |
| paths-ignore: | |
| - "docs/**" | |
| - "tests/benchmarks/cloudopsbench/infra/**" | |
| - "platform/cloudflare_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: Install OpenSSL 3.2+ for cryptography (macOS) | |
| if: runner.os == 'macOS' | |
| shell: bash | |
| run: | | |
| set -euo pipefail | |
| # cryptography 50's Rust bindings reference SSL_get0_group_name | |
| # (OpenSSL >= 3.2). darwin-x64 has no cryptography wheel, so uv builds | |
| # from source; PyInstaller must then ship the *same* libssl, not an | |
| # older libssl.3.dylib discovered on PATH (smoke failure: | |
| # Symbol not found: _SSL_get0_group_name). | |
| brew install openssl@3 | |
| OPENSSL_PREFIX="$(brew --prefix openssl@3)" | |
| { | |
| echo "OPENSSL_DIR=${OPENSSL_PREFIX}" | |
| echo "PKG_CONFIG_PATH=${OPENSSL_PREFIX}/lib/pkgconfig:${PKG_CONFIG_PATH:-}" | |
| echo "LDFLAGS=-L${OPENSSL_PREFIX}/lib ${LDFLAGS:-}" | |
| echo "CFLAGS=-I${OPENSSL_PREFIX}/include ${CFLAGS:-}" | |
| } >> "$GITHUB_ENV" | |
| # Prefer `openssl version` over `nm | grep -q`: under pipefail, grep -q | |
| # closes the pipe early → nm SIGPIPE ("Broken pipe") → false negative | |
| # even when OpenSSL 3.6.x is fine. | |
| # Compare with a portable major.minor.patch check — do not use | |
| # `sort -V` (GNU); macOS /usr/bin/sort is BSD and may reject -V. | |
| OPENSSL_VER="$("${OPENSSL_PREFIX}/bin/openssl" version | awk '{print $2}')" | |
| openssl_version_ge() { | |
| local ver="$1" min="$2" i | |
| local -a va vb | |
| IFS='.' read -r -a va <<<"${ver}" | |
| IFS='.' read -r -a vb <<<"${min}" | |
| for i in 0 1 2; do | |
| local ai="${va[i]:-0}" bi="${vb[i]:-0}" | |
| ai="${ai%%[!0-9]*}" | |
| bi="${bi%%[!0-9]*}" | |
| ai="${ai:-0}" | |
| bi="${bi:-0}" | |
| if ((10#${ai} > 10#${bi})); then | |
| return 0 | |
| fi | |
| if ((10#${ai} < 10#${bi})); then | |
| return 1 | |
| fi | |
| done | |
| return 0 | |
| } | |
| if ! openssl_version_ge "${OPENSSL_VER}" '3.2.0'; then | |
| echo "::error::Homebrew openssl@3 at ${OPENSSL_PREFIX} is ${OPENSSL_VER} (need >= 3.2)" >&2 | |
| exit 1 | |
| fi | |
| echo "Using OpenSSL ${OPENSSL_VER} from ${OPENSSL_PREFIX}" | |
| - 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: Align bundled libssl with cryptography (macOS onedir) | |
| if: runner.os == 'macOS' && matrix.pyinstaller_mode == 'onedir' | |
| shell: bash | |
| run: | | |
| set -euo pipefail | |
| # PyInstaller's binary dependency scan can still collect a stale | |
| # libssl.3.dylib. If the bundled lib lacks SSL_get0_group_name | |
| # (OpenSSL < 3.2), replace from the Homebrew OpenSSL we built against | |
| # and rewrite install names so smoke is not masked by brew on the runner. | |
| INTERNAL="./dist/opensre/_internal" | |
| OPENSSL_PREFIX="${OPENSSL_DIR:-$(brew --prefix openssl@3)}" | |
| if [ ! -d "$INTERNAL" ]; then | |
| echo "No onedir _internal; skipping libssl alignment" | |
| exit 0 | |
| fi | |
| BUNDLED_SSL="${INTERNAL}/libssl.3.dylib" | |
| BUNDLED_CRYPTO="${INTERNAL}/libcrypto.3.dylib" | |
| needs_align=0 | |
| if [ -f "$BUNDLED_SSL" ]; then | |
| # Buffer nm output — do not use `nm | grep -q` under pipefail. | |
| ssl_syms="$(nm -gU "$BUNDLED_SSL" 2>/dev/null || true)" | |
| if ! grep -Fq 'SSL_get0_group_name' <<<"$ssl_syms"; then | |
| needs_align=1 | |
| fi | |
| fi | |
| if [ "$needs_align" -eq 0 ]; then | |
| echo "Bundled libssl already exports SSL_get0_group_name; leaving as-is" | |
| exit 0 | |
| fi | |
| for lib in libssl.3.dylib libcrypto.3.dylib; do | |
| src="${OPENSSL_PREFIX}/lib/${lib}" | |
| if [ ! -f "$src" ]; then | |
| echo "::error::Missing ${src} (needed to replace stale bundled OpenSSL)" >&2 | |
| exit 1 | |
| fi | |
| cp -f "$src" "${INTERNAL}/${lib}" | |
| echo "Installed ${lib} from ${src}" | |
| done | |
| # Homebrew dylibs keep absolute install names; rewrite for onedir rpath. | |
| install_name_tool -id '@rpath/libcrypto.3.dylib' "$BUNDLED_CRYPTO" | |
| install_name_tool -id '@rpath/libssl.3.dylib' "$BUNDLED_SSL" | |
| # libssl → libcrypto (whatever absolute path brew baked in) | |
| while read -r old; do | |
| [ -n "$old" ] || continue | |
| install_name_tool -change "$old" '@rpath/libcrypto.3.dylib' "$BUNDLED_SSL" || true | |
| done < <(otool -L "$BUNDLED_SSL" | awk '/libcrypto\.3\.dylib/{print $1}' | grep -v '@rpath' || true) | |
| ssl_syms="$(nm -gU "$BUNDLED_SSL" 2>/dev/null || true)" | |
| if ! grep -Fq 'SSL_get0_group_name' <<<"$ssl_syms"; then | |
| echo "::error::Bundled libssl.3.dylib still lacks SSL_get0_group_name after align" >&2 | |
| exit 1 | |
| fi | |
| - 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/<name>; 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 | |
| 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 |